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

11 KiB
Raw Blame History

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:

kubectl -n playwright port-forward deployment/playwright 18081:8080 --address 127.0.0.1

Leave the tunnel running. In another terminal, from this checkout:

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

python3 client/rapp_playwright.py run --spec examples/trial.json --out .local/run-01

Example request:

{
  "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:

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):

{"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, browser contexts, browser lifecycle. 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.