#!/usr/bin/env bash
# claude-container — a sandboxed coding agent (Claude Code, Codex or Pi) from the published image, for
# hosts WITHOUT a devcontainer workflow. Requirements: rootless podman
# (or docker) and /dev/net/tun on the host. cd into a project and run
# `uvx claude-sandbox` (the wheel ships this script and pins the matching
# image), or copy this script onto your PATH and run it by name.
#
# One NAMED container per project directory: created on first run, reused
# on later runs. gh/glab logins made inside it therefore live for the
# container's lifetime — the same container-scoped credential model as a
# devcontainer (re-authenticate after --recreate), never a host-credential
# mount. The container's own process is an idle keeper; every session is
# an exec into it, so the verb and AGENT_ARGS apply on every run.
# Only --bridge, --peers, --mount and --mount-rw are fixed at create time
# (--recreate to change them). The container stops when its last session
# exits.
#
# Filesystem view: the project dir is bound read-write at its host path.
# --peers also binds its PARENT read-write, as a devcontainer mounts
# /workspaces, so sibling checkouts can be opened as sessions of their own
# (skipped when the parent is / or contains $HOME, which would expose ~/.ssh
# and every other host secret to the container). Each agent session still
# writes only to its own project: the sandbox binds $PWD, not the parent.
# Peers are off by default because every sibling, and any credential in it,
# is then readable by agents.
#
# Usage: claude-container [OPTIONS] [VERB] [ARGS...]
#
# Verbs (the default is claude):
#   claude | codex | pi   a sandboxed session of that agent. All use the same
#                   sandbox. Pi supports cloud and local models; see the
#                   use-pi guide for the localhost model relay.
#   shell           a plain (UNSANDBOXED) shell in the container instead of an
#                   agent — e.g. to run `claude-sandbox gh-auth` first. Start a
#                   sandboxed session from it with claude / codex / pi.
#   gh-auth | glab-auth | verify | pi-local | version | update | doctor
#                   run that helper of the in-container `claude-sandbox` CLI
#                   (same as typing it in a shell session), e.g.
#                   `uvx claude-sandbox gh-auth` or `uvx claude-sandbox verify`.
#   clean [--force] [--images]
#                   remove the STOPPED project containers this launcher
#                   created on this host (forge logins in them are lost).
#                   Running ones are listed and kept; --force removes those
#                   too, ending their sessions. --images also removes
#                   claude-sandbox image tags no container uses any more.
#                   Venvs on the cache volume whose project container no
#                   longer exists are removed too (a new container gets a
#                   fresh venv anyway).
#                   Use it before testing a newly pulled image, so no launch
#                   silently reconnects to a container built from the old one.
#   install         (uvx only) install the sandbox into the devcontainer this
#                   runs in — see the sandbox-a-team-devcontainer guide.
#
# Options (before the verb):
#   --recreate      remove and recreate the project container (e.g. after
#                   a `podman pull`); forge auth must be re-done after
#   --bridge        create on the engine's bridge network instead of the
#                   default --network=host. Host networking is what lets Pi
#                   reach a local model server and non-agent shells see EPICS
#                   CA broadcast and X11; the agents' egress jail is the same
#                   either way (it only ever restricts).
#   --mount PATH    bind PATH READ-ONLY into the container (repeatable);
#                   agents can read it, nothing in the container can write it
#   --mount-rw PATH bind PATH read-write into the container AND the
#                   sandbox (repeatable; becomes an allow-write entry)
#   --gpu           enable all NVIDIA GPUs (host NVIDIA Container Toolkit
#                   required; Podman uses CDI, Docker uses --gpus all)
#   --device PATH   expose a /dev device node to the container AND sandbox
#                   read-write at the same path (repeatable)
#   --peers         also bind the project's parent directory read-write, so
#                   sibling projects can be opened from a shell. Sibling
#                   projects are then readable by sandboxed agents.
#   --no-peers      the default: no parent-directory mount. Accepted so
#                   commands written for 4.4.0 keep working.
#   --version       print launcher version and exit
#   -h | --help     this text
#
# Environment (all optional):
#   CLAUDE_SANDBOX_IMAGE          image ref (default ghcr.io/diamondlightsource/claude-sandbox:latest;
#                                 `uvx claude-sandbox` pins the wheel's own version)
#   CLAUDE_SANDBOX_ENGINE         podman | docker (default podman)
#   CLAUDE_SANDBOX_SHARED_CONFIG  host dir mounted at /user-terminal-config to
#                                 persist Claude login/memory across containers
#                                 (default ~/.config/terminal-config)
#   CLAUDE_SANDBOX_CONF           host sandbox conf, mounted READ-ONLY at
#                                 /etc/claude-sandbox.conf when the file exists
#                                 (default ~/.config/claude-sandbox.conf)
#   CLAUDE_SANDBOX_SHELL          shell the `shell` verb runs, by name: zsh, bash, ...
#                                 (default: the shell you ran this from, else
#                                 $SHELL; bash when the image lacks it). Per run.
#   CLAUDE_SANDBOX_CACHE          named volume at /cache — uv cache, pre-commit
#                                 home and the per-project venvs, shared by every
#                                 project container (default claude-sandbox-cache;
#                                 empty = none)
#   CLAUDE_SANDBOX_*              any other sandbox variable is passed through
#                                 at create time (allow-ip, egress-jail, ...)
set -euo pipefail

