Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![PyPI version](https://img.shields.io/pypi/v/openadapt.svg)](https://pypi.org/project/openadapt/)
[![Downloads](https://img.shields.io/pypi/dm/openadapt.svg)](https://pypi.org/project/openadapt/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
[![Python 3.10–3.12](https://img.shields.io/badge/python-3.10%E2%80%933.12-blue)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20the%20community-7289da?logo=discord&logoColor=white)](https://discord.gg/yF527cQbDG)

> **Lifecycle: Beta launcher/meta-package.** The active compiler and governed
Expand Down Expand Up @@ -61,18 +61,32 @@ pip install openadapt[all] # Everything, including research extras
```

The flagship compiler ships in the base install, so `openadapt flow …` works
right after `pip install openadapt`. Install `openadapt-flow[hosted]` when you
want OS-keychain token storage; environment-based token configuration remains
available on headless systems. This launcher requires `openadapt-flow>=1.7.0,<2`
right after `pip install openadapt`. The base install includes OS-keychain
credential storage for secure Cloud pairing; environment-based token
configuration remains available on headless systems. This launcher requires
`openadapt-flow>=1.17.0,<2`
so clean installs cannot resolve an older engine that lacks the governed hosted
artifact commands documented below.

**Requirements:** Python 3.10+
**Requirements:** Python 3.10–3.12

---

## Quick Start

To connect this computer to the workspace you already opened in Cloud, click
**Connect local OpenAdapt** there. The desktop app opens when its protocol
handler is installed; otherwise Cloud gives you one command:

```bash
openadapt connect --pairing oap_... --host https://app.openadapt.ai
```

The one-time pairing expires after five minutes. The resulting workspace
credential is saved in the operating system keychain and can be revoked in
Cloud settings. Pairing grants the existing governed ingest capability; it
does not let a web page run terminal commands or browse local files.

Run the bundled MockMed workflow first. This is the reproducible path exercised
by `openadapt-flow` CI and requires no account or target application:

Expand Down
55 changes: 55 additions & 0 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def list_commands(self, ctx):
"emit-skill": "Emit an Agent Skills folder for a bundle.",
"emit-mcp": "Emit a standalone MCP server for a bundle.",
"teach": "Teach a governed correction after a halt.",
"connect": "Connect this computer to an authenticated Cloud workspace.",
"login": "Validate and store a hosted ingest token.",
"sanitize": "Create a verified sanitized derivative locally.",
"review-sanitized": "Review original and sanitized content locally.",
Expand Down Expand Up @@ -169,6 +170,60 @@ def flow():
pass


@main.command("connect")
@click.option(
"--pairing", default=None, help="Five-minute pairing code from Cloud settings"
)
@click.option("--uri", default=None, help="Exact openadapt://connect desktop deep link")
@click.option(
"--host", default=None, help="Cloud origin (default: https://app.openadapt.ai)"
)
@click.option("--device-name", default=None, help="Name shown for this computer")
@click.option(
"--destination-kind",
type=click.Choice(["openadapt-managed", "customer-managed", "local"]),
default=None,
help="Trust class for the pairing destination",
)
@click.option(
"--trusted-host",
multiple=True,
help="Exact allowed customer-managed origin (repeatable)",
)
def connect(pairing, uri, host, device_name, destination_kind, trusted_host):
"""Connect this computer to the signed-in OpenAdapt Cloud workspace.

The browser creates a five-minute, one-use pairing. This command claims it
and saves the resulting workspace credential in the OS keychain. It cannot
execute arbitrary terminal commands or grant browser access to the shell.
"""
if bool(pairing) == bool(uri):
raise click.UsageError("Pass exactly one of --pairing or --uri.")
try:
from openadapt_flow import hosted

if not hasattr(hosted, "connect"):
raise ImportError
except ImportError:
click.echo(
"Error: this connection flow needs a newer openadapt-flow. "
"Run: pip install --upgrade openadapt",
err=True,
)
raise click.exceptions.Exit(1)

argv = ["connect", "--pairing", pairing] if pairing else ["connect", "--uri", uri]
if host:
argv += ["--host", host]
if device_name:
argv += ["--device-name", device_name]
if destination_kind:
argv += ["--destination-kind", destination_kind]
for value in trusted_host:
argv += ["--trusted-host", value]
_run_flow(argv)


@flow.command("record")
@click.option("--url", required=True, help="URL of the app to record against")
@click.option("--out", required=True, help="Recording output directory")
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "openadapt"
version = "1.6.4"
description = "Beta launcher for openadapt-flow: compile demonstrated GUI workflows into deterministic, governed local replay"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.10,<3.13"
license = "MIT"
authors = [
{name = "Richard Abrich", email = "richard@openadapt.ai"}
Expand All @@ -27,7 +27,7 @@ classifiers = [
# All other capabilities (capture, ml, evals, ...) remain opt-in extras.
dependencies = [
"click>=8.0.0",
"openadapt-flow>=1.7.0,<2.0.0",
"openadapt-flow[hosted]>=1.17.0,<2.0.0",
]

[project.optional-dependencies]
Expand All @@ -51,13 +51,13 @@ retrieval = [
"openadapt-retrieval>=0.1.0",
]
privacy = [
"openadapt-flow[privacy]>=1.7.0,<2.0.0",
"openadapt-flow[privacy]>=1.17.0,<2.0.0",
]
# `flow` now ships in the base install (see `dependencies` above). This
# extra is kept for backward compatibility so `pip install openadapt[flow]`
# and the `all` bundle still resolve.
flow = [
"openadapt-flow>=1.7.0,<2.0.0",
"openadapt-flow>=1.17.0,<2.0.0",
]
# Bundles
core = [
Expand Down
15 changes: 13 additions & 2 deletions scripts/verify_release_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ def _quoted_table_value(text: str, table: str, key: str) -> str:
raise ValueError(f"missing quoted {key!r} in [{table}]")


def _project_identity(root: Path) -> tuple[str, str]:
def _project_identity(root: Path) -> tuple[str, str, str]:
project_text = (root / "pyproject.toml").read_text(encoding="utf-8")
return (
_quoted_table_value(project_text, "project", "name"),
_quoted_table_value(project_text, "project", "version"),
_quoted_table_value(project_text, "project", "requires-python"),
)


Expand Down Expand Up @@ -81,12 +82,16 @@ def _lifecycle(metadata: Message) -> list[str]:
]


def _specifier_terms(value: str) -> set[str]:
return {term.strip() for term in value.split(",") if term.strip()}


def verify_release_artifacts(
dist_dir: Path,
root: Path = ROOT,
) -> tuple[Path, Path]:
"""Return the verified ``(wheel, sdist)`` paths or raise ``ValueError``."""
package_name, project_version = _project_identity(root)
package_name, project_version, project_requires_python = _project_identity(root)
wheel_name = re.sub(r"[-_.]+", "_", package_name)
sdist_name = re.sub(r"[-_.]+", "-", package_name)

Expand Down Expand Up @@ -122,6 +127,12 @@ def verify_release_artifacts(
raise ValueError(f"{source} package name does not match {package_name}")
if metadata["Version"] != project_version:
raise ValueError(f"{source} version does not match {project_version}")
if _specifier_terms(metadata["Requires-Python"]) != _specifier_terms(
project_requires_python
):
raise ValueError(
f"{source} Requires-Python does not match {project_requires_python}"
)
if _lifecycle(metadata) != [BETA_CLASSIFIER]:
raise ValueError(f"{source} does not declare the Beta launcher lifecycle")

Expand Down
109 changes: 102 additions & 7 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ def test_distribution_metadata_advertises_beta_lifecycle():
assert lifecycle == ["Development Status :: 4 - Beta"]


def test_distribution_metadata_matches_engine_python_range():
"""The launcher must fail before resolution on unsupported Python versions."""
from importlib.metadata import metadata

actual = metadata("openadapt")["Requires-Python"]
assert {term.strip() for term in actual.split(",")} == {">=3.10", "<3.13"}


def test_doctor_lists_flow_as_core_not_extras():
"""`openadapt doctor` must treat openadapt-flow as core and the opt-in
extras (capture/ml/evals/viewer/...) as optional, never flagging a
Expand Down Expand Up @@ -142,13 +150,14 @@ def test_doctor_lists_flow_as_core_not_extras():
FLOW_VERBS = {"demo-record", "record", "compile", "replay", "lint", "certify"}


def test_launcher_requires_hosted_flow_release():
"""The base and compatibility extras must not resolve pre-hosted engines."""
def test_launcher_requires_pairing_enabled_flow_release():
"""Every install route must resolve an engine with transactional pairing."""
metadata = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()

assert metadata.count('"openadapt-flow>=1.7.0,<2.0.0"') == 2
assert metadata.count('"openadapt-flow[privacy]>=1.7.0,<2.0.0"') == 1
assert "openadapt-flow>=1.6.0" not in metadata
assert metadata.count('"openadapt-flow[hosted]>=1.17.0,<2.0.0"') == 1
assert metadata.count('"openadapt-flow>=1.17.0,<2.0.0"') == 1
assert metadata.count('"openadapt-flow[privacy]>=1.17.0,<2.0.0"') == 1
assert "openadapt-flow>=1.7.0" not in metadata


def test_top_level_help_leads_with_flow():
Expand Down Expand Up @@ -203,7 +212,9 @@ def broken_import(name, *args, **kwargs):
monkeypatch.delitem(sys.modules, "openadapt_flow.__main__", raising=False)

runner = CliRunner()
result = runner.invoke(cli_main, ["flow", "compile", "rec", "--out", "b", "--name", "x"])
result = runner.invoke(
cli_main, ["flow", "compile", "rec", "--out", "b", "--name", "x"]
)
assert result.exit_code != 0
assert "pip install --upgrade openadapt" in result.output
assert "pip install openadapt-flow" in result.output
Expand Down Expand Up @@ -282,6 +293,7 @@ def test_flow_help_lists_delegated_launch_commands():
for command in (
"run",
"teach",
"connect",
"login",
"sanitize",
"review-sanitized",
Expand All @@ -293,13 +305,96 @@ def test_flow_help_lists_delegated_launch_commands():
assert command in result.output


def test_top_level_connect_is_one_command_and_delegates_narrow_pairing(monkeypatch):
import openadapt_flow.hosted as hosted

captured = {}
monkeypatch.setattr(hosted, "connect", object(), raising=False)
monkeypatch.setattr(
"openadapt.cli._run_flow",
lambda argv: captured.update(argv=list(argv)),
)
result = CliRunner().invoke(
cli_main,
[
"connect",
"--pairing",
"oap_" + "A" * 43,
"--host",
"https://app.openadapt.ai",
"--device-name",
"Reception PC",
],
)
assert result.exit_code == 0, result.output
assert captured["argv"] == [
"connect",
"--pairing",
"oap_" + "A" * 43,
"--host",
"https://app.openadapt.ai",
"--device-name",
"Reception PC",
]


def test_top_level_connect_preserves_exact_desktop_uri(monkeypatch):
import openadapt_flow.hosted as hosted

captured = {}
uri = (
"openadapt://connect?pairing=oap_"
+ "A" * 43
+ "&host=https%3A%2F%2Fapp.openadapt.ai"
+ "&destination_kind=openadapt-managed"
)
monkeypatch.setattr(hosted, "connect", object(), raising=False)
monkeypatch.setattr(
"openadapt.cli._run_flow",
lambda argv: captured.update(argv=list(argv)),
)

result = CliRunner().invoke(
cli_main,
["connect", "--uri", uri, "--device-name", "Reception PC"],
)

assert result.exit_code == 0, result.output
assert captured["argv"] == [
"connect",
"--uri",
uri,
"--device-name",
"Reception PC",
]


def test_top_level_connect_requires_exactly_one_fixed_pairing_source():
runner = CliRunner()
assert runner.invoke(cli_main, ["connect"]).exit_code != 0
both = runner.invoke(
cli_main,
[
"connect",
"--pairing",
"oap_" + "A" * 43,
"--uri",
"openadapt://connect?pairing=x&host=https://app.openadapt.ai",
],
)
assert both.exit_code != 0
assert "exactly one" in both.output


def test_flow_replay_argv_reconstruction(monkeypatch):
"""Repeatable and flag options are forwarded verbatim to flow's main."""
_require_openadapt_flow()
import openadapt_flow.__main__ as flow_main_mod

calls = []
monkeypatch.setattr(flow_main_mod, "main", lambda argv: calls.append(list(argv)) or 0)
monkeypatch.setattr(
flow_main_mod, "main", lambda argv: calls.append(list(argv)) or 0
)

runner = CliRunner()
result = runner.invoke(
Expand Down
Loading