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:
+284
@@ -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 "$@"
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user