A disposable Podman container or Lima virtual machine for running coding agents (Claude Code, Gemini CLI, OpenAI Codex, GitHub Copilot) with --dangerously-skip-permissions / --dangerously-bypass-approvals-and-sandbox enabled by default.
- Why
- Setup
- Prerequisites
- Host browser
- 1Password bridge
- GitHub bridge
- Claude Code cloud setup
- OAuth Login
- Token Refresh Service
- Claude Code plugins
- Codex plugins
- MCP Servers
- Persistent terminals inside a Linux VM
- Remote control
- Fast mode
- RabbitMQ
- Tricks
- Stuff
Agents work best when they can freely run shell commands, edit files, install packages, and poke at databases — but you don't want them doing that against your host. This image gives each session its own throwaway Linux environment with:
- A Claude Code
PermissionRequesthook that approves Bash requests, including the critical-pathrmsafeguard that bypass-permissions mode leaves enabled. - The project you're working on mounted at
/app. - Language runtimes, databases (PostgreSQL, LavinMQ, RabbitMQ, Redis), and common tools preinstalled, so agents don't spend turns bootstrapping.
- OAuth credentials and API keys mounted from
~/ai/settings, with multi-profile support and automatic token refresh. - Shell history, agent session history, cloned repos, and installed gems persisted on the host across container restarts.
- A shared directory mounted at
/share(from~/ai/share) for passing files between the host and containers. mitmproxyavailable for inspecting what the agent actually sends over the wire.- An optional real Linux VM with rootful Docker for testing Compose, systemd, networking, image builds, and other host-level behavior.
The image publishing workflow builds ghcr.io/dentarg/ai for Linux AMD64 and
ARM64 on pushes to main and manual runs from main. After both native
builds pass the agent smoke checks and local session integration tests, it publishes
latest and sha-<full-commit-sha> multi-platform tags. Docker and Podman
select the matching architecture automatically.
The package is public and supports anonymous downloads. The workflow uses
GITHUB_TOKEN with packages: write; no registry secret is needed.
# Download instead of building, keeping the local tag used by the host tools.
podman pull ghcr.io/dentarg/ai:latest
podman tag ghcr.io/dentarg/ai:latest ai:latest
# Inside a Linux VM, download directly into its Docker daemon.
docker pull ghcr.io/dentarg/ai:latestRepeat the pull to update, or use a sha-<full-commit-sha> tag to select a
particular source revision. Registry digests can be used for an exact image.
./build_image still builds the local ai:latest image for development.
Drop per-profile OAuth credentials in $HOME/ai/settings as
.credentials.<profile>.json. They get mounted into the container and copied
into ~/.claude/ when you launch claude with a matching profile. See the
OAuth Login section for how to generate these.
Codex profiles are stored as $HOME/ai/settings/codex_<profile>/auth.json.
The unprofiled default remains $HOME/ai/settings/codex/auth.json.
Claude profile settings are stored as
$HOME/ai/settings/claude_<profile>/settings.json and are merged over the
baked defaults at launch.
Profile model values are validated against a small bundled catalog. Run
bin/ai models refresh to download RubyLLM's model registry, retain only the
OpenAI and Anthropic fields used here, and cache the compact result under
$HOME/ai/cache/models.json. Launching, completing commands, and setting a
model never downloads the registry.
Optionally, add AGENTS.md to $HOME/ai/settings — it becomes CLAUDE.md
for Claude Code, GEMINI.md for Gemini CLI, and is copied into Codex's
session config.
Optionally, add sentry.token to $HOME/ai/settings to enable the
Sentry MCP server in Claude Code. See
MCP Servers.
# create configuration files for a named Codex and Claude profile
bin/ai profile create <profile>
# remove a profile's Codex settings, Claude settings, and credentials
bin/ai profile remove <profile>
# authenticate either agent for the profile
bin/ai profile login <profile> codex
bin/ai profile login <profile> claude
# set the model each agent starts with for this profile
bin/ai profile set-model <profile> codex gpt-6-astra
bin/ai profile set-model <profile> claude claude-opus-4-8
# refresh the cached OpenAI and Anthropic model catalog
bin/ai models refresh
# show configured profiles and authentication status
bin/ai profile list
# enable command, profile, agent, and model completion
source <(bin/ai completion zsh)
# use "bash" instead of "zsh" when appropriate
# launch a new Podman container with an interactive shell
bin/ai
# select an agent explicitly: c or claude, cx or codex
# profiles and resumed sessions require an agent
# start Claude Code in Podman and share the current working directory
bin/ai c
# instead, clone an ephemeral Lima VM from ai-base; it is deleted on exit
bin/ai c --vm
# keep the VM running after exit so it can be inspected with limactl shell
bin/ai c --keep-vm
# enable nested virtualization and give the outer VM additional resources
bin/ai c --vm --nested-virt --cpus 8 --memory 16
# use the GPU-enabled krunkit base VM
bin/ai c --vm --gpu
# allow this session to request approved, allowlisted 1Password secrets
bin/ai c --1password
# launch a visible, isolated Chrome Canary on the host for the agent to control
bin/ai c --host-browser
# start podman and auto-launch "c <profile>" once the container is up.
# before launching, the shared "~/ai/settings" token for <profile> is
# refreshed on the host. If it has expired, recent Claude session history is
# searched for a newer active copy before falling back to interactive login.
bin/ai c <profile>
# start podman and auto-launch "cx" once the container is up.
bin/ai cx
# launch Codex with a specific profile.
bin/ai cx <profile>
# resume a prior Claude session: passed through to "c --resume <id>" on launch.
# works with or without a profile (c auto-detects it from the session).
bin/ai c --resume <session-id>
bin/ai c <profile> --resume <session-id>
# resume a prior Codex session: passed through to "cx --resume <id>" on launch.
# the profile is auto-detected from the original session.
bin/ai cx --resume <session-id>
# publish extra ports from the container to the host. each entry is either
# "PORT" (host==container) or "SRC:DST" (host:container); comma-separate many.
bin/ai c --ports 9999 # host 9999 -> container 9999
bin/ai c --ports 8888:7777 # host 8888 -> container 7777
bin/ai c <profile> --ports 9999,8888:7777
# all launch/profile/resume/port options also work with the VM backend
bin/ai --vm cx --ports 9999
# expose a UDP port from a Lima VM (host port 41641 -> guest port 41641)
bin/ai c --vm --keep-vm --udp-ports 41641
# enable Claude Code remote control for the session (off by default).
# equivalently set AI_REMOTE=1 in your shell. see "Remote control" below.
bin/ai c <profile> --remote
AI_REMOTE=1 bin/ai c <profile>
# enable fast mode for the session (off by default).
# equivalently set AI_FAST=1 in your shell. see "Fast mode" below.
bin/ai c <profile> --fast
AI_FAST=1 bin/ai c <profile>
# start services (and run "bundle install" if Gemfile exists).
# also runs automatically as part of "c" below.
s
# launch claude with a specific oauth profile (runs "s" first)
c <profile>
# or launch claude with an Anthropic API key
c --apikey sk-ant-...
# resume a prior Claude session (searches /history for the session id; a prefix is enough).
# profile is auto-detected from the session's saved .profile file.
c --resume <session-id>
# launch gemini
g
# launch openai codex
cx
# launch codex with a specific oauth profile
cx <profile>
# layer a native Codex configuration profile from
# ~/ai/settings/codex[_<oauth-profile>]/<config-profile>.config.toml
cx [<oauth-profile>] --profile <config-profile>
# resume a prior Codex session (searches /history for the session id; a prefix is enough).
# oauth and configuration profiles are auto-detected from the session.
cx --resume <session-id>
# exit the container or VM session
xbin/ai mounts the project at /app, and cx preserves that working
directory when it launches Codex. Terminal tabs show
project [oauth-profile] - Codex, with the host working directory name
first so it stays visible in narrow tabs. Hostnames are omitted from the title.
Containers still use matching names and hostnames of ai-c-XX-<project>,
distinct from the VM prefixes ai-l-, ai-g-, and ai-m-. Containers
choose the first free number from 00 to 99, including stopped containers
when checking availability. Codex title updates are disabled so they do not
replace the title with app. Its generated statusline
shows the current directory, git branch, model/reasoning, context used, and
thread id. TUI notifications are disabled for quieter terminal sessions. Codex defaults to
gpt-6-astra. To override the default for an OAuth profile, add native Codex
configuration such as
model = "gpt-6-sol" to
~/ai/settings/codex_<oauth-profile>/default.config.toml. Use
~/ai/settings/codex/default.config.toml for the unprofiled account.
cx --resume <id> searches archived Codex rollouts under /history and
reuses the original Codex home before launching codex resume <id>.
Podman is required for the default backend. Lima and jq are required for
--vm. The optional 1Password bridge also requires 1Password 8 and 1Password
CLI on the host.
brew install podman
brew install lima jq # for --vm
brew tap libkrun/krun # for --vm --gpu
brew trust libkrun/krun
brew install krunkit
brew install 1password-cli # optional
# init the Podman machine, enable zram swap, set kernel.keys quotas
bin/setup-vm
# normal builds use the pinned agent versions in "versions/" and do not
# check upstream "latest" endpoints.
./build_image
# update pinned Claude Code, Codex and plugin marketplace versions, then rebuild
./build_image --update-agents
# update only one pinned version file
./build_image --update-claude
./build_image --update-codex
./build_image --update-plugins
# rebuild all layers and pull latest base image
./build_image --force
# build the stopped ai-base Lima instance used by bin/ai c --vm
./build_vm
# replace an existing base VM; accepts the same agent update flags
./build_vm --force
./build_vm --force --update-agents
./build_vm --force --update-plugins
# build the separate ai-base-gpu instance with Lima's krunkit driver
./build_vm --gpu
./build_vm --gpu --forceOn an Apple-silicon Mac with Lima 2.1 or later and jq, build a separate
macOS base and launch an ephemeral session:
./build_vm --macos
bin/ai c --macos
bin/ai --macos cx work
bin/ai c --macos work --ports 9999,8888:7777The default base is ai-base-macos. If tool installation fails, run
./build_vm --macos --resume to resend the current build assets and rerun
provisioning in the existing VM, preserving its installed OS and packages.
--force deletes and rebuilds it instead; these options cannot be combined.
The existing
--update-agents, --update-claude, --update-codex, and --update-plugins
options also work. AI_VM_BASE, AI_VM_CPUS, AI_VM_MEMORY, and AI_VM_DISK
override the build defaults. AI_VM_BUILD_TIMEOUT controls each Lima startup
(default 60m); subsequent tool installation streams directly over SSH.
The host must support the macOS 26 restore image supplied by the installed
Lima template and run the same or a newer macOS version.
Install full Xcode on the host before building. The build copies
/Applications/Xcode.app into the guest; set AI_VM_XCODE_APP to use another
Xcode application path. It accepts the Xcode license, installs first-launch
components, and downloads the iOS simulator runtime. The guest includes
xcodebuild, Swift, the macOS and iOS SDKs, and simctl. Allow space for Xcode
and the simulator on both machines, plus a temporary Xcode archive during the
build. Signing identities and provisioning profiles must be configured
separately for signed device builds and distribution.
Provisioning installs Homebrew, native language tools, the pinned Claude and
Codex versions, Codex plugins, and the shared agent wrappers. The guest user
gets passwordless sudo inside this disposable VM. The build reboots to activate
macOS synthetic links for /workspace, /app, and the other shared paths,
then verifies the installed tools after another restart before protecting the
base. No desktop login or Lima 2.3 suppressFirstLoginSetup setting is required
by this SSH workflow. After startup, the launcher attempts to hide Lima's
macOS display using the host's application API. The window may briefly appear
and take focus before it is hidden; Lima still requires the display to exist.
If that API fails, it tries System Events, which may request Automation or
Accessibility permission for the host terminal. Hiding failures are nonfatal.
The hide attempt times out after five seconds so it cannot block the build.
Set AI_VM_SHOW_DISPLAY=1 when building or launching to leave it visible.
Profiles, resume, --keep-vm, CPU/memory overrides, and the opt-in host bridges
use the existing launcher. TCP ports are forwarded over an SSH tunnel bound to
host loopback; the tunnel closes when the launcher exits, even with --keep-vm.
UDP forwarding, --gpu, and --nested-virt are rejected for this backend.
Native gem caches live in $AI_DIR/bundle-macos, separate from Linux gems.
Codex transcripts remain in shared history, while its SQLite runtime databases
live under ~/.codex-state on the macOS guest disk to avoid shared-filesystem
I/O errors. These local databases are discarded with an ephemeral VM.
s starts native PostgreSQL, Redis, and LavinMQ as system launch daemons running
as the guest user, then runs Bundler. The Linux Docker stack and Chromium setup
are not installed in macOS guests; use --host-browser for the host browser.
The macOS backend is experimental. Its build/launch command flow is covered by
bash tools/test_macos_vm.sh; full installation and agent login still require
verification on an Apple-silicon Mac. See Lima's macOS guest documentation.
build_vm provisions the expensive language runtimes and development tools
once, verifies Docker and the coding agents, stops the resulting ai-base
instance, and protects it from accidental deletion. bin/ai c --vm clones that
base for each session, adds the same /app, /settings, /history, /share,
and other mounts used by the Podman backend, then deletes the clone when the
interactive shell exits. The image and VM builds share the Chromium, system
tool, language-runtime, coding-agent, and token-refresh service recipes under
inside_deps/; only backend-specific setup remains in their provisioners.
The initial build may take a while; AI_VM_BUILD_TIMEOUT controls its Lima
startup and provisioning timeout and defaults to 60m. Long-running
provisioning runs separately from Lima's boot scripts, and build_vm streams
its output. This avoids Lima's fixed ten-minute boot-script and cloud-init
progress-monitor limits.
Runtime instances and guest hostnames use ai-l-XX-<project> for standard Linux
VMs, ai-g-XX-<project> for GPU VMs, and ai-m-XX-<project> for macOS VMs.
XX is the first available two-digit index for that type and project, and
project is the sanitized current directory name, truncated to keep the VM
name within 63 characters.
Guests use UTC, matching the container backend.
VZ runtime clones use Apple's native vzNAT networking to avoid Lima's
user-mode TCP forwarding limits. The launcher configures this when cloning,
so existing base VMs do not need rebuilding. Other VM drivers keep their
existing network configuration.
Lima runtime clones expose only loopback TCP ports by default. Use
--udp-ports HOST:CONTAINER when a guest service needs an externally reachable
UDP port, such as Tailscale's default 41641. The host-side port must also be
reachable through any outer firewall or NAT device. For Tailscale, use the same
port on both sides and configure the guest daemon to listen on that port.
Ubuntu's docker.io, docker-buildx, and docker-compose-v2 packages provide
a rootful Docker stack inside the guest. The normal Lima user belongs to the
docker group and has passwordless sudo, so tests can exercise a realistic
Docker host without exposing the host Docker or Podman socket. Docker itself
does not require nested virtualization.
Use --nested-virt to expose KVM to software that starts another VM inside the
Lima guest. Lima supports this with the vz driver on Apple M3 or newer Macs;
the inner VM must use the native architecture. For QEMU, use -accel kvm -cpu host. The option is disabled by default. --cpus and --memory override the
cloned VM's resources for workloads that need more than the base VM allocation.
GPU acceleration uses a separate ai-base-gpu instance because Lima selects
the VM driver when an instance is created. build_vm --gpu uses Lima's
experimental krunkit driver and verifies that /dev/dri/renderD128 exists;
bin/ai c --vm --gpu clones that base. This requires Apple Silicon, macOS 14 or
newer, and krunkit installed on the host.
The guest receives a paravirtualized Vulkan device rather than direct hardware passthrough. Vulkan commands travel through Mesa Venus and MoltenVK to the Apple GPU. Container images therefore need compatible Vulkan userspace drivers and must receive the device explicitly. Verify the path with the patched Fedora Mesa image recommended for krunkit:
docker run --rm --device /dev/dri --env XDG_RUNTIME_DIR=/tmp --entrypoint vulkaninfo quay.io/slopezpa/fedora-vgpu --summaryGPU workloads do not use the guest's Ubuntu Mesa because its Venus protocol is incompatible with krunkit's host-side virglrenderer. They carry compatible Mesa libraries in their container image, using the patched Fedora image above as a base when appropriate.
Debian sid supplies Chromium without Ubuntu's snap wrapper. Its repository is pinned below Ubuntu so installing Chromium cannot upgrade the guest's Mesa and other base libraries to Debian versions.
The GPU base includes a pinned llama.cpp build with Vulkan support. The
llama-cli and llama-server commands run it in the compatible Fedora image,
pass through /dev/dri, and mount both the current directory and /share.
Models are deliberately not baked into the ephemeral VM; keep GGUF files under
/share so they survive VM rebuilds. For example:
mkdir -p /share/models
curl -L -o /share/models/qwen2.5-0.5b-instruct-q4_k_m.gguf \
https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf
llama-cli \
--model /share/models/qwen2.5-0.5b-instruct-q4_k_m.gguf \
--gpu-layers 99The wrapper passes GGML_VK_DISABLE_F16 through when it is set. Hybrid
DeltaNet models such as Qwen3.6 need this Vulkan workaround on the
paravirtualized Apple GPU. Qwen3.6-27B Q5_K_M runs reliably with 36 layers
offloaded, CPU-side KV cache, and reduced batch sizes:
GGML_VK_DISABLE_F16=1 llama-cli \
--model /share/models/Qwen3.6-27B-Q5_K_M.gguf \
--gpu-layers 36 \
--no-kv-offload \
--ctx-size 8192 \
--batch-size 64 \
--ubatch-size 32llama-server uses host networking, so its default port is directly available
through the VM's configured forwards.
The GPU base runs a shared, authenticated llama.cpp router as
local-code-server.service. Model instances load on demand and unload when a
different model is selected, so multiple Pi sessions share one model allocation.
Qwen has one 16K slot and Gemma has one 64K slot. Qwen is the supported default
for autonomous repository work; use Gemma for bounded generation, review, and
diagnosis.
The local-code client is available in both the VM and the OCI image. It uses
Qwen3.8-27B IQ4_XS by default and accepts qwen38 and gemma4 aliases:
curl -L -o /share/models/Qwen3.8-27B-UD-IQ4_XS.gguf \
https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf
local-code
curl -L -o /share/models/gemma-4-26B_q4_0-it.gguf \
https://huggingface.co/google/gemma-4-26B-A4B-it-qat-q4_0-gguf/resolve/main/gemma-4-26B_q4_0-it.gguf
local-code --model gemma4Pi gives the model read, write, edit, and bash tools. After each task it
prints a Cooked for duration and completion time. Host-networked Docker
containers in the GPU VM can reach the same server at 127.0.0.1:8080; set
LOCAL_CODE_BASE_URL when using a different address. The API key defaults to
local and can be changed with LOCAL_CODE_API_KEY on the service and clients.
The client reserves 4K tokens for each response. When compacting, it retains the
newest 2K tokens for Qwen and 4K for Gemma. A Pi extension blocks unbounded
recursive listings, stops after three consecutive tool errors, and limits Gemma
tasks to 24 tool calls. Gemma uses Google's pinned canonical chat template and
preserves thinking across tool-call turns. Pi sessions persist under
/history/pi and appear in the history viewer.
Run the end-to-end smoke test inside a GPU VM to verify that the default model can inspect a broken JavaScript function, edit it, and pass its test:
tools/test_local_code_model.shThe model presets use 36 GPU layers for qwen38 and 20 for gemma4. To
override the selected profile, stop the service and run the server directly:
sudo systemctl stop local-code-server
LOCAL_CODE_GPU_LAYERS=18 local-code-serverThe primary guest port still comes from PORT (1337 by default), but the VM
launcher chooses a free loopback host port and prints it when the VM is ready.
Set AI_VM_HOST_PORT to request a fixed primary host port. Explicit --ports
mappings remain fixed. Resource defaults for build_vm can be changed with
AI_VM_CPUS, AI_VM_MEMORY (GiB), and AI_VM_DISK (GiB); AI_VM_BASE
changes the base instance name for both building and launching.
Lima bases enable zram swap sized to half of guest RAM, using zstd compression. This absorbs temporary memory spikes from compilers and container builds before the guest has to invoke the OOM killer.
Use --keep-vm when diagnosing a guest problem. The launcher prints the
instance name, which can then be opened or removed manually. The launcher also
keeps the VM automatically when its console exits nonzero, preserving guest
logs and Docker build cache for diagnosis.
# attach to a running VM for the current directory with the sandbox shell
bin/vm
# list instances, select one explicitly, or run a command in one
bin/vm list
bin/vm <instance>
bin/vm <instance> docker ps
limactl stop <instance>
limactl delete <instance>--host-browser launches Google Chrome Canary on macOS with a dedicated
profile and makes its Chrome DevTools Protocol endpoint available inside either
the Podman container or Lima VM. Canary and the proxy stop when the session
exits. The profile remains under $HOME/ai/host-browser/profile, preserving
browser state such as cookies, logins, and extensions across sessions and host
reboots. Only one host-browser session may use the profile at a time.
On every launch, translation offers and password-saving prompts are disabled for both new and existing profiles. Other preferences and saved browser state are preserved.
Use a named profile to run isolated browser identities concurrently. Named
profiles remain under $HOME/ai/host-browser/profiles/<name>:
bin/ai c --host-browser=work
bin/ai c --host-browser=personalThe host-facing Chrome debugging socket remains on loopback. A TLS proxy
accepts guest connections using a random bearer token, rewrites the advertised
WebSocket endpoint, and strips the token before forwarding traffic to Chrome.
Its per-session certificate is valid for one year so long-running sessions do
not lose access.
The guest receives HOST_BROWSER_URL, HOST_BROWSER_TOKEN,
HOST_BROWSER_CA, and NODE_EXTRA_CA_CERTS. The token is passed to Lima
through a temporary file rather than a process argument.
Connect with the preinstalled puppeteer-core package:
const puppeteer = require("puppeteer-core");
const authorization = `Bearer ${process.env.HOST_BROWSER_TOKEN}`;
const browser = await puppeteer.connect({
browserURL: process.env.HOST_BROWSER_URL,
wsOptions: {headers: {Authorization: authorization}},
});
const page = await browser.newPage({background: true});
await page.goto("https://example.com");
await browser.disconnect();Run the script with the globally installed package on Node's module path:
NODE_PATH="$(npm root -g)" node browser-script.cjsCanary starts without an initial window so launching it does not activate the
application. Always create pages with {background: true} and do not call
page.bringToFront(); foreground targets can cause macOS to switch focus to
Canary. Select Canary yourself when you want to inspect its windows.
Chrome Canary must be installed at its normal application path. Override it
with AI_CHROME_CANARY_PATH when necessary. Set AI_HOST_BROWSER=1 instead
of passing the flag to enable the same behavior. Set
AI_HOST_BROWSER_PROFILE=<name> to select a named profile through the
environment.
The isolated profile supports extension development. Load an unpacked extension in Canary, then use Puppeteer to inspect its extension pages, content-script pages, and Manifest V3 service-worker targets. Manually loaded extensions persist in the dedicated profile between sessions.
Applications running in the guest still need --ports so the host browser can
reach them. For example, bin/ai c --host-browser --ports 3000 exposes a guest
server on http://127.0.0.1:3000 to Canary.
The optional bridge lets a container resolve individual secrets through the
macOS 1Password app without giving the container access to op or its desktop
session. Every retrieval must match the host-side alias allowlist and is
confirmed with a macOS dialog. The first op use in a terminal session may
also require 1Password biometric authorization.
First enable Settings > Developer > Integrate with 1Password CLI in the
1Password app. Verify the host integration with op vault list.
Manage ~/.config/ai/1password-bridge.json on the host with the CLI (Ruby required):
bin/1password-bridge init my.1password.com
bin/1password-bridge set github-token op://Agent/GitHub/token
bin/1password-bridge show
bin/1password-bridge remove github-token
bin/1password-bridge editThe policy applies to every session launched with --1password, regardless
of checkout. init updates the host account while retaining existing aliases;
set adds or replaces an alias. edit opens the entire policy using $VISUAL,
then $EDITOR, or vi. Changes are validated before an atomic save with mode
0600; invalid edits leave the original policy untouched. The tool stores
references only and does not retrieve secrets or invoke op.
Host-only configuration follows XDG_CONFIG_HOME, defaulting to
$HOME/.config when unset, empty, or relative. Both the CLI and launcher use
$XDG_CONFIG_HOME/ai/1password-bridge.json when an absolute base is set.
The CLI also accepts --file PATH for editing a policy explicitly; the
launcher always reads the standard location. AI_DIR still controls container
settings and persistent data, including bridge logs and runtime files.
Restart the bridge session after changing its policy.
Run the CLI tests with ruby tools/onepassword-bridge/test_config.rb.
The resulting policy has this structure:
{
"account": "my.1password.com",
"secrets": {
"github-token": "op://Agent/GitHub/token",
"anthropic-api-key": "op://Agent/Anthropic/credential"
}
}Secret aliases may contain lowercase letters, digits, dots, underscores, and hyphens. The launch directory appears in approval dialogs and audit logs, but does not select the policy. Protect the policy from modification:
chmod 600 "$HOME/.config/ai/1password-bridge.json"The policy deliberately lives outside directories mounted into containers. Start an enabled session, then request a configured alias inside it:
bin/ai c --1password
# inside the container; prints the value after host approval
op-read github-tokenThe broker starts with the container and stops when it exits. It loads the
host policy once, accepts only fixed aliases over an authenticated ephemeral
TLS connection, and never accepts arbitrary op arguments or op://
references from the container. Audit events, without secret values or
references, are appended to $HOME/ai/logs/1password-bridge.log.
Any value returned by op-read is visible to the container and can be retained
by the agent. The bridge limits which secrets can be requested and requires
approval when they are requested; it cannot protect a secret after release.
Use bin/ai c --github=work (or AI_GITHUB=1 AI_GITHUB_PROFILE=work) to enable restricted host gh
operations. Like the 1Password bridge, this requires a macOS host with ruby,
op, and the 1Password desktop integration enabled. Install gh on the host
as well. The container and Lima guests use the gh-host client; rebuild the
image or VM base to install it.
The image and both VM bases also ship the shared
gh-host skill. On bridge-enabled launches, c
links it into the session's ~/.claude/skills/ and cx links it into
~/.agents/skills/. Both agents can select it automatically for PR and issue tasks;
you can also invoke /gh-host in Claude Code or $gh-host in Codex.
It explains repository selection, supported commands, approval, and safe
handling of denied or uncertain requests. Launching without the bridge removes
only the link managed by the wrapper; an existing user-authored skill is
preserved. The host broker remains responsible for enforcing permissions.
Store a fine-grained GitHub token in 1Password, restricted to the repositories
needed and with an expiration. Grant Pull requests read permission for PR reads
and Issues read permission for issue reads; Pull requests write permission is
needed to create PRs or edit descriptions. The bridge resolves the
configured reference through host op for each request and passes the token
only to host gh. It does not use your normal gh login or expose a token-read
operation to the container.
Configure access once on the host, from any directory:
bin/github-bridge init \
work my.1password.com op://Agent/GitHub/token \
'company/*' 'another-org/*' owner/example
bin/github-bridge show work
# Optional: replace the allowed operations, including PR writes.
bin/github-bridge allow work \
pr-list pr-view pr-diff pr-comments pr-reviews pr-review-comments \
issue-list issue-view issue-comments issue-timeline pr-create pr-edit-body
bin/github-bridge check work
bin/github-bridge list
bin/ai cx --github=workEach named profile has its own token, repository scopes, and allowed operations,
independent of checkout. Mix exact OWNER/REPO entries with quoted ORG/*
entries to allow all current and future repositories under one or more owners.
Matching is case-insensitive; owner scopes also work for personal accounts.
Only an entire repository component may be *; arbitrary globs are rejected.
The token must also have access to each target repository.
init replaces only the named profile and resets its operations to read-only;
allow replaces only that profile's operation list. Other profiles are preserved.
Both validate and save atomically with mode 0600. The 1Password reference is
configurable per profile. Each session uses exactly one profile and cannot switch
tokens through gh-host. Bare --github selects AI_GITHUB_PROFILE, defaulting
to default; unknown profiles fail at startup.
The launch directory is included only in audit logs and approval dialogs.
The policy lives at ~/.config/ai/github-bridge.json, honoring an absolute
XDG_CONFIG_HOME, with the same defaults as the 1Password policy. --file
selects a different file for the configuration CLI only. The launcher reads
the standard location. Restart enabled sessions after policy changes.
{
"profiles": {
"work": {
"account": "my.1password.com",
"token": "op://Agent/GitHub/token",
"repositories": ["company/*", "another-org/*", "owner/example"],
"operations": [
"pr-list", "pr-view", "pr-diff", "pr-comments", "pr-reviews",
"pr-review-comments", "issue-list", "issue-view",
"issue-comments", "issue-timeline"
]
}
}
}Inside an enabled container or VM:
gh-host pr-list owner/example
gh-host pr-view owner/example 123
gh-host pr-diff owner/example 123
gh-host pr-comments owner/example 123
gh-host pr-reviews owner/example 123
gh-host pr-review-comments owner/example 123
gh-host issue-list owner/example
gh-host issue-view owner/example 456
gh-host issue-comments owner/example 456
gh-host issue-timeline owner/example 456
printf '%s\n' 'Describe the change here.' | \
gh-host pr-create owner/example feature-branch main 'PR title'
gh-host pr-edit-body owner/example 123 < pr-body.txtpr-comments reads discussion comments, pr-reviews reads review summaries and
verdicts, and pr-review-comments reads inline comments and replies with their
file/line context. issue-list reads open and closed issues, excluding PRs;
issue-view reads the issue body and metadata (including labels, assignees,
milestone, and reaction counts). issue-comments reads the conversation and
issue-timeline reads activity such as label changes and cross-references.
These collection commands follow all pages and return one JSON array. Existing
output and time limits still apply: oversized reads fail rather than return
partial results. Attachment links remain links; their contents are not fetched.
Existing profiles must enable the new operations with allow and restart their
sessions; allow replaces the complete operation list.
pr-list returns up to 30 open PRs. Read operations return GitHub JSON or a
plain diff. Creation uses an already-pushed branch in the selected repository,
creates a draft with maintainer edits disabled, and requires a macOS dialog
showing the exact request. Denial or a 60-second approval timeout prevents the
write. Reads rely on the host allowlist without an extra approval dialog;
1Password may still require authorization. Creating a PR may trigger repository
automation. A failed or timed-out create can have succeeded remotely; inspect
the PR list before retrying.
pr-edit-body replaces an existing PR's description with the exact text from
stdin (up to 8 KiB); empty input clears it. It sends only the body field and
returns the updated PR as JSON. Enable pr-edit-body in the host operation
allowlist and restart the session. Like creation, it requires Pull requests
write permission and a host dialog showing the repository, PR number, and new
body. Read the current description with pr-view before editing. Replacement
can overwrite concurrent edits; after a failed or timed-out update, read the
PR again before deciding whether to retry. New profiles remain read-only.
The broker supports github.com and the operations listed above only. It constructs
fixed gh api requests, validates all arguments on the host, and rejects extra
fields. Each gh process uses an empty configuration directory and a restricted
environment, outside the project checkout. Neither arbitrary commands, API
paths, host file paths, nor caller-supplied environment variables are accepted.
Each enabled session gets a bearer token and ephemeral TLS certificate. Only
the public bridge directory is mounted into the guest; the policy and command
configuration stay on the host. Requests are limited to 16 KiB, PR bodies to
8 KiB, command output to 2 MiB, and each op/gh process to 60 seconds. TLS and
request reads have a 10-second deadline. The broker handles one request at a
time. It exits with the session and removes its runtime directory.
Audit events go to $AI_DIR/logs/github-bridge.log (default
~/ai/logs/github-bridge.log) without tokens, token references, PR bodies, or
subprocess error output. Returned repository content is visible to the agent.
Run the bridge tests with:
for test in tools/github-bridge/test_*.rb; do ruby "$test" || break; done
bash tools/test_github_bridge.sh
bash tools/test_agent_skills.shPaste this into the cloud environment's Setup script field:
#!/bin/bash
# provisioning-revision: 2026-10-12-1 (change to refresh the cache)
set -euo pipefail
script=$(mktemp)
trap 'rm -f "$script"' EXIT
curl --fail --silent --show-error --location --retry 3 \
--connect-timeout 15 --max-time 120 \
https://raw.githubusercontent.com/dentarg/ai/main/tools/claude-cloud-setup.sh \
--output "$script"
bash "$script"Set Network access to Full in the Claude Code cloud environment. See the setup script for what it installs.
First-time setup to get OAuth credentials.
Claude Code:
# inside the container, or via podman run
refresh-tokens --login
refresh-tokens --login <profile>This generates an OAuth authorization URL. Open it in your browser, sign in, and paste the code back into the terminal. Credentials are saved to ~/.claude/.credentials.json (or .credentials.<profile>.json).
OpenAI Codex:
# inside the container
codex-login
codex-login <profile>
# or from the host
bin/codex-login
bin/codex-login <profile>This standalone helper starts Codex's "Sign in with Device Code" flow without
running the Codex CLI. Open the displayed URL, enter the one-time code, and
finish sign-in in your browser. Credentials are saved to
/settings/codex/auth.json in the container, or
$HOME/ai/settings/codex/auth.json from the host.
For a named profile, codex_<profile> replaces the codex directory name.
OAuth tokens expire periodically. bin/ai refreshes the profile's
credentials in ~/ai/settings before each launch, and inside a session
Claude Code refreshes its own copy when it nears expiry. Sessions do not run
a refresh daemon: each refresh rotates the token, and extra rotations from
idle sessions sharing a profile can invalidate each other's copies. Use l
in Claude Code to recover the freshest credentials from session history.
# one-shot refresh (e.g. before launching a session)
refresh-tokens --once
# inspect active credentials saved in recent Claude session history
refresh-tokens --list-active
refresh-tokens --list-active <profile>
# print or copy the freshest active credentials for a profile
refresh-tokens --find-active <profile>
refresh-tokens --copy-active <profile> /path/to/.credentials.jsonThe service can also run as a standalone container to refresh /settings credentials:
podman run -d --name token-refresh \
--env CREDENTIALS_DIR=/settings \
--volume ${HOME}/ai/settings:/settings \
ai:latest /usr/local/bin/refresh-tokens --daemonEnvironment variables:
| Variable | Default | Description |
|---|---|---|
CREDENTIALS_DIR |
~/.claude |
Directory containing .credentials*.json files |
CHECK_INTERVAL |
300 |
Seconds between checks |
REFRESH_BEFORE |
3600 |
Seconds before expiry to trigger refresh |
HISTORY_DIR |
~/ai/history |
Claude session history to search |
HISTORY_DAYS |
2 |
Recent file-age window to search |
Plugin marketplaces are baked into the image, so their commands are there in
every session and every project with no per-project configuration. List one per
line in versions/claude-plugins:
# <name> <git url> <commit>
84codes https://github.com/84codes/claude-plugins.git d53b7805…
At build time each is cloned to /opt/claude-plugins/marketplaces/<name>,
validated, and every plugin it declares with an in-repo source is symlinked
into /opt/claude-plugins/enabled/. On launch, c links those into the
session's ~/.claude/skills/, where Claude Code auto-loads each as
<name>@skills-dir, enabled by default. Today that gives you /gem:bump.
Plugins a marketplace sources from another repo are skipped, with a count in the build log — add that repo as its own line to vendor them. Two marketplaces providing the same plugin name is a build error rather than a coin flip.
# re-pin every marketplace to its remote HEAD, then rebuild
./build_image --update-pluginsEverything baked in loads by default. To keep one off, name it in
claude/plugins.blocklist (version controlled, ships in the image) or in
~/ai/settings/plugins.blocklist on the host (no rebuild needed) — the two are
merged:
# one plugin name per line
code-simplifier
Blocked plugins are still installed and still listed by /plugin; they just
start disabled, so you can turn one on for a single session:
claude plugin list
# ❯ gem@skills-dir ✔ loaded
# ❯ code-simplifier@skills-dir ✘ disabled
claude plugin enable code-simplifier@skills-dirc rewrites the session's settings.json on every launch by merging
claude/settings.json with the selected profile's
~/ai/settings/claude_<profile>/settings.json. Removing a name from the
blocklist therefore re-enables it next time — nothing to undo.
~/.claude is a fresh per-session directory under /history (see
tools/claude.sh). A real install writes known_marketplaces.json,
installed_plugins.json and enabledPlugins into it, all carrying absolute
paths into that throwaway directory, so it would be discarded on every launch.
extraKnownMarketplaces in settings doesn't help either: it is only acted on by
the interactive trust dialog. Symlinking into ~/.claude/skills/ sidesteps all
of it — and because they're symlinks into the image, a rebuild reaches resumed
sessions too.
Codex marketplaces are pinned separately in versions/codex-plugins:
# <name> <git url> <commit>
dentarg https://github.com/dentarg/codex-plugins.git d9eeb3d3…
Image and VM builds clone each marketplace, install every local plugin into a
validated Codex cache, and enable it in a reusable configuration fragment.
cx copies that cache and configuration into every new or resumed session.
The dentarg/codex-plugins marketplace currently provides the gem@84codes
plugin and its $gem-bump skill.
./build_image --update-plugins refreshes the Claude Code and Codex pins;
./build_vm --update-plugins refreshes the Codex pins used by the VM. A
selected Codex configuration profile can disable a baked plugin because profile
configuration is layered above the generated base configuration.
To enable Sentry's MCP server so the agent can fetch issues, events, and
stack traces directly, drop your Sentry
user auth token in
$HOME/ai/settings/sentry.token (just the token, no quotes or whitespace).
When you launch c <profile>, an mcpServers.sentry entry is injected into
the session's ~/.claude.json that runs
@sentry/mcp-server locally over
stdio with SENTRY_ACCESS_TOKEN set. We don't use the hosted
mcp.sentry.dev because its OAuth flow expects a callback on
localhost:62880 inside the browser host — unreachable from this container.
For self-hosted Sentry, additionally drop the hostname in
$HOME/ai/settings/sentry.host (e.g. sentry.example.com) — it's passed
through as --host=....
No sentry.token → no MCP server registered.
bin/ai session runs multiplayer sessions with a shared native terminal. Start sessions
inside a Linux sandbox VM, with Docker available. Sessions use
ghcr.io/dentarg/ai:latest by default and pull it if missing from the VM's
Docker daemon. Pull explicitly to update a cached image. Use --image ai:latest
to use a local development build, or --image ghcr.io/dentarg/ai:sha-<commit>
to select a published revision. An image on the host's Podman daemon is not
automatically available in the VM.
Use --keep-vm when launching the VM: terminal detachment does not override
the outer launcher's VM cleanup policy.
Each session runs its agent in a separate Docker container. Local and invited remote participants attach to the same native agent UI and type freely.
# Run from this repository inside the VM; choose the agent's working directory.
bin/ai session start demo --workspace /repos/demo -- c myprofile
# Start without attaching, and publish a web app on an unused VM loopback port.
bin/ai session start web --workspace /repos/web --detach --port 3000 -- cx myprofile
# From another terminal in the same VM:
bin/ai session list
bin/ai session attach demo
bin/ai session attach demo --read-only
bin/ai session ports webDetach with Ctrl+b, then d. Ctrl+C still reaches the agent. Everyone shares one input buffer; coordinate typing out of band. Writable clients can also use tmux commands inside the agent container. Read-only attachment is a local convenience, not a remote authentication boundary. Viewer terminals do not resize the shared screen; the latest active writable client determines its size. Use Ctrl+b, then i to redisplay the session information without leaving the terminal. The owner can list invitations or retrieve the same invitation from another VM terminal.
The image must contain Bash, tmux, util-linux (script), and the requested
agent command. --image selects another image. The normal agent wrappers
and shell configuration are loaded, but the container runs without systemd
or automatic database startup. Supporting services belong in the VM.
Neither the Docker socket nor the VM's home directory is mounted into it.
--settings selects the settings directory (default /settings), mounted
read-only. Everyone with write access to the terminal can use the credentials
in that directory. Agent history, bundle cache, and shell history are isolated
per session. Token changes stay in the session; syncing refreshed credentials
back to the read-only settings directory is not supported in this first slice.
Repeat --port (also accepted as --ports) for additional TCP services.
--port 3000 selects an unused
VM port; --port 18080:3000 selects VM port 18080 explicitly. Both bind only
to 127.0.0.1 in the VM. Apps must listen on 0.0.0.0 inside the container.
session ports prints HTTP preview URLs; the forwarding itself also carries
WebSockets and other TCP protocols.
These are session ports: use session start --port 3000. The VM launcher's
--ports option is not required for Tailcat previews and does not publish a
port from an agent container. Ports must be selected when creating the session.
The VM needs Tailcat (tested with v0.4.0), OpenSSH server, and a running systemd
user manager. Enable persistent user services once if needed:
sudo loginctl enable-linger "$USER". The invitation commands run as your
normal VM user and do not change the VM's existing SSH server.
# On the VM: create one invitation per participant. Repeating this command
# displays the same invitation, including after attaching to the terminal.
bin/ai session invite web alice
# List invitations without displaying credentials.
bin/ai session invitations web
# On the participant's Mac or Linux machine, from a checkout of this repo:
bin/ai session join 'ai-session-v1.REPLACE_WITH_INVITATION' --port 3000
# Or receive the invitation as a file, keeping the credential out of shell
# history and process arguments:
bin/ai session join @alice.invite --port 3000
# Home-relative invitation paths also work:
bin/ai session join @~/Downloads/alice.invite --port 3000
# On the VM: disconnect this invitation's terminal and preview tunnels.
bin/ai session revoke web aliceJoining directly from a checkout requires Ruby 3.1 or newer, OpenSSH client, and
tailcat on PATH.
On macOS, install Tailcat with brew install tailcat
(upstream installation instructions).
join checks for missing client tools before attempting a connection.
To keep SSH and Tailcat inside a container, select Podman or Docker:
bin/ai session join --podman @~/Downloads/alice.invite --port 3000
bin/ai session join --docker @~/Downloads/alice.invite --port 3000
# Use a different local browser port:
bin/ai session join --podman @~/Downloads/alice.invite --port 13000:3000The helper requires Ruby and the selected container engine on the participant's
machine. Start the engine first; for Podman on macOS, run podman machine start
after the usual initial setup. The helper runs ghcr.io/dentarg/ai:latest,
mounts the invitation read-only, sets the preview bind address, and publishes
ports only on host loopback. Repeat --port for more apps, or omit it for
terminal-only access. --image IMAGE
selects a different participant image. A missing image is pulled automatically;
use podman pull ghcr.io/dentarg/ai:latest or docker pull ghcr.io/dentarg/ai:latest
to refresh an already cached image.
Participants can also join using Docker without cloning this repository or installing Ruby or Tailcat locally. Save the invitation as a file, then run:
docker pull ghcr.io/dentarg/ai:latest
docker run --rm --init -it \
--mount "type=bind,src=$HOME/Downloads/alice.invite,dst=/invitation,readonly" \
--entrypoint ai-join \
ghcr.io/dentarg/ai:latest @/invitationTo browse an app published by the owner with session start --port 3000:
docker run --rm --init -it \
--mount "type=bind,src=$HOME/Downloads/alice.invite,dst=/invitation,readonly" \
--publish 127.0.0.1:3000:3000 \
--entrypoint ai-join \
ghcr.io/dentarg/ai:latest @/invitation --port 3000 --bind 0.0.0.0Open http://127.0.0.1:3000 on the participant's machine. --bind 0.0.0.0
lets Docker reach the SSH preview listener inside the client container;
--publish 127.0.0.1:3000:3000 keeps access on the host limited to localhost.
To use host port 13000, change only the publish mapping to
127.0.0.1:13000:3000. Use --bind 0.0.0.0 only with the container setup;
normal session join previews bind to localhost by default.
Detach with Ctrl+b, then d. Docker removes the client container and its
temporary credentials; the shared agent keeps running. Run the same command
again to reconnect with the invitation.
An invitation grants access immediately; send it privately through your usual chat. It contains an SSH private key and a pinned server host key. Anyone with the invitation can use it, so use a separate participant name for each person. Invitations remain valid until revoked, the session stops, or the VM shuts down. The same invitation supports repeated and simultaneous connections while valid. Revoked participant names cannot be reused until the session is stopped and started again. Restarting requires fresh invitations; old ones stay revoked.
Tailcat carries an encrypted connection to a dedicated SSH endpoint on VM loopback. SSH forces terminal attachment inside the agent container and permits forwarding only to this session's published preview ports. Arbitrary VM commands, agent forwarding, remote forwarding, and Unix socket forwarding are disabled. Each invitation has its own systemd service, so revocation also closes existing connections without interrupting other participants. Share only with trusted collaborators: terminal writers have the agent container's full capabilities and can read its mounted credentials and project files. After revocation, the participant's terminal may take a few seconds to detect the lost transport and close; access is already stopped on the VM.
join --port 3000 makes the app available at http://127.0.0.1:3000 on the
participant's machine while attached. Use --port 13000:3000 if local port 3000
is occupied, and repeat the option for other published ports. Browser requests
and WebSockets travel through the same authenticated connection. Detaching
closes these tunnels; joining again restores them. The participant's SSH
credentials are saved with private permissions in
${XDG_CONFIG_HOME:-~/.config}/ai/session-joins/.
Remote terminal events identify the invitation name, not individual keystrokes.
Detailed bridge diagnostics are available through journalctl --user -u UNIT;
the service name is in the invitation's invitation.json under session data.
bin/ai session logs demo
bin/ai session logs demo --follow
bin/ai session replay demo
bin/ai session stop demoUse logs --follow (or logs -f) to print existing lifecycle events and keep
watching for new ones. Press Ctrl+C to stop following.
Session data lives in /history/multiplayer/<name> when /history exists,
otherwise $AI_DIR/history/multiplayer/<name> (AI_DIR defaults to ~/ai).
AI_SESSION_DIR overrides this location. It contains the agent's own history,
a terminal output/timing recording, and a logfmt lifecycle log. Raw keystrokes
are not recorded, but text echoed on screen is. Terminal recordings do not
attribute prompts to participants. Replay uses util-linux scriptreplay in
the VM. Stopping removes the container while retaining these files. Start a
stopped session again with the same name and an agent command:
bin/ai session start demo --detach --port 3333 -- cxThe saved workspace, image, settings, and port mappings are reused unless
overridden. Supplying --port replaces the previous port mappings. Each start
creates a new container and agent process; use the agent's resume option to
continue an earlier conversation. Agent history, shell history, and bundle
cache are retained, but packages installed only in the removed container are
lost. The previous recording, session metadata, and invitation records move
to runs/<previous-run-id>/ within the session directory. session replay
plays the current run's recording. Create new invitations after restarting.
Disconnects preserve running sessions, but VM reboot recovery is not implemented.
Run the integration tests without agent credentials or model requests. The remote tests need the VM services listed above and reach Tailcat's relay network; the local tests need only Docker and Ruby with Minitest:
docker build -f tools/session/Dockerfile.test -t ai-session-test:latest tools/session
ruby tools/session/test_session.rb
# Local tests only (also run against each candidate image in CI):
ruby tools/session/test_session.rb --exclude '/remote|tailcat/'
# Include joining through both Docker and Podman helpers with browser preview forwarding:
docker build -t ai-session-client-test:latest .
docker save ai-session-client-test:latest | podman load
AI_SESSION_CLIENT_IMAGE=ai-session-client-test:latest ruby tools/session/test_session.rbRemote control lets you monitor and steer a running Claude Code session from claude.ai or the Claude mobile app. The session keeps running in the container — only its I/O is mirrored, over the same outbound HTTPS the agent already uses.
It is off by default and opt-in per session, because it exposes the session (filesystem, MCP servers, every tool call) to anyone with your claude.ai login. Turn it on with either:
bin/ai c <profile> --remote # one-off flag
AI_REMOTE=1 bin/ai c <profile> # or set the env in your shellbin/ai forwards this into the container as AI_REMOTE=1; c then sets
remoteControlAtStartup in the session's ~/.claude/settings.json — the same
key the /config "Enable Remote Control for all sessions" toggle writes, so the
bridge starts automatically. Running c directly honours the same --remote
flag and AI_REMOTE env. To make it the default for every session, export
AI_REMOTE=1 in your host shell profile.
Remote sessions are labelled by the host directory in the claude.ai/mobile
list — c sets CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX from HOST_DIR, so
they show as <dir>-<random-words> instead of the container hostname.
To enable it on an already-running session, run /remote-control (or /rc)
in the TUI; a /rc active link then appears in the footer.
Fast mode runs Opus with higher-speed output. It is off by default and opt-in per session, because it draws from usage credits at a higher rate and has separate rate limits. Turn it on with either:
bin/ai c <profile> --fast # one-off flag
AI_FAST=1 bin/ai c <profile> # or set the env in your shellbin/ai forwards this into the container as AI_FAST=1; c then sets
fastMode in the session's ~/.claude/settings.json — the same key the
/fast toggle writes. Running c directly honours the same --fast flag and
AI_FAST env. To make it the default for every session, export AI_FAST=1 in
your host shell profile. Toggle it within a session with /fast [on|off].
c also exports CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1 in the fast path.
Without it the persisted fastMode is evaluated once at startup, while the
async fast-mode availability check is still pending, so in a fresh container it
resolves to off and never re-applies; skipping that check lets the setting
engage immediately.
RabbitMQ and Erlang are installed in the container, both VM backends, and Claude cloud setup. Linux uses Ubuntu packages; macOS uses Homebrew. LavinMQ remains the default broker. Both use port 5672, so stop LavinMQ before starting RabbitMQ:
# Linux container or VM
sudo systemctl stop lavinmq
sudo systemctl start rabbitmq-server
# Claude cloud sandbox (root)
service lavinmq stop
service rabbitmq-server start
# macOS VM
sudo brew services stop lavinmq
brew services start rabbitmqzsh things:
# pod # list all running containers
# pod <id> # launch bash shell in selected container
# pod last # launch bash shell in the youngest container
function pod() {
[ $# -lt 1 ] && podman ps && return 0
[ "$1" = "last" ] && podman exec -it $(podman ps | tail -1 | cut -d ' ' -f 1) ${2:-bash} && return
local container
container=$1
podman exec -it $container ${2:-bash}
}Start it capturing everything:
./mitmdump --mode regular --listen-port 8080 --ssl-insecure --set flow_detail=3 -w claude.flowmitmproxy generates its CA at ~/.mitmproxy/mitmproxy-ca-cert.pem on first run.
export NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem
export HTTPS_PROXY=http://127.0.0.1:8080Start the agent
claudeOutput the (partially) binary dump as text (--mode picking another port is important if proxy already running)
./mitmdump --mode regular@8082 --set flow_detail=3 -r claude.flow --set export_format=curltcpdump
podman run --rm -it --cap-add=NET_RAW --cap-add=NET_ADMIN --net=container:<container> nicolaka/netshoot tcpdump -i eth0podman
# to see current settings
podman machine inspect
# when we can't build because we're out of space
podman system prune--all
# initial setup, or after `podman machine reset`:
# inits the VM (300 GB disk, 8 GB RAM), enables zram swap, and persists
# kernel.keys.maxkeys / maxbytes so we don't hit the keyring quota
# ("crun: join keyctl ... Disk quota exceeded")
bin/setup-vm- Claude Code
- GitHub Copilot
- Google Gemini
- Node.js
- Bun
TypeScript - Ruby
- Crystal 1.21.1 (official bundled release, checksum verified)
- Python
- Rust
- Go
- Terraform
- ast-grep
- fnox — encrypted and remote secret manager
- tmux
- SSH (
ssh-keygen, ...) - SQLite
- PostgreSQL
- LavinMQ
- RabbitMQ and Erlang
- Redis
- Toxiproxy — network failure simulation
- amqpcat
- tailcat — netcat over Tailscale's data plane
- rusage — better
time(1), prints fullgetrusage(2)stats - logcli — Grafana Loki CLI