From d6b8b545b107281f278a0bb1730666f600aee071 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 1 Sep 2026 15:57:25 -0400 Subject: [PATCH 1/2] docs: rewrite get-started for a human who wants an agent on this computer The live opener treated the calling agent as the default reader and led with claude mcp add. Humans install Desktop or pip first. Tutorial is optional. Validator strings stay on the page, not in the lead. --- docs/get-started/index.md | 307 +++++---------------------- docs/getting-started/index.md | 13 +- docs/getting-started/installation.md | 10 +- docs/getting-started/quickstart.md | 18 +- docs/index.md | 7 +- docs/llms.txt | 2 +- docs/start/index.md | 15 +- 7 files changed, 88 insertions(+), 284 deletions(-) diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 5d8f588..d0089d8 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -1,104 +1,76 @@ --- description: >- - Install OpenAdapt, run the MockMed tutorial, watch --break-it halt a fake - success, then record one read-only workflow. + Install OpenAdapt on this computer with Desktop or pip. Show the task + once; it becomes a program your agent can run. Tutorial is optional. --- # Get started -The default reader is the calling agent. A named human authors the program -and resolves identity, effect, and judgment halts. You need no account, target -application, API key, or operating-system automation permission. Python 3.10 -through 3.12. +You want an agent to do a job on this computer. Keep that job here. -Point Claude Code or Cursor at the local server: +You show the clicks once. OpenAdapt turns that into a program on this +computer, and later an agent runs it and checks the work landed. Healthy +repeats don't send your data to a model. -```bash -claude mcp add openadapt -- \ - uvx --from 'openadapt-agent[tutorial]' openadapt-agent \ - serve --allow-run -``` +OpenAdapt is a compiler. The chat is the IDE, and credentials stay on this +computer. The core is free forever under MIT. + +## Install -`--allow-run` is an explicit opt-in. The server generates the public synthetic -tutorial at serve time. Halt, refused, timeout, and error come back as those -outcomes. Never summarize halt as success. +Python 3.10 through 3.12 for pip. Desktop and the CLI compile the same +program. -!!! info "What the calling agent may do / must not do" - **May:** bind declared parameters, invoke the compiled program, read - typed outcomes, supply a missing declared parameter, retry a retryable - transport failure, escalate to a human. +### Desktop - **Must not:** summarize halt as success, resolve identity or effect - contradictions, or be the sole source of a production demonstration. +Get the installer for this machine from +[openadapt.ai/download](https://openadapt.ai/download). You can also start +from [openadapt.ai/start](https://openadapt.ai/start). Open the app. Grant +Screen Recording and Accessibility if asked. - Full contract: [agents.txt](../agents.txt). +First-run permissions and checksums are in +[Install Desktop](../desktop/install.md). -Same loop from the CLI: +### CLI ```bash python -m pip install --upgrade openadapt -openadapt quickstart -openadapt quickstart --break-it ``` -Add `--headed` if you want to watch the browser. - -The bundled workflow is a tutorial. Qualifying a real one means declaring its -application boundary, its action risks, its identities, its effect verifiers, -its fault cases, and its deployment policy. - -`openadapt quickstart` records a task in MockMed, a synthetic -practice-management fixture, compiles the observed -[effect contract](../reference/glossary.md#effect-contract), certifies it with -the shipped clinical-write [policy](../reference/glossary.md#policy), and runs -it under the Standard [profile](../reference/glossary.md#profile). A separate -read-only API confirms the saved record outside the screen that performed the -write. The healthy run returns [`VERIFIED`](../reference/run-outcomes.md) with -no model or Cloud call. - -`--break-it` is the aha. Same certified bundle. The backend rejects the write -after the app has already painted its success banner, so every on-screen check -passes and the run halts anyway, because the independent read disagrees. The -store is unchanged. - -OpenAdapt refuses to overwrite `openadapt-quickstart/`. Artifacts land there. - -**Isolated CLI alternative.** The public installer creates and maintains an -isolated environment with [uv](https://docs.astral.sh/uv/): +If you don't want to touch the Python you already have, use the isolated +installer: ```bash curl -fsSL https://openadapt.ai/install.sh | sh ``` -Both paths install the same `openadapt` command. You need no package extra for -the browser tutorial. +Either command leaves `openadapt` on PATH. + +## After install -## The receipt you just got +If you came from a job page (`/j/{id}`), go back to that tab. It's already +there. If a local coding agent is on this machine, it can call `openadapt` +on PATH. -Tutorial `VERIFIED` is a local receipt on synthetic MockMed. It is not a -production Seal. `--break-it` is the aha: the banner can lie, and the -independent read stops the run. When you qualify a real job, that same -independent check is what a Seal attests. Public synthetic verify lives at -[openadapt.ai/seals](https://openadapt.ai/seals). The contract is -[The Seal](../commercial/seal.md). +Sign in on this computer, in the real app. Not in the chat. -A production Seal needs a qualified program and an oracle at tier 2 or 3. -Oracle tiers 0 (visual) and 1 (second-session UI) never mint one. Local -unsigned replay stays free. +### Optional tutorial -## What the healthy run wrote +`openadapt quickstart` records a bundled tutorial and compiles it, then +runs it locally. To see a halt when the on-screen banner disagrees with +the record, run `openadapt quickstart --break-it` against the same +compiled tutorial; the run stops because the independent read failed, and +the store stays as it was. Add `--headed` if you want to watch the +browser. -You now have: +Skip it if you already have a real task. -- `openadapt-quickstart/recording/`: the demonstration and retained target - evidence -- `openadapt-quickstart/bundle/`: the inspectable compiled workflow -- `openadapt-quickstart/run/REPORT.md`: the ordered actions, evidence, outcome, - and any halt reason -- `openadapt-quickstart/run/receipt.json`: a local, privacy-safe summary of the - synthetic verified run +OpenAdapt refuses to overwrite `openadapt-quickstart/`. Artifacts land +there: -Open the report, then inspect the program and its deployment gaps: +- `openadapt-quickstart/recording/` +- `openadapt-quickstart/bundle/` +- `openadapt-quickstart/run/REPORT.md` +- `openadapt-quickstart/run/receipt.json` ```bash less openadapt-quickstart/run/REPORT.md @@ -106,190 +78,27 @@ openadapt flow visualize openadapt-quickstart/bundle --out graph.html openadapt flow lint openadapt-quickstart/bundle ``` -Open `graph.html` in a browser. That page is the compiled program: the steps -it can take, the evidence each one needs, and the paths that stop the run. -See [Read a compiled program](../concepts/program-visualizer.md). - -!!! important "A tutorial result is not production certification" - The bundled fixture proves that the local product path and its Standard - verification gates work. It certifies only this bundled synthetic task, - application, and local system of record. A customer workflow must bind its - own application, execution surface, action risks, identity checks, - independent effect verifier, fault cases, and deployment policy. - -## What qualifying a real job adds - -Qualification tests the workflow against real failures in its environment -before it runs. You declare: - -- the application boundary -- the action risks -- the identities -- the effect verifiers -- the fault cases -- the deployment policy - -Start with one real, read-only task. Don't start with a write. - -| Goal | Next guide | -|---|---| -| Author one real, read-only browser workflow | [Author a workflow](first-workflow.md) | -| See what the compiled program looks like | [Read a compiled program](../concepts/program-visualizer.md) | -| Bind identity, effects, faults, and policy | [Qualify a workflow](../guides/qualify-a-workflow.md) | -| Use the Desktop application | [Install Desktop](../desktop/install.md) | -| Use native desktop, RDP, or Citrix | [Install a different execution surface](#install-a-different-execution-surface) | -| Prepare a qualified production run | [Move from demo to deployment](#move-from-demo-to-deployment) | - -
- ![An openIMIS eligibility check: a recorded demonstration, a verified replay, and a replay that halts.](../assets/showcase/demo.gif){ width="900" } -
The same loop against openIMIS 25.10 on synthetic data. One - recorded eligibility check, then the compiled program replaying that check - twice. A read-only SQL query verifies the first replay and contradicts the - second, so the second one halts.
-
- -Want to watch before you record your own app? - -- **[Hosted demo](https://app.openadapt.ai/demo)**: recorded demonstrations, - verified replays, and fail-safe halts on real footage. -- **[Template gallery](https://openadapt.ai/templates)**: ready-to-adapt - workflow templates. -- **[Blog](https://blog.openadapt.ai)**: guides, updates, and automation - recipes. - -## First real (read-only) workflow - -[Author a workflow](first-workflow.md) records one small real task that -doesn't change business data. A read-only lookup against test data works. -Open a known test record, then stop when a field shows the expected value. - -Don't start with a task that saves, submits, creates, or deletes data. A -write waits until qualification binds its risks, identities, and effect -verifiers. - -## See a fail-safe halt from UI drift - -`--break-it` already showed a painted success that failed the independent -read. Theme drift is a different halt. Use the compiled tutorial bundle in an -ordinary Demo-profile replay. This path has no independent verifier, so -OpenAdapt must not reuse the Standard `VERIFIED` result: - -```bash -openadapt flow replay openadapt-quickstart/bundle \ - --drift modal \ - --run-dir openadapt-quickstart-halt -``` - -!!! note "Why this command exits 1" - The command expects a [halt](../reference/glossary.md#halt), so it exits - `1`. The compiled program has no approved branch for the changed screen - state and refuses to act. If you see `Replay HALTED`, open - `openadapt-quickstart-halt/REPORT.md` to see the retained evidence. Do not - retry a possibly dispatched write; reconcile it against an independent - system of record first. Every outcome is defined in - [Run outcomes and halt reasons](../reference/run-outcomes.md). +## Local coding agents -## Install a different execution surface - -The base package includes the browser driver. Its matching Chromium build -downloads only when a browser action starts. Native desktop, RDP, and Citrix -workflows do not start or download Chromium. - -For native or remote-only work, install the selected driver: - -```bash -pip install openadapt -pip install 'openadapt[capture,windows]' # example: native Windows -pip install 'openadapt[capture,rdp]' # example: network RDP -``` - -The selected native or remote extras do not download Chromium. The public -command remains `openadapt flow ` for every surface. The standalone -[`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow) package is for -engine contributors. It is not a second end-user path. - -## When a real run halts - -The bundled theme drift is a deterministic re-resolution demonstration, not a -general teaching demo. When a real, durable run halts on an unhandled state, -record only the corrective actions and feed the halted run to `teach`: - -```bash -openadapt flow teach runs/ \ - --fix recordings/ \ - --bundle bundle \ - --out bundle-v2 -``` - -`teach` writes `bundle-v2` only if the correction is promoted. The shipped -deterministic reference inducer covers the optional-dialog correction class; it -does not generalize arbitrary UI changes. An underdetermined or safety-weakening -correction is refused and the original bundle remains halting. - -## Move from demo to deployment - -Follow [Run a deployment](../guides/run-a-deployment.md) to seal the exact -bundle, certify it, run a dry check, and start the governed run. A failed -certification exits `2` and names each violated requirement. Close those gaps -before another attempt. Do not promote a bundle because the sample application -passed; complete the [security and deployment review](../guides/security-review.md) -for the real environment. - -## Where to go next - -To compile several recordings of the same task, read -[Induce a program](../guides/induce-a-program.md). A task that starts in one -application and finishes in another is two recordings. Don't record them as -one. Sequence the compiled bundles with `compose`, or after each child is -admitted, with a process parent: +Claude Code and Cursor can talk to OpenAdapt over MCP after `openadapt` is +on this machine. ChatGPT.com can't. Don't send a hosted chat your +passwords. ```bash -openadapt flow compose \ - --child intake=./intake-bundle \ - --child posting=./posting-bundle \ - --handoff intake.patient_id=posting.patient_id \ - --out composed -``` - -`visualize composed` draws those two children and the `patient_id` handoff. -Each child stays its own compiled program: - -```mermaid -flowchart TD - n0(["intake
web"]) - n1(["posting
linux"]) - n2{{"End of declared steps"}} - n0 --> n1 - n1 --> n2 - n0 -->|patient_id| n1 +claude mcp add openadapt -- \ + uvx --from 'openadapt-agent[tutorial]' openadapt-agent \ + serve --allow-run ``` -See [Sequence work across two applications](../guides/compose-multi-application.md) -and [Read a compiled program](../concepts/program-visualizer.md). -[Durable runs](../concepts/durable-runtime.md) explains how an operator can -resume from the last verified checkpoint after a halt. - -
- -- [__Author a workflow__](first-workflow.md) - - Record a read-only task with test data, review it, supervise its first - replay, and inspect the report. - -- [__What you get__](what-you-get.md) - - The bundle, the run report, and what each artifact is for. - -- [__Read a compiled program__](../concepts/program-visualizer.md) - - The program map, a composed parent, and a process parent. - -- [__Qualification evidence__](what-works-today.md) +`--allow-run` is an explicit opt-in. Halt, refused, timeout, and error come +back as those outcomes. Never summarize halt as success. - Accepted substrate results, exact environments, and deployment boundaries. +The machine contract is in [agents.txt](../agents.txt). -- [__Core concepts__](../concepts/index.md) +## Next - Understand the compiler model before you deploy it for real work. +Author one real read-only task on your own app in +[Author a workflow](first-workflow.md). Start with a lookup that doesn't +change business data. -
+More Desktop setup is in [Install Desktop](../desktop/install.md). diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 87566a4..a67198c 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,6 +1,6 @@ --- title: Getting started moved -description: Install OpenAdapt and run the current record, compile, replay tutorial. +description: Install OpenAdapt on this computer, then continue at Get started. canonical: https://docs.openadapt.ai/get-started/ redirect_to: /get-started/ hide: @@ -10,14 +10,15 @@ hide: # Getting started moved -This URL is from an older docs tree. Start at [Get started](/get-started/). +This URL is from an older docs tree. Install first, then continue at +[Get started](/get-started/). ```bash -pip install openadapt -openadapt quickstart -openadapt quickstart --break-it +python -m pip install --upgrade openadapt ``` -`openadapt quickstart` records a demonstration, compiles it, and replays the program. `--break-it` is the halt demo. For your own app, use `openadapt flow record`, `compile`, and `replay`. +The bundled tutorial (`openadapt quickstart`, then optionally +`openadapt quickstart --break-it`) is optional. For your own app, see +[Author a workflow](/get-started/first-workflow/). [Continue to Get started](/get-started/){ .md-button .md-button--primary } diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 6848863..3a8bc14 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,6 +1,6 @@ --- title: Installation moved -description: Install OpenAdapt with pip, then run the current tutorial. +description: Install OpenAdapt with Desktop or pip, then continue at Get started. canonical: https://docs.openadapt.ai/get-started/ redirect_to: /get-started/ hide: @@ -10,13 +10,13 @@ hide: # Installation moved -Install the current package, then run the tutorial: +Install the current package, then continue at +[Get started](/get-started/). Tutorial is optional. ```bash -pip install openadapt -openadapt quickstart +python -m pip install --upgrade openadapt ``` -The same `openadapt` command drives `openadapt flow record`, `compile`, and `replay`. Details are on [Get started](/get-started/). +Desktop works too: [openadapt.ai/download](https://openadapt.ai/download). [Continue to Get started](/get-started/){ .md-button .md-button--primary } diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 484cd83..ba6a2e2 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,6 +1,6 @@ --- title: Quick Start moved -description: Record a GUI workflow, compile it, and replay the program with openadapt flow. +description: Install OpenAdapt on this computer, then continue at Get started. canonical: https://docs.openadapt.ai/get-started/ redirect_to: /get-started/ hide: @@ -10,21 +10,13 @@ hide: # Quick Start moved -The current first run is at [Get started](/get-started/). +The current first run is install, then [Get started](/get-started/). ```bash -pip install openadapt -openadapt quickstart +python -m pip install --upgrade openadapt ``` -That records a bundled demonstration, compiles a program, and replays it locally. A healthy run makes no model API call. - -To record your own application: - -```bash -openadapt flow record --backend web --url https://your.app --out rec -openadapt flow compile rec --out bundle --name my-task -openadapt flow replay bundle --url https://your.app --headed -``` +Desktop works too: [openadapt.ai/start](https://openadapt.ai/start). +Tutorial is optional. [Continue to Get started](/get-started/){ .md-button .md-button--primary } diff --git a/docs/index.md b/docs/index.md index 950c8ca..443bec4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ no model calls. Uncertainty escalates. Humans audit. Computer-use agents are the user of OpenAdapt. They are not the executor inside it.

-[Call it from an agent](get-started/index.md){ .md-button .md-button--primary } +[Install OpenAdapt](get-started/index.md){ .md-button .md-button--primary } [See how the compiler works](concepts/demonstration-compiler.md){ .md-button } [Author a workflow](get-started/first-workflow.md){ .md-button } @@ -158,10 +158,9 @@ report determine its outcome.
-- [__Call it from an agent__](get-started/index.md) +- [__Install OpenAdapt__](get-started/index.md) - `claude mcp add openadapt`, then `openadapt quickstart --break-it`. - Never summarize halt as success. + Desktop or pip on this computer. Tutorial is optional. - [__Author a workflow__](get-started/first-workflow.md) diff --git a/docs/llms.txt b/docs/llms.txt index a1329bb..0bbeaab 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -4,7 +4,7 @@ ## Get started - [agents.txt](https://docs.openadapt.ai/agents.txt): MCP, skills, outcome vocabulary (`VERIFIED` / `HALTED` / `RECONCILIATION_REQUIRED`), who may resolve a halt -- [Get started](https://docs.openadapt.ai/get-started/): `claude mcp add openadapt`, then `openadapt quickstart`, then `openadapt quickstart --break-it`. The bundled workflow is a tutorial. The current CLI is `openadapt flow record`, `compile`, `replay`. Older `/getting-started/` URLs and the `/start/` alias redirect here. +- [Get started](https://docs.openadapt.ai/get-started/): Install with Desktop or `python -m pip install --upgrade openadapt`. Tutorial (`openadapt quickstart`, optionally `openadapt quickstart --break-it`) is optional. Local coding agents can `claude mcp add openadapt` later. Never summarize halt as success. Older `/getting-started/` URLs and the `/start/` alias redirect here. The current CLI after install is `openadapt flow record`, `compile`, `replay`. - [Qualification evidence](https://docs.openadapt.ai/get-started/what-works-today/): Accepted substrate results, exact environments, and deployment boundaries - [Author a workflow](https://docs.openadapt.ai/get-started/first-workflow/): A named human records, compiles, lints, replays, and reads the report on your own web app. Authority, not daily operator. - [What you get](https://docs.openadapt.ai/get-started/what-you-get/): The workflow bundle, the run report, and what each artifact is for diff --git a/docs/start/index.md b/docs/start/index.md index 0a8580e..12046ff 100644 --- a/docs/start/index.md +++ b/docs/start/index.md @@ -1,6 +1,6 @@ --- title: Start moved -description: Install OpenAdapt and run the current record, compile, replay tutorial. +description: Install OpenAdapt on this computer, then continue at Get started. canonical: https://docs.openadapt.ai/get-started/ redirect_to: /get-started/ search: @@ -12,12 +12,15 @@ hide: # Start moved -This URL is an alias. The walkthrough is [Get started](/get-started/). +This URL is an alias. Install first. The walkthrough is +[Get started](/get-started/). ```bash -pip install openadapt -openadapt quickstart -openadapt quickstart --break-it +python -m pip install --upgrade openadapt ``` -The bundled workflow is a tutorial. [Continue to Get started](/get-started/){ .md-button .md-button--primary } +Desktop works too: [openadapt.ai/start](https://openadapt.ai/start). The +bundled tutorial (`openadapt quickstart`, then optionally +`openadapt quickstart --break-it`) is optional. + +[Continue to Get started](/get-started/){ .md-button .md-button--primary } From 5aeebd7a2494f4447ce3bdd6f456c59cca2bfa29 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 1 Sep 2026 16:57:11 -0400 Subject: [PATCH 2/2] fix(ci): fetch vendored design tokens through the GitHub API openadapt-web is private, so raw.githubusercontent.com 404s in Actions. Match desktop #154: Contents API + ADMIN_TOKEN, fail closed on HTTP errors. --- .github/workflows/ci.yml | 4 +- scripts/vendor_design_tokens.py | 60 ++++++++++++++-- tests/test_vendor_design_tokens.py | 110 +++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 tests/test_vendor_design_tokens.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ba2929..7766cf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,5 +74,7 @@ jobs: # committed. Nothing is installed, so it cannot fail on a lock refresh. - name: Check the vendored tokens against openadapt-web@main env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # github.token cannot read private OpenAdaptAI/openadapt-web. + # ADMIN_TOKEN can; fall back so local/fork runs still try github.token. + GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN || github.token }} run: python scripts/vendor_design_tokens.py --check diff --git a/scripts/vendor_design_tokens.py b/scripts/vendor_design_tokens.py index d046cea..27cb5ec 100644 --- a/scripts/vendor_design_tokens.py +++ b/scripts/vendor_design_tokens.py @@ -21,6 +21,8 @@ import json import os import sys +import urllib.error +import urllib.parse import urllib.request from pathlib import Path @@ -29,24 +31,64 @@ PROVENANCE_PATH = VENDOR_DIR / "provenance.json" TIMEOUT_SECONDS = 30 +USER_AGENT = "openadapt-ops-vendor-design-tokens" +API_ROOT = "https://api.github.com" + + +class FetchError(RuntimeError): + """A canonical-file fetch failed. --check must not treat this as a match.""" def sha256(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() +def github_token() -> str | None: + return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + + +def contents_url(repository: str, path: str, ref: str) -> str: + return ( + f"{API_ROOT}/repos/{repository}/contents/{path}" + f"?ref={urllib.parse.quote(ref, safe='')}" + ) + + def fetch(url: str, accept: str) -> bytes: - request = urllib.request.Request(url, headers={"Accept": accept}) - token = os.environ.get("GITHUB_TOKEN") - if token and url.startswith("https://api.github.com/"): + headers = {"Accept": accept, "User-Agent": USER_AGENT} + request = urllib.request.Request(url, headers=headers) + token = github_token() + if token and url.startswith(f"{API_ROOT}/"): request.add_header("Authorization", f"Bearer {token}") - with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: - return response.read() + try: + with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: + return response.read() + except urllib.error.HTTPError as exc: + raise FetchError(f"GET {url} -> HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise FetchError(f"GET {url} -> {exc.reason}") from exc + except TimeoutError as exc: + raise FetchError(f"GET {url} -> timed out") from exc + + +def fetch_canonical(provenance: dict, entry: dict) -> bytes: + # openadapt-web is private, so raw.githubusercontent.com 404s in Actions. + # A repo-scoped GITHUB_TOKEN cannot authenticate that host. Use the + # Contents API the same way openadapt-desktop does (PR 154). + token = github_token() + if token: + url = contents_url( + provenance["canonical_repository"], + entry["canonical_path"], + provenance["canonical_branch"], + ) + return fetch(url, "application/vnd.github.raw") + return fetch(entry["raw_url"], "text/plain") def canonical_commit(provenance: dict) -> str: url = ( - f"https://api.github.com/repos/{provenance['canonical_repository']}" + f"{API_ROOT}/repos/{provenance['canonical_repository']}" f"/commits/{provenance['canonical_branch']}" ) return json.loads(fetch(url, "application/vnd.github+json"))["sha"] @@ -76,7 +118,11 @@ def main() -> int: f"{entry['canonical_path']} instead." ) - canonical = fetch(entry["raw_url"], "text/plain") + try: + canonical = fetch_canonical(provenance, entry) + except FetchError as exc: + print(f"{name}: {exc}", file=sys.stderr) + return 1 canonical_digest = sha256(canonical) if arguments.write: diff --git a/tests/test_vendor_design_tokens.py b/tests/test_vendor_design_tokens.py new file mode 100644 index 0000000..f2f15b6 --- /dev/null +++ b/tests/test_vendor_design_tokens.py @@ -0,0 +1,110 @@ +"""Fail-closed fetch for the online half of the design-token guard. + +``tests/test_design_tokens.py`` is the offline half. This file covers +``scripts/vendor_design_tokens.py`` without hitting the network: a mocked +HTTP 404 must exit non-zero, and a token must send the request to the +GitHub Contents API rather than raw.githubusercontent.com. +""" + +from __future__ import annotations + +import io +import sys +import urllib.error +from email.message import Message +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import vendor_design_tokens as vendor # noqa: E402 + + +CI_WORKFLOW = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") +VENDOR_DIR = ROOT / "docs" / "stylesheets" / "vendor" / "openadapt-web" + + +class FakeResponse: + def __init__(self, body: bytes) -> None: + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self) -> FakeResponse: + return self + + def __exit__(self, *args: object) -> None: + return None + + +def _http_error(url: str, code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url, code, "Not Found", Message(), io.BytesIO(b"") + ) + + +def test_fetch_raises_on_http_404(mocker) -> None: + url = ( + "https://api.github.com/repos/OpenAdaptAI/openadapt-web" + "/contents/styles/tokens.json?ref=main" + ) + mocker.patch.object( + vendor.urllib.request, + "urlopen", + side_effect=_http_error(url, 404), + ) + with pytest.raises(vendor.FetchError, match="HTTP 404"): + vendor.fetch(url, "application/vnd.github.raw") + + +def test_check_returns_nonzero_when_canonical_fetch_404s( + monkeypatch, mocker, capsys +) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "test-token") + monkeypatch.setattr(sys, "argv", ["vendor_design_tokens.py", "--check"]) + + def fake_urlopen(request, timeout=None): + raise _http_error(request.full_url, 404) + + mocker.patch.object(vendor.urllib.request, "urlopen", side_effect=fake_urlopen) + + assert vendor.main() == 1 + captured = capsys.readouterr() + assert "HTTP 404" in captured.err + assert "Vendored design tokens match" not in captured.out + assert "/contents/styles/tokens.json" in captured.err + + +def test_check_uses_contents_api_when_token_is_set(monkeypatch, mocker) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "test-token") + monkeypatch.setattr(sys, "argv", ["vendor_design_tokens.py", "--check"]) + requested: list[str] = [] + authed: list[bool] = [] + + def fake_urlopen(request, timeout=None): + url = request.full_url + requested.append(url) + authed.append(request.has_header("Authorization")) + if "raw.githubusercontent.com" in url: + raise _http_error(url, 404) + if "/contents/styles/tokens.json" in url: + return FakeResponse((VENDOR_DIR / "tokens.json").read_bytes()) + if "/contents/styles/tokens.css" in url: + return FakeResponse((VENDOR_DIR / "tokens.css").read_bytes()) + raise _http_error(url, 404) + + mocker.patch.object(vendor.urllib.request, "urlopen", side_effect=fake_urlopen) + + assert vendor.main() == 0 + assert any("/contents/styles/tokens.json" in url for url in requested) + assert any("/contents/styles/tokens.css" in url for url in requested) + assert not any("raw.githubusercontent.com" in url for url in requested) + assert all(authed) + + +def test_ci_job_still_runs_check_and_reads_private_web_with_admin_token() -> None: + assert "python scripts/vendor_design_tokens.py --check" in CI_WORKFLOW + assert "secrets.ADMIN_TOKEN || github.token" in CI_WORKFLOW