feat(k1): add offline control execution gate
This commit is contained in:
parent
d7749208a7
commit
ea811ff370
|
|
@ -145,8 +145,12 @@ the response supplies live vendor identity, serial, model, activation and
|
|||
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
|
||||
as one private application-level value in the observed client, not a manual
|
||||
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
||||
byte-matched offline, but there is no MQTT publisher, `vendor_writes_enabled`
|
||||
remains false, and no application command is sent.
|
||||
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
|
||||
application authority without env/file/browser fallback, and a five-batch
|
||||
orchestrator passes all retained response barriers. The installed publisher
|
||||
boundary is structurally write-disabled and cannot call its sink. There is no
|
||||
MQTT publisher, `vendor_writes_enabled` remains false, and no application
|
||||
command is sent.
|
||||
|
||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||
standalone release install. The frontend consumes sibling `file:` packages from
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
|||
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
|
||||
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
|
||||
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||
| K1 application control | PAUSE (exact shadow dialogue implemented) — all 10 retained pre-START requests and START/STOP are byte-matched; BLE-selected live DeviceInfo supplies per-scanner identity while one private application-level OpenAPI authority remains outside Git/Ops; publisher, physical save acceptance and recovery policy remain closed gates |
|
||||
| K1 application control | PAUSE (offline execution gate complete) — exact five-batch response-barrier orchestrator passes the retained 10-request dialogue; the 36-byte application authority loads only from macOS Keychain; current publisher boundary is structurally write-disabled and never touches its sink; runtime wiring, physical START/STOP and durable save remain closed gates |
|
||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
|
|
@ -33,13 +33,17 @@ private application-level value in this LixelGO build, not a per-scanner
|
|||
credential. The selected BLE peripheral returns its own LAN IP; the initial
|
||||
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
|
||||
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
|
||||
10 observed pre-START requests byte-for-byte: three unbound discovery reads,
|
||||
one response-bound time/timezone sync and six further reads. A live-only safety
|
||||
layer rejects identity drift or malformed status, and can form a non-executable
|
||||
START only from unbound `READY` or STOP only from bound `SCANNING`. Public output
|
||||
contains only digests and wire metadata. Secure application-authority loading,
|
||||
an authorized response-gated one-shot publisher and durable post-stop save are
|
||||
not yet proven. The physical button remains the known-safe fallback.
|
||||
10 observed pre-START requests byte-for-byte across five recovered batches:
|
||||
initial DeviceInfo binding; ModelingStatus plus RTK read; time sync plus
|
||||
DeviceInfo/RTK; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
|
||||
ModelingStatus request has no mandatory synchronous response barrier; readiness
|
||||
is separately attested through live DeviceStatus. A fixed macOS Keychain loader
|
||||
now reads the exact 36-byte authority without environment/file/browser fallback.
|
||||
The response orchestrator correlates required headers/results and rejects
|
||||
identity/profile drift. The installed publish boundary is a separate
|
||||
write-disabled type that cannot call its injected sink. Runtime wiring, a
|
||||
reviewed write-capable type and durable post-stop save remain unproven. The
|
||||
physical button remains the known-safe fallback.
|
||||
|
||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. The queue holds four preview messages;
|
||||
|
|
@ -258,7 +262,7 @@ useful stream is decoded or structurally identified.
|
|||
- firmware or internal-Linux analysis;
|
||||
- physical end-to-end shared-timeline playback for a newly archived session
|
||||
containing point cloud plus one selected K1 camera;
|
||||
- private application-level OpenAPI authority loading, authorized command transport
|
||||
- operator-owned Keychain provisioning/execution lease, authorized command transport
|
||||
and stable-artifact proof after the observed stop lifecycle;
|
||||
- long-running large-session WebViewer/WASM memory telemetry;
|
||||
- production retention, replication, encryption and cross-platform packaging.
|
||||
|
|
|
|||
|
|
@ -188,6 +188,15 @@ repository shadow bootstrap reproduces all ten retained payloads and their
|
|||
topic order byte-for-byte. Time sync is the only mutation before START; the
|
||||
other nine requests are reads.
|
||||
|
||||
The retained request/response timing further divides the dialogue into five
|
||||
bounded batches. DeviceInfo response establishes identity before later bound
|
||||
requests. Required response barriers are correlated by topic, exact session,
|
||||
vendor identity, application authority and numeric success. The initial
|
||||
ModelingStatus request is intentionally not treated as a required synchronous
|
||||
response: the clean cycle continued without one, and readiness belongs to the
|
||||
live DeviceStatus gate. The offline orchestrator completes over the retained
|
||||
payloads without substitution or skipped requests.
|
||||
|
||||
The repository now contains an inert bounded encoder/response parser and a
|
||||
fail-closed device-status state machine for this exact profile. A live-only
|
||||
shadow planner accepts START only from `READY` with no project and STOP only
|
||||
|
|
@ -195,9 +204,12 @@ from `SCANNING` with a bound project. It emits only a non-executable digest and
|
|||
wire metadata, uses QoS 2 with retain false, and declares automatic retry
|
||||
forbidden. It has no MQTT publish dependency and cannot send a command.
|
||||
|
||||
Publishing remains deliberately disabled: the private application authority
|
||||
still needs a secure runtime loader and the complete stop/save sequence has not
|
||||
physically proved a durable vendor project. A correlated STOP success, stream quiescence, return to
|
||||
Publishing remains deliberately disabled. The exact 36-byte authority now has a
|
||||
read-only macOS Keychain loader with no environment, file, browser or API
|
||||
fallback, but it is not wired to the live facade. The current one-shot boundary
|
||||
is structurally write-disabled and cannot touch its injected sink. The complete
|
||||
stop/save sequence has not physically proved a durable vendor project. A
|
||||
correlated STOP success, stream quiescence, return to
|
||||
`READY`/steady-green hardware state and appearance of a reusable native project
|
||||
are separate gates. The verified physical double-click remains the acquisition
|
||||
control until a separately reviewed one-shot write gate closes.
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
|
|||
responses, and classifies live device states without importing MQTT or
|
||||
publishing anything. One private application-level OpenAPI authority is kept
|
||||
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
||||
Secure authority loading and durable save completion after stop remain open, so
|
||||
The fixed Keychain loader and offline response orchestrator now exist, but live
|
||||
execution-lease wiring and durable save completion after stop remain open, so
|
||||
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
|
||||
|
||||
## Automatic Rerun source and lifecycle
|
||||
|
|
@ -331,8 +332,8 @@ listener and its process memory must be closed unconditionally.
|
|||
separate generic media path. Historical sessions predating that archive have
|
||||
no recoverable video.
|
||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||
publisher needs reviewed private application-authority loading, response-gated
|
||||
one-shot behavior and a proven durable-save completion gate.
|
||||
publisher needs reviewed Keychain provisioning/execution-lease wiring,
|
||||
response-gated one-shot behavior and a proven durable-save completion gate.
|
||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||
mission planner or vehicle control is implemented by this viewer milestone.
|
||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
|
|||
device-status values into an observation-only state machine. The OpenAPI value
|
||||
is one private application-level authority; vendor ID and serial come from the
|
||||
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
|
||||
exists. Secure authority loading and stable-artifact proof after STOP remain
|
||||
exists. A fixed Keychain loader and offline response orchestrator are present;
|
||||
operator execution-lease wiring and stable-artifact proof after STOP remain
|
||||
unresolved, so `vendor_writes_enabled=false`.
|
||||
|
||||
## Semantic lifecycle
|
||||
|
|
@ -221,8 +222,8 @@ views.
|
|||
4. Physically accept a newly archived left/right K1 session, then package the
|
||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||
plane. Keep the modeling-command publisher disabled until private application
|
||||
authority loading and durable-save confirmation close its separate safety
|
||||
plane. Keep the modeling-command publisher disabled until operator-owned
|
||||
Keychain execution-lease wiring and durable-save confirmation close its safety
|
||||
gate.
|
||||
|
||||
Complex equipment will likely be assembled from separately useful component
|
||||
|
|
|
|||
|
|
@ -513,5 +513,5 @@ viewer/WASM payload. That is a packaging optimization item, not a failed gate.
|
|||
No retained physical K1 session contains the new canonical camera archive, so a
|
||||
real point-cloud plus one-camera recorded playback remains an explicit hardware
|
||||
acceptance test. Automated protocol tests also do not authorize K1 modeling
|
||||
publishing: private application-authority loading and durable save completion
|
||||
publishing: operator-owned Keychain execution lease and durable save completion
|
||||
remain separate physical/security gates.
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ The operator project name is normalized and validated by the K1 contribution,
|
|||
stored as display/catalog metadata and never used as a path component. This ADR
|
||||
does not claim that it reaches the scanner: the inert application-control codec
|
||||
has no publisher, and automatic K1 writes remain disabled pending reviewed
|
||||
private application-authority loading and durable post-stop save evidence.
|
||||
operator-owned Keychain execution-lease wiring and durable post-stop save evidence.
|
||||
|
||||
ADR 0011 subsequently places the action control plane behind a versioned
|
||||
descriptor/handshake/health transport seam. Observation discovery and export
|
||||
|
|
|
|||
|
|
@ -77,24 +77,30 @@ captured special relation
|
|||
|
||||
## Current implementation boundary
|
||||
|
||||
`application_bootstrap.py` provides the bounded application authority type,
|
||||
DeviceInfo response correlation/decoding and the exact non-executable ten-step
|
||||
shadow plan. Its retained clean-cycle regression is 10/10 payloads and topic
|
||||
order. `LiveModelingControlSafety` independently binds that DeviceInfo identity
|
||||
to live status and creates non-executable START/STOP plans.
|
||||
`application_bootstrap.py` provides DeviceInfo decoding, the exact ten-request
|
||||
shadow plan and a five-batch response-barrier orchestrator. Its private retained
|
||||
regression completes with 10/10 request payload/topic matches and correlated
|
||||
required responses. `ModelingStatusRequest` remains non-blocking exactly where
|
||||
the capture had no synchronous response. `application_authority.py` reads the
|
||||
exact 36-byte authority only from a fixed macOS Keychain item and offers no
|
||||
plaintext fallback. `LiveModelingControlSafety` independently binds DeviceInfo
|
||||
identity to live status and creates non-executable START/STOP plans.
|
||||
|
||||
Public state contains only booleans, counts and wire metadata. Private identity,
|
||||
authority and payload bytes stay out of repr/API output. Both shadow paths are
|
||||
blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT
|
||||
publisher or automatic retry path exists.
|
||||
authority and payload bytes stay out of repr/API output.
|
||||
`application_publish.py` defines reviewed one-shot envelopes plus a current
|
||||
write-disabled publisher type. That type has no enable switch and raises before
|
||||
its injected transport sink can be called. Both shadow paths remain blocked by
|
||||
`vendor-writes-disabled` and `publisher-not-installed`; no MQTT publisher or
|
||||
automatic retry path exists.
|
||||
|
||||
## Promotion gate
|
||||
|
||||
A future publisher requires separate review and an operator-present physical
|
||||
acceptance:
|
||||
|
||||
1. load the private application authority through a reviewed local secret
|
||||
mechanism;
|
||||
1. provision the reviewed private authority into the fixed local Keychain item
|
||||
and load it for one operator-owned execution lease;
|
||||
2. confirm battery/storage and select one K1 over BLE;
|
||||
3. run the exact response-gated bootstrap and attest activated FW 3.0.2;
|
||||
4. confirm READY identity against DeviceInfo and review shadow metadata;
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
|
|||
capture verifies the `ModelingRequest` topic, action values, field layout,
|
||||
literal `{device_id}:ModelingRequest` session relation, retained start settings
|
||||
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
||||
one private application-level value rather than a per-scanner profile. Its
|
||||
secure runtime loading remains unresolved, as do durable save completion,
|
||||
timeout and rollback behavior. Acquisition therefore
|
||||
one private application-level value rather than a per-scanner profile. A fixed
|
||||
read-only macOS Keychain loader and offline response orchestrator now exist;
|
||||
operator-owned provisioning/execution-lease wiring, durable save completion,
|
||||
timeout and rollback behavior remain unresolved. Acquisition therefore
|
||||
stays `operator-manual` through the verified physical double-click.
|
||||
The standalone encoder models the recovered wire schema, including enum values
|
||||
outside the retained request. It is not an authorization policy: any future
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@
|
|||
},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"secure private application-authority loading",
|
||||
"operator-owned Keychain authority provisioning and execution-lease wiring",
|
||||
"authorization policy for any setting outside the retained request",
|
||||
"timeout, rejection and rollback contract"
|
||||
],
|
||||
|
|
@ -406,7 +406,7 @@
|
|||
"request_fields": {},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"secure private application-authority loading",
|
||||
"operator-owned Keychain authority provisioning and execution-lease wiring",
|
||||
"save-completion and final-standby state mapping",
|
||||
"timeout and rollback contract"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
ApplicationControlAuthority,
|
||||
)
|
||||
|
||||
KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
|
||||
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
|
||||
KEYCHAIN_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class ApplicationAuthorityLoadError(RuntimeError):
|
||||
"""The private application authority could not be loaded safely."""
|
||||
|
||||
|
||||
class CommandRunner(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
check: bool,
|
||||
timeout: float,
|
||||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationAuthoritySourceSnapshot:
|
||||
provider: str = "macos-keychain"
|
||||
service: str = KEYCHAIN_SERVICE
|
||||
account: str = KEYCHAIN_ACCOUNT
|
||||
secret_cached: bool = False
|
||||
secret_exportable: bool = False
|
||||
file_fallback_enabled: bool = False
|
||||
environment_fallback_enabled: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"service": self.service,
|
||||
"account": self.account,
|
||||
"secret_cached": self.secret_cached,
|
||||
"secret_exportable": self.secret_exportable,
|
||||
"file_fallback_enabled": self.file_fallback_enabled,
|
||||
"environment_fallback_enabled": self.environment_fallback_enabled,
|
||||
}
|
||||
|
||||
|
||||
class MacOSKeychainApplicationAuthorityLoader:
|
||||
"""Read the exact-profile authority from the current user's Keychain.
|
||||
|
||||
There is deliberately no environment, plaintext-file, browser or API
|
||||
fallback. The secret is requested through the absolute ``security`` binary
|
||||
and is never interpolated into an exception, repr or subprocess argument.
|
||||
"""
|
||||
|
||||
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
||||
return ApplicationAuthoritySourceSnapshot()
|
||||
|
||||
def load(self) -> ApplicationControlAuthority:
|
||||
if platform.system() != "Darwin":
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"application authority loading is supported only through macOS Keychain"
|
||||
)
|
||||
security = shutil.which("security")
|
||||
if security != "/usr/bin/security":
|
||||
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
|
||||
|
||||
try:
|
||||
completed = self._runner(
|
||||
[
|
||||
security,
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
|
||||
secret_buffer = bytearray(completed.stdout)
|
||||
try:
|
||||
while secret_buffer.endswith((b"\n", b"\r")):
|
||||
secret_buffer.pop()
|
||||
try:
|
||||
secret = secret_buffer.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority has an invalid encoding"
|
||||
) from exc
|
||||
try:
|
||||
return ApplicationControlAuthority(openapi_key=secret)
|
||||
except ApplicationBootstrapError as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority does not match the reviewed profile"
|
||||
) from exc
|
||||
finally:
|
||||
for index in range(len(secret_buffer)):
|
||||
secret_buffer[index] = 0
|
||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
|
@ -62,6 +63,10 @@ class ApplicationControlAuthority:
|
|||
"application authority does not match the reviewed K1 profile"
|
||||
)
|
||||
_validate_ascii_identity(self.openapi_key, "openapi_key")
|
||||
if len(self.openapi_key) != 36:
|
||||
raise ApplicationBootstrapError(
|
||||
"openapi_key must match the exact 36-byte reviewed application profile"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -133,6 +138,9 @@ class EncodedApplicationRequest:
|
|||
payload_bytes: int
|
||||
mutates_device: bool
|
||||
requires_live_binding: bool
|
||||
response_required: bool
|
||||
session_id: str = field(repr=False)
|
||||
vendor_device_id: str | None = field(repr=False)
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
|
||||
|
|
@ -147,6 +155,7 @@ class EncodedApplicationRequest:
|
|||
"payload_bytes": self.payload_bytes,
|
||||
"mutates_device": self.mutates_device,
|
||||
"requires_live_binding": self.requires_live_binding,
|
||||
"response_required": self.response_required,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
}
|
||||
|
|
@ -181,6 +190,39 @@ class DeviceInfoResponse:
|
|||
result_code: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationResponse:
|
||||
session_id: str = field(repr=False)
|
||||
vendor_device_id: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
result_code: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationBootstrapSnapshot:
|
||||
current_batch: int
|
||||
total_batches: int
|
||||
batch_issued: bool
|
||||
pending_response_topics: tuple[str, ...]
|
||||
live_binding_observed: bool
|
||||
bootstrap_complete: bool
|
||||
executable: bool = False
|
||||
vendor_writes_enabled: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "shadow-only",
|
||||
"current_batch": self.current_batch,
|
||||
"total_batches": self.total_batches,
|
||||
"batch_issued": self.batch_issued,
|
||||
"pending_response_topics": list(self.pending_response_topics),
|
||||
"live_binding_observed": self.live_binding_observed,
|
||||
"bootstrap_complete": self.bootstrap_complete,
|
||||
"executable": self.executable,
|
||||
"vendor_writes_enabled": self.vendor_writes_enabled,
|
||||
}
|
||||
|
||||
|
||||
def build_shadow_bootstrap(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
|
|
@ -213,6 +255,7 @@ def build_shadow_bootstrap(
|
|||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
|
|
@ -221,6 +264,7 @@ def build_shadow_bootstrap(
|
|||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
|
|
@ -229,6 +273,7 @@ def build_shadow_bootstrap(
|
|||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -237,6 +282,7 @@ def build_shadow_bootstrap(
|
|||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
DEVICE_CONFIG_TIME_CONTEXT,
|
||||
True,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -245,6 +291,7 @@ def build_shadow_bootstrap(
|
|||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -253,6 +300,7 @@ def build_shadow_bootstrap(
|
|||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -261,6 +309,7 @@ def build_shadow_bootstrap(
|
|||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -269,6 +318,7 @@ def build_shadow_bootstrap(
|
|||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -277,6 +327,7 @@ def build_shadow_bootstrap(
|
|||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
|
|
@ -285,12 +336,19 @@ def build_shadow_bootstrap(
|
|||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
),
|
||||
)
|
||||
requests: list[EncodedApplicationRequest] = []
|
||||
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
|
||||
specs, start=1
|
||||
):
|
||||
for ordinal, (
|
||||
phase,
|
||||
message_type,
|
||||
topic,
|
||||
response_topic,
|
||||
context,
|
||||
mutates,
|
||||
response_required,
|
||||
) in enumerate(specs, start=1):
|
||||
request_binding = None if ordinal <= 3 else binding
|
||||
header = ApplicationRequestHeader(
|
||||
message_type=message_type,
|
||||
|
|
@ -316,6 +374,11 @@ def build_shadow_bootstrap(
|
|||
payload_bytes=len(payload),
|
||||
mutates_device=mutates,
|
||||
requires_live_binding=ordinal > 3,
|
||||
response_required=response_required,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=(
|
||||
request_binding.vendor_device_id if request_binding is not None else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return ShadowApplicationBootstrap(tuple(requests))
|
||||
|
|
@ -324,6 +387,9 @@ def build_shadow_bootstrap(
|
|||
def decode_and_bind_device_info_response(
|
||||
payload: bytes,
|
||||
authority: ApplicationControlAuthority,
|
||||
*,
|
||||
expected_session_id: str = ":DeviceInfoRequest",
|
||||
expected_vendor_device_id: str | None = None,
|
||||
) -> DeviceInfoResponse:
|
||||
"""Correlate the bootstrap response and derive a live per-device binding."""
|
||||
|
||||
|
|
@ -333,8 +399,12 @@ def decode_and_bind_device_info_response(
|
|||
device_id = _identity_field(header, 4, "header.device_id")
|
||||
session_id = _identity_field(header, 5, "header.session_id")
|
||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
||||
if session_id != ":DeviceInfoRequest":
|
||||
if not hmac.compare_digest(session_id, expected_session_id):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed")
|
||||
if expected_vendor_device_id is not None and not hmac.compare_digest(
|
||||
device_id, expected_vendor_device_id
|
||||
):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse vendor identity mismatch")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
|
||||
|
||||
|
|
@ -371,6 +441,221 @@ def decode_and_bind_device_info_response(
|
|||
return DeviceInfoResponse(binding, session_id, openapi_key, result_code)
|
||||
|
||||
|
||||
def correlate_application_response(
|
||||
payload: bytes,
|
||||
expected: EncodedApplicationRequest,
|
||||
authority: ApplicationControlAuthority,
|
||||
*,
|
||||
live_binding: LiveDeviceControlBinding | None = None,
|
||||
) -> ApplicationResponse:
|
||||
"""Correlate a non-DeviceInfo response to an exact emitted request."""
|
||||
|
||||
expected_device_id = expected.vendor_device_id
|
||||
if expected_device_id is None and live_binding is not None:
|
||||
expected_device_id = live_binding.vendor_device_id
|
||||
if expected_device_id is None:
|
||||
raise ApplicationBootstrapError(
|
||||
"generic response correlation requires live DeviceInfo identity"
|
||||
)
|
||||
_check_payload(payload, "application response")
|
||||
top = _selected_unique_fields(
|
||||
payload,
|
||||
"application response",
|
||||
max_fields=64,
|
||||
selected={1, 15},
|
||||
)
|
||||
header = _selected_unique_fields(
|
||||
_required_bytes(top, 1, "response.header"),
|
||||
"header",
|
||||
max_fields=32,
|
||||
selected={4, 5, 6},
|
||||
)
|
||||
device_id = _identity_field(header, 4, "header.device_id")
|
||||
session_id = _identity_field(header, 5, "header.session_id")
|
||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
||||
if not hmac.compare_digest(device_id, expected_device_id):
|
||||
raise ApplicationBootstrapError("application response vendor identity mismatch")
|
||||
if not hmac.compare_digest(session_id, expected.session_id):
|
||||
raise ApplicationBootstrapError("application response session correlation failed")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise ApplicationBootstrapError("application response authority mismatch")
|
||||
|
||||
error = _selected_unique_fields(
|
||||
_required_bytes(top, 15, "response.error"),
|
||||
"error",
|
||||
max_fields=8,
|
||||
selected={1, 2},
|
||||
)
|
||||
result_code = _required_uint(error, 1, "response.error.code")
|
||||
if result_code != OPENAPI_SUCCESS:
|
||||
raise ApplicationBootstrapError(
|
||||
f"application response rejected with result code {result_code}"
|
||||
)
|
||||
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
|
||||
|
||||
|
||||
class ShadowApplicationBootstrapOrchestrator:
|
||||
"""Advance the retained dialogue only across observed response barriers.
|
||||
|
||||
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
|
||||
second batch had no required synchronous response before preparation
|
||||
continued; readiness remains a separate live DeviceStatus gate.
|
||||
"""
|
||||
|
||||
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: ApplicationControlAuthority,
|
||||
*,
|
||||
epoch_seconds: int,
|
||||
timezone_name: str,
|
||||
) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._authority = authority
|
||||
self._epoch_seconds = epoch_seconds
|
||||
self._timezone_name = timezone_name
|
||||
self._binding: LiveDeviceControlBinding | None = None
|
||||
self._plan: ShadowApplicationBootstrap | None = None
|
||||
self._batch_index = 0
|
||||
self._batch_issued = False
|
||||
self._pending: dict[str, EncodedApplicationRequest] = {}
|
||||
self._complete = False
|
||||
self._initial_request = _build_initial_device_info_request(authority)
|
||||
|
||||
def snapshot(self) -> ApplicationBootstrapSnapshot:
|
||||
with self._lock:
|
||||
return ApplicationBootstrapSnapshot(
|
||||
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
|
||||
total_batches=len(self._BATCH_ORDINALS),
|
||||
batch_issued=self._batch_issued,
|
||||
pending_response_topics=tuple(sorted(self._pending)),
|
||||
live_binding_observed=self._binding is not None,
|
||||
bootstrap_complete=self._complete,
|
||||
)
|
||||
|
||||
@property
|
||||
def binding(self) -> LiveDeviceControlBinding | None:
|
||||
with self._lock:
|
||||
return self._binding
|
||||
|
||||
def next_batch(self) -> tuple[EncodedApplicationRequest, ...]:
|
||||
"""Return the next batch once; repeated access fails closed."""
|
||||
|
||||
with self._lock:
|
||||
if self._complete:
|
||||
raise ApplicationBootstrapError("application bootstrap is already complete")
|
||||
if self._batch_issued:
|
||||
raise ApplicationBootstrapError(
|
||||
"current application bootstrap batch was already issued"
|
||||
)
|
||||
requests: tuple[EncodedApplicationRequest, ...]
|
||||
if self._batch_index == 0:
|
||||
requests = (self._initial_request,)
|
||||
else:
|
||||
if self._plan is None:
|
||||
raise ApplicationBootstrapError("live DeviceInfo binding is missing")
|
||||
requests = tuple(
|
||||
self._plan.requests[ordinal - 1]
|
||||
for ordinal in self._BATCH_ORDINALS[self._batch_index]
|
||||
)
|
||||
pending = {
|
||||
request.response_topic: request for request in requests if request.response_required
|
||||
}
|
||||
if len(pending) != sum(request.response_required for request in requests):
|
||||
raise ApplicationBootstrapError(
|
||||
"bootstrap batch contains ambiguous required response topics"
|
||||
)
|
||||
if not pending:
|
||||
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
|
||||
self._pending = pending
|
||||
self._batch_issued = True
|
||||
return requests
|
||||
|
||||
def accept_response(self, topic: str, payload: bytes) -> None:
|
||||
"""Correlate one required response and unlock only the next batch."""
|
||||
|
||||
with self._lock:
|
||||
if not self._batch_issued:
|
||||
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
|
||||
expected = self._pending.get(topic)
|
||||
if expected is None:
|
||||
raise ApplicationBootstrapError(
|
||||
"application response is not required by the current batch"
|
||||
)
|
||||
|
||||
if expected.ordinal == 1:
|
||||
response = decode_and_bind_device_info_response(payload, self._authority)
|
||||
binding = response.binding
|
||||
plan = build_shadow_bootstrap(
|
||||
self._authority,
|
||||
binding,
|
||||
epoch_seconds=self._epoch_seconds,
|
||||
timezone_name=self._timezone_name,
|
||||
)
|
||||
if plan.requests[0].payload != self._initial_request.payload:
|
||||
raise ApplicationBootstrapError(
|
||||
"bound bootstrap plan changed the emitted identity request"
|
||||
)
|
||||
self._binding = binding
|
||||
self._plan = plan
|
||||
else:
|
||||
if self._binding is None:
|
||||
raise ApplicationBootstrapError("live DeviceInfo binding is missing")
|
||||
if expected.message_type == "DeviceInfoRequest":
|
||||
response = decode_and_bind_device_info_response(
|
||||
payload,
|
||||
self._authority,
|
||||
expected_session_id=expected.session_id,
|
||||
expected_vendor_device_id=self._binding.vendor_device_id,
|
||||
)
|
||||
if response.binding != self._binding:
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo facts changed during bootstrap"
|
||||
)
|
||||
else:
|
||||
correlate_application_response(
|
||||
payload,
|
||||
expected,
|
||||
self._authority,
|
||||
live_binding=self._binding,
|
||||
)
|
||||
|
||||
del self._pending[topic]
|
||||
if self._pending:
|
||||
return
|
||||
self._batch_issued = False
|
||||
self._batch_index += 1
|
||||
if self._batch_index == len(self._BATCH_ORDINALS):
|
||||
self._complete = True
|
||||
|
||||
|
||||
def _build_initial_device_info_request(
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> EncodedApplicationRequest:
|
||||
header = ApplicationRequestHeader(
|
||||
message_type="DeviceInfoRequest",
|
||||
authority=authority,
|
||||
)
|
||||
payload = _bytes_field(1, _encode_header(header))
|
||||
_check_payload(payload, "application request")
|
||||
return EncodedApplicationRequest(
|
||||
ordinal=1,
|
||||
phase="identity-discovery",
|
||||
message_type="DeviceInfoRequest",
|
||||
topic=DEVICE_INFO_REQUEST_TOPIC,
|
||||
response_topic=DEVICE_INFO_RESPONSE_TOPIC,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=False,
|
||||
requires_live_binding=False,
|
||||
response_required=True,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _encode_header(header: ApplicationRequestHeader) -> bytes:
|
||||
parts: list[bytes] = []
|
||||
if header.binding is not None:
|
||||
|
|
@ -438,6 +723,26 @@ def _unique_fields(
|
|||
return result
|
||||
|
||||
|
||||
def _selected_unique_fields(
|
||||
payload: bytes,
|
||||
name: str,
|
||||
*,
|
||||
max_fields: int,
|
||||
selected: set[int],
|
||||
) -> dict[int, ProtoField]:
|
||||
result: dict[int, ProtoField] = {}
|
||||
try:
|
||||
for item in iter_fields(payload, max_fields=max_fields):
|
||||
if item.number not in selected:
|
||||
continue
|
||||
if item.number in result:
|
||||
raise ApplicationBootstrapError(f"{name} field {item.number} is duplicated")
|
||||
result[item.number] = item
|
||||
except ProtobufWireError as exc:
|
||||
raise ApplicationBootstrapError(f"invalid {name}: {exc}") from exc
|
||||
return result
|
||||
|
||||
|
||||
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
EncodedApplicationRequest,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
ShadowModelingCommand,
|
||||
)
|
||||
|
||||
|
||||
class VendorWritesDisabledError(RuntimeError):
|
||||
"""The active compatibility profile cannot touch a publish transport."""
|
||||
|
||||
|
||||
class ApplicationPublishSink(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OneShotPublishEnvelope:
|
||||
topic: str
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
qos: int
|
||||
retain: bool
|
||||
automatic_retry: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_bootstrap_request(
|
||||
cls,
|
||||
request: EncodedApplicationRequest,
|
||||
) -> OneShotPublishEnvelope:
|
||||
return cls(
|
||||
topic=request.topic,
|
||||
payload=request.payload,
|
||||
payload_sha256=request.payload_sha256,
|
||||
payload_bytes=request.payload_bytes,
|
||||
qos=request.qos,
|
||||
retain=request.retain,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_modeling_command(
|
||||
cls,
|
||||
command: ShadowModelingCommand,
|
||||
) -> OneShotPublishEnvelope:
|
||||
return cls(
|
||||
topic=command.topic,
|
||||
payload=command.command.payload,
|
||||
payload_sha256=command.payload_sha256,
|
||||
payload_bytes=command.payload_bytes,
|
||||
qos=command.qos,
|
||||
retain=command.retain,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.qos != 2 or self.retain or self.automatic_retry:
|
||||
raise ValueError("publish envelope differs from the reviewed one-shot contract")
|
||||
if self.payload_bytes != len(self.payload):
|
||||
raise ValueError("publish envelope payload length mismatch")
|
||||
if not hashlib.sha256(self.payload).hexdigest() == self.payload_sha256:
|
||||
raise ValueError("publish envelope payload digest mismatch")
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"topic": self.topic,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
"automatic_retry": self.automatic_retry,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WriteDisabledPublisherSnapshot:
|
||||
denied_attempts: int
|
||||
transport_calls: int = 0
|
||||
vendor_writes_enabled: bool = False
|
||||
publisher_armed: bool = False
|
||||
automatic_retry: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "write-disabled",
|
||||
"denied_attempts": self.denied_attempts,
|
||||
"transport_calls": self.transport_calls,
|
||||
"vendor_writes_enabled": self.vendor_writes_enabled,
|
||||
"publisher_armed": self.publisher_armed,
|
||||
"automatic_retry": self.automatic_retry,
|
||||
}
|
||||
|
||||
|
||||
class WriteDisabledOneShotPublisher:
|
||||
"""Current-profile publish boundary that cannot call its injected sink.
|
||||
|
||||
A future write-capable implementation must be a separate reviewed type. It
|
||||
cannot be enabled by flipping a runtime boolean on this class.
|
||||
"""
|
||||
|
||||
def __init__(self, sink: ApplicationPublishSink) -> None:
|
||||
self._sink = sink
|
||||
self._lock = threading.Lock()
|
||||
self._denied_attempts = 0
|
||||
|
||||
def publish_once(self, _envelope: OneShotPublishEnvelope) -> None:
|
||||
with self._lock:
|
||||
self._denied_attempts += 1
|
||||
raise VendorWritesDisabledError(
|
||||
"active K1 compatibility profile cannot publish vendor requests"
|
||||
)
|
||||
|
||||
def snapshot(self) -> WriteDisabledPublisherSnapshot:
|
||||
with self._lock:
|
||||
return WriteDisabledPublisherSnapshot(denied_attempts=self._denied_attempts)
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
KEYCHAIN_ACCOUNT,
|
||||
KEYCHAIN_SERVICE,
|
||||
ApplicationAuthorityLoadError,
|
||||
MacOSKeychainApplicationAuthorityLoader,
|
||||
)
|
||||
|
||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
||||
_which: object,
|
||||
_system: object,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def runner(
|
||||
args: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
check: bool,
|
||||
timeout: float,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
calls.append(args)
|
||||
assert capture_output
|
||||
assert check is False
|
||||
assert timeout == 5.0
|
||||
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
|
||||
|
||||
loader = MacOSKeychainApplicationAuthorityLoader(runner=runner)
|
||||
authority = loader.load()
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
]
|
||||
]
|
||||
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
|
||||
assert loader.snapshot().as_dict() == {
|
||||
"provider": "macos-keychain",
|
||||
"service": KEYCHAIN_SERVICE,
|
||||
"account": KEYCHAIN_ACCOUNT,
|
||||
"secret_cached": False,
|
||||
"secret_exportable": False,
|
||||
"file_fallback_enabled": False,
|
||||
"environment_fallback_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Linux")
|
||||
def test_authority_loader_has_no_non_keychain_fallback(_system: object) -> None:
|
||||
loader = MacOSKeychainApplicationAuthorityLoader()
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError, match="only through macOS Keychain"):
|
||||
loader.load()
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loader_redacts_keychain_failures(
|
||||
_which: object,
|
||||
_system: object,
|
||||
) -> None:
|
||||
private_error = b"private-keychain-diagnostic"
|
||||
|
||||
def runner(
|
||||
args: list[str],
|
||||
**_kwargs: object,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.CompletedProcess(args, 44, stdout=b"", stderr=private_error)
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||
|
||||
assert private_error.decode() not in str(error.value)
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||
_which: object,
|
||||
_system: object,
|
||||
) -> None:
|
||||
invalid_secret = b"too-short-private-value"
|
||||
|
||||
def runner(
|
||||
args: list[str],
|
||||
**_kwargs: object,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.CompletedProcess(args, 0, stdout=invalid_secret, stderr=b"")
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||
|
||||
assert invalid_secret.decode() not in str(error.value)
|
||||
|
|
@ -6,6 +6,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
|||
ApplicationBootstrapError,
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_shadow_bootstrap,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
|
|
@ -75,6 +76,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
|||
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
|
||||
|
||||
|
||||
def _generic_response(session_id: str) -> bytes:
|
||||
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
|
||||
repeated_vendor_body = _text(3, "opaque-a") + _text(3, "opaque-b")
|
||||
return _bytes(1, header) + repeated_vendor_body + _bytes(15, error)
|
||||
|
||||
|
||||
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
|
||||
plan = build_shadow_bootstrap(
|
||||
_authority(),
|
||||
|
|
@ -165,3 +173,89 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
|||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
|
||||
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
_authority(),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
identity = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in identity] == [1]
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
|
||||
|
||||
initial_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in initial_reads] == [2, 3]
|
||||
assert [request.response_required for request in initial_reads] == [False, True]
|
||||
orchestrator.accept_response(
|
||||
initial_reads[1].response_topic,
|
||||
_generic_response(initial_reads[1].session_id),
|
||||
)
|
||||
|
||||
preparation = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
||||
device_info = preparation[1]
|
||||
orchestrator.accept_response(
|
||||
device_info.response_topic,
|
||||
_device_info_response(session_id=device_info.session_id),
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
for request in (preparation[2], preparation[0]):
|
||||
orchestrator.accept_response(
|
||||
request.response_topic,
|
||||
_generic_response(request.session_id),
|
||||
)
|
||||
|
||||
ntrip = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in ntrip] == [7]
|
||||
orchestrator.accept_response(
|
||||
ntrip[0].response_topic,
|
||||
_generic_response(ntrip[0].session_id),
|
||||
)
|
||||
|
||||
final_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in final_reads] == [8, 9, 10]
|
||||
for request in final_reads:
|
||||
payload = (
|
||||
_device_info_response(session_id=request.session_id)
|
||||
if request.message_type == "DeviceInfoRequest"
|
||||
else _generic_response(request.session_id)
|
||||
)
|
||||
orchestrator.accept_response(request.response_topic, payload)
|
||||
|
||||
snapshot = orchestrator.snapshot()
|
||||
assert snapshot.bootstrap_complete
|
||||
assert snapshot.live_binding_observed
|
||||
assert snapshot.pending_response_topics == ()
|
||||
assert snapshot.executable is False
|
||||
assert snapshot.vendor_writes_enabled is False
|
||||
with pytest.raises(ApplicationBootstrapError, match="already complete"):
|
||||
orchestrator.next_batch()
|
||||
|
||||
|
||||
def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
_authority(),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
orchestrator.next_batch()
|
||||
|
||||
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
||||
orchestrator.accept_response(
|
||||
"lixel/application/response/get_rtk_advance",
|
||||
_generic_response(":GetRtkAdvanceRequest"),
|
||||
)
|
||||
|
||||
assert orchestrator.snapshot().current_batch == 1
|
||||
assert orchestrator.snapshot().batch_issued
|
||||
|
||||
|
||||
def test_application_authority_requires_exact_captured_profile_length() -> None:
|
||||
with pytest.raises(ApplicationBootstrapError, match="36-byte"):
|
||||
ApplicationControlAuthority(openapi_key="too-short")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
build_shadow_bootstrap,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
VendorWritesDisabledError,
|
||||
WriteDisabledOneShotPublisher,
|
||||
)
|
||||
|
||||
PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
class SpySink:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> None:
|
||||
del topic, payload, qos, retain
|
||||
self.calls += 1
|
||||
|
||||
|
||||
def _envelope() -> OneShotPublishEnvelope:
|
||||
authority = ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY)
|
||||
binding = LiveDeviceControlBinding(
|
||||
vendor_device_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
device_serial="K1SERIAL01",
|
||||
software_version="V3.0.2-build.1",
|
||||
system_version="V3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="K1",
|
||||
is_activated=True,
|
||||
)
|
||||
request = build_shadow_bootstrap(
|
||||
authority,
|
||||
binding,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
).requests[0]
|
||||
return OneShotPublishEnvelope.from_bootstrap_request(request)
|
||||
|
||||
|
||||
def test_write_disabled_publisher_never_touches_injected_transport() -> None:
|
||||
sink = SpySink()
|
||||
publisher = WriteDisabledOneShotPublisher(sink)
|
||||
envelope = _envelope()
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(VendorWritesDisabledError, match="cannot publish"):
|
||||
publisher.publish_once(envelope)
|
||||
|
||||
assert sink.calls == 0
|
||||
assert publisher.snapshot().as_dict() == {
|
||||
"mode": "write-disabled",
|
||||
"denied_attempts": 2,
|
||||
"transport_calls": 0,
|
||||
"vendor_writes_enabled": False,
|
||||
"publisher_armed": False,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
|
||||
envelope = _envelope()
|
||||
|
||||
assert PRIVATE_AUTHORITY not in repr(envelope)
|
||||
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
|
||||
with pytest.raises(ValueError, match="one-shot contract"):
|
||||
OneShotPublishEnvelope(
|
||||
topic=envelope.topic,
|
||||
payload=envelope.payload,
|
||||
payload_sha256=envelope.payload_sha256,
|
||||
payload_bytes=envelope.payload_bytes,
|
||||
qos=1,
|
||||
retain=False,
|
||||
)
|
||||
|
|
@ -65,7 +65,7 @@ def _status_message(
|
|||
|
||||
|
||||
def _authority() -> ApplicationControlAuthority:
|
||||
return ApplicationControlAuthority(openapi_key="application-private-key")
|
||||
return ApplicationControlAuthority(openapi_key="11111111-2222-3333-4444-555555555555")
|
||||
|
||||
|
||||
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||
|
|
@ -120,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
|
|||
assert plan.executable is False
|
||||
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
||||
assert plan.retry_policy == "never-automatic"
|
||||
assert "application-private-key" not in repr(plan)
|
||||
assert "application-private-key" not in str(plan.as_dict())
|
||||
assert "11111111-2222-3333-4444-555555555555" not in repr(plan)
|
||||
assert "11111111-2222-3333-4444-555555555555" not in str(plan.as_dict())
|
||||
|
||||
|
||||
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
|
||||
|
|
|
|||
Loading…
Reference in New Issue