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/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 } 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