From 0818f7932f8fc63e0f38ee39c3de5d783f5c5428 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 25 Oct 2026 10:08:02 -0400 Subject: [PATCH] feat(quickstart): end on the person's own Seal, in an offline viewer `openadapt quickstart` now ends by opening the Seal of the run the person just did: one offline page built from the same code as openadapt.ai's Seal pages, with nothing uploaded. The same code as #1437 (reviewed there), squashed onto main, with the verifier's test key derived at run time instead of committed. - packages/seal-view (@openadapt/seal-view, MIT): openadapt.ai's Seal page (openadapt-web#587 through 4da61c59): the step ring, the read-back table, the iris drawn from the digests, the replay synced with the step table and the full program workbench (with a Seal card in its inspector), the tamper demo, and every check (offline Seals, run authorizations, Rekor anchors, RFC 3161 timestamps with their .tsr file). The site's parts come in as props; the rings read any program graph and fall back to each step's own record checks; a pure-JS Ed25519 check covers browsers without WebCrypto Ed25519. - The offline viewer: one HTML file (Preact, the program map and styles inlined), 212,474 bytes (68,660 gzipped), budget 262,144, CSP allowing no requests. The launcher ships it as openadapt/seal_view/viewer.html; openadapt.seal_view embeds a run's exact files in it. - Quickstart finale: seals the stopped run too, writes run-1/seal.html and run-broken/seal.html, opens the first when a display is available, and prints "Your Seal: " then the openadapt.ai/seals line. The page says it was checked against your computer's Seal key. - `openadapt seal view RUN_DIR [--keys FILE] [--bundle DIR] [--no-open]`. - Tests: a real quickstart run's pages from file:// in Chromium (valid, tampered, no requests), the viewer's size snapshot, RFC 8032 vectors (public keys and signatures only), a Seal signed with a key derived from sha256("openadapt-test-seal"), and a published OpenEMR Seal's Rekor entry and timestamp; a seal-view job in packages.yml. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + .github/workflows/packages.yml | 61 + CAPABILITIES.md | 1 + llms-full.txt | 1 + openadapt/cli.py | 85 +- openadapt/quickstart/__init__.py | 3 + openadapt/quickstart/engine.py | 72 + openadapt/quickstart/story.py | 51 +- openadapt/quickstart/view.py | 25 +- openadapt/seal_view/__init__.py | 319 ++++ openadapt/seal_view/viewer.html | 41 + packages/seal-view/.gitignore | 2 + packages/seal-view/CHANGELOG.md | 23 + packages/seal-view/LICENSE | 21 + packages/seal-view/README.md | 85 + 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/InspectorSeal.jsx | 143 ++ .../src/components/InspectorSeal.module.css | 103 + .../src/components/OpenAdaptMark.jsx | 10 + packages/seal-view/src/components/SealArt.jsx | 386 ++++ .../src/components/SealArt.module.css | 858 +++++++++ .../seal-view/src/components/SealEmblem.jsx | 30 + .../src/components/SealEmblem.module.css | 133 ++ packages/seal-view/src/components/SealMap.jsx | 114 ++ .../seal-view/src/components/SealPage.jsx | 1070 +++++++++++ .../src/components/SealPage.module.css | 1527 +++++++++++++++ packages/seal-view/src/index.js | 26 + 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 | 310 +++ 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 | 38 + packages/seal-view/src/lib/sealOutcome.mjs | 25 + packages/seal-view/src/lib/sealProps.mjs | 83 + 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 | 348 ++++ packages/seal-view/src/viewer/template.html | 18 + packages/seal-view/src/viewer/viewer.jsx | 93 + .../test/fixtures/openemr-r3/README.md | 7 + .../test/fixtures/openemr-r3/seal.anchor.json | 51 + .../test/fixtures/openemr-r3/seal.json | 15 + .../test/fixtures/openemr-r3/seal.tsa.json | 9 + .../test/fixtures/openemr-r3/seal.tsr | Bin 0 -> 1252 bytes .../test/fixtures/openemr-r3/site-keys.json | 7 + packages/seal-view/test/rfc8032.test.mjs | 186 ++ packages/seal-view/test/seal.test.mjs | 104 + packages/seal-view/test/witnesses.test.mjs | 41 + 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 | 125 +- tests/test_seal_view.py | 358 ++++ 72 files changed, 13661 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/InspectorSeal.jsx create mode 100644 packages/seal-view/src/components/InspectorSeal.module.css 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/sealOutcome.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/fixtures/openemr-r3/README.md create mode 100644 packages/seal-view/test/fixtures/openemr-r3/seal.anchor.json create mode 100644 packages/seal-view/test/fixtures/openemr-r3/seal.json create mode 100644 packages/seal-view/test/fixtures/openemr-r3/seal.tsa.json create mode 100644 packages/seal-view/test/fixtures/openemr-r3/seal.tsr create mode 100644 packages/seal-view/test/fixtures/openemr-r3/site-keys.json create mode 100644 packages/seal-view/test/rfc8032.test.mjs create mode 100644 packages/seal-view/test/seal.test.mjs create mode 100644 packages/seal-view/test/witnesses.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..b0c125dfb --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 5670026a0..6b537fe5f 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -52,6 +52,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 and in the queue, plus # macOS and Windows on main, and the bundled-Flow qualification contract) and @@ -130,6 +136,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 @@ -185,6 +192,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")" @@ -197,6 +206,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" @@ -1014,6 +1024,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 @@ -1116,6 +1176,7 @@ jobs: - tray-contract - tray-desktop-ipc - program-graph + - seal-view - install-weight - capture-core-record - desktop-release-intent diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 513a7683f..b813e55a2 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -270,6 +270,7 @@ improvising or retrying when something is off. | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | +| See a run's Seal offline: one HTML page that checks the Ed25519 signature over the run's exact report in the browser, draws the Seal from it, replays the run over its program map, and lets the reader tamper with a copy; no network requests; the quickstart ends on the person's own | On main ([#1437](https://github.com/OpenAdaptAI/OpenAdapt/pull/1437)) | A real quickstart run's Seal opened from `file://` in Chromium (valid, tampered, no requests); RFC 8032 test vectors; a published OpenEMR Seal's Rekor entry and timestamp | `openadapt seal view RUN_DIR`; `openadapt quickstart`; [`@openadapt/seal-view`](packages/seal-view/README.md) | | Run authorizations signed with the authorizer's GitHub SSH key (`ssh-keygen -Y sign`, after a summary of what it allows), naming the signing key and bound to the bundle digest, items, profile and Seal keys, and referenced by each Seal; the identity checked against github.com (`--live`) or pinned allowed signers; a pseudonymous operator profile counted from anchored Seals only | On main ([#1401](https://github.com/OpenAdaptAI/OpenAdapt/pull/1401)) | Unit tests with synthetic data; signatures checked against OpenSSH's `ssh-keygen` both ways | `openadapt-flow local-seal authorize`, `local-seal run --authorization`, `local-seal profile`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#run-authorizations-who-authorized-the-runs) | | Run reports redacted before they are sealed: session and CSRF tokens, OAuth grants and credential headers become `` and are listed in `redactions`; a Seal refuses a report that still holds one; per-app policy | On main ([#1407](https://github.com/OpenAdaptAI/OpenAdapt/pull/1407)) | Unit tests with generated tokens; 7,773 local reports scanned, only OpenEMR `token_main` matched | `openadapt-flow redact-report`, `local-seal run --allow-unredacted`; [REPORT_REDACTION.md](packages/flow/docs/REPORT_REDACTION.md) | | Strict no-outbound mode and an unattended on-prem service | Released | Synthetic | `openadapt-flow no-outbound`, `service`; [ON_PREM.md](packages/flow/docs/ON_PREM.md) | diff --git a/llms-full.txt b/llms-full.txt index 490afde94..7048dad12 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -667,6 +667,7 @@ improvising or retrying when something is off. | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | +| See a run's Seal offline: one HTML page that checks the Ed25519 signature over the run's exact report in the browser, draws the Seal from it, replays the run over its program map, and lets the reader tamper with a copy; no network requests; the quickstart ends on the person's own | On main ([#1437](https://github.com/OpenAdaptAI/OpenAdapt/pull/1437)) | A real quickstart run's Seal opened from `file://` in Chromium (valid, tampered, no requests); RFC 8032 test vectors; a published OpenEMR Seal's Rekor entry and timestamp | `openadapt seal view RUN_DIR`; `openadapt quickstart`; [`@openadapt/seal-view`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/seal-view/README.md) | | Run authorizations signed with the authorizer's GitHub SSH key (`ssh-keygen -Y sign`, after a summary of what it allows), naming the signing key and bound to the bundle digest, items, profile and Seal keys, and referenced by each Seal; the identity checked against github.com (`--live`) or pinned allowed signers; a pseudonymous operator profile counted from anchored Seals only | On main ([#1401](https://github.com/OpenAdaptAI/OpenAdapt/pull/1401)) | Unit tests with synthetic data; signatures checked against OpenSSH's `ssh-keygen` both ways | `openadapt-flow local-seal authorize`, `local-seal run --authorization`, `local-seal profile`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#run-authorizations-who-authorized-the-runs) | | Run reports redacted before they are sealed: session and CSRF tokens, OAuth grants and credential headers become `` and are listed in `redactions`; a Seal refuses a report that still holds one; per-app policy | On main ([#1407](https://github.com/OpenAdaptAI/OpenAdapt/pull/1407)) | Unit tests with generated tokens; 7,773 local reports scanned, only OpenEMR `token_main` matched | `openadapt-flow redact-report`, `local-seal run --allow-unredacted`; [REPORT_REDACTION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/REPORT_REDACTION.md) | | Strict no-outbound mode and an unattended on-prem service | Released | Synthetic | `openadapt-flow no-outbound`, `service`; [ON_PREM.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/ON_PREM.md) | diff --git a/openadapt/cli.py b/openadapt/cli.py index 85ddb9477..2448e410c 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -24,6 +24,7 @@ openadapt doctor """ +import json import os import platform import re @@ -1263,12 +1264,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: @@ -1289,6 +1293,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..2f96c4045 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,67 @@ 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_broken(self, broken: BreakFacts) -> bool: + """Sign the stopped run's report too; True once its Seal is written. + + Its Seal says the report is the one the run wrote, stop and all. + Engines without offline Seals sign nothing. + """ + + try: + from openadapt_flow.offline_seal import seal_home, seal_run + except ImportError: + return False + seal_run(broken.run_dir, home=seal_home(), enqueue=False) + return (broken.run_dir / "seal.json").is_file() + + 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. + + Only a sealed run gets a page. 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.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 your computer, in " + "MockMed with synthetic data, signed with your computer's " + "Seal key and checked here against that 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 and (broken.run_dir / "seal.json").is_file(): + 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 +779,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..7de867d26 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,15 @@ def read_screen(self, broken: BreakFacts) -> Optional[str]: ... def seal(self, runs: list[RunFacts]) -> Optional[SealFacts]: ... + def seal_broken(self, broken: BreakFacts) -> bool: ... + + 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 +102,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 +119,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 +285,42 @@ 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: + """Sign the stopped run, then write the Seal pages. + + The receipt counts the stopped run's Seal when it was written, whether or + not its page is; a page that can't be written leaves no Seal line. + """ + + if outcome.seal is not None and outcome.broken is not None: + try: + sealed = engine.seal_broken(outcome.broken) + except Exception: # noqa: BLE001 - the checked runs' Seals stand alone + sealed = False + if sealed: + outcome.seal = SealFacts(outcome.seal.key_id, outcome.seal.sealed + 1) + 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() + + def _in_background(work: Callable[[], Any]) -> Any: """Start ``work`` on a thread; ``.result()`` waits and re-raises.""" @@ -312,6 +356,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..25153e478 --- /dev/null +++ b/openadapt/seal_view/__init__.py @@ -0,0 +1,319 @@ +"""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``; Preact, 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``, ``seal.tsa.json`` and ``seal.tsr`` 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", + "seal.tsr", +) + + +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 _b64(path: Path) -> Optional[str]: + try: + return base64.b64encode(path.read_bytes()).decode("ascii") + 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"), + "tsr_b64": _b64(run_dir / "seal.tsr"), + "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() + # A page forwarded to someone else stays a local check, not third-party proof. + host = { + "trustWords": "checked against your 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..2c10f0b18 --- /dev/null +++ b/openadapt/seal_view/viewer.html @@ -0,0 +1,41 @@ + + + + + + + + + +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..6ebf5bc03 --- /dev/null +++ b/packages/seal-view/CHANGELOG.md @@ -0,0 +1,23 @@ +# 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`, `MiniIris`, `SealEmblem`, `SealMap` (the full program + workbench, with a Seal card in its inspector: `InspectorSealCard`, + `useInspectorHost`), `HashLine`, `OpenAdaptMark`; `sealOutcomeLine`. +- 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, + through 4da61c59). 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..3fda3fb90 --- /dev/null +++ b/packages/seal-view/README.md @@ -0,0 +1,85 @@ +# @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 full program workbench + ([`@openadapt/program-graph`](../program-graph/README.md)): sections, + inspector, evidence lanes, stop rules and a tour. While the inspector shows + the step the Seal certifies (the save, or the stop), a card at its top says + so and goes back up to the Seal. +- **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` naming its token file `seal.tsr`) 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`, `map` (`kicker`, `title`, `resolveMedia` for the workbench), +`copyHref`, `kicker`, `footer`, `trustWords`, `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/InspectorSeal.jsx b/packages/seal-view/src/components/InspectorSeal.jsx new file mode 100644 index 000000000..5642b1cc8 --- /dev/null +++ b/packages/seal-view/src/components/InspectorSeal.jsx @@ -0,0 +1,143 @@ +// The Seal card at the top of the program map's step inspector, shown exactly +// while the inspector shows a step a Seal certifies (the save its read-backs +// prove, or the stop), or a folded card holding that step. Nothing else gates +// it: not playback, not the end of the runs. The package +// (@openadapt/program-graph) owns the inspector's markup and has no slot for +// it, so useInspectorHost watches the inspector, puts one container first in +// it while it shows such a step, and the card renders into it with a portal +// (as the landing map's run chips do). +// +// On openadapt.ai's landing page the card opens the Seal of the run that ended +// there (a chooser when several did); on a Seal page it says this is the step +// the Seal certifies and goes back up to the Seal. `Link` is the host's link +// component (default: a plain ). +import { useEffect, useState } from 'react' +import { MiniIris } from './SealArt.jsx' +import s from './InspectorSeal.module.css' + +/** What the inspector shows: { step: node id } for one step, { fold: title } for a folded card, or null. */ +export function inspectorShows(inspector) { + const head = inspector.querySelector('.opg-inspector-head span')?.textContent || '' + if (head === 'Selected step') { + const id = inspector.querySelector(':scope > p.opg-mono')?.textContent || null + return id ? { step: id } : null + } + if (head === 'Selected steps') { + const title = inspector.querySelector(':scope > h3')?.textContent || null + return title ? { fold: title } : null + } + return null +} + +/** + * The anchor node ids the inspector shows: the step itself, or every anchor in + * the folded card it shows. anchors: node ids; groups: the folds' groups. + */ +export function anchorsShown(shows, anchors, groups) { + if (!shows) return [] + if (shows.step) return anchors.includes(shows.step) ? [shows.step] : [] + const group = groups.find((g) => (g.title ?? g.id) === shows.fold) + return group ? anchors.filter((id) => group.nodes.includes(id)) : [] +} + +/** A host first in the inspector while it shows any of `anchors`, and which ones. */ +export function useInspectorHost(root, anchors, groups) { + const [state, setState] = useState({ host: null, ids: [] }) + const key = anchors.join(' ') + useEffect(() => { + const el = root.current + if (!el || !key) return undefined + const list = key.split(' ') + let box = null + let last = '' + const sync = () => { + const inspector = el.querySelector('.opg-inspector') + const ids = inspector ? anchorsShown(inspectorShows(inspector), list, groups) : [] + if (!ids.length) { + if (box) { + box.remove() + box = null + last = '' + setState({ host: null, ids: [] }) + } + return + } + if (!box) { + box = document.createElement('div') + box.className = s.host + box.dataset.sealCardHost = '' + } + if (inspector.firstChild !== box) inspector.prepend(box) + const now = ids.join(' ') + if (now !== last) { + last = now + setState({ host: box, ids }) + } + } + const mo = new MutationObserver(sync) + mo.observe(el, { childList: true, subtree: true, characterData: true }) + sync() + return () => { + mo.disconnect() + box?.remove() + setState({ host: null, ids: [] }) + } + }, [root, key, groups]) + return state +} + +/** + * seals: [{ n, href, irisSeed, tone, line, anchor }], the first one primary. + * Landing: links to the Seals. Seal page: onBack scrolls up to the Seal. + */ +export function InspectorSealCard({ seals, onBack = null, Link = 'a' }) { + const [primary, ...others] = seals + const stop = primary.anchor?.kind === 'stop' + const landing = !onBack + return ( +
+ +
+

