Implement bounded browser service, consumer evidence and managed package

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13590-a16f-7f60-be60-fd71627bc99d
This commit is contained in:
tegwick 2026-10-13 19:23:29 +02:00
parent 386b0c0f04
commit ccf46d8c59
81 changed files with 3455 additions and 73 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
.git
node_modules
.local
.rendered
history
docs/evidence
__pycache__

5
.gitignore vendored
View file

@ -3,3 +3,8 @@
.claude/*
!.claude/rules/
!.claude/rules/*.md
node_modules/
.local/
.rendered/
__pycache__/
*.pyc

View file

@ -185,24 +185,31 @@ get wrong.
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
## Repository workflow and stopping boundary
## Repository workflow and verification
Read `INTENT.md`, `SCOPE.md`, `docs/demand-and-reuse.md` and `workplans/README.md`.
The initial assignment ends after planning, registration and the reuse-surface
notification. WP-0002 through WP-0004 remain proposed for subsequent work.
Read `INTENT.md`, `SCOPE.md`, `docs/consumer-contract.md`, `docs/operations.md`
and `workplans/README.md`. The founder authorized implementation of WP-0002
through WP-0004 after the bootstrap; the original planning stopping boundary
is superseded. Platform lifecycle ownership is `railiance-platform`.
This is a planning-only checkout. There are no install, build, lint, service-start
or browser-test commands yet. For documentation changes, check `git diff --check`,
Markdown links, YAML frontmatter and fenced task blocks. Run the repository sync
above to publish and reconcile work records, then inspect its receipt; use
`statehub fix-consistency --repo rapp-playwright --json` for a separate audit.
Future implementation must add and document real runtime verification commands.
Use `make install`, `make test`, `make test-integration` and `make build`.
Integration tests run real Chromium in the pinned Docker image; the consumer
uses Python 3.10+ without a locally installed browser. Keep all client `run.json`
handles under ignored `.local/`; they contain secret capabilities. Retain only
sanitized receipts and selected artifacts in `docs/evidence/`.
Keep service availability explicitly planned until browser and consumer receipts
exist. Reuse the existing `capability.infotech.headless-browser` registry record;
send provider contract and evidence updates to reuse-surface when authorized.
Content-explorer owns its acceptance tasks. No service endpoint, version pin,
deployment binding or working trial command may be invented to fill a placeholder.
Validate the rapp declaration against railiance-master and render an immutable
image reference before any activation. Managed availability needs platform
admission and live consumer receipts. Local Docker evidence does not establish
Railiance availability. Follow `docs/operations.md` for deployment and recovery.
Check `git diff --check`, repository links and workplan task blocks. Run the
repository sync above to publish and reconcile work records, then inspect its
receipt; use `statehub fix-consistency --repo rapp-playwright --json` for a
separate audit. Do not finish an externally blocked task or infer owner approval
from a sent message. Reuse the existing `capability.infotech.headless-browser`
record; its maintenance belongs to reuse-surface. Content-explorer owns acceptance
of its own screenshot and keyboard task.
---

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends tini=0.19.0-1 && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY src ./src
COPY fixtures ./fixtures
COPY client ./client
COPY --chmod=0555 tools/chromium-launcher.py ./tools/chromium-launcher.py
RUN mkdir /data && chown pwuser:pwuser /data
USER pwuser
ENV HOST=127.0.0.1 PORT=8080 DATA_DIR=/data EXECUTION_LOCATION=container MAX_CONCURRENCY=1
EXPOSE 8080
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["node", "src/server.mjs"]

View file

@ -68,9 +68,8 @@ credentials and browser storage state must not enter ordinary result logs.
## Initial scope and later decisions
Start with Chromium, one consumer-independent trial, and the content-explorer
pilot. Native Playwright compatibility is the starting point. The contract
workplan will decide whether remote sessions, submitted jobs, or a combination
best satisfies agent use and artifact retrieval. Transport, endpoint, deployment
pilot. Native Playwright compatibility is the starting point. The consumption contract decides whether remote sessions, submitted jobs, or a
combination best satisfies agent use and artifact retrieval. Transport, endpoint, deployment
binding, package versions, and access mechanism are not selected by this intent.
Multi-browser coverage, a general browser farm, unrestricted script execution,
@ -87,8 +86,9 @@ Isolation, timeouts and cleanup must have executed negative-case evidence.
Planning, registration, image publication and a successful health check alone do
not prove this outcome. Availability advances only with runtime and consumer
receipts. Until then the service is **planned**, with no verified endpoint;
reuse-surface's existing source-reuse evidence remains unchanged.
receipts. Claims must distinguish a verified local runtime from an admitted managed
endpoint; neither image publication nor local proof establishes Railiance
availability.
See [SCOPE.md](SCOPE.md) for current facts and [workplans](workplans/README.md)
for the staged implementation and verification gates.

35
Makefile Normal file
View file

@ -0,0 +1,35 @@
SHELL := /bin/bash
IMAGE_REPOSITORY := forgejo.coulomb.social/coulomb/rapp-playwright
TAG ?= 0.1.0
IMAGE_REFERENCE ?=
TARGET ?= railiance01
TRIAL_OUT ?= .local/live-trial
.PHONY: install check test test-integration build render bootstrap-namespace server-dry-run deploy verify-live rollback
install:
npm ci --ignore-scripts
check:
npm run check
python3 -m py_compile client/rapp_playwright.py tools/render.py
test: check
npm test
test-integration:
docker run --rm --init --user 1000:1000 --read-only --tmpfs /tmp:rw,size=512m --shm-size=256m --memory=1g --cpus=1 --cap-drop ALL --security-opt no-new-privileges --mount type=bind,src=$(CURDIR),dst=/app,readonly -w /app mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f node --test tests/integration.mjs
build:
docker build -t $(IMAGE_REPOSITORY):$(TAG) .
render:
IMAGE_REFERENCE='$(IMAGE_REFERENCE)' python3 tools/render.py
bootstrap-namespace: render
ssh $(TARGET) 'kubectl apply --server-side --dry-run=server -f -' < .rendered/namespace.yaml
ssh $(TARGET) 'kubectl diff -f -; result=$$?; test $$result -le 1' < .rendered/namespace.yaml
ssh $(TARGET) 'kubectl apply --server-side -f -' < .rendered/namespace.yaml
server-dry-run: render
ssh $(TARGET) 'kubectl apply --server-side --dry-run=server -f -' < .rendered/workload.yaml
deploy: server-dry-run
ssh $(TARGET) 'kubectl diff -f -; result=$$?; test $$result -le 1' < .rendered/workload.yaml
ssh $(TARGET) 'kubectl apply --server-side -f -' < .rendered/workload.yaml
ssh $(TARGET) 'kubectl -n playwright rollout status deployment/playwright --timeout=180s'
verify-live:
python3 client/rapp_playwright.py preflight
python3 client/rapp_playwright.py trial --out '$(TRIAL_OUT)'
rollback: deploy

View file

@ -1,22 +1,48 @@
# rapp-playwright
Railiance package for a Playwright and headless Chromium service that coding
agents can use to arrange UI tests, responsive screenshots, and keyboard journeys.
A bounded Playwright/Chromium job service for responsive screenshots and keyboard
journeys, with a standalone Python client and a private Railiance workload package.
**Status: planning only.** No service endpoint, runnable client, browser image,
or launch receipt is available from this repo yet. State Hub registration tracks
the work; it does not establish browser availability.
**Implemented and verified locally. Managed activation awaits platform admission.**
The independent agent trial and pinned OnCo home-page pilot passed. The service
uses Playwright 1.64.0 and Chromium 156.0.8078.4; consumers need Python 3.10+ and
no browser installation.
For an available provider endpoint:
```sh
python3 client/rapp_playwright.py preflight
python3 client/rapp_playwright.py trial --out .local/trial-01
```
The default endpoint is a loopback tunnel at `http://127.0.0.1:18081`.
[Access and contract](docs/consumer-contract.md) explain tunnel setup, target
reachability, receipts, failures and limits. This address alone is not evidence
that a managed deployment exists. See [current scope](SCOPE.md).
For a reproducible local provider (Docker and npm required):
```sh
make install test test-integration build
docker run --rm --name rapp-playwright-local --read-only \
--tmpfs /tmp:rw,size=512m --shm-size=256m --memory=1g --cpus=1 \
--cap-drop ALL --security-opt no-new-privileges \
--mount type=volume,src=rapp-playwright-local-data,dst=/data \
-p 127.0.0.1:18081:8080 -e HOST=0.0.0.0 \
-e EXECUTION_LOCATION=workstation-container \
forgejo.coulomb.social/coulomb/rapp-playwright:0.1.0
```
The local Docker listener is published only on host loopback; the managed pod
listens on pod loopback. The fixture works without external target grants.
Use an immutable published digest for managed installation.
- [Intent and ownership](INTENT.md)
- [Current scope](SCOPE.md)
- [Demand and sibling conventions](docs/demand-and-reuse.md)
- [Workplan sequence](workplans/README.md)
- [Verified registration and handoff](docs/evidence/2026-10-13-bootstrap.md)
- [Build, deployment and recovery](docs/operations.md)
- [Independent consumer evidence](docs/evidence/2026-10-13-independent-local-consumer.md)
- [Content-explorer pilot](docs/content-explorer-pilot.md)
- [Workplan status](workplans/README.md)
Registered in State Hub under `infotech / stack`. The completed bootstrap and
three proposed delivery plans are listed in [WORK-RECORDS.md](WORK-RECORDS.md).
The first consumer is content-explorer (`CONTENT-WP-0000-T04`). Discovery stays
with reuse-surface's existing `capability.infotech.headless-browser` record;
rapp-playwright owns service delivery. Implementation starts with the proposed
consumer contract workplan after the initial planning handoff.
Registered in State Hub under `infotech / stack`. `railiance-platform` owns the
platform lifecycle; this repo owns service implementation. Discovery stays with
reuse-surface's existing `capability.infotech.headless-browser` record.

View file

@ -1,22 +1,68 @@
# Scope — rapp-playwright
As of 2026-10-13 this repository contains intent, demand analysis and proposed
delivery workplans. The bootstrap covers planning, State Hub integration and a
reuse-surface handoff. The user requested stopping at that boundary.
As of 2026-10-13, the service, standalone Python client, browser trial and
Railiance package are implemented and verified locally. **Managed Railiance
availability is not established.** Image publication failed and platform admission
has not been confirmed. The founder selected `railiance-platform` as lifecycle
owner; service implementation remains here.
There is no implemented service, client command, browser image, package
declaration, deployment, verified endpoint or browser run. No install, build,
lint or UI-test command is available yet. Documentation and work-record checks
are the applicable verification today.
## Implemented and evidenced
Future delivery is tracked in:
- Protocol-v1 bounded HTTP jobs with preflight, submission, status, cancellation,
terminal receipts and scoped artifact downloads. Python 3.10+ client needs no
local browser. Playwright 1.64.0 / Chromium 156.0.8078.4 run at the provider.
- Fresh browser process per job and context per viewport, exact origin gate with
DNS address checks, isolated run capabilities, queue/time/artifact bounds,
temporary-profile cleanup, restart interruption and one-hour expiry.
- Pinned upstream image and lockfile, non-root/read-only container with Tini,
real launch readiness, private loopback/RBAC package, resource envelope,
NetworkPolicy, PVC, immutable rendering and operational instructions.
- Three unit tests, four real-browser integration cases and a separate missing-
browser negative passed. Independent initial and final-image trials verified
screenshots, keyboard results, artifact hashes and failure categories. Final
image restart preserved completed receipt/artifact retrieval.
- Pinned OnCo home-page pilot passed at desktop and narrow widths with skip-link
keyboard assertions. Evidence was handed to content-explorer; its T04 and
visual/route acceptance remain consumer-owned.
| Workplan | Deliverable | Current state |
| --- | --- | --- |
| RAPP-PLAYWRIGHT-WP-0002 | Agent consumption contract and trial design | Proposed |
| RAPP-PLAYWRIGHT-WP-0003 | Reproducible service, client path and package | Proposed |
| RAPP-PLAYWRIGHT-WP-0004 | Deployment, independent consumer proof and discovery update | Proposed |
[Commands, logs, exact image identity and receipts](docs/evidence/2026-10-13-implementation.md)
support these statements. The final local provider was verified at
`http://127.0.0.1:18083` (`rapp-playwright-final`). Example:
Content-explorer's screenshot/keyboard task remains its own open work. Search
relevance and false-`new` plan-check results remain reuse-surface work. Neither is
completed by this repository's registration.
```sh
python3 client/rapp_playwright.py --url http://127.0.0.1:18083 trial --out .local/new-trial
```
This is a workstation Docker process, not a stable managed endpoint. Server
artifacts expire; sanitized evidence under `docs/evidence/` is retained.
## Delivery state and remaining work
| Workplan | State and remaining evidence |
| --- | --- |
| WP-0002 | Finished: consumption contract, lifecycle and trial/pilot design |
| WP-0003 | Local implementation complete; T01 waits on OCI publication. Forgejo blob PUT returned HTTP 499/500 and remote manifest inspection returned `manifest unknown`. |
| WP-0004 | T02 provider trial/pilot done. T01 waits on publication and platform admission/access; T03 waits on reuse-surface's maintained/published discovery update. |
The `playwright` namespace is absent. No cluster workload was created. Server
admission, private consumer binding, live target reachability, managed independent
trial, restart/cancellation and cross-version rollback remain unverified.
A local build digest is not a published release. Package validation/rendering
and Kubernetes client dry-run passed; they do not imply server admission.
Existing capability `capability.infotech.headless-browser` was still D3/A2/C2/R2
with source-reuse/planned-provider wording at readback. The owner received actual
local evidence and the usable client path; registry publication is pending.
Open workplan tasks retain blocker messages and exact resume steps.
## Deliberate limits
V1 provides trusted-target Chromium jobs and a finite action/assertion vocabulary.
It does not provide arbitrary scripts, native remote sessions, authenticated
page state, a general browser farm or visual-baseline approval. Chromium's OS
sandbox is disabled; hostile pages and mutually untrusted tenants are unsupported.
Target failures identify the category but not detailed DNS/TLS/HTTP causes.
The OnCo pilot covers the home page, not all consumer routes or accessibility.
See the [timestamped intent assessment](history/2026-10-13-1918-intent-scope-assessment.md)
for the prioritized gaps and their acceptance evidence.

158
client/rapp_playwright.py Normal file
View file

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Dependency-free client for the rapp-playwright v1 job API (Python 3.10+)."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class ClientError(Exception):
pass
class Client:
def __init__(self, url):
parsed = urllib.parse.urlsplit(url)
if parsed.scheme not in ('http', 'https') or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ClientError('invalid service URL')
if parsed.scheme == 'http' and parsed.hostname not in ('localhost', '127.0.0.1', '::1'):
raise ClientError('HTTP requires a loopback tunnel; remote endpoints require HTTPS')
self.url = url.rstrip('/')
self.opener = urllib.request.build_opener(NoRedirect)
def request(self, path, method='GET', body=None, token=None, binary=False):
headers = {'X-Rapp-Protocol': '1'}
if token:
headers['Authorization'] = 'Bearer ' + token
data = None
if body is not None:
headers['Content-Type'] = 'application/json'
data = json.dumps(body).encode()
req = urllib.request.Request(self.url + path, data=data, headers=headers, method=method)
try:
with self.opener.open(req, timeout=30) as response:
data = response.read(21 * 1024 * 1024)
if len(data) >= 21 * 1024 * 1024:
raise ClientError('response exceeds artifact limit')
return data if binary else json.loads(data)
except urllib.error.HTTPError as exc:
try:
detail = json.loads(exc.read(32768))
except (ValueError, OSError):
detail = {'error': 'http_error'}
raise ClientError(f'HTTP {exc.code}: {detail.get("error", "service_unavailable")}') from None
except (urllib.error.URLError, TimeoutError, OSError):
raise ClientError('service_unreachable: establish the documented tunnel and retry') from None
def save(path, data):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(data, stream, indent=2)
stream.write('\n')
def handle(path):
data = json.loads(path.read_text())
if not re.fullmatch(r'[a-f0-9-]{36}', data['id']) or not re.fullmatch(r'[a-f0-9]{64}', data['token']):
raise ClientError('invalid local run handle')
return data
def collect(client, run, out):
receipt = client.request('/v1/runs/' + run['id'], token=run['token'])
downloads = []
for artifact in receipt.get('artifacts', []):
name = artifact['name']
if not re.fullmatch(r'[a-z0-9-]+\.png', name):
raise ClientError('invalid artifact name')
data = client.request('/v1/runs/' + run['id'] + '/artifacts/' + name, token=run['token'], binary=True)
if len(data) != artifact['bytes']:
raise ClientError('artifact size mismatch')
fd = os.open(out / name, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
downloads.append({'name': name, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()})
receipt['downloads'] = downloads
save(out / 'receipt.json', receipt)
return receipt
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--url', default=os.environ.get('RAPP_PLAYWRIGHT_URL', 'http://127.0.0.1:18081'))
subs = parser.add_subparsers(dest='command', required=True)
subs.add_parser('preflight')
for cmd in ('trial', 'run'):
sub = subs.add_parser(cmd)
sub.add_argument('--out', type=Path, required=True, help='New private artifact directory')
if cmd == 'run':
sub.add_argument('--spec', type=Path, required=True)
for cmd in ('status', 'cancel', 'download'):
sub = subs.add_parser(cmd)
sub.add_argument('--handle', type=Path, required=True)
args = parser.parse_args()
client = Client(args.url)
if args.command == 'preflight':
print(json.dumps(client.request('/v1/preflight'), indent=2))
return 0
if args.command in ('status', 'cancel', 'download'):
run = handle(args.handle)
if args.command == 'download':
result = collect(client, run, args.handle.parent)
else:
result = client.request('/v1/runs/' + run['id'], method='DELETE' if args.command == 'cancel' else 'GET', token=run['token'])
print(json.dumps(result, indent=2))
return 0
preflight = client.request('/v1/preflight')
if not preflight['available'] or preflight['api_version'] != '1':
raise ClientError('service_not_ready_or_incompatible')
spec = client.request('/v1/trial') if args.command == 'trial' else json.loads(args.spec.read_text())
args.out.mkdir(parents=True, mode=0o700, exist_ok=False)
save(args.out / 'preflight.json', preflight)
save(args.out / 'invocation.json', {'argv': sys.argv, 'service_url': args.url})
run = client.request('/v1/runs', 'POST', spec)
save(args.out / 'run.json', run) # Contains the capability; never print or publish.
deadline = time.monotonic() + spec.get('timeout_ms', 60000) / 1000 + 520
try:
while True:
result = client.request('/v1/runs/' + run['id'], token=run['token'])
if result['status'] in ('passed', 'failed', 'cancelled', 'interrupted'):
break
if time.monotonic() > deadline:
raise ClientError('client_wait_timeout: run cancellation requested')
time.sleep(0.25)
except (KeyboardInterrupt, ClientError):
try:
client.request('/v1/runs/' + run['id'], 'DELETE', token=run['token'])
except ClientError:
pass # Server time budget still bounds disconnected clients.
raise
result = collect(client, run, args.out)
print(json.dumps({'id': result['id'], 'status': result['status'], 'error': result['error'],
'receipt': str(args.out / 'receipt.json'), 'artifacts': result['downloads']}, indent=2))
return 0 if result['status'] == 'passed' else 2 if result['error'] == 'ui_assertion_failed' else 3
if __name__ == '__main__':
try:
sys.exit(main())
except (ClientError, ValueError, OSError, KeyError) as exc:
print(json.dumps({'error': str(exc)}), file=sys.stderr)
sys.exit(3)
except KeyboardInterrupt:
print(json.dumps({'error': 'cancelled_by_client'}), file=sys.stderr)
sys.exit(4)

50
declarations/rapp.yaml Normal file
View file

@ -0,0 +1,50 @@
kind: managed-workload-package
repo_family: rapp
rapp_id: rapp-playwright
repo: rapp-playwright
ownership_repo: railiance-platform
contract_version: 1.0.0
readiness_state: draft
workload_identity:
name: playwright
package_type: manifest-managed-platform-service
app_version: 0.1.0
service_account: playwright
notes:
- Service implementation and consumer contract remain in rapp-playwright.
- Founder selected railiance-platform as lifecycle owner on 2026-10-13; admission confirmation remains requested in State Hub c82d75f3-3191-410d-a8c8-fd647bf7f3ef.
data_classification: internal
criticality: low
primary_rail: rail-kubernetes
supported_rails: [rail-kubernetes]
bound_reefs: []
runtime_dependencies: [kubernetes-api, forgejo-oci-registry, local-path-storage]
exposure:
posture: private
composition:
purpose: Provide bounded Playwright UI test jobs with responsive screenshots and keyboard evidence to coding agents.
member_repos:
- repo: rapp-playwright
role: browser service, consumer client and managed package
deployables: [playwright]
upstream_components:
- name: playwright
source: https://github.com/microsoft/playwright
version: 1.64.0
- name: playwright-browser-image
source: mcr.microsoft.com/playwright
version: sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f
rollout_contract:
default_mode: kubectl-server-side-apply
commands: [make render, make server-dry-run, make deploy]
production_gate: founder-authorized-workplan-and-platform-admission
smoke_contract:
required: [browser-launch, responsive-keyboard-trial, run-token-isolation, origin-gate, bounded-cancellation, restart-recovery, immutable-image-match]
commands: [make test, make test-integration, make verify-live]
rollback_contract:
order: [previous-immutable-image-digest, apply-reviewed-git-revision]
commands: [make rollback IMAGE_REFERENCE=<previous-digest-reference>]
source_documents:
- {repo: rapp-playwright, path: INTENT.md}
- {repo: rapp-playwright, path: docs/consumer-contract.md}
- {repo: railiance-master, path: schemas/rapp.schema.json}

205
docs/consumer-contract.md Normal file
View file

@ -0,0 +1,205 @@
# 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.

View file

@ -0,0 +1,67 @@
# Content-explorer pilot
Consumer requirement: `CONTENT-WP-0000-T04`, responsive screenshots and a keyboard
walkthrough. Baseline SHA: `b594d7433bf4e408b44215e25e5304696d6f840e`, as recorded
in content-explorer's `research/baseline/ONCO_PIN`.
The first gate is the provider fixture trial: two viewports, Tab/Enter, observed
focus and counter update, downloaded PNGs and a versioned receipt. It checks the
service independently of OnCo. It does not complete the consumer task.
The second gate uses a runnable instance of the **pinned** OnCo checkout. Start
its UI without invoking the corpus/network publication pipeline; document the
exact launch path and ensure the provider can reach the target. Use its pinned
page markup to write selectors and keyboard assertions. The consumer retains
the upstream license/data boundary and decides which captures it may retain.
Candidate routes from the consumer baseline are `/`, `/cancers/`,
`/cancers/head-and-neck/`, `/query/` and `/graph/`. Capture desktop and narrow
screens, verify at least one meaningful rendered element, and document a real
keyboard sequence and its observed result. Keep the pinned-source receipt
separate from any exploratory run against `https://onco.cc/`, whose revision can
change. A live website receipt must not be described as proof of the pinned SHA.
Consumer acceptance checklist:
- Run URL and pinned source SHA, target launch command, browser/service versions.
- Commands, timestamps, run ID, downloaded artifacts and hashes.
- Viewport dimensions and named page/route for each capture.
- Keyboard steps with focus and action assertions; any failed step explained.
- Separate infrastructure and semantic/visual mismatches.
- Consumer review and task disposition in content-explorer, without automatic
closure by the provider.
## Executed home-page pilot — 2026-10-13
Passed run `1e89ce26-fc3c-4eb3-8280-b36800b92efc`, using the pinned SHA above,
Playwright 1.64.0 / Chromium 156.0.8078.4 in the local Docker provider. The
original consumer checkout was left untouched. A temporary copy of its existing
checkout and installed dependencies ran with:
```sh
NODE_OPTIONS=--max-old-space-size=6144 /tmp/rapp-playwright-onco/node_modules/.bin/next dev --hostname 0.0.0.0 --port 3300
python3 client/rapp_playwright.py run --spec examples/content-explorer-onco.json --out .local/content-explorer-onco-01
```
The launch command ran from `/tmp/rapp-playwright-onco`; it did not invoke the
corpus generation/publication scripts. The provider explicitly allowed
`http://host.docker.internal:3300` as a private target and used Docker's host
gateway mapping. This is local connectivity evidence, not a Railiance tunnel.
All 16 steps passed across 1280×800 and 390×844: the home heading matched,
baseline screenshots were captured, Tab focused `a.skip-link`, Enter navigated
to `/#main`, and post-keyboard screenshots were captured. Six PNG downloads
were hashed; duplicate final/keyboard images share hashes. The URL assertion
proves navigation to the anchor, not a complete accessibility audit.
Retained [receipt](evidence/2026-10-13-content-explorer-pilot/receipt.json),
[preflight](evidence/2026-10-13-content-explorer-pilot/preflight.json),
[invocation](evidence/2026-10-13-content-explorer-pilot/invocation.json),
[desktop baseline](evidence/2026-10-13-content-explorer-pilot/desktop-2-baseline.png),
[narrow baseline](evidence/2026-10-13-content-explorer-pilot/narrow-2-baseline.png),
[desktop result](evidence/2026-10-13-content-explorer-pilot/desktop-final.png) and
[narrow result](evidence/2026-10-13-content-explorer-pilot/narrow-final.png) exclude
the secret client run handle. These are home-page smoke results. Other routes,
search, graph interaction and consumer visual acceptance remain unverified.
The handoff to content-explorer is message
`621635c9-0a72-4a88-9fd6-2ee1fbddc68f`; its T04 remains consumer-owned.

View file

@ -0,0 +1,13 @@
docker run --rm --init --user 1000:1000 --read-only --tmpfs /tmp:rw,size=512m --shm-size=256m --memory=1g --cpus=1 --cap-drop ALL --security-opt no-new-privileges --mount type=bind,src=/home/worsch/rapp-playwright,dst=/app,readonly -w /app mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f node --test tests/integration.mjs
✔ real browser produces responsive keyboard evidence, fresh state and scoped artifacts (34881.527994ms)
✔ UI failures, unreachable targets and forbidden redirects remain distinguishable (33199.838535ms)
✔ queue limits, cancellation, timeout, worker crash and subsequent recovery (24287.921891ms)
✔ restart marks abandoned runs interrupted and expiry removes artifacts (6352.642442ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 99315.842719

View file

@ -0,0 +1,86 @@
npm run check
> rapp-playwright@0.1.0 check
> node --check src/server.mjs && node --check src/worker.mjs && node --check src/proxy.mjs
python3 -m py_compile client/rapp_playwright.py tools/render.py
npm test
> rapp-playwright@0.1.0 test
> node --test tests/*.test.mjs
✔ validated contract requires explicit protocol, allowed origin, bounded input and assertions (3.01824ms)
✔ network gate denies non-public addresses unless explicitly granted (6.721745ms)
✔ proxy pins DNS and denies unlisted origins and private addresses before connection (49.97023ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 196.640985
docker build -t forgejo.coulomb.social/coulomb/rapp-playwright:0.1.0 .
#0 building with "default" instance using docker driver
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 773B done
#1 DONE 0.0s
#2 [internal] load metadata for mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f
#2 DONE 0.1s
#3 [internal] load .dockerignore
#3 transferring context: 109B done
#3 DONE 0.0s
#4 [internal] load build context
#4 DONE 0.0s
#5 [ 1/10] FROM mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f
#5 resolve mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f 0.1s done
#5 DONE 0.1s
#4 [internal] load build context
#4 transferring context: 18.23kB 0.0s done
#4 DONE 0.0s
#6 [ 3/10] RUN apt-get update && apt-get install -y --no-install-recommends tini=0.19.0-1 && rm -rf /var/lib/apt/lists/*
#6 CACHED
#7 [ 4/10] COPY package.json package-lock.json ./
#7 CACHED
#8 [ 2/10] WORKDIR /app
#8 CACHED
#9 [ 5/10] RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
#9 CACHED
#10 [ 6/10] COPY src ./src
#10 DONE 0.1s
#11 [ 7/10] COPY fixtures ./fixtures
#11 DONE 0.1s
#12 [ 8/10] COPY client ./client
#12 DONE 0.1s
#13 [ 9/10] COPY --chmod=0555 tools/chromium-launcher.py ./tools/chromium-launcher.py
#13 DONE 0.1s
#14 [10/10] RUN mkdir /data && chown pwuser:pwuser /data
#14 DONE 0.5s
#15 exporting to image
#15 exporting layers
#15 exporting layers 0.5s done
#15 exporting manifest sha256:20d8310d44cd807579aca06df01090a7bb27892ce387519f9957e4dd67f64cc7 0.0s done
#15 exporting config sha256:5bda1139fda091648b1e984f6be8119291ad5616263b206d62ac7986ba2762b3 0.0s done
#15 exporting attestation manifest sha256:3267896f3b778dedce0b2c92ea1c67b64a6e168417aa422c4750dcbcb0d165f5 0.1s done
#15 exporting manifest list sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441
#15 exporting manifest list sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441 0.0s done
#15 naming to forgejo.coulomb.social/coulomb/rapp-playwright:0.1.0 done
#15 unpacking to forgejo.coulomb.social/coulomb/rapp-playwright:0.1.0
#15 unpacking to forgejo.coulomb.social/coulomb/rapp-playwright:0.1.0 0.2s done
#15 DONE 1.0s

View file

@ -0,0 +1,17 @@
{
"id": "621635c9-0a72-4a88-9fd6-2ee1fbddc68f",
"from_agent": "rapp-playwright",
"to_agent": "content-explorer",
"subject": "Pinned OnCo responsive and keyboard pilot evidence available",
"body": "WP-0004-T02 provider pilot passed on pinned OnCo SHA b594d7433bf4e408b44215e25e5304696d6f840e. Original upstream-onco checkout untouched; temporary copy served with Next dev on port3300. examples/content-explorer-onco.json asserted home heading, captured 1280x800 / 390x844, Tab focus on a.skip-link, Enter and #main URL. Run 1e89ce26-fc3c-4eb3-8280-b36800b92efc passed all16steps and6PNGs. Sanitized receipts and selected PNGs: /home/worsch/rapp-playwright/docs/evidence/2026-10-13-content-explorer-pilot/. Full local outputs .local/content-explorer-onco-01 (private run handle must not be published). Please review for CONTENT-WP-0000-T04; you own acceptance and any further route/keyboard coverage. This is workstation-container evidence, not managed Railiance or current live onco.cc. Provider will retain exact launch command and limits in docs/content-explorer-pilot.md.",
"thread_id": null,
"read_at": null,
"archived_at": null,
"created_at": "2026-10-13T17:12:27.179074Z",
"kind": "message",
"expires_at": null,
"supersedes_id": null,
"reader": null,
"delivered_at": null,
"acknowledged_at": null
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

View file

@ -0,0 +1,11 @@
{
"argv": [
"client/rapp_playwright.py",
"run",
"--spec",
"examples/content-explorer-onco.json",
"--out",
".local/content-explorer-onco-01"
],
"service_url": "http://127.0.0.1:18081"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View file

@ -0,0 +1,25 @@
{
"available": true,
"error": null,
"checked_at": "2026-10-13T16:43:51.651Z",
"browser_version": "156.0.8078.4",
"api_version": "1",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"execution_location": "workstation-container",
"release": "development",
"allowed_origins": [
"http://host.docker.internal:3300",
"https://onco.cc"
],
"fixture": true,
"active": 0,
"queued": 0,
"limits": {
"concurrency": 1,
"max_queue": 4,
"max_timeout_ms": 120000,
"retention_ms": 3600000,
"max_runs": 64
}
}

View file

@ -0,0 +1,214 @@
{
"id": "1e89ce26-fc3c-4eb3-8280-b36800b92efc",
"api_version": "1",
"status": "passed",
"error": null,
"label": "content-explorer-pinned-onco-home",
"target": "http://host.docker.internal:3300/",
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
},
{
"name": "narrow",
"width": 390,
"height": 844
}
],
"timeout_ms": 120000,
"created_at": "2026-10-13T16:43:53.447Z",
"expires_at": "2026-10-13T17:43:53.447Z",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"release": "development",
"execution_location": "workstation-container",
"browser_version": "156.0.8078.4",
"completed_steps": 16,
"steps": [
{
"viewport": "desktop",
"index": 0,
"action": "visible",
"status": "passed",
"at": "2026-10-13T16:44:10.155Z",
"selector": "h1"
},
{
"viewport": "desktop",
"index": 1,
"action": "text",
"status": "passed",
"at": "2026-10-13T16:44:14.051Z",
"selector": "h1"
},
{
"viewport": "desktop",
"index": 2,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:44:14.353Z"
},
{
"viewport": "desktop",
"index": 3,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:44:17.012Z",
"key": "Tab"
},
{
"viewport": "desktop",
"index": 4,
"action": "focused",
"status": "passed",
"at": "2026-10-13T16:44:17.073Z",
"selector": "a.skip-link"
},
{
"viewport": "desktop",
"index": 5,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:44:17.255Z",
"key": "Enter"
},
{
"viewport": "desktop",
"index": 6,
"action": "url",
"status": "passed",
"at": "2026-10-13T16:44:17.296Z"
},
{
"viewport": "desktop",
"index": 7,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:44:17.296Z"
},
{
"viewport": "narrow",
"index": 0,
"action": "visible",
"status": "passed",
"at": "2026-10-13T16:44:30.719Z",
"selector": "h1"
},
{
"viewport": "narrow",
"index": 1,
"action": "text",
"status": "passed",
"at": "2026-10-13T16:44:31.430Z",
"selector": "h1"
},
{
"viewport": "narrow",
"index": 2,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:44:31.588Z"
},
{
"viewport": "narrow",
"index": 3,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:44:34.905Z",
"key": "Tab"
},
{
"viewport": "narrow",
"index": 4,
"action": "focused",
"status": "passed",
"at": "2026-10-13T16:44:34.968Z",
"selector": "a.skip-link"
},
{
"viewport": "narrow",
"index": 5,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:44:35.026Z",
"key": "Enter"
},
{
"viewport": "narrow",
"index": 6,
"action": "url",
"status": "passed",
"at": "2026-10-13T16:44:35.085Z"
},
{
"viewport": "narrow",
"index": 7,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:44:35.085Z"
}
],
"artifacts": [
{
"name": "desktop-2-baseline.png",
"bytes": 342951
},
{
"name": "desktop-7-keyboard-skip.png",
"bytes": 314186
},
{
"name": "desktop-final.png",
"bytes": 314186
},
{
"name": "narrow-2-baseline.png",
"bytes": 140972
},
{
"name": "narrow-7-keyboard-skip.png",
"bytes": 129764
},
{
"name": "narrow-final.png",
"bytes": 129764
}
],
"started_at": "2026-10-13T16:43:53.457Z",
"blocked_requests": 0,
"finished_at": "2026-10-13T16:44:36.275Z",
"downloads": [
{
"name": "desktop-2-baseline.png",
"bytes": 342951,
"sha256": "c36fb610e09bf04ba76e9755103ab2c36d39bfcfc286be8590c517c9fa7705b8"
},
{
"name": "desktop-7-keyboard-skip.png",
"bytes": 314186,
"sha256": "a64096394d8750ef422b1f5b6b0f939607cb13a57703f5b2d39c2be84bf2e3c7"
},
{
"name": "desktop-final.png",
"bytes": 314186,
"sha256": "a64096394d8750ef422b1f5b6b0f939607cb13a57703f5b2d39c2be84bf2e3c7"
},
{
"name": "narrow-2-baseline.png",
"bytes": 140972,
"sha256": "7c3a5278d3dcd35413658fbfceb6d19e176439f2ffc2e628f7bbfea6ee6b641f"
},
{
"name": "narrow-7-keyboard-skip.png",
"bytes": 129764,
"sha256": "865fe283ca6a38742ff176c67f4eeee68f37ac92d8d60a6e67fb8b00778ba58e"
},
{
"name": "narrow-final.png",
"bytes": 129764,
"sha256": "865fe283ca6a38742ff176c67f4eeee68f37ac92d8d60a6e67fb8b00778ba58e"
}
]
}

View file

@ -0,0 +1,18 @@
{
"workplan_id": "98fb515e-9239-57d5-9ffa-8a5c5a86ed90",
"id": "90c6d03a-6308-4741-ab71-ac837e242e85",
"topic_id": "595afc64-bd28-47bf-aafb-ba230b28371b",
"title": "Use bounded browser jobs and assign platform lifecycle ownership",
"description": "Implement protocol-v1 HTTP jobs with Python stdlib client, pinned Playwright 1.64.0 / Chromium image, per-run process/context, private RBAC port-forward and scoped run capabilities. Founder explicitly selected railiance-platform as lifecycle owner on 2026-10-13; service implementation remains in rapp-playwright. Managed activation still requires platform admission.",
"decision_type": "made",
"status": "resolved",
"rationale": "Independent client avoids matching local browser installs; bounded jobs constrain network and resource use. See docs/consumer-contract.md. Ownership follows the founder response, not inferred owner approval.",
"decided_by": "codex (implementation contract); founder (lifecycle owner selection)",
"decided_at": null,
"deadline": null,
"escalation_note": null,
"superseded_by": null,
"created_at": "2026-10-13T17:15:54.752167Z",
"updated_at": "2026-10-13T17:15:54.752167Z",
"workstream_id": "98fb515e-9239-57d5-9ffa-8a5c5a86ed90"
}

View file

@ -0,0 +1,39 @@
{
"url": "https://reuse.coulomb.social/v1/federated",
"matches": [
{
"id": "capability.infotech.headless-browser",
"name": "Headless Browser Automation",
"summary": "Headless browser automation with Playwright and Chromium for UI testing, screenshots and page extraction; recommended default based on existing whynot-design, coulomb-social, core-hub and frontend-patterns implementations. A rapp-playwright-provided service will be available shortly (announced 2026-10-13; planned, not yet verified available).",
"vector": "D3 / A2 / C2 / R2",
"domain": "infotech",
"status": "reviewed",
"owner": "reuse-surface",
"path": "registry/capabilities/capability.infotech.headless-browser.md",
"tags": [
"headless",
"browser",
"automation",
"playwright",
"chromium",
"testing",
"screenshots",
"extraction",
"firefox",
"webkit",
"selenium",
"puppeteer"
],
"consumption_modes": [
"informational",
"source adaptation",
"JavaScript library",
"Python library"
],
"source_repo": "reuse-surface",
"source_url": "https://forgejo.coulomb.social/coulomb/reuse-surface/raw/branch/main/registry/indexes/capabilities.yaml",
"source_index": "/data/cache/reuse-surface.yaml"
}
],
"composed_at": "2026-10-13T14:03:25+00:00"
}

View file

@ -0,0 +1,49 @@
{
"action": "docker restart rapp-playwright-final",
"preflight": {
"available": true,
"error": null,
"checked_at": "2026-10-13T17:17:36.100Z",
"browser_version": "156.0.8078.4",
"api_version": "1",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"execution_location": "workstation-release-check",
"release": "sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441",
"allowed_origins": [],
"fixture": true,
"active": 0,
"queued": 0,
"limits": {
"concurrency": 1,
"max_queue": 4,
"max_timeout_ms": 120000,
"retention_ms": 3600000,
"max_runs": 64
}
},
"retrieved_run_id": "35124424-950a-4339-adea-45525b470cab",
"status": "passed",
"artifacts": [
{
"name": "desktop-6-keyboard-result.png",
"bytes": 26622,
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad"
},
{
"name": "desktop-final.png",
"bytes": 26622,
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad"
},
{
"name": "narrow-6-keyboard-result.png",
"bytes": 24280,
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8"
},
{
"name": "narrow-final.png",
"bytes": 24280,
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8"
}
]
}

View file

@ -0,0 +1,40 @@
{
"observed_at": "2026-10-13T17:20:07.614706+00:00",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"browser_version": "156.0.8078.4",
"local_image": "forgejo.coulomb.social/coulomb/rapp-playwright@sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441",
"registry_publication": "blocked: blob PUT HTTP 499 and 500; remote manifest inspect returns manifest unknown",
"unit_tests": {
"passed": 3,
"command": "make test"
},
"browser_integration": {
"passed": 4,
"command": "make test-integration",
"log": "2026-10-13-browser-integration.log"
},
"additional_browser_test": {
"passed": 1,
"name": "missing browser fails preflight and prevents admission",
"log": "2026-10-13-missing-browser.log"
},
"family_declaration_validation": "2 declaration(s) ok with rail-kubernetes dependency",
"render": "passed with exact candidate digest; floating references rejected",
"kubernetes_client_dry_run": "7 objects validated; no objects persisted",
"kubernetes_server_dry_run": "not run: namespace absent and admission pending",
"cluster_mutations": "none",
"capacity": {
"node_ip": "92.205.62.239",
"node_ready": true,
"cpu_request_m": 3460,
"cpu_allocatable_m": 4000,
"memory_requests_mi": 9472
},
"final_container": "rapp-playwright-final",
"local_url": "http://127.0.0.1:18083",
"independent_final_run": "35124424-950a-4339-adea-45525b470cab",
"restart": "completed receipt plus four hashed PNGs retrieved after docker restart",
"platform_request": "e77247e5-ce25-4711-a64f-af81a9f7cbb1",
"managed_status": "not deployed; namespace playwright absent"
}

View file

@ -0,0 +1,67 @@
# Implementation verification — 2026-10-13
The bounded service, Python client and managed package are implemented. Local
runtime and independent consumption passed. Image publication and managed
activation are blocked by external delivery dependencies; neither is claimed.
[Machine-readable checks](2026-10-13-implementation-checks.json) record the
candidate image identity, commands, resource observations and handoff IDs.
- `make test`: syntax/compile checks and 3 unit tests passed.
- `make test-integration`: 4 real-browser cases passed in 99 seconds under
non-root, read-only Docker, dropped capabilities, no-new-privileges, 1 CPU,
1Gi memory and bounded tmpfs. [Log](2026-10-13-browser-integration.log).
- The added missing-browser case separately passed: HTTP 503 launch preflight
and rejected run admission. [Log](2026-10-13-missing-browser.log).
- [Build log](2026-10-13-build.log): final pinned runtime image built successfully.
- [Independent trials](2026-10-13-independent-local-consumer.md): initial and
final-image consumer trials, keyboard/screenshots and failure cases passed.
- [Final-image restart](2026-10-13-final-image-restart.json): availability recovered
and completed run/artifacts remained retrievable from its retained volume.
- [Pinned OnCo pilot](../content-explorer-pilot.md): home-page responsive and
skip-link evidence passed; content-explorer retains its own acceptance.
The integration suite checks fresh cookie/localStorage state, foreign-token
receipt/artifact denial, protocol mismatch, assertion failure, unavailable target,
blocked redirect without request leakage, concurrent admission bounds, queued
and active cancellation, hard timeout, forced worker crash with no remaining
browser process group, subsequent recovery, interrupted persisted work and
expiry deletion. Temporary browser profiles and home directories are scoped to
the supervised worker and removed after its exit. This is not proof of safety
for hostile pages or mutually untrusted tenants; Chromium's OS sandbox is off.
Package schema validation passed against railiance-master with rail-kubernetes
references. Digest rendering and Kubernetes client validation passed for all
seven objects. No server-side admission success is claimed. The namespace does
not exist and no cluster object was created in this session. At the recorded
capacity check, railiance01 was Ready with 3460m CPU requested out of 4000m;
the package requests 100m and uses Recreate. Recheck before deployment.
## Publication and owner dependencies
Three OCI upload attempts encountered final blob PUT failures (HTTP 499, then
HTTP 500); smaller layers uploaded. Remote manifest inspection returned
`manifest unknown`. Local image index:
`sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441`.
This digest identifies a built image, not an available registry release.
The founder selected railiance-platform lifecycle ownership. Admission request
`c82d75f3-3191-410d-a8c8-fd647bf7f3ef` and the final
[platform handoff](2026-10-13-platform-implementation-notification.json) await
an owner response. Cluster access binding, live restart and rollback remain
WP-0004-T01 work. There is no previous managed release for cross-version rollback.
The [reuse-surface handoff](2026-10-13-reuse-surface-implementation-notification.json)
asks the owner to publish verified local provider references without claiming
managed availability. Readback at 19:18 Europe/Berlin still showed the source-only
D3/A2/C2/R2 entry and planned provider; no maintained update was inferred from
message delivery. The [published discovery readback](2026-10-13-discovery-readback.json)
also returned the planned provider and D3/A2/C2/R2 from the live federated API.
WP-0004-T03 remains open. The
[content-explorer handoff](2026-10-13-content-explorer-implementation-notification.json)
delivers the home-page evidence without closing CONTENT-WP-0000-T04.
The development provider, failed intermediate check container and temporary
OnCo server were stopped. The final local provider remains on loopback port
18083 for inspection; it is a workstation process, not a managed endpoint.
Server artifacts expire after one hour; the linked safe evidence is retained.

View file

@ -0,0 +1,38 @@
[
{
"name": "desktop-6-keyboard-result.png",
"dimensions": [
1280,
800
],
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad",
"bytes": 26622
},
{
"name": "desktop-final.png",
"dimensions": [
1280,
800
],
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad",
"bytes": 26622
},
{
"name": "narrow-6-keyboard-result.png",
"dimensions": [
390,
844
],
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8",
"bytes": 24280
},
{
"name": "narrow-final.png",
"dimensions": [
390,
844
],
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8",
"bytes": 24280
}
]

View file

@ -0,0 +1,29 @@
{
"trial_exit": 0,
"intentional_ui_failure_exit": 2,
"failed_ui_status_command_exit": 0,
"unavailable_service_preflight": {
"argv": [
"python3",
"client/rapp_playwright.py",
"--url",
"http://127.0.0.1:18089",
"preflight"
],
"exit": 3,
"response": {
"error": "service_unreachable: establish the documented tunnel and retry"
}
},
"trial_directory_mode": "0700",
"trial_handle_mode": "0600",
"external_target_tested": false,
"status_without_url_override": {
"exit": 3,
"response": {
"error": "HTTP 404: not_found"
},
"service_url": "http://127.0.0.1:18081"
},
"failed_ui_status_command_url": "http://127.0.0.1:18083"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,24 @@
{
"api_version": "1",
"target": "fixture",
"label": "independent-final-ui-failure",
"timeout_ms": 30000,
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
}
],
"steps": [
{
"action": "screenshot",
"name": "before-failure"
},
{
"action": "text",
"selector": "#count",
"value": "99999"
}
]
}

View file

@ -0,0 +1,11 @@
{
"argv": [
"client/rapp_playwright.py",
"--url",
"http://127.0.0.1:18083",
"trial",
"--out",
".local/independent-final-01"
],
"service_url": "http://127.0.0.1:18083"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,22 @@
{
"available": true,
"error": null,
"checked_at": "2026-10-13T17:14:29.108Z",
"browser_version": "156.0.8078.4",
"api_version": "1",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"execution_location": "workstation-release-check",
"release": "sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441",
"allowed_origins": [],
"fixture": true,
"active": 0,
"queued": 0,
"limits": {
"concurrency": 1,
"max_queue": 4,
"max_timeout_ms": 120000,
"retention_ms": 3600000,
"max_runs": 64
}
}

View file

@ -0,0 +1,184 @@
{
"id": "35124424-950a-4339-adea-45525b470cab",
"api_version": "1",
"status": "passed",
"error": null,
"label": "independent-browser-trial",
"target": "fixture",
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
},
{
"name": "narrow",
"width": 390,
"height": 844
}
],
"timeout_ms": 60000,
"created_at": "2026-10-13T17:14:50.050Z",
"expires_at": "2026-10-13T18:14:50.050Z",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"release": "sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441",
"execution_location": "workstation-release-check",
"browser_version": "156.0.8078.4",
"completed_steps": 14,
"steps": [
{
"viewport": "desktop",
"index": 0,
"action": "visible",
"status": "passed",
"at": "2026-10-13T17:14:56.554Z",
"selector": "h1"
},
{
"viewport": "desktop",
"index": 1,
"action": "text",
"status": "passed",
"at": "2026-10-13T17:14:57.033Z",
"selector": "#count"
},
{
"viewport": "desktop",
"index": 2,
"action": "press",
"status": "passed",
"at": "2026-10-13T17:14:57.233Z",
"key": "Tab"
},
{
"viewport": "desktop",
"index": 3,
"action": "focused",
"status": "passed",
"at": "2026-10-13T17:14:57.247Z",
"selector": "#increment"
},
{
"viewport": "desktop",
"index": 4,
"action": "press",
"status": "passed",
"at": "2026-10-13T17:14:57.341Z",
"key": "Enter"
},
{
"viewport": "desktop",
"index": 5,
"action": "text",
"status": "passed",
"at": "2026-10-13T17:14:57.354Z",
"selector": "#count"
},
{
"viewport": "desktop",
"index": 6,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T17:14:57.441Z"
},
{
"viewport": "narrow",
"index": 0,
"action": "visible",
"status": "passed",
"at": "2026-10-13T17:15:00.135Z",
"selector": "h1"
},
{
"viewport": "narrow",
"index": 1,
"action": "text",
"status": "passed",
"at": "2026-10-13T17:15:00.349Z",
"selector": "#count"
},
{
"viewport": "narrow",
"index": 2,
"action": "press",
"status": "passed",
"at": "2026-10-13T17:15:00.547Z",
"key": "Tab"
},
{
"viewport": "narrow",
"index": 3,
"action": "focused",
"status": "passed",
"at": "2026-10-13T17:15:00.633Z",
"selector": "#increment"
},
{
"viewport": "narrow",
"index": 4,
"action": "press",
"status": "passed",
"at": "2026-10-13T17:15:00.644Z",
"key": "Enter"
},
{
"viewport": "narrow",
"index": 5,
"action": "text",
"status": "passed",
"at": "2026-10-13T17:15:00.654Z",
"selector": "#count"
},
{
"viewport": "narrow",
"index": 6,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T17:15:00.734Z"
}
],
"artifacts": [
{
"name": "desktop-6-keyboard-result.png",
"bytes": 26622
},
{
"name": "desktop-final.png",
"bytes": 26622
},
{
"name": "narrow-6-keyboard-result.png",
"bytes": 24280
},
{
"name": "narrow-final.png",
"bytes": 24280
}
],
"started_at": "2026-10-13T17:14:50.055Z",
"blocked_requests": 13,
"finished_at": "2026-10-13T17:15:01.861Z",
"downloads": [
{
"name": "desktop-6-keyboard-result.png",
"bytes": 26622,
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad"
},
{
"name": "desktop-final.png",
"bytes": 26622,
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad"
},
{
"name": "narrow-6-keyboard-result.png",
"bytes": 24280,
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8"
},
{
"name": "narrow-final.png",
"bytes": 24280,
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8"
}
]
}

View file

@ -0,0 +1,13 @@
{
"argv": [
"client/rapp_playwright.py",
"--url",
"http://127.0.0.1:18083",
"run",
"--spec",
".local/independent-final-01/failure-spec.json",
"--out",
".local/independent-final-ui-failure-01"
],
"service_url": "http://127.0.0.1:18083"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,57 @@
{
"id": "75c6b50e-94a1-467f-98fc-7e8937f4362e",
"api_version": "1",
"status": "failed",
"error": "ui_assertion_failed",
"label": "independent-final-ui-failure",
"target": "fixture",
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
}
],
"timeout_ms": 30000,
"created_at": "2026-10-13T17:15:12.803Z",
"expires_at": "2026-10-13T18:15:12.803Z",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"release": "sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441",
"execution_location": "workstation-release-check",
"browser_version": "156.0.8078.4",
"completed_steps": 1,
"steps": [
{
"viewport": "desktop",
"index": 0,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T17:15:22.422Z"
},
{
"viewport": "desktop",
"index": 1,
"action": "text",
"status": "failed",
"at": "2026-10-13T17:15:23.542Z",
"selector": "#count"
}
],
"artifacts": [
{
"name": "desktop-0-before-failure.png",
"bytes": 25740
}
],
"started_at": "2026-10-13T17:15:12.806Z",
"blocked_requests": 9,
"finished_at": "2026-10-13T17:15:29.015Z",
"downloads": [
{
"name": "desktop-0-before-failure.png",
"bytes": 25740,
"sha256": "141dcadca4774da5fefec835bb789c5221685d3f453070a593e69aa6ad4782ce"
}
]
}

View file

@ -0,0 +1,6 @@
{
"http_status": 404,
"body": {
"error": "not_found"
}
}

View file

@ -0,0 +1,140 @@
# Independent local consumer trial — 2026-10-13
The fresh consumer agent completed the documented Python/HTTP trial against
`http://127.0.0.1:18081` without installing Playwright or a browser, reading
provider source, or copying sibling fixtures. This proves the **local Docker
container** consumer path only. Preflight and every receipt identify
`execution_location: workstation-container`, `release: development`, service
0.1.0, Playwright 1.64.0 and Chromium 156.0.8078.4. It does not prove a Railiance
deployment or accept content-explorer's own task.
The agent first read the repository instructions and
[consumer contract](../consumer-contract.md), then ran:
```sh
python3 client/rapp_playwright.py preflight
python3 client/rapp_playwright.py trial --out .local/independent-trial-01
```
The fresh output directory was created by the documented client. Preflight
reported browser availability; the retained follow-up
[preflight](2026-10-13-independent-local-consumer/preflight.json) records the
same runtime identity. The successful run
`6b97d75c-0c39-415f-9cf4-f1813c205066` ran from
2026-10-13T16:43:17.124Z to 16:43:30.498Z.
## Evidence
| Check | Observed result |
| --- | --- |
| Keyboard and assertions | All 14 steps passed across desktop and narrow contexts: visible heading, count 0, Tab, focused increment button, Enter, count 1, screenshot. |
| PNG dimensions | Both desktop files are 1280×800; both narrow files are 390×844. Parsed the PNG IHDR directly and independently recomputed each download SHA-256. |
| Screenshot inspection | Narrow final image visibly shows Count: 1 and an orange focus outline around Increment; no horizontal overflow is visible. This is inspection, not automated visual-baseline acceptance. |
| Capability boundary | GET of the successful run with a deliberately incorrect bearer token returned HTTP 404 and `not_found`. |
| Deliberate UI failure | Run `d6b71146-4f82-4d9c-bf72-4d2b672080d2` expected impossible count 99999: `ui_assertion_failed`, client exit 2, failed desktop step index 1/action text, one prior screenshot preserved. |
| Target failure | Run `57b03fc8-4947-43a9-8c9b-058f1c46a9e0` navigated to an intentionally missing path on allowed `http://host.docker.internal:3300`: `target_unreachable`, client exit 3, zero completed steps/artifacts. Receipt does not distinguish HTTP status, connection, DNS or timeout cause. |
| Service connection failure | Preflight against unused local service port 18089 returned `service_unreachable: establish the documented tunnel and retry`, exit 3. The real service was not stopped. |
| Private handles | Each client-created output directory is 0700 and its run.json is 0600. No run handles or bearer capabilities are copied into retained evidence. |
Retained evidence:
- [Successful receipt](2026-10-13-independent-local-consumer/receipt.json),
[invocation](2026-10-13-independent-local-consumer/invocation.json),
[server trial specification](2026-10-13-independent-local-consumer/trial-spec.json)
and [independent artifact checks](2026-10-13-independent-local-consumer/artifact-checks.json).
- [Desktop screenshot](2026-10-13-independent-local-consumer/desktop-final.png)
and [narrow screenshot](2026-10-13-independent-local-consumer/narrow-final.png).
- [Wrong-token response](2026-10-13-independent-local-consumer/wrong-token.json).
- [UI failure receipt](2026-10-13-independent-local-consumer/ui-failure-receipt.json),
[specification](2026-10-13-independent-local-consumer/failure-spec.json),
[invocation](2026-10-13-independent-local-consumer/ui-failure-invocation.json)
and [preserved screenshot](2026-10-13-independent-local-consumer/ui-failure-prior-screenshot.png).
- [Target failure receipt](2026-10-13-independent-local-consumer/target-failure-receipt.json),
[specification](2026-10-13-independent-local-consumer/target-spec.json)
and [invocation](2026-10-13-independent-local-consumer/target-failure-invocation.json).
- [Client exits, unavailable service probe and permissions](2026-10-13-independent-local-consumer/client-outcomes.json).
## Usability findings
1. The first-command path worked without source inspection or dependency setup.
Error categories distinguish assertion, target and service connection failure.
2. At trial time, the contract's general “Client exit codes” wording did not
explain that `status --handle` returns 0 when successfully retrieving a failed
run. Both failed runs above reproduced this. A script must inspect JSON status;
clarify that command-specific behavior or align its exit codes.
3. Target diagnosis identifies the correct failure category but supplies no
sanitized reason such as HTTP status or connection failure. The documentation
points to browser-network reachability; more precise safe diagnostics would
reduce operator round trips. This test makes no claim about which transport
cause produced the observed error.
4. At initial reading, SCOPE.md, AGENTS.md and workplans/README.md still described
a planning-only repository despite the working client and service. The parent
delivery session was informed; those status statements need reconciliation
before publishing managed availability.
This report does not establish lifecycle cleanup, cancellation, restart recovery,
cross-run browser state isolation, hostile-page safety, a managed endpoint, or
consumer application acceptance; those require separate evidence.
## Final release verification — 2026-10-13, 17:14–17:16 UTC
Repeated the independent client trial against the corrected immutable release at
`http://127.0.0.1:18083`, using fresh client output directories:
```sh
python3 client/rapp_playwright.py --url http://127.0.0.1:18083 preflight
python3 client/rapp_playwright.py --url http://127.0.0.1:18083 trial --out .local/independent-final-01
python3 client/rapp_playwright.py --url http://127.0.0.1:18083 run --spec .local/independent-final-01/failure-spec.json --out .local/independent-final-ui-failure-01
```
The release recorded by preflight and both receipts is
`sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441`.
Execution location is `workstation-release-check`; service 0.1.0, Playwright
1.64.0 and Chromium 156.0.8078.4 remain unchanged. Preflight lists no allowed
external origins, and this repeat used only the provider fixture. No external
target, redirect diagnosis or target-unreachable probe was attempted against
this fixture-only container.
Run `35124424-950a-4339-adea-45525b470cab` passed all 14 steps, from
2026-10-13T17:14:50.055Z through 17:15:01.861Z, and the client exited 0. Independent
PNG header/hash checks again confirmed four screenshots: two at 1280×800 and two
at 390×844. Their hashes match the initial successful trial. The run-token
rejection probe again returned HTTP 404 / `not_found`. The new output directory
is 0700 and its private handle is 0600.
Run `75c6b50e-94a1-467f-98fc-7e8937f4362e` deliberately failed the count assertion
at desktop step index 1 with `ui_assertion_failed`, client exit 2, and the prior
1280×800 screenshot preserved with a verified hash. Preflight against unused
service port 18089 again produced `service_unreachable` and exit 3. Retrieving the
failed run with `--url http://127.0.0.1:18083 status --handle` succeeded with exit 0, now explicitly documented
in the consumer contract. AGENTS.md and README.md now describe implemented
verification commands and distinguish local evidence from managed availability.
Recovery usability detail: `--url` must also be repeated on `status` for a
nondefault endpoint. The first status invocation omitted it, queried the default
18081 service and returned HTTP 404 / exit 3; repeating with the correct 18083
override returned the expected failed-run receipt / exit 0. The private handle
does not automatically select the originating service URL.
Retained final evidence:
- [Preflight](2026-10-13-independent-final-consumer/preflight.json),
[successful receipt](2026-10-13-independent-final-consumer/receipt.json),
[invocation](2026-10-13-independent-final-consumer/invocation.json),
[artifact checks](2026-10-13-independent-final-consumer/artifact-checks.json),
[wrong-token response](2026-10-13-independent-final-consumer/wrong-token.json)
and [client outcomes](2026-10-13-independent-final-consumer/client-outcomes.json).
- [Desktop final](2026-10-13-independent-final-consumer/desktop-final.png),
[desktop keyboard](2026-10-13-independent-final-consumer/desktop-6-keyboard-result.png),
[narrow final](2026-10-13-independent-final-consumer/narrow-final.png)
and [narrow keyboard](2026-10-13-independent-final-consumer/narrow-6-keyboard-result.png).
- [Failed assertion receipt](2026-10-13-independent-final-consumer/ui-failure-receipt.json),
[specification](2026-10-13-independent-final-consumer/failure-spec.json),
[invocation](2026-10-13-independent-final-consumer/ui-failure-invocation.json)
and [preserved screenshot](2026-10-13-independent-final-consumer/ui-failure-prior-screenshot.png).
These are local final-release consumer checks. Read-only root configuration,
worker temporary-directory isolation and crash cleanup were reported by the
provider; this consumer trial does not independently inspect or establish those
properties. Managed platform admission remains pending, and no Railiance
deployment or content-explorer acceptance is claimed.

View file

@ -0,0 +1,38 @@
[
{
"name": "desktop-6-keyboard-result.png",
"dimensions": [
1280,
800
],
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad",
"bytes": 26622
},
{
"name": "desktop-final.png",
"dimensions": [
1280,
800
],
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad",
"bytes": 26622
},
{
"name": "narrow-6-keyboard-result.png",
"dimensions": [
390,
844
],
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8",
"bytes": 24280
},
{
"name": "narrow-final.png",
"dimensions": [
390,
844
],
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8",
"bytes": 24280
}
]

View file

@ -0,0 +1,34 @@
{
"trial_exit": 0,
"intentional_ui_failure_exit": 2,
"target_failure_exit": 3,
"failed_ui_status_command_exit": 0,
"failed_target_status_command_exit": 0,
"unavailable_service_preflight": {
"argv": [
"python3",
"client/rapp_playwright.py",
"--url",
"http://127.0.0.1:18089",
"preflight"
],
"exit": 3,
"response": {
"error": "service_unreachable: establish the documented tunnel and retry"
}
},
"permissions": {
".local/independent-trial-01": {
"directory_mode": "0o700",
"handle_mode": "0o600"
},
".local/independent-ui-failure-01": {
"directory_mode": "0o700",
"handle_mode": "0o600"
},
".local/independent-target-failure-01": {
"directory_mode": "0o700",
"handle_mode": "0o600"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,24 @@
{
"api_version": "1",
"target": "fixture",
"label": "independent-intentional-ui-failure",
"timeout_ms": 30000,
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
}
],
"steps": [
{
"action": "screenshot",
"name": "before-failure"
},
{
"action": "text",
"selector": "#count",
"value": "99999"
}
]
}

View file

@ -0,0 +1,9 @@
{
"argv": [
"client/rapp_playwright.py",
"trial",
"--out",
".local/independent-trial-01"
],
"service_url": "http://127.0.0.1:18081"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,25 @@
{
"available": true,
"error": null,
"checked_at": "2026-10-13T16:43:51.651Z",
"browser_version": "156.0.8078.4",
"api_version": "1",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"execution_location": "workstation-container",
"release": "development",
"allowed_origins": [
"http://host.docker.internal:3300",
"https://onco.cc"
],
"fixture": true,
"active": 1,
"queued": 0,
"limits": {
"concurrency": 1,
"max_queue": 4,
"max_timeout_ms": 120000,
"retention_ms": 3600000,
"max_runs": 64
}
}

View file

@ -0,0 +1,184 @@
{
"id": "6b97d75c-0c39-415f-9cf4-f1813c205066",
"api_version": "1",
"status": "passed",
"error": null,
"label": "independent-browser-trial",
"target": "fixture",
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
},
{
"name": "narrow",
"width": 390,
"height": 844
}
],
"timeout_ms": 60000,
"created_at": "2026-10-13T16:43:17.082Z",
"expires_at": "2026-10-13T17:43:17.082Z",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"release": "development",
"execution_location": "workstation-container",
"browser_version": "156.0.8078.4",
"completed_steps": 14,
"steps": [
{
"viewport": "desktop",
"index": 0,
"action": "visible",
"status": "passed",
"at": "2026-10-13T16:43:27.379Z",
"selector": "h1"
},
{
"viewport": "desktop",
"index": 1,
"action": "text",
"status": "passed",
"at": "2026-10-13T16:43:27.722Z",
"selector": "#count"
},
{
"viewport": "desktop",
"index": 2,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:43:27.838Z",
"key": "Tab"
},
{
"viewport": "desktop",
"index": 3,
"action": "focused",
"status": "passed",
"at": "2026-10-13T16:43:27.884Z",
"selector": "#increment"
},
{
"viewport": "desktop",
"index": 4,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:43:28.011Z",
"key": "Enter"
},
{
"viewport": "desktop",
"index": 5,
"action": "text",
"status": "passed",
"at": "2026-10-13T16:43:28.027Z",
"selector": "#count"
},
{
"viewport": "desktop",
"index": 6,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:43:28.052Z"
},
{
"viewport": "narrow",
"index": 0,
"action": "visible",
"status": "passed",
"at": "2026-10-13T16:43:29.234Z",
"selector": "h1"
},
{
"viewport": "narrow",
"index": 1,
"action": "text",
"status": "passed",
"at": "2026-10-13T16:43:29.476Z",
"selector": "#count"
},
{
"viewport": "narrow",
"index": 2,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:43:29.640Z",
"key": "Tab"
},
{
"viewport": "narrow",
"index": 3,
"action": "focused",
"status": "passed",
"at": "2026-10-13T16:43:29.663Z",
"selector": "#increment"
},
{
"viewport": "narrow",
"index": 4,
"action": "press",
"status": "passed",
"at": "2026-10-13T16:43:29.757Z",
"key": "Enter"
},
{
"viewport": "narrow",
"index": 5,
"action": "text",
"status": "passed",
"at": "2026-10-13T16:43:29.773Z",
"selector": "#count"
},
{
"viewport": "narrow",
"index": 6,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:43:29.814Z"
}
],
"artifacts": [
{
"name": "desktop-6-keyboard-result.png",
"bytes": 26622
},
{
"name": "desktop-final.png",
"bytes": 26622
},
{
"name": "narrow-6-keyboard-result.png",
"bytes": 24280
},
{
"name": "narrow-final.png",
"bytes": 24280
}
],
"started_at": "2026-10-13T16:43:17.124Z",
"blocked_requests": 0,
"finished_at": "2026-10-13T16:43:30.498Z",
"downloads": [
{
"name": "desktop-6-keyboard-result.png",
"bytes": 26622,
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad"
},
{
"name": "desktop-final.png",
"bytes": 26622,
"sha256": "b15998824256b86c74f2bbb75f061061be65db0b4f3bb5444529ac69a723cdad"
},
{
"name": "narrow-6-keyboard-result.png",
"bytes": 24280,
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8"
},
{
"name": "narrow-final.png",
"bytes": 24280,
"sha256": "49d27cb169b8e180ce93969c6d2b8619a7684cf035bb9dcae9cd91d8bd9952e8"
}
]
}

View file

@ -0,0 +1,11 @@
{
"argv": [
"client/rapp_playwright.py",
"run",
"--spec",
".local/independent-trial-01/target-spec.json",
"--out",
".local/independent-target-failure-01"
],
"service_url": "http://127.0.0.1:18081"
}

View file

@ -0,0 +1,30 @@
{
"id": "57b03fc8-4947-43a9-8c9b-058f1c46a9e0",
"api_version": "1",
"status": "failed",
"error": "target_unreachable",
"label": "independent-target-http-failure",
"target": "http://host.docker.internal:3300/independent-trial-deliberately-missing-page-20261013",
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
}
],
"timeout_ms": 30000,
"created_at": "2026-10-13T16:44:48.609Z",
"expires_at": "2026-10-13T17:44:48.609Z",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"release": "development",
"execution_location": "workstation-container",
"browser_version": "156.0.8078.4",
"completed_steps": 0,
"steps": [],
"artifacts": [],
"started_at": "2026-10-13T16:44:48.614Z",
"blocked_requests": 0,
"finished_at": "2026-10-13T16:45:11.290Z",
"downloads": []
}

View file

@ -0,0 +1,19 @@
{
"api_version": "1",
"target": "http://host.docker.internal:3300/independent-trial-deliberately-missing-page-20261013",
"label": "independent-target-http-failure",
"timeout_ms": 30000,
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
}
],
"steps": [
{
"action": "visible",
"selector": "h1"
}
]
}

View file

@ -0,0 +1,37 @@
{
"api_version": "1",
"target": "fixture",
"label": "independent-browser-trial",
"steps": [
{
"action": "visible",
"selector": "h1"
},
{
"action": "text",
"selector": "#count",
"value": "0"
},
{
"action": "press",
"key": "Tab"
},
{
"action": "focused",
"selector": "#increment"
},
{
"action": "press",
"key": "Enter"
},
{
"action": "text",
"selector": "#count",
"value": "1"
},
{
"action": "screenshot",
"name": "keyboard-result"
}
]
}

View file

@ -0,0 +1,11 @@
{
"argv": [
"client/rapp_playwright.py",
"run",
"--spec",
".local/independent-trial-01/failure-spec.json",
"--out",
".local/independent-ui-failure-01"
],
"service_url": "http://127.0.0.1:18081"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,57 @@
{
"id": "d6b71146-4f82-4d9c-bf72-4d2b672080d2",
"api_version": "1",
"status": "failed",
"error": "ui_assertion_failed",
"label": "independent-intentional-ui-failure",
"target": "fixture",
"viewports": [
{
"name": "desktop",
"width": 1280,
"height": 800
}
],
"timeout_ms": 30000,
"created_at": "2026-10-13T16:44:28.954Z",
"expires_at": "2026-10-13T17:44:28.954Z",
"service_version": "0.1.0",
"playwright_version": "1.64.0",
"release": "development",
"execution_location": "workstation-container",
"browser_version": "156.0.8078.4",
"completed_steps": 1,
"steps": [
{
"viewport": "desktop",
"index": 0,
"action": "screenshot",
"status": "passed",
"at": "2026-10-13T16:44:41.184Z"
},
{
"viewport": "desktop",
"index": 1,
"action": "text",
"status": "failed",
"at": "2026-10-13T16:44:41.508Z",
"selector": "#count"
}
],
"artifacts": [
{
"name": "desktop-0-before-failure.png",
"bytes": 25740
}
],
"started_at": "2026-10-13T16:44:36.279Z",
"blocked_requests": 0,
"finished_at": "2026-10-13T16:44:46.861Z",
"downloads": [
{
"name": "desktop-0-before-failure.png",
"bytes": 25740,
"sha256": "141dcadca4774da5fefec835bb789c5221685d3f453070a593e69aa6ad4782ce"
}
]
}

View file

@ -0,0 +1,6 @@
{
"http_status": 404,
"body": {
"error": "not_found"
}
}

View file

@ -0,0 +1,9 @@
✔ missing browser fails preflight and prevents admission (3280.374816ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 4049.467658

View file

@ -0,0 +1,17 @@
{
"id": "e77247e5-ce25-4711-a64f-af81a9f7cbb1",
"from_agent": "rapp-playwright",
"to_agent": "railiance-platform",
"subject": "Browser package ready locally; admission and Forgejo OCI upload blockers",
"body": "Founder explicitly selected railiance-platform lifecycle ownership. Final source implementation and independent client evidence are in rapp-playwright; candidate local OCI index sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441, Playwright1.64.0/Chromium156.0.8078.4. Read-only Docker integration4/4 plus missing-browser negative1/1; independent final trial passed. No cluster mutation. Current node92.205.62.239 Ready, requests3460m/4CPU and9472Mi; package100m/256Mi limit1CPU/1Gi,2GiPVC,loopbackRBAC,Recreate. OCI publication to forgejo.coulomb.social/coulomb/rapp-playwright:0.1.0 failed repeatedly at final blob PUT: HTTP499 then500, including digests9a29e489bcc76cee14bece1a90741a2a7512f0c69ffefbe7f5d17500f24d8ba5 and071058c704d16066a57f826f0df652f0f8780f9e825cbf4242946b68f4fa79e0. Smaller layers uploaded; manifest publication not established. Please confirm admission/access lane and diagnose registry large-blob failures in owner scope. WP0003-T01 and WP0004-T01 remain explicit waits. Evidence/docs and sync commit will be in repo; no secret or credential request.",
"thread_id": "c82d75f3-3191-410d-a8c8-fd647bf7f3ef",
"read_at": null,
"archived_at": null,
"created_at": "2026-10-13T17:18:22.948833Z",
"kind": "message",
"expires_at": null,
"supersedes_id": null,
"reader": null,
"delivered_at": null,
"acknowledged_at": null
}

View file

@ -0,0 +1,17 @@
{
"id": "30503ee1-77f9-4e9e-a63a-2f70ac097823",
"from_agent": "rapp-playwright",
"to_agent": "reuse-surface",
"subject": "rapp-playwright implemented: independent local consumer evidence; managed admission pending",
"body": "WP-0004 provider handoff: service and Python stdlib client implemented in /home/worsch/rapp-playwright. docs/consumer-contract.md is the v1 command/API contract. docs/evidence/2026-10-13-independent-local-consumer.md retains independent successful fixture, two viewports, keyboard evidence, wrong-token/UI/target/service negatives. docs/content-explorer-pilot.md maps the pinned OnCo home-page pilot. make test-integration now passes real Chromium isolation, proxy redirects, cancellation/timeout/crash/restart/expiry under read-only Docker. Playwright 1.64.0 / Chromium 156.0.8078.4. Founder selected railiance-platform lifecycle ownership; managed admission remains pending (c82d75f3-3191-410d-a8c8-fd647bf7f3ef), so do not claim Railiance availability. Please update existing capability.infotech.headless-browser with local verified provider contract/evidence and the conditional managed command, then return published discovery readback. Keep source-only fallback explicit. Source commit/immutable image evidence will follow in docs/evidence. No duplicate capability.",
"thread_id": null,
"read_at": null,
"archived_at": null,
"created_at": "2026-10-13T17:12:22.927559Z",
"kind": "message",
"expires_at": null,
"supersedes_id": null,
"reader": null,
"delivered_at": null,
"acknowledged_at": null
}

71
docs/operations.md Normal file
View file

@ -0,0 +1,71 @@
# Service operations
Build with `make install test build`. `make test-integration` launches real Chromium
inside the pinned upstream image; it needs Docker, not a host browser. Publish
the tested image through the existing Forgejo OCI credential lane and read its
digest back. `make render IMAGE_REFERENCE=forgejo.coulomb.social/coulomb/rapp-playwright@sha256:…`
rejects floating tags. Never record a placeholder as a published digest.
The initial package uses a single replica, Recreate updates, 100m CPU/256Mi
requests, 1 CPU/1Gi limits, and a 2Gi local-path PVC. Artifacts expire after one
hour; they are transient evidence, not a backup service. Consumers download
what they need to retain. Recreate avoids a surge on the capacity-constrained
single-node cluster and preserves the RWO volume.
Before activation, confirm the node identity and current resource headroom,
platform lifecycle ownership/admission, publication/pull access, and private
consumer connectivity. Run `make server-dry-run IMAGE_REFERENCE=…`, inspect
`kubectl diff -f .rendered/workload.yaml` on railiance01, then deploy the reviewed
manifest with `make deploy IMAGE_REFERENCE=…`. No existing workload or policy
needs relaxation. Platform confirmation is tracked in WP-0004-T01.
For the first installation, after admission, run `make bootstrap-namespace
IMAGE_REFERENCE=…` before the full server dry run. Kubernetes does not persist
a dry-run Namespace, so namespace-scoped objects cannot be server-validated
until the namespace exists. This bootstrap dry-runs and diffs only the reviewed
Namespace before creating it. The full deployment also diffs before applying.
The `playwright-consumer` Role grants only pod discovery and port-forward. Bind
an approved existing user/group through the identity/platform owner. This repo
does not mint credentials or silently bind all authenticated users. Callers with
existing authorized Kubernetes access can use the documented port-forward.
The pod has no service-account token and no cluster permissions.
Readiness performs the authenticated-protocol preflight over pod loopback. It
requires a successful browser launch; liveness checks the process only. The pod
uses non-root UID/GID 1000, read-only root, dropped capabilities and RuntimeDefault
seccomp. Tini reaps orphaned browser processes. Chromium's OS sandbox is disabled
for this trusted-target pilot; it is not approved for hostile websites or
mutually untrusted tenants. The HTTP/CONNECT origin gate, private-address checks,
blocked workers/WebSockets, per-run process/context and resource limits cover
the documented application-level boundary, not browser-kernel exploits.
## Verify and recover
After establishing the consumer tunnel, `make verify-live TRIAL_OUT=.local/live-N`
must produce four PNGs, focus/keyboard assertions and a receipt. Check the live
container image and imageID against the published digest, PVC binding, deny-all
ingress and ready replica. Exercise wrong run token, deliberate UI failure,
unreachable target, cancellation and service restart; a passing health endpoint
alone is insufficient.
After restart, accepted nonterminal runs become `interrupted`; completed runs
remain retrievable until expiry. A client disconnect does not cancel an accepted
job, so all runs have enforced deadlines. Supervisor cancellation closes the
browser; after two seconds it kills the recorded worker/browser process groups.
Service shutdown drains/cancels bounded work. Corrupt receipts cause startup to
fail closed; restore or quarantine the affected transient run directory through
the owner, without exposing capability hashes or screenshots in logs.
Rollback: retain the previous immutable image reference and rendered revision,
review its schema compatibility, then run `make rollback IMAGE_REFERENCE=…`.
Verify the replacement's imageID and repeat the trial. For the first release,
test recovery by reapplying the same verified digest; do not call that proof of
cross-version rollback. Once a second release exists, exercise an actual digest
change and return to the reviewed release. No automated rollback migrates or
deletes the PVC. V1 receipts are JSON; future incompatible formats require an
explicit migration/rollback decision.
Upgrade Playwright and its base image together, update the lockfile, repeat the
unit/browser/client tests and consumer pilot, then publish a new immutable image.
The Python client checks protocol v1 and does not install Playwright.

View file

@ -0,0 +1,41 @@
{
"api_version": "1",
"target": "http://host.docker.internal:3300/",
"label": "content-explorer-pinned-onco-home",
"timeout_ms": 120000,
"steps": [
{
"action": "visible",
"selector": "h1"
},
{
"action": "text",
"selector": "h1",
"value": "Total information dominance on cancer."
},
{
"action": "screenshot",
"name": "baseline"
},
{
"action": "press",
"key": "Tab"
},
{
"action": "focused",
"selector": "a.skip-link"
},
{
"action": "press",
"key": "Enter"
},
{
"action": "url",
"value": "http://host.docker.internal:3300/#main"
},
{
"action": "screenshot",
"name": "keyboard-skip"
}
]
}

37
examples/trial.json Normal file
View file

@ -0,0 +1,37 @@
{
"api_version": "1",
"target": "fixture",
"label": "independent-browser-trial",
"steps": [
{
"action": "visible",
"selector": "h1"
},
{
"action": "text",
"selector": "#count",
"value": "0"
},
{
"action": "press",
"key": "Tab"
},
{
"action": "focused",
"selector": "#increment"
},
{
"action": "press",
"key": "Enter"
},
{
"action": "text",
"selector": "#count",
"value": "1"
},
{
"action": "screenshot",
"name": "keyboard-result"
}
]
}

17
fixtures/trial.html Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browser service trial</title>
<style>
body{font:20px system-ui;margin:0;padding:clamp(20px,6vw,80px);background:#eef4fb;color:#18283d}
main{max-width:760px;margin:auto;padding:24px;background:white;border:1px solid #c7d4e4;border-radius:12px}
button{font:inherit;padding:12px 20px;background:#1c4f8a;color:white;border:0;border-radius:6px}
button:focus-visible{outline:4px solid #cf6500;outline-offset:4px}output{font-weight:bold}
</style>
<main><h1>Browser service trial</h1><p>Press Tab, then Enter to activate the counter.</p>
<button id="increment">Increment</button><p>Count: <output id="count" aria-live="polite">0</output></p>
<p id="storage">Fresh browser state</p></main>
<script>
document.querySelector('#increment').onclick=()=>{document.querySelector('#count').textContent=String(Number(document.querySelector('#count').textContent)+1)};
if(localStorage.getItem('visited') || document.cookie.includes('visited=')) document.querySelector('#storage').textContent='Leaked browser state';
localStorage.setItem('visited','yes');document.cookie='visited=yes; SameSite=Strict';
</script></html>

View file

@ -0,0 +1,76 @@
# INTENT against SCOPE — 2026-10-13 19:18 Europe/Berlin
The implementation demonstrates the central browser-consumption behavior: a
fresh agent can use a standalone client, execute responsive and keyboard checks,
retrieve hashed screenshots and distinguish UI failures from setup failures.
**INTENT is not fully met:** managed Railiance operation and published discovery
remain missing. WP-0002 is complete; WP-0003 and WP-0004 retain explicit open
dependencies. This assessment does not turn those waits into completed work.
## Evidence and coverage
| INTENT expectation | Current SCOPE | Assessment |
| --- | --- | --- |
| Fresh agent, no local browser, one supported path | Python stdlib client; independent final-image run `35124424-950a-4339-adea-45525b470cab` passed 14 steps and four PNGs | Met locally; managed path unverified |
| Screenshots, keyboard result, inspectable receipt | 1280×800 / 390×844 captures; focus/Enter/count assertions; versions, location, timestamps, failures and hashes | Met for the fixture |
| Consumer development-target connectivity | Explicit host-gateway allowlist; pinned OnCo home pilot with 16 passed steps and six PNG downloads | Demonstrated locally; managed preview/tunnel still needed |
| Isolation, bounds and cleanup | Cookie/storage freshness, foreign-token denial, redirect/private-network gate, queue, cancel/timeout/crash/restart/TTL checks | Executed local evidence; trusted-target boundary only |
| Reproducible runtime and immutable release | Pinned base/Playwright/lockfile; final local OCI index known | Build met; publication failed |
| Operated Railiance package with recovery/rollback | Valid declaration, renderer, manifest, resource limits, probes and operations guide | Prepared; not admitted or deployed |
| Evidence-backed discovery | Provider contract and evidence sent to canonical registry owner | Maintained/published update pending |
## Most important gaps
1. **The package is not installable from the registry yet.** Forgejo rejected
blob finalization with HTTP 499 and 500 across repeated upload attempts.
Remote inspection of the candidate returned `manifest unknown`. WP-0003-T01
remains `wait`; railiance-platform received blocker message
`e77247e5-ce25-4711-a64f-af81a9f7cbb1`. Completion requires successful publication
and remote digest readback of the tested image. Do not describe local index
`sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441`
as a released image.
2. **No managed Railiance endpoint or operational proof exists.** The founder
selected railiance-platform lifecycle ownership, but admission/access remains
unanswered (`c82d75f3-3191-410d-a8c8-fd647bf7f3ef`). Namespace `playwright` is absent;
no cluster mutation was made. WP-0004-T01 needs owner admission, current
capacity, namespace bootstrap, server dry-run/diff, immutable rollout, scoped
consumer access, an independent managed trial, cancellation/restart and an
actual rollback exercise. Same-image local restart is not cross-version
rollback. This is the largest gap against the promise to operate on Railiance.
3. **Discovery still offers the old availability statement.** The maintained
headless-browser capability still described source reuse and a planned
provider at readback. Message `30503ee1-77f9-4e9e-a63a-2f70ac097823` supplies
verified local evidence and asks for accurate guidance. WP-0004-T03 needs the
owner-maintained record and published discovery response inspected after
update; a sent message alone is insufficient. Managed availability must stay
unclaimed until gap 2 closes.
[Published API readback](../docs/evidence/2026-10-13-discovery-readback.json)
confirms the old statement is still served.
4. **The consumer pilot is narrower than the consumer's full baseline.** The
pinned OnCo home page and skip-link journey passed. Other proposed routes,
search/graph behavior and visual acceptance were not exercised. Content-explorer
received `621635c9-0a72-4a88-9fd6-2ee1fbddc68f` and retains authority over
CONTENT-WP-0000-T04. It must decide whether to accept this smoke evidence or
request further cases. The provider has not closed that task.
Failure diagnostics can be improved: `target_unreachable` groups DNS, connection,
TLS and target HTTP errors. The independent trial identified this limitation.
The documented category is useful but can require a separate reachability check.
Native sessions, multiple browsers, authenticated-page state, hostile-site
execution and visual-baseline approval are deliberate non-goals of this increment.
## Validation and disposition
`make test` passed three unit cases and syntax checks. Four real-browser
integration cases passed under a read-only, non-root, resource-bounded container;
the additional missing-browser case passed separately. The final image passed an
independent consumer trial and persisted artifact retrieval after restart. Family
schema checks, immutable rendering and Kubernetes client validation passed.
Retained evidence: [implementation](../docs/evidence/2026-10-13-implementation.md),
[independent consumer](../docs/evidence/2026-10-13-independent-local-consumer.md),
[pinned pilot](../docs/content-explorer-pilot.md), [current scope](../SCOPE.md).
Open delivery gaps stay in existing live workplan tasks with owner messages and
resume instructions; no workplan was falsely finished. Work stops after publishing
this assessment as requested, with registry delivery/admission/discovery requiring
external owner action before further managed-service progress.

177
manifests/workload.yaml Normal file
View file

@ -0,0 +1,177 @@
apiVersion: v1
kind: Namespace
metadata:
name: playwright
labels:
app.kubernetes.io/part-of: rapp-playwright
pod-security.kubernetes.io/enforce: restricted
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: playwright
namespace: playwright
automountServiceAccountToken: false
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: playwright-artifacts
namespace: playwright
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: playwright
namespace: playwright
labels:
app.kubernetes.io/name: playwright
app.kubernetes.io/part-of: rapp-playwright
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: playwright
template:
metadata:
labels:
app.kubernetes.io/name: playwright
app.kubernetes.io/part-of: rapp-playwright
spec:
serviceAccountName: playwright
automountServiceAccountToken: false
terminationGracePeriodSeconds: 15
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: service
image: "${IMAGE_REFERENCE}"
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- {name: HOST, value: "127.0.0.1"}
- {name: DATA_DIR, value: /data}
- {name: EXECUTION_LOCATION, value: railiance01/playwright}
- {name: RELEASE_ID, value: "${IMAGE_REFERENCE}"}
- {name: MAX_CONCURRENCY, value: "1"}
- {name: RETENTION_MS, value: "3600000"}
- {name: ALLOWED_ORIGINS, value: "https://onco.cc"}
- {name: CHROMIUM_SANDBOX, value: "false"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 256Mi
limits:
cpu: "1"
memory: 1Gi
ephemeral-storage: 1Gi
volumeMounts:
- {name: data, mountPath: /data}
- {name: tmp, mountPath: /tmp}
- {name: shm, mountPath: /dev/shm}
startupProbe:
exec:
command: [node, -e, "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
periodSeconds: 5
failureThreshold: 20
timeoutSeconds: 3
readinessProbe:
exec:
command: [node, -e, "fetch('http://127.0.0.1:8080/v1/preflight',{headers:{'X-Rapp-Protocol':'1'}}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
periodSeconds: 15
timeoutSeconds: 3
livenessProbe:
exec:
command: [node, -e, "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
periodSeconds: 30
timeoutSeconds: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: playwright-artifacts
- name: tmp
emptyDir:
sizeLimit: 512Mi
- name: shm
emptyDir:
medium: Memory
sizeLimit: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: playwright
namespace: playwright
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: playwright
ports:
- {name: http, port: 8080, targetPort: http}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: playwright
namespace: playwright
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: playwright
policyTypes: [Ingress, Egress]
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: [0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4]
ports:
- {protocol: TCP, port: 443}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: playwright-consumer
namespace: playwright
rules:
- apiGroups: [""]
resources: [pods]
verbs: [get, list]
- apiGroups: [""]
resources: [pods/portforward]
verbs: [create]
- apiGroups: [apps]
resources: [deployments]
resourceNames: [playwright]
verbs: [get]

45
package-lock.json generated Normal file
View file

@ -0,0 +1,45 @@
{
"name": "rapp-playwright",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rapp-playwright",
"version": "0.1.0",
"dependencies": {
"playwright": "1.64.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "rapp-playwright",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"start": "node src/server.mjs",
"test": "node --test tests/*.test.mjs",
"test:integration": "node --test tests/integration.mjs",
"check": "node --check src/server.mjs && node --check src/worker.mjs && node --check src/proxy.mjs"
},
"dependencies": { "playwright": "1.64.0" }
}

66
src/protocol.mjs Normal file
View file

@ -0,0 +1,66 @@
import { createHash } from 'node:crypto';
export const API_VERSION = '1';
export const hash = value => createHash('sha256').update(value).digest('hex');
export class Problem extends Error {
constructor(code, status = 400) { super(code); this.code = code; this.status = status; }
}
export const now = () => new Date().toISOString();
export const terminal = state => ['passed', 'failed', 'cancelled', 'interrupted'].includes(state);
function string(value, max = 2048) { return typeof value === 'string' && value.length > 0 && value.length <= max; }
function integer(value, min, max) { return Number.isInteger(value) && value >= min && value <= max; }
export function origin(value) {
try {
const u = new URL(value);
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw new Error();
return u.origin;
} catch { throw new Problem('invalid_target'); }
}
export function validateJob(input, allowedOrigins, fixtureURL) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Problem('invalid_request');
const known = new Set(['api_version', 'target', 'viewports', 'steps', 'timeout_ms', 'label']);
if (Object.keys(input).some(k => !known.has(k))) throw new Problem('unknown_field');
if (input.api_version !== API_VERSION) throw new Problem('api_version_mismatch', 409);
const target = input.target === 'fixture' ? fixtureURL : input.target;
if (!string(target) || !allowedOrigins.includes(origin(target))) throw new Problem('target_not_allowed', 403);
// Queries can carry credentials. V1 targets are public/fixture pages, without queries.
if (new URL(target).search || new URL(target).hash) throw new Problem('target_query_not_supported');
const viewports = input.viewports ?? [{name:'desktop',width:1280,height:800},{name:'narrow',width:390,height:844}];
if (!Array.isArray(viewports) || viewports.length < 1 || viewports.length > 4) throw new Problem('invalid_viewports');
const names = new Set();
for (const v of viewports) {
if (!v || Object.keys(v).some(k=>!['name','width','height'].includes(k)) || !/^[a-z][a-z0-9-]{0,31}$/.test(v.name) ||
names.has(v.name) || !integer(v.width, 240, 1920) || !integer(v.height, 240, 1600)) throw new Problem('invalid_viewports');
names.add(v.name);
}
const steps = input.steps;
if (!Array.isArray(steps) || steps.length < 1 || steps.length > 40) throw new Problem('invalid_steps');
for (const step of steps) {
if (!step || typeof step !== 'object') throw new Problem('invalid_step');
const fields = {click:['selector'],focus:['selector'],press:['key'],fill:['selector','value'],
visible:['selector'],text:['selector','value'],focused:['selector'],url:['value'],screenshot:['name']};
if (!fields[step.action] || Object.keys(step).some(k=>!['action',...fields[step.action]].includes(k))) throw new Problem('invalid_step');
for (const key of fields[step.action]) if (!string(step[key], key === 'value' ? 4096 : 512)) throw new Problem('invalid_step');
if (step.action === 'press' && !/^(?:(?:Shift|Control|Alt|Meta)\+)*(?:Tab|Enter|Space|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|Backspace|[a-z0-9])$/.test(step.key)) throw new Problem('invalid_key');
if (step.action === 'screenshot' && !/^[a-z][a-z0-9-]{0,31}$/.test(step.name)) throw new Problem('invalid_artifact_name');
if (step.action === 'url' && !allowedOrigins.includes(origin(step.value))) throw new Problem('target_not_allowed',403);
}
if (!steps.some(s=>['visible','text','focused','url'].includes(s.action))) throw new Problem('assertion_required');
const timeout_ms = input.timeout_ms ?? 60000;
if (!integer(timeout_ms, 1000, 120000)) throw new Problem('invalid_timeout');
if (input.label !== undefined && !/^[a-zA-Z0-9 ._-]{1,80}$/.test(input.label)) throw new Problem('invalid_label');
return {api_version:API_VERSION,target,viewports,steps,timeout_ms,label:input.label??'ui-test'};
}
export const trial = {
api_version: API_VERSION, target: 'fixture', label: 'independent-browser-trial',
steps: [
{action:'visible',selector:'h1'},
{action:'text',selector:'#count',value:'0'},
{action:'press',key:'Tab'},
{action:'focused',selector:'#increment'},
{action:'press',key:'Enter'},
{action:'text',selector:'#count',value:'1'},
{action:'screenshot',name:'keyboard-result'},
],
};

56
src/proxy.mjs Normal file
View file

@ -0,0 +1,56 @@
// Every browser HTTP/HTTPS connection goes through this per-run origin gate.
// Resolve once and connect to the verified address, avoiding a DNS rebind race.
import http from 'node:http';
import net from 'node:net';
import { lookup } from 'node:dns/promises';
import { Problem } from './protocol.mjs';
export function publicAddress(address) {
const ip=address.toLowerCase();
if (net.isIP(ip) === 4) {
const [a,b]=ip.split('.').map(Number);
return !(a===0 || a===10 || a===127 || a>=224 || (a===169&&b===254) ||
(a===172&&b>=16&&b<=31) || (a===192&&b===168) || (a===100&&b>=64&&b<=127) ||
(a===192&&b===0) || (a===198&&(b===18||b===19)));
}
// Public global unicast only. Denies loopback, link local and IPv4 mapped forms.
return net.isIP(ip) === 6 && /^[23][0-9a-f]{3}:/.test(ip);
}
export async function startProxy(allowed, privateOrigins = [], onBlocked = ()=>{}) {
const sockets=new Set();
async function resolve(url) {
if (!allowed.includes(url.origin) || url.username || url.password) throw new Problem('target_not_allowed');
const addresses=await lookup(url.hostname.replace(/^\[|\]$/g,''),{all:true});
if (!addresses.length || (!privateOrigins.includes(url.origin) && addresses.some(a=>!publicAddress(a.address)))) throw new Problem('private_address_denied');
return addresses[0];
}
function track(socket) { sockets.add(socket);socket.on('close',()=>sockets.delete(socket));socket.on('error',()=>{});return socket; }
const proxy=http.createServer(async(req,res)=>{
let target, address;
try { target=new URL(req.url); if(target.protocol!=='http:')throw new Error();address=await resolve(target); }
catch(e) { if(e instanceof Problem)onBlocked(target);res.writeHead(e instanceof Problem?403:502);res.end();return; }
const headers={...req.headers,host:target.host};delete headers['proxy-authorization'];delete headers['proxy-connection'];
const upstream=http.request({hostname:address.address,family:address.family,port:target.port||80,
path:target.pathname+target.search,method:req.method,headers,timeout:15000},reply=>{
res.writeHead(reply.statusCode,reply.headers);reply.pipe(res);
});
upstream.on('socket',track);upstream.on('timeout',()=>upstream.destroy());
upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();});
req.on('aborted',()=>upstream.destroy());req.pipe(upstream);
});
proxy.on('connect',async(req,client,head)=>{
track(client);
let target,address;
try { target=new URL('https://'+req.url);address=await resolve(target); }
catch(e) { if(e instanceof Problem)onBlocked(target);client.end(`HTTP/1.1 ${e instanceof Problem?'403 Forbidden':'502 Bad Gateway'}\r\n\r\n`);return; }
const upstream=track(net.connect({host:address.address,port:Number(target.port||443),family:address.family}));
upstream.setTimeout(30000,()=>upstream.destroy());
upstream.once('connect',()=>{client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client);});
upstream.once('error',()=>client.destroy());client.once('close',()=>upstream.destroy());
});
proxy.on('connection',track);proxy.on('clientError',(_,socket)=>socket.destroy());
await new Promise(resolve=>proxy.listen(0,'127.0.0.1',resolve));
return {url:`http://127.0.0.1:${proxy.address().port}`,close:async()=>{
for(const socket of sockets)socket.destroy();await new Promise(resolve=>proxy.close(resolve));
}};
}

187
src/server.mjs Normal file
View file

@ -0,0 +1,187 @@
import http from 'node:http';
import { fork } from 'node:child_process';
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, rename, mkdir, readdir, rm, stat } from 'node:fs/promises';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { API_VERSION, Problem, hash, now, terminal, origin, trial, validateJob } from './protocol.mjs';
const packageInfo=JSON.parse(await readFile(new URL('../package.json',import.meta.url)));
const playwrightInfo=JSON.parse(await readFile(new URL('../node_modules/playwright/package.json',import.meta.url)));
if(playwrightInfo.version!==packageInfo.dependencies.playwright)throw new Error('playwright_package_version_mismatch');
const workerPath=fileURLToPath(new URL('./worker.mjs',import.meta.url));
const fixture=await readFile(new URL('../fixtures/trial.html',import.meta.url));
const sha256Pattern=/^[a-f0-9]{64}$/;
export async function startService(options={}) {
const dir=resolve(options.dir??process.env.DATA_DIR??'.local/runs');
const host=options.host??process.env.HOST??'127.0.0.1';
const port=options.port??Number(process.env.PORT??8080);
const concurrency=options.concurrency??Number(process.env.MAX_CONCURRENCY??1);
const retention=options.retention??Number(process.env.RETENTION_MS??3600000);
const maxRuns=options.maxRuns??64;
if(!Number.isInteger(concurrency)||concurrency<1||concurrency>4||!Number.isInteger(retention)||retention<1000||retention>86400000)throw new Error('invalid service limits');
const configured=(options.allowedOrigins??process.env.ALLOWED_ORIGINS??'').split(',').filter(Boolean).map(origin);
const privateConfigured=(options.privateOrigins??process.env.PRIVATE_ORIGINS??'').split(',').filter(Boolean).map(origin);
if(privateConfigured.some(o=>!configured.includes(o)))throw new Error('private origins must be explicitly allowed');
await mkdir(dir,{recursive:true,mode:0o700});
const runs=new Map();const queue=[];let active=0,closed=false,probeActive=false;
let availability={available:false,error:'browser_not_probed',checked_at:null,browser_version:null};
const fixtureServer=http.createServer((req,res)=>{
if(req.method==='GET'&&req.url==='/'){res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});res.end(fixture);}
else {res.writeHead(404);res.end();}
});
await new Promise(r=>fixtureServer.listen(0,'127.0.0.1',r));
const fixtureURL=`http://127.0.0.1:${fixtureServer.address().port}/`;
const allowedOrigins=[...configured,origin(fixtureURL)];
const privateOrigins=[...privateConfigured,origin(fixtureURL)];
for(const id of await readdir(dir)){
if(!/^[a-f0-9-]{36}$/.test(id))continue;
try{
const data=JSON.parse(await readFile(join(dir,id,'receipt.json'),'utf8'));
if(data.id!==id||!sha256Pattern.test(data.token_hash)||!Number.isFinite(Date.parse(data.expires_at)))throw new Error();
if(Date.parse(data.expires_at)<=Date.now()){await rm(join(dir,id),{recursive:true,force:true});continue;}
if(!terminal(data.status)){data.status='interrupted';data.error='service_restarted';data.finished_at=now();data.artifacts=[];
for(const name of await readdir(join(dir,id)))if(name!=='receipt.json')await rm(join(dir,id,name),{recursive:true,force:true});}
const run={data,pending:Promise.resolve()};runs.set(id,run);await save(run);
}catch{throw new Error(`invalid persisted run ${id}`);}
}
function save(run){
const snapshot=JSON.stringify(run.data,null,2)+'\n';
run.pending=run.pending.then(async()=>{
const tmp=join(dir,run.data.id,'receipt.tmp');await writeFile(tmp,snapshot,{mode:0o600});await rename(tmp,join(dir,run.data.id,'receipt.json'));
});return run.pending;
}
function receipt(run){const {token_hash,...rest}=run.data;return rest;}
function checkToken(req,run){
const token=req.headers.authorization?.match(/^Bearer ([a-f0-9]{64})$/)?.[1];
return token&&timingSafeEqual(Buffer.from(hash(token),'hex'),Buffer.from(run.data.token_hash,'hex'));
}
async function reap(){
for(const [id,run] of runs){if(terminal(run.data.status)&&Date.parse(run.data.expires_at)<=Date.now()){
runs.delete(id);await run.pending;await rm(join(dir,id),{recursive:true,force:true});
}}
}
function killGroup(pid){if(pid)try{process.kill(-pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}}
function launch(config,onMessage,onExit){
const temporary=mkdtempSync(join(tmpdir(),'rapp-worker-'));
const child=fork(workerPath,[],{detached:true,stdio:['ignore','ignore','ignore','ipc'],
env:{PATH:process.env.PATH,HOME:temporary,TMPDIR:temporary,PLAYWRIGHT_BROWSERS_PATH:process.env.PLAYWRIGHT_BROWSERS_PATH,
CHROMIUM_SANDBOX:process.env.CHROMIUM_SANDBOX??'false'}});
let browserPid=null,killTimer;
child.on('message',msg=>{if(msg.type==='browser_pid')browserPid=msg.pid;else onMessage(msg);});
child.once('error',()=>{});
child.once('exit',async(code)=>{clearTimeout(killTimer);killGroup(browserPid);await rm(temporary,{recursive:true,force:true});onExit(code);});
child.send({...config,allowedOrigins,privateOrigins});
return {stop:()=>{child.kill('SIGTERM');killTimer=setTimeout(()=>{killGroup(browserPid);killGroup(child.pid);},2000);},pid:child.pid,get browserPid(){return browserPid;}};
}
async function probe(){
if(probeActive||closed||active)return;probeActive=true;
try {
availability=await new Promise(resolve=>{
let result;const worker=launch({probe:true,dir},msg=>{if(msg.type==='result')result=msg.result;},()=>{
clearTimeout(timer);resolve({available:result?.status==='passed',error:result?.status==='passed'?null:'browser_launch_failed',checked_at:now(),browser_version:result?.browser_version??null});
});const timer=setTimeout(()=>worker.stop(),25000);
});
}finally{probeActive=false;drain();}
}
function drain(){
if(closed||probeActive)return;
while(active<concurrency&&queue.length){const run=queue.shift();if(run.data.status!=='queued')continue;execute(run);}
}
async function execute(run){
active++;run.data.status='running';run.data.started_at=now();
try{await save(run);}catch{active--;run.data.status='failed';run.data.error='storage_failed';return;}
let result;
run.worker=launch({job:run.job,dir:join(dir,run.data.id)},msg=>{
if(msg.type==='result')result=msg.result;
if(msg.type==='progress')run.data.completed_steps=msg.steps;
},async()=>{
clearTimeout(run.timer);
if(run.stopReason){run.data.status=run.stopReason==='cancelled'?'cancelled':'failed';run.data.error=run.stopReason;}
else if(result)Object.assign(run.data,result);
else {run.data.status='failed';run.data.error='browser_crashed';}
run.data.finished_at=now();delete run.worker;delete run.job;
await save(run);active--;drain();
});
run.timer=setTimeout(()=>cancel(run,'run_timeout'),run.data.timeout_ms);
if(run.stopReason)run.worker.stop();
}
async function cancel(run,reason='cancelled'){
if(terminal(run.data.status))return;
run.stopReason=reason;
if(run.data.status==='queued'){
run.data.status='cancelled';run.data.error=reason;run.data.finished_at=now();delete run.job;await save(run);
}else run.worker?.stop();
}
function json(res,status,value){res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});res.end(JSON.stringify(value));}
async function body(req){
let size=0,chunks=[];
for await(const chunk of req){size+=chunk.length;if(size>32768)throw new Problem('request_too_large',413);chunks.push(chunk);}
try{return JSON.parse(Buffer.concat(chunks).toString());}catch{throw new Problem('invalid_json');}
}
const server=http.createServer(async(req,res)=>{
try{
if(closed)throw new Problem('service_stopping',503);
const path=new URL(req.url,'http://localhost').pathname;
if(req.method==='GET'&&path==='/healthz')return json(res,200,{status:'ok',version:packageInfo.version});
if(req.headers['x-rapp-protocol']!==API_VERSION)throw new Problem('api_version_mismatch',409);
if(req.method==='GET'&&path==='/v1/preflight')return json(res,availability.available?200:503,{
...availability,api_version:API_VERSION,service_version:packageInfo.version,playwright_version:playwrightInfo.version,
execution_location:process.env.EXECUTION_LOCATION??'local-development',release:process.env.RELEASE_ID??'development',
allowed_origins:configured,fixture:true,active,queued:queue.filter(r=>r.data.status==='queued').length,
limits:{concurrency,max_queue:4,max_timeout_ms:120000,retention_ms:retention,max_runs:maxRuns},
});
if(req.method==='GET'&&path==='/v1/trial')return json(res,200,trial);
if(req.method==='POST'&&path==='/v1/runs'){
if(!availability.available)throw new Problem('browser_unavailable',503);
const input=await body(req);const job=validateJob(input,allowedOrigins,fixtureURL);
await reap();
const bytes=[...runs.values()].reduce((n,r)=>n+(r.data.artifacts??[]).reduce((s,a)=>s+a.bytes,0),0);
const reserved=[...runs.values()].filter(r=>!terminal(r.data.status)).length;
if(runs.size>=maxRuns||bytes+(reserved+1)*20*1024*1024>200*1024*1024)throw new Problem('artifact_capacity',429);
if([...runs.values()].filter(r=>r.data.status==='queued').length>=4)throw new Problem('queue_full',429);
const id=randomUUID(),token=randomBytes(32).toString('hex');
const run={job,pending:Promise.resolve(),data:{id,token_hash:hash(token),api_version:API_VERSION,
status:'queued',error:null,label:job.label,target:input.target,viewports:job.viewports,
timeout_ms:job.timeout_ms,created_at:now(),expires_at:new Date(Date.now()+retention).toISOString(),
service_version:packageInfo.version,playwright_version:playwrightInfo.version,
release:process.env.RELEASE_ID??'development',execution_location:process.env.EXECUTION_LOCATION??'local-development',
browser_version:null,completed_steps:0,steps:[],artifacts:[]}};
runs.set(id,run);
try{await mkdir(join(dir,id),{mode:0o700});await save(run);}catch(e){runs.delete(id);throw e;}
queue.push(run);drain();
return json(res,202,{id,token,status:'queued',expires_at:run.data.expires_at});
}
const match=path.match(/^\/v1\/runs\/([a-f0-9-]{36})(?:\/artifacts\/([a-z0-9-]+\.png))?$/);
if(!match)throw new Problem('not_found',404);
const run=runs.get(match[1]);
if(!run||!checkToken(req,run)||Date.parse(run.data.expires_at)<=Date.now())throw new Problem('not_found',404);
if(match[2]&&req.method==='GET'){
const artifact=run.data.artifacts.find(a=>a.name===match[2]);
if(!terminal(run.data.status)||!artifact)throw new Problem('not_found',404);
const data=await readFile(join(dir,run.data.id,artifact.name));
res.writeHead(200,{'Content-Type':'image/png','Content-Length':data.length,'Cache-Control':'no-store'});return res.end(data);
}
if(!match[2]&&req.method==='GET')return json(res,200,receipt(run));
if(!match[2]&&req.method==='DELETE'){await cancel(run);return json(res,202,receipt(run));}
throw new Problem('method_not_allowed',405);
}catch(e){if(!res.headersSent)json(res,e instanceof Problem?e.status:500,{error:e instanceof Problem?e.code:'internal_error'});else res.end();}
});
server.requestTimeout=15000;server.headersTimeout=10000;server.keepAliveTimeout=2000;
await probe();await new Promise(r=>server.listen(port,host,r));
const maintenance=setInterval(()=>{reap().catch(()=>{});if(!availability.checked_at||Date.now()-Date.parse(availability.checked_at)>60000)probe();},1000);maintenance.unref();
return {server,url:`http://${host}:${server.address().port}`,runs,async close(){
closed=true;clearInterval(maintenance);
await Promise.all([...runs.values()].filter(r=>!terminal(r.data.status)).map(r=>cancel(r,'service_stopped')));
const deadline=Date.now()+5000;while(active&&Date.now()<deadline)await new Promise(r=>setTimeout(r,50));
server.closeAllConnections();await new Promise(r=>server.close(r));await new Promise(r=>fixtureServer.close(r));
}};
}
if(process.argv[1]===fileURLToPath(import.meta.url)){
const service=await startService();console.log(JSON.stringify({event:'listening',url:service.url}));
let closing=false;for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>{if(closing)return;closing=true;service.close().finally(()=>process.exit(0));});
}

93
src/worker.mjs Normal file
View file

@ -0,0 +1,93 @@
import { chromium } from 'playwright';
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startProxy } from './proxy.mjs';
import { now } from './protocol.mjs';
// The supervisor supplies a validated job via IPC; no consumer JS is evaluated.
process.once('message',async config=>{
const {job,dir,allowedOrigins,privateOrigins}=config;
let browserServer,browser,proxy,browserHome,phase='browser_launch_failed',stopping=false,navigationBlocked=false,navigationOrigin=null;
const result={status:'passed',error:null,steps:[],artifacts:[],browser_version:null,blocked_requests:0};
const send=value=>{if(process.connected)process.send(value);};
const stop=async()=>{if(stopping)return;stopping=true;await browser?.close().catch(()=>{});await browserServer?.close().catch(()=>{});await proxy?.close();if(browserHome)await rm(browserHome,{recursive:true,force:true});};
process.on('SIGTERM',()=>{stop().finally(()=>process.exit(143));});
process.on('disconnect',()=>{stop().finally(()=>process.exit(1));});
try {
proxy=await startProxy(allowedOrigins,privateOrigins,url=>{
result.blocked_requests++;
if(url?.origin===navigationOrigin)navigationBlocked=true;
});
browserHome=await mkdtemp(join(tmpdir(),'rapp-browser-home-'));
browserServer=await chromium.launchServer({headless:true,proxy:{server:proxy.url},
executablePath:fileURLToPath(new URL('../tools/chromium-launcher.py',import.meta.url)),
env:{...process.env,HOME:browserHome,RAPP_CHROMIUM_EXECUTABLE:chromium.executablePath()},
args:['--proxy-bypass-list=<-loopback>','--disable-quic','--disable-background-networking'],
chromiumSandbox:process.env.CHROMIUM_SANDBOX==='true',timeout:20000});
send({type:'browser_pid',pid:browserServer.process().pid});
browser=await chromium.connect(browserServer.wsEndpoint());
result.browser_version=browser.version();
if(config.probe)return;
for(const viewport of job.viewports){
const context=await browser.newContext({viewport:{width:viewport.width,height:viewport.height},
serviceWorkers:'block',acceptDownloads:false,locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
context.on('request',request=>{
if(request.isNavigationRequest()&&request.frame().parentFrame()===null)
navigationOrigin=new URL(request.url()).origin;
});
await context.route('**/*',route=>{
const u=new URL(route.request().url());
const navigation=route.request().isNavigationRequest()&&route.request().frame().parentFrame()===null;
if(navigation)navigationOrigin=u.origin;
if(!['http:','https:'].includes(u.protocol)||!allowedOrigins.includes(u.origin)){
if(navigation)navigationBlocked=true;
result.blocked_requests++;return route.abort('blockedbyclient');
}
return route.continue();
});
await context.routeWebSocket('**/*',ws=>ws.close());
context.on('page',p=>{p.on('dialog',d=>d.dismiss());});
const page=await context.newPage();page.setDefaultTimeout(5000);page.setDefaultNavigationTimeout(15000);
phase='target_unreachable';
const response=await page.goto(job.target,{waitUntil:'domcontentloaded'});
if(!response || response.status()>=400)throw new Error('target_http_error');
phase='ui_assertion_failed';
for(let i=0;i<job.steps.length;i++){
const step=job.steps[i];const receipt={viewport:viewport.name,index:i,action:step.action,status:'passed',at:now()};
// Record selectors/key names but not fill values, page text, headers or console output.
if(step.selector)receipt.selector=step.selector;
if(step.key)receipt.key=step.key;
result.steps.push(receipt);
try{
const locator=step.selector?page.locator(step.selector):null;
switch(step.action){
case 'click':await locator.click();break;
case 'focus':await locator.focus();break;
case 'fill':await locator.fill(step.value);break;
case 'press':await page.keyboard.press(step.key);break;
case 'visible':await locator.waitFor({state:'visible'});break;
case 'text':await page.waitForFunction(({selector,value})=>document.querySelector(selector)?.textContent?.trim()===value,{selector:step.selector,value:step.value},{timeout:5000});break;
case 'focused':await page.waitForFunction(selector=>document.querySelector(selector)===document.activeElement,step.selector,{timeout:5000});break;
case 'url':await page.waitForURL(url=>url.href===step.value,{timeout:5000});break;
case 'screenshot':await screenshot(page,`${viewport.name}-${i}-${step.name}.png`);break;
}
}catch(e){receipt.status='failed';throw e;}
send({type:'progress',steps:result.steps.length});
}
await screenshot(page,`${viewport.name}-final.png`);
await context.close();
}
} catch {
result.status='failed';result.error=navigationBlocked&&phase==='target_unreachable'?'target_not_allowed':phase;
} finally {
await stop();send({type:'result',result});if(process.connected)process.disconnect();
}
async function screenshot(page,name){
const data=await page.screenshot({type:'png',animations:'disabled',timeout:5000});
const size=result.artifacts.reduce((sum,a)=>sum+a.bytes,0)+data.length;
if(size>20*1024*1024) {phase='artifact_limit';throw new Error();}
await writeFile(join(dir,name),data,{mode:0o600});result.artifacts.push({name,bytes:data.length});
}
});

