The secret store outlives the sandbox, so recreating one to change its image or add a profile does not lose the token. Prompting for a replacement anyway made --replace impractical and trained the habit of minting tokens that are never revoked. Only a token scoped to this sandbox counts. A global one would authenticate the agent too, but reaching every repository it can reach is what this task exists to prevent. The token command still always prompts; it is the way to replace an expired or revoked token.
1606 lines
48 KiB
Bash
Executable File
1606 lines
48 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
PROGRAM="ai:sbx"
|
|
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/ai-sbx"
|
|
DEFAULT_AGENT="${AI_SBX_AGENT:-claude}"
|
|
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
|
|
DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
|
|
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
|
|
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
|
|
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
|
|
DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"
|
|
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
|
|
|
|
# Rendered dotfiles are checked for these before the archive enters the sandbox.
|
|
# chezmoi archive decrypts as it renders, so this is the last line of defence
|
|
# behind the allowlist rather than the first.
|
|
DOTFILES_CREDENTIAL_PATTERNS=(
|
|
'gh[pousr]_[A-Za-z0-9]{16,}'
|
|
'github_pat_[A-Za-z0-9_]{20,}'
|
|
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
|
|
'AKIA[0-9A-Z]{16}'
|
|
'_authToken[[:space:]]*='
|
|
'aws_secret_access_key'
|
|
)
|
|
|
|
# VAR|host[,host...]|requirement. Provisioned for every repository when the
|
|
# variable is present in the host environment. "docker" skips the entry on a
|
|
# sandbox that cannot run containers, where the credential would be useless.
|
|
HOST_WIDE_SECRETS=(
|
|
"LOCALSTACK_AUTH_TOKEN|localstack.cloud,*.localstack.cloud|docker"
|
|
)
|
|
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
|
|
# required to push any commit touching .github/workflows and is separate from
|
|
# "actions".
|
|
TOKEN_URL_PERMISSIONS=(
|
|
metadata=read
|
|
contents=write
|
|
pull_requests=write
|
|
issues=write
|
|
workflows=write
|
|
actions=write
|
|
statuses=read
|
|
security_events=write
|
|
secret_scanning_alerts=read
|
|
vulnerability_alerts=read
|
|
)
|
|
|
|
# Fine-grained tokens cannot reach the Checks API at all: GitHub's permission
|
|
# reference has no Checks section and lists no check-run endpoint, so there is
|
|
# no box to tick. Both calls below degrade to empty output rather than a
|
|
# permission error, which reads as broken CI unless it is called out.
|
|
TOKEN_LIMITATIONS=(
|
|
"gh pr checks shows only commit statuses, not check runs"
|
|
"gh run view returns no annotations"
|
|
)
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
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 [--launch agent|tmux] [-- agent arguments...]
|
|
mise run ai:sbx -- status
|
|
mise run ai:sbx -- remove
|
|
|
|
Setup opens a pre-filled GitHub token form in your browser, unless a token is
|
|
already stored for the sandbox. The secret store outlives the sandbox, so
|
|
recreating one with --replace keeps its token. Use "token" on its own to
|
|
replace an expired or revoked token.
|
|
|
|
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.
|
|
|
|
Registry credentials are declared per repository in the user's config
|
|
directory as a "secrets" file, one line of VAR|host|command each. The command
|
|
runs on the host and its output becomes an sbx custom secret, so the sandbox
|
|
sees a placeholder and the proxy substitutes the real value.
|
|
LOCALSTACK_AUTH_TOKEN is provisioned for every repository when it is set on
|
|
the host and the sandbox has Docker to make use of it.
|
|
|
|
AI_SBX_NETWORK lists hosts to allow through the sandbox network policy, comma
|
|
or space separated. Hosts backing a provisioned secret are allowed
|
|
automatically, since a credential for a denied host can never be used.
|
|
|
|
AI_SBX_LAUNCH selects what "run" attaches to: "agent" starts the agent alone
|
|
and is the default, "tmux" attaches to a three-window workspace (agent, edit,
|
|
shell) that survives detaching. --launch overrides it for one run. Agent
|
|
arguments apply to "agent" only.
|
|
|
|
AI_SBX_DOTFILES=chezmoi renders the host's chezmoi dotfiles into the sandbox.
|
|
It requires an allowlist of target paths, one per line, in the user's config
|
|
directory as a "dotfiles" file. chezmoi decrypts as it renders, so setup
|
|
refuses to run when an encrypted file resolves inside the allowlist.
|
|
|
|
AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting
|
|
to bun because several Claude plugins run their hooks under it. Set it to an
|
|
empty string to install none.
|
|
|
|
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
|
|
-readonly is stripped from the profile name
|
|
written into the sandbox.
|
|
--agent NAME Sandbox agent. Default: claude
|
|
--direct Mount the host working tree read-write.
|
|
--clone Give the agent a private in-container clone
|
|
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:
|
|
mise run ai:sbx -- setup \
|
|
--aws-profile api-portal-readonly \
|
|
--aws-profile prod-readonly
|
|
|
|
mise run ai:sbx -- run
|
|
|
|
mise run ai:sbx -- run -- --dangerously-bypass-approvals-and-sandbox \
|
|
"Review the Terraform plan"
|
|
EOF
|
|
}
|
|
|
|
die() {
|
|
printf '%s: %s\n' "$PROGRAM" "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 ||
|
|
die "Required command not found: $1"
|
|
}
|
|
|
|
url_encode() {
|
|
local string="$1" index character encoded=""
|
|
|
|
for ((index = 0; index < ${#string}; index++)); do
|
|
character="${string:index:1}"
|
|
case "$character" in
|
|
[a-zA-Z0-9.~_-])
|
|
encoded+="$character"
|
|
;;
|
|
*)
|
|
printf -v character '%%%02X' "'$character"
|
|
encoded+="$character"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
printf '%s' "$encoded"
|
|
}
|
|
|
|
token_url() {
|
|
local owner="${REPOSITORY%%/*}"
|
|
local name="${REPOSITORY#*/}"
|
|
local url="https://github.com/settings/personal-access-tokens/new"
|
|
|
|
url+="?name=$(url_encode "ai-sbx $name")"
|
|
url+="&description=$(url_encode "AI agent sandbox for $REPOSITORY")"
|
|
url+="&target_name=$(url_encode "$owner")"
|
|
url+="&expires_in=$(url_encode "$DEFAULT_TOKEN_DAYS")"
|
|
|
|
local permission
|
|
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
|
|
url+="&$permission"
|
|
done
|
|
|
|
printf '%s' "$url"
|
|
}
|
|
|
|
open_browser() {
|
|
local url="$1" opener
|
|
|
|
for opener in "${BROWSER:-}" xdg-open open; do
|
|
[[ -n "$opener" ]] || continue
|
|
|
|
if command -v "$opener" >/dev/null 2>&1; then
|
|
"$opener" "$url" >/dev/null 2>&1 &
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
read_token() {
|
|
local token
|
|
|
|
# -s keeps the token off the terminal; it never reaches shell history
|
|
# because it is read into a variable rather than typed as an argument.
|
|
IFS= read -rsp 'Paste token: ' token </dev/tty
|
|
printf '\n' >&2
|
|
|
|
[[ -n "$token" ]] ||
|
|
die "No token entered."
|
|
|
|
case "$token" in
|
|
github_pat_*) ;;
|
|
ghp_*)
|
|
die "That is a classic token. Generate a fine-grained token from the link above."
|
|
;;
|
|
*)
|
|
die "That does not look like a fine-grained token (expected a github_pat_ prefix)."
|
|
;;
|
|
esac
|
|
|
|
printf '%s' "$token"
|
|
}
|
|
|
|
# Only a token scoped to this sandbox counts. A global one would authenticate
|
|
# the agent too, but reaching every repository the token can reach is exactly
|
|
# what this task exists to prevent, so it is not treated as satisfying setup.
|
|
sandbox_has_github_token() {
|
|
sbx secret ls 2>/dev/null |
|
|
awk -v scope="$SANDBOX_NAME" '
|
|
$1 == scope && $2 == "service" && $3 == "github" { found = 1 }
|
|
END { exit !found }
|
|
'
|
|
}
|
|
|
|
install_github_token() {
|
|
local url
|
|
url="$(token_url)"
|
|
|
|
cat >&2 <<EOF
|
|
|
|
Create a fine-grained token for $REPOSITORY.
|
|
|
|
Everything except the repository is pre-filled. On the page:
|
|
|
|
1. Repository access -> Only select repositories -> ${REPOSITORY#*/}
|
|
2. Generate token, then paste it below.
|
|
|
|
Every permission is pre-filled. Fine-grained tokens cannot read check runs,
|
|
so inside the sandbox:
|
|
EOF
|
|
|
|
local limitation
|
|
for limitation in "${TOKEN_LIMITATIONS[@]}"; do
|
|
printf ' %s\n' "$limitation" >&2
|
|
done
|
|
|
|
cat >&2 <<'EOF'
|
|
|
|
A 403 will name what it wanted in the X-Accepted-GitHub-Permissions header.
|
|
|
|
EOF
|
|
|
|
if open_browser "$url"; then
|
|
printf 'Opened your browser.\n\n' >&2
|
|
else
|
|
printf 'Open this link:\n\n%s\n\n' "$url" >&2
|
|
fi
|
|
|
|
# --force is mandatory: without it a second write prompts for confirmation,
|
|
# reads the prompt from the already-consumed stdin, cancels, and still
|
|
# exits 0 - leaving the previous, expired token in place.
|
|
read_token |
|
|
sbx secret set --force "$SANDBOX_NAME" github >/dev/null
|
|
|
|
printf 'Stored the token for %s in sandbox %s.\n' "$REPOSITORY" "$SANDBOX_NAME" >&2
|
|
}
|
|
# Terraform and provider blocks reference the account profile name, while the
|
|
# host distinguishes the read-only grant with a -readonly suffix. The suffix is
|
|
# a host-side naming convention, so it is stripped on the way into the sandbox.
|
|
sandbox_profile_name() {
|
|
local profile="$1"
|
|
local mapped="${profile%-readonly}"
|
|
|
|
[[ -n "$mapped" ]] ||
|
|
die "AWS profile name is empty after stripping -readonly: $profile"
|
|
|
|
printf '%s' "$mapped"
|
|
}
|
|
|
|
# A task included from the global mise config runs with the config root as its
|
|
# working directory ($HOME), not the directory the user invoked it from, so the
|
|
# repository would otherwise be undiscoverable from anywhere.
|
|
enter_invocation_directory() {
|
|
local invoked_from="${MISE_ORIGINAL_CWD:-$PWD}"
|
|
|
|
cd "$invoked_from" ||
|
|
die "Could not enter the invoking directory: $invoked_from"
|
|
}
|
|
|
|
repository_context() {
|
|
enter_invocation_directory
|
|
|
|
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" ||
|
|
die "This command must be run inside a Git repository."
|
|
|
|
local remote
|
|
remote="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null)" ||
|
|
die "The repository has no origin remote."
|
|
|
|
case "$remote" in
|
|
[email protected]:*)
|
|
REPOSITORY="${remote#[email protected]:}"
|
|
;;
|
|
ssh://[email protected]/*)
|
|
REPOSITORY="${remote#ssh://[email protected]/}"
|
|
;;
|
|
https://github.com/*)
|
|
REPOSITORY="${remote#https://github.com/}"
|
|
;;
|
|
http://github.com/*)
|
|
REPOSITORY="${remote#http://github.com/}"
|
|
;;
|
|
*)
|
|
die "Unsupported GitHub origin: $remote"
|
|
;;
|
|
esac
|
|
|
|
REPOSITORY="${REPOSITORY%.git}"
|
|
REPOSITORY="${REPOSITORY%/}"
|
|
|
|
[[ "$REPOSITORY" =~ ^[^/]+/[^/]+$ ]] ||
|
|
die "Could not derive owner/repository from origin: $remote"
|
|
|
|
local slug
|
|
slug="$(
|
|
printf '%s' "$REPOSITORY" |
|
|
tr '[:upper:]' '[:lower:]' |
|
|
tr '/_' '--' |
|
|
tr -cd 'a-z0-9.-'
|
|
)"
|
|
|
|
# Include a short digest to avoid collisions caused by normalization.
|
|
local digest
|
|
digest="$(
|
|
printf '%s' "$REPOSITORY" |
|
|
sha256sum |
|
|
cut -c1-10
|
|
)"
|
|
|
|
SANDBOX_NAME="ai-${slug}-${digest}"
|
|
REPO_CONFIG_DIR="$CONFIG_ROOT/repos/$digest"
|
|
REPO_CONFIG_FILE="$REPO_CONFIG_DIR/config"
|
|
}
|
|
|
|
sandbox_exists() {
|
|
sbx ls --quiet 2>/dev/null |
|
|
grep -Fxq "$SANDBOX_NAME"
|
|
}
|
|
|
|
load_config() {
|
|
[[ -f "$REPO_CONFIG_FILE" ]] ||
|
|
die "Repository is not configured. Run: mise run ai:sbx -- setup"
|
|
|
|
# This file is user-owned, mode 600, and contains no credentials.
|
|
# shellcheck disable=SC1090
|
|
source "$REPO_CONFIG_FILE"
|
|
|
|
[[ "${CONFIG_REPOSITORY:-}" == "$REPOSITORY" ]] ||
|
|
die "Repository configuration does not match the current origin."
|
|
|
|
[[ -n "${CONFIG_AGENT:-}" ]] ||
|
|
die "Agent is missing from $REPO_CONFIG_FILE"
|
|
|
|
|
|
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"
|
|
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
|
|
|
|
{
|
|
printf 'CONFIG_REPOSITORY=%q\n' "$REPOSITORY"
|
|
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
|
|
for profile in "${profiles[@]}"; do
|
|
printf ' %q' "$profile"
|
|
done
|
|
printf ' )\n'
|
|
} >"$REPO_CONFIG_FILE"
|
|
|
|
chmod 600 "$REPO_CONFIG_FILE"
|
|
}
|
|
|
|
validate_aws_profile() {
|
|
local profile="$1"
|
|
|
|
aws configure list-profiles | grep -Fxq "$profile" ||
|
|
die "AWS profile does not exist on the host: $profile"
|
|
|
|
printf 'Validating AWS profile %s...\n' "$profile" >&2
|
|
|
|
if ! aws sts get-caller-identity \
|
|
--profile "$profile" \
|
|
--output json \
|
|
>/dev/null; then
|
|
printf '\nAWS authentication failed for profile %s.\n' "$profile" >&2
|
|
printf 'Run:\n\n aws sso login --profile %q\n\n' "$profile" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Two host profiles mapping to the same sandbox name would silently write two
|
|
# sections with one identity, so reject it before any credentials are exported.
|
|
validate_profile_mapping() {
|
|
local -A claimed_by=()
|
|
local profile mapped
|
|
|
|
for profile in "$@"; do
|
|
mapped="$(sandbox_profile_name "$profile")"
|
|
|
|
if [[ -n "${claimed_by[$mapped]:-}" ]]; then
|
|
die "AWS profiles ${claimed_by[$mapped]} and $profile both map to sandbox profile $mapped"
|
|
fi
|
|
|
|
claimed_by["$mapped"]="$profile"
|
|
done
|
|
}
|
|
|
|
write_aws_files() {
|
|
load_config
|
|
|
|
local output_dir="$1"
|
|
local config_file="$output_dir/config"
|
|
local credentials_file="$output_dir/credentials"
|
|
|
|
validate_profile_mapping "${CONFIG_AWS_PROFILES[@]}"
|
|
|
|
mkdir -p "$output_dir"
|
|
chmod 700 "$output_dir"
|
|
|
|
: >"$config_file"
|
|
: >"$credentials_file"
|
|
|
|
local profile
|
|
for profile in "${CONFIG_AWS_PROFILES[@]}"; do
|
|
validate_aws_profile "$profile"
|
|
|
|
local sandbox_profile
|
|
sandbox_profile="$(sandbox_profile_name "$profile")"
|
|
|
|
local credential_json
|
|
credential_json="$(
|
|
aws configure export-credentials \
|
|
--profile "$profile" \
|
|
--format process
|
|
)"
|
|
|
|
local access_key secret_key session_token expiration region output
|
|
access_key="$(jq -er '.AccessKeyId' <<<"$credential_json")"
|
|
secret_key="$(jq -er '.SecretAccessKey' <<<"$credential_json")"
|
|
session_token="$(jq -er '.SessionToken' <<<"$credential_json")"
|
|
expiration="$(jq -er '.Expiration // empty' <<<"$credential_json" || true)"
|
|
|
|
region="$(
|
|
aws configure get region --profile "$profile" 2>/dev/null ||
|
|
true
|
|
)"
|
|
output="$(
|
|
aws configure get output --profile "$profile" 2>/dev/null ||
|
|
true
|
|
)"
|
|
|
|
region="${region:-us-east-1}"
|
|
output="${output:-json}"
|
|
|
|
cat >>"$config_file" <<EOF
|
|
[profile $sandbox_profile]
|
|
region = $region
|
|
output = $output
|
|
|
|
EOF
|
|
|
|
cat >>"$credentials_file" <<EOF
|
|
[$sandbox_profile]
|
|
aws_access_key_id = $access_key
|
|
aws_secret_access_key = $secret_key
|
|
aws_session_token = $session_token
|
|
|
|
EOF
|
|
|
|
printf 'Exported %-30s as %-30s expires %s\n' \
|
|
"$profile" \
|
|
"$sandbox_profile" \
|
|
"${expiration:-unknown}" >&2
|
|
|
|
unset credential_json access_key secret_key session_token
|
|
done
|
|
|
|
chmod 600 "$config_file" "$credentials_file"
|
|
}
|
|
|
|
install_sandbox_aws_files() {
|
|
load_config
|
|
|
|
if ((${#CONFIG_AWS_PROFILES[@]} == 0)); then
|
|
printf 'No AWS profiles configured; skipping AWS credential refresh.\n'
|
|
return
|
|
fi
|
|
|
|
require_command aws
|
|
require_command jq
|
|
|
|
local temporary_directory
|
|
temporary_directory="$(mktemp -d)"
|
|
trap 'rm -rf "$temporary_directory"' RETURN
|
|
|
|
write_aws_files "$temporary_directory"
|
|
|
|
# The agent user differs between sandbox images, so ask rather than assume.
|
|
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/.aws" && chmod 700 "$HOME/.aws"'
|
|
|
|
sbx cp \
|
|
"$temporary_directory/config" \
|
|
"$SANDBOX_NAME:$sandbox_home/.aws/config"
|
|
|
|
sbx cp \
|
|
"$temporary_directory/credentials" \
|
|
"$SANDBOX_NAME:$sandbox_home/.aws/credentials"
|
|
|
|
# Every expansion below belongs to the sandbox shell, not the host.
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c '
|
|
chmod 700 "$HOME/.aws"
|
|
chmod 600 "$HOME/.aws/config" "$HOME/.aws/credentials"
|
|
|
|
persistent=/etc/sandbox-persistent.sh
|
|
marker="# BEGIN ai-sbx AWS configuration"
|
|
|
|
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
|
|
cat >>"$persistent" <<'"'"'EOF'"'"'
|
|
# BEGIN ai-sbx AWS configuration
|
|
export AWS_CONFIG_FILE="$HOME/.aws/config"
|
|
export AWS_SHARED_CREDENTIALS_FILE="$HOME/.aws/credentials"
|
|
export AWS_SDK_LOAD_CONFIG=1
|
|
export AWS_EC2_METADATA_DISABLED=true
|
|
unset AWS_ACCESS_KEY_ID
|
|
unset AWS_SECRET_ACCESS_KEY
|
|
unset AWS_SESSION_TOKEN
|
|
unset AWS_SECURITY_TOKEN
|
|
# END ai-sbx AWS configuration
|
|
EOF
|
|
'
|
|
|
|
rm -rf "$temporary_directory"
|
|
trap - RETURN
|
|
|
|
printf 'Installed isolated AWS profiles in sandbox %s:\n' "$SANDBOX_NAME"
|
|
|
|
local profile
|
|
for profile in "${CONFIG_AWS_PROFILES[@]}"; do
|
|
printf ' %s -> %s\n' "$profile" "$(sandbox_profile_name "$profile")"
|
|
done
|
|
}
|
|
|
|
# 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.
|
|
host_marketplaces() {
|
|
jq -r '
|
|
to_entries[]
|
|
| [
|
|
.key,
|
|
(.value.source.source // ""),
|
|
(.value.source.repo // ""),
|
|
(.value.source.url // "")
|
|
]
|
|
| join("|")
|
|
' "$1"
|
|
}
|
|
|
|
host_enabled_plugins() {
|
|
jq -r '(.enabledPlugins // {}) | to_entries[] | select(.value) | .key' "$1"
|
|
}
|
|
|
|
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
|
|
# --force is mandatory: the store is shared, so a second setup finds
|
|
# skills already there and prompts per skill. With output redirected
|
|
# the prompt is invisible and setup hangs on stdin forever.
|
|
sbx skills import --force </dev/null >/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
|
|
allow_sandbox_host "$host"
|
|
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 < <(host_marketplaces "$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 < <(host_enabled_plugins "$settings")
|
|
}
|
|
|
|
# A repository declares which registry credentials it needs, but the declaration
|
|
# lives in the user's own config rather than the repository, so a checkout can
|
|
# never choose which host commands run or which secrets get resolved.
|
|
#
|
|
# VAR | host[,host...] | command printing the value on stdout
|
|
#
|
|
# The command runs on the host, from the repository root, where 1Password and
|
|
# the developer's keychain are available.
|
|
read_secret_declarations() {
|
|
local file="$REPO_CONFIG_DIR/secrets"
|
|
|
|
[[ -f "$file" ]] || return 0
|
|
|
|
local line var hosts command
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
line="${line%%#*}"
|
|
[[ -n "${line//[[:space:]]/}" ]] || continue
|
|
|
|
IFS='|' read -r var hosts command <<<"$line"
|
|
|
|
var="$(printf '%s' "$var" | xargs)"
|
|
hosts="$(printf '%s' "$hosts" | xargs)"
|
|
command="$(printf '%s' "$command" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
|
|
[[ -n "$var" && -n "$hosts" && -n "$command" ]] || {
|
|
printf 'Ignoring malformed secret declaration: %s\n' "$line" >&2
|
|
continue
|
|
}
|
|
|
|
printf '%s|%s|%s\n' "$var" "$hosts" "$command"
|
|
done <"$file"
|
|
}
|
|
|
|
# Derived rather than random so re-running setup does not invalidate the value
|
|
# already exported inside a running sandbox. The placeholder is not a secret;
|
|
# it is the stand-in the proxy swaps for one.
|
|
secret_placeholder() {
|
|
local var="$1" digest
|
|
|
|
digest="$(printf '%s' "$REPOSITORY/$var" | sha256sum | cut -c1-16)"
|
|
printf 'sbx-cs-%s' "$digest"
|
|
}
|
|
|
|
allow_sandbox_host() {
|
|
local host="$1"
|
|
|
|
[[ -n "$host" ]] || return 0
|
|
|
|
sbx policy allow network --sandbox "$SANDBOX_NAME" "$host" \
|
|
</dev/null >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# The default policy denies everything outside common development hosts, so a
|
|
# private registry is unreachable no matter what credential it holds.
|
|
install_sandbox_network() {
|
|
[[ -n "$DEFAULT_NETWORK" ]] || return 0
|
|
|
|
# A multi-line TOML string is a natural way to write a long list, and read
|
|
# stops at the first newline, so separators are flattened before splitting.
|
|
local normalized="${DEFAULT_NETWORK//,/ }"
|
|
normalized="${normalized//$'\n'/ }"
|
|
normalized="${normalized//$'\r'/ }"
|
|
|
|
local -a allow_hosts
|
|
read -r -a allow_hosts <<<"$normalized"
|
|
|
|
((${#allow_hosts[@]})) || return 0
|
|
|
|
local host
|
|
for host in "${allow_hosts[@]}"; do
|
|
allow_sandbox_host "$host"
|
|
done
|
|
|
|
printf 'Allowed network access to: %s\n' "${allow_hosts[*]}"
|
|
}
|
|
|
|
sandbox_has_docker() {
|
|
sbx exec "$SANDBOX_NAME" \
|
|
bash -lc 'command -v docker >/dev/null' \
|
|
</dev/null >/dev/null 2>&1
|
|
}
|
|
|
|
provision_secret() {
|
|
local var="$1" hosts="$2" value="$3"
|
|
local placeholder
|
|
placeholder="$(secret_placeholder "$var")"
|
|
|
|
local -a host_args=()
|
|
local host
|
|
for host in ${hosts//,/ }; do
|
|
host_args+=(--host "$host")
|
|
|
|
# A credential for a host the policy denies is dead weight: the request
|
|
# never leaves the sandbox, so the proxy never substitutes anything.
|
|
allow_sandbox_host "$host"
|
|
done
|
|
|
|
# Piped rather than --value: the secret would otherwise be visible in the
|
|
# process list to anything running as this user.
|
|
printf '%s' "$value" |
|
|
sbx secret set-custom "$SANDBOX_NAME" \
|
|
"${host_args[@]}" \
|
|
--env "$var" \
|
|
--placeholder "$placeholder" >/dev/null 2>&1 ||
|
|
{
|
|
printf 'Could not store %s in the sandbox.\n' "$var" >&2
|
|
return 1
|
|
}
|
|
|
|
# A sandbox that already exists keeps whatever environment it was created
|
|
# with, so the placeholder is exported explicitly.
|
|
sbx exec "$SANDBOX_NAME" bash -c "
|
|
persistent=/etc/sandbox-persistent.sh
|
|
marker=$(printf '%q' "# ai-sbx secret $var")
|
|
|
|
grep -Fq \"\$marker\" \"\$persistent\" 2>/dev/null && exit 0
|
|
|
|
printf '%s\nexport %s=%s\n' \
|
|
\"\$marker\" $(printf '%q' "$var") $(printf '%q' "$placeholder") \
|
|
>>\"\$persistent\"
|
|
" </dev/null >/dev/null 2>&1 || true
|
|
|
|
printf 'Provisioned %s for %s\n' "$var" "${hosts//,/, }"
|
|
}
|
|
|
|
# Credentials worth provisioning for every repository rather than declaring per
|
|
# repository, taken straight from the host environment. LocalStack only matters
|
|
# when the agent can run containers, so it is skipped where Docker is absent.
|
|
install_host_wide_secrets() {
|
|
local entry var hosts requirement
|
|
|
|
for entry in "${HOST_WIDE_SECRETS[@]}"; do
|
|
IFS='|' read -r var hosts requirement <<<"$entry"
|
|
|
|
[[ -n "${!var:-}" ]] || continue
|
|
|
|
if [[ "$requirement" == docker ]] && ! sandbox_has_docker; then
|
|
printf 'Skipping %s: the sandbox has no Docker to run it.\n' "$var" >&2
|
|
continue
|
|
fi
|
|
|
|
provision_secret "$var" "$hosts" "${!var}" || true
|
|
done
|
|
}
|
|
|
|
install_sandbox_secrets() {
|
|
install_host_wide_secrets
|
|
|
|
local declarations
|
|
declarations="$(read_secret_declarations)" || return 0
|
|
|
|
[[ -n "$declarations" ]] || return 0
|
|
|
|
local var hosts command value
|
|
|
|
while IFS='|' read -r var hosts command; do
|
|
[[ -n "$var" ]] || continue
|
|
|
|
# The resolver prints guidance to stderr naming where to obtain the
|
|
# credential, which is more useful than anything this task could add.
|
|
if ! value="$(cd "$REPO_ROOT" && eval "$command" 2>&1)"; then
|
|
printf 'Could not resolve %s:\n%s\n' "$var" "$value" >&2
|
|
continue
|
|
fi
|
|
|
|
[[ -n "$value" ]] || {
|
|
printf 'Resolver for %s printed nothing.\n' "$var" >&2
|
|
continue
|
|
}
|
|
|
|
provision_secret "$var" "$hosts" "$value" || true
|
|
|
|
unset value
|
|
done <<<"$declarations"
|
|
}
|
|
|
|
# Plugins bring their own runtime requirements - claude-mem and others run
|
|
# their hooks under bun, which the sandbox image does not carry - and a missing
|
|
# one surfaces as a hook error on every prompt rather than at install time.
|
|
install_sandbox_tools() {
|
|
local sandbox_home="$1"
|
|
|
|
[[ -n "$DEFAULT_TOOLS" ]] || return 0
|
|
|
|
local -a tools
|
|
read -r -a tools <<<"$DEFAULT_TOOLS"
|
|
|
|
((${#tools[@]})) || return 0
|
|
|
|
printf 'Installing sandbox tools: %s\n' "${tools[*]}"
|
|
|
|
# mise resolves these from GitHub releases, which the default network
|
|
# policy already allows. $HOME and $@ belong to the sandbox shell.
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -lc '
|
|
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"
|
|
mise use -g "$@" && mise reshim
|
|
' _ "${tools[@]}" </dev/null >/dev/null 2>&1 ||
|
|
printf 'Could not install sandbox tools: %s\n' "${tools[*]}" >&2
|
|
}
|
|
|
|
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
|
|
|
|
install_sandbox_tools "$sandbox_home"
|
|
|
|
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
|
|
'
|
|
}
|
|
|
|
# A sandbox image ships without a locale, which leaves LC_CTYPE at POSIX. Every
|
|
# multibyte glyph then degrades to a placeholder: Nerd Font icons in the editor
|
|
# render as underscores, and bash printf emits \uXXXX escapes literally. LANG
|
|
# alone is enough, and leaves a user free to override individual categories.
|
|
install_sandbox_locale() {
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c '
|
|
persistent=/etc/sandbox-persistent.sh
|
|
marker="# BEGIN ai-sbx locale"
|
|
|
|
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
|
|
# locale -a spells these inconsistently across distributions, so probe
|
|
# each one for usability rather than matching its name.
|
|
for candidate in C.UTF-8 en_US.UTF-8; do
|
|
if LC_ALL="$candidate" locale >/dev/null 2>&1; then
|
|
chosen="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
|
|
[[ -n "${chosen:-}" ]] || exit 0
|
|
|
|
cat >>"$persistent" <<EOF
|
|
# BEGIN ai-sbx locale
|
|
export LANG=$chosen
|
|
# END ai-sbx locale
|
|
EOF
|
|
' </dev/null >/dev/null 2>&1 || true
|
|
}
|
|
|
|
validate_launch_mode() {
|
|
case "$1" in
|
|
agent | tmux) ;;
|
|
*)
|
|
die "Unknown launch mode: $1 (expected agent or tmux)"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# The image may already carry the launcher. Its copy is built from this same
|
|
# file, so the two cannot drift, and skipping keeps a custom image authoritative
|
|
# about its own contents.
|
|
install_sandbox_workspace() {
|
|
local launcher
|
|
launcher="$(dirname "${BASH_SOURCE[0]}")/workspace"
|
|
|
|
[[ -f "$launcher" ]] ||
|
|
die "Workspace launcher is missing: $launcher"
|
|
|
|
if sbx exec "$SANDBOX_NAME" \
|
|
bash -c '[[ -x /usr/local/bin/ai-sbx-workspace ]]' \
|
|
</dev/null >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
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/.local/bin"'
|
|
|
|
sbx cp "$launcher" "$SANDBOX_NAME:$sandbox_home/.local/bin/ai-sbx-workspace"
|
|
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c 'chmod 755 "$HOME/.local/bin/ai-sbx-workspace"'
|
|
}
|
|
|
|
# One target path per line, relative to the home directory. Comments and blank
|
|
# lines are ignored.
|
|
read_dotfiles_allowlist() {
|
|
local file="$CONFIG_ROOT/dotfiles"
|
|
|
|
[[ -f "$file" ]] ||
|
|
die "AI_SBX_DOTFILES is set but $file does not exist. List one target path per line, for example .config/nvim"
|
|
|
|
local line
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
line="${line%%#*}"
|
|
line="$(printf '%s' "$line" | xargs)"
|
|
|
|
[[ -n "$line" ]] || continue
|
|
|
|
printf '%s\n' "$line"
|
|
done <"$file"
|
|
}
|
|
|
|
# chezmoi archive decrypts as it renders, so an encrypted file inside the
|
|
# allowlist would arrive in the sandbox as plaintext credentials - defeating the
|
|
# proxy-injected GitHub token in a single step. A denylist would rot as new
|
|
# encrypted files appear, and the failure mode is silent, so refuse instead.
|
|
assert_no_encrypted_targets() {
|
|
local source_dir
|
|
source_dir="$(chezmoi source-path)" ||
|
|
die "Could not determine the chezmoi source directory."
|
|
|
|
local encrypted target allowed
|
|
while IFS= read -r encrypted; do
|
|
[[ -n "$encrypted" ]] || continue
|
|
|
|
target="$(chezmoi target-path "$encrypted" 2>/dev/null)" || continue
|
|
target="${target#"$HOME/"}"
|
|
|
|
for allowed in "$@"; do
|
|
[[ "$target" == "$allowed" || "$target" == "$allowed"/* ]] ||
|
|
continue
|
|
|
|
die "Dotfiles allowlist entry $allowed contains the encrypted file $target, which chezmoi would render in plaintext. Narrow $CONFIG_ROOT/dotfiles."
|
|
done
|
|
done < <(find "$source_dir" -type f -name '*encrypted_*' 2>/dev/null)
|
|
}
|
|
|
|
scan_dotfiles_archive() {
|
|
local archive="$1" pattern
|
|
|
|
for pattern in "${DOTFILES_CREDENTIAL_PATTERNS[@]}"; do
|
|
grep -aEq -- "$pattern" "$archive" ||
|
|
continue
|
|
|
|
die "The rendered dotfiles archive contains something shaped like a credential (matching /$pattern/). Narrow $CONFIG_ROOT/dotfiles."
|
|
done
|
|
}
|
|
|
|
# Rendered on the host, where the age identity already lives, and copied in as a
|
|
# tar. The sandbox needs no dotfiles repository, no key and no network for this.
|
|
install_sandbox_dotfiles() {
|
|
[[ -n "$DEFAULT_DOTFILES" ]] || return 0
|
|
|
|
[[ "$DEFAULT_DOTFILES" == chezmoi ]] ||
|
|
die "Unknown AI_SBX_DOTFILES value: $DEFAULT_DOTFILES (expected chezmoi)"
|
|
|
|
require_command chezmoi
|
|
|
|
local -a targets
|
|
mapfile -t targets < <(read_dotfiles_allowlist)
|
|
|
|
((${#targets[@]})) ||
|
|
die "The dotfiles allowlist $CONFIG_ROOT/dotfiles is empty."
|
|
|
|
assert_no_encrypted_targets "${targets[@]}"
|
|
|
|
local -a target_paths=()
|
|
local target
|
|
for target in "${targets[@]}"; do
|
|
target_paths+=("$HOME/$target")
|
|
done
|
|
|
|
local archive
|
|
archive="$(mktemp)"
|
|
trap 'rm -f "$archive"' RETURN
|
|
|
|
# DEV_CONTAINER=1 is required, not cosmetic: .chezmoi.toml.tmpl branches on
|
|
# it to disable git.autoCommit and git.autoPush, and without it an agent in
|
|
# the sandbox could push to the dotfiles repository.
|
|
DEV_CONTAINER=1 chezmoi archive --format tar "${target_paths[@]}" >"$archive" ||
|
|
die "chezmoi could not render the dotfiles archive."
|
|
|
|
scan_dotfiles_archive "$archive"
|
|
|
|
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."
|
|
|
|
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" <"$archive" ||
|
|
die "Could not unpack the dotfiles archive in $SANDBOX_NAME."
|
|
|
|
rm -f "$archive"
|
|
trap - RETURN
|
|
|
|
printf 'Installed dotfiles into %s: %s\n' "$SANDBOX_NAME" "${targets[*]}"
|
|
}
|
|
|
|
create_sandbox() {
|
|
load_config
|
|
|
|
local -a create_args=(
|
|
create
|
|
--name "$SANDBOX_NAME"
|
|
)
|
|
|
|
# Clone mode gives the agent its own in-container clone, so its commits
|
|
# never land on whatever the host has checked out.
|
|
if [[ "$CONFIG_MODE" == "clone" ]]; then
|
|
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"
|
|
)
|
|
|
|
sbx "${create_args[@]}"
|
|
}
|
|
|
|
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
|
|
--aws-profile)
|
|
(($# >= 2)) || die "--aws-profile requires a value"
|
|
aws_profiles+=("$2")
|
|
shift 2
|
|
;;
|
|
--agent)
|
|
(($# >= 2)) || die "--agent requires a value"
|
|
agent="$2"
|
|
shift 2
|
|
;;
|
|
--clone)
|
|
mode="clone"
|
|
shift
|
|
;;
|
|
--direct)
|
|
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
|
|
;;
|
|
-h | --help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "Unknown setup option: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
validate_profile_mapping "${aws_profiles[@]}"
|
|
|
|
local profile
|
|
for profile in "${aws_profiles[@]}"; do
|
|
validate_aws_profile "$profile"
|
|
done
|
|
|
|
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
|
|
printf 'Removing existing sandbox %s...\n' "$SANDBOX_NAME"
|
|
sbx rm --force "$SANDBOX_NAME" </dev/null
|
|
else
|
|
printf 'Using existing sandbox %s.\n' "$SANDBOX_NAME"
|
|
fi
|
|
fi
|
|
|
|
if ! sandbox_exists; then
|
|
printf 'Creating sandbox %s for %s...\n' \
|
|
"$SANDBOX_NAME" "$REPOSITORY"
|
|
create_sandbox
|
|
fi
|
|
|
|
# The secret store outlives the sandbox, so recreating one to change its
|
|
# image keeps the token. Prompting anyway would train the habit of minting
|
|
# replacement tokens and never revoking the old ones.
|
|
if sandbox_has_github_token; then
|
|
printf 'Keeping the GitHub token already stored for %s. Replace it with: mise run ai:sbx -- token\n' \
|
|
"$SANDBOX_NAME"
|
|
else
|
|
install_github_token
|
|
fi
|
|
|
|
install_sandbox_aws_files
|
|
|
|
install_sandbox_claude_config
|
|
|
|
install_sandbox_dotfiles
|
|
|
|
install_sandbox_network
|
|
|
|
install_sandbox_secrets
|
|
|
|
install_sandbox_mise
|
|
|
|
cat <<EOF
|
|
|
|
Setup complete.
|
|
|
|
Repository: $REPOSITORY
|
|
Sandbox: $SANDBOX_NAME
|
|
Agent: $agent
|
|
Mode: $mode
|
|
|
|
Run it with:
|
|
|
|
mise run ai:sbx -- run
|
|
EOF
|
|
}
|
|
|
|
token_command() {
|
|
load_config
|
|
|
|
sandbox_exists ||
|
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
|
|
|
install_github_token
|
|
}
|
|
|
|
refresh_command() {
|
|
load_config
|
|
|
|
sandbox_exists ||
|
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
|
|
|
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
|
|
|
|
install_sandbox_dotfiles
|
|
|
|
install_sandbox_network
|
|
|
|
install_sandbox_secrets
|
|
}
|
|
|
|
run_command() {
|
|
local launch="$DEFAULT_LAUNCH"
|
|
|
|
# Everything after -- belongs to the agent, including anything that looks
|
|
# like an option of this task.
|
|
while (($#)); do
|
|
case "$1" in
|
|
--launch)
|
|
(($# >= 2)) || die "--launch requires a value"
|
|
launch="$2"
|
|
shift 2
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
validate_launch_mode "$launch"
|
|
|
|
if [[ "$launch" == tmux ]] && (($#)); then
|
|
die "Agent arguments are only supported with --launch agent; got: $*"
|
|
fi
|
|
|
|
load_config
|
|
|
|
sandbox_exists ||
|
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
|
|
|
# The GitHub token is long-lived and stays in the sbx secret store; only
|
|
# 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
|
|
|
|
install_sandbox_locale
|
|
|
|
if [[ "$launch" == tmux ]]; then
|
|
install_sandbox_workspace
|
|
|
|
# bash -lc is mandatory: /etc/sandbox-persistent.sh is where PATH, the
|
|
# mise shims, the AWS credentials and every secret placeholder live, and
|
|
# the tmux server inherits its environment from this shell.
|
|
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
|
|
bash -lc "ai-sbx-workspace $(printf '%q' "$CONFIG_AGENT")"
|
|
fi
|
|
|
|
if (($#)); then
|
|
exec sbx run "$SANDBOX_NAME" -- "$@"
|
|
else
|
|
exec sbx run "$SANDBOX_NAME"
|
|
fi
|
|
}
|
|
|
|
status_command() {
|
|
load_config
|
|
|
|
printf 'Repository: %s\n' "$REPOSITORY"
|
|
printf 'Root: %s\n' "$REPO_ROOT"
|
|
printf 'Sandbox: %s\n' "$SANDBOX_NAME"
|
|
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'
|
|
if ((${#CONFIG_AWS_PROFILES[@]})); then
|
|
local profile
|
|
for profile in "${CONFIG_AWS_PROFILES[@]}"; do
|
|
printf ' %s -> %s\n' "$profile" "$(sandbox_profile_name "$profile")"
|
|
done
|
|
else
|
|
printf ' none\n'
|
|
fi
|
|
|
|
printf 'Sandbox exists: '
|
|
if sandbox_exists; then
|
|
printf 'yes\n'
|
|
else
|
|
printf 'no\n'
|
|
fi
|
|
|
|
printf '\nConfigured sandbox secrets:\n'
|
|
sbx secret ls
|
|
}
|
|
|
|
remove_command() {
|
|
load_config
|
|
|
|
if sandbox_exists; then
|
|
sbx rm --force "$SANDBOX_NAME" </dev/null
|
|
fi
|
|
|
|
rm -rf "$REPO_CONFIG_DIR"
|
|
|
|
printf 'Removed sandbox and local configuration for %s.\n' "$REPOSITORY"
|
|
}
|
|
|
|
main() {
|
|
local command="${1:-}"
|
|
if (($#)); then
|
|
shift
|
|
fi
|
|
|
|
# These work outside a repository and without the sandbox toolchain.
|
|
case "$command" in
|
|
-h | --help | help | "")
|
|
usage
|
|
return
|
|
;;
|
|
esac
|
|
|
|
require_command git
|
|
require_command sbx
|
|
require_command sha256sum
|
|
|
|
repository_context
|
|
|
|
case "$command" in
|
|
setup)
|
|
setup_command "$@"
|
|
;;
|
|
token)
|
|
token_command "$@"
|
|
;;
|
|
refresh)
|
|
refresh_command "$@"
|
|
;;
|
|
config)
|
|
config_command "$@"
|
|
;;
|
|
run)
|
|
run_command "$@"
|
|
;;
|
|
status)
|
|
status_command "$@"
|
|
;;
|
|
remove)
|
|
remove_command "$@"
|
|
;;
|
|
*)
|
|
die "Unknown command: $command"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Sourcing the task exposes its functions for tests without running a command.
|
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
main "$@"
|
|
fi
|