diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ab1400 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,103 @@ +# Agent Sandbox — isolated Linux environment for CLI coding agents +# Designed for macOS hosts (Docker Desktop / OrbStack) +FROM ubuntu:24.04 + +ARG TARGETARCH +ARG DEBIAN_FRONTEND=noninteractive +ARG USERNAME=agent +ARG USER_UID=1000 +ARG USER_GID=1000 + +LABEL org.opencontainers.image.title="asandbox" \ + org.opencontainers.image.description="Secure Docker sandbox for CLI AI coding agents" \ + org.opencontainers.image.source="local" + +# Base tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + wget \ + git \ + gnupg \ + openssh-client \ + sudo \ + less \ + nano \ + vim-tiny \ + jq \ + unzip \ + zip \ + tar \ + gzip \ + bzip2 \ + xz-utils \ + build-essential \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + ripgrep \ + fd-find \ + tree \ + rsync \ + locales \ + tzdata \ + procps \ + iproute2 \ + iputils-ping \ + dnsutils \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/bin/fdfind /usr/local/bin/fd \ + && locale-gen en_US.UTF-8 + +ENV LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + NPM_CONFIG_FUND=false + +# Node.js 22 LTS (many agents are Node-based) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable || true + +# uv (fast Python package manager) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv /root/.local/bin/uv /usr/local/bin/uv \ + && mv /root/.local/bin/uvx /usr/local/bin/uvx 2>/dev/null || true + +# Create non-root user matching host UID/GID when possible (macOS staff=20 often exists as dialout) +RUN set -eux; \ + if getent group "${USER_GID}" >/dev/null; then \ + EXISTING_GROUP="$(getent group "${USER_GID}" | cut -d: -f1)"; \ + useradd --uid "${USER_UID}" --gid "${USER_GID}" -m -s /bin/bash "${USERNAME}" \ + || useradd --uid "${USER_UID}" -g "${EXISTING_GROUP}" -m -s /bin/bash "${USERNAME}"; \ + else \ + groupadd --gid "${USER_GID}" "${USERNAME}"; \ + useradd --uid "${USER_UID}" --gid "${USER_GID}" -m -s /bin/bash "${USERNAME}"; \ + fi; \ + echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}"; \ + chmod 0440 "/etc/sudoers.d/${USERNAME}" + +# installagent + shell integration (agents installed on demand inside shell) +COPY installagent /usr/local/bin/installagent +COPY entrypoint.sh /usr/local/bin/asandbox-entrypoint +COPY bashrc.asandbox /etc/asandbox.bashrc +RUN chmod +x /usr/local/bin/installagent /usr/local/bin/asandbox-entrypoint \ + && echo 'source /etc/asandbox.bashrc' >> /home/${USERNAME}/.bashrc \ + && echo 'source /etc/asandbox.bashrc' >> /home/${USERNAME}/.profile \ + && mkdir -p /home/${USERNAME}/.local/bin /home/${USERNAME}/.npm-global/bin \ + && chown -R ${USERNAME} /home/${USERNAME} + +# Workspace mount point (host path is also bind-mounted at real path) +RUN mkdir -p /workspace && chown ${USERNAME} /workspace + +WORKDIR /workspace +USER ${USERNAME} +ENV HOME=/home/${USERNAME} \ + PATH="/home/${USERNAME}/.local/bin:/home/${USERNAME}/.npm-global/bin:/usr/local/bin:${PATH}" + +ENTRYPOINT ["/usr/local/bin/asandbox-entrypoint"] +CMD ["bash", "-l"] diff --git a/asandbox b/asandbox new file mode 100644 index 0000000..18610b5 --- /dev/null +++ b/asandbox @@ -0,0 +1,670 @@ +#!/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 [options] [--] [agent-args...] + +COMMANDS + run Run an agent or arbitrary command in the sandbox + shell Interactive bash inside the sandbox + installagent 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 or asandbox run ): + + 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 /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 "$@" diff --git a/bashrc.asandbox b/bashrc.asandbox new file mode 100644 index 0000000..8c02efd --- /dev/null +++ b/bashrc.asandbox @@ -0,0 +1,17 @@ +# Sourced for interactive shells inside asandbox +export PATH="${HOME}/.local/bin:${HOME}/.npm-global/bin:${PATH}" + +# npm user prefix for installagent +if command -v npm >/dev/null 2>&1; then + npm config set prefix "${HOME}/.npm-global" >/dev/null 2>&1 || true +fi + +alias install-agent='installagent' +alias agents='installagent list' + +# Helpful tip once per shell when installagent exists +if [[ $- == *i* ]] && command -v installagent >/dev/null 2>&1; then + if [[ "${ASANDBOX_QUIET:-0}" != "1" && "${ASANDBOX_NO_TIP:-0}" != "1" ]]; then + echo "tip: installagent list | installagent claude|cursor|opencode|antigravity|gemini|..." >&2 + fi +fi diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..f48a249 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# asandbox container entrypoint — normalize env, ensure dirs, run command +set -euo pipefail + +# Ensure common agent config + install dirs exist (host mounts may be empty) +mkdir -p \ + "${HOME}/.claude" \ + "${HOME}/.codex" \ + "${HOME}/.config" \ + "${HOME}/.local/bin" \ + "${HOME}/.npm-global/bin" \ + "${HOME}/.ssh" \ + "${HOME}/.npm" \ + "${HOME}/.gemini" \ + "${HOME}/.cursor" \ + 2>/dev/null || true + +export PATH="${HOME}/.local/bin:${HOME}/.npm-global/bin:/usr/local/bin:${PATH}" + +# npm prefix for user installs via installagent +if command -v npm >/dev/null 2>&1; then + npm config set prefix "${HOME}/.npm-global" >/dev/null 2>&1 || true +fi + +# Git identity fallback so commits work without host gitconfig mount +if [[ -n "${GIT_AUTHOR_NAME:-}" ]]; then + git config --global user.name "${GIT_AUTHOR_NAME}" 2>/dev/null || true +fi +if [[ -n "${GIT_AUTHOR_EMAIL:-}" ]]; then + git config --global user.email "${GIT_AUTHOR_EMAIL}" 2>/dev/null || true +fi + +# Respect ASANDBOX_WORKDIR if set +if [[ -n "${ASANDBOX_WORKDIR:-}" && -d "${ASANDBOX_WORKDIR}" ]]; then + cd "${ASANDBOX_WORKDIR}" +fi + +# Interactive shell banner +_is_shell=0 +if [[ $# -eq 0 || "${1:-}" == "bash" || "${1:-}" == "sh" || "${1:-}" == "bash" ]]; then + _is_shell=1 +fi + +if [[ "${ASANDBOX_BANNER:-0}" == "1" ]] || { [[ -t 0 ]] && [[ $_is_shell -eq 1 ]]; }; then + if [[ "${ASANDBOX_QUIET:-0}" != "1" ]]; then + echo "┌──────────────────────────────────────────────────┐" >&2 + echo "│ asandbox — isolated agent environment │" >&2 + echo "│ cwd: $(pwd)" >&2 + echo "│ │" >&2 + echo "│ installagent list │" >&2 + echo "│ installagent # claude cursor opencode │" >&2 + echo "│ # antigravity gemini … │" >&2 + echo "└──────────────────────────────────────────────────┘" >&2 + fi +fi + +if [[ $# -eq 0 ]]; then + exec bash -l +fi + +# If launching bash login/interactive, keep as-is +exec "$@" diff --git a/installagent b/installagent new file mode 100644 index 0000000..83eb627 --- /dev/null +++ b/installagent @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# installagent — install CLI AI coding agents inside asandbox +# Usage: installagent | installagent list | installagent status +set -euo pipefail + +VERSION="1.1.0" + +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}installagent:${C_RST} $*" >&2; exit 1; } +info() { echo "${C_BLU}installagent:${C_RST} $*" >&2; } +ok() { echo "${C_GRN}installagent:${C_RST} $*" >&2; } +warn() { echo "${C_YLW}installagent:${C_RST} $*" >&2; } + +ensure_local_path() { + mkdir -p "${HOME}/.local/bin" "${HOME}/.local/lib" "${HOME}/.npm-global" + # Prefer user-local npm so installs survive container recreate (via persist mount) + if command -v npm >/dev/null 2>&1; then + npm config set prefix "${HOME}/.npm-global" >/dev/null 2>&1 || true + fi + export PATH="${HOME}/.local/bin:${HOME}/.npm-global/bin:${PATH}" +} + +npm_g() { + ensure_local_path + npm install -g --prefix "${HOME}/.npm-global" "$@" +} + +have_cmd() { command -v "$1" >/dev/null 2>&1; } + +# ── catalog ───────────────────────────────────────────────────────── +# id|aliases|binary|description +catalog() { + cat <<'EOF' +antigravity|agy,antigravity-cli|agy|Google Antigravity CLI +cursor|cursor-cli,cursor-agent|agent|Cursor Agent CLI +opencode|open-code|opencode|OpenCode open-source coding agent +claude|claude-code,claude-cli|claude|Anthropic Claude Code +gemini|gemini-cli,google-gemini|gemini|Google Gemini CLI +codex|openai-codex|codex|OpenAI Codex CLI +aider|aider-chat|aider|Aider Python coding agent +qwen|qwen-code,qwen-cli|qwen|Qwen Code CLI +amp|sourcegraph-amp|amp|Sourcegraph Amp CLI +goose|block-goose|goose|Block Goose agent +continue|cn|cn|Continue CLI +EOF +} + +normalize_name() { + echo "$1" | tr '[:upper:]' '[:lower:]' | tr '_' '-' +} + +resolve_id() { + local want + want=$(normalize_name "$1") + local line id aliases + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + id="${line%%|*}" + aliases="${line#*|}" + aliases="${aliases%%|*}" + if [[ "$want" == "$id" ]]; then + echo "$id" + return 0 + fi + local a + IFS=',' read -ra _als <<< "$aliases" + for a in "${_als[@]}"; do + a=$(echo "$a" | tr -d ' ') + if [[ "$want" == "$a" ]]; then + echo "$id" + return 0 + fi + done + done < <(catalog) + return 1 +} + +agent_binary() { + local id="$1" line + while IFS= read -r line; do + [[ "${line%%|*}" == "$id" ]] || continue + # id|aliases|binary|desc + local rest="${line#*|}" + rest="${rest#*|}" + echo "${rest%%|*}" + return 0 + done < <(catalog) +} + +agent_desc() { + local id="$1" line + while IFS= read -r line; do + [[ "${line%%|*}" == "$id" ]] || continue + echo "${line##*|}" + return 0 + done < <(catalog) +} + +# ── installers ────────────────────────────────────────────────────── +install_antigravity() { + info "installing Antigravity CLI (agy)..." + ensure_local_path + curl -fsSL https://antigravity.google/cli/install.sh | bash + # Some installers put binary only in a custom dir — ensure on PATH + if ! have_cmd agy; then + # common locations + for p in \ + "${HOME}/.local/bin/agy" \ + "${HOME}/.antigravity/bin/agy" \ + "${HOME}/.agy/bin/agy" \ + /usr/local/bin/agy; do + if [[ -x "$p" ]]; then + mkdir -p "${HOME}/.local/bin" + ln -sfn "$p" "${HOME}/.local/bin/agy" + break + fi + done + fi + have_cmd agy || die "agy not found after install — check installer output" + ok "Antigravity CLI ready: $(command -v agy)" + agy --version 2>/dev/null || agy version 2>/dev/null || true +} + +install_cursor() { + info "installing Cursor Agent CLI..." + ensure_local_path + curl -fsSL https://cursor.com/install | bash + if ! have_cmd agent; then + for p in \ + "${HOME}/.local/bin/agent" \ + "${HOME}/.cursor/bin/agent" \ + "${HOME}/.cursor-agent/bin/agent"; do + if [[ -x "$p" ]]; then + mkdir -p "${HOME}/.local/bin" + ln -sfn "$p" "${HOME}/.local/bin/agent" + break + fi + done + fi + have_cmd agent || die "agent not found after install" + ok "Cursor CLI ready: $(command -v agent)" + agent --version 2>/dev/null || true +} + +install_opencode() { + info "installing OpenCode..." + ensure_local_path + if have_cmd npm; then + npm_g opencode-ai + else + curl -fsSL https://opencode.ai/install | bash + fi + if ! have_cmd opencode; then + for p in "${HOME}/.npm-global/bin/opencode" "${HOME}/.local/bin/opencode" /usr/local/bin/opencode; do + [[ -x "$p" ]] && ln -sfn "$p" "${HOME}/.local/bin/opencode" && break + done + fi + have_cmd opencode || die "opencode not found after install" + ok "OpenCode ready: $(command -v opencode)" + opencode --version 2>/dev/null || true +} + +install_claude() { + info "installing Claude Code..." + have_cmd npm || die "npm required (Node.js)" + npm_g @anthropic-ai/claude-code + have_cmd claude || die "claude not found after install" + ok "Claude Code ready: $(command -v claude)" + claude --version 2>/dev/null || true +} + +install_gemini() { + info "installing Gemini CLI..." + have_cmd npm || die "npm required (Node.js)" + npm_g @google/gemini-cli + have_cmd gemini || die "gemini not found after install" + ok "Gemini CLI ready: $(command -v gemini)" + gemini --version 2>/dev/null || true +} + +install_codex() { + info "installing OpenAI Codex CLI..." + have_cmd npm || die "npm required (Node.js)" + npm_g @openai/codex + have_cmd codex || die "codex not found after install" + ok "Codex ready: $(command -v codex)" + codex --version 2>/dev/null || true +} + +install_aider() { + info "installing Aider..." + ensure_local_path + if have_cmd uv; then + uv tool install aider-chat + elif have_cmd pip3; then + pip3 install --user --break-system-packages aider-chat \ + || pip3 install --user aider-chat + else + die "need uv or pip3 to install aider" + fi + # uv tools go to ~/.local/bin + have_cmd aider || die "aider not found after install — ensure ~/.local/bin is on PATH" + ok "Aider ready: $(command -v aider)" + aider --version 2>/dev/null || true +} + +install_qwen() { + info "installing Qwen Code..." + have_cmd npm || die "npm required (Node.js)" + npm_g @qwen-code/qwen-code + # binary name may be qwen or qwen-code + if ! have_cmd qwen && have_cmd qwen-code; then + ln -sfn "$(command -v qwen-code)" "${HOME}/.local/bin/qwen" + fi + if have_cmd qwen || have_cmd qwen-code; then + ok "Qwen Code ready: $(command -v qwen 2>/dev/null || command -v qwen-code)" + else + die "qwen binary not found after install" + fi +} + +install_amp() { + info "installing Amp (Sourcegraph)..." + ensure_local_path + # Official installer when available + if curl -fsSL https://ampcode.com/install.sh 2>/dev/null | bash; then + : + elif have_cmd npm; then + npm_g @sourcegraph/amp 2>/dev/null || npm_g amp 2>/dev/null || true + fi + have_cmd amp || warn "amp may need manual install — see https://ampcode.com" + have_cmd amp && ok "Amp ready: $(command -v amp)" +} + +install_goose() { + info "installing Goose..." + ensure_local_path + curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh 2>/dev/null | bash \ + || curl -fsSL https://raw.githubusercontent.com/block/goose/main/download_cli.sh 2>/dev/null | bash \ + || true + if ! have_cmd goose; then + # fallback cargo if present + if have_cmd cargo; then + cargo install goose-cli 2>/dev/null || true + fi + fi + have_cmd goose || die "goose not found after install — see https://github.com/block/goose" + ok "Goose ready: $(command -v goose)" +} + +install_continue() { + info "installing Continue CLI..." + ensure_local_path + have_cmd npm || die "npm required" + npm_g @continuedev/cli 2>/dev/null || npm_g continue-cli 2>/dev/null || true + if have_cmd cn || have_cmd continue; then + ok "Continue ready: $(command -v cn 2>/dev/null || command -v continue)" + else + warn "Continue CLI package name may have changed — try: npm install -g @continuedev/cli" + fi +} + +do_install() { + local id="$1" + case "$id" in + antigravity) install_antigravity ;; + cursor) install_cursor ;; + opencode) install_opencode ;; + claude) install_claude ;; + gemini) install_gemini ;; + codex) install_codex ;; + aider) install_aider ;; + qwen) install_qwen ;; + amp) install_amp ;; + goose) install_goose ;; + continue) install_continue ;; + *) die "no installer for: $id" ;; + esac +} + +cmd_list() { + echo "${C_BLD}Available agents${C_RST} (installagent )" + echo + printf " ${C_BLD}%-14s${C_RST} %-12s %s\n" "NAME" "BINARY" "DESCRIPTION" + printf " %-14s %-12s %s\n" "----" "------" "-----------" + local line id aliases bin desc + while IFS= read -r line; do + id="${line%%|*}" + rest="${line#*|}" + aliases="${rest%%|*}" + rest="${rest#*|}" + bin="${rest%%|*}" + desc="${rest#*|}" + local mark=" " + if have_cmd "$bin"; then + mark="${C_GRN}✓${C_RST}" + else + mark="${C_DIM}·${C_RST}" + fi + printf " %s %-12s %-12s %s\n" "$mark" "$id" "$bin" "$desc" + if [[ -n "$aliases" ]]; then + printf " ${C_DIM}aliases: %s${C_RST}\n" "$aliases" + fi + done < <(catalog) + echo + echo "Examples:" + echo " installagent claude" + echo " installagent antigravity" + echo " installagent cursor" + echo " installagent opencode" + echo " installagent all # install core set" +} + +cmd_status() { + ensure_local_path + echo "${C_BLD}Installed agents${C_RST}" + local line id bin + while IFS= read -r line; do + id="${line%%|*}" + rest="${line#*|}" + rest="${rest#*|}" + bin="${rest%%|*}" + if have_cmd "$bin"; then + printf " ${C_GRN}✓${C_RST} %-14s %s\n" "$id" "$(command -v "$bin")" + else + printf " ${C_DIM}·${C_RST} %-14s (not installed)\n" "$id" + fi + done < <(catalog) + echo + echo "PATH includes:" + echo " ${HOME}/.local/bin" + echo " ${HOME}/.npm-global/bin" +} + +cmd_all() { + # Core set commonly used + local core=(claude gemini codex opencode cursor antigravity aider) + local name + for name in "${core[@]}"; do + info "── $name ──" + if do_install "$name"; then + ok "$name done" + else + warn "$name failed (continuing)" + fi + done + cmd_status +} + +usage() { + cat < Install agent by name or alias + installagent list Show catalog + installagent status Show what is installed + installagent all Install core agents + installagent help This help + +AGENTS + antigravity (agy) Google Antigravity CLI + cursor (agent) Cursor Agent CLI + opencode OpenCode + claude Claude Code + gemini Gemini CLI + codex OpenAI Codex + aider Aider + qwen Qwen Code + amp Sourcegraph Amp + goose Block Goose + continue Continue CLI + +Installs go to ~/.local and ~/.npm-global (persisted by asandbox). +EOF +} + +main() { + ensure_local_path + + local cmd="${1:-list}" + shift || true + + case "$cmd" in + list|ls|-l) cmd_list ;; + status|st) cmd_status ;; + all) cmd_all ;; + help|-h|--help) usage ;; + version|-V|--version) echo "installagent $VERSION" ;; + *) + local id + if ! id=$(resolve_id "$cmd"); then + die "unknown agent: $cmd (try: installagent list)" + fi + info "resolved '$cmd' → $id ($(agent_desc "$id"))" + do_install "$id" + echo + ok "run: $(agent_binary "$id")" + ;; + esac +} + +main "$@"