From fcc9e1f464fd5b0eece620a9c8bb467e7905b0c3 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 00:34:34 +0200 Subject: [PATCH 1/4] feat: add guided deployment preflight --- openadapt/cli.py | 129 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli_smoke.py | 40 +++++++++++++ 2 files changed, 169 insertions(+) diff --git a/openadapt/cli.py b/openadapt/cli.py index dd582068f..e276b09ec 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -25,6 +25,8 @@ openadapt doctor """ +import platform +import re import sys from pathlib import Path from typing import Optional @@ -259,6 +261,133 @@ def quickstart(out: Path, headed: bool, break_it: bool) -> None: click.echo("Qualify a consequential workflow: https://openadapt.ai/qualify") +_SECRET_REFERENCE = re.compile(r"^(?:env:[A-Z][A-Z0-9_]*|keychain:[^/\s]+/[^/\s]+)$") + + +@main.command("deploy") +@click.option( + "--backend", + type=click.Choice(["web", "windows", "macos", "linux", "rdp", "citrix"]), + default="web", + show_default=True, + help="The execution surface that this customer deployment will use.", +) +@click.option( + "--secret-ref", + multiple=True, + help="Secret reference only: env:NAME or keychain:service/item. Never pass a secret value.", +) +def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: + """Check this host and print the safe Flow/Desktop deployment path. + + This launcher command does not create another runtime or service manager. + It records no secret value, starts no connector, and does not treat an + incomplete preflight as a healthy deployment. It composes the installed + Flow connector, operator console, and repair lifecycle instead. + """ + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as dist_version + from importlib.util import find_spec + + click.echo("OpenAdapt deployment preflight") + click.echo("=" * 30) + click.echo("Environment fingerprint:") + click.echo(f" platform: {platform.system()} {platform.release()}") + click.echo(f" machine: {platform.machine() or 'unknown'}") + click.echo(f" python: {platform.python_version()}") + try: + flow_version = dist_version("openadapt-flow") + except PackageNotFoundError: + flow_version = "not installed" + click.echo(f" openadapt-flow: {flow_version}") + click.echo(f" requested backend: {backend}") + + bad_refs = [value for value in secret_ref if not _SECRET_REFERENCE.fullmatch(value)] + click.echo("\nSecret references:") + if not secret_ref: + click.echo(" [--] none supplied (valid for a local-only preflight)") + for value in secret_ref: + status = "[INVALID]" if value in bad_refs else "[OK]" + click.echo(f" {status} {value}") + if bad_refs: + raise click.UsageError( + "Secret references must use env:NAME or keychain:service/item; " + "do not pass secret values." + ) + + click.echo("\nHealth checks:") + flow_ready = find_spec("openadapt_flow") is not None + if flow_ready: + click.echo(" [OK] canonical openadapt-flow engine is installed") + else: + click.echo(" [MISSING] canonical openadapt-flow engine is not installed") + + if backend == "web": + browser_ready = find_spec("playwright") is not None + if browser_ready: + click.echo(" [OK] Playwright is installed for the web backend") + else: + click.echo( + " [SETUP] install the web extra before recording or replay: " + "python -m pip install 'openadapt[browser]'" + ) + else: + click.echo( + f" [SETUP] {backend} readiness is checked by Flow when the " + "configured target opens; this guide does not claim it is ready." + ) + + if not flow_ready: + raise click.ClickException( + "Preflight failed. Install the canonical engine with: " + "python -m pip install --upgrade openadapt" + ) + + click.echo( + "\nPreflight passed for the installed components. No service was started." + ) + click.echo("\nGuided deployment path:") + click.echo( + " 1. Re-run full host diagnostics: openadapt doctor --backend " + backend + ) + click.echo( + " 2. Set up the customer-local Flow connector (provide references " + "through its environment, never on the command line):" + ) + click.echo( + " openadapt flow connector enroll --profile deployment.yaml " + "--storage-root /secure/openadapt" + ) + click.echo( + " 3. Start one governed poll only after enrollment: " + "openadapt flow connector run --profile deployment.yaml --once" + ) + click.echo( + " 4. Inspect local health, reports, and halt evidence: " + "openadapt flow console --bundles bundles --runs runs" + ) + click.echo( + " The console is loopback-only and read-only unless you explicitly " + "enable its governed actions." + ) + click.echo( + " 5. In OpenAdapt Desktop, use the signed-in workspace connection and " + "open the matching run report; do not use the Desktop view as proof of effect." + ) + click.echo( + " 6. Upgrade the launcher and its pinned Flow dependency: " + "python -m pip install --upgrade openadapt" + ) + click.echo( + " 7. Roll back a governed bundle, not an unverified package: " + "openadapt flow repair rollback --store repair-store" + ) + click.echo( + " 8. Uninstall only after retaining required run evidence: " + "python -m pip uninstall openadapt openadapt-flow" + ) + + @main.group(cls=_FlowPassthroughGroup) def flow(): """Record, compile, and replay workflows (the demonstration compiler). diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 25597a5af..358a0eac8 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -308,6 +308,46 @@ def test_doctor_does_not_require_browser_for_citrix(monkeypatch): assert "no Playwright or Chromium setup will run" in result.output +def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( + monkeypatch, +): + """A clean host gets diagnostics plus commands for the existing engine. + + The deployment guide must remain a launcher seam: it does not start a + second engine, accept a secret value, or replace Flow's rollback path. + """ + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name == "openadapt_flow" else None, + ) + result = CliRunner().invoke( + cli_main, + ["deploy", "--backend", "rdp", "--secret-ref", "env:BYOC_CONNECTOR_TOKEN"], + ) + + assert result.exit_code == 0, result.output + assert "Environment fingerprint" in result.output + assert "env:BYOC_CONNECTOR_TOKEN" in result.output + assert "connector enroll" in result.output + assert "flow console" in result.output + assert "flow repair rollback" in result.output + assert "No service was started" in result.output + + +def test_deploy_preflight_refuses_secret_values_and_missing_engine(monkeypatch): + monkeypatch.setattr("importlib.util.find_spec", lambda _name: None) + + runner = CliRunner() + secret_result = runner.invoke(cli_main, ["deploy", "--secret-ref", "actual-secret"]) + assert secret_result.exit_code != 0 + assert "do not pass secret values" in secret_result.output + + missing_result = runner.invoke(cli_main, ["deploy", "--backend", "windows"]) + assert missing_result.exit_code != 0 + assert "[MISSING]" in missing_result.output + assert "Preflight failed" in missing_result.output + + def test_top_level_help_leads_with_flow(): """`openadapt --help` must list flow before the other commands.""" runner = CliRunner() From 5ec33eed5f7992dad5e7cfb077a853cc9157b77c Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 00:39:10 +0200 Subject: [PATCH 2/4] fix: fail closed in deployment preflight --- openadapt/cli.py | 58 +++++++++++++++++++++++++++++------------ tests/test_cli_smoke.py | 35 ++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/openadapt/cli.py b/openadapt/cli.py index e276b09ec..fcda07360 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -262,6 +262,18 @@ def quickstart(out: Path, headed: bool, break_it: bool) -> None: _SECRET_REFERENCE = re.compile(r"^(?:env:[A-Z][A-Z0-9_]*|keychain:[^/\s]+/[^/\s]+)$") +_SUPPORTED_FLOW_RANGE = ">=1.29.0,<2.0.0" + + +def _supported_flow_version(value: str) -> bool: + """Return whether a stable Flow version is in the launcher's supported range.""" + match = re.fullmatch( + r"(\d+)\.(\d+)\.(\d+)(?:\.post\d+)?(?:\+[a-zA-Z0-9.-]+)?", value + ) + if match is None: + return False + parsed = tuple(int(part) for part in match.groups()) + return (1, 29, 0) <= parsed < (2, 0, 0) @main.command("deploy") @@ -307,8 +319,10 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: if not secret_ref: click.echo(" [--] none supplied (valid for a local-only preflight)") for value in secret_ref: - status = "[INVALID]" if value in bad_refs else "[OK]" - click.echo(f" {status} {value}") + if value in bad_refs: + click.echo(" [INVALID] rejected secret reference (value hidden)") + else: + click.echo(f" [OK] {value}") if bad_refs: raise click.UsageError( "Secret references must use env:NAME or keychain:service/item; " @@ -316,17 +330,31 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: ) click.echo("\nHealth checks:") - flow_ready = find_spec("openadapt_flow") is not None - if flow_ready: - click.echo(" [OK] canonical openadapt-flow engine is installed") - else: + failures = [] + flow_importable = find_spec("openadapt_flow") is not None + flow_version_ready = flow_version != "not installed" and _supported_flow_version( + flow_version + ) + if flow_importable and flow_version_ready: + click.echo( + " [OK] canonical openadapt-flow engine is installed at a supported version" + ) + elif not flow_importable or flow_version == "not installed": + failures.append("flow") click.echo(" [MISSING] canonical openadapt-flow engine is not installed") + else: + failures.append("flow-version") + click.echo( + f" [UNSUPPORTED] openadapt-flow {flow_version}; this launcher " + f"requires {_SUPPORTED_FLOW_RANGE}" + ) if backend == "web": browser_ready = find_spec("playwright") is not None if browser_ready: click.echo(" [OK] Playwright is installed for the web backend") else: + failures.append("browser") click.echo( " [SETUP] install the web extra before recording or replay: " "python -m pip install 'openadapt[browser]'" @@ -337,10 +365,10 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: "configured target opens; this guide does not claim it is ready." ) - if not flow_ready: + if failures: raise click.ClickException( - "Preflight failed. Install the canonical engine with: " - "python -m pip install --upgrade openadapt" + "Preflight failed. Resolve every [MISSING], [UNSUPPORTED], and " + "[SETUP] item above before service setup." ) click.echo( @@ -350,16 +378,14 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: click.echo( " 1. Re-run full host diagnostics: openadapt doctor --backend " + backend ) + click.echo(" 2. Open the authenticated Cloud connector settings:") + click.echo(" https://app.openadapt.ai/dashboard/settings/connectors") click.echo( - " 2. Set up the customer-local Flow connector (provide references " - "through its environment, never on the command line):" - ) - click.echo( - " openadapt flow connector enroll --profile deployment.yaml " - "--storage-root /secure/openadapt" + " Create the customer-local connector there and put the issued " + "references in its environment or OS keychain." ) click.echo( - " 3. Start one governed poll only after enrollment: " + " 3. Start one governed poll only after authenticated setup: " "openadapt flow connector run --profile deployment.yaml --once" ) click.echo( diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 358a0eac8..596973814 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -320,6 +320,7 @@ def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( "importlib.util.find_spec", lambda name: object() if name == "openadapt_flow" else None, ) + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") result = CliRunner().invoke( cli_main, ["deploy", "--backend", "rdp", "--secret-ref", "env:BYOC_CONNECTOR_TOKEN"], @@ -328,7 +329,9 @@ def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( assert result.exit_code == 0, result.output assert "Environment fingerprint" in result.output assert "env:BYOC_CONNECTOR_TOKEN" in result.output - assert "connector enroll" in result.output + assert "dashboard/settings/connectors" in result.output + assert "connector enroll" not in result.output + assert "connector run" in result.output assert "flow console" in result.output assert "flow repair rollback" in result.output assert "No service was started" in result.output @@ -341,6 +344,8 @@ def test_deploy_preflight_refuses_secret_values_and_missing_engine(monkeypatch): secret_result = runner.invoke(cli_main, ["deploy", "--secret-ref", "actual-secret"]) assert secret_result.exit_code != 0 assert "do not pass secret values" in secret_result.output + assert "actual-secret" not in secret_result.output + assert "value hidden" in secret_result.output missing_result = runner.invoke(cli_main, ["deploy", "--backend", "windows"]) assert missing_result.exit_code != 0 @@ -348,6 +353,34 @@ def test_deploy_preflight_refuses_secret_values_and_missing_engine(monkeypatch): assert "Preflight failed" in missing_result.output +def test_deploy_preflight_fails_without_web_runtime(monkeypatch): + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name == "openadapt_flow" else None, + ) + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") + + result = CliRunner().invoke(cli_main, ["deploy", "--backend", "web"]) + + assert result.exit_code != 0 + assert "[SETUP] install the web extra" in result.output + assert "Preflight failed" in result.output + assert "Preflight passed" not in result.output + + +@pytest.mark.parametrize("flow_version", ["1.28.9", "2.0.0", "2.1.0", "invalid"]) +def test_deploy_preflight_fails_for_unsupported_flow(monkeypatch, flow_version): + monkeypatch.setattr("importlib.util.find_spec", lambda _name: object()) + monkeypatch.setattr("importlib.metadata.version", lambda _name: flow_version) + + result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"]) + + assert result.exit_code != 0 + assert "[UNSUPPORTED]" in result.output + assert ">=1.29.0,<2.0.0" in result.output + assert "Preflight passed" not in result.output + + def test_top_level_help_leads_with_flow(): """`openadapt --help` must list flow before the other commands.""" runner = CliRunner() From 98b922535b85ae7246459ce24822f58c08c8231d Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 00:48:43 +0200 Subject: [PATCH 3/4] fix: gate optional deployment console step --- openadapt/cli.py | 31 +++++++++++++++++++++++-------- tests/test_cli_smoke.py | 21 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/openadapt/cli.py b/openadapt/cli.py index fcda07360..6c7239cc2 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -365,6 +365,12 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: "configured target opens; this guide does not claim it is ready." ) + console_ready = all(find_spec(name) is not None for name in ("fastapi", "uvicorn")) + if console_ready: + click.echo(" [OK] optional local operator console is installed") + else: + click.echo(" [--] optional local operator console is not installed") + if failures: raise click.ClickException( "Preflight failed. Resolve every [MISSING], [UNSUPPORTED], and " @@ -388,14 +394,23 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: " 3. Start one governed poll only after authenticated setup: " "openadapt flow connector run --profile deployment.yaml --once" ) - click.echo( - " 4. Inspect local health, reports, and halt evidence: " - "openadapt flow console --bundles bundles --runs runs" - ) - click.echo( - " The console is loopback-only and read-only unless you explicitly " - "enable its governed actions." - ) + if console_ready: + click.echo( + " 4. Inspect local health, reports, and halt evidence: " + "openadapt flow console --bundles bundles --runs runs" + ) + click.echo( + " The console is loopback-only and read-only unless you explicitly " + "enable its governed actions." + ) + else: + click.echo(" 4. Optional local console setup:") + click.echo( + f" python -m pip install 'openadapt-flow[console]=={flow_version}'" + ) + click.echo( + " Re-run this preflight after installation before you start the console." + ) click.echo( " 5. In OpenAdapt Desktop, use the signed-in workspace connection and " "open the matching run report; do not use the Desktop view as proof of effect." diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 596973814..cadfeba61 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -318,7 +318,9 @@ def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( """ monkeypatch.setattr( "importlib.util.find_spec", - lambda name: object() if name == "openadapt_flow" else None, + lambda name: ( + object() if name in {"openadapt_flow", "fastapi", "uvicorn"} else None + ), ) monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") result = CliRunner().invoke( @@ -337,6 +339,23 @@ def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( assert "No service was started" in result.output +def test_deploy_base_hosted_install_gives_conditional_console_setup(monkeypatch): + """Base Flow hosted installs must not receive an unusable console command.""" + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name == "openadapt_flow" else None, + ) + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") + + result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"]) + + assert result.exit_code == 0, result.output + assert "optional local operator console is not installed" in result.output + assert "python -m pip install 'openadapt-flow[console]==1.29.0'" in result.output + assert "openadapt flow console --bundles" not in result.output + assert "Re-run this preflight" in result.output + + def test_deploy_preflight_refuses_secret_values_and_missing_engine(monkeypatch): monkeypatch.setattr("importlib.util.find_spec", lambda _name: None) From 6725507403351e22d7691e183c2fe6e5011e1cf0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 00:52:23 +0200 Subject: [PATCH 4/4] fix: complete deployment console preflight --- openadapt/cli.py | 5 ++++- platform-manifest.json | 16 ++++++++-------- tests/test_cli_smoke.py | 22 +++++++++++++++++++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/openadapt/cli.py b/openadapt/cli.py index 6c7239cc2..847d40357 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -365,7 +365,10 @@ def deploy(backend: str, secret_ref: tuple[str, ...]) -> None: "configured target opens; this guide does not claim it is ready." ) - console_ready = all(find_spec(name) is not None for name in ("fastapi", "uvicorn")) + console_ready = all( + find_spec(name) is not None + for name in ("fastapi", "uvicorn", "openadapt_types") + ) if console_ready: click.echo(" [OK] optional local operator console is installed") else: diff --git a/platform-manifest.json b/platform-manifest.json index 551d47531..a1d3e7c2d 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -1,7 +1,7 @@ { "manifest_kind": "openadapt-platform-release-manifest", "schema_version": "1.0.0", - "generated_at": "2026-08-02T20:05:53+00:00", + "generated_at": "2026-08-05T22:51:33+00:00", "release_channel": "beta", "components": { "launcher": { @@ -26,21 +26,21 @@ }, "flow": { "package": "openadapt-flow", - "version": "1.29.0", + "version": "1.30.0", "source": "pypi", "requires_python": "<3.13,>=3.10", "artifacts": [ { "type": "bdist_wheel", - "filename": "openadapt_flow-1.29.0-py3-none-any.whl", - "url": "https://files.pythonhosted.org/packages/8f/ab/f05f65adb368a6135a32eb201e341e00e5ccc7334715d4381aad2e656c3a/openadapt_flow-1.29.0-py3-none-any.whl", - "sha256": "2c5f49199161fb2964bb43cfae2470fb79e5dbcb264e3031d6f8802ecb161a6a" + "filename": "openadapt_flow-1.30.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/5c/0e/680aebe9683c358d1f4b9d0aac62c21f6698f63c46dbff4e027f9d3836ae/openadapt_flow-1.30.0-py3-none-any.whl", + "sha256": "7bf1a7b00388172a79bda666def182688c296ffb5bc9be2fd3281169fc36ae63" }, { "type": "sdist", - "filename": "openadapt_flow-1.29.0.tar.gz", - "url": "https://files.pythonhosted.org/packages/a1/4f/53d31b2a8600a61e35ab9aa469e25591e327eb2301039c5e55f788044a82/openadapt_flow-1.29.0.tar.gz", - "sha256": "d4ba0fb0af915d8fbf642fdf0843524f04992e8b3080febfb618febe507311a7" + "filename": "openadapt_flow-1.30.0.tar.gz", + "url": "https://files.pythonhosted.org/packages/c8/5d/883e608ac2a8e414551b55368fdc3b0b52a83f58ccaff8ba47956e22f5be/openadapt_flow-1.30.0.tar.gz", + "sha256": "3a402610e35f47fd54daaf066b8c3a6006c13483cb4c778740014abea1854ea4" } ] }, diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index cadfeba61..4f90aa74b 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -319,7 +319,9 @@ def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( monkeypatch.setattr( "importlib.util.find_spec", lambda name: ( - object() if name in {"openadapt_flow", "fastapi", "uvicorn"} else None + object() + if name in {"openadapt_flow", "fastapi", "uvicorn", "openadapt_types"} + else None ), ) monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") @@ -356,6 +358,24 @@ def test_deploy_base_hosted_install_gives_conditional_console_setup(monkeypatch) assert "Re-run this preflight" in result.output +def test_deploy_console_requires_openadapt_types(monkeypatch): + """Flow 1.30 imports openadapt-types when the operator console starts.""" + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: ( + object() if name in {"openadapt_flow", "fastapi", "uvicorn"} else None + ), + ) + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.30.0") + + result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"]) + + assert result.exit_code == 0, result.output + assert "optional local operator console is not installed" in result.output + assert "python -m pip install 'openadapt-flow[console]==1.30.0'" in result.output + assert "openadapt flow console --bundles" not in result.output + + def test_deploy_preflight_refuses_secret_values_and_missing_engine(monkeypatch): monkeypatch.setattr("importlib.util.find_spec", lambda _name: None)