rapp-playwright/docs/consumer-contract.md
tegwick ccf46d8c59 Implement bounded browser service, consumer evidence and managed package
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13590-a16f-7f60-be60-fd71627bc99d
2026-10-13 19:23:29 +02:00

205 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Browser job contract v1
An agent needs Python 3.10+ and the single file `client/rapp_playwright.py`.
Playwright and Chromium run at the provider. The service's JSON protocol is
versioned independently of Playwright; clients send `X-Rapp-Protocol: 1` and
requests contain `api_version: "1"`. A mismatch returns HTTP 409 before a run.
## Access and first command
The managed endpoint is private. With a Kubernetes identity allowed to get the
playwright pod and create its port-forward:
```sh
kubectl -n playwright port-forward deployment/playwright 18081:8080 --address 127.0.0.1
```
Leave the tunnel running. In another terminal, from this checkout:
```sh
python3 client/rapp_playwright.py preflight
python3 client/rapp_playwright.py trial --out .local/trial-01
```
The trial obtains its specification from the service, opens the bundled fixture,
checks the rendered counter, presses Tab, asserts button focus, presses Enter,
asserts the increment, and saves screenshots at 1280×800 and 390×844. It downloads
artifacts and a receipt. Use a **new output directory** for each invocation.
This command becomes a live service path only after the deployment exists;
see SCOPE.md and retained runtime evidence for actual availability.
Preflight reports actual browser launch evidence, its timestamp, browser and
Playwright versions, release, execution location, allowed target origins,
concurrency and retention. The launch probe runs at startup and at most once
per minute while idle. It is not an assertion about the consumer's target.
HTTP 503 indicates an unavailable browser; connection failure means the service
or tunnel is unavailable. It never implies a locally installed browser.
The service listens on **pod loopback**, with a deny-all ingress policy and no
Ingress/NodePort. Kubernetes RBAC/SSH authenticates the tunnel; the service does
not provide an unauthenticated public listener or issue platform identities.
No shared application password is required. Do not widen the listener to expose
it remotely without adding an authenticated transport and reviewing the boundary.
## Run a consumer scenario
```sh
python3 client/rapp_playwright.py run --spec examples/trial.json --out .local/run-01
```
Example request:
```json
{
"api_version": "1",
"target": "fixture",
"label": "keyboard-smoke",
"timeout_ms": 60000,
"viewports": [{"name":"desktop","width":1280,"height":800}, {"name":"narrow","width":390,"height":844}],
"steps": [
{"action":"visible","selector":"h1"},
{"action":"press","key":"Tab"},
{"action":"focused","selector":"#increment"},
{"action":"press","key":"Enter"},
{"action":"text","selector":"#count","value":"1"},
{"action":"screenshot","name":"keyboard-result"}
]
}
```
`target` is `fixture` or an HTTP(S) URL whose exact origin the provider has
allowed. Userinfo, query strings and fragments in the initial target are rejected
to avoid putting credentials into ordinary receipts. V1 tests public or synthetic
pages; it has no credential injection, storage-state import or arbitrary code.
Consumers own permission to use targets and handle captured content.
Steps execute in order in a new browser context for each viewport. Supported
actions are `click`, `focus`, `fill`, `press`, `visible`, `text`, `focused`, `url`
and `screenshot`. Selectors for `text` are CSS; use CSS consistently across the
scenario. `text` compares trimmed text exactly with a five-second retry window.
`focused` checks the active element; `url` checks the complete current URL.
At least one assertion is required. A failed step stops the run, preserves prior
artifacts, and records the viewport, index, action and failure. Each completed
viewport also produces a final screenshot. Screenshots are viewport-sized,
not full-page captures. No visual-baseline pass is inferred from a saved PNG.
## HTTP lifecycle
| Method/path | Result |
| --- | --- |
| `GET /healthz` | Process liveness only; no protocol header needed |
| `GET /v1/preflight` | Launch availability, supported versions, location and limits |
| `GET /v1/trial` | Consumer-independent fixture scenario |
| `POST /v1/runs` | HTTP 202: run ID, secret capability token, expiry |
| `GET /v1/runs/{id}` | Current status or terminal receipt; requires run token |
| `DELETE /v1/runs/{id}` | Idempotent cancellation request; requires run token |
| `GET /v1/runs/{id}/artifacts/{name}` | Terminal run's named PNG; requires run token |
Run operations use `Authorization: Bearer <run-token>`. The service stores only
the token's SHA-256 hash and has no run-listing endpoint. Wrong/missing tokens,
unknown IDs and expired IDs all return 404. Do not publish `run.json`, which is
the client's private run handle. Directory permissions are 0700 and files 0600.
The client prints no token. Receipts omit fill values, expected page text,
cookies, browser state, console output and request headers. Screenshots can still
contain sensitive page content; only test appropriate targets and control copies.
States: `queued → running → passed|failed|cancelled`. A previously nonterminal
run found after a service restart becomes `interrupted/service_restarted`, and
its uncommitted artifacts are removed. Terminal receipts/artifacts survive a
restart when the provider's data volume is retained. V1 provides no automatic
retry: a lost submission response may require waiting for the server time budget
and starting a new run. A run ID is not an idempotency key.
The caller can recover with:
```sh
python3 client/rapp_playwright.py status --handle .local/run-01/run.json
python3 client/rapp_playwright.py cancel --handle .local/run-01/run.json
python3 client/rapp_playwright.py download --handle .local/run-01/run.json
```
For a nondefault endpoint, repeat `--url http://127.0.0.1:PORT` before each
subcommand (or set `RAPP_PLAYWRIGHT_URL`). A run handle does not select the
originating service. `invocation.json` retains the original service URL.
Disconnecting does not implicitly cancel an accepted job. Ctrl-C in the client
requests cancellation; server deadlines still apply when the client disappears.
## Limits and evidence
Defaults: one active browser, four queued jobs, 64 retained runs, 60-second run
budget (maximum 120 seconds), 40 steps, four viewports, width ≤1920 and height
≤1600, 32 KiB submission, 20 MiB artifacts per run and 200 MiB retained artifact
admission budget. Runs expire one hour after acceptance, including capabilities,
receipts and server artifacts. The janitor runs every second. The managed pod
adds CPU, memory and ephemeral-storage limits; PVC exhaustion remains an operator
failure, not UI failure. Retained client copies have a consumer-owned lifecycle.
Receipt fields include ID, status/error, label, target, viewports, acceptance,
start/finish/expiry timestamps, service/Playwright/browser versions, release and
execution location, completed steps, per-step outcomes and artifact names/sizes.
The Python client adds download SHA-256 hashes and saves the exact command in
`invocation.json`. The service deliberately does not retain the full request.
Terminal result examples (selected fields; complete executed receipts are in
[independent consumer evidence](evidence/2026-10-13-independent-local-consumer.md)):
```json
{"status":"passed","error":null,"completed_steps":14}
{"status":"failed","error":"ui_assertion_failed","steps":[{"viewport":"desktop","index":1,"action":"text","status":"failed"}]}
{"status":"failed","error":"target_unreachable","steps":[],"artifacts":[]}
```
An unavailable browser is rejected before admission with HTTP 503 and
`{"error":"browser_unavailable"}`; a request with the wrong API version receives
HTTP 409. A missing service/tunnel fails at the client as `service_unreachable`.
| Error | Meaning / next action |
| --- | --- |
| `browser_unavailable` / `browser_launch_failed` | Provider dependencies or browser failed; check preflight/operator logs |
| `target_not_allowed` | Provider origin policy denied navigation or a redirect; request a scoped target change |
| `target_unreachable` | DNS, connection, TLS or target HTTP error; verify address from the browser network |
| `ui_assertion_failed` | Rendered UI/action did not satisfy the scenario; inspect step and available artifacts |
| `run_timeout` | Whole run exceeded its budget; simplify or increase up to the documented maximum |
| `browser_crashed` | Worker exited without a terminal result; provider runtime diagnosis |
| `queue_full` / `artifact_capacity` | HTTP 429; retry after capacity frees |
| `service_restarted` / `service_stopped` | Provider interruption; submit a fresh run |
For `trial` and `run`, exit codes are 0 passed, 2 failed UI assertion, 3 setup/
infrastructure or other unsuccessful run, and 4 keyboard interruption. `status`,
`cancel` and `download` return 0 when that API operation succeeds, regardless of
the run outcome; inspect the receipt's `status` and `error`. A receipt cannot substitute
for content-explorer accepting its own screenshot/keyboard task.
## Target reachability
A remote browser's `localhost` is the provider, not the caller's workstation.
The origin gate covers redirects and subresources via a per-run HTTP/CONNECT
proxy. DNS resolves once per connection; non-public addresses are denied unless
the exact origin is explicitly listed in `PRIVATE_ORIGINS`. WebSockets and
service workers are blocked. A consumer cannot request broader origin grants.
For a development app, first publish a reviewed private preview reachable from
the service, or arrange an owner-approved reverse tunnel to a **dedicated target
port** and register that exact origin. The browser API port-forward does not
forward the consumer app. For local Docker development, explicitly allow the
host gateway origin and bind the dev server to an address the container can
reach. Never map all private networks just to make localhost work.
## Decision and upstream reuse
Chosen: bounded HTTP jobs, Python standard-library client, native Playwright
inside a per-run worker, fresh Chromium process and per-viewport context. Native
remote WebSocket sessions would require matching clients and expose broader
automation/network access; defer them until a consumer needs arbitrary suites.
This release adds the missing orchestration/evidence path, not a new browser engine.
The design reuses Playwright headless/context/screenshot patterns inspected in
whynot-design, coulomb-social, core-hub and frontend-patterns. Official references:
[Docker and version matching](https://playwright.dev/docs/docker),
[browser contexts](https://playwright.dev/docs/api/class-browsercontext),
[browser lifecycle](https://playwright.dev/docs/api/class-browser).
The pinned package and browser image are both Playwright 1.64.0. The container
uses a non-root user. Chromium's OS sandbox is configurable and defaults off for
the trusted-target managed pilot; this is **not a hostile-page or mutually
untrusted-tenant execution service**. No arbitrary user scripts run in Node.