Skip to content

Architectural Decision Records (ADRs)

Architectural decisions documenting the evolution, design invariants, and runtime model of the Modal Cloud Sandbox Platform.

Decision Index

Number Title Summary / Status
0001 Pre-baked Android Toolchain in Standard Sandbox
0002 Shared Agent Configurations and In-Sandbox SOPS Antigravity Token Previously, agent configurations (opencode.json, settings.json, keybindings.json) were duplicated across standard_sandbox/ and vm_sandbox/, and sandbox runners attempted to inspect and bake unencrypted credentials from the developer's local workstation into container images. We decided to consolidate all shared agent configuration files into the top-level shared/ directory and encrypt the Antigravity OAuth token at secrets/antigravity-oauth-token.json. Sandbox runners no longer read credentials from the host machine or ~/.gemini; instead, specifying --agy automatically enables in-sandbox SOPS decryption via scripts/setup.sh, which decrypts the token directly into ~/.gemini/antigravity-cli/antigravity-oauth-token (mode 0600).
0003 Externalize SSH Private Key Management and Decouple Sandbox Runners Previously, the repository standardized on a dedicated repository key pair where the private key was encrypted with SOPS at secrets/ssh-private-key, decrypted locally to ~/.ssh/modal-sandbox via scripts/setup.sh, and launch runners aborted early if ~/.ssh/modal-sandbox was not present on the host machine. We decided to completely externalize SSH private key management: secrets/ssh-private-key has been removed from the repository, scripts/setup.sh no longer decrypts SSH keys, and sandbox runners validate only the public key tracked at shared/ssh-key.pub. Developers maintain the matching private key out-of-band at ~/.ssh/modal-sandbox (or update shared/ssh-key.pub if rotated), allowing sandbox launch scripts to run in headless, CI/CD, or automated container environments without inspecting or depending on host workstation credentials.
0004 Ephemeral SOPS Credentials and In-Sandbox Key Purging Previously, during sandbox bootstrap with --sops or --agy, startup.sh extracted AGE_PRIVATE_KEY from environment variables, wrote the private key to ~/.config/sops/age/keys.txt, and executed scripts/setup.sh. While startup.sh unset AGE_PRIVATE_KEY in bash login profiles, the plaintext Age key file remained permanently on disk at ~/.config/sops/age/keys.txt, leaving the root decryption key accessible to interactive developer shells, untrusted code, and autonomous agents running inside the container. We decided to make SOPS decryption strictly ephemeral: immediately after scripts/setup.sh runs (whether it succeeds or fails), startup.sh unconditionally purges ~/.config/sops via rm -rf ~/.config/sops and clears AGE_PRIVATE_KEY from memory.
0005 Decouple Setup Script Flags and Retire Sandbox SOPS Flag Previously, scripts/setup.sh supported opt-in Modal client credential decryption via --modal, but continued to decrypt the Antigravity OAuth token unconditionally by default. As a result, running scripts/setup.sh --modal on host workstations unintentionally decrypted Antigravity tokens, there was no independent --agy flag, and sandboxes relied on zero-argument execution to decrypt Antigravity tokens via a generic --sops launcher flag. We decided to decouple credential provisioning entirely: scripts/setup.sh now independently accepts --modal and --agy, neither flag triggers the other, zero-argument invocations exit cleanly as a no-op, and the redundant --sops flag has been removed from sandbox runners with container decryption triggered strictly by --agy.
0006 Opt-In Modal Development Tooling and Credential Provisioning in Sandboxes Previously, sandboxes supported --python for runtime virtual environment isolation, while setup scripts and ADR-0005 intentionally prohibited decrypting Modal client credentials (~/.modal.toml) inside sandboxes to prevent container processes from accessing root Modal cloud account infrastructure. However, workflows developing Modal applications, sandboxes, or distributed functions from inside a cloud sandbox require both a Python environment and authenticated Modal CLI/SDK access. We decided to retire --python and replace it with --modal across both standard and VM sandboxes: passing --modal couples Python 3 virtual environment provisioning (with the modal package pre-installed) with SOPS decryption of secrets/modal.toml into ~/.modal.toml. When --modal is omitted, neither Python tooling nor Modal client credentials are provisioned inside the sandbox.
0007 Automated OpenCode and MCP Credentials Provisioning via SOPS Previously, OpenCode server and remote Model Context Protocol (MCP) integrations (Exa, Context7) required manual credential configuration after sandbox initialization, and OPENCODE_GO_API_KEY set via environment variables did not register in OpenCode WebUI without account integration. We decided to automate credential provisioning through SOPS: secrets/opencode.env stores encrypted API keys (OPENCODE_GO_API_KEY, EXA_API_KEY, CONTEXT7_API_KEY), scripts/setup.sh decrypts them via a new --opencode flag to ~/.config/opencode/opencode.env and exports them to the environment, shared/opencode.json configures MCP servers using {env:...} header substitution and configures native OpenCode Go provider environment mapping via provider.opencode-go.env, and sandbox startup scripts export OPENCODE_API_KEY before launching opencode serve.
0008 Unified Base Image for Standard and VM Sandboxes Previously, Standard Sandboxes and VM Sandboxes maintained separate base images: Standard Sandboxes built on Debian (node:24.21-bookworm-slim) with pre-baked Node.js and no Docker tooling, while VM Sandboxes built on Ubuntu 24.04, installed docker.io, and installed Node.js 24 dynamically at runtime via fnm install 24 in startup.sh. We decided to unify the foundational base image across both sandbox types onto Ubuntu 24.04 with pre-baked Node.js 24 (via NodeSource), pnpm, Docker CLI tools, and a shared shared/startup.sh. This ensures 100% Modal image layer caching parity across both runtimes, eliminates runtime Node installation latency in VM Sandboxes, and consolidates sandbox bootstrap scripts.
0009 Pre-baked Chrome for Browser MCP Tooling Previously, shared/opencode.json enabled the Playwright and Chrome DevTools MCP servers, but the Shared Base Image shipped no browser. Both servers launched on demand and then failed with Could not find Google Chrome executable for channel 'stable' at /opt/google/chrome/chrome; neither ran headless, and neither disabled Chrome's sandbox, so they could not start as root in a display-less container. Every agent session saw two dead MCP servers that required manual provisioning. We decided to pre-bake a single headless Google Chrome into a dynamic image layer whenever Browser Tooling is requested, and to launch both MCP servers against that one binary with the flags the sandbox environment requires.
0010 Antigravity Remote Control and Unattended Permissions Previously, passing --agy installed the Antigravity CLI and decrypted the Antigravity OAuth token, but required an interactive terminal (SSH or modal shell) to operate. Users had no web-based graphical interface for Antigravity equivalent to OpenCode WebUI, and commands or file edits requested approval prompts by default. We decided to automatically launch the background Antigravity Remote Control daemon on container initialization when --agy is specified, assign the instance name to match the sandbox's unique Modal app_name, pre-configure wildcard permissions in shared/antigravity-settings.json, and alias agy in ~/.bashrc to pass --dangerously-skip-permissions for fully unattended execution.
0011 Shared Workspace Secret (share0) for SOPS Age Key Previously, AGE_PRIVATE_KEY was expected inside each repository's --secret-collection, duplicating the master Age private key across multiple secret collections and coupling repository cloning credentials with organizational encryption keys. We decided to decouple secret resolution: repository secret collections passed via --secret-collection provide only repository-scoped variables (GH_REPO, GH_TOKEN, DOTENV_PRIVATE_KEY), while a dedicated Modal secret collection named share0 provides shared workspace credentials, specifically AGE_PRIVATE_KEY. Sandbox runners conditionally mount modal.Secret.from_name("share0") whenever credentials decryption is requested (--agy, --modal, or --opencode).
0012 Pre-baked Cloudflared and Default Tunnel Ingress Previously, sandboxes relied solely on ephemeral Modal tunnels (sandbox.tunnels()[web_port].url) or direct SSH connections to access web applications running inside the container. We decided to pre-bake the standalone cloudflared binary into the Shared Base Image and start Cloudflare Tunnel as a background service by default during sandbox bootstrap. The service automatically routes external HTTPS traffic from the persistent custom domain https://3000.mahesh0.dev directly to local port 3000 across all Standard and VM Sandboxes without requiring open inbound firewall ports.
0013 Unified Sandbox Runner Previously, the repository maintained two separate sandbox launcher scripts: standard_sandbox/main.py for lightweight container sandboxes and vm_sandbox/main.py for virtualized VM sandboxes. Although the image definition (shared/image.py) and startup script (shared/startup.sh) were already unified in ADR 0005, the two runners were ~90% duplicate code and suffered from behavioral drift. We decided to unify both sandbox execution models into a single launcher at sandbox/main.py differentiated by an explicit --vm boolean flag, remove the redundant standard_sandbox/ and vm_sandbox/ directories, and strictly constrain --memory to VM mode.
0014 SOPS-Decrypted Cloudflare Tunnel Token and Default share0 Mounting Previously, shared/startup.sh configured Cloudflare Tunnel using a cleartext fallback tunnel token and accepted runtime overrides via the CLOUDFLARE_TUNNEL_TOKEN environment variable, while Modal secret share0 was only mounted when explicit agent flags (--agy, --modal, --opencode) were passed (ADR 0005, ADR 0003). We decided to encrypt the tunnel credential at rest in secrets/cloudflare.env, bake the encrypted file into the Shared Base Image at /root/secrets/cloudflare.env, mount share0 by default across all sandboxes unless --no-cloudflare is given, decrypt CF_TOKEN directly at container boot in Step 0 via SOPS without intermediate unencrypted files, completely eliminate cleartext fallback tokens and environment variable overrides, and unconditionally scrub AGE_PRIVATE_KEY and temporary SOPS state after sandbox bootstrap.
0015 Local SOPS-Encrypted Repository Secrets and Retirement of Modal Secret Collections Previously, repository cloning credentials (GH_REPO, GH_TOKEN, and optional DOTENV_PRIVATE_KEY) were managed as cloud-hosted Modal secret collections passed via --secret-collection (ADR 0005). This required maintaining separate Modal secret objects in the Modal cloud dashboard for each repository and coupled sandbox launching with remote secret collection provisioning. We decided to retire --secret-collection and all repository-level Modal secret collections in favor of local SOPS-encrypted Repository Fetch Secrets stored version-controlled at secrets/<secret-name>/fetch.env, selected via -s or --secret <secret-name>. Sandbox runners bake the encrypted secret file into the container image at /root/secrets/fetch.env, mount the Shared Workspace Secret share0 unconditionally across all sandboxes, and decrypt the credentials ephemerally into memory in shared/startup.sh at container boot using AGE_PRIVATE_KEY before immediately purging /root/secrets/fetch.env from disk and unsetting GH_TOKEN from memory prior to user session handoff.
0016 Repository Environment Secrets and In-Sandbox Mapping (env.map)
0017 In-Sandbox Tooling Secrets and Setup Script Staging Previously, shared/startup.sh attempted to run scripts/setup.sh from the cloned target repository ($TARGET_DIR). Because external guest repositories do not track the factory's setup script and the image builder (shared/image.py) never staged tooling secrets (secrets/opencode.env, secrets/antigravity-oauth-token.json, secrets/modal.toml), sandboxes launched with --opencode, --agy, or --modal silently skipped credential decryption, resulting in missing API key errors. We decided to decouple factory tooling setup from the cloned repository: scripts/setup.sh is staged into the image at /root/scripts/setup.sh and dynamically resolves secrets relative to its own script location, build_sandbox_image stages only the specific encrypted tooling secrets matching requested flags into /root/secrets/, shared/startup.sh invokes /root/scripts/setup.sh via absolute path, and all staged encrypted tooling secrets are purged immediately following decryption alongside ~/.config/sops.
0018 Retire Modal Web Port Tunnel in Favor of Cloudflare Ingress Previously, the sandbox runner unconditionally configured port 3000 as an encrypted tunnel on Modal (encrypted_ports = [args.web_port]), generating an ephemeral public URL (https://<id>-3000.modal.host) on every launch alongside the pre-baked Cloudflare Tunnel (https://3000.mahesh0.dev). With Cloudflare Tunnel running as the persistent, zero-config default ingress managed by the port watcher daemon, the redundant Modal web tunnel cluttered launch output and confused developers about the canonical access URL. We decided to retire Modal encrypted tunnel creation for port 3000, remove the --web-port CLI parameter, standardize local web applications strictly on port 3000 (PORT=3000), and rely exclusively on Cloudflare Tunnel for public web ingress across both Standard and VM Sandboxes.
0019 Pre-bake Docker Compose v2 in Shared Base Image We decided to pre-install docker-compose-v2 directly into the Shared Base Image (shared/image.py) via base_image.apt_install(), providing native Docker CLI plugin support (docker compose) across all sandboxes while strictly omitting legacy hyphenated symlinks (docker-compose). Pre-baking the Compose v2 plugin into the base image maintains 100% Modal layer caching parity between Standard and VM Sandboxes (per ADR 0005), ensuring Compose is instantly available when dockerd starts in VM Sandboxes while remaining inert in Standard Sandboxes.
0020 OpenCode Go Provider and Target Project Prerequisite
0021 Isolated Test Repository Secrets and Testing Flag Status: superseded by ADR-0023
0022 Opt-In SOPS Credential Retention for Interactive Sandboxes Previously, ADR 0002 established that SOPS credentials (AGE_PRIVATE_KEY and ~/.config/sops) must be unconditionally purged during sandbox bootstrap, and ADR 0005 retired the --sops flag from sandbox runners. While this enforced zero-leakage security, it prevented developers from using sops or inspecting encrypted repository files during interactive debugging sessions without manually re-entering keys. We decided to re-introduce --sops in sandbox/main.py as an explicit, opt-in developer flag: default sandbox launches remain strictly ephemeral per ADR 0002, but passing --sops sets ENABLE_SOPS=1, configures ~/.config/sops/age/keys.txt (mode 0600), exports SOPS environment variables (SOPS_AGE_KEY, SOPS_AGE_KEY_FILE, AGE_PRIVATE_KEY) to ~/.bashrc and /etc/environment, and bypasses the credential purge.
0023 Organization Credentials and Canonical Test Organization Status: Accepted
0024 Filesystem Snapshots in Unified Sandbox Runner We added Filesystem Snapshot capture and restoration capabilities to sandbox/main.py using Modal's snapshot_filesystem() API. Developers can freeze an active sandbox session into a reusable modal.Image and launch fresh Standard or VM Sandboxes from snapshot images, bypassing git cloning and dependency installation for near-instant cold starts.
0025 T3 Code Server and Agent Control Surface We added support for T3 Code (pingdotgg/t3code) as an opt-in agent control surface running inside Modal sandboxes. Developers can launch sandboxes with --t3 (or --t3code) to run a background headless server (t3 serve) bound to default port 3775, exposed via a dedicated Modal encrypted tunnel, and connected to the target project workspace.
0026 Production File Line Budget and Ratchet We established a 500 physical line budget (wc -l) for all production code files in this repository, enforced via agent review standards and a non-expansion ratchet on existing legacy files.
0027 Universal File Line Budget and Systematic Test Suite Decomposition Status: Accepted (Supersedes the test suite exemption in ADR 0005)
0028 Optional Secret and Standalone Sandbox Launch Previously, passing -s / --secret was mandatory for all standard sandbox launches in sandbox/main.py (ADR 0003), coupling sandbox provisioning directly to repository cloning and SOPS secret decryption. When resuming from snapshots (ADR 0024), --secret was made optional by requiring --name. We decided to make -s / --secret optional for all sandbox launches, enabling Standalone Sandboxes provisioned without target repository fetch. When launched without --secret, --name <app-name> is mandatory, repository-specific flags (--github-branch and --testing) are rejected, and shared/startup.sh sets the workspace at /root/<name>, initializing an empty Git repository with a default .gitignore and an initial commit.
0029 Constant Modal App and Named Sandboxes Previously, sandbox/main.py created a new, ephemeral Modal App name for every sandbox launch (e.g. <bare-repo>-<random-id> or test-<bare-repo>-<random-id>), resulting in high App proliferation on Modal. We decided to transition to a constant App model where the Modal App remains fixed while individual Sandboxes are uniquely named (modal.Sandbox.create(name=...)) inside that App. For repository launches (-s/--secret), the App name is deterministic (<bare-repo>, or test-<bare-repo> when testing). For standalone launches without --secret and snapshot restores, --name defines the constant App name. Within the App, each sandbox is assigned a unique name (<app-name>-<random-id>) passed to Sandbox.create().
0030 Shallow Repository Clone Depth in Sandboxes
0031 Migrate VM Runtime to Modal Stable Runtime and Unify Memory Bursting Previously, VM sandboxes in this repository were provisioned using the internal experimental option experimental_options={"vm_runtime": True} with a static memory allocation (memory_mib = memory_gb * 1024, default 1024 MiB), while Standard Sandboxes ran on gVisor with dynamic memory bursting (128, 16384) MiB (ADR 0002). In Modal SDK v1.6.0+, Modal graduated the virtual machine runtime into a first-class parameter runtime="vm", added native memory bursting support for VM sandboxes, and deprecated experimental_options['vm_runtime'] with server-side warnings. We decided to migrate all VM sandboxes to runtime="vm", explicitly set runtime="gvisor" for Standard Sandboxes, unify VM memory to use identical burstable RAM (128, 16384) MiB, and soft-deprecate --memory while retaining it to scale SWAP_GB for backwards compatibility.
0032 Remove Working Directory Mutation From Sandbox Bashrc Previously, shared/startup.sh appended cd $TARGET_DIR to ~/.bashrc during sandbox initialization. This was intended to automatically place interactive shell users inside the cloned repository or standalone workspace upon login. However, because .bashrc is evaluated on interactive bash subshells and remote developer environments (such as VS Code Remote - SSH or agent subshells), injecting cd <path> into .bashrc hijacked the working directory of every subshell, broke IDE terminal directory restoration, and prevented developers and tooling from executing in /root or parent paths without being forced back to the workspace root. We decided to unconditionally remove cd $TARGET_DIR from ~/.bashrc across all sandbox launches, leaving the default working directory as /root and preserving shell working directory integrity for interactive terminals and remote IDEs.
0033 React Native File Watcher and Expo Tunnel Tooling
0034 Pi Coding Agent CLI Integration Status: Accepted (Amends ADR 0005)
0035 Multi-Account Antigravity Provider Instances in T3 Code Status: Accepted (Amends ADR 0025)
0036 Opt-In Full Git Repository Clone in Sandboxes
0037 Git History Secret Sanitization and OAuth Parameterization Status: Accepted
0038 Centralized Organization Credentials and In-Repo Secrets Architecture Status: Accepted (Amends ADR 0002)
0039 Pre-bake GitHub CLI in Shared Base Image Status: Accepted (Amends ADR 0005, ADR 0002, ADR 0025, and ADR 0038)
0040 Hosted Sandbox Control Plane Function Status: Accepted (Amends ADR 0005, ADR 0005, ADR 0003, and ADR 0063)
0041 Automatic SSH Config Registration for Modal Sandboxes Status: Accepted (Amends ADR 0003 and ADR 0040)
0042 Modal Tunnel Pairing URL and QR Code in T3 Code CLI Status: Accepted (Amends ADR 0025, ADR 0035, and ADR 0028)
0043 Sandbox Dot Directories for Staged Secrets and Scripts Status: Accepted (Amends ADR 0005, ADR 0023, and ADR 0035)
0044 Persistent T3 Code Usage, Agent Transcripts, and Scan Caches via Modal Volume Status: Accepted (Amends ADR 0025, ADR 0035, ADR 0028, and ADR 0042)
0045 Sandbox Web Dashboard, Snapshot Registry, and Multi-Profile Workspace Discovery Status: Accepted (Amends ADR 0024, ADR 0003, and ADR 0040)
0046 Retire Local Web UI and Enforce Strict HTTP Basic Authentication Status: Accepted (Amends ADR 0040 and ADR 0024)
0047 Cross-App Sandbox Discovery and Environment-Wide Listing in Control Plane Web UI Status: Accepted (Amends ADR 0003, ADR 0040, and ADR 0024)
0048 Antigravity ACP Credential Preservation in Filesystem Snapshots Status: Accepted (Amends ADR 0024 and ADR 0035)
0049 Launch Fresh Sandboxes from Control Plane Web UI Status: Accepted (Amends ADR 0003, ADR 0040, ADR 0024, and ADR 0025)
0050 Developer CLI Tooling Precedence and Dot-Directory Resolution Status: Accepted (Amends ADR 0005 and ADR 0043)
0051 Control Plane Secret Staging and Credential Validation Status: Accepted (Amends ADR 0038, ADR 0043, and ADR 0049)
0052 Collapse Sandbox Provisioning Seam Status: Accepted (Amends ADR 0040 and ADR 0041)
0053 Consolidate Secrets to Single .env and Retire Legacy Staging Status: Accepted (Amends ADR 0005, ADR 0038, ADR 0043, and ADR 0005)
0054 Codebase De-Hardcoding and Parameterization Status: Accepted (Amends ADR 0003, ADR 0038, ADR 0063, and ADR 0053)
0055 Packaging, Developer Experience (DX), and Open-Source Governance Status: Accepted (Amends ADR 0027, the retirement of Factory Studio, and ADR 0054)
0056 Multi-Modal Account Profiles in .env and Native Sandbox Provisioning Status: Accepted (Amends ADR 0003, ADR 0024, and ADR 0053)
0057 Central Environment Store and Host-Side Secret Injection Status: Accepted (Amends ADR 0038 and ADR 0053)
0058 T3 Code Pairing Tunnel Wrapper and Unit Test Isolation Boundary Status: Accepted (Amends ADR 0042 and ADR 0055)
0059 Sandbox Software-Factory Commit Provenance and Inspection Tooling Status: Accepted (Amends ADR 0002, ADR 0024, and ADR 0052)
0060 Unify Control Plane Basic Authentication and Retire Antigravity Project ID Status: Accepted (Amends ADR 0040, ADR 0025, ADR 0048, and ADR 0049)
0061 T3 Code Browser Headless Shell and Sandbox Environment Status: Accepted (Amends ADR 0003 and ADR 0025)
0062 Publish Documentation as Modal Web Function Status: Accepted
0063 Replace --secret Flag with --repo and Retire Legacy Secret Staging in Sandbox Runner Status: Accepted (Amends ADR 0002, ADR 0003, ADR 0023, ADR 0028, and ADR 0038)
0064 Default Antigravity Text Generation and Source Control Writer Model in T3 Code Status: Accepted (Amends ADR 0025 and ADR 0035)
0065 Retire In-Repo env.map Fallback and Standardize ghclone on Central Environment Store Status: Accepted (Amends ADR 0038, ADR 0050, and ADR 0057)
0066 Multi-Tier Git Identity and Env-Store Authorship Separation Status: Accepted (Amends ADR 0054 and ADR 0057)