blob: 04f0c5c5aadc5e1b39715a77168ef3697584eb9c [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Public phase 1 host API: plan-driven runs composing consent, the
transport, and evidence.
The workflow is prepare (validate the plan, resolve consent -- no target
access), execute (open a session with the exact plan-derived allowlist,
run bounded operations, drain audit), and finalize (hash evidence,
manifest last). Missing consent fails closed, and no denial or failure
skips evidence finalization.
"""
from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Mapping, Sequence
from pathlib import Path
from typing import Any, Literal
from driver_lab.consent import (
READ_WARNING,
WRITE_WARNING,
ConsentDecision,
ConsentPrompt,
grant_from_decision,
)
from driver_lab.discovery import (
DEFAULT_PROXY_DRIVER_URL,
DiscoveryError,
NodeDescription,
NodeDiscovery,
NodeSummary,
ProxyActivator,
)
from driver_lab.evidence import EvidenceRecorder
from driver_lab.models import AccessClass, AccessRequest, ResourceKind
from driver_lab.permissions import (
GrantStoreError,
Outcome,
Resolution,
add_grant,
load_grants,
resolve,
)
from driver_lab.plans import is_mutating_plan, plan_digest, validate_plan
from driver_lab.recovery import (
RecoveryController,
SerialCapture,
SerialCaptureResult,
SerialCaptureSession,
detect_panic,
extract_panic_summary,
)
from driver_lab.session import (
AccessRequirements,
HardwareSession,
SessionCapabilities,
)
from driver_lab.transport import (
STALE_REJECTIONS,
AllowRule,
DirectTransport,
Expectations,
OpenSessionRejected,
OperationDenied,
ProxyDescription,
ProxyTransport,
ResourceInfo,
SequenceItem,
SessionContext,
SessionMode,
SnapshotItem,
TransportError,
)
EXIT_SUCCESS = 0
EXIT_PERMISSION = 2
EXIT_STALE = 3
EXIT_OPERATION = 4
EXIT_TRANSPORT = 5
EXIT_EVIDENCE = 7
EXIT_ACTIVATION = 8
EXIT_UNSUPPORTED = 10
# What a phase 1 proxy-mode run guarantees. Requirements a plan declares
# are checked against this before any target connection, so an
# unsupported guarantee never silently downgrades.
PROXY_CAPABILITIES = {
"mode": "proxy",
"target_policy": True,
"target_audit": True,
"target_local_timing": False,
}
# Direct mode connects to a published protocol without proxy mediation.
# It makes no claim to private MMIO, target policy, or target audit.
DIRECT_CAPABILITIES = {
"mode": "direct",
"target_policy": False,
"target_audit": False,
"target_local_timing": False,
}
# Phase 2 in-situ mode connects directly to a live driver's embedded
# fuchsia.driver.lab.Service endpoint without unbinding the driver.
IN_SITU_CAPABILITIES = {
"mode": "in-situ",
"target_policy": True,
"target_audit": True,
"target_local_timing": True,
}
_DEFERRED_ARTIFACTS = (
"target.description.json",
"permission-resolution.json",
"operations.jsonl",
"target-audit.jsonl",
)
# Spec-defined artifacts a phase 1 read-only run can never produce.
# Marked not applicable rather than silently omitted: takeover
# restoration and proxy activation are phase 2; mutation approval,
# node discovery snapshots, serial capture, and the interpretation
# layer have not landed yet.
_NOT_APPLICABLE_ARTIFACTS = (
"approval.json",
"node.before.json",
"node.after.json",
"proxy-access.jsonl",
"restoration.json",
"serial.log",
"host-events.jsonl",
"interpretation.json",
)
class DriverLabError(Exception):
"""A host-side configuration or usage error."""
@dataclasses.dataclass(frozen=True)
class ReadRecord:
"""One completed 32-bit read from a plan run."""
operation_index: int
resource: str
offset: int
value: int
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class WriteRecord:
"""One completed 32-bit write from a plan run."""
operation_index: int
resource: str
offset: int
value: int
write_mask: int
readback_value: int | None
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class PollRecord:
"""One completed 32-bit poll from a plan run."""
operation_index: int
resource: str
offset: int
expected: int
mask: int
value: int
audit_seq: int
timestamp_ns: int
@dataclasses.dataclass(frozen=True)
class SequenceItemRecord:
"""Outcome of one item executed in a sequence."""
index: int
ok: bool
kind: str
value: int | None = None
readback_value: int | None = None
audit_seq: int = 0
timestamp_ns: int = 0
error: str | None = None
@dataclasses.dataclass(frozen=True)
class SequenceRecord:
"""One executed sequence from a plan run."""
operation_index: int
results: tuple[SequenceItemRecord, ...]
complete: bool
@dataclasses.dataclass(frozen=True)
class RunResult:
"""The outcome of one plan run. Evidence is always finalized."""
exit_category: int
evidence_dir: Path
plan_digest: str
reads: tuple[ReadRecord, ...] = ()
writes: tuple[WriteRecord, ...] = ()
polls: tuple[PollRecord, ...] = ()
sequences: tuple[SequenceRecord, ...] = ()
calls: tuple[Mapping[str, Any], ...] = ()
failure: str | None = None
@property
def ok(self) -> bool:
"""Whether the run succeeded completely."""
return self.exit_category == EXIT_SUCCESS
def interpret(self) -> dict[str, Any]:
"""Generates evidence-linked interpretation report per Spec Section 17."""
from driver_lab.recovery import interpret_evidence
return interpret_evidence(self.evidence_dir)
@dataclasses.dataclass(frozen=True)
class _ReadOp:
index: int
resource: ResourceInfo
offset: int
kind: str = "mmio_read32"
@dataclasses.dataclass(frozen=True)
class _SnapshotOp:
index: int
items: tuple[tuple[ResourceInfo, int], ...]
@dataclasses.dataclass(frozen=True)
class _WriteOp:
index: int
resource: ResourceInfo
offset: int
value: int
write_mask: int
precondition: tuple[int, int] | None
readback: bool
kind: str = "mmio_write32"
@dataclasses.dataclass(frozen=True)
class _PollOp:
index: int
resource: ResourceInfo
offset: int
expected: int
mask: int
interval_ns: int
timeout_ns: int
kind: str = "mmio_poll32"
@dataclasses.dataclass(frozen=True)
class _SequenceOp:
index: int
items: tuple[SequenceItem, ...]
has_mutation: bool
class _StaleResource(Exception):
pass
class DriverLab:
"""Facade over one target for plan-driven runs."""
def __init__(
self,
transport: ProxyTransport | DirectTransport,
*,
grants_path: Path,
evidence_root: Path,
target_scope: str,
node_id: str,
consent: ConsentPrompt | None = None,
discovery: NodeDiscovery | None = None,
activator: ProxyActivator | None = None,
serial_capture: SerialCapture | None = None,
recovery: RecoveryController | None = None,
driver_moniker: str | None = None,
driver_url: str | None = None,
) -> None:
self._transport = transport
self._grants_path = grants_path
self._evidence_root = evidence_root
self._target_scope = target_scope
self._node_id = node_id
self._consent = consent
self._discovery = discovery
self._activator = activator
self._serial_capture = serial_capture
self._recovery = recovery
self._driver_moniker = driver_moniker
self._driver_url = driver_url
@property
def serial_capture(self) -> SerialCapture | None:
return self._serial_capture
@property
def recovery(self) -> RecoveryController | None:
return self._recovery
@property
def driver_moniker(self) -> str | None:
return self._driver_moniker
@property
def driver_url(self) -> str | None:
return self._driver_url
async def recover_target(self, mode: str = "normal") -> None:
"""Triggers out-of-band recovery.
Per Spec Section 14.3: Recovery creates a new run context; it does
not resume old mutations or retry failed operations.
"""
if self._recovery is None:
raise DriverLabError(
"no recovery controller configured for this DriverLab instance"
)
await self._recovery.reboot(mode=mode)
async def list_nodes(
self,
node_filter: list[str] | None = None,
exact_match: bool = False,
) -> list[NodeSummary]:
"""Discovers nodes on the target control plane."""
if self._discovery is None:
raise DriverLabError(
"node discovery is not configured for this DriverLab instance"
)
return await self._discovery.list_nodes(node_filter, exact_match)
async def find_debug_capable_drivers(self) -> list[NodeSummary]:
"""Discovers active bound drivers exposing fuchsia.driver.lab.Service (Phase 2)."""
if self._discovery is None:
raise DriverLabError(
"node discovery is not configured for this DriverLab instance"
)
return await self._discovery.find_debug_capable()
async def describe_node(self, node_id: str) -> NodeDescription | None:
"""Describes a node by moniker."""
if self._discovery is None:
raise DriverLabError(
"node discovery is not configured for this DriverLab instance"
)
return await self._discovery.describe_node(node_id)
async def describe_driver(self) -> ProxyDescription:
"""Fetches ProxyDescription directly from the live driver's embedded endpoint."""
if not isinstance(self._transport, ProxyTransport):
raise DriverLabError(
"Proxy/in-situ transport is not configured for this DriverLab instance"
)
return await self._transport.describe()
async def bind_proxy(
self,
node_id: str,
driver_url: str = DEFAULT_PROXY_DRIVER_URL,
) -> None:
"""Activates the proxy driver on an unclaimed node."""
if self._activator is None:
raise DriverLabError(
"proxy activator is not configured for this DriverLab instance"
)
await self._activator.bind_proxy(node_id, driver_url)
async def end_proxy(
self,
node_id: str,
driver_url: str = DEFAULT_PROXY_DRIVER_URL,
) -> None:
"""Ends proxy access and unbinds/disables the proxy driver."""
if self._activator is None:
raise DriverLabError(
"proxy activator is not configured for this DriverLab instance"
)
await self._activator.end_proxy(node_id, driver_url)
@classmethod
async def connect(
cls,
target: str = "default",
*,
transport: str = "auto",
timeout_s: float = 10.0,
grants_path: Path | None = None,
evidence_root: Path | None = None,
target_scope: str | None = None,
node_id: str | None = None,
driver_moniker: str | None = None,
driver_url: str | None = None,
consent: ConsentPrompt | None = None,
discovery: NodeDiscovery | None = None,
activator: ProxyActivator | None = None,
serial_capture: SerialCapture | None = None,
recovery: RecoveryController | None = None,
proxy_transport: ProxyTransport | None = None,
direct_transport: DirectTransport | None = None,
) -> "DriverLab":
"""Connects to a target device or directly to a live driver moniker (Phase 2)."""
endpoint_moniker = driver_moniker or node_id or target
active_transport: ProxyTransport | DirectTransport
if proxy_transport is not None:
active_transport = proxy_transport
elif direct_transport is not None:
active_transport = direct_transport
elif transport in ("proxy", "in-situ", "auto"):
from driver_lab.fidl_transport import connect_transport
active_transport = connect_transport(
endpoint_moniker, target=target
)
else:
raise DriverLabError(
"Direct transport must be explicitly provided via direct_transport"
)
if discovery is None:
try:
from driver_lab.discovery import connect_discovery
discovery = connect_discovery(
target=None if target == "default" else target
)
except Exception:
discovery = None
if activator is None:
try:
from driver_lab.discovery import connect_activator
activator = connect_activator(
target=None if target == "default" else target
)
except Exception:
activator = None
return cls(
transport=active_transport,
grants_path=grants_path or Path("grants.toml"),
evidence_root=evidence_root or Path("evidence"),
target_scope=target_scope or target,
node_id=node_id or driver_moniker or target,
consent=consent,
discovery=discovery,
activator=activator,
serial_capture=serial_capture,
recovery=recovery,
driver_moniker=driver_moniker,
driver_url=driver_url,
)
async def attach(
self,
node_id: str,
*,
mode: Literal["auto", "direct", "proxy", "in-situ"] = "auto",
requirements: AccessRequirements | None = None,
session_mode: SessionMode = SessionMode.READ_ONLY,
allowlist: Sequence[AllowRule] | None = None,
expectations: Expectations | Mapping[str, Any] | None = None,
context: SessionContext | None = None,
consent: ConsentPrompt | None = None,
) -> HardwareSession:
"""Attaches to a node or active driver moniker, returning a HardwareSession."""
if mode not in ("auto", "direct", "proxy", "in-situ"):
raise ValueError(
f"Invalid mode '{mode}': must be 'auto', 'direct', 'proxy', or 'in-situ'"
)
is_in_situ = mode == "in-situ" or self._driver_moniker is not None
if not is_in_situ and self._discovery is not None:
try:
node_desc = await self._discovery.describe_node(node_id)
if (
node_desc is not None
and not node_desc.is_unclaimed
and node_desc.debug_capable
):
is_in_situ = True
except Exception:
pass
if mode == "auto":
if is_in_situ:
selected_mode = "in-situ"
elif requirements and (
requirements.needs_mmio
or requirements.needs_sequence
or requirements.needs_target_timing
or requirements.needs_target_policy
or requirements.needs_target_audit
or requirements.is_mutating
):
selected_mode = "proxy"
elif isinstance(self._transport, DirectTransport) or (
requirements and requirements.protocol
):
selected_mode = "direct"
else:
selected_mode = "proxy"
elif mode == "direct":
if requirements and (
requirements.needs_mmio
or requirements.needs_sequence
or requirements.needs_target_timing
or requirements.needs_target_policy
or requirements.needs_target_audit
or requirements.is_mutating
):
raise DriverLabError(
"Direct mode cannot satisfy requested requirements (requires proxy or in-situ mode)"
)
selected_mode = "direct"
elif mode == "in-situ":
selected_mode = "in-situ"
else:
selected_mode = "proxy"
if selected_mode == "direct":
if not isinstance(self._transport, DirectTransport):
raise DriverLabError(
"Direct transport is not configured for this DriverLab instance"
)
direct_desc = await self._transport.describe()
direct_ctx = context or SessionContext(
run_id=f"direct-{node_id}",
case_id="interactive",
plan_digest="direct-session",
host_tool_version="0.1.0",
)
direct_session = await self._transport.open_direct_session(
direct_ctx
)
caps = SessionCapabilities(
mode="direct",
target_policy=False,
target_audit=False,
target_local_timing=False,
fault_isolation="driver_host",
production_driver_active=bool(direct_desc.bound_driver_url),
restoration_required=False,
resources=(),
protocols=(direct_desc.protocol_name,),
)
return HardwareSession(
capabilities=caps,
direct_session=direct_session,
direct_description=direct_desc,
)
# Proxy or In-Situ mode
if not isinstance(self._transport, ProxyTransport):
raise DriverLabError(
"Proxy transport is not configured for this DriverLab instance"
)
proxy_desc = await self._transport.describe()
if session_mode == SessionMode.MUTATING:
active_consent = consent or self._consent
if active_consent is None:
raise DriverLabError(
"Mutating session requires operator consent, but no consent prompt is configured"
)
req = AccessRequest(
target_scope=self._target_scope,
node_id=node_id,
resource_digest=proxy_desc.resource_digest,
resource="*",
offset=0,
width=4,
access=AccessClass.WRITE,
)
decision = await active_consent.request_consent(req, WRITE_WARNING)
if decision != ConsentDecision.ALLOW_ONCE:
raise DriverLabError(
f"Mutating session was not authorized by operator (decision: {decision})"
)
rules: list[AllowRule] = []
if allowlist is not None:
rules = list(allowlist)
else:
for res in proxy_desc.resources:
if res.kind == ResourceKind.INTERRUPT:
rules.append(
AllowRule(
resource=res.id,
offset=0,
width=0,
access=AccessClass.INTERRUPT,
)
)
continue
if res.kind != ResourceKind.MMIO:
width = 1 if res.kind == ResourceKind.GPIO else 32
rules.append(
AllowRule(
resource=res.id,
offset=0,
width=width,
access=AccessClass.PROTOCOL,
)
)
rules.append(
AllowRule(
resource=res.id,
offset=0,
width=width,
access=AccessClass.SEQUENCE,
)
)
continue
mmio_resources = [
res
for res in proxy_desc.resources
if res.kind == ResourceKind.MMIO
]
max_mmio_rules = 250 - len(rules)
if mmio_resources and max_mmio_rules > 0:
rules_per_res = max(10, max_mmio_rules // len(mmio_resources))
for res in mmio_resources:
res_rules = 0
for off in range(0, min(res.logical_size, 256), 4):
rules.append(
AllowRule(
resource=res.id,
offset=off,
width=4,
access=AccessClass.READ_ONCE,
)
)
rules.append(
AllowRule(
resource=res.id,
offset=off,
width=4,
access=AccessClass.SNAPSHOT,
)
)
rules.append(
AllowRule(
resource=res.id,
offset=off,
width=4,
access=AccessClass.POLL,
)
)
rules.append(
AllowRule(
resource=res.id,
offset=off,
width=4,
access=AccessClass.SEQUENCE,
)
)
res_rules += 4
if session_mode == SessionMode.MUTATING:
rules.append(
AllowRule(
resource=res.id,
offset=off,
width=4,
access=AccessClass.WRITE,
)
)
res_rules += 1
if res_rules >= rules_per_res or len(rules) >= 250:
break
if len(rules) >= 250:
break
proxy_ctx = context or SessionContext(
run_id=f"{selected_mode}-{node_id}",
case_id="interactive",
plan_digest=f"{selected_mode}-session",
host_tool_version="0.1.0",
)
exp: Expectations
if expectations is not None:
if isinstance(expectations, Expectations):
exp = expectations
else:
exp = Expectations(
boot_id=str(expectations["boot_id"]),
proxy_generation=int(expectations["proxy_generation"]),
resource_digest=str(expectations["resource_digest"]),
policy_digest=str(expectations["policy_digest"]),
)
else:
exp = Expectations(
boot_id=proxy_desc.boot_id,
proxy_generation=proxy_desc.proxy_generation,
resource_digest=proxy_desc.resource_digest,
policy_digest=proxy_desc.policy_digest,
)
try:
proxy_session = await self._transport.open_session(
context=proxy_ctx,
mode=session_mode,
expectations=exp,
allowlist=rules,
)
except OpenSessionRejected as exc:
if allowlist is not None:
raise
# When no explicit allowlist was provided and the embedded driver's
# target ceiling hard-denied or write-restricted some default probe
# offsets, probe individual rules to retain only ceiling-permitted rules.
permitted_rules: list[AllowRule] = []
for candidate in rules:
try:
probe_sess = await self._transport.open_session(
context=proxy_ctx,
mode=SessionMode.READ_ONLY
if candidate.access != AccessClass.WRITE
else session_mode,
expectations=exp,
allowlist=[candidate],
)
await probe_sess.close()
permitted_rules.append(candidate)
except OpenSessionRejected:
continue
proxy_session = await self._transport.open_session(
context=proxy_ctx,
mode=session_mode,
expectations=exp,
allowlist=permitted_rules,
)
caps = SessionCapabilities(
mode=selected_mode,
target_policy=True,
target_audit=True,
target_local_timing=True,
fault_isolation="driver_host",
production_driver_active=(selected_mode == "in-situ" or is_in_situ),
restoration_required=False,
resources=tuple(r.name for r in proxy_desc.resources),
protocols=(),
)
cursor = 0
async def _drain_audit() -> None:
nonlocal cursor
page = await proxy_session.read_audit(cursor=cursor, limit=64)
cursor = page.next_cursor
return HardwareSession(
capabilities=caps,
proxy_session=proxy_session,
proxy_description=proxy_desc,
audit_drainer=_drain_audit,
)
def _derive(
self, canonical: Mapping[str, Any], description: ProxyDescription
) -> tuple[
list[_ReadOp | _SnapshotOp | _WriteOp | _PollOp | _SequenceOp],
list[tuple[int, AccessRequest]],
]:
operations: list[
_ReadOp | _SnapshotOp | _WriteOp | _PollOp | _SequenceOp
] = []
requests: list[tuple[int, AccessRequest]] = []
def lookup(name: str) -> ResourceInfo:
resource = description.resource_named(name)
if resource is None:
raise _StaleResource(f"resource not offered by target: {name}")
return resource
def request(
resource: ResourceInfo, offset: int, access: AccessClass
) -> AccessRequest:
return AccessRequest(
target_scope=self._target_scope,
node_id=self._node_id,
resource_digest=resource.digest,
resource=resource.name,
offset=offset,
width=4,
access=access,
)
for index, operation in enumerate(canonical["operations"]):
kind = operation["kind"]
if kind in ("mmio_read32", "state_read32"):
resource = lookup(operation["resource"])
offset = operation["offset"]
operations.append(
_ReadOp(
index=index,
resource=resource,
offset=offset,
kind=kind,
)
)
requests.append(
(index, request(resource, offset, AccessClass.READ_ONCE))
)
elif kind == "mmio_snapshot32":
items = tuple(
(lookup(item["resource"]), item["offset"])
for item in operation["items"]
)
operations.append(_SnapshotOp(index=index, items=items))
requests.extend(
(index, request(resource, offset, AccessClass.SNAPSHOT))
for resource, offset in items
)
elif kind in ("mmio_write32", "knob_write32", "trigger_write32"):
resource = lookup(operation["resource"])
offset = operation["offset"]
precondition = None
if (
"precondition" in operation
and operation["precondition"] is not None
):
precondition = (
operation["precondition"]["expected"],
operation["precondition"]["mask"],
)
operations.append(
_WriteOp(
index=index,
resource=resource,
offset=offset,
value=operation["value"],
write_mask=operation.get("write_mask", 0xFFFF_FFFF),
precondition=precondition,
readback=operation.get("readback", True),
kind=kind,
)
)
requests.append(
(index, request(resource, offset, AccessClass.WRITE))
)
elif kind in ("mmio_poll32", "state_poll32"):
resource = lookup(operation["resource"])
offset = operation["offset"]
operations.append(
_PollOp(
index=index,
resource=resource,
offset=offset,
expected=operation["expected"],
mask=operation.get("mask", 0xFFFF_FFFF),
interval_ns=operation["interval_ns"],
timeout_ns=operation["timeout_ns"],
kind=kind,
)
)
requests.append(
(index, request(resource, offset, AccessClass.POLL))
)
elif kind == "sequence":
seq_items: list[SequenceItem] = []
has_mutation = False
for item in operation["items"]:
ikind = item["kind"]
if ikind in ("mmio_read32", "state_read32"):
res = lookup(item["resource"])
off = item["offset"]
seq_items.append(
SequenceItem(
kind="read32",
resource=res.id,
offset=off,
)
)
requests.append(
(index, request(res, off, AccessClass.SEQUENCE))
)
elif ikind in (
"mmio_write32",
"knob_write32",
"trigger_write32",
):
res = lookup(item["resource"])
off = item["offset"]
has_mutation = True
p = None
if (
"precondition" in item
and item["precondition"] is not None
):
p = (
item["precondition"]["expected"],
item["precondition"]["mask"],
)
seq_items.append(
SequenceItem(
kind="write32",
resource=res.id,
offset=off,
value=item["value"],
write_mask=item.get("write_mask", 0xFFFF_FFFF),
precondition=p,
readback=item.get("readback", True),
)
)
requests.append(
(index, request(res, off, AccessClass.WRITE))
)
elif ikind in ("mmio_poll32", "state_poll32"):
res = lookup(item["resource"])
off = item["offset"]
seq_items.append(
SequenceItem(
kind="poll32",
resource=res.id,
offset=off,
expected=item["expected"],
mask=item.get("mask", 0xFFFF_FFFF),
interval_ns=item["interval_ns"],
timeout_ns=item["timeout_ns"],
)
)
requests.append(
(index, request(res, off, AccessClass.POLL))
)
elif ikind == "delay_ns":
seq_items.append(
SequenceItem(
kind="delay_ns",
delay_ns=item["duration_ns"],
)
)
elif ikind == "barrier":
seq_items.append(
SequenceItem(
kind="barrier",
barrier=item.get("variant", "memory"),
)
)
operations.append(
_SequenceOp(
index=index,
items=tuple(seq_items),
has_mutation=has_mutation,
)
)
return operations, requests
async def _run_direct(
self,
canonical: Mapping[str, Any],
digest: str,
recorder: EvidenceRecorder,
finish: Callable[[int, str | None], Awaitable[RunResult]],
calls: list[Mapping[str, Any]],
) -> RunResult:
recorder.mark_not_applicable("permission-resolution.json")
recorder.mark_not_applicable("target-audit.jsonl")
context = SessionContext(
run_id=canonical["run_id"],
case_id=canonical["case_id"],
plan_digest=digest,
)
try:
assert isinstance(self._transport, DirectTransport)
direct_session = await self._transport.open_direct_session(context)
except TransportError as error:
return await finish(
EXIT_TRANSPORT, f"direct connection failed: {error}"
)
exit_category = EXIT_SUCCESS
failure: str | None = None
operation_rows: list[dict[str, object]] = []
for index, op in enumerate(canonical["operations"]):
method = op["method"]
args = op.get("args", {})
try:
outcome = await direct_session.call_fidl(method, args)
row = {
"operation": index,
"kind": "fidl_call",
"method": method,
"args": args,
"response": outcome.response,
"timestamp_ns": outcome.timestamp_ns,
}
operation_rows.append(row)
calls.append(row)
except OperationDenied as error:
operation_rows.append(
{
"operation": index,
"kind": "fidl_call",
"method": method,
"error": error.denial.value,
}
)
exit_category = EXIT_OPERATION
failure = error.denial.value
break
except TransportError as error:
operation_rows.append(
{
"operation": index,
"kind": "fidl_call",
"method": method,
"error": str(error),
}
)
exit_category = EXIT_TRANSPORT
failure = f"direct FIDL call failed: {error}"
break
except (KeyboardInterrupt, asyncio.CancelledError):
exit_category = EXIT_OPERATION
failure = "cancelled by operator"
break
recorder.write_jsonl("operations.jsonl", operation_rows)
try:
await direct_session.close()
except TransportError as error:
if failure is None:
exit_category = EXIT_TRANSPORT
failure = f"session close failed: {error}"
return await finish(exit_category, failure)
async def run_plan(self, plan: Mapping[str, object]) -> RunResult:
"""Runs one validated plan to a finalized evidence bundle.
Raises `PlanError` for a structurally invalid plan and
`EvidenceError` when the evidence directory cannot be created;
every other failure is reported through the returned
`RunResult` with its evidence finalized.
"""
canonical = validate_plan(plan)
digest = plan_digest(plan)
recorder = EvidenceRecorder(self._evidence_root, canonical["run_id"])
recorder.write_json("plan.requested.json", dict(plan))
recorder.write_json(
"plan.canonical.json", {"digest": digest, "plan": canonical}
)
reads: list[ReadRecord] = []
writes: list[WriteRecord] = []
polls: list[PollRecord] = []
sequences: list[SequenceRecord] = []
calls: list[Mapping[str, Any]] = []
serial_session: SerialCaptureSession | None = None
serial_result: SerialCaptureResult | None = None
was_proxy_activated = False
teardown_done = False
access = canonical["access"]
requested_mode = access["mode"]
is_in_situ = (
requested_mode == "in-situ"
or (
requested_mode == "proxy"
and access.get("activation") == "in-situ"
)
or self._driver_moniker is not None
)
recorded_driver_moniker: str | None = (
canonical["node"].get("driver_moniker")
or self._driver_moniker
or (canonical["node"]["id"] if is_in_situ else None)
)
recorded_driver_url: str | None = self._driver_url
async def finish(exit_category: int, failure: str | None) -> RunResult:
nonlocal was_proxy_activated, teardown_done, serial_result
if serial_session is not None and serial_result is None:
try:
serial_result = await serial_session.stop()
recorder.write_bytes("serial.log", serial_result.data)
if serial_result.contains_panic and failure is None:
exit_category = EXIT_TRANSPORT
failure = (
"target kernel panic detected in serial log: "
f"{serial_result.panic_summary}"
)
except Exception as exc:
if failure is None:
failure = f"stopping serial capture failed: {exc}"
if was_proxy_activated and not teardown_done:
teardown_done = True
node_id = canonical["node"]["id"]
try:
if self._activator is not None:
await self._activator.end_proxy(node_id)
if self._discovery is not None:
post_desc = await self._discovery.describe_node(node_id)
if post_desc is not None:
recorder.write_json(
"node.after.json", dataclasses.asdict(post_desc)
)
if not post_desc.is_unclaimed:
if failure is None:
exit_category = EXIT_ACTIVATION
failure = (
f"verified teardown failed: node {node_id} "
f"still bound to {post_desc.bound_driver_url}"
)
except Exception as exc:
if failure is None:
exit_category = EXIT_ACTIVATION
failure = f"teardown failed: {exc}"
elif (
is_in_situ and not teardown_done and self._discovery is not None
):
teardown_done = True
node_id = canonical["node"]["id"]
try:
post_desc = await self._discovery.describe_node(node_id)
if post_desc is not None:
recorder.write_json(
"node.after.json", dataclasses.asdict(post_desc)
)
except Exception:
pass
for name in _DEFERRED_ARTIFACTS + _NOT_APPLICABLE_ARTIFACTS:
if not recorder.recorded(name):
recorder.mark_not_applicable(name)
manifest_extra: dict[str, Any] = {
"run_id": canonical["run_id"],
"case_id": canonical["case_id"],
"plan_digest": digest,
"failure": failure,
}
if is_in_situ or recorded_driver_moniker is not None:
manifest_extra["driver_moniker"] = recorded_driver_moniker
manifest_extra["driver_url"] = recorded_driver_url
if serial_result is not None:
manifest_extra[
"serial_capture"
] = serial_result.to_manifest_metadata()
recorder.finalize(
exit_category,
manifest_extra,
)
return RunResult(
exit_category=exit_category,
evidence_dir=recorder.directory,
plan_digest=digest,
reads=tuple(reads),
writes=tuple(writes),
polls=tuple(polls),
sequences=tuple(sequences),
calls=tuple(calls),
failure=failure,
)
# Mode and guarantee resolution happen before any target
# connection: an unsupported mode or guarantee fails here, never
# by silently substituting a different backend.
is_direct_transport = getattr(self._transport, "is_direct", False)
if requested_mode == "direct":
if not is_direct_transport:
return await finish(
EXIT_UNSUPPORTED,
"transport does not support direct mode; a direct-mode plan must never silently execute over the proxy",
)
capabilities = DIRECT_CAPABILITIES
elif requested_mode == "in-situ":
if is_direct_transport:
return await finish(
EXIT_UNSUPPORTED,
"transport does not support in-situ mode",
)
capabilities = IN_SITU_CAPABILITIES
elif requested_mode == "proxy":
if is_direct_transport:
return await finish(
EXIT_UNSUPPORTED,
"transport does not support proxy mode",
)
capabilities = (
IN_SITU_CAPABILITIES if is_in_situ else PROXY_CAPABILITIES
)
else:
return await finish(
EXIT_UNSUPPORTED,
f"access.mode {requested_mode!r} is not implemented",
)
recorder.write_json("access.capabilities.json", capabilities)
for flag, capability in (
("requires_target_policy", "target_policy"),
("requires_target_audit", "target_audit"),
("requires_target_local_timing", "target_local_timing"),
):
if access[flag] and not capabilities[capability]:
return await finish(
EXIT_UNSUPPORTED,
f"plan requires {capability}, which {requested_mode} mode does not provide",
)
if requested_mode == "direct":
if any(
op["kind"]
in (
"mmio_read32",
"state_read32",
"mmio_snapshot32",
"mmio_write32",
"knob_write32",
"trigger_write32",
"mmio_poll32",
"state_poll32",
"sequence",
)
for op in canonical["operations"]
):
return await finish(
EXIT_UNSUPPORTED,
"direct mode does not provide private MMIO access",
)
elif requested_mode in ("proxy", "in-situ"):
if any(op["kind"] == "fidl_call" for op in canonical["operations"]):
return await finish(
EXIT_UNSUPPORTED,
f"{requested_mode} mode does not support fidl_call",
)
# Spec 14.1 Step 9: Verify independent serial/recovery availability.
requires_recovery = canonical["target"].get("requires_recovery", False)
if requires_recovery:
if self._recovery is None:
return await finish(
EXIT_UNSUPPORTED,
"target.requires_recovery specified but no recovery controller configured",
)
if not await self._recovery.is_available():
return await finish(
EXIT_ACTIVATION,
"independent recovery channel is unavailable",
)
requires_serial = canonical["target"].get("requires_serial", False)
if requires_serial:
if self._serial_capture is None:
return await finish(
EXIT_UNSUPPORTED,
"target.requires_serial specified but no serial capture configured",
)
if not await self._serial_capture.is_available():
return await finish(
EXIT_ACTIVATION,
"serial capture channel is unavailable",
)
# Spec 14.2 Step 2: Start serial and liveness capture.
if self._serial_capture is not None:
try:
if await self._serial_capture.is_available():
serial_session = await self._serial_capture.start()
except Exception as exc:
if requires_serial:
return await finish(
EXIT_ACTIVATION,
f"starting serial capture failed: {exc}",
)
if is_in_situ:
node_id = canonical["node"]["id"]
if self._discovery is not None:
try:
node_desc = await self._discovery.describe_node(node_id)
except DiscoveryError as exc:
return await finish(
EXIT_ACTIVATION,
f"node discovery failed for {node_id}: {exc}",
)
if node_desc is not None:
expected_unclaimed = canonical["node"].get(
"expected_unclaimed", False
)
if expected_unclaimed and not node_desc.is_unclaimed:
return await finish(
EXIT_ACTIVATION,
f"node {node_id} is bound to {node_desc.bound_driver_url}, expected unclaimed",
)
if not expected_unclaimed and node_desc.is_unclaimed:
return await finish(
EXIT_ACTIVATION,
f"node {node_id} is unclaimed, expected active bound driver for in-situ session",
)
if node_desc.bound_driver_url:
recorded_driver_url = node_desc.bound_driver_url
recorder.write_json(
"node.before.json", dataclasses.asdict(node_desc)
)
elif "expected_unclaimed" in canonical["node"]:
return await finish(
EXIT_ACTIVATION,
f"node {node_id} not found during discovery",
)
elif "expected_unclaimed" in canonical["node"]:
return await finish(
EXIT_UNSUPPORTED,
"node.expected_unclaimed cannot be verified without node discovery",
)
elif "expected_unclaimed" in canonical["node"] or (
requested_mode == "proxy"
and access.get("activation") == "bind-unclaimed"
and self._discovery is not None
and self._activator is not None
):
if self._discovery is None:
# Node discovery is not configured, so the assertion cannot be
# verified; fail closed rather than running unverified.
return await finish(
EXIT_UNSUPPORTED,
"node.expected_unclaimed cannot be verified without node discovery",
)
expected_unclaimed = canonical["node"].get(
"expected_unclaimed", True
)
node_id = canonical["node"]["id"]
try:
node_desc = await self._discovery.describe_node(node_id)
except DiscoveryError as exc:
return await finish(
EXIT_ACTIVATION,
f"node discovery failed for {node_id}: {exc}",
)
if node_desc is None:
return await finish(
EXIT_ACTIVATION,
f"node {node_id} not found during discovery",
)
if expected_unclaimed and not node_desc.is_unclaimed:
return await finish(
EXIT_ACTIVATION,
f"node {node_id} is bound to {node_desc.bound_driver_url}; "
"managed takeover is phase 2",
)
if not expected_unclaimed and node_desc.is_unclaimed:
return await finish(
EXIT_ACTIVATION,
f"node {node_id} is unclaimed, expected bound driver",
)
recorder.write_json(
"node.before.json", dataclasses.asdict(node_desc)
)
if (
not is_in_situ
and requested_mode == "proxy"
and access.get("activation") == "bind-unclaimed"
and self._activator is not None
and self._discovery is not None
):
node_id = canonical["node"]["id"]
try:
await self._activator.bind_proxy(node_id)
was_proxy_activated = True
except Exception as error:
return await finish(
EXIT_ACTIVATION,
f"proxy activation failed: {error}",
)
# Prepare: describe, freeze expectations, resolve consent. No
# hardware access happens in this phase.
try:
description = await self._transport.describe()
except TransportError as error:
return await finish(EXIT_TRANSPORT, f"describe failed: {error}")
recorder.write_json(
"target.description.json", dataclasses.asdict(description)
)
expected_boot_id = canonical["target"].get("expected_boot_id")
if (
expected_boot_id is not None
and expected_boot_id != description.boot_id
):
return await finish(
EXIT_STALE, "expected_boot_id does not match target"
)
expected_digest = canonical["node"].get("expected_resource_digest")
if expected_digest is not None:
if requested_mode == "direct":
return await finish(
EXIT_STALE,
"direct mode target has no proxy resource digest",
)
assert isinstance(description, ProxyDescription)
if expected_digest != description.resource_digest:
return await finish(
EXIT_STALE, "expected_resource_digest does not match target"
)
if requested_mode == "direct":
return await self._run_direct(
canonical, digest, recorder, finish, calls
)
assert isinstance(description, ProxyDescription)
assert isinstance(self._transport, ProxyTransport)
try:
operations, requests = self._derive(canonical, description)
except _StaleResource as error:
return await finish(EXIT_STALE, str(error))
try:
grants = load_grants(self._grants_path)
except GrantStoreError as error:
return await finish(EXIT_PERMISSION, f"grant store error: {error}")
resolved: list[tuple[int, AccessRequest, Resolution]] = []
undecided: dict[
tuple[str, str, str, str, int, int, AccessClass], AccessRequest
] = {}
persistent_denied = False
for index, access_request in requests:
resolution = resolve(access_request, grants)
resolved.append((index, access_request, resolution))
if resolution.outcome is Outcome.DENIED:
persistent_denied = True
elif resolution.outcome is Outcome.UNDECIDED:
undecided.setdefault(access_request.match_key, access_request)
# A persistent deny short-circuits prompting: the operator already
# made a durable decision, so nobody is asked to re-litigate it.
prompted: dict[
tuple[str, str, str, str, int, int, AccessClass], ConsentDecision
] = {}
if not persistent_denied and undecided and self._consent is not None:
for access_request in undecided.values():
warning = (
WRITE_WARNING
if access_request.access == AccessClass.WRITE
else READ_WARNING
)
decision = await self._consent.request_consent(
access_request, warning
)
prompted[access_request.match_key] = decision
if decision in (
ConsentDecision.ALWAYS_ALLOW,
ConsentDecision.ALWAYS_DENY,
):
if (
decision == ConsentDecision.ALWAYS_ALLOW
and access_request.access
in (AccessClass.WRITE, AccessClass.SEQUENCE)
):
return await finish(
EXIT_PERMISSION,
f"persistent {access_request.access.value} grants are not supported",
)
try:
add_grant(
self._grants_path,
grant_from_decision(access_request, decision),
)
except (GrantStoreError, ValueError) as error:
return await finish(
EXIT_PERMISSION, f"grant store error: {error}"
)
resolution_rows: list[dict[str, object]] = []
operator_denied = False
fail_closed = False
for index, access_request, resolution in resolved:
grant_id: str | None = None
if resolution.decided:
outcome_value = resolution.outcome.value
source = "persistent"
grant_id = (
resolution.grant.grant_id if resolution.grant else None
)
elif access_request.match_key in prompted:
decision = prompted[access_request.match_key]
allowed = decision in (
ConsentDecision.ALLOW_ONCE,
ConsentDecision.ALWAYS_ALLOW,
)
outcome_value = (
Outcome.ALLOWED if allowed else Outcome.DENIED
).value
operator_denied = operator_denied or not allowed
source = decision.value
else:
outcome_value = Outcome.UNDECIDED.value
source = "fail_closed"
fail_closed = True
resolution_rows.append(
{
"operation": index,
"resource": access_request.resource,
"offset": access_request.offset,
"access": access_request.access.value,
"outcome": outcome_value,
"source": source,
"grant_id": grant_id,
}
)
recorder.write_json("permission-resolution.json", resolution_rows)
if persistent_denied:
return await finish(EXIT_PERMISSION, "denied by persistent grant")
if operator_denied:
return await finish(EXIT_PERMISSION, "denied by operator")
if fail_closed:
return await finish(
EXIT_PERMISSION,
"consent required; unattended operation fails closed",
)
allowlist = sorted(
{
AllowRule(
resource=description.resource_named(req.resource).id, # type: ignore[union-attr]
offset=req.offset,
width=req.width,
access=req.access,
)
for _, req in requests
},
key=lambda rule: (rule.resource, rule.offset, rule.access.value),
)
# Execute: open the session with the exact allowlist and session mode,
# run the bounded operations in order, then drain audit before closing.
mutating = is_mutating_plan(canonical)
session_mode = (
SessionMode.MUTATING if mutating else SessionMode.READ_ONLY
)
context = SessionContext(
run_id=canonical["run_id"],
case_id=canonical["case_id"],
plan_digest=digest,
)
try:
session = await self._transport.open_session(
context,
description.expectations(),
allowlist,
mode=session_mode,
)
except OpenSessionRejected as error:
category = (
EXIT_STALE
if error.reason in STALE_REJECTIONS
else EXIT_ACTIVATION
)
return await finish(
category, f"session rejected: {error.reason.value}"
)
except TransportError as error:
return await finish(EXIT_TRANSPORT, f"open_session failed: {error}")
exit_category = EXIT_SUCCESS
failure: str | None = None
operation_rows: list[dict[str, object]] = []
audit_rows: list[dict[str, object]] = []
cursor = 0
async def drain_audit() -> None:
nonlocal cursor
while True:
page = await session.read_audit(cursor, 64)
if not page.entries:
break
audit_rows.extend(entry.to_json() for entry in page.entries)
cursor = page.next_cursor
for operation in operations:
try:
if isinstance(operation, _ReadOp):
read_outcome = await session.read32(
operation.resource.id, operation.offset
)
operation_rows.append(
{
"operation": operation.index,
"kind": operation.kind,
"resource": operation.resource.name,
"offset": operation.offset,
"value": read_outcome.value,
"audit_seq": read_outcome.audit_seq,
"timestamp_ns": read_outcome.timestamp_ns,
}
)
reads.append(
ReadRecord(
operation_index=operation.index,
resource=operation.resource.name,
offset=operation.offset,
value=read_outcome.value,
audit_seq=read_outcome.audit_seq,
timestamp_ns=read_outcome.timestamp_ns,
)
)
elif isinstance(operation, _SnapshotOp):
items = [
SnapshotItem(resource=resource.id, offset=offset)
for resource, offset in operation.items
]
snapshot = await session.snapshot(items)
operation_rows.append(
{
"operation": operation.index,
"kind": "mmio_snapshot32",
"complete": snapshot.complete,
"results": [
{
"resource": result.item.resource,
"offset": result.item.offset,
"ok": result.ok,
"value": (
result.value if result.ok else None
),
"audit_seq": result.audit_seq,
"timestamp_ns": result.timestamp_ns,
}
for result in snapshot.results
],
}
)
if not snapshot.complete:
exit_category = EXIT_OPERATION
failure = "snapshot stopped at a backend failure"
break
elif isinstance(operation, _WriteOp):
write_outcome = await session.write32(
operation.resource.id,
operation.offset,
operation.value,
write_mask=operation.write_mask,
precondition=operation.precondition,
readback=operation.readback,
)
operation_rows.append(
{
"operation": operation.index,
"kind": operation.kind,
"resource": operation.resource.name,
"offset": operation.offset,
"value": operation.value,
"write_mask": operation.write_mask,
"readback_value": write_outcome.readback_value,
"audit_seq": write_outcome.audit_seq,
"timestamp_ns": write_outcome.timestamp_ns,
}
)
writes.append(
WriteRecord(
operation_index=operation.index,
resource=operation.resource.name,
offset=operation.offset,
value=operation.value,
write_mask=operation.write_mask,
readback_value=write_outcome.readback_value,
audit_seq=write_outcome.audit_seq,
timestamp_ns=write_outcome.timestamp_ns,
)
)
# Spec 14.2 step 6: drain audit immediately after each write.
await drain_audit()
elif isinstance(operation, _PollOp):
poll_outcome = await session.poll32(
operation.resource.id,
operation.offset,
operation.expected,
mask=operation.mask,
interval_ns=operation.interval_ns,
timeout_ns=operation.timeout_ns,
)
operation_rows.append(
{
"operation": operation.index,
"kind": operation.kind,
"resource": operation.resource.name,
"offset": operation.offset,
"expected": operation.expected,
"mask": operation.mask,
"value": poll_outcome.value,
"audit_seq": poll_outcome.audit_seq,
"timestamp_ns": poll_outcome.timestamp_ns,
}
)
polls.append(
PollRecord(
operation_index=operation.index,
resource=operation.resource.name,
offset=operation.offset,
expected=operation.expected,
mask=operation.mask,
value=poll_outcome.value,
audit_seq=poll_outcome.audit_seq,
timestamp_ns=poll_outcome.timestamp_ns,
)
)
elif isinstance(operation, _SequenceOp):
seq_outcome = await session.execute_sequence(
operation.items
)
item_records = [
SequenceItemRecord(
index=res.index,
ok=res.ok,
kind=res.kind,
value=res.value,
readback_value=res.readback_value,
audit_seq=res.audit_seq,
timestamp_ns=res.timestamp_ns,
error=res.error.value
if res.error is not None
else None,
)
for res in seq_outcome.results
]
seq_record = SequenceRecord(
operation_index=operation.index,
results=tuple(item_records),
complete=seq_outcome.complete,
)
sequences.append(seq_record)
operation_rows.append(
{
"operation": operation.index,
"kind": "sequence",
"complete": seq_outcome.complete,
"results": [
{
"index": r.index,
"ok": r.ok,
"kind": r.kind,
"value": r.value,
"readback_value": r.readback_value,
"audit_seq": r.audit_seq,
"timestamp_ns": r.timestamp_ns,
"error": r.error,
}
for r in item_records
],
}
)
if operation.has_mutation:
# Spec 14.2 step 6: drain audit immediately after mutating sequence.
await drain_audit()
if not seq_outcome.complete:
exit_category = EXIT_OPERATION
failure = "sequence stopped at an item failure"
break
except OperationDenied as error:
kind = "unknown"
if isinstance(operation, (_ReadOp, _WriteOp, _PollOp)):
kind = operation.kind
elif isinstance(operation, _SnapshotOp):
kind = "mmio_snapshot32"
elif isinstance(operation, _SequenceOp):
kind = "sequence"
operation_rows.append(
{
"operation": operation.index,
"kind": kind,
"error": error.denial.value,
}
)
exit_category = EXIT_OPERATION
failure = error.denial.value
break
except TransportError as error:
kind = "unknown"
if isinstance(operation, (_ReadOp, _WriteOp, _PollOp)):
kind = operation.kind
elif isinstance(operation, _SnapshotOp):
kind = "mmio_snapshot32"
elif isinstance(operation, _SequenceOp):
kind = "sequence"
operation_rows.append(
{
"operation": operation.index,
"kind": kind,
"error": str(error),
}
)
exit_category = EXIT_TRANSPORT
failure = f"operation failed: {error}"
if serial_session is not None:
try:
curr_log = await serial_session.get_current_log()
if detect_panic(curr_log):
panic_sum = extract_panic_summary(curr_log)
failure = (
"target kernel panic detected: " f"{panic_sum}"
)
except Exception:
pass
if self._recovery is not None:
try:
if not await self._recovery.check_liveness():
if "panic" not in str(failure):
failure = (
f"target liveness check failed: {failure}"
)
except Exception:
pass
break
except (KeyboardInterrupt, asyncio.CancelledError):
# Operator cancellation stops execution but never skips
# audit draining, session close, or evidence finalization.
exit_category = EXIT_OPERATION
failure = "cancelled by operator"
break
recorder.write_jsonl("operations.jsonl", operation_rows)
# Drain audit and close even after an operation failure or a
# cancellation so partial runs still produce complete evidence.
try:
await drain_audit()
await session.close()
except (
TransportError,
KeyboardInterrupt,
asyncio.CancelledError,
) as error:
if failure is None:
exit_category = EXIT_TRANSPORT
failure = f"audit drain failed: {error}"
recorder.write_jsonl("target-audit.jsonl", audit_rows)
return await finish(exit_category, failure)
async def connect(
target: str = "default",
*,
transport: str = "auto",
timeout_s: float = 10.0,
**kwargs: Any,
) -> DriverLab:
"""Connects to a target, returning a configured DriverLab instance (Spec Section 9)."""
return await DriverLab.connect(
target=target, transport=transport, timeout_s=timeout_s, **kwargs
)