# The launcher version, compared with the image label by warn_if_outdated.
# The release number is the git tag: the uvx front door passes it in from
# the wheel (built from that tag), and CI bakes the tag into the image. The
# literal only serves a copied script, which cannot know its tag.
VERSION="4.0.0"
[ -n "${CLAUDE_SANDBOX_LAUNCHER_VERSION:-}" ] && VERSION="$CLAUDE_SANDBOX_LAUNCHER_VERSION"
# How to spell this launcher in hints: the uvx front door sets
# CLAUDE_SANDBOX_LAUNCHER; a copied script is run by its own name.
SELF="claude-container"
[ "${CLAUDE_SANDBOX_LAUNCHER:-}" = uvx ] && SELF="uvx claude-sandbox"

IMAGE="${CLAUDE_SANDBOX_IMAGE:-ghcr.io/diamondlightsource/claude-sandbox:latest}"
ENGINE="${CLAUDE_SANDBOX_ENGINE:-podman}"
SHARED="${CLAUDE_SANDBOX_SHARED_CONFIG:-$HOME/.config/terminal-config}"
CONF="${CLAUDE_SANDBOX_CONF:-$HOME/.config/claude-sandbox.conf}"
# Named volume at /cache (empty = none, the container layer only).
CACHE_VOLUME="${CLAUDE_SANDBOX_CACHE-claude-sandbox-cache}"

# Messages: one "claude-sandbox:" headline, then one indented detail per line
# with an aligned label, so no line wraps mid-name and each command copies
# whole. WARNED records a warning for pause_after_warnings.
WARNED=0
say() { echo "claude-sandbox: $1" >&2; }
note() { printf '  %-11s %s\n' "$1" "$2" >&2; }
warn() { note warning "$1"; WARNED=1; }

# pause_after_warnings: an agent redraws the whole terminal as it starts, so
# a warning printed before it vanishes at once. Wait for a key after any
# warning, but only before an agent verb and only when a person is at the
# terminal (stdin and stderr are ttys). A shell or a helper verb does not
# redraw, so its warnings stay visible without a wait.
pause_after_warnings() {
    local _key
    [ "$WARNED" = 1 ] && [ "$shell" = 0 ] && [ "$inner" = 0 ] || return 0
    [ -t 0 ] && [ -t 2 ] || return 0
    printf 'Press any key to continue, Ctrl-C to cancel.' >&2
    read -rsn 1 _key
    printf '\n' >&2
}

usage() {
    # The header comment above IS the manual; print it (strip the '# ').
    sed -n '2,/^set -euo pipefail$/p' "$0" | sed -e '$d' -e 's/^# \{0,1\}//'
}

