Paired web applications
How a trainer desktop reaches a private Draw.io companion through the Sandbox gateway — and what blocks that path today.
Sandbox can start an Ubuntu desktop and a separate application container (today: Draw.io), then link them as one pair. Chrome on the desktop is given a token-protected Sandbox URL. When Chrome opens it, Sandbox checks that the token belongs to the right companion and pair, then forwards the request to Draw.io inside Kubernetes. Draw.io itself has no public address.
This page is the step-by-step platform view of that design, plus the current desktop-to-companion network route and its in-VPC fix.
Intended path (step by step)
- Create the pair —
POST /sandbox/sandboxes/v1/environments/pairswith immutable desktop and companion pins (application_version+ digest). - Poll —
GET /sandbox/sandboxes/v1/environments/pairs/{pair_id}untilstatus=ready(orfailed). - Open the launch URL — when ready,
launch_urlpoints at the gateway, not at the companion pod IP and not at127.0.0.1. - Chrome on the trainer desktop loads that URL over HTTPS.
- Companion proxy authenticates the ticket, resolves the pair from the companion sandbox record, and reverse-proxies to the private Draw.io container.
- Assets reuse an HttpOnly cookie scoped to the gateway-visible companion
path (uses
X-Forwarded-Prefix).
Refresh reconnects to the same pair. A new attempt (attempt_key) gets a
fresh pair and clean state.
URL and auth (shareable facts)
| Topic | Fact |
|---|---|
| Launch URL shape | /sandbox/sandboxes/v1/<companion-id>/companion/?token=… |
| ID in the path | Companion sandbox ID, not the pair ID. Sandbox finds the pair from the companion record (pair_id label). |
| Token checks | Companion ID (sid), tenant/project scope (tid / pid), and pair status (ready, or a purpose-scoped provisioning probe). |
| Trainer identity | Do not claim a fresh trainer-identity check on every browser request — that check is not on this path today. |
| Token lifetime | Aligned with the pair TTL (default two hours via SANDBOX_PAIRED_ENVIRONMENT_TTL_SEC). “Short-lived” is relative to that bound, not a one-minute ticket. |
| Network policy | Companion ingress allows traffic from the Sandboxes service and the gateway. Saying “only from the gateway” is too narrow. |
| Browser destination | Must be the gateway host — never 127.0.0.1 as the trainer’s Draw.io URL. Internal health checks may still use loopback. |
Example launch URL (shape only):
https://sandbox-preprod.turing.com/sandbox/sandboxes/v1/<companion-sandbox-id>/companion/?token=<pair-scoped-ticket>What works in code today
| Piece | Role |
|---|---|
| Pair create / status / destroy | Durable pair record, async provision, reconnect, cleanup |
| Launch URL minting | Writes token URL into the desktop workspace binding file |
| Companion proxy | Ticket auth + private upstream HTTP to Draw.io |
| Version pins | Catalog-owned application_version ↔ image digest |
API surface: Sandboxes API — pairs.
Why the desktop cannot use the public gateway
Trainer desktops are GCE VMs in a subnet with no external IP and no Cloud NAT
(by design: "Runtime desktops continue to use the primary subnet without internet
NAT"). The public gateway is an internet-facing load balancer, so a desktop that
resolves its hostname still times out connecting to :443. Preprod tests from
two running desktops showed exactly this. No amount of proxy work helps until
Chrome has a route it can actually dial.
The desktop subnet can reach resources inside the VPC: the firewall already
allows the desktop subnet to GKE nodes, and the Sandbox service reaches desktops
the same way over :5000 / :8080. So the fix is an in-VPC address for the
companion route, which Sandbox owns end to end (Helm + config), with no change
to the public gateway or Cloud NAT.
Trainer Chrome (desktop VM, no internet)
│ HTTP, in-VPC only
▼
Internal LB sandbox-sandboxes-internal (source range = desktop subnet)
│
▼
sandbox-sandboxes pod → /v1/<companion-id>/companion/ (token-checked)
│ private, in-cluster
▼
Draw.io pod (no public address)How it is configured
Everything lives in one values block per environment, sandboxes.companionInternalLb
(defaults in infra/helm/sandbox/values.yaml):
| Key | Purpose |
|---|---|
enabled | Renders the internal LoadBalancer Service sandbox-sandboxes-internal |
loadBalancerIP | Reserved regional internal address in the cluster subnet (required). The sandboxes pod derives SANDBOX_COMPANION_DESKTOP_BASE_URL=http://<ip>:8784 from it, so one deploy is enough |
sourceRanges | Desktop subnet CIDRs allowed to call it (required). Take them from SANDBOX_GCE_UBUNTU_GNOME_PLACEMENTS |
globalAccess | true when desktops sit in regions other than the cluster's (prod) |
baseUrl | Optional override of the origin given to desktops (for example a private DNS name) |
Rendering fails if the block is enabled without loadBalancerIP or baseUrl, so
an environment can never hand desktops the unreachable public URL by accident.
Why a reserved address: GKE does not reserve the address you set with
loadBalancerIP (it can be released when the Service is deleted), and an
ephemeral address "might" change when the Service is updated. Both are in the
GKE LoadBalancer Service parameters
reference. A launch URL that points at a moved address would silently stop
working.
The base URL must be an http(s) origin with no path and never a loopback host;
the service rejects anything else. A direct in-VPC request has no gateway prefix,
so the cookie is scoped to /v1/<companion-id>/companion (_companion_cookie_prefix
in app.py).
| Environment | Desktop subnet(s) | Reserved address | State |
|---|---|---|---|
| dev | 10.40.0.0/20 | 10.40.8.100 | Enabled |
| preprod | 10.50.0.0/20 | 10.50.8.100 | Enabled |
| prod | 16 regional subnets (10.60.0.0/20, 10.70.0.0/20 … 10.84.0.0/20) | 10.60.8.100 | Prepared with globalAccess: true; enabled: false |
Rolling it out in an environment (no DevOps ticket)
-
Reserve the address first (once per environment). The command fails if the address is already taken, which is the check you want:
gcloud compute addresses create sandbox-companion-internal \ --project turing-agios-<env> --region us-central1 \ --subnet turing-agios-<env>-us-central1 --addresses <loadBalancerIP>Do not reuse a name that starts with
k8s-or contains the Service name. -
Deploy once. GKE creates the internal load balancer on that address and the
sandboxespod gets the matching base URL in the same rollout. -
Create a pair. It only becomes
readyafter the desktop itself fetches the companion route, so a wrong address fails closed withdesktop could not reach companion gateway URL.
Enabling a new environment (for example prod)
- Confirm desktops have no internet route: no Cloud NAT covering their subnets.
- List every desktop subnet CIDR from
SANDBOX_GCE_UBUNTU_GNOME_PLACEMENTS(gcloud compute networks subnets list --project <project>) and put them insourceRanges; setglobalAccess: trueif any are outside the cluster's region. - Pick an address in the cluster subnet that is not used by any VM, forwarding
rule or reservation, reserve it (step 1 above), set
loadBalancerIP, setenabled: true, deploy.
Known limits
- Plain HTTP inside the VPC. The launch URL carries a bearer token over HTTP between a desktop and the load balancer. The traffic never leaves the VPC, but it is not encrypted. Mitigations today: the token is scoped to one companion and expires with the pair, and after the first request it moves into a path-scoped cookie. TLS on the internal route is a follow-up.
- Chrome blocks downloads from an HTTP origin. Saving from a companion app
(for example Draw.io's File → Save) fails with "Insecure download blocked".
The desktop image's Chrome wrapper therefore passes
--unsafely-treat-insecure-origin-as-securefor the companion origin. Task code can write thelaunch-urlfile in the workspace, so the wrapper never trusts it to name an origin. It trusts only origins listed in the root-owned/etc/sandbox/companion-secure-origins, and only whenlaunch-urlis a plain, non-symlink file with exactlyhttp://<private IPv4>:<port>/v1/<sandbox>/companion/...for that origin. The image builder writes the allowlist fromSANDBOX_COMPANION_DESKTOP_BASE_URLfor every image that carries a companion app (the build worker needs that variable, and the chart sets it whencompanionInternalLbis enabled). The origin is part of the image's content hash, so an image built for another environment is never reused. Without the allowlist the wrapper trusts nothing and downloads stay blocked, and a build with an HTTP origin that is not a private IPv4 address fails. This needs a desktop image rebuilt with the updated wrapper; Chrome shows an "unsupported command-line flag" bar while it is active. - The whole Sandboxes service is reachable from the desktop subnet. The load
balancer cannot filter by path, so the service is exposed to
sourceRanges. Application authentication (companion token or API key) is still enforced on every route, and the firewall limits callers to desktop subnets. A dedicated companion-only listener or Deployment would remove this exposure and also isolate Draw.io traffic from the control plane; it is a follow-up. - Scope. This covers the paired Draw.io route. It does not prove the validator or other app templates; each needs its own end-to-end run.
Scaling and tuning (hundreds of trainers)
The companion proxy is built so that many trainers can use their own Draw.io at the same time:
- Route cache. The first request for a pair resolves its upstream (a control-plane
lookup); the result is cached and shared by concurrent requests, so a trainer loading
200 assets costs one lookup, not 200. A
502from the upstream drops the cached route. Onlyreadyroutes are cached. Deleting a pair (or stopping or deleting its companion) drops the cached route in the pod that handled the request at once, including a lookup that is running at that moment (it is never cached). The cache is per pod, so other Sandboxes replicas can keep serving the route for up to the TTL, or until the pair's expiry if that comes first. Lower the TTL, or set it to0, if that window is too long for your environment. Tune withSANDBOX_COMPANION_ROUTE_CACHE_TTL_SEC(default15, range0-120;0disables caching). - Pooled, streamed upstream calls. One pooled HTTP client per worker streams
responses through unchanged (compressed assets stay compressed). Tune with
SANDBOX_COMPANION_PROXY_MAX_CONNECTIONS(default256). When the pool is full the proxy answers503withRetry-After: 1instead of queueing forever. - Per-profile contract cache. A profile's private-web contract is read from the catalog once per process. Rebuilding the catalog on every lookup cost about 200 ms of CPU and was the main limit under load.
- Measured (single process, load generator in the same process, fake upstream).
100 trainers (2,100 requests): no errors, asset p99 about 0.4 s, one route lookup per
pair. 200 trainers: no errors, asset p99 about 1.1 s. 500 trainers shares one core with
the load generator and degrades (first-load p99 above 10 s), so run several Sandboxes
replicas for that scale. The chart's CPU HPA already covers the
sandboxesDeployment (autoscalingininfra/helm/sandbox/values-*.yaml: dev 1-2, preprod 2-8, prod 3-12 replicas). Before a large session, raiseminReplicasso capacity is warm instead of waiting on the HPA, and confirm it with a load test on that environment. Reproduce withSANDBOX_LOAD_TEST=1 pytest -s sandbox/services/sandboxes/tests/test_companion_load.py. - Admission quota. Each pair is two sandboxes. Check the project's concurrent
sandbox quota (
SANDBOX_PROJECT_MAX_CONCURRENT; 10 by default) covers2 x trainersbefore a large session.
Status
| Layer | Status |
|---|---|
| Pair API + pins | Deployed |
| Draw.io companion container | Can become running |
| Proxy + token checks | In code, covered by tests |
| In-VPC route (internal LB + reserved address) | In code and chart (dev, preprod); not yet proven on a live desktop |
| Full Draw.io in Chrome | Proven only after the checklist below passes |
Related canary signals (dev)
Recent sandbox-dev canaries also hit deploy/IAM issues that can fail the
desktop half of the pair before Chrome even starts (for example desktop
credential storage HTTP 403, or a crash-looping sandboxes revision). Fix those
separately so both children stay running.
Unblock checklist
You are done with the connectivity canary when:
- The address is reserved (
gcloud compute addresses describe sandbox-companion-internal --region us-central1) andkubectl -n sandbox get svc sandbox-sandboxes-internalshows thatEXTERNAL-IP. - From a running trainer desktop,
curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://<EXTERNAL-IP>:8784/healthzconnects (no timeout). - Pair reaches
status=readywith a non-loopbacklaunch_urlon that address. - Opening
launch_urlin desktop Chrome loads Draw.io HTML (not 401 / 409 / 502). - A follow-up asset request works via the companion cookie (no token in the query).
- Refresh reconnects to the same pair without resetting trainer work.
Operator verify sketch
export SANDBOX_GATEWAY_URL=https://sandbox-preprod.turing.com # or sandbox-dev
export SANDBOX_API_KEY=…
AUTH=(-H "X-Api-Key: $SANDBOX_API_KEY" -H "Content-Type: application/json" \
-H "X-Sandbox-Tenant-Id: ctp" -H "X-Sandbox-Project-Id: sandbox")
# 1) Create pair (pins must match catalog)
curl -fsS "${AUTH[@]}" -d @pair-request.json \
"$SANDBOX_GATEWAY_URL/sandbox/sandboxes/v1/environments/pairs"
# 2) Poll until ready / failed
curl -fsS "${AUTH[@]}" \
"$SANDBOX_GATEWAY_URL/sandbox/sandboxes/v1/environments/pairs/$PAIR_ID"
# 3) On the desktop VM (not your laptop), prove the in-VPC route
curl -vI --max-time 15 "$SANDBOX_COMPANION_DESKTOP_BASE_URL/healthz"
# 4) Only then open launch_url in desktop ChromeIf step 3 times out, stop and fix the route (internal LB, source range, base URL) — proxy debugging will not help.
Deep reference (repo)
| Area | Location |
|---|---|
| Pair TTL, launch URL, desktop connectivity probe | sandbox/sandboxes_service/.../paired_environments.py |
| Token checks + cookie path + proxy route | sandbox/services/sandboxes/.../app.py (/{sandbox_id}/companion/...) |
| Upstream HTTP proxy | sandbox/sandboxes_service/.../companion_proxy.py |
| Catalog pins (Draw.io versions) | infra/catalog/desktop_apps/drawio.yaml |
MVP companion is Draw.io (state_model=file). Stateful companions (for
example Overleaf) remain rejected until a dedicated design and canary.