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 |
|
| `--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
@@ -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'
|
||||||
|
|||||||
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