# CI bakes two labels into the image: the version of this script it was
# built and tested with, and the git revision to fetch it from. Compare
# against the LOCAL image (no network, no container start) and print an
# actionable hint on mismatch. Notify only — this script runs
# unsandboxed on the host, so updating it must stay a deliberate,
# reviewable act, never something it does to itself.
warn_if_outdated() {
    local img_ver rev
    img_ver="$("$ENGINE" image inspect \
        -f '{{index .Config.Labels "io.diamondlightsource.claude-sandbox.launcher-version"}}' \
        "$IMAGE" 2>/dev/null)" || return 0
    { [ -n "$img_ver" ] && [ "$img_ver" != "$VERSION" ]; } || return 0
    if [ "$(printf '%s\n' "$VERSION" "$img_ver" | sort -V | head -1)" = "$VERSION" ]; then
        rev="$("$ENGINE" image inspect \
            -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
            "$IMAGE" 2>/dev/null)" || rev=""
        say "launcher v$VERSION is older than the image (v$img_ver)"
        if [ "${CLAUDE_SANDBOX_LAUNCHER:-}" = uvx ]; then
            note update "uvx claude-sandbox@latest"
            note pin "uvx claude-sandbox==$img_ver"
        else
            note update "curl -fsSLO https://raw.githubusercontent.com/DiamondLightSource/claude-sandbox/${rev:-main}/container/claude-container"
        fi
    else
        say "launcher v$VERSION is newer than the local image (v$img_ver)"
        note pull "$ENGINE pull $IMAGE"
        note rebuild "$SELF --recreate"
    fi
    WARNED=1
}

# Idle PID 1, also used to identify containers owned by this launcher.
KEEPER_CMD='trap "exit 0" TERM INT; while :; do sleep 60 & wait $!; done'

