"""Polling RX-laptop agent for the RF-001 authenticated HTTP channel. Only fixed inventory, software verification, receive-only capture, capture status, and offline decode tasks are implemented. The agent has no generic command endpoint and contains no RF-transmit operation. """ from __future__ import annotations import argparse from dataclasses import dataclass from datetime import datetime, timezone import getpass import hashlib import json import os from pathlib import Path import platform import re import shutil import socket import subprocess import sys import time from typing import Callable, Sequence from urllib.parse import quote ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from tools.rf001_http_control import ( # noqa: E402 AGENT_ID_PATTERN, CAPTURE_DECODE_TASK, CAPTURE_DIAGNOSTICS_TASK, DEFAULT_TOKEN_PATH, INVENTORY_TASK, OFFLINE_VERIFY_TASK, PROTOCOL_VERSION, ProtocolError, RX_CAPTURE_START_TASK, RX_CAPTURE_STATUS_TASK, SESSION_ID_PATTERN, SHA256_PATTERN, STAIRCASE_SEARCH_TASK, UPDATE_AGENT_TASK, read_token, request_file, request_json, utc_now, ) from tools.rf001_agent_update import ( # noqa: E402 ReleaseValidationError, atomic_write_json, inspect_release_archive, sha256_file as update_sha256_file, verify_update_manifest_signature, ) from tools.rf001_rx_capture import ( # noqa: E402 decode_completed_capture, read_capture_diagnostics, read_capture_status, start_rx_capture, ) DEFAULT_SERVER = "http://192.168.255.185:8765" DEFAULT_POLL_INTERVAL_SECONDS = 2.0 AGENT_VERSION = "2026.09.29.2" SUPPORTED_TASKS = ( "INVENTORY", "OFFLINE_VERIFY", "RX_CAPTURE_START", "RX_CAPTURE_STATUS", "CAPTURE_DECODE", "CAPTURE_DIAGNOSTICS", "STAIRCASE_SEARCH", "UPDATE_AGENT", ) DEFAULT_WORK_ROOT = ( Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Documents" / "SDR_Rover_Runtime" ) CommandRunner = Callable[[Sequence[str], float], "CommandResult"] def redirect_output(path: Path) -> None: """Send service-mode stdout and stderr to one line-buffered UTF-8 log.""" path.parent.mkdir(parents=True, exist_ok=True) stream = path.open("a", encoding="utf-8", buffering=1) sys.stdout = stream sys.stderr = stream @dataclass(frozen=True) class CommandResult: arguments: list[str] returncode: int stdout: str stderr: str def as_mapping(self) -> dict[str, object]: return { "arguments": self.arguments, "returncode": self.returncode, "stdout": self.stdout, "stderr": self.stderr, } def run_command(arguments: Sequence[str], timeout: float) -> CommandResult: completed = subprocess.run( list(arguments), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) return CommandResult( arguments=list(arguments), returncode=completed.returncode, stdout=completed.stdout.strip(), stderr=completed.stderr.strip(), ) def normalized_agent_id(value: str) -> str: candidate = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()).strip("-._") if len(candidate) < 3: candidate = f"rx-{candidate or 'node'}" candidate = candidate[:64] if AGENT_ID_PATTERN.fullmatch(candidate) is None: raise ValueError("unable to derive a valid agent ID") return candidate def read_git_metadata(repository: Path) -> dict[str, object]: """Read branch and HEAD without requiring git.exe or changing the checkout.""" marker = repository / ".git" if not marker.exists(): raise ValueError(f"Git metadata is absent: {marker}") if marker.is_dir(): git_dir = marker.resolve() else: line = marker.read_text(encoding="utf-8-sig").strip() match = re.fullmatch(r"gitdir:\s*(.+)", line) if match is None: raise ValueError("invalid .git indirection file") candidate = Path(match.group(1).strip()) git_dir = ( candidate.resolve() if candidate.is_absolute() else (repository / candidate).resolve() ) common_dir_file = git_dir / "commondir" if common_dir_file.is_file(): common_value = Path(common_dir_file.read_text(encoding="utf-8-sig").strip()) common_dir = ( common_value.resolve() if common_value.is_absolute() else (git_dir / common_value).resolve() ) else: common_dir = git_dir head_entry = (git_dir / "HEAD").read_text(encoding="ascii").strip() if head_entry.startswith("ref: "): ref_name = head_entry[5:] if not ref_name.startswith("refs/heads/"): raise ValueError(f"unsupported HEAD reference: {ref_name}") branch = ref_name.removeprefix("refs/heads/") loose_refs = ( git_dir.joinpath(*ref_name.split("/")), common_dir.joinpath(*ref_name.split("/")), ) loose_ref = next((path for path in loose_refs if path.is_file()), None) if loose_ref is not None: head = loose_ref.read_text(encoding="ascii").strip() else: head = "" for packed_refs in (git_dir / "packed-refs", common_dir / "packed-refs"): if packed_refs.is_file(): for packed_line in packed_refs.read_text( encoding="ascii" ).splitlines(): fields = packed_line.split() if len(fields) == 2 and fields[1] == ref_name: head = fields[0] break if head: break if not head: raise ValueError(f"Git reference is unresolved: {ref_name}") else: branch = "DETACHED" head = head_entry if re.fullmatch(r"[0-9a-f]{40}", head) is None: raise ValueError("Git HEAD is not a lowercase 40-character hash") return { "git_dir": str(git_dir), "common_dir": str(common_dir), "head_entry": head_entry, "branch": branch, "head": head, "status": "NOT_RUN_NO_GIT_EXECUTABLE", } def _git_inventory( repository: Path, command_runner: CommandRunner, ) -> dict[str, object]: result: dict[str, object] = { "path": str(repository), "exists": repository.is_dir(), } if not repository.is_dir(): return result try: result["metadata"] = read_git_metadata(repository) except (OSError, ValueError) as error: result["metadata_error"] = f"{type(error).__name__}: {error}" commands = { "top_level": ["git", "-C", str(repository), "rev-parse", "--show-toplevel"], "branch": ["git", "-C", str(repository), "branch", "--show-current"], "head": ["git", "-C", str(repository), "rev-parse", "HEAD"], "status": ["git", "-C", str(repository), "status", "--short", "--branch"], } for name, arguments in commands.items(): result[name] = command_runner(arguments, 15.0).as_mapping() return result def _resolve_hackrf_info(configured: Path | None) -> Path | None: if configured is not None: return configured discovered = shutil.which("hackrf_info") or shutil.which("hackrf_info.exe") if discovered: return Path(discovered) user_profile = Path(os.environ.get("USERPROFILE", str(Path.home()))) candidates = ( user_profile / "Tools" / "HackRF" / "Library" / "bin" / "hackrf_info.exe", user_profile / "radioconda" / "Library" / "bin" / "hackrf_info.exe", ) return next((path for path in candidates if path.is_file()), None) def collect_inventory( repository: Path, *, include_hackrf_info: bool, hackrf_info_path: Path | None, command_runner: CommandRunner = run_command, ) -> tuple[dict[str, object], list[str]]: """Collect fixed read-only host, Git, and optional HackRF information.""" errors: list[str] = [] inventory: dict[str, object] = { "hostname": socket.gethostname(), "account": getpass.getuser(), "platform": platform.platform(), "python_executable": sys.executable, "python_version": platform.python_version(), "collected_at": utc_now(), "rf_tx": 0, } try: inventory["hostname_command"] = command_runner(["hostname"], 10.0).as_mapping() except (OSError, subprocess.SubprocessError) as error: errors.append(f"hostname failed: {type(error).__name__}: {error}") try: inventory["whoami_command"] = command_runner(["whoami"], 10.0).as_mapping() except (OSError, subprocess.SubprocessError) as error: errors.append(f"whoami failed: {type(error).__name__}: {error}") try: inventory["git"] = _git_inventory(repository, command_runner) except (OSError, subprocess.SubprocessError) as error: errors.append(f"git inventory failed: {type(error).__name__}: {error}") if include_hackrf_info: resolved = _resolve_hackrf_info(hackrf_info_path) if resolved is None: inventory["hackrf_info"] = { "found": False, "executed": False, "rf_tx": 0, } errors.append("hackrf_info executable was not found") elif not resolved.is_file(): inventory["hackrf_info"] = { "found": False, "configured_path": str(resolved), "executed": False, "rf_tx": 0, } errors.append(f"hackrf_info path does not exist: {resolved}") else: try: outcome = command_runner([str(resolved)], 20.0) inventory["hackrf_info"] = { "found": True, "path": str(resolved), "executed": True, "result": outcome.as_mapping(), "rf_tx": 0, } if outcome.returncode != 0: errors.append( f"hackrf_info returned exit code {outcome.returncode}" ) except (OSError, subprocess.SubprocessError) as error: inventory["hackrf_info"] = { "found": True, "path": str(resolved), "executed": False, "rf_tx": 0, } errors.append(f"hackrf_info failed: {type(error).__name__}: {error}") else: inventory["hackrf_info"] = { "requested": False, "executed": False, "rf_tx": 0, } return inventory, errors def _bounded_command(result: CommandResult) -> dict[str, object]: return { "arguments": result.arguments, "returncode": result.returncode, "stdout": result.stdout[-8_000:], "stderr": result.stderr[-8_000:], } def run_offline_verify( repository: Path, work_root: Path, task_id: str, expected_head: str, *, command_runner: CommandRunner = run_command, ) -> tuple[dict[str, object], list[str]]: """Generate and decode the deterministic RF-001A waveform without hardware.""" errors: list[str] = [] result: dict[str, object] = { "mode": "SOFTWARE_ONLY", "hardware_accessed": False, "rf_tx": 0, } try: metadata = read_git_metadata(repository) result["git"] = metadata if metadata["branch"] != "video-development": raise ValueError("repository branch is not video-development") if metadata["head"] != expected_head: raise ValueError( f"repository HEAD {metadata['head']} does not match {expected_head}" ) prepare_script = repository / "tools" / "rf001a_prepare.py" decoder_script = ROOT / "tools" / "rf001_offline_decode.py" truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json" for required in (prepare_script, decoder_script, truth_path): if not required.is_file(): raise FileNotFoundError(f"required project file is absent: {required}") output_directory = work_root / "offline_verify" / task_id output_directory.parent.mkdir(parents=True, exist_ok=True) if output_directory.exists(): raise FileExistsError( f"offline verification directory already exists: {output_directory}" ) report_path = output_directory / "offline_decode_report.json" prepare = command_runner( [ sys.executable, "-B", "-X", "utf8", str(prepare_script), "--output-dir", str(output_directory), ], 300.0, ) result["prepare"] = _bounded_command(prepare) if prepare.returncode != 0: raise RuntimeError(f"rf001a_prepare exited with {prepare.returncode}") iq_path = output_directory / "tx_waveform.cs8" generated_truth = output_directory / "tx_metadata.json" decode = command_runner( [ sys.executable, "-B", "-X", "utf8", str(decoder_script), "--repository", str(repository), "--iq-file", str(iq_path), "--truth-metadata", str(generated_truth), "--report", str(report_path), ], 900.0, ) result["decode"] = _bounded_command(decode) if decode.returncode != 0: raise RuntimeError(f"qpsk_rf_rx exited with {decode.returncode}") report = json.loads(report_path.read_text(encoding="utf-8-sig")) exact_packets = report.get("exact_packets") crc_valid_packets = report.get("crc_valid_packets") result.update( { "output_directory": str(output_directory), "report_path": str(report_path), "crc_valid_packets": crc_valid_packets, "exact_packets": exact_packets, "expected_packets": 100, } ) if crc_valid_packets != 100 or exact_packets != 100: raise RuntimeError( "offline decode did not produce 100 CRC-valid and exact packets" ) except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: errors.append(f"{type(error).__name__}: {error}") return result, errors def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: while block := stream.read(4 * 1024 * 1024): digest.update(block) return digest.hexdigest() def _staircase_result_from_report( report: dict[str, object], *, report_path: Path, command: CommandResult | None, reused_existing_report: bool, ) -> dict[str, object]: search = report.get("search") if not isinstance(search, dict): raise ValueError("staircase report search section is invalid") candidates = search.get("candidates") if not isinstance(candidates, list): raise ValueError("staircase report candidates are invalid") result: dict[str, object] = { "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", "session_id": report_path.parent.name, "reference_detection": report.get("verdict"), "verdict_reasons": report.get("verdict_reasons"), "coverage_start_sample": search.get("coverage_start_sample"), "coverage_stop_sample": search.get("coverage_stop_sample"), "coverage_seconds": search.get("coverage_seconds"), "coverage_complete": search.get("coverage_complete"), "source_samples_processed": search.get("source_samples_processed"), "frequency_grid_hz": search.get("frequency_grid_hz"), "background_score_p99": search.get("background_score_p99"), "candidates": candidates, "verifications": report.get("verifications"), "capture_sha256": report.get("capture_sha256"), "reference_sha256": report.get("reference_sha256"), "analysis_seconds": report.get("analysis_seconds"), "peak_process_memory_bytes": report.get("peak_process_memory_bytes"), "peak_additional_memory_bytes": report.get("peak_additional_memory_bytes"), "analyzer_version": report.get("analyzer_version"), "report_path": str(report_path), "report_bytes": report_path.stat().st_size, "report_sha256": _sha256_file(report_path), "reused_existing_report": reused_existing_report, "automatic_retry": False, "hardware_accessed": False, "rf_tx": 0, } if command is not None: result["analysis_process"] = _bounded_command(command) return result def run_staircase_search( work_root: Path, session_id: str, expected_rx_sha256: str, expected_reference_sha256: str, *, command_runner: CommandRunner = run_command, progress_path: Path | None = None, ) -> tuple[dict[str, object], list[str]]: """Run the one fixed, software-only staircase analysis.""" session_directory = work_root / "rx_capture" / session_id capture_path = session_directory / "rf001a_ota_capture.cs8" reference_path = work_root / "references" / "lab065_reference_staircase.cs8" analyzer_path = Path(__file__).resolve().with_name("rf001_staircase_search.py") report_path = session_directory / "staircase_search_report.json" result: dict[str, object] = { "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", "session_id": session_id, "reference_detection": "EXECUTION_FAILED", "report_path": str(report_path), "automatic_retry": False, "hardware_accessed": False, "rf_tx": 0, } errors: list[str] = [] try: for required in (capture_path, reference_path, analyzer_path): if not required.is_file(): raise FileNotFoundError(f"required file is absent: {required}") capture_sha256 = _sha256_file(capture_path) if capture_sha256 != expected_rx_sha256: raise ValueError("RX SHA256 does not match the task") reference_sha256 = _sha256_file(reference_path) if reference_sha256 != expected_reference_sha256: raise ValueError("reference SHA256 does not match the task") if report_path.exists(): report = json.loads(report_path.read_text(encoding="utf-8-sig")) if not isinstance(report, dict): raise ValueError("existing staircase report is not a JSON object") if report.get("capture_sha256") != expected_rx_sha256: raise ValueError("existing report has another RX SHA256") if report.get("reference_sha256") != expected_reference_sha256: raise ValueError("existing report has another reference SHA256") if report.get("hardware_accessed") is not False: raise ValueError("existing report does not prove software-only analysis") if report.get("rf_tx") != 0: raise ValueError("existing report does not preserve rf_tx = 0") return ( _staircase_result_from_report( report, report_path=report_path, command=None, reused_existing_report=True, ), [], ) command_arguments = [ sys.executable, "-B", "-X", "utf8", str(analyzer_path), "--reference", str(reference_path), "--capture", str(capture_path), "--capture-sha256", expected_rx_sha256, "--report", str(report_path), ] if progress_path is not None: command_arguments.extend(("--progress", str(progress_path))) command = command_runner( command_arguments, 43_200.0, ) result["analysis_process"] = _bounded_command(command) if command.returncode != 0: raise RuntimeError( f"staircase analyzer exited with {command.returncode}" ) if not report_path.is_file(): raise RuntimeError("staircase analyzer did not create its report") report = json.loads(report_path.read_text(encoding="utf-8-sig")) if not isinstance(report, dict): raise ValueError("staircase report is not a JSON object") if report.get("capture_sha256") != expected_rx_sha256: raise ValueError("staircase report RX SHA256 is invalid") if report.get("reference_sha256") != expected_reference_sha256: raise ValueError("staircase report reference SHA256 is invalid") result = _staircase_result_from_report( report, report_path=report_path, command=command, reused_existing_report=False, ) except ( OSError, ValueError, RuntimeError, json.JSONDecodeError, subprocess.SubprocessError, ) as error: errors.append(f"{type(error).__name__}: {error}") return result, errors def _read_optional_json(path: Path | None) -> dict[str, object] | None: if path is None or not path.is_file(): return None value = json.loads(path.read_text(encoding="utf-8-sig")) return value if isinstance(value, dict) else None def lightweight_inventory( repository: Path, *, release_id: str | None = None, launcher_status_path: Path | None = None, ) -> dict[str, object]: inventory: dict[str, object] = { "agent_version": AGENT_VERSION, "release_id": release_id, "supported_tasks": list(SUPPORTED_TASKS), "hostname": socket.gethostname(), "account": getpass.getuser(), "python_version": platform.python_version(), "repository": str(repository), "repository_exists": repository.is_dir(), "checked_at": utc_now(), "rf_tx": 0, } try: launcher_status = _read_optional_json(launcher_status_path) except (OSError, ValueError, json.JSONDecodeError) as error: inventory["launcher_status_error"] = f"{type(error).__name__}: {error}" else: inventory["launcher_status"] = launcher_status return inventory def _process_exists(process_id: int) -> bool: if process_id <= 0: return False try: os.kill(process_id, 0) except OSError: return False return True def _job_pointer_path(work_root: Path) -> Path: return work_root / "agent_jobs" / "active_job.json" def _job_directory(work_root: Path, task_id: str) -> Path: return work_root / "agent_jobs" / task_id def _load_active_job(work_root: Path) -> dict[str, object] | None: pointer = _job_pointer_path(work_root) if not pointer.is_file(): return None value = json.loads(pointer.read_text(encoding="utf-8-sig")) if not isinstance(value, dict): raise ValueError("active job pointer is invalid") return value def _staircase_total_samples(work_root: Path, task: dict[str, object]) -> int: session_id = str(task.get("session_id", "")) capture = work_root / "rx_capture" / session_id / "rf001a_ota_capture.cs8" if not capture.is_file() or capture.stat().st_size % 2: return 1 return max(1, capture.stat().st_size // 2) def _failed_job_result( pointer: dict[str, object], *, error: str, ) -> dict[str, object]: task = pointer.get("task") if not isinstance(task, dict): raise ValueError("active job task is invalid") return { "protocol_version": PROTOCOL_VERSION, "task_id": str(task["task_id"]), "agent_id": str(task["agent_id"]), "status": "FAILED", "started_at": str(pointer.get("started_at", utc_now())), "finished_at": utc_now(), "result": { "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", "session_id": task.get("session_id"), "reference_detection": "EXECUTION_FAILED", "automatic_retry": False, "hardware_accessed": False, "rf_tx": 0, }, "errors": [error], "rf_tx": 0, } def _ensure_job_result(work_root: Path, pointer: dict[str, object]) -> Path | None: task = pointer.get("task") if not isinstance(task, dict): raise ValueError("active job task is invalid") directory = _job_directory(work_root, str(task["task_id"])) result_path = directory / "result.json" if result_path.is_file(): return result_path process_id = pointer.get("pid") if type(process_id) is int and _process_exists(process_id): return None atomic_write_json( result_path, _failed_job_result( pointer, error="background staircase process ended without a result; not retried", ), ) return result_path def _active_task_payload( work_root: Path, pointer: dict[str, object], ) -> dict[str, object]: task = pointer.get("task") if not isinstance(task, dict): raise ValueError("active job task is invalid") directory = _job_directory(work_root, str(task["task_id"])) progress_path = directory / "progress.json" total = _staircase_total_samples(work_root, task) processed = 0 phase = "STARTING" if progress_path.is_file(): progress = json.loads(progress_path.read_text(encoding="utf-8-sig")) if isinstance(progress, dict): candidate_total = progress.get("total_samples") candidate_processed = progress.get("processed_samples") if type(candidate_total) is int and candidate_total > 0: total = candidate_total if type(candidate_processed) is int: processed = min(total, max(0, candidate_processed)) phase = str(progress.get("phase", "RUNNING")) result_path = directory / "result.json" state = "RESULT_PENDING" if result_path.is_file() else "RUNNING" return { "task_id": str(task["task_id"]), "kind": STAIRCASE_SEARCH_TASK, "state": state, "phase": phase, "processed_samples": processed, "total_samples": total, "last_error": None, "hardware_accessed": False, "rf_tx": 0, } def start_staircase_background_job( task: dict[str, object], *, agent_id: str, work_root: Path, ) -> dict[str, object]: if _load_active_job(work_root) is not None: raise RuntimeError("another background job is already active") task_id = str(task.get("task_id", "")) directory = _job_directory(work_root, task_id) directory.mkdir(parents=True, exist_ok=False) spec_path = directory / "job_spec.json" result_path = directory / "result.json" progress_path = directory / "progress.json" spec = { "schema_version": 1, "task": task, "agent_id": agent_id, "work_root": str(work_root), "progress_path": str(progress_path), "hardware_accessed": False, "rf_tx": 0, } atomic_write_json(spec_path, spec) worker = Path(__file__).resolve().with_name("rf001_staircase_job.py") if not worker.is_file(): raise FileNotFoundError(f"staircase worker is absent: {worker}") log_stream = (directory / "worker.log").open( "a", encoding="utf-8", buffering=1 ) creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) pointer: dict[str, object] = { "schema_version": 1, "task": task, "pid": None, "started_at": utc_now(), "automatic_retry": False, "hardware_accessed": False, "rf_tx": 0, } # Persist ownership before creating the child. If the agent stops at any # point after this write, its replacement sees the pointer and must not # start a second analyzer. atomic_write_json(_job_pointer_path(work_root), pointer) process = subprocess.Popen( [ sys.executable, "-u", "-B", "-X", "utf8", str(worker), "--job-spec", str(spec_path), "--result", str(result_path), ], cwd=ROOT, stdin=subprocess.DEVNULL, stdout=log_stream, stderr=subprocess.STDOUT, text=True, creationflags=creation_flags, ) log_stream.close() pointer["pid"] = process.pid atomic_write_json(_job_pointer_path(work_root), pointer) return pointer def deliver_background_result( arguments: argparse.Namespace, token: bytes, pointer: dict[str, object], ) -> bool: result_path = _ensure_job_result(arguments.work_root, pointer) if result_path is None: return False result = json.loads(result_path.read_text(encoding="utf-8-sig")) if not isinstance(result, dict): raise ValueError("background result is invalid") acknowledgement = request_json( arguments.server, token, "POST", "/api/v1/agent/result", result, timeout=arguments.timeout, ) if acknowledgement.get("accepted") is not True: raise RuntimeError("server did not accept background result") task = pointer.get("task") if not isinstance(task, dict): raise ValueError("active job task is invalid") directory = _job_directory(arguments.work_root, str(task["task_id"])) atomic_write_json( directory / "delivery_receipt.json", { "delivered_at": utc_now(), "acknowledgement": acknowledgement, "rf_tx": 0, }, ) os.replace(_job_pointer_path(arguments.work_root), directory / "delivered_job.json") return True def _validate_update_task(task: dict[str, object], agent_id: str) -> None: if task.get("kind") != UPDATE_AGENT_TASK: raise ProtocolError("task is not UPDATE_AGENT") if task.get("agent_id") != agent_id: raise ProtocolError("update task is addressed to another agent") if task.get("protocol_version") != PROTOCOL_VERSION: raise ProtocolError("update task protocol version is unsupported") if task.get("compatible_protocol_version") != PROTOCOL_VERSION: raise ProtocolError("release protocol version is incompatible") if task.get("automatic_retry") is not False: raise ProtocolError("agent update must prohibit automatic retry") if task.get("hardware_accessed") is not False or task.get("rf_tx") != 0: raise ProtocolError("agent update must be software-only") if SHA256_PATTERN.fullmatch(str(task.get("package_sha256", ""))) is None: raise ProtocolError("update package SHA256 is invalid") if SHA256_PATTERN.fullmatch( str(task.get("content_manifest_sha256", "")) ) is None: raise ProtocolError("update content manifest SHA256 is invalid") package_bytes = task.get("package_bytes") if type(package_bytes) is not int or package_bytes <= 0: raise ProtocolError("update package size is invalid") expires = task.get("approval_expires_at") if not isinstance(expires, str): raise ProtocolError("update approval expiry is invalid") if datetime.fromisoformat(expires) < datetime.now(timezone.utc): raise ProtocolError("update approval expired") def stage_agent_update( task: dict[str, object], *, arguments: argparse.Namespace, token: bytes, command_runner: CommandRunner = run_command, ) -> tuple[dict[str, object], list[str], dict[str, object] | None]: result: dict[str, object] = { "mode": "FIXED_AGENT_UPDATE", "update_status": "EXECUTION_FAILED", "automatic_retry": False, "hardware_accessed": False, "rf_tx": 0, } errors: list[str] = [] launcher_request: dict[str, object] | None = None try: _validate_update_task(task, arguments.agent_id) if arguments.install_root is None or arguments.launcher_request is None: raise RuntimeError("versioned launcher paths are not configured") release_id = str(task["release_id"]) agent_version = str(task["agent_version"]) install_root = arguments.install_root.resolve() releases_root = install_root / "versions" final_release = releases_root / release_id ready_path = final_release / "READY.json" if ready_path.is_file(): ready = json.loads(ready_path.read_text(encoding="utf-8-sig")) if not isinstance(ready, dict): raise ValueError("existing release READY marker is invalid") if ( ready.get("release_id") != release_id or ready.get("agent_version") != agent_version or ready.get("package_sha256") != task["package_sha256"] ): raise ValueError("existing release differs from the approved release") update_status = "ALREADY_STAGED" else: quoted_release = quote(release_id, safe="") quoted_agent = quote(arguments.agent_id, safe="") manifest_target = ( f"/api/v1/agent/releases/{quoted_release}/manifest" f"?agent_id={quoted_agent}" ) manifest_response = request_json( arguments.server, token, "GET", manifest_target, timeout=arguments.timeout, ) manifest = manifest_response.get("manifest") signature = manifest_response.get("manifest_signature") if not isinstance(manifest, dict) or not isinstance(signature, str): raise ReleaseValidationError("signed release manifest is invalid") verify_update_manifest_signature(token, manifest, signature) expected = { "release_id": release_id, "agent_version": agent_version, "protocol_version": task["compatible_protocol_version"], "package_sha256": task["package_sha256"], "package_bytes": task["package_bytes"], "content_manifest_sha256": task["content_manifest_sha256"], "package_kind": "AGENT_RELEASE", } for field, expected_value in expected.items(): if manifest.get(field) != expected_value: raise ReleaseValidationError( f"signed release manifest field mismatch: {field}" ) required_free = ( int(task["package_bytes"]) + int(manifest.get("uncompressed_bytes", 0)) + 32 * 1024 * 1024 ) if shutil.disk_usage(install_root).free < required_free: raise RuntimeError("insufficient free space for staged update") staging = install_root / "staging" / str(task["task_id"]) staging.mkdir(parents=True, exist_ok=True) package_target = ( f"/api/v1/agent/releases/{quoted_release}/package" f"?agent_id={quoted_agent}" ) inspection = None extracted = None last_error: Exception | None = None download_state_path = staging / "download_state.json" download_state = _read_optional_json(download_state_path) or { "schema_version": 1, "network_attempts": 0, "maximum_network_attempts": 2, "hardware_accessed": False, "rf_tx": 0, } network_attempts = download_state.get("network_attempts") if type(network_attempts) is not int or not 0 <= network_attempts <= 2: raise ValueError("staged download attempt counter is invalid") while network_attempts < 2: attempt = network_attempts + 1 download = staging / f"package_attempt_{attempt}.zip" if download.exists(): last_error = RuntimeError( f"download attempt {attempt} was interrupted" ) network_attempts = attempt download_state["network_attempts"] = network_attempts atomic_write_json(download_state_path, download_state) continue network_attempts = attempt download_state["network_attempts"] = network_attempts download_state["last_attempt_at"] = utc_now() atomic_write_json(download_state_path, download_state) try: request_file( arguments.server, token, package_target, download, expected_bytes=int(task["package_bytes"]), timeout=max(30.0, arguments.timeout), ) if update_sha256_file(download) != task["package_sha256"]: raise ReleaseValidationError("downloaded package SHA256 mismatch") extracted = staging / f"extracted_attempt_{attempt}" inspection = inspect_release_archive( download, expected_package_sha256=str(task["package_sha256"]), expected_package_bytes=int(task["package_bytes"]), expected_content_manifest_sha256=str( task["content_manifest_sha256"] ), extraction_root=extracted, ) break except (OSError, RuntimeError, ReleaseValidationError) as error: last_error = error if inspection is None or extracted is None: raise RuntimeError(f"release download failed twice: {last_error}") if ( inspection.release_id != release_id or inspection.agent_version != agent_version or inspection.protocol_version != PROTOCOL_VERSION ): raise ReleaseValidationError("extracted release identity mismatch") self_test = command_runner( [ sys.executable, "-B", "-X", "utf8", str(extracted / "app" / "tools" / "rf001_rx_http_agent.py"), "--self-test", ], 120.0, ) result["self_test"] = _bounded_command(self_test) if self_test.returncode != 0: raise RuntimeError( f"new agent self-test exited with {self_test.returncode}" ) ready = { "schema_version": 1, "status": "READY", "release_id": release_id, "agent_version": agent_version, "protocol_version": PROTOCOL_VERSION, "package_sha256": inspection.package_sha256, "content_manifest_sha256": inspection.content_manifest_sha256, "verified_at": utc_now(), "hardware_accessed": False, "rf_tx": 0, } atomic_write_json(extracted / "READY.json", ready) releases_root.mkdir(parents=True, exist_ok=True) os.replace(extracted, final_release) atomic_write_json( install_root / "update_receipts" / f"{task['task_id']}.json", {**ready, "task_id": task["task_id"]}, ) update_status = "STAGED" launcher_status = _read_optional_json(arguments.launcher_status) active_release = ( launcher_status.get("active_release_id") if isinstance(launcher_status, dict) else arguments.release_id ) if active_release == release_id: update_status = "ALREADY_INSTALLED" else: launcher_request = { "schema_version": 1, "task_id": task["task_id"], "from_release_id": active_release, "release_id": release_id, "agent_version": agent_version, "package_sha256": task["package_sha256"], "safe_to_switch": True, "requested_at": utc_now(), "hardware_accessed": False, "rf_tx": 0, } result.update( { "update_status": update_status, "release_id": release_id, "agent_version": agent_version, "package_sha256": task["package_sha256"], "launcher_switch_requested": launcher_request is not None, } ) except ( OSError, ValueError, RuntimeError, ProtocolError, ReleaseValidationError, subprocess.SubprocessError, json.JSONDecodeError, ) as error: errors.append(f"{type(error).__name__}: {error}") return result, errors, launcher_request def execute_task( task: dict[str, object], *, agent_id: str, repository: Path, hackrf_info_path: Path | None, hackrf_clock_path: Path | None = None, hackrf_transfer_path: Path | None = None, work_root: Path = DEFAULT_WORK_ROOT, command_runner: CommandRunner = run_command, ) -> dict[str, object]: if task.get("protocol_version") != PROTOCOL_VERSION: raise ProtocolError("task protocol version is not supported") if task.get("agent_id") != agent_id: raise ProtocolError("task is addressed to another agent") if task.get("rf_tx") != 0: raise ProtocolError("task does not preserve rf_tx = 0") task_id = task.get("task_id") if not isinstance(task_id, str): raise ProtocolError("task_id is missing") started_at = utc_now() kind = task.get("kind") if kind == INVENTORY_TASK: include = task.get("include_hackrf_info") if type(include) is not bool: raise ProtocolError("include_hackrf_info must be boolean") task_result, errors = collect_inventory( repository, include_hackrf_info=include, hackrf_info_path=hackrf_info_path, command_runner=command_runner, ) elif kind == OFFLINE_VERIFY_TASK: expected_head = task.get("expected_head") if not isinstance(expected_head, str) or re.fullmatch( r"[0-9a-f]{40}", expected_head ) is None: raise ProtocolError("expected_head is invalid") task_result, errors = run_offline_verify( repository, work_root, task_id, expected_head, command_runner=command_runner, ) elif kind == RX_CAPTURE_START_TASK: expected_head = task.get("expected_head") session_id = task.get("session_id") rx_serial = task.get("rx_serial") if task.get("rx_authorized") is not True: raise ProtocolError("RX capture task is not explicitly authorized") if task.get("automatic_retry") is not False: raise ProtocolError("RX capture task must prohibit automatic retry") if not isinstance(expected_head, str) or re.fullmatch( r"[0-9a-f]{40}", expected_head ) is None: raise ProtocolError("expected_head is invalid") if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( session_id ) is None: raise ProtocolError("session_id is invalid") if not isinstance(rx_serial, str): raise ProtocolError("rx_serial is invalid") metadata = read_git_metadata(repository) if metadata["branch"] != "video-development": raise ProtocolError("repository branch is not video-development") if metadata["head"] != expected_head: raise ProtocolError("repository HEAD does not match the capture task") task_result, errors = start_rx_capture( repository, work_root, session_id, rx_serial, hackrf_info_path=hackrf_info_path, hackrf_clock_path=hackrf_clock_path, hackrf_transfer_path=hackrf_transfer_path, command_runner=command_runner, ) task_result["git"] = metadata elif kind == RX_CAPTURE_STATUS_TASK: session_id = task.get("session_id") if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( session_id ) is None: raise ProtocolError("session_id is invalid") task_result, errors = read_capture_status(work_root, session_id) elif kind == CAPTURE_DECODE_TASK: session_id = task.get("session_id") tx_started_at = task.get("tx_started_at") tx_finished_at = task.get("tx_finished_at") if not all( isinstance(value, str) for value in (session_id, tx_started_at, tx_finished_at) ): raise ProtocolError("capture decode task fields are invalid") if SESSION_ID_PATTERN.fullmatch(str(session_id)) is None: raise ProtocolError("session_id is invalid") task_result, errors = decode_completed_capture( repository, work_root, str(session_id), str(tx_started_at), str(tx_finished_at), command_runner=command_runner, ) elif kind == CAPTURE_DIAGNOSTICS_TASK: session_id = task.get("session_id") tx_started_at = task.get("tx_started_at") tx_finished_at = task.get("tx_finished_at") if not all( isinstance(value, str) for value in (session_id, tx_started_at, tx_finished_at) ): raise ProtocolError("capture diagnostics task fields are invalid") if SESSION_ID_PATTERN.fullmatch(str(session_id)) is None: raise ProtocolError("session_id is invalid") task_result, errors = read_capture_diagnostics( work_root, str(session_id), str(tx_started_at), str(tx_finished_at), ) elif kind == STAIRCASE_SEARCH_TASK: session_id = task.get("session_id") expected_rx_sha256 = task.get("expected_rx_sha256") expected_reference_sha256 = task.get("expected_reference_sha256") if task.get("automatic_retry") is not False: raise ProtocolError("staircase search must prohibit automatic retry") if task.get("hardware_accessed") is not False: raise ProtocolError("staircase search must be software-only") if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( session_id ) is None: raise ProtocolError("session_id is invalid") if not isinstance(expected_rx_sha256, str) or SHA256_PATTERN.fullmatch( expected_rx_sha256 ) is None: raise ProtocolError("expected_rx_sha256 is invalid") if not isinstance( expected_reference_sha256, str ) or SHA256_PATTERN.fullmatch(expected_reference_sha256) is None: raise ProtocolError("expected_reference_sha256 is invalid") task_result, errors = run_staircase_search( work_root, session_id, expected_rx_sha256, expected_reference_sha256, command_runner=command_runner, ) else: raise ProtocolError("unsupported task kind") return { "protocol_version": PROTOCOL_VERSION, "task_id": task_id, "agent_id": agent_id, "status": "COMPLETE" if not errors else "FAILED", "started_at": started_at, "finished_at": utc_now(), "result": task_result, "errors": errors, "rf_tx": 0, } def run_once(arguments: argparse.Namespace, token: bytes) -> str: pointer = _load_active_job(arguments.work_root) if pointer is not None and deliver_background_result(arguments, token, pointer): pointer = None elif pointer is not None: _ensure_job_result(arguments.work_root, pointer) active_task = ( _active_task_payload(arguments.work_root, pointer) if pointer is not None else None ) write_health(arguments, active_task) check_in = { "protocol_version": PROTOCOL_VERSION, "agent_id": arguments.agent_id, "inventory": lightweight_inventory( arguments.repository, release_id=getattr(arguments, "release_id", None), launcher_status_path=getattr(arguments, "launcher_status", None), ), "active_task": active_task, "rf_tx": 0, } response = request_json( arguments.server, token, "POST", "/api/v1/agent/check-in", check_in, timeout=arguments.timeout, ) if response.get("protocol_version") != PROTOCOL_VERSION: raise ProtocolError("server protocol version is not supported") if response.get("rf_tx") != 0: raise ProtocolError("server response does not preserve rf_tx = 0") task = response.get("task") if active_task is not None: if task is not None: raise ProtocolError("server issued a task while another task is active") return "TASK_RUNNING" if task is None: if not arguments.quiet_idle: print("TASK = NONE") print("RF TX = 0") return "NO_TASK" if not isinstance(task, dict): raise ProtocolError("server task is not a JSON object") if task.get("kind") == STAIRCASE_SEARCH_TASK: if task.get("automatic_retry") is not False: raise ProtocolError("staircase search must prohibit automatic retry") session_id = task.get("session_id") if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( session_id ) is None: raise ProtocolError("session_id is invalid") for field in ("expected_rx_sha256", "expected_reference_sha256"): value = task.get(field) if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None: raise ProtocolError(f"{field} is invalid") start_staircase_background_job( task, agent_id=arguments.agent_id, work_root=arguments.work_root, ) print(f"TASK ID = {task['task_id']}") print("TASK STATUS = STARTED_BACKGROUND") print("HARDWARE ACCESSED = false") print("RF TX = 0") return "STARTED_BACKGROUND" launcher_request = None if task.get("kind") == UPDATE_AGENT_TASK: task_result, errors, launcher_request = stage_agent_update( task, arguments=arguments, token=token, ) result = { "protocol_version": PROTOCOL_VERSION, "task_id": task["task_id"], "agent_id": arguments.agent_id, "status": "COMPLETE" if not errors else "FAILED", "started_at": utc_now(), "finished_at": utc_now(), "result": task_result, "errors": errors, "rf_tx": 0, } else: result = execute_task( task, agent_id=arguments.agent_id, repository=arguments.repository, hackrf_info_path=arguments.hackrf_info, hackrf_clock_path=arguments.hackrf_clock, hackrf_transfer_path=arguments.hackrf_transfer, work_root=arguments.work_root, ) acknowledgement = request_json( arguments.server, token, "POST", "/api/v1/agent/result", result, timeout=arguments.timeout, ) if acknowledgement.get("accepted") is not True: raise RuntimeError("server did not accept task result") if launcher_request is not None: launcher_request_path = arguments.launcher_request if launcher_request_path is None: raise RuntimeError("launcher request path is not configured") if launcher_request_path.exists(): existing = json.loads( launcher_request_path.read_text(encoding="utf-8-sig") ) if existing != launcher_request: raise RuntimeError("another launcher request already exists") else: atomic_write_json(launcher_request_path, launcher_request) print(f"TASK ID = {result['task_id']}") print(f"TASK STATUS = {result['status']}") print(f"RESULT ACCEPTED = {acknowledgement.get('accepted')}") print("RF TX = 0") return str(result["status"]) def write_health( arguments: argparse.Namespace, active_task: dict[str, object] | None, ) -> None: health_file = getattr(arguments, "health_file", None) if health_file is None: return atomic_write_json( health_file, { "schema_version": 1, "state": "RUNNING", "pid": os.getpid(), "agent_version": AGENT_VERSION, "release_id": getattr(arguments, "release_id", None), "launch_nonce": getattr(arguments, "launch_nonce", None), "busy": active_task is not None, "active_task_id": ( active_task.get("task_id") if active_task is not None else None ), "updated_at": utc_now(), "hardware_accessed": False, "rf_tx": 0, }, ) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--server", default=DEFAULT_SERVER) parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) parser.add_argument( "--agent-id", default=normalized_agent_id(socket.gethostname()), type=normalized_agent_id, ) parser.add_argument("--repository", type=Path, default=ROOT) parser.add_argument("--hackrf-info", type=Path) parser.add_argument("--hackrf-clock", type=Path) parser.add_argument("--hackrf-transfer", type=Path) parser.add_argument("--work-root", type=Path, default=DEFAULT_WORK_ROOT) parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS) parser.add_argument("--timeout", type=float, default=10.0) parser.add_argument("--once", action="store_true") parser.add_argument("--quiet-idle", action="store_true") parser.add_argument("--log-file", type=Path) parser.add_argument("--release-id") parser.add_argument("--launch-nonce") parser.add_argument("--health-file", type=Path) parser.add_argument("--launcher-status", type=Path) parser.add_argument("--launcher-request", type=Path) parser.add_argument("--install-root", type=Path) parser.add_argument("--self-test", action="store_true") return parser def run_self_test() -> None: required = ( Path(__file__).resolve().with_name("rf001_agent_update.py"), Path(__file__).resolve().with_name("rf001_http_control.py"), Path(__file__).resolve().with_name("rf001_staircase_job.py"), Path(__file__).resolve().with_name("rf001_staircase_search.py"), ) missing = [str(path) for path in required if not path.is_file()] if missing: raise FileNotFoundError(f"self-test files are absent: {missing}") if UPDATE_AGENT_TASK not in SUPPORTED_TASKS: raise RuntimeError("UPDATE_AGENT is not enabled") if STAIRCASE_SEARCH_TASK not in SUPPORTED_TASKS: raise RuntimeError("STAIRCASE_SEARCH is not enabled") print(f"AGENT VERSION = {AGENT_VERSION}") print("SELF TEST = PASS") print("HARDWARE ACCESSED = false") print("RF TX = 0") def main() -> None: arguments = build_parser().parse_args() if arguments.self_test: run_self_test() return if arguments.log_file is not None: redirect_output(arguments.log_file) if arguments.poll_interval <= 0: raise ValueError("poll interval must be positive") if arguments.timeout <= 0: raise ValueError("timeout must be positive") token = read_token(arguments.token_file) print(f"AGENT ID = {arguments.agent_id}") print(f"SERVER = {arguments.server}") print( f"AGENT VERSION = {AGENT_VERSION}\n" f"SUPPORTED TASKS = {', '.join(SUPPORTED_TASKS)}" ) print("RF TX = 0") while True: try: run_once(arguments, token) except (OSError, RuntimeError, ProtocolError, json.JSONDecodeError) as error: timestamp = datetime.now(timezone.utc).isoformat() print(f"{timestamp} AGENT ERROR = {type(error).__name__}: {error}") print("RF TX = 0") if arguments.once: raise SystemExit(1) from error if arguments.once: return time.sleep(arguments.poll_interval) if __name__ == "__main__": main()