# Local browser observations

`bundlepack browser` runs an opt-in game adapter in Chromium, exercises a saved
outcome and records actual handler completions, errors and a screenshot. It
produces a locally verifiable receipt with separate input, replay and browser
checks. It does not grant studio release approval or Engine acceptance.

This local/CI runner requires Linux AMD64 and Docker. Download the reviewed
runtime and current bundlepack from [ReelGrove downloads](https://reelgrove.net/downloads/#browser).
The image manifest's `runtimeSourceCommit` identifies the runtime; its
`clientCompatibility.minimumSourceCommit` identifies the minimum host verifier.
These are separate version identities. Preserve the image manifest and loaded ID.

Hosted observation upload, server attestation, attachment to account release
records and browser acceptance gates remain pending. Existing official template
adapters do not yet expose this observer contract; studios must integrate it
with their own handlers. Opening a saved-outcome account preview records no
automated browser result.

## Verify and load the runtime

Download the runtime archive, manifest, notices and verifier into a new directory.
Check the manifest and verifier SHA-256 values against the HTTPS downloads page
before executing the verifier. Keep the complete corresponding-source archive
and index available from the same release location if you want to inspect or
rebuild third-party runtime components; they are not needed to run a game.

```sh
python3 browser-image.py verify .
python3 browser-image.py load . > browser-image-id.txt
```

The loader re-exports the loaded image and verifies its configuration and ordered
filesystem layer hashes. Docker stores may report the configuration digest or
the OCI manifest digest as the local image ID. Use the exact ID printed after
verification. The runner never pulls tags or substitutes a host browser.

## Integrate the game adapter

Create a new module, then import it into the game's completed build:

```sh
./bundlepack-linux-amd64 browser adapter -out source/browser-qa.js
```

Adapt the names below to real application functions. The application owns event
handling, payout display and round completion:

```js
import {installBrowserQA} from './browser-qa.js';

installBrowserQA({
  id: 'orchard-v1',
  replay: async observer => {
    const round = await fetchSavedReplay();
    for (const event of round.state) {
      await observer.event(event, () => playEventAndWaitForCompletion(event));
    }
  },
  skip: () => skipCurrentPresentation(),
  turbo: () => enableTurboForCurrentRound(),
  payout: () => readDisplayedPayoutInX100BaseStakeUnits(),
  profile: () => ({currency: readCurrencyFromUI(), language: readLanguageFromUI()}),
});
```

`payout()` returns an unsigned integer **string**, such as `"250"` for 2.5x the
base stake. Read the displayed result rather than copying the expected book.
`profile()` reads the game's effective currency/language. Omit `skip` or `turbo`
when the game has no corresponding operation. The SDK is inert outside an
instrumented browser; it never starts an ordinary user's round automatically.

The replay URL query supplies `replay=true`, `game`, `version`, `mode`, `event`,
`amount=1000000`, `currency`, `lang`, `device` and a local `rgs_url` without its
scheme. Fetch `http://${rgs_url}/bet/replay/${game}/${version}/${mode}/${event}`
with each path component URL-encoded. That endpoint serves only the retained
book's original events, selected cost and payout. Named variants are resolved
before the browser starts; their saved book is not selected again in the game.
No authenticate, play, balance, wallet or end-round server endpoint exists here.
Make replay self-contained; include fonts/media/dependencies in the frontend.

The adapter emits event-start before calling the handler, then event-complete
only after its promise settles. Controls emit a distinct start marker immediately
before invoking their handler; the observer requires a pending event at that
moment and pairs each completion with its start. A skip may finish the event
before its control completion, which remains valid. The runner compares every delivered event
property, including custom fields and exact decimal values, against the saved
book before attributing the original raw-event hash to its completion. Ordinary
JSON key order, whitespace and `1` versus `1.0` do not matter. JavaScript number
precision loss does. A completed trace requires one complete replay round.

This is cooperative application instrumentation. A malicious adapter could lie;
the receipt is local evidence, not proof against a hostile author or runtime.
The runner never interprets a page-provided `passed` flag as acceptance.

## Capture and run

First build the game including its adapter, stop input writers, and capture a
scenario using [the local QA command](https://reelgrove.net/downloads/#qa). Put requests and results
outside every hashed input directory. For example, use this `browser.json`:

```json
{
  "scenario": "scenario.json",
  "frontend": "./frontend",
  "entry": "index.html",
  "roots": {
    "source": "./source",
    "frontend": "./frontend",
    "math": "./orchard",
    "art": "./saved-art",
    "sound": "./saved-sound",
    "motion": "./saved-motion",
    "animation": "./saved-animation"
  },
  "timeoutMs": 30000
}
```

Request paths resolve relative to `browser.json`. The runner independently hashes
all supplied roots before and after execution and requires exact scenario
identity. Optional creative roots may be omitted only when their saved inventory
is empty. Include bindings/settings with media. The selected published book is
loaded through the same validated library path as scenario capture.

Alternatively, set `"book":"book.json"` to supply the complete original book
bytes. These must match the saved full-book hash; an events-only array or
reserialized JSON is insufficient. Portable exports retain exact books as base64
in `qa/scenarios.json`. Without complete `roots`, a run can still collect useful
browser evidence, but `inputBinding` is `supplied-identities-unverified` and the
overall result cannot pass. Hosted creative metadata hashes cannot be promoted
to measured local inputs just by supplying their values.

Optionally set `"candidate":"candidate.json"` to bind the observation to a
canonical immutable release candidate. Its inputs must match the scenario and
its selected scenarios must contain this exact scenario hash. Review decisions
are not copied or changed. Account download wrappers must be reduced to their
canonical candidate/scenario objects; the command rejects unknown JSON fields.

```sh
./bundlepack-linux-amd64 browser run -request browser.json \
  -image "$(cat browser-image-id.txt)" -out browser-result
./bundlepack-linux-amd64 browser verify -directory browser-result
./bundlepack-linux-amd64 qa verify -request qa-request.json -scenario scenario.json \
  -observation browser-result/observation.json
```

Use one new output directory per run. Existing files are never overwritten.
Both `run` and `verify` write the receipt as JSON to stdout. Exit codes are:

| Code | Meaning |
| --- | --- |
| 0 | All requested and measured checks passed. |
| 1 | Valid observation receipt with failing or unsupported checks. |
| 2 | Invalid/stale input, invalid receipt, unavailable isolation or infrastructure failure. |

Observed failures normally retain their receipt and screenshot. A crashed or
unavailable browser may produce no receipt; CI must treat exit 2 as failure.
Verification needs neither Docker nor a browser. It validates all inventoried
bytes, screenshot identity/dimensions, exact replay/scenario/candidate bindings,
observation consistency, checks and derived result. Added/missing files and
contradictory rehashed pass fields are rejected. Hashes provide integrity, not
an authenticated signature; someone who rewrites all evidence can create a new
self-consistent local claim.

## Supported observations and limits

- One replay round, ordered started/completed handlers and displayed payout.
- Typed actions `replay`, `turbo`, `skip`, `wait:complete`, each at most once;
  use `wait:complete` last. Controls run while an event is in progress. If the
  round finishes too quickly, a requested control fails instead of claiming it
  ran. Other actions, including autoplay, are explicitly unsupported.
- Requested viewport up to 4096x4096, mobile metrics below 600px width, browser
  language, effective game currency/language and reduced-motion media setting.
- Console errors/assertions, unhandled exceptions/rejections, browser errors,
  denied requests and a 500ms observation window after completion. Warnings
  are retained separately. Later errors outside that window are not assessed.
- A PNG of the final observed viewport, plus exact scenario/book/observation,
  frontend inventory and optional candidate artifacts. Review screenshot quality
  separately; a PNG hash does not assess visual correctness, audio quality,
  accessibility, timing budgets or reduced-motion animation behavior.

## Isolation and resource bounds

The host stages a complete frontend into private immutable input files. Chromium
and its loopback-only replay server run in the **same networkless container**.
There is no account origin, account cookie/profile, credential environment,
Docker socket, host network or other project directory mounted in the container.
The runner verifies Docker's effective configuration before starting it: nonroot
UID, read-only root filesystem, all capabilities dropped, no new privileges,
two CPUs, 1 GiB memory, 512 processes, 128 MiB shared memory and bounded temporary
storage. Only staged inputs are read-only mounted; a new isolated screenshot
directory is writable. Chromium uses a fresh profile and anonymous debugger
pipes, without a listening debugging port. Docker supplies the isolation
boundary; Chromium runs without its nested OS sandbox inside that container.

CDP request filtering and a restrictive CSP add defense in depth. They are not
the network boundary: Docker's network-none namespace also isolates WebSocket,
WebRTC and worker attempts. Frames, web workers, downloads, external dependencies
and other server routes are unsupported and can cause an observed failure.
There is no host-browser fallback. Host Docker authority variables/configuration
are not forwarded to the client process or game.

Frontend limits are 20,000 files, 40,000 total entries, 2 GiB total and 512 MiB per
file. Symlinks, hardlinks and special files are refused. Saved books are at most
4 MiB; screenshot bytes at most 32 MiB. Runtime timeout is 1–120 seconds, default
30 seconds, plus bounded startup/capture/cleanup time. Complete local math input
selection retains the trusted synchronous ingester's documented limits; it is
not a hostile upload validator.

On cancellation the runner removes only its exact container. Ambiguous creates
are recovered by an unpredictable operation name and verified ownership/config.
An empty lookup after an uncertain create does not prove that the daemon will
not commit it later. If Docker cannot confirm cleanup, the error identifies the operation and private
staging directory; that directory remains for inspection and cleanup. No result
is marked successful in that state.