recreate=0
host_net=1
peers=0
agent=claude
shell=0
inner=0
clean=0
clean_force=0
clean_images=0
create_opts=()     # create-time flags the user passed, for the reuse warning
mounts_ro=()
mounts_rw=()
devices=()
gpu=0
while [ $# -gt 0 ]; do
    case "$1" in
        --recreate) recreate=1; shift ;;
        --bridge) host_net=0; create_opts+=( --bridge ); shift ;;
        --peers) peers=1; create_opts+=( --peers ); shift ;;
        --no-peers) peers=0; shift ;;
        --gpu) gpu=1; create_opts+=( --gpu ); shift ;;
        --device)
            if [ $# -lt 2 ] || [[ "$2" != /dev/* || "$2" == *$'\n'* ]]; then
                say '--device needs an absolute /dev device path'
                exit 1
            fi
            device="$(realpath -e -- "$2")" || exit 1
            if [[ "$device" != /dev/* ]] || { [ ! -c "$device" ] && [ ! -b "$device" ]; }; then
                say "--device needs a character or block device under /dev: $2"
                exit 1
            fi
            devices+=( "$device" )
            create_opts+=( "--device $2" )
            shift 2
            ;;
        --mount|--mount-rw)
            if [ $# -lt 2 ]; then
                echo "claude-sandbox: $1 needs a PATH" >&2
                exit 1
            fi
            if [ "$1" = --mount ]; then
                mounts_ro+=( "$(realpath "$2")" )
            else
                mounts_rw+=( "$(realpath "$2")" )
            fi
            create_opts+=( "$1 $2" )
            shift 2
            ;;
        --version) echo "claude-sandbox $VERSION"; exit 0 ;;
        -h|--help) usage; exit 0 ;;
        --) shift; break ;;
        *) break ;;
    esac
done
# An optional verb, then the agent's own args (applied on every run). All
# are wrapped by the same shadow inside; the verb only chooses the command.
case "${1:-}" in
    claude|codex|pi) agent="$1"; shift ;;
    shell) shell=1; shift ;;
    gh-auth|glab-auth|verify|pi-local|version|update|doctor)
        # Helper verbs of the in-container `claude-sandbox` CLI. Forwarded, so
        # they do the same thing typed on the host as inside: without this a
        # host `uvx claude-sandbox version` launched claude with "version" as
        # its argument. The verb stays in $@ as the inner CLI's argv.
        inner=1 ;;
    clean)
        clean=1; shift
        while [ $# -gt 0 ]; do
            case "$1" in
                --force)  clean_force=1 ;;
                --images) clean_images=1 ;;
                *) echo "claude-sandbox: clean takes only --force and --images" >&2; exit 2 ;;
            esac
            shift
        done
        ;;
    install)
        echo "claude-sandbox: install is a uvx verb: run \`uvx claude-sandbox install\` INSIDE the devcontainer" >&2
        exit 2
        ;;
esac

# Inside a container there is nothing for this launcher to do: an installed
# sandbox is run by its own command names, and a bare container wants the
# installer. (Podman writes /run/.containerenv; docker writes /.dockerenv.)
# CLAUDE_SANDBOX_NESTED=1 launches anyway (an engine inside a container is
# a real setup; it is also the test seam, since CI runs in a container).
if { [ -e /run/.containerenv ] || [ -e /.dockerenv ]; } && [ "${CLAUDE_SANDBOX_NESTED:-0}" != 1 ]; then
    if [ -x /usr/local/bin/claude ]; then
        echo "claude-sandbox: you are already inside an claude-sandbox container — run claude, codex or pi directly" >&2
    else
        echo "claude-sandbox: this is a container without the sandbox — install it with: uvx claude-sandbox install" >&2
    fi
    exit 1
fi

if ! command -v "$ENGINE" >/dev/null 2>&1; then
    echo "claude-sandbox: $ENGINE not found (set CLAUDE_SANDBOX_ENGINE=docker?)" >&2
    exit 1
fi

# Silently a no-op until the image has been pulled / carries the labels.
warn_if_outdated

# clean: remove the stopped project containers this launcher created on this
# host (all of them with --force), so stale ones stop piling up in the engine's store and a
# later launch is unambiguously a fresh container from the image now pulled
# (reconnecting to an existing container keeps its OLD image). Matched by
# the name prefix AND the keeper command, never by name alone, so an
# unrelated container that happens to start with claude-sandbox- is left.
# Like --recreate, this drops container-local packages and forge logins;
# project files and the shared agent config on the host survive.
# --images also removes claude-sandbox image tags no container uses.
clean_containers() {
    local n removed=0 kept=0
    while IFS= read -r n; do
        [ -n "$n" ] || continue
        "$ENGINE" container inspect -f '{{join .Config.Cmd " "}}' "$n" 2>/dev/null \
            | grep -qF 'sleep 60 & wait' || continue
        if [ "$clean_force" != 1 ] && [ "$("$ENGINE" container inspect -f '{{.State.Running}}' "$n" 2>/dev/null)" = true ]; then
            echo "claude-sandbox: kept $n (running; --force removes it and ends its sessions)" >&2
            kept=$((kept + 1))
            continue
        fi
        "$ENGINE" rm -f "$n" >/dev/null && { echo "claude-sandbox: removed $n" >&2; removed=$((removed + 1)); }
    done < <("$ENGINE" ps -a --filter name='^claude-sandbox-' --format '{{.Names}}' 2>/dev/null)
    echo "claude-sandbox: $removed container(s) removed, $kept running kept" >&2
    [ "$clean_images" = 1 ] || return 0
    local img
    while IFS= read -r img; do
        [ -n "$img" ] || continue
        # rmi refuses an image a remaining container still uses; that is the
        # right outcome, so its complaint is dropped.
        "$ENGINE" rmi "$img" >/dev/null 2>&1 && echo "claude-sandbox: removed image $img" >&2
    done < <("$ENGINE" images --filter reference='*/diamondlightsource/claude-sandbox' --format '{{.Repository}}:{{.Tag}}' 2>/dev/null)
}
# Venvs under /cache/venv-for<path> on the cache volume whose project
# container no longer exists — always, as part of clean: a removed
# container gets a fresh venv on its next create anyway, so its old one
# is only disk. Listed and removed from a throwaway container (the
# volume's host mountpoint is engine-specific and, under docker,
# root-owned) with the entrypoint BYPASSED: it would run the userns
# probe and venv setup first, and a refusal there reads as "no venvs".
# The container name is recomputed from the path the same way the launcher names it, so a venv is only pruned when ITS
# container is gone — a live project keeps its venv.
clean_venvs() {
    [ -n "$CACHE_VOLUME" ] || return 0
    local venv path slug hash removed=0
    while IFS= read -r venv; do
        [ -n "$venv" ] || continue
        path="${venv#/cache/venv-for}"
        slug="$(basename "$path" | tr -c 'a-zA-Z0-9_.-' '-')"; slug="${slug%-}"
        hash="$(printf '%s' "$path" | cksum | awk '{print $1}')"
        if "$ENGINE" container inspect "claude-sandbox-${slug}-${hash}" >/dev/null 2>&1; then
            continue
        fi
        "$ENGINE" run --rm --entrypoint rm -v "$CACHE_VOLUME:/cache" "$IMAGE" -rf "$venv" >/dev/null \
            && { echo "claude-sandbox: removed venv for $path" >&2; removed=$((removed + 1)); }
    done < <("$ENGINE" run --rm --entrypoint find -v "$CACHE_VOLUME:/cache" "$IMAGE" \
        /cache/venv-for -name pyvenv.cfg -printf '%h\n' 2>/dev/null)
    echo "claude-sandbox: $removed venv(s) removed" >&2
}
if [ "$clean" = 1 ]; then
    clean_containers
    clean_venvs
    exit 0
fi

# Stable per-project container name: a readable basename slug plus a hash
# of the full path, so same-named projects under different parents don't
# collide.
slug="$(basename "$PWD" | tr -c 'a-zA-Z0-9_.-' '-')"
slug="${slug%-}"
path_hash="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
NAME="claude-sandbox-${slug}-${path_hash}"
# Short form of NAME for prompts and status lines, e.g. myproj-3f2a. The
# entrypoint writes it to /etc/claude-sandbox-tag, where every agent's jail
# can read it (the jail clears the environment). The hash suffix tells apart
# same-named projects under different parents.
TAG="$(printf '%.20s-%04x' "$slug" $((path_hash % 65536)))"

container_exists() {
    "$ENGINE" container inspect "$NAME" >/dev/null 2>&1
}

# The shell the user is typing in, for the shell verb: walk up from our
# parent (through uvx's wrapper processes) to the first known interactive
# shell. $SHELL is only the LOGIN shell — at DLS that is bash while the
# terminal runs zsh — so it is the fallback, not the answer.
detect_shell() {
    local pid=$PPID stat comm n
    for n in 1 2 3 4 5 6; do
        [ "$pid" -gt 1 ] 2>/dev/null || break
        # /proc/PID/stat: "PID (comm) STATE PPID ..." — comm may hold spaces,
        # so split on the closing paren. No ps: it fails under a bound /proc.
        stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || break
        comm="${stat#*\(}"; comm="${comm%\)*}"
        case "${comm#-}" in
            zsh|bash|fish|ksh|tcsh|dash|sh) echo "${comm#-}"; return ;;
        esac
        stat="${stat##*\) }"; stat="${stat#* }"; pid="${stat%% *}"
    done
    basename "${SHELL:-bash}"
}

container_running() {
    [ "$("$ENGINE" container inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null)" = "true" ]
}

is_keeper() {
    "$ENGINE" container inspect -f '{{join .Config.Cmd " "}}' "$NAME" 2>/dev/null \
        | grep -qF 'sleep 60 & wait'
}

create_container() {
    mkdir -p "$SHARED"
    local -a args=(
        create --name "$NAME"
        # The egress jail (ADR 0015) is fail-closed without /dev/net/tun.
        --device /dev/net/tun
        # SELinux hosts (RHEL): don't try to relabel NFS/home mounts.
        --security-opt label=disable
        -v "$SHARED:/user-terminal-config"
        -e "TERM=${TERM:-xterm-256color}"
        -e "CLAUDE_SANDBOX_TAG=$TAG"
    )
    if [ "$gpu" = 1 ]; then
        case "${ENGINE##*/}" in
            podman) args+=( --device nvidia.com/gpu=all ) ;;
            docker) args+=( --gpus all ) ;;
            *) say '--gpu requires podman or docker'; return 1 ;;
        esac
    fi
    local device allow_devices="${CLAUDE_SANDBOX_ALLOW_DEVICES:-}"
    for device in "${devices[@]+"${devices[@]}"}"; do
        args+=( --device "$device" )
        allow_devices="${allow_devices:+$allow_devices$'\n'}$device"
    done
    # Rootless Podman drops the host user's supplementary groups, so nodes
    # that only a group may open (ttyUSB* is dialout, renderD*/kfd render)
    # fail with EACCES. keep-groups carries them in. Docker has no equivalent.
    if [ -n "${devices[*]+x}" ] && [ "${ENGINE##*/}" = podman ]; then
        args+=( --group-add keep-groups )
    fi
    [ -z "$allow_devices" ] || args+=( -e "CLAUDE_SANDBOX_ALLOW_DEVICES=$allow_devices" )
    if [ "$gpu" = 1 ]; then
        args+=( -e CLAUDE_SANDBOX_GPU=1 )
    elif [ -n "${CLAUDE_SANDBOX_GPU:-}" ]; then
        args+=( -e "CLAUDE_SANDBOX_GPU=$CLAUDE_SANDBOX_GPU" )
    fi
    # Siblings with --peers, as a devcontainer's /workspaces mount gives them: the
    # parent dir read-write at its host path, so a shell can start a
    # sandboxed session in any sibling; the sandbox still confines each
    # agent's writes to its own $PWD. The project bind below is kept for when the
    # parent is skipped. Slave propagation (here and on
    # --mount/--mount-rw) is for autofs trees such as /dls_sw: a process in
    # the container's user namespace may not TRIGGER an automount (EPERM on
    # readdir), but mounts the host automounter makes propagate in. Not when the parent is / (that
    # would shadow the image root) or holds $HOME (that would hand ~/.ssh
    # and every host token to the container, ro or not).
    local parent
    parent="$(dirname "$PWD")"
    skipped_parent=""
    if [ "$peers" = 1 ]; then
        case "$parent" in
            /) ;;
            *)
                case "$HOME/" in
                    "$parent"/*) skipped_parent="$parent" ;;
                    *) args+=( --mount "type=bind,src=$parent,dst=$parent,bind-propagation=slave" ) ;;
                esac
                ;;
        esac
    fi
    args+=( -v "$PWD:$PWD" -w "$PWD" )
    if [ "$host_net" = 1 ]; then
        args+=( --network=host )
    fi
    # Locale for zsh, git and friends; the image generates en_US.UTF-8.
    args+=( -e "LANG=${LANG:-en_US.UTF-8}" )
    # /cache on a named volume, laid out as the DLS python-copier devcontainer
    # does it: the uv download cache, pre-commit home and a PER-PROJECT venv at
    # /cache/venv-for<project path>, all on one filesystem so uv hardlinks
    # wheels into the venv instead of copying. The entrypoint (re)creates the
    # venv on a fresh container — like postCreate's `uv venv --clear` — and
    # points /opt/venv (on the image PATH) at it. Venvs of projects whose
    # container is gone accumulate on the volume: `clean` prunes them.
    if [ -n "$CACHE_VOLUME" ]; then
        args+=( -v "$CACHE_VOLUME:/cache" )
    fi
    args+=( -e "UV_PROJECT_ENVIRONMENT=/cache/venv-for$PWD" -e "VIRTUAL_ENV=/cache/venv-for$PWD"
            -e PRE_COMMIT_HOME=/cache/pre-commit -e UV_PYTHON_CACHE_DIR=/cache/uv-python )
    # X11 for the UNSANDBOXED shell verb only: agents never see it (the
    # shadow masks ~/.Xauthority and the egress jail has no host sockets).
    # Needs --network=host (the default) for the abstract socket; the
    # filesystem socket dir is bound for the --bridge case.
    if [ -n "${DISPLAY:-}" ]; then
        args+=( -e "DISPLAY=$DISPLAY" )
        [ -d /tmp/.X11-unix ] && args+=( -v /tmp/.X11-unix:/tmp/.X11-unix:ro )
        local xauth="${XAUTHORITY:-$HOME/.Xauthority}"
        [ -f "$xauth" ] && args+=( -v "$xauth:/root/.Xauthority:ro" )
    fi
    # Read-only git identity (name/email) for commits inside the sandbox.
    if [ -f "$HOME/.gitconfig" ]; then
        args+=( -v "$HOME/.gitconfig:/root/.gitconfig-host:ro" )
    fi
    # Durable per-user sandbox conf: READ-ONLY at the canonical /etc path
    # (Invariant 4 — conf stays outside the sandbox rw set). The
    # entrypoint sees the mount and skips re-stamping the baked copy.
    if [ -f "$CONF" ]; then
        args+=( -v "$CONF:/etc/claude-sandbox.conf:ro" )
    fi
    # --mount PATH: read-only at the same path. The sandbox's --ro-bind / /
    # shows it to agents as-is; nothing in the container can write it.
    local m allow_write="${CLAUDE_SANDBOX_ALLOW_WRITE:-}"
    for m in "${mounts_ro[@]+"${mounts_ro[@]}"}"; do
        args+=( --mount "type=bind,src=$m,dst=$m,ro,bind-propagation=slave" )
    done
    # --mount-rw PATH: read-write at the same path, and tell the sandbox to
    # bind it rw too. The shadow reads one allow-write path per line.
    for m in "${mounts_rw[@]+"${mounts_rw[@]}"}"; do
        args+=( --mount "type=bind,src=$m,dst=$m,bind-propagation=slave" )
        allow_write="${allow_write:+$allow_write$'\n'}$m"
    done
    if [ -n "$allow_write" ]; then
        args+=( -e "CLAUDE_SANDBOX_ALLOW_WRITE=$allow_write" )
    fi

    # Pass the rest of the CLAUDE_SANDBOX_* env through (allow-ip,
    # egress-jail, workspace-root, no-forge, ...). Frozen at create time
    # — use --recreate (or the conf file) to change them later.
    local v
    for v in $(compgen -v CLAUDE_SANDBOX_ || true); do
        case "$v" in
            CLAUDE_SANDBOX_IMAGE|CLAUDE_SANDBOX_ENGINE) continue ;;
            CLAUDE_SANDBOX_SHARED_CONFIG|CLAUDE_SANDBOX_CONF) continue ;;
            CLAUDE_SANDBOX_ALLOW_WRITE|CLAUDE_SANDBOX_SHELL|CLAUDE_SANDBOX_CACHE) continue ;;
            CLAUDE_SANDBOX_TAG) continue ;;
            CLAUDE_SANDBOX_GPU|CLAUDE_SANDBOX_ALLOW_DEVICES) continue ;;
        esac
        args+=( -e "$v=${!v}" )
    done

    # The baked command is an idle keeper, not an agent: sessions are execs
    # (see the launch sequence below), so the agent and its arguments are
    # chosen per run rather than frozen here. Bash as PID 1 reaps the
    # orphans a session leaves behind; the trap lets `stop` end it promptly.
    args+=( "$IMAGE" bash -c "$KEEPER_CMD" )
    "$ENGINE" "${args[@]}" >/dev/null
    say "created $NAME"
    note image "$IMAGE"
    note "forge auth" "$SELF shell, then claude-sandbox gh-auth"
    if [ -n "$skipped_parent" ]; then
        warn "did not mount $skipped_parent: it contains your home directory"
    fi
}

if [ "$recreate" = 1 ] && container_exists; then
    "$ENGINE" rm -f "$NAME" >/dev/null
fi

if container_exists; then
    if ! is_keeper; then
        say "$NAME does not have the expected keeper command"
        note rebuild "$SELF --recreate  (forge logins must be re-done)"
        exit 1
    fi
    # One line on every launch; details only when something needs acting on.
    say "reusing $NAME"
    rebuild=0
    # A pulled image does not reach an existing container: compare the
    # container's image ID with the local tag's. Silent when either lookup
    # fails (no local image yet, docker/podman template differences).
    local_img="$("$ENGINE" image inspect -f '{{.Id}}' "$IMAGE" 2>/dev/null)" || local_img=""
    ctr_img="$("$ENGINE" container inspect -f '{{.Image}}' "$NAME" 2>/dev/null)" || ctr_img=""
    if [ -n "$local_img" ] && [ -n "$ctr_img" ] && [ "$local_img" != "$ctr_img" ]; then
        created="$("$ENGINE" container inspect -f '{{.Created}}' "$NAME" 2>/dev/null | cut -c1-16 | tr T ' ')"
        warn "runs an older image than the one pulled (created ${created:-?})"
        rebuild=1
    fi
    # Peers were on by default in 4.4.0. A container created then keeps
    # its parent mount until it is recreated, so say so when peers are off.
    if [ "$peers" = 0 ] && "$ENGINE" container inspect \
            -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$NAME" 2>/dev/null \
            | grep -Fxq -- "$(dirname "$PWD")"; then
        warn "mounts the parent directory; peers are now off by default"
        rebuild=1
    fi
    if [ ${#create_opts[@]} -gt 0 ]; then
        # Everything else applies per run; these are baked into the
        # container and silently ignored on reuse — say so.
        warn "ignored on an existing container: ${create_opts[*]}"
        rebuild=1
    fi
    if [ "$rebuild" = 1 ]; then
        note rebuild "$SELF --recreate"
    fi
else
    create_container
fi

if ! container_running; then
    "$ENGINE" start "$NAME" >/dev/null
    if ! container_running; then
        # The entrypoint refused (typically: this host cannot nest user
        # namespaces). Its message is in the container log.
        echo "claude-sandbox: $NAME exited during start:" >&2
        "$ENGINE" logs "$NAME" >&2 || true
        exit 1
    fi
fi

# The session. Fresh args apply every time: this is an exec, not a replay
# of create-time arguments. A second claude-container in the same project
# while one is active simply execs another session into the same container.
if [ "$inner" = 1 ]; then
    set -- claude-sandbox "$@"
elif [ "$shell" = 1 ]; then
    # The user's own shell, as in a devcontainer terminal: the base image
    # ships zsh and bash, each with its rc sourcing /user-terminal-config.
    # Resolved inside, falling back to bash in an image without it.
    want="${CLAUDE_SANDBOX_SHELL:-$(detect_shell)}"
    set -- sh -c 'command -v "$1" >/dev/null && exec "$@"; shift; exec bash "$@"' _ "$want" "$@"
else
    set -- "$agent" "$@"
fi
pause_after_warnings
rc=0
"$ENGINE" exec -it "$NAME" "$@" || rc=$?

# A TUI killed inside the session (an agent under `shell`, or an exec torn
# down mid-draw) never sends its mouse-tracking resets, so the host terminal
# keeps reporting pointer motion as typed junk. Turn the DEC mouse modes
# off: xterm-standard resets every terminal honours, including RHEL 8 VTE.
[ -t 1 ] && printf '\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l'

# Stop the keeper when ours was the last session, so an idle project does
# not leave a running container behind; a session still open in another
# terminal keeps it up.
if [ "$("$ENGINE" container inspect -f '{{len .ExecIDs}}' "$NAME" 2>/dev/null)" = 0 ]; then
    "$ENGINE" stop -t 2 "$NAME" >/dev/null 2>&1 || true
fi
exit "$rc"
