Implement bounded browser service, consumer evidence and managed package
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13590-a16f-7f60-be60-fd71627bc99d
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.git
|
||||
node_modules
|
||||
.local
|
||||
.rendered
|
||||
history
|
||||
docs/evidence
|
||||
__pycache__
|
||||
5
.gitignore
vendored
|
|
@ -3,3 +3,8 @@
|
|||
.claude/*
|
||||
!.claude/rules/
|
||||
!.claude/rules/*.md
|
||||
node_modules/
|
||||
.local/
|
||||
.rendered/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
37
AGENTS.md
|
|
@ -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
|
|
@ -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"]
|
||||
10
INTENT.md
|
|
@ -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
|
|
@ -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
|
||||
58
README.md
|
|
@ -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.
|
||||
|
|
|
|||
78
SCOPE.md
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
||||
67
docs/content-explorer-pilot.md
Normal 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.
|
||||
13
docs/evidence/2026-10-13-browser-integration.log
Normal 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
|
||||
86
docs/evidence/2026-10-13-build.log
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/evidence/2026-10-13-content-explorer-pilot/narrow-final.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
214
docs/evidence/2026-10-13-content-explorer-pilot/receipt.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
docs/evidence/2026-10-13-contract-decision.json
Normal 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"
|
||||
}
|
||||
39
docs/evidence/2026-10-13-discovery-readback.json
Normal 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"
|
||||
}
|
||||
49
docs/evidence/2026-10-13-final-image-restart.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
docs/evidence/2026-10-13-implementation-checks.json
Normal 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"
|
||||
}
|
||||
67
docs/evidence/2026-10-13-implementation.md
Normal 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.
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
184
docs/evidence/2026-10-13-independent-final-consumer/receipt.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"http_status": 404,
|
||||
"body": {
|
||||
"error": "not_found"
|
||||
}
|
||||
}
|
||||
140
docs/evidence/2026-10-13-independent-local-consumer.md
Normal 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.
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"argv": [
|
||||
"client/rapp_playwright.py",
|
||||
"trial",
|
||||
"--out",
|
||||
".local/independent-trial-01"
|
||||
],
|
||||
"service_url": "http://127.0.0.1:18081"
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
184
docs/evidence/2026-10-13-independent-local-consumer/receipt.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"http_status": 404,
|
||||
"body": {
|
||||
"error": "not_found"
|
||||
}
|
||||
}
|
||||
9
docs/evidence/2026-10-13-missing-browser.log
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
41
examples/content-explorer-onco.json
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||
76
history/2026-10-13-1918-intent-scope-assessment.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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')
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||