Pass a custom template and mixin kits through to sbx

Sandboxes ignore the host ~/.claude by design: the agent runs as a separate
user with HOME elsewhere, so even a read-only mount is not picked up. Skills
can be shared with sbx skills import, but plugins carry commands, hooks and
MCP servers that only a custom image can deliver.

Adds --template, --stock-template and a repeatable --kit, persisted per
repository so run and refresh reuse them. AI_SBX_TEMPLATE supplies the default
image, so one custom template can be declared once in the user's mise config
and apply to every repository, with --template overriding it per repository
and --stock-template opting out.

save_config now packs two arrays into one argument list separated by a count,
so it ships with a round-trip test covering empty arrays, values containing
spaces, and the boundary between kits and AWS profiles.
This commit is contained in:
2026-07-30 16:29:25 -05:00
parent 4af8c1c153
commit ace4e81f97
3 changed files with 184 additions and 3 deletions
+49
View File
@@ -258,6 +258,9 @@ provider "aws" {
| `--aws-profile NAME` | none | Host profile to expose. Repeatable. Trailing `-readonly` stripped inside the sandbox | | `--aws-profile NAME` | none | Host profile to expose. Repeatable. Trailing `-readonly` stripped inside the sandbox |
| `--agent NAME` | `codex` | Sandbox agent. See `sbx create --help` for the list | | `--agent NAME` | `codex` | Sandbox agent. See `sbx create --help` for the list |
| `--clone` | on | Give the agent a private in-container clone; its commits reach the host via the `sandbox-<name>` git remote | | `--clone` | on | Give the agent a private in-container clone; its commits reach the host via the `sandbox-<name>` git remote |
| `--template REF` | `AI_SBX_TEMPLATE` | Custom sandbox image |
| `--stock-template` | off | Ignore `AI_SBX_TEMPLATE` for this repository |
| `--kit PATH` | none | Mixin kit to apply. Repeatable |
| `--direct` | off | Mount the host working tree read-write | | `--direct` | off | Mount the host working tree read-write |
| `--replace` | off | Destroy and recreate an existing sandbox | | `--replace` | off | Destroy and recreate an existing sandbox |
@@ -268,6 +271,52 @@ provider "aws" {
| `AI_SBX_AGENT` | `codex` | `--agent` | | `AI_SBX_AGENT` | `codex` | `--agent` |
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` | | `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
| `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) | | `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) |
| `AI_SBX_TEMPLATE` | unset | `--template` / `--stock-template` |
## Carrying your Claude configuration into the sandbox
Sandboxes deliberately ignore your host `~/.claude`. The agent runs as a separate
`agent` user with `HOME` pointing elsewhere, so even a read-only mount of `~/.claude`
is not picked up. Three mechanisms exist, covering progressively more:
**Skills — supported, no build required:**
```bash
sbx skills import # add --dry-run to preview
```
Copies each skill directory from `~/.claude/skills` (and `~/.agents/skills`,
`~/.copilot/skills`, `~/.cursor/skills`, `~/.factory/skills`) into a shared store
mounted into every new sandbox. Symlinks and loose top-level files are skipped. Skills
under `~/.claude/plugins/` are **not** scanned — only the top-level skills directory.
**Kits — for tools, env vars, network rules, and startup commands:**
```bash
mise run ai:sbx -- setup --kit ~/kits/my-kit
```
**Templates — for anything else, including plugins:**
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.
```bash
export AI_SBX_TEMPLATE=ghcr.io/you/claude-sbx:v1
mise run ai:sbx -- setup # every repository now uses it
```
Set `AI_SBX_TEMPLATE` once in `~/.config/mise/config.toml` and every repository picks
it up; override per repository with `--template`, or opt out with `--stock-template`.
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.
## AWS profile naming ## AWS profile naming
+69 -3
View File
@@ -6,6 +6,7 @@ CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/ai-sbx"
DEFAULT_AGENT="${AI_SBX_AGENT:-codex}" DEFAULT_AGENT="${AI_SBX_AGENT:-codex}"
DEFAULT_MODE="${AI_SBX_MODE:-clone}" DEFAULT_MODE="${AI_SBX_MODE:-clone}"
DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}" DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
# GitHub accepts these as query parameters on the token creation form. A write # 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 # level implies read, so only the highest level is listed. "workflows" is
@@ -43,6 +44,9 @@ Usage:
Setup opens a pre-filled GitHub token form in your browser. Use "token" on Setup opens a pre-filled GitHub token form in your browser. Use "token" on
its own to replace an expired or revoked token later. 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.
Setup options: Setup options:
--aws-profile NAME Host AWS profile to expose inside the sandbox. --aws-profile NAME Host AWS profile to expose inside the sandbox.
May be supplied more than once. A trailing May be supplied more than once. A trailing
@@ -54,6 +58,12 @@ Setup options:
of the repository, mounted read-only. of the repository, mounted read-only.
Its commits reach the host through the Its commits reach the host through the
sandbox-<name> git remote. This is the default. sandbox-<name> git remote. This is the default.
--template REF Custom sandbox image. Defaults to
AI_SBX_TEMPLATE when set.
--stock-template Ignore AI_SBX_TEMPLATE and use the agent's
stock image.
--kit PATH Mixin kit to apply. May be supplied more
than once.
--replace Replace the existing sandbox. --replace Replace the existing sandbox.
Examples: Examples:
@@ -292,13 +302,22 @@ load_config() {
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 || declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
CONFIG_AWS_PROFILES=() CONFIG_AWS_PROFILES=()
declare -p CONFIG_KITS >/dev/null 2>&1 ||
CONFIG_KITS=()
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-}"
} }
save_config() { save_config() {
local agent="$1" local agent="$1"
local mode="$2" local mode="$2"
shift 2 local template="$3"
local -a profiles=("$@") local kit_count="$4"
shift 4
local -a kits=("${@:1:kit_count}")
local -a profiles=("${@:kit_count + 1}")
mkdir -p "$REPO_CONFIG_DIR" mkdir -p "$REPO_CONFIG_DIR"
chmod 700 "$CONFIG_ROOT" "$CONFIG_ROOT/repos" "$REPO_CONFIG_DIR" 2>/dev/null || true chmod 700 "$CONFIG_ROOT" "$CONFIG_ROOT/repos" "$REPO_CONFIG_DIR" 2>/dev/null || true
@@ -308,6 +327,14 @@ save_config() {
printf 'CONFIG_SANDBOX=%q\n' "$SANDBOX_NAME" printf 'CONFIG_SANDBOX=%q\n' "$SANDBOX_NAME"
printf 'CONFIG_AGENT=%q\n' "$agent" printf 'CONFIG_AGENT=%q\n' "$agent"
printf 'CONFIG_MODE=%q\n' "$mode" printf 'CONFIG_MODE=%q\n' "$mode"
printf 'CONFIG_TEMPLATE=%q\n' "$template"
printf 'CONFIG_KITS=('
local kit
for kit in ${kits[@]+"${kits[@]}"}; do
printf ' %q' "$kit"
done
printf ' )\n'
printf 'CONFIG_AWS_PROFILES=(' printf 'CONFIG_AWS_PROFILES=('
local profile local profile
@@ -517,6 +544,15 @@ create_sandbox() {
create_args+=(--clone) create_args+=(--clone)
fi fi
if [[ -n "$CONFIG_TEMPLATE" ]]; then
create_args+=(--template "$CONFIG_TEMPLATE")
fi
local kit
for kit in ${CONFIG_KITS[@]+"${CONFIG_KITS[@]}"}; do
create_args+=(--kit "$kit")
done
create_args+=( create_args+=(
"$CONFIG_AGENT" "$CONFIG_AGENT"
"$REPO_ROOT" "$REPO_ROOT"
@@ -528,8 +564,10 @@ create_sandbox() {
setup_command() { setup_command() {
local agent="$DEFAULT_AGENT" local agent="$DEFAULT_AGENT"
local mode="$DEFAULT_MODE" local mode="$DEFAULT_MODE"
local template="$DEFAULT_TEMPLATE"
local replace=false local replace=false
local -a aws_profiles=() local -a aws_profiles=()
local -a kits=()
while (($#)); do while (($#)); do
case "$1" in case "$1" in
@@ -551,6 +589,20 @@ setup_command() {
mode="direct" mode="direct"
shift shift
;; ;;
--template)
(($# >= 2)) || die "--template requires a value"
template="$2"
shift 2
;;
--stock-template)
template=""
shift
;;
--kit)
(($# >= 2)) || die "--kit requires a value"
kits+=("$2")
shift 2
;;
--replace) --replace)
replace=true replace=true
shift shift
@@ -572,7 +624,14 @@ setup_command() {
validate_aws_profile "$profile" validate_aws_profile "$profile"
done done
save_config "$agent" "$mode" "${aws_profiles[@]}" local kit
for kit in ${kits[@]+"${kits[@]}"}; do
[[ -e "$kit" ]] ||
die "Kit does not exist: $kit"
done
save_config "$agent" "$mode" "$template" "${#kits[@]}" \
${kits[@]+"${kits[@]}"} ${aws_profiles[@]+"${aws_profiles[@]}"}
if sandbox_exists; then if sandbox_exists; then
if [[ "$replace" == true ]]; then if [[ "$replace" == true ]]; then
@@ -656,6 +715,13 @@ status_command() {
printf 'Agent: %s\n' "$CONFIG_AGENT" printf 'Agent: %s\n' "$CONFIG_AGENT"
printf 'Mode: %s\n' "$CONFIG_MODE" printf 'Mode: %s\n' "$CONFIG_MODE"
printf 'Template: %s\n' "${CONFIG_TEMPLATE:-stock}"
if ((${#CONFIG_KITS[@]})); then
printf 'Kits:\n'
printf ' %s\n' "${CONFIG_KITS[@]}"
fi
printf 'Token days: %s\n' "$DEFAULT_TOKEN_DAYS" printf 'Token days: %s\n' "$DEFAULT_TOKEN_DAYS"
printf 'AWS profiles (host -> sandbox):\n' printf 'AWS profiles (host -> sandbox):\n'
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
REPOSITORY="CareEvolution/api-portal"
SANDBOX_NAME="ai-test"
CONFIG_ROOT="$work/config"
REPO_CONFIG_DIR="$CONFIG_ROOT/repos/deadbeef"
REPO_CONFIG_FILE="$REPO_CONFIG_DIR/config"
roundtrip() {
local agent="$1" mode="$2" template="$3" kit_count="$4"
shift 4
rm -rf "$CONFIG_ROOT"
save_config "$agent" "$mode" "$template" "$kit_count" "$@"
unset CONFIG_KITS CONFIG_AWS_PROFILES CONFIG_TEMPLATE
load_config
}
roundtrip codex clone "" 0
[[ "$CONFIG_TEMPLATE" == "" ]] || fail "empty template did not survive: $CONFIG_TEMPLATE"
((${#CONFIG_KITS[@]} == 0)) || fail "expected no kits, got ${#CONFIG_KITS[@]}"
((${#CONFIG_AWS_PROFILES[@]} == 0)) || fail "expected no profiles, got ${#CONFIG_AWS_PROFILES[@]}"
roundtrip claude direct ghcr.io/me/img:v1 0 dev-readonly prod-readonly
[[ "$CONFIG_TEMPLATE" == "ghcr.io/me/img:v1" ]] || fail "template lost: $CONFIG_TEMPLATE"
((${#CONFIG_KITS[@]} == 0)) || fail "profiles leaked into kits: ${CONFIG_KITS[*]}"
[[ "${CONFIG_AWS_PROFILES[*]}" == "dev-readonly prod-readonly" ]] ||
fail "profiles wrong: ${CONFIG_AWS_PROFILES[*]}"
roundtrip claude clone img:v2 2 /kits/a /kits/b api-portal
((${#CONFIG_KITS[@]} == 2)) || fail "expected 2 kits, got ${#CONFIG_KITS[@]}"
[[ "${CONFIG_KITS[*]}" == "/kits/a /kits/b" ]] || fail "kits wrong: ${CONFIG_KITS[*]}"
[[ "${CONFIG_AWS_PROFILES[*]}" == "api-portal" ]] ||
fail "kits leaked into profiles: ${CONFIG_AWS_PROFILES[*]}"
roundtrip claude clone "reg/img:v3" 1 "/kits/with space" "profile one"
[[ "${CONFIG_KITS[0]}" == "/kits/with space" ]] || fail "kit with space mangled: ${CONFIG_KITS[0]}"
[[ "${CONFIG_AWS_PROFILES[0]}" == "profile one" ]] ||
fail "profile with space mangled: ${CONFIG_AWS_PROFILES[0]}"
[[ "$(stat -c '%a' "$REPO_CONFIG_FILE")" == "600" ]] ||
fail "config file is not mode 600"
grep -q 'CONFIG_TEMPLATE=' "$REPO_CONFIG_FILE" || fail "template not persisted"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All config round-trip assertions passed.\n'