Skip to content

Execution environments and checkpoint recovery

Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.

Default images are generic harness/toolchain images. They do not bundle the Preloop application, PostgreSQL, or another customer's application. A project can supply its own image and dependencies. An optional hosted environment profile supplies the application dependencies an implementation agent needs before it spends its model budget. An administrator installs a JSON registry and sets FLOW_ENVIRONMENT_PROFILES_FILE to its path. A flow selects an entry with agent_config.environment_profile. Issue descriptions cannot add images, mounts, host privileges or secrets to that registry.

Profiles require an image digest, harness (codex or opencode) and protocol version 1. The image contains Python 3.12+, the harness, and /opt/preloop-environment.json with version: 1 and the harness name. Startup rejects an incompatible image before setup. Source still comes from the requested repository checkout. The profile image does not include a stale copy of the application.

environments/preloop/Dockerfile is a Preloop-specific example and integration fixture, not a default agent image. It extends the existing Dockerfile.dev image with pinned Codex, Playwright/Chromium, a PostgreSQL Python driver, and a distro Perl toolchain (perlver, perlcritic, and prove). Build from the repository root so the hash-pinned tools/ lockfile and requirements.txt copy in. Build the dev image first, then pass its digest as DEV_IMAGE. Register the resulting image digest. environments/preloop/profile.json.example contains component, backend and full-application profiles; replace the explicit digest placeholders with operator-approved digests. The component profile only installs frontend packages. The backend profile starts disposable PostgreSQL/pgvector and NATS. Neither starts the complete application for every change. The application profile explicitly calls scripts/flow-environment-app-check.sh, which starts and health-checks the API/frontend, seeds disposable test data and cleans up its child process groups.

Each dependency has a pinned image, unique name/port, optional command and nonproduction environment variables. Docker provisions an execution-specific network with service DNS aliases. Kubernetes uses native sidecars (Kubernetes 1.29+ with sidecars enabled), localhost ports and startup probes; the kubelet terminates them when the main container ends. Services are also removed during executor cleanup. No host Docker socket is mounted. Private runners currently reject named version-1 profiles explicitly because their custom image entrypoint contract differs. Existing private custom images remain supported; they receive GIT_CLONE_CONFIG and CUSTOM_COMMANDS JSON, including setup commands. Those images must implement these entrypoint fields themselves.

A private-runner flow can select its project image directly through the flow API: agent_config: {"image": "registry.example.com/team/project-agent:release"}. The private launch log reports the effective image reference, including either custom-image alias, alongside the installed harness version. The reference is the requested tag or digest; a tag alone does not attest the pulled image digest. No named profile registry is required. docker_image is also accepted; when both keys are present the runner checks image first. Omit environment_profile for this raw image path. An explicit nonempty override takes precedence over the operator's per-harness image environment variable and the generic fallback. Docker on the private host must be able to pull that image, whose entrypoint must consume the flow environment contract. The flow form offers a Custom container image field for ephemeral execution on a private runner (an explicit runner or label, or a private account default). It loads image first and falls back to docker_image, writes a trimmed image on save, and keeps the saved value untouched while the field is unavailable (hosted, Auto, persistent, or native host execution), so an override can still be managed through the API.

Setup has its own timeout and failure marker, with output under /workspace/evidence/setup.log. Readiness runs on every attempt. Profiles may list lockfiles and cache paths; dependency setup is reused only when the profile and lockfile contents match and all declared cache paths still exist. Restoring a workspace without its reproducible dependencies forces setup to run again. Image layers remain reusable independently. Test-command groups and artifact paths describe the repository's verification contract; the publication gate chooses and verifies the relevant commands. Issue readiness requires an enabled verification gate. Every command in its always/rule/unknown-impact policy must have an environment command group with the same ID and exact shell text (multiple steps are joined with newlines). Issue acceptance command IDs must also appear in the verification policy. Capability readiness is not test-result attestation; agent-sandbox files and log markers cannot authorize isolated publication.

