mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
214 lines
8.5 KiB
Bash
214 lines
8.5 KiB
Bash
#!/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[@]}"}
|