From 0a28aa296672079b3cbb16fe3a795fc973ab6c16 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 20 Oct 2026 12:35:53 -0400 Subject: [PATCH 1/3] feat(quickstart): end on the person's own Seal, in an offline viewer Move openadapt.ai's Seal page (openadapt-web#587) into a shared package, packages/seal-view (@openadapt/seal-view, MIT): the Seal art, the replay, the tamper demo and every check (offline Seals, run authorizations, Rekor anchors, RFC 3161 timestamps), with the site-specific parts passed in as props. The rings now read any program graph, and fall back to each step's own record checks when a run has no governed requirements (the quickstart's case). The package builds one HTML viewer with Preact, the program map and the styles inlined: 202,139 bytes (65,614 gzipped), budget 262,144. Its CSP allows no requests. The launcher ships it as openadapt/seal_view/viewer.html, and openadapt.seal_view embeds a run's exact files in it. The quickstart now seals the stopped run too, writes run-1/seal.html and run-broken/seal.html, opens the first one when a display is available, and prints "Your Seal: " and then the openadapt.ai/seals line. `openadapt seal view RUN_DIR` writes and opens the page for any sealed run. Tests: tests/test_seal_view.py opens a real quickstart run's pages from file:// in Chromium (valid, tampered, no requests) and snapshots the viewer's size; the new seal-view job in packages.yml runs it with the browser required. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + .github/workflows/packages.yml | 61 + openadapt/cli.py | 85 +- openadapt/quickstart/__init__.py | 3 + openadapt/quickstart/engine.py | 62 + openadapt/quickstart/story.py | 41 +- openadapt/quickstart/view.py | 25 +- openadapt/seal_view/__init__.py | 301 +++ openadapt/seal_view/viewer.html | 40 + packages/seal-view/.gitignore | 2 + packages/seal-view/CHANGELOG.md | 20 + packages/seal-view/LICENSE | 21 + packages/seal-view/README.md | 81 + packages/seal-view/package-lock.json | 1667 +++++++++++++++++ packages/seal-view/package.json | 57 + packages/seal-view/scripts/build.mjs | 112 ++ .../seal-view/src/components/HashLine.jsx | 88 + .../src/components/OpenAdaptMark.jsx | 10 + packages/seal-view/src/components/SealArt.jsx | 376 ++++ .../src/components/SealArt.module.css | 826 ++++++++ .../seal-view/src/components/SealEmblem.jsx | 30 + .../src/components/SealEmblem.module.css | 133 ++ packages/seal-view/src/components/SealMap.jsx | 70 + .../seal-view/src/components/SealPage.jsx | 1023 ++++++++++ .../src/components/SealPage.module.css | 1494 +++++++++++++++ packages/seal-view/src/index.js | 24 + packages/seal-view/src/lib/ed25519ph.mjs | 161 ++ packages/seal-view/src/lib/localSeal.mjs | 292 +++ .../seal-view/src/lib/programStepNames.mjs | 53 + packages/seal-view/src/lib/receiptCheck.mjs | 184 ++ packages/seal-view/src/lib/rekorLive.mjs | 44 + .../seal-view/src/lib/runAuthorization.mjs | 308 +++ packages/seal-view/src/lib/sealAnchor.mjs | 375 ++++ packages/seal-view/src/lib/sealArt.mjs | 380 ++++ packages/seal-view/src/lib/sealDates.mjs | 11 + packages/seal-view/src/lib/sealFiles.mjs | 36 + packages/seal-view/src/lib/sealProps.mjs | 82 + packages/seal-view/src/lib/sealReplay.mjs | 96 + packages/seal-view/src/lib/sealRings.mjs | 259 +++ packages/seal-view/src/lib/sealSummary.mjs | 80 + packages/seal-view/src/lib/sealTamper.mjs | 62 + packages/seal-view/src/lib/sealWitnesses.mjs | 46 + packages/seal-view/src/lib/sshsig.mjs | 193 ++ packages/seal-view/src/lib/tsaToken.mjs | 300 +++ packages/seal-view/src/viewer/template.html | 17 + packages/seal-view/src/viewer/viewer.jsx | 92 + packages/seal-view/test/seal.test.mjs | 104 + packages/seal-view/vitest.config.js | 5 + tests/fixtures/seal_view/README.md | 11 + tests/fixtures/seal_view/keys.json | 7 + tests/fixtures/seal_view/program.json | 557 ++++++ tests/fixtures/seal_view/run-1/report.json | 943 ++++++++++ tests/fixtures/seal_view/run-1/seal.json | 13 + .../fixtures/seal_view/run-broken/report.json | 942 ++++++++++ tests/fixtures/seal_view/run-broken/seal.json | 13 + tests/snapshots/quickstart_plain.snapshot | 4 +- tests/snapshots/seal_viewer_size.json | 5 + tests/test_quickstart.py | 83 +- tests/test_seal_view.py | 330 ++++ 59 files changed, 12732 insertions(+), 10 deletions(-) create mode 100644 .gitattributes create mode 100644 openadapt/seal_view/__init__.py create mode 100644 openadapt/seal_view/viewer.html create mode 100644 packages/seal-view/.gitignore create mode 100644 packages/seal-view/CHANGELOG.md create mode 100644 packages/seal-view/LICENSE create mode 100644 packages/seal-view/README.md create mode 100644 packages/seal-view/package-lock.json create mode 100644 packages/seal-view/package.json create mode 100644 packages/seal-view/scripts/build.mjs create mode 100644 packages/seal-view/src/components/HashLine.jsx create mode 100644 packages/seal-view/src/components/OpenAdaptMark.jsx create mode 100644 packages/seal-view/src/components/SealArt.jsx create mode 100644 packages/seal-view/src/components/SealArt.module.css create mode 100644 packages/seal-view/src/components/SealEmblem.jsx create mode 100644 packages/seal-view/src/components/SealEmblem.module.css create mode 100644 packages/seal-view/src/components/SealMap.jsx create mode 100644 packages/seal-view/src/components/SealPage.jsx create mode 100644 packages/seal-view/src/components/SealPage.module.css create mode 100644 packages/seal-view/src/index.js create mode 100644 packages/seal-view/src/lib/ed25519ph.mjs create mode 100644 packages/seal-view/src/lib/localSeal.mjs create mode 100644 packages/seal-view/src/lib/programStepNames.mjs create mode 100644 packages/seal-view/src/lib/receiptCheck.mjs create mode 100644 packages/seal-view/src/lib/rekorLive.mjs create mode 100644 packages/seal-view/src/lib/runAuthorization.mjs create mode 100644 packages/seal-view/src/lib/sealAnchor.mjs create mode 100644 packages/seal-view/src/lib/sealArt.mjs create mode 100644 packages/seal-view/src/lib/sealDates.mjs create mode 100644 packages/seal-view/src/lib/sealFiles.mjs create mode 100644 packages/seal-view/src/lib/sealProps.mjs create mode 100644 packages/seal-view/src/lib/sealReplay.mjs create mode 100644 packages/seal-view/src/lib/sealRings.mjs create mode 100644 packages/seal-view/src/lib/sealSummary.mjs create mode 100644 packages/seal-view/src/lib/sealTamper.mjs create mode 100644 packages/seal-view/src/lib/sealWitnesses.mjs create mode 100644 packages/seal-view/src/lib/sshsig.mjs create mode 100644 packages/seal-view/src/lib/tsaToken.mjs create mode 100644 packages/seal-view/src/viewer/template.html create mode 100644 packages/seal-view/src/viewer/viewer.jsx create mode 100644 packages/seal-view/test/seal.test.mjs create mode 100644 packages/seal-view/vitest.config.js create mode 100644 tests/fixtures/seal_view/README.md create mode 100644 tests/fixtures/seal_view/keys.json create mode 100644 tests/fixtures/seal_view/program.json create mode 100644 tests/fixtures/seal_view/run-1/report.json create mode 100644 tests/fixtures/seal_view/run-1/seal.json create mode 100644 tests/fixtures/seal_view/run-broken/report.json create mode 100644 tests/fixtures/seal_view/run-broken/seal.json create mode 100644 tests/snapshots/seal_viewer_size.json create mode 100644 tests/test_seal_view.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..0e052e039 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Signed files: their exact bytes are what the Seals cover. +tests/fixtures/seal_view/** -text diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 5018e7046..fc038e9d1 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -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 @@ -75,6 +81,7 @@ jobs: privacy: ${{ steps.classify.outputs.privacy }} desktop: ${{ steps.classify.outputs.desktop }} 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 @@ -121,6 +128,8 @@ jobs: desktop="$(hit "^packages/desktop/|^\.github/workflows/desktop-[a-z-]+\.yml$|$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")" @@ -132,6 +141,7 @@ jobs: echo "privacy=$privacy" echo "desktop=$desktop" echo "program_graph=$program_graph" + echo "seal_view=$seal_view" echo "weight=$weight" } | tee -a "$GITHUB_OUTPUT" @@ -711,6 +721,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 @@ -807,6 +867,7 @@ jobs: - desktop-test - desktop-build - program-graph + - seal-view - install-weight - capture-core-record - desktop-release-intent diff --git a/openadapt/cli.py b/openadapt/cli.py index 9f00b71f5..17556ba16 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -24,6 +24,7 @@ openadapt doctor """ +import json import os import platform import re @@ -1198,12 +1199,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: @@ -1224,6 +1228,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(): diff --git a/openadapt/quickstart/__init__.py b/openadapt/quickstart/__init__.py index 712ad4536..cd84f8f2a 100644 --- a/openadapt/quickstart/__init__.py +++ b/openadapt/quickstart/__init__.py @@ -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" ), diff --git a/openadapt/quickstart/engine.py b/openadapt/quickstart/engine.py index c2d4e8cd2..1f731cf9e 100644 --- a/openadapt/quickstart/engine.py +++ b/openadapt/quickstart/engine.py @@ -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.""" @@ -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).""" @@ -708,6 +769,7 @@ def open_page(page: Path) -> bool: "FlowEngine", "RunFacts", "SealFacts", + "SealPages", "StepTicker", "demo_step_label", "display_available", diff --git a/openadapt/quickstart/story.py b/openadapt/quickstart/story.py index 59d2597d3..b4fdf2f7a 100644 --- a/openadapt/quickstart/story.py +++ b/openadapt/quickstart/story.py @@ -20,10 +20,13 @@ DemoStep, RunFacts, SealFacts, + SealPages, ) #: The placeholder app address in the "try it on your own app" command. YOUR_APP_URL = "https://your-app.example" +#: Where the finale points for Seals of real runs, after the person's own. +REAL_SEALS_URL = "https://openadapt.ai/seals" def display_path(path: Path, cwd: Optional[Path] = None) -> Path: @@ -75,6 +78,13 @@ def read_screen(self, broken: BreakFacts) -> Optional[str]: ... def seal(self, runs: list[RunFacts]) -> Optional[SealFacts]: ... + def seal_pages( + self, + runs: list[RunFacts], + broken: Optional[BreakFacts], + command: Optional[Callable[[Path], str]] = None, + ) -> SealPages: ... + def write_page(self, first: RunFacts, broken: BreakFacts) -> Optional[Path]: ... @@ -90,6 +100,8 @@ class NextSteps: #: Under ``uvx`` only: a warning when the shell's ``openadapt`` is another #: version, which the plain commands would reach instead. shadow: Optional[str] = None + #: The command that checks one run folder, for its Seal page. + verify_run: Optional[Callable[[Path], str]] = None @dataclass @@ -105,6 +117,9 @@ class Outcome: act2_seconds: float = 0.0 broken: Optional[BreakFacts] = None seal: Optional[SealFacts] = None + #: The Seal pages (``seal.html``) of the first checked run and the stopped run. + seal_pages: SealPages = field(default_factory=SealPages) + seal_page_opened: bool = False page: Optional[Path] = None page_opened: bool = False total_seconds: float = 0.0 @@ -268,15 +283,34 @@ def finished(run: RunFacts) -> None: outcome.seal = sealing.result() if paging is not None: outcome.page = paging.result() - if outcome.page is not None and open_page is not None: - outcome.page_opened = bool(open_page(outcome.page)) finally: engine.__exit__(None, None, None) + steps = next_steps(outcome) + if outcome.seal is not None: + _write_seal_pages(engine, outcome, steps) + # The person's own Seal opens; the side-by-side page stays a path. + page = outcome.seal_pages.verified + if page is not None and open_page is not None: + outcome.seal_page_opened = bool(open_page(page)) outcome.total_seconds = clock() - started - paused - view.finale(outcome, next_steps(outcome)) + view.finale(outcome, steps) return outcome +def _write_seal_pages(engine: Engine, outcome: Outcome, steps: NextSteps) -> None: + """Write the Seal pages; a page that can't be written leaves no Seal line.""" + + try: + outcome.seal_pages = engine.seal_pages( + list(outcome.runs), outcome.broken, steps.verify_run + ) + except Exception: # noqa: BLE001 - the runs and their Seals stand without a page + outcome.seal_pages = SealPages() + return + if outcome.seal is not None and outcome.seal_pages.broken is not None: + outcome.seal = SealFacts(outcome.seal.key_id, outcome.seal.sealed + 1) + + def _in_background(work: Callable[[], Any]) -> Any: """Start ``work`` on a thread; ``.result()`` waits and re-raises.""" @@ -312,6 +346,7 @@ def _act1_subtitle(visible: bool) -> str: "Engine", "NextSteps", "Outcome", + "REAL_SEALS_URL", "View", "run_story", ] diff --git a/openadapt/quickstart/view.py b/openadapt/quickstart/view.py index 4a6e399da..0c297640d 100644 --- a/openadapt/quickstart/view.py +++ b/openadapt/quickstart/view.py @@ -16,7 +16,12 @@ from typing import IO, Any, Iterator, Mapping, Optional from openadapt.quickstart.engine import BreakFacts, DemoStep, RunFacts -from openadapt.quickstart.story import NextSteps, Outcome, display_path +from openadapt.quickstart.story import ( + REAL_SEALS_URL, + NextSteps, + Outcome, + display_path, +) #: Width of the step and note column, so times line up. _LABEL_WIDTH = 40 @@ -154,6 +159,18 @@ def details_line(outcome: Outcome) -> str: return line +def seal_lines(outcome: Outcome) -> list[tuple[str, str]]: + """The person's own Seal page, then where the Seals of real runs are.""" + + page = outcome.seal_pages.verified + if page is None: + return [] + return [ + ("Your Seal", str(display_path(page))), + ("See real Seals from OpenEMR runs", REAL_SEALS_URL), + ] + + def next_step_lines(steps: NextSteps) -> list[str]: lines = [ f"Try it on your own app: {steps.record}", @@ -290,6 +307,8 @@ def finale(self, outcome: Outcome, steps: NextSteps) -> None: self._say("Receipt") for name, value in receipt_rows(outcome, self.marks): self._say(f" {name:<9} {value}") + for name, value in seal_lines(outcome): + self._say(f"{name}: {value}") if steps.verify: self._say(f"Check it: {steps.verify}") self._say(details_line(outcome)) @@ -503,6 +522,9 @@ def finale(self, outcome: Outcome, steps: NextSteps) -> None: self.console.print( Panel(table, title="Receipt", title_align="left", expand=False) ) + for name, value in seal_lines(outcome): + style = "bold green" if name == "Your Seal" else "cyan" + self.console.print(f"{name}: [{style}]{_escape(value)}[/]") if steps.verify: self.console.print(f"[dim]Check it:[/] [bold]{_escape(steps.verify)}[/]") self.console.print(f"[dim]{_escape(details_line(outcome))}[/]") @@ -535,5 +557,6 @@ def _wrap(text: str, width: int) -> list[str]: "RichView", "aha_line", "make_view", + "seal_lines", "wants_plain", ] diff --git a/openadapt/seal_view/__init__.py b/openadapt/seal_view/__init__.py new file mode 100644 index 000000000..65ec6ec66 --- /dev/null +++ b/openadapt/seal_view/__init__.py @@ -0,0 +1,301 @@ +"""Write a sealed run's Seal as one offline HTML page: ``RUN_DIR/seal.html``. + +The page is ``viewer.html`` (built from ``packages/seal-view``; React, the +program map and the Seal inlined) with the run's own files embedded in it: +the exact bytes of ``report.json``, the exact text of ``seal.json`` (and of +``seal.anchor.json`` and ``seal.tsa.json`` when the run has them), the Seal +keys the reader trusts, and the program graph of the run's bundle. In the +browser it checks the Ed25519 signature over the embedded report, draws the +Seal from it, replays the run over the step table and the program map, and +lets the reader tamper with a copy. It makes no network requests (its +Content-Security-Policy allows none), so it works from ``file://`` offline, +and nothing is uploaded. +""" + +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import sys +import webbrowser +from importlib import resources +from pathlib import Path +from typing import Any, Mapping, Optional, Sequence + +#: The file the page is written to, next to the run's ``report.json``. +PAGE_NAME = "seal.html" +#: Where the run's files go in the viewer. +DATA_SLOT = "" +#: The run's files a reader can open from the page, when present. +RUN_FILES = ("report.json", "seal.json", "seal.anchor.json", "seal.tsa.json") + + +class SealViewError(RuntimeError): + """The run can't be shown: no Seal, no report, or no viewer.""" + + +def viewer_template() -> str: + """The viewer as shipped in this package.""" + + try: + return ( + resources.files("openadapt.seal_view") + .joinpath("viewer.html") + .read_text(encoding="utf-8") + ) + except (FileNotFoundError, ModuleNotFoundError) as exc: # pragma: no cover + raise SealViewError("this install has no Seal viewer (viewer.html)") from exc + + +def _text(path: Path) -> Optional[str]: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + + +def _digest_of(spec: Mapping[str, Any]) -> Optional[str]: + provenance = (spec.get("bundle") or {}).get("provenance") or {} + return provenance.get("content_digest") + + +def bundle_graph(bundle_dir: Path) -> dict[str, Any]: + """The program graph of a compiled bundle, as the program map reads it. + + The remote-safe projection: step kinds, checks and stop rules, with no + recorded text or images. The page names each step from the run's own + report instead. + """ + + from openadapt_flow.ir import Workflow + from openadapt_flow.visualize import ( + PresentationProfile, + build_program_graph, + project_program_graph, + ) + + full = build_program_graph(Workflow.load(Path(bundle_dir))) + spec = json.loads( + project_program_graph( + full, PresentationProfile("remote-safe") + ).model_dump_json() + ) + # The projection drops the bundle's content digest; the page needs it to + # match the program to the report, which carries the same digest. + bundle = spec.setdefault("bundle", {}) + provenance = bundle.get("provenance") or {} + provenance["content_digest"] = full.bundle.provenance.content_digest + bundle["provenance"] = provenance + return spec + + +def _bundle_candidates(run_dir: Path) -> list[Path]: + # A run folder of the quickstart or the tutorial sits next to its bundle. + return [run_dir / "bundle", run_dir.parent / "bundle"] + + +def find_program( + run_dir: Path, report: Mapping[str, Any], bundle_dir: Optional[Path] = None +) -> dict[str, Any]: + """The run's program graph: from ``bundle_dir``, else a bundle next to the run. + + Only a bundle whose content digest is the report's counts. Without one, + the program is the run's own steps in order, read from the report. + """ + + digest = report.get("bundle_content_digest") + candidates = [Path(bundle_dir)] if bundle_dir else _bundle_candidates(run_dir) + for candidate in candidates: + if not (candidate / "workflow.json").is_file(): + continue + try: + spec = bundle_graph(candidate) + except Exception: # noqa: BLE001 - another candidate, or the report's steps + continue + if _digest_of(spec) == digest: + return spec + if bundle_dir: + raise SealViewError( + f"{candidate} is not the program this run ran " + f"(bundle digest {_digest_of(spec)}, the report says {digest})" + ) + return report_graph(report) + + +def report_graph(report: Mapping[str, Any]) -> dict[str, Any]: + """A program graph of the run's own steps, in order, when no bundle is at hand.""" + + results = [r for r in report.get("results") or [] if isinstance(r, Mapping)] + nodes: list[dict[str, Any]] = [] + for index, result in enumerate(results): + intent = str(result.get("intent") or "") + nodes.append( + { + "id": str(result.get("step_id") or f"step_{index:03d}"), + "index": index, + "kind": "action", + "title": intent or f"Step {index + 1}", + "action": intent.split(" ", 1)[0] if intent else "click", + "identity": None, + "effects": [], + "postconditions": [], + "halts": [], + "badges": [], + } + ) + nodes.append( + { + "id": "__end__", + "index": len(nodes), + "kind": "terminal", + "title": "End of the run's steps", + "outcome": "success", + "effects": [], + "postconditions": [], + "halts": [], + "badges": [], + } + ) + edges = [ + {"source": a["id"], "target": b["id"], "kind": "sequence", "label": ""} + for a, b in zip(nodes, nodes[1:]) + ] + return { + "spec_version": 2, + "bundle": { + "name": str(report.get("workflow_name") or "run"), + "provenance": {"content_digest": report.get("bundle_content_digest")}, + }, + "nodes": nodes, + "edges": edges, + } + + +def local_keys(home: Optional[Path] = None) -> list[dict[str, str]]: + """This computer's Seal key, as a one-key registry.""" + + from openadapt_flow.offline_seal import public_key_entry, seal_home + + return [public_key_entry(home or seal_home())] + + +def page_data( + run_dir: Path, + *, + keys: Sequence[Mapping[str, Any]] | Mapping[str, Any], + program: Mapping[str, Any], + about: Optional[Mapping[str, Any]] = None, + host: Optional[Mapping[str, Any]] = None, + commands: Optional[str] = None, +) -> dict[str, Any]: + """What the viewer reads: the run's files exactly as they are on disk.""" + + run_dir = Path(run_dir) + report = (run_dir / "report.json").read_bytes() + seal = _text(run_dir / "seal.json") + if seal is None: + raise SealViewError(f"{run_dir} has no seal.json") + present = [name for name in RUN_FILES if (run_dir / name).is_file()] + return { + "report_b64": base64.b64encode(report).decode("ascii"), + "seal_text": seal, + "anchor_text": _text(run_dir / "seal.anchor.json"), + "tsa_text": _text(run_dir / "seal.tsa.json"), + "keys": keys, + "program": {"spec": dict(program)}, + "about": dict(about or {}), + "host": {"files": present, **dict(host or {})}, + "commands": commands, + } + + +def render_page(data: Mapping[str, Any], template: Optional[str] = None) -> str: + """The viewer with ``data`` in its slot.""" + + template = template if template is not None else viewer_template() + if DATA_SLOT not in template: + raise SealViewError("the Seal viewer has no slot for the run's files") + # JSON in a script element: escape "<" so no "" can end it early. + payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) + payload = payload.replace("<", "\\u003c") + block = f'' + return template.replace(DATA_SLOT, block, 1) + + +def write_seal_page( + run_dir: Path, + *, + keys: Optional[Sequence[Mapping[str, Any]] | Mapping[str, Any]] = None, + bundle_dir: Optional[Path] = None, + program: Optional[Mapping[str, Any]] = None, + about: Optional[Mapping[str, Any]] = None, + host: Optional[Mapping[str, Any]] = None, + commands: Optional[str] = None, + out: Optional[Path] = None, +) -> Path: + """Write ``run_dir/seal.html`` (or ``out``) and return its path. + + ``keys`` is the registry the page trusts; by default this computer's Seal + key, which is the key the quickstart and local runs sign with. ``program`` + is the run's program graph when the caller has it (:func:`bundle_graph`); + otherwise it is found from ``bundle_dir`` or next to the run. + """ + + run_dir = Path(run_dir) + report_path = run_dir / "report.json" + if not (run_dir / "seal.json").is_file(): + raise SealViewError(f"{run_dir} is not a sealed run (no seal.json)") + try: + report = json.loads(report_path.read_bytes()) + except (OSError, ValueError) as exc: + raise SealViewError(f"{report_path} can't be read") from exc + if keys is None: + keys = local_keys() + host = {"keyWords": "this computer's Seal key", **dict(host or {})} + if program is None or _digest_of(program) != report.get("bundle_content_digest"): + program = find_program(run_dir, report, bundle_dir) + data = page_data( + run_dir, + keys=keys, + program=program, + about=about, + host={"unchanged": "The files on disk are unchanged.", **dict(host or {})}, + commands=commands, + ) + target = Path(out) if out else run_dir / PAGE_NAME + target.write_text(render_page(data), encoding="utf-8") + return target + + +def open_page(page: Path) -> bool: + """Open ``page`` in the default browser; False when nothing could open it.""" + + uri = Path(page).resolve().as_uri() + try: + if sys.platform == "darwin": + return ( + subprocess.run(["open", uri], check=False, timeout=10).returncode == 0 + ) + if os.name == "nt": # pragma: no cover - Windows + os.startfile(uri) # type: ignore[attr-defined] # noqa: S606 + return True + return bool(webbrowser.open(uri)) + except Exception: # noqa: BLE001 - the path is printed either way + return False + + +__all__ = [ + "PAGE_NAME", + "SealViewError", + "bundle_graph", + "find_program", + "local_keys", + "open_page", + "page_data", + "render_page", + "report_graph", + "viewer_template", + "write_seal_page", +] diff --git a/openadapt/seal_view/viewer.html b/openadapt/seal_view/viewer.html new file mode 100644 index 000000000..b135c42f7 --- /dev/null +++ b/openadapt/seal_view/viewer.html @@ -0,0 +1,40 @@ + + + + + + + + +OpenAdapt Seal + + + +
+ + + + diff --git a/packages/seal-view/.gitignore b/packages/seal-view/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/packages/seal-view/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/seal-view/CHANGELOG.md b/packages/seal-view/CHANGELOG.md new file mode 100644 index 000000000..6e3d167b2 --- /dev/null +++ b/packages/seal-view/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## v0.1.0 (unreleased) + +- `SealPage`: one run's Seal, drawn from its signed report (step ring, screen + checks, read-backs, right-record checks and an iris drawn from the + digests), checked in the browser (Ed25519 over the exact `report.json`), + replayed over the step table and the program map, with a tamper demo. The + host's own parts (links, footer, live checks, how files load) come in + `host`. +- `SealArt`, `SealEmblem`, `SealMap`, `HashLine`, `OpenAdaptMark`. +- The checks: offline Seals (`openadapt.local-seal/v1`), run authorizations, + Sigstore Rekor anchors and RFC 3161 timestamps, the same rules and refusal + codes as `openadapt-flow local-seal verify`. A pure-JavaScript Ed25519 + check runs when WebCrypto has no Ed25519. +- `sealPageProps`: the page's props from a run's files as data, in the + browser or in Node. +- `dist/seal-viewer.html`: the offline viewer, one HTML file with no network + requests. The launcher ships it as `openadapt/seal_view/viewer.html`. +- Extracted from openadapt.ai's Seal page (OpenAdaptAI/openadapt-web#587). diff --git a/packages/seal-view/LICENSE b/packages/seal-view/LICENSE new file mode 100644 index 000000000..f9273392b --- /dev/null +++ b/packages/seal-view/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenAdapt AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/seal-view/README.md b/packages/seal-view/README.md new file mode 100644 index 000000000..8596cd8ba --- /dev/null +++ b/packages/seal-view/README.md @@ -0,0 +1,81 @@ +# @openadapt/seal-view + +One run's Seal as a React component, and an offline viewer built from it. Give +it a run's `report.json`, its `seal.json` and the Seal keys you trust, and it +checks the Ed25519 signature over the exact report in the browser, draws the +Seal from what the report recorded, plays the run back over the step table and +the program map, and lets a reader change a copy of either file to watch the +check fail. + +openadapt.ai's Seal pages and `openadapt quickstart` use the same code. + +## What it shows + +- **The Seal.** An arc per program step, a tick per screen check, a dot per + read of the saved record, a short arc per right-record check, and an iris + drawn from the report's SHA-256 and the Seal's own digest. A change to + either file changes the iris. +- **The check.** The report's SHA-256 computed in the browser against the one + the Seal signed, then the signature against the pinned key. A failure names + its reason and code (`subject_mismatch`, `bad_signature`, `unknown_key` and + the others `openadapt-flow local-seal verify` uses). +- **The replay.** One frame index drives the ring, the step line, the step + table, the read-back table and the program map + ([`@openadapt/program-graph`](../program-graph/README.md)). +- **Tamper with it.** Change the outcome or the AI call count in a copy of the + report, or the outcome in a copy of the Seal, and check the copy. +- **Witnesses.** A Sigstore Rekor entry (`seal.anchor.json`) and an RFC 3161 + timestamp (`seal.tsa.json`) are checked offline and shown only when the run + has them. + +## Use it + +```jsx +import { SealPage, sealPageProps } from '@openadapt/seal-view' +import '@openadapt/program-graph/styles.css' +import '@openadapt/seal-view/styles.css' + +const files = { report: reportBytes, sealText } // exact bytes and text +const { page } = await sealPageProps({ files, keys, program: { spec } }) + + files, live: false }} /> +``` + +`spec` is the program graph of the run's bundle (`openadapt-flow visualize +--format json`). `host` holds the parts that differ between hosts: `Link`, +`links`, `copyHref`, `kicker`, `footer`, `keyWords`, `live` (Rekor's live entry +and GitHub's key list, default on) and `loadFiles` (default: fetch the paths in +`seal.files`). `sealPageMeta(page)` gives the title and description for the +host's ``. + +## The offline viewer + +`node scripts/build.mjs` writes `dist/seal-viewer.html` and copies it to +`openadapt/seal_view/viewer.html`, which the launcher ships. It is one file: +the viewer and the program map built with Preact's React-compatible runtime, +the styles inlined, and a ` css).replace('/*JS*/', () => js) +} + +const lib = await library() +const html = await viewer() +const size = Buffer.byteLength(html) +if (size > VIEWER_BUDGET) { + console.error(`seal-viewer.html is ${size} bytes, over its ${VIEWER_BUDGET}-byte budget`) + process.exit(1) +} +if (check) { + const shipped = fs.existsSync(LAUNCHER_COPY) ? fs.readFileSync(LAUNCHER_COPY, 'utf8') : null + if (shipped !== html) { + console.error(`${path.relative(repo, LAUNCHER_COPY)} is not this build; run: node packages/seal-view/scripts/build.mjs`) + process.exit(1) + } + console.log(`seal-viewer.html matches the launcher's copy, ${size} bytes`) +} else { + fs.rmSync(dist, { recursive: true, force: true }) + fs.mkdirSync(dist, { recursive: true }) + fs.writeFileSync(path.join(dist, 'index.js'), lib.js) + fs.writeFileSync(path.join(dist, 'styles.css'), lib.css) + fs.writeFileSync(path.join(dist, 'seal-viewer.html'), html) + fs.mkdirSync(path.dirname(LAUNCHER_COPY), { recursive: true }) + fs.writeFileSync(LAUNCHER_COPY, html) + console.log(`built dist/ and ${path.relative(repo, LAUNCHER_COPY)}: seal-viewer.html ${size} bytes`) +} diff --git a/packages/seal-view/src/components/HashLine.jsx b/packages/seal-view/src/components/HashLine.jsx new file mode 100644 index 000000000..b653b95e2 --- /dev/null +++ b/packages/seal-view/src/components/HashLine.jsx @@ -0,0 +1,88 @@ +// The report's SHA-256 as the browser computes it. While the check runs, the +// line starts as the Seal's signature (base64) and resolves, left to right, +// into the digest of the file actually checked. Characters that differ from +// the digest the Seal signed are marked, so a changed copy shows exactly where. +import { useEffect, useRef, useState } from 'react' +import s from './SealPage.module.css' + +const HEX = '0123456789abcdef' +const LEN = 64 +const RESOLVE_MS = 1100 + +export default function HashLine({ label, from, to, against, running, still }) { + const [text, setText] = useState(() => (from || '').slice(0, LEN)) + const [done, setDone] = useState(false) + const toRef = useRef(to) + toRef.current = to + + useEffect(() => { + if (!running) { + if (to) { + setText(to) + setDone(true) + } + return undefined + } + setDone(false) + if (still) { + if (to) { + setText(to) + setDone(true) + } + return undefined + } + const seed = (from || '').slice(0, LEN).padEnd(LEN, '0') + let frame = 0 + let last = 0 + let resolveStart = null + const tick = (now) => { + const target = toRef.current + if (target && resolveStart == null) resolveStart = now + const k = resolveStart == null ? 0 : Math.min(1, (now - resolveStart) / RESOLVE_MS) + if (now - last > 45 || k === 1) { + last = now + const fixed = Math.floor(k * LEN) + let out = '' + for (let i = 0; i < LEN; i += 1) { + if (target && i < fixed) out += target[i] + else if (i < fixed + 6 || !target) out += Math.random() < 0.5 ? seed[(i * 7 + Math.floor(now / 60)) % LEN] : HEX[Math.floor(Math.random() * 16)] + else out += seed[i] + } + setText(out) + } + if (k < 1) frame = requestAnimationFrame(tick) + else setDone(true) + } + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [running, still]) + + // A digest that arrives after the sweep (or with no animation) shows at once. + useEffect(() => { + if (!running && to) { + setText(to) + setDone(true) + } + }, [to, running]) + + const final = done && to + const matches = final && to === against + return ( +
+ + {label} + {final ? {matches ? ' · matches' : ' · differs'} : null} + + + {final + ? [...to].map((ch, i) => ( + + {ch} + + )) + : text} + +
+ ) +} diff --git a/packages/seal-view/src/components/OpenAdaptMark.jsx b/packages/seal-view/src/components/OpenAdaptMark.jsx new file mode 100644 index 000000000..819de94df --- /dev/null +++ b/packages/seal-view/src/components/OpenAdaptMark.jsx @@ -0,0 +1,10 @@ +// The OpenAdapt mark (the robot head from public/favicon.svg), in the current colour. +export default function OpenAdaptMark({ className }) { + return ( + + ) +} diff --git a/packages/seal-view/src/components/SealArt.jsx b/packages/seal-view/src/components/SealArt.jsx new file mode 100644 index 000000000..725541050 --- /dev/null +++ b/packages/seal-view/src/components/SealArt.jsx @@ -0,0 +1,376 @@ +// The Seal of one run, drawn from its report (lib/sealRings.mjs, geometry in +// lib/sealArt.mjs): an arc per program step, a tick per screen check, a dot per +// read of the saved record, a short arc per right-record check, and a core +// whose fine pattern is drawn from the report's SHA-256. +// +// `phase` drives it: 'build' assembles the marks in the order the run made +// them (each mark's delay is its time in the run, compressed to `buildMs`), +// 'verify' runs a sweep over every mark, 'valid' closes the outer ring, +// 'broken' fractures it, 'rest' just shows it. `still` (reduced motion) shows +// the finished Seal with no movement at all. The full Seal has a focusable +// target per step (arrow keys move between them); the emblem is a picture. +import { useEffect, useId, useMemo, useRef } from 'react' +import { C, R, iris, polar, sealArt } from '../lib/sealArt.mjs' +import s from './SealArt.module.css' + +const DELAY_CAP = 0.94 + +export function stepLabel(step, total) { + const what = { + passed: 'passed', + stopped: 'the run stopped here', + not_reached: 'not reached', + }[step.status] + return `Step ${step.i + 1} of ${total}: ${step.name}, ${what}` +} + +export default function SealArt({ + rings, + tone, + digest, + phase = 'rest', + still = false, + variant = 'full', + buildMs = 3200, + active = null, + focusIndex = 0, + onActive, + onPick, + onKey, + ringFocus = null, + witnesses = null, + lit = null, + spot = null, + label, +}) { + const art = useMemo(() => sealArt(rings), [rings]) + const full = variant === 'full' + const broken = phase === 'broken' + const irisLayers = useMemo(() => (full ? iris(digest, { radius: 100, disorder: broken ? 1 : 0 }) : null), [digest, broken, full]) + const anchor = rings.anchor + const anchorPulse = Boolean(lit && anchor && lit.step === anchor.step && ['done', 'stop', 'readback'].includes(lit.stage)) + const raw = useId() + const uid = `s${raw.replace(/[^a-zA-Z0-9]/g, '')}` + const runMs = Math.max(1, rings.runMs) + const delay = (t) => + t == null ? 0 : Math.round((Math.min(t / runMs, 1) * DELAY_CAP) * buildMs) + const on = (step) => (active != null && step === active ? '1' : undefined) + // During a replay every piece says whether the current frame has reached it. + const L = (ok) => (lit ? (ok ? '1' : '0') : undefined) + const pieceStyle = (p, extra = {}) => ({ + '--d': `${delay(p.t)}ms`, + '--a': p.at, + '--bx': `${p.bx}px`, + '--by': `${p.by}px`, + '--br': `${p.br}deg`, + ...extra, + }) + + return ( +
+