# 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 `. 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.