Backend and frontend tests in the image

The image built from environments/preloop/Dockerfile runs the Preloop test suites from a checkout with no network and no install step.

  • /opt/preloop-tests is a virtualenv installed from .github/requirements/app-dev.txt, the hash-pinned lock the CI backend shards install (runtime dependencies plus the dev extra, including pytest). It is on PATH. The checkout itself is not installed.
  • preloop-pytest [pytest args] runs pytest from the checkout root with backend/ on PYTHONPATH. When DATABASE_URL is unset it starts a throwaway PostgreSQL 16 cluster under /tmp (pgvector 0.8.6, built in the image because Ubuntu's 0.6.0 lacks subvector()), applies the checkout's migrations with scripts/init_db.py, and reapplies them only when backend/preloop/models/alembic changes. With DATABASE_URL set (the backend profile's sidecar) it uses that database as is. Tests, migrations and init_db.py run under an environment allowlist: provider keys are mock_key and agent API, gateway and git tokens are dropped. It works as root, uid 1000 and uid 10000 (the Docker harness user, which the image gives a passwd entry so initdb can run).
  • preloop-frontend-deps copies the image's frontend/node_modules into the checkout when frontend/package-lock.json is byte-identical to the one the image was built from, then cd frontend && npm test (or npx --no-install web-test-runner <file>) runs offline against the frontend's own pinned headless Chromium. On a different lock it exits 65; run npm --prefix frontend ci, which needs registry access.
  • A pull request that changes app-dev.txt makes preloop-pytest warn that the baked venv may lack a new dependency. Rebuild the image to pick it up.

The frontend test runner uses one browser session at a time inside flow containers, identified by both FLOW_ID and EXECUTION_ID. This avoids a host-CPU-derived default exceeding the container's resource budget. Set PRELOOP_TEST_CONCURRENCY to a positive integer to override that limit, for example PRELOOP_TEST_CONCURRENCY=2 npm test from frontend/. Invalid overrides fail configuration loading. Outside flow containers, an unset override preserves the test runner's normal default. Run scoped backend and frontend checks sequentially so they do not compete for the same container resources.

environments/preloop/python-venv-smoke.sh is the build gate (imports only). Given a checkout path it runs a database-backed backend test file and a frontend test file offline:

docker run --rm --network none --user 10000:10000 -e HOME=/tmp \
  -v "$PWD:/src:ro" <image> /opt/preloop-pip/python-venv-smoke.sh /src

backend/tests/test_preloop_backend_test_venv.py runs the same command when PRELOOP_ENVIRONMENT_IMAGE names a built image and skips otherwise. The backend venv (about 980 MB), frontend tree (about 280 MB) and second headless Chromium (about 270 MB) grow the image from 2.5 GB to 3.8 GB unpacked (1.07 GB to 1.52 GB compressed).

Browser profile

preloop-browser in environments/preloop/profile.json.example uses the same image as the component profile and adds an egress-proxy sidecar. The proxy contract (listen port, EGRESS_ALLOWED_ORIGINS, EGRESS_ALLOW_PRIVATE_CIDRS, EGRESS_DENY_PRIVATE, and GET /healthz) is the one in environments/egress-proxy/README.md. DependencyService.env already stores that service environment on the registered profile. A flow only selects the profile identifier, so the origin allowlist is fixed per profile rather than copied from agent_config.

environments/preloop/browser/enable.sh reads PRELOOP_BROWSER_PROXY (http://egress-proxy:3128 on Docker, http://127.0.0.1:3128 on Kubernetes), renders playwright-mcp.config.json, and registers a browser MCP server for PRELOOP_HARNESS (codex or claude). Codex reads ~/.codex/config.toml ([mcp_servers.browser] with command and args). The hosted Codex executor writes [mcp_servers.preloop] from backend/preloop/agents/codex.py and would replace that file; setup also leaves ~/.codex/preloop-browser-mcp.toml, which the executor appends after its own write. There is no Claude Code writer in this repository; Claude Code reads .mcp.json in the checkout, and enable.sh merges the browser entry there. The pinned package is @playwright/mcp@0.0.82, installed in the profile image next to Playwright 1.64.0-alpha-1789764292000 (the build that package bundles). The MCP command is the image binary /opt/preloop-env-tools/node_modules/.bin/playwright-mcp with --config, --proxy-server, --isolated, and --headless. Setup does not fetch the package from the npm registry. --isolated starts Chromium with an empty profile: no cookies and no operator storage state. The rendered launch args also include --proxy-bypass-list=<-loopback> so loopback is not a path around the proxy. The self-check probes the metadata address, a non-allowlisted origin, and a listener on 127.0.0.1.

The example profile does not list test_commands. A flow that gates verification on command IDs must add those IDs to the registered profile; otherwise readiness reports environment_command_missing.

Chromium is started with the two flags the proxy README requires: --proxy-server and --host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE <proxy host>. Without them the browser can open connections that never pass the allowlist. --no-sandbox is added only when the probe runs as uid 0.

The proxy enforces origins. The harness MCP entry and Preloop tool permissions decide which tools the agent may call. Tool selection is not a network boundary. enable.sh then runs selfcheck.sh, which requires $PRELOOP_BROWSER_PROXY/healthz to answer ok and launches Chromium once against http://169.254.169.254/ and https://example.org/. Both probes must fail with a proxy error (egress_denied or a Chromium proxy/tunnel error). Any other outcome, including a missing proxy variable, a failed health check, or a probe that loads, exits browser_egress_not_enforced and aborts profile setup.

Routing this browser MCP server through the Preloop firewall is a follow-up. The control plane cannot reach the execution network to sit on that path today. Issue #885 would then add timeline rows for those tool calls.

Durable hosted artifacts

Enable FLOW_ARTIFACT_DIRECT_UPLOAD when the runner can reach PRELOOP_URL. The checkpoint Helm overlay enables direct uploads with a 64 MiB compressed cap and matching 80 MiB proxy limits. Merge its extraEnv entries with existing installation values. Without it, the legacy snapshot path remains in effect, including the 2 MiB Kubernetes log-channel cap. Raising WORKSPACE_SNAPSHOT_MAX_BYTES alone does not raise that log cap. FLOW_EVIDENCE_LOG_PLAINTEXT=false refuses that log channel (the snapshot is then skipped with plaintext_disabled); see evidence storage. A skipped legacy snapshot does not mean setup failed. With direct upload enabled, workspace checkpoints travel through authenticated HTTP, never the pod log channel. The service validates compressed and expanded size, archive paths and file kinds, encrypts the payload with the configured encryption key, and commits the immutable manifest and payload together in PostgreSQL. An interrupted upload cannot become the latest checkpoint. Evidence packs use the same store with kind=evidence and a separate retention window; see Evidence storage and retention. Workspace checkpoint log transport is skipped when a checkpoint PUT token is present.

Capabilities permit one artifact kind and operation for one execution, account, flow and implementation thread. They confer no general storage access. Reads check the persisted thread binding, manifest identity and payload digest. Native-session archives use their own artifact kind and capability; FLOW_NATIVE_SESSION_RETENTION_HOURS (168 by default) caps their manifest expiry independently of workspace retention, so either artifact can report its own expiry; workspace capture excludes session directories. Agent containers never receive the control plane's encryption key. Use a dedicated SECURITY__ENCRYPTION_KEY in production, protect it separately from the database, and retain it across restarts. Key rotation must preserve access to artifacts encrypted by old keys.

A five-minute loop captures source and git state. It checks file sizes, modification times and membership for changes during capture, declining a busy snapshot rather than committing inconsistent state. The last completed checkpoint survives process/pod loss; writes after that checkpoint can be lost. Controlled exits attempt a final checkpoint. Before legacy wrapper publication, a failed checkpoint blocks publication, except when the archive exceeds the storage cap: that case logs PRELOOP_CHECKPOINT skipped checkpoint_oversized, exits 0, and leaves the last completed checkpoint as the resume point. A trusted external publisher must make this checkpoint barrier part of its handoff as well.

Restore occurs before setup or agent startup on Docker and Kubernetes. It logs the age of the checkpoint it recovered (PRELOOP_CHECKPOINT restored age_seconds=... created_at=...), so work lost to node loss is visible rather than assumed to be zero. Source, each repository's branch, head and upstream base commit, staged/unstaged edits and required untracked files are retained. Reproducible dependencies, known credential locations, environment files, symlinks, git configuration (including submodule and worktree copies, which carry the same remote URL credential) and native session directories are excluded. This exclusion list is not a content-level secret detector; keep production credentials out of implementation workspaces. The trusted clone configuration recreates remotes. Divergent/newer remote commits are detected without checking out or overwriting the restored local branch. A restored branch that has never been pushed can continue when the remote confirms that the branch is absent. Authentication/network errors remain explicit remote_unavailable failures rather than being confused with divergence or absence. A branch identity mismatch blocks recovery. Missing, corrupt or expired checkpoints fail resume explicitly. Automatic cold branch fallback is disabled because a remote branch does not prove unpublished local work was preserved. Private executors never receive hosted artifact capabilities.

WORKSPACE_SNAPSHOT_MAX_BYTES bounds compressed uploads; FLOW_ARTIFACT_EXPANDED_MAX_BYTES bounds extraction; FLOW_ARTIFACT_ACCOUNT_QUOTA_BYTES bounds retained encrypted account payloads. Retention uses WORKSPACE_SNAPSHOT_TTL_HOURS; zero expires on the next cleanup pass. Downloads take a lease so cleanup cannot remove their payload during restore. Metadata remains with availability expired after bytes are removed. Use retention appropriate to the review window, and surface expiry instead of claiming a resumable session remains available.

Private workspaces remain in the runner's local configuration directory with restricted directory/file permissions. Operators are responsible for host disk encryption. PRELOOP_RUNNER_WORKSPACE_MAX_BYTES bounds retained workspace bytes (default 4 GiB); oldest unleased directories are removed first and a local .expired tombstone distinguishes expiry/quota loss from a missing runner. PRELOOP_RUNNER_WORKSPACE_TTL_HOURS=0 disables retention; active jobs are protected. Cleanup also runs during idle heartbeats. No private workspace is uploaded by this transport. A continuation of a persisted workspace is leased only to the runner that holds it: while that runner is offline the execution stays queued with a message naming it, and after the queue deadline it fails for operator action rather than moving to another host. If a job does reach a runner without the workspace it was told to resume, the runner refuses it (workspace_recovery_unavailable) instead of cloning cold and dropping the unpublished work; the local .expired tombstone distinguishes retention or quota loss from a wrong host.

Validation

The repository includes archive security, tenant/thread isolation, encrypted roundtrip, lease/expiry, setup timeout, cache invalidation and private runner contract tests. scripts/tests/flow_environment_integration.py runs real SQL and Chromium checks through the hosted executor, checkpoints through HTTP, kills the sandbox, and checks exact unpushed/dirty/untracked recovery in a new one. Supply a migrated disposable DATABASE_URL, image digests and, for Kubernetes, an explicit disposable --kubeconfig. It never invokes a model. Set PRELOOP_DISABLE_TELEMETRY=true for all such tests and setup scripts.

This fixture verifies the generic runtime and recovery contract. The SQL/browser probe is an optional example, not a required application stack or a claim that a project's full end-to-end suite passes. Run the repository's relevant verification commands separately; missing dependencies and unavailable checks remain explicit blocked evidence. Use immutable repository@sha256:<digest> references for both profile and service images. Raw private-runner image overrides do not require a named profile registry; named private profiles remain unsupported until the runner advertises that protocol.