Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Signed files: their exact bytes are what the Seals cover.
tests/fixtures/seal_view/** -text
packages/seal-view/test/fixtures/** -text
61 changes: 61 additions & 0 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
# typecheck, the ESM build (which refuses any runtime import beyond React) and
# a vendored copy's checksum verification.
#
# @openadapt/seal-view (packages/seal-view), one run's Seal drawn and checked
# from its files, runs its unit tests, rebuilds the offline viewer and fails
# when the launcher's copy (openadapt/seal_view/viewer.html) differs or is over
# its size budget, then opens the quickstart fixture's Seal pages from file://
# in Chromium (tests/test_seal_view.py): valid, tampered, and no requests.
#
# OpenAdapt Desktop (packages/desktop) runs two reusable workflows:
# desktop-test.yml (the Python engine on Linux for a PR, plus macOS and Windows
# on main, and the bundled-Flow qualification contract) and desktop-build.yml
Expand Down Expand Up @@ -109,6 +115,7 @@ jobs:
desktop: ${{ steps.classify.outputs.desktop }}
tray: ${{ steps.classify.outputs.tray }}
program_graph: ${{ steps.classify.outputs.program_graph }}
seal_view: ${{ steps.classify.outputs.seal_view }}
weight: ${{ steps.classify.outputs.weight }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -165,6 +172,8 @@ jobs:
tray="$(hit "^packages/tray/|$wf")"
# The program map checks its schema against Flow's copy.
program_graph="$(hit "^packages/program-graph/|^packages/flow/schemas/program-graph-v2\.json$|^\.github/workflows/release-program-graph\.yml$|$wf")"
# The Seal viewer inlines the program map's source.
seal_view="$(hit "^packages/(seal-view|program-graph)/|^openadapt/seal_view/|^tests/test_seal_view\.py$|^tests/fixtures/seal_view/|$wf")"
# Any package's metadata, or the launcher's, can change what an
# install pulls in.
weight="$(hit "^packages/|^pyproject\.toml$|^openadapt/|^hatch_build\.py$|^scripts/install_weight/|$wf")"
Expand All @@ -177,6 +186,7 @@ jobs:
echo "desktop=$desktop"
echo "tray=$tray"
echo "program_graph=$program_graph"
echo "seal_view=$seal_view"
echo "weight=$weight"
} | tee -a "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -840,6 +850,56 @@ jobs:
node scripts/vendor.mjs --out "$RUNNER_TEMP/program-graph"
node "$RUNNER_TEMP/program-graph/check.mjs"

seal-view:
name: seal-view (test, viewer build, browser)
needs: scope
if: needs.scope.outputs.seal_view == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "22"
cache: npm
cache-dependency-path: packages/seal-view/package-lock.json
- name: Install locked dependencies
working-directory: packages/seal-view
run: npm ci --ignore-scripts
- name: Unit tests
working-directory: packages/seal-view
run: npm test
- name: Rebuild the viewer; it must match the launcher's copy and its budget
working-directory: packages/seal-view
run: node scripts/build.mjs --check
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install the launcher
run: pip install -e ".[dev]"
- name: Resolve Playwright version
id: pw
run: |
python -c "import importlib.metadata as m; print('version=' + m.version('playwright'))" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.pw.outputs.version }}
- name: Install and launch Playwright browser
timeout-minutes: 12
run: >-
python packages/flow/scripts/install_playwright_browser.py
--attempts 2 --attempt-timeout-seconds 270
- name: The Seal pages in a browser, from file://
env:
OPENADAPT_SEAL_VIEW_BROWSER: "1"
run: pytest -v tests/test_seal_view.py

install-weight:
# Each package is published on its own and must stay cheap to install:
# build every in-tree wheel, install each one alone, and fail on a
Expand Down Expand Up @@ -939,6 +999,7 @@ jobs:
- tray-contract
- tray-desktop-ipc
- program-graph
- seal-view
- install-weight
- capture-core-record
- desktop-release-intent
Expand Down
85 changes: 82 additions & 3 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
openadapt doctor
"""

import json
import os
import platform
import re
Expand Down Expand Up @@ -1210,12 +1211,15 @@ def seal(command_ctx: click.Context) -> None:

A folder is a run folder (or a folder of them, such as the quickstart's)
signed on this computer; each run's report is checked against its
`seal.json`. Also `openadapt seal keys pin` to pin Cloud's Seal keys after
a person confirms them. Every other argument passes to `openadapt-agent
seal` unchanged.
`seal.json`. `openadapt seal view RUN_DIR` writes the run's Seal as one
offline page (`RUN_DIR/seal.html`) and opens it. Also `openadapt seal keys
pin` to pin Cloud's Seal keys after a person confirms them. Every other
argument passes to `openadapt-agent seal` unchanged.
"""
_require_supported_python()
args = list(command_ctx.args)
if args[:1] == ["view"]:
sys.exit(_view_sealed_runs(args[1:]))
if len(args) >= 2 and args[0] == "verify" and Path(args[1]).is_dir():
sys.exit(_verify_sealed_runs(Path(args[1]), args[2:]))
try:
Expand All @@ -1236,6 +1240,81 @@ def seal(command_ctx: click.Context) -> None:
sys.exit(int(agent_main(["seal", *command_ctx.args]) or 0))


def _view_sealed_runs(argv: list[str]) -> int:
"""`openadapt seal view RUN_DIR`: write each sealed run's offline Seal page."""
import argparse

parser = argparse.ArgumentParser(
prog="openadapt seal view",
description=(
"Write a sealed run's Seal as one offline page, RUN_DIR/seal.html, and "
"open it. The page checks the Ed25519 signature over the run's report "
"in the browser, replays the run over its program map, and makes no "
"network requests. A folder of runs (such as the quickstart's) gets "
"one page per sealed run."
),
)
parser.add_argument(
"run_dir", type=Path, help="a sealed run folder, or a folder of them"
)
parser.add_argument(
"--keys",
type=Path,
default=None,
help="the Seal key registry to trust (default: this computer's Seal key)",
)
parser.add_argument(
"--bundle",
type=Path,
default=None,
help="the run's compiled bundle, for the program map (default: found next to the run)",
)
parser.add_argument("--no-open", action="store_true", help="write the page only")
options = parser.parse_args(argv)
folder = options.run_dir
runs = _sealed_run_dirs(folder) if folder.is_dir() else []
if not runs:
click.echo(f"no sealed run in {folder} (no seal.json)", err=True)
return 1
try:
from openadapt.seal_view import SealViewError, open_page, write_seal_page
except ImportError: # pragma: no cover - the viewer ships with the launcher
click.echo("This install has no Seal viewer.", err=True)
return 1
keys = None
if options.keys is not None:
try:
keys = json.loads(options.keys.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
click.echo(f"can't read the key registry {options.keys}: {exc}", err=True)
return 1
pages = []
for run in runs:
try:
page = write_seal_page(
run,
keys=keys,
bundle_dir=options.bundle,
commands=_hint(f"seal verify {_shell_quote(run)}"),
)
except (SealViewError, ImportError) as exc:
click.echo(f" {run.name} no page: {exc}", err=True)
continue
except Exception as exc: # noqa: BLE001 - e.g. no Seal key on this computer
click.echo(f" {run.name} no page: {type(exc).__name__}: {exc}", err=True)
continue
pages.append(page)
click.echo(f"Seal: {page}")
if not pages:
return 1
if not options.no_open:
from openadapt.quickstart.engine import display_available

if display_available():
open_page(pages[0])
return 0 if len(pages) == len(runs) else 1


def _sealed_run_dirs(folder: Path) -> list[Path]:
"""The run folder itself when it is sealed, else its sealed run folders."""
if (folder / "seal.json").is_file():
Expand Down
3 changes: 3 additions & 0 deletions openadapt/quickstart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def build(outcome: Outcome) -> NextSteps:
)
return NextSteps(
verify=verify,
verify_run=lambda run_dir: hint(
f"seal verify {quote(display_path(run_dir))}"
),
record=hint(
f"flow record --backend web --url {YOUR_APP_URL} --out my-task"
),
Expand Down
62 changes: 62 additions & 0 deletions openadapt/quickstart/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ class SealFacts:
sealed: int


@dataclass
class SealPages:
"""The offline Seal pages the finale wrote (``seal.html`` in a run folder)."""

#: The first checked run's page: the one the finale opens.
verified: Optional[Path] = None
#: The stopped run's page, when Act 3 ran.
broken: Optional[Path] = None


def demo_step_label(event: dict[str, Any]) -> Optional[str]:
"""A short label for one recorded action, from what the recorder logged."""

Expand Down Expand Up @@ -610,6 +620,57 @@ def seal(self, runs: list[RunFacts]) -> Optional[SealFacts]:
key_id = str(seal.key_id)
return SealFacts(key_id=key_id, sealed=len(checked))

def seal_pages(
self,
runs: list[RunFacts],
broken: Optional[BreakFacts],
command: Optional[Callable[[Path], str]] = None,
) -> SealPages:
"""Write the Seal pages: the first checked run's and the stopped run's.

The stopped run is sealed here too: its Seal says the report is the
one the run wrote, stop and all. Each page is the offline viewer with
that run's files in it (:mod:`openadapt.seal_view`); nothing leaves
this computer. Engines without offline Seals get no pages.
"""

try:
from openadapt_flow.offline_seal import seal_home, seal_run

from openadapt.seal_view import bundle_graph, write_seal_page
except ImportError:
return SealPages()
program = bundle_graph(self.bundle_dir)
about = {"app": "MockMed, a synthetic clinic app", "job": "Add a triage note"}

def page(run_dir: Path, kicker: str) -> Path:
return write_seal_page(
run_dir,
program=program,
about={**about, "kicker": kicker},
host={
"footer": (
"A run of the program you compiled, on this computer, in "
"MockMed with synthetic data. Signed with this computer's "
"Seal key. This page makes no network requests; nothing "
"was uploaded."
),
},
commands=command(run_dir) if command else None,
)

pages = SealPages()
# Runs finish in any order; the page is run 1's when it checked.
first = min(
(run for run in runs if run.checked), key=lambda r: r.index, default=None
)
if first is not None and (first.run_dir / "seal.json").is_file():
pages.verified = page(first.run_dir, f"Run {first.index} of {len(runs)}")
if broken is not None:
seal_run(broken.run_dir, home=seal_home(), enqueue=False)
pages.broken = page(broken.run_dir, "The run with the failed save")
return pages

def write_page(self, first: RunFacts, broken: BreakFacts) -> Optional[Path]:
"""Write the side-by-side page (``openadapt flow demo``'s page)."""

Expand Down Expand Up @@ -708,6 +769,7 @@ def open_page(page: Path) -> bool:
"FlowEngine",
"RunFacts",
"SealFacts",
"SealPages",
"StepTicker",
"demo_step_label",
"display_available",
Expand Down
Loading
Loading