112
tests/integration.mjs Normal file
View file

@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,readFile,writeFile,readdir,rm,mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import http from 'node:http';
import {randomUUID} from 'node:crypto';
import {startService} from '../src/server.mjs';
import {trial,hash} from '../src/protocol.mjs';
const pause=ms=>new Promise(r=>setTimeout(r,ms));
async function environment(options={}){
const dir=await mkdtemp(join(tmpdir(),'rapp-playwright-test-'));
const service=await startService({dir,port:0,...options});
const api=async(path,method='GET',body,token)=>{
const response=await fetch(service.url+path,{method,headers:{'X-Rapp-Protocol':'1',...(token?{Authorization:'Bearer '+token}:{})},body:body?JSON.stringify(body):undefined});
return {status:response.status,data:response.headers.get('content-type')?.startsWith('image')?Buffer.from(await response.arrayBuffer()):await response.json()};
};
async function submit(spec=trial){const r=await api('/v1/runs','POST',spec);assert.equal(r.status,202,JSON.stringify(r));return r.data;}
async function finish(run){
const end=Date.now()+30000;
while(Date.now()<end){const r=await api('/v1/runs/'+run.id,'GET',null,run.token);assert.equal(r.status,200,JSON.stringify(r));
if(['passed','failed','cancelled','interrupted'].includes(r.data.status))return r.data;await pause(100);}
throw new Error('run did not terminate');
}
return {dir,service,api,submit,finish,close:async()=>{await service.close();await rm(dir,{recursive:true,force:true});}};
}
test('missing browser fails preflight and prevents admission',async()=>{
const previous=process.env.PLAYWRIGHT_BROWSERS_PATH;
process.env.PLAYWRIGHT_BROWSERS_PATH='/nonexistent-rapp-browser';
let e;
try{
e=await environment();
const ready=await e.api('/v1/preflight');
assert.equal(ready.status,503);assert.equal(ready.data.error,'browser_launch_failed');
const run=await e.api('/v1/runs','POST',trial);
assert.equal(run.status,503);assert.equal(run.data.error,'browser_unavailable');
}finally{
if(e)await e.close();
if(previous===undefined)delete process.env.PLAYWRIGHT_BROWSERS_PATH;
else process.env.PLAYWRIGHT_BROWSERS_PATH=previous;
}
});
test('real browser produces responsive keyboard evidence, fresh state and scoped artifacts',async()=>{
const e=await environment();try{
const ready=await e.api('/v1/preflight');assert.equal(ready.status,200);assert.equal(ready.data.available,true);assert.ok(ready.data.browser_version);
const spec={...trial,steps:[{action:'text',selector:'#storage',value:'Fresh browser state'},...trial.steps]};
const run=await e.submit(spec),receipt=await e.finish(run);
assert.equal(receipt.status,'passed',JSON.stringify(receipt));assert.equal(receipt.steps.length,16);assert.equal(receipt.artifacts.length,4);
assert.equal(receipt.steps.filter(s=>s.action==='focused'&&s.status==='passed').length,2);
assert.equal('token_hash' in receipt,false);
const other=await e.submit(spec);assert.equal((await e.finish(other)).status,'passed');
assert.equal((await e.api('/v1/runs/'+run.id)).status,404);
assert.equal((await e.api('/v1/runs/'+run.id,'GET',null,other.token)).status,404);
for(const artifact of receipt.artifacts){
const path='/v1/runs/'+run.id+'/artifacts/'+artifact.name;
assert.equal((await e.api(path,'GET',null,other.token)).status,404);
const png=await e.api(path,'GET',null,run.token);assert.equal(png.status,200);
assert.equal(png.data.readUInt32BE(16),artifact.name.startsWith('desktop')?1280:390);
}
const disk=await readFile(join(e.dir,run.id,'receipt.json'),'utf8');assert.ok(!disk.includes(run.token));
assert.equal((await fetch(e.service.url+'/v1/preflight')).status,409);
}finally{await e.close();}
});
test('UI failures, unreachable targets and forbidden redirects remain distinguishable',async()=>{
let leaked=0;const sink=http.createServer((req,res)=>{leaked++;res.end('should not be reached');});await new Promise(r=>sink.listen(0,'127.0.0.1',r));
const redirect=http.createServer((req,res)=>{res.writeHead(302,{Location:`http://127.0.0.1:${sink.address().port}/`});res.end();});await new Promise(r=>redirect.listen(0,'127.0.0.1',r));
const allow=`http://127.0.0.1:${redirect.address().port},http://127.0.0.1:9`;
const e=await environment({allowedOrigins:allow,privateOrigins:allow});try{
const wrong=await e.submit({...trial,viewports:[{name:'desktop',width:1280,height:800}],steps:[{action:'text',selector:'#count',value:'sensitive-expected-value'}]});
const failed=await e.finish(wrong);assert.equal(failed.error,'ui_assertion_failed');assert.equal(failed.steps[0].status,'failed');assert.ok(!JSON.stringify(failed).includes('sensitive-expected-value'));
const unreachable=await e.submit({...trial,target:'http://127.0.0.1:9/'});assert.equal((await e.finish(unreachable)).error,'target_unreachable');
const denied=await e.submit({...trial,target:`http://127.0.0.1:${redirect.address().port}/`});assert.equal((await e.finish(denied)).error,'target_not_allowed');assert.equal(leaked,0);
assert.equal((await e.api('/v1/runs','POST',{...trial,target:'http://169.254.169.254/'})).status,403);
}finally{await e.close();await new Promise(r=>redirect.close(r));await new Promise(r=>sink.close(r));}
});
test('queue limits, cancellation, timeout, worker crash and subsequent recovery',async()=>{
const e=await environment();try{
const slow={...trial,timeout_ms:15000,steps:[{action:'visible',selector:'#never'}]};
const responses=await Promise.all(Array.from({length:12},()=>e.api('/v1/runs','POST',slow)));
assert.ok(responses.some(r=>r.status===429));assert.ok(responses.filter(r=>r.status===202).length<=5);
for(const r of responses.filter(r=>r.status===202)){await e.api('/v1/runs/'+r.data.id,'DELETE',null,r.data.token);assert.equal((await e.finish(r.data)).status,'cancelled');}
const timed=await e.submit({...slow,timeout_ms:1000});assert.equal((await e.finish(timed)).error,'run_timeout');
const crashed=await e.submit(slow);
const deadline=Date.now()+20000;
while(!e.service.runs.get(crashed.id).worker?.browserPid&&Date.now()<deadline)await pause(20);
const worker=e.service.runs.get(crashed.id).worker;assert.ok(worker?.browserPid);
const browserPid=worker.browserPid;
process.kill(worker.pid,'SIGKILL');assert.equal((await e.finish(crashed)).error,'browser_crashed');
await pause(500);assert.throws(()=>process.kill(-browserPid,0),{code:'ESRCH'});
assert.equal((await e.finish(await e.submit())).status,'passed');
assert.equal((await e.api('/v1/preflight')).data.active,0);
}finally{await e.close();}
});
test('restart marks abandoned runs interrupted and expiry removes artifacts',async()=>{
const dir=await mkdtemp(join(tmpdir(),'rapp-playwright-restart-'));
const id=randomUUID(),token='a'.repeat(64);await mkdir(join(dir,id));
const persisted={id,token_hash:hash(token),status:'running',expires_at:new Date(Date.now()+60000).toISOString(),artifacts:[]};
await writeFile(join(dir,id,'receipt.json'),JSON.stringify(persisted));await writeFile(join(dir,id,'orphan.png'),'orphan');
const s=await startService({dir,port:0});try{
const result=await fetch(s.url+'/v1/runs/'+id,{headers:{'X-Rapp-Protocol':'1',Authorization:'Bearer '+token}});
const receipt=await result.json();assert.equal(receipt.status,'interrupted');assert.equal(receipt.error,'service_restarted');
assert.deepEqual(await readdir(join(dir,id)),['receipt.json']);
s.runs.get(id).data.expires_at=new Date(Date.now()-1).toISOString();
await pause(1500);assert.equal((await readdir(dir)).includes(id),false);
}finally{await s.close();await rm(dir,{recursive:true,force:true});}
});

