Harden RF node control and agent updates
This commit is contained in:
8
INSTALL_RX_AGENT.cmd
Normal file
8
INSTALL_RX_AGENT.cmd
Normal file
@@ -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%
|
||||||
7
UNINSTALL_RX_AGENT.cmd
Normal file
7
UNINSTALL_RX_AGENT.cmd
Normal file
@@ -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%
|
||||||
223
docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md
Normal file
223
docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md
Normal file
@@ -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` для этого исправления не требуется.
|
||||||
310
docs/handoff/RF001_HTTP_CONTROL.md
Normal file
310
docs/handoff/RF001_HTTP_CONTROL.md
Normal file
@@ -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 <SESSION_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
TX разрешён только при свежем результате `READY`, открытом окне захвата и после
|
||||||
|
точной отдельной фразы `AUTHORIZE RF TX`. В сетевом протоколе операции TX нет.
|
||||||
|
|
||||||
|
После завершения захвата сервер может поставить потоковое декодирование с тремя
|
||||||
|
фактическими временами. Отчёт и IQ остаются на RX-ноутбуке вне Git.
|
||||||
|
|
||||||
|
## Текущее продолжение
|
||||||
|
|
||||||
|
На RX-ноутбуке работает версия агента `2026.09.29.2`; штатное обновление через
|
||||||
|
подписанный HTTP-канал и полный `STAIRCASE_SEARCH` завершены. Дальнейшая работа
|
||||||
|
с существующим сеансом является только потоковой офлайн-диагностикой сохранённой
|
||||||
|
IQ-записи. Новый RX-захват и любой RF TX требуют нового отдельного разрешения;
|
||||||
|
разрешения, использованные в завершённом сеансе, повторно не применяются.
|
||||||
157
docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md
Normal file
157
docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md
Normal file
@@ -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` в рамках этой фазы не создавался.
|
||||||
625
tests/test_rf001_agent_launcher.py
Normal file
625
tests/test_rf001_agent_launcher.py
Normal file
@@ -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")]
|
||||||
172
tests/test_rf001_agent_update.py
Normal file
172
tests/test_rf001_agent_update.py
Normal file
@@ -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()
|
||||||
133
tests/test_rf001_bootstrap_install_orchestrator.py
Normal file
133
tests/test_rf001_bootstrap_install_orchestrator.py
Normal file
@@ -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
|
||||||
319
tests/test_rf001_build_agent_packages.py
Normal file
319
tests/test_rf001_build_agent_packages.py
Normal file
@@ -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()
|
||||||
48
tests/test_rf001_capture_runner.py
Normal file
48
tests/test_rf001_capture_runner.py
Normal file
@@ -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
|
||||||
537
tests/test_rf001_http_control.py
Normal file
537
tests/test_rf001_http_control.py
Normal file
@@ -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)
|
||||||
429
tests/test_rf001_installer_transaction.py
Normal file
429
tests/test_rf001_installer_transaction.py
Normal file
@@ -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)
|
||||||
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
@@ -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()
|
||||||
55
tests/test_rf001_offline_decode.py
Normal file
55
tests/test_rf001_offline_decode.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
356
tests/test_rf001_rx_capture.py
Normal file
356
tests/test_rf001_rx_capture.py
Normal file
@@ -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 == []
|
||||||
824
tests/test_rf001_rx_http_agent.py
Normal file
824
tests/test_rf001_rx_http_agent.py
Normal file
@@ -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()
|
||||||
354
tests/test_rf001_staircase_search.py
Normal file
354
tests/test_rf001_staircase_search.py
Normal file
@@ -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
|
||||||
69
tools/install_rf001_main_autostart.ps1
Normal file
69
tools/install_rf001_main_autostart.ps1
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$taskName = "SDR Rover HTTP Control Server"
|
||||||
|
$repository = Split-Path -Parent $PSScriptRoot
|
||||||
|
$pythonCommand = Get-Command python.exe -ErrorAction Stop
|
||||||
|
$python = $pythonCommand.Source
|
||||||
|
$serverScript = Join-Path $repository "tools\rf001_http_control.py"
|
||||||
|
$tokenFile = Join-Path $repository ".local\rf001_http_token.txt"
|
||||||
|
$stateFile = Join-Path $repository ".local\rf001_http_server_state.json"
|
||||||
|
$logFile = Join-Path $repository ".local\logs\rf001_http_service.log"
|
||||||
|
|
||||||
|
foreach ($required in @($python, $serverScript, $tokenFile)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||||
|
throw "Required file is absent: $required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments = @(
|
||||||
|
"-u",
|
||||||
|
"-B",
|
||||||
|
('"{0}"' -f $serverScript),
|
||||||
|
"serve",
|
||||||
|
"--bind", "192.168.255.185",
|
||||||
|
"--port", "8765",
|
||||||
|
"--allowed-agent-ip", "192.168.255.56",
|
||||||
|
"--state-file", ('"{0}"' -f $stateFile),
|
||||||
|
"--token-file", ('"{0}"' -f $tokenFile),
|
||||||
|
"--log-file", ('"{0}"' -f $logFile),
|
||||||
|
"--bind-retry-seconds", "300"
|
||||||
|
) -join " "
|
||||||
|
|
||||||
|
$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $repository
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $account
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited
|
||||||
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-AllowStartIfOnBatteries `
|
||||||
|
-DontStopIfGoingOnBatteries `
|
||||||
|
-StartWhenAvailable `
|
||||||
|
-RestartCount 10 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||||
|
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||||
|
-MultipleInstances IgnoreNew
|
||||||
|
|
||||||
|
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $existing) {
|
||||||
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Register-ScheduledTask `
|
||||||
|
-TaskName $taskName `
|
||||||
|
-Action $action `
|
||||||
|
-Trigger $trigger `
|
||||||
|
-Principal $principal `
|
||||||
|
-Settings $settings `
|
||||||
|
-Description "Authenticated RF-001 control server. No network RF TX operation." `
|
||||||
|
-Force | Out-Null
|
||||||
|
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$task = Get-ScheduledTask -TaskName $taskName
|
||||||
|
$taskInfo = Get-ScheduledTaskInfo -TaskName $taskName
|
||||||
|
|
||||||
|
"AUTOSTART_TASK=$taskName"
|
||||||
|
"AUTOSTART_ACCOUNT=$account"
|
||||||
|
"AUTOSTART_MODE=USER_LOGON"
|
||||||
|
"TASK_STATE=$($task.State)"
|
||||||
|
"LAST_TASK_RESULT=$($taskInfo.LastTaskResult)"
|
||||||
|
"RF_TX=0"
|
||||||
994
tools/install_rf001_rx_autostart.ps1
Normal file
994
tools/install_rf001_rx_autostart.ps1
Normal file
@@ -0,0 +1,994 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$taskName = "SDR Rover RX Agent"
|
||||||
|
$sourceRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
|
||||||
|
$installRoot = Join-Path $env:LOCALAPPDATA "SDR_Rover_RX_Agent"
|
||||||
|
$versionsRoot = Join-Path $installRoot "versions"
|
||||||
|
$launcherRoot = Join-Path $installRoot "launcher"
|
||||||
|
$sharedRoot = Join-Path $installRoot "shared"
|
||||||
|
$logsRoot = Join-Path $installRoot "logs"
|
||||||
|
$repository = Join-Path $env:USERPROFILE "Documents\Codex\SDR_Rover_Video"
|
||||||
|
$python = Join-Path $repository ".venv\Scripts\pythonw.exe"
|
||||||
|
$hackrfRoot = Join-Path $env:USERPROFILE "Documents\Codex\sdr-tools\envs\sdr\Library\bin"
|
||||||
|
$workRoot = Join-Path $env:USERPROFILE "Documents\SDR_Rover_Runtime"
|
||||||
|
$manifestPath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.json"
|
||||||
|
$signaturePath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.hmac"
|
||||||
|
$legacyToken = Join-Path $installRoot "app\.local\rf001_http_token.txt"
|
||||||
|
$sharedToken = Join-Path $sharedRoot "rf001_http_token.txt"
|
||||||
|
$expectedReferenceHash = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||||
|
|
||||||
|
function Get-LowerSha256([string]$Path) {
|
||||||
|
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
||||||
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha256.Dispose()
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-FixedTimeHex([string]$Left, [string]$Right) {
|
||||||
|
if ($Left.Length -ne $Right.Length) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$difference = 0
|
||||||
|
for ($index = 0; $index -lt $Left.Length; $index++) {
|
||||||
|
$difference = $difference -bor ([int][char]$Left[$index] -bxor [int][char]$Right[$index])
|
||||||
|
}
|
||||||
|
return $difference -eq 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExactReleasePath(
|
||||||
|
[string]$InstallRoot,
|
||||||
|
[string]$VersionsRoot,
|
||||||
|
[string]$ReleaseId,
|
||||||
|
[string]$TargetRelease
|
||||||
|
) {
|
||||||
|
$expectedVersions = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions"))
|
||||||
|
$actualVersions = [IO.Path]::GetFullPath($VersionsRoot)
|
||||||
|
if (-not $expectedVersions.Equals($actualVersions, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Release versions root is outside the fixed install root"
|
||||||
|
}
|
||||||
|
$expectedTarget = [IO.Path]::GetFullPath((Join-Path $actualVersions $ReleaseId))
|
||||||
|
$actualTarget = [IO.Path]::GetFullPath($TargetRelease)
|
||||||
|
if (-not $expectedTarget.Equals($actualTarget, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Release target path is not the installer-defined canonical path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ReleaseDirectoryState(
|
||||||
|
[string]$TargetRelease,
|
||||||
|
[object[]]$ReleaseRows,
|
||||||
|
[string]$ReleasePrefix
|
||||||
|
) {
|
||||||
|
if (-not (Test-Path -LiteralPath $TargetRelease)) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
state = "TARGET_ABSENT"
|
||||||
|
mismatches = @()
|
||||||
|
conflicting_files = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $TargetRelease -PathType Container)) {
|
||||||
|
$item = Get-Item -LiteralPath $TargetRelease -Force
|
||||||
|
$digest = $null
|
||||||
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
|
||||||
|
try {
|
||||||
|
$digest = Get-LowerSha256 $item.FullName
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$digest = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
state = "TARGET_CONFLICT"
|
||||||
|
mismatches = @([ordered]@{
|
||||||
|
kind = "TARGET_NOT_DIRECTORY"
|
||||||
|
path = "."
|
||||||
|
expected_bytes = $null
|
||||||
|
actual_bytes = [int64]$item.Length
|
||||||
|
expected_sha256 = $null
|
||||||
|
actual_sha256 = $digest
|
||||||
|
})
|
||||||
|
conflicting_files = @([ordered]@{
|
||||||
|
path = "."
|
||||||
|
bytes = [int64]$item.Length
|
||||||
|
sha256 = $digest
|
||||||
|
reparse_point = (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = @{}
|
||||||
|
$normalizedRows = @($ReleaseRows | ForEach-Object { $_ })
|
||||||
|
foreach ($row in $normalizedRows) {
|
||||||
|
$pathValues = @($row.path)
|
||||||
|
$byteValues = @($row.bytes)
|
||||||
|
$hashValues = @($row.sha256)
|
||||||
|
if ($pathValues.Count -ne 1 -or $byteValues.Count -ne 1 -or $hashValues.Count -ne 1) {
|
||||||
|
throw "Release manifest row fields are absent or ambiguous"
|
||||||
|
}
|
||||||
|
$manifestPath = [string]$pathValues[0]
|
||||||
|
if (-not $manifestPath.StartsWith($ReleasePrefix, [StringComparison]::Ordinal)) {
|
||||||
|
throw "Release manifest row is outside the expected prefix: $manifestPath"
|
||||||
|
}
|
||||||
|
$relative = $manifestPath.Substring($ReleasePrefix.Length)
|
||||||
|
if (
|
||||||
|
[string]::IsNullOrWhiteSpace($relative) -or
|
||||||
|
$relative -match '(^/|\\|(^|/)\.\.(/|$)|:)'
|
||||||
|
) {
|
||||||
|
throw "Unsafe path inside release manifest: $relative"
|
||||||
|
}
|
||||||
|
$key = $relative.ToLowerInvariant()
|
||||||
|
if ($expected.ContainsKey($key)) {
|
||||||
|
throw "Duplicate path inside release manifest: $relative"
|
||||||
|
}
|
||||||
|
$expected[$key] = [pscustomobject]@{
|
||||||
|
path = $relative
|
||||||
|
bytes = [int64]($byteValues[0])
|
||||||
|
sha256 = ([string]$hashValues[0]).ToLowerInvariant()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($expected.Count -eq 0) {
|
||||||
|
throw "Release manifest does not contain any files"
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPrefix = [IO.Path]::GetFullPath($TargetRelease).TrimEnd('\') + '\'
|
||||||
|
$actual = @{}
|
||||||
|
$mismatches = @()
|
||||||
|
$conflictingFiles = @()
|
||||||
|
$entries = @(Get-ChildItem -LiteralPath $TargetRelease -Recurse -Force)
|
||||||
|
foreach ($entry in $entries) {
|
||||||
|
$relative = $entry.FullName.Substring($targetPrefix.Length).Replace('\', '/')
|
||||||
|
$isReparsePoint = ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0
|
||||||
|
if ($entry.PSIsContainer) {
|
||||||
|
if ($isReparsePoint) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "REPARSE_POINT"
|
||||||
|
path = $relative
|
||||||
|
expected_bytes = $null
|
||||||
|
actual_bytes = $null
|
||||||
|
expected_sha256 = $null
|
||||||
|
actual_sha256 = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$digest = $null
|
||||||
|
$hashError = $null
|
||||||
|
if (-not $isReparsePoint) {
|
||||||
|
try {
|
||||||
|
$digest = Get-LowerSha256 $entry.FullName
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$hashError = $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$metadata = [pscustomobject]@{
|
||||||
|
path = $relative
|
||||||
|
bytes = [int64]$entry.Length
|
||||||
|
sha256 = $digest
|
||||||
|
reparse_point = $isReparsePoint
|
||||||
|
hash_error = $hashError
|
||||||
|
}
|
||||||
|
$conflictingFiles += $metadata
|
||||||
|
$key = $relative.ToLowerInvariant()
|
||||||
|
if ($actual.ContainsKey($key)) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "DUPLICATE_PATH"
|
||||||
|
path = $relative
|
||||||
|
expected_bytes = $null
|
||||||
|
actual_bytes = [int64]$entry.Length
|
||||||
|
expected_sha256 = $null
|
||||||
|
actual_sha256 = $digest
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$actual[$key] = $metadata
|
||||||
|
if ($isReparsePoint) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "REPARSE_POINT"
|
||||||
|
path = $relative
|
||||||
|
expected_bytes = $null
|
||||||
|
actual_bytes = [int64]$entry.Length
|
||||||
|
expected_sha256 = $null
|
||||||
|
actual_sha256 = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($null -ne $hashError) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "HASH_UNAVAILABLE"
|
||||||
|
path = $relative
|
||||||
|
expected_bytes = $null
|
||||||
|
actual_bytes = [int64]$entry.Length
|
||||||
|
expected_sha256 = $null
|
||||||
|
actual_sha256 = $null
|
||||||
|
error = $hashError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($key in $expected.Keys) {
|
||||||
|
$wanted = $expected[$key]
|
||||||
|
if (-not $actual.ContainsKey($key)) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "MISSING"
|
||||||
|
path = $wanted.path
|
||||||
|
expected_bytes = $wanted.bytes
|
||||||
|
actual_bytes = $null
|
||||||
|
expected_sha256 = $wanted.sha256
|
||||||
|
actual_sha256 = $null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$found = $actual[$key]
|
||||||
|
if ($found.reparse_point -or $null -ne $found.hash_error) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($found.bytes -ne $wanted.bytes) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "SIZE_MISMATCH"
|
||||||
|
path = $wanted.path
|
||||||
|
expected_bytes = $wanted.bytes
|
||||||
|
actual_bytes = $found.bytes
|
||||||
|
expected_sha256 = $wanted.sha256
|
||||||
|
actual_sha256 = $found.sha256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($found.sha256 -ne $wanted.sha256) {
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "SHA256_MISMATCH"
|
||||||
|
path = $wanted.path
|
||||||
|
expected_bytes = $wanted.bytes
|
||||||
|
actual_bytes = $found.bytes
|
||||||
|
expected_sha256 = $wanted.sha256
|
||||||
|
actual_sha256 = $found.sha256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($key in $actual.Keys) {
|
||||||
|
if (-not $expected.ContainsKey($key)) {
|
||||||
|
$found = $actual[$key]
|
||||||
|
$mismatches += [ordered]@{
|
||||||
|
kind = "UNEXPECTED"
|
||||||
|
path = $found.path
|
||||||
|
expected_bytes = $null
|
||||||
|
actual_bytes = $found.bytes
|
||||||
|
expected_sha256 = $null
|
||||||
|
actual_sha256 = $found.sha256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = if ($mismatches.Count -eq 0) { "TARGET_VERIFIED" } else { "TARGET_CONFLICT" }
|
||||||
|
return [pscustomobject]@{
|
||||||
|
state = $state
|
||||||
|
mismatches = @($mismatches)
|
||||||
|
conflicting_files = @($conflictingFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ReleaseMetadata(
|
||||||
|
[string]$ReleaseRoot,
|
||||||
|
[string]$ExpectedBootstrapId,
|
||||||
|
[string]$ExpectedReleaseId,
|
||||||
|
[string]$ExpectedAgentVersion,
|
||||||
|
[int]$ExpectedProtocolVersion
|
||||||
|
) {
|
||||||
|
$readyPath = Join-Path $ReleaseRoot "READY.json"
|
||||||
|
if (-not (Test-Path -LiteralPath $readyPath -PathType Leaf)) {
|
||||||
|
throw "Release READY metadata is absent: $readyPath"
|
||||||
|
}
|
||||||
|
$ready = Get-Content -LiteralPath $readyPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if (
|
||||||
|
$ready.schema_version -ne 1 -or
|
||||||
|
$ready.status -ne "READY" -or
|
||||||
|
$ready.bootstrap_id -ne $ExpectedBootstrapId -or
|
||||||
|
$ready.release_id -ne $ExpectedReleaseId -or
|
||||||
|
$ready.agent_version -ne $ExpectedAgentVersion -or
|
||||||
|
$ready.protocol_version -ne $ExpectedProtocolVersion -or
|
||||||
|
$ready.hardware_accessed -ne $false -or
|
||||||
|
$ready.rf_tx -ne 0
|
||||||
|
) {
|
||||||
|
throw "Release READY metadata does not match the signed bootstrap manifest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-UniqueQuarantineDestination(
|
||||||
|
[string]$InstallRoot,
|
||||||
|
[string]$ReleaseId,
|
||||||
|
[string]$Category
|
||||||
|
) {
|
||||||
|
if ($Category -notin @("conflict", "failed_staging", "failed_release")) {
|
||||||
|
throw "Unsupported quarantine category"
|
||||||
|
}
|
||||||
|
$quarantineRoot = Join-Path $InstallRoot "quarantine"
|
||||||
|
New-Item -ItemType Directory -Force -Path $quarantineRoot | Out-Null
|
||||||
|
for ($attempt = 0; $attempt -lt 16; $attempt++) {
|
||||||
|
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'")
|
||||||
|
$unique = [Guid]::NewGuid().ToString("N")
|
||||||
|
$name = "{0}_{1}_{2}_{3}" -f $ReleaseId, $Category, $stamp, $unique
|
||||||
|
$candidate = Join-Path $quarantineRoot $name
|
||||||
|
if (
|
||||||
|
-not (Test-Path -LiteralPath $candidate) -and
|
||||||
|
-not (Test-Path -LiteralPath ($candidate + ".diagnostic.json"))
|
||||||
|
) {
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "Could not allocate a unique quarantine path"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-CreateNewUtf8Json([string]$Path, [object]$Value) {
|
||||||
|
$encoding = New-Object Text.UTF8Encoding($false)
|
||||||
|
$bytes = $encoding.GetBytes((($Value | ConvertTo-Json -Depth 10) + "`n"))
|
||||||
|
$stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||||
|
try {
|
||||||
|
$stream.Write($bytes, 0, $bytes.Length)
|
||||||
|
$stream.Flush($true)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Move-ConflictingTargetToQuarantine(
|
||||||
|
[string]$InstallRoot,
|
||||||
|
[string]$VersionsRoot,
|
||||||
|
[string]$TargetRelease,
|
||||||
|
[string]$ReleaseId,
|
||||||
|
[string]$BootstrapId,
|
||||||
|
[object]$State
|
||||||
|
) {
|
||||||
|
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
|
||||||
|
if ($State.state -ne "TARGET_CONFLICT") {
|
||||||
|
throw "Only TARGET_CONFLICT may be moved by the conflict quarantine operation"
|
||||||
|
}
|
||||||
|
$destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId "conflict"
|
||||||
|
$diagnosticPath = $destination + ".diagnostic.json"
|
||||||
|
$diagnostic = [ordered]@{
|
||||||
|
schema_version = 1
|
||||||
|
timestamp_utc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
source_path = [IO.Path]::GetFullPath($TargetRelease)
|
||||||
|
quarantine_path = [IO.Path]::GetFullPath($destination)
|
||||||
|
bootstrap_id = $BootstrapId
|
||||||
|
expected_release_id = $ReleaseId
|
||||||
|
mismatches = @($State.mismatches)
|
||||||
|
conflicting_files = @($State.conflicting_files)
|
||||||
|
}
|
||||||
|
Write-CreateNewUtf8Json $diagnosticPath $diagnostic
|
||||||
|
Move-Item -LiteralPath $TargetRelease -Destination $destination
|
||||||
|
return [pscustomobject]@{
|
||||||
|
path = $destination
|
||||||
|
diagnostic_path = $diagnosticPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Move-FailedReleasePathToQuarantine(
|
||||||
|
[string]$InstallRoot,
|
||||||
|
[string]$Path,
|
||||||
|
[string]$ReleaseId,
|
||||||
|
[string]$BootstrapId,
|
||||||
|
[string]$Category,
|
||||||
|
[string]$Cause
|
||||||
|
) {
|
||||||
|
$versionsRoot = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions")).TrimEnd('\') + '\'
|
||||||
|
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||||
|
if (-not $fullPath.StartsWith($versionsRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Failed release path is outside the fixed versions root"
|
||||||
|
}
|
||||||
|
$leaf = Split-Path -Leaf $fullPath
|
||||||
|
$validCanonical = $leaf.Equals($ReleaseId, [StringComparison]::OrdinalIgnoreCase)
|
||||||
|
$validStaging = $leaf.StartsWith(".$ReleaseId.staging_", [StringComparison]::OrdinalIgnoreCase)
|
||||||
|
if (
|
||||||
|
($Category -eq "failed_release" -and -not $validCanonical) -or
|
||||||
|
($Category -eq "failed_staging" -and -not $validStaging)
|
||||||
|
) {
|
||||||
|
throw "Failed release path is not an installer-defined release path"
|
||||||
|
}
|
||||||
|
$destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId $Category
|
||||||
|
$diagnosticPath = $destination + ".diagnostic.json"
|
||||||
|
$diagnostic = [ordered]@{
|
||||||
|
schema_version = 1
|
||||||
|
timestamp_utc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
source_path = $fullPath
|
||||||
|
quarantine_path = [IO.Path]::GetFullPath($destination)
|
||||||
|
bootstrap_id = $BootstrapId
|
||||||
|
expected_release_id = $ReleaseId
|
||||||
|
category = $Category
|
||||||
|
cause = $Cause
|
||||||
|
}
|
||||||
|
Write-CreateNewUtf8Json $diagnosticPath $diagnostic
|
||||||
|
Move-Item -LiteralPath $fullPath -Destination $destination
|
||||||
|
return $destination
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-CleanRelease(
|
||||||
|
[string]$InstallRoot,
|
||||||
|
[string]$VersionsRoot,
|
||||||
|
[string]$SourceRelease,
|
||||||
|
[string]$TargetRelease,
|
||||||
|
[string]$ReleaseId,
|
||||||
|
[string]$BootstrapId,
|
||||||
|
[string]$ReleaseBootstrapId,
|
||||||
|
[string]$AgentVersion,
|
||||||
|
[int]$ProtocolVersion,
|
||||||
|
[object[]]$ReleaseRows,
|
||||||
|
[string]$ReleasePrefix
|
||||||
|
) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) {
|
||||||
|
$ReleaseBootstrapId = $BootstrapId
|
||||||
|
}
|
||||||
|
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
|
||||||
|
if (Test-Path -LiteralPath $TargetRelease) {
|
||||||
|
throw "Canonical release path is not absent before clean deployment"
|
||||||
|
}
|
||||||
|
$staging = Join-Path $VersionsRoot (".{0}.staging_{1}" -f $ReleaseId, [Guid]::NewGuid().ToString("N"))
|
||||||
|
$canonicalCreated = $false
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||||
|
Copy-Item -LiteralPath (Join-Path $SourceRelease "app") -Destination $staging -Recurse
|
||||||
|
Copy-Item -LiteralPath (Join-Path $SourceRelease "READY.json") -Destination $staging
|
||||||
|
$stagingState = Get-ReleaseDirectoryState `
|
||||||
|
-TargetRelease $staging `
|
||||||
|
-ReleaseRows $ReleaseRows `
|
||||||
|
-ReleasePrefix $ReleasePrefix
|
||||||
|
if ($stagingState.state -ne "TARGET_VERIFIED") {
|
||||||
|
throw "Staged release failed exact file verification: $($stagingState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
|
||||||
|
}
|
||||||
|
Assert-ReleaseMetadata `
|
||||||
|
-ReleaseRoot $staging `
|
||||||
|
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||||
|
-ExpectedReleaseId $ReleaseId `
|
||||||
|
-ExpectedAgentVersion $AgentVersion `
|
||||||
|
-ExpectedProtocolVersion $ProtocolVersion
|
||||||
|
Move-Item -LiteralPath $staging -Destination $TargetRelease
|
||||||
|
$canonicalCreated = $true
|
||||||
|
$targetState = Get-ReleaseDirectoryState `
|
||||||
|
-TargetRelease $TargetRelease `
|
||||||
|
-ReleaseRows $ReleaseRows `
|
||||||
|
-ReleasePrefix $ReleasePrefix
|
||||||
|
if ($targetState.state -ne "TARGET_VERIFIED") {
|
||||||
|
throw "Canonical release failed exact file verification: $($targetState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
|
||||||
|
}
|
||||||
|
Assert-ReleaseMetadata `
|
||||||
|
-ReleaseRoot $TargetRelease `
|
||||||
|
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||||
|
-ExpectedReleaseId $ReleaseId `
|
||||||
|
-ExpectedAgentVersion $AgentVersion `
|
||||||
|
-ExpectedProtocolVersion $ProtocolVersion
|
||||||
|
return $targetState
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failure = $_.Exception.Message
|
||||||
|
$quarantineErrors = @()
|
||||||
|
if ($canonicalCreated -and (Test-Path -LiteralPath $TargetRelease)) {
|
||||||
|
try {
|
||||||
|
Move-FailedReleasePathToQuarantine $InstallRoot $TargetRelease $ReleaseId $BootstrapId "failed_release" $failure | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$quarantineErrors += $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $staging) {
|
||||||
|
try {
|
||||||
|
Move-FailedReleasePathToQuarantine $InstallRoot $staging $ReleaseId $BootstrapId "failed_staging" $failure | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$quarantineErrors += $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($quarantineErrors.Count -ne 0) {
|
||||||
|
throw "Clean release deployment failed: $failure; failed-path quarantine errors: $($quarantineErrors -join '; ')"
|
||||||
|
}
|
||||||
|
throw "Clean release deployment failed: $failure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Prepare-TargetRelease(
|
||||||
|
[string]$InstallRoot,
|
||||||
|
[string]$VersionsRoot,
|
||||||
|
[string]$SourceRelease,
|
||||||
|
[string]$TargetRelease,
|
||||||
|
[string]$ReleaseId,
|
||||||
|
[string]$BootstrapId,
|
||||||
|
[string]$ReleaseBootstrapId,
|
||||||
|
[string]$AgentVersion,
|
||||||
|
[int]$ProtocolVersion,
|
||||||
|
[object[]]$ReleaseRows,
|
||||||
|
[string]$ReleasePrefix
|
||||||
|
) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) {
|
||||||
|
$ReleaseBootstrapId = $BootstrapId
|
||||||
|
}
|
||||||
|
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
|
||||||
|
$sourceState = Get-ReleaseDirectoryState `
|
||||||
|
-TargetRelease $SourceRelease `
|
||||||
|
-ReleaseRows $ReleaseRows `
|
||||||
|
-ReleasePrefix $ReleasePrefix
|
||||||
|
if ($sourceState.state -ne "TARGET_VERIFIED") {
|
||||||
|
throw "Signed bootstrap release content is not exact: $($sourceState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
|
||||||
|
}
|
||||||
|
Assert-ReleaseMetadata `
|
||||||
|
-ReleaseRoot $SourceRelease `
|
||||||
|
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||||
|
-ExpectedReleaseId $ReleaseId `
|
||||||
|
-ExpectedAgentVersion $AgentVersion `
|
||||||
|
-ExpectedProtocolVersion $ProtocolVersion
|
||||||
|
|
||||||
|
$initial = Get-ReleaseDirectoryState `
|
||||||
|
-TargetRelease $TargetRelease `
|
||||||
|
-ReleaseRows $ReleaseRows `
|
||||||
|
-ReleasePrefix $ReleasePrefix
|
||||||
|
$quarantine = $null
|
||||||
|
if ($initial.state -eq "TARGET_CONFLICT") {
|
||||||
|
$quarantine = Move-ConflictingTargetToQuarantine `
|
||||||
|
-InstallRoot $InstallRoot `
|
||||||
|
-VersionsRoot $VersionsRoot `
|
||||||
|
-TargetRelease $TargetRelease `
|
||||||
|
-ReleaseId $ReleaseId `
|
||||||
|
-BootstrapId $BootstrapId `
|
||||||
|
-State $initial
|
||||||
|
}
|
||||||
|
if ($initial.state -ne "TARGET_VERIFIED") {
|
||||||
|
$verified = Install-CleanRelease `
|
||||||
|
-InstallRoot $InstallRoot `
|
||||||
|
-VersionsRoot $VersionsRoot `
|
||||||
|
-SourceRelease $SourceRelease `
|
||||||
|
-TargetRelease $TargetRelease `
|
||||||
|
-ReleaseId $ReleaseId `
|
||||||
|
-BootstrapId $BootstrapId `
|
||||||
|
-ReleaseBootstrapId $ReleaseBootstrapId `
|
||||||
|
-AgentVersion $AgentVersion `
|
||||||
|
-ProtocolVersion $ProtocolVersion `
|
||||||
|
-ReleaseRows $ReleaseRows `
|
||||||
|
-ReleasePrefix $ReleasePrefix
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Assert-ReleaseMetadata `
|
||||||
|
-ReleaseRoot $TargetRelease `
|
||||||
|
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||||
|
-ExpectedReleaseId $ReleaseId `
|
||||||
|
-ExpectedAgentVersion $AgentVersion `
|
||||||
|
-ExpectedProtocolVersion $ProtocolVersion
|
||||||
|
$verified = $initial
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
initial_state = $initial.state
|
||||||
|
final_state = $verified.state
|
||||||
|
reused = ($initial.state -eq "TARGET_VERIFIED")
|
||||||
|
quarantine_path = if ($null -eq $quarantine) { $null } else { $quarantine.path }
|
||||||
|
quarantine_diagnostic = if ($null -eq $quarantine) { $null } else { $quarantine.diagnostic_path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SignedAgentSelfTest(
|
||||||
|
[string]$Python,
|
||||||
|
[string]$AgentScript,
|
||||||
|
[string]$ExpectedAgentVersion,
|
||||||
|
[string]$StandardOutputPath,
|
||||||
|
[string]$StandardErrorPath
|
||||||
|
) {
|
||||||
|
$process = Start-Process `
|
||||||
|
-FilePath $Python `
|
||||||
|
-ArgumentList @("-B", "-X", "utf8", $AgentScript, "--self-test") `
|
||||||
|
-RedirectStandardOutput $StandardOutputPath `
|
||||||
|
-RedirectStandardError $StandardErrorPath `
|
||||||
|
-Wait -PassThru -WindowStyle Hidden
|
||||||
|
if ($process.ExitCode -ne 0) {
|
||||||
|
throw "Agent self-test exited with code $($process.ExitCode)"
|
||||||
|
}
|
||||||
|
$output = if (Test-Path -LiteralPath $StandardOutputPath) {
|
||||||
|
Get-Content -LiteralPath $StandardOutputPath -Raw -Encoding UTF8
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if ($output -notmatch ("(?m)^AGENT VERSION = {0}\s*$" -f [regex]::Escape($ExpectedAgentVersion))) {
|
||||||
|
throw "Agent self-test did not confirm signed AGENT_VERSION"
|
||||||
|
}
|
||||||
|
if ($output -notmatch '(?m)^SELF TEST = PASS\s*$') {
|
||||||
|
throw "Agent self-test did not report PASS"
|
||||||
|
}
|
||||||
|
return $output
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($required in @(
|
||||||
|
$manifestPath,
|
||||||
|
$signaturePath,
|
||||||
|
$python,
|
||||||
|
(Join-Path $repository ".git"),
|
||||||
|
(Join-Path $hackrfRoot "hackrf_info.exe"),
|
||||||
|
(Join-Path $hackrfRoot "hackrf_clock.exe"),
|
||||||
|
(Join-Path $hackrfRoot "hackrf_transfer.exe")
|
||||||
|
)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $required)) {
|
||||||
|
throw "Required path is absent: $required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((Test-Path -LiteralPath $sharedToken) -and (Test-Path -LiteralPath $legacyToken)) {
|
||||||
|
if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $legacyToken)) {
|
||||||
|
throw "Installed signing-token copies differ; refusing to choose one"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $sharedToken) {
|
||||||
|
$tokenSource = $sharedToken
|
||||||
|
}
|
||||||
|
elseif (Test-Path -LiteralPath $legacyToken) {
|
||||||
|
$tokenSource = $legacyToken
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw "The existing HTTP signing token is absent; it is intentionally not in this package"
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestBytes = [IO.File]::ReadAllBytes($manifestPath)
|
||||||
|
$tokenText = [IO.File]::ReadAllText($tokenSource, [Text.Encoding]::UTF8).Trim()
|
||||||
|
$tokenBytes = [Text.Encoding]::UTF8.GetBytes($tokenText)
|
||||||
|
if ($tokenBytes.Length -lt 32) {
|
||||||
|
throw "The existing HTTP signing token is invalid"
|
||||||
|
}
|
||||||
|
$domainBytes = [Text.Encoding]::UTF8.GetBytes("RF001-UPDATE-MANIFEST-V1`n")
|
||||||
|
$signedBytes = New-Object byte[] ($domainBytes.Length + $manifestBytes.Length)
|
||||||
|
[Buffer]::BlockCopy($domainBytes, 0, $signedBytes, 0, $domainBytes.Length)
|
||||||
|
[Buffer]::BlockCopy($manifestBytes, 0, $signedBytes, $domainBytes.Length, $manifestBytes.Length)
|
||||||
|
$hmac = New-Object Security.Cryptography.HMACSHA256 -ArgumentList (, $tokenBytes)
|
||||||
|
try {
|
||||||
|
$expectedSignature = ([BitConverter]::ToString($hmac.ComputeHash($signedBytes))).Replace("-", "").ToLowerInvariant()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$hmac.Dispose()
|
||||||
|
}
|
||||||
|
$suppliedSignature = [IO.File]::ReadAllText($signaturePath, [Text.Encoding]::ASCII).Trim().ToLowerInvariant()
|
||||||
|
if (-not (Test-FixedTimeHex $expectedSignature $suppliedSignature)) {
|
||||||
|
throw "Bootstrap manifest signature mismatch"
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = [Text.Encoding]::UTF8.GetString($manifestBytes) | ConvertFrom-Json
|
||||||
|
if ($manifest.schema_version -ne 1 -or $manifest.package_kind -ne "BOOTSTRAP") {
|
||||||
|
throw "Unsupported bootstrap manifest"
|
||||||
|
}
|
||||||
|
if ($manifest.protocol_version -ne 1) {
|
||||||
|
throw "Unsupported agent protocol version"
|
||||||
|
}
|
||||||
|
if ($manifest.main_server -ne "http://192.168.255.185:8765") {
|
||||||
|
throw "Bootstrap MAIN address differs from the approved address"
|
||||||
|
}
|
||||||
|
if ($manifest.allowed_rx_ipv4 -ne "192.168.255.56") {
|
||||||
|
throw "Bootstrap RX address differs from the approved address"
|
||||||
|
}
|
||||||
|
if ($manifest.reference_sha256 -ne $expectedReferenceHash) {
|
||||||
|
throw "Bootstrap reference identity is invalid"
|
||||||
|
}
|
||||||
|
$releaseId = [string]$manifest.agent_release_id
|
||||||
|
$agentVersion = [string]$manifest.agent_version
|
||||||
|
$bootstrapId = [string]$manifest.bootstrap_id
|
||||||
|
$releaseBootstrapId = [string]$manifest.agent_release_bootstrap_id
|
||||||
|
if ([string]::IsNullOrWhiteSpace($releaseBootstrapId)) {
|
||||||
|
$releaseBootstrapId = $bootstrapId
|
||||||
|
}
|
||||||
|
if ($bootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||||
|
throw "Bootstrap ID is invalid"
|
||||||
|
}
|
||||||
|
if ($releaseBootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||||
|
throw "Agent release bootstrap ID is invalid"
|
||||||
|
}
|
||||||
|
if ($releaseId -notmatch '^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||||
|
throw "Bootstrap release_id is invalid"
|
||||||
|
}
|
||||||
|
if ($agentVersion -notmatch '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||||
|
throw "Bootstrap agent version is invalid"
|
||||||
|
}
|
||||||
|
if ($releaseId -ne "rf001-agent-$agentVersion") {
|
||||||
|
throw "Bootstrap release_id does not match agent_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
$releasePrefix = "versions/$releaseId/"
|
||||||
|
$expectedPaths = @(
|
||||||
|
"INSTALL_RX_AGENT.cmd",
|
||||||
|
"README_RX_AGENT.txt",
|
||||||
|
"UNINSTALL_RX_AGENT.cmd",
|
||||||
|
"launcher/rf001_agent_launcher.py",
|
||||||
|
"reference/lab065_reference_staircase.cs8",
|
||||||
|
"tools/install_rf001_rx_autostart.ps1",
|
||||||
|
"tools/uninstall_rf001_rx_autostart.ps1",
|
||||||
|
"${releasePrefix}READY.json",
|
||||||
|
"${releasePrefix}app/tools/hackrf_process_check.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_agent_update.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_capture_runner.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_http_control.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_offline_decode.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_rx_capture.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_rx_http_agent.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_staircase_job.py",
|
||||||
|
"${releasePrefix}app/tools/rf001_staircase_search.py"
|
||||||
|
)
|
||||||
|
$expectedSet = @{}
|
||||||
|
foreach ($path in $expectedPaths) {
|
||||||
|
$expectedSet[$path.ToLowerInvariant()] = $true
|
||||||
|
}
|
||||||
|
$manifestSet = @{}
|
||||||
|
$sourcePrefix = $sourceRoot.TrimEnd('\') + '\'
|
||||||
|
foreach ($row in @($manifest.files)) {
|
||||||
|
$relative = [string]$row.path
|
||||||
|
if ($relative -match '(^/|\\|(^|/)\.\.(/|$)|:)' -or [string]::IsNullOrWhiteSpace($relative)) {
|
||||||
|
throw "Unsafe bootstrap path: $relative"
|
||||||
|
}
|
||||||
|
$key = $relative.ToLowerInvariant()
|
||||||
|
if ($manifestSet.ContainsKey($key)) {
|
||||||
|
throw "Duplicate bootstrap path: $relative"
|
||||||
|
}
|
||||||
|
if (-not $expectedSet.ContainsKey($key)) {
|
||||||
|
throw "Bootstrap file is outside the fixed allowlist: $relative"
|
||||||
|
}
|
||||||
|
$manifestSet[$key] = $true
|
||||||
|
$source = [IO.Path]::GetFullPath((Join-Path $sourceRoot ($relative.Replace('/', '\'))))
|
||||||
|
if (-not $source.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Bootstrap path escaped source root: $relative"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||||
|
throw "Bootstrap file is absent: $relative"
|
||||||
|
}
|
||||||
|
$item = Get-Item -LiteralPath $source
|
||||||
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||||
|
throw "Bootstrap reparse points are prohibited: $relative"
|
||||||
|
}
|
||||||
|
if ($item.Length -ne [int64]$row.bytes) {
|
||||||
|
throw "Bootstrap file size mismatch: $relative"
|
||||||
|
}
|
||||||
|
if ((Get-LowerSha256 $source) -ne ([string]$row.sha256).ToLowerInvariant()) {
|
||||||
|
throw "Bootstrap file SHA256 mismatch: $relative"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($manifestSet.Count -ne $expectedSet.Count) {
|
||||||
|
throw "Bootstrap manifest does not contain the complete fixed allowlist"
|
||||||
|
}
|
||||||
|
$actualFiles = Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | ForEach-Object {
|
||||||
|
$_.FullName.Substring($sourcePrefix.Length).Replace('\', '/')
|
||||||
|
}
|
||||||
|
$allowedPackageFiles = @{}
|
||||||
|
foreach ($path in $expectedPaths + @("BOOTSTRAP_MANIFEST.json", "BOOTSTRAP_MANIFEST.hmac")) {
|
||||||
|
$allowedPackageFiles[$path.ToLowerInvariant()] = $true
|
||||||
|
}
|
||||||
|
foreach ($relative in $actualFiles) {
|
||||||
|
if (-not $allowedPackageFiles.ContainsKey($relative.ToLowerInvariant())) {
|
||||||
|
throw "Unexpected file in the bootstrap package: $relative"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($actualFiles.Count -ne $allowedPackageFiles.Count) {
|
||||||
|
throw "Bootstrap package file set is incomplete or ambiguous"
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $existingTask) {
|
||||||
|
throw "The existing scheduled task '$taskName' is absent"
|
||||||
|
}
|
||||||
|
$wasRunning = $existingTask.State -eq "Running"
|
||||||
|
$backupRoot = Join-Path $installRoot (
|
||||||
|
"backups\bootstrap_{0}_{1}" -f
|
||||||
|
[DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'"),
|
||||||
|
[Guid]::NewGuid().ToString("N").Substring(0, 12)
|
||||||
|
)
|
||||||
|
New-Item -ItemType Directory -Path $backupRoot | Out-Null
|
||||||
|
$taskXmlPath = Join-Path $backupRoot "scheduled_task.xml"
|
||||||
|
[IO.File]::WriteAllText($taskXmlPath, (Export-ScheduledTask -TaskName $taskName), [Text.Encoding]::Unicode)
|
||||||
|
$backupInventory = @()
|
||||||
|
foreach ($name in @("app", "launcher", "shared", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) {
|
||||||
|
$current = Join-Path $installRoot $name
|
||||||
|
$existed = Test-Path -LiteralPath $current
|
||||||
|
$backupInventory += [ordered]@{
|
||||||
|
name = $name
|
||||||
|
existed = $existed
|
||||||
|
container = ($existed -and (Test-Path -LiteralPath $current -PathType Container))
|
||||||
|
}
|
||||||
|
if ($existed) {
|
||||||
|
Copy-Item -LiteralPath $current -Destination (Join-Path $backupRoot $name) -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-CreateNewUtf8Json (Join-Path $backupRoot "install_state_inventory.json") $backupInventory
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $versionsRoot, $launcherRoot, $sharedRoot, $logsRoot | Out-Null
|
||||||
|
if (-not (Test-Path -LiteralPath $sharedToken)) {
|
||||||
|
Copy-Item -LiteralPath $tokenSource -Destination $sharedToken
|
||||||
|
}
|
||||||
|
if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $tokenSource)) {
|
||||||
|
throw "The preserved signing token differs after copying"
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceRelease = Join-Path $sourceRoot ($releasePrefix.Replace('/', '\').TrimEnd('\'))
|
||||||
|
$targetRelease = Join-Path $versionsRoot $releaseId
|
||||||
|
$sourceAgentScript = Join-Path $sourceRelease "app\tools\rf001_rx_http_agent.py"
|
||||||
|
Invoke-SignedAgentSelfTest `
|
||||||
|
-Python $python `
|
||||||
|
-AgentScript $sourceAgentScript `
|
||||||
|
-ExpectedAgentVersion $agentVersion `
|
||||||
|
-StandardOutputPath (Join-Path $backupRoot "source_agent_self_test.stdout.txt") `
|
||||||
|
-StandardErrorPath (Join-Path $backupRoot "source_agent_self_test.stderr.txt") | Out-Null
|
||||||
|
$releaseRows = @($manifest.files | Where-Object { ([string]$_.path).StartsWith($releasePrefix) })
|
||||||
|
$targetPreparation = Prepare-TargetRelease `
|
||||||
|
-InstallRoot $installRoot `
|
||||||
|
-VersionsRoot $versionsRoot `
|
||||||
|
-SourceRelease $sourceRelease `
|
||||||
|
-TargetRelease $targetRelease `
|
||||||
|
-ReleaseId $releaseId `
|
||||||
|
-BootstrapId $bootstrapId `
|
||||||
|
-ReleaseBootstrapId $releaseBootstrapId `
|
||||||
|
-AgentVersion $agentVersion `
|
||||||
|
-ProtocolVersion ([int]$manifest.protocol_version) `
|
||||||
|
-ReleaseRows $releaseRows `
|
||||||
|
-ReleasePrefix $releasePrefix
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath (Join-Path $sourceRoot "launcher\rf001_agent_launcher.py") -Destination $launcherRoot -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $sourceRoot "tools\uninstall_rf001_rx_autostart.ps1") -Destination $installRoot -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $sourceRoot "UNINSTALL_RX_AGENT.cmd") -Destination $installRoot -Force
|
||||||
|
$referenceRoot = Join-Path $workRoot "references"
|
||||||
|
$referenceDestination = Join-Path $referenceRoot "lab065_reference_staircase.cs8"
|
||||||
|
New-Item -ItemType Directory -Force -Path $referenceRoot | Out-Null
|
||||||
|
if (Test-Path -LiteralPath $referenceDestination) {
|
||||||
|
if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) {
|
||||||
|
throw "The existing staircase reference differs; refusing to overwrite it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $sourceRoot "reference\lab065_reference_staircase.cs8") -Destination $referenceDestination
|
||||||
|
}
|
||||||
|
if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) {
|
||||||
|
throw "Installed staircase reference SHA256 is invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = Join-Path $installRoot "agent_config.json"
|
||||||
|
$activeReleasePath = Join-Path $installRoot "active_release.json"
|
||||||
|
$launcherStatusPath = Join-Path $installRoot "launcher_status.json"
|
||||||
|
$launcherRequestPath = Join-Path $installRoot "launcher_request.json"
|
||||||
|
$healthPath = Join-Path $installRoot "agent_health.json"
|
||||||
|
$agentLogPath = Join-Path $logsRoot "agent.log"
|
||||||
|
$launcherLogPath = Join-Path $logsRoot "launcher.log"
|
||||||
|
$agentArguments = @(
|
||||||
|
"--server", "http://192.168.255.185:8765",
|
||||||
|
"--token-file", $sharedToken,
|
||||||
|
"--agent-id", "user-pc",
|
||||||
|
"--repository", $repository,
|
||||||
|
"--hackrf-info", (Join-Path $hackrfRoot "hackrf_info.exe"),
|
||||||
|
"--hackrf-clock", (Join-Path $hackrfRoot "hackrf_clock.exe"),
|
||||||
|
"--hackrf-transfer", (Join-Path $hackrfRoot "hackrf_transfer.exe"),
|
||||||
|
"--work-root", $workRoot,
|
||||||
|
"--poll-interval", "5",
|
||||||
|
"--quiet-idle"
|
||||||
|
)
|
||||||
|
$config = [ordered]@{
|
||||||
|
schema_version = 1
|
||||||
|
install_root = $installRoot
|
||||||
|
releases_root = $versionsRoot
|
||||||
|
active_release_file = $activeReleasePath
|
||||||
|
launcher_status = $launcherStatusPath
|
||||||
|
launcher_request = $launcherRequestPath
|
||||||
|
health_file = $healthPath
|
||||||
|
agent_log = $agentLogPath
|
||||||
|
startup_timeout_seconds = 30
|
||||||
|
restart_delay_seconds = 2
|
||||||
|
agent_arguments = $agentArguments
|
||||||
|
hardware_accessed = $false
|
||||||
|
rf_tx = 0
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText($configPath, (($config | ConvertTo-Json -Depth 5 -Compress) + "`n"), [Text.Encoding]::UTF8)
|
||||||
|
$activeRelease = [ordered]@{
|
||||||
|
schema_version = 1
|
||||||
|
release_id = $releaseId
|
||||||
|
updated_at = [DateTime]::UtcNow.ToString("o")
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText($activeReleasePath, (($activeRelease | ConvertTo-Json -Compress) + "`n"), [Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
$agentScript = Join-Path $targetRelease "app\tools\rf001_rx_http_agent.py"
|
||||||
|
$selfTestOutput = Join-Path $backupRoot "new_agent_self_test.stdout.txt"
|
||||||
|
$selfTestError = Join-Path $backupRoot "new_agent_self_test.stderr.txt"
|
||||||
|
Invoke-SignedAgentSelfTest `
|
||||||
|
-Python $python `
|
||||||
|
-AgentScript $agentScript `
|
||||||
|
-ExpectedAgentVersion $agentVersion `
|
||||||
|
-StandardOutputPath $selfTestOutput `
|
||||||
|
-StandardErrorPath $selfTestError | Out-Null
|
||||||
|
|
||||||
|
$launcherScript = Join-Path $launcherRoot "rf001_agent_launcher.py"
|
||||||
|
$launcherArguments = @(
|
||||||
|
"-u",
|
||||||
|
"-B",
|
||||||
|
"-X", "utf8",
|
||||||
|
('"{0}"' -f $launcherScript),
|
||||||
|
"--config", ('"{0}"' -f $configPath),
|
||||||
|
"--log-file", ('"{0}"' -f $launcherLogPath)
|
||||||
|
) -join " "
|
||||||
|
$action = New-ScheduledTaskAction -Execute $python -Argument $launcherArguments -WorkingDirectory $launcherRoot
|
||||||
|
$healthBefore = if (Test-Path -LiteralPath $healthPath) { (Get-Item -LiteralPath $healthPath).LastWriteTimeUtc } else { [DateTime]::MinValue }
|
||||||
|
$installStartedAt = [DateTime]::UtcNow
|
||||||
|
|
||||||
|
try {
|
||||||
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
Set-ScheduledTask -TaskName $taskName -Action $action | Out-Null
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds(35)
|
||||||
|
$confirmed = $false
|
||||||
|
do {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$currentTask = Get-ScheduledTask -TaskName $taskName
|
||||||
|
if ((Test-Path -LiteralPath $healthPath) -and (Test-Path -LiteralPath $launcherStatusPath)) {
|
||||||
|
$healthItem = Get-Item -LiteralPath $healthPath
|
||||||
|
$health = Get-Content -LiteralPath $healthPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
$launcherStatus = Get-Content -LiteralPath $launcherStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if (
|
||||||
|
$currentTask.State -eq "Running" -and
|
||||||
|
$healthItem.LastWriteTimeUtc -gt $healthBefore -and
|
||||||
|
$healthItem.LastWriteTimeUtc -ge $installStartedAt.AddSeconds(-2) -and
|
||||||
|
$health.state -eq "RUNNING" -and
|
||||||
|
$health.agent_version -eq $agentVersion -and
|
||||||
|
$health.release_id -eq $releaseId -and
|
||||||
|
$health.rf_tx -eq 0 -and
|
||||||
|
$launcherStatus.state -eq "RUNNING" -and
|
||||||
|
$launcherStatus.active_release_id -eq $releaseId -and
|
||||||
|
$launcherStatus.active_agent_version -eq $agentVersion -and
|
||||||
|
$launcherStatus.rf_tx -eq 0
|
||||||
|
) {
|
||||||
|
$confirmed = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (-not $confirmed -and [DateTime]::UtcNow -lt $deadline)
|
||||||
|
if (-not $confirmed) {
|
||||||
|
$currentInfo = Get-ScheduledTaskInfo -TaskName $taskName
|
||||||
|
throw "Versioned agent did not report healthy startup; LastTaskResult=$($currentInfo.LastTaskResult)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failure = $_.Exception.Message
|
||||||
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
$xml = [IO.File]::ReadAllText($taskXmlPath, [Text.Encoding]::Unicode)
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Xml $xml -Force | Out-Null
|
||||||
|
foreach ($name in @("launcher", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) {
|
||||||
|
$inventoryRow = @($backupInventory | Where-Object { $_.name -eq $name })
|
||||||
|
if ($inventoryRow.Count -ne 1) {
|
||||||
|
throw "Bootstrap failed and rollback inventory is invalid. Original cause: $failure"
|
||||||
|
}
|
||||||
|
$current = Join-Path $installRoot $name
|
||||||
|
$saved = Join-Path $backupRoot $name
|
||||||
|
if ($inventoryRow[0].existed) {
|
||||||
|
if ($inventoryRow[0].container) {
|
||||||
|
if (-not (Test-Path -LiteralPath $current -PathType Container)) {
|
||||||
|
New-Item -ItemType Directory -Path $current -Force | Out-Null
|
||||||
|
}
|
||||||
|
Copy-Item -Path (Join-Path $saved '*') -Destination $current -Recurse -Force
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Copy-Item -LiteralPath $saved -Destination $current -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($wasRunning) {
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
}
|
||||||
|
throw "Bootstrap failed; previous scheduled task restored. Cause: $failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskAfter = Get-ScheduledTask -TaskName $taskName
|
||||||
|
$taskInfoAfter = Get-ScheduledTaskInfo -TaskName $taskName
|
||||||
|
"BOOTSTRAP_INSTALL=COMPLETE"
|
||||||
|
"INSTALL_ROOT=$installRoot"
|
||||||
|
"BACKUP_ROOT=$backupRoot"
|
||||||
|
"AUTOSTART_TASK=$taskName"
|
||||||
|
"AUTOSTART_ACCOUNT=$($taskAfter.Principal.UserId)"
|
||||||
|
"AUTOSTART_RUN_LEVEL=$($taskAfter.Principal.RunLevel)"
|
||||||
|
"TASK_STATE=$($taskAfter.State)"
|
||||||
|
"LAST_TASK_RESULT=$($taskInfoAfter.LastTaskResult)"
|
||||||
|
"BOOTSTRAP_ID=$bootstrapId"
|
||||||
|
"AGENT_RELEASE_ID=$releaseId"
|
||||||
|
"AGENT_VERSION=$agentVersion"
|
||||||
|
"TARGET_INITIAL_STATE=$($targetPreparation.initial_state)"
|
||||||
|
"TARGET_FINAL_STATE=$($targetPreparation.final_state)"
|
||||||
|
"TARGET_REUSED=$($targetPreparation.reused)"
|
||||||
|
"CONFLICT_QUARANTINE_PATH=$($targetPreparation.quarantine_path)"
|
||||||
|
"CONFLICT_DIAGNOSTIC_PATH=$($targetPreparation.quarantine_diagnostic)"
|
||||||
|
"CLEAN_RELEASE_HASH_VERIFICATION=PASS"
|
||||||
|
"REFERENCE_SHA256=$expectedReferenceHash"
|
||||||
|
"MANIFEST_AUTHENTICATION=HMAC-SHA256"
|
||||||
|
"ARBITRARY_SHELL=DISABLED"
|
||||||
|
"HARDWARE_ACCESSED=false"
|
||||||
|
"RF_TX=0"
|
||||||
1009
tools/rf001_agent_launcher.py
Normal file
1009
tools/rf001_agent_launcher.py
Normal file
File diff suppressed because it is too large
Load Diff
422
tools/rf001_agent_update.py
Normal file
422
tools/rf001_agent_update.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""Fixed release packaging and verification for the RF-001 RX agent.
|
||||||
|
|
||||||
|
The module deliberately has no command execution, SDR access, or generic
|
||||||
|
archive extraction. A release contains one exact allowlist of Python files
|
||||||
|
and a content manifest. Approval metadata is authenticated separately with
|
||||||
|
the already provisioned HTTP-channel HMAC secret.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
from typing import Any, Mapping
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
UPDATE_MANIFEST_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
|
||||||
|
CONTENT_MANIFEST_NAME = "RELEASE_CONTENT.json"
|
||||||
|
RELEASE_ID_PREFIX = "rf001-agent-"
|
||||||
|
RELEASE_ID_PATTERN = re.compile(r"^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
|
||||||
|
AGENT_VERSION_PATTERN = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
|
||||||
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
MAX_PACKAGE_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
|
||||||
|
MAX_ARCHIVE_ENTRIES = 32
|
||||||
|
MAX_CONTENT_MANIFEST_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
RELEASE_SOURCE_FILES: dict[str, str] = {
|
||||||
|
"app/tools/hackrf_process_check.py": "tools/hackrf_process_check.py",
|
||||||
|
"app/tools/rf001_agent_update.py": "tools/rf001_agent_update.py",
|
||||||
|
"app/tools/rf001_capture_runner.py": "tools/rf001_capture_runner.py",
|
||||||
|
"app/tools/rf001_http_control.py": "tools/rf001_http_control.py",
|
||||||
|
"app/tools/rf001_offline_decode.py": "tools/rf001_offline_decode.py",
|
||||||
|
"app/tools/rf001_rx_capture.py": "tools/rf001_rx_capture.py",
|
||||||
|
"app/tools/rf001_rx_http_agent.py": "tools/rf001_rx_http_agent.py",
|
||||||
|
"app/tools/rf001_staircase_job.py": "tools/rf001_staircase_job.py",
|
||||||
|
"app/tools/rf001_staircase_search.py": "tools/rf001_staircase_search.py",
|
||||||
|
}
|
||||||
|
REQUIRED_RELEASE_FILES = frozenset(RELEASE_SOURCE_FILES)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseValidationError(ValueError):
|
||||||
|
"""A release is malformed, unauthenticated, or outside its allowlist."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleaseInspection:
|
||||||
|
release_id: str
|
||||||
|
agent_version: str
|
||||||
|
protocol_version: int
|
||||||
|
package_sha256: str
|
||||||
|
package_bytes: int
|
||||||
|
content_manifest_sha256: str
|
||||||
|
uncompressed_bytes: int
|
||||||
|
file_count: int
|
||||||
|
|
||||||
|
def descriptor(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"release_id": self.release_id,
|
||||||
|
"agent_version": self.agent_version,
|
||||||
|
"protocol_version": self.protocol_version,
|
||||||
|
"package_sha256": self.package_sha256,
|
||||||
|
"package_bytes": self.package_bytes,
|
||||||
|
"content_manifest_sha256": self.content_manifest_sha256,
|
||||||
|
"uncompressed_bytes": self.uncompressed_bytes,
|
||||||
|
"file_count": self.file_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def source_agent_version(agent_source: Path) -> str:
|
||||||
|
"""Read the single literal AGENT_VERSION from an agent source file."""
|
||||||
|
try:
|
||||||
|
syntax = ast.parse(agent_source.read_text(encoding="utf-8"), agent_source.name)
|
||||||
|
except (OSError, SyntaxError, UnicodeError) as error:
|
||||||
|
raise ReleaseValidationError("agent source cannot be parsed") from error
|
||||||
|
values: list[ast.expr] = []
|
||||||
|
for statement in syntax.body:
|
||||||
|
if isinstance(statement, ast.Assign):
|
||||||
|
if any(
|
||||||
|
isinstance(target, ast.Name) and target.id == "AGENT_VERSION"
|
||||||
|
for target in statement.targets
|
||||||
|
):
|
||||||
|
if not (
|
||||||
|
len(statement.targets) == 1
|
||||||
|
and isinstance(statement.targets[0], ast.Name)
|
||||||
|
and statement.targets[0].id == "AGENT_VERSION"
|
||||||
|
):
|
||||||
|
raise ReleaseValidationError(
|
||||||
|
"agent source AGENT_VERSION assignment is ambiguous"
|
||||||
|
)
|
||||||
|
values.append(statement.value)
|
||||||
|
elif (
|
||||||
|
isinstance(statement, ast.AnnAssign)
|
||||||
|
and isinstance(statement.target, ast.Name)
|
||||||
|
and statement.target.id == "AGENT_VERSION"
|
||||||
|
):
|
||||||
|
if statement.value is None:
|
||||||
|
raise ReleaseValidationError("agent source AGENT_VERSION has no value")
|
||||||
|
values.append(statement.value)
|
||||||
|
if len(values) != 1:
|
||||||
|
raise ReleaseValidationError(
|
||||||
|
"agent source must contain one AGENT_VERSION assignment"
|
||||||
|
)
|
||||||
|
value = values[0]
|
||||||
|
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
|
||||||
|
raise ReleaseValidationError("agent source AGENT_VERSION must be a literal string")
|
||||||
|
if AGENT_VERSION_PATTERN.fullmatch(value.value) is None:
|
||||||
|
raise ReleaseValidationError("agent source AGENT_VERSION is invalid")
|
||||||
|
return value.value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_identity(
|
||||||
|
source_root: Path,
|
||||||
|
*,
|
||||||
|
release_id: str,
|
||||||
|
requested_agent_version: str,
|
||||||
|
) -> str:
|
||||||
|
"""Require release id, requested version, and packaged source to agree."""
|
||||||
|
if RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
||||||
|
raise ReleaseValidationError("release_id is invalid")
|
||||||
|
if AGENT_VERSION_PATTERN.fullmatch(requested_agent_version) is None:
|
||||||
|
raise ReleaseValidationError("agent_version is invalid")
|
||||||
|
if release_id != f"{RELEASE_ID_PREFIX}{requested_agent_version}":
|
||||||
|
raise ReleaseValidationError("release_id does not match agent_version")
|
||||||
|
agent_source = (
|
||||||
|
source_root / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
|
||||||
|
).resolve()
|
||||||
|
actual_agent_version = source_agent_version(agent_source)
|
||||||
|
if actual_agent_version != requested_agent_version:
|
||||||
|
raise ReleaseValidationError(
|
||||||
|
"source AGENT_VERSION does not match requested agent_version"
|
||||||
|
)
|
||||||
|
return actual_agent_version
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while block := stream.read(4 * 1024 * 1024):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign_update_manifest(token: bytes, manifest: Mapping[str, Any]) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
token,
|
||||||
|
UPDATE_MANIFEST_DOMAIN + canonical_json_bytes(manifest),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_update_manifest_signature(
|
||||||
|
token: bytes,
|
||||||
|
manifest: Mapping[str, Any],
|
||||||
|
signature: str,
|
||||||
|
) -> None:
|
||||||
|
if SHA256_PATTERN.fullmatch(signature) is None:
|
||||||
|
raise ReleaseValidationError("update manifest signature is invalid")
|
||||||
|
expected = sign_update_manifest(token, manifest)
|
||||||
|
if not hmac.compare_digest(expected, signature):
|
||||||
|
raise ReleaseValidationError("update manifest signature mismatch")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_member_name(name: str) -> PurePosixPath:
|
||||||
|
if not name or "\\" in name or name.startswith("/"):
|
||||||
|
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
|
||||||
|
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
|
||||||
|
if any(":" in part for part in path.parts):
|
||||||
|
raise ReleaseValidationError(f"drive-like archive path: {name!r}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_link(info: zipfile.ZipInfo) -> bool:
|
||||||
|
unix_mode = (info.external_attr >> 16) & 0xFFFF
|
||||||
|
return stat.S_IFMT(unix_mode) == stat.S_IFLNK
|
||||||
|
|
||||||
|
|
||||||
|
def _load_content_manifest(
|
||||||
|
archive: zipfile.ZipFile,
|
||||||
|
info: zipfile.ZipInfo,
|
||||||
|
) -> tuple[dict[str, Any], bytes]:
|
||||||
|
if info.file_size > MAX_CONTENT_MANIFEST_BYTES:
|
||||||
|
raise ReleaseValidationError("release content manifest is too large")
|
||||||
|
raw = archive.read(info)
|
||||||
|
try:
|
||||||
|
decoded = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise ReleaseValidationError("release content manifest is invalid") from error
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
raise ReleaseValidationError("release content manifest must be an object")
|
||||||
|
if raw != canonical_json_bytes(decoded):
|
||||||
|
raise ReleaseValidationError("release content manifest is not canonical JSON")
|
||||||
|
return decoded, raw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_content_manifest(manifest: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
|
if manifest.get("schema_version") != 1:
|
||||||
|
raise ReleaseValidationError("unsupported release content schema")
|
||||||
|
release_id = manifest.get("release_id")
|
||||||
|
agent_version = manifest.get("agent_version")
|
||||||
|
protocol_version = manifest.get("protocol_version")
|
||||||
|
if not isinstance(release_id, str) or RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
||||||
|
raise ReleaseValidationError("release_id is invalid")
|
||||||
|
if not isinstance(agent_version, str) or AGENT_VERSION_PATTERN.fullmatch(agent_version) is None:
|
||||||
|
raise ReleaseValidationError("agent_version is invalid")
|
||||||
|
if type(protocol_version) is not int or protocol_version <= 0:
|
||||||
|
raise ReleaseValidationError("protocol_version is invalid")
|
||||||
|
rows = manifest.get("files")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise ReleaseValidationError("release files list is invalid")
|
||||||
|
files: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise ReleaseValidationError("release file entry is invalid")
|
||||||
|
name = row.get("path")
|
||||||
|
digest = row.get("sha256")
|
||||||
|
size = row.get("bytes")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ReleaseValidationError("release file path is invalid")
|
||||||
|
_validate_member_name(name)
|
||||||
|
key = name.casefold()
|
||||||
|
if key in (existing.casefold() for existing in files):
|
||||||
|
raise ReleaseValidationError("duplicate release file path")
|
||||||
|
if SHA256_PATTERN.fullmatch(str(digest)) is None:
|
||||||
|
raise ReleaseValidationError(f"release file hash is invalid: {name}")
|
||||||
|
if type(size) is not int or size < 0:
|
||||||
|
raise ReleaseValidationError(f"release file size is invalid: {name}")
|
||||||
|
files[name] = {"path": name, "sha256": digest, "bytes": size}
|
||||||
|
if frozenset(files) != REQUIRED_RELEASE_FILES:
|
||||||
|
unexpected = sorted(set(files) - REQUIRED_RELEASE_FILES)
|
||||||
|
missing = sorted(REQUIRED_RELEASE_FILES - set(files))
|
||||||
|
raise ReleaseValidationError(
|
||||||
|
f"release file allowlist mismatch; unexpected={unexpected}; missing={missing}"
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_release_archive(
|
||||||
|
package_path: Path,
|
||||||
|
*,
|
||||||
|
expected_package_sha256: str | None = None,
|
||||||
|
expected_package_bytes: int | None = None,
|
||||||
|
expected_content_manifest_sha256: str | None = None,
|
||||||
|
extraction_root: Path | None = None,
|
||||||
|
) -> ReleaseInspection:
|
||||||
|
package_path = package_path.resolve()
|
||||||
|
package_bytes = package_path.stat().st_size
|
||||||
|
if package_bytes <= 0 or package_bytes > MAX_PACKAGE_BYTES:
|
||||||
|
raise ReleaseValidationError("release package size is outside the limit")
|
||||||
|
if expected_package_bytes is not None and package_bytes != expected_package_bytes:
|
||||||
|
raise ReleaseValidationError("release package size mismatch")
|
||||||
|
package_sha256 = sha256_file(package_path)
|
||||||
|
if expected_package_sha256 is not None and package_sha256 != expected_package_sha256:
|
||||||
|
raise ReleaseValidationError("release package SHA256 mismatch")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(package_path, "r") as archive:
|
||||||
|
entries = archive.infolist()
|
||||||
|
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
|
||||||
|
raise ReleaseValidationError("release archive entry count is invalid")
|
||||||
|
seen: set[str] = set()
|
||||||
|
regular: dict[str, zipfile.ZipInfo] = {}
|
||||||
|
uncompressed_bytes = 0
|
||||||
|
for info in entries:
|
||||||
|
path = _validate_member_name(info.filename)
|
||||||
|
normalized = path.as_posix()
|
||||||
|
key = normalized.casefold()
|
||||||
|
if key in seen:
|
||||||
|
raise ReleaseValidationError("ambiguous duplicate archive entry")
|
||||||
|
seen.add(key)
|
||||||
|
if info.flag_bits & 0x1:
|
||||||
|
raise ReleaseValidationError("encrypted archive entries are prohibited")
|
||||||
|
if _is_link(info):
|
||||||
|
raise ReleaseValidationError("archive links are prohibited")
|
||||||
|
if info.is_dir():
|
||||||
|
raise ReleaseValidationError("explicit archive directory entries are prohibited")
|
||||||
|
uncompressed_bytes += info.file_size
|
||||||
|
if uncompressed_bytes > MAX_UNCOMPRESSED_BYTES:
|
||||||
|
raise ReleaseValidationError("release uncompressed size exceeds the limit")
|
||||||
|
regular[normalized] = info
|
||||||
|
|
||||||
|
if CONTENT_MANIFEST_NAME not in regular:
|
||||||
|
raise ReleaseValidationError("release content manifest is absent")
|
||||||
|
content_manifest, content_raw = _load_content_manifest(
|
||||||
|
archive,
|
||||||
|
regular[CONTENT_MANIFEST_NAME],
|
||||||
|
)
|
||||||
|
content_sha256 = hashlib.sha256(content_raw).hexdigest()
|
||||||
|
if (
|
||||||
|
expected_content_manifest_sha256 is not None
|
||||||
|
and content_sha256 != expected_content_manifest_sha256
|
||||||
|
):
|
||||||
|
raise ReleaseValidationError("release content manifest SHA256 mismatch")
|
||||||
|
files = _validate_content_manifest(content_manifest)
|
||||||
|
expected_names = set(REQUIRED_RELEASE_FILES) | {CONTENT_MANIFEST_NAME}
|
||||||
|
if set(regular) != expected_names:
|
||||||
|
raise ReleaseValidationError("release archive contents are not allowlisted")
|
||||||
|
for name, metadata in files.items():
|
||||||
|
info = regular[name]
|
||||||
|
if info.file_size != metadata["bytes"]:
|
||||||
|
raise ReleaseValidationError(f"release file size mismatch: {name}")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with archive.open(info, "r") as stream:
|
||||||
|
while block := stream.read(1024 * 1024):
|
||||||
|
digest.update(block)
|
||||||
|
if digest.hexdigest() != metadata["sha256"]:
|
||||||
|
raise ReleaseValidationError(f"release file SHA256 mismatch: {name}")
|
||||||
|
|
||||||
|
if extraction_root is not None:
|
||||||
|
extraction_root = extraction_root.resolve()
|
||||||
|
extraction_root.mkdir(parents=True, exist_ok=False)
|
||||||
|
for name in sorted(REQUIRED_RELEASE_FILES):
|
||||||
|
target = extraction_root.joinpath(*PurePosixPath(name).parts)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
resolved = target.resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(extraction_root)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ReleaseValidationError("archive path escaped extraction root") from error
|
||||||
|
with archive.open(regular[name], "r") as source:
|
||||||
|
with target.open("xb") as destination:
|
||||||
|
shutil.copyfileobj(source, destination, length=1024 * 1024)
|
||||||
|
manifest_target = extraction_root / CONTENT_MANIFEST_NAME
|
||||||
|
with manifest_target.open("xb") as stream:
|
||||||
|
stream.write(content_raw)
|
||||||
|
|
||||||
|
return ReleaseInspection(
|
||||||
|
release_id=str(content_manifest["release_id"]),
|
||||||
|
agent_version=str(content_manifest["agent_version"]),
|
||||||
|
protocol_version=int(content_manifest["protocol_version"]),
|
||||||
|
package_sha256=package_sha256,
|
||||||
|
package_bytes=package_bytes,
|
||||||
|
content_manifest_sha256=content_sha256,
|
||||||
|
uncompressed_bytes=uncompressed_bytes,
|
||||||
|
file_count=len(files),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_archive(
|
||||||
|
source_root: Path,
|
||||||
|
package_path: Path,
|
||||||
|
*,
|
||||||
|
release_id: str,
|
||||||
|
agent_version: str,
|
||||||
|
protocol_version: int,
|
||||||
|
) -> ReleaseInspection:
|
||||||
|
if protocol_version <= 0:
|
||||||
|
raise ReleaseValidationError("protocol_version is invalid")
|
||||||
|
source_root = source_root.resolve()
|
||||||
|
validate_release_identity(
|
||||||
|
source_root,
|
||||||
|
release_id=release_id,
|
||||||
|
requested_agent_version=agent_version,
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
sources: dict[str, Path] = {}
|
||||||
|
for archive_name, relative_source in RELEASE_SOURCE_FILES.items():
|
||||||
|
source = (source_root / relative_source).resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise FileNotFoundError(f"release source is absent: {source}")
|
||||||
|
sources[archive_name] = source
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"path": archive_name,
|
||||||
|
"bytes": source.stat().st_size,
|
||||||
|
"sha256": sha256_file(source),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
content = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"release_id": release_id,
|
||||||
|
"agent_version": agent_version,
|
||||||
|
"protocol_version": protocol_version,
|
||||||
|
"files": sorted(rows, key=lambda row: str(row["path"])),
|
||||||
|
}
|
||||||
|
content_raw = canonical_json_bytes(content)
|
||||||
|
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if package_path.exists():
|
||||||
|
raise FileExistsError(f"release package already exists: {package_path}")
|
||||||
|
timestamp = (2026, 9, 29, 0, 0, 0)
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
package_path,
|
||||||
|
"x",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel=9,
|
||||||
|
) as archive:
|
||||||
|
manifest_info = zipfile.ZipInfo(CONTENT_MANIFEST_NAME, timestamp)
|
||||||
|
manifest_info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
manifest_info.external_attr = 0o100600 << 16
|
||||||
|
archive.writestr(manifest_info, content_raw)
|
||||||
|
for name in sorted(sources):
|
||||||
|
info = zipfile.ZipInfo(name, timestamp)
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
info.external_attr = 0o100600 << 16
|
||||||
|
archive.writestr(info, sources[name].read_bytes())
|
||||||
|
return inspect_release_archive(package_path)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_bytes(canonical_json_bytes(value) + b"\n")
|
||||||
|
os.replace(temporary, path)
|
||||||
334
tools/rf001_bootstrap_install_orchestrator.py
Normal file
334
tools/rf001_bootstrap_install_orchestrator.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""Безопасный запуск уже распакованного bootstrap-пакета RX-агента.
|
||||||
|
|
||||||
|
Утилита принадлежит MAIN и не входит в подписанный bootstrap. Она проверяет
|
||||||
|
HMAC и точный состав распакованного пакета до запуска, сохраняет stdout,
|
||||||
|
stderr и снимки дерева только во внешнем каталоге, а после завершения
|
||||||
|
подтверждает, что пакет не изменился.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
|
||||||
|
SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
|
||||||
|
INSTALLER_NAME = "INSTALL_RX_AGENT.cmd"
|
||||||
|
SIGNATURE_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
|
||||||
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class BootstrapInstallError(RuntimeError):
|
||||||
|
"""Базовая ошибка безопасного запуска bootstrap."""
|
||||||
|
|
||||||
|
|
||||||
|
class PackageValidationError(BootstrapInstallError):
|
||||||
|
"""Пакет не прошёл HMAC, manifest или точную проверку состава."""
|
||||||
|
|
||||||
|
|
||||||
|
class EvidencePathError(BootstrapInstallError):
|
||||||
|
"""Каталог доказательств небезопасен или уже содержит журналы запуска."""
|
||||||
|
|
||||||
|
|
||||||
|
class PackageMutationError(BootstrapInstallError):
|
||||||
|
"""Дерево bootstrap изменилось во время запуска установщика."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TreeEntry:
|
||||||
|
path: str
|
||||||
|
bytes: int
|
||||||
|
sha256: str
|
||||||
|
modified_time_ns: int
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while block := stream.read(1024 * 1024):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_tree(root: Path) -> list[TreeEntry]:
|
||||||
|
entries: list[TreeEntry] = []
|
||||||
|
for path in sorted(
|
||||||
|
(candidate for candidate in root.rglob("*") if candidate.is_file()),
|
||||||
|
key=lambda candidate: candidate.as_posix().casefold(),
|
||||||
|
):
|
||||||
|
stat = path.stat()
|
||||||
|
entries.append(
|
||||||
|
TreeEntry(
|
||||||
|
path=path.relative_to(root).as_posix(),
|
||||||
|
bytes=stat.st_size,
|
||||||
|
sha256=sha256_file(path),
|
||||||
|
modified_time_ns=stat.st_mtime_ns,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _content_identity(entries: Sequence[TreeEntry]) -> list[tuple[str, int, str]]:
|
||||||
|
return [(entry.path, entry.bytes, entry.sha256) for entry in entries]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, value: Any) -> None:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_manifest_path(value: object) -> str:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise PackageValidationError("bootstrap manifest contains an empty path")
|
||||||
|
if "\\" in value or ":" in value:
|
||||||
|
raise PackageValidationError(f"unsafe bootstrap path: {value}")
|
||||||
|
relative = PurePosixPath(value)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts or "." in relative.parts:
|
||||||
|
raise PackageValidationError(f"unsafe bootstrap path: {value}")
|
||||||
|
return relative.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pristine_package(package_root: Path, token_file: Path) -> dict[str, Any]:
|
||||||
|
package_root = package_root.resolve()
|
||||||
|
token_file = token_file.resolve()
|
||||||
|
if not package_root.is_dir():
|
||||||
|
raise PackageValidationError("bootstrap package directory is absent")
|
||||||
|
manifest_path = package_root / MANIFEST_NAME
|
||||||
|
signature_path = package_root / SIGNATURE_NAME
|
||||||
|
installer_path = package_root / INSTALLER_NAME
|
||||||
|
for required in (manifest_path, signature_path, installer_path, token_file):
|
||||||
|
if not required.is_file():
|
||||||
|
raise PackageValidationError(f"required file is absent: {required}")
|
||||||
|
|
||||||
|
manifest_bytes = manifest_path.read_bytes()
|
||||||
|
token = token_file.read_text(encoding="utf-8").strip().encode("utf-8")
|
||||||
|
if len(token) < 32:
|
||||||
|
raise PackageValidationError("installed signing token is invalid")
|
||||||
|
supplied_signature = signature_path.read_text(encoding="ascii").strip().lower()
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
token,
|
||||||
|
SIGNATURE_DOMAIN + manifest_bytes,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
if not SHA256_PATTERN.fullmatch(supplied_signature) or not hmac.compare_digest(
|
||||||
|
supplied_signature,
|
||||||
|
expected_signature,
|
||||||
|
):
|
||||||
|
raise PackageValidationError("bootstrap manifest signature mismatch")
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise PackageValidationError("bootstrap manifest is invalid JSON") from error
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise PackageValidationError("bootstrap manifest root must be an object")
|
||||||
|
if manifest.get("schema_version") != 1 or manifest.get("package_kind") != "BOOTSTRAP":
|
||||||
|
raise PackageValidationError("unsupported bootstrap manifest")
|
||||||
|
rows = manifest.get("files")
|
||||||
|
if not isinstance(rows, list) or not rows:
|
||||||
|
raise PackageValidationError("bootstrap manifest file list is invalid")
|
||||||
|
|
||||||
|
expected: dict[str, tuple[int, str]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise PackageValidationError("bootstrap manifest file row is invalid")
|
||||||
|
relative = _relative_manifest_path(row.get("path"))
|
||||||
|
folded = relative.casefold()
|
||||||
|
if folded in expected:
|
||||||
|
raise PackageValidationError(f"duplicate bootstrap path: {relative}")
|
||||||
|
byte_count = row.get("bytes")
|
||||||
|
digest = row.get("sha256")
|
||||||
|
if type(byte_count) is not int or byte_count < 0:
|
||||||
|
raise PackageValidationError(f"invalid bootstrap size: {relative}")
|
||||||
|
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest.lower()):
|
||||||
|
raise PackageValidationError(f"invalid bootstrap SHA256: {relative}")
|
||||||
|
expected[folded] = (byte_count, digest.lower())
|
||||||
|
|
||||||
|
source = package_root.joinpath(*PurePosixPath(relative).parts)
|
||||||
|
if not source.is_file() or source.is_symlink():
|
||||||
|
raise PackageValidationError(f"bootstrap file is absent or unsafe: {relative}")
|
||||||
|
if source.stat().st_size != byte_count:
|
||||||
|
raise PackageValidationError(f"bootstrap file size mismatch: {relative}")
|
||||||
|
if sha256_file(source) != digest.lower():
|
||||||
|
raise PackageValidationError(f"bootstrap file SHA256 mismatch: {relative}")
|
||||||
|
|
||||||
|
actual_entries = snapshot_tree(package_root)
|
||||||
|
actual = {entry.path.casefold() for entry in actual_entries}
|
||||||
|
allowed = set(expected) | {MANIFEST_NAME.casefold(), SIGNATURE_NAME.casefold()}
|
||||||
|
unexpected = sorted(actual - allowed)
|
||||||
|
missing = sorted(allowed - actual)
|
||||||
|
if unexpected or missing:
|
||||||
|
raise PackageValidationError(
|
||||||
|
"bootstrap package allowlist mismatch; "
|
||||||
|
f"unexpected={unexpected}; missing={missing}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"bootstrap_id": manifest.get("bootstrap_id"),
|
||||||
|
"agent_release_id": manifest.get("agent_release_id"),
|
||||||
|
"agent_version": manifest.get("agent_version"),
|
||||||
|
"hmac": "PASS",
|
||||||
|
"manifest_file_count": len(expected),
|
||||||
|
"package_file_count": len(actual_entries),
|
||||||
|
"unexpected": 0,
|
||||||
|
"missing": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_external_evidence(package_root: Path, evidence_root: Path) -> None:
|
||||||
|
package_root = package_root.resolve()
|
||||||
|
evidence_root = evidence_root.resolve()
|
||||||
|
if evidence_root == package_root or package_root in evidence_root.parents:
|
||||||
|
raise EvidencePathError("evidence directory must be outside the package tree")
|
||||||
|
|
||||||
|
|
||||||
|
def run_installer(
|
||||||
|
package_root: Path,
|
||||||
|
evidence_root: Path,
|
||||||
|
token_file: Path,
|
||||||
|
*,
|
||||||
|
command: Sequence[str] | None = None,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
package_root = package_root.resolve()
|
||||||
|
evidence_root = evidence_root.resolve()
|
||||||
|
_assert_external_evidence(package_root, evidence_root)
|
||||||
|
preflight = validate_pristine_package(package_root, token_file)
|
||||||
|
|
||||||
|
evidence_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
paths = {
|
||||||
|
"stdout": evidence_root / "install.stdout.txt",
|
||||||
|
"stderr": evidence_root / "install.stderr.txt",
|
||||||
|
"runtime": evidence_root / "install_runtime.json",
|
||||||
|
"before": evidence_root / "package_tree_before.json",
|
||||||
|
"immediate": evidence_root / "package_tree_immediate.json",
|
||||||
|
"after": evidence_root / "package_tree_after.json",
|
||||||
|
"preflight": evidence_root / "package_preflight.json",
|
||||||
|
}
|
||||||
|
occupied = [str(path) for path in paths.values() if path.exists()]
|
||||||
|
if occupied:
|
||||||
|
raise EvidencePathError(f"runtime evidence paths already exist: {occupied}")
|
||||||
|
|
||||||
|
before = snapshot_tree(package_root)
|
||||||
|
_write_json(paths["preflight"], preflight)
|
||||||
|
_write_json(paths["before"], [asdict(entry) for entry in before])
|
||||||
|
invocation = list(command) if command is not None else [
|
||||||
|
os.environ.get("COMSPEC", "cmd.exe"),
|
||||||
|
"/d",
|
||||||
|
"/c",
|
||||||
|
"call",
|
||||||
|
str(package_root / INSTALLER_NAME),
|
||||||
|
]
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"started_at_utc": utc_now(),
|
||||||
|
"package_root": str(package_root),
|
||||||
|
"evidence_root": str(evidence_root),
|
||||||
|
"working_directory": str(package_root),
|
||||||
|
"stdout_path": str(paths["stdout"]),
|
||||||
|
"stderr_path": str(paths["stderr"]),
|
||||||
|
"rf_rx": 0,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
with paths["stdout"].open("wb") as stdout_stream, paths["stderr"].open(
|
||||||
|
"wb"
|
||||||
|
) as stderr_stream:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
invocation,
|
||||||
|
cwd=package_root,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=stdout_stream,
|
||||||
|
stderr=stderr_stream,
|
||||||
|
creationflags=creation_flags,
|
||||||
|
)
|
||||||
|
metadata["installer_process_id"] = process.pid
|
||||||
|
immediate = snapshot_tree(package_root)
|
||||||
|
_write_json(paths["immediate"], [asdict(entry) for entry in immediate])
|
||||||
|
metadata["package_content_unchanged_immediate"] = (
|
||||||
|
_content_identity(immediate) == _content_identity(before)
|
||||||
|
)
|
||||||
|
metadata["package_metadata_unchanged_immediate"] = immediate == before
|
||||||
|
try:
|
||||||
|
exit_code = process.wait(timeout=timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
metadata["timed_out"] = True
|
||||||
|
metadata["completed_at_utc"] = utc_now()
|
||||||
|
_write_json(paths["runtime"], metadata)
|
||||||
|
raise BootstrapInstallError(
|
||||||
|
"installer timeout; the process was not restarted or killed automatically"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
after = snapshot_tree(package_root)
|
||||||
|
_write_json(paths["after"], [asdict(entry) for entry in after])
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"completed_at_utc": utc_now(),
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"timed_out": False,
|
||||||
|
"package_content_unchanged_after": (
|
||||||
|
_content_identity(after) == _content_identity(before)
|
||||||
|
),
|
||||||
|
"package_metadata_unchanged_after": after == before,
|
||||||
|
"stdout_bytes": paths["stdout"].stat().st_size,
|
||||||
|
"stderr_bytes": paths["stderr"].stat().st_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_write_json(paths["runtime"], metadata)
|
||||||
|
if not metadata["package_content_unchanged_immediate"] or not metadata[
|
||||||
|
"package_content_unchanged_after"
|
||||||
|
]:
|
||||||
|
raise PackageMutationError("bootstrap package changed during installer run")
|
||||||
|
if exit_code != 0:
|
||||||
|
raise BootstrapInstallError(f"installer exited with code {exit_code}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--package-root", required=True, type=Path)
|
||||||
|
parser.add_argument("--evidence-root", required=True, type=Path)
|
||||||
|
parser.add_argument("--token-file", required=True, type=Path)
|
||||||
|
parser.add_argument("--timeout-seconds", type=float, default=None)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = parse_args()
|
||||||
|
try:
|
||||||
|
result = run_installer(
|
||||||
|
arguments.package_root,
|
||||||
|
arguments.evidence_root,
|
||||||
|
arguments.token_file,
|
||||||
|
timeout_seconds=arguments.timeout_seconds,
|
||||||
|
)
|
||||||
|
except BootstrapInstallError as error:
|
||||||
|
print(f"BOOTSTRAP INSTALL = FAILED: {error}")
|
||||||
|
print("RF RX = 0")
|
||||||
|
print("RF TX = 0")
|
||||||
|
return 1
|
||||||
|
print("BOOTSTRAP INSTALL = COMPLETE")
|
||||||
|
print(f"INSTALL EXIT CODE = {result['exit_code']}")
|
||||||
|
print(f"EVIDENCE ROOT = {result['evidence_root']}")
|
||||||
|
print("PACKAGE TREE UNCHANGED = YES")
|
||||||
|
print("RF RX = 0")
|
||||||
|
print("RF TX = 0")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
16
tools/rf001_bootstrap_readme.txt
Normal file
16
tools/rf001_bootstrap_readme.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
RF-001 RX Agent — первоначальная установка механизма обновления
|
||||||
|
|
||||||
|
1. Распакуйте ZIP целиком в отдельный каталог.
|
||||||
|
2. Запустите INSTALL_RX_AGENT.cmd обычным двойным щелчком.
|
||||||
|
3. Дождитесь строки BOOTSTRAP_INSTALL=COMPLETE и нажмите любую клавишу.
|
||||||
|
|
||||||
|
Установщик использует существующее задание «SDR Rover RX Agent» и уже
|
||||||
|
установленный секрет HTTP-канала. Секрета в этом ZIP нет. Пользователь,
|
||||||
|
уровень прав, сетевые настройки, драйверы и параметры SDR не меняются.
|
||||||
|
|
||||||
|
Установка не запускает захват или передачу. При неудаче запуска новой версии
|
||||||
|
восстанавливается прежнее определение задания Планировщика.
|
||||||
|
|
||||||
|
MAIN: 192.168.255.185:8765
|
||||||
|
Разрешённый адрес RX: 192.168.255.56
|
||||||
|
RF TX: 0
|
||||||
355
tools/rf001_build_agent_packages.py
Normal file
355
tools/rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
"""Build authenticated RF-001 agent release and bootstrap packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import BinaryIO, Mapping
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tools.rf001_agent_update import (
|
||||||
|
RELEASE_SOURCE_FILES,
|
||||||
|
UPDATE_MANIFEST_DOMAIN,
|
||||||
|
build_release_archive,
|
||||||
|
canonical_json_bytes,
|
||||||
|
sha256_file,
|
||||||
|
validate_release_identity,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError: # direct execution from tools/
|
||||||
|
from rf001_agent_update import ( # type: ignore[no-redef]
|
||||||
|
RELEASE_SOURCE_FILES,
|
||||||
|
UPDATE_MANIFEST_DOMAIN,
|
||||||
|
build_release_archive,
|
||||||
|
canonical_json_bytes,
|
||||||
|
sha256_file,
|
||||||
|
validate_release_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BOOTSTRAP_ID_PATTERN = re.compile(
|
||||||
|
r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$"
|
||||||
|
)
|
||||||
|
BOOTSTRAP_MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
|
||||||
|
BOOTSTRAP_SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
|
||||||
|
REFERENCE_SHA256 = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||||
|
MAX_BOOTSTRAP_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BootstrapInspection:
|
||||||
|
bootstrap_id: str
|
||||||
|
agent_release_id: str
|
||||||
|
agent_version: str
|
||||||
|
protocol_version: int
|
||||||
|
package_sha256: str
|
||||||
|
package_bytes: int
|
||||||
|
content_manifest_sha256: str
|
||||||
|
file_count: int
|
||||||
|
|
||||||
|
|
||||||
|
def _token_bytes(path: Path) -> bytes:
|
||||||
|
value = path.read_text(encoding="utf-8-sig").strip().encode("utf-8")
|
||||||
|
if len(value) < 32:
|
||||||
|
raise ValueError("existing signing token is missing or too short")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_archive_name(name: str) -> None:
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
if (
|
||||||
|
not name
|
||||||
|
or "\\" in name
|
||||||
|
or name.startswith("/")
|
||||||
|
or path.is_absolute()
|
||||||
|
or any(part in ("", ".", "..") or ":" in part for part in path.parts)
|
||||||
|
):
|
||||||
|
raise ValueError(f"unsafe bootstrap path: {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _source_files(
|
||||||
|
source_root: Path,
|
||||||
|
*,
|
||||||
|
agent_release_id: str,
|
||||||
|
) -> dict[str, Path]:
|
||||||
|
result = {
|
||||||
|
"INSTALL_RX_AGENT.cmd": source_root / "INSTALL_RX_AGENT.cmd",
|
||||||
|
"README_RX_AGENT.txt": source_root / "tools" / "rf001_bootstrap_readme.txt",
|
||||||
|
"UNINSTALL_RX_AGENT.cmd": source_root / "UNINSTALL_RX_AGENT.cmd",
|
||||||
|
"launcher/rf001_agent_launcher.py": source_root
|
||||||
|
/ "tools"
|
||||||
|
/ "rf001_agent_launcher.py",
|
||||||
|
"reference/lab065_reference_staircase.cs8": source_root
|
||||||
|
/ ".local"
|
||||||
|
/ "rf001_probe"
|
||||||
|
/ "lab065_reference_staircase.cs8",
|
||||||
|
"tools/install_rf001_rx_autostart.ps1": source_root
|
||||||
|
/ "tools"
|
||||||
|
/ "install_rf001_rx_autostart.ps1",
|
||||||
|
"tools/uninstall_rf001_rx_autostart.ps1": source_root
|
||||||
|
/ "tools"
|
||||||
|
/ "uninstall_rf001_rx_autostart.ps1",
|
||||||
|
}
|
||||||
|
for archive_name, relative in RELEASE_SOURCE_FILES.items():
|
||||||
|
result[f"versions/{agent_release_id}/{archive_name}"] = source_root / relative
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_to_zip(
|
||||||
|
archive: zipfile.ZipFile,
|
||||||
|
name: str,
|
||||||
|
source: Path,
|
||||||
|
*,
|
||||||
|
timestamp: tuple[int, int, int, int, int, int],
|
||||||
|
) -> None:
|
||||||
|
info = zipfile.ZipInfo(name, timestamp)
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
info.external_attr = 0o100600 << 16
|
||||||
|
with source.open("rb") as input_stream, archive.open(info, "w") as output_stream:
|
||||||
|
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bytes(
|
||||||
|
archive: zipfile.ZipFile,
|
||||||
|
name: str,
|
||||||
|
value: bytes,
|
||||||
|
*,
|
||||||
|
timestamp: tuple[int, int, int, int, int, int],
|
||||||
|
) -> None:
|
||||||
|
info = zipfile.ZipInfo(name, timestamp)
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
info.external_attr = 0o100600 << 16
|
||||||
|
archive.writestr(info, value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bootstrap_archive(
|
||||||
|
source_root: Path,
|
||||||
|
package_path: Path,
|
||||||
|
*,
|
||||||
|
bootstrap_id: str,
|
||||||
|
agent_release_id: str,
|
||||||
|
agent_version: str,
|
||||||
|
protocol_version: int,
|
||||||
|
token_path: Path,
|
||||||
|
agent_release_bootstrap_id: str | None = None,
|
||||||
|
) -> BootstrapInspection:
|
||||||
|
if BOOTSTRAP_ID_PATTERN.fullmatch(bootstrap_id) is None:
|
||||||
|
raise ValueError("bootstrap_id is invalid")
|
||||||
|
if protocol_version <= 0:
|
||||||
|
raise ValueError("protocol_version is invalid")
|
||||||
|
release_bootstrap_id = agent_release_bootstrap_id or bootstrap_id
|
||||||
|
if BOOTSTRAP_ID_PATTERN.fullmatch(release_bootstrap_id) is None:
|
||||||
|
raise ValueError("agent_release_bootstrap_id is invalid")
|
||||||
|
source_root = source_root.resolve()
|
||||||
|
validate_release_identity(
|
||||||
|
source_root,
|
||||||
|
release_id=agent_release_id,
|
||||||
|
requested_agent_version=agent_version,
|
||||||
|
)
|
||||||
|
sources = _source_files(source_root, agent_release_id=agent_release_id)
|
||||||
|
for name, source in sources.items():
|
||||||
|
_validate_archive_name(name)
|
||||||
|
if not source.is_file():
|
||||||
|
raise FileNotFoundError(f"bootstrap source is absent: {source}")
|
||||||
|
reference = sources["reference/lab065_reference_staircase.cs8"]
|
||||||
|
if sha256_file(reference) != REFERENCE_SHA256:
|
||||||
|
raise ValueError("staircase reference SHA256 is invalid")
|
||||||
|
|
||||||
|
ready = canonical_json_bytes(
|
||||||
|
{
|
||||||
|
"agent_version": agent_version,
|
||||||
|
"bootstrap_id": release_bootstrap_id,
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"protocol_version": protocol_version,
|
||||||
|
"release_id": agent_release_id,
|
||||||
|
"rf_tx": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"status": "READY",
|
||||||
|
}
|
||||||
|
) + b"\n"
|
||||||
|
ready_name = f"versions/{agent_release_id}/READY.json"
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"path": name,
|
||||||
|
"bytes": source.stat().st_size,
|
||||||
|
"sha256": sha256_file(source),
|
||||||
|
}
|
||||||
|
for name, source in sources.items()
|
||||||
|
]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"path": ready_name,
|
||||||
|
"bytes": len(ready),
|
||||||
|
"sha256": hashlib.sha256(ready).hexdigest(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest: dict[str, object] = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"package_kind": "BOOTSTRAP",
|
||||||
|
"bootstrap_id": bootstrap_id,
|
||||||
|
"agent_release_bootstrap_id": release_bootstrap_id,
|
||||||
|
"agent_release_id": agent_release_id,
|
||||||
|
"agent_version": agent_version,
|
||||||
|
"protocol_version": protocol_version,
|
||||||
|
"main_server": "http://192.168.255.185:8765",
|
||||||
|
"allowed_rx_ipv4": "192.168.255.56",
|
||||||
|
"reference_sha256": REFERENCE_SHA256,
|
||||||
|
"files": sorted(rows, key=lambda row: str(row["path"])),
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
manifest_raw = canonical_json_bytes(manifest)
|
||||||
|
signature = hmac.new(
|
||||||
|
_token_bytes(token_path),
|
||||||
|
UPDATE_MANIFEST_DOMAIN + manifest_raw,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest().encode("ascii") + b"\n"
|
||||||
|
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if package_path.exists():
|
||||||
|
raise FileExistsError(f"bootstrap package already exists: {package_path}")
|
||||||
|
timestamp = (2026, 9, 29, 0, 0, 0)
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
package_path,
|
||||||
|
"x",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel=9,
|
||||||
|
) as archive:
|
||||||
|
_write_bytes(
|
||||||
|
archive, BOOTSTRAP_MANIFEST_NAME, manifest_raw, timestamp=timestamp
|
||||||
|
)
|
||||||
|
_write_bytes(
|
||||||
|
archive, BOOTSTRAP_SIGNATURE_NAME, signature, timestamp=timestamp
|
||||||
|
)
|
||||||
|
_write_bytes(archive, ready_name, ready, timestamp=timestamp)
|
||||||
|
for name in sorted(sources):
|
||||||
|
_copy_to_zip(archive, name, sources[name], timestamp=timestamp)
|
||||||
|
return inspect_bootstrap_archive(package_path, token_path=token_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _digest_stream(stream: BinaryIO) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
while block := stream.read(1024 * 1024):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_bootstrap_archive(
|
||||||
|
package_path: Path,
|
||||||
|
*,
|
||||||
|
token_path: Path,
|
||||||
|
) -> BootstrapInspection:
|
||||||
|
package_bytes = package_path.stat().st_size
|
||||||
|
if package_bytes <= 0 or package_bytes > MAX_BOOTSTRAP_BYTES:
|
||||||
|
raise ValueError("bootstrap package size is outside the limit")
|
||||||
|
with zipfile.ZipFile(package_path, "r") as archive:
|
||||||
|
infos: dict[str, zipfile.ZipInfo] = {}
|
||||||
|
for info in archive.infolist():
|
||||||
|
_validate_archive_name(info.filename)
|
||||||
|
key = info.filename.casefold()
|
||||||
|
if key in (name.casefold() for name in infos):
|
||||||
|
raise ValueError("duplicate bootstrap archive entry")
|
||||||
|
if info.is_dir() or info.flag_bits & 0x1:
|
||||||
|
raise ValueError("bootstrap archive contains a prohibited entry")
|
||||||
|
infos[info.filename] = info
|
||||||
|
if BOOTSTRAP_MANIFEST_NAME not in infos or BOOTSTRAP_SIGNATURE_NAME not in infos:
|
||||||
|
raise ValueError("bootstrap authentication metadata is absent")
|
||||||
|
manifest_raw = archive.read(infos[BOOTSTRAP_MANIFEST_NAME])
|
||||||
|
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||||
|
if not isinstance(manifest, dict) or manifest_raw != canonical_json_bytes(manifest):
|
||||||
|
raise ValueError("bootstrap manifest is not canonical JSON")
|
||||||
|
supplied = archive.read(infos[BOOTSTRAP_SIGNATURE_NAME]).decode("ascii").strip()
|
||||||
|
expected = hmac.new(
|
||||||
|
_token_bytes(token_path),
|
||||||
|
UPDATE_MANIFEST_DOMAIN + manifest_raw,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
if not hmac.compare_digest(supplied, expected):
|
||||||
|
raise ValueError("bootstrap manifest signature mismatch")
|
||||||
|
rows = manifest.get("files")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise ValueError("bootstrap file list is invalid")
|
||||||
|
expected_names = {BOOTSTRAP_MANIFEST_NAME, BOOTSTRAP_SIGNATURE_NAME}
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise ValueError("bootstrap file entry is invalid")
|
||||||
|
name = row.get("path")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ValueError("bootstrap file path is invalid")
|
||||||
|
_validate_archive_name(name)
|
||||||
|
if name in expected_names or name not in infos:
|
||||||
|
raise ValueError("bootstrap file set is invalid")
|
||||||
|
expected_names.add(name)
|
||||||
|
info = infos[name]
|
||||||
|
if info.file_size != row.get("bytes"):
|
||||||
|
raise ValueError(f"bootstrap file size mismatch: {name}")
|
||||||
|
with archive.open(info, "r") as stream:
|
||||||
|
digest = _digest_stream(stream)
|
||||||
|
if digest != row.get("sha256"):
|
||||||
|
raise ValueError(f"bootstrap file SHA256 mismatch: {name}")
|
||||||
|
if set(infos) != expected_names:
|
||||||
|
raise ValueError("bootstrap archive contains unexpected files")
|
||||||
|
return BootstrapInspection(
|
||||||
|
bootstrap_id=str(manifest["bootstrap_id"]),
|
||||||
|
agent_release_id=str(manifest["agent_release_id"]),
|
||||||
|
agent_version=str(manifest["agent_version"]),
|
||||||
|
protocol_version=int(manifest["protocol_version"]),
|
||||||
|
package_sha256=sha256_file(package_path),
|
||||||
|
package_bytes=package_bytes,
|
||||||
|
content_manifest_sha256=hashlib.sha256(manifest_raw).hexdigest(),
|
||||||
|
file_count=len(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
release = subparsers.add_parser("release")
|
||||||
|
release.add_argument("--source-root", type=Path, required=True)
|
||||||
|
release.add_argument("--package", type=Path, required=True)
|
||||||
|
release.add_argument("--release-id", required=True)
|
||||||
|
release.add_argument("--agent-version", required=True)
|
||||||
|
release.add_argument("--protocol-version", type=int, default=1)
|
||||||
|
bootstrap = subparsers.add_parser("bootstrap")
|
||||||
|
bootstrap.add_argument("--source-root", type=Path, required=True)
|
||||||
|
bootstrap.add_argument("--package", type=Path, required=True)
|
||||||
|
bootstrap.add_argument("--bootstrap-id", required=True)
|
||||||
|
bootstrap.add_argument("--agent-release-id", required=True)
|
||||||
|
bootstrap.add_argument("--agent-version", required=True)
|
||||||
|
bootstrap.add_argument("--agent-release-bootstrap-id")
|
||||||
|
bootstrap.add_argument("--protocol-version", type=int, default=1)
|
||||||
|
bootstrap.add_argument("--token-file", type=Path, required=True)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
if arguments.command == "release":
|
||||||
|
inspection = build_release_archive(
|
||||||
|
arguments.source_root,
|
||||||
|
arguments.package,
|
||||||
|
release_id=arguments.release_id,
|
||||||
|
agent_version=arguments.agent_version,
|
||||||
|
protocol_version=arguments.protocol_version,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inspection = build_bootstrap_archive(
|
||||||
|
arguments.source_root,
|
||||||
|
arguments.package,
|
||||||
|
bootstrap_id=arguments.bootstrap_id,
|
||||||
|
agent_release_id=arguments.agent_release_id,
|
||||||
|
agent_version=arguments.agent_version,
|
||||||
|
protocol_version=arguments.protocol_version,
|
||||||
|
token_path=arguments.token_file,
|
||||||
|
agent_release_bootstrap_id=arguments.agent_release_bootstrap_id,
|
||||||
|
)
|
||||||
|
print(f"PACKAGE = {arguments.package.resolve()}")
|
||||||
|
print(f"PACKAGE SHA256 = {inspection.package_sha256}")
|
||||||
|
print(f"PACKAGE BYTES = {inspection.package_bytes}")
|
||||||
|
print("HARDWARE ACCESSED = false")
|
||||||
|
print("RF TX = 0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
133
tools/rf001_capture_runner.py
Normal file
133
tools/rf001_capture_runner.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Run one finite RF-001 RX child and persist start/final evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while block := stream.read(1024 * 1024):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def run_capture(
|
||||||
|
executable: Path,
|
||||||
|
arguments_path: Path,
|
||||||
|
capture_path: Path,
|
||||||
|
stdout_path: Path,
|
||||||
|
stderr_path: Path,
|
||||||
|
started_path: Path,
|
||||||
|
result_path: Path,
|
||||||
|
) -> int:
|
||||||
|
arguments_value = json.loads(arguments_path.read_text(encoding="utf-8-sig"))
|
||||||
|
if not isinstance(arguments_value, list) or not all(
|
||||||
|
isinstance(item, str) for item in arguments_value
|
||||||
|
):
|
||||||
|
raise ValueError("arguments JSON must be an array of strings")
|
||||||
|
started_at = utc_now()
|
||||||
|
child: subprocess.Popen[bytes] | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
runner_error: str | None = None
|
||||||
|
try:
|
||||||
|
with stdout_path.open("xb") as stdout_stream, stderr_path.open(
|
||||||
|
"xb"
|
||||||
|
) as stderr_stream:
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[str(executable), *arguments_value],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=stdout_stream,
|
||||||
|
stderr=stderr_stream,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
atomic_json(
|
||||||
|
started_path,
|
||||||
|
{
|
||||||
|
"state": "RUNNING",
|
||||||
|
"pid": int(child.pid),
|
||||||
|
"started_at": started_at,
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"rf_tx": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
exit_code = int(child.wait())
|
||||||
|
except Exception as error: # persisted at the process boundary
|
||||||
|
runner_error = f"{type(error).__name__}: {error}"
|
||||||
|
finished_at = utc_now()
|
||||||
|
capture_size = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||||
|
capture_hash: str | None = None
|
||||||
|
if capture_path.is_file():
|
||||||
|
try:
|
||||||
|
capture_hash = sha256_file(capture_path)
|
||||||
|
except OSError as error:
|
||||||
|
detail = f"{type(error).__name__}: {error}"
|
||||||
|
runner_error = detail if runner_error is None else f"{runner_error}; {detail}"
|
||||||
|
duration = (
|
||||||
|
datetime.fromisoformat(finished_at) - datetime.fromisoformat(started_at)
|
||||||
|
).total_seconds()
|
||||||
|
atomic_json(
|
||||||
|
result_path,
|
||||||
|
{
|
||||||
|
"pid": int(child.pid) if child is not None else None,
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": finished_at,
|
||||||
|
"duration": duration,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"capture_size": capture_size,
|
||||||
|
"sha256": capture_hash,
|
||||||
|
"runner_error": runner_error,
|
||||||
|
"rf_tx": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 255 if exit_code is None else exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--executable", required=True, type=Path)
|
||||||
|
parser.add_argument("--arguments-json", required=True, type=Path)
|
||||||
|
parser.add_argument("--capture-path", required=True, type=Path)
|
||||||
|
parser.add_argument("--stdout-path", required=True, type=Path)
|
||||||
|
parser.add_argument("--stderr-path", required=True, type=Path)
|
||||||
|
parser.add_argument("--started-status", required=True, type=Path)
|
||||||
|
parser.add_argument("--result-status", required=True, type=Path)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
raise SystemExit(
|
||||||
|
run_capture(
|
||||||
|
arguments.executable,
|
||||||
|
arguments.arguments_json,
|
||||||
|
arguments.capture_path,
|
||||||
|
arguments.stdout_path,
|
||||||
|
arguments.stderr_path,
|
||||||
|
arguments.started_status,
|
||||||
|
arguments.result_status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1805
tools/rf001_http_control.py
Normal file
1805
tools/rf001_http_control.py
Normal file
File diff suppressed because it is too large
Load Diff
742
tools/rf001_launcher_health_diagnostic.py
Normal file
742
tools/rf001_launcher_health_diagnostic.py
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
"""Isolated Windows diagnostic for the RF-001 launcher/health handshake.
|
||||||
|
|
||||||
|
The probe imports the production launcher and calls its ``start_release`` path,
|
||||||
|
but replaces only the child command with this module's ``health-child`` mode.
|
||||||
|
That child imports ``write_health`` from the immutable canonical release, writes
|
||||||
|
one diagnostic health file, never polls HTTP, never touches SDR, and exits after
|
||||||
|
the parent has preserved process evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_STATE = "RUNNING"
|
||||||
|
NO_HARDWARE = False
|
||||||
|
NO_RF_TX = 0
|
||||||
|
_ORIGINAL_POPEN = subprocess.Popen
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_utc(value: object) -> datetime | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> dict[str, Any]:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"JSON object is required: {path}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcessRecord:
|
||||||
|
pid: int
|
||||||
|
parent_pid: int
|
||||||
|
name: str | None
|
||||||
|
executable: str | None
|
||||||
|
command_line: str | None
|
||||||
|
created_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HandshakeExpectation:
|
||||||
|
release_id: str
|
||||||
|
agent_version: str
|
||||||
|
launch_nonce: str
|
||||||
|
state: str
|
||||||
|
launcher_pid: int
|
||||||
|
popen_pid: int
|
||||||
|
launched_at: str
|
||||||
|
diagnostic_script: str
|
||||||
|
|
||||||
|
|
||||||
|
def _powershell_json(script: str) -> object:
|
||||||
|
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||||
|
process = _ORIGINAL_POPEN(
|
||||||
|
[
|
||||||
|
"powershell.exe",
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-EncodedCommand",
|
||||||
|
encoded,
|
||||||
|
],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=False,
|
||||||
|
)
|
||||||
|
stdout, stderr = process.communicate(timeout=20)
|
||||||
|
if process.returncode != 0:
|
||||||
|
detail = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
raise RuntimeError(f"Windows process inventory failed: {detail}")
|
||||||
|
text = stdout.decode("utf-8-sig", errors="strict").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_windows_processes() -> list[ProcessRecord]:
|
||||||
|
if os.name != "nt":
|
||||||
|
raise RuntimeError("Windows process inventory is required")
|
||||||
|
script = r'''
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
|
||||||
|
$rows = @(Get-CimInstance Win32_Process | ForEach-Object {
|
||||||
|
$created = $null
|
||||||
|
if ($null -ne $_.CreationDate) {
|
||||||
|
$created = $_.CreationDate.ToUniversalTime().ToString("o")
|
||||||
|
}
|
||||||
|
[ordered]@{
|
||||||
|
pid = [int]$_.ProcessId
|
||||||
|
parent_pid = [int]$_.ParentProcessId
|
||||||
|
name = $_.Name
|
||||||
|
executable = $_.ExecutablePath
|
||||||
|
command_line = $_.CommandLine
|
||||||
|
created_at = $created
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ConvertTo-Json -InputObject $rows -Depth 4 -Compress
|
||||||
|
'''
|
||||||
|
raw = _powershell_json(script)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
raw = [raw]
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError("Windows process inventory is not a list")
|
||||||
|
records: list[ProcessRecord] = []
|
||||||
|
for row in raw:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
records.append(
|
||||||
|
ProcessRecord(
|
||||||
|
pid=int(row["pid"]),
|
||||||
|
parent_pid=int(row["parent_pid"]),
|
||||||
|
name=row.get("name") if isinstance(row.get("name"), str) else None,
|
||||||
|
executable=(
|
||||||
|
row.get("executable")
|
||||||
|
if isinstance(row.get("executable"), str)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
command_line=(
|
||||||
|
row.get("command_line")
|
||||||
|
if isinstance(row.get("command_line"), str)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
created_at=(
|
||||||
|
row.get("created_at")
|
||||||
|
if isinstance(row.get("created_at"), str)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def process_map(records: Sequence[ProcessRecord]) -> dict[int, ProcessRecord]:
|
||||||
|
return {record.pid: record for record in records}
|
||||||
|
|
||||||
|
|
||||||
|
def is_descendant(
|
||||||
|
child_pid: int,
|
||||||
|
ancestor_pid: int,
|
||||||
|
records: Sequence[ProcessRecord],
|
||||||
|
) -> bool:
|
||||||
|
by_pid = process_map(records)
|
||||||
|
current = by_pid.get(child_pid)
|
||||||
|
visited: set[int] = set()
|
||||||
|
while current is not None and current.pid not in visited:
|
||||||
|
if current.parent_pid == ancestor_pid:
|
||||||
|
return True
|
||||||
|
visited.add(current.pid)
|
||||||
|
current = by_pid.get(current.parent_pid)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _record_is_fresh(record: ProcessRecord, launched_at: datetime) -> bool:
|
||||||
|
created_at = parse_utc(record.created_at)
|
||||||
|
if created_at is None:
|
||||||
|
return False
|
||||||
|
return created_at >= launched_at
|
||||||
|
|
||||||
|
|
||||||
|
def _record_matches_launch(
|
||||||
|
record: ProcessRecord,
|
||||||
|
expectation: HandshakeExpectation,
|
||||||
|
) -> bool:
|
||||||
|
command_line = record.command_line or ""
|
||||||
|
return (
|
||||||
|
expectation.launch_nonce in command_line
|
||||||
|
and expectation.diagnostic_script.casefold() in command_line.casefold()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_health_fields(
|
||||||
|
health: Mapping[str, Any] | None,
|
||||||
|
expectation: HandshakeExpectation,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
if health is None:
|
||||||
|
return {
|
||||||
|
"release_id_match": False,
|
||||||
|
"agent_version_match": False,
|
||||||
|
"launch_nonce_match": False,
|
||||||
|
"state_match": False,
|
||||||
|
"pid_match": False,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"release_id_match": health.get("release_id") == expectation.release_id,
|
||||||
|
"agent_version_match": health.get("agent_version")
|
||||||
|
== expectation.agent_version,
|
||||||
|
"launch_nonce_match": health.get("launch_nonce")
|
||||||
|
== expectation.launch_nonce,
|
||||||
|
"state_match": health.get("state") == expectation.state,
|
||||||
|
"pid_match": health.get("pid") == expectation.popen_pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_identity(
|
||||||
|
health: Mapping[str, Any] | None,
|
||||||
|
expectation: HandshakeExpectation,
|
||||||
|
records: Sequence[ProcessRecord],
|
||||||
|
*,
|
||||||
|
popen_alive: bool,
|
||||||
|
health_process_alive: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
fields = compare_health_fields(health, expectation)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
**fields,
|
||||||
|
"identity_valid": False,
|
||||||
|
"identity_model": "REJECTED",
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
if health is None:
|
||||||
|
result["reason"] = "HEALTH_ABSENT"
|
||||||
|
return result
|
||||||
|
if not all(
|
||||||
|
fields[name]
|
||||||
|
for name in (
|
||||||
|
"release_id_match",
|
||||||
|
"agent_version_match",
|
||||||
|
"launch_nonce_match",
|
||||||
|
"state_match",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
result["reason"] = "HEALTH_FIELDS_MISMATCH"
|
||||||
|
return result
|
||||||
|
updated_at = parse_utc(health.get("updated_at"))
|
||||||
|
launched_at = parse_utc(expectation.launched_at)
|
||||||
|
if updated_at is None or launched_at is None or updated_at < launched_at:
|
||||||
|
result["reason"] = "STALE_HEALTH"
|
||||||
|
return result
|
||||||
|
health_pid = health.get("pid")
|
||||||
|
if type(health_pid) is not int:
|
||||||
|
result["reason"] = "HEALTH_PID_INVALID"
|
||||||
|
return result
|
||||||
|
by_pid = process_map(records)
|
||||||
|
health_record = by_pid.get(health_pid)
|
||||||
|
if health_record is None:
|
||||||
|
result["reason"] = "HEALTH_PROCESS_ABSENT"
|
||||||
|
return result
|
||||||
|
if not health_process_alive:
|
||||||
|
result["reason"] = "HEALTH_PROCESS_DIED"
|
||||||
|
return result
|
||||||
|
if not _record_is_fresh(health_record, launched_at):
|
||||||
|
result["reason"] = "PROCESS_CREATION_TIME_MISMATCH"
|
||||||
|
return result
|
||||||
|
if not _record_matches_launch(health_record, expectation):
|
||||||
|
result["reason"] = "PROCESS_COMMAND_MISMATCH"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if health_pid == expectation.popen_pid:
|
||||||
|
if not popen_alive:
|
||||||
|
result["reason"] = "TARGET_DIED"
|
||||||
|
return result
|
||||||
|
result.update(
|
||||||
|
identity_valid=True,
|
||||||
|
identity_model="EXACT_POPEN_PROCESS",
|
||||||
|
reason="PASS",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
popen_record = by_pid.get(expectation.popen_pid)
|
||||||
|
if popen_record is None:
|
||||||
|
result["reason"] = "POPEN_PROCESS_NOT_CAPTURED"
|
||||||
|
return result
|
||||||
|
direct_children = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.parent_pid == expectation.popen_pid
|
||||||
|
and _record_is_fresh(record, launched_at)
|
||||||
|
and _record_matches_launch(record, expectation)
|
||||||
|
]
|
||||||
|
if health_record.parent_pid != expectation.popen_pid:
|
||||||
|
if is_descendant(health_pid, expectation.popen_pid, records):
|
||||||
|
result["reason"] = "UNEXPECTED_GRANDCHILD"
|
||||||
|
else:
|
||||||
|
result["reason"] = "UNRELATED_PID"
|
||||||
|
return result
|
||||||
|
if len(direct_children) != 1 or direct_children[0].pid != health_pid:
|
||||||
|
result["reason"] = "AMBIGUOUS_DIRECT_CHILD"
|
||||||
|
return result
|
||||||
|
if not _record_matches_launch(popen_record, expectation):
|
||||||
|
result["reason"] = "POPEN_COMMAND_MISMATCH"
|
||||||
|
return result
|
||||||
|
result.update(
|
||||||
|
identity_valid=True,
|
||||||
|
identity_model="UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME",
|
||||||
|
reason="PASS",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def strict_owned_process_ids(
|
||||||
|
expectation: HandshakeExpectation,
|
||||||
|
records: Sequence[ProcessRecord],
|
||||||
|
) -> list[int]:
|
||||||
|
launched_at = parse_utc(expectation.launched_at)
|
||||||
|
if launched_at is None:
|
||||||
|
return []
|
||||||
|
result: list[int] = []
|
||||||
|
for record in records:
|
||||||
|
if record.pid == expectation.launcher_pid:
|
||||||
|
continue
|
||||||
|
if not _record_is_fresh(record, launched_at):
|
||||||
|
continue
|
||||||
|
if not _record_matches_launch(record, expectation):
|
||||||
|
continue
|
||||||
|
if record.pid == expectation.popen_pid or is_descendant(
|
||||||
|
record.pid, expectation.popen_pid, records
|
||||||
|
):
|
||||||
|
result.append(record.pid)
|
||||||
|
return sorted(set(result), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def relevant_process_tree(
|
||||||
|
records: Sequence[ProcessRecord],
|
||||||
|
expectation: HandshakeExpectation,
|
||||||
|
health_pid: int | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
by_pid = process_map(records)
|
||||||
|
selected: set[int] = {expectation.launcher_pid, expectation.popen_pid}
|
||||||
|
if health_pid is not None:
|
||||||
|
selected.add(health_pid)
|
||||||
|
for record in records:
|
||||||
|
if is_descendant(record.pid, expectation.popen_pid, records):
|
||||||
|
selected.add(record.pid)
|
||||||
|
for seed in tuple(selected):
|
||||||
|
current = by_pid.get(seed)
|
||||||
|
while current is not None and current.parent_pid not in selected:
|
||||||
|
if current.parent_pid == 0:
|
||||||
|
break
|
||||||
|
selected.add(current.parent_pid)
|
||||||
|
current = by_pid.get(current.parent_pid)
|
||||||
|
return [asdict(by_pid[pid]) for pid in sorted(selected) if pid in by_pid]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(path: Path, name: str) -> ModuleType:
|
||||||
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise ImportError(f"Could not load Python module: {path}")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[name] = module
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def run_health_child(arguments: argparse.Namespace) -> int:
|
||||||
|
agent_source = arguments.agent_source.resolve()
|
||||||
|
agent = _load_module(agent_source, f"rf001_diag_agent_{os.getpid()}")
|
||||||
|
namespace = argparse.Namespace(
|
||||||
|
health_file=arguments.health_file.resolve(),
|
||||||
|
release_id=arguments.release_id,
|
||||||
|
launch_nonce=arguments.launch_nonce,
|
||||||
|
)
|
||||||
|
child_report = arguments.child_report.resolve()
|
||||||
|
started_at = utc_now()
|
||||||
|
atomic_write_json(
|
||||||
|
child_report,
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "BEFORE_HEALTH",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"parent_pid": os.getppid(),
|
||||||
|
"sys_executable": sys.executable,
|
||||||
|
"command_line": list(sys.argv),
|
||||||
|
"started_at": started_at,
|
||||||
|
"network_task_polling": False,
|
||||||
|
"hardware_accessed": NO_HARDWARE,
|
||||||
|
"rf_tx": NO_RF_TX,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
agent.write_health(namespace, None)
|
||||||
|
health_written_at = utc_now()
|
||||||
|
atomic_write_json(
|
||||||
|
child_report,
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "HEALTH_WRITTEN",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"parent_pid": os.getppid(),
|
||||||
|
"sys_executable": sys.executable,
|
||||||
|
"command_line": list(sys.argv),
|
||||||
|
"started_at": started_at,
|
||||||
|
"health_written_at": health_written_at,
|
||||||
|
"network_task_polling": False,
|
||||||
|
"hardware_accessed": NO_HARDWARE,
|
||||||
|
"rf_tx": NO_RF_TX,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
time.sleep(arguments.hold_seconds)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _process_is_present(pid: int, records: Sequence[ProcessRecord]) -> bool:
|
||||||
|
return any(record.pid == pid for record in records)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_owned_processes(
|
||||||
|
expectation: HandshakeExpectation,
|
||||||
|
records: Sequence[ProcessRecord],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
actions: list[dict[str, Any]] = []
|
||||||
|
for pid in strict_owned_process_ids(expectation, records):
|
||||||
|
fresh = capture_windows_processes()
|
||||||
|
current = process_map(fresh).get(pid)
|
||||||
|
original = process_map(records).get(pid)
|
||||||
|
if current is None or original is None:
|
||||||
|
continue
|
||||||
|
if current.created_at != original.created_at or not _record_matches_launch(
|
||||||
|
current, expectation
|
||||||
|
):
|
||||||
|
actions.append({"pid": pid, "status": "IDENTITY_CHANGED_NOT_TERMINATED"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
actions.append({"pid": pid, "status": "ALREADY_EXITED"})
|
||||||
|
else:
|
||||||
|
actions.append({"pid": pid, "status": "TERMINATED"})
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe_once(
|
||||||
|
launcher: ModuleType,
|
||||||
|
*,
|
||||||
|
canonical_release: Path,
|
||||||
|
output_directory: Path,
|
||||||
|
startup_timeout: float,
|
||||||
|
hold_seconds: float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=False)
|
||||||
|
ready = read_json(canonical_release / "READY.json")
|
||||||
|
release_id = str(ready["release_id"])
|
||||||
|
agent_version = str(ready["agent_version"])
|
||||||
|
agent_source = canonical_release / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||||
|
health_path = output_directory / "diagnostic_health.json"
|
||||||
|
child_report_path = output_directory / "diagnostic_child.json"
|
||||||
|
config: dict[str, object] = {
|
||||||
|
"install_root": str(output_directory / "isolated_install_root"),
|
||||||
|
"releases_root": str(canonical_release.parent),
|
||||||
|
"active_release_file": str(output_directory / "unused_active_release.json"),
|
||||||
|
"launcher_status": str(output_directory / "unused_launcher_status.json"),
|
||||||
|
"launcher_request": str(output_directory / "unused_launcher_request.json"),
|
||||||
|
"health_file": str(health_path),
|
||||||
|
"agent_log": str(output_directory / "diagnostic_child.log"),
|
||||||
|
"startup_timeout_seconds": startup_timeout,
|
||||||
|
"agent_arguments": [],
|
||||||
|
}
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
process_ready = threading.Event()
|
||||||
|
health_observed = threading.Event()
|
||||||
|
observer_snapshot: list[ProcessRecord] = []
|
||||||
|
original_command = launcher._agent_command
|
||||||
|
original_spawn_agent = launcher._spawn_agent
|
||||||
|
launcher_pid = os.getpid()
|
||||||
|
launched_at = utc_now()
|
||||||
|
|
||||||
|
def diagnostic_command(
|
||||||
|
_config: Mapping[str, Any],
|
||||||
|
actual_agent_source: Path,
|
||||||
|
actual_release_id: str,
|
||||||
|
launch_nonce: str,
|
||||||
|
) -> list[str]:
|
||||||
|
captured["launch_nonce"] = launch_nonce
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
"-u",
|
||||||
|
"-B",
|
||||||
|
"-X",
|
||||||
|
"utf8",
|
||||||
|
str(Path(__file__).resolve()),
|
||||||
|
"health-child",
|
||||||
|
"--agent-source",
|
||||||
|
str(actual_agent_source),
|
||||||
|
"--release-id",
|
||||||
|
actual_release_id,
|
||||||
|
"--launch-nonce",
|
||||||
|
launch_nonce,
|
||||||
|
"--health-file",
|
||||||
|
str(health_path),
|
||||||
|
"--child-report",
|
||||||
|
str(child_report_path),
|
||||||
|
"--hold-seconds",
|
||||||
|
str(hold_seconds),
|
||||||
|
]
|
||||||
|
captured["command"] = command
|
||||||
|
return command
|
||||||
|
|
||||||
|
def recording_spawn_agent(
|
||||||
|
command: Sequence[str],
|
||||||
|
*,
|
||||||
|
cwd: Path,
|
||||||
|
log_stream: Any,
|
||||||
|
) -> subprocess.Popen[str]:
|
||||||
|
process = original_spawn_agent(command, cwd=cwd, log_stream=log_stream)
|
||||||
|
captured["process"] = process
|
||||||
|
captured["popen_pid"] = process.pid
|
||||||
|
captured["popen_created_at"] = utc_now()
|
||||||
|
process_ready.set()
|
||||||
|
return process
|
||||||
|
|
||||||
|
def observer() -> None:
|
||||||
|
nonlocal observer_snapshot
|
||||||
|
if not process_ready.wait(timeout=10):
|
||||||
|
return
|
||||||
|
deadline = time.monotonic() + max(10.0, startup_timeout + 5.0)
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if health_path.exists() and child_report_path.exists():
|
||||||
|
observer_snapshot = capture_windows_processes()
|
||||||
|
health_observed.set()
|
||||||
|
return
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
observer_thread = threading.Thread(target=observer, name="rf001-health-observer")
|
||||||
|
observer_thread.start()
|
||||||
|
launcher._agent_command = diagnostic_command
|
||||||
|
launcher._spawn_agent = recording_spawn_agent
|
||||||
|
start_result = "UNKNOWN"
|
||||||
|
start_error: str | None = None
|
||||||
|
returned_process: subprocess.Popen[str] | None = None
|
||||||
|
try:
|
||||||
|
returned_process, returned_version = launcher.start_release(
|
||||||
|
config,
|
||||||
|
release_id,
|
||||||
|
expected_version=agent_version,
|
||||||
|
)
|
||||||
|
start_result = "PASS"
|
||||||
|
captured["returned_version"] = returned_version
|
||||||
|
except Exception as error:
|
||||||
|
start_result = "FAIL"
|
||||||
|
start_error = f"{type(error).__name__}: {error}"
|
||||||
|
finally:
|
||||||
|
launcher._agent_command = original_command
|
||||||
|
launcher._spawn_agent = original_spawn_agent
|
||||||
|
observer_thread.join(timeout=15)
|
||||||
|
|
||||||
|
popen_pid = int(captured.get("popen_pid", -1))
|
||||||
|
launch_nonce = str(captured.get("launch_nonce", ""))
|
||||||
|
expectation = HandshakeExpectation(
|
||||||
|
release_id=release_id,
|
||||||
|
agent_version=agent_version,
|
||||||
|
launch_nonce=launch_nonce,
|
||||||
|
state=EXPECTED_STATE,
|
||||||
|
launcher_pid=launcher_pid,
|
||||||
|
popen_pid=popen_pid,
|
||||||
|
launched_at=launched_at,
|
||||||
|
diagnostic_script=str(Path(__file__).resolve()),
|
||||||
|
)
|
||||||
|
health = read_json(health_path) if health_path.exists() else None
|
||||||
|
child_report = read_json(child_report_path) if child_report_path.exists() else None
|
||||||
|
evidence_snapshot = observer_snapshot or capture_windows_processes()
|
||||||
|
health_pid = health.get("pid") if isinstance(health, dict) else None
|
||||||
|
typed_health_pid = health_pid if type(health_pid) is int else None
|
||||||
|
popen_process = captured.get("process")
|
||||||
|
popen_alive_at_evaluation = (
|
||||||
|
isinstance(popen_process, subprocess.Popen) and popen_process.poll() is None
|
||||||
|
)
|
||||||
|
health_process_alive = (
|
||||||
|
typed_health_pid is not None
|
||||||
|
and _process_is_present(typed_health_pid, evidence_snapshot)
|
||||||
|
)
|
||||||
|
evaluation = evaluate_identity(
|
||||||
|
health,
|
||||||
|
expectation,
|
||||||
|
evidence_snapshot,
|
||||||
|
popen_alive=popen_alive_at_evaluation,
|
||||||
|
health_process_alive=health_process_alive,
|
||||||
|
)
|
||||||
|
post_failure_snapshot = capture_windows_processes()
|
||||||
|
cleanup_actions = _terminate_owned_processes(expectation, post_failure_snapshot)
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
final_snapshot = capture_windows_processes()
|
||||||
|
while strict_owned_process_ids(expectation, final_snapshot) and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.1)
|
||||||
|
final_snapshot = capture_windows_processes()
|
||||||
|
orphan_pids = strict_owned_process_ids(expectation, final_snapshot)
|
||||||
|
health_created_at = None
|
||||||
|
if health_path.exists():
|
||||||
|
health_created_at = datetime.fromtimestamp(
|
||||||
|
health_path.stat().st_ctime,
|
||||||
|
tz=timezone.utc,
|
||||||
|
).isoformat()
|
||||||
|
report: dict[str, Any] = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
|
||||||
|
"launcher_source": str(Path(launcher.__file__).resolve()),
|
||||||
|
"canonical_release": str(canonical_release.resolve()),
|
||||||
|
"canonical_release_id": release_id,
|
||||||
|
"canonical_agent_version": agent_version,
|
||||||
|
"launcher_pid": launcher_pid,
|
||||||
|
"launcher_executable": sys.executable,
|
||||||
|
"launcher_command_line": list(sys.argv),
|
||||||
|
"popen_pid": popen_pid,
|
||||||
|
"popen_command": captured.get("command"),
|
||||||
|
"popen_created_at": captured.get("popen_created_at"),
|
||||||
|
"launch_nonce": launch_nonce,
|
||||||
|
"launched_at": launched_at,
|
||||||
|
"start_release_result": start_result,
|
||||||
|
"start_release_error": start_error,
|
||||||
|
"health_observed_before_cleanup": health_observed.is_set(),
|
||||||
|
"health_path": str(health_path.resolve()),
|
||||||
|
"health_file_created_at": health_created_at,
|
||||||
|
"health": health,
|
||||||
|
"child_report": child_report,
|
||||||
|
"field_comparison": compare_health_fields(health, expectation),
|
||||||
|
"strict_identity_evaluation": evaluation,
|
||||||
|
"process_tree_at_health": relevant_process_tree(
|
||||||
|
evidence_snapshot,
|
||||||
|
expectation,
|
||||||
|
typed_health_pid,
|
||||||
|
),
|
||||||
|
"popen_alive_at_evaluation": popen_alive_at_evaluation,
|
||||||
|
"health_process_alive_at_evaluation": health_process_alive,
|
||||||
|
"cleanup_actions": cleanup_actions,
|
||||||
|
"orphan_pids_after_cleanup": orphan_pids,
|
||||||
|
"network_task_polling": False,
|
||||||
|
"hardware_accessed": NO_HARDWARE,
|
||||||
|
"rf_tx": NO_RF_TX,
|
||||||
|
"completed_at": utc_now(),
|
||||||
|
}
|
||||||
|
atomic_write_json(output_directory / "diagnostic_report.json", report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe(arguments: argparse.Namespace) -> int:
|
||||||
|
canonical_release = arguments.canonical_release.resolve()
|
||||||
|
launcher_source = arguments.launcher_source.resolve()
|
||||||
|
output_root = arguments.output_root.resolve()
|
||||||
|
if output_root.exists():
|
||||||
|
raise FileExistsError(f"diagnostic output root already exists: {output_root}")
|
||||||
|
output_root.mkdir(parents=True)
|
||||||
|
launcher = _load_module(launcher_source, "rf001_diagnostic_launcher")
|
||||||
|
reports = []
|
||||||
|
for index in range(1, arguments.runs + 1):
|
||||||
|
reports.append(
|
||||||
|
run_probe_once(
|
||||||
|
launcher,
|
||||||
|
canonical_release=canonical_release,
|
||||||
|
output_directory=output_root / f"run_{index:02d}",
|
||||||
|
startup_timeout=arguments.startup_timeout,
|
||||||
|
hold_seconds=arguments.hold_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nonces = [str(report["launch_nonce"]) for report in reports]
|
||||||
|
summary = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
|
||||||
|
"runs": reports,
|
||||||
|
"run_count": len(reports),
|
||||||
|
"nonces_unique": len(nonces) == len(set(nonces)),
|
||||||
|
"orphan_pids_after_test": sorted(
|
||||||
|
{
|
||||||
|
pid
|
||||||
|
for report in reports
|
||||||
|
for pid in report["orphan_pids_after_cleanup"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"network_task_polling": False,
|
||||||
|
"hardware_accessed": NO_HARDWARE,
|
||||||
|
"rf_tx": NO_RF_TX,
|
||||||
|
"completed_at": utc_now(),
|
||||||
|
}
|
||||||
|
atomic_write_json(output_root / "diagnostic_summary.json", summary)
|
||||||
|
print(f"DIAGNOSTIC_REPORT={output_root / 'diagnostic_summary.json'}")
|
||||||
|
print(f"RUNS={len(reports)}")
|
||||||
|
print(f"NONCES_UNIQUE={summary['nonces_unique']}")
|
||||||
|
print(f"ORPHAN_PIDS={summary['orphan_pids_after_test']}")
|
||||||
|
print("NETWORK_TASK_POLLING=false")
|
||||||
|
print("HARDWARE_ACCESSED=false")
|
||||||
|
print("RF_TX=0")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
child = subparsers.add_parser("health-child")
|
||||||
|
child.add_argument("--agent-source", required=True, type=Path)
|
||||||
|
child.add_argument("--release-id", required=True)
|
||||||
|
child.add_argument("--launch-nonce", required=True)
|
||||||
|
child.add_argument("--health-file", required=True, type=Path)
|
||||||
|
child.add_argument("--child-report", required=True, type=Path)
|
||||||
|
child.add_argument("--hold-seconds", required=True, type=float)
|
||||||
|
probe = subparsers.add_parser("probe")
|
||||||
|
probe.add_argument("--launcher-source", required=True, type=Path)
|
||||||
|
probe.add_argument("--canonical-release", required=True, type=Path)
|
||||||
|
probe.add_argument("--output-root", required=True, type=Path)
|
||||||
|
probe.add_argument("--runs", type=int, default=2)
|
||||||
|
probe.add_argument("--startup-timeout", type=float, default=2.0)
|
||||||
|
probe.add_argument("--hold-seconds", type=float, default=10.0)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
arguments = build_parser().parse_args()
|
||||||
|
if arguments.command == "health-child":
|
||||||
|
raise SystemExit(run_health_child(arguments))
|
||||||
|
if arguments.runs < 1 or arguments.runs > 4:
|
||||||
|
raise ValueError("runs must be 1 to 4")
|
||||||
|
if arguments.startup_timeout <= 0 or arguments.hold_seconds <= 0:
|
||||||
|
raise ValueError("diagnostic timeouts must be positive")
|
||||||
|
raise SystemExit(run_probe(arguments))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
86
tools/rf001_offline_decode.py
Normal file
86
tools/rf001_offline_decode.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Fixed software-only RF-001A decoder used by the HTTP RX agent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
Decoder = Callable[..., dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_offline(
|
||||||
|
repository: Path,
|
||||||
|
iq_path: Path,
|
||||||
|
truth_path: Path,
|
||||||
|
report_path: Path,
|
||||||
|
*,
|
||||||
|
capture_started_at: str | None = None,
|
||||||
|
tx_started_at: str | None = None,
|
||||||
|
tx_finished_at: str | None = None,
|
||||||
|
decoder: Decoder | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decode one generated cs8 file with the dependency-free legacy backend."""
|
||||||
|
|
||||||
|
repository = repository.resolve()
|
||||||
|
if not repository.is_dir():
|
||||||
|
raise FileNotFoundError(f"repository is absent: {repository}")
|
||||||
|
for required in (iq_path, truth_path):
|
||||||
|
if not required.is_file():
|
||||||
|
raise FileNotFoundError(f"required input is absent: {required}")
|
||||||
|
if report_path.exists():
|
||||||
|
raise FileExistsError(f"report already exists: {report_path}")
|
||||||
|
|
||||||
|
if decoder is None:
|
||||||
|
sys.path.insert(0, str(repository))
|
||||||
|
from tools.qpsk_rf_rx import decode_file
|
||||||
|
|
||||||
|
decoder = decode_file
|
||||||
|
result = decoder(
|
||||||
|
iq_path,
|
||||||
|
iq_format="cs8",
|
||||||
|
truth_metadata_path=truth_path,
|
||||||
|
decoder_backend="legacy",
|
||||||
|
capture_started_at=capture_started_at,
|
||||||
|
tx_started_at=tx_started_at,
|
||||||
|
tx_finished_at=tx_finished_at,
|
||||||
|
)
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path.write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--repository", required=True, type=Path)
|
||||||
|
parser.add_argument("--iq-file", required=True, type=Path)
|
||||||
|
parser.add_argument("--truth-metadata", required=True, type=Path)
|
||||||
|
parser.add_argument("--report", required=True, type=Path)
|
||||||
|
parser.add_argument("--capture-started-at")
|
||||||
|
parser.add_argument("--tx-started-at")
|
||||||
|
parser.add_argument("--tx-finished-at")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
result = decode_offline(
|
||||||
|
arguments.repository,
|
||||||
|
arguments.iq_file,
|
||||||
|
arguments.truth_metadata,
|
||||||
|
arguments.report,
|
||||||
|
capture_started_at=arguments.capture_started_at,
|
||||||
|
tx_started_at=arguments.tx_started_at,
|
||||||
|
tx_finished_at=arguments.tx_finished_at,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"RF-001 OFFLINE RX: "
|
||||||
|
f"CRC={result['crc_valid_packets']}; "
|
||||||
|
f"exact={result['exact_packets']}; RF TX=0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
684
tools/rf001_rx_capture.py
Normal file
684
tools/rf001_rx_capture.py
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
"""Fixed, receive-only RF-001A capture operations for the HTTP agent.
|
||||||
|
|
||||||
|
This module has no transmit operation. A capture uses one immutable RF profile,
|
||||||
|
one known RX serial number, a unique directory outside Git, and one finite
|
||||||
|
``hackrf_transfer`` child process. Failed readiness checks stop that exact
|
||||||
|
process tree and never retry the capture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
FIXED_RX_SERIAL = "000000000000000097c063dc2e5327d3"
|
||||||
|
PROFILE_NAME = "RF001A_COMMON_CLOCK_001"
|
||||||
|
MINIMUM_FREE_BYTES = 6_000_000_000
|
||||||
|
READINESS_TIMEOUT_SECONDS = 5.0
|
||||||
|
CAPTURE_SECONDS = 300
|
||||||
|
CAPTURE_SAMPLES = 2_400_000_000
|
||||||
|
CAPTURE_FILE_NAME = "rf001a_ota_capture.cs8"
|
||||||
|
MANIFEST_FILE_NAME = "capture_manifest.json"
|
||||||
|
STARTED_FILE_NAME = "capture_started.json"
|
||||||
|
RESULT_FILE_NAME = "capture_result.json"
|
||||||
|
REPORT_FILE_NAME = "offline_decode_report.json"
|
||||||
|
MAX_REPORT_BYTES = 192 * 1024
|
||||||
|
|
||||||
|
EXPECTED_PROFILE: dict[str, object] = {
|
||||||
|
"test_id": PROFILE_NAME,
|
||||||
|
"center_frequency_hz": 1_400_000_000,
|
||||||
|
"sample_rate_hz": 8_000_000,
|
||||||
|
"symbol_rate_sps": 500_000,
|
||||||
|
"modulation": "QPSK",
|
||||||
|
"inner_fec": "2/3",
|
||||||
|
"application_payload_bytes": 256,
|
||||||
|
"packet_count": 100,
|
||||||
|
"inter_packet_gap_ms": 5,
|
||||||
|
"clock_mode": "COMMON_10_MHZ",
|
||||||
|
"rx_lna_db": 24,
|
||||||
|
"rx_vga_db": 24,
|
||||||
|
"rf_amp_enabled": False,
|
||||||
|
"antenna_bias_enabled": False,
|
||||||
|
"hardware_format": "cs8",
|
||||||
|
"rx_capture_seconds": 20,
|
||||||
|
"rx_capture_samples": 160_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandRunner = Callable[[Sequence[str], float], Any]
|
||||||
|
BackgroundLauncher = Callable[[Sequence[str]], int]
|
||||||
|
ProcessGate = Callable[[], tuple[bool, Mapping[str, object]]]
|
||||||
|
ProcessStopper = Callable[[int], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"JSON object required: {path}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixed_profile(repository: Path) -> dict[str, object]:
|
||||||
|
path = repository / "configs" / "rf" / "rf001a_common_clock.json"
|
||||||
|
profile = _read_json(path)
|
||||||
|
mismatches = {
|
||||||
|
key: {"expected": expected, "actual": profile.get(key)}
|
||||||
|
for key, expected in EXPECTED_PROFILE.items()
|
||||||
|
if profile.get(key) != expected
|
||||||
|
}
|
||||||
|
if mismatches:
|
||||||
|
raise ValueError(f"RF-001A capture profile mismatch: {mismatches}")
|
||||||
|
return {key: profile[key] for key in EXPECTED_PROFILE}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hackrf_tools(
|
||||||
|
hackrf_info_path: Path | None,
|
||||||
|
hackrf_clock_path: Path | None,
|
||||||
|
hackrf_transfer_path: Path | None,
|
||||||
|
) -> tuple[Path, Path, Path]:
|
||||||
|
if hackrf_info_path is None:
|
||||||
|
raise FileNotFoundError("hackrf_info path is required for RX capture")
|
||||||
|
base = hackrf_info_path.parent
|
||||||
|
info = hackrf_info_path
|
||||||
|
clock = hackrf_clock_path or base / "hackrf_clock.exe"
|
||||||
|
transfer = hackrf_transfer_path or base / "hackrf_transfer.exe"
|
||||||
|
for path in (info, clock, transfer):
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"required HackRF tool is absent: {path}")
|
||||||
|
return info, clock, transfer
|
||||||
|
|
||||||
|
|
||||||
|
def default_process_gate() -> tuple[bool, Mapping[str, object]]:
|
||||||
|
from tools.hackrf_process_check import ProcessState, check_processes
|
||||||
|
|
||||||
|
check = check_processes()
|
||||||
|
return check.state is ProcessState.CLEAR, check.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def default_background_launcher(arguments: Sequence[str]) -> int:
|
||||||
|
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
|
||||||
|
subprocess, "CREATE_NO_WINDOW", 0
|
||||||
|
)
|
||||||
|
process = subprocess.Popen(
|
||||||
|
list(arguments),
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=flags,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
return int(process.pid)
|
||||||
|
|
||||||
|
|
||||||
|
def default_process_stopper(pid: int) -> None:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["taskkill.exe", "/PID", str(pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
if completed.returncode not in {0, 128}:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"taskkill failed for pid {pid}: {completed.returncode}: "
|
||||||
|
f"{completed.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_utc(value: str) -> datetime:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must contain a timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_command(result: Any) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"arguments": list(result.arguments),
|
||||||
|
"returncode": int(result.returncode),
|
||||||
|
"stdout": str(result.stdout)[-8_000:],
|
||||||
|
"stderr": str(result.stderr)[-8_000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_rx_capture(
|
||||||
|
repository: Path,
|
||||||
|
work_root: Path,
|
||||||
|
session_id: str,
|
||||||
|
rx_serial: str,
|
||||||
|
*,
|
||||||
|
hackrf_info_path: Path | None,
|
||||||
|
hackrf_clock_path: Path | None,
|
||||||
|
hackrf_transfer_path: Path | None,
|
||||||
|
command_runner: CommandRunner,
|
||||||
|
process_gate: ProcessGate = default_process_gate,
|
||||||
|
background_launcher: BackgroundLauncher = default_background_launcher,
|
||||||
|
process_stopper: ProcessStopper = default_process_stopper,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
|
) -> tuple[dict[str, object], list[str]]:
|
||||||
|
"""Start one finite RX capture and return only after the READY gate."""
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"state": "NOT_READY",
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"common_clock_verified": False,
|
||||||
|
"rf_rx": 0,
|
||||||
|
"rf_tx": 0,
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
runner_pid: int | None = None
|
||||||
|
child_pid: int | None = None
|
||||||
|
try:
|
||||||
|
if rx_serial != FIXED_RX_SERIAL:
|
||||||
|
raise ValueError("RX serial does not match the fixed RF-001 receiver")
|
||||||
|
profile = load_fixed_profile(repository)
|
||||||
|
info_path, clock_path, transfer_path = resolve_hackrf_tools(
|
||||||
|
hackrf_info_path,
|
||||||
|
hackrf_clock_path,
|
||||||
|
hackrf_transfer_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_directory = work_root / "rx_capture" / session_id
|
||||||
|
session_directory.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if session_directory.exists():
|
||||||
|
raise FileExistsError(f"capture session already exists: {session_directory}")
|
||||||
|
free_bytes = shutil.disk_usage(session_directory.parent).free
|
||||||
|
result["free_bytes_before"] = free_bytes
|
||||||
|
if free_bytes < MINIMUM_FREE_BYTES:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"capture volume has {free_bytes} free bytes; "
|
||||||
|
f"{MINIMUM_FREE_BYTES} required"
|
||||||
|
)
|
||||||
|
|
||||||
|
clear, process_evidence = process_gate()
|
||||||
|
result["process_gate"] = dict(process_evidence)
|
||||||
|
if not clear:
|
||||||
|
raise RuntimeError("conflicting SDR process gate is not CLEAR")
|
||||||
|
|
||||||
|
info = command_runner([str(info_path)], 20.0)
|
||||||
|
result["hackrf_info"] = _bounded_command(info)
|
||||||
|
result["hardware_accessed"] = True
|
||||||
|
if info.returncode != 0 or rx_serial.lower() not in (
|
||||||
|
f"{info.stdout}\n{info.stderr}".lower()
|
||||||
|
):
|
||||||
|
raise RuntimeError("hackrf_info did not confirm the fixed RX serial")
|
||||||
|
|
||||||
|
clock = command_runner([str(clock_path), "-d", rx_serial, "-i"], 20.0)
|
||||||
|
result["hackrf_clock"] = _bounded_command(clock)
|
||||||
|
clock_lines = {
|
||||||
|
line.strip().lower()
|
||||||
|
for line in f"{clock.stdout}\n{clock.stderr}".splitlines()
|
||||||
|
}
|
||||||
|
if clock.returncode != 0 or not any(
|
||||||
|
line.endswith("clkin status: clock signal detected")
|
||||||
|
for line in clock_lines
|
||||||
|
):
|
||||||
|
raise RuntimeError("CLKIN clock signal was not detected")
|
||||||
|
result["common_clock_verified"] = True
|
||||||
|
|
||||||
|
session_directory.mkdir()
|
||||||
|
|
||||||
|
capture_path = session_directory / CAPTURE_FILE_NAME
|
||||||
|
arguments_path = session_directory / "hackrf_transfer_arguments.json"
|
||||||
|
stdout_path = session_directory / "hackrf_transfer.stdout.txt"
|
||||||
|
stderr_path = session_directory / "hackrf_transfer.stderr.txt"
|
||||||
|
started_path = session_directory / STARTED_FILE_NAME
|
||||||
|
final_path = session_directory / RESULT_FILE_NAME
|
||||||
|
manifest_path = session_directory / MANIFEST_FILE_NAME
|
||||||
|
runner_path = Path(__file__).resolve().with_name("rf001_capture_runner.py")
|
||||||
|
if not runner_path.is_file():
|
||||||
|
raise FileNotFoundError(f"capture runner is absent: {runner_path}")
|
||||||
|
|
||||||
|
transfer_arguments = [
|
||||||
|
"-d", rx_serial,
|
||||||
|
"-r", str(capture_path),
|
||||||
|
"-f", str(profile["center_frequency_hz"]),
|
||||||
|
"-s", str(profile["sample_rate_hz"]),
|
||||||
|
"-n", str(CAPTURE_SAMPLES),
|
||||||
|
"-l", str(profile["rx_lna_db"]),
|
||||||
|
"-g", str(profile["rx_vga_db"]),
|
||||||
|
"-a", "0",
|
||||||
|
"-p", "0",
|
||||||
|
"-B",
|
||||||
|
]
|
||||||
|
arguments_path.write_text(
|
||||||
|
json.dumps(transfer_arguments, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
launch_arguments = [
|
||||||
|
sys.executable,
|
||||||
|
"-B",
|
||||||
|
"-X",
|
||||||
|
"utf8",
|
||||||
|
str(runner_path),
|
||||||
|
"--executable",
|
||||||
|
str(transfer_path),
|
||||||
|
"--arguments-json",
|
||||||
|
str(arguments_path),
|
||||||
|
"--capture-path",
|
||||||
|
str(capture_path),
|
||||||
|
"--stdout-path",
|
||||||
|
str(stdout_path),
|
||||||
|
"--stderr-path",
|
||||||
|
str(stderr_path),
|
||||||
|
"--started-status",
|
||||||
|
str(started_path),
|
||||||
|
"--result-status",
|
||||||
|
str(final_path),
|
||||||
|
]
|
||||||
|
runner_pid = background_launcher(launch_arguments)
|
||||||
|
result["runner_pid"] = runner_pid
|
||||||
|
result["rf_rx"] = 1
|
||||||
|
|
||||||
|
deadline = monotonic() + READINESS_TIMEOUT_SECONDS
|
||||||
|
first_positive_size: int | None = None
|
||||||
|
latest_size = 0
|
||||||
|
started: dict[str, Any] | None = None
|
||||||
|
while monotonic() < deadline:
|
||||||
|
if final_path.is_file():
|
||||||
|
final = _read_json(final_path)
|
||||||
|
raise RuntimeError(f"capture exited before READY: {final}")
|
||||||
|
if started_path.is_file():
|
||||||
|
started = _read_json(started_path)
|
||||||
|
raw_pid = started.get("pid")
|
||||||
|
if type(raw_pid) is int and raw_pid > 0:
|
||||||
|
child_pid = raw_pid
|
||||||
|
if capture_path.is_file():
|
||||||
|
latest_size = capture_path.stat().st_size
|
||||||
|
if latest_size > 0:
|
||||||
|
if first_positive_size is None:
|
||||||
|
first_positive_size = latest_size
|
||||||
|
elif latest_size > first_positive_size and started is not None:
|
||||||
|
break
|
||||||
|
sleep(0.2)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("capture did not become READY within five seconds")
|
||||||
|
|
||||||
|
if started is None or child_pid is None or first_positive_size is None:
|
||||||
|
raise RuntimeError("capture readiness evidence is incomplete")
|
||||||
|
capture_started_at = str(started.get("started_at", ""))
|
||||||
|
expected_end = _iso_utc(capture_started_at) + timedelta(
|
||||||
|
seconds=CAPTURE_SECONDS
|
||||||
|
)
|
||||||
|
expected_bytes = CAPTURE_SAMPLES * 2
|
||||||
|
manifest: dict[str, object] = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"session_id": session_id,
|
||||||
|
"profile": profile,
|
||||||
|
"capture_seconds": CAPTURE_SECONDS,
|
||||||
|
"capture_samples": CAPTURE_SAMPLES,
|
||||||
|
"capture_duration_reason": "manual AUTHORIZE RF TX gate",
|
||||||
|
"rx_serial": rx_serial,
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"capture_started_at": capture_started_at,
|
||||||
|
"expected_capture_end": expected_end.isoformat(),
|
||||||
|
"expected_capture_bytes": expected_bytes,
|
||||||
|
"runner_pid": runner_pid,
|
||||||
|
"child_pid": child_pid,
|
||||||
|
"started_status_path": str(started_path),
|
||||||
|
"result_status_path": str(final_path),
|
||||||
|
"stdout_path": str(stdout_path),
|
||||||
|
"stderr_path": str(stderr_path),
|
||||||
|
"common_clock_verified": True,
|
||||||
|
"automatic_retry": False,
|
||||||
|
"rf_rx": 1,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
_atomic_json(manifest_path, manifest)
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"state": "READY",
|
||||||
|
"session_directory": str(session_directory),
|
||||||
|
"manifest_path": str(manifest_path),
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"capture_started_at": capture_started_at,
|
||||||
|
"expected_capture_end": expected_end.isoformat(),
|
||||||
|
"expected_capture_bytes": expected_bytes,
|
||||||
|
"child_pid": child_pid,
|
||||||
|
"capture_bytes_at_ready": latest_size,
|
||||||
|
"common_clock_verified": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
ImportError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
RuntimeError,
|
||||||
|
subprocess.SubprocessError,
|
||||||
|
) as error:
|
||||||
|
errors.append(f"{type(error).__name__}: {error}")
|
||||||
|
for pid in (child_pid, runner_pid):
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
process_stopper(pid)
|
||||||
|
except (OSError, RuntimeError, subprocess.SubprocessError) as stop_error:
|
||||||
|
errors.append(
|
||||||
|
f"failed to stop pid {pid}: {type(stop_error).__name__}: "
|
||||||
|
f"{stop_error}"
|
||||||
|
)
|
||||||
|
result["rf_rx"] = 0
|
||||||
|
return result, errors
|
||||||
|
|
||||||
|
|
||||||
|
def read_capture_status(
|
||||||
|
work_root: Path,
|
||||||
|
session_id: str,
|
||||||
|
) -> tuple[dict[str, object], list[str]]:
|
||||||
|
errors: list[str] = []
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
session_directory = work_root / "rx_capture" / session_id
|
||||||
|
manifest = _read_json(session_directory / MANIFEST_FILE_NAME)
|
||||||
|
if manifest.get("session_id") != session_id or manifest.get("rf_tx") != 0:
|
||||||
|
raise ValueError("capture manifest does not match the requested session")
|
||||||
|
capture_path = Path(str(manifest["capture_path"]))
|
||||||
|
final_path = Path(str(manifest["result_status_path"]))
|
||||||
|
resolved_session = session_directory.resolve()
|
||||||
|
for path in (capture_path, final_path):
|
||||||
|
try:
|
||||||
|
path.resolve().relative_to(resolved_session)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("capture manifest path escapes the session") from error
|
||||||
|
expected_bytes = int(manifest["expected_capture_bytes"])
|
||||||
|
current_bytes = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"manifest_path": str(session_directory / MANIFEST_FILE_NAME),
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"capture_started_at": manifest.get("capture_started_at"),
|
||||||
|
"expected_capture_end": manifest.get("expected_capture_end"),
|
||||||
|
"expected_capture_bytes": expected_bytes,
|
||||||
|
"capture_bytes": current_bytes,
|
||||||
|
"common_clock_verified": manifest.get("common_clock_verified"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expected_end = _iso_utc(str(manifest["expected_capture_end"]))
|
||||||
|
result["capture_window_open"] = datetime.now(timezone.utc) < expected_end
|
||||||
|
if final_path.is_file():
|
||||||
|
final = _read_json(final_path)
|
||||||
|
result["runner_result"] = final
|
||||||
|
complete = (
|
||||||
|
final.get("exit_code") == 0
|
||||||
|
and final.get("runner_error") in {None, ""}
|
||||||
|
and final.get("capture_size") == expected_bytes
|
||||||
|
and current_bytes == expected_bytes
|
||||||
|
)
|
||||||
|
result["state"] = "CAPTURE_COMPLETE" if complete else "CAPTURE_FAILED"
|
||||||
|
if not complete:
|
||||||
|
errors.append("finite RX capture did not meet the complete-file gate")
|
||||||
|
else:
|
||||||
|
result["state"] = "CAPTURE_RUNNING"
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||||
|
errors.append(f"{type(error).__name__}: {error}")
|
||||||
|
result["state"] = "STATUS_FAILED"
|
||||||
|
return result, errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tx_window(
|
||||||
|
capture_started_at: str,
|
||||||
|
tx_started_at: str,
|
||||||
|
tx_finished_at: str,
|
||||||
|
) -> None:
|
||||||
|
capture = _iso_utc(capture_started_at)
|
||||||
|
started = _iso_utc(tx_started_at)
|
||||||
|
finished = _iso_utc(tx_finished_at)
|
||||||
|
if not capture <= started <= finished:
|
||||||
|
raise ValueError("TX timestamps are outside the ordered capture window")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_completed_capture(
|
||||||
|
repository: Path,
|
||||||
|
work_root: Path,
|
||||||
|
session_id: str,
|
||||||
|
tx_started_at: str,
|
||||||
|
tx_finished_at: str,
|
||||||
|
*,
|
||||||
|
command_runner: CommandRunner,
|
||||||
|
) -> tuple[dict[str, object], list[str]]:
|
||||||
|
errors: list[str] = []
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"mode": "OFFLINE_CAPTURE_DECODE",
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
status, status_errors = read_capture_status(work_root, session_id)
|
||||||
|
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
|
||||||
|
raise RuntimeError(f"capture is not complete: {status_errors or status}")
|
||||||
|
capture_started_at = str(status["capture_started_at"])
|
||||||
|
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
|
||||||
|
session_directory = work_root / "rx_capture" / session_id
|
||||||
|
report_path = session_directory / "offline_decode_report.json"
|
||||||
|
if report_path.exists():
|
||||||
|
raise FileExistsError(f"decode report already exists: {report_path}")
|
||||||
|
decoder_script = Path(__file__).resolve().with_name("rf001_offline_decode.py")
|
||||||
|
truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json"
|
||||||
|
decode = command_runner(
|
||||||
|
[
|
||||||
|
os.fspath(Path(sys.executable)),
|
||||||
|
"-B",
|
||||||
|
"-X",
|
||||||
|
"utf8",
|
||||||
|
str(decoder_script),
|
||||||
|
"--repository",
|
||||||
|
str(repository),
|
||||||
|
"--iq-file",
|
||||||
|
str(status["capture_path"]),
|
||||||
|
"--truth-metadata",
|
||||||
|
str(truth_path),
|
||||||
|
"--report",
|
||||||
|
str(report_path),
|
||||||
|
"--capture-started-at",
|
||||||
|
capture_started_at,
|
||||||
|
"--tx-started-at",
|
||||||
|
tx_started_at,
|
||||||
|
"--tx-finished-at",
|
||||||
|
tx_finished_at,
|
||||||
|
],
|
||||||
|
1_800.0,
|
||||||
|
)
|
||||||
|
result["decode"] = _bounded_command(decode)
|
||||||
|
if decode.returncode != 0:
|
||||||
|
raise RuntimeError(f"offline capture decoder exited with {decode.returncode}")
|
||||||
|
report = _read_json(report_path)
|
||||||
|
diagnostics = read_capture_diagnostics(
|
||||||
|
work_root,
|
||||||
|
session_id,
|
||||||
|
tx_started_at,
|
||||||
|
tx_finished_at,
|
||||||
|
)
|
||||||
|
if diagnostics[1]:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"capture diagnostics failed: {diagnostics[1]}"
|
||||||
|
)
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"report_path": str(report_path),
|
||||||
|
"crc_valid_packets": report.get("crc_valid_packets"),
|
||||||
|
"exact_packets": report.get("exact_packets"),
|
||||||
|
"peak_analysis_memory_bytes": report.get("peak_analysis_memory_bytes"),
|
||||||
|
"window_source": report.get("window_source"),
|
||||||
|
"diagnostics": diagnostics[0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, RuntimeError, KeyError, TypeError) as error:
|
||||||
|
errors.append(f"{type(error).__name__}: {error}")
|
||||||
|
return result, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _power_metrics(
|
||||||
|
capture_path: Path,
|
||||||
|
start_sample: int,
|
||||||
|
stop_sample: int,
|
||||||
|
*,
|
||||||
|
chunk_samples: int = 1_000_000,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Measure one fixed cs8 window without loading it all into memory."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
total_samples = capture_path.stat().st_size // 2
|
||||||
|
if not 0 <= start_sample < stop_sample <= total_samples:
|
||||||
|
raise ValueError("diagnostic window is outside the cs8 capture")
|
||||||
|
raw = np.memmap(capture_path, dtype=np.int8, mode="r")
|
||||||
|
power_sum = 0.0
|
||||||
|
i_sum = 0.0
|
||||||
|
q_sum = 0.0
|
||||||
|
rail_codes = 0
|
||||||
|
peak_component = 0
|
||||||
|
for chunk_start in range(start_sample, stop_sample, chunk_samples):
|
||||||
|
chunk_stop = min(stop_sample, chunk_start + chunk_samples)
|
||||||
|
values = np.asarray(
|
||||||
|
raw[chunk_start * 2:chunk_stop * 2],
|
||||||
|
dtype=np.int8,
|
||||||
|
).reshape(-1, 2)
|
||||||
|
wide = values.astype(np.float64)
|
||||||
|
i_values = wide[:, 0]
|
||||||
|
q_values = wide[:, 1]
|
||||||
|
power_sum += float(np.sum(i_values * i_values + q_values * q_values))
|
||||||
|
i_sum += float(np.sum(i_values))
|
||||||
|
q_sum += float(np.sum(q_values))
|
||||||
|
rail_codes += int(np.count_nonzero((values == -128) | (values == 127)))
|
||||||
|
peak_component = max(peak_component, int(np.max(np.abs(wide))))
|
||||||
|
sample_count = stop_sample - start_sample
|
||||||
|
mean_power = power_sum / sample_count
|
||||||
|
return {
|
||||||
|
"start_sample": start_sample,
|
||||||
|
"stop_sample": stop_sample,
|
||||||
|
"sample_count": sample_count,
|
||||||
|
"mean_power_code_squared": mean_power,
|
||||||
|
"rms_complex_normalized": math.sqrt(mean_power) / 128.0,
|
||||||
|
"mean_i_code": i_sum / sample_count,
|
||||||
|
"mean_q_code": q_sum / sample_count,
|
||||||
|
"peak_component_code": peak_component,
|
||||||
|
"rail_codes": rail_codes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_capture_diagnostics(
|
||||||
|
work_root: Path,
|
||||||
|
session_id: str,
|
||||||
|
tx_started_at: str,
|
||||||
|
tx_finished_at: str,
|
||||||
|
) -> tuple[dict[str, object], list[str]]:
|
||||||
|
"""Return the fixed report and signal metrics for one completed capture."""
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"mode": "OFFLINE_CAPTURE_DIAGNOSTICS",
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
status, status_errors = read_capture_status(work_root, session_id)
|
||||||
|
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
|
||||||
|
raise RuntimeError(f"capture is not complete: {status_errors or status}")
|
||||||
|
capture_started_at = str(status["capture_started_at"])
|
||||||
|
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
|
||||||
|
capture_path = Path(str(status["capture_path"]))
|
||||||
|
session_directory = work_root / "rx_capture" / session_id
|
||||||
|
report_path = session_directory / REPORT_FILE_NAME
|
||||||
|
if not report_path.is_file():
|
||||||
|
raise FileNotFoundError(f"decode report is absent: {report_path}")
|
||||||
|
report_size = report_path.stat().st_size
|
||||||
|
if report_size <= 0 or report_size > MAX_REPORT_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"decode report size {report_size} is outside the allowed range"
|
||||||
|
)
|
||||||
|
report_bytes = report_path.read_bytes()
|
||||||
|
report = json.loads(report_bytes.decode("utf-8-sig"))
|
||||||
|
if not isinstance(report, dict) or report.get("rf_tx") != 0:
|
||||||
|
raise ValueError("decode report is invalid or does not preserve rf_tx = 0")
|
||||||
|
runner_result = status.get("runner_result")
|
||||||
|
if not isinstance(runner_result, dict):
|
||||||
|
raise ValueError("capture result is absent")
|
||||||
|
capture_sha256 = str(runner_result.get("sha256", ""))
|
||||||
|
if report.get("iq_sha256") != capture_sha256:
|
||||||
|
raise ValueError("decode report does not match the capture SHA-256")
|
||||||
|
|
||||||
|
sample_rate = int(EXPECTED_PROFILE["sample_rate_hz"])
|
||||||
|
total_samples = capture_path.stat().st_size // 2
|
||||||
|
capture_time = _iso_utc(capture_started_at)
|
||||||
|
tx_start = _iso_utc(tx_started_at)
|
||||||
|
tx_finish = _iso_utc(tx_finished_at)
|
||||||
|
tx_start_sample = round((tx_start - capture_time).total_seconds() * sample_rate)
|
||||||
|
tx_stop_sample = round((tx_finish - capture_time).total_seconds() * sample_rate)
|
||||||
|
tx_start_sample = max(0, min(total_samples, tx_start_sample))
|
||||||
|
tx_stop_sample = max(0, min(total_samples, tx_stop_sample))
|
||||||
|
if tx_stop_sample <= tx_start_sample:
|
||||||
|
raise ValueError("TX window does not overlap the capture")
|
||||||
|
tx_samples = tx_stop_sample - tx_start_sample
|
||||||
|
pre_start = max(0, tx_start_sample - tx_samples)
|
||||||
|
post_stop = min(total_samples, tx_stop_sample + tx_samples)
|
||||||
|
|
||||||
|
signal = _power_metrics(capture_path, tx_start_sample, tx_stop_sample)
|
||||||
|
pre = _power_metrics(capture_path, pre_start, tx_start_sample)
|
||||||
|
post = _power_metrics(capture_path, tx_stop_sample, post_stop)
|
||||||
|
baseline_power = (
|
||||||
|
float(pre["mean_power_code_squared"])
|
||||||
|
+ float(post["mean_power_code_squared"])
|
||||||
|
) / 2.0
|
||||||
|
signal_power = float(signal["mean_power_code_squared"])
|
||||||
|
power_ratio = signal_power / baseline_power if baseline_power > 0 else None
|
||||||
|
power_delta_db = (
|
||||||
|
10.0 * math.log10(power_ratio)
|
||||||
|
if power_ratio is not None and power_ratio > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"report_path": str(report_path),
|
||||||
|
"report_bytes": report_size,
|
||||||
|
"report_sha256": hashlib.sha256(report_bytes).hexdigest(),
|
||||||
|
"capture_sha256": capture_sha256,
|
||||||
|
"report": report,
|
||||||
|
"signal_metrics": {
|
||||||
|
"tx_window": signal,
|
||||||
|
"pre_tx_baseline": pre,
|
||||||
|
"post_tx_baseline": post,
|
||||||
|
"tx_to_baseline_power_ratio": power_ratio,
|
||||||
|
"tx_to_baseline_db": power_delta_db,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
ImportError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
RuntimeError,
|
||||||
|
KeyError,
|
||||||
|
TypeError,
|
||||||
|
) as error:
|
||||||
|
errors.append(f"{type(error).__name__}: {error}")
|
||||||
|
return result, errors
|
||||||
1529
tools/rf001_rx_http_agent.py
Normal file
1529
tools/rf001_rx_http_agent.py
Normal file
File diff suppressed because it is too large
Load Diff
74
tools/rf001_staircase_job.py
Normal file
74
tools/rf001_staircase_job.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Detached software-only worker for one fixed STAIRCASE_SEARCH task."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from tools.rf001_http_control import PROTOCOL_VERSION, STAIRCASE_SEARCH_TASK, utc_now
|
||||||
|
from tools.rf001_rx_http_agent import run_staircase_search
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, value: dict[str, object]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(spec_path: Path, result_path: Path) -> dict[str, object]:
|
||||||
|
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
raise ValueError("job spec must be a JSON object")
|
||||||
|
task = spec.get("task")
|
||||||
|
if not isinstance(task, dict) or task.get("kind") != STAIRCASE_SEARCH_TASK:
|
||||||
|
raise ValueError("job spec does not contain STAIRCASE_SEARCH")
|
||||||
|
started_at = utc_now()
|
||||||
|
task_result, errors = run_staircase_search(
|
||||||
|
Path(str(spec["work_root"])),
|
||||||
|
str(task["session_id"]),
|
||||||
|
str(task["expected_rx_sha256"]),
|
||||||
|
str(task["expected_reference_sha256"]),
|
||||||
|
progress_path=Path(str(spec["progress_path"])),
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"protocol_version": PROTOCOL_VERSION,
|
||||||
|
"task_id": str(task["task_id"]),
|
||||||
|
"agent_id": str(spec["agent_id"]),
|
||||||
|
"status": "COMPLETE" if not errors else "FAILED",
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": utc_now(),
|
||||||
|
"result": task_result,
|
||||||
|
"errors": errors,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
_atomic_write(result_path, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--job-spec", required=True, type=Path)
|
||||||
|
parser.add_argument("--result", required=True, type=Path)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
result = run_job(arguments.job_spec, arguments.result)
|
||||||
|
print(f"TASK ID = {result['task_id']}")
|
||||||
|
print(f"TASK STATUS = {result['status']}")
|
||||||
|
print("HARDWARE ACCESSED = false")
|
||||||
|
print("RF TX = 0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
937
tools/rf001_staircase_search.py
Normal file
937
tools/rf001_staircase_search.py
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
"""Потоковый поиск эталонной лестницы Lab064/Lab065 в большом cs8-файле.
|
||||||
|
|
||||||
|
Утилита не обращается к SDR, не использует пакетный приёмник и не меняет
|
||||||
|
исходные IQ-файлы. Полная запись просматривается блоками с перекрытием.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy import signal
|
||||||
|
|
||||||
|
EXPECTED_REFERENCE_SHA256 = (
|
||||||
|
"fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||||
|
)
|
||||||
|
ANALYZER_VERSION = "2026.09.28.1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReferenceLayout:
|
||||||
|
sample_rate_hz: int = 8_000_000
|
||||||
|
symbol_rate: int = 20_000
|
||||||
|
carrier_hz: float = 50_000.0
|
||||||
|
leading_samples: int = 800_000
|
||||||
|
segment_samples: int = 800_000
|
||||||
|
gap_samples: int = 400_000
|
||||||
|
trailing_samples: int = 800_000
|
||||||
|
levels_db: tuple[float, ...] = (-18.0, -12.0, -6.0, 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_samples(self) -> int:
|
||||||
|
return (
|
||||||
|
self.leading_samples
|
||||||
|
+ len(self.levels_db) * self.segment_samples
|
||||||
|
+ (len(self.levels_db) - 1) * self.gap_samples
|
||||||
|
+ self.trailing_samples
|
||||||
|
)
|
||||||
|
|
||||||
|
def active_windows(self) -> tuple[tuple[int, int], ...]:
|
||||||
|
cursor = self.leading_samples
|
||||||
|
windows = []
|
||||||
|
for index in range(len(self.levels_db)):
|
||||||
|
windows.append((cursor, cursor + self.segment_samples))
|
||||||
|
cursor += self.segment_samples
|
||||||
|
if index + 1 < len(self.levels_db):
|
||||||
|
cursor += self.gap_samples
|
||||||
|
return tuple(windows)
|
||||||
|
|
||||||
|
def background_windows(self) -> tuple[tuple[int, int], ...]:
|
||||||
|
width = min(self.gap_samples, self.leading_samples, self.trailing_samples) // 2
|
||||||
|
cursor = (self.leading_samples - width) // 2
|
||||||
|
windows = [(cursor, cursor + width)]
|
||||||
|
cursor = self.leading_samples + self.segment_samples
|
||||||
|
for index in range(len(self.levels_db) - 1):
|
||||||
|
start = cursor + (self.gap_samples - width) // 2
|
||||||
|
windows.append((start, start + width))
|
||||||
|
cursor += self.gap_samples + self.segment_samples
|
||||||
|
final_start = self.total_samples - self.trailing_samples
|
||||||
|
start = final_start + (self.trailing_samples - width) // 2
|
||||||
|
windows.append((start, start + width))
|
||||||
|
return tuple(windows)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchCriteria:
|
||||||
|
decimation: int = 32
|
||||||
|
input_block_samples: int = 4_000_000
|
||||||
|
anti_alias_passband_hz: float = 75_000.0
|
||||||
|
anti_alias_stopband_hz: float = 120_000.0
|
||||||
|
anti_alias_attenuation_db: float = 80.0
|
||||||
|
feature_period_seconds: float = 0.001
|
||||||
|
refinement_radius_seconds: float = 0.003
|
||||||
|
cfo_min_hz: float = -3.0
|
||||||
|
cfo_max_hz: float = 3.0
|
||||||
|
cfo_step_hz: float = 0.25
|
||||||
|
normalized_correlation_minimum: float = 0.05
|
||||||
|
peak_to_background_minimum_db: float = 6.0
|
||||||
|
maximum_step_error_db: float = 3.0
|
||||||
|
l3_matched_to_residual_minimum_db: float = -18.0
|
||||||
|
l3_spectral_contrast_minimum_db: float = 3.0
|
||||||
|
histogram_stride: int = 1024
|
||||||
|
maximum_reported_candidates: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LAYOUT = ReferenceLayout()
|
||||||
|
DEFAULT_CRITERIA = SearchCriteria()
|
||||||
|
|
||||||
|
|
||||||
|
def current_process_rss_bytes() -> int:
|
||||||
|
if not hasattr(ctypes, "windll"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class ProcessMemoryCounters(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cb", ctypes.c_ulong),
|
||||||
|
("page_fault_count", ctypes.c_ulong),
|
||||||
|
("peak_working_set_size", ctypes.c_size_t),
|
||||||
|
("working_set_size", ctypes.c_size_t),
|
||||||
|
("quota_peak_paged_pool_usage", ctypes.c_size_t),
|
||||||
|
("quota_paged_pool_usage", ctypes.c_size_t),
|
||||||
|
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
|
||||||
|
("quota_non_paged_pool_usage", ctypes.c_size_t),
|
||||||
|
("pagefile_usage", ctypes.c_size_t),
|
||||||
|
("peak_pagefile_usage", ctypes.c_size_t),
|
||||||
|
]
|
||||||
|
|
||||||
|
counters = ProcessMemoryCounters()
|
||||||
|
counters.cb = ctypes.sizeof(counters)
|
||||||
|
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
|
||||||
|
get_current_process.restype = ctypes.c_void_p
|
||||||
|
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
|
||||||
|
get_process_memory_info.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.POINTER(ProcessMemoryCounters),
|
||||||
|
ctypes.c_ulong,
|
||||||
|
]
|
||||||
|
get_process_memory_info.restype = ctypes.c_int
|
||||||
|
succeeded = get_process_memory_info(
|
||||||
|
get_current_process(),
|
||||||
|
ctypes.byref(counters),
|
||||||
|
counters.cb,
|
||||||
|
)
|
||||||
|
return int(counters.working_set_size) if succeeded else 0
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryPeakSampler:
|
||||||
|
def __init__(self, poll_seconds: float = 0.02) -> None:
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
self.baseline_bytes = current_process_rss_bytes()
|
||||||
|
self.peak_bytes = self.baseline_bytes
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._sample, daemon=True)
|
||||||
|
|
||||||
|
def _sample(self) -> None:
|
||||||
|
while not self._stop.wait(self.poll_seconds):
|
||||||
|
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||||
|
|
||||||
|
def __enter__(self) -> "MemoryPeakSampler":
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: object) -> None:
|
||||||
|
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_peak_bytes(self) -> int:
|
||||||
|
return max(0, self.peak_bytes - self.baseline_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
phase: str,
|
||||||
|
processed_samples: int,
|
||||||
|
total_samples: int,
|
||||||
|
) -> None:
|
||||||
|
value = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": phase,
|
||||||
|
"processed_samples": processed_samples,
|
||||||
|
"total_samples": total_samples,
|
||||||
|
"coverage_complete": (
|
||||||
|
phase == "COMPLETE" and processed_samples == total_samples
|
||||||
|
),
|
||||||
|
"updated_at_epoch": time.time(),
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(
|
||||||
|
path: Path,
|
||||||
|
progress: Callable[[int, int], None] | None = None,
|
||||||
|
) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
total = path.stat().st_size
|
||||||
|
processed = 0
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while block := stream.read(4 * 1024 * 1024):
|
||||||
|
digest.update(block)
|
||||||
|
processed += len(block)
|
||||||
|
if progress is not None:
|
||||||
|
progress(processed, total)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def cs8_sample_count(path: Path) -> int:
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size <= 0 or size % 2:
|
||||||
|
raise ValueError("cs8-файл должен содержать целое ненулевое число пар I/Q")
|
||||||
|
return size // 2
|
||||||
|
|
||||||
|
|
||||||
|
def design_antialias_filter(
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Build the fixed linear-phase FIR used before every decimation."""
|
||||||
|
|
||||||
|
if criteria.decimation <= 0:
|
||||||
|
raise ValueError("decimation must be positive")
|
||||||
|
if criteria.decimation == 1:
|
||||||
|
return np.ones(1, dtype=np.float64)
|
||||||
|
nyquist = layout.sample_rate_hz / 2
|
||||||
|
decimated_nyquist = layout.sample_rate_hz / (2 * criteria.decimation)
|
||||||
|
if not (
|
||||||
|
0 < criteria.anti_alias_passband_hz
|
||||||
|
< criteria.anti_alias_stopband_hz
|
||||||
|
< decimated_nyquist
|
||||||
|
):
|
||||||
|
raise ValueError("anti-alias passband and stopband are invalid")
|
||||||
|
transition_width = (
|
||||||
|
criteria.anti_alias_stopband_hz - criteria.anti_alias_passband_hz
|
||||||
|
) / nyquist
|
||||||
|
taps_count, beta = signal.kaiserord(
|
||||||
|
criteria.anti_alias_attenuation_db,
|
||||||
|
transition_width,
|
||||||
|
)
|
||||||
|
taps_count = max(3, taps_count | 1)
|
||||||
|
cutoff_hz = (
|
||||||
|
criteria.anti_alias_passband_hz + criteria.anti_alias_stopband_hz
|
||||||
|
) / 2
|
||||||
|
return signal.firwin(
|
||||||
|
taps_count,
|
||||||
|
cutoff_hz,
|
||||||
|
window=("kaiser", beta),
|
||||||
|
fs=layout.sample_rate_hz,
|
||||||
|
).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cs8_complex(path: Path, start: int, stop: int) -> np.ndarray:
|
||||||
|
"""Read one bounded source interval; out-of-file samples are zero."""
|
||||||
|
|
||||||
|
if start >= stop:
|
||||||
|
raise ValueError("invalid source interval")
|
||||||
|
total = cs8_sample_count(path)
|
||||||
|
values = np.zeros(stop - start, dtype=np.complex64)
|
||||||
|
clipped_start = max(0, start)
|
||||||
|
clipped_stop = min(total, stop)
|
||||||
|
if clipped_start >= clipped_stop:
|
||||||
|
return values
|
||||||
|
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||||
|
interleaved = np.asarray(
|
||||||
|
raw[2 * clipped_start:2 * clipped_stop],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
destination = slice(clipped_start - start, clipped_stop - start)
|
||||||
|
values[destination] = interleaved[0::2] + 1j * interleaved[1::2]
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def read_filtered_decimated_window(
|
||||||
|
path: Path,
|
||||||
|
start: int,
|
||||||
|
stop: int,
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
taps: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Read a decimated window with FIR overlap and delay correction.
|
||||||
|
|
||||||
|
Output index ``k`` is centred on input sample ``k * decimation``. The
|
||||||
|
function reads half the FIR on both sides, so adjacent blocks have exactly
|
||||||
|
the overlap required to match one full-file convolution. Zeros are used
|
||||||
|
only outside the physical file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total = cs8_sample_count(path)
|
||||||
|
total_decimated = math.ceil(total / criteria.decimation)
|
||||||
|
if not 0 <= start < stop <= total_decimated:
|
||||||
|
raise ValueError("invalid filtered-decimated window")
|
||||||
|
delay = (taps.size - 1) // 2
|
||||||
|
first_center = start * criteria.decimation
|
||||||
|
last_center = (stop - 1) * criteria.decimation
|
||||||
|
source_start = first_center - delay
|
||||||
|
source_stop = last_center + delay + 1
|
||||||
|
values = _read_cs8_complex(path, source_start, source_stop)
|
||||||
|
if taps.size == 1:
|
||||||
|
return values[::criteria.decimation].astype(np.complex64, copy=False)
|
||||||
|
filtered = signal.fftconvolve(values, taps, mode="valid")
|
||||||
|
result = filtered[::criteria.decimation]
|
||||||
|
if result.size != stop - start:
|
||||||
|
raise RuntimeError("filtered decimator produced an unexpected length")
|
||||||
|
return result.astype(np.complex64, copy=False)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_filtered_decimated(
|
||||||
|
path: Path,
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
taps: np.ndarray,
|
||||||
|
) -> Iterable[tuple[int, np.ndarray]]:
|
||||||
|
"""Yield the full file in bounded, boundary-correct decimated blocks."""
|
||||||
|
|
||||||
|
if criteria.input_block_samples <= 0:
|
||||||
|
raise ValueError("input block size must be positive")
|
||||||
|
total = cs8_sample_count(path)
|
||||||
|
total_decimated = math.ceil(total / criteria.decimation)
|
||||||
|
block = max(1, criteria.input_block_samples // criteria.decimation)
|
||||||
|
for start in range(0, total_decimated, block):
|
||||||
|
stop = min(total_decimated, start + block)
|
||||||
|
yield start, read_filtered_decimated_window(
|
||||||
|
path,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
taps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_reference(
|
||||||
|
path: Path,
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
*,
|
||||||
|
expected_sha256: str | None,
|
||||||
|
) -> tuple[np.ndarray, str]:
|
||||||
|
if cs8_sample_count(path) != layout.total_samples:
|
||||||
|
raise ValueError("размер эталона не соответствует фиксированной структуре")
|
||||||
|
digest = sha256_file(path)
|
||||||
|
if expected_sha256 is not None and digest != expected_sha256:
|
||||||
|
raise ValueError("SHA256 эталона не совпадает с разрешённым значением")
|
||||||
|
stop = math.ceil(layout.total_samples / criteria.decimation)
|
||||||
|
taps = design_antialias_filter(layout, criteria)
|
||||||
|
reference = read_filtered_decimated_window(
|
||||||
|
path,
|
||||||
|
0,
|
||||||
|
stop,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
taps,
|
||||||
|
)
|
||||||
|
reference -= np.mean(reference, dtype=np.complex128)
|
||||||
|
return reference, digest
|
||||||
|
|
||||||
|
|
||||||
|
def _frequency_grid(criteria: SearchCriteria) -> np.ndarray:
|
||||||
|
count = round(
|
||||||
|
(criteria.cfo_max_hz - criteria.cfo_min_hz) / criteria.cfo_step_hz
|
||||||
|
)
|
||||||
|
return criteria.cfo_min_hz + criteria.cfo_step_hz * np.arange(count + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _histogram_quantile(histogram: np.ndarray, quantile: float) -> float:
|
||||||
|
total = int(histogram.sum())
|
||||||
|
if total == 0:
|
||||||
|
return 0.0
|
||||||
|
target = math.ceil(total * quantile)
|
||||||
|
index = int(np.searchsorted(np.cumsum(histogram), target, side="left"))
|
||||||
|
return min(1.0, (index + 0.5) / histogram.size)
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_candidates(
|
||||||
|
candidates: Iterable[dict[str, float | int]],
|
||||||
|
minimum_separation: int,
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, float | int]]:
|
||||||
|
selected: list[dict[str, float | int]] = []
|
||||||
|
for candidate in sorted(candidates, key=lambda row: float(row["score"]), reverse=True):
|
||||||
|
start = int(candidate["start_decimated_sample"])
|
||||||
|
if any(
|
||||||
|
abs(start - int(existing["start_decimated_sample"])) < minimum_separation
|
||||||
|
for existing in selected
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
selected.append(candidate)
|
||||||
|
if len(selected) == limit:
|
||||||
|
break
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _power_features(values: np.ndarray, stride: int) -> np.ndarray:
|
||||||
|
complete = values.size // stride
|
||||||
|
if complete == 0:
|
||||||
|
return np.empty(0, dtype=np.float64)
|
||||||
|
reshaped = values[:complete * stride].reshape(complete, stride)
|
||||||
|
return np.mean(np.abs(reshaped) ** 2, axis=1, dtype=np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_sliding_scores(
|
||||||
|
values: np.ndarray,
|
||||||
|
template: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
centred = template.astype(np.float64) - float(np.mean(template))
|
||||||
|
template_energy = float(np.vdot(centred, centred).real)
|
||||||
|
count = centred.size
|
||||||
|
prefix = np.concatenate(([0.0], np.cumsum(values, dtype=np.float64)))
|
||||||
|
prefix_power = np.concatenate(
|
||||||
|
([0.0], np.cumsum(values * values, dtype=np.float64))
|
||||||
|
)
|
||||||
|
window_sum = prefix[count:] - prefix[:-count]
|
||||||
|
window_power = prefix_power[count:] - prefix_power[:-count]
|
||||||
|
ac_power = window_power - (window_sum * window_sum) / count
|
||||||
|
denominator = np.sqrt(np.maximum(template_energy * ac_power, 1e-30))
|
||||||
|
correlation = signal.fftconvolve(values, centred[::-1], mode="valid")
|
||||||
|
return np.abs(correlation) / denominator
|
||||||
|
|
||||||
|
|
||||||
|
def _refine_candidate(
|
||||||
|
capture_path: Path,
|
||||||
|
reference: np.ndarray,
|
||||||
|
approximate_start: int,
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
taps: np.ndarray,
|
||||||
|
) -> dict[str, float | int]:
|
||||||
|
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||||
|
radius = max(1, round(criteria.refinement_radius_seconds * decimated_rate))
|
||||||
|
total_decimated = math.ceil(
|
||||||
|
cs8_sample_count(capture_path) / criteria.decimation
|
||||||
|
)
|
||||||
|
first = max(0, approximate_start - radius)
|
||||||
|
last = min(total_decimated - reference.size, approximate_start + radius)
|
||||||
|
received = read_filtered_decimated_window(
|
||||||
|
capture_path,
|
||||||
|
first,
|
||||||
|
last + reference.size,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
taps,
|
||||||
|
)
|
||||||
|
count = reference.size
|
||||||
|
prefix_power = np.concatenate(
|
||||||
|
([0.0], np.cumsum(np.abs(received) ** 2, dtype=np.float64))
|
||||||
|
)
|
||||||
|
prefix_sum = np.concatenate(
|
||||||
|
([0.0 + 0.0j], np.cumsum(received, dtype=np.complex128))
|
||||||
|
)
|
||||||
|
window_power = prefix_power[count:] - prefix_power[:-count]
|
||||||
|
window_sum = prefix_sum[count:] - prefix_sum[:-count]
|
||||||
|
window_ac_power = window_power - (np.abs(window_sum) ** 2) / count
|
||||||
|
reference_energy = float(np.vdot(reference, reference).real)
|
||||||
|
denominator = np.sqrt(np.maximum(reference_energy * window_ac_power, 1e-30))
|
||||||
|
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
|
||||||
|
best_score = -1.0
|
||||||
|
best_index = 0
|
||||||
|
best_cfo = 0.0
|
||||||
|
for cfo_hz in _frequency_grid(criteria):
|
||||||
|
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
|
||||||
|
correlation = signal.fftconvolve(
|
||||||
|
received,
|
||||||
|
np.conj(rotated[::-1]),
|
||||||
|
mode="valid",
|
||||||
|
)
|
||||||
|
scores = np.abs(correlation) / denominator
|
||||||
|
index = int(np.argmax(scores))
|
||||||
|
score = float(scores[index])
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_index = index
|
||||||
|
best_cfo = float(cfo_hz)
|
||||||
|
start_decimated = first + best_index
|
||||||
|
return {
|
||||||
|
"score": best_score,
|
||||||
|
"start_decimated_sample": start_decimated,
|
||||||
|
"start_sample": start_decimated * criteria.decimation,
|
||||||
|
"start_seconds": (
|
||||||
|
start_decimated * criteria.decimation / layout.sample_rate_hz
|
||||||
|
),
|
||||||
|
"cfo_hz": best_cfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def locate_reference_streaming(
|
||||||
|
capture_path: Path,
|
||||||
|
reference: np.ndarray,
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
progress: Callable[[int, int], None] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Scan every source sample through a bounded anti-aliased feature stream."""
|
||||||
|
|
||||||
|
total_samples = cs8_sample_count(capture_path)
|
||||||
|
total_decimated = math.ceil(total_samples / criteria.decimation)
|
||||||
|
if total_decimated < reference.size:
|
||||||
|
raise ValueError("RX короче эталонной лестницы")
|
||||||
|
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||||
|
feature_stride = round(criteria.feature_period_seconds * decimated_rate)
|
||||||
|
if feature_stride <= 0 or criteria.histogram_stride <= 0:
|
||||||
|
raise ValueError("feature period and histogram stride must be positive")
|
||||||
|
taps = design_antialias_filter(layout, criteria)
|
||||||
|
feature_parts: list[np.ndarray] = []
|
||||||
|
pending = np.empty(0, dtype=np.complex64)
|
||||||
|
maximum_loaded = 0
|
||||||
|
streamed_decimated = 0
|
||||||
|
for block_start, block in iter_filtered_decimated(
|
||||||
|
capture_path,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
taps,
|
||||||
|
):
|
||||||
|
if block_start != streamed_decimated:
|
||||||
|
raise RuntimeError("decimated stream is not contiguous")
|
||||||
|
streamed_decimated += block.size
|
||||||
|
if progress is not None:
|
||||||
|
progress(
|
||||||
|
min(total_samples, streamed_decimated * criteria.decimation),
|
||||||
|
total_samples,
|
||||||
|
)
|
||||||
|
combined = np.concatenate((pending, block)) if pending.size else block
|
||||||
|
complete = combined.size // feature_stride
|
||||||
|
used = complete * feature_stride
|
||||||
|
if used:
|
||||||
|
feature_parts.append(_power_features(combined[:used], feature_stride))
|
||||||
|
pending = combined[used:].copy()
|
||||||
|
maximum_loaded = max(maximum_loaded, int(combined.size))
|
||||||
|
if pending.size:
|
||||||
|
feature_parts.append(
|
||||||
|
np.array([np.mean(np.abs(pending) ** 2, dtype=np.float64)])
|
||||||
|
)
|
||||||
|
features = np.concatenate(feature_parts)
|
||||||
|
reference_features = _power_features(reference, feature_stride)
|
||||||
|
if features.size < reference_features.size:
|
||||||
|
raise ValueError("RX feature stream is shorter than the reference")
|
||||||
|
scores = _normalized_sliding_scores(features, reference_features)
|
||||||
|
sampled = np.clip(scores[::criteria.histogram_stride], 0.0, 1.0)
|
||||||
|
histogram = np.histogram(sampled, bins=2000, range=(0.0, 1.0))[0]
|
||||||
|
background_p99 = _histogram_quantile(histogram, 0.99)
|
||||||
|
candidate_count = min(max(20, criteria.maximum_reported_candidates * 8), scores.size)
|
||||||
|
indexes = np.argpartition(scores, -candidate_count)[-candidate_count:]
|
||||||
|
coarse = [
|
||||||
|
{
|
||||||
|
"score": float(scores[index]),
|
||||||
|
"start_decimated_sample": int(index * feature_stride),
|
||||||
|
"start_feature": int(index),
|
||||||
|
}
|
||||||
|
for index in indexes
|
||||||
|
]
|
||||||
|
coarse = _deduplicate_candidates(
|
||||||
|
coarse,
|
||||||
|
max(1, reference.size // 3),
|
||||||
|
criteria.maximum_reported_candidates,
|
||||||
|
)
|
||||||
|
refined = []
|
||||||
|
for row in coarse:
|
||||||
|
candidate = _refine_candidate(
|
||||||
|
capture_path,
|
||||||
|
reference,
|
||||||
|
int(row["start_decimated_sample"]),
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
taps,
|
||||||
|
)
|
||||||
|
temporal_score = float(row["score"])
|
||||||
|
candidate["temporal_power_score"] = temporal_score
|
||||||
|
candidate["score_to_background_p99_db"] = (
|
||||||
|
20.0 * math.log10(temporal_score / background_p99)
|
||||||
|
if temporal_score > 0 and background_p99 > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
refined.append(candidate)
|
||||||
|
independent = _deduplicate_candidates(
|
||||||
|
refined,
|
||||||
|
max(1, reference.size // 3),
|
||||||
|
criteria.maximum_reported_candidates,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"coverage_start_sample": 0,
|
||||||
|
"coverage_stop_sample": total_samples,
|
||||||
|
"coverage_seconds": total_samples / layout.sample_rate_hz,
|
||||||
|
"coverage_complete": streamed_decimated == total_decimated,
|
||||||
|
"source_samples_processed": total_samples,
|
||||||
|
"tested_feature_start_positions": int(scores.size),
|
||||||
|
"feature_stride_decimated_samples": feature_stride,
|
||||||
|
"feature_stride_source_samples": feature_stride * criteria.decimation,
|
||||||
|
"frequency_grid_hz": [
|
||||||
|
float(value) for value in _frequency_grid(criteria)
|
||||||
|
],
|
||||||
|
"background_score_p99": background_p99,
|
||||||
|
"candidates": independent,
|
||||||
|
"maximum_loaded_decimated_samples": maximum_loaded,
|
||||||
|
"maximum_loaded_iq_bytes": maximum_loaded * np.dtype(np.complex64).itemsize,
|
||||||
|
"anti_alias_filter": {
|
||||||
|
"kind": "linear_phase_fir_kaiser",
|
||||||
|
"tap_count": int(taps.size),
|
||||||
|
"passband_hz": criteria.anti_alias_passband_hz,
|
||||||
|
"stopband_hz": criteria.anti_alias_stopband_hz,
|
||||||
|
"design_attenuation_db": criteria.anti_alias_attenuation_db,
|
||||||
|
"group_delay_source_samples": (int(taps.size) - 1) // 2,
|
||||||
|
"position_delay_correction_source_samples": (
|
||||||
|
-((int(taps.size) - 1) // 2)
|
||||||
|
),
|
||||||
|
"boundary_handling": "symmetric_overlap_with_zero_extension_at_file_edges",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_candidate(
|
||||||
|
capture_path: Path,
|
||||||
|
start_decimated: int,
|
||||||
|
count: int,
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
) -> np.ndarray:
|
||||||
|
taps = design_antialias_filter(layout, criteria)
|
||||||
|
return read_filtered_decimated_window(
|
||||||
|
capture_path,
|
||||||
|
start_decimated,
|
||||||
|
start_decimated + count,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
taps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decimated_window(window: tuple[int, int], decimation: int) -> tuple[int, int]:
|
||||||
|
return round(window[0] / decimation), round(window[1] / decimation)
|
||||||
|
|
||||||
|
|
||||||
|
def _band_power(values: np.ndarray, sample_rate_hz: float, low: float, high: float) -> float:
|
||||||
|
nperseg = min(4096, values.size)
|
||||||
|
frequencies, density = signal.welch(
|
||||||
|
values,
|
||||||
|
fs=sample_rate_hz,
|
||||||
|
nperseg=nperseg,
|
||||||
|
noverlap=nperseg // 2,
|
||||||
|
return_onesided=False,
|
||||||
|
scaling="density",
|
||||||
|
)
|
||||||
|
mask = (frequencies >= low) & (frequencies <= high)
|
||||||
|
if not np.any(mask):
|
||||||
|
return 0.0
|
||||||
|
return float(np.trapezoid(density[mask], frequencies[mask]))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_candidate(
|
||||||
|
capture_path: Path,
|
||||||
|
reference: np.ndarray,
|
||||||
|
candidate: dict[str, float | int],
|
||||||
|
layout: ReferenceLayout,
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
start_decimated = int(candidate["start_decimated_sample"])
|
||||||
|
received = _read_candidate(
|
||||||
|
capture_path,
|
||||||
|
start_decimated,
|
||||||
|
reference.size,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
)
|
||||||
|
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||||
|
cfo_hz = float(candidate["cfo_hz"])
|
||||||
|
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
|
||||||
|
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
|
||||||
|
segment_decimated = round(layout.segment_samples / criteria.decimation)
|
||||||
|
trim = min(
|
||||||
|
max(1, round(21 * layout.sample_rate_hz / layout.symbol_rate / criteria.decimation)),
|
||||||
|
max(1, segment_decimated // 10),
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for index, window in enumerate(layout.active_windows()):
|
||||||
|
start, stop = _decimated_window(window, criteria.decimation)
|
||||||
|
start += trim
|
||||||
|
stop -= trim
|
||||||
|
expected = rotated[start:stop]
|
||||||
|
observed = received[start:stop]
|
||||||
|
expected = expected - np.mean(expected, dtype=np.complex128)
|
||||||
|
observed = observed - np.mean(observed, dtype=np.complex128)
|
||||||
|
expected_energy = float(np.vdot(expected, expected).real)
|
||||||
|
observed_energy = float(np.vdot(observed, observed).real)
|
||||||
|
gain = np.vdot(expected, observed) / max(expected_energy, 1e-30)
|
||||||
|
matched = gain * expected
|
||||||
|
residual = observed - matched
|
||||||
|
matched_power = float(np.mean(np.abs(matched) ** 2))
|
||||||
|
residual_power = float(np.mean(np.abs(residual) ** 2))
|
||||||
|
coherence = float(
|
||||||
|
abs(np.vdot(expected, observed))
|
||||||
|
/ math.sqrt(max(expected_energy * observed_energy, 1e-30))
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"label": f"L{index}",
|
||||||
|
"expected_level_db": layout.levels_db[index],
|
||||||
|
"matched_power": matched_power,
|
||||||
|
"residual_power": residual_power,
|
||||||
|
"matched_to_residual_db": (
|
||||||
|
10.0 * math.log10(matched_power / residual_power)
|
||||||
|
if matched_power > 0 and residual_power > 0
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"normalized_coherence": coherence,
|
||||||
|
"gain_abs": float(abs(gain)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
step_rows = []
|
||||||
|
for lower, upper in zip(rows, rows[1:]):
|
||||||
|
expected_step = float(upper["expected_level_db"]) - float(lower["expected_level_db"])
|
||||||
|
lower_power = float(lower["matched_power"])
|
||||||
|
upper_power = float(upper["matched_power"])
|
||||||
|
observed_step = (
|
||||||
|
10.0 * math.log10(upper_power / lower_power)
|
||||||
|
if lower_power > 0 and upper_power > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
step_rows.append(
|
||||||
|
{
|
||||||
|
"comparison": f"{upper['label']}/{lower['label']}",
|
||||||
|
"expected_step_db": expected_step,
|
||||||
|
"observed_step_db": observed_step,
|
||||||
|
"error_db": observed_step - expected_step if observed_step is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finite_errors = [
|
||||||
|
abs(float(row["error_db"]))
|
||||||
|
for row in step_rows
|
||||||
|
if row["error_db"] is not None
|
||||||
|
]
|
||||||
|
maximum_step_error = max(finite_errors) if len(finite_errors) == len(step_rows) else None
|
||||||
|
|
||||||
|
l3_start, l3_stop = _decimated_window(layout.active_windows()[-1], criteria.decimation)
|
||||||
|
background_start, background_stop = _decimated_window(
|
||||||
|
layout.background_windows()[-2], criteria.decimation
|
||||||
|
)
|
||||||
|
band_half_width = 0.675 * layout.symbol_rate
|
||||||
|
low = layout.carrier_hz - band_half_width
|
||||||
|
high = layout.carrier_hz + band_half_width
|
||||||
|
l3_band_power = _band_power(received[l3_start:l3_stop], decimated_rate, low, high)
|
||||||
|
background_band_power = _band_power(
|
||||||
|
received[background_start:background_stop], decimated_rate, low, high
|
||||||
|
)
|
||||||
|
spectral_contrast = (
|
||||||
|
10.0 * math.log10(l3_band_power / background_band_power)
|
||||||
|
if l3_band_power > 0 and background_band_power > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
l3_ratio = rows[-1]["matched_to_residual_db"]
|
||||||
|
matched_powers = [float(row["matched_power"]) for row in rows]
|
||||||
|
monotonic = all(upper > lower for lower, upper in zip(matched_powers, matched_powers[1:]))
|
||||||
|
step_pass = (
|
||||||
|
maximum_step_error is not None
|
||||||
|
and maximum_step_error <= criteria.maximum_step_error_db
|
||||||
|
and monotonic
|
||||||
|
)
|
||||||
|
coherence_pass = (
|
||||||
|
l3_ratio is not None
|
||||||
|
and float(l3_ratio) >= criteria.l3_matched_to_residual_minimum_db
|
||||||
|
)
|
||||||
|
spectral_pass = (
|
||||||
|
spectral_contrast is not None
|
||||||
|
and spectral_contrast >= criteria.l3_spectral_contrast_minimum_db
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"segments": rows,
|
||||||
|
"steps": step_rows,
|
||||||
|
"maximum_absolute_step_error_db": maximum_step_error,
|
||||||
|
"monotonic_matched_power": monotonic,
|
||||||
|
"step_pattern_pass": step_pass,
|
||||||
|
"l3_matched_to_residual_pass": coherence_pass,
|
||||||
|
"l3_band_power": l3_band_power,
|
||||||
|
"background_band_power": background_band_power,
|
||||||
|
"l3_spectral_contrast_db": spectral_contrast,
|
||||||
|
"spectral_contrast_pass": spectral_pass,
|
||||||
|
"qualified": step_pass and coherence_pass and spectral_pass,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_search(
|
||||||
|
search: dict[str, object],
|
||||||
|
verifications: list[dict[str, object]],
|
||||||
|
criteria: SearchCriteria,
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
if search.get("coverage_complete") is not True:
|
||||||
|
return "ANALYSIS_INCOMPLETE", ["FULL_CAPTURE_COVERAGE_NOT_CONFIRMED"]
|
||||||
|
candidates = list(search["candidates"])
|
||||||
|
if not candidates:
|
||||||
|
return "NOT_DETECTED", ["NO_CANDIDATES"]
|
||||||
|
peak = candidates[0]
|
||||||
|
score = float(peak["score"])
|
||||||
|
contrast = peak.get("score_to_background_p99_db")
|
||||||
|
correlation_pass = score >= criteria.normalized_correlation_minimum
|
||||||
|
contrast_pass = (
|
||||||
|
contrast is not None
|
||||||
|
and float(contrast) >= criteria.peak_to_background_minimum_db
|
||||||
|
)
|
||||||
|
qualified = [row for row in verifications if row.get("qualified") is True]
|
||||||
|
reasons = []
|
||||||
|
if not correlation_pass:
|
||||||
|
reasons.append("NORMALIZED_CORRELATION_BELOW_THRESHOLD")
|
||||||
|
if not contrast_pass:
|
||||||
|
reasons.append("PEAK_TO_BACKGROUND_CONTRAST_BELOW_THRESHOLD")
|
||||||
|
if not qualified:
|
||||||
|
reasons.append("STEP_OR_SPECTRAL_CONFIRMATION_FAILED")
|
||||||
|
if correlation_pass and contrast_pass and len(qualified) == 1:
|
||||||
|
return "DETECTED", []
|
||||||
|
if (correlation_pass and contrast_pass) or qualified:
|
||||||
|
if len(qualified) > 1:
|
||||||
|
reasons.append("MULTIPLE_QUALIFIED_CANDIDATES")
|
||||||
|
return "AMBIGUOUS", reasons
|
||||||
|
return "NOT_DETECTED", reasons
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(
|
||||||
|
reference_path: Path,
|
||||||
|
capture_path: Path,
|
||||||
|
report_path: Path,
|
||||||
|
*,
|
||||||
|
expected_capture_sha256: str,
|
||||||
|
layout: ReferenceLayout = DEFAULT_LAYOUT,
|
||||||
|
criteria: SearchCriteria = DEFAULT_CRITERIA,
|
||||||
|
expected_reference_sha256: str = EXPECTED_REFERENCE_SHA256,
|
||||||
|
progress_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if report_path.exists():
|
||||||
|
raise FileExistsError(f"отчёт уже существует: {report_path}")
|
||||||
|
total_samples = cs8_sample_count(capture_path)
|
||||||
|
if progress_path is not None:
|
||||||
|
_write_progress(
|
||||||
|
progress_path,
|
||||||
|
phase="VERIFYING_REFERENCE",
|
||||||
|
processed_samples=0,
|
||||||
|
total_samples=total_samples,
|
||||||
|
)
|
||||||
|
reference, reference_sha256 = load_reference(
|
||||||
|
reference_path,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
expected_sha256=expected_reference_sha256,
|
||||||
|
)
|
||||||
|
capture_progress: Callable[[int, int], None] | None = None
|
||||||
|
if progress_path is not None:
|
||||||
|
capture_progress = lambda processed, total: _write_progress(
|
||||||
|
progress_path,
|
||||||
|
phase="VERIFYING_RX_SHA256",
|
||||||
|
processed_samples=processed // 2,
|
||||||
|
total_samples=total // 2,
|
||||||
|
)
|
||||||
|
capture_sha256 = sha256_file(capture_path, progress=capture_progress)
|
||||||
|
if capture_sha256 != expected_capture_sha256:
|
||||||
|
raise ValueError("SHA256 RX-файла не совпадает с ожидаемым значением")
|
||||||
|
analysis_started = time.perf_counter()
|
||||||
|
with MemoryPeakSampler() as memory:
|
||||||
|
search_progress: Callable[[int, int], None] | None = None
|
||||||
|
if progress_path is not None:
|
||||||
|
search_progress = lambda processed, total: _write_progress(
|
||||||
|
progress_path,
|
||||||
|
phase="SEARCHING",
|
||||||
|
processed_samples=processed,
|
||||||
|
total_samples=total,
|
||||||
|
)
|
||||||
|
search = locate_reference_streaming(
|
||||||
|
capture_path,
|
||||||
|
reference,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
progress=search_progress,
|
||||||
|
)
|
||||||
|
verifications = []
|
||||||
|
for candidate in search["candidates"]:
|
||||||
|
verification = verify_candidate(
|
||||||
|
capture_path,
|
||||||
|
reference,
|
||||||
|
candidate,
|
||||||
|
layout,
|
||||||
|
criteria,
|
||||||
|
)
|
||||||
|
verifications.append({"candidate": candidate, **verification})
|
||||||
|
verdict, reasons = classify_search(search, verifications, criteria)
|
||||||
|
analysis_seconds = time.perf_counter() - analysis_started
|
||||||
|
result = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"analyzer_version": ANALYZER_VERSION,
|
||||||
|
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
||||||
|
"reference_profile": "LAB065_REFERENCE_STAIRCASE",
|
||||||
|
"reference_path": str(reference_path),
|
||||||
|
"reference_sha256": reference_sha256,
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"capture_sha256": capture_sha256,
|
||||||
|
"capture_bytes": capture_path.stat().st_size,
|
||||||
|
"capture_complex_samples": cs8_sample_count(capture_path),
|
||||||
|
"layout": asdict(layout),
|
||||||
|
"criteria_fixed_before_capture_analysis": asdict(criteria),
|
||||||
|
"search": search,
|
||||||
|
"verifications": verifications,
|
||||||
|
"verdict": verdict,
|
||||||
|
"verdict_reasons": reasons,
|
||||||
|
"peak_process_memory_bytes": memory.peak_bytes,
|
||||||
|
"peak_additional_memory_bytes": memory.additional_peak_bytes,
|
||||||
|
"analysis_seconds": analysis_seconds,
|
||||||
|
"hardware_accessed": False,
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
serialized = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
|
||||||
|
with report_path.open("x", encoding="utf-8") as stream:
|
||||||
|
stream.write(serialized)
|
||||||
|
if progress_path is not None:
|
||||||
|
_write_progress(
|
||||||
|
progress_path,
|
||||||
|
phase="COMPLETE",
|
||||||
|
processed_samples=total_samples,
|
||||||
|
total_samples=total_samples,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--reference", required=True, type=Path)
|
||||||
|
parser.add_argument("--capture", required=True, type=Path)
|
||||||
|
parser.add_argument("--capture-sha256", required=True)
|
||||||
|
parser.add_argument("--report", required=True, type=Path)
|
||||||
|
parser.add_argument("--progress", type=Path)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
result = analyze(
|
||||||
|
arguments.reference,
|
||||||
|
arguments.capture,
|
||||||
|
arguments.report,
|
||||||
|
expected_capture_sha256=arguments.capture_sha256,
|
||||||
|
progress_path=arguments.progress,
|
||||||
|
)
|
||||||
|
print(f"REFERENCE STAIRCASE = {result['verdict']}; RF TX = 0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
tools/uninstall_rf001_main_autostart.ps1
Normal file
14
tools/uninstall_rf001_main_autostart.ps1
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$taskName = "SDR Rover HTTP Control Server"
|
||||||
|
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $task) {
|
||||||
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||||
|
"AUTOSTART_REMOVED=YES"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
"AUTOSTART_REMOVED=NOT_PRESENT"
|
||||||
|
}
|
||||||
|
"RUNTIME_DATA_REMOVED=NO"
|
||||||
|
"RF_TX=0"
|
||||||
15
tools/uninstall_rf001_rx_autostart.ps1
Normal file
15
tools/uninstall_rf001_rx_autostart.ps1
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$taskName = "SDR Rover RX Agent"
|
||||||
|
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $task) {
|
||||||
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||||
|
"AUTOSTART_REMOVED=YES"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
"AUTOSTART_REMOVED=NOT_PRESENT"
|
||||||
|
}
|
||||||
|
"INSTALLATION_FILES_REMOVED=NO"
|
||||||
|
"RUNTIME_DATA_REMOVED=NO"
|
||||||
|
"RF_TX=0"
|
||||||
Reference in New Issue
Block a user