Environments & the Gateway
The AI Test Agent is no longer a single container on :8083. That port is now a
gateway that authenticates every caller and routes each run onto a pool of
per-environment worker containers. This page explains the topology, what an
"environment" is, how you pick one per run, and the scheduling rules that keep
two runs from colliding on the same emulator.
|
At a glance
|
Why a Gateway
The old model was one test-runner container that you retargeted by editing all four base URLs in Settings before a run. That serialised everyone onto one emulator and one Chromium, and gave no way to run two environments at once.
The gateway (test-runner-gateway image) replaces that. It is the only
process users talk to. Behind it sit one or more workers — each an ordinary
test-runner container (plus its mcp-tr-emul and bbf-tree-mcp sidecars) that
is env-agnostic: it holds no fixed environment of its own. The gateway pushes
the chosen env’s settings onto a worker just before it starts the run.
Authentication lives entirely in the gateway. gateway_service/app.py runs an
auth middleware that rejects any request without a valid Bearer session token,
except the allowlist above and the /novnc/* asset prefix. The gateway then
forwards the caller’s identity to the worker as X-Forwarded-User /
X-Forwarded-Role headers, signed with the shared X-Gateway-Secret.
Topology
| Component | Role |
|---|---|
Gateway ( |
The only public port. Hosts the SPA, terminates auth, owns the central user /
stand / profile database ( |
Worker (test-runner container) |
Executes runs. Env-agnostic — the gateway materializes a profile’s settings
onto it per dispatch via |
Per-env emulator wrapper ( |
Optional |
Workers reach the gateway-forwarded headers as the source of truth for
who is running. A worker never sees a user password — only the signed identity
headers. The X-Gateway-Secret must match on both sides or the worker rejects
the forwarded identity.
|
Environment Profiles
An environment is a named JSON settings blob stored in the gateway’s
profiles table (name, settings_json, created_by, updated_at). The
settings shape is identical to the per-worker Settings form
(support / management / backend / emulator / ACS URLs, credentials, MCP URLs,
…) — but it lives centrally instead of on one container.
Manage profiles from the Settings tab, which is now an env-profile editor:
| Action | Effect |
|---|---|
Create / Save-as |
|
Export / Import |
Round-trips a profile’s JSON so envs can be copied between deployments. |
Delete |
|
Listing profiles (GET /api/gateway/profiles) is allowed for any
logged-in user so the Env: dropdown can populate; create / read-full /
delete are admin-only.
|
Selecting an Env per Run
On the Tests tab, an Env: dropdown sits next to the run controls. Your
choice is remembered in localStorage, so it persists across reloads — see
running-tests.adoc#env-select.
When you start a run, the SPA calls POST /api/gateway/run (or
/api/gateway/run/batch) with {env, test_file|test_files, model?}. The
gateway then:
-
Picks a free worker from the enabled pool.
-
Materializes the env —
PUT /api/settingsonto that worker (as the gateway admin) with the profile’s settings. -
Starts the run on that worker as you (your forwarded identity), and records
run_id → workerso later reads / WebSocket progress route back to the same worker.
This replaces the old "edit all four Settings URLs to retarget" workflow — you no longer mutate a shared container to switch environments; you just choose a different env in the dropdown.
Scheduling Model
The scheduler (gateway_service/scheduler.py) enforces two independent
constraints.
Env-exclusive + emulator-mutex
A worker is "free" only when it is enabled, not already busy, and reports zero
active runs upstream. On top of that, exclusivity is keyed on the env’s
physical emulator (emulator_url), not its name:
-
Two different envs that point at the same emulator (e.g.
Default,qa65,kira2all ontr069-emulator.test-agent) share one device serial per case, so they are mutually exclusive — the second submit gets409"the emulator for env '…' is busy". -
Envs on distinct emulators (e.g.
kira3→qa11m) carry different mutex keys and run in parallel.
| Condition | Response |
|---|---|
Requested env’s emulator already has a run |
|
No enabled worker is free |
|
Keying the mutex on the emulator (not the env name) turns a silent
device-serial collision into a clean 409. If a profile’s emulator_url
lookup ever fails, the scheduler degrades to a per-env-name mutex rather than
breaking dispatch.
|
Per-account isolation
Runs, history, force-stop, and the live browser are scoped to the calling user.
The gateway fans these reads out to every enabled worker as the caller; the
worker endpoints are owner-scoped, so you only ever see your own runs. Admins
see all. The reads affected: GET /api/runs, GET /api/runs/active, the
GET /api/run/batch* family, POST /api/run/stop, and the noVNC live view
(GET /api/live/stand + /novnc/{stand}/websockify, gated so you may watch a
worker’s browser only while you own the active run on it).
Workers Tab
Workers (stands) are managed from the Workers tab (admin-only):
| Action | Endpoint |
|---|---|
Register a worker |
|
Enable / disable |
|
Add Worker (provision) |
|
Deregister / teardown |
|
Portainer Auto-Provisioning
gateway_service/provision.py can deploy stacks straight into Portainer. It is
best-effort and inert when Portainer credentials are unset — provisioning
simply does nothing and the save / add still succeeds.
| Trigger | Provisions |
|---|---|
New env profile with an |
A per-env |
Add Worker |
A |
Provisioned stacks publish host ports the gateway then addresses by
base_url. Teardown (?teardown=true on profile or stand delete) removes the
matching Portainer stack.
|
Invariants
Read these before changing the gateway or adding a worker:
-
GATEWAY_SECRETmust match across the gateway and every worker. It signs the forwarded-identity headers; a mismatch makes workers reject all proxied requests. -
Workers run
TRUST_GATEWAY=1. This disables their local login and makes them trustX-Forwarded-User/X-Forwarded-Rolefrom the gateway. A worker in this mode must never be exposed to users directly — it has no auth of its own. -
Per-stand persistent volumes keep each worker’s state isolated:
test-cases-<STAND>,run-reports-<STAND>,qa-notes-<STAND>, and/opt/mcp-shared-<STAND>(serial isolation). They survive redeploys. -
Shared Claude OAuth — the
${CLAUDE_CONFIG_DIR:-/root/.claude}volume is mounted read-write on each worker so a token refresh on one propagates to all.
Gateway Endpoints
The gateway-specific surface (everything else falls through to the catch-all proxy → primary worker). Full request/response detail in REST API.
| Endpoint | Purpose |
|---|---|
|
Session auth. Login returns a |
|
Env profile CRUD ( |
|
Worker (stand) registration, enable / disable, deregister. |
|
Auto-provision a new worker container. |
|
Schedule a single / batch run onto a free worker for the chosen |
|
Per-account fan-out reads across enabled workers. |
|
Live-view target resolution, owner-scoped stop, run progress stream, noVNC. |
See also: Settings (the env-profile editor and field reference), Running Tests (picking an env per run), and API Reference (gateway endpoint details).