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) |