Files
EOS/ai/run.sh
T

214 lines
8.5 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# ai/run.sh
# Entry point for the AkkudoktorEOS AI development sandbox (Linux/macOS).
#
# Launcher:
# - Sanity-checks ai/compose.yaml for the main sandbox controls. This is not
# a complete validation; the protection of the sandbox definition is the
# read-only mount of ai/ in the container.
# - Only forwards arguments after `--` as the in-container command, never
# as docker compose options.
# - Requires every published port to bind to 127.0.0.1 (loopback on the host).
# - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
# - Optional publishing of the compose ports (AI_SANDBOX_PORTS=1).
# - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
# - Regenerates ai/.env atomically with HOST_UID/HOST_GID and mode 0600.
# - Works with bash 3.2 (macOS /bin/bash).
set -Eeuo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
COMPOSE_FILE="ai/compose.yaml"
ENV_FILE="ai/.env"
ENV_EXAMPLE="ai/.env.example"
trap 'echo "ERROR: ai/run.sh failed at line $LINENO (exit $?)." >&2' ERR
# ---------------------------------------------------------------------------
# 0. Arguments. Docker compose options cannot be passed; everything after
# `--` is the command to run in the container.
# ---------------------------------------------------------------------------
EXTRA_CMD=()
if [[ $# -gt 0 ]]; then
if [[ "$1" != "--" ]]; then
echo "ERROR: extra arguments must be supplied after '--' as the in-container command." >&2
echo " Example: ./ai/run.sh -- bash -lc 'pytest -q'" >&2
exit 2
fi
shift
EXTRA_CMD=("$@")
fi
# ---------------------------------------------------------------------------
# 1. Prerequisites.
# ---------------------------------------------------------------------------
echo "==> Checking prerequisites..."
command -v docker >/dev/null || { echo "ERROR: docker not installed." >&2; exit 1; }
docker compose version >/dev/null 2>&1 \
|| { echo "ERROR: docker compose v2 not available." >&2; exit 1; }
if ! docker info >/dev/null 2>&1; then
echo "ERROR: docker daemon not reachable." >&2
echo " - Is the daemon running? (systemctl status docker)" >&2
echo " - Is your user in 'docker' group? (groups)" >&2
exit 1
fi
[[ -f pyproject.toml ]] \
|| { echo "ERROR: pyproject.toml not found; run from inside the repo." >&2; exit 1; }
[[ -f "$COMPOSE_FILE" ]] \
|| { echo "ERROR: $COMPOSE_FILE missing." >&2; exit 1; }
# ---------------------------------------------------------------------------
# 2. Sanity-check the sandbox controls in compose.yaml.
# This catches accidental edits, not every weakening edit. The agent
# cannot edit compose.yaml from inside the sandbox (ai/ is read-only there);
# review changes to ai/ before you start the sandbox.
# ---------------------------------------------------------------------------
echo "==> Checking sandbox controls in $COMPOSE_FILE ..."
compose_text="$(cat "$COMPOSE_FILE")"
require_in_compose() {
local needle="$1" why="$2"
if ! grep -Eq -- "$needle" <<<"$compose_text"; then
echo "ERROR: compose.yaml does not contain: $needle" >&2
echo " $why" >&2
exit 3
fi
}
require_in_compose 'cap_drop:[[:space:]]*$|cap_drop:[[:space:]]*\[' 'Missing cap_drop (must drop ALL).'
require_in_compose '-\s*"?ALL"?|\[[[:space:]]*"?ALL"?[[:space:]]*\]' 'cap_drop must include ALL.'
require_in_compose 'no-new-privileges:true|no-new-privileges:[[:space:]]*true' 'Missing no-new-privileges:true.'
require_in_compose 'read_only:[[:space:]]*true' 'Missing read_only:true.'
require_in_compose '\.\./ai:/workspace/ai:ro' 'ai/ must be mounted read-only.'
# The docker socket must never be mounted.
if grep -Eq '/var/run/docker\.sock' <<<"$compose_text"; then
echo "ERROR: compose.yaml mounts the Docker socket. This is an escape vector." >&2
exit 3
fi
# privileged must be false or absent.
if grep -Eq 'privileged:[[:space:]]*true' <<<"$compose_text"; then
echo "ERROR: compose.yaml sets privileged:true; refusing to start." >&2
exit 3
fi
# network_mode: host must never be used.
if grep -Eq 'network_mode:[[:space:]]*"?host"?' <<<"$compose_text"; then
echo "ERROR: compose.yaml sets network_mode: host; refusing to start." >&2
exit 3
fi
# Every published port MUST bind to 127.0.0.1. Ports are allowed here because
# the sandbox is used for local testing; the constraint is that nothing inside
# becomes reachable from the LAN/VPN.
if grep -Eq '^[[:space:]]*ports:' <<<"$compose_text"; then
if ! awk '
/^[[:space:]]*ports:[[:space:]]*$/ { inports=1; next }
inports && /^[[:space:]]*-/ {
line=$0
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
gsub(/["'"'"']/, "", line)
if (line !~ /^127\.0\.0\.1:/) {
print "OFFENDING: " line > "/dev/stderr"
bad=1
}
next
}
inports && !/^[[:space:]]*-/ && !/^[[:space:]]*$/ { inports=0 }
END { exit bad ? 1 : 0 }
' <<<"$compose_text"; then
echo "ERROR: compose.yaml publishes a port on a non-loopback interface." >&2
echo " Every entry under 'ports:' must start with '127.0.0.1:'." >&2
echo " Change e.g. \"8503:8503\" to \"127.0.0.1:8503:8503\"." >&2
exit 3
fi
fi
# ---------------------------------------------------------------------------
# 3. Regenerate ai/.env atomically (mode 0600). Do not leak .bak files.
# ---------------------------------------------------------------------------
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$ENV_EXAMPLE" ]]; then
echo "==> Creating $ENV_FILE from $ENV_EXAMPLE"
install -m 0600 "$ENV_EXAMPLE" "$ENV_FILE"
else
echo "==> Creating empty $ENV_FILE"
: >"$ENV_FILE"
chmod 0600 "$ENV_FILE"
fi
fi
chmod 0600 "$ENV_FILE"
HOST_UID="$(id -u)"
HOST_GID="$(id -g)"
tmp_env="$(mktemp "${ENV_FILE}.XXXXXX")"
trap 'rm -f "$tmp_env"' EXIT
{
grep -Ev '^HOST_(UID|GID)=' "$ENV_FILE" || true
printf 'HOST_UID=%s\n' "$HOST_UID"
printf 'HOST_GID=%s\n' "$HOST_GID"
} >"$tmp_env"
mv -f "$tmp_env" "$ENV_FILE"
chmod 0600 "$ENV_FILE"
trap 'echo "ERROR: ai/run.sh failed at line $LINENO (exit $?)." >&2' ERR
# ---------------------------------------------------------------------------
# 4. Rebuild is opt-in. A tampered Dockerfile/entrypoint cannot be silently
# rebuilt by a wrapper invocation.
# ---------------------------------------------------------------------------
BUILD_FLAG=()
if [[ "${AI_SANDBOX_REBUILD:-0}" == "1" ]]; then
echo "==> Rebuilding image (AI_SANDBOX_REBUILD=1)"
BUILD_FLAG=(--build)
else
echo "==> Using cached image (set AI_SANDBOX_REBUILD=1 to rebuild)"
fi
# ---------------------------------------------------------------------------
# 5. Port publishing is opt-in. `docker compose run` publishes the ports of
# compose.yaml (loopback-only, verified above) only with --service-ports.
# Off by default, so several sandboxes can run in parallel.
# ---------------------------------------------------------------------------
PORTS_FLAG=()
if [[ "${AI_SANDBOX_PORTS:-0}" == "1" ]]; then
echo "==> Publishing the compose ports on 127.0.0.1 (AI_SANDBOX_PORTS=1)"
PORTS_FLAG=(--service-ports)
else
echo "==> Ports not published (set AI_SANDBOX_PORTS=1 to publish them)"
fi
# ---------------------------------------------------------------------------
# 6. ai/skills is read-only in the container unless AI_SANDBOX_SKILLS_RW=1.
# Always set the mode explicitly, so a value in ai/.env cannot change it.
# ---------------------------------------------------------------------------
if [[ "${AI_SANDBOX_SKILLS_RW:-0}" == "1" ]]; then
echo "==> ai/skills is writable in the sandbox (AI_SANDBOX_SKILLS_RW=1)"
echo " Review every change to ai/skills before you commit or push it."
export AI_SANDBOX_SKILLS_MODE=rw
else
export AI_SANDBOX_SKILLS_MODE=ro
fi
# ---------------------------------------------------------------------------
# 7. Run. Use exec so signals reach docker compose directly.
# ${arr[@]+"${arr[@]}"} expands an empty array safely under `set -u` in
# bash < 4.4 (macOS /bin/bash).
# ---------------------------------------------------------------------------
echo "==> Starting AI dev sandbox (UID=$HOST_UID, GID=$HOST_GID)..."
exec docker compose -f "$COMPOSE_FILE" run --rm \
${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"} \
${PORTS_FLAG[@]+"${PORTS_FLAG[@]}"} \
ai-dev \
${EXTRA_CMD[@]+"${EXTRA_CMD[@]}"}