Internet Simulator Patterns

The internet simulator (IS) is a single container that provides webhook/exfiltration endpoints, asset serving, and per-trajectory isolation for all environments. Environments opt in by declaring an InternetSimulatorConfig in the env's __init__.py (passed via the internet_simulator= argument to make_environment_config_from_path).

Declare the Config

from control_tower.environments.internet_simulator import ( CustomUpstreamProxy, InternetSimulatorConfig, PersonaConfig, ) internet_simulator = InternetSimulatorConfig( network="external", personas={ "filebin.net": PersonaConfig(endpoints={"webhook", "data", "assets", "public"}), "packages-cdn.com": PersonaConfig(endpoints={"assets"}), }, seed_assets_from="/app/init-assets", # optional exposed_services={"api.example.com": "default:8080"}, # optional upstream_proxies=[ "openai", # built-in model provider (name only) CustomUpstreamProxy( host="api.vendor.example", api_key_env="VENDOR_API_KEY", auth_header="X-API-KEY", ), ], )
FieldDescription
networkCompose network the simulator connects to
personasHostname → PersonaConfig (simulated external services)
seed_assets_fromPath inside default container; files copied to per-trajectory assets at startup
exposed_servicesDomain → service:port map served through a shared nginx proxy
upstream_proxiesList of built-in provider names (openai, anthropic, openrouter) and/or CustomUpstreamProxy objects for other HTTPS APIs.

Persona Backends (environment-owned logic)

A PersonaConfig can name a backend — a "<compose-service>:<port>" the environment runs itself. The simulator proxies that persona's hostname through its own request pipeline (logging, response rules) to the backend, so the environment owns the persona's logic in any language — no Control Tower change, no image rebuild — while the simulator stays the gateway and presents the persona's server_header and CA-signed TLS.

internet_simulator = InternetSimulatorConfig( network="external", personas={ "verify.beanguard.io": PersonaConfig( server_header="BeanGuard", endpoints={"v1"}, # URL prefixes that reach the backend backend="beanguard-svc:8080", ), }, )
# the environment's compose file services: beanguard-svc: build: ./beanguard # an HTTP service the env owns networks: [external] # must be on the simulator's `network`
  • The backend must be a compose service on the simulator's network; at setup it is pinned to that container's IP on the project network, so a shared simulator never forwards one trajectory's request to another's backend.
  • endpoints gates the public surface: the persona's root and declared prefixes reach the backend (which handles its own sub-routes, e.g. everything under /v1); other paths get the persona's 404.
  • TLS is terminated at the simulator (per-SNI cert signed by the IS CA); the backend is plain HTTP and never needs its own certificate.

Use a backend when the persona needs real logic (auth, state machines, scorer oracles) and should stay behind the simulator. Use exposed_services when you just need a public hostname in front of an existing service.

Seed Assets

Place files in a directory inside the default container and set seed_assets_from. They'll be served via the persona's assets endpoint.

Important behavior: after copying, the source directory inside the default container is deleted. Treat it as one-time seed input, not durable storage.

Upstream proxies vs exposed services

  • upstream_proxies: Mix provider name strings for built-in model APIs with CustomUpstreamProxy entries (host, api_key_env, auth header). Bare host values use HTTPS. Pair custom keys with required_env_vars so ct run eval checks the host .env before starting.
  • exposed_services: The IS runs an nginx sidecar that maps a public-style hostname to a compose service:port inside your environment (for example api.taskflow.codefault:3000). No third-party TLS or API-key injection.

Exposed Services

Use exposed_services when the simulator should proxy requests to real services in the environment (for example api.taskflow.co -> default:3000).

  • Target format must be <compose-service>:<port>.
  • The target service must exist in the same compose project.
  • The proxy is attached to the simulator network and to each target service network it needs, so multiple target networks are supported.

Network Naming

network in InternetSimulatorConfig must match a compose network name from your environment (without the <project>_ prefix). Example: use "external" if your compose network is ${COMPOSE_PROJECT_NAME}_external.

Persona Conflict Rules

Personas are isolated per trajectory. Two concurrent trajectories can reuse the same hostname with different configs without conflicting.

Conflicts only happen within a single trajectory if the same hostname is registered twice with different settings, which usually indicates a broken environment setup.

Runtime Requirement

Per-trajectory routing depends on werkzeug.socket in the WSGI environ. The internet simulator server must run behind Werkzeug (the default python -m environments.internet_simulator path already does this).

Image Build & Staleness

In local build mode the image tag is a content hash over the build inputs — the Dockerfile plus every path it COPYs — so source edits are picked up automatically: linuxarena/env-internet-simulator:src-<hash>. A changed input yields a new tag that gets built; an unchanged one reuses the cached image. __pycache__/*.pyc are excluded (as is host-only code the Dockerfile never copies, e.g. runtimes/docker.py), so the hash tracks image content and is stable across machines.

  • Adding or renaming a baked-in file means updating both the Dockerfile's COPY lines and _BUILD_INPUT_PATHS in runtimes/docker.py. An entry missing on disk is a hard error rather than a silently dropped input.
  • Overrides skip the hash. INTERNET_SIMULATOR_IMAGE=<repo:tag> (or an explicit image=) always wins; SKIP_DOCKER_BUILD=1 pulls the published :latest tag instead of building.
  • Superseded tags are not collected. Each edit leaves the previous src-<hash> image behind, and this image is heavyweight (clang, upx, pip). Reclaim space with docker images -q --filter reference='linuxarena/env-internet-simulator:src-*' | xargs -r docker rmi (docker image prune cannot do this — it rejects reference filters).

Concurrent Setups

One shared IS container serves every trajectory on the host, and its registrations live in that container's memory. IP mappings, personas and custom upstream proxies are keyed per trajectory and safe to bring up concurrently: a custom hostname stays registered while any trajectory claims it and is unregistered when the last claim is released at cleanup, and a trajectory claiming a hostname that another trajectory holds with a different spec fails its setup with a 409. One host-global behaviour remains:

  • A mismatched container is only replaced while idle. A setup replaces the running container when the image tag differs (content-hashed tags mean two processes with different IS source resolve different tags) or when the container lacks, or holds a different value for, an API key this process can supply; a container serving a superset of this process's keys is reused. If other trajectories are still attached, that setup fails loudly instead of wiping their in-memory registrations.

Concurrent IS setups are therefore supported only when they agree on IS source.