Environment design (and what we borrowed from Harbor)
August 21, 2026 · View on GitHub
Proteus runs harnesses it does not control, so the environment a harness needs — runtime,
system packages, the harness itself — has to be packaged, pinned, and separated from the
evolving state. Before designing environments/, we surveyed
Harbor (Laude Institute; the framework behind
Terminal-Bench 2.0), which manages containerized environments for 40+ agents at scale.
This note records what we adopted, what we deliberately do differently, and why.
Adopted from Harbor
- One manifest with a
docker_imageshort-circuit. Harbor'stask.tomlaccepts either anenvironment/Dockerfileor a prebuiltdocker_imageref; the same schema serves development builds and pinned releases. Ourenvironment.tomldoes the same (SandboxConfig.from_manifest). - Declared network policy. Harbor tasks declare
network_mode(NO_NETWORK|PUBLIC|ALLOWLIST) instead of leaving isolation to the invoker. Our manifests declarenetworkper environment;noneis the default inSandboxConfig. - Date/version-tagged prebuilt images, never
latest. Harbor ships every Terminal-Bench task as a Docker Hub image tagged by build date. We tagproteus-env-<name>:<harness-version>(e.g.proteus-env-dsh-src:0.1.0-rc.7). - Prefixed image naming for safe cleanup. Harbor prefixes locally built images
(
hb__*) socache cleancan match them. Ourproteus-env-prefix serves the same purpose. - State outside the image. Harbor injects agents into containers and collects
artifacts from fixed mount points. We mount the evolving workspace and the harness's
internal state (
/workspace,/statefor dsh); the image never carries run state, so one image serves every arm and seed. - Resource limits in the manifest. cpus / memory / storage live in Harbor's task
config, not in runner flags. Ours declare
cpus/memoryper environment.
Noted for later (not implemented)
- Egress allowlist via a sidecar. Harbor implements
ALLOWLISTwith a NET_ADMIN sidecar container and can switch policy mid-trial (agent offline, verifier online). The natural Proteus use: evolution episodes offline, evaluator phases online. Requires compose-level orchestration we don't have yet. - Reward-file contract. Harbor verifiers write
/logs/verifier/reward.txt|json— scoring decoupled from the harness language. A Proteus evaluator that reads a file the episode wrote would let any container self-report a score through the file boundary. - Digest pinning. Harbor pins image tags; digests (
@sha256:) are stricter and we should adopt them when we start publishing prebuilt images to a registry. - Oracle/no-op baselines. Harbor validates environments by running the reference solution and a no-op agent through the identical pipeline. The Proteus analogue — a scripted adapter replaying a fixed action list through a real environment — would validate an environment before any model spends tokens in it.
Deliberately different
- Harbor evaluates agents on tasks; the environment hosts one task attempt and is
discarded. Proteus evolves harnesses across episodes; the environment is re-entered
30+ times and the mounted workspace is the experiment's subject. Hence: no per-episode
image rebuilds, snapshots of the mounts instead of artifact collection, and no
benchmark registry — the
environments/directory in-repo is the registry. - Harbor's agent abstraction installs the agent into the task container at trial time. Proteus source-mode images bake a pinned pristine harness, its dependencies, and its build toolchain once. At seed time the adapter extracts the real source into the mounted workspace; later boots rebuild from that evolvable copy without rebuilding the image. Dependencies and toolchain are the constant apparatus; the run-local source is part of the measured subject.
Bringing your own environment
Point --env at a compatible image reference, or at a directory / environment.toml
describing one, to override a containerized adapter's default environment:
proteus run --harness dsh --env ghcr.io/you/your-env:1.4 --network host \
--arm neutral --seeds 2 --episodes 10 --out runs/mine
A manifest is the same thing, versioned, with the settings attached:
[environment]
docker_image = "ghcr.io/you/your-env:1.4" # or `image` for a tag you build locally
network = "host" # none (default) | host | bridge | a named network
memory = "8g"
cpus = "4"
workdir = "/workspace"
user = "1000:1000" # avoid root-owned files in the mounts
env_passthrough = ["DEEPSEEK_API_KEY"] # forwarded from your shell — secrets go here
docker_args = ["--gpus", "all"] # anything the fields above do not name
[[environment.mounts]] # extra bind mounts, repeatable
host = "/data/corpora"
container = "/corpora"
[environment.env] # literal values, visible in the process table
LANG = "C.UTF-8"
proteus run --harness pi --env ./my-env --out runs/mine ...
Command-line flags (--network, --mem, --cpus, --docker-arg) override the manifest,
so one manifest can serve several runs. In Python the same object is passed directly, which
is also how a custom adapter accepts one:
from proteus.sandbox import DockerSandbox, SandboxConfig
from proteus.adapters.dsh import DshHarness
env = SandboxConfig.from_spec("./my-env", network="host")
harness = DshHarness(sandbox=DockerSandbox(env), phase_timeout_s=1200)
The image contract belongs to the adapter. For dsh and pi, a replacement must provide
the same source-mode contract as their bundled images: the expected source tar
(/opt/dsh-source.tar or /opt/pi-source.tar), an entrypoint that exact-syncs
/workspace/src onto the pinned tree, rebuilds on source-hash changes, and then accepts the
adapter's CLI arguments. During model phases, the adapters mount the frozen active snapshot
read-only at /workspace, the writable candidate at /workspace/candidate, native state
at /state, and, for benchmark runs, the task at /workspace/task. The same boot contract
is reused model-free after reflect with the candidate at /workspace for boundary
validation. Their defaults also run containers as the host uid/gid so bind-mounted files
remain editable and snapshot-cleanable on Linux. A custom adapter may define a different
image contract.
Bounding an episode
The episode budget and wall-clock backstop are independent:
--max-turnsis the iteration budget: the number of steps an episode may take before it stops, enforced by the adapter rather than merely suggested to the model.minimalandllmenforce it directly.dshandpienforce it exactly between phases and approximately within a phase by polling their native session logs and stopping the container at the budget line.--min-turns-per-phasereserves budget for later phases;--announce-budgetadditionally tells the agent the limit, an off-by-default experimental condition. Budget stops recordturn_cappedand snapshot normally.- For long source-evolution tasks,
--phase-turnsreplaces the uniform minimum with an exact normal plan,--hard-max-turnsadds a bounded burst ceiling, and unused early quota is reserved for act.--checkpoint-turnskeeps an agent-visible tail for its own persistent handoff and requires--announce-budget. A recommended starting point is300normal /500hard withobserve=40,propose=25,act=200,reflect=35and a two-call checkpoint reserve. These are experimental-condition fields and must be repeated unchanged when resuming. --phase-timeoutis wall-clock seconds per phase for containerised harnesses, where the external CLI owns its own loop. Reaching it ends the episode with a timeout error rather than hanging the sweep. Default 600.
Episode cost grows with episode index — later episodes wake up to a larger harness and read more of it — so a cap that is comfortable at episode 1 is the one that matters at episode 30. The hard limit is still a ceiling, not a target: a phase may stop early when it has enough evidence or a complete change.