Run T3, Codex, Claude Code and their terminal processes in a rootless container
on any Linux host: a WSL2 distribution on a Windows PC, a NUC or home server,
a workstation, or a cloud VM. The selected project is mounted read/write at
/workspace. The container uses --userns=keep-id and your Linux UID/GID, so
files it creates belong to you.
Coding agents run shell commands, install packages and edit files on your behalf. Running them directly on your machine gives them the same reach as your user account: every file in your home, your SSH keys, your keyrings and your whole network. This project puts T3 Code and its agents into a hardened, rootless Podman container so they only see what you hand them, while staying convenient enough for daily use.
Why use it
- Contain the blast radius. Agents see the mounted project and selected state directories, not your entire home, host sockets or SSH agent.
- Run anywhere Linux runs. Use it in WSL2 on a laptop, or on an always-on NUC or server and connect to it from the T3 desktop app over the network.
- No root required. Rootless Podman with your own UID/GID means no daemon,
no
sudoand no root-owned files in your project. - Reproducible environments. A single image ships pinned build tooling and a broad toolchain, so every machine and profile behaves the same.
Key features
- Batteries-included image: Node.js, npm/pnpm/Yarn/Bun, TypeScript, Python/uv, Go, Rust, JDK/Maven, compilers, Git/GitHub CLI, database clients, T3, Codex and Claude Code, plus a preinstalled headless browser.
- Multiple profiles: independent containers, images, ports and persistent
homes per project or task (
PROFILE=feature-a), all configured in YAML. - Optional egress firewall: restricted networking routes all outbound traffic through an allowlisting gateway with per-profile denial logs, and never falls back to unrestricted access.
- Hardened defaults: all capabilities dropped,
no-new-privileges, private PID/IPC namespaces, default seccomp and configurable CPU/memory/PID limits. - Flexible credentials: share your host Codex/Claude/T3 state, or keep private per-profile logins and API keys outside the mounted project.
- Simple workflow:
make init,make build,make start,make pair, and paste the pairing URL into the T3 desktop app.
Install the host prerequisites as your ordinary Linux user (inside the WSL2 distribution when using Windows). Any reasonably current distribution with rootless Podman works; for example:
# Debian / Ubuntu (including WSL2)
sudo apt update
sudo apt install -y podman uidmap slirp4netns fuse-overlayfs make python3 curl
# Fedora / RHEL-compatible
sudo dnf install -y podman shadow-utils slirp4netns fuse-overlayfs make python3 curl
# Arch Linux
sudo pacman -S --needed podman shadow slirp4netns fuse-overlayfs make python curlThen confirm that Podman runs rootless:
podman info --format '{{.Host.Security.Rootless}}' # must print trueInstall uv as your ordinary user if it is not already available:
curl -LsSf https://astral.sh/uv/install.sh | shEnsure uv is on your PATH (restart your shell after installation).
make init creates a repo-local .venv and installs the dependencies pinned in
uv.lock. Python 3.11 or newer is required; uv can download a compatible Python
if needed. Python packages are installed only in .venv, leaving system Python
unchanged. make python-env syncs this environment without creating a profile.
The launcher and test targets use uv run --locked, so no manual activation is
needed; they also create or update the environment when necessary.
Rootless Podman needs a free, nonoverlapping subordinate ID range of at least 65536 in
both /etc/subuid and /etc/subgid; make doctor checks this. Never run Make
with sudo. If resource limits fail, enable systemd and cgroup v2 delegation for
your user (in WSL2, enable systemd in /etc/wsl.conf).
make init
nano ~/.config/t3-sandbox/profiles/default.yaml
make doctor
make build
make start
make status
make pairSet project.directory to your project. By default a missing project directory
is created; set project.create_if_missing: false to require an existing one.
Configuration is YAML data. Paths support ~ and ${HOME} expansion. The
commented default profile documents every field.
The default profile is also initialized automatically by configuration commands.
Every Make command selects PROFILE=default unless you supply another name.
PROFILE_DIR=/absolute/path selects a different profile directory. Configuration
comes exclusively from the selected YAML file; legacy .env and SANDBOX
settings are no longer read. Existing .env files and old Podman resources are
left in place; migrate their settings manually into a profile.
T3 listens on container port 3773. The default host bind is 0.0.0.0:3774; host
port 3773 is left free for a T3 UI running on the host (for example on Windows,
which WSL port probes cannot detect). This bind exposes the service beyond
localhost according to your host firewall (or WSL networking) settings; on a
NUC or server this typically means your LAN. Set
server.bind_address: 127.0.0.1 to limit it to host loopback.
make pair preserves the token and rewrites the address to a usable host route;
wildcard binds use 127.0.0.1 (IPv6 :: uses ::1). Set
server.advertised_url when clients need another address, for example an HTTPS
reverse proxy. Paste the pairing URL into the T3 desktop app's Settings →
Connections → Add environment, and choose /workspace for the project.
Pairing links expire and are single-use. Check make logs if startup fails.
On WSL2, Windows clients normally use WSL localhost forwarding. On a NUC or
other remote host, set server.advertised_url to an address your client can
reach (for example http://nuc.local:3774) and allow the port in its firewall.
To customize the host name shown in T3 Code, set runtime.hostname in your
profile YAML:
runtime:
hostname: my-dev-hostUse a DNS hostname with letters, digits, and hyphens (dot-separated labels are
also supported). Omit the setting or use null to keep the generated
t3-sandbox-<uid>-<profile> name. Apply changes with
make recreate PROFILE=<profile>; rebuilding the image is unnecessary.
Each profile keeps its home in
~/.local/share/t3-sandbox/<profile>/home. These are ordinary host directories,
not Podman volumes. Recreation and removal retain them and the project. The
launcher never copies state, recursively changes ownership, or removes T3 locks.
Runtime management files and logs live beside the home and cannot be mounted
through the selected project or state directories.
The default profile shares the original host ~/.t3 by default. Other profiles
use private T3 state. state.share_host_provider_state independently controls
sharing ~/.codex and ~/.claude and defaults to true. The three source paths
are configurable under state; missing selected state directories are created.
Disabling sharing reveals any private state already in the persistent home.
Shared credentials and files can be changed or deleted by container processes.
Host SSH agents, OS keyrings and ~/.claude.json are not mounted.
A shared .t3 contains the database, pairing credentials and environment identity.
Use it with one T3 server at a time. Stop the host server before starting the
default container, or set state.share_host_t3_state: false and recreate. The
launcher checks active managed containers' actual mounts and probes T3's existing
SQLite lifetime lock before new or stopped shared-state launches. Container PID
records do not establish host ownership. T3's lock remains authoritative for races; never delete a
lock to work around an active server. Stored host project paths do not become
/workspace automatically; register the project inside the container if needed.
For private provider credentials, disable provider sharing and authenticate:
make exec CMD='codex login --device-auth'
make exec CMD='claude auth login'
make exec CMD='gh auth login'For a private GitHub login, also set state.share_host_gh_state: false, or use
a fine-grained GH_TOKEN (see GitHub CLI and git push).
Open the displayed URLs in your normal browser. For Claude subscriptions, run
make exec CMD='claude auth login' from an interactive terminal and sign in with
your Claude account. If the browser displays a code instead of completing the
local callback, paste it into the waiting terminal. This is Claude's
documented headless login flow;
its CLI does not document a --device-auth flag. You can check the result with
make exec CMD='claude auth status'. Credentials saved inside the container
persist in the provider state directories described above.
An ANTHROPIC_API_KEY can take precedence over your Claude subscription; remove
it from the container environment when using subscription authentication.
Use make shell for other interactive flows.
For API keys, create a private file outside the mounted project:
install -m 600 /dev/null ~/.config/t3-sandbox/default.env
nano ~/.config/t3-sandbox/default.envSet runtime.env_file: ~/.config/t3-sandbox/default.env in the profile, then
recreate. It uses Podman's NAME=value format without shell expansion. Secrets
enter the container environment, never image build arguments; make config
prints only the file path, never its contents.
state.share_host_gh_state (default true) shares your host GitHub login and
git configuration:
| Host source (profile field) | Container path | Access |
|---|---|---|
~/.config/gh (state.gh_directory) |
~/.config/gh |
read/write |
~/.gitconfig (state.gitconfig_file) |
~/.gitconfig |
read-only, only if the file exists |
The image sets git's system credential helper to gh auth git-credential for
github.com and gist.github.com, so git push/fetch over HTTPS use the
same token as gh. Your host identity (user.name, user.email) and other
settings come from the read-only ~/.gitconfig; git config --global cannot
change it inside the container. Use repository-level git config for local
overrides. Files referenced by include.path or core.excludesFile are not
mounted. Creating or removing the host ~/.gitconfig later requires
make recreate.
Where the token comes from. gh uses the first of these that is set:
GH_TOKEN, thenGITHUB_TOKEN, from the container environment;oauth_tokenin~/.config/gh/hosts.yml, which is your host login when sharing is enabled.
The host login works only if its token is stored in hosts.yml. Run
gh auth status on the host: it should report
Logged in to github.com account ... (~/.config/gh/hosts.yml). Keyring
logins are not available in the container, because OS keyrings are not mounted.
Explicit, fine-grained token (recommended). Container processes, including
agents, can read and use whatever token they get. Your host login usually has
access to all of your repositories. To limit this, create a
fine-grained personal access token
for the sandbox only. Restrict it to the selected repositories and grant
Contents: Read and write (for push), plus Pull requests or Issues if gh
should manage them; add an expiry. Put it in the profile's env file:
(umask 077 && touch ~/.config/t3-sandbox/default.env) # keeps existing entries
nano ~/.config/t3-sandbox/default.env # add GH_TOKEN=github_pat_...runtime:
env_file: ~/.config/t3-sandbox/default.env
state:
share_host_gh_state: false # optional: keep the host token out entirelyThen run make recreate. GH_TOKEN takes precedence over a shared
hosts.yml, so it also works with sharing enabled. Sharing still mounts the
host token file, though, so disable sharing to keep the broader host token
completely out of the container. With sharing disabled, ~/.gitconfig is not
mounted either; set your identity with
make exec CMD='git config --global user.name "Your Name"' (and
user.email). It persists in the profile home. Verify with
make exec CMD='gh auth status'.
SSH remotes are not supported because no SSH agent or keys are mounted, and
restricted mode allows HTTPS only. Switch a remote with
git remote set-url origin https://github.com/<owner>/<repo>.git.
make shell
make exec CMD='git status'
make smoke # tools, HTTPS, server and real file ownership
make logs # Ctrl-C leaves the container running
make logs FOLLOW=0 TAIL=20
make proxy-restart PROFILE=work # apply saved allowlist; T3 keeps running
make firewall-logs PROFILE=work # latest 100 policy denials, then follow
make firewall-logs PROFILE=work FOLLOW=0 TAIL=100 # finite JSONL output
make stop
make start
make restart
make remove # retains project and persistent home
make rebuild # pull bases and rebuild without cache using latest T3
make recreate # replace container with current image/settings
make init PROFILE=feature-a
nano ~/.config/t3-sandbox/profiles/feature-a.yaml
make build PROFILE=feature-a
make start PROFILE=feature-a
make pair PROFILE=feature-a
make stop PROFILE=feature-amake rebuild always installs the latest published T3 release, overriding
build.t3_version for that build without changing the profile. make build
honors the configured version. Run make recreate after rebuilding to use the
new image in the container.
Named initialization never allocates host port 3773 (host T3 UI), reserves 3774
for default, and allocates an available port from 3775 upward. Each profile selects separate containers, images and
persistent homes. Providers remain shared by default; disable sharing for
independent credentials. Sharing a project shares its actual files. Isolation
is per container/environment; T3 threads within an environment share its mounts
and credentials.
Build/rebuild leave existing containers running on their original image. Start and restart reject changed mounts, ports, resource limits, health checks, env file path, or network settings until you explicitly recreate. Build selector and whitelist edits alone do not replace T3. The selected image ID is recorded separately from the runtime topology. Stop, status, logs, firewall-logs and remove can recover owned resources even when the profile is invalid or missing. Status shows the running gateway readiness digest separately from the saved YAML policy digest; an unavailable gateway or invalid saved policy is reported explicitly. No global prune or state deletion target is provided.
Automatic Podman health timers are disabled by default. Startup checks T3's
local HTTP endpoint and reports immediate exit codes without requiring systemd.
Set runtime.automatic_healthcheck: true and recreate if your systemd user
session supports timers. Use resources.memory: null and resources.cpus: null
to omit those limits; resources.pids_limit stays positive.
Build selectors under build choose the Ubuntu and Node images and the Codex,
Claude and T3 versions. The default Ubuntu selector is ubuntu:rolling; choose
docker.io/library/ubuntu:latest for LTS. Pin digests and CLI versions for
reproducibility. Make supplies the matching HOST_USER, HOST_UID and
HOST_GID build arguments. Global tools are image-managed; install system
packages in the Dockerfile, then rebuild and recreate. User-installed tools
live in the persistent home and can override image tools on PATH.
The image includes Node.js, npm/pnpm/Yarn/Bun, TypeScript, T3, Codex, Claude, Python/uv, Go, Rust, JDK/Maven, native compilers and debugging tools, Git/GitHub CLI, database clients and common terminal tools. Project-specific dependencies still need their own installation. On WSL2, use Linux filesystem paths; Windows mounts may have synthesized permissions and slower operations. Supplementary host groups are not passed through.
T3's native release and its required sidecar files are installed under
/opt/t3-runtime. During the image build, an isolated local T3 server invokes
that release's own pinned browser installer. The build first initializes an
MCP session; releases requiring agent thread scope use the authenticated local
preview.open WebSocket RPC instead. Generated credentials and server state
are removed within the same build layer. Unsupported interfaces fail the build.
Browser files and their version/hash manifest live in /opt/t3-browser.
Startup verifies them locally and seeds an executable wrapper into a writable
tmpfs at ~/.t3/tools/chrome-headless-shell. This shadows only the rebuildable
browser cache, preserving shared host browser files and the rest of the home.
No browser or T3 runtime download is needed at startup, including with an empty
home. A missing tmpfs or mismatched image assets stops startup before T3 runs.
Restricted mode supplies both uppercase and lowercase HTTP/HTTPS/ALL proxy
variables after the env file, loopback-only NO_PROXY, and Node 24's
NODE_USE_ENV_PROXY=1. The browser wrapper uses Chromium's explicit HTTP proxy
switch and keeps in-container loopback pages reachable. T3_SANDBOX_BROWSER_PROXY
is launcher-owned; an env file cannot change it. Clients ignoring proxy settings
remain subject to the network boundary. No browser sandbox-disabling flag is
added automatically.
Unrestricted networking is the default (network.restricted: false) and needs
only the T3 image/container. It uses
slirp4netns:allow_host_loopback=false. This allows outbound internet and can
reach LAN or host services; it is not an egress firewall. To select restricted mode, set network.restricted: true in the profile YAML,
configure network.allow, then run make build PROFILE=work and
make start PROFILE=work. Switching modes requires make recreate PROFILE=work.
Restricted mode requires local rootless Podman with Netavark; Podman 4.9.3 /
Netavark 1.4.0 is the verified baseline. Unsupported controls fail closed.
Restricted T3 joins only an internal network with DNS disabled and no default route. A separate gateway publishes the management port, proxies permitted outbound traffic on private port 3128, and has forwarding disabled. The launcher disables host-side private bridge IPv6, rejects an IPv4-addressed bridge, verifies live routes with a trusted disposable probe, and verifies gateway readiness before starting T3. The proxy port is never published. Removing proxy variables does not grant T3 direct internet, host or LAN access.
Runtime policy and topology snapshots live outside T3 mounts in the profile's
runtime directory; gateway denial logs live in its separate logs directory.
The gateway receives only its read-only policy directory and its logs. Start
checks actual image IDs, mounts, ports and network memberships before reuse.
Topology changes require make recreate. Apply saved allowlist edits with
make proxy-restart PROFILE=work: the candidate is validated using the existing
gateway image before its policy is published atomically and only that gateway
is restarted. T3 keeps its identity, process, mounts and private address. Active
management and outbound streams must reconnect during the brief interruption.
Invalid candidates leave the running gateway untouched. A failed replacement
leaves the gateway unavailable, with the candidate retained and no policy
rollback or unrestricted fallback; fix the policy and retry make proxy-restart.
make firewall-logs PROFILE=work prints normalized denial JSONL records from only
the selected profile, showing the latest 100 records across retained files and
following rotation and gateway restarts. Use FOLLOW=0 TAIL=100 for finite output
in chronological file order; selectors do not modify YAML. Ctrl-C stops viewing
and leaves containers running. Diagnostics use stderr. This is a proxy-policy
log: direct attempts that fail locally because T3 has no route never reach it.
Stop shuts down T3 before the gateway. Remove preserves persistent homes and logs.
Restricted mode never falls back to unrestricted networking.
For gh and git over HTTPS in restricted mode, add github.com and
api.github.com to network.allow.domains (plus codeload.github.com and
objects.githubusercontent.com for archives and release downloads).
The restricted-mode compiler currently supports only IPv4 external subnets and
DNS resolvers, and rejects IPv6 or mixed operational topology. IPv6-only
resolvers and upstream services are unavailable in this version. IPv6
allowlist parsing and protection of IPv6 host, private, and mapped addresses
remain enabled; this does not provide IPv6 egress support.
The container drops all capabilities, enables no-new-privileges, uses private
PID/IPC namespaces and keeps default seccomp. It receives no engine or host
namespace sockets and no network administration capability. Agents can modify
mounted files and send accessible data over allowed network paths. Containers
share the host kernel; use a separate VM for deliberately hostile code.
Some nested browser/provider sandboxes depend on host kernel policies. Do not
resolve failures with privileged mode or host sockets. T3's browser opt-out is
T3CODE_SERVER_BROWSER_SANDBOX=0 in the runtime env file; this reduces browser
isolation and is not enabled by default.
Normal commands neither import legacy settings nor copy or delete legacy volumes. Initialize the selected profile, inspect its YAML, and translate values manually:
| Legacy setting | Profile field or action |
|---|---|
SANDBOX |
Select PROFILE=<name> on every Make command |
PROJECT_DIR |
project.directory |
HOST_PORT |
server.port; select server.bind_address explicitly |
SHARE_HOST_STATE |
state.share_host_provider_state; choose T3 sharing independently |
SHARE_HOST_T3_STATE |
state.share_host_t3_state |
HOST_T3_DIR, HOST_CODEX_DIR, HOST_CLAUDE_DIR |
Corresponding state.*_directory |
UBUNTU_IMAGE, NODE_IMAGE |
build.ubuntu_image, build.node_image |
CODEX_VERSION, CLAUDE_VERSION, T3_VERSION |
Corresponding build.*_version |
MEMORY, CPUS, PIDS_LIMIT |
Corresponding resources fields; use YAML null to omit memory/CPU limits |
AUTO_HEALTHCHECK |
runtime.automatic_healthcheck as YAML true/false |
RUNTIME_ENV_FILE |
runtime.env_file |
IMAGE |
No image override; images are derived from UID/profile and built from build selectors |
The old SHARE_HOST_STATE=0 disabled all sharing; translate that intent by
setting both sharing flags to false. Strings such as "0" are not YAML booleans.
Legacy profiles bound loopback; explicitly use 127.0.0.1 to retain that exposure.
Copy a legacy private home only while both old and new T3 servers are stopped.
First run podman ps -a and podman volume inspect <exact-legacy-volume>.
Inspect the old container's mounts to distinguish the private home volume from
host .t3/provider bind mounts: bind-mounted contents are not in that volume.
The historical name was t3-sandbox-<uid>-<SANDBOX>-home; confirm it instead of
guessing. Record its mountpoint, ownership and contents, and take an offline
backup before changing anything. Do not remove database lock files.
For a private-state destination, set both sharing flags false and initialize the profile without starting it. The following is a template for an empty destination; substitute reviewed absolute paths and the inspected exact volume:
podman volume inspect t3-sandbox-1000-main-home
podman unshare ls -lan /absolute/inspected/volume/_data
install -d -m 700 ~/.local/share/t3-sandbox/migrated/home
# Inspect this directory; stop if it already contains state.
ls -la ~/.local/share/t3-sandbox/migrated/home
umask 077
legacy_backup=$(mktemp /tmp/t3-legacy-home.XXXXXXXX)
podman unshare tar -C /absolute/inspected/volume/_data -cpf "$legacy_backup" .
# The backup may contain credentials: protect it and retain it offline.
chmod 600 "$legacy_backup"
podman unshare tar --no-same-owner -C ~/.local/share/t3-sandbox/migrated/home -xpf "$legacy_backup"Rootless namespace root may map to your host user, while legacy workload files may have subordinate-ID ownership. Inspect the copied files as your ordinary user before starting; if inaccessible, stop and plan an explicit ownership conversion for only the copied destination. No automatic recursive chown is performed. Review old absolute paths and provider files before use. Build/start the new profile and verify its data; keep the original volume and backup until you have independently validated the migration. No volume deletion is required.
make test runs unit tests only. T3_SANDBOX_INTEGRATION=1 make test-integration
builds images and runs real Podman tests with random it-* profiles, temporary
homes and explicit cleanup; it needs the documented host prerequisites and
internet access for permitted HTTPS controls. See
network verification for coverage and limitations.
make smoke checks local and published T3 readiness and project file ownership;
restricted mode probes provider HTTPS, accepting an expected unauthenticated
401/403, and does not contact npm.
Mutating commands serialize on a per-profile lock outside removable runtime
files. Separate profiles can operate concurrently. Never run two servers over
one shared .t3. Allowlist edits require proxy-restart; changing structural
settings requires recreate. Set network.restricted: false, then recreate to
disable enforcement explicitly.
A wildcard bind controls listening, while server.advertised_url controls the
pairing URL; advertising an address does not open WSL/Windows/host firewalls or
configure routing/TLS. Remote clients require separately configured forwarding
and firewall rules. A successful host non-loopback check does not establish
reachability from another physical device.
Destination grants permit data transfer to that destination. HTTPS is tunneled without TLS interception, so grants do not restrict URL paths or content. The host, kernel, container engine and trusted gateway remain in the trust boundary. Proxy denial logs exclude direct local routing failures and upstream application 403 responses; they are not a complete traffic audit.