From cc018e7147fe9fd91772c122b282ca183319cfec Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 2 Sep 2026 23:34:11 -0400 Subject: [PATCH 1/4] fix: bind Linux browser remedy to runtime Python Signed-off-by: Richard Abrich --- README.md | 7 ++++- docs/TUTORIAL.md | 13 ++++---- openadapt_flow/_browser_setup.py | 16 ++++++++-- tests/test_browser_setup.py | 51 +++++++++++++++++++++++++++++++- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9fd7a48f..215433b9 100644 --- a/README.md +++ b/README.md @@ -479,10 +479,15 @@ episode instead. This package keeps the worker and the HTTP client. See ```bash git clone https://github.com/OpenAdaptAI/openadapt-flow && cd openadapt-flow pip install -e '.[dev]' -playwright install chromium # optional; otherwise downloaded on first launch +python -m playwright install chromium # optional browser pre-provisioning pytest -q ``` +On Linux, Flow checks the Chromium host libraries before an automatic browser +download. A minimal host may need a one-time system-library install. Flow stops +before the download and prints a command for the exact Python environment that +runs it. + Contributions welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). If you want a first one that is genuinely useful: pick a module off the mypy type-debt burn-down list (`[[tool.mypy.overrides]]` in `pyproject.toml`), tighten its diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 1b02d841..0bc880a0 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -92,11 +92,14 @@ one-command rollback). See [`REPAIR_LIFECYCLE.md`](REPAIR_LIFECYCLE.md). The base `openadapt-flow` package stays lightweight for native desktop, RDP, and Citrix runners. The `browser` extra adds Playwright only for web workflows; -the first browser command then downloads its matching Chromium build once -(about 150 MB), with no separate `playwright install chromium` step. Prefer the -canonical `pip install 'openadapt[browser]'` launcher path for normal use. In -air-gapped or CI environments that pre-provision the browser, set -`OPENADAPT_FLOW_NO_AUTO_INSTALL=1` to disable the auto-download. +the first browser command checks its Linux host libraries and then downloads +the matching Chromium build once (about 150 MB). A minimal Linux host may need +Playwright's one-time system-library install. Flow stops before the browser +download and prints a command bound to the exact Python environment that runs +it. Playwright requests administrator access if the system package manager +needs it. Prefer the canonical `pip install 'openadapt[browser]'` launcher path +for normal use. In air-gapped or CI environments that pre-provision the +browser, set `OPENADAPT_FLOW_NO_AUTO_INSTALL=1` to disable the auto-download. The weekly clean-machine test runs this complete install-to-uninstall journey on Linux, macOS, and Windows. See the diff --git a/openadapt_flow/_browser_setup.py b/openadapt_flow/_browser_setup.py index e8c97312..76ccc9ff 100644 --- a/openadapt_flow/_browser_setup.py +++ b/openadapt_flow/_browser_setup.py @@ -32,6 +32,7 @@ import importlib.util import os import re +import shlex import subprocess import sys import threading @@ -112,6 +113,11 @@ def _opted_out() -> bool: ) +def _playwright_module_command(*args: str) -> str: + """Return a copyable Playwright command for this exact Python environment.""" + return shlex.join([sys.executable, "-m", "playwright", *args]) + + def _missing_chromium_system_libs() -> list[str]: """Return the Chromium shared libraries missing on this Linux machine. @@ -139,11 +145,14 @@ def _require_linux_system_libs() -> None: if not missing: return libs = ", ".join(missing) + install_deps = _playwright_module_command("install-deps", "chromium") raise RuntimeError( "Chromium cannot launch on this machine yet: required system " f"libraries are missing ({libs}).\n\n" - "Install them once with:\n\n" - " sudo python -m playwright install-deps chromium\n\n" + "Install them once with the same Python environment that runs " + "OpenAdapt. Playwright requests administrator access if the system " + "package manager needs it:\n\n" + f" {install_deps}\n\n" "or, on Debian/Ubuntu:\n\n" f" sudo apt-get install -y {_LINUX_APT_PACKAGES}\n\n" "Then run your command again. Nothing was downloaded." @@ -205,10 +214,11 @@ def _install_chromium() -> None: check=True, ) except (subprocess.CalledProcessError, OSError) as exc: + install_browser = _playwright_module_command("install", "chromium") raise RuntimeError( "openadapt-flow could not automatically download the Chromium " "browser it needs. To install it manually, run:\n\n" - " playwright install chromium\n\n" + f" {install_browser}\n\n" "If you are behind a corporate proxy or firewall that blocks the " "Playwright download CDN, set HTTPS_PROXY first " "(for example: export HTTPS_PROXY=http://proxy.example.com:8080) " diff --git a/tests/test_browser_setup.py b/tests/test_browser_setup.py index 7e493a16..ad7239aa 100644 --- a/tests/test_browser_setup.py +++ b/tests/test_browser_setup.py @@ -285,12 +285,61 @@ def test_missing_system_libs_abort_before_any_download(monkeypatch): msg = str(exc.value) assert "nss3" in msg - assert "playwright install-deps chromium" in msg # exact primary remedy + assert "-m playwright install-deps chromium" in msg + assert "sudo python -m playwright" not in msg + assert "requests administrator access" in msg assert "apt-get install" in msg # apt alternative line assert "Nothing was downloaded" in msg assert calls == [] +def test_linux_remedy_quotes_the_exact_python_environment(monkeypatch): + """The remedy survives spaces and does not depend on a PATH entry.""" + monkeypatch.setattr(bs.sys, "executable", "/opt/OpenAdapt Tool/bin/python3") + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: ["nss3"]) + + with pytest.raises(RuntimeError) as exc: + bs._require_linux_system_libs() + + msg = str(exc.value) + assert ( + "'/opt/OpenAdapt Tool/bin/python3' -m playwright install-deps chromium" in msg + ) + assert "sudo python" not in msg + + +def test_browser_download_failure_uses_the_exact_python_environment(monkeypatch): + """The CDN remedy must work when Playwright is not exposed on PATH.""" + monkeypatch.setattr(bs.sys, "executable", "/opt/OpenAdapt Tool/bin/python3") + monkeypatch.setattr(bs, "_chromium_present", lambda: False) + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: []) + + def fail_download(*_args, **_kwargs): + raise bs.subprocess.CalledProcessError(1, "playwright") + + monkeypatch.setattr(bs.subprocess, "run", fail_download) + + with pytest.raises(RuntimeError) as exc: + bs._install_chromium() + + assert "'/opt/OpenAdapt Tool/bin/python3' -m playwright install chromium" in str( + exc.value + ) + + +def test_public_setup_copy_states_the_linux_dependency_boundary(): + """README and tutorial do not promise an unconditional first download.""" + root = Path(__file__).parents[1] + readme = (root / "README.md").read_text() + tutorial = (root / "docs" / "TUTORIAL.md").read_text() + + assert "checks the Chromium host libraries before" in readme + assert "A minimal Linux host may need" in tutorial + assert "exact Python environment" in readme + assert "exact Python environment" in tutorial + assert "sudo python -m playwright" not in readme + tutorial + + def test_present_system_libs_do_not_block_install(monkeypatch): """Empty probe result -> the normal download path proceeds unchanged.""" monkeypatch.setattr(bs, "_chromium_present", lambda: False) From 4a74196421eba5d144e9c997d8878a648686e7e3 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 3 Sep 2026 15:59:48 -0400 Subject: [PATCH 2/4] fix: align tutorial with the public oracle contract --- README.md | 15 ++++++++++----- docs/EFFECT_KIT.md | 31 ++++++++++++++++++------------- openadapt_flow/__main__.py | 14 ++++++++++++-- openadapt_flow/tutorial.py | 7 ++++--- openadapt_flow/verification.py | 19 +++++++++++++++++++ tests/test_oracle_vocabulary.py | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 tests/test_oracle_vocabulary.py diff --git a/README.md b/README.md index 215433b9..65cb1fdf 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ and confirms the write by querying the record store out of band: ``` [1/5] Record the demonstration against a real persistence boundary -[2/5] Compile, mining the effect contract from the observed delta - 2 system-of-record effect(s) derived from the demonstration's record delta on step_005 +[2/5] Compile and propose a fixture-specific effect contract + 2 system-of-record effect proposal(s) derived from the fixture's record delta on step_005; review is required for a real deployment [3/5] Certify against the clinical-write policy [4/5] Admit and execute under the standard profile VERIFIED in 4.1s; 0 model calls; the system of record holds 1 record(s) @@ -59,11 +59,16 @@ VERIFIED: /run/REPORT.md metering class billable (this local tutorial was not reported or charged) profile standard model calls 0 - effects 2/2 confirmed at evidence tier 1 (independent system of record) + effects 2/2 confirmed by an independent system-of-record read (Seal Oracle tier 2) ``` -That's real output from 1.34.0, run on macOS on 2026-08-28, with the run -directory shortened and the receipt paths cut. Now break it on purpose: +That output comes from the bundled MockMed fixture. Its compiler can propose +an effect contract because the fixture exposes an observed record delta. A +real deployment must review the proposed contract or declare it directly, then +configure the independent verifier described in +[`docs/EFFECT_KIT.md`](docs/EFFECT_KIT.md). The sample run used version 1.34.0 +on macOS on 2026-08-28. The path is shorter here, and the receipt paths are +not shown. Now break it on purpose: ```bash openadapt-flow tutorial --break-it diff --git a/docs/EFFECT_KIT.md b/docs/EFFECT_KIT.md index 22330683..27e3f00e 100644 --- a/docs/EFFECT_KIT.md +++ b/docs/EFFECT_KIT.md @@ -30,8 +30,8 @@ from the reference apps. `deployment.yaml` wires one `EffectVerifier` (REST / GraphQL / FHIR / SQL / file / email / document / document-hash, or a registered plugin adapter) plus its secret-isolated auth. When more than one reviewed read boundary is - available, `candidates:` selects the strongest evidence tier for each resolved - effect before input. It does not downgrade after input. An unavailable + available, `candidates:` selects the strongest verification class for each + resolved effect before input. It does not downgrade after input. An unavailable selected proof halts or enters reconciliation. 3. **The runtime refuses to guess.** Every verdict is CONFIRMED / REFUTED / INDETERMINATE; both non-confirmed verdicts HALT. A step that declares @@ -175,8 +175,8 @@ resume, attended qualified read-back) refuse it rather than judge it against a synthesized empty baseline. **Backward compatibility and the honest boundary.** This kind is **additive -and opt-in**. Flow contracts are operator-authored — there is no derivation -step that could turn the guard on for you — so every contract written before +and opt-in**. Flow contracts are operator-authored. There is no derivation +step that can turn the guard on for you. Thus, every contract written before this option judges **exactly** as it did before, and its `contract_hash` is byte-identical (the new fields enter the digest only on the new kind). The boundary follows directly: **an existing contract does not detect an @@ -250,24 +250,29 @@ screen read-back as proof of a consequential write. For more than one reviewed boundary, use `effects.candidates` instead of `effects.kind`. Each candidate has the normal `EffectsConfig` fields. Flow -constructs every candidate before actuation, then selects the lowest numeric -`VerificationTier` for each resolved effect; declaration order resolves a tie. -This makes the choice deterministic and reviewable. A missing secret, an -invalid config, or an invalid plugin tier refuses the run before input. The -on-screen candidate is tier 3 only for that exact effect when its read-back -reopens persisted state through a different path. It is tier 4 for a -same-surface read-back. After the action, Flow does not fall back to a weaker +constructs every candidate before actuation, then selects the strongest +`VerificationTier` enum member for each resolved effect; declaration order +resolves a tie. `VerificationTier` is a persisted Flow v1 implementation field. +Do not present its numeric value as a Seal Oracle tier. Public receipts use one +ladder: Oracle tier 0 is visual evidence, tier 1 is a separate read-only +session, tier 2 is a system-of-record read, and tier 3 is a counterparty +acknowledgment. This makes the choice deterministic and reviewable. A missing +secret, invalid config, or invalid plugin verification class refuses the run +before input. The on-screen candidate uses +`PERSISTED_STATE_REACQUISITION` only for that exact effect when its read-back +reopens persisted state through a different path. It uses `IMMEDIATE_SCREEN` +for a same-surface read-back. After the action, Flow does not fall back to a weaker candidate if the selected verifier is unavailable. It records the unavailable proof and halts or creates the normal reconciliation task. ```yaml effects: candidates: - - kind: document # independent export arrival (tier 1) + - kind: document # independent system-of-record export root: /secure/exports file_pattern: "confirmation-*.json" document_format: json - - kind: onscreen # lower-tier persisted-state read-back + - kind: onscreen # persisted-state read-back ``` The single `kind:` form remains the recommended configuration when one diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index b5f231e5..aa951c01 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -1380,10 +1380,20 @@ def _cmd_tutorial(args: argparse.Namespace) -> int: print(f" metering class {metering_class} ({local_charge})") print(f" profile {result.execution_profile}") print(f" model calls {result.model_calls}") + from openadapt_flow.verification import oracle_tier_from_verification_tier + + effect_summary = ( + "no qualifying independent proof" + if result.effect_tier is None + else ( + "confirmed by an independent system-of-record read " + f"(Seal Oracle tier " + f"{oracle_tier_from_verification_tier(result.effect_tier)})" + ) + ) print( f" effects {result.effects_confirmed}/{result.effects_required} " - f"confirmed at evidence tier {result.effect_tier} " - "(independent system of record)" + f"{effect_summary}" ) print(f" bundle digest {result.bundle_digest}") if result.receipt_paths: diff --git a/openadapt_flow/tutorial.py b/openadapt_flow/tutorial.py index 5ce08dd2..39917db4 100644 --- a/openadapt_flow/tutorial.py +++ b/openadapt_flow/tutorial.py @@ -567,14 +567,15 @@ def run_tutorial( presentation_delay_s=delay, ) - say("[2/5] Compile, mining the effect contract from the observed delta") + say("[2/5] Compile and propose a fixture-specific effect contract") workflow = compile_recording( recording_dir, bundle_dir, name=name, mine_effects=True ) save = consequential_step(workflow) say( - f" {len(save.effects)} system-of-record effect(s) derived from " - f"the demonstration's record delta on {save.id}" + f" {len(save.effects)} system-of-record effect proposal(s) " + f"derived from the fixture's record delta on {save.id}; review " + "is required for a real deployment" ) say(f"[3/5] Certify against the {TUTORIAL_POLICY} policy") diff --git a/openadapt_flow/verification.py b/openadapt_flow/verification.py index 2bb3d587..330dc228 100644 --- a/openadapt_flow/verification.py +++ b/openadapt_flow/verification.py @@ -70,6 +70,25 @@ def is_independent_system_of_record(self) -> bool: return int(self) <= int(VerificationTier.INDEPENDENT_SESSION) +def oracle_tier_from_verification_tier( + tier: VerificationTier | int, +) -> int: + """Map the legacy Flow verifier rank to the public Seal oracle ladder. + + ``VerificationTier`` is a persisted Flow v1 field where lower numbers are + stronger. Public receipts use the Seal ladder, where higher numbers are + stronger. Keep this conversion at the boundary instead of presenting the + two incompatible number systems to an operator. + """ + + value = VerificationTier(tier) + if value is VerificationTier.INDEPENDENT_SYSTEM: + return 2 + if value is VerificationTier.INDEPENDENT_SESSION: + return 1 + return 0 + + #: Production ``VERIFIED`` requires this floor or stronger (lower int). #: The Standard *gate* still admits persisted-state read-back so a #: pixel-only run can execute with halt-on-doubt; the outcome classifier diff --git a/tests/test_oracle_vocabulary.py b/tests/test_oracle_vocabulary.py new file mode 100644 index 00000000..f0fc85f2 --- /dev/null +++ b/tests/test_oracle_vocabulary.py @@ -0,0 +1,33 @@ +"""The public oracle ladder must not expose Flow's inverse legacy rank.""" + +from __future__ import annotations + +from pathlib import Path + +from openadapt_flow.verification import ( + VerificationTier, + oracle_tier_from_verification_tier, +) + + +def test_legacy_verification_rank_maps_to_seal_oracle_tier() -> None: + assert oracle_tier_from_verification_tier(VerificationTier.INDEPENDENT_SYSTEM) == 2 + assert oracle_tier_from_verification_tier(VerificationTier.INDEPENDENT_SESSION) == 1 + assert ( + oracle_tier_from_verification_tier( + VerificationTier.PERSISTED_STATE_REACQUISITION + ) + == 0 + ) + assert oracle_tier_from_verification_tier(VerificationTier.IMMEDIATE_SCREEN) == 0 + + +def test_public_docs_use_the_seal_oracle_ladder() -> None: + root = Path(__file__).parents[1] + readme = (root / "README.md").read_text(encoding="utf-8") + kit = (root / "docs" / "EFFECT_KIT.md").read_text(encoding="utf-8") + + assert "Seal Oracle tier 2" in readme + assert "evidence tier 1 (independent system of record)" not in readme + assert "tier 0 is visual evidence" in kit + assert "tier 2 is a system-of-record read" in kit From 94c364fa3e032840234812d250dc116b13d5c4f9 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 3 Sep 2026 16:44:53 -0400 Subject: [PATCH 3/4] test: keep hosted release receipt fixture current --- ...test_hosted_runner_product_release_gate.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/test_hosted_runner_product_release_gate.py b/tests/test_hosted_runner_product_release_gate.py index 38b5339e..d497e68f 100644 --- a/tests/test_hosted_runner_product_release_gate.py +++ b/tests/test_hosted_runner_product_release_gate.py @@ -26,6 +26,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from openadapt_flow.qualification_admission_v2 import canonical_json from openadapt_flow.runner.config import ( AdmissionTrustFiles, LocalRuntimeRelease, @@ -51,6 +52,7 @@ SEQUENCE = 7 SET_ID = "00000000-0000-4000-8000-000000000099" +VERIFICATION_ID_DOMAIN = b"OpenAdapt qualification release verification receipt v1\0" def _stamp(moment: datetime) -> str: @@ -332,6 +334,26 @@ def _canonical_artifact_bytes(artifact: ProductReleaseAdmissionArtifact) -> byte ).encode("utf-8") +def _current_flow_receipt_bytes() -> bytes: + """Return a self-bound test copy whose finite validity window is current.""" + fixture = Path( + "tests/fixtures/remote-safe-synthetic-flow-release-verification.json" + ) + payload = json.loads(fixture.read_text(encoding="utf-8")) + now = _now() + payload["verified_at"] = _stamp(now - timedelta(hours=1)) + payload["expires_at"] = _stamp(now + timedelta(days=7)) + projection = dict(payload) + projection.pop("verification_id_sha256") + payload["verification_id_sha256"] = ( + "sha256:" + + hashlib.sha256( + VERIFICATION_ID_DOMAIN + canonical_json(projection) + ).hexdigest() + ) + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + + def _gate_fixture(tmp_path: Path, *, payload_raw=None, sequence: int = SEQUENCE): """Build a real adapter, config, and dispatch carrier for the gate.""" @@ -377,9 +399,7 @@ def _v2_gate_fixture( flow_release_id: str = "1.35.0", flow_artifact_sha256: str | None = None, ): - receipt_raw = Path( - "tests/fixtures/remote-safe-synthetic-flow-release-verification.json" - ).read_bytes() + receipt_raw = _current_flow_receipt_bytes() receipt_artifact = FlowReleaseVerificationReceiptArtifactBytes( artifact_bytes_base64=b64encode(receipt_raw).decode("ascii"), artifact_sha256="sha256:" + hashlib.sha256(receipt_raw).hexdigest(), From f73aae0a44c1c070f3bb392bd749f1505426462e Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 3 Sep 2026 16:45:45 -0400 Subject: [PATCH 4/4] fix: update pip past current advisory --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 971e8461..9c5e02e6 100644 --- a/uv.lock +++ b/uv.lock @@ -2495,11 +2495,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.2" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/96/e6f8e9d9d7b9cc4457092712a7e919c3186aa2c2fa9ffed2c5d29cc947e8/pip-26.2.tar.gz", hash = "sha256:2d8542afcc84cdd8e846c2b36b2861fad1da376dd98f8e7113e9108a3c331690", size = 1848845, upload-time = "2026-07-29T21:57:56.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/62/36/a3aed958d60531cb442b7ab4596cda7b3621cfb916f8ae1d6769795c7dc1/pip-26.2-py3-none-any.whl", hash = "sha256:931c303696af6fa3417112103b1cad26890e5a07eccb5b99783700e33f2b8aad", size = 1816475, upload-time = "2026-07-29T21:57:54.763Z" }, ] [[package]]