Install Claude configuration, plugins and mise into sandboxes

Custom templates are the documented way to carry user-level configuration into a
sandbox, but sbx v0.37.x silently drops every layer stacked on the base image
(docker/sbx-releases#366), so nothing baked into an image arrives. This installs
the same material into a stock sandbox after creation instead.

setup copies an allowlist of ~/.claude into the sandbox, imports skills into the
shared store, and adds each known marketplace before installing every enabled
plugin. Enablement survives here precisely because it happens after creation:
claude plugin install writes enabledPlugins itself, whereas sbx recreates
settings.json when the sandbox is created.

Tools come from mise, copied from the host because mise.jdx.dev is outside the
default network policy. Tools resolve through shims rather than mise activate,
which only fires for interactive shells and would leave the agent silently using
system versions.

sbx exec drains stdin, which truncated both install loops to their first entry,
and tab is an IFS whitespace character, which collapsed the empty repo field and
shifted the URL into it for git-sourced marketplaces. Both are handled.
This commit is contained in:
2026-07-31 08:18:43 -05:00
parent ace4e81f97
commit 1fdbbff2e2
2 changed files with 336 additions and 9 deletions
+52 -9
View File
@@ -244,10 +244,11 @@ provider "aws" {
| Command | Effect |
| --- | --- |
| `setup [options]` | Configure the repository, create the sandbox, open the token form, install AWS profiles |
| `setup [options]` | Configure the repository, create the sandbox, open the token form, install AWS profiles, Claude configuration and plugins, and mise |
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change |
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
| `refresh` | Same, without attaching |
| `run [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent |
| `refresh` | Refresh AWS credentials, without attaching |
| `config` | Re-apply your Claude configuration and plugins after the host changes, without recreating the sandbox |
| `status` | Show repository, sandbox, agent, mode, token expiry setting, profile mapping, stored secrets |
| `remove` | Remove the sandbox and this repository's local configuration |
@@ -296,10 +297,27 @@ under `~/.claude/plugins/` are **not** scanned — only the top-level skills dir
mise run ai:sbx -- setup --kit ~/kits/my-kit
```
**Templates — for anything else, including plugins:**
**`setup` — configuration and plugins, no image required:**
Plugins carry commands, hooks, agents, and MCP servers, none of which `skills import`
or a kit will move. A custom image is the only mechanism that does.
For the `claude` agent, `setup` copies `CLAUDE.md`, `AGENTS.md`, `agents`, `commands`
and `hooks` from `~/.claude` into the sandbox, runs `sbx skills import`, then adds
every marketplace in `~/.claude/plugins/known_marketplaces.json` and installs every
plugin your host has enabled. `config` re-applies all of it without recreating the
sandbox.
The copy is an allowlist. Credentials, conversation transcripts, `history.jsonl` and
shell snapshots are never copied, and anything added to `~/.claude` later stays on the
host until the allowlist names it.
A marketplace on a host other than `github.com` gets a matching network policy rule,
scoped to that sandbox — the default policy denies it otherwise.
Plugin *enablement* survives here because `claude plugin install` writes
`enabledPlugins` itself, after the sandbox has been created. Baking plugins into an
image does not survive: sbx recreates `~/.claude/settings.json` at creation, which
drops enablement while leaving the plugin files in place.
**Templates — for toolchains and anything else the base image lacks:**
```bash
export AI_SBX_TEMPLATE=ghcr.io/you/claude-sbx:v1
@@ -314,9 +332,34 @@ Two constraints worth knowing before building one:
- The sandbox's Docker daemon pulls templates **from a registry** and does not share
your host image store, so the image must be pushed somewhere reachable. Docker Hub
reuses your `sbx login`; for other registries use `sbx secret set --registry`.
- Claude overwrites `~/.claude/settings.json` at startup, so plugin *enablement*
baked into the image may not survive. Bake the plugin files in, and pass
`--settings` to the agent if enablement is lost.
- **sbx v0.37.0 and v0.37.1 cannot consume custom templates.** Every layer stacked on
the base image is silently dropped: the sandbox boots with base content only and
`sbx create` still exits 0. This is upstream
[#366](https://github.com/docker/sbx-releases/issues/366) — the erofs snapshotter
stopped building the merged `fsmeta`. v0.35.0 is unaffected. Until it is fixed, a
template is an expensive no-op and `setup` is the mechanism that works.
## Repository toolchains
`setup` and `run` install mise in the sandbox and resolve the repository's pinned
tools, so the agent runs the versions the project specifies rather than whatever the
base image ships.
The mise binary is copied from the host: `mise.jdx.dev` is outside the default network
policy, so the network installer fails at the tarball step. Tools resolve through
**shims** rather than `mise activate` — the agent runs non-interactive shells, which
never fire the activation hook and would otherwise silently get system versions.
A personal mise config that must stay out of the repository goes in the repository's
config directory:
```bash
$XDG_CONFIG_HOME/ai-sbx/repos/<digest>/mise.local.toml
```
It is copied to the workspace root on every run. This matters under `--clone`, where
the agent gets a fresh git clone and an untracked `mise.local.toml` on the host would
not reach it. Repositories with no mise configuration are left alone.
## AWS profile naming
+284
View File
@@ -7,6 +7,21 @@ DEFAULT_AGENT="${AI_SBX_AGENT:-codex}"
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
CLAUDE_HOME="${CLAUDE_HOME:-$HOME/.claude}"
# Only these leave the host. ~/.claude also holds OAuth credentials, shell
# snapshots and conversation transcripts, so this is an allowlist rather than
# a list of exclusions: anything added to ~/.claude later stays put by default.
#
# skills is absent deliberately: sbx mounts its own shared skills store over
# that path, so it is seeded with "sbx skills import" instead of copied.
CLAUDE_CONFIG_ALLOW=(
CLAUDE.md
AGENTS.md
agents
commands
hooks
)
# GitHub accepts these as query parameters on the token creation form. A write
# level implies read, so only the highest level is listed. "workflows" is
@@ -37,6 +52,7 @@ Usage:
mise run ai:sbx -- setup [options]
mise run ai:sbx -- token
mise run ai:sbx -- refresh
mise run ai:sbx -- config
mise run ai:sbx -- run [-- agent arguments...]
mise run ai:sbx -- status
mise run ai:sbx -- remove
@@ -47,6 +63,17 @@ its own to replace an expired or revoked token later.
Set AI_SBX_TEMPLATE in your mise config to reuse one custom image across
every repository without repeating --template.
For the claude agent, setup copies your user-level configuration (CLAUDE.md,
AGENTS.md, agents, commands, hooks, skills) into the sandbox and installs the
marketplaces and plugins your host has enabled. Credentials, transcripts and
history are never copied. Use "config" to re-apply after the host changes.
Setup and run install mise in the sandbox and resolve the repository's
pinned tools, so the agent runs the same versions you do. A personal
mise config that must stay out of the repository goes in the per-repository
config directory as mise.local.toml; it is copied to the workspace root on
every run. Repositories without any mise configuration are left alone.
Setup options:
--aws-profile NAME Host AWS profile to expose inside the sandbox.
May be supplied more than once. A trailing
@@ -530,6 +557,240 @@ EOF
done
}
marketplace_url() {
local source_kind="$1"
local repo="$2"
local url="$3"
case "$source_kind" in
github)
[[ -n "$repo" ]] || return 1
printf 'https://github.com/%s.git' "$repo"
;;
git)
[[ -n "$url" ]] || return 1
printf '%s' "$url"
;;
*)
return 1
;;
esac
}
# sbx cp places a source directory *inside* an existing destination directory,
# so a repeat copy would nest hooks/hooks. Clearing the target first keeps this
# idempotent.
copy_claude_config_item() {
local item="$1"
local sandbox_home="$2"
local target="$sandbox_home/.claude/$item"
# sbx mounts parts of ~/.claude from its own stores. Those paths belong to
# sbx, and removing one fails with EBUSY partway through the copy.
if sbx exec "$SANDBOX_NAME" \
bash -c "mountpoint -q $(printf '%q' "$target")" 2>/dev/null; then
printf 'Skipping %s: managed by sbx inside the sandbox.\n' "$item" >&2
return 0
fi
sbx exec "$SANDBOX_NAME" \
bash -c "rm -rf $(printf '%q' "$target")"
sbx cp "$CLAUDE_HOME/$item" "$SANDBOX_NAME:$sandbox_home/.claude/"
}
install_sandbox_claude_config() {
if [[ "$CONFIG_AGENT" != claude ]]; then
printf 'Agent is %s, not claude; skipping Claude configuration.\n' \
"$CONFIG_AGENT"
return 0
fi
if [[ ! -d "$CLAUDE_HOME" ]]; then
printf 'No %s on the host; skipping Claude configuration.\n' \
"$CLAUDE_HOME" >&2
return 0
fi
require_command jq
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.claude"'
local item
for item in "${CLAUDE_CONFIG_ALLOW[@]}"; do
[[ -e "$CLAUDE_HOME/$item" ]] || continue
copy_claude_config_item "$item" "$sandbox_home"
printf 'Copied %s into %s.\n' "$item" "$SANDBOX_NAME"
done
# The store is shared by every sandbox, so this seeds all of them at once.
if [[ -d "$CLAUDE_HOME/skills" ]]; then
sbx skills import >/dev/null 2>&1 ||
printf 'Could not import skills into the shared store.\n' >&2
fi
install_sandbox_claude_plugins
}
install_sandbox_claude_plugins() {
local known="$CLAUDE_HOME/plugins/known_marketplaces.json"
local settings="$CLAUDE_HOME/settings.json"
if [[ ! -f "$known" || ! -f "$settings" ]]; then
printf 'No plugin manifest on the host; skipping plugin install.\n' >&2
return 0
fi
# A fresh sandbox knows no marketplaces at all, including the official one
# the host acquires on first run, so every marketplace is added explicitly.
# Not @tsv: tab is an IFS whitespace character, so read collapses the empty
# field an entry without a repo produces and shifts the URL into it.
local name source_kind repo url
while IFS='|' read -r name source_kind repo url; do
[[ -n "$name" ]] || continue
local marketplace
if ! marketplace="$(marketplace_url "$source_kind" "$repo" "$url")"; then
printf 'Skipping marketplace %s: unsupported source %s\n' \
"$name" "$source_kind" >&2
continue
fi
# Only github.com is reachable under the default network policy.
local host
host="${marketplace#https://}"
host="${host%%/*}"
if [[ "$host" != "github.com" ]]; then
sbx policy allow network \
--sandbox "$SANDBOX_NAME" "$host" \
</dev/null >/dev/null 2>&1 || true
fi
# Without </dev/null sbx exec drains the loop's input and only the
# first marketplace is ever processed.
sbx exec "$SANDBOX_NAME" \
claude plugin marketplace add "$marketplace" \
</dev/null >/dev/null 2>&1 ||
printf 'Could not add marketplace %s (%s).\n' \
"$name" "$marketplace" >&2
done < <(
jq -r '
to_entries[]
| [
.key,
(.value.source.source // ""),
(.value.source.repo // ""),
(.value.source.url // "")
]
| join("|")
' "$known"
)
local plugin
while read -r plugin; do
[[ -n "$plugin" ]] || continue
if sbx exec "$SANDBOX_NAME" \
claude plugin install "$plugin" </dev/null >/dev/null 2>&1; then
printf 'Installed plugin %s\n' "$plugin"
else
printf 'Could not install plugin %s\n' "$plugin" >&2
fi
done < <(
jq -r '(.enabledPlugins // {}) | to_entries[] | select(.value) | .key' \
"$settings"
)
}
install_sandbox_mise() {
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
# mise.jdx.dev sits outside the default network policy, so the sandbox gets
# the host binary rather than the network installer. This also pins the
# agent to the same mise version the host runs.
# shellcheck disable=SC2016
if ! sbx exec "$SANDBOX_NAME" \
bash -c 'command -v mise >/dev/null || [[ -x "$HOME/.local/bin/mise" ]]'; then
local host_mise
if ! host_mise="$(command -v mise)"; then
printf 'mise is not on the host PATH; skipping sandbox mise setup.\n' >&2
return 0
fi
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.local/bin"'
sbx cp "$host_mise" "$SANDBOX_NAME:$sandbox_home/.local/bin/mise"
fi
local personal="$REPO_CONFIG_DIR/mise.local.toml"
if [[ -f "$personal" ]]; then
sbx cp "$personal" "$SANDBOX_NAME:$REPO_ROOT/mise.local.toml"
printf 'Installed personal mise.local.toml in %s.\n' "$SANDBOX_NAME"
fi
# Shims rather than "mise activate": the agent runs non-interactive shells,
# which never fire the activation hook, and would silently get the system
# toolchain instead of the pinned one.
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
persistent=/etc/sandbox-persistent.sh
marker="# BEGIN ai-sbx mise configuration"
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
exit 0
fi
cat >>"$persistent" <<'"'"'EOF'"'"'
# BEGIN ai-sbx mise configuration
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"
# END ai-sbx mise configuration
EOF
'
# A repository with no mise configuration is normal, and a broken config is
# the repository's problem, not a reason to refuse to start the agent.
# Only the workspace path below is expanded by the host shell.
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
export PATH="$HOME/.local/bin:$PATH"
cd '"$(printf '%q' "$REPO_ROOT")"' 2>/dev/null || exit 0
for candidate in \
mise.toml mise.local.toml .mise.toml .mise.local.toml \
.config/mise.toml .config/mise/config.toml; do
[[ -f "$candidate" ]] && found=true && break
done
[[ "${found:-false}" == true ]] || exit 0
mise trust . >/dev/null 2>&1 || true
if mise install; then
mise reshim >/dev/null 2>&1 || true
else
printf "mise install failed; the agent starts without pinned tools.\n" >&2
fi
'
}
create_sandbox() {
load_config
@@ -652,6 +913,10 @@ setup_command() {
install_sandbox_aws_files
install_sandbox_claude_config
install_sandbox_mise
cat <<EOF
Setup complete.
@@ -685,6 +950,18 @@ refresh_command() {
install_sandbox_aws_files
}
# The sandbox home survives stop/start, so this is a setup-time job. It exists
# as its own command for the case where the host configuration changed and the
# sandbox should catch up without being recreated.
config_command() {
load_config
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
install_sandbox_claude_config
}
run_command() {
load_config
@@ -695,6 +972,10 @@ run_command() {
# the AWS credentials expire between sessions.
install_sandbox_aws_files
# Tool pins change with the branch the agent is about to work on, so this
# runs every time rather than only at setup.
install_sandbox_mise
if (($#)) && [[ "$1" == "--" ]]; then
shift
fi
@@ -787,6 +1068,9 @@ main() {
refresh)
refresh_command "$@"
;;
config)
config_command "$@"
;;
run)
run_command "$@"
;;