671 lines
20 KiB
Bash
671 lines
20 KiB
Bash
#!/usr/bin/env bash
|
|
# asandbox — run any CLI coding agent inside Docker with host FS integration
|
|
# Works on macOS (Docker Desktop / OrbStack) and Linux.
|
|
set -euo pipefail
|
|
|
|
VERSION="1.1.0"
|
|
# Program root = folder where this script lives (e.g. /Users/macbook/asandbox)
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
IMAGE_NAME="${ASANDBOX_IMAGE:-asandbox:latest}"
|
|
SHARE_DIR="${ASANDBOX_SHARE:-${ROOT_DIR}}"
|
|
CONFIG_DIR="${ASANDBOX_CONFIG_DIR:-${ROOT_DIR}/config}"
|
|
CONFIG_FILE="${CONFIG_DIR}/config.env"
|
|
DEFAULT_NETWORK="${ASANDBOX_NETWORK:-bridge}"
|
|
DEFAULT_MEMORY="${ASANDBOX_MEMORY:-4g}"
|
|
DEFAULT_CPUS="${ASANDBOX_CPUS:-4}"
|
|
|
|
# ── colors ──────────────────────────────────────────────────────────
|
|
if [[ -t 2 ]]; then
|
|
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YLW=$'\033[33m'
|
|
C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'; C_BLD=$'\033[1m'
|
|
else
|
|
C_RED=; C_GRN=; C_YLW=; C_BLU=; C_DIM=; C_RST=; C_BLD=
|
|
fi
|
|
|
|
die() { echo "${C_RED}asandbox:${C_RST} $*" >&2; exit 1; }
|
|
info() { echo "${C_BLU}asandbox:${C_RST} $*" >&2; }
|
|
ok() { echo "${C_GRN}asandbox:${C_RST} $*" >&2; }
|
|
warn() { echo "${C_YLW}asandbox:${C_RST} $*" >&2; }
|
|
|
|
# ── usage ───────────────────────────────────────────────────────────
|
|
usage() {
|
|
cat <<'EOF'
|
|
asandbox — run CLI AI agents in Docker with host filesystem integration
|
|
|
|
USAGE
|
|
asandbox <command> [options] [--] [agent-args...]
|
|
|
|
COMMANDS
|
|
run <agent|cmd...> Run an agent or arbitrary command in the sandbox
|
|
shell Interactive bash inside the sandbox
|
|
installagent <name> Install an agent inside the sandbox (claude, cursor, …)
|
|
build Build (or rebuild) the sandbox Docker image
|
|
agents List known agent presets / installagent catalog
|
|
config Show / create config
|
|
doctor Check Docker, image, mounts
|
|
version Print version
|
|
help Show this help
|
|
|
|
OPTIONS (run / shell)
|
|
-w, --workdir DIR Host directory to mount (default: git root or cwd)
|
|
-n, --name NAME Container name (default: auto)
|
|
--network MODE none | bridge | host (default: bridge)
|
|
--memory SIZE Memory limit (default: 4g)
|
|
--cpus N CPU limit (default: 4)
|
|
--read-only Read-only root filesystem (+tmpfs /tmp)
|
|
--no-mount-home Do not mount agent config dirs from host home
|
|
--mount-ssh Mount ~/.ssh (read-only) for git over SSH
|
|
--mount-gitconfig Mount ~/.gitconfig (read-only)
|
|
--mount PATH[:CONT] Extra bind mount (repeatable). CONT defaults to PATH
|
|
--env KEY=VAL Pass env var (repeatable)
|
|
--env-file FILE Pass env file
|
|
--pass-env KEY Pass host env KEY if set (repeatable)
|
|
--image NAME Override image (default: asandbox:latest)
|
|
--rm / --keep Remove container on exit (default) / keep it
|
|
--detach, -d Run in background
|
|
-q, --quiet Less noise
|
|
--dry-run Print docker command and exit
|
|
|
|
AGENT PRESETS / installagent
|
|
claude, codex, gemini, antigravity (agy), cursor (agent),
|
|
opencode, aider, qwen, amp, goose, continue, bash
|
|
Inside shell: installagent list | installagent claude
|
|
Or any command: asandbox run -- node script.js
|
|
|
|
EXAMPLES
|
|
asandbox build
|
|
asandbox shell
|
|
asandbox installagent list
|
|
asandbox installagent claude
|
|
asandbox installagent cursor
|
|
asandbox installagent antigravity
|
|
asandbox installagent opencode
|
|
asandbox run claude
|
|
asandbox run claude "explain this project"
|
|
asandbox run -w ~/proj codex
|
|
asandbox run --network none --read-only -- rg "TODO"
|
|
asandbox run --mount-ssh -- git pull
|
|
asandbox run --env ANTHROPIC_API_KEY=sk-... claude
|
|
|
|
SECURITY
|
|
• No Docker socket is mounted (agents cannot escape via Docker).
|
|
• Runs as non-root user inside the container.
|
|
• Capabilities dropped; no-new-privileges enabled.
|
|
• Host project directory is bind-mounted (same absolute path on Mac).
|
|
• Agent configs (~/.claude, ~/.codex, …) mounted so auth/state persist.
|
|
• Use --network none for fully offline runs when possible.
|
|
|
|
CONFIG
|
|
Program folder: same directory as this script (default ~/asandbox)
|
|
config/config.env optional defaults
|
|
Dockerfile, installagent, persist/ live next to the script
|
|
|
|
EOF
|
|
}
|
|
|
|
# ── config ──────────────────────────────────────────────────────────
|
|
load_config() {
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
# shellcheck disable=SC1090
|
|
set -a; source "$CONFIG_FILE"; set +a
|
|
fi
|
|
}
|
|
|
|
write_default_config() {
|
|
mkdir -p "$CONFIG_DIR"
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
info "config already exists: $CONFIG_FILE"
|
|
return 0
|
|
fi
|
|
cat > "$CONFIG_FILE" <<'EOF'
|
|
# asandbox defaults (bash-compatible env file)
|
|
# ASANDBOX_IMAGE=asandbox:latest
|
|
# ASANDBOX_NETWORK=bridge
|
|
# ASANDBOX_MEMORY=4g
|
|
# ASANDBOX_CPUS=4
|
|
# ASANDBOX_MOUNT_SSH=0
|
|
# ASANDBOX_MOUNT_GITCONFIG=1
|
|
# ASANDBOX_QUIET=0
|
|
|
|
# Extra env keys always forwarded when set on host:
|
|
# ASANDBOX_PASS_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GOOGLE_API_KEY XAI_API_KEY"
|
|
EOF
|
|
ok "wrote $CONFIG_FILE"
|
|
}
|
|
|
|
# ── helpers ─────────────────────────────────────────────────────────
|
|
require_docker() {
|
|
command -v docker >/dev/null 2>&1 || die "docker not found. Install Docker Desktop or OrbStack."
|
|
docker info >/dev/null 2>&1 || die "Docker daemon not running. Start Docker Desktop / OrbStack."
|
|
}
|
|
|
|
image_exists() {
|
|
docker image inspect "$IMAGE_NAME" >/dev/null 2>&1
|
|
}
|
|
|
|
resolve_workdir() {
|
|
local dir="${1:-}"
|
|
if [[ -n "$dir" ]]; then
|
|
(cd "$dir" && pwd -P)
|
|
return
|
|
fi
|
|
# Prefer git root when inside a repo
|
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
git rev-parse --show-toplevel
|
|
return
|
|
fi
|
|
pwd -P
|
|
}
|
|
|
|
# Map agent name → argv inside container
|
|
resolve_agent_cmd() {
|
|
local agent="$1"
|
|
shift || true
|
|
case "$agent" in
|
|
claude|claude-code)
|
|
echo "claude"; printf '%s\n' "$@"
|
|
;;
|
|
codex|openai-codex)
|
|
echo "codex"; printf '%s\n' "$@"
|
|
;;
|
|
gemini|gemini-cli)
|
|
echo "gemini"; printf '%s\n' "$@"
|
|
;;
|
|
antigravity|agy|antigravity-cli)
|
|
echo "agy"; printf '%s\n' "$@"
|
|
;;
|
|
cursor|cursor-cli|cursor-agent)
|
|
echo "agent"; printf '%s\n' "$@"
|
|
;;
|
|
opencode|open-code)
|
|
echo "opencode"; printf '%s\n' "$@"
|
|
;;
|
|
aider|aider-chat)
|
|
echo "aider"; printf '%s\n' "$@"
|
|
;;
|
|
qwen|qwen-code)
|
|
echo "qwen"; printf '%s\n' "$@"
|
|
;;
|
|
amp)
|
|
echo "amp"; printf '%s\n' "$@"
|
|
;;
|
|
goose)
|
|
echo "goose"; printf '%s\n' "$@"
|
|
;;
|
|
continue|cn)
|
|
echo "cn"; printf '%s\n' "$@"
|
|
;;
|
|
installagent|install-agent)
|
|
echo "installagent"; printf '%s\n' "$@"
|
|
;;
|
|
bash|sh|shell)
|
|
if [[ $# -eq 0 ]]; then
|
|
echo "bash"; echo "-l"
|
|
else
|
|
echo "bash"; echo "-lc"; echo "$*"
|
|
fi
|
|
;;
|
|
*)
|
|
# Treat first token as command
|
|
echo "$agent"; printf '%s\n' "$@"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Known host config dirs to bind-mount (host → container path under $HOME)
|
|
agent_home_mounts() {
|
|
# format: host_relpath
|
|
cat <<'EOF'
|
|
.claude
|
|
.codex
|
|
.config/gh
|
|
.gemini
|
|
.grok
|
|
.cursor
|
|
.local/share/claude
|
|
.npm
|
|
.antigravity
|
|
.agy
|
|
.config/opencode
|
|
.config/aider
|
|
EOF
|
|
}
|
|
|
|
# Persistent install roots (survive --rm containers)
|
|
ensure_persist_dirs() {
|
|
mkdir -p \
|
|
"${SHARE_DIR}/persist/.local/bin" \
|
|
"${SHARE_DIR}/persist/.npm-global/bin" \
|
|
"${SHARE_DIR}/persist/.cache"
|
|
}
|
|
|
|
# Env keys commonly needed by agents
|
|
default_pass_env_keys() {
|
|
echo "ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL GEMINI_API_KEY GOOGLE_API_KEY GOOGLE_GENAI_API_KEY XAI_API_KEY GROK_API_KEY CURSOR_API_KEY GITHUB_TOKEN GH_TOKEN TERM COLORTERM LANG LC_ALL EDITOR"
|
|
}
|
|
|
|
# ── build ───────────────────────────────────────────────────────────
|
|
cmd_build() {
|
|
require_docker
|
|
local no_cache=0 force=0
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--no-cache) no_cache=1; shift ;;
|
|
--force|-f) force=1; shift ;;
|
|
-h|--help) echo "Usage: asandbox build [--no-cache] [--force]"; return 0 ;;
|
|
*) die "unknown build option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
[[ -f "$SHARE_DIR/Dockerfile" ]] || die "Dockerfile not found at $SHARE_DIR/Dockerfile"
|
|
|
|
local args=(build -t "$IMAGE_NAME" -f "$SHARE_DIR/Dockerfile" "$SHARE_DIR")
|
|
if [[ $no_cache -eq 1 ]]; then
|
|
args+=(--no-cache)
|
|
fi
|
|
|
|
# Pass host UID/GID so files created in mounts match macOS user
|
|
args+=(--build-arg "USER_UID=$(id -u)" --build-arg "USER_GID=$(id -g)")
|
|
|
|
info "building image ${C_BLD}${IMAGE_NAME}${C_RST} from $SHARE_DIR"
|
|
docker "${args[@]}"
|
|
ok "image ready: $IMAGE_NAME"
|
|
}
|
|
|
|
# ── doctor ──────────────────────────────────────────────────────────
|
|
cmd_doctor() {
|
|
echo "${C_BLD}asandbox doctor${C_RST} (v$VERSION)"
|
|
echo
|
|
|
|
if command -v docker >/dev/null 2>&1; then
|
|
ok "docker CLI: $(command -v docker)"
|
|
else
|
|
die "docker CLI missing"
|
|
fi
|
|
|
|
if docker info >/dev/null 2>&1; then
|
|
local ctx
|
|
ctx=$(docker context show 2>/dev/null || echo unknown)
|
|
ok "docker daemon: running (context: $ctx)"
|
|
else
|
|
die "docker daemon not running"
|
|
fi
|
|
|
|
if image_exists; then
|
|
ok "image: $IMAGE_NAME present"
|
|
else
|
|
warn "image: $IMAGE_NAME missing — run: asandbox build"
|
|
fi
|
|
|
|
echo
|
|
echo "paths:"
|
|
echo " share: $SHARE_DIR"
|
|
echo " config: $CONFIG_FILE"
|
|
echo " home: $HOME"
|
|
echo " cwd: $(pwd -P)"
|
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
echo " git: $(git rev-parse --show-toplevel)"
|
|
fi
|
|
echo
|
|
echo "platform: $(uname -s)/$(uname -m)"
|
|
echo "uid/gid: $(id -u):$(id -g)"
|
|
|
|
if [[ -f "$SHARE_DIR/Dockerfile" ]]; then
|
|
ok "Dockerfile found"
|
|
else
|
|
warn "Dockerfile missing at $SHARE_DIR"
|
|
fi
|
|
}
|
|
|
|
# ── agents list ─────────────────────────────────────────────────────
|
|
cmd_agents() {
|
|
cat <<'EOF'
|
|
Agent presets (run via asandbox <name> or asandbox run <name>):
|
|
|
|
claude Anthropic Claude Code
|
|
codex OpenAI Codex CLI
|
|
gemini Google Gemini CLI
|
|
antigravity Google Antigravity CLI (binary: agy)
|
|
cursor Cursor Agent CLI (binary: agent)
|
|
opencode OpenCode
|
|
aider Aider
|
|
qwen Qwen Code
|
|
amp Sourcegraph Amp
|
|
goose Block Goose
|
|
continue Continue CLI (binary: cn)
|
|
bash Interactive shell
|
|
|
|
Install inside sandbox (persisted under <program>/persist):
|
|
|
|
asandbox installagent list
|
|
asandbox installagent claude
|
|
asandbox installagent cursor
|
|
asandbox installagent antigravity
|
|
asandbox installagent opencode
|
|
asandbox installagent all
|
|
|
|
# or inside shell:
|
|
asandbox shell
|
|
$ installagent list
|
|
$ installagent gemini
|
|
|
|
Host config dirs auto-mounted (if present):
|
|
~/.claude ~/.codex ~/.gemini ~/.grok ~/.cursor ~/.config/gh
|
|
~/.antigravity ~/.agy ~/.config/opencode
|
|
EOF
|
|
}
|
|
|
|
# ── installagent (host wrapper → container) ─────────────────────────
|
|
cmd_installagent() {
|
|
if [[ $# -eq 0 ]]; then
|
|
set -- list
|
|
fi
|
|
# Run installagent inside sandbox with network (needed to download)
|
|
cmd_run -q --network bridge -- installagent "$@"
|
|
}
|
|
|
|
# ── run / shell ─────────────────────────────────────────────────────
|
|
cmd_run() {
|
|
require_docker
|
|
|
|
local workdir="" container_name="" network="$DEFAULT_NETWORK"
|
|
local memory="$DEFAULT_MEMORY" cpus="$DEFAULT_CPUS"
|
|
local read_only=0 mount_home=1 mount_ssh=0 mount_gitconfig=1
|
|
local rm_flag=1 detach=0 quiet=0 dry_run=0
|
|
local -a extra_mounts=() env_args=() pass_envs=() env_files=()
|
|
local -a raw_cmd=()
|
|
|
|
# defaults from env/config
|
|
[[ "${ASANDBOX_MOUNT_SSH:-0}" == "1" ]] && mount_ssh=1
|
|
[[ "${ASANDBOX_MOUNT_GITCONFIG:-1}" == "0" ]] && mount_gitconfig=0
|
|
[[ "${ASANDBOX_QUIET:-0}" == "1" ]] && quiet=1
|
|
|
|
# Parse ASANDBOX_PASS_ENV
|
|
if [[ -n "${ASANDBOX_PASS_ENV:-}" ]]; then
|
|
# shellcheck disable=SC2206
|
|
pass_envs+=($ASANDBOX_PASS_ENV)
|
|
fi
|
|
# shellcheck disable=SC2206
|
|
pass_envs+=($(default_pass_env_keys))
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-w|--workdir) workdir="${2:-}"; shift 2 ;;
|
|
-n|--name) container_name="${2:-}"; shift 2 ;;
|
|
--network) network="${2:-}"; shift 2 ;;
|
|
--memory) memory="${2:-}"; shift 2 ;;
|
|
--cpus) cpus="${2:-}"; shift 2 ;;
|
|
--read-only) read_only=1; shift ;;
|
|
--no-mount-home) mount_home=0; shift ;;
|
|
--mount-ssh) mount_ssh=1; shift ;;
|
|
--mount-gitconfig) mount_gitconfig=1; shift ;;
|
|
--no-mount-gitconfig) mount_gitconfig=0; shift ;;
|
|
--mount)
|
|
extra_mounts+=("${2:-}")
|
|
shift 2
|
|
;;
|
|
--env|-e)
|
|
env_args+=(--env "${2:-}")
|
|
shift 2
|
|
;;
|
|
--env-file)
|
|
env_files+=(--env-file "${2:-}")
|
|
shift 2
|
|
;;
|
|
--pass-env)
|
|
pass_envs+=("${2:-}")
|
|
shift 2
|
|
;;
|
|
--image) IMAGE_NAME="${2:-}"; shift 2 ;;
|
|
--rm) rm_flag=1; shift ;;
|
|
--keep) rm_flag=0; shift ;;
|
|
-d|--detach) detach=1; shift ;;
|
|
-q|--quiet) quiet=1; shift ;;
|
|
--dry-run) dry_run=1; shift ;;
|
|
-h|--help) usage; return 0 ;;
|
|
--) shift; raw_cmd+=("$@"); break ;;
|
|
-*)
|
|
die "unknown option: $1 (try --help)"
|
|
;;
|
|
*)
|
|
raw_cmd+=("$@")
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if ! image_exists; then
|
|
warn "image $IMAGE_NAME not found — building now..."
|
|
cmd_build
|
|
fi
|
|
|
|
local host_wd
|
|
host_wd=$(resolve_workdir "$workdir")
|
|
[[ -d "$host_wd" ]] || die "workdir does not exist: $host_wd"
|
|
|
|
# Resolve command
|
|
local -a cmd=()
|
|
if [[ ${#raw_cmd[@]} -eq 0 ]]; then
|
|
cmd=(bash)
|
|
else
|
|
# read resolve_agent_cmd output into array
|
|
local line
|
|
while IFS= read -r line; do
|
|
cmd+=("$line")
|
|
done < <(resolve_agent_cmd "${raw_cmd[@]}")
|
|
fi
|
|
|
|
# Unique name
|
|
if [[ -z "$container_name" ]]; then
|
|
local base slug
|
|
base=$(basename "$host_wd" | tr -c 'a-zA-Z0-9._-' '-' | tr '[:upper:]' '[:lower:]')
|
|
slug=$(echo "${cmd[0]}" | tr -c 'a-zA-Z0-9' '-')
|
|
container_name="asandbox-${base}-${slug}-$$"
|
|
# docker name length limit
|
|
container_name="${container_name:0:60}"
|
|
fi
|
|
|
|
local -a docker_args=(
|
|
run
|
|
--name "$container_name"
|
|
--hostname "asandbox"
|
|
--network "$network"
|
|
--memory "$memory"
|
|
--cpus "$cpus"
|
|
--security-opt no-new-privileges:true
|
|
--cap-drop ALL
|
|
--cap-add CHOWN
|
|
--cap-add SETUID
|
|
--cap-add SETGID
|
|
--cap-add DAC_OVERRIDE
|
|
--cap-add FOWNER
|
|
--cap-add NET_BIND_SERVICE
|
|
# Interactive when stdin is a TTY and not detached
|
|
)
|
|
|
|
if [[ $rm_flag -eq 1 ]]; then
|
|
docker_args+=(--rm)
|
|
fi
|
|
if [[ $detach -eq 1 ]]; then
|
|
docker_args+=(-d)
|
|
else
|
|
docker_args+=(-i)
|
|
if [[ -t 0 && -t 1 ]]; then
|
|
docker_args+=(-t)
|
|
fi
|
|
fi
|
|
if [[ $read_only -eq 1 ]]; then
|
|
docker_args+=(--read-only --tmpfs /tmp:rw,exec,nosuid,size=1g --tmpfs /home/agent:rw,exec,nosuid,size=512m)
|
|
fi
|
|
|
|
# ── filesystem integration ──────────────────────────────────────
|
|
# 1) Mount workspace at the SAME absolute path (critical for Mac path fidelity)
|
|
docker_args+=(-v "${host_wd}:${host_wd}:rw")
|
|
# 2) Also expose as /workspace for convenience
|
|
docker_args+=(-v "${host_wd}:/workspace:rw")
|
|
docker_args+=(-w "$host_wd")
|
|
docker_args+=(-e "ASANDBOX_WORKDIR=$host_wd")
|
|
docker_args+=(-e "ASANDBOX_HOST_OS=$(uname -s)")
|
|
docker_args+=(-e "ASANDBOX_QUIET=$quiet")
|
|
|
|
# 2b) Persist installagent packages across container runs
|
|
ensure_persist_dirs
|
|
docker_args+=(-v "${SHARE_DIR}/persist/.local:/home/agent/.local:rw")
|
|
docker_args+=(-v "${SHARE_DIR}/persist/.npm-global:/home/agent/.npm-global:rw")
|
|
docker_args+=(-v "${SHARE_DIR}/persist/.cache:/home/agent/.cache:rw")
|
|
|
|
# 3) Agent config / state from host home
|
|
if [[ $mount_home -eq 1 ]]; then
|
|
local rel host_path cont_path
|
|
while IFS= read -r rel; do
|
|
[[ -z "$rel" ]] && continue
|
|
host_path="${HOME}/${rel}"
|
|
cont_path="/home/agent/${rel}"
|
|
if [[ -e "$host_path" ]]; then
|
|
# ensure parent exists in container via mount of the path itself
|
|
docker_args+=(-v "${host_path}:${cont_path}:rw")
|
|
fi
|
|
done < <(agent_home_mounts)
|
|
fi
|
|
|
|
# 4) SSH keys (optional, read-only)
|
|
if [[ $mount_ssh -eq 1 && -d "${HOME}/.ssh" ]]; then
|
|
docker_args+=(-v "${HOME}/.ssh:/home/agent/.ssh:ro")
|
|
fi
|
|
|
|
# 5) gitconfig
|
|
if [[ $mount_gitconfig -eq 1 && -f "${HOME}/.gitconfig" ]]; then
|
|
docker_args+=(-v "${HOME}/.gitconfig:/home/agent/.gitconfig:ro")
|
|
fi
|
|
|
|
# 6) Extra mounts
|
|
local m host_m cont_m
|
|
for m in "${extra_mounts[@]+"${extra_mounts[@]}"}"; do
|
|
if [[ "$m" == *:* ]]; then
|
|
host_m="${m%%:*}"
|
|
cont_m="${m#*:}"
|
|
else
|
|
host_m="$m"
|
|
cont_m="$m"
|
|
fi
|
|
# resolve host side if relative
|
|
if [[ "$host_m" != /* ]]; then
|
|
host_m="$(cd "$(dirname "$host_m")" && pwd -P)/$(basename "$host_m")"
|
|
fi
|
|
docker_args+=(-v "${host_m}:${cont_m}")
|
|
done
|
|
|
|
# 7) Pass through env (bash 3.2-compatible dedupe)
|
|
local key seen_env=" "
|
|
for key in "${pass_envs[@]+"${pass_envs[@]}"}"; do
|
|
[[ -z "$key" ]] && continue
|
|
case "$seen_env" in
|
|
*" ${key} "*) continue ;;
|
|
esac
|
|
seen_env+="${key} "
|
|
# indirect expansion works in bash 3.2
|
|
if [[ -n "${!key:-}" ]]; then
|
|
docker_args+=(-e "${key}=${!key}")
|
|
fi
|
|
done
|
|
|
|
# Git author from host
|
|
local gname gemail
|
|
gname=$(git config --global user.name 2>/dev/null || true)
|
|
gemail=$(git config --global user.email 2>/dev/null || true)
|
|
[[ -n "$gname" ]] && docker_args+=(-e "GIT_AUTHOR_NAME=$gname" -e "GIT_COMMITTER_NAME=$gname")
|
|
[[ -n "$gemail" ]] && docker_args+=(-e "GIT_AUTHOR_EMAIL=$gemail" -e "GIT_COMMITTER_EMAIL=$gemail")
|
|
|
|
docker_args+=("${env_args[@]+"${env_args[@]}"}")
|
|
docker_args+=("${env_files[@]+"${env_files[@]}"}")
|
|
|
|
# Label for cleanup
|
|
docker_args+=(--label "asandbox=1" --label "asandbox.workdir=${host_wd}")
|
|
|
|
docker_args+=("$IMAGE_NAME")
|
|
docker_args+=("${cmd[@]}")
|
|
|
|
if [[ $quiet -eq 0 ]]; then
|
|
info "workdir ${C_DIM}${host_wd}${C_RST}"
|
|
info "network ${C_DIM}${network}${C_RST} memory=${memory} cpus=${cpus}"
|
|
info "command ${C_DIM}${cmd[*]}${C_RST}"
|
|
fi
|
|
|
|
if [[ $dry_run -eq 1 ]]; then
|
|
echo "docker $(printf '%q ' "${docker_args[@]}")"
|
|
return 0
|
|
fi
|
|
|
|
# Never mount docker.sock — explicit safety check
|
|
local a
|
|
for a in "${docker_args[@]}"; do
|
|
if [[ "$a" == *docker.sock* ]]; then
|
|
die "refusing to mount docker.sock"
|
|
fi
|
|
done
|
|
|
|
exec docker "${docker_args[@]}"
|
|
}
|
|
|
|
cmd_shell() {
|
|
cmd_run "$@" -- bash
|
|
}
|
|
|
|
cmd_config() {
|
|
case "${1:-show}" in
|
|
show|cat)
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
echo "# $CONFIG_FILE"
|
|
cat "$CONFIG_FILE"
|
|
else
|
|
warn "no config yet — run: asandbox config init"
|
|
fi
|
|
;;
|
|
init|create)
|
|
write_default_config
|
|
;;
|
|
path)
|
|
echo "$CONFIG_FILE"
|
|
;;
|
|
*)
|
|
die "usage: asandbox config [show|init|path]"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ── main ────────────────────────────────────────────────────────────
|
|
main() {
|
|
load_config
|
|
|
|
local cmd="${1:-}"
|
|
if [[ -z "$cmd" ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
shift || true
|
|
|
|
case "$cmd" in
|
|
run) cmd_run "$@" ;;
|
|
shell|sh|bash) cmd_shell "$@" ;;
|
|
installagent|install-agent|ia)
|
|
cmd_installagent "$@"
|
|
;;
|
|
build) cmd_build "$@" ;;
|
|
agents|list) cmd_agents "$@" ;;
|
|
config) cmd_config "$@" ;;
|
|
doctor) cmd_doctor "$@" ;;
|
|
version|--version|-V) echo "asandbox $VERSION" ;;
|
|
help|-h|--help) usage ;;
|
|
# Convenience: asandbox claude ... → asandbox run claude ...
|
|
claude|claude-code|codex|gemini|gemini-cli|aider|antigravity|agy|cursor|opencode|qwen|amp|goose)
|
|
cmd_run "$cmd" "$@"
|
|
;;
|
|
*)
|
|
# Unknown subcommand: treat as agent/command name
|
|
cmd_run "$cmd" "$@"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|