37
tests/protocol.test.mjs Normal file
View file

@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {validateJob,trial} from '../src/protocol.mjs';
import {publicAddress,startProxy} from '../src/proxy.mjs';
import http from 'node:http';
const fixture='http://127.0.0.1:9099/';
const valid=input=>validateJob(input,['http://127.0.0.1:9099'],fixture);
test('validated contract requires explicit protocol, allowed origin, bounded input and assertions',()=>{
assert.equal(valid(trial).viewports.length,2);
for(const patch of [
{api_version:'2'},{target:'file:///etc/passwd'},{target:'http://127.0.0.1:1/'},
{target:fixture+'?token=secret'},{timeout_ms:120001},{steps:[{action:'press',key:'Enter'}]},
{steps:[{action:'eval',value:'process.exit()'}]},{viewports:[{name:'../x',width:390,height:844}]},
{steps:[{action:'screenshot',name:'../../receipt'}]}, {storageState:{}},
])assert.throws(()=>valid({...trial,...patch}));
});
test('network gate denies non-public addresses unless explicitly granted',()=>{
for(const ip of ['127.0.0.1','169.254.169.254','10.0.0.1','172.17.0.1','192.168.1.1','100.64.0.1','::1','::ffff:127.0.0.1','fe80::1','fc00::1'])assert.equal(publicAddress(ip),false,ip);
for(const ip of ['93.184.216.34','2606:4700:4700::1111'])assert.equal(publicAddress(ip),true,ip);
});
test('proxy pins DNS and denies unlisted origins and private addresses before connection',async()=>{
let requests=0;
const target=http.createServer((req,res)=>{requests++;res.end('ok');});
await new Promise(r=>target.listen(0,'127.0.0.1',r));
const url=`http://127.0.0.1:${target.address().port}`;
const blocked=await startProxy([url],[]);
const allowed=await startProxy([url],[url]);
function request(proxy,path){return new Promise((resolve,reject)=>{
const req=http.get(proxy,{path},res=>{res.resume();res.on('end',()=>resolve(res.statusCode));});req.on('error',reject);
});}
try{
assert.equal(await request(blocked.url,url+'/'),403);assert.equal(requests,0);
assert.equal(await request(allowed.url,'http://127.0.0.1:1/'),403);assert.equal(requests,0);
assert.equal(await request(allowed.url,url+'/'),200);assert.equal(requests,1);
}finally{await blocked.close();await allowed.close();await new Promise(r=>target.close(r));}
});

