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:
@@ -258,6 +258,9 @@ provider "aws" {
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--replace` | off | Destroy and recreate an existing sandbox |
|
||||
|
||||
@@ -268,6 +271,52 @@ provider "aws" {
|
||||
| `AI_SBX_AGENT` | `codex` | `--agent` |
|
||||
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
|
||||
| `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
|
||||
|
||||
|
||||
+69
-3
@@ -6,6 +6,7 @@ CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/ai-sbx"
|
||||
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:-}"
|
||||
|
||||
# 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
|
||||
@@ -43,6 +44,9 @@ Usage:
|
||||
Setup opens a pre-filled GitHub token form in your browser. Use "token" on
|
||||
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:
|
||||
--aws-profile NAME Host AWS profile to expose inside the sandbox.
|
||||
May be supplied more than once. A trailing
|
||||
@@ -54,6 +58,12 @@ Setup options:
|
||||
of the repository, mounted read-only.
|
||||
Its commits reach the host through the
|
||||
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.
|
||||
|
||||
Examples:
|
||||
@@ -292,13 +302,22 @@ load_config() {
|
||||
|
||||
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
|
||||
CONFIG_AWS_PROFILES=()
|
||||
|
||||
declare -p CONFIG_KITS >/dev/null 2>&1 ||
|
||||
CONFIG_KITS=()
|
||||
|
||||
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-}"
|
||||
}
|
||||
|
||||
save_config() {
|
||||
local agent="$1"
|
||||
local mode="$2"
|
||||
shift 2
|
||||
local -a profiles=("$@")
|
||||
local template="$3"
|
||||
local kit_count="$4"
|
||||
shift 4
|
||||
|
||||
local -a kits=("${@:1:kit_count}")
|
||||
local -a profiles=("${@:kit_count + 1}")
|
||||
|
||||
mkdir -p "$REPO_CONFIG_DIR"
|
||||
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_AGENT=%q\n' "$agent"
|
||||
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=('
|
||||
local profile
|
||||
@@ -517,6 +544,15 @@ create_sandbox() {
|
||||
create_args+=(--clone)
|
||||
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+=(
|
||||
"$CONFIG_AGENT"
|
||||
"$REPO_ROOT"
|
||||
@@ -528,8 +564,10 @@ create_sandbox() {
|
||||
setup_command() {
|
||||
local agent="$DEFAULT_AGENT"
|
||||
local mode="$DEFAULT_MODE"
|
||||
local template="$DEFAULT_TEMPLATE"
|
||||
local replace=false
|
||||
local -a aws_profiles=()
|
||||
local -a kits=()
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
@@ -551,6 +589,20 @@ setup_command() {
|
||||
mode="direct"
|
||||
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=true
|
||||
shift
|
||||
@@ -572,7 +624,14 @@ setup_command() {
|
||||
validate_aws_profile "$profile"
|
||||
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 [[ "$replace" == true ]]; then
|
||||
@@ -656,6 +715,13 @@ status_command() {
|
||||
printf 'Agent: %s\n' "$CONFIG_AGENT"
|
||||
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 'AWS profiles (host -> sandbox):\n'
|
||||
|
||||
Executable
+66
@@ -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'
|
||||
Reference in New Issue
Block a user