blob: 56874066a0f4fa48a63d3aa4f5d73f8c4e8d150c [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.
"""
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
)