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
+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 "$@"
;;