From 0d5750a0878721f5216c9776938f40a1c4e69e59 Mon Sep 17 00:00:00 2001 From: Alec Hill Date: Sun, 9 Aug 2026 12:58:47 +0000 Subject: [PATCH 1/8] feat: extract the cage; add codex-box as a second payload Split the host-isolation and nested-engine machinery (the cage) out of the Claude-specific payload so another harness can reuse it, and add codex-box as the first proof. - Dockerfile.cage: payload-free cage-base image (engine + generic tooling, no harness; no claude, no ugrep grep-shadow, no bun). - entrypoint-cage.sh: promotes the generic-exec seam to the default, adds a payload-init hook and a cage-neutral host->box relay; --init on all postures. - libcage.sh: the engine block once (posture resolution, userns ladder, apparmor profile, device probes, ssh/colima relays, image build, exit-status contract, docker run assembly), driven by per-wrapper config + hooks. bash-3.2-safe. - codex-box + Dockerfile.codex: Codex CLI on cage-base, interactive with --dangerously-bypass-approvals-and-sandbox (the cage is the sandbox). Persists ~/.codex login; lifts Linear and other MCP OAuth logins out of the macOS Keychain into file-mode .credentials.json, read once then self-refreshed. claude-box is unchanged; Phase B migrates it onto the shared cage. Co-Authored-By: Claude Opus 4.8 --- Dockerfile.cage | 81 +++++++ Dockerfile.codex | 9 + codex-box | 206 +++++++++++++++++ entrypoint-cage.sh | 186 +++++++++++++++ libcage.sh | 564 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1046 insertions(+) create mode 100644 Dockerfile.cage create mode 100644 Dockerfile.codex create mode 100755 codex-box create mode 100755 entrypoint-cage.sh create mode 100644 libcage.sh diff --git a/Dockerfile.cage b/Dockerfile.cage new file mode 100644 index 0000000..368098d --- /dev/null +++ b/Dockerfile.cage @@ -0,0 +1,81 @@ +FROM node:22-bookworm-slim + +# cage-base — the payload-free box: host-isolation tooling and a full nested +# container engine (ADR-0041 decision 3), with NO agent harness baked in. A +# payload image (claude-box, codex-box) does `FROM cage-base` and adds its own +# harness. Nothing Claude-specific lives here: no claude-code, no ugrep +# grep-shadow, no bun — those moved to the Claude payload layer. +# +# The cage gets its OWN dockerd — rootless by default, rootful under sysbox / +# privileged dind — never a mounted host socket. docker-ce brings dockerd; +# docker-ce-rootless-extras brings dockerd-rootless.sh + rootlesskit; +# uidmap/slirp4netns/fuse-overlayfs/iproute2/iptables are the rootless engine's +# userns, networking, and storage tooling. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl ca-certificates gnupg unzip jq openssh-client socat \ + python3 python3-pip python3-venv \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list \ + && curl -fsSL https://download.docker.com/linux/debian/gpg \ + | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" \ + | tee /etc/apt/sources.list.d/docker.list \ + && apt-get update && apt-get install -y --no-install-recommends gh \ + docker-ce docker-ce-cli containerd.io \ + docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras \ + uidmap slirp4netns fuse-overlayfs iproute2 iptables kmod procps \ + && rm -rf /var/lib/apt/lists/* + +# Chromium runtime libs so Playwright (installed per-project in venvs) can launch +# its bundled browser without needing root at runtime. Browser binary itself is +# not baked in — `playwright install chromium` fetches it on demand. Generic +# capability, not Claude's, so it belongs in the cage. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \ + libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 \ + libpango-1.0-0 libcairo2 libxkbcommon0 \ + && rm -rf /var/lib/apt/lists/* + +# AWS CLI v2 — official bundled installer (arch-aware). Netlify CLI ships via +# npm below; flyctl via its official install script. Generic deploy CLIs. +RUN case "$(dpkg --print-architecture)" in \ + amd64) awscli_arch=x86_64 ;; \ + arm64) awscli_arch=aarch64 ;; \ + *) echo "unsupported arch for aws cli" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${awscli_arch}.zip" -o /tmp/awscliv2.zip \ + && unzip -q /tmp/awscliv2.zip -d /tmp \ + && /tmp/aws/install \ + && rm -rf /tmp/awscliv2.zip /tmp/aws + +# flyctl — Fly.io CLI, via the official install script. FLYCTL_INSTALL sets the +# install prefix so the binary lands on the system PATH. +RUN curl -fsSL https://fly.io/install.sh | FLYCTL_INSTALL=/usr/local sh + +# Netlify CLI + Wrangler (Cloudflare) — npm globals +RUN npm install -g netlify-cli wrangler \ + && npm cache clean --force + +# uv — fast Python package + venv manager. Used by skills that bootstrap Python +# deps without polluting system site-packages or hitting PEP 668. Pulled from +# the official Astral image so we don't curl-pipe-sh. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +# Yarn Berry (for Node.js projects using Yarn 4) +RUN corepack enable && corepack prepare yarn@4.13.0 --activate + +# gosu — minimal su/sudo for Docker; does a direct exec as the target user +# so the TTY and signal handling are properly inherited (unlike su -c "..."). +RUN curl -fsSL "https://github.com/tianon/gosu/releases/download/1.17/gosu-$(dpkg --print-architecture)" \ + -o /usr/local/bin/gosu \ + && chmod +x /usr/local/bin/gosu \ + && gosu nobody true + +COPY entrypoint-cage.sh /usr/local/bin/cage-entrypoint.sh +COPY userns-probe.sh /usr/local/bin/claude-box-userns-probe +RUN chmod +x /usr/local/bin/cage-entrypoint.sh /usr/local/bin/claude-box-userns-probe + +ENTRYPOINT ["/usr/local/bin/cage-entrypoint.sh"] diff --git a/Dockerfile.codex b/Dockerfile.codex new file mode 100644 index 0000000..5f1b016 --- /dev/null +++ b/Dockerfile.codex @@ -0,0 +1,9 @@ +FROM cage-base + +# codex-box payload: the OpenAI Codex CLI on top of the payload-free cage. +# Nothing else is added — the cage already carries git, gh, the nested engine, +# and the generic dev tooling. Auth is a persisted ~/.codex login synced by the +# launcher; the harness runs with --dangerously-bypass-approvals-and-sandbox +# because the cage is the isolation boundary. +RUN npm install -g @openai/codex \ + && npm cache clean --force diff --git a/codex-box b/codex-box new file mode 100755 index 0000000..cb072ca --- /dev/null +++ b/codex-box @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# codex-box — run the OpenAI Codex CLI in the cage (see libcage.sh). +# +# Mounts the project dir at its exact host path so tooling resolves. Persists +# Codex state (~/.codex: auth.json, config.toml, sessions) in ~/.codex-box/state +# across runs — delete that dir to reset. Authenticates from a persisted +# `codex login`: log in once on the host, and the box reuses it; an in-box login +# or token refresh is synced back to the host on exit. +# +# Always runs in the current directory (the project root). cd into your project +# first, then: +# codex-box # interactive session +# codex-box "fix the flaky test"# start with a prompt +# codex-box resume --last # any codex subcommand/flags pass through +# +# Every argument is passed straight through to codex. Use `--` to force +# everything after it to codex (e.g. `codex-box -- --engine foo`). +# +# codex-box-specific flags (consumed before codex sees them; position-free): +# --no-ssh / --upgrade / --engine / --name / --name-file +# — identical to claude-box; see libcage.sh and the README. +# +# Installation: +# ln -sf ~/claude-box/codex-box /usr/local/bin/codex-box + +set -euo pipefail + +# Resolve symlinks so an install via `ln -sf` still finds the repo dir. +_self="${BASH_SOURCE[0]}" +while [[ -L "$_self" ]]; do + _link=$(readlink "$_self") + [[ "$_link" = /* ]] && _self="$_link" || _self="$(dirname "$_self")/$_link" +done +BOX_SRC_DIR="$(cd "$(dirname "$_self")" && pwd)" + +# --- cage config --- +BOX_LABEL="codex-box" +BOX_IMAGE="codex-box" +BOX_DOCKERFILE="${BOX_SRC_DIR}/Dockerfile.codex" +BOX_STATE_DIR="${HOME}/.codex-box/state" +BOX_STATE_MOUNT="${HOME}/.codex" +BOX_ENV_PREFIX="CODEX_BOX" +BOX_FORWARD_VARS=(OPENAI_API_KEY) +# The cage is the isolation boundary, so Codex runs with its own sandbox and +# approval friction off (its analogue of claude's --dangerously-skip-permissions). +# mcp_oauth_credentials_store=file forces MCP OAuth logins (e.g. the Linear +# remote MCP) into ${CODEX_HOME}/.credentials.json instead of the OS keyring, so +# they live under ~/.codex = the persisted, host-seeded state volume. A macOS +# Keychain token can't cross into a Linux box; a file one seeds straight in. +BOX_PAYLOAD_CMD=(codex --dangerously-bypass-approvals-and-sandbox -c 'mcp_oauth_credentials_store="file"') + +# shellcheck source=libcage.sh +source "${BOX_SRC_DIR}/libcage.sh" + +# --- payload hooks --- + +# Extract every "Codex MCP Credentials" Keychain entry (macOS) and rewrite it as +# ${STATE_DIR}/.credentials.json in the file format the box reads. The Keychain +# stores a StoredOAuthTokens JSON keyed by the store key (== the Keychain account +# name); the file wants a BTreeMap with the token +# flattened out. So we key by the account verbatim and transform the shape. +# All-or-nothing best effort: any failure leaves the seeded file untouched and +# the launch proceeds. macOS-only (guards on `security`); no-op elsewhere. +_codex_seed_mcp_from_keychain() { + command -v security >/dev/null 2>&1 || return 0 + command -v node >/dev/null 2>&1 || return 0 + + # Read the Keychain ONCE. Every read triggers a macOS access prompt, so once + # the box has a seeded .credentials.json we leave it alone: Codex refreshes the + # file's tokens itself in file mode via the stored refresh_token, so it stays + # valid without touching the Keychain again. Force a fresh read (e.g. after a + # host re-login) with CODEX_BOX_RESEED_MCP=1, or by deleting the state file: + # rm ~/.codex-box/state/.credentials.json + local reseed_var="${BOX_ENV_PREFIX}_RESEED_MCP" + if [[ -s "${STATE_DIR}/.credentials.json" && -z "${!reseed_var:-}" ]]; then + return 0 + fi + + local svc="Codex MCP Credentials" accts a pw stream="" + + # Account names come from dump-keychain (attributes only; no access prompt). + accts=$(security dump-keychain 2>/dev/null | awk -v S="$svc" ' + /^keychain: / { if (svce==S && acct!="") print acct; acct=""; svce="" } + /"acct"=/ { l=$0; sub(/.*"acct"="/,"",l); sub(/"$/,"",l); acct=l } + /"svce"=/ { l=$0; sub(/.*"svce"="/,"",l); sub(/"$/,"",l); svce=l } + END { if (svce==S && acct!="") print acct } + ') + # Fallback to the single primary item if the dump yielded nothing. + if [ -z "$accts" ]; then + accts=$(security find-generic-password -s "$svc" -g 2>&1 \ + | sed -n 's/^[[:space:]]*"acct"="\(.*\)"$/\1/p' | head -1) + fi + [ -n "$accts" ] || return 0 + + # Read each item's password (the StoredOAuthTokens JSON). This is the step that + # may pop a one-time Keychain access prompt per item (click Always Allow). + while IFS= read -r a; do + [ -n "$a" ] || continue + pw=$(security find-generic-password -s "$svc" -a "$a" -w 2>/dev/null) || continue + [ -n "$pw" ] || continue + stream+="${a}"$'\n'"${pw}"$'\n' + done <<< "$accts" + [ -n "$stream" ] || return 0 + + # PID-based temp name (not mktemp): BSD mktemp on macOS rejects a suffix after + # the X's, and this matches how the monolith stages its sync helper. + local xform="${TMPDIR:-/tmp}/codex-kc.$$.js" + cat > "$xform" <<'JSEOF' +// stdin: alternating lines of account\nStoredOAuthTokens-JSON\n ... +// stdout: { "": FallbackTokenEntry } for the file store. +const fs = require('fs'); +let buf = ''; try { buf = fs.readFileSync(0, 'utf8'); } catch { process.exit(1); } +const lines = buf.split('\n'); +const out = {}; +for (let i = 0; i + 1 < lines.length; i += 2) { + const acct = lines[i], raw = lines[i + 1]; + if (!acct || !raw) continue; + let s; try { s = JSON.parse(raw); } catch { continue; } + const tr = s.token_response || {}; // WrappedOAuthTokenResponse is transparent + const access = tr.access_token; + if (!access) continue; + const refresh = (tr.refresh_token != null) ? tr.refresh_token : null; + const scopes = tr.scope ? String(tr.scope).trim().split(/\s+/).filter(Boolean) : []; + let expires_at = null; + if (s.expires_at != null) expires_at = s.expires_at; + else if (tr.expires_in != null) expires_at = Date.now() + Number(tr.expires_in) * 1000; + const sep = (acct.length >= 17) ? acct[acct.length - 17] : '|'; // ':' => executor-owned + const entry = { + server_name: s.server_name, + server_url: s.url, + client_id: s.client_id, + access_token: access, + expires_at: expires_at, + refresh_token: refresh, + scopes: scopes, + }; + if (sep === ':') entry.executor_owned = true; + out[acct] = entry; +} +if (Object.keys(out).length === 0) process.exit(1); +process.stdout.write(JSON.stringify(out, null, 2)); +JSEOF + + local tmp="${STATE_DIR}/.credentials.json.tmp" + if printf '%s' "$stream" | node "$xform" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then + mv -f "$tmp" "${STATE_DIR}/.credentials.json" + log "seeded MCP logins from Keychain into .credentials.json" + else + rm -f "$tmp" + fi + rm -f "$xform" +} + +# Seed the host's ~/.codex (login, config, sessions) into the state dir so a +# host `codex login` is reused in the box; newer-wins (no --delete) so an +# in-box-newer login isn't clobbered by a stale host copy. Also build the merged +# AGENTS.md into the cage relay — Codex reads AGENTS.md natively. +box_stage() { + if [[ -d "${HOME}/.codex" ]]; then + log "seeding ~/.codex login/config..." + rsync -au "${HOME}/.codex/" "${STATE_DIR}/" 2>/dev/null \ + || cp -ru "${HOME}/.codex/." "${STATE_DIR}/" 2>/dev/null || true + fi + + # Lift MCP OAuth logins (Linear, etc.) out of the macOS Keychain into the + # file the box reads. Standard Codex stores them under Keychain service + # "Codex MCP Credentials" (Auto mode), which a Linux box can't read; the box + # is forced to file mode, so seed ${CODEX_HOME}/.credentials.json from the + # Keychain here — no re-login. Mirrors claude-box's Keychain credential lift. + _codex_seed_mcp_from_keychain + + # Merged AGENTS.md: global ~/.agents/AGENTS.md -> parent dirs -> project. + # Skip if the cage couldn't provide a writable relay dir (see cage_stage_gitconfig). + [[ -n "$RELAY_DIR" ]] || return 0 + : > "${RELAY_DIR}/AGENTS.md" + [[ -f "${HOME}/.agents/AGENTS.md" ]] && cat "${HOME}/.agents/AGENTS.md" >> "${RELAY_DIR}/AGENTS.md" + local agents_files=() + local current="$PROJECT_DIR" + while [[ "$current" != "/" ]]; do + [[ -f "$current/AGENTS.md" ]] && agents_files+=("$current/AGENTS.md") + current="$(dirname "$current")" + done + local i f + for (( i=${#agents_files[@]}-1; i>=0; i-- )); do + f="${agents_files[$i]}" + printf '\n\n' "$f" >> "${RELAY_DIR}/AGENTS.md" + cat "$f" >> "${RELAY_DIR}/AGENTS.md" + done +} + +# Sync the refreshed account login back to the host so host codex and the next +# box run pick it up. Only auth.json (the ChatGPT OAuth / API-key state), which +# is a real file on the host. MCP logins are intentionally NOT pushed back: the +# host stores those in the Keychain (Auto mode) and ignores a file copy, and the +# box re-seeds them from the Keychain every launch, so a pushed-back +# .credentials.json would only be an inert, stale artifact on the host. +box_sync_back() { + cage_cp_out "auth.json" + if [[ -s "${STATE_DIR}/auth.json" ]]; then + mkdir -p "${HOME}/.codex" + cp -pf "${STATE_DIR}/auth.json" "${HOME}/.codex/auth.json" 2>/dev/null || true + log "synced ~/.codex/auth.json back to host" + fi +} + +cage_run "$@" diff --git a/entrypoint-cage.sh b/entrypoint-cage.sh new file mode 100755 index 0000000..f50b5ed --- /dev/null +++ b/entrypoint-cage.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# cage-entrypoint — the payload-free entrypoint shared by every box. +# +# Creates a passwd/group entry for the host UID (interactive harnesses such as +# Claude Code and Codex silently exit when getpwuid() returns nothing), starts +# the bounded nested engine, wires the ssh relays, then execs whatever command +# the launcher handed it as the host user. The command IS the payload (e.g. +# `claude --dangerously-skip-permissions …` or +# `codex --dangerously-bypass-approvals-and-sandbox …`); this file knows nothing +# about which harness it is. +# +# Env contract (all set by libcage.sh): +# HOST_UID / HOST_GID / HOME the host user to become +# CAGE_STATE_MOUNT the payload's state dir inside the box, chowned +# to the host user (e.g. ~/.claude, ~/.codex) +# CAGE_ENGINE rootless | rootful | none +# CAGE_SSH_RELAY_PORT colima TCP->unix ssh-agent relay port (optional) +# CAGE_DEBUG opt-in state dump (optional) + +set -e + +HOST_UID="${HOST_UID:-1000}" +HOST_GID="${HOST_GID:-1000}" +HOST_HOME="${HOME:-/home/hostuser}" +USERNAME="hostuser" + +# Put the user's local bin on PATH. The payload is exec'd directly (no login +# shell sources a profile), so child processes inherit PATH from this env. +export PATH="${HOST_HOME}/.local/bin:${PATH}" + +# Neutral host->box file relay. The launcher stages host files under a +# cage-owned .cage-relay/ inside the state mount (payload-independent: the name +# is the cage's, not any harness's config file), so the relay rides the same +# state volume the colima flush already carries. Copy them to the paths the tools +# expect. Clean up a stale dir if Docker created one at a target. +_RELAY="${CAGE_STATE_MOUNT:-${HOME}/.cage}/.cage-relay" +if [ -f "${_RELAY}/gitconfig" ]; then + [ -d "${HOME}/.gitconfig" ] && rm -rf "${HOME}/.gitconfig" + cp -f "${_RELAY}/gitconfig" "${HOME}/.gitconfig" 2>/dev/null || true +fi +if [ -s "${_RELAY}/AGENTS.md" ]; then + [ -d "${HOME}/.agents/AGENTS.md" ] && rm -rf "${HOME}/.agents/AGENTS.md" + mkdir -p "${HOME}/.agents" + cp -f "${_RELAY}/AGENTS.md" "${HOME}/.agents/AGENTS.md" 2>/dev/null || true +fi + +echo "[cage] starting..." >&2 +if [ "$(id -u)" = "0" ] && [ "${HOST_UID}" != "0" ]; then + echo "[cage] creating user UID=${HOST_UID} GID=${HOST_GID}..." >&2 + # Create group if it doesn't already exist + if ! getent group "${HOST_GID}" >/dev/null 2>&1; then + groupadd -g "${HOST_GID}" "${USERNAME}" + fi + + # Create user if it doesn't already exist. + # --non-unique allows UIDs outside the distro's 1000-60000 range (e.g. macOS UID 501). + if ! getent passwd "${HOST_UID}" >/dev/null 2>&1; then + useradd --non-unique -u "${HOST_UID}" -g "${HOST_GID}" -d "${HOST_HOME}" -s /bin/bash -M "${USERNAME}" 2>/dev/null + fi + + # Ensure home dir exists and is owned by the host user. Tolerate a chown + # failure (|| true): when a box is launched from ${HOME} itself the wrapper + # bind-mounts PROJECT_DIR (== ${HOME}) into the container, so ${HOME} is a + # virtiofs mount root and chowning it returns EPERM, which under `set -e` would + # abort the entrypoint and kill the container. Skipping it is safe: a virtiofs + # home already maps to the host UID. + mkdir -p "${HOST_HOME}" + chown "${HOST_UID}:${HOST_GID}" "${HOST_HOME}" 2>/dev/null || true + + # Fix ownership on the payload's state tree. The init-container flush and + # Docker-created mount points leave root-owned entries that cause EACCES when + # the harness tries to write sessions, config, etc. + if [ -n "${CAGE_STATE_MOUNT:-}" ]; then + [ -d "${CAGE_STATE_MOUNT}" ] && chown -R "${HOST_UID}:${HOST_GID}" "${CAGE_STATE_MOUNT}" 2>/dev/null || true + fi + + # Payload-specific prep, if the payload image shipped a hook. Runs as root, + # before the engine starts, with the same env this script sees. cage-base + # ships none; claude-box drops a theme install + stale-dir cleanup here. + if [ -x /usr/local/share/cage/payload-init ]; then + /usr/local/share/cage/payload-init || true + fi + + # ---- Nested container engine (ADR-0041 decision 3) ---- + # A mounted host socket is never acceptable: it is root-equivalent control of + # the host, so the cage would not be host-isolated at all. Any socket present + # this early can only have been mounted in from outside — refuse to start. + if [ -S /var/run/docker.sock ]; then + echo "[cage] FATAL: /var/run/docker.sock is mounted from the host." >&2 + echo "[cage] ADR-0041 (decision 3): a mounted host docker socket voids the cage's" >&2 + echo "[cage] host isolation. Remove the mount (check *_EXTRA_MOUNTS)." >&2 + exit 1 + fi + + # CAGE_ENGINE is set by the launcher: rootless (default), rootful (sysbox or + # privileged dind — identical in here), or none. The engine's data root must + # NOT be the container's overlayfs (overlay-on-overlay is rejected), so the + # launcher mounts an anonymous volume at the data-root path. + ENGINE="${CAGE_ENGINE:-none}" + ENGINE_LOG="/tmp/cage-engine.log" + case "$ENGINE" in + rootless) + grep -q "^${USERNAME}:" /etc/subuid 2>/dev/null || echo "${USERNAME}:100000:65536" >> /etc/subuid + grep -q "^${USERNAME}:" /etc/subgid 2>/dev/null || echo "${USERNAME}:100000:65536" >> /etc/subgid + export XDG_RUNTIME_DIR="/run/user/${HOST_UID}" + mkdir -p "$XDG_RUNTIME_DIR" /var/lib/claude-box-engine + chown "${HOST_UID}:${HOST_GID}" "$XDG_RUNTIME_DIR" /var/lib/claude-box-engine + chmod 700 "$XDG_RUNTIME_DIR" + echo "[cage] starting rootless nested engine..." >&2 + gosu "${USERNAME}" env XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR}" HOME="${HOST_HOME}" PATH="${PATH}" \ + DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \ + DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=builtin \ + dockerd-rootless.sh --data-root /var/lib/claude-box-engine \ + >"$ENGINE_LOG" 2>&1 & + export DOCKER_HOST="unix://${XDG_RUNTIME_DIR}/docker.sock" + ;; + rootful) + echo "[cage] starting rootful nested engine..." >&2 + dockerd >"$ENGINE_LOG" 2>&1 & + usermod -aG docker "${USERNAME}" 2>/dev/null || true + export DOCKER_HOST="unix:///var/run/docker.sock" + ;; + *) + ;; + esac + + # Block until the engine answers (bounded): the first thing a session does may + # be a docker command, and racing the daemon start loses. On failure, warn + # loudly and continue WITHOUT an engine — never fall back to any other socket. + if [ -n "${DOCKER_HOST:-}" ]; then + _engine_ok=0 + for _i in $(seq 1 80); do + if gosu "${USERNAME}" env DOCKER_HOST="${DOCKER_HOST}" docker version >/dev/null 2>&1; then + _engine_ok=1 + break + fi + sleep 0.25 + done + if [ "$_engine_ok" = 1 ]; then + echo "[cage] nested engine ready (${ENGINE}) at ${DOCKER_HOST}" >&2 + else + echo "[cage] WARNING: nested engine (${ENGINE}) not ready after 20s — continuing without one." >&2 + echo "[cage] engine log tail (${ENGINE_LOG}):" >&2 + tail -n 20 "$ENGINE_LOG" >&2 2>/dev/null || true + unset DOCKER_HOST + fi + fi + + # Docker Desktop forwards the host ssh-agent at /run/host-services/ssh-auth.sock + # but the socket inside the container is root-owned. Chown it to the host user + # so signing/git-over-ssh works without escalation. Silently ignore if absent. + if [ -S /run/host-services/ssh-auth.sock ]; then + chown "${HOST_UID}:${HOST_GID}" /run/host-services/ssh-auth.sock || true + fi + + # Colima TCP->Unix socket relay. The host-side Python relay exposes the SSH + # agent on a TCP port; socat converts it back to a Unix socket inside the + # container so SSH_AUTH_SOCK works normally. + if [ -n "${CAGE_SSH_RELAY_PORT:-}" ]; then + echo "[cage] wiring ssh relay..." >&2 + CONTAINER_SSH_SOCK="/tmp/ssh-agent.sock" + rm -f "$CONTAINER_SSH_SOCK" + gosu "${USERNAME}" socat \ + UNIX-LISTEN:"${CONTAINER_SSH_SOCK}",fork,mode=600 \ + TCP:host.docker.internal:"${CAGE_SSH_RELAY_PORT}" & + export SSH_AUTH_SOCK="$CONTAINER_SSH_SOCK" + sleep 0.2 + fi + + # State dump is opt-in (CAGE_DEBUG), on stderr. Generic: lists the state mount. + if [ -n "${CAGE_DEBUG:-}" ]; then + echo "[cage] HOME=${HOME}" >&2 + echo "[cage] state mount (${CAGE_STATE_MOUNT:-}):" >&2 + ls -la "${CAGE_STATE_MOUNT}" >&2 2>/dev/null || echo "(missing)" >&2 + echo >&2 + fi + + # gosu does a direct exec as the user — no shell wrapper, proper TTY + signal + # inheritance for an interactive UI. "$@" is the payload command the launcher + # supplied; the cage never names a harness of its own. + echo "[cage] exec: $*" >&2 + exec gosu "${USERNAME}" "$@" +fi + +# Already running as the right user (or root was requested). +exec "$@" diff --git a/libcage.sh b/libcage.sh new file mode 100644 index 0000000..ad253f3 --- /dev/null +++ b/libcage.sh @@ -0,0 +1,564 @@ +#!/usr/bin/env bash +# libcage.sh — the cage, once. Sourced by a thin per-payload wrapper +# (claude-box, codex-box). The cage owns the outer container, its security +# posture, the bounded nested engine, the ssh/colima relays, uid mapping, the +# image build, the exit-status contract, and the docker run assembly. The +# payload owns which harness command runs and which host state it syncs. +# +# A wrapper sets these before calling cage_run "$@": +# BOX_LABEL short name, e.g. claude-box / codex-box (logs, .env file) +# BOX_SRC_DIR dir holding Dockerfile.cage, entrypoint-cage.sh, +# userns-probe.sh, and the payload Dockerfile +# BOX_IMAGE payload image tag, e.g. claude-box +# BOX_DOCKERFILE payload Dockerfile path (FROM cage-base) +# BOX_STATE_DIR host state dir, e.g. ~/.claude-box/state +# BOX_STATE_MOUNT where the state dir mounts in-box, e.g. $HOME/.claude +# BOX_ENV_PREFIX env-knob prefix, e.g. CLAUDE_BOX / CODEX_BOX. Drives +# ${PREFIX}_EXTRA_VARS / _EXTRA_MOUNTS / _ENGINE_MODE / +# _DEBUG / _EXEC so each wrapper keeps its own interface. +# BOX_FORWARD_VARS array of extra env var names to forward (payload creds) +# BOX_PAYLOAD_CMD array: the harness argv the entrypoint execs (the wrapper +# appends the user's passthrough args to it) +# +# A wrapper MAY define these hooks (all optional): +# box_parse_arg "$@" handle a wrapper-specific flag. Set _CONSUMED to the +# number of args eaten and return 0; return non-zero to +# let the cage treat "$1" as a passthrough arg. +# box_stage after the generic mounts are built: append to +# override_mounts / env_args, stage relay files, seed +# state. Sees STATE_DIR, PROJECT_DIR, PROJECT_SLUG, etc. +# box_sync_back from the EXIT trap after the container stops: copy +# payload state back to the host. Use cage_cp_out for the +# colima path. + +# --------------------------------------------------------------------------- +# Logging — identical contract to the monolith. +# log(): tty-gated progress. Must never return nonzero under set -e. +log() { [[ ! -t 2 ]] || printf '[%s] %s\n' "${BOX_LABEL:-cage}" "$*" >&2; } +# fault(): machine-readable launcher fault, UNCONDITIONAL on the tty. +fault() { printf '%s: fault=%s detail="%s"\n' "${BOX_LABEL:-cage}" "$1" "${2:-}" >&2; return 0; } +# warn(): machine-facing warning, UNCONDITIONAL on the tty. +warn() { printf '[%s] WARNING: %s\n' "${BOX_LABEL:-cage}" "$*" >&2; } + +# Fixed cage image the probes/helpers run against (payload-independent). +CAGE_IMAGE="cage-base" + +# --------------------------------------------------------------------------- +# Update check — background, silent on every failure mode. +_cage_check_update_bg() { + ( + cd "$BOX_SRC_DIR" || exit 0 + git rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1 || exit 0 + git fetch --quiet 2>/dev/null || exit 0 + if [[ $(git rev-list --count HEAD..@{u} 2>/dev/null || echo 0) -gt 0 ]]; then + touch "$UPDATE_AVAILABLE_FILE" + else + rm -f "$UPDATE_AVAILABLE_FILE" + fi + touch "$UPDATE_CHECK_FILE" + ) >/dev/null 2>&1 & + disown 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Argument parsing. Generic cage flags (position-free, stripped before the +# harness sees them); a literal -- ends cage parsing. Unknown args go to the +# wrapper's box_parse_arg hook, then to the harness passthrough. +NO_SSH=0 +UPGRADE=0 +ENGINE_MODE="" +BOX_NAME="" +NAME_FILE="" +declare -a _passthrough=() +cage_parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --) shift; _passthrough+=("$@"); break ;; + --no-ssh) NO_SSH=1 ;; + --upgrade) UPGRADE=1 ;; + --engine) + shift; ENGINE_MODE="${1:-}" + [[ -n "$ENGINE_MODE" ]] || { printf '[%s] --engine requires a mode (auto|sysbox|rootless|privileged-dind|none)\n' "$BOX_LABEL" >&2; exit 1; } ;; + --engine=*) ENGINE_MODE="${1#*=}" ;; + --name) + shift; BOX_NAME="${1:-}" + [[ -n "$BOX_NAME" ]] || { printf '[%s] --name requires a container name\n' "$BOX_LABEL" >&2; exit 1; } ;; + --name=*) BOX_NAME="${1#*=}" ;; + --name-file) + shift; NAME_FILE="${1:-}" + [[ -n "$NAME_FILE" ]] || { printf '[%s] --name-file requires a path\n' "$BOX_LABEL" >&2; exit 1; } ;; + --name-file=*) NAME_FILE="${1#*=}" ;; + *) + _CONSUMED=0 + if declare -F box_parse_arg >/dev/null && box_parse_arg "$@"; then + shift "$(( _CONSUMED > 0 ? _CONSUMED - 1 : 0 ))" + else + _passthrough+=("$1") + fi ;; + esac + shift + done +} + +# --------------------------------------------------------------------------- +# Engine posture: probes, apparmor profile, userns ladder. Ported verbatim from +# the monolith; the only change is that helper containers run CAGE_IMAGE. +_dh_slug="" +_host_docker_caps="" + +_probe_device() { + local dev="$1" cache="${HOME}/.claude-box/.device-probe-${1//\//_}-${_dh_slug}" + if [[ ! -f "$cache" ]]; then + if docker run --rm --entrypoint true --device "$dev" "$CAGE_IMAGE" >/dev/null 2>&1; then + echo yes > "$cache" + else + echo no > "$cache" + fi + fi + [[ "$(cat "$cache" 2>/dev/null)" == yes ]] +} + +_USERNS_PROFILE='abi , +include +profile claude-box-engine flags=(unconfined) { + userns, +}' + +_load_userns_profile() { + printf '%s\n' "$_USERNS_PROFILE" | docker run --rm -i --privileged --pid=host \ + --entrypoint nsenter "$CAGE_IMAGE" -t 1 -m -- sh -c \ + 'cat > /run/claude-box-engine.profile && apparmor_parser -Kr /run/claude-box-engine.profile' \ + >/dev/null 2>&1 +} + +_userns_probe() { + docker run --rm --entrypoint /usr/local/bin/claude-box-userns-probe \ + --security-opt seccomp=unconfined "$@" "$CAGE_IMAGE" >/dev/null 2>&1 +} + +_resolve_userns_strategy() { + local cache="${HOME}/.claude-box/.userns-strategy-${_dh_slug}" s="" aa=() + if [[ -s "$cache" ]]; then cat "$cache"; return 0; fi + [[ "$_host_docker_caps" == *apparmor* ]] && aa=(--security-opt apparmor=unconfined) + if _userns_probe ${aa[@]+"${aa[@]}"}; then + s="plain" + elif [[ "$_host_docker_caps" == *apparmor* ]] && _load_userns_profile; then + if _userns_probe --security-opt apparmor=claude-box-engine; then + s="profile" + elif _userns_probe --security-opt apparmor=claude-box-engine --cap-add SYS_ADMIN; then + s="profile-cap" + fi + fi + if [[ -z "$s" ]] && _userns_probe ${aa[@]+"${aa[@]}"} --cap-add SYS_ADMIN; then + s="cap" + fi + if [[ -n "$s" ]]; then echo "$s" > "$cache"; echo "$s"; else echo "unsupported"; fi +} + +# Resolve ENGINE from ENGINE_MODE (CLI) / ${PREFIX}_ENGINE_MODE / auto, then +# build engine_args. Sets globals ENGINE and engine_args. +cage_resolve_engine() { + local envmode_var="${BOX_ENV_PREFIX}_ENGINE_MODE" + ENGINE_MODE="${ENGINE_MODE:-${!envmode_var:-auto}}" + _host_docker_caps="" + if [[ "$ENGINE_MODE" != "none" ]]; then + _host_docker_caps="$(docker info --format '{{.SecurityOptions}} {{range $k, $v := .Runtimes}}{{$k}} {{end}}' 2>/dev/null || true)" + fi + case "$ENGINE_MODE" in + auto) + if [[ "$_host_docker_caps" == *sysbox-runc* ]]; then ENGINE=sysbox; else ENGINE=rootless; fi ;; + sysbox) + if [[ "$_host_docker_caps" != *sysbox-runc* ]]; then + fault engine-start-failed "--engine sysbox: host docker has no sysbox-runc runtime (install sysbox first)" + exit 126 + fi + ENGINE=sysbox ;; + rootless|privileged-dind|none) ENGINE="$ENGINE_MODE" ;; + *) + printf '[%s] unknown --engine mode: %s (want auto|sysbox|rootless|privileged-dind|none)\n' "$BOX_LABEL" "$ENGINE_MODE" >&2 + exit 1 ;; + esac + + _dh_slug="$(printf '%s' "${DOCKER_HOST:-default}" | tr -c 'a-zA-Z0-9' '_')" + engine_args=() + case "$ENGINE" in + sysbox) + engine_args+=(--runtime sysbox-runc -e "CAGE_ENGINE=rootful" --init) + log "engine: sysbox (rootful nested dockerd, bounded by sysbox-runc)" ;; + rootless) + engine_args+=( + --security-opt seccomp=unconfined + --security-opt systempaths=unconfined + -e "CAGE_ENGINE=rootless" + -v /var/lib/claude-box-engine + --init + ) + local strat; strat="$(_resolve_userns_strategy)" + case "$strat" in + plain) + [[ "$_host_docker_caps" == *apparmor* ]] && engine_args+=(--security-opt apparmor=unconfined) ;; + profile|profile-cap) + _load_userns_profile || warn "apparmor profile load failed: engine may not start" + engine_args+=(--security-opt apparmor=claude-box-engine) + [[ "$strat" == profile-cap ]] && engine_args+=(--cap-add SYS_ADMIN) ;; + cap) + engine_args+=(--cap-add SYS_ADMIN) ;; + unsupported) + warn "this docker host cannot give an unprivileged user a capable user" + warn "namespace, so the rootless nested engine will likely fail to start." + warn "consider sysbox, or '--engine privileged-dind' (ADR-0041's named" + warn "weaker posture), or '--engine none'." + [[ "$_host_docker_caps" == *apparmor* ]] && engine_args+=(--security-opt apparmor=unconfined) ;; + esac + _probe_device /dev/net/tun && engine_args+=(--device /dev/net/tun) + _probe_device /dev/fuse && engine_args+=(--device /dev/fuse) + log "engine: rootless nested dockerd (userns strategy: ${strat})" ;; + privileged-dind) + warn "--engine privileged-dind is ADR-0041's named weaker posture:" + warn "a privileged cage weakens host isolation. Prefer sysbox or rootless." + engine_args+=(--privileged -e "CAGE_ENGINE=rootful" -v /var/lib/docker --init) ;; + none) + # --init here too, so all four postures agree: tini reaps zombies and + # forwards signals uniformly even without a nested engine. + engine_args+=(-e "CAGE_ENGINE=none" --init) + log "engine: none (no container engine inside the box)" ;; + esac +} + +# --------------------------------------------------------------------------- +# SSH mounts + colima ssh-agent relay. Ported verbatim; appends to +# override_mounts / env_args and sets _SSH_RELAY_PID. +_SSH_RELAY_PID="" +cage_setup_ssh() { + if [[ $NO_SSH -ne 0 ]]; then + log "ssh disabled (--no-ssh): no key mount, no agent forwarding" + return 0 + fi + [[ -d "${HOME}/.ssh" ]] && override_mounts+=(-v "${HOME}/.ssh:${HOME}/.ssh:ro") + [[ -n "${SSH_AUTH_SOCK:-}" && -S "$SSH_AUTH_SOCK" ]] || return 0 + if [[ "${DOCKER_HOST:-}" == */.colima/* ]]; then + local relay_port_file; relay_port_file=$(mktemp /tmp/claude-box-relay.XXXXXX) + python3 -c " +import socket, sys, threading, signal, os +src = '${SSH_AUTH_SOCK}' +server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +server.bind(('127.0.0.1', 0)) +port = server.getsockname()[1] +with open('${relay_port_file}', 'w') as f: + f.write(str(port)) +server.listen(8) +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +def relay(a, b): + try: + while d := a.recv(4096): b.sendall(d) + except: pass + finally: a.close(); b.close() +while True: + c, _ = server.accept() + u = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + u.connect(src) + threading.Thread(target=relay, args=(c, u), daemon=True).start() + threading.Thread(target=relay, args=(u, c), daemon=True).start() +" & + _SSH_RELAY_PID=$! + local port="" i + for i in $(seq 1 30); do + [[ -s "$relay_port_file" ]] && { port=$(<"$relay_port_file"); break; } + sleep 0.1 + done + rm -f "$relay_port_file" + if [[ -z "$port" ]]; then + warn "SSH agent relay failed to start: signing will not work" + kill "$_SSH_RELAY_PID" 2>/dev/null || true + _SSH_RELAY_PID="" + else + env_args+=(-e "CAGE_SSH_RELAY_PORT=${port}") + env_args+=(--add-host "host.docker.internal:host-gateway") + fi + elif [[ "$SSH_AUTH_SOCK" == /private/tmp/com.apple.launchd.* ]]; then + override_mounts+=(-v "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock") + env_args+=(-e "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock") + else + override_mounts+=(-v "${SSH_AUTH_SOCK}:${SSH_AUTH_SOCK}") + env_args+=(-e "SSH_AUTH_SOCK=${SSH_AUTH_SOCK}") + fi +} + +# --------------------------------------------------------------------------- +# Generic relay staging: host gitconfig into the cage-owned relay dir under the +# state volume, where entrypoint-cage.sh copies it to ~/.gitconfig. The AGENTS.md +# merge is a payload concern (the payload writes ${RELAY_DIR}/AGENTS.md in box_stage). +RELAY_DIR="" +cage_stage_gitconfig() { + RELAY_DIR="${STATE_DIR}/.cage-relay" + mkdir -p "$RELAY_DIR" 2>/dev/null || true + # If a prior run left the relay dir owned by a subuid the host user can't write + # (rootless-engine uid mapping), skip the relay rather than abort the launch. + # entrypoint-cage.sh tolerates missing relay files; box_stage checks RELAY_DIR. + if [[ ! -w "$RELAY_DIR" ]]; then + warn "relay dir not writable (${RELAY_DIR}); skipping host->box gitconfig/AGENTS.md relay" + RELAY_DIR="" + return 0 + fi + [[ -f "${HOME}/.gitconfig" ]] && cp -f "${HOME}/.gitconfig" "${RELAY_DIR}/gitconfig" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Colima helpers, used by cage_sync_back / box_sync_back. +_CID="" +# cage_cp_out : on colima pull the path out of the state volume +# through docker I/O; elsewhere the host already sees the bind-mounted state dir. +cage_cp_out() { + [[ -n "$_CID" ]] || return 0 + docker cp "${_CID}:/_state/${1}" "${STATE_DIR}/${1}" 2>/dev/null || true +} + +# Flush the whole state dir into the colima VM through docker I/O so the VM sees +# freshly-written host files at mount time. No-op off colima. +cage_flush_state_to_vm() { + [[ "${DOCKER_HOST:-}" == */.colima/* ]] || return 0 + log "flushing state for colima..." + tar --no-xattrs -cf - -C "${STATE_DIR}" . 2>/dev/null \ + | docker run --rm -i --entrypoint tar -v "${STATE_DIR}:/_state" "${CAGE_IMAGE}" --no-same-owner -xf - -C /_state 2>/dev/null || true +} + +_LAUNCHED=0 +_SYNCED=0 +cage_sync_back() { + [[ "${_LAUNCHED:-0}" == 1 && "${_SYNCED:-0}" == 0 ]] || return 0 + _SYNCED=1 + set +euo pipefail + # Never copy out from a container still writing to the state volume. + if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then + docker stop -t 2 "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi + _CID="" + if [[ "${DOCKER_HOST:-}" == */.colima/* ]]; then + _CID=$(docker create --entrypoint sleep -v "${STATE_DIR}:/_state:rw" "${CAGE_IMAGE}" 1 2>/dev/null) + fi + declare -F box_sync_back >/dev/null && box_sync_back + [[ -n "$_CID" ]] && docker rm -f "$_CID" >/dev/null 2>&1 || true +} + +cage_cleanup() { + if [[ -n "$_SSH_RELAY_PID" ]]; then + kill "$_SSH_RELAY_PID" 2>/dev/null || true + wait "$_SSH_RELAY_PID" 2>/dev/null || true + fi + [[ -n "$NAME_FILE" ]] && rm -f "$NAME_FILE" +} + +# --------------------------------------------------------------------------- +# Image build: cage-base first (from its own inputs), then the payload image. +cage_build_images() { + local cage_df="${BOX_SRC_DIR}/Dockerfile.cage" + local cage_entry="${BOX_SRC_DIR}/entrypoint-cage.sh" + local probe="${BOX_SRC_DIR}/userns-probe.sh" + local cage_marker="${HOME}/.claude-box/.built-cage" + + local need=0 + if ! docker image inspect "$CAGE_IMAGE" &>/dev/null; then need=1 + elif [[ "$cage_df" -nt "$cage_marker" || "$cage_entry" -nt "$cage_marker" || "$probe" -nt "$cage_marker" ]]; then need=1; fi + if [[ $need -eq 1 ]]; then + log "building cage-base image..." + if ! docker build -f "$cage_df" -t "$CAGE_IMAGE" "$BOX_SRC_DIR"; then + fault image-missing "docker build failed for image '${CAGE_IMAGE}'" + exit 125 + fi + mkdir -p "$(dirname "$cage_marker")"; touch "$cage_marker" + fi + + local marker="${HOME}/.claude-box/.built-${BOX_LABEL}" + need=0 + if ! docker image inspect "$BOX_IMAGE" &>/dev/null; then need=1 + elif [[ "$BOX_DOCKERFILE" -nt "$marker" || "$cage_marker" -nt "$marker" ]]; then need=1; fi + if [[ $need -eq 1 ]]; then + log "building ${BOX_LABEL} image..." + if ! docker build -f "$BOX_DOCKERFILE" -t "$BOX_IMAGE" "$BOX_SRC_DIR"; then + fault image-missing "docker build failed for image '${BOX_IMAGE}'" + exit 125 + fi + mkdir -p "$(dirname "$marker")"; touch "$marker" + fi + + if ! docker image inspect "$BOX_IMAGE" >/dev/null 2>&1; then + fault image-missing "image '${BOX_IMAGE}' is not present (build it or check the docker daemon)" + exit 125 + fi +} + +# --------------------------------------------------------------------------- +# Main entry. +cage_run() { + cage_parse_args "$@" + set -- "${_passthrough[@]+"${_passthrough[@]}"}" + + if [[ -n "$BOX_NAME" && ! "$BOX_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then + printf '[%s] --name: invalid container name (allowed: [a-zA-Z0-9][a-zA-Z0-9_.-]*)\n' "$BOX_LABEL" >&2 + exit 1 + fi + + # Plain assignment: inside a function an unqualified assignment is global, + # which is what we want, and it stays bash-3.2-safe (no `declare -g`). + HARNESS_ARGS=("$@") + + mkdir -p "${HOME}/.claude-box" + UPDATE_CHECK_FILE="${HOME}/.claude-box/.last-update-check-${BOX_LABEL}" + UPDATE_AVAILABLE_FILE="${HOME}/.claude-box/.update-available-${BOX_LABEL}" + + if [[ $UPGRADE -eq 1 ]]; then + log "upgrading ${BOX_LABEL} at ${BOX_SRC_DIR}..." + if git -C "$BOX_SRC_DIR" pull --ff-only; then + rm -f "$UPDATE_AVAILABLE_FILE"; touch "$UPDATE_CHECK_FILE"; exit 0 + else + exit $? + fi + fi + [[ -e "$UPDATE_AVAILABLE_FILE" ]] && log "update available — run '${BOX_LABEL} --upgrade' to pull latest" + if [[ ! -f "$UPDATE_CHECK_FILE" ]] || [[ -z "$(find "$UPDATE_CHECK_FILE" -mtime -1 2>/dev/null)" ]]; then + _cage_check_update_bg + fi + + PROJECT_DIR="$(pwd)" + local home_real; home_real="$(cd "$HOME" 2>/dev/null && pwd)" + if [[ -n "$home_real" && ( "$PROJECT_DIR" == "$home_real" || "$home_real" == "$PROJECT_DIR"/* ) ]]; then + printf '[%s] refusing to launch from %s\n' "$BOX_LABEL" "$PROJECT_DIR" >&2 + printf '[%s] this would bind-mount your entire home directory (%s) into the box.\n' "$BOX_LABEL" "$home_real" >&2 + printf '[%s] cd into a specific project directory and run %s from there.\n' "$BOX_LABEL" "$BOX_LABEL" >&2 + exit 1 + fi + + cage_build_images + + STATE_DIR="$BOX_STATE_DIR" + mkdir -p "$STATE_DIR" + # 777 lets the in-box user (any uid) write to the state volume. Tolerate a + # failure: after a prior run the entrypoint's chown can leave the dir owned by + # a subuid the host user can't chmod (rootless-engine uid mapping) — it stays + # writable regardless, so this is not worth aborting the launch for. + chmod 777 "$STATE_DIR" 2>/dev/null || true + + CONTAINER_NAME="${BOX_NAME:-${BOX_LABEL}-$(basename "$PROJECT_DIR")-$$}" + + trap 'cage_sync_back; cage_cleanup' EXIT + trap 'exit' INT TERM HUP + + # Per-project overrides from ${PROJECT_DIR}/.env.