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", ), ], )
| Field | Description |
|---|---|
network | Compose network the simulator connects to |
personas | Hostname → PersonaConfig (simulated external services) |
seed_assets_from | Path inside default container; files copied to per-trajectory assets at startup |
exposed_services | Domain → service:port map served through a shared nginx proxy |
upstream_proxies | List 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>" that the environment runs itself. The simulator proxies the persona's hostname through its own request pipeline (logging, response rules) to the backend and presents the persona's server_header and CA-signed TLS. The environment owns the persona's logic, in any language, with no Control Tower change or image rebuild.
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. endpointsgates 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 only 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.
After copying, the source directory inside the default container is deleted, so treat it as one-time seed input.
A backendless persona serves the same per-trajectory assets under any custom endpoint name it declares: with endpoints={"wiki"}, https://en.wikipedia.org/wiki/Docker_(software) serves assets/wiki/Docker_(software), and a directory path (or one ending in /) serves its index.html.
Upstream proxies vs exposed services
upstream_proxies: Mix provider name strings for built-in model APIs withCustomUpstreamProxyentries (host,api_key_env, auth header). Barehostvalues use HTTPS. Each proxy'sapi_key_envis required in the process env exactly like arequired_env_varsentry:ct run evalchecks it before starting and forwards it to fleet workers. List it inrequired_env_varsonly to attach a description and setup instructions to the error message.exposed_services: The IS runs an nginx sidecar that maps a public-style hostname to a composeservice:portinside your environment (for exampleapi.taskflow.co→default:3000), with no third-party TLS or API-key injection.
Exposed Services
- 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.
Resolver
The simulator also runs a DNS server on port 53 (UDP and TCP). A query arriving at one of the simulator's project-network addresses is attributed to that address's trajectory through the same network mapping the HTTP endpoints use. The name is answered with that address if it is one of the trajectory's registered personas and with NXDOMAIN otherwise, and every query is logged.
Under --no-internet, Control Tower points every sandbox service's dns at this server on each network the service joins, and pins the simulator to those addresses with resolver_addresses, joining every project network rather than only the declared one (see Network Isolation). In open mode nothing queries it.
Image Build & Staleness
In local build mode the image tag, linuxarena/env-internet-simulator:src-<hash>, is a content hash over the build inputs: the Dockerfile plus every path it COPYs. A changed input yields a new tag that gets built, and an unchanged one reuses the cached image. __pycache__ and *.pyc are excluded, as is host-only code the Dockerfile never copies (e.g. runtimes/docker.py), so the hash is stable across machines.
- Overrides skip the hash.
INTERNET_SIMULATOR_IMAGE=<repo:tag>(or an explicitimage=) always wins;SKIP_DOCKER_BUILD=1pulls the published:latesttag 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 withdocker images -q --filter reference='linuxarena/env-internet-simulator:src-*' | xargs -r docker rmi(docker image prunerejectsreferencefilters).
Concurrent Setups
One shared IS container serves every trajectory on the host and keeps its registrations in 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; a trajectory claiming a hostname that another trajectory holds with a different spec fails its setup with a 409.
A mismatched container is only replaced while idle. A setup replaces the running container when the image tag differs (two processes with different IS source resolve different content-hashed 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, the setup fails loudly instead of wiping their registrations. Concurrent IS setups are therefore supported only when they agree on IS source.