29
tools/chromium-launcher.py Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/python3
"""Keep the browser process group tied to its worker, even during launch/crash.
Playwright starts this launcher in a new process group. Linux PR_SET_PDEATHSIG
closes the gap before launchServer() has returned a browser PID to the service.
"""
import ctypes
import os
import signal
import subprocess
import sys
def terminate(_signal, _frame):
os.killpg(os.getpgrp(), signal.SIGKILL)
parent = os.getppid()
signal.signal(signal.SIGTERM, terminate)
signal.signal(signal.SIGINT, terminate)
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0:
sys.exit(70)
if os.getppid() != parent or parent == 1:
terminate(None, None)
binary = os.environ['RAPP_CHROMIUM_EXECUTABLE']
# Playwright's --remote-debugging-pipe uses these inherited descriptors.
child = subprocess.Popen([binary, *sys.argv[1:]], pass_fds=(3, 4))
sys.exit(child.wait())

18
tools/render.py Normal file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Render only a supplied immutable image; no floating defaults can deploy."""
import os
from pathlib import Path
import re
image = os.environ.get('IMAGE_REFERENCE', '')
if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/rapp-playwright@sha256:[a-f0-9]{64}', image):
raise SystemExit('IMAGE_REFERENCE must be the published rapp-playwright sha256 reference')
source = Path('manifests/workload.yaml').read_text()
rendered = source.replace('${IMAGE_REFERENCE}', image)
if '${' in rendered:
raise SystemExit('unresolved template field')
out = Path('.rendered')
out.mkdir(exist_ok=True)
(out / 'workload.yaml').write_text(rendered)
(out / 'namespace.yaml').write_text(rendered.split('---',1)[0])
print(out / 'workload.yaml')

View file

@ -5,7 +5,7 @@ title: "Define the agent consumption contract and first browser trial"
domain: infotech
repo: rapp-playwright
topic_slug: stack
status: proposed
status: finished
flavor: planning
owner: codex
depends_on: [RAPP-PLAYWRIGHT-WP-0001]
@ -24,7 +24,7 @@ trial specification cover the complete agent path without claiming availability.
```task
id: RAPP-PLAYWRIGHT-WP-0002-T01
status: todo
status: done
priority: high
state_hub_task_id: "6d93439c-7e89-5685-9514-1a8af9f56e9e"
```
@ -44,7 +44,7 @@ No speculative endpoint is presented as live.
```task
id: RAPP-PLAYWRIGHT-WP-0002-T02
status: todo
status: done
priority: high
state_hub_task_id: "9cad9657-17a3-5b70-adea-102b71a242bf"
```
@ -64,7 +64,7 @@ failure; defined limits and lifecycle semantics; planned availability is explici
```task
id: RAPP-PLAYWRIGHT-WP-0002-T03
status: todo
status: done
priority: high
state_hub_task_id: "90d893fc-4222-591f-ab50-9da52a533907"
```
@ -78,3 +78,13 @@ or treating the fixture trial as the consumer's completed task.
Acceptance: trial specification and consumer checklist are traceable to every
demand row; integration and failure cases are ready for WP-0003 implementation.
## Execution evidence — 2026-10-13
All contract tasks passed. See [consumer contract](../docs/consumer-contract.md),
[trial specification](../examples/trial.json), [pilot plan and result](../docs/content-explorer-pilot.md)
and decision `90c6d03a-6308-4741-ab71-ac837e242e85`. Bounded jobs, Python client,
private RBAC transport, target reachability, error/lifecycle/evidence limits and
trial assertions are concrete. Founder selected railiance-platform ownership.
Runtime/publication and managed/discovery work remain in WP-0003 and WP-0004;
no residual delivery work is closed by this planning completion.

View file

@ -5,7 +5,7 @@ title: "Implement the reproducible browser service and Railiance package"
domain: infotech
repo: rapp-playwright
topic_slug: stack
status: proposed
status: active
flavor: implementation
owner: codex
depends_on: [RAPP-PLAYWRIGHT-WP-0002]
@ -25,7 +25,7 @@ unverified until WP-0004.
```task
id: RAPP-PLAYWRIGHT-WP-0003-T01
status: todo
status: wait
priority: high
state_hub_task_id: "227bec3f-1de8-5d8d-9dfd-b4eb5a836ae6"
```
@ -42,7 +42,7 @@ fails clearly, image identity is reproducible and build/run instructions work.
```task
id: RAPP-PLAYWRIGHT-WP-0003-T02
status: todo
status: done
priority: high
state_hub_task_id: "3f935c96-b4be-5f2d-b3de-d9b11d1fcfab"
```
@ -60,7 +60,7 @@ for setup failures and deliberately failing UI assertions.
```task
id: RAPP-PLAYWRIGHT-WP-0003-T03
status: todo
status: done
priority: high
state_hub_task_id: "e504c3f0-47ee-5822-86d9-c0bd8bd63b02"
```
@ -77,7 +77,7 @@ diagnosable outcomes and no leaked browser processes or retained sensitive state
```task
id: RAPP-PLAYWRIGHT-WP-0003-T04
status: todo
status: done
priority: high
state_hub_task_id: "dccce465-f948-50d5-874d-d22dcbbc42cc"
```
@ -90,3 +90,19 @@ operator diagnostics. Validate against current Railiance schemas and render tool
Acceptance: package validation and rendering pass; installation, smoke and
rollback commands are reviewable. Readiness reflects only evidence obtained;
local build success does not imply deployed service availability.
## Execution evidence — 2026-10-13
T02, T03 and T04 passed: client/real-browser path, isolation/cleanup negatives,
managed declaration, immutable rendering and client-side Kubernetes validation.
See [verification and retained logs](../docs/evidence/2026-10-13-implementation.md)
and [independent final-image trial](../docs/evidence/2026-10-13-independent-local-consumer.md).
T01 remains **wait**. The final image builds and launches, but its required OCI
publication failed at Forgejo blob PUT with HTTP 499/500. Remote digest inspection
returns `manifest unknown`. Candidate local index is
`sha256:8d6fd5a8172cf906bb503642ed620761945c5138b86f6396c36f65cbe6def441`.
Platform handoff `e77247e5-ce25-4711-a64f-af81a9f7cbb1` carries this blocker.
Resume by resolving registry delivery with its owner, pushing the tested image,
and reading back the immutable remote digest. Do not mark this plan finished
or advertise an installable release before that proof.

View file

@ -5,7 +5,7 @@ title: "Activate the service, prove agent consumption and update discovery"
domain: infotech
repo: rapp-playwright
topic_slug: stack
status: proposed
status: blocked
flavor: implementation
owner: codex
depends_on: [RAPP-PLAYWRIGHT-WP-0003]
@ -26,7 +26,7 @@ the verified contract and its limits.
```task
id: RAPP-PLAYWRIGHT-WP-0004-T01
status: todo
status: wait
priority: high
state_hub_task_id: "09f87694-8832-5ae8-9d0b-b66834ffcfcc"
```
@ -44,7 +44,7 @@ dependencies as explicit waits rather than inferring admission from repo setup.
```task
id: RAPP-PLAYWRIGHT-WP-0004-T02
status: todo
status: done
priority: high
state_hub_task_id: "08fb80b5-d36b-5b31-8b67-20be5ead70b2"
```
@ -65,7 +65,7 @@ content-explorer assesses its own completion. A health response alone cannot pas
```task
id: RAPP-PLAYWRIGHT-WP-0004-T03
status: todo
status: wait
priority: high
state_hub_task_id: "c8642437-27c5-5e58-b0f8-847c285fb4e6"
```
@ -80,3 +80,28 @@ Acceptance: inspect the maintained capability and published discovery response
to confirm the provider references and claimed availability match the receipts.
Retain the notification and readback evidence, synchronize work records, and
track any remaining delivery or consumer gap in a live follow-up work record.
## Execution evidence — 2026-10-13
T02 passed for provider acceptance: independent initial/final local-container
trials and a pinned OnCo home-page pilot retained receipts, screenshots, keyboard
outcomes and negative cases. See [independent report](../docs/evidence/2026-10-13-independent-local-consumer.md)
and [pilot](../docs/content-explorer-pilot.md). Content-explorer was notified via
`621635c9-0a72-4a88-9fd6-2ee1fbddc68f`; it decides its own T04 acceptance and further
route coverage. Local proof does not imply managed endpoint proof.
T01 remains **wait** on WP-0003 image publication and railiance-platform admission
(`c82d75f3-3191-410d-a8c8-fd647bf7f3ef`, follow-up
`e77247e5-ce25-4711-a64f-af81a9f7cbb1`). Founder selected that lifecycle owner.
The namespace is absent; no workload was deployed. Current capacity and reviewed
package exist. Resume with owner admission/access, bootstrap/server dry-run/diff,
immutable rollout, independent managed trial and live cancellation/restart/rollback.
T03 remains **wait** on reuse-surface's maintained and published update.
Notification `30503ee1-77f9-4e9e-a63a-2f70ac097823` provides contract and local evidence.
Readback still showed planned/source-only provider availability. Delivery of a
message is not registry publication. Resume by inspecting the owner update and
published discovery response; preserve the local/managed distinction.
[Implementation evidence](../docs/evidence/2026-10-13-implementation.md) and
[scope](../SCOPE.md) preserve these live waits. This workplan is not finished.

View file

@ -1,12 +1,14 @@
# Workplans
| ID | Purpose | Depends on |
| ID | Purpose | Status |
| --- | --- | --- |
| [RAPP-PLAYWRIGHT-WP-0001](RAPP-PLAYWRIGHT-WP-0001-statehub-bootstrap.md) | Demand, intent, planning and State Hub bootstrap | — |
| [RAPP-PLAYWRIGHT-WP-0002](RAPP-PLAYWRIGHT-WP-0002-consumer-contract.md) | Decide the agent contract and runnable trial | WP-0001 |
| [RAPP-PLAYWRIGHT-WP-0003](RAPP-PLAYWRIGHT-WP-0003-service-package.md) | Implement the runtime, agent path and Railiance package | WP-0002 |
| [RAPP-PLAYWRIGHT-WP-0004](RAPP-PLAYWRIGHT-WP-0004-consumer-verification.md) | Activate, prove consumer use and update discovery | WP-0003 |
| [WP-0001](RAPP-PLAYWRIGHT-WP-0001-statehub-bootstrap.md) | Demand and State Hub bootstrap | Finished |
| [WP-0002](RAPP-PLAYWRIGHT-WP-0002-consumer-contract.md) | Agent contract and trial | Finished |
| [WP-0003](RAPP-PLAYWRIGHT-WP-0003-service-package.md) | Service, client and Railiance package | Implemented locally; image publication waits on Forgejo |
| [WP-0004](RAPP-PLAYWRIGHT-WP-0004-consumer-verification.md) | Activate, prove consumption and update discovery | Independent local trial/pilot done; managed activation and registry update blocked |
WP-0002 through WP-0004 are proposed work for subsequent sessions. The initial
request ends after documentation, registration and notification; it does not
start service implementation or complete content-explorer's own acceptance task.
The founder authorized implementation after the original bootstrap. Platform
lifecycle ownership is railiance-platform. Open tasks retain concrete external
dependencies and resume instructions; local tests do not close managed delivery.
See [implementation evidence](../docs/evidence/2026-10-13-implementation.md).
Content-explorer retains acceptance of its own screenshot/keyboard task.