+ {landing ? `Sealed here${primary.n ? ` · referral ${primary.n}` : ''}` : 'This is the step the Seal certifies'} +

+

{primary.line}

+

+ {stop + ? 'The run stopped here, before saving, and was signed.' + : 'The save its read-backs prove, signed on the machine that ran it.'} +

+ {landing ? ( + + Open the Seal → + + ) : ( + + )} + {others.length ? ( +

+ Also sealed here: + {others.map((o) => ( + + Referral {o.n} + + ))} +

+ ) : null} +
+
+ ) +} diff --git a/packages/seal-view/src/components/InspectorSeal.module.css b/packages/seal-view/src/components/InspectorSeal.module.css new file mode 100644 index 000000000..9b8281690 --- /dev/null +++ b/packages/seal-view/src/components/InspectorSeal.module.css @@ -0,0 +1,103 @@ +/* The Seal card at the top of the program map's inspector (InspectorSeal.js). + The map is dark in both site themes, so the card uses its dark palette. */ +.host { + display: block; + margin: 0 0 14px; +} +.card { + --t0: #4cf0b8; + --edge: rgba(76, 240, 184, 0.45); + --wash: rgba(76, 240, 184, 0.09); + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px; + border: 1px solid var(--edge); + border-radius: 14px; + background: linear-gradient(160deg, var(--wash), rgba(255, 255, 255, 0.02) 70%); + box-shadow: 0 0 28px -12px var(--edge); + animation: cardIn 420ms ease-out both; +} +.card[data-tone='halt'] { + --t0: #ffbf55; + --edge: rgba(255, 191, 85, 0.5); + --wash: rgba(255, 191, 85, 0.09); +} +.body { + min-width: 0; +} +.kicker { + margin: 0 0 2px; + color: var(--t0); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.line { + margin: 0; + color: #f2f7fb; + font-size: 14px; + font-weight: 600; + line-height: 1.35; +} +.sub { + margin: 4px 0 10px; + color: #9fb0c3; + font-size: 12px; + line-height: 1.4; +} +.action { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 14px; + border: 0; + border-radius: 999px; + background: var(--t0); + color: #04120c; + font: inherit; + font-size: 13px; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} +.card[data-tone='halt'] .action { + color: #1a1002; +} +.action:hover { + filter: brightness(1.08); +} +.action:focus-visible { + outline: 2px solid #a2c6ff; + outline-offset: 2px; +} +@keyframes cardIn { + from { + opacity: 0; + transform: translateY(-6px); + } +} +@media (prefers-reduced-motion: reduce) { + .card { + animation: none; + } +} +/* Several runs ended on this step: the others' Seals, one link each. */ +.others { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin: 10px 0 0; + color: #9fb0c3; + font-size: 12px; +} +.others a { + color: var(--t0); + font-weight: 600; +} +.others a:focus-visible { + outline: 2px solid #a2c6ff; + outline-offset: 2px; +} 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..e7181b0c1 --- /dev/null +++ b/packages/seal-view/src/components/SealArt.jsx @@ -0,0 +1,386 @@ +// 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 ( +
+