From cc70a86a7a4897ec0737cd641b5b3575fbdefc23 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 26 Aug 2026 14:29:45 -0400 Subject: [PATCH 1/5] Add strict hosted runner execution adapter --- openadapt_flow/runner/__init__.py | 47 +- openadapt_flow/runner/commands.py | 8 +- openadapt_flow/runner/config.py | 262 +++- openadapt_flow/runner/hosted_adapter.py | 1356 +++++++++++++++++++ openadapt_flow/runner/inputs.py | 95 ++ openadapt_flow/runner/product_release.py | 267 ++++ openadapt_flow/runner/protocol.py | 14 +- openadapt_flow/runner/verify.py | 60 +- openadapt_flow/runtime/durable/authority.py | 15 +- tests/test_durable_authority_v13.py | 47 + tests/test_hosted_runner_adapter.py | 686 ++++++++++ tests/test_runner_client_lib.py | 2 +- 12 files changed, 2809 insertions(+), 50 deletions(-) create mode 100644 openadapt_flow/runner/hosted_adapter.py create mode 100644 openadapt_flow/runner/inputs.py create mode 100644 openadapt_flow/runner/product_release.py create mode 100644 tests/test_hosted_runner_adapter.py diff --git a/openadapt_flow/runner/__init__.py b/openadapt_flow/runner/__init__.py index 013f6222..dd641fd5 100644 --- a/openadapt_flow/runner/__init__.py +++ b/openadapt_flow/runner/__init__.py @@ -1,15 +1,8 @@ -"""EXPERIMENTAL runner-client LIBRARY (verification, lease logic, evidence, -command mapping) for the hosted control-plane / local-execution-plane runner -protocol. NO daemon, NO network loop, NO CLI verb — deliberately. +"""Flow-owned hosted-runner verification and execution library. -Scope: the merged ``/api/runners/*`` control-plane surface in openadapt-cloud -(``src/lib/runners.ts``) is mock-gated (410 in live) and its transport-facing -half is scheduled to CHANGE before any customer daemon can ship (poll cadence -and hosting economics, lease renewal + sleep reclaim, control-verb channel, -mandatory params-by-reference for regulated orgs — see -``docs/design/RUNNER_CLIENT_LIBRARY.md`` for the verified findings and the -required revisions). This package therefore contains only the transport- -agnostic half that SURVIVES that revision: +Desktop owns the authenticated register, poll, and callback transport loop. +This package owns the transport-independent trust, admission, one-use, +governed-execution, and terminal-classification boundary: * :mod:`~openadapt_flow.runner.protocol` — strict typed models of the dispatch wire contract (contract drift is a refusal, not a best guess); @@ -31,7 +24,9 @@ evidence queue (a run that finishes offline reports late, never never); * :mod:`~openadapt_flow.runner.commands` — mapping of governed dispatch verbs onto the EXISTING CLI entry points (``run`` / ``resume``); unmappable verbs - refuse. + refuse; +* :mod:`~openadapt_flow.runner.hosted_adapter` — the strict Cloud lease wire, + protected local trust, managed child bridge, and no-replay result contract. """ from openadapt_flow.runner.commands import ( @@ -52,6 +47,21 @@ read_managed_dispatch_envelope, write_managed_dispatch_envelope, ) +from openadapt_flow.runner.hosted_adapter import ( + CallbackRequest, + CallbackResponse, + DeliveryAuthority, + HostedDispatch, + HostedDispatchRefusal, + HostedRecoveryBinding, + HostedRunnerAdapter, + HostedRunnerTransport, + HostedRunResult, + PollRequest, + RegisterCapabilities, + RegisterRequest, + RegisterResponse, +) from openadapt_flow.runner.lease import ( CompletionDisposition, LeaseError, @@ -78,8 +88,17 @@ __all__ = [ "CompletionDisposition", + "CallbackRequest", + "CallbackResponse", + "DeliveryAuthority", "DispatchParseError", "EvidenceOutbox", + "HostedDispatch", + "HostedDispatchRefusal", + "HostedRecoveryBinding", + "HostedRunResult", + "HostedRunnerAdapter", + "HostedRunnerTransport", "LeaseError", "ManagedDispatchEnvelope", "ManagedDispatchEnvelopeError", @@ -88,6 +107,10 @@ "LeasedDispatch", "Refusal", "RefusalCode", + "PollRequest", + "RegisterCapabilities", + "RegisterRequest", + "RegisterResponse", "RunnerConfig", "RunnerConfigError", "RunnerDispatchPayload", diff --git a/openadapt_flow/runner/commands.py b/openadapt_flow/runner/commands.py index a92d2a32..6feee772 100644 --- a/openadapt_flow/runner/commands.py +++ b/openadapt_flow/runner/commands.py @@ -3,9 +3,8 @@ The runner never grows a private execution path: a dispatched run is the same fail-closed ``openadapt-flow run`` admission gate + shared replayer the local CLI uses, in a child process (crash isolation; the design doc's "the agent -shells them"). This module only BUILDS argv — executing it belongs to the -future daemon, which is deliberately not in this library (see -``docs/design/RUNNER_CLIENT_LIBRARY.md``). +shells them"). This module only builds argv. The hosted adapter executes that +argv in the managed child process. Verb coverage, honestly stated: @@ -51,6 +50,7 @@ def build_run_argv( params_file: Optional[Path], *, managed_dispatch_file: Path, + qualification_authority_file: Optional[Path] = None, ) -> list[str]: """The exact governed CLI invocation for a verified ``run`` dispatch. @@ -81,6 +81,8 @@ def build_run_argv( ] if params_file is not None: argv += ["--params-file", str(params_file)] + if qualification_authority_file is not None: + argv += ["--qualification-authority-file", str(qualification_authority_file)] if verified.bundle.policy: argv += ["--policy", verified.bundle.policy] if ( diff --git a/openadapt_flow/runner/config.py b/openadapt_flow/runner/config.py index 30b72e48..d752542c 100644 --- a/openadapt_flow/runner/config.py +++ b/openadapt_flow/runner/config.py @@ -3,7 +3,7 @@ Nothing writes this file programmatically. It names the deployment profiles a dispatch may reference and the exact sealed bundles (by content digest) this machine is willing to execute — a digest absent from this file is refused. -That is the no-remote-code-delivery hard line: the future runner daemon only +That is the no-remote-code-delivery hard line: the hosted adapter only ever executes bundles the operator ALREADY installed and listed here; the dispatch's ``bundle.url`` is never fetched. @@ -42,16 +42,25 @@ import os import re +import stat from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional from openadapt_flow.hosted import HostedError +from openadapt_flow.private_file import ( + PrivateFileAclError, + windows_descriptor_has_private_acl, +) _HEX64_RE = re.compile(r"^[a-f0-9]{64}$") +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$") -def _load_manifest_toml(path: Path) -> dict[str, Any]: +def _load_manifest_toml(path: Path, *, protected: bool = False) -> dict[str, Any]: """Full-TOML parse (the manifest uses ``[[bundles]]`` array tables, which ``hosted._load_toml``'s 3.10 minimal fallback cannot represent). Uses stdlib ``tomllib`` on 3.11+ and the declared ``tomli`` dependency on 3.10. @@ -60,6 +69,81 @@ def _load_manifest_toml(path: Path) -> dict[str, Any]: import tomllib except ModuleNotFoundError: # pragma: no cover - Python 3.10 import tomli as tomllib # type: ignore[no-redef] + if protected: + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + path_before = path.lstat() + if not stat.S_ISREG(path_before.st_mode) or stat.S_ISLNK( + path_before.st_mode + ): + raise RunnerConfigError( + "hosted runner manifest is not a private regular file" + ) + descriptor = os.open(path, flags) + except OSError as exc: + raise RunnerConfigError( + "hosted runner manifest could not be opened safely" + ) from exc + try: + before = os.fstat(descriptor) + try: + private = ( + windows_descriptor_has_private_acl(descriptor) + if os.name == "nt" + else ( + before.st_uid == os.geteuid() + and stat.S_IMODE(before.st_mode) == 0o600 + ) + ) + except PrivateFileAclError as exc: + raise RunnerConfigError( + "hosted runner manifest ACL could not be verified" + ) from exc + if ( + not stat.S_ISREG(before.st_mode) + or before.st_size > 1024 * 1024 + or not private + ): + raise RunnerConfigError( + "hosted runner manifest is not a private regular file" + ) + chunks: list[bytes] = [] + remaining = before.st_size + while remaining: + chunk = os.read(descriptor, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + after = os.fstat(descriptor) + try: + path_after = path.lstat() + except OSError as exc: + raise RunnerConfigError( + "hosted runner manifest changed during its protected read" + ) from exc + if ( + len(raw) != before.st_size + or stat.S_ISLNK(path_after.st_mode) + or (path_before.st_dev, path_before.st_ino) + != (before.st_dev, before.st_ino) + or (path_after.st_dev, path_after.st_ino) + != (before.st_dev, before.st_ino) + or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) + ): + raise RunnerConfigError( + "hosted runner manifest changed during its protected read" + ) + finally: + os.close(descriptor) + try: + return tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise RunnerConfigError("hosted runner manifest is not valid TOML") from exc try: with path.open("rb") as fh: return tomllib.load(fh) @@ -103,6 +187,9 @@ class TrustedBundle: allow_unverified_writes: bool = False #: Local escape hatch mirroring ``run --allow-unencrypted``. allow_unencrypted: bool = False + #: Exact archive/object digest named by the workflow admission. Hosted + #: execution requires this local pin; ordinary local execution does not. + artifact_sha256: Optional[str] = None @dataclass(frozen=True) @@ -112,6 +199,32 @@ class BusinessDecisionServiceConfig: key_file: Path +@dataclass(frozen=True) +class LocalRuntimeRelease: + """One independently installed target release used during enrollment.""" + + target: str + admission_id: str + admission_sha256: str + release_version: str + release_artifact_sha256: str + + +@dataclass(frozen=True) +class AdmissionTrustFiles: + """Local signer and revocation state used to verify hosted admissions.""" + + signer_registry: Path + state: Path + + +@dataclass(frozen=True) +class WorkflowAdmissionTrustFiles(AdmissionTrustFiles): + """Local v2 expectation that is independent from the leased artifact.""" + + expected_bindings: Path + + @dataclass(frozen=True) class RunnerConfig: """Parsed trust manifest.""" @@ -121,9 +234,14 @@ class RunnerConfig: profiles: dict[str, Path] = field(default_factory=dict) bundles: dict[str, TrustedBundle] = field(default_factory=dict) #: Capability advertisement (deployment.yaml backend kinds this machine - #: can drive) for the future register/poll payloads. Advisory only. + #: can drive) for hosted registration. Advisory only. backends: tuple[str, ...] = ("web",) business_decisions: Optional[BusinessDecisionServiceConfig] = None + local_runtime_release: tuple[LocalRuntimeRelease, ...] = () + product_release_admission: Optional[AdmissionTrustFiles] = None + workflow_admission: Optional[WorkflowAdmissionTrustFiles] = None + params_ref_root: Optional[Path] = None + evidence_runner_private_key: Optional[Path] = None def _parse_param_patterns(raw: object, index: int) -> dict[str, str]: @@ -147,7 +265,9 @@ def _parse_param_patterns(raw: object, index: int) -> dict[str, str]: return patterns -def load_runner_config(path: Optional[Path] = None) -> RunnerConfig: +def load_runner_config( + path: Optional[Path] = None, *, protected: bool = False +) -> RunnerConfig: """Load and validate ``runner.toml``. Fail loudly on anything malformed.""" cfg_path = path or runner_config_path() if not cfg_path.is_file(): @@ -156,7 +276,7 @@ def load_runner_config(path: Optional[Path] = None) -> RunnerConfig: "list the deployment profiles and the exact sealed bundles (by " "content digest) this machine may execute." ) - data = _load_manifest_toml(cfg_path) + data = _load_manifest_toml(cfg_path, protected=protected) runner_tbl = data.get("runner") or {} if not isinstance(runner_tbl, dict): @@ -214,7 +334,18 @@ def load_runner_config(path: Optional[Path] = None) -> RunnerConfig: param_patterns=_parse_param_patterns(entry.get("param_patterns"), i), allow_unverified_writes=bool(entry.get("allow_unverified_writes", False)), allow_unencrypted=bool(entry.get("allow_unencrypted", False)), + artifact_sha256=( + str(entry["artifact_sha256"]) + if entry.get("artifact_sha256") is not None + else None + ), ) + if bundles[digest].artifact_sha256 is not None and not _HEX64_RE.fullmatch( + bundles[digest].artifact_sha256 or "" + ): + raise RunnerConfigError( + f"[[bundles]] entry {i} artifact_sha256 must be 64 lowercase hex" + ) decision_tbl = data.get("business_decisions") business_decisions = None @@ -237,6 +368,122 @@ def load_runner_config(path: Optional[Path] = None) -> RunnerConfig: ) business_decisions = BusinessDecisionServiceConfig(key_file=key_file) + local_release_tbl = data.get("local_runtime_release") or {} + if not isinstance(local_release_tbl, dict): + raise RunnerConfigError("[local_runtime_release] must be a table") + local_runtime_release: list[LocalRuntimeRelease] = [] + expected_release_targets = ("flow", "desktop", "capture") + for target in expected_release_targets: + entry = local_release_tbl.get(target) + if entry is None: + continue + if not isinstance(entry, dict) or set(entry) != { + "admission_id", + "admission_sha256", + "release_version", + "release_artifact_sha256", + }: + raise RunnerConfigError( + f"[local_runtime_release.{target}] has an invalid exact shape" + ) + admission_sha256 = str(entry["admission_sha256"]) + artifact_sha256 = str(entry["release_artifact_sha256"]) + admission_id = str(entry["admission_id"]) + release_version = str(entry["release_version"]) + if not _HEX64_RE.fullmatch(admission_sha256) or not _HEX64_RE.fullmatch( + artifact_sha256 + ): + raise RunnerConfigError( + f"[local_runtime_release.{target}] contains an invalid digest" + ) + if not _UUID_RE.fullmatch(admission_id) or not _SAFE_ID_RE.fullmatch( + release_version + ): + raise RunnerConfigError( + f"[local_runtime_release.{target}] contains an invalid identity" + ) + local_runtime_release.append( + LocalRuntimeRelease( + target=target, + admission_id=admission_id, + admission_sha256=admission_sha256, + release_version=release_version, + release_artifact_sha256=artifact_sha256, + ) + ) + unknown_release_targets = sorted( + set(local_release_tbl).difference(expected_release_targets) + ) + if unknown_release_targets: + raise RunnerConfigError( + "[local_runtime_release] contains unknown target(s): " + + ", ".join(unknown_release_targets) + ) + + def admission_trust_files(table_name: str) -> Optional[AdmissionTrustFiles]: + table = data.get(table_name) + if table is None: + return None + if not isinstance(table, dict) or set(table) != {"signer_registry", "state"}: + raise RunnerConfigError(f"[{table_name}] has an invalid exact shape") + registry = Path(str(table["signer_registry"])).expanduser() + state = Path(str(table["state"])).expanduser() + if not registry.is_file() or not state.is_file(): + raise RunnerConfigError( + f"[{table_name}] trust files must be existing regular files" + ) + return AdmissionTrustFiles(signer_registry=registry, state=state) + + product_release_admission = admission_trust_files("product_release_admission") + workflow_table = data.get("workflow_admission") + workflow_admission = None + if workflow_table is not None: + if not isinstance(workflow_table, dict) or set(workflow_table) != { + "signer_registry", + "state", + "expected_bindings", + }: + raise RunnerConfigError("[workflow_admission] has an invalid exact shape") + workflow_paths = { + key: Path(str(workflow_table[key])).expanduser() + for key in ("signer_registry", "state", "expected_bindings") + } + if any(not path.is_file() for path in workflow_paths.values()): + raise RunnerConfigError( + "[workflow_admission] trust files must be existing regular files" + ) + workflow_admission = WorkflowAdmissionTrustFiles( + signer_registry=workflow_paths["signer_registry"], + state=workflow_paths["state"], + expected_bindings=workflow_paths["expected_bindings"], + ) + + params_tbl = data.get("params") + params_ref_root = None + if params_tbl is not None: + if not isinstance(params_tbl, dict) or set(params_tbl) != {"protected_root"}: + raise RunnerConfigError("[params] has an invalid exact shape") + params_ref_root = Path(str(params_tbl["protected_root"])).expanduser() + if not params_ref_root.is_dir(): + raise RunnerConfigError( + "params.protected_root must be an existing directory" + ) + + evidence_tbl = data.get("evidence_runner") + evidence_runner_private_key = None + if evidence_tbl is not None: + if not isinstance(evidence_tbl, dict) or set(evidence_tbl) != { + "private_key_file" + }: + raise RunnerConfigError("[evidence_runner] has an invalid exact shape") + evidence_runner_private_key = Path( + str(evidence_tbl["private_key_file"]) + ).expanduser() + if not evidence_runner_private_key.is_file(): + raise RunnerConfigError( + "evidence_runner.private_key_file must be an existing file" + ) + return RunnerConfig( name=name, host=host, @@ -244,4 +491,9 @@ def load_runner_config(path: Optional[Path] = None) -> RunnerConfig: bundles=bundles, backends=tuple(str(b).strip() for b in backends_raw), business_decisions=business_decisions, + local_runtime_release=tuple(local_runtime_release), + product_release_admission=product_release_admission, + workflow_admission=workflow_admission, + params_ref_root=params_ref_root, + evidence_runner_private_key=evidence_runner_private_key, ) diff --git a/openadapt_flow/runner/hosted_adapter.py b/openadapt_flow/runner/hosted_adapter.py new file mode 100644 index 00000000..45c16fb7 --- /dev/null +++ b/openadapt_flow/runner/hosted_adapter.py @@ -0,0 +1,1356 @@ +"""Strict Flow-owned bridge between a hosted lease and governed execution. + +The Desktop host owns HTTP and credential storage. This module owns every +decision that can authorize or classify execution: admission verification, +local trust, input resolution, one-use reservation, managed child execution, +evidence projection, and terminal verification. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import subprocess +from base64 import b64decode, b64encode +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Literal, Mapping, Protocol, Union +from urllib.parse import urlsplit + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from openadapt_flow.ir import RunReport, Workflow +from openadapt_flow.private_file import ( + PrivateFileAclError, + windows_descriptor_has_private_acl, +) +from openadapt_flow.production_qualification import ( + ProductionQualificationAuthority, + ProductionQualificationGuard, + _read_private_json, +) +from openadapt_flow.qualification_admission_v2 import ( + QualificationAdmissionEnvelope, + QualificationAdmissionExpected, + QualificationSignerRegistry, + contract_sha256, + verify_qualification_admission, +) +from openadapt_flow.runner.commands import build_run_argv +from openadapt_flow.runner.config import RunnerConfig, load_runner_config +from openadapt_flow.runner.dispatch_envelope import write_managed_dispatch_envelope +from openadapt_flow.runner.evidence import failure_events, refusal_events, report_events +from openadapt_flow.runner.inputs import resolve_admitted_params +from openadapt_flow.runner.product_release import ( + ProductReleaseAdmissionArtifact, + ProductReleaseAdmissionPayload, + load_product_release_signer_trust, + verify_product_release_admission, +) +from openadapt_flow.runner.protocol import DispatchParamsValues, RunnerDispatchPayload +from openadapt_flow.runner.verify import Refusal, RefusalCode, verify_dispatch +from openadapt_flow.runtime.durable.authority import ( + REMOTE_AUTHORITY_TOKEN_ENV, + REMOTE_AUTHORITY_URL_ENV, + REMOTE_DISPATCH_SESSION_ID_ENV, +) +from openadapt_flow.terminal_verification_v2 import ( + ProductionTerminalVerificationEnvelope, + evidence_runner_signer_sha256, +) +from openadapt_flow.transaction import ( + DuplicateActuation, + IdempotencyLedger, + TransactionOutcome, + classify_transaction_outcome, +) + +_UUID = r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +_HEX64 = r"^[a-f0-9]{64}$" +_IDEMPOTENCY = r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,199}$" +_LEASE_TOKEN = r"^oal_[a-f0-9]{64}$" +_RUNNER_TOKEN = r"^oar_[a-f0-9]{64}$" +_SAFE_ID = r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$" +_UTC_SECONDS = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") +_MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 + + +def _utc_seconds(value: str, *, label: str) -> datetime: + if _UTC_SECONDS.fullmatch(value) is None: + raise ValueError(f"{label} is not canonical UTC seconds") + try: + return datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ValueError(f"{label} is not canonical UTC seconds") from exc + + +class _Closed(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid", frozen=True) + + +class LocalRuntimeReleaseBinding(_Closed): + target: Literal["flow", "desktop", "capture"] + admission_id: str = Field(pattern=_UUID) + admission_sha256: str = Field(pattern=_HEX64) + release_version: str = Field(pattern=_SAFE_ID) + release_artifact_sha256: str = Field(pattern=_HEX64) + + +CapabilityKind = Literal[ + "web", "windows", "macos", "linux", "rdp", "citrix", "rdp_window" +] + + +class RegisterCapabilities(_Closed): + backends: tuple[CapabilityKind, ...] = Field(min_length=1, max_length=16) + attended: bool + effects_substrates: tuple[CapabilityKind, ...] = Field(min_length=1, max_length=16) + + @model_validator(mode="after") + def _closed_capabilities(self) -> "RegisterCapabilities": + for label, values in ( + ("backend", self.backends), + ("effect substrate", self.effects_substrates), + ): + if len(values) != len(set(values)): + raise ValueError(f"runner {label} capabilities are invalid") + return self + + +class RegisterRequest(_Closed): + schema_version: Literal["openadapt.hosted-runner-registration/v1"] = ( + "openadapt.hosted-runner-registration/v1" + ) + name: str = Field(min_length=1, max_length=80) + platform: Literal["windows", "macos", "linux"] + agent_version: str = Field(min_length=1, max_length=40) + engine_version: str = Field(min_length=1, max_length=40) + mode: Literal["attended", "service"] + capabilities: RegisterCapabilities + local_runtime_release: dict[ + Literal["flow", "desktop", "capture"], LocalRuntimeReleaseBinding + ] + + @model_validator(mode="after") + def _exact_local_targets(self) -> "RegisterRequest": + if set(self.local_runtime_release) != {"flow", "desktop", "capture"} or any( + key != item.target for key, item in self.local_runtime_release.items() + ): + raise ValueError( + "local runtime release targets must be flow, desktop, capture" + ) + return self + + +class RegisterResponse(_Closed): + schema_version: Literal["openadapt.hosted-runner-registration-result/v1"] + runner_id: str = Field(pattern=_UUID) + tenant_id: str = Field(pattern=_UUID) + runner_session_id: str = Field(pattern=_UUID) + runner_token: str = Field(pattern=_RUNNER_TOKEN, repr=False) + token_expires_at: str + + @model_validator(mode="after") + def _canonical_expiry(self) -> "RegisterResponse": + _utc_seconds(self.token_expires_at, label="runner token expiry") + return self + + +class PollRequest(_Closed): + schema_version: Literal["openadapt.hosted-runner-poll/v1"] = ( + "openadapt.hosted-runner-poll/v1" + ) + runner_session_id: str = Field(pattern=_UUID) + wait_seconds: int = Field(ge=0, le=25) + lease_seconds: int = Field(ge=1, le=900) + + +class AdmissionArtifactBytes(_Closed): + artifact_bytes_base64: str = Field(min_length=4, max_length=2_796_204) + artifact_sha256: str = Field(pattern=_HEX64) + + def decode(self) -> bytes: + try: + raw = b64decode(self.artifact_bytes_base64, validate=True) + except ValueError as exc: + raise ValueError("admission artifact is not canonical base64") from exc + if len(raw) > _MAX_ARTIFACT_BYTES: + raise ValueError("admission artifact exceeds the size limit") + if b64encode(raw).decode("ascii") != self.artifact_bytes_base64: + raise ValueError("admission artifact is not canonical base64") + if hashlib.sha256(raw).hexdigest() != self.artifact_sha256: + raise ValueError("admission artifact digest does not match its bytes") + return raw + + @model_validator(mode="after") + def _bytes_match_digest(self) -> "AdmissionArtifactBytes": + self.decode() + return self + + +class HostedDispatch(_Closed): + schema_version: Literal["openadapt.hosted-runner/v1"] + dispatch_id: str = Field(pattern=_UUID) + tenant_id: str = Field(pattern=_UUID) + runner_id: str = Field(pattern=_UUID) + runner_session_id: str = Field(pattern=_UUID) + dispatch_session_id: str = Field(pattern=_UUID) + run_id: str = Field(pattern=_UUID) + workflow_id: str = Field(pattern=_UUID) + workflow_version_id: str = Field(pattern=_UUID) + idempotency_key: str = Field(pattern=_IDEMPOTENCY) + lease_token: str = Field(pattern=_LEASE_TOKEN, repr=False) + lease_expires_at: str + product_release_admission: AdmissionArtifactBytes + workflow_admission: AdmissionArtifactBytes + managed_delivery_authority_url: str = Field(min_length=1, max_length=2048) + delivery_authority_token: str = Field(pattern=_HEX64, repr=False) + payload: RunnerDispatchPayload + + @model_validator(mode="after") + def _exact_run_binding(self) -> "HostedDispatch": + _utc_seconds(self.lease_expires_at, label="hosted lease expiry") + if ( + self.payload.run_id != self.run_id + or self.payload.workflow_id != self.workflow_id + ): + raise ValueError("hosted lease identity does not match its payload") + if self.payload.bundle.version_id != self.workflow_version_id: + raise ValueError("hosted lease workflow version does not match its bundle") + return self + + +class HostedRecoveryBinding(_Closed): + """Callback state without params or the delivery-authority credential. + + This projection remains credential-bearing because it retains the lease + token required for the exact terminal callback. + """ + + schema_version: Literal["openadapt.hosted-runner-recovery/v1"] = ( + "openadapt.hosted-runner-recovery/v1" + ) + dispatch_id: str = Field(pattern=_UUID) + runner_session_id: str = Field(pattern=_UUID) + dispatch_session_id: str = Field(pattern=_UUID) + run_id: str = Field(pattern=_UUID) + workflow_id: str = Field(pattern=_UUID) + idempotency_key: str = Field(pattern=_IDEMPOTENCY) + lease_token: str = Field(pattern=_LEASE_TOKEN, repr=False) + product_release_admission_sha256: str = Field(pattern=_HEX64) + workflow_admission_sha256: str = Field(pattern=_HEX64) + bundle_content_digest: str = Field(pattern=_HEX64) + authorization_id: str = Field(min_length=1, max_length=128) + + +class HostedTerminalEvent(_Closed): + schema_version: Literal["openadapt.hosted-runner-terminal/v1"] = ( + "openadapt.hosted-runner-terminal/v1" + ) + run_id: str = Field(pattern=_UUID) + outcome: Literal[ + "VERIFIED", + "HALTED_BEFORE_EFFECT", + "RECONCILIATION_REQUIRED", + "FAILED_PLATFORM", + "CANCELED", + "REJECTED_POLICY", + "COMPLETED_UNVERIFIED", + "ROLLED_BACK", + ] + report_sha256: str = Field(pattern=_HEX64) + started: bool + uncertain_delivery: bool + terminal_verification_artifact_bytes_base64: str | None = Field( + default=None, max_length=2_796_204 + ) + terminal_verification_artifact_sha256: str | None = Field( + default=None, pattern=_HEX64 + ) + + @model_validator(mode="after") + def _verified_requires_exact_proof(self) -> "HostedTerminalEvent": + has_proof = self.terminal_verification_artifact_bytes_base64 is not None + if has_proof != (self.terminal_verification_artifact_sha256 is not None): + raise ValueError("terminal verification binding is incomplete") + if self.outcome == "VERIFIED" and not has_proof: + raise ValueError("VERIFIED requires exact terminal verification") + if self.outcome != "VERIFIED" and has_proof: + raise ValueError("non-VERIFIED callback cannot carry a success proof") + return self + + +class HostedRunResult(_Closed): + kind: Literal["result"] = "result" + dispatch_id: str = Field(pattern=_UUID) + run_id: str = Field(pattern=_UUID) + outcome: TransactionOutcome + evidence_batch: tuple[dict[str, Any], ...] + terminal_verification: ProductionTerminalVerificationEnvelope | None = None + started: bool + uncertain_delivery: bool + report_sha256: str = Field(pattern=_HEX64) + + @model_validator(mode="after") + def _closed_terminal(self) -> "HostedRunResult": + if (self.outcome is TransactionOutcome.VERIFIED) != ( + self.terminal_verification is not None + ): + raise ValueError("only a terminally verified result can be VERIFIED") + if self.uncertain_delivery and self.outcome not in { + TransactionOutcome.RECONCILIATION_REQUIRED, + TransactionOutcome.VERIFIED, + }: + raise ValueError("uncertain delivery has an invalid terminal outcome") + return self + + +class HostedDispatchRefusal(_Closed): + kind: Literal["refusal"] = "refusal" + dispatch_id: str | None = None + run_id: str | None = None + code: str = Field(min_length=1, max_length=64) + detail: str = Field(min_length=1, max_length=400) + evidence_batch: tuple[dict[str, Any], ...] = () + started: Literal[False] = False + uncertain_delivery: Literal[False] = False + outcome: Literal["REJECTED_POLICY"] = "REJECTED_POLICY" + report_sha256: str = Field(default="0" * 64, pattern=_HEX64) + + +class CallbackRequest(_Closed): + schema_version: Literal["openadapt.hosted-runner-callback/v1"] = ( + "openadapt.hosted-runner-callback/v1" + ) + dispatch_id: str = Field(pattern=_UUID) + runner_session_id: str = Field(pattern=_UUID) + idempotency_key: str = Field(pattern=_IDEMPOTENCY) + lease_token: str = Field(pattern=_LEASE_TOKEN, repr=False) + product_release_admission_sha256: str = Field(pattern=_HEX64) + workflow_admission_sha256: str = Field(pattern=_HEX64) + events: tuple[dict[str, Any], ...] = Field(min_length=1, max_length=10_001) + + +class CallbackResponse(_Closed): + schema_version: Literal["openadapt.hosted-runner-callback-result/v1"] + status: Literal["accepted", "duplicate"] + run_id: str = Field(pattern=_UUID) + outcome: TransactionOutcome + dispatch_state: Literal["closed"] + accepted_events: int = Field(ge=0, le=10_001) + + +class HostedRunnerTransport(Protocol): + """Desktop-owned HTTP surface. Credentials stay in its transport state.""" + + def register(self, request: RegisterRequest) -> RegisterResponse: ... + + def poll(self, request: PollRequest) -> HostedDispatch | None: ... + + def callback(self, run_id: str, request: CallbackRequest) -> CallbackResponse: ... + + +@dataclass(frozen=True) +class DeliveryAuthority: + """Run-scoped configuration for the existing per-input-edge authority path.""" + + url: str + token: str = field(repr=False) + + def __post_init__(self) -> None: + try: + parsed = urlsplit(self.url) + except ValueError as exc: + raise ValueError("managed delivery authority URL is invalid") from exc + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path != "/api/internal/managed-delivery-permit" + ): + raise ValueError( + "managed delivery authority URL is not a pinned HTTPS edge" + ) + if re.fullmatch(_HEX64, self.token) is None: + raise ValueError("managed delivery authority token is invalid") + + def child_environment(self) -> dict[str, str]: + return { + REMOTE_AUTHORITY_URL_ENV: self.url, + REMOTE_AUTHORITY_TOKEN_ENV: self.token, + } + + +@dataclass(frozen=True) +class ManagedExecution: + returncode: int + report_bytes: bytes | None + terminal_verification: ProductionTerminalVerificationEnvelope | None = None + + +ManagedRunner = Callable[[list[str], Path, Mapping[str, str]], ManagedExecution] + + +def _subprocess_runner( + argv: list[str], run_dir: Path, child_env: Mapping[str, str] +) -> ManagedExecution: + process = subprocess.run( # nosec - argv is built from verified local material + argv, + capture_output=True, + text=True, + env=dict(child_env), + ) + report_path = run_dir / "report.json" + report_bytes = report_path.read_bytes() if report_path.is_file() else None + proof_path = run_dir / "production-terminal-verification.json" + proof = None + if proof_path.is_file(): + proof = ProductionTerminalVerificationEnvelope.model_validate_json( + proof_path.read_bytes() + ) + return ManagedExecution(process.returncode, report_bytes, proof) + + +class HostedRunnerAdapter: + def __init__( + self, + ledger_path: Path, + *, + runner: ManagedRunner = _subprocess_runner, + ) -> None: + self.ledger_path = Path(ledger_path) + self._ledger = IdempotencyLedger( + self.ledger_path, namespace="openadapt-hosted-runner/v1" + ) + self._runner = runner + self._release_state_path = self.ledger_path.with_suffix( + self.ledger_path.suffix + ".product-release.json" + ) + + @staticmethod + def _protected_runner_origin(config: RunnerConfig) -> str: + raw = config.host + if raw is None: + raise ValueError("hosted runner requires a protected runner host origin") + try: + parsed = urlsplit(raw) + port = parsed.port + except ValueError as exc: + raise ValueError("protected runner host origin is invalid") from exc + canonical = f"https://{parsed.netloc}" + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.hostname != parsed.hostname.lower() + or parsed.netloc != parsed.netloc.lower() + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or port == 443 + or raw != canonical + ): + raise ValueError("protected runner host is not one canonical HTTPS origin") + return canonical + + def registration_request( + self, + *, + runner_config: Path, + name: str, + platform: str, + agent_version: str, + engine_version: str, + mode: str, + capabilities: RegisterCapabilities | Mapping[str, object], + ) -> RegisterRequest: + config = load_runner_config(runner_config, protected=True) + self._protected_runner_origin(config) + releases = config.local_runtime_release + if tuple(item.target for item in releases) != ("flow", "desktop", "capture"): + raise ValueError( + "hosted registration requires exact flow, desktop, and capture releases" + ) + if not isinstance(capabilities, RegisterCapabilities): + if not isinstance(capabilities, Mapping) or set(capabilities) != { + "backends", + "attended", + "effects_substrates", + }: + raise ValueError("runner capabilities have an invalid exact shape") + backends = capabilities["backends"] + effects = capabilities["effects_substrates"] + attended = capabilities["attended"] + if ( + not isinstance(backends, (list, tuple)) + or not isinstance(effects, (list, tuple)) + or type(attended) is not bool + ): + raise ValueError("runner capabilities have an invalid exact shape") + capabilities = RegisterCapabilities( + backends=tuple(backends), + attended=attended, + effects_substrates=tuple(effects), + ) + return RegisterRequest( + name=name, + platform=platform, + agent_version=agent_version, + engine_version=engine_version, + mode=mode, + capabilities=capabilities, + local_runtime_release={ + item.target: LocalRuntimeReleaseBinding(**item.__dict__) + for item in releases + }, + ) + + @staticmethod + def _load_json(path: Path) -> object: + try: + return _read_private_json(path) + except (OSError, ValueError) as exc: + raise ValueError(f"admission trust state {path} is invalid") from exc + + @staticmethod + def _read_private_bytes(path: Path, *, maximum_bytes: int, label: str) -> bytes: + """Read one owner-only regular file without following a final link.""" + + path = Path(path) + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + path_before = path.lstat() + if not stat.S_ISREG(path_before.st_mode) or stat.S_ISLNK( + path_before.st_mode + ): + raise ValueError(f"{label} is not a private regular file") + descriptor = os.open(path, flags) + except OSError as exc: + raise ValueError(f"{label} could not be opened safely") from exc + try: + before = os.fstat(descriptor) + try: + private_permissions = ( + windows_descriptor_has_private_acl(descriptor) + if os.name == "nt" + else ( + before.st_uid == os.geteuid() + and stat.S_IMODE(before.st_mode) == 0o600 + ) + ) + except PrivateFileAclError as exc: + raise ValueError(f"{label} ACL could not be verified") from exc + if ( + not stat.S_ISREG(before.st_mode) + or before.st_size > maximum_bytes + or not private_permissions + ): + raise ValueError(f"{label} is not a private regular file") + chunks: list[bytes] = [] + remaining = min(before.st_size, maximum_bytes) + 1 + while remaining: + chunk = os.read(descriptor, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + after = os.fstat(descriptor) + try: + path_after = path.lstat() + except OSError as exc: + raise ValueError(f"{label} changed during its protected read") from exc + if ( + len(raw) != before.st_size + or len(raw) > maximum_bytes + or stat.S_ISLNK(path_after.st_mode) + or (path_before.st_dev, path_before.st_ino) + != (before.st_dev, before.st_ino) + or (path_after.st_dev, path_after.st_ino) + != (before.st_dev, before.st_ino) + or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) + ): + raise ValueError(f"{label} changed during its protected read") + return raw + finally: + os.close(descriptor) + + def _load_evidence_private_key(self, config: RunnerConfig) -> Ed25519PrivateKey: + path = config.evidence_runner_private_key + if path is None: + raise ValueError("hosted runner has no evidence-runner private key") + raw = self._read_private_bytes( + path, maximum_bytes=4096, label="evidence-runner private key" + ) + try: + if len(raw) == 32: + key = Ed25519PrivateKey.from_private_bytes(raw) + else: + loaded = serialization.load_pem_private_key(raw, password=None) + if not isinstance(loaded, Ed25519PrivateKey): + raise ValueError("evidence-runner key is not Ed25519") + key = loaded + except (TypeError, ValueError) as exc: + raise ValueError("evidence-runner private key is invalid") from exc + return key + + def _accept_newest_product_sequence( + self, payload: ProductReleaseAdmissionPayload, artifact_sha256: str + ) -> None: + current: dict[str, object] | None = None + if self._release_state_path.exists(): + metadata = self._release_state_path.lstat() + if not stat.S_ISREG(metadata.st_mode) or ( + os.name != "nt" and stat.S_IMODE(metadata.st_mode) != 0o600 + ): + raise ValueError("product release sequence ledger is unsafe") + loaded = self._load_json(self._release_state_path) + if not isinstance(loaded, dict): + raise ValueError("product release sequence ledger is invalid") + current = loaded + if current is not None: + sequence = current.get("sequence") + digest = current.get("artifact_sha256") + if not isinstance(sequence, int) or not isinstance(digest, str): + raise ValueError("product release sequence ledger is invalid") + if payload.sequence < sequence: + raise ValueError("product release admission sequence is stale") + if payload.sequence == sequence and artifact_sha256 != digest: + raise ValueError("product release admission changed at one sequence") + if payload.sequence == sequence: + return + raw = json.dumps( + {"sequence": payload.sequence, "artifact_sha256": artifact_sha256}, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + self._release_state_path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._release_state_path.with_suffix( + self._release_state_path.suffix + ".tmp" + ) + descriptor = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.write(descriptor, raw) + os.fsync(descriptor) + finally: + os.close(descriptor) + os.replace(tmp, self._release_state_path) + if os.name != "nt": + os.chmod(self._release_state_path, 0o600) + + def _verify_product_release( + self, dispatch: HostedDispatch, config: RunnerConfig + ) -> ProductReleaseAdmissionPayload: + trust_files = config.product_release_admission + if trust_files is None: + raise ValueError("hosted runner has no product release admission trust") + raw = dispatch.product_release_admission.decode() + artifact = ProductReleaseAdmissionArtifact.model_validate_json(raw) + if ( + artifact.artifact_sha256() + != dispatch.product_release_admission.artifact_sha256 + ): + raise ValueError("product release artifact canonical digest changed") + trust = load_product_release_signer_trust( + self._load_json(trust_files.signer_registry) + ) + state = self._load_json(trust_files.state) + if not isinstance(state, dict) or set(state) != { + "newest_sequence", + "revoked_set_ids", + }: + raise ValueError("product release authority state is invalid") + newest = state["newest_sequence"] + revoked = state["revoked_set_ids"] + if ( + not isinstance(newest, int) + or not isinstance(revoked, list) + or any(not isinstance(item, str) for item in revoked) + ): + raise ValueError("product release authority state is invalid") + payload = verify_product_release_admission( + artifact, + trusted_signers=trust, + newest_sequence=newest, + revoked_set_ids=frozenset(revoked), + ) + local = {item.target: item for item in config.local_runtime_release} + if set(local) != {"flow", "desktop", "capture"}: + raise ValueError("hosted runner local release inventory is incomplete") + admitted = {item.target: item for item in payload.targets} + for target, installed in local.items(): + item = admitted[target] + if ( + installed.admission_id, + installed.admission_sha256, + installed.release_version, + installed.release_artifact_sha256, + ) != ( + item.admission_id, + item.admission_sha256, + item.release_id, + item.release_artifact_sha256, + ): + raise ValueError(f"local {target} release is not exactly admitted") + self._accept_newest_product_sequence( + payload, dispatch.product_release_admission.artifact_sha256 + ) + return payload + + def _verify_workflow_admission( + self, + dispatch: HostedDispatch, + config: RunnerConfig, + *, + evidence_private_key: Ed25519PrivateKey, + ) -> tuple[ProductionQualificationAuthority, bytes]: + trust_files = config.workflow_admission + if trust_files is None: + raise ValueError("hosted runner has no workflow admission trust") + raw = dispatch.workflow_admission.decode() + envelope = QualificationAdmissionEnvelope.model_validate_json(raw) + canonical = json.dumps( + envelope.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + if canonical != raw or ( + envelope.artifact_sha256() != dispatch.workflow_admission.artifact_sha256 + ): + raise ValueError("workflow admission canonical digest changed") + authorization = dispatch.payload.authorization + local_fields = tuple( + name + for name in authorization.model_fields + if name.startswith("production_qualification_") + ) + ("qualification_admission", "qualification_admission_sha256") + if any(getattr(authorization, name) is not None for name in local_fields): + raise ValueError("dispatch authorization supplies runner-local authority") + state = self._load_json(trust_files.state) + if not isinstance(state, dict) or set(state) != {"revoked_admission_ids"}: + raise ValueError("workflow admission authority state is invalid") + revoked = state["revoked_admission_ids"] + if not isinstance(revoked, list) or any( + not isinstance(item, str) for item in revoked + ): + raise ValueError("workflow admission authority state is invalid") + registry_raw = self._load_json(trust_files.signer_registry) + registry = QualificationSignerRegistry.model_validate(registry_raw) + if registry.model_dump(mode="json") != registry_raw: + raise ValueError("workflow signer registry is not canonical") + expected_raw = self._load_json(trust_files.expected_bindings) + expected = QualificationAdmissionExpected.model_validate(expected_raw) + if expected.model_dump(mode="json") != expected_raw: + raise ValueError("workflow admission expected bindings are not canonical") + + trusted = config.bundles.get(dispatch.payload.bundle.content_digest) + if trusted is None or trusted.artifact_sha256 is None: + raise ValueError("hosted bundle lacks its local artifact digest pin") + workflow = Workflow.load(trusted.path) + manifest = workflow.manifest + project = workflow.qualification + if manifest is None or project is None: + raise ValueError("hosted workflow is not sealed and qualified") + template = manifest.provenance.governed_authorization_template + if template is None: + raise ValueError("hosted workflow lacks its governed template") + profile_path = config.profiles.get(dispatch.payload.deployment_profile_id) + if profile_path is None: + raise ValueError("hosted workflow profile is not locally configured") + deployment_bytes = self._read_private_bytes( + profile_path, + maximum_bytes=1024 * 1024, + label="hosted deployment profile", + ) + deployment_sha256 = hashlib.sha256(deployment_bytes).hexdigest() + effect_contract_sha256 = contract_sha256( + [ + item.model_dump(mode="json") + for item in template.qualified_effect_requirements + ] + ) + public_key = evidence_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + evidence_key_sha256 = evidence_runner_signer_sha256(public_key) + local_flow = next( + (item for item in config.local_runtime_release if item.target == "flow"), + None, + ) + if local_flow is None: + raise ValueError("hosted runner lacks its local Flow release binding") + local_expected = { + "tenant_id": dispatch.tenant_id, + "workflow_id": dispatch.workflow_id, + "workflow_version_id": dispatch.workflow_version_id, + "bundle_version_id": dispatch.workflow_version_id, + "bundle_artifact_sha256": trusted.artifact_sha256, + "bundle_content_digest": manifest.content_digest, + "environment_digest": project.environment.environment_digest, + "governed_authorization_template_sha256": template.template_sha256, + "environment_contract_sha256": ( + template.qualification_environment_contract_sha256 + ), + "input_policy_sha256": template.parameter_contract_sha256, + "action_policy_sha256": template.qualification_project_contract_sha256, + "identity_contract_sha256": template.identity_contract_sha256, + "effect_contract_sha256": effect_contract_sha256, + "evidence_runner_signer_sha256": evidence_key_sha256, + "deployment_manifest_sha256": deployment_sha256, + } + mismatches = sorted( + name + for name, value in local_expected.items() + if getattr(expected, name) != value + ) + runtime = expected.runtime_build_identity + if ( + runtime.flow_version != local_flow.release_version + or runtime.flow_wheel_sha256 != local_flow.release_artifact_sha256 + ): + mismatches.append("runtime_build_identity") + if mismatches: + raise ValueError( + "local workflow admission expectation differs from live state: " + + ", ".join(sorted(set(mismatches))) + ) + verify_qualification_admission( + envelope, + registry=registry, + expected=expected, + revoked_admission_ids=frozenset(revoked), + ) + return ( + ProductionQualificationAuthority( + qualification_admission=envelope, + qualification_admission_sha256=envelope.artifact_sha256(), + expected=expected, + qualification_signer_registry=registry, + qualification_signer_registry_sha256=registry.artifact_sha256(), + permit_trust_snapshot=None, + revoked_admission_ids=tuple(sorted(set(revoked))), + ), + deployment_bytes, + ) + + @staticmethod + def _write_private_json( + path: Path, value: BaseModel | Mapping[str, object] + ) -> Path: + payload: object = ( + value.model_dump(mode="json") if isinstance(value, BaseModel) else value + ) + raw = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + os.write(descriptor, raw) + os.fsync(descriptor) + finally: + os.close(descriptor) + return path + + @staticmethod + def _write_private_bytes(path: Path, raw: bytes) -> Path: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + offset = 0 + while offset < len(raw): + written = os.write(descriptor, raw[offset:]) + if written <= 0: + raise OSError("protected file write did not make progress") + offset += written + os.fsync(descriptor) + finally: + os.close(descriptor) + return path + + def _resolve_params( + self, + dispatch: HostedDispatch, + config: RunnerConfig, + ) -> dict[str, str]: + trusted = config.bundles.get(dispatch.payload.bundle.content_digest) + if trusted is None: + raise ValueError("hosted bundle is not locally trusted") + workflow = Workflow.load(trusted.path) + if isinstance(dispatch.payload.params, DispatchParamsValues): + supplied = dict(dispatch.payload.params.values) + inline = True + else: + root = config.params_ref_root + if root is None: + raise ValueError("parameter reference has no protected local root") + ref = dispatch.payload.params.ref + parsed_url = urlsplit(ref) + relative = PurePosixPath(ref) + if ( + parsed_url.scheme + or parsed_url.netloc + or parsed_url.query + or parsed_url.fragment + or relative.is_absolute() + or not relative.parts + or any(part in {"", ".", ".."} for part in relative.parts) + or "\\" in ref + ): + raise ValueError("parameter reference is not a safe local path") + root_stat = root.lstat() + if ( + not stat.S_ISDIR(root_stat.st_mode) + or stat.S_ISLNK(root_stat.st_mode) + or ( + os.name != "nt" + and ( + root_stat.st_uid != os.geteuid() + or stat.S_IMODE(root_stat.st_mode) & 0o077 + ) + ) + ): + raise ValueError("parameter reference root is not protected") + current = root + for component in relative.parts[:-1]: + current /= component + component_stat = current.lstat() + if ( + not stat.S_ISDIR(component_stat.st_mode) + or stat.S_ISLNK(component_stat.st_mode) + or ( + os.name != "nt" + and ( + component_stat.st_uid != os.geteuid() + or stat.S_IMODE(component_stat.st_mode) & 0o077 + ) + ) + ): + raise ValueError("parameter reference traverses an unsafe path") + raw = self._read_private_bytes( + root.joinpath(*relative.parts), + maximum_bytes=256 * 1024, + label="parameter reference", + ) + try: + supplied = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("parameter reference is not valid JSON") from exc + if not isinstance(supplied, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in supplied.items() + ): + raise ValueError("parameter reference has an invalid exact shape") + inline = False + return resolve_admitted_params(workflow, supplied, inline=inline) + + @staticmethod + def _write_params(path: Path, params: dict[str, str]) -> Path | None: + if not params: + return None + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + raw = json.dumps(params, sort_keys=True, separators=(",", ":")).encode() + os.write(descriptor, raw) + os.fsync(descriptor) + finally: + os.close(descriptor) + return path + + @staticmethod + def _validate_terminal( + dispatch: HostedDispatch, + report_bytes: bytes, + proof: ProductionTerminalVerificationEnvelope, + ) -> None: + del dispatch, report_bytes, proof + raise ValueError( + "hosted production success requires a locally produced proof from " + "independent expected state and retained delivery receipts" + ) + + @staticmethod + def _refusal( + dispatch: HostedDispatch | None, code: str, detail: str + ) -> HostedDispatchRefusal: + events: tuple[dict[str, Any], ...] = () + if dispatch is not None: + refusal = Refusal(RefusalCode.MALFORMED_DISPATCH, detail[:300]) + events = tuple( + refusal_events( + refusal, + run_id=dispatch.run_id, + workflow_id=dispatch.workflow_id, + bundle_digest=dispatch.payload.bundle.content_digest, + authorization_id=dispatch.payload.authorization.authorization_id, + ) + ) + return HostedDispatchRefusal( + dispatch_id=dispatch.dispatch_id if dispatch else None, + run_id=dispatch.run_id if dispatch else None, + code=code, + detail=detail[:400], + evidence_batch=events, + ) + + @staticmethod + def recovery_binding(dispatch: HostedDispatch) -> HostedRecoveryBinding: + """Project the exact credential-bearing state needed after a crash.""" + + return HostedRecoveryBinding( + dispatch_id=dispatch.dispatch_id, + runner_session_id=dispatch.runner_session_id, + dispatch_session_id=dispatch.dispatch_session_id, + run_id=dispatch.run_id, + workflow_id=dispatch.workflow_id, + idempotency_key=dispatch.idempotency_key, + lease_token=dispatch.lease_token, + product_release_admission_sha256=( + dispatch.product_release_admission.artifact_sha256 + ), + workflow_admission_sha256=dispatch.workflow_admission.artifact_sha256, + bundle_content_digest=dispatch.payload.bundle.content_digest, + authorization_id=dispatch.payload.authorization.authorization_id, + ) + + def reconciliation_required( + self, + binding: HostedRecoveryBinding | Mapping[str, object], + *, + code: str = "runner_result_lost", + ) -> HostedRunResult: + """Close a crash window without re-entering the execution path.""" + + parsed = HostedRecoveryBinding.model_validate(binding) + if ( + not code + or len(code) > 64 + or any( + character not in "abcdefghijklmnopqrstuvwxyz0123456789_" + for character in code + ) + ): + raise ValueError("reconciliation code is invalid") + return HostedRunResult( + dispatch_id=parsed.dispatch_id, + run_id=parsed.run_id, + outcome=TransactionOutcome.RECONCILIATION_REQUIRED, + evidence_batch=tuple( + failure_events( + run_id=parsed.run_id, + bundle_digest=parsed.bundle_content_digest, + authorization_id=parsed.authorization_id, + ) + ), + started=True, + uncertain_delivery=True, + report_sha256="0" * 64, + ) + + def execute( + self, + dispatch: HostedDispatch | Mapping[str, object], + *, + runner_config: Path, + run_dir: Path, + authority: DeliveryAuthority, + ) -> Union[HostedRunResult, HostedDispatchRefusal]: + parsed: HostedDispatch | None = None + try: + parsed = HostedDispatch.model_validate(dispatch) + expiry = _utc_seconds(parsed.lease_expires_at, label="hosted lease expiry") + if datetime.now(timezone.utc) >= expiry: + raise ValueError("hosted lease expired before execution") + if ( + authority.url != parsed.managed_delivery_authority_url + or authority.token != parsed.delivery_authority_token + ): + raise ValueError("delivery authority does not match the hosted lease") + config = load_runner_config(runner_config, protected=True) + configured_origin = self._protected_runner_origin(config) + authority_host = urlsplit(authority.url) + if ( + configured_origin + != f"{authority_host.scheme}://{authority_host.netloc}" + ): + raise ValueError( + "delivery authority origin differs from the protected runner host" + ) + self._verify_product_release(parsed, config) + evidence_private_key = self._load_evidence_private_key(config) + qualification, deployment_bytes = self._verify_workflow_admission( + parsed, + config, + evidence_private_key=evidence_private_key, + ) + params = self._resolve_params(parsed, config) + run_dir = Path(run_dir) + run_dir.mkdir(parents=True, exist_ok=False, mode=0o700) + if os.name != "nt": + run_dir.chmod(0o700) + run_dir_stat = run_dir.lstat() + if ( + not stat.S_ISDIR(run_dir_stat.st_mode) + or stat.S_ISLNK(run_dir_stat.st_mode) + or ( + os.name != "nt" + and ( + run_dir_stat.st_uid != os.geteuid() + or stat.S_IMODE(run_dir_stat.st_mode) != 0o700 + ) + ) + ): + raise ValueError("hosted run directory is not protected") + profile_path = self._write_private_bytes( + run_dir / "deployment.yaml", deployment_bytes + ) + staged_deployment_bytes = self._read_private_bytes( + profile_path, + maximum_bytes=1024 * 1024, + label="staged hosted deployment profile", + ) + if staged_deployment_bytes != deployment_bytes: + raise ValueError("staged hosted deployment profile changed") + staged_profiles = dict(config.profiles) + staged_profiles[parsed.payload.deployment_profile_id] = profile_path + staged_config = replace(config, profiles=staged_profiles) + verified = verify_dispatch( + parsed.payload, + staged_config, + resolved_params=params, + ) + if isinstance(verified, Refusal): + return self._refusal(parsed, verified.code.value, verified.reason()) + except Exception as exc: + return self._refusal( + parsed, + "hosted_admission_refused", + f"prestart_{type(exc).__name__}", + ) + + reservation_key = f"{parsed.tenant_id}:{parsed.idempotency_key}" + try: + self._ledger.reserve(reservation_key, run_id=parsed.run_id) + except DuplicateActuation: + return self.reconciliation_required( + self.recovery_binding(parsed), code="dispatch_already_consumed" + ) + + try: + params_file = self._write_params(run_dir / "params.json", params) + qualification_authority_file = self._write_private_json( + run_dir / "qualification-authority.json", qualification + ) + guard = ProductionQualificationGuard( + qualification_authority_file, + remote_permit_revalidation=True, + ) + production_binding = guard.authorization_binding(verified.workflow) + local_authorization = verified.payload.authorization.model_copy( + update=production_binding + ) + local_payload = verified.payload.model_copy( + update={"authorization": local_authorization} + ) + verified = replace(verified, payload=local_payload) + dispatch_file = write_managed_dispatch_envelope( + run_dir / "managed-dispatch.json", verified + ) + argv = build_run_argv( + verified, + run_dir, + params_file, + managed_dispatch_file=dispatch_file, + qualification_authority_file=qualification_authority_file, + ) + child_env = os.environ.copy() + child_env.pop(REMOTE_AUTHORITY_URL_ENV, None) + child_env.pop(REMOTE_AUTHORITY_TOKEN_ENV, None) + child_env.pop(REMOTE_DISPATCH_SESSION_ID_ENV, None) + child_env.update(authority.child_environment()) + child_env[REMOTE_DISPATCH_SESSION_ID_ENV] = parsed.dispatch_session_id + except Exception: # preparation failed before the managed child started + self._ledger.record_outcome( + reservation_key, + TransactionOutcome.FAILED_PLATFORM, + run_id=parsed.run_id, + ) + events = tuple( + failure_events( + run_id=parsed.run_id, + bundle_digest=parsed.payload.bundle.content_digest, + authorization_id=parsed.payload.authorization.authorization_id, + ) + ) + return HostedRunResult( + dispatch_id=parsed.dispatch_id, + run_id=parsed.run_id, + outcome=TransactionOutcome.FAILED_PLATFORM, + evidence_batch=events, + started=False, + uncertain_delivery=False, + report_sha256="0" * 64, + ) + + try: + # Once this call starts, the child can reach a real input edge. Any + # lost or malformed result is uncertain and can never be retried. + execution = self._runner(argv, run_dir, child_env) + except Exception: + outcome = TransactionOutcome.RECONCILIATION_REQUIRED + self._ledger.record_outcome(reservation_key, outcome, run_id=parsed.run_id) + return HostedRunResult( + dispatch_id=parsed.dispatch_id, + run_id=parsed.run_id, + outcome=outcome, + evidence_batch=tuple( + failure_events( + run_id=parsed.run_id, + bundle_digest=parsed.payload.bundle.content_digest, + authorization_id=parsed.payload.authorization.authorization_id, + ) + ), + started=True, + uncertain_delivery=True, + report_sha256="0" * 64, + ) + + if execution.report_bytes is None: + outcome = TransactionOutcome.RECONCILIATION_REQUIRED + self._ledger.record_outcome(reservation_key, outcome, run_id=parsed.run_id) + return HostedRunResult( + dispatch_id=parsed.dispatch_id, + run_id=parsed.run_id, + outcome=outcome, + evidence_batch=tuple( + failure_events( + run_id=parsed.run_id, + bundle_digest=parsed.payload.bundle.content_digest, + authorization_id=parsed.payload.authorization.authorization_id, + ) + ), + started=True, + uncertain_delivery=True, + report_sha256="0" * 64, + ) + report: RunReport | None = None + try: + report = RunReport.model_validate_json(execution.report_bytes) + outcome = classify_transaction_outcome(report) + proof = execution.terminal_verification + if outcome is TransactionOutcome.VERIFIED: + if proof is None: + outcome = TransactionOutcome.RECONCILIATION_REQUIRED + else: + self._validate_terminal(parsed, execution.report_bytes, proof) + elif proof is not None: + raise ValueError("non-VERIFIED execution supplied a success proof") + except ValueError: + outcome = TransactionOutcome.RECONCILIATION_REQUIRED + proof = None + if outcome is not TransactionOutcome.VERIFIED: + proof = None + report_digest = hashlib.sha256(execution.report_bytes).hexdigest() + self._ledger.record_outcome(reservation_key, outcome, run_id=parsed.run_id) + if report is None: + events = tuple( + failure_events( + run_id=parsed.run_id, + bundle_digest=parsed.payload.bundle.content_digest, + authorization_id=parsed.payload.authorization.authorization_id, + ) + ) + else: + events = tuple( + report_events( + report, + run_id=parsed.run_id, + workflow_id=parsed.workflow_id, + bundle_digest=parsed.payload.bundle.content_digest, + authorization_id=parsed.payload.authorization.authorization_id, + consequential_steps=verified.consequential_steps, + effect_covered_consequential_steps=( + verified.effect_covered_consequential_steps + ), + ) + ) + uncertain = outcome is TransactionOutcome.RECONCILIATION_REQUIRED or any( + result.delivery_uncertainty is not None + for result in (report.results if report is not None else ()) + ) + return HostedRunResult( + dispatch_id=parsed.dispatch_id, + run_id=parsed.run_id, + outcome=outcome, + evidence_batch=events, + terminal_verification=proof, + started=True, + uncertain_delivery=uncertain, + report_sha256=report_digest, + ) + + def callback_request( + self, + dispatch: HostedDispatch, + result: HostedRunResult | HostedDispatchRefusal, + ) -> CallbackRequest: + if ( + result.dispatch_id != dispatch.dispatch_id + or result.run_id != dispatch.run_id + ): + raise ValueError("hosted result does not bind the callback lease") + events = list(result.evidence_batch) + proof_bytes = None + proof_digest = None + if isinstance(result, HostedRunResult): + if result.terminal_verification is not None: + raise ValueError( + "the full local v2 proof cannot cross the hosted callback boundary" + ) + terminal = HostedTerminalEvent( + run_id=dispatch.run_id, + outcome=( + result.outcome.value + if isinstance(result.outcome, TransactionOutcome) + else result.outcome + ), + report_sha256=result.report_sha256, + started=result.started, + uncertain_delivery=result.uncertain_delivery, + terminal_verification_artifact_bytes_base64=proof_bytes, + terminal_verification_artifact_sha256=proof_digest, + ) + events.append(terminal.model_dump(mode="json")) + return CallbackRequest( + dispatch_id=dispatch.dispatch_id, + runner_session_id=dispatch.runner_session_id, + idempotency_key=dispatch.idempotency_key, + lease_token=dispatch.lease_token, + product_release_admission_sha256=( + dispatch.product_release_admission.artifact_sha256 + ), + workflow_admission_sha256=dispatch.workflow_admission.artifact_sha256, + events=tuple(events), + ) diff --git a/openadapt_flow/runner/inputs.py b/openadapt_flow/runner/inputs.py new file mode 100644 index 00000000..977fdab8 --- /dev/null +++ b/openadapt_flow/runner/inputs.py @@ -0,0 +1,95 @@ +"""Fail-closed resolution of hosted values from the sealed input schema.""" + +from __future__ import annotations + +import math +from datetime import date + +from openadapt_flow.ir import ParamKind, ParamSpec, Workflow +from openadapt_flow.runtime.authorization import effective_runtime_params + + +class AdmittedInputError(ValueError): + """Hosted inputs do not fit the exact schema sealed into the workflow.""" + + +def _validate_value(spec: ParamSpec, value: str) -> None: + if spec.type is ParamKind.ENUM: + if not spec.choices or value not in spec.choices: + raise AdmittedInputError( + f"parameter {spec.name!r} is outside its admitted enum" + ) + elif spec.type is ParamKind.DATE: + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise AdmittedInputError( + f"parameter {spec.name!r} is not an ISO date" + ) from exc + if parsed.isoformat() != value: + raise AdmittedInputError( + f"parameter {spec.name!r} is not a canonical ISO date" + ) + elif spec.type is ParamKind.NUMBER: + try: + number = float(value) + except ValueError as exc: + raise AdmittedInputError( + f"parameter {spec.name!r} is not a number" + ) from exc + if not math.isfinite(number): + raise AdmittedInputError(f"parameter {spec.name!r} must be a finite number") + + +def resolve_admitted_params( + workflow: Workflow, + supplied: dict[str, str], + *, + inline: bool, +) -> dict[str, str]: + """Resolve exact hosted params without inventing or widening the schema. + + Hosted execution requires the sealed typed schema. Inline input can never + carry a declared secret. Values obtained through a customer-local reference + resolver may carry a secret, but they still pass the same exact name and + type checks before the authorization digest is recomputed. + """ + + if not workflow.param_specs: + raise AdmittedInputError( + "hosted execution requires a sealed typed parameter schema" + ) + if any(name != spec.name for name, spec in workflow.param_specs.items()): + raise AdmittedInputError("the sealed parameter schema has a name mismatch") + + admitted_names = set(workflow.param_specs) + unknown = sorted(set(supplied).difference(admitted_names)) + if unknown: + raise AdmittedInputError( + "hosted input contains parameter(s) outside the admitted schema: " + + ", ".join(unknown) + ) + if inline: + inline_secrets = sorted(set(supplied).intersection(workflow.secret_params)) + if inline_secrets: + raise AdmittedInputError( + "inline hosted input contains declared secret parameter(s): " + + ", ".join(inline_secrets) + ) + + resolved = effective_runtime_params(workflow, supplied) + unknown_defaults = sorted(set(resolved).difference(admitted_names)) + if unknown_defaults: + raise AdmittedInputError( + "workflow defaults fall outside the admitted parameter schema" + ) + for name, spec in sorted(workflow.param_specs.items()): + value = resolved.get(name) + if value is None: + if spec.required: + raise AdmittedInputError(f"required parameter {name!r} is missing") + continue + if not isinstance(value, str): + raise AdmittedInputError(f"parameter {name!r} is not a string value") + _validate_value(spec, value) + return resolved diff --git a/openadapt_flow/runner/product_release.py b/openadapt_flow/runner/product_release.py new file mode 100644 index 00000000..22db9849 --- /dev/null +++ b/openadapt_flow/runner/product_release.py @@ -0,0 +1,267 @@ +"""Verification of the signed seven-target Product release admission.""" + +from __future__ import annotations + +import hashlib +import json +import re +from base64 import b64decode, urlsafe_b64decode, urlsafe_b64encode +from datetime import datetime, timezone +from typing import Literal, Mapping + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +DOMAIN = b"openadapt.product-release-admission-payload.v1\0" +TARGETS = ("agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt") +_HEX64 = r"^[a-f0-9]{64}$" +_UUID = r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +_KEY_ID = r"^release-admission-ed25519-[a-f0-9]{16}$" +_SAFE_ID = r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$" +_UTC = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + + +class ProductReleaseAdmissionError(ValueError): + """The aggregate admission is invalid or inactive.""" + + +class _Closed(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid", frozen=True) + + +def _canonical_json(value: object) -> bytes: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json") + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _utc(value: str, *, field: str) -> datetime: + if _UTC.fullmatch(value) is None: + raise ValueError(f"{field} is not canonical UTC seconds") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ValueError(f"{field} is not UTC seconds") from exc + if parsed.tzinfo is None or parsed.microsecond != 0: + raise ValueError(f"{field} is not UTC seconds") + return parsed.astimezone(timezone.utc) + + +class ProductReleaseTarget(_Closed): + target: Literal["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"] + admission_id: str = Field(pattern=_UUID) + admission_sha256: str = Field(pattern=_HEX64) + release_id: str = Field(pattern=_SAFE_ID) + release_artifact_sha256: str = Field(pattern=_HEX64) + admission_issued_at: str + admission_expires_at: str + revoked_at: str | None + artifact_authority_sha256: str = Field(pattern=_HEX64) + artifact_authority_state: Literal["active", "revoked", "expired", "unavailable"] + artifact_authority_checked_at: str + artifact_authority_expires_at: str + + @model_validator(mode="after") + def _chronology(self) -> "ProductReleaseTarget": + issued = _utc(self.admission_issued_at, field="target admission_issued_at") + expires = _utc(self.admission_expires_at, field="target admission_expires_at") + checked = _utc( + self.artifact_authority_checked_at, + field="target artifact_authority_checked_at", + ) + authority_expires = _utc( + self.artifact_authority_expires_at, + field="target artifact_authority_expires_at", + ) + if issued >= expires or checked >= authority_expires: + raise ValueError("product release target chronology is invalid") + if self.revoked_at is not None: + _utc(self.revoked_at, field="target revoked_at") + return self + + +class ProductReleaseAdmissionPayload(_Closed): + schema_version: Literal["openadapt.product-release-admission-payload/v1"] + set_id: str = Field(pattern=_UUID) + sequence: int = Field(gt=0, le=9_007_199_254_740_991) + policy_sha256: str = Field(pattern=_HEX64) + issued_at: str + expires_at: str + targets: tuple[ProductReleaseTarget, ...] = Field(min_length=7, max_length=7) + + @model_validator(mode="after") + def _closed_set(self) -> "ProductReleaseAdmissionPayload": + issued = _utc(self.issued_at, field="product admission issued_at") + expires = _utc(self.expires_at, field="product admission expires_at") + if issued >= expires: + raise ValueError("product release admission chronology is invalid") + if tuple(item.target for item in self.targets) != TARGETS: + raise ValueError( + "product release admission targets are not exact and ordered" + ) + return self + + def canonical_bytes(self) -> bytes: + return _canonical_json(self) + + def payload_sha256_value(self) -> str: + return hashlib.sha256(DOMAIN + self.canonical_bytes()).hexdigest() + + +class ProductReleaseSigner(_Closed): + algorithm: Literal["ed25519"] + key_id: str = Field(pattern=_KEY_ID) + public_key: str + + @field_validator("public_key") + @classmethod + def _key(cls, value: str) -> str: + try: + raw = b64decode(value, validate=True) + except ValueError as exc: + raise ValueError("product release signer key is invalid") from exc + if len(raw) != 32: + raise ValueError("product release signer key is invalid") + return value + + @model_validator(mode="after") + def _key_id_matches(self) -> "ProductReleaseSigner": + raw = b64decode(self.public_key, validate=True) + expected = "release-admission-ed25519-" + hashlib.sha256(raw).hexdigest()[:16] + if self.key_id != expected: + raise ValueError("product release signer key id is invalid") + return self + + +class ProductReleaseAdmissionArtifact(_Closed): + schema_version: Literal["openadapt.product-release-admission-artifact/v1"] + payload: ProductReleaseAdmissionPayload + payload_sha256: str = Field(pattern=_HEX64) + signer: ProductReleaseSigner + signature: str = Field(min_length=86, max_length=86) + + @model_validator(mode="after") + def _self_consistent(self) -> "ProductReleaseAdmissionArtifact": + if self.payload_sha256 != self.payload.payload_sha256_value(): + raise ValueError("product release admission payload digest is invalid") + try: + signature = urlsafe_b64decode(self.signature + "==") + except ValueError as exc: + raise ValueError("product release admission signature is invalid") from exc + if ( + len(signature) != 64 + or urlsafe_b64encode(signature).decode("ascii").rstrip("=") + != self.signature + ): + raise ValueError("product release admission signature is invalid") + try: + Ed25519PublicKey.from_public_bytes( + b64decode(self.signer.public_key, validate=True) + ).verify(signature, DOMAIN + self.payload.canonical_bytes()) + except (InvalidSignature, ValueError) as exc: + raise ValueError("product release admission signature is invalid") from exc + return self + + def artifact_sha256(self) -> str: + return hashlib.sha256(_canonical_json(self)).hexdigest() + + +class ProductReleaseSignerTrust(_Closed): + public_key: str + status: Literal["active", "revoked"] + revoked_at: str | None + + @model_validator(mode="after") + def _state(self) -> "ProductReleaseSignerTrust": + if self.status == "active" and self.revoked_at is not None: + raise ValueError("active product release signer has a revocation time") + if self.status == "revoked" and self.revoked_at is None: + raise ValueError("revoked product release signer lacks a revocation time") + if self.revoked_at is not None: + _utc(self.revoked_at, field="product release signer revoked_at") + return self + + +def verify_product_release_admission( + artifact: ProductReleaseAdmissionArtifact, + *, + trusted_signers: Mapping[str, ProductReleaseSignerTrust], + newest_sequence: int, + revoked_set_ids: set[str] | frozenset[str] = frozenset(), + now: datetime | None = None, +) -> ProductReleaseAdmissionPayload: + """Verify signature, authority state, time, revocation, and newest sequence.""" + + try: + artifact = ProductReleaseAdmissionArtifact.model_validate_json( + _canonical_json(artifact) + ) + except ValueError as exc: + raise ProductReleaseAdmissionError(str(exc)) from exc + trust = trusted_signers.get(artifact.signer.key_id) + if trust is None or trust.public_key != artifact.signer.public_key: + raise ProductReleaseAdmissionError("product release signer is not trusted") + if trust.status != "active": + raise ProductReleaseAdmissionError("product release signer is revoked") + payload = artifact.payload + if payload.set_id in revoked_set_ids: + raise ProductReleaseAdmissionError("product release admission is revoked") + if payload.sequence != newest_sequence: + raise ProductReleaseAdmissionError("product release admission is superseded") + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if ( + not _utc(payload.issued_at, field="issued_at") + <= current + < _utc(payload.expires_at, field="expires_at") + ): + raise ProductReleaseAdmissionError("product release admission is not active") + for target in payload.targets: + if target.revoked_at is not None: + raise ProductReleaseAdmissionError( + f"product release target {target.target} is revoked" + ) + if target.artifact_authority_state != "active": + raise ProductReleaseAdmissionError( + f"product release target {target.target} authority is not active" + ) + if ( + not _utc(target.admission_issued_at, field="target issued_at") + <= current + < _utc(target.admission_expires_at, field="target expires_at") + ): + raise ProductReleaseAdmissionError( + f"product release target {target.target} admission is not active" + ) + if ( + not _utc(target.artifact_authority_checked_at, field="authority checked_at") + <= current + < _utc(target.artifact_authority_expires_at, field="authority expires_at") + ): + raise ProductReleaseAdmissionError( + f"product release target {target.target} authority is stale" + ) + return payload + + +def load_product_release_signer_trust( + raw: object, +) -> dict[str, ProductReleaseSignerTrust]: + if not isinstance(raw, dict) or not raw: + raise ProductReleaseAdmissionError( + "product release signer trust is unavailable" + ) + try: + parsed = { + str(key): ProductReleaseSignerTrust.model_validate(value) + for key, value in raw.items() + } + except ValueError as exc: + raise ProductReleaseAdmissionError( + "product release signer trust is invalid" + ) from exc + if any(re.fullmatch(_KEY_ID, key) is None for key in parsed): + raise ProductReleaseAdmissionError("product release signer key id is invalid") + return parsed diff --git a/openadapt_flow/runner/protocol.py b/openadapt_flow/runner/protocol.py index f469b603..be85f9f5 100644 --- a/openadapt_flow/runner/protocol.py +++ b/openadapt_flow/runner/protocol.py @@ -43,6 +43,8 @@ #: v2 permit carries its own admission and authority digests. DISPATCH_BINDING_LOCAL_FIELDS = frozenset( { + "qualification_admission", + "qualification_admission_sha256", "production_qualification_admission_id", "production_qualification_admission_sha256", "production_qualification_evidence_identity_sha256", @@ -112,9 +114,13 @@ class DispatchParamsValues(BaseModel): class DispatchParamsRef(BaseModel): - """Regulated-lane params-by-reference. Parsed, but refused in v1: the - local reference resolver does not exist yet, and guessing values would - break the runtime-inputs digest binding.""" + """Regulated-lane reference to protected customer-local parameters. + + The hosted adapter treats ``ref`` only as a relative path under the + operator-configured protected root. It refuses URLs, traversal, and links, + then checks ``expected_digest`` after typed local resolution. Resolved + values stay local and never enter the callback. + """ model_config = ConfigDict(frozen=True, extra="forbid") @@ -133,7 +139,7 @@ class RunnerDispatchPayload(BaseModel): # exact UUID across runner restart and reassignment. run_id: str = Field( pattern=( - "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + "^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" ) ) workflow_id: str diff --git a/openadapt_flow/runner/verify.py b/openadapt_flow/runner/verify.py index 6e644387..8819b0b1 100644 --- a/openadapt_flow/runner/verify.py +++ b/openadapt_flow/runner/verify.py @@ -143,6 +143,7 @@ def verify_dispatch( *, now: Optional[datetime] = None, active_workflow_ids: Optional[set[str]] = None, + resolved_params: Optional[dict[str, str]] = None, ) -> VerifiedDispatch | Refusal: """Independently verify ``payload`` against local trust. Never executes. @@ -194,18 +195,47 @@ def verify_dispatch( "this runner never downloads bundles", ) - if not isinstance(payload.params, DispatchParamsValues): - return Refusal( - RefusalCode.PARAMS_REF_UNSUPPORTED, - "params-by-reference (regulated lane) has no local resolver in v1", - ) - if trusted.params_ref_required: + from openadapt_flow.ir import Workflow + + try: + workflow = Workflow.load(trusted.path) + except Exception as exc: # noqa: BLE001 - crypto/integrity/shape: refuse return Refusal( - RefusalCode.PARAMS_VALUES_REFUSED, - "this bundle requires params-by-reference; inline params.values " - "dispatches are refused on this machine", + RefusalCode.BUNDLE_LOAD_FAILED, + f"trusted bundle failed to load: {type(exc).__name__}", ) - params = dict(payload.params.values) + + if isinstance(payload.params, DispatchParamsValues): + if trusted.params_ref_required: + return Refusal( + RefusalCode.PARAMS_VALUES_REFUSED, + "this bundle requires params-by-reference; inline params.values " + "dispatches are refused on this machine", + ) + params = dict(payload.params.values) + if resolved_params is not None and resolved_params != params: + return Refusal( + RefusalCode.RUNTIME_INPUTS_MISMATCH, + "locally resolved params differ from inline dispatch values", + ) + else: + if resolved_params is None: + return Refusal( + RefusalCode.PARAMS_REF_UNSUPPORTED, + "params-by-reference requires an explicit local resolver", + ) + params = dict(resolved_params) + from openadapt_flow.runtime.authorization import runtime_inputs_digest + + resolved_digest = runtime_inputs_digest(workflow, params, None) + if ( + resolved_digest != payload.params.expected_digest + or resolved_digest != payload.authorization.runtime_inputs_digest + ): + return Refusal( + RefusalCode.RUNTIME_INPUTS_MISMATCH, + "locally resolved parameter values do not match the admitted digest", + ) if trusted.param_patterns: for key in sorted(params): @@ -246,16 +276,6 @@ def verify_dispatch( "contract requires screenshots_may_leave_box=false", ) - from openadapt_flow.ir import Workflow - - try: - workflow = Workflow.load(trusted.path) - except Exception as exc: # noqa: BLE001 - crypto/integrity/shape: refuse - return Refusal( - RefusalCode.BUNDLE_LOAD_FAILED, - f"trusted bundle failed to load: {type(exc).__name__}", - ) - fit_refusal = payload.authorization.validate_workflow(workflow) if fit_refusal is not None: return Refusal(RefusalCode.AUTHORIZATION_MISMATCH, fit_refusal) diff --git a/openadapt_flow/runtime/durable/authority.py b/openadapt_flow/runtime/durable/authority.py index 591853c8..dde650d8 100644 --- a/openadapt_flow/runtime/durable/authority.py +++ b/openadapt_flow/runtime/durable/authority.py @@ -52,9 +52,10 @@ AUTHORITY_DB_ENV = "OPENADAPT_DURABLE_AUTHORITY_DB" REMOTE_AUTHORITY_URL_ENV = "OPENADAPT_DURABLE_AUTHORITY_URL" -# Reuse the enrolled runner credential. The operator configures one trust -# relationship with the control plane, not a second delivery-only secret. +# The managed parent injects a run-scoped delivery-authority credential. Keep +# the established environment name for child-runtime compatibility. REMOTE_AUTHORITY_TOKEN_ENV = "OPENADAPT_RUNNER_TOKEN" +REMOTE_DISPATCH_SESSION_ID_ENV = "OPENADAPT_DURABLE_DISPATCH_SESSION_ID" # This observer exists only for the closed synthetic Execute acceptance run. # A Modal launcher owns the fixed, pre-opened non-blocking pipe at descriptor # three. A bundle, CLI invocation, or remote caller cannot select a path, @@ -70,9 +71,8 @@ JOURNAL_GENESIS_DIGEST = "sha256:" + hashlib.sha256(b"").hexdigest() JOURNAL_MAC_DOMAIN = b"openadapt-attended-journal-v1\0" _REMOTE_UUID_RE = re.compile( - r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-" - r"[89ab][0-9a-f]{3}-[0-9a-f]{12}", - re.IGNORECASE, + r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-" + r"[89ab][0-9a-f]{3}-[0-9a-f]{12}" ) _REMOTE_AUTHORITY_ID_RE = _REMOTE_UUID_RE _REMOTE_TOKEN_RE = re.compile(r"[a-f0-9]{32}") @@ -2006,6 +2006,7 @@ def _require_remote_delivery_permit( ) url = os.getenv(REMOTE_AUTHORITY_URL_ENV, "") token = os.getenv(REMOTE_AUTHORITY_TOKEN_ENV, "") + expected_dispatch_session_id = os.getenv(REMOTE_DISPATCH_SESSION_ID_ENV, "") if not url or not token: raise DurableAuthorityBusy( "production delivery requires configured remote authority credentials" @@ -2121,6 +2122,10 @@ def _require_remote_delivery_permit( and _REMOTE_UUID_RE.fullmatch(response["permit_id"]) and isinstance(response["dispatch_session_id"], str) and _REMOTE_UUID_RE.fullmatch(response["dispatch_session_id"]) + and ( + not expected_dispatch_session_id + or response["dispatch_session_id"] == expected_dispatch_session_id + ) and isinstance(response["one_use_claim_id"], str) and _REMOTE_UUID_RE.fullmatch(response["one_use_claim_id"]) and isinstance(response["permit_artifact_sha256"], str) diff --git a/tests/test_durable_authority_v13.py b/tests/test_durable_authority_v13.py index a9240dea..f484327e 100644 --- a/tests/test_durable_authority_v13.py +++ b/tests/test_durable_authority_v13.py @@ -38,6 +38,7 @@ AUTHORITY_DB_ENV, REMOTE_AUTHORITY_TOKEN_ENV, REMOTE_AUTHORITY_URL_ENV, + REMOTE_DISPATCH_SESSION_ID_ENV, SYNTHETIC_DELIVERY_MARKER_ENABLED_ENV, SYNTHETIC_DELIVERY_MARKER_RUN_ID_ENV, DurableAuthority, @@ -534,6 +535,52 @@ def test_v2_permit_remains_pending_until_signed_receipt_commits_edge( assert entry.runtime_delivery_sequence == 0 +def test_remote_permit_refuses_a_different_hosted_dispatch_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + transport = _issued_permit_transport("wrong-session", "4" * 64) + manifest, authority = _remote_initial_authority(tmp_path, monkeypatch, transport) + monkeypatch.setenv( + REMOTE_DISPATCH_SESSION_ID_ENV, + "30000000-0000-4000-8000-000000000002", + ) + + with pytest.raises(DurableAuthorityBusy, match="does not match request"): + authority.before_initial_delivery(manifest) + + assert authority.validate(manifest).delivery_sequence == 0 + + +def test_lost_delivery_acknowledgment_response_blocks_replay_dispatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + issued = _issued_permit_transport("lost-ack", "5" * 64) + permit_calls = 0 + acknowledgment_calls = 0 + + def transport(url: str, headers: dict[str, str], body: bytes) -> bytes: + nonlocal permit_calls, acknowledgment_calls + response = issued(url, headers, body) + if url.endswith("/managed-delivery-acknowledgment"): + acknowledgment_calls += 1 + raise TimeoutError("acknowledgment response lost after server commit") + permit_calls += 1 + return response + + manifest, authority = _remote_initial_authority(tmp_path, monkeypatch, transport) + permit = authority.before_initial_delivery(manifest) + assert permit is not None + + with pytest.raises(DurableAuthorityBusy, match="unavailable or refused"): + authority.acknowledge_remote_delivery(manifest, permit) + with pytest.raises(DurableAuthorityBusy, match="lacks an acknowledgment"): + authority.before_initial_delivery(manifest) + + assert permit_calls == 1 + assert acknowledgment_calls == 1 + assert authority.validate(manifest).delivery_sequence == 0 + + def test_receipt_digest_mismatch_keeps_delivery_uncertain_and_blocks_next_edge( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_hosted_runner_adapter.py b/tests/test_hosted_runner_adapter.py new file mode 100644 index 00000000..40146033 --- /dev/null +++ b/tests/test_hosted_runner_adapter.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +import hashlib +import json +import os +from base64 import b64encode, urlsafe_b64encode +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +import openadapt_flow.runner.hosted_adapter as hosted +from openadapt_flow.ir import ParamKind, ParamSpec +from openadapt_flow.runner.hosted_adapter import ( + AdmissionArtifactBytes, + DeliveryAuthority, + HostedDispatch, + HostedRunnerAdapter, + ManagedExecution, + RegisterCapabilities, +) +from openadapt_flow.runner.product_release import ( + DOMAIN, + TARGETS, + ProductReleaseAdmissionArtifact, + ProductReleaseAdmissionError, + ProductReleaseAdmissionPayload, + ProductReleaseSignerTrust, + verify_product_release_admission, +) +from openadapt_flow.runner.protocol import ( + DispatchParamsRef, + RunnerDispatchPayload, + dispatch_binding_sha256, +) +from openadapt_flow.runner.verify import VerifiedDispatch +from openadapt_flow.runtime.durable.authority import REMOTE_DISPATCH_SESSION_ID_ENV +from openadapt_flow.transaction import TransactionOutcome +from tests.test_runner_client_lib import dispatch_payload + +pytest_plugins = ("tests.test_runner_client_lib",) + + +def _release_payload() -> dict[str, object]: + targets = [] + for index, target in enumerate(TARGETS, start=1): + targets.append( + { + "target": target, + "admission_id": f"00000000-0000-4000-8000-{index:012d}", + "admission_sha256": f"{index:x}" * 64, + "release_id": "1.2.3", + "release_artifact_sha256": f"{index + 7:x}" * 64, + "admission_issued_at": "2026-08-25T00:00:00Z", + "admission_expires_at": "2026-08-28T00:00:00Z", + "revoked_at": None, + "artifact_authority_sha256": f"{index + 8:x}" * 64, + "artifact_authority_state": "active", + "artifact_authority_checked_at": "2026-08-25T00:00:00Z", + "artifact_authority_expires_at": "2026-08-28T00:00:00Z", + } + ) + return { + "schema_version": "openadapt.product-release-admission-payload/v1", + "set_id": "00000000-0000-4000-8000-000000000099", + "sequence": 7, + "policy_sha256": "a" * 64, + "issued_at": "2026-08-25T00:00:00Z", + "expires_at": "2026-08-28T00:00:00Z", + "targets": tuple(targets), + } + + +def _release_artifact() -> tuple[ + ProductReleaseAdmissionArtifact, ProductReleaseSignerTrust +]: + private_key = Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33))) + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + payload = ProductReleaseAdmissionPayload.model_validate(_release_payload()) + signature = private_key.sign(DOMAIN + payload.canonical_bytes()) + public_b64 = b64encode(public_key).decode("ascii") + artifact = ProductReleaseAdmissionArtifact.model_validate( + { + "schema_version": "openadapt.product-release-admission-artifact/v1", + "payload": payload, + "payload_sha256": payload.payload_sha256_value(), + "signer": { + "algorithm": "ed25519", + "key_id": ( + "release-admission-ed25519-" + + hashlib.sha256(public_key).hexdigest()[:16] + ), + "public_key": public_b64, + }, + "signature": urlsafe_b64encode(signature).decode("ascii").rstrip("="), + } + ) + return artifact, ProductReleaseSignerTrust( + public_key=public_b64, + status="active", + revoked_at=None, + ) + + +@pytest.mark.parametrize("sequence", [True, "7"]) +def test_product_release_sequence_refuses_scalar_coercion(sequence: object) -> None: + raw = _release_payload() + raw["sequence"] = sequence + with pytest.raises(ValueError): + ProductReleaseAdmissionPayload.model_validate(raw) + + +def test_product_release_refuses_noncanonical_utc() -> None: + raw = _release_payload() + raw["issued_at"] = "2026-08-25T00:00:00+00:00" + with pytest.raises(ValueError, match="canonical UTC"): + ProductReleaseAdmissionPayload.model_validate(raw) + + +def test_product_release_refuses_revoked_signer() -> None: + artifact, trust = _release_artifact() + revoked = trust.model_copy( + update={"status": "revoked", "revoked_at": "2026-08-25T00:00:00Z"} + ) + with pytest.raises(ProductReleaseAdmissionError, match="revoked"): + verify_product_release_admission( + artifact, + trusted_signers={artifact.signer.key_id: revoked}, + newest_sequence=7, + now=datetime(2026, 8, 26, tzinfo=timezone.utc), + ) + + +def _hosted_dispatch(workflow) -> HostedDispatch: + workflow_id = "33333333-3333-4333-8333-333333333333" + version_id = "44444444-4444-4444-8444-444444444444" + payload_raw = dispatch_payload( + workflow, + workflow_id=workflow_id, + bundle={ + "version_id": version_id, + "content_digest": workflow.manifest.content_digest, + "url": "https://invalid.example/never-fetched", + }, + ) + payload = RunnerDispatchPayload.model_validate(payload_raw) + artifact_raw = b"{}" + artifact = AdmissionArtifactBytes( + artifact_bytes_base64=b64encode(artifact_raw).decode("ascii"), + artifact_sha256=hashlib.sha256(artifact_raw).hexdigest(), + ) + return HostedDispatch( + schema_version="openadapt.hosted-runner/v1", + dispatch_id="11111111-1111-4111-8111-111111111111", + dispatch_session_id="12111111-1111-4111-8111-111111111111", + tenant_id="22222222-2222-4222-8222-222222222222", + runner_id="55555555-5555-4555-8555-555555555555", + runner_session_id="66666666-6666-4666-8666-666666666666", + run_id=payload.run_id, + workflow_id=workflow_id, + workflow_version_id=version_id, + idempotency_key="hosted-dispatch-0001", + lease_token="oal_" + "a" * 64, + lease_expires_at="2099-01-01T00:00:00Z", + product_release_admission=artifact, + workflow_admission=artifact, + managed_delivery_authority_url=( + "https://cloud.example/api/internal/managed-delivery-permit" + ), + delivery_authority_token="b" * 64, + payload=payload, + ) + + +def test_hosted_dispatch_accepts_lowercase_v8_run_id(sealed) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + run_id = "018f6c0a-4cce-8f47-8d71-c3d63bf1c001" + payload = dispatch.payload.model_copy( + update={ + "run_id": run_id, + "dispatch_binding_sha256": dispatch_binding_sha256( + run_id, dispatch.payload.authorization + ), + } + ) + + parsed = HostedDispatch.model_validate( + dispatch.model_dump(mode="python") | {"run_id": run_id, "payload": payload} + ) + + assert parsed.run_id == run_id + + +def test_registration_refuses_without_protected_runner_origin( + monkeypatch, tmp_path, config +) -> None: + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + monkeypatch.setattr(hosted, "load_runner_config", lambda *_args, **_kwargs: config) + + with pytest.raises(ValueError, match="protected runner host"): + adapter.registration_request( + runner_config=tmp_path / "runner.toml", + name="runner", + platform="linux", + agent_version="1.0.0", + engine_version="1.33.0", + mode="service", + capabilities=RegisterCapabilities( + backends=("linux",), + attended=False, + effects_substrates=("linux",), + ), + ) + + +def _prepared_adapter(monkeypatch, tmp_path, config, workflow, runner): + config = replace(config, host="https://cloud.example") + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite", runner=runner) + dispatch = _hosted_dispatch(workflow) + verified = VerifiedDispatch( + payload=dispatch.payload, + bundle=config.bundles[workflow.manifest.content_digest], + profile_path=config.profiles["default"], + params={"visit_date": "2026-07-01"}, + workflow=workflow, + consequential_steps=1, + effect_covered_consequential_steps=1, + ) + monkeypatch.setattr(hosted, "load_runner_config", lambda _, **__: config) + monkeypatch.setattr(adapter, "_verify_product_release", lambda *_: None) + monkeypatch.setattr(adapter, "_load_evidence_private_key", lambda *_: object()) + monkeypatch.setattr( + adapter, + "_verify_workflow_admission", + lambda *_, **__: ({}, b"runtime:\n durable: false\n"), + ) + monkeypatch.setattr(adapter, "_resolve_params", lambda *_: verified.params) + monkeypatch.setattr( + hosted, + "verify_dispatch", + lambda _payload, staged_config, **_kwargs: replace( + verified, + profile_path=staged_config.profiles[dispatch.payload.deployment_profile_id], + ), + ) + + class Guard: + def __init__(self, *_args, **_kwargs): + pass + + def authorization_binding(self, _workflow): + return {} + + monkeypatch.setattr(hosted, "ProductionQualificationGuard", Guard) + return adapter, dispatch + + +@pytest.mark.parametrize( + ("fault", "execution"), + [ + ( + "backend_response_lost", + RuntimeError("backend response lost after possible actuation"), + ), + ( + "delivery_acknowledgment_response_lost", + RuntimeError("delivery acknowledgment response lost after backend call"), + ), + ( + "receipt_unavailable", + ManagedExecution(returncode=1, report_bytes=None), + ), + ( + "malformed_terminal_report", + ManagedExecution(returncode=0, report_bytes=b"not-json"), + ), + ], + ids=( + "backend-response-lost", + "delivery-acknowledgment-response-lost", + "receipt-unavailable", + "malformed-terminal-report", + ), +) +def test_hosted_uncertain_delivery_fault_never_replays( + monkeypatch, tmp_path, config, sealed, fault, execution +) -> None: + workflow, _ = sealed + calls = 0 + seen_child_env = None + seen_argv = None + + def runner(argv, _run_dir, child_env): + nonlocal calls + nonlocal seen_child_env + nonlocal seen_argv + calls += 1 + seen_child_env = child_env + seen_argv = argv + if isinstance(execution, Exception): + raise execution + return execution + + adapter, dispatch = _prepared_adapter( + monkeypatch, tmp_path, config, workflow, runner + ) + authority = DeliveryAuthority( + dispatch.managed_delivery_authority_url, + dispatch.delivery_authority_token, + ) + first = adapter.execute( + dispatch, + runner_config=tmp_path / "runner.toml", + run_dir=tmp_path / "run", + authority=authority, + ) + second = adapter.execute( + dispatch, + runner_config=tmp_path / "runner.toml", + run_dir=tmp_path / "run-again", + authority=authority, + ) + + assert first.outcome is TransactionOutcome.RECONCILIATION_REQUIRED + assert first.started is True + assert first.uncertain_delivery is True + assert second.outcome is TransactionOutcome.RECONCILIATION_REQUIRED + assert second.started is True + assert second.uncertain_delivery is True + assert calls == 1 + assert seen_child_env[REMOTE_DISPATCH_SESSION_ID_ENV] == ( + dispatch.dispatch_session_id + ) + profile_path = Path(seen_argv[seen_argv.index("--config") + 1]) + assert profile_path == tmp_path / "run" / "deployment.yaml" + assert profile_path.read_bytes() == b"runtime:\n durable: false\n" + if os.name != "nt": + assert profile_path.stat().st_mode & 0o777 == 0o600 + assert fault in { + "backend_response_lost", + "delivery_acknowledgment_response_lost", + "receipt_unavailable", + "malformed_terminal_report", + } + + +def test_params_reference_resolves_only_from_protected_local_root( + monkeypatch, tmp_path, config, sealed +) -> None: + workflow, _ = sealed + workflow.param_specs = { + "visit_date": ParamSpec( + name="visit_date", + type=ParamKind.DATE, + required=True, + ) + } + dispatch = _hosted_dispatch(workflow) + expected_digest = dispatch.payload.authorization.runtime_inputs_digest + dispatch = dispatch.model_copy( + update={ + "payload": dispatch.payload.model_copy( + update={ + "params": DispatchParamsRef( + ref="records/run.json", + expected_digest=expected_digest, + ) + } + ) + } + ) + root = tmp_path / "params" + nested = root / "records" + nested.mkdir(parents=True, mode=0o700) + root.chmod(0o700) + nested.chmod(0o700) + ref_file = nested / "run.json" + ref_file.write_text(json.dumps({"visit_date": "2026-07-01"}), encoding="utf-8") + ref_file.chmod(0o600) + local_config = replace( + config, + host="https://cloud.example", + params_ref_root=root, + ) + monkeypatch.setattr(hosted.Workflow, "load", lambda *_: workflow) + + adapter = HostedRunnerAdapter(tmp_path / "resolver-ledger.sqlite") + assert adapter._resolve_params(dispatch, local_config) == { + "visit_date": "2026-07-01" + } + + traversing = dispatch.model_copy( + update={ + "payload": dispatch.payload.model_copy( + update={ + "params": DispatchParamsRef( + ref="../outside.json", + expected_digest=expected_digest, + ) + } + ) + } + ) + with pytest.raises(ValueError, match="safe local path"): + adapter._resolve_params(traversing, local_config) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink contract") +def test_params_reference_refuses_symlink( + monkeypatch, tmp_path, config, sealed +) -> None: + workflow, _ = sealed + workflow.param_specs = { + "visit_date": ParamSpec(name="visit_date", type=ParamKind.DATE) + } + dispatch = _hosted_dispatch(workflow) + expected_digest = dispatch.payload.authorization.runtime_inputs_digest + root = tmp_path / "params" + root.mkdir(mode=0o700) + target = tmp_path / "target.json" + target.write_text(json.dumps({"visit_date": "2026-07-01"}), encoding="utf-8") + target.chmod(0o600) + (root / "run.json").symlink_to(target) + dispatch = dispatch.model_copy( + update={ + "payload": dispatch.payload.model_copy( + update={ + "params": DispatchParamsRef( + ref="run.json", + expected_digest=expected_digest, + ) + } + ) + } + ) + monkeypatch.setattr(hosted.Workflow, "load", lambda *_: workflow) + adapter = HostedRunnerAdapter(tmp_path / "resolver-ledger.sqlite") + + with pytest.raises(ValueError, match="private regular file"): + adapter._resolve_params(dispatch, replace(config, params_ref_root=root)) + + +def test_params_reference_digest_mismatch_refuses_before_managed_runner( + monkeypatch, tmp_path, config, sealed +) -> None: + workflow, _ = sealed + workflow.param_specs = { + "visit_date": ParamSpec( + name="visit_date", + type=ParamKind.DATE, + required=True, + ) + } + dispatch = _hosted_dispatch(workflow) + root = tmp_path / "params" + root.mkdir(mode=0o700) + ref_file = root / "run.json" + ref_file.write_text(json.dumps({"visit_date": "2026-07-01"}), encoding="utf-8") + ref_file.chmod(0o600) + dispatch = dispatch.model_copy( + update={ + "payload": dispatch.payload.model_copy( + update={ + "params": DispatchParamsRef( + ref="run.json", + expected_digest="f" * 64, + ) + } + ) + } + ) + local_config = replace( + config, + host="https://cloud.example", + params_ref_root=root, + ) + calls = 0 + + def runner(*_args): + nonlocal calls + calls += 1 + raise AssertionError("managed runner must not start") + + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite", runner=runner) + monkeypatch.setattr( + hosted, "load_runner_config", lambda *_args, **_kwargs: local_config + ) + monkeypatch.setattr(adapter, "_verify_product_release", lambda *_: None) + monkeypatch.setattr(adapter, "_load_evidence_private_key", lambda *_: object()) + monkeypatch.setattr( + adapter, + "_verify_workflow_admission", + lambda *_, **__: ({}, b"runtime:\n durable: false\n"), + ) + monkeypatch.setattr(hosted.Workflow, "load", lambda *_: workflow) + authority = DeliveryAuthority( + dispatch.managed_delivery_authority_url, + dispatch.delivery_authority_token, + ) + + result = adapter.execute( + dispatch, + runner_config=tmp_path / "runner.toml", + run_dir=tmp_path / "run", + authority=authority, + ) + + assert result.outcome == "REJECTED_POLICY" + assert result.code == "runtime_inputs_mismatch" + assert result.started is False + assert result.uncertain_delivery is False + assert calls == 0 + + +@pytest.mark.parametrize("manifest_kind", ["missing", "malformed", "public", "symlink"]) +def test_untrusted_runner_manifest_refuses_before_managed_runner( + tmp_path, sealed, manifest_kind +) -> None: + if manifest_kind == "symlink" and os.name == "nt": + pytest.skip("POSIX symlink contract") + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + manifest = tmp_path / "runner.toml" + if manifest_kind == "malformed": + manifest.write_text("[runner\n", encoding="utf-8") + manifest.chmod(0o600) + elif manifest_kind == "public": + manifest.write_text("[runner]\nname = 'runner'\n", encoding="utf-8") + manifest.chmod(0o644) + elif manifest_kind == "symlink": + target = tmp_path / "target.toml" + target.write_text("[runner]\nname = 'runner'\n", encoding="utf-8") + target.chmod(0o600) + manifest.symlink_to(target) + calls = 0 + + def runner(*_args): + nonlocal calls + calls += 1 + raise AssertionError("managed runner must not start") + + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite", runner=runner) + authority = DeliveryAuthority( + dispatch.managed_delivery_authority_url, + dispatch.delivery_authority_token, + ) + + result = adapter.execute( + dispatch, + runner_config=manifest, + run_dir=tmp_path / "run", + authority=authority, + ) + + assert result.outcome == "REJECTED_POLICY" + assert result.code == "hosted_admission_refused" + assert result.detail.startswith("prestart_") + assert result.started is False + assert result.uncertain_delivery is False + assert calls == 0 + + +@pytest.mark.parametrize( + "runner_host", + [ + None, + "http://cloud.example", + "https://cloud.example/", + "https://different.example", + ], +) +def test_protected_runner_host_binds_delivery_authority_origin( + monkeypatch, tmp_path, config, sealed, runner_host +) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + calls = 0 + + def runner(*_args): + nonlocal calls + calls += 1 + raise AssertionError("managed runner must not start") + + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite", runner=runner) + local_config = replace(config, host=runner_host) + monkeypatch.setattr( + hosted, "load_runner_config", lambda *_args, **_kwargs: local_config + ) + authority = DeliveryAuthority( + dispatch.managed_delivery_authority_url, + dispatch.delivery_authority_token, + ) + + result = adapter.execute( + dispatch, + runner_config=tmp_path / "runner.toml", + run_dir=tmp_path / "run", + authority=authority, + ) + + assert result.outcome == "REJECTED_POLICY" + assert result.started is False + assert result.detail == "prestart_ValueError" + assert calls == 0 + + +def test_protected_profile_mutation_refuses_before_managed_runner( + monkeypatch, tmp_path, config, sealed +) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + config = replace(config, host="https://cloud.example") + profile_path = config.profiles["default"] + profile_path.chmod(0o600) + calls = 0 + + def runner(*_args): + nonlocal calls + calls += 1 + raise AssertionError("managed runner must not start") + + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite", runner=runner) + monkeypatch.setattr(hosted, "load_runner_config", lambda *_args, **_kwargs: config) + monkeypatch.setattr(adapter, "_verify_product_release", lambda *_: None) + monkeypatch.setattr(adapter, "_load_evidence_private_key", lambda *_: object()) + + def verify_profile(*_args, **_kwargs): + raw = adapter._read_private_bytes( + profile_path, + maximum_bytes=1024 * 1024, + label="hosted deployment profile", + ) + return {}, raw + + monkeypatch.setattr(adapter, "_verify_workflow_admission", verify_profile) + real_read = os.read + changed = False + + def mutating_read(descriptor, count): + nonlocal changed + chunk = real_read(descriptor, count) + if not changed: + changed = True + profile_path.write_bytes(chunk + b"# changed\n") + profile_path.chmod(0o600) + return chunk + + monkeypatch.setattr(hosted.os, "read", mutating_read) + authority = DeliveryAuthority( + dispatch.managed_delivery_authority_url, + dispatch.delivery_authority_token, + ) + + result = adapter.execute( + dispatch, + runner_config=tmp_path / "runner.toml", + run_dir=tmp_path / "run", + authority=authority, + ) + + assert result.outcome == "REJECTED_POLICY" + assert result.started is False + assert result.detail == "prestart_ValueError" + assert calls == 0 + + +def test_parsed_refusal_callback_contains_closed_terminal(tmp_path, sealed) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + refusal = adapter._refusal(dispatch, "hosted_admission_refused", "refused") + + callback = adapter.callback_request(dispatch, refusal) + + terminal = callback.events[-1] + assert terminal["schema_version"] == "openadapt.hosted-runner-terminal/v1" + assert terminal["outcome"] == "REJECTED_POLICY" + assert terminal["started"] is False + assert terminal["uncertain_delivery"] is False diff --git a/tests/test_runner_client_lib.py b/tests/test_runner_client_lib.py index 507100d4..89e1786e 100644 --- a/tests/test_runner_client_lib.py +++ b/tests/test_runner_client_lib.py @@ -333,7 +333,7 @@ def test_dispatch_binding_known_vector(self): dispatch_binding_sha256( "11111111-1111-4111-8111-111111111111", authorization ) - == "sha256:efd01f7c8c56a0df02200d684a5ab6104e47ec769090b2d76eb090624cdcc272" + == "sha256:367411c4ff350c05d6dad465db3dd1f57e8d47d620d3c0f16b70adec0857047e" ) def test_dispatch_binding_refuses_changed_run_or_authorization(self, sealed): From 40f03bb6cb0ce0d1886cd8d7cd7efc1e0836cf91 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 26 Aug 2026 14:58:17 -0400 Subject: [PATCH 2/5] fix: type hosted runner release bindings --- openadapt_flow/runner/config.py | 12 +++++++++--- openadapt_flow/runner/hosted_adapter.py | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/openadapt_flow/runner/config.py b/openadapt_flow/runner/config.py index d752542c..5fcb26c9 100644 --- a/openadapt_flow/runner/config.py +++ b/openadapt_flow/runner/config.py @@ -45,7 +45,7 @@ import stat from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Optional +from typing import Any, Literal, Optional from openadapt_flow.hosted import HostedError from openadapt_flow.private_file import ( @@ -59,6 +59,8 @@ ) _SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$") +LocalReleaseTarget = Literal["flow", "desktop", "capture"] + def _load_manifest_toml(path: Path, *, protected: bool = False) -> dict[str, Any]: """Full-TOML parse (the manifest uses ``[[bundles]]`` array tables, which @@ -203,7 +205,7 @@ class BusinessDecisionServiceConfig: class LocalRuntimeRelease: """One independently installed target release used during enrollment.""" - target: str + target: LocalReleaseTarget admission_id: str admission_sha256: str release_version: str @@ -372,7 +374,11 @@ def load_runner_config( if not isinstance(local_release_tbl, dict): raise RunnerConfigError("[local_runtime_release] must be a table") local_runtime_release: list[LocalRuntimeRelease] = [] - expected_release_targets = ("flow", "desktop", "capture") + expected_release_targets: tuple[LocalReleaseTarget, ...] = ( + "flow", + "desktop", + "capture", + ) for target in expected_release_targets: entry = local_release_tbl.get(target) if entry is None: diff --git a/openadapt_flow/runner/hosted_adapter.py b/openadapt_flow/runner/hosted_adapter.py index 45c16fb7..361482dd 100644 --- a/openadapt_flow/runner/hosted_adapter.py +++ b/openadapt_flow/runner/hosted_adapter.py @@ -468,10 +468,10 @@ def registration_request( *, runner_config: Path, name: str, - platform: str, + platform: Literal["windows", "macos", "linux"], agent_version: str, engine_version: str, - mode: str, + mode: Literal["attended", "service"], capabilities: RegisterCapabilities | Mapping[str, object], ) -> RegisterRequest: config = load_runner_config(runner_config, protected=True) From bf8b0a8192af2286e5eb4ee17fcda01200ce7684 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 26 Aug 2026 17:10:22 -0400 Subject: [PATCH 3/5] feat: complete hosted runner target state --- openadapt_flow/__init__.py | 2 +- openadapt_flow/__main__.py | 35 +- openadapt_flow/action_evidence.py | 10 +- openadapt_flow/compiler/induction.py | 6 +- openadapt_flow/console/halt_detail.py | 5 +- openadapt_flow/deployment.py | 6 +- openadapt_flow/execution_profiles.py | 67 ++- openadapt_flow/ir.py | 7 +- openadapt_flow/learning/halt_loop.py | 3 +- openadapt_flow/learning/teach.py | 3 +- openadapt_flow/qualification.py | 27 +- openadapt_flow/runner/__init__.py | 4 + openadapt_flow/runner/hosted_adapter.py | 500 ++++++++++++++-- openadapt_flow/runner/inputs.py | 38 +- openadapt_flow/runner/protocol.py | 38 +- openadapt_flow/runner/verify.py | 10 +- openadapt_flow/runtime/authorization.py | 174 +++++- openadapt_flow/runtime/durable/attended.py | 30 +- .../runtime/durable/business_decision.py | 7 +- openadapt_flow/runtime/durable/checkpoint.py | 11 +- openadapt_flow/runtime/durable/controller.py | 11 +- .../runtime/durable/program_checkpoint.py | 8 +- openadapt_flow/runtime/durable/resume.py | 13 +- openadapt_flow/runtime/effects/adapter.py | 2 +- openadapt_flow/runtime/effects/effect.py | 34 +- openadapt_flow/runtime/program_predicates.py | 10 +- openadapt_flow/runtime/replayer.py | 153 +++-- openadapt_flow/visualize/builder.py | 7 +- pyproject.toml | 2 +- tests/test_effect_kit_config.py | 19 + tests/test_execution_profiles.py | 49 +- tests/test_governed_authorization.py | 56 ++ tests/test_hosted_runner_adapter.py | 549 +++++++++++++++++- tests/test_program_ir_phase1.py | 23 + tests/test_runner_client_lib.py | 38 ++ uv.lock | 2 +- 36 files changed, 1702 insertions(+), 257 deletions(-) diff --git a/openadapt_flow/__init__.py b/openadapt_flow/__init__.py index 5b7c47ca..5a782114 100644 --- a/openadapt_flow/__init__.py +++ b/openadapt_flow/__init__.py @@ -1,6 +1,6 @@ """openadapt-flow: record once, compile, replay deterministically, heal on drift.""" -__version__ = "1.33.0" +__version__ = "1.34.0" from openadapt_flow.ir import ( # noqa: F401 ActionKind, diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index b603836e..70a0c6a9 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -65,13 +65,23 @@ import sys from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Iterator, Literal, Optional, Sequence, cast +from typing import ( + TYPE_CHECKING, + Any, + Iterator, + Literal, + Mapping, + Optional, + Sequence, + cast, +) from urllib.parse import urlsplit from uuid import UUID if TYPE_CHECKING: # pragma: no cover from openadapt_flow.backend import Backend from openadapt_flow.ir import ExecutionTargetKind, RunReport + from openadapt_flow.runtime.authorization import RuntimeParamScalar from openadapt_flow.tutorial import BreakItResult _VIEWPORT = {"width": 1280, "height": 800} @@ -258,7 +268,7 @@ def _resolve_record_capture_window( def _replay_params( pairs: Sequence[str] | None, params_file: str | None = None, -) -> dict[str, str]: +) -> dict[str, "RuntimeParamScalar"]: """Load replay bindings without requiring sensitive values in argv. ``--params-file`` is intended for managed runners: the file can be staged @@ -267,7 +277,9 @@ def _replay_params( """ import json - params: dict[str, str] = {} + from openadapt_flow.runtime.authorization import is_runtime_param_scalar + + params: dict[str, RuntimeParamScalar] = {} if params_file: path = Path(params_file) try: @@ -281,11 +293,9 @@ def _replay_params( for key, value in raw.items(): if not isinstance(key, str) or not key: raise SystemExit("--params-file keys must be non-empty strings") - if not isinstance(value, (str, int, float, bool)) or isinstance( - value, (dict, list) - ): + if not is_runtime_param_scalar(value): raise SystemExit(f"--params-file value for {key!r} must be a scalar") - params[key] = str(value) + params[key] = value params.update(_parse_params(pairs)) return params @@ -400,7 +410,10 @@ def _deployment_sections(args: argparse.Namespace): return cfg, effects, actuation -def _deployment_runtime(args: argparse.Namespace, params: dict[str, str] | None = None): +def _deployment_runtime( + args: argparse.Namespace, + params: Mapping[str, "RuntimeParamScalar"] | None = None, +): """Resolve the deployment wiring for a replay/run from ``--config`` + flags. Returns ``(cfg, effect_verifier, api_actuator, durable, allow_egress)``. @@ -416,10 +429,14 @@ def _deployment_runtime(args: argparse.Namespace, params: dict[str, str] | None ignores it. """ from openadapt_flow.deployment import build_api_actuator, build_effect_verifier + from openadapt_flow.runtime.authorization import runtime_params_for_gui cfg, effects, actuation = _deployment_sections(args) try: - effect_verifier = build_effect_verifier(effects, params=params) + effect_verifier = build_effect_verifier( + effects, + params=runtime_params_for_gui(params or {}), + ) api_actuator = build_api_actuator(actuation) except ValueError as e: raise SystemExit(str(e)) diff --git a/openadapt_flow/action_evidence.py b/openadapt_flow/action_evidence.py index 2ef604e1..97987687 100644 --- a/openadapt_flow/action_evidence.py +++ b/openadapt_flow/action_evidence.py @@ -13,6 +13,7 @@ from typing import Any, Optional from openadapt_flow.ir import ActionKind, Step +from openadapt_flow.runtime.authorization import RuntimeParamScalar, runtime_param_text AUTOMATED_GUI_ACTUATIONS = frozenset( {"uia", "dom", "guarded_coordinate", "guarded_keyboard", "remote_guarded"} @@ -177,7 +178,7 @@ def _delivery_receipt_error( step: Step, result: Any, *, - params: Mapping[str, str], + params: Mapping[str, RuntimeParamScalar], ) -> Optional[str]: receipt = result.delivery_receipt if receipt is None: @@ -226,7 +227,10 @@ def _delivery_receipt_error( return "non-drag delivery receipt contains a destination fingerprint" if step.action is ActionKind.SELECT_OPTION: - selected = params.get(step.param) if step.param is not None else step.text + selected_value = params.get(step.param) if step.param is not None else step.text + selected = ( + runtime_param_text(selected_value) if selected_value is not None else None + ) if selected is None or step.selection_commit_key is None: return "selection delivery receipt lacks its compiled input contract" if ( @@ -287,7 +291,7 @@ def action_evidence_error( step: Step, result: Any, *, - params: Mapping[str, str] | None = None, + params: Mapping[str, RuntimeParamScalar] | None = None, identity_required: bool = False, strict_production: bool = True, ) -> Optional[str]: diff --git a/openadapt_flow/compiler/induction.py b/openadapt_flow/compiler/induction.py index 54a32a10..84904730 100644 --- a/openadapt_flow/compiler/induction.py +++ b/openadapt_flow/compiler/induction.py @@ -110,6 +110,7 @@ Transition, Workflow, ) +from openadapt_flow.runtime.authorization import runtime_param_text TraceInput = Union[Workflow, str, Path] @@ -717,7 +718,10 @@ def induce_program( program=program, subflows=subflows, param_specs=param_specs, - params={k: (v.example or "") for k, v in param_specs.items()}, + params={ + key: runtime_param_text(spec.example) if spec.example is not None else "" + for key, spec in param_specs.items() + }, data_sources=data_sources, ) result.program = program diff --git a/openadapt_flow/console/halt_detail.py b/openadapt_flow/console/halt_detail.py index d91adcfc..c171882e 100644 --- a/openadapt_flow/console/halt_detail.py +++ b/openadapt_flow/console/halt_detail.py @@ -53,6 +53,7 @@ from openadapt_flow.console import data from openadapt_flow.ir import ActionKind, Anchor, Rung, Step, Workflow from openadapt_flow.runtime import identity as _id +from openadapt_flow.runtime.authorization import runtime_params_for_gui from openadapt_flow.runtime.durable.checkpoint import CheckpointStore, PendingEscalation #: The resolution ladder in strongest-first order, taken from the engine's own @@ -444,9 +445,9 @@ def halt_detail( anchor = step.anchor if step is not None else None params: dict[str, str] = {} if report is not None and getattr(report, "params", None): - params = dict(report.params) + params = runtime_params_for_gui(report.params) elif pending is not None: - params = dict(pending.params) + params = runtime_params_for_gui(pending.params) role, label = _safe_target_label(step, params) resolved_rung = None diff --git a/openadapt_flow/deployment.py b/openadapt_flow/deployment.py index 09449741..7359e9bf 100644 --- a/openadapt_flow/deployment.py +++ b/openadapt_flow/deployment.py @@ -908,7 +908,7 @@ def build_replayer( def _resolve_config_exprs( section: str, exprs: Mapping[str, ValueExpr], - params: Optional[Mapping[str, str]], + params: Optional[Mapping[str, object]], ) -> dict[str, str]: """Resolve a config's ``ValueExpr`` mapping against the run's params. @@ -945,7 +945,7 @@ def _require_env(name: str, what: str) -> str: def build_effect_verifier( - cfg: EffectsConfig, params: Optional[Mapping[str, str]] = None + cfg: EffectsConfig, params: Optional[Mapping[str, object]] = None ) -> Optional[Any]: """Construct the configured ``EffectVerifier`` (or None for ``kind: none``). @@ -1073,7 +1073,7 @@ def sanitized(value: Any, *, key: str = "") -> Any: def _build_effect_verifier_unredacted( - cfg: EffectsConfig, params: Optional[Mapping[str, str]] = None + cfg: EffectsConfig, params: Optional[Mapping[str, object]] = None ) -> Optional[Any]: """The per-kind construction behind :func:`build_effect_verifier`.""" kind = (cfg.kind or "none").strip().lower() diff --git a/openadapt_flow/execution_profiles.py b/openadapt_flow/execution_profiles.py index eff5bc8c..4f344045 100644 --- a/openadapt_flow/execution_profiles.py +++ b/openadapt_flow/execution_profiles.py @@ -26,6 +26,11 @@ action_evidence_error, ) from openadapt_flow.decision_delivery import DecisionDeliveryTier +from openadapt_flow.runtime.authorization import ( + RuntimeParamScalar, + runtime_param_text, + runtime_params_for_gui, +) from openadapt_flow.verification import VerificationTier if TYPE_CHECKING: @@ -289,7 +294,7 @@ def _api_identity_evidence_is_exact( workflow: Workflow, step: Any, check: Any, - scoped_params: Mapping[str, str], + scoped_params: Mapping[str, RuntimeParamScalar], effects: list[Any], ) -> bool: """Return whether an API result matches its exact identity binding. @@ -303,6 +308,10 @@ def _api_identity_evidence_is_exact( binding = step.api_binding if binding is None or not binding.identity or check is None: return False + try: + text_params = runtime_params_for_gui(scoped_params) + except ValueError: + return False project = workflow.qualification policy = project.identity_policies.get(step.id) if project is not None else None if policy is not None: @@ -316,7 +325,7 @@ def _api_identity_evidence_is_exact( check=check, step=step, actuation_path="api", - runtime_params=scoped_params, + runtime_params=text_params, recorded_params=workflow.params, ) is not None @@ -353,7 +362,7 @@ def _api_identity_evidence_is_exact( return False for identity in binding.identity: - if not scoped_params.get(identity.param): + if identity.param not in text_params or text_params[identity.param] == "": return False effect_path = tuple(identity.effect_field.split(".")) if not any( @@ -413,7 +422,7 @@ def _program_action_trace( workflow: Workflow, visited_states: list[str], *, - runtime_params: Mapping[str, str] | None = None, + runtime_params: Mapping[str, str | bool | int | float] | None = None, runtime_worklists: Mapping[str, list[dict[str, str]]] | None = None, transition_evidence: list[Any] | None = None, exception_evidence: list[Any] | None = None, @@ -498,7 +507,7 @@ def _rows(relation: str) -> list[dict[str, str]] | None: return None if declared is None else list(declared.rows) def _reported_guard_value( - predicate: Any, current_params: Mapping[str, str] + predicate: Any, current_params: Mapping[str, str | bool | int | float] ) -> bool | None: """Recompute guards whose inputs are retained in the run report. @@ -511,9 +520,13 @@ def _reported_guard_value( kind = predicate.kind if kind is PredicateKind.PARAM_EQUALS: - return predicate.param is not None and str( - current_params.get(predicate.param) - ) == str(predicate.value) + return ( + predicate.param is not None + and predicate.value is not None + and predicate.param in current_params + and runtime_param_text(current_params[predicate.param]) + == predicate.value + ) if kind is PredicateKind.AND: values = [ _reported_guard_value(item, current_params) @@ -618,7 +631,7 @@ def _validated_evidence_target( graph_id: str, state: Any, scope: tuple[Any, ...], - current_params: Mapping[str, str], + current_params: Mapping[str, str | bool | int | float], ) -> str | None: nonlocal evaluator_contract_sha256 group = _matching_evidence_group( @@ -726,7 +739,7 @@ def _validated_evidence_target( recomputed_visual = evaluate_program_predicate( transition.guard, frame, - current_params, + runtime_params_for_gui(current_params), vision=transition_predicate_vision, viewport=item.observed_viewport, asset_loader=retained_assets.get, @@ -745,7 +758,7 @@ def _validated_attended_target( graph_id: str, state: Any, scope: tuple[Any, ...], - current_params: Mapping[str, str], + current_params: Mapping[str, str | bool | int | float], ) -> tuple[bool, str | None, str | None]: nonlocal attended_evidence_cursor, expected_evidence_decision_index evidence = attended_transition_evidence or [] @@ -869,7 +882,7 @@ def _validated_business_decision( graph_id: str, state: Any, scope: tuple[Any, ...], - current_params: dict[str, str], + current_params: dict[str, str | bool | int | float], ) -> str: nonlocal business_evidence_cursor, expected_evidence_decision_index evidence = business_decision_evidence or [] @@ -1013,7 +1026,7 @@ def _validated_exception_target( def _selected_transition_target( state: Any, - current_params: dict[str, str], + current_params: Mapping[str, str | bool | int | float], *, graph_id: str, scope: tuple[Any, ...], @@ -1064,7 +1077,7 @@ def _next_state( state: Any, graph: Any, occurrence_index: int | None, - current_params: dict[str, str], + current_params: Mapping[str, str | bool | int | float], *, graph_id: str, scope: tuple[Any, ...], @@ -1219,7 +1232,7 @@ def _consume_graph( scope: tuple[Any, ...], *, depth: int, - current_params: dict[str, str], + current_params: dict[str, str | bool | int | float], ) -> None: nonlocal cursor, halted_at_requested_action if depth > 64: @@ -1677,7 +1690,7 @@ def classify_execution_outcome( assert minimum is not None from openadapt_flow.ir import ActionKind - def _scoped_params(result: Any) -> dict[str, str] | None: + def _scoped_params(result: Any) -> dict[str, str | bool | int | float] | None: if workflow.program is None and result.program_scope: return None scoped = dict(report.params) @@ -1697,16 +1710,20 @@ def _scoped_params(result: Any) -> dict[str, str] | None: return scoped def _reported_parameter_predicate_value( - predicate: Any, current_params: Mapping[str, str] + predicate: Any, current_params: Mapping[str, str | bool | int | float] ) -> bool | None: """Recompute a guard only when all of its inputs are report-bound.""" from openadapt_flow.ir import PredicateKind if predicate.kind is PredicateKind.PARAM_EQUALS: - return predicate.param is not None and str( - current_params.get(predicate.param) - ) == str(predicate.value) + return ( + predicate.param is not None + and predicate.value is not None + and predicate.param in current_params + and runtime_param_text(current_params[predicate.param]) + == predicate.value + ) if predicate.kind is PredicateKind.AND: values = [ _reported_parameter_predicate_value(item, current_params) @@ -1802,7 +1819,7 @@ def _reported_parameter_predicate_value( check=result.identity, step=step, actuation_path=("api" if result.actuation == "api" else "gui"), - runtime_params=scoped_params, + runtime_params=runtime_params_for_gui(scoped_params), recorded_params=workflow.params, evidence_root=transition_evidence_root, recorded_asset_sha256=recorded_asset_sha256, @@ -1928,7 +1945,7 @@ def _reported_parameter_predicate_value( try: expected_hashes = Counter( effect.resolved_contract_hash( - scoped_params, + runtime_params_for_gui(scoped_params), opaque_param_sha256=opaque, ) for effect in effects @@ -1976,7 +1993,7 @@ def _reported_parameter_predicate_value( ) try: effect_hash = effect.resolved_contract_hash( - scoped_params, + runtime_params_for_gui(scoped_params), opaque_param_sha256=opaque, ) except ValueError: @@ -2163,7 +2180,7 @@ def build_outcome_envelope( envelope_requirements = () effect_requirements_valid = False - def _scoped_params(result: Any) -> dict[str, str] | None: + def _scoped_params(result: Any) -> dict[str, str | bool | int | float] | None: if workflow.program is None and result.program_scope: return None scoped = dict(report.params) @@ -2279,7 +2296,7 @@ def _scoped_params(result: Any) -> dict[str, str] | None: "qualified effect contract does not match" ) effect_hash = effect.resolved_contract_hash( - scoped_params, + runtime_params_for_gui(scoped_params), opaque_param_sha256=opaque, ) expected_hashes.append(effect_hash) diff --git a/openadapt_flow/ir.py b/openadapt_flow/ir.py index b54bf8d6..b6a8ffb2 100644 --- a/openadapt_flow/ir.py +++ b/openadapt_flow/ir.py @@ -29,7 +29,7 @@ from datetime import datetime, timezone from enum import Enum from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, Final, Iterator, Literal, Optional +from typing import TYPE_CHECKING, Any, Final, Iterator, Literal, Optional, Union from pydantic import ( BaseModel, @@ -637,6 +637,7 @@ class ParamKind(str, Enum): DATE = "date" ENUM = "enum" NUMBER = "number" + BOOLEAN = "boolean" ENTITY_REF = "entity_ref" @@ -651,7 +652,7 @@ class ParamSpec(BaseModel): name: str type: ParamKind = ParamKind.STRING - example: Optional[str] = Field( + example: Optional[Union[str, bool, int, float]] = Field( default=None, description="Recorded demo value; also the replay default when the " "caller supplies no value for this parameter.", @@ -3634,7 +3635,7 @@ class RunReport(BaseModel): ) required_identity_step_ids: list[str] = Field(default_factory=list) approved_unverified_effect_step_ids: list[str] = Field(default_factory=list) - params: dict[str, str] = Field(default_factory=dict) + params: dict[str, Union[str, bool, int, float]] = Field(default_factory=dict) results: list[StepResult] = Field(default_factory=list) success: bool = False # Workflow-program IR, Phase 2: the outcome of the terminal state the graph diff --git a/openadapt_flow/learning/halt_loop.py b/openadapt_flow/learning/halt_loop.py index 72df1e5c..65997069 100644 --- a/openadapt_flow/learning/halt_loop.py +++ b/openadapt_flow/learning/halt_loop.py @@ -47,6 +47,7 @@ learn_from_traces, ) from openadapt_flow.learning.trace import ExecutionTrace, TraceStep +from openadapt_flow.runtime.authorization import runtime_params_for_gui from openadapt_flow.runtime.healing.governance import RegressionGate @@ -78,7 +79,7 @@ def execution_trace_from_halt( outcome="failure", steps=steps, facts=facts, - params=dict(params or report.params), + params=runtime_params_for_gui(params or report.params), failure_reason=halt.reason, ) diff --git a/openadapt_flow/learning/teach.py b/openadapt_flow/learning/teach.py index 66a74577..abe2d538 100644 --- a/openadapt_flow/learning/teach.py +++ b/openadapt_flow/learning/teach.py @@ -65,6 +65,7 @@ class the halt->learn loop was built for (splice a guarded, reversible dismiss from openadapt_flow.learning.loop import Inducer, LearnOutcome from openadapt_flow.learning.synth_stream import StructuralDiffInducer from openadapt_flow.learning.trace import ExecutionTrace, TraceStep +from openadapt_flow.runtime.authorization import runtime_params_for_gui class TeachError(Exception): @@ -245,7 +246,7 @@ def _correction_from_spec( resolution_steps=resolution_steps, tail_intents=tail, trace_id=f"{report.workflow_name}-correction", - params=spec.params or dict(report.params), + params=spec.params or runtime_params_for_gui(report.params), ) return correction, _baseline_success(program, report, tail) diff --git a/openadapt_flow/qualification.py b/openadapt_flow/qualification.py index 95e68266..ef5791f7 100644 --- a/openadapt_flow/qualification.py +++ b/openadapt_flow/qualification.py @@ -2556,7 +2556,11 @@ def _case_run_report_integrity_error( QualificationRefusalCode.CASE_ATTESTATION_INVALID, "case-input bytes do not match the signed case-result binding", ) - from openadapt_flow.runtime.authorization import parse_runtime_inputs_bytes + from openadapt_flow.runtime.authorization import ( + parse_runtime_inputs_bytes, + runtime_param_text, + runtime_params_for_gui, + ) try: case_params, case_worklists = parse_runtime_inputs_bytes( @@ -2569,7 +2573,9 @@ def _case_run_report_integrity_error( "case input is not a valid canonical governed-input artifact", ) - def scoped_case_params(item: Any) -> Optional[dict[str, str]]: + def scoped_case_params( + item: Any, + ) -> Optional[dict[str, str | bool | int | float]]: if workflow.program is None: return dict(case_params) if not item.program_scope else None if not item.program_scope or item.program_scope[0].graph_id != "__program__": @@ -3155,9 +3161,10 @@ def delivery_receipt_error(item: Any, step: "Step") -> Optional[str]: ) if selected_value is None or step.selection_commit_key is None: return "selection delivery receipt lacks its compiled input contract" + selected_text = runtime_param_text(selected_value) if ( receipt.selection_value_sha256 - != hashlib.sha256(selected_value.encode("utf-8")).hexdigest() + != hashlib.sha256(selected_text.encode("utf-8")).hexdigest() or receipt.selection_commit_key != step.selection_commit_key ): return "selection delivery receipt differs from the compiled input" @@ -3238,7 +3245,7 @@ def identity_evidence_error( check=item.identity, step=step, actuation_path=actuation_path, - runtime_params=scoped, + runtime_params=runtime_params_for_gui(scoped), recorded_params=workflow.params, evidence_root=run_evidence_root, recorded_asset_sha256=recorded_asset_sha256, @@ -3248,7 +3255,7 @@ def identity_evidence_error( check=item.identity, step=step, actuation_path=actuation_path, - runtime_params=scoped, + runtime_params=runtime_params_for_gui(scoped), recorded_params=workflow.params, evidence_root=run_evidence_root, recorded_asset_sha256=recorded_asset_sha256, @@ -3277,7 +3284,7 @@ def resolved_effect_contracts( ( index, effect.resolved_contract_hash( - scoped, + runtime_params_for_gui(scoped), opaque_param_sha256={"__run_id__": result.run_id_sha256 or ""}, ), effect_policies.get((step.id, actuation_path, index)), @@ -3913,7 +3920,7 @@ def result_has_sufficient_effect_evidence(item: Any) -> bool: ( index, effect.resolved_contract_hash( - resolved_params, + runtime_params_for_gui(resolved_params), opaque_param_sha256={ "__run_id__": result.run_id_sha256 or "" }, @@ -4006,7 +4013,11 @@ def result_has_sufficient_effect_evidence(item: Any) -> bool: check=item.identity, step=step, actuation_path=actuation_path, - runtime_params=scoped_case_params(item), + runtime_params=( + runtime_params_for_gui(scoped) + if (scoped := scoped_case_params(item)) is not None + else None + ), recorded_params=workflow.params, evidence_root=run_evidence_root, recorded_asset_sha256=( diff --git a/openadapt_flow/runner/__init__.py b/openadapt_flow/runner/__init__.py index dd641fd5..3d975663 100644 --- a/openadapt_flow/runner/__init__.py +++ b/openadapt_flow/runner/__init__.py @@ -48,6 +48,7 @@ write_managed_dispatch_envelope, ) from openadapt_flow.runner.hosted_adapter import ( + RUNNER_RENEWAL_HEADER, CallbackRequest, CallbackResponse, DeliveryAuthority, @@ -61,6 +62,7 @@ RegisterCapabilities, RegisterRequest, RegisterResponse, + registration_renewal_headers, ) from openadapt_flow.runner.lease import ( CompletionDisposition, @@ -108,6 +110,7 @@ "Refusal", "RefusalCode", "PollRequest", + "RUNNER_RENEWAL_HEADER", "RegisterCapabilities", "RegisterRequest", "RegisterResponse", @@ -126,6 +129,7 @@ "map_control_verb", "read_managed_dispatch_envelope", "parse_dispatch", + "registration_renewal_headers", "server_reclaim_outcome", "verify_dispatch", "write_managed_dispatch_envelope", diff --git a/openadapt_flow/runner/hosted_adapter.py b/openadapt_flow/runner/hosted_adapter.py index 361482dd..9ee003fb 100644 --- a/openadapt_flow/runner/hosted_adapter.py +++ b/openadapt_flow/runner/hosted_adapter.py @@ -39,6 +39,7 @@ QualificationAdmissionEnvelope, QualificationAdmissionExpected, QualificationSignerRegistry, + canonical_json, contract_sha256, verify_qualification_admission, ) @@ -53,16 +54,31 @@ load_product_release_signer_trust, verify_product_release_admission, ) -from openadapt_flow.runner.protocol import DispatchParamsValues, RunnerDispatchPayload +from openadapt_flow.runner.protocol import ( + DispatchParamsValues, + RunnerDispatchPayload, + validate_runtime_param_name, +) +from openadapt_flow.runner.protocol import ( + dispatch_binding_sha256 as governed_dispatch_binding_sha256, +) from openadapt_flow.runner.verify import Refusal, RefusalCode, verify_dispatch +from openadapt_flow.runtime.authorization import RuntimeParamScalar from openadapt_flow.runtime.durable.authority import ( REMOTE_AUTHORITY_TOKEN_ENV, REMOTE_AUTHORITY_URL_ENV, REMOTE_DISPATCH_SESSION_ID_ENV, + DurableAuthority, ) +from openadapt_flow.runtime.durable.checkpoint import CheckpointStore from openadapt_flow.terminal_verification_v2 import ( + ProductionTerminalVerificationContext, ProductionTerminalVerificationEnvelope, + ProductionTerminalVerificationExpected, + build_production_terminal_verification, evidence_runner_signer_sha256, + prepare_production_terminal_evidence, + verify_production_terminal_verification_from_report, ) from openadapt_flow.transaction import ( DuplicateActuation, @@ -80,6 +96,21 @@ _UTC_SECONDS = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") _MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 +# The current runner credential is never part of a request model. Desktop may +# project it into this header only for POST /api/runners/register on the exact +# protected runner origin. +RUNNER_RENEWAL_HEADER = "x-openadapt-runner-renewal-token" + + +def registration_renewal_headers(current_runner_token: str | None) -> dict[str, str]: + """Return the one register-only renewal header without retaining it.""" + + if current_runner_token is None or current_runner_token == "": + return {} + if re.fullmatch(_RUNNER_TOKEN, current_runner_token) is None: + raise ValueError("current runner renewal credential is invalid") + return {RUNNER_RENEWAL_HEADER: current_runner_token} + def _utc_seconds(value: str, *, label: str) -> datetime: if _UTC_SECONDS.fullmatch(value) is None: @@ -283,6 +314,31 @@ def _verified_requires_exact_proof(self) -> "HostedTerminalEvent": raise ValueError("VERIFIED requires exact terminal verification") if self.outcome != "VERIFIED" and has_proof: raise ValueError("non-VERIFIED callback cannot carry a success proof") + if has_proof: + assert self.terminal_verification_artifact_bytes_base64 is not None + assert self.terminal_verification_artifact_sha256 is not None + try: + raw = b64decode( + self.terminal_verification_artifact_bytes_base64, + validate=True, + ) + proof = ProductionTerminalVerificationEnvelope.model_validate_json(raw) + except (ValueError, TypeError) as exc: + raise ValueError("terminal verification artifact is invalid") from exc + if ( + len(raw) > _MAX_ARTIFACT_BYTES + or b64encode(raw).decode("ascii") + != self.terminal_verification_artifact_bytes_base64 + or canonical_json(proof) != raw + or proof.artifact_sha256() != self.terminal_verification_artifact_sha256 + ): + raise ValueError("terminal verification artifact binding is invalid") + if ( + proof.payload.run_id != self.run_id + or proof.payload.run_report_sha256 != self.report_sha256 + or proof.payload.run_report_object_sha256 != self.report_sha256 + ): + raise ValueError("terminal verification names a different run report") return self @@ -308,6 +364,14 @@ def _closed_terminal(self) -> "HostedRunResult": TransactionOutcome.VERIFIED, }: raise ValueError("uncertain delivery has an invalid terminal outcome") + if self.terminal_verification is not None and ( + self.terminal_verification.payload.run_id != self.run_id + or self.terminal_verification.payload.run_report_sha256 + != self.report_sha256 + or self.terminal_verification.payload.run_report_object_sha256 + != self.report_sha256 + ): + raise ValueError("terminal verification names a different run report") return self @@ -411,13 +475,7 @@ def _subprocess_runner( ) report_path = run_dir / "report.json" report_bytes = report_path.read_bytes() if report_path.is_file() else None - proof_path = run_dir / "production-terminal-verification.json" - proof = None - if proof_path.is_file(): - proof = ProductionTerminalVerificationEnvelope.model_validate_json( - proof_path.read_bytes() - ) - return ManagedExecution(process.returncode, report_bytes, proof) + return ManagedExecution(process.returncode, report_bytes) class HostedRunnerAdapter: @@ -463,6 +521,13 @@ def _protected_runner_origin(config: RunnerConfig) -> str: raise ValueError("protected runner host is not one canonical HTTPS origin") return canonical + def protected_runner_origin(self, runner_config: Path) -> str: + """Return the origin from one protected, strictly parsed runner config.""" + + return self._protected_runner_origin( + load_runner_config(runner_config, protected=True) + ) + def registration_request( self, *, @@ -891,7 +956,7 @@ def _resolve_params( self, dispatch: HostedDispatch, config: RunnerConfig, - ) -> dict[str, str]: + ) -> dict[str, RuntimeParamScalar]: trusted = config.bundles.get(dispatch.payload.bundle.content_digest) if trusted is None: raise ValueError("hosted bundle is not locally trusted") @@ -955,16 +1020,17 @@ def _resolve_params( supplied = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError("parameter reference is not valid JSON") from exc - if not isinstance(supplied, dict) or any( - not isinstance(key, str) or not isinstance(value, str) - for key, value in supplied.items() - ): + if not isinstance(supplied, dict): raise ValueError("parameter reference has an invalid exact shape") inline = False + for name in supplied: + if not isinstance(name, str): + raise ValueError("runtime parameter name is invalid") + validate_runtime_param_name(name) return resolve_admitted_params(workflow, supplied, inline=inline) @staticmethod - def _write_params(path: Path, params: dict[str, str]) -> Path | None: + def _write_params(path: Path, params: dict[str, RuntimeParamScalar]) -> Path | None: if not params: return None flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL @@ -972,24 +1038,319 @@ def _write_params(path: Path, params: dict[str, str]) -> Path | None: flags |= os.O_NOFOLLOW descriptor = os.open(path, flags, 0o600) try: - raw = json.dumps(params, sort_keys=True, separators=(",", ":")).encode() + raw = json.dumps( + params, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() os.write(descriptor, raw) os.fsync(descriptor) finally: os.close(descriptor) return path - @staticmethod - def _validate_terminal( + def _produce_terminal_verification( + self, + *, dispatch: HostedDispatch, - report_bytes: bytes, - proof: ProductionTerminalVerificationEnvelope, - ) -> None: - del dispatch, report_bytes, proof - raise ValueError( - "hosted production success requires a locally produced proof from " - "independent expected state and retained delivery receipts" + report: RunReport, + run_dir: Path, + qualification: ProductionQualificationAuthority, + private_key: Ed25519PrivateKey, + verified_params: dict[str, RuntimeParamScalar], + dispatch_binding_sha256: str, + ) -> tuple[ProductionTerminalVerificationEnvelope, str]: + """Build, retain, reread, and verify one exact terminal-v2 proof.""" + + store = CheckpointStore(run_dir) + manifest = store.read_manifest() + if ( + manifest is None + or manifest.delivery_authority_kind != "cloud_runner" + or manifest.remote_delivery_run_id != dispatch.run_id + or manifest.managed_dispatch_binding_sha256 != dispatch_binding_sha256 + or manifest.params != verified_params + ): + raise ValueError("retained managed run manifest differs from the dispatch") + authorization = manifest.governed_authorization + admission = qualification.qualification_admission + evidence_identity = admission.payload.evidence_identity + expected_production_binding = { + "production_qualification_admission_id": admission.payload.admission_id, + "production_qualification_admission_sha256": ( + qualification.qualification_admission_sha256 + ), + "production_qualification_evidence_identity_sha256": ( + evidence_identity.artifact_sha256() + ), + "production_qualification_runtime_validation_id": ( + qualification.expected.runtime_validation_id + ), + "production_qualification_signer_registry_sha256": ( + qualification.qualification_signer_registry_sha256 + ), + "production_qualification_signer_registry_revision": ( + qualification.qualification_signer_registry.revision + ), + "production_qualification_signer_registry_expires_at": ( + qualification.qualification_signer_registry.expires_at + ), + "production_qualification_authority_sha256": ( + qualification.immutable_binding_sha256() + ), + } + if authorization is None or any( + getattr(authorization, field) != value + for field, value in expected_production_binding.items() + ): + raise ValueError("retained production authority differs from admission") + if ( + dispatch_binding_sha256 != dispatch.payload.dispatch_binding_sha256 + or governed_dispatch_binding_sha256(dispatch.run_id, authorization) + != dispatch.payload.dispatch_binding_sha256 + ): + raise ValueError("retained governed authorization differs from dispatch") + qualification_case_id_sha256 = ( + None + if authorization.qualification_case_id is None + else hashlib.sha256( + authorization.qualification_case_id.encode("utf-8") + ).hexdigest() ) + authorized_effect_contracts = { + approval.step_id: list(approval.effect_contract_hashes) + for approval in authorization.unverified_write_approvals + } + if ( + report.params != verified_params + or report.governed_authorization_id != authorization.authorization_id + or report.governed_authorization_created_at != authorization.created_at + or report.governed_runtime_inputs_digest + != authorization.runtime_inputs_digest + or report.governed_policy_name != authorization.admitted_policy_name + or report.governed_policy_contract_sha256 + != authorization.admitted_policy_contract_sha256 + or report.governed_minimum_effect_tier != authorization.minimum_effect_tier + or report.governed_approval_source != authorization.approval_source + or report.execution_profile != authorization.execution_profile + or tuple(report.governed_qualified_effect_requirements) + != authorization.qualified_effect_requirements + or tuple(report.required_identity_step_ids) + != authorization.required_identity_step_ids + or report.approved_unverified_effect_step_ids + != [item.step_id for item in authorization.unverified_write_approvals] + or report.governed_authorized_effect_contracts + != authorized_effect_contracts + or report.governed_qualification_project_id + != authorization.qualification_project_id + or report.governed_qualification_project_revision + != authorization.qualification_project_revision + or report.governed_qualification_project_contract_sha256 + != authorization.qualification_project_contract_sha256 + or report.governed_qualification_campaign_id_sha256 + != authorization.qualification_campaign_id_sha256 + or report.governed_qualification_case_id_sha256 + != qualification_case_id_sha256 + or report.governed_qualification_case_input_sha256 + != authorization.qualification_case_input_sha256 + or report.governed_qualification_run_id_sha256 + != authorization.qualification_run_id_sha256 + or report.governed_qualification_case_kind + != authorization.qualification_case_kind + or report.governed_qualification_case_action_paths + != authorization.qualification_case_action_paths + or report.governed_qualification_fault_driver_id + != authorization.qualification_fault_driver_id + or report.governed_qualification_fault_driver_contract_sha256 + != authorization.qualification_fault_driver_contract_sha256 + or report.governed_qualification_fault_driver_key_id + != authorization.qualification_fault_driver_key_id + or report.governed_qualification_fault_step_id_sha256 + != authorization.qualification_fault_step_id_sha256 + ): + raise ValueError("run report differs from retained governed inputs") + + chain = DurableAuthority(run_dir, store).production_delivery_permit_chain() + first = chain.entries[0] + expected = qualification.expected + runtime = expected.runtime_build_identity + flow_run_id_sha256 = hashlib.sha256(dispatch.run_id.encode("utf-8")).hexdigest() + runner_id_sha256 = hashlib.sha256( + dispatch.runner_id.encode("utf-8") + ).hexdigest() + runner_session_id_sha256 = hashlib.sha256( + dispatch.runner_session_id.encode("utf-8") + ).hexdigest() + if ( + first.run_id != dispatch.run_id + or first.flow_run_id_sha256 != flow_run_id_sha256 + or first.admission_artifact_sha256 + != qualification.qualification_admission_sha256 + or first.evidence_identity_sha256 + != admission.payload.evidence_identity.artifact_sha256() + or first.environment_digest != expected.environment_digest + or first.qualification_signer_registry_sha256 + != qualification.qualification_signer_registry_sha256 + or first.qualification_signer_registry_revision + != qualification.qualification_signer_registry.revision + or first.authenticated_runner_id_sha256 != runner_id_sha256 + or first.authenticated_session_id_sha256 != runner_session_id_sha256 + ): + raise ValueError("retained delivery chain differs from admitted live state") + + prepared = prepare_production_terminal_evidence(report) + now = datetime.now(timezone.utc).replace(microsecond=0) + now_text = now.isoformat().replace("+00:00", "Z") + context = ProductionTerminalVerificationContext( + run_id=dispatch.run_id, + tenant_id=dispatch.tenant_id, + workflow_id=dispatch.workflow_id, + workflow_version_id=dispatch.workflow_version_id, + bundle_version_id=dispatch.workflow_version_id, + bundle_artifact_sha256=expected.bundle_artifact_sha256, + environment_digest=expected.environment_digest, + environment_contract_sha256=expected.environment_contract_sha256, + runtime_environment_sha256=expected.runtime_environment_sha256, + identity_contract_sha256=expected.identity_contract_sha256, + effect_contract_sha256=expected.effect_contract_sha256, + runtime_validation_id=expected.runtime_validation_id, + runtime_substrate=runtime.substrate, + admission_id=admission.payload.admission_id, + admission_artifact_sha256=(qualification.qualification_admission_sha256), + admission_policy_sha256=evidence_identity.admission_policy_sha256, + evidence_identity_sha256=evidence_identity.artifact_sha256(), + admitted_runtime_build_sha256=runtime.artifact_sha256(), + evidence_runner_signer_sha256=expected.evidence_runner_signer_sha256, + qualification_signer_registry_sha256=( + qualification.qualification_signer_registry_sha256 + ), + qualification_signer_registry_revision=( + qualification.qualification_signer_registry.revision + ), + execution_authority_id=first.execution_authority_id, + execution_authority_sha256=first.execution_authority_sha256, + execution_authority_signer_sha256=first.authority_signer_sha256, + permit_chain=chain, + run_report_object_version="sha256:" + prepared.report_sha256, + verified_at=now_text, + issued_at=now_text, + ) + built = build_production_terminal_verification( + report, + context=context, + private_key=private_key, + ) + if ( + built.report_bytes != prepared.report_bytes + or built.report_sha256 != prepared.report_sha256 + ): + raise ValueError("terminal report changed during proof production") + envelope_bytes = canonical_json(built.envelope) + payload = built.envelope.payload + final = chain.entries[-1] + live_expected = ProductionTerminalVerificationExpected( + run_id=dispatch.run_id, + flow_run_id_sha256=flow_run_id_sha256, + tenant_id=dispatch.tenant_id, + workflow_id=dispatch.workflow_id, + workflow_version_id=dispatch.workflow_version_id, + bundle_version_id=dispatch.workflow_version_id, + bundle_artifact_sha256=expected.bundle_artifact_sha256, + bundle_content_digest=expected.bundle_content_digest, + environment_digest=expected.environment_digest, + environment_contract_sha256=expected.environment_contract_sha256, + runtime_environment_sha256=expected.runtime_environment_sha256, + identity_contract_sha256=expected.identity_contract_sha256, + effect_contract_sha256=expected.effect_contract_sha256, + runtime_validation_id=expected.runtime_validation_id, + runtime_substrate=runtime.substrate, + admission_id=admission.payload.admission_id, + admission_artifact_sha256=(qualification.qualification_admission_sha256), + admission_policy_sha256=evidence_identity.admission_policy_sha256, + evidence_identity_sha256=evidence_identity.artifact_sha256(), + admitted_runtime_build_sha256=runtime.artifact_sha256(), + evidence_runner_signer_sha256=expected.evidence_runner_signer_sha256, + qualification_signer_registry_sha256=( + qualification.qualification_signer_registry_sha256 + ), + qualification_signer_registry_revision=( + qualification.qualification_signer_registry.revision + ), + execution_authority_id=first.execution_authority_id, + execution_authority_sha256=first.execution_authority_sha256, + execution_authority_signer_sha256=first.authority_signer_sha256, + permit_chain_sha256=chain.permit_chain_sha256, + permit_count=len(chain.entries), + final_authority_sequence=final.authority_sequence, + final_runtime_delivery_sequence=final.runtime_delivery_sequence, + authenticated_runner_id_sha256=runner_id_sha256, + authenticated_session_id_sha256=runner_session_id_sha256, + acknowledged_one_use_claim_ids=tuple( + item.one_use_claim_id for item in chain.entries + ), + workflow_contract_sha256=payload.workflow_contract_sha256, + execution_outcome_sha256=payload.execution_outcome_sha256, + run_receipt_sha256=payload.run_receipt_sha256, + run_report_sha256=built.report_sha256, + run_report_object_version=context.run_report_object_version, + run_report_object_sha256=built.report_sha256, + evidence_manifests=payload.evidence_manifests, + ) + artifact_sha256 = verify_production_terminal_verification_from_report( + built.envelope, + report_bytes=built.report_bytes, + expected=live_expected, + now=now, + ) + if artifact_sha256 != hashlib.sha256(envelope_bytes).hexdigest(): + raise ValueError("terminal verification artifact digest changed") + + # Final-named evidence exists only after the complete in-memory proof + # passes. If storage or the required reread fails, remove only files + # created by this call so no failed terminalization leaves success + # artifacts behind. + report_path = run_dir / "production-terminal-report.json" + envelope_path = run_dir / "production-terminal-verification.json" + written: list[Path] = [] + try: + self._write_private_bytes(report_path, built.report_bytes) + written.append(report_path) + self._write_private_bytes(envelope_path, envelope_bytes) + written.append(envelope_path) + stored_report = self._read_private_bytes( + report_path, + maximum_bytes=_MAX_ARTIFACT_BYTES, + label="production terminal report", + ) + stored_envelope = self._read_private_bytes( + envelope_path, + maximum_bytes=_MAX_ARTIFACT_BYTES, + label="production terminal verification", + ) + if stored_report != built.report_bytes or stored_envelope != envelope_bytes: + raise ValueError("stored terminal evidence changed after write") + reread = ProductionTerminalVerificationEnvelope.model_validate_json( + stored_envelope + ) + if canonical_json(reread) != stored_envelope: + raise ValueError("stored terminal verification is not canonical") + reread_sha256 = verify_production_terminal_verification_from_report( + reread, + report_bytes=stored_report, + expected=live_expected, + now=now, + ) + if reread_sha256 != artifact_sha256: + raise ValueError("stored terminal verification digest changed") + except Exception: + for path in reversed(written): + try: + path.unlink() + except OSError: + pass + raise + return reread, built.report_sha256 @staticmethod def _refusal( @@ -1256,23 +1617,47 @@ def execute( report_sha256="0" * 64, ) report: RunReport | None = None + report_digest = hashlib.sha256(execution.report_bytes).hexdigest() try: report = RunReport.model_validate_json(execution.report_bytes) outcome = classify_transaction_outcome(report) - proof = execution.terminal_verification + proof: ProductionTerminalVerificationEnvelope | None = None + if execution.terminal_verification is not None: + raise ValueError("managed child supplied an untrusted terminal proof") if outcome is TransactionOutcome.VERIFIED: - if proof is None: - outcome = TransactionOutcome.RECONCILIATION_REQUIRED - else: - self._validate_terminal(parsed, execution.report_bytes, proof) - elif proof is not None: - raise ValueError("non-VERIFIED execution supplied a success proof") - except ValueError: + if execution.returncode != 0: + raise ValueError("managed child exited unsuccessfully") + terminal_config = load_runner_config(runner_config, protected=True) + if self._protected_runner_origin(terminal_config) != configured_origin: + raise ValueError("protected runner origin changed during execution") + self._verify_product_release(parsed, terminal_config) + terminal_key = self._load_evidence_private_key(terminal_config) + terminal_qualification, terminal_deployment = ( + self._verify_workflow_admission( + parsed, + terminal_config, + evidence_private_key=terminal_key, + ) + ) + if ( + terminal_qualification != qualification + or terminal_deployment != deployment_bytes + ): + raise ValueError("production admission changed during execution") + proof, report_digest = self._produce_terminal_verification( + dispatch=parsed, + report=report, + run_dir=run_dir, + qualification=qualification, + private_key=terminal_key, + verified_params=params, + dispatch_binding_sha256=verified.payload.dispatch_binding_sha256, + ) + except Exception: # noqa: BLE001 - post-delivery terminalization fails closed outcome = TransactionOutcome.RECONCILIATION_REQUIRED proof = None if outcome is not TransactionOutcome.VERIFIED: proof = None - report_digest = hashlib.sha256(execution.report_bytes).hexdigest() self._ledger.record_outcome(reservation_key, outcome, run_id=parsed.run_id) if report is None: events = tuple( @@ -1313,24 +1698,33 @@ def execute( def callback_request( self, - dispatch: HostedDispatch, + dispatch: HostedDispatch | HostedRecoveryBinding | Mapping[str, object], result: HostedRunResult | HostedDispatchRefusal, ) -> CallbackRequest: - if ( - result.dispatch_id != dispatch.dispatch_id - or result.run_id != dispatch.run_id - ): + if isinstance(dispatch, HostedDispatch): + binding: HostedDispatch | HostedRecoveryBinding = dispatch + elif isinstance(dispatch, HostedRecoveryBinding): + binding = dispatch + else: + schema = dispatch.get("schema_version") + if schema == "openadapt.hosted-runner-recovery/v1": + binding = HostedRecoveryBinding.model_validate(dispatch) + else: + binding = HostedDispatch.model_validate(dispatch) + if result.dispatch_id != binding.dispatch_id or result.run_id != binding.run_id: raise ValueError("hosted result does not bind the callback lease") events = list(result.evidence_batch) - proof_bytes = None + proof_base64 = None proof_digest = None if isinstance(result, HostedRunResult): if result.terminal_verification is not None: - raise ValueError( - "the full local v2 proof cannot cross the hosted callback boundary" - ) + proof_bytes = canonical_json(result.terminal_verification) + proof_digest = result.terminal_verification.artifact_sha256() + if hashlib.sha256(proof_bytes).hexdigest() != proof_digest: + raise ValueError("terminal proof digest differs from exact bytes") + proof_base64 = b64encode(proof_bytes).decode("ascii") terminal = HostedTerminalEvent( - run_id=dispatch.run_id, + run_id=binding.run_id, outcome=( result.outcome.value if isinstance(result.outcome, TransactionOutcome) @@ -1339,18 +1733,24 @@ def callback_request( report_sha256=result.report_sha256, started=result.started, uncertain_delivery=result.uncertain_delivery, - terminal_verification_artifact_bytes_base64=proof_bytes, + terminal_verification_artifact_bytes_base64=proof_base64, terminal_verification_artifact_sha256=proof_digest, ) events.append(terminal.model_dump(mode="json")) return CallbackRequest( - dispatch_id=dispatch.dispatch_id, - runner_session_id=dispatch.runner_session_id, - idempotency_key=dispatch.idempotency_key, - lease_token=dispatch.lease_token, + dispatch_id=binding.dispatch_id, + runner_session_id=binding.runner_session_id, + idempotency_key=binding.idempotency_key, + lease_token=binding.lease_token, product_release_admission_sha256=( - dispatch.product_release_admission.artifact_sha256 + binding.product_release_admission.artifact_sha256 + if isinstance(binding, HostedDispatch) + else binding.product_release_admission_sha256 + ), + workflow_admission_sha256=( + binding.workflow_admission.artifact_sha256 + if isinstance(binding, HostedDispatch) + else binding.workflow_admission_sha256 ), - workflow_admission_sha256=dispatch.workflow_admission.artifact_sha256, events=tuple(events), ) diff --git a/openadapt_flow/runner/inputs.py b/openadapt_flow/runner/inputs.py index 977fdab8..48d1ebf1 100644 --- a/openadapt_flow/runner/inputs.py +++ b/openadapt_flow/runner/inputs.py @@ -6,20 +6,28 @@ from datetime import date from openadapt_flow.ir import ParamKind, ParamSpec, Workflow -from openadapt_flow.runtime.authorization import effective_runtime_params +from openadapt_flow.runtime.authorization import ( + RuntimeParamScalar, + effective_runtime_params, + is_runtime_param_scalar, +) class AdmittedInputError(ValueError): """Hosted inputs do not fit the exact schema sealed into the workflow.""" -def _validate_value(spec: ParamSpec, value: str) -> None: +def _validate_value(spec: ParamSpec, value: RuntimeParamScalar) -> None: if spec.type is ParamKind.ENUM: - if not spec.choices or value not in spec.choices: + if not isinstance(value, str) or not spec.choices or value not in spec.choices: raise AdmittedInputError( f"parameter {spec.name!r} is outside its admitted enum" ) elif spec.type is ParamKind.DATE: + if not isinstance(value, str): + raise AdmittedInputError( + f"parameter {spec.name!r} is not an ISO date string" + ) try: parsed = date.fromisoformat(value) except ValueError as exc: @@ -31,22 +39,24 @@ def _validate_value(spec: ParamSpec, value: str) -> None: f"parameter {spec.name!r} is not a canonical ISO date" ) elif spec.type is ParamKind.NUMBER: - try: - number = float(value) - except ValueError as exc: - raise AdmittedInputError( - f"parameter {spec.name!r} is not a number" - ) from exc + if type(value) not in {int, float}: + raise AdmittedInputError(f"parameter {spec.name!r} is not a number") + number = float(value) if not math.isfinite(number): raise AdmittedInputError(f"parameter {spec.name!r} must be a finite number") + elif spec.type is ParamKind.BOOLEAN: + if type(value) is not bool: + raise AdmittedInputError(f"parameter {spec.name!r} is not a Boolean") + elif not isinstance(value, str): + raise AdmittedInputError(f"parameter {spec.name!r} is not a string") def resolve_admitted_params( workflow: Workflow, - supplied: dict[str, str], + supplied: dict[str, RuntimeParamScalar], *, inline: bool, -) -> dict[str, str]: +) -> dict[str, RuntimeParamScalar]: """Resolve exact hosted params without inventing or widening the schema. Hosted execution requires the sealed typed schema. Inline input can never @@ -89,7 +99,9 @@ def resolve_admitted_params( if spec.required: raise AdmittedInputError(f"required parameter {name!r} is missing") continue - if not isinstance(value, str): - raise AdmittedInputError(f"parameter {name!r} is not a string value") + if not is_runtime_param_scalar(value): + raise AdmittedInputError( + f"parameter {name!r} is not a finite JSON scalar value" + ) _validate_value(spec, value) return resolved diff --git a/openadapt_flow/runner/protocol.py b/openadapt_flow/runner/protocol.py index be85f9f5..018c223d 100644 --- a/openadapt_flow/runner/protocol.py +++ b/openadapt_flow/runner/protocol.py @@ -17,11 +17,17 @@ import hashlib import json +import re from typing import Union from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator -from openadapt_flow.runtime.authorization import GovernedRunAuthorization +from openadapt_flow.runtime.authorization import ( + GovernedRunAuthorization, + RuntimeParamScalar, + is_runtime_param_scalar, + normalize_runtime_param_scalar, +) #: The only job kind the v1 client executes. JOB_KIND_GOVERNED_RUN = "governed_run" @@ -34,6 +40,21 @@ #: Cloud long-poll ceiling (runners.ts POLL_MAX_WAIT_S). POLL_MAX_WAIT_S = 25 +# This is the exact closed grammar used by Cloud's production runtime schema. +# Keeping hosted parameter keys in ASCII also makes Python and JavaScript key +# ordering byte-identical for the authorization digest. +RUNTIME_PARAM_NAME_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]{0,127}$" +_RUNTIME_PARAM_NAME = re.compile(RUNTIME_PARAM_NAME_PATTERN) + + +def validate_runtime_param_name(name: str) -> str: + """Refuse a hosted parameter name outside the shared Cloud grammar.""" + + if _RUNTIME_PARAM_NAME.fullmatch(name) is None: + raise ValueError("runtime parameter name is invalid") + return name + + #: Runtime-local v2 authority bindings are deliberately OUTSIDE the dispatch #: binding digest. The digest grammar is a cross-repo contract: Cloud and Flow #: must hash byte-identical payloads, so fields Cloud does not emit may not @@ -110,7 +131,20 @@ class DispatchParamsValues(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - values: dict[str, str] + values: dict[str, RuntimeParamScalar] = Field(max_length=100) + + @model_validator(mode="after") + def _finite_json_scalars(self) -> "DispatchParamsValues": + normalized: dict[str, RuntimeParamScalar] = {} + for key, value in self.values.items(): + validate_runtime_param_name(key) + if not is_runtime_param_scalar(value): + raise ValueError( + "runtime parameters must be finite JSON-safe scalar values" + ) + normalized[key] = normalize_runtime_param_scalar(value) + object.__setattr__(self, "values", normalized) + return self class DispatchParamsRef(BaseModel): diff --git a/openadapt_flow/runner/verify.py b/openadapt_flow/runner/verify.py index 8819b0b1..996b2d96 100644 --- a/openadapt_flow/runner/verify.py +++ b/openadapt_flow/runner/verify.py @@ -49,6 +49,10 @@ DispatchParamsValues, RunnerDispatchPayload, ) +from openadapt_flow.runtime.authorization import ( + RuntimeParamScalar, + runtime_param_text, +) if TYPE_CHECKING: # pragma: no cover - typing only from pathlib import Path @@ -119,7 +123,7 @@ class VerifiedDispatch: payload: RunnerDispatchPayload bundle: TrustedBundle profile_path: "Path" - params: dict[str, str] + params: dict[str, RuntimeParamScalar] workflow: "Workflow" #: Whole-workflow coverage counts for the terminal run_summary (computed #: from the sealed bundle, not from the cloud's claims). @@ -143,7 +147,7 @@ def verify_dispatch( *, now: Optional[datetime] = None, active_workflow_ids: Optional[set[str]] = None, - resolved_params: Optional[dict[str, str]] = None, + resolved_params: Optional[dict[str, RuntimeParamScalar]] = None, ) -> VerifiedDispatch | Refusal: """Independently verify ``payload`` against local trust. Never executes. @@ -245,7 +249,7 @@ def verify_dispatch( RefusalCode.PARAM_DOMAIN_REFUSED, f"param {key!r} has no operator-pinned domain pattern", ) - if re.fullmatch(pattern, params[key]) is None: + if re.fullmatch(pattern, runtime_param_text(params[key])) is None: return Refusal( RefusalCode.PARAM_DOMAIN_REFUSED, f"param {key!r} does not match its pinned domain pattern", diff --git a/openadapt_flow/runtime/authorization.py b/openadapt_flow/runtime/authorization.py index 5e814c85..a59e8e82 100644 --- a/openadapt_flow/runtime/authorization.py +++ b/openadapt_flow/runtime/authorization.py @@ -10,11 +10,12 @@ import hashlib import json +import math import re import threading from datetime import datetime, timezone from pathlib import Path -from typing import Literal +from typing import Literal, Mapping, TypeAlias, Union, cast from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -30,22 +31,167 @@ _CONSUMED_LOCK = threading.Lock() _QUALIFICATION_ID_RE = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" +RuntimeParamScalar: TypeAlias = Union[str, bool, int, float] +_JS_SAFE_INTEGER = 9_007_199_254_740_991 -def effective_runtime_params( - workflow: Workflow, supplied: dict[str, str] | None + +def is_runtime_param_scalar(value: object) -> bool: + """Return whether ``value`` is one exact supported finite JSON scalar.""" + + if type(value) in {str, bool}: + return True + if type(value) is int: + if -_JS_SAFE_INTEGER <= value <= _JS_SAFE_INTEGER: + return True + # JSON has one Number type. Admit a large integer spelling only when + # the JavaScript renderer of its finite IEEE-754 value returns that + # same spelling. The normalizer converts it to that Number before any + # retained artifact. + try: + number = float(value) + except OverflowError: + return False + return math.isfinite(number) and _javascript_number_text(number) == str(value) + return type(value) is float and math.isfinite(value) + + +def normalize_runtime_param_scalar(value: object) -> RuntimeParamScalar: + """Return one exact finite JSON scalar in its cross-language representation.""" + + if not is_runtime_param_scalar(value): + raise ValueError("runtime parameter is not a finite interoperable JSON scalar") + if type(value) is int and not -_JS_SAFE_INTEGER <= value <= _JS_SAFE_INTEGER: + return float(value) + return cast(RuntimeParamScalar, value) + + +def _javascript_number_text(value: int | float) -> str: + """Return the finite number text used by JavaScript ``JSON.stringify``. + + The hosted service uses ``JSON.stringify`` for primitive values in its + sorted-key canonical JSON. Python and JavaScript choose different exponent + formatting thresholds, so ordinary ``json.dumps`` is not interoperable. + An out-of-safe-range Python integer reaches this function only when its + spelling is the canonical JavaScript rendering of the normalized Number. + """ + + if type(value) is int: + if not is_runtime_param_scalar(value): + raise ValueError("integer parameter exceeds the JSON safe-integer range") + if not -_JS_SAFE_INTEGER <= value <= _JS_SAFE_INTEGER: + return _javascript_number_text(float(value)) + return str(value) + if type(value) is not float or not math.isfinite(value): + raise ValueError("number parameter must be finite") + if value == 0: + return "0" + + negative = value < 0 + source = repr(abs(value)).lower() + mantissa, separator, exponent_text = source.partition("e") + exponent = int(exponent_text) if separator else 0 + integer, dot, fraction = mantissa.partition(".") + digits = integer + (fraction if dot else "") + decimal_exponent = exponent - len(fraction) + while len(digits) > 1 and digits.endswith("0"): + digits = digits[:-1] + decimal_exponent += 1 + decimal_point = len(digits) + decimal_exponent + + absolute = abs(value) + if 1e-6 <= absolute < 1e21: + if decimal_point <= 0: + rendered = "0." + ("0" * -decimal_point) + digits + elif decimal_point >= len(digits): + rendered = digits + ("0" * (decimal_point - len(digits))) + else: + rendered = digits[:decimal_point] + "." + digits[decimal_point:] + else: + scientific_exponent = decimal_point - 1 + rendered = digits[0] + if len(digits) > 1: + rendered += "." + digits[1:] + rendered += "e" + ("+" if scientific_exponent >= 0 else "") + rendered += str(scientific_exponent) + return ("-" if negative else "") + rendered + + +def _hosted_canonical_json(value: object) -> str: + """Match the hosted sorted-key ``canonicalJson`` implementation exactly.""" + + if value is None: + return "null" + if type(value) is bool: + return "true" if value else "false" + if type(value) in {int, float}: + return _javascript_number_text(cast(Union[int, float], value)) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if isinstance(value, list): + return "[" + ",".join(_hosted_canonical_json(item) for item in value) + "]" + if isinstance(value, dict): + if any(not isinstance(key, str) for key in value): + raise ValueError("canonical JSON object keys must be strings") + # JavaScript Array.sort compares UTF-16 code units. Python's default + # string order compares Unicode code points, which differs for astral + # characters versus BMP characters at U+D800 and above. + ordered_keys = sorted(value, key=lambda key: key.encode("utf-16-be")) + members = ( + json.dumps(key, ensure_ascii=False) + + ":" + + _hosted_canonical_json(value[key]) + for key in ordered_keys + ) + return "{" + ",".join(members) + "}" + raise ValueError(f"unsupported canonical JSON value: {type(value).__name__}") + + +def runtime_param_text(value: RuntimeParamScalar) -> str: + """Render one admitted scalar only at the final GUI text boundary.""" + + value = normalize_runtime_param_scalar(value) + if isinstance(value, str): + return value + if type(value) is bool: + return "true" if value else "false" + return _javascript_number_text(value) + + +def runtime_params_for_gui( + params: Mapping[str, RuntimeParamScalar], ) -> dict[str, str]: + """Convert the already-authorized typed parameter set to GUI text.""" + + return {name: runtime_param_text(value) for name, value in params.items()} + + +def effective_runtime_params( + workflow: Workflow, supplied: Mapping[str, RuntimeParamScalar] | None +) -> dict[str, RuntimeParamScalar]: """Resolve defaults exactly as :meth:`Replayer.run` does.""" - merged = dict(workflow.params) + merged: dict[str, RuntimeParamScalar] = dict(workflow.params) for name, spec in workflow.param_specs.items(): if spec.example is not None: merged.setdefault(name, spec.example) merged.update(supplied or {}) - return merged + try: + return { + name: normalize_runtime_param_scalar(value) + for name, value in merged.items() + } + except ValueError as exc: + invalid = [ + name for name, value in merged.items() if not is_runtime_param_scalar(value) + ] + raise ValueError( + "runtime parameters must be strings, Booleans, or finite JSON-safe " + "numbers: " + ", ".join(sorted(invalid)) + ) from exc def runtime_inputs_bytes( workflow: Workflow, - params: dict[str, str] | None, + params: Mapping[str, RuntimeParamScalar] | None, worklists: dict[str, list[dict[str, str]]] | None, *, interstitials: list[Interstitial] | None = None, @@ -64,9 +210,7 @@ def runtime_inputs_bytes( payload["interstitials"] = [ interstitial.model_dump(mode="json") for interstitial in interstitials ] - canonical = json.dumps( - payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False - ) + canonical = _hosted_canonical_json(payload) return canonical.encode("utf-8") @@ -74,7 +218,7 @@ def parse_runtime_inputs_bytes( value: bytes, *, workflow: Workflow, -) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]: +) -> tuple[dict[str, RuntimeParamScalar], dict[str, list[dict[str, str]]]]: """Parse bytes that this workflow's runtime serializer can emit exactly.""" try: @@ -88,7 +232,7 @@ def parse_runtime_inputs_bytes( params = payload.get("params") worklists = payload.get("worklists") if not isinstance(params, dict) or any( - not isinstance(key, str) or not isinstance(item, str) + not isinstance(key, str) or not is_runtime_param_scalar(item) for key, item in params.items() ): raise ValueError("runtime-input artifact has invalid parameters") @@ -135,12 +279,12 @@ def parse_runtime_inputs_bytes( ) if canonical != value: raise ValueError("runtime-input artifact is not in canonical form") - return dict(params), canonical_worklists + return effective_runtime_params(workflow, dict(params)), canonical_worklists def runtime_inputs_digest( workflow: Workflow, - params: dict[str, str] | None, + params: Mapping[str, RuntimeParamScalar] | None, worklists: dict[str, list[dict[str, str]]] | None, *, interstitials: list[Interstitial] | None = None, @@ -692,7 +836,7 @@ def validate_execution( workflow: Workflow, *, bundle_dir: Path | str, - params: dict[str, str] | None, + params: Mapping[str, RuntimeParamScalar] | None, worklists: dict[str, list[dict[str, str]]] | None, interstitials: list[Interstitial] | None = None, continuation: bool = False, @@ -713,7 +857,7 @@ def validate_execution_snapshot( workflow: Workflow, *, bundle_dir: Path | str, - params: dict[str, str] | None, + params: Mapping[str, RuntimeParamScalar] | None, worklists: dict[str, list[dict[str, str]]] | None, interstitials: list[Interstitial] | None = None, continuation: bool = False, diff --git a/openadapt_flow/runtime/durable/attended.py b/openadapt_flow/runtime/durable/attended.py index 75fb208b..0afde4dc 100644 --- a/openadapt_flow/runtime/durable/attended.py +++ b/openadapt_flow/runtime/durable/attended.py @@ -49,6 +49,7 @@ effects_for_actuation, project_step_safety, ) +from openadapt_flow.runtime.authorization import RuntimeParamScalar from openadapt_flow.runtime.durable.approval import ( ApprovalRecord, ApprovalRequired, @@ -2749,7 +2750,7 @@ def _validated_attended_result( *, identity: Optional[IdentityCheck], skipped: bool, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], manifest: Any, ) -> StepResult: """Return the exact evidence shape emitted by an attended completion.""" @@ -2813,7 +2814,7 @@ def checkpoint_human_completed_step( capability: AttendedPauseCapability, approval: ApprovalRecord, result: StepResult, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], key: Optional[str] = None, ) -> RunCheckpoint: """Advance a linear resume point after outcome verification, without acting.""" @@ -3344,7 +3345,7 @@ def _program_context( store: CheckpointStore, workflow: Workflow, capability: AttendedPauseCapability, - ) -> tuple[PendingEscalation, State, dict[str, str]]: + ) -> tuple[PendingEscalation, State, dict[str, RuntimeParamScalar]]: pending = store.read_pending() state = _program_pause_state(workflow, pending) if pending is not None else None if ( @@ -3370,7 +3371,7 @@ def _resume_program( approval: ApprovalRecord, pending: PendingEscalation, state: State, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], result: StepResult, skipped: bool, target_state_id: Optional[str], @@ -3753,7 +3754,12 @@ def _continue_run_locked( approval: ApprovalRecord, ) -> AttendedExecutionResult: program_context: Optional[ - tuple[PendingEscalation, State, dict[str, str], Optional[str]] + tuple[ + PendingEscalation, + State, + dict[str, RuntimeParamScalar], + Optional[str], + ] ] = None try: store, manifest, workflow = self._load(run_dir, capability) @@ -3907,7 +3913,12 @@ def _reconcile_run_locked( ) reconciliation_delivery_state = capability.delivery_state program_context: Optional[ - tuple[PendingEscalation, State, dict[str, str], Optional[str]] + tuple[ + PendingEscalation, + State, + dict[str, RuntimeParamScalar], + Optional[str], + ] ] = None try: store, manifest, workflow = self._load(run_dir, capability) @@ -4087,7 +4098,12 @@ def _skip_run_locked( approval: ApprovalRecord, ) -> AttendedExecutionResult: program_context: Optional[ - tuple[PendingEscalation, State, dict[str, str], Optional[str]] + tuple[ + PendingEscalation, + State, + dict[str, RuntimeParamScalar], + Optional[str], + ] ] = None try: store, manifest, workflow = self._load(run_dir, capability) diff --git a/openadapt_flow/runtime/durable/business_decision.py b/openadapt_flow/runtime/durable/business_decision.py index 3d52b81c..858264fe 100644 --- a/openadapt_flow/runtime/durable/business_decision.py +++ b/openadapt_flow/runtime/durable/business_decision.py @@ -27,6 +27,7 @@ ProgramExecutionScopeFrame, Workflow, ) +from openadapt_flow.runtime.authorization import RuntimeParamScalar from openadapt_flow.runtime.durable.approval import ( ApprovalRecord, ApprovalRequired, @@ -806,7 +807,7 @@ def issue( graph_id: str, state_id: str, frames: list[GraphFrame], - params: dict[str, str], + params: dict[str, RuntimeParamScalar], spec: BusinessDecisionSpec, governed_runtime_inputs_digest: str | None, now: datetime | None = None, @@ -933,7 +934,7 @@ def _validate_live_binding( graph_id: str, state_id: str, frames: list[GraphFrame], - params: dict[str, str], + params: dict[str, RuntimeParamScalar], spec: BusinessDecisionSpec, governed_runtime_inputs_digest: str | None, ) -> None: @@ -1183,7 +1184,7 @@ def consume( graph_id: str, state_id: str, frames: list[GraphFrame], - params: dict[str, str], + params: dict[str, RuntimeParamScalar], spec: BusinessDecisionSpec, governed_runtime_inputs_digest: str | None, now: datetime | None = None, diff --git a/openadapt_flow/runtime/durable/checkpoint.py b/openadapt_flow/runtime/durable/checkpoint.py index 6072cf9c..aa69f853 100644 --- a/openadapt_flow/runtime/durable/checkpoint.py +++ b/openadapt_flow/runtime/durable/checkpoint.py @@ -62,7 +62,10 @@ ProgramTransitionEvidence, Resolution, ) -from openadapt_flow.runtime.authorization import GovernedRunAuthorization +from openadapt_flow.runtime.authorization import ( + GovernedRunAuthorization, + RuntimeParamScalar, +) from openadapt_flow.runtime.durable.approval import ApprovalRecord from openadapt_flow.runtime.durable.program_checkpoint import ( GraphFrame, @@ -115,7 +118,7 @@ class RunManifest(BaseModel): bundle_dir: str #: The run's fully-resolved parameter bindings (defaults + caller #: overrides), so a resume re-binds identically. - params: dict[str, str] = Field(default_factory=dict) + params: dict[str, RuntimeParamScalar] = Field(default_factory=dict) #: Stable at-most-once reservation for the complete logical durable run. #: A resumed leg reuses it only after it proves ownership in the ledger. idempotency_key: Optional[str] = None @@ -206,7 +209,7 @@ class RunCheckpoint(BaseModel): #: point at an arbitrary successor state. next_step_index: int #: The run's parameter bindings at checkpoint time (resume re-binds these). - params: dict[str, str] = Field(default_factory=dict) + params: dict[str, RuntimeParamScalar] = Field(default_factory=dict) #: Verification evidence carried for the audit trail / operator. effect_verified: Optional[bool] = None effect_approved_unverified: bool = False @@ -295,7 +298,7 @@ class PendingEscalation(BaseModel): resume_from_index: int = 0 resume_from_step_id: Optional[str] = None #: The run's parameter bindings, so an approved resume re-binds identically. - params: dict[str, str] = Field(default_factory=dict) + params: dict[str, RuntimeParamScalar] = Field(default_factory=dict) #: ``rejected`` is TERMINAL: an operator answered the attended pause with #: ``reject``, asserting this run must not proceed. The file is retained #: rather than cleared so the audit trail keeps WHY the run stopped and diff --git a/openadapt_flow/runtime/durable/controller.py b/openadapt_flow/runtime/durable/controller.py index e1994a84..1b5c8ccd 100644 --- a/openadapt_flow/runtime/durable/controller.py +++ b/openadapt_flow/runtime/durable/controller.py @@ -43,7 +43,10 @@ StepResult, Workflow, ) -from openadapt_flow.runtime.authorization import GovernedRunAuthorization +from openadapt_flow.runtime.authorization import ( + GovernedRunAuthorization, + RuntimeParamScalar, +) from openadapt_flow.runtime.durable.approval import StateDiverged from openadapt_flow.runtime.durable.checkpoint import ( CheckpointStore, @@ -262,7 +265,7 @@ def __init__( run_id: str, workflow_name: str, bundle_dir: Path | str, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], idempotency_key: Optional[str] = None, save_healed_to: Optional[Path | str] = None, @@ -452,7 +455,7 @@ def record( step_index: int, step: Step, result: StepResult, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], *, workflow: Optional[Workflow] = None, transition_observation: Optional["TransitionObservation"] = None, @@ -617,7 +620,7 @@ def record_program_halt( state_id: str, intent: str, result: StepResult, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], workflow: Optional[Workflow] = None, transition_observation: Optional["TransitionObservation"] = None, program_frames: Optional[list[GraphFrame]] = None, diff --git a/openadapt_flow/runtime/durable/program_checkpoint.py b/openadapt_flow/runtime/durable/program_checkpoint.py index 40b91bb3..f9bd5111 100644 --- a/openadapt_flow/runtime/durable/program_checkpoint.py +++ b/openadapt_flow/runtime/durable/program_checkpoint.py @@ -20,6 +20,7 @@ import hashlib import json +from collections.abc import Mapping from datetime import datetime, timezone from pathlib import Path from typing import Any, Literal, Optional @@ -40,6 +41,7 @@ ProgramTransitionEvidence, Resolution, ) +from openadapt_flow.runtime.authorization import RuntimeParamScalar #: The synthetic ``graph_id`` of the top-level ``Workflow.program`` graph (every #: OTHER graph is a named entry in ``Workflow.subflows`` -- including a loop @@ -142,7 +144,7 @@ class GraphFrame(BaseModel): state_id: str #: The parameter bindings in scope for this graph frame (a loop body frame's #: scope is the parent's params merged with the current row). - params: dict[str, str] = Field(default_factory=dict) + params: dict[str, RuntimeParamScalar] = Field(default_factory=dict) #: Present iff this frame is a loop-body iteration -- the loop's cursor. loop: Optional[LoopCursor] = None @@ -156,7 +158,7 @@ def control_frames_hash(frames: list[GraphFrame]) -> str: return "sha256:" + hashlib.sha256(canonical).hexdigest() -def bound_params_sha256(params: dict[str, str]) -> str: +def bound_params_sha256(params: Mapping[str, RuntimeParamScalar]) -> str: """Return a PHI-free digest of one exact attended parameter scope.""" canonical = json.dumps( @@ -227,7 +229,7 @@ class ProgramCheckpoint(BaseModel): #: :class:`GraphFrame`). ``frames[-1]`` is the leaf (the verified state). frames: list[GraphFrame] = Field(min_length=1) #: The parameter bindings in scope at the leaf (resume re-binds these). - bound_params: dict[str, str] = Field(default_factory=dict) + bound_params: dict[str, RuntimeParamScalar] = Field(default_factory=dict) #: Contract hashes (``Effect.contract_hash``) of the effects CONFIRMED AT #: THIS state -- appended to the run's completed-effect ledger. Union across #: all checkpoints = every already-performed consequential write, so a resume diff --git a/openadapt_flow/runtime/durable/resume.py b/openadapt_flow/runtime/durable/resume.py index aaaa1cd9..35ee8bb2 100644 --- a/openadapt_flow/runtime/durable/resume.py +++ b/openadapt_flow/runtime/durable/resume.py @@ -30,6 +30,7 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime from pathlib import Path from types import SimpleNamespace @@ -37,6 +38,10 @@ from openadapt_flow.ir import ExecutionTargetKind, RunReport, Step, Workflow from openadapt_flow.policy import effects_for_actuation +from openadapt_flow.runtime.authorization import ( + RuntimeParamScalar, + runtime_params_for_gui, +) from openadapt_flow.runtime.durable.approval import ( ApprovalRecord, ApprovalRequired, @@ -62,13 +67,13 @@ def _resolved_step_effects( step: Step, *, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], run_id: str, actuation: Optional[str], ) -> list[Effect]: """Resolve the exact path-specific effects declared by one retained step.""" - namespace = {**params, "__run_id__": run_id} + namespace = {**runtime_params_for_gui(params), "__run_id__": run_id} return [ effect.resolve(namespace) for effect in effects_for_actuation(step, actuation) ] @@ -77,7 +82,7 @@ def _resolved_step_effects( def _validate_retained_step_proof( *, step: Step, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], run_id: str, skipped: bool, actuation: Optional[str], @@ -1103,7 +1108,7 @@ def _resume_program( checkpoint: Optional[ProgramCheckpoint], checkpoints: list[ProgramCheckpoint], bundle_dir: Path, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], save_healed_to: Optional[Path | str], live_bundle_version: str, diff --git a/openadapt_flow/runtime/effects/adapter.py b/openadapt_flow/runtime/effects/adapter.py index 559e7de1..212824dd 100644 --- a/openadapt_flow/runtime/effects/adapter.py +++ b/openadapt_flow/runtime/effects/adapter.py @@ -118,7 +118,7 @@ #: run parameters for ``ValueExpr({param: ...})`` binding. A factory must fail #: LOUD (raise ``ValueError``) on missing config or secrets -- never construct #: a silently broken verifier. -VerifierFactory = Callable[[Any, Optional[Mapping[str, str]]], Any] +VerifierFactory = Callable[[Any, Optional[Mapping[str, object]]], Any] class ConnectionProbe(BaseModel): diff --git a/openadapt_flow/runtime/effects/effect.py b/openadapt_flow/runtime/effects/effect.py index 87bd6abe..908bca6f 100644 --- a/openadapt_flow/runtime/effects/effect.py +++ b/openadapt_flow/runtime/effects/effect.py @@ -93,7 +93,7 @@ def _exactly_one_source(self) -> "ValueExpr": raise ValueError("effect parameter name must not be empty") return self - def resolve(self, params: Mapping[str, str]) -> Optional[str]: + def resolve(self, params: Mapping[str, object]) -> Optional[str]: """Resolve to a concrete string against ``params``. A ``param`` reference reads ``params[param]`` (``None`` when the run did @@ -102,10 +102,16 @@ def resolve(self, params: Mapping[str, str]) -> Optional[str]: record). A pure literal returns its literal unchanged. """ if self.param is not None: - return params.get(self.param) + if self.param not in params: + return None + # Keep the authorized JSON scalar typed until this exact + # string-only effect-verifier boundary. + from openadapt_flow.runtime.authorization import runtime_param_text + + return runtime_param_text(params[self.param]) # type: ignore[arg-type] return self.literal - def resolved(self, params: Mapping[str, str]) -> "ValueExpr": + def resolved(self, params: Mapping[str, object]) -> "ValueExpr": """Return a pure-literal copy of this expression bound to ``params``.""" return ValueExpr(literal=self.resolve(params)) @@ -482,7 +488,7 @@ def requires_baseline(self) -> bool: # -- run-time parameter binding (P0-3) ----------------------------------- def resolve( self, - params: Mapping[str, str], + params: Mapping[str, object], *, opaque_param_sha256: Mapping[str, str] | None = None, ) -> "Effect": @@ -519,7 +525,7 @@ def resolve( def resolved_contract_hash( self, - params: Mapping[str, str], + params: Mapping[str, object], *, opaque_param_sha256: Mapping[str, str] | None = None, ) -> str: @@ -538,14 +544,14 @@ def resolved_contract_hash( char not in "0123456789abcdef" for char in digest ): raise ValueError(f"opaque parameter {name!r} has an invalid digest") - if ( - name in params - and hashlib.sha256(str(params[name]).encode("utf-8")).hexdigest() - != digest - ): - raise ValueError( - f"opaque parameter {name!r} digest does not match its value" - ) + if name in params: + resolved_value = ValueExpr(param=name).resolve(params) + if resolved_value is None or ( + hashlib.sha256(resolved_value.encode("utf-8")).hexdigest() != digest + ): + raise ValueError( + f"opaque parameter {name!r} digest does not match its value" + ) missing = self.referenced_params().difference(params).difference(opaque) if missing: raise ValueError("effect contract references an unavailable parameter") @@ -558,7 +564,7 @@ def value(expr: ValueExpr | None) -> object: "opaque_param": expr.param, "sha256": opaque[expr.param], } - return str(expr.resolved(params)) + return expr.resolve(params) payload: dict[str, object] = { "kind": self.kind.value, diff --git a/openadapt_flow/runtime/program_predicates.py b/openadapt_flow/runtime/program_predicates.py index 6707e97c..8ba9452f 100644 --- a/openadapt_flow/runtime/program_predicates.py +++ b/openadapt_flow/runtime/program_predicates.py @@ -269,7 +269,7 @@ def predicate_uses_frame(predicate: Predicate | None) -> bool: def evaluate_program_predicate( predicate: Predicate, frame_png: bytes, - params: Mapping[str, str], + params: Mapping[str, object], *, vision: Any, viewport: tuple[int, int] | None, @@ -308,9 +308,11 @@ def evaluate_program_predicate( if kind is PredicateKind.TEXT_ABSENT: return not (predicate.text and vision.text_present(frame_png, predicate.text)) if kind is PredicateKind.PARAM_EQUALS: - return predicate.param is not None and str(params.get(predicate.param)) == str( - predicate.value - ) + if predicate.param is None or predicate.param not in params: + return False + from openadapt_flow.runtime.authorization import runtime_param_text + + return runtime_param_text(params[predicate.param]) == predicate.value # type: ignore[arg-type] if kind is PredicateKind.AND: return all( evaluate_program_predicate( diff --git a/openadapt_flow/runtime/replayer.py b/openadapt_flow/runtime/replayer.py index 53f64903..91928d6f 100644 --- a/openadapt_flow/runtime/replayer.py +++ b/openadapt_flow/runtime/replayer.py @@ -46,7 +46,16 @@ from copy import deepcopy from datetime import date, datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Literal, + Mapping, + Optional, + TypeVar, + cast, +) from urllib.parse import urlsplit from openadapt_flow.backend import ( @@ -135,7 +144,10 @@ from openadapt_flow.runtime import identity as identity_mod from openadapt_flow.runtime.authorization import ( GovernedRunAuthorization, + RuntimeParamScalar, runtime_inputs_digest, + runtime_param_text, + runtime_params_for_gui, ) from openadapt_flow.runtime.durable.approval import StateDiverged from openadapt_flow.runtime.durable.program_checkpoint import ( @@ -314,7 +326,7 @@ def __init__(self, outcome: str, reason: str, *, safety: bool = False) -> None: # Empty means the halt is not eligible for a generic attended # Continue/Skip transition. self.program_frames: list[GraphFrame] = [] - self.program_params: dict[str, str] = {} + self.program_params: dict[str, RuntimeParamScalar] = {} self.program_history_hash: str = "" @@ -598,7 +610,7 @@ def __init__( self._governed_asset_hashes: dict[str, str] = {} self._governed_plaintext_assets = False self._governed_asset_mutation: Optional[str] = None - self._governed_base_params: Optional[dict[str, str]] = None + self._governed_base_params: Optional[dict[str, RuntimeParamScalar]] = None self._active_runtime_worklists: Optional[dict[str, list[dict[str, str]]]] = None self._active_delivery_resolution: Optional[Resolution] = None self._active_delivery_region: Optional[Region] = None @@ -751,7 +763,7 @@ def _durable_resume_payload( run_dir: Path, bundle_dir: Path, run_id: str, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], resume_from: Optional[int], resume_program: Optional[ProgramCheckpoint], @@ -848,7 +860,7 @@ def _admit_durable_resume( run_dir: Path, bundle_dir: Path, run_id: Optional[str], - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], resume_from: Optional[int], resume_program: Optional[ProgramCheckpoint], @@ -913,7 +925,7 @@ def _consume_durable_resume_admission( run_dir: Path, bundle_dir: Path, run_id: str, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], resume_from: Optional[int], resume_program: Optional[ProgramCheckpoint], @@ -983,7 +995,7 @@ def run( self, workflow: Workflow, *, - params: Optional[dict[str, str]] = None, + params: Optional[dict[str, RuntimeParamScalar]] = None, worklists: Optional[dict[str, list[dict[str, str]]]] = None, bundle_dir: Path, run_dir: Path, @@ -1134,7 +1146,7 @@ def run( # with caller-supplied values overriding both. A v0 bundle (empty # ``param_specs``) collapses to exactly the old ``{**workflow.params, # **caller}`` merge. - merged: dict[str, str] = {**workflow.params} + merged: dict[str, RuntimeParamScalar] = {**workflow.params} for pname, spec in workflow.param_specs.items(): if spec.example is not None: merged.setdefault(pname, spec.example) @@ -1543,7 +1555,11 @@ def acknowledge_delivery(self_nonlocal) -> None: missing = sorted( pname for pname, spec in workflow.param_specs.items() - if spec.required and not params.get(pname) + if spec.required + and ( + pname not in params + or (isinstance(params[pname], str) and params[pname] == "") + ) ) if missing: report.results.append( @@ -2295,7 +2311,7 @@ def _interpret_program( self, workflow: Workflow, *, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], bundle_dir: Path, run_dir: Path, @@ -2334,7 +2350,7 @@ def _interpret_program( # Where the interpreter currently is (for a durable pause record). self._current_state_id: str = "" self._current_intent: str = "" - self._current_params: dict[str, str] = dict(params) + self._current_params: dict[str, RuntimeParamScalar] = dict(params) if durable_run is not None: self._bundle_version = _bundle_version(bundle_dir) if self._durable_resume_mode == "program": @@ -2532,7 +2548,7 @@ def _walk_graph( *, graph_id: str, workflow: Workflow, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], bundle_dir: Path, run_dir: Path, @@ -2591,7 +2607,7 @@ def _run_states_from( frame: dict, *, workflow: Workflow, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], bundle_dir: Path, run_dir: Path, @@ -2632,7 +2648,7 @@ def _run_states_from( state.decision.question if state.decision is not None else state.id ) ) - self._current_params = params + self._current_params = dict(params) if state.kind is StateKind.TERMINAL: self._raise_on_governed_asset_mutation() @@ -2668,7 +2684,7 @@ def _exec_state( graph: ProgramGraph, *, workflow: Workflow, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], bundle_dir: Path, run_dir: Path, @@ -2780,7 +2796,7 @@ def _exec_action_state( graph: ProgramGraph, *, workflow: Workflow, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], bundle_dir: Path, run_dir: Path, report: RunReport, @@ -2927,7 +2943,7 @@ def _exec_loop_state( state: State, *, workflow: Workflow, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], worklists: dict[str, list[dict[str, str]]], bundle_dir: Path, run_dir: Path, @@ -3058,7 +3074,7 @@ def _exec_business_decision_state( state: State, *, workflow: Workflow, - params: dict[str, str], + params: dict[str, RuntimeParamScalar], bundle_dir: Path, report: RunReport, run_dir: Path, @@ -3212,7 +3228,7 @@ def _select_transition( state: State, *, workflow: Workflow, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], bundle_dir: Path, report: Optional[RunReport] = None, run_dir: Optional[Path] = None, @@ -3566,14 +3582,14 @@ def _program_scope_from_frames( def _params_with_business_decisions( self, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], scope: list[ProgramExecutionScopeFrame], evidence: list[BusinessDecisionEvidence], *, run_dir: Path, workflow: Workflow, governed_runtime_inputs_digest: str | None, - ) -> dict[str, str]: + ) -> dict[str, RuntimeParamScalar]: """Reapply authenticated decisions made in this exact frame scope.""" if not evidence: @@ -3600,7 +3616,10 @@ def _params_with_business_decisions( return resolved def _skip_completed_effect_state( - self, state: State, params: dict[str, str], report: RunReport + self, + state: State, + params: Mapping[str, RuntimeParamScalar], + report: RunReport, ) -> bool: """Idempotency guard: skip an action state whose declared effects were ALL already CONFIRMED (in the completed-effect ledger). @@ -3797,7 +3816,7 @@ def _record_program_checkpoint( self, state: State, result: StepResult, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], report: RunReport, ) -> None: """Persist a verified-state interpreter checkpoint (Tier-3, program mode). @@ -4342,7 +4361,7 @@ def revalidate_attended_program_completion( *, graph_id: str, state_id: str, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], bundle_dir: Path, run_dir: Path, run_id: str, @@ -4418,7 +4437,7 @@ def select_attended_program_transition( *, graph_id: str, state_id: str, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], bundle_dir: Path, ) -> Optional[str]: """Select and prove one exact successor for an attended action state.""" @@ -4488,7 +4507,7 @@ def revalidate_attended_completion( workflow: Workflow, *, step_index: int, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], bundle_dir: Path, run_dir: Path, run_id: str, @@ -5495,7 +5514,7 @@ def _run_step( *, workflow: Workflow, step_index: int, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], bundle_dir: Path, run_dir: Path, new_crops: dict[str, bytes], @@ -6363,7 +6382,7 @@ def _run_step( selection_error: Optional[str] = None if step.action is ActionKind.SELECT_OPTION: selection_text = ( - params[step.param] + runtime_param_text(params[step.param]) if step.param is not None else step.text or "" ) @@ -6670,7 +6689,7 @@ def _api_request_pointer_field_path(pointer: str) -> tuple[str, ...]: def _api_identity_refusal( self, step: Step, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, effects: list["Effect"], result: StepResult, @@ -6709,7 +6728,8 @@ def _api_identity_refusal( f"({step.intent}): semantic signal {identity.key!r} is not " "part of the qualified identity policy" ) - value = params.get(identity.param) + raw_value = params.get(identity.param) + value = runtime_param_text(raw_value) if raw_value is not None else None if value is None or value == "": return ( f"API identity verification HALTED step '{step.id}' " @@ -6782,7 +6802,7 @@ def _api_identity_refusal( def _try_api_tier( self, step: Step, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], result: StepResult, *, workflow: Workflow, @@ -6988,7 +7008,10 @@ def _try_api_tier( result.delivery_attempted = True try: self._require_qualification_environment_current() - outcome = self.api_actuator.actuate(binding, params) + outcome = self.api_actuator.actuate( + binding, + runtime_params_for_gui(params), + ) except Exception as exc: # noqa: BLE001 - external actuator boundary # The actuator contract is no-throw, but a deployment adapter can # still violate it. The delivery boundary was crossed immediately @@ -7332,7 +7355,7 @@ def _set_api_unavailable_refusal( # -- system-of-record effect verification ----------------------------------- def _resolve_effects( - self, effects: list["Effect"], params: dict[str, str] + self, effects: list["Effect"], params: Mapping[str, RuntimeParamScalar] ) -> list["Effect"]: """Bind each effect's ``ValueExpr`` contract to THIS run's params (P0-3). @@ -7343,7 +7366,10 @@ def _resolve_effects( idempotency key can be bound per-run. A pure-literal (v1) effect is returned value-identical -- ``resolve`` is a no-op for it. """ - namespace = {**params, "__run_id__": self._run_id} + namespace = { + **{name: runtime_param_text(value) for name, value in params.items()}, + "__run_id__": self._run_id, + } run_id_sha256 = hashlib.sha256(self._run_id.encode("utf-8")).hexdigest() return [ effect.resolve( @@ -7810,7 +7836,7 @@ def _identity_gate_error( step: Step, resolution: Resolution, frame_png: bytes, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, bundle_dir: Path, result: StepResult, @@ -8391,7 +8417,7 @@ def _revalidate_consequential_actuation( resolution: Optional[Resolution], matched_region: Optional[Region], before_png: bytes, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, bundle_dir: Path, result: StepResult, @@ -8982,9 +9008,9 @@ def _fresh_actuation_event( def _active_program_frame_refusal( self, workflow: Workflow, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], step: Step, - base_params: dict[str, str], + base_params: Mapping[str, RuntimeParamScalar], ) -> Optional[str]: """Bind the live interpreter path to the sealed program before input.""" @@ -9103,7 +9129,7 @@ def _active_program_frame_refusal( def _fresh_actuation_authorization_refusal( self, workflow: Workflow, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], step: Step, ) -> Optional[str]: """Recheck exact governed authority and inputs before fresh input.""" @@ -9336,7 +9362,7 @@ def _qualification_campaign_refusal(self, workflow: Workflow) -> Optional[str]: def _delivery_authorization_refusal( self, workflow: Workflow, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], step: Step, result: StepResult, ) -> Optional[str]: @@ -9400,7 +9426,7 @@ def _act( self, step: Step, resolution: Optional[Resolution], - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], *, workflow: Workflow, step_index: int, @@ -9888,7 +9914,7 @@ def _act( f"Step '{step.id}' ({step.intent}) requires parameter " f"'{step.param}' but it was not provided" ) - text = params[step.param] + text = runtime_param_text(params[step.param]) elif step.text is not None: text = step.text else: @@ -10677,7 +10703,7 @@ def _handle_interstitials( step: Step, before_png: bytes, bundle_dir: Path, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], result: StepResult, audit_events: list[InterstitialActionResult], workflow: Optional[Workflow], @@ -11003,7 +11029,7 @@ def _apply_step_gates( step: Step, before_png: bytes, bundle_dir: Path, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], result: StepResult, workflow: Optional[Workflow] = None, ) -> tuple[bool, Optional[str], bytes]: @@ -11135,7 +11161,7 @@ def _predicate_holds( pred: Predicate, frame_png: bytes, bundle_dir: Path, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], *, workflow: Optional[Workflow] = None, ) -> bool: @@ -11150,7 +11176,7 @@ def _predicate_holds( return evaluate_program_predicate( pred, frame_png, - params, + runtime_params_for_gui(params), vision=self.vision, viewport=self.backend.viewport, asset_loader=lambda rel: self._asset_bytes( @@ -11220,7 +11246,7 @@ def _compare_qualified_signal_text( signal: Any, anchor: Anchor, live: Optional[str], - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, ) -> Literal["verified", "conflict", "unverifiable"]: """Compare one live source without returning or logging identity values.""" @@ -11259,7 +11285,7 @@ def _compare_qualified_signal_text( ) if set(parameter_names) != set(signal.params): return "unverifiable" - live_values = {**workflow.params, **params} + live_values = {**workflow.params, **runtime_params_for_gui(params)} live_form, used = parameterize_identity_text( live, live_values, @@ -11293,7 +11319,7 @@ def _compare_qualified_signal_text( match=signal.match.value, normalizers=signal.normalizers, live=live, - params=params, + params=runtime_params_for_gui(params), param_examples=workflow.params, parameter_names=signal.params, extract_pattern=extract_pattern, @@ -11406,7 +11432,7 @@ def _verify_signal_quorum( step: Step, resolution: Resolution, before_png: bytes, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, bundle_dir: Optional[Path], policy: Any, @@ -11519,7 +11545,10 @@ def _verify_signal_quorum( # pixels than the demonstration. Exact/explicitly normalized # OCR may verify it only when the expected value does not # occupy the known glyph-confusable identifier class. - live_values = {**workflow.params, **params} + live_values = { + **workflow.params, + **runtime_params_for_gui(params), + } vulnerable = any( identity_mod.identity_rests_on_confusable_identifier( live_values.get(name) @@ -11571,7 +11600,7 @@ def _compare_direct_signal_text( recorded: Optional[str], live: Optional[str], *, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, ) -> tuple[ Literal["verified", "conflict", "unverifiable"], @@ -11590,7 +11619,7 @@ def _compare_direct_signal_text( return "unverifiable", signal.params live, used = parameterize_identity_text( live, - {**workflow.params, **params}, + {**workflow.params, **runtime_params_for_gui(params)}, names=parameter_names, minimum_chars=identity_mod.MIN_PARAM_CHARS, case_sensitive=not ( @@ -11614,7 +11643,7 @@ def _verify_identity( step: Step, resolution: Resolution, before_png: bytes, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, bundle_dir: Optional[Path] = None, ) -> IdentityCheck: @@ -11668,7 +11697,7 @@ def _verify_identity( step=step, resolution=resolution, before_png=before_png, - params=params, + params=runtime_params_for_gui(params), workflow=workflow, bundle_dir=bundle_dir, policy=identity_policy, @@ -11696,7 +11725,7 @@ def structured_tier() -> Optional[IdentityCheck]: return itmpl.verify_structured_template( tmpl, live, - params=params, + params=runtime_params_for_gui(params), param_examples=workflow.params, ) return identity_mod.verify_structured_identity(recorded, live) @@ -11802,7 +11831,7 @@ def _verify_identity_ocr( step: Step, resolution: Resolution, before_png: bytes, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], workflow: Workflow, ) -> IdentityCheck: """OCR name+DOB-primary identity tier (the pixel-substrate fallback). @@ -11902,7 +11931,7 @@ def attempt( return itmpl.verify_template_identity( anchor.identity_template, observed, - params=params, + params=runtime_params_for_gui(params), param_examples=workflow.params, ) # This branch means no identity_template, so the constructor-time @@ -11912,7 +11941,7 @@ def attempt( return identity_mod.verify_target_identity( anchor.context_text, observed, - params=params, + params=runtime_params_for_gui(params), param_examples=workflow.params, ) @@ -12135,7 +12164,7 @@ def _verify_typed_input( result: StepResult, *, workflow: Workflow, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], bundle_dir: Path, run_dir: Path, resolution: Optional[Resolution], @@ -12525,7 +12554,7 @@ def _implicit_scroll_target_ready( *, workflow: Workflow, bundle_dir: Path, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], ) -> bool: """Resolve an implicit scroll target and honor its armed identity. @@ -12595,7 +12624,7 @@ def _act_scroll( bundle_dir: Path, run_dir: Path, before_png: bytes, - params: dict[str, str], + params: Mapping[str, RuntimeParamScalar], result: StepResult, graph_ctx: Optional["_GraphStepContext"] = None, ) -> Optional[str]: diff --git a/openadapt_flow/visualize/builder.py b/openadapt_flow/visualize/builder.py index 5608d968..be45cdff 100644 --- a/openadapt_flow/visualize/builder.py +++ b/openadapt_flow/visualize/builder.py @@ -14,6 +14,7 @@ import re from typing import TYPE_CHECKING, Callable, Optional +from openadapt_flow.runtime.authorization import runtime_param_text from openadapt_flow.visualize.spec import ( BundleMeta, EdgeKind, @@ -293,7 +294,11 @@ def _bundle_meta( type=spec.type.value, required=spec.required, secret=name in (workflow.secret_params or []), - example=spec.example, + example=( + runtime_param_text(spec.example) + if spec.example is not None + else None + ), choices=list(spec.choices), ) ) diff --git a/pyproject.toml b/pyproject.toml index f1e5871e..faa60490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-flow" -version = "1.33.0" +version = "1.34.0" description = "Compile demonstrated GUI workflows into deterministic local replay with governed repair and refusal" readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_effect_kit_config.py b/tests/test_effect_kit_config.py index 221b1623..4a43a4bd 100644 --- a/tests/test_effect_kit_config.py +++ b/tests/test_effect_kit_config.py @@ -76,6 +76,25 @@ def test_path_params_bind_run_params_url_quoted(self, monkeypatch): assert "applicant=OpenAdapt%20Synthetic" in v.records_path assert v.headers == {"Authorization": "Bearer tok"} + def test_path_params_render_typed_scalars_with_json_text(self): + cfg = EffectsConfig( + kind="rest", + base_url="http://sor.local", + records_path="/x?enabled={enabled}&count={count}&ratio={ratio}", + path_params={ + "enabled": {"param": "enabled"}, + "count": {"param": "count"}, + "ratio": {"param": "ratio"}, + }, + ) + + verifier = build_effect_verifier( + cfg, + params={"enabled": False, "count": 0, "ratio": 1e-7}, + ) + + assert verifier.records_path == "/x?enabled=false&count=0&ratio=1e-7" + def test_auth_headers_sent_on_reads_only_when_configured(self): class _Session: def __init__(self): diff --git a/tests/test_execution_profiles.py b/tests/test_execution_profiles.py index 827f5e35..0a2da9cd 100644 --- a/tests/test_execution_profiles.py +++ b/tests/test_execution_profiles.py @@ -69,7 +69,9 @@ from openadapt_flow.runner.evidence import summary_status from openadapt_flow.runtime.authorization import ( GovernedRunAuthorization, + RuntimeParamScalar, runtime_inputs_digest, + runtime_param_text, ) from openadapt_flow.runtime.durable import ApprovalRequired, CheckpointStore, resume from openadapt_flow.runtime.effects import ( @@ -1523,21 +1525,26 @@ def _workflow(transitions: list[Transition]) -> Workflow: ), ) - def _report(workflow: Workflow, *, route: str) -> RunReport: + def _report( + workflow: Workflow, + *, + route: RuntimeParamScalar, + selected_state: str = "second", + ) -> RunReport: report = RunReport( workflow_name=workflow.name, started_at="2026-07-28T00:00:00Z", success=True, execution_completed=True, terminal_outcome="success", - visited_states=["pick", "second", "done"], + visited_states=["pick", selected_state, "done"], params={"route": route}, governed_authorization_id="authorization-1", governed_runtime_inputs_digest="b" * 64, results=[ StepResult( - step_id="second", - intent="second", + step_id=selected_state, + intent=selected_state, ok=True, starting_state_settled=True, delivery_attempted=True, @@ -1550,7 +1557,9 @@ def _report(workflow: Workflow, *, route: str) -> RunReport: _bind_report_to_workflow(report, workflow) pick = workflow.program.states["pick"] first_guard_matches = bool( - pick.transitions[0].guard is not None and route == "first" + pick.transitions[0].guard is not None + and pick.transitions[0].guard.value is not None + and runtime_param_text(route) == pick.transitions[0].guard.value ) report.program_transition_evidence = [ *_transition_evidence( @@ -1562,7 +1571,7 @@ def _report(workflow: Workflow, *, route: str) -> RunReport: ), *_transition_evidence( decision_index=1, - state=workflow.program.states["second"], + state=workflow.program.states[selected_state], verdicts=[True], target="done", inputs_digest=report.governed_runtime_inputs_digest or "", @@ -1611,6 +1620,34 @@ def _report(workflow: Workflow, *, route: str) -> RunReport: ) is ExecutionOutcome.VERIFIED ) + for value, expected_text in [ + (False, "false"), + (0, "0"), + (1e-7, "1e-7"), + (1e20, "100000000000000000000"), + (1e21, "1e+21"), + ]: + typed_guard = _workflow( + [ + Transition( + guard=Predicate( + kind=PredicateKind.PARAM_EQUALS, + param="route", + value=expected_text, + ), + target="first", + ), + Transition(target="second"), + ] + ) + assert ( + classify_execution_outcome( + _report(typed_guard, route=value, selected_state="first"), + typed_guard, + ExecutionProfile.STANDARD, + ) + is ExecutionOutcome.VERIFIED + ) exact = _report(ordered_guard, route="second") for update in ( {"graph_id": "forged-graph"}, diff --git a/tests/test_governed_authorization.py b/tests/test_governed_authorization.py index e6879534..bd500352 100644 --- a/tests/test_governed_authorization.py +++ b/tests/test_governed_authorization.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import pytest @@ -28,6 +29,7 @@ from openadapt_flow.runtime.authorization import ( GovernedRunAuthorization, UnverifiedWriteApproval, + runtime_inputs_bytes, runtime_inputs_digest, ) from openadapt_flow.runtime.durable import ( @@ -119,6 +121,60 @@ def _authorization( ) +def test_runtime_inputs_match_hosted_javascript_scalar_vector() -> None: + workflow = Workflow(name="cross-language-scalars") + params = { + "whole": 7, + "float": 1.5, + "small": 1e-7, + "mid": 1e20, + "large": 1e21, + "bool_true": True, + "bool_false": False, + "string_int": "7", + "string_float": "1.5", + "string_bool": "true", + } + expected = ( + b'{"params":{"bool_false":false,"bool_true":true,"float":1.5,' + b'"large":1e+21,"mid":100000000000000000000,"small":1e-7,' + b'"string_bool":"true",' + b'"string_float":"1.5","string_int":"7","whole":7},' + b'"worklists":{}}' + ) + + actual = runtime_inputs_bytes(workflow, params, None) + + assert actual == expected + assert hashlib.sha256(actual).hexdigest() == ( + "1f9cf0ab74f3194d4759be349239e6f7bbeef13f1f4641bd9e4e55b018728293" + ) + + +def test_runtime_inputs_sort_object_keys_as_javascript_utf16() -> None: + workflow = Workflow(name="cross-language-unicode-order") + params = {"\ue000": 1, "\U0001f600": 2} + expected = '{"params":{"😀":2,"":1},"worklists":{}}'.encode() + + actual = runtime_inputs_bytes(workflow, params, None) + + assert actual == expected + assert hashlib.sha256(actual).hexdigest() == ( + "f70fa507dff3b9f0d54ab13325e5588a18b718f697d3e4a7877eadf06dcc7196" + ) + assert runtime_inputs_digest(workflow, params, None) != runtime_inputs_digest( + workflow, + {name: str(value) for name, value in params.items()}, + None, + ) + + +@pytest.mark.parametrize("value", [9_007_199_254_740_993, float("nan"), float("inf")]) +def test_runtime_inputs_refuse_non_interoperable_numbers(value: object) -> None: + with pytest.raises(ValueError, match="runtime parameters"): + runtime_inputs_bytes(Workflow(name="unsafe-number"), {"value": value}, None) + + def test_in_memory_semantic_mutation_halts_before_action(tmp_path): step = context_click_step("Jane Sample 1980-01-15 MRN 123") workflow, bundle = _seal(tmp_path, Workflow(name="semantic", steps=[step])) diff --git a/tests/test_hosted_runner_adapter.py b/tests/test_hosted_runner_adapter.py index 40146033..1f8b6e08 100644 --- a/tests/test_hosted_runner_adapter.py +++ b/tests/test_hosted_runner_adapter.py @@ -3,25 +3,35 @@ import hashlib import json import os -from base64 import b64encode, urlsafe_b64encode +from base64 import b64decode, b64encode, urlsafe_b64encode from dataclasses import replace from datetime import datetime, timezone from pathlib import Path +from types import SimpleNamespace import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey import openadapt_flow.runner.hosted_adapter as hosted -from openadapt_flow.ir import ParamKind, ParamSpec +from openadapt_flow.__main__ import _replay_params +from openadapt_flow.ir import ParamKind, ParamSpec, Workflow from openadapt_flow.runner.hosted_adapter import ( + RUNNER_RENEWAL_HEADER, AdmissionArtifactBytes, + CallbackRequest, DeliveryAuthority, HostedDispatch, HostedRunnerAdapter, + HostedRunResult, + HostedTerminalEvent, ManagedExecution, + PollRequest, RegisterCapabilities, + RegisterRequest, + registration_renewal_headers, ) +from openadapt_flow.runner.inputs import resolve_admitted_params from openadapt_flow.runner.product_release import ( DOMAIN, TARGETS, @@ -33,13 +43,30 @@ ) from openadapt_flow.runner.protocol import ( DispatchParamsRef, + DispatchParamsValues, RunnerDispatchPayload, dispatch_binding_sha256, ) from openadapt_flow.runner.verify import VerifiedDispatch +from openadapt_flow.runtime.authorization import ( + runtime_inputs_bytes, + runtime_param_text, +) from openadapt_flow.runtime.durable.authority import REMOTE_DISPATCH_SESSION_ID_ENV +from openadapt_flow.terminal_verification_v2 import ( + ProductionDeliveryPermit, + ProductionDeliveryPermitChain, + ProductionDeliveryPermitPayload, + ProductionDeliveryReceiptPayload, + evidence_runner_signer_sha256, + sign_production_delivery_permit, + sign_production_delivery_receipt, + sign_production_terminal_verification, +) from openadapt_flow.transaction import TransactionOutcome +from tests.test_run_receipt import _report as _production_report from tests.test_runner_client_lib import dispatch_payload +from tests.test_terminal_verification_v2 import _payload, _private_key pytest_plugins = ("tests.test_runner_client_lib",) @@ -220,6 +247,151 @@ def test_registration_refuses_without_protected_runner_origin( ) +def test_protected_runner_origin_is_public_strict_accessor( + monkeypatch, tmp_path, config +) -> None: + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + local_config = replace(config, host="https://cloud.example") + monkeypatch.setattr( + hosted, "load_runner_config", lambda *_args, **_kwargs: local_config + ) + + assert adapter.protected_runner_origin(tmp_path / "runner.toml") == ( + "https://cloud.example" + ) + + +def test_runner_renewal_token_has_one_register_only_header_boundary() -> None: + token = "oar_" + "a" * 64 + + assert registration_renewal_headers(None) == {} + assert registration_renewal_headers("") == {} + assert registration_renewal_headers(token) == { + RUNNER_RENEWAL_HEADER: token, + } + for invalid in ("runner-token", "oar_" + "A" * 64, "oar_" + "a" * 63): + with pytest.raises(ValueError, match="renewal credential"): + registration_renewal_headers(invalid) + + for request_type in (RegisterRequest, PollRequest, HostedDispatch, CallbackRequest): + assert "runner_token" not in request_type.model_fields + assert RUNNER_RENEWAL_HEADER not in request_type.model_fields + assert all("renewal" not in name for name in request_type.model_fields) + + +def test_dispatch_param_scalars_round_trip_without_coercion() -> None: + values = { + "text": "1.5", + "enabled": True, + "count": 7, + "ratio": 1.5, + "small": 1e-7, + "large": 1e21, + } + + parsed = DispatchParamsValues.model_validate({"values": values}) + + assert parsed.model_dump(mode="json")["values"] == values + assert type(parsed.values["enabled"]) is bool + assert type(parsed.values["count"]) is int + assert type(parsed.values["ratio"]) is float + + +@pytest.mark.parametrize( + "value", + [None, {}, [], float("nan"), float("inf"), 9_007_199_254_740_993], +) +def test_dispatch_param_scalars_refuse_invalid_values(value: object) -> None: + with pytest.raises(ValueError): + DispatchParamsValues.model_validate({"values": {"value": value}}) + + +@pytest.mark.parametrize( + "name", + ["\ue000", "\U0001f600", "has-dash", "a" * 129], +) +def test_dispatch_param_names_use_shared_ascii_grammar(name: str) -> None: + with pytest.raises(ValueError, match="parameter name"): + DispatchParamsValues.model_validate({"values": {name: "value"}}) + + +def test_private_params_file_preserves_scalar_types(tmp_path) -> None: + values = {"text": "false", "enabled": False, "count": 0, "ratio": 1.5} + + path = HostedRunnerAdapter._write_params(tmp_path / "params.json", values) + + assert path is not None + assert json.loads(path.read_bytes()) == values + if os.name != "nt": + assert path.stat().st_mode & 0o777 == 0o600 + + +def test_scalar_dispatch_to_gui_boundary_preserves_exact_types(tmp_path) -> None: + values = { + "string_int": "7", + "string_float": "1.5", + "string_bool": "true", + "bool_true": True, + "bool_false": False, + "whole": 7, + "float": 1.5, + "small": 1e-7, + "mid": 1e20, + "large": 1e21, + } + kinds = { + "string_int": ParamKind.STRING, + "string_float": ParamKind.STRING, + "string_bool": ParamKind.STRING, + "bool_true": ParamKind.BOOLEAN, + "bool_false": ParamKind.BOOLEAN, + "whole": ParamKind.NUMBER, + "float": ParamKind.NUMBER, + "small": ParamKind.NUMBER, + "mid": ParamKind.NUMBER, + "large": ParamKind.NUMBER, + } + workflow = Workflow( + name="scalar-path", + param_specs={ + name: ParamSpec(name=name, type=kind, required=True) + for name, kind in kinds.items() + }, + ) + + wire = DispatchParamsValues.model_validate({"values": values}) + admitted = resolve_admitted_params(workflow, dict(wire.values), inline=True) + expected = ( + b'{"params":{"bool_false":false,"bool_true":true,"float":1.5,' + b'"large":1e+21,"mid":100000000000000000000,"small":1e-7,' + b'"string_bool":"true","string_float":"1.5","string_int":"7",' + b'"whole":7},"worklists":{}}' + ) + params_path = HostedRunnerAdapter._write_params(tmp_path / "params.json", admitted) + assert params_path is not None + child_params = _replay_params(None, str(params_path)) + + assert runtime_inputs_bytes(workflow, admitted, None) == expected + assert child_params == values + assert {name: type(value) for name, value in child_params.items()} == { + name: type(value) for name, value in values.items() + } + assert { + name: runtime_param_text(value) for name, value in child_params.items() + } == { + "string_int": "7", + "string_float": "1.5", + "string_bool": "true", + "bool_true": "true", + "bool_false": "false", + "whole": "7", + "float": "1.5", + "small": "1e-7", + "mid": "100000000000000000000", + "large": "1e+21", + } + + def _prepared_adapter(monkeypatch, tmp_path, config, workflow, runner): config = replace(config, host="https://cloud.example") adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite", runner=runner) @@ -262,6 +434,321 @@ def authorization_binding(self, _workflow): return adapter, dispatch +def _terminal_delivery_chain( + dispatch: HostedDispatch, + *, + admission_sha256: str, + evidence_identity_sha256: str, + environment_digest: str, + registry_sha256: str, +) -> ProductionDeliveryPermitChain: + authority_key = Ed25519PrivateKey.from_private_bytes(bytes(range(32, 64))) + permit_payload = ProductionDeliveryPermitPayload( + execution_authority_id="00000000-0000-4000-8000-000000000008", + execution_authority_sha256="1" * 64, + permit_id="permit:hosted:1", + run_id=dispatch.run_id, + flow_run_id_sha256=hashlib.sha256(dispatch.run_id.encode("utf-8")).hexdigest(), + run_request_sha256="3" * 64, + action_request_sha256="4" * 64, + admission_artifact_sha256=admission_sha256, + evidence_identity_sha256=evidence_identity_sha256, + environment_digest=environment_digest, + qualification_signer_registry_sha256=registry_sha256, + qualification_signer_registry_revision=7, + qualification_signer_registry_checked_at="2026-08-26T11:59:30Z", + qualification_signer_registry_expires_at="2026-08-28T12:00:00Z", + input_edge_sequence=1, + authority_sequence=0, + issued_at="2026-08-26T12:00:00Z", + ) + permit = sign_production_delivery_permit(permit_payload, authority_key) + receipt_payload = ProductionDeliveryReceiptPayload( + execution_authority_id=permit_payload.execution_authority_id, + permit_id=permit_payload.permit_id, + permit_artifact_sha256=permit.artifact_sha256(), + authenticated_runner_id_sha256=hashlib.sha256( + dispatch.runner_id.encode("utf-8") + ).hexdigest(), + authenticated_session_id_sha256=hashlib.sha256( + dispatch.runner_session_id.encode("utf-8") + ).hexdigest(), + one_use_claim_id="00000000-0000-4000-8000-000000000010", + runtime_delivery_sequence=9, + delivered_at="2026-08-26T12:00:01Z", + ) + receipt = sign_production_delivery_receipt(receipt_payload, authority_key) + return ProductionDeliveryPermitChain.build( + (ProductionDeliveryPermit.build(permit, receipt),) + ) + + +def test_outer_adapter_builds_stores_rereads_and_verifies_terminal_v2( + monkeypatch, tmp_path, sealed +) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + verified_params = dict(dispatch.payload.params.values) + report = _production_report( + run_id_sha256=hashlib.sha256(dispatch.run_id.encode("utf-8")).hexdigest(), + bundle_content_digest=dispatch.payload.bundle.content_digest, + params=verified_params, + ) + authorization = dispatch.payload.authorization.model_copy( + update={ + "admitted_policy_name": report.governed_policy_name + or dispatch.payload.authorization.admitted_policy_name, + "admitted_policy_contract_sha256": (report.governed_policy_contract_sha256), + "execution_profile": report.execution_profile, + "minimum_effect_tier": report.governed_minimum_effect_tier, + "qualified_effect_requirements": tuple( + report.governed_qualified_effect_requirements + ), + "required_identity_step_ids": tuple(report.required_identity_step_ids), + "approval_source": report.governed_approval_source, + } + ) + payload = dispatch.payload.model_copy( + update={ + "authorization": authorization, + "dispatch_binding_sha256": dispatch_binding_sha256( + dispatch.run_id, authorization + ), + } + ) + dispatch = dispatch.model_copy(update={"payload": payload}) + report = report.model_copy( + update={ + "governed_authorization_id": authorization.authorization_id, + "governed_authorization_created_at": authorization.created_at, + "governed_approval_source": authorization.approval_source, + "governed_policy_name": authorization.admitted_policy_name, + "governed_runtime_inputs_digest": authorization.runtime_inputs_digest, + } + ) + private_key = _private_key() + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + admission_sha256 = "5" * 64 + evidence_identity_sha256 = "6" * 64 + environment_digest = "7" * 64 + registry_sha256 = "8" * 64 + qualification_sha256 = "f" * 64 + evidence_identity = SimpleNamespace( + admission_policy_sha256="9" * 64, + artifact_sha256=lambda: evidence_identity_sha256, + ) + runtime = SimpleNamespace( + substrate="web", + artifact_sha256=lambda: "0" * 64, + ) + expected = SimpleNamespace( + bundle_artifact_sha256="b" * 64, + bundle_content_digest=dispatch.payload.bundle.content_digest, + environment_digest=environment_digest, + environment_contract_sha256="a" * 64, + runtime_environment_sha256="b" * 64, + identity_contract_sha256="c" * 64, + effect_contract_sha256="d" * 64, + runtime_validation_id="00000000-0000-4000-8000-000000000006", + runtime_build_identity=runtime, + evidence_runner_signer_sha256=evidence_runner_signer_sha256(public_key), + ) + admission = SimpleNamespace( + payload=SimpleNamespace( + admission_id="00000000-0000-4000-8000-000000000001", + evidence_identity=evidence_identity, + ) + ) + qualification = SimpleNamespace( + qualification_admission_sha256=admission_sha256, + qualification_admission=admission, + expected=expected, + qualification_signer_registry_sha256=registry_sha256, + qualification_signer_registry=SimpleNamespace( + revision=7, + expires_at="2026-08-28T12:00:00Z", + ), + immutable_binding_sha256=lambda: qualification_sha256, + ) + chain = _terminal_delivery_chain( + dispatch, + admission_sha256=admission_sha256, + evidence_identity_sha256=evidence_identity_sha256, + environment_digest=environment_digest, + registry_sha256=registry_sha256, + ) + binding = dispatch.payload.dispatch_binding_sha256 + local_authorization = authorization.model_copy( + update={ + "production_qualification_admission_id": (admission.payload.admission_id), + "production_qualification_admission_sha256": admission_sha256, + "production_qualification_evidence_identity_sha256": ( + evidence_identity_sha256 + ), + "production_qualification_runtime_validation_id": ( + expected.runtime_validation_id + ), + "production_qualification_signer_registry_sha256": registry_sha256, + "production_qualification_signer_registry_revision": 7, + "production_qualification_signer_registry_expires_at": ( + "2026-08-28T12:00:00Z" + ), + "production_qualification_authority_sha256": qualification_sha256, + } + ) + manifest = SimpleNamespace( + delivery_authority_kind="cloud_runner", + remote_delivery_run_id=dispatch.run_id, + managed_dispatch_binding_sha256=binding, + params=verified_params, + governed_authorization=local_authorization, + ) + + class Store: + def __init__(self, _run_dir): + pass + + def read_manifest(self): + return manifest + + class Authority: + def __init__(self, _run_dir, _store): + pass + + def production_delivery_permit_chain(self): + return chain + + fixed_now = datetime(2026, 8, 26, 12, 0, 2, tzinfo=timezone.utc) + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed_now if tz is not None else fixed_now.replace(tzinfo=None) + + monkeypatch.setattr(hosted, "CheckpointStore", Store) + monkeypatch.setattr(hosted, "DurableAuthority", Authority) + monkeypatch.setattr(hosted, "datetime", FixedDatetime) + run_dir = tmp_path / "run" + run_dir.mkdir(mode=0o700) + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + + mismatched_dir = tmp_path / "mismatched-run" + mismatched_dir.mkdir(mode=0o700) + with pytest.raises(ValueError, match="run report differs"): + adapter._produce_terminal_verification( + dispatch=dispatch, + report=report.model_copy(update={"params": {"visit_date": "wrong"}}), + run_dir=mismatched_dir, + qualification=qualification, + private_key=private_key, + verified_params=verified_params, + dispatch_binding_sha256=binding, + ) + assert not (mismatched_dir / "production-terminal-report.json").exists() + assert not (mismatched_dir / "production-terminal-verification.json").exists() + + storage_failure_dir = tmp_path / "storage-failure-run" + storage_failure_dir.mkdir(mode=0o700) + original_read = adapter._read_private_bytes + + def fail_proof_reread(path, *, maximum_bytes, label): + if label == "production terminal verification": + raise OSError("simulated protected storage failure") + return original_read(path, maximum_bytes=maximum_bytes, label=label) + + monkeypatch.setattr(adapter, "_read_private_bytes", fail_proof_reread) + with pytest.raises(OSError, match="storage failure"): + adapter._produce_terminal_verification( + dispatch=dispatch, + report=report, + run_dir=storage_failure_dir, + qualification=qualification, + private_key=private_key, + verified_params=verified_params, + dispatch_binding_sha256=binding, + ) + assert not (storage_failure_dir / "production-terminal-report.json").exists() + assert not (storage_failure_dir / "production-terminal-verification.json").exists() + monkeypatch.setattr(adapter, "_read_private_bytes", original_read) + + proof, report_sha256 = adapter._produce_terminal_verification( + dispatch=dispatch, + report=report, + run_dir=run_dir, + qualification=qualification, + private_key=private_key, + verified_params=verified_params, + dispatch_binding_sha256=binding, + ) + + report_path = run_dir / "production-terminal-report.json" + proof_path = run_dir / "production-terminal-verification.json" + assert hashlib.sha256(report_path.read_bytes()).hexdigest() == report_sha256 + assert proof_path.read_bytes() == hosted.canonical_json(proof) + assert hashlib.sha256(proof_path.read_bytes()).hexdigest() == ( + proof.artifact_sha256() + ) + if os.name != "nt": + assert report_path.stat().st_mode & 0o777 == 0o600 + assert proof_path.stat().st_mode & 0o777 == 0o600 + assert "2026-07-01" in report_path.read_text(encoding="utf-8") + assert "2026-07-01" not in proof_path.read_text(encoding="utf-8") + + +def test_terminal_admission_is_revalidated_after_child_execution( + monkeypatch, tmp_path, config, sealed +) -> None: + workflow, _ = sealed + report = _production_report() + calls = 0 + + def runner(_argv, _run_dir, _child_env): + nonlocal calls + calls += 1 + return ManagedExecution( + returncode=0, + report_bytes=report.model_dump_json().encode(), + ) + + adapter, dispatch = _prepared_adapter( + monkeypatch, tmp_path, config, workflow, runner + ) + release_checks = 0 + + def verify_release(*_args): + nonlocal release_checks + release_checks += 1 + if release_checks == 2: + raise ValueError("release admission was revoked during execution") + + monkeypatch.setattr(adapter, "_verify_product_release", verify_release) + monkeypatch.setattr( + adapter, + "_produce_terminal_verification", + lambda **_kwargs: pytest.fail("revoked run must not produce terminal proof"), + ) + authority = DeliveryAuthority( + dispatch.managed_delivery_authority_url, + dispatch.delivery_authority_token, + ) + + first = adapter.execute( + dispatch, + runner_config=tmp_path / "runner.toml", + run_dir=tmp_path / "run", + authority=authority, + ) + + assert first.outcome is TransactionOutcome.RECONCILIATION_REQUIRED + assert first.started is True + assert first.terminal_verification is None + assert calls == 1 + assert release_checks == 2 + + @pytest.mark.parametrize( ("fault", "execution"), [ @@ -684,3 +1171,61 @@ def test_parsed_refusal_callback_contains_closed_terminal(tmp_path, sealed) -> N assert terminal["outcome"] == "REJECTED_POLICY" assert terminal["started"] is False assert terminal["uncertain_delivery"] is False + + +def test_recovery_callback_retains_exact_terminal_v2_envelope(tmp_path, sealed) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + proof = sign_production_terminal_verification(_payload(), _private_key()) + binding = adapter.recovery_binding(dispatch).model_copy( + update={"run_id": proof.payload.run_id} + ) + result = HostedRunResult( + dispatch_id=dispatch.dispatch_id, + run_id=binding.run_id, + outcome=TransactionOutcome.VERIFIED, + evidence_batch=(), + terminal_verification=proof, + started=True, + uncertain_delivery=False, + report_sha256=proof.payload.run_report_sha256, + ) + + callback = adapter.callback_request(binding, result) + terminal = HostedTerminalEvent.model_validate(callback.events[-1]) + assert terminal.terminal_verification_artifact_bytes_base64 is not None + raw = b64decode(terminal.terminal_verification_artifact_bytes_base64, validate=True) + assert hashlib.sha256(raw).hexdigest() == ( + terminal.terminal_verification_artifact_sha256 + ) + decoded = json.loads(raw) + assert decoded["payload"]["schema_version"] == ( + "openadapt.production-terminal-verification/v2" + ) + assert "params" not in decoded["payload"] + assert "report" not in decoded["payload"] + assert callback.runner_session_id == dispatch.runner_session_id + assert callback.workflow_admission_sha256 == ( + dispatch.workflow_admission.artifact_sha256 + ) + + +def test_callback_refuses_terminal_proof_for_a_different_run(tmp_path, sealed) -> None: + workflow, _ = sealed + dispatch = _hosted_dispatch(workflow) + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + proof = sign_production_terminal_verification(_payload(), _private_key()) + result = HostedRunResult.model_construct( + dispatch_id=dispatch.dispatch_id, + run_id=dispatch.run_id, + outcome=TransactionOutcome.VERIFIED, + evidence_batch=(), + terminal_verification=proof, + started=True, + uncertain_delivery=False, + report_sha256=proof.payload.run_report_sha256, + ) + + with pytest.raises(ValueError, match="different run report"): + adapter.callback_request(adapter.recovery_binding(dispatch), result) diff --git a/tests/test_program_ir_phase1.py b/tests/test_program_ir_phase1.py index 63d43377..7f19ac29 100644 --- a/tests/test_program_ir_phase1.py +++ b/tests/test_program_ir_phase1.py @@ -182,6 +182,29 @@ def test_missing_required_param_fails_fast_naming_it(bundle, run_dir): assert backend.actions == [] # nothing ran +@pytest.mark.parametrize( + ("kind", "value"), + [(ParamKind.BOOLEAN, False), (ParamKind.NUMBER, 0)], +) +def test_required_false_and_zero_are_present(kind, value, bundle, run_dir): + wf = Workflow( + name="wf", + param_specs={"required": ParamSpec(name="required", type=kind, required=True)}, + steps=[key_step()], + ) + backend = FakeBackend() + report = Replayer(backend, vision=FakeVision()).run( + wf, + params={"required": value}, + bundle_dir=bundle, + run_dir=run_dir, + ) + assert report.success is True + assert report.params["required"] == value + assert type(report.params["required"]) is type(value) + assert backend.actions == [("press", "Enter")] + + # -- wait_until: bounded readiness, fail-safe HALT on timeout ----------------- diff --git a/tests/test_runner_client_lib.py b/tests/test_runner_client_lib.py index 89e1786e..482c3d48 100644 --- a/tests/test_runner_client_lib.py +++ b/tests/test_runner_client_lib.py @@ -492,6 +492,44 @@ def test_full_admit_returns_execution_snapshot(self, sealed, config): assert verdict.effect_covered_consequential_steps == 0 assert verdict.workflow.manifest is not None + def test_param_domains_use_canonical_scalar_text(self, tmp_path, sealed, profile): + workflow, bundle = sealed + params = { + "enabled": False, + "count": 0, + "small": 1e-7, + "fixed": 1e20, + "large": 1e21, + } + manifest = write_manifest( + tmp_path, + f""" +[runner] +name = "n" +[profiles] +default = "{profile}" +[[bundles]] +content_digest = "{workflow.manifest.content_digest}" +path = "{bundle}" +[bundles.param_patterns] +enabled = '^false$' +count = '^0$' +small = '^1e-7$' +fixed = '^100000000000000000000$' +large = '^1e[+]21$' +""", + ) + cfg = load_runner_config(manifest) + authorization = mint_authorization(workflow, params) + verdict = verified_or_refusal( + workflow, + cfg, + params={"values": params}, + authorization=authorization, + ) + assert not isinstance(verdict, Refusal) + assert verdict.params == params + class TestVerifyRefusals: def test_unknown_job_kind(self, sealed, config): diff --git a/uv.lock b/uv.lock index 0bacf324..efcc28a9 100644 --- a/uv.lock +++ b/uv.lock @@ -2136,7 +2136,7 @@ wheels = [ [[package]] name = "openadapt-flow" -version = "1.33.0" +version = "1.34.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 9fb807bd6188691dde0da1f967a58a08ec1d16a1 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 26 Aug 2026 17:56:59 -0400 Subject: [PATCH 4/5] test: preserve boolean parameter digest --- tests/test_run_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_run_gate.py b/tests/test_run_gate.py index 09f53c76..83be1a2e 100644 --- a/tests/test_run_gate.py +++ b/tests/test_run_gate.py @@ -1389,7 +1389,7 @@ def capture(args): expected = { "patient_id": secret_value, "count": "3", - "approved": "True", + "approved": True, } authorization = captured["authorization"] assert authorization.runtime_inputs_digest == runtime_inputs_digest( From 13154a0b413522d738c9071700af4bd4da6747ef Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 27 Aug 2026 18:07:07 -0400 Subject: [PATCH 5/5] test: exercise the product release admission gate instead of stubbing it The hosted runtime tests replace HostedRunnerAdapter._verify_product_release with a double in all four places it appears, so the release-admission gate that guards managed execution was never driven by a test. The pure verifier had three tests and none of them was a positive control, so a verifier that refused unconditionally would also have passed. Drive both halves for real: - verify_product_release_admission: a valid artifact verifies, plus refusal of a tampered payload, a foreign signature, a mismatched payload digest, an untrusted key id, a substituted public key, a revoked signer, a revoked set id, any sequence other than the newest, a closed validity window, and per-target revocation, inactive authority, closed admission window, and stale authority check. - HostedRunnerAdapter._verify_product_release: the real method against real 0600 trust files, covering the canonical artifact-byte binding, the exact local flow/desktop/capture inventory comparison, an incomplete inventory, a malformed authority state file, a sequence rollback, and a changed artifact at one sequence. Fixtures build their validity window around the current time so the suite does not expire on a fixed date. Co-Authored-By: Claude Opus 5 --- ...test_hosted_runner_product_release_gate.py | 482 ++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 tests/test_hosted_runner_product_release_gate.py diff --git a/tests/test_hosted_runner_product_release_gate.py b/tests/test_hosted_runner_product_release_gate.py new file mode 100644 index 00000000..b1fb02fb --- /dev/null +++ b/tests/test_hosted_runner_product_release_gate.py @@ -0,0 +1,482 @@ +"""Exercise the product release admission gate itself. + +The hosted runtime tests replace ``HostedRunnerAdapter._verify_product_release`` +with a double, so the release-admission gate that guards managed execution is +never driven by them. These tests drive both halves of the gate for real: the +pure ``verify_product_release_admission`` verifier and the adapter method that +binds it to the leased artifact bytes, the local runtime inventory, and the +monotonic sequence ledger. + +Every fixture builds its validity window around the current time so the suite +does not expire on a fixed date. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from base64 import b64encode, urlsafe_b64encode +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from openadapt_flow.runner.config import ( + AdmissionTrustFiles, + LocalRuntimeRelease, + RunnerConfig, +) +from openadapt_flow.runner.hosted_adapter import ( + AdmissionArtifactBytes, + HostedRunnerAdapter, +) +from openadapt_flow.runner.product_release import ( + DOMAIN, + TARGETS, + ProductReleaseAdmissionArtifact, + ProductReleaseAdmissionError, + ProductReleaseAdmissionPayload, + ProductReleaseSignerTrust, + verify_product_release_admission, +) + +SEQUENCE = 7 +SET_ID = "00000000-0000-4000-8000-000000000099" + + +def _stamp(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _now() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _release_payload(**overrides: object) -> dict[str, object]: + opened = _stamp(_now() - timedelta(days=1)) + closes = _stamp(_now() + timedelta(days=30)) + targets = [] + for index, target in enumerate(TARGETS, start=1): + targets.append( + { + "target": target, + "admission_id": f"00000000-0000-4000-8000-{index:012d}", + "admission_sha256": f"{index:x}" * 64, + "release_id": "1.34.0", + "release_artifact_sha256": f"{index + 7:x}" * 64, + "admission_issued_at": opened, + "admission_expires_at": closes, + "revoked_at": None, + "artifact_authority_sha256": f"{index + 8:x}" * 64, + "artifact_authority_state": "active", + "artifact_authority_checked_at": opened, + "artifact_authority_expires_at": closes, + } + ) + payload: dict[str, object] = { + "schema_version": "openadapt.product-release-admission-payload/v1", + "set_id": SET_ID, + "sequence": SEQUENCE, + "policy_sha256": "a" * 64, + "issued_at": opened, + "expires_at": closes, + "targets": tuple(targets), + } + payload.update(overrides) + return payload + + +def _sign(payload_raw: dict[str, object], *, key: Ed25519PrivateKey | None = None): + """Return a signed artifact plus the matching active signer trust.""" + + private_key = key or Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33))) + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + payload = ProductReleaseAdmissionPayload.model_validate(payload_raw) + signature = private_key.sign(DOMAIN + payload.canonical_bytes()) + public_b64 = b64encode(public_key).decode("ascii") + artifact = ProductReleaseAdmissionArtifact.model_validate( + { + "schema_version": "openadapt.product-release-admission-artifact/v1", + "payload": payload, + "payload_sha256": payload.payload_sha256_value(), + "signer": { + "algorithm": "ed25519", + "key_id": ( + "release-admission-ed25519-" + + hashlib.sha256(public_key).hexdigest()[:16] + ), + "public_key": public_b64, + }, + "signature": urlsafe_b64encode(signature).decode("ascii").rstrip("="), + } + ) + trust = ProductReleaseSignerTrust( + public_key=public_b64, status="active", revoked_at=None + ) + return artifact, trust + + +def _verify(artifact, trust, **kwargs): + params: dict[str, object] = { + "trusted_signers": {artifact.signer.key_id: trust}, + "newest_sequence": SEQUENCE, + "now": _now(), + } + params.update(kwargs) + return verify_product_release_admission(artifact, **params) + + +# -------------------------------------------------------------------------- +# Positive control. Without this, every refusal test below would still pass +# against a verifier that rejected unconditionally. +# -------------------------------------------------------------------------- + + +def test_valid_product_release_admission_verifies() -> None: + artifact, trust = _sign(_release_payload()) + payload = _verify(artifact, trust) + assert payload.set_id == SET_ID + assert payload.sequence == SEQUENCE + assert tuple(item.target for item in payload.targets) == TARGETS + + +# -------------------------------------------------------------------------- +# Signature and signer authority. +# -------------------------------------------------------------------------- + + +def _artifact_json(artifact, **overrides) -> str: + """Serialize an artifact to JSON, overriding top-level fields.""" + + raw = json.loads(_canonical_artifact_bytes(artifact)) + raw.update(overrides) + return json.dumps(raw) + + +def test_refuses_artifact_whose_payload_was_altered_after_signing() -> None: + """Re-digesting a tampered payload must not launder a stale signature.""" + + artifact, _ = _sign(_release_payload()) + tampered = artifact.payload.model_copy(update={"policy_sha256": "b" * 64}) + raw = json.loads(_canonical_artifact_bytes(artifact)) + raw["payload"] = json.loads( + tampered.model_dump_json() + ) # keep the artifact self-consistent + raw["payload_sha256"] = tampered.payload_sha256_value() + with pytest.raises(ValueError, match="signature is invalid"): + ProductReleaseAdmissionArtifact.model_validate_json(json.dumps(raw)) + + +def test_refuses_a_payload_digest_that_does_not_cover_the_payload() -> None: + artifact, _ = _sign(_release_payload()) + with pytest.raises(ValueError, match="payload digest is invalid"): + ProductReleaseAdmissionArtifact.model_validate_json( + _artifact_json(artifact, payload_sha256="b" * 64) + ) + + +def test_refuses_signature_from_another_key() -> None: + other = Ed25519PrivateKey.from_private_bytes(bytes(range(33, 65))) + artifact, _ = _sign(_release_payload()) + foreign, _ = _sign(_release_payload(), key=other) + assert foreign.signature != artifact.signature + with pytest.raises(ValueError, match="signature is invalid"): + ProductReleaseAdmissionArtifact.model_validate_json( + _artifact_json(artifact, signature=foreign.signature) + ) + + +def test_refuses_untrusted_signer_key_id() -> None: + artifact, trust = _sign(_release_payload()) + with pytest.raises(ProductReleaseAdmissionError, match="not trusted"): + _verify(artifact, trust, trusted_signers={}) + + +def test_refuses_registry_entry_with_a_different_public_key() -> None: + artifact, trust = _sign(_release_payload()) + other = Ed25519PrivateKey.from_private_bytes(bytes(range(33, 65))) + other_b64 = b64encode( + other.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + ).decode("ascii") + swapped = trust.model_copy(update={"public_key": other_b64}) + with pytest.raises(ProductReleaseAdmissionError, match="not trusted"): + _verify(artifact, swapped) + + +def test_refuses_revoked_signer() -> None: + artifact, trust = _sign(_release_payload()) + revoked = trust.model_copy( + update={"status": "revoked", "revoked_at": _stamp(_now())} + ) + with pytest.raises(ProductReleaseAdmissionError, match="signer is revoked"): + _verify(artifact, revoked) + + +# -------------------------------------------------------------------------- +# Set revocation, sequence, and validity window. +# -------------------------------------------------------------------------- + + +def test_refuses_revoked_set_id() -> None: + artifact, trust = _sign(_release_payload()) + with pytest.raises(ProductReleaseAdmissionError, match="admission is revoked"): + _verify(artifact, trust, revoked_set_ids=frozenset({SET_ID})) + + +@pytest.mark.parametrize("newest", [SEQUENCE - 1, SEQUENCE + 1]) +def test_refuses_any_sequence_other_than_the_newest(newest: int) -> None: + artifact, trust = _sign(_release_payload()) + with pytest.raises(ProductReleaseAdmissionError, match="superseded"): + _verify(artifact, trust, newest_sequence=newest) + + +def test_refuses_admission_before_it_is_issued() -> None: + artifact, trust = _sign(_release_payload()) + with pytest.raises(ProductReleaseAdmissionError, match="is not active"): + _verify(artifact, trust, now=_now() - timedelta(days=2)) + + +def test_refuses_expired_admission() -> None: + artifact, trust = _sign(_release_payload()) + with pytest.raises(ProductReleaseAdmissionError, match="is not active"): + _verify(artifact, trust, now=_now() + timedelta(days=31)) + + +# -------------------------------------------------------------------------- +# Per-target state. Each of the seven targets must independently be live. +# -------------------------------------------------------------------------- + + +def _with_target(index: int, **changes: object) -> dict[str, object]: + raw = _release_payload() + targets = [dict(item) for item in raw["targets"]] # type: ignore[arg-type] + targets[index].update(changes) + raw["targets"] = tuple(targets) + return raw + + +@pytest.mark.parametrize("index", range(len(TARGETS))) +def test_refuses_a_revoked_target(index: int) -> None: + artifact, trust = _sign(_with_target(index, revoked_at=_stamp(_now()))) + with pytest.raises( + ProductReleaseAdmissionError, match=f"target {TARGETS[index]} is revoked" + ): + _verify(artifact, trust) + + +@pytest.mark.parametrize("state", ["revoked", "expired", "unavailable"]) +def test_refuses_a_target_whose_artifact_authority_is_not_active(state: str) -> None: + artifact, trust = _sign(_with_target(5, artifact_authority_state=state)) + with pytest.raises(ProductReleaseAdmissionError, match="authority is not active"): + _verify(artifact, trust) + + +def test_refuses_a_target_whose_admission_window_closed() -> None: + closed = _stamp(_now() - timedelta(hours=1)) + artifact, trust = _sign(_with_target(5, admission_expires_at=closed)) + with pytest.raises(ProductReleaseAdmissionError, match="admission is not active"): + _verify(artifact, trust) + + +def test_refuses_a_target_whose_authority_check_is_stale() -> None: + stale = _stamp(_now() - timedelta(hours=1)) + artifact, trust = _sign(_with_target(5, artifact_authority_expires_at=stale)) + with pytest.raises(ProductReleaseAdmissionError, match="authority is stale"): + _verify(artifact, trust) + + +def test_refuses_an_incomplete_or_reordered_target_set() -> None: + raw = _release_payload() + targets = list(raw["targets"]) # type: ignore[arg-type] + with pytest.raises(ValueError): + ProductReleaseAdmissionPayload.model_validate({**raw, "targets": targets[:6]}) + reordered = [targets[1], targets[0], *targets[2:]] + with pytest.raises(ValueError, match="not exact and ordered"): + ProductReleaseAdmissionPayload.model_validate( + {**raw, "targets": tuple(reordered)} + ) + + +# -------------------------------------------------------------------------- +# The adapter method: artifact-byte binding, local inventory, sequence ledger. +# -------------------------------------------------------------------------- + + +def _private_file(path: Path, raw: bytes) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(raw) + os.chmod(path, 0o600) + return path + + +def _canonical_artifact_bytes(artifact: ProductReleaseAdmissionArtifact) -> bytes: + return json.dumps( + artifact.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +def _gate_fixture(tmp_path: Path, *, payload_raw=None, sequence: int = SEQUENCE): + """Build a real adapter, config, and dispatch carrier for the gate.""" + + artifact, trust = _sign(payload_raw or _release_payload()) + raw = _canonical_artifact_bytes(artifact) + bytes_model = AdmissionArtifactBytes( + artifact_bytes_base64=b64encode(raw).decode("ascii"), + artifact_sha256=hashlib.sha256(raw).hexdigest(), + ) + registry = _private_file( + tmp_path / "trust" / "signers.json", + json.dumps({artifact.signer.key_id: trust.model_dump(mode="json")}).encode(), + ) + state = _private_file( + tmp_path / "trust" / "state.json", + json.dumps({"newest_sequence": sequence, "revoked_set_ids": []}).encode(), + ) + admitted = {item.target: item for item in artifact.payload.targets} + config = RunnerConfig( + name="gate", + product_release_admission=AdmissionTrustFiles( + signer_registry=registry, state=state + ), + local_runtime_release=tuple( + LocalRuntimeRelease( + target=target, + admission_id=admitted[target].admission_id, + admission_sha256=admitted[target].admission_sha256, + release_version=admitted[target].release_id, + release_artifact_sha256=admitted[target].release_artifact_sha256, + ) + for target in ("flow", "desktop", "capture") + ), + ) + adapter = HostedRunnerAdapter(tmp_path / "ledger.sqlite") + dispatch = SimpleNamespace(product_release_admission=bytes_model) + return adapter, dispatch, config, artifact + + +def test_adapter_gate_accepts_an_exactly_admitted_local_runtime(tmp_path) -> None: + adapter, dispatch, config, artifact = _gate_fixture(tmp_path) + payload = adapter._verify_product_release(dispatch, config) + assert payload.sequence == SEQUENCE + ledger = json.loads(adapter._release_state_path.read_bytes()) + assert ledger == { + "sequence": SEQUENCE, + "artifact_sha256": dispatch.product_release_admission.artifact_sha256, + } + + +def test_adapter_gate_refuses_when_no_trust_is_configured(tmp_path) -> None: + adapter, dispatch, config, _ = _gate_fixture(tmp_path) + from dataclasses import replace + + with pytest.raises(ValueError, match="no product release admission trust"): + adapter._verify_product_release( + dispatch, replace(config, product_release_admission=None) + ) + + +@pytest.mark.parametrize( + "field", + [ + "admission_id", + "admission_sha256", + "release_version", + "release_artifact_sha256", + ], +) +def test_adapter_gate_refuses_a_local_release_that_is_not_admitted( + tmp_path, field: str +) -> None: + from dataclasses import replace + + adapter, dispatch, config, _ = _gate_fixture(tmp_path) + installed = config.local_runtime_release[0] + # The fixture uses digests "1".."7" and "8".."e", so "f" cannot collide + # with the value legitimately admitted for any of the seven targets. + substitute = { + "admission_id": "00000000-0000-4000-8000-000000009999", + "admission_sha256": "f" * 64, + "release_version": "1.33.0", + "release_artifact_sha256": "f" * 64, + }[field] + assert getattr(installed, field) != substitute + mutated = replace(installed, **{field: substitute}) + config = replace( + config, + local_runtime_release=(mutated, *config.local_runtime_release[1:]), + ) + with pytest.raises(ValueError, match="is not exactly admitted"): + adapter._verify_product_release(dispatch, config) + + +def test_adapter_gate_refuses_an_incomplete_local_inventory(tmp_path) -> None: + from dataclasses import replace + + adapter, dispatch, config, _ = _gate_fixture(tmp_path) + config = replace(config, local_runtime_release=config.local_runtime_release[:2]) + with pytest.raises(ValueError, match="local release inventory is incomplete"): + adapter._verify_product_release(dispatch, config) + + +def test_adapter_gate_refuses_noncanonical_artifact_bytes(tmp_path) -> None: + """The leased bytes must be the canonical serialization, not merely valid.""" + + adapter, dispatch, config, artifact = _gate_fixture(tmp_path) + padded = json.dumps( + artifact.model_dump(mode="json"), sort_keys=True, indent=1 + ).encode("utf-8") + dispatch.product_release_admission = AdmissionArtifactBytes( + artifact_bytes_base64=b64encode(padded).decode("ascii"), + artifact_sha256=hashlib.sha256(padded).hexdigest(), + ) + with pytest.raises(ValueError, match="canonical digest changed"): + adapter._verify_product_release(dispatch, config) + + +def test_adapter_gate_refuses_a_sequence_rollback(tmp_path) -> None: + adapter, dispatch, config, _ = _gate_fixture(tmp_path) + adapter._verify_product_release(dispatch, config) + + older = _release_payload(sequence=SEQUENCE - 1) + adapter2, dispatch2, config2, _ = _gate_fixture( + tmp_path / "second", payload_raw=older, sequence=SEQUENCE - 1 + ) + # Point the older dispatch at the ledger the newer sequence already wrote. + adapter2._release_state_path = adapter._release_state_path + with pytest.raises(ValueError, match="sequence is stale"): + adapter2._verify_product_release(dispatch2, config2) + + +def test_adapter_gate_refuses_a_changed_artifact_at_one_sequence(tmp_path) -> None: + adapter, dispatch, config, _ = _gate_fixture(tmp_path) + adapter._verify_product_release(dispatch, config) + + altered = _release_payload(policy_sha256="e" * 64) + adapter2, dispatch2, config2, _ = _gate_fixture( + tmp_path / "third", payload_raw=altered + ) + adapter2._release_state_path = adapter._release_state_path + with pytest.raises(ValueError, match="changed at one sequence"): + adapter2._verify_product_release(dispatch2, config2) + + +def test_adapter_gate_refuses_a_malformed_authority_state_file(tmp_path) -> None: + adapter, dispatch, config, _ = _gate_fixture(tmp_path) + _private_file( + config.product_release_admission.state, + json.dumps({"newest_sequence": SEQUENCE}).encode(), + ) + with pytest.raises(ValueError, match="authority state is invalid"): + adapter._verify_product_release(dispatch, config)