diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml new file mode 100644 index 0000000000..bc369b56d9 --- /dev/null +++ b/.github/workflows/release-images.yml @@ -0,0 +1,68 @@ +name: Release Images + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Image tag (defaults to latest) + required: false + default: latest + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + GATEWAY_IMAGE: ghcr.io/jwbron/egg-gateway + SANDBOX_IMAGE: ghcr.io/jwbron/egg-sandbox + +jobs: + build-and-push: + name: Build and Push Images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image tag + id: tag + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push gateway image + uses: docker/build-push-action@v6 + with: + context: . + file: gateway/Dockerfile + push: true + platforms: linux/amd64 + tags: | + ${{ env.GATEWAY_IMAGE }}:${{ steps.tag.outputs.tag }} + ${{ env.GATEWAY_IMAGE }}:latest + + - name: Build and push sandbox image + uses: docker/build-push-action@v6 + with: + context: . + file: sandbox/Dockerfile + push: true + platforms: linux/amd64 + tags: | + ${{ env.SANDBOX_IMAGE }}:${{ steps.tag.outputs.tag }} + ${{ env.SANDBOX_IMAGE }}:latest diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000000..b6fafbf93c --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,105 @@ +name: Test Action + +on: + pull_request: + branches: [main] + paths: + - "action/**" + - ".github/workflows/test-action.yml" + workflow_dispatch: + +jobs: + config-generation: + name: Config Generation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Test generate-config.sh + env: + INPUT_ANTHROPIC_OAUTH_TOKEN: test-token-placeholder + INPUT_GITHUB_TOKEN: test-github-token + INPUT_BOT_USERNAME: egg + RUNNER_TEMP: ${{ runner.temp }} + run: | + bash action/generate-config.sh + + # GITHUB_* vars are built-in GHA variables that cannot be overridden, + # so we validate against the actual runtime values + CONFIG_DIR="${RUNNER_TEMP}/egg-config-${GITHUB_RUN_ID}" + REPO_NAME="${GITHUB_REPOSITORY#*/}" + + echo "=== Validating repositories.yaml ===" + cat "$CONFIG_DIR/repositories.yaml" + + # Verify required fields use actual GHA context values + # Use -F for fixed string matching — GITHUB_ACTOR may contain + # regex-special characters (e.g., "app[bot]") + grep -qF "github_username: ${GITHUB_ACTOR}" "$CONFIG_DIR/repositories.yaml" + grep -qF "bot_username: egg" "$CONFIG_DIR/repositories.yaml" + grep -qF "${GITHUB_REPOSITORY}" "$CONFIG_DIR/repositories.yaml" + grep -qF "auth_mode: user" "$CONFIG_DIR/repositories.yaml" + grep -qF "/home/egg/repos/${REPO_NAME}" "$CONFIG_DIR/repositories.yaml" + + echo "=== Validating secrets.env ===" + # Don't cat secrets, just verify they exist + test -f "$CONFIG_DIR/secrets.env" + grep -q "CLAUDE_CODE_OAUTH_TOKEN=" "$CONFIG_DIR/secrets.env" + # GITHUB_USER_TOKEN should NOT be in secrets.env — gateway reads it + # from the environment variable, not from this file + if grep -q "GITHUB_USER_TOKEN=" "$CONFIG_DIR/secrets.env"; then + echo "ERROR: GITHUB_USER_TOKEN should not be in secrets.env" + exit 1 + fi + + echo "=== Validating launcher-secret ===" + test -f "$CONFIG_DIR/launcher-secret" + test -s "$CONFIG_DIR/launcher-secret" + + echo "All config generation tests passed" + + - name: Test generate-config.sh with bot token + env: + INPUT_ANTHROPIC_OAUTH_TOKEN: test-token-placeholder + INPUT_GITHUB_TOKEN: test-github-token + INPUT_BOT_GITHUB_TOKEN: test-bot-token + INPUT_BOT_USERNAME: my-bot + RUNNER_TEMP: ${{ runner.temp }} + run: | + # Clean up config from previous step so we validate fresh output + rm -rf "${RUNNER_TEMP}/egg-config-${GITHUB_RUN_ID}" + + bash action/generate-config.sh + + CONFIG_DIR="${RUNNER_TEMP}/egg-config-${GITHUB_RUN_ID}" + + grep -q "bot_username: my-bot" "$CONFIG_DIR/repositories.yaml" + grep -q "auth_mode: bot" "$CONFIG_DIR/repositories.yaml" + grep -q "BOT_GITHUB_TOKEN=" "$CONFIG_DIR/secrets.env" + + echo "Bot token config generation tests passed" + + shellcheck: + name: Shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run shellcheck on action scripts + run: | + shellcheck --severity=warning action/entrypoint.sh action/generate-config.sh + + integration-test: + name: Integration Test + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Run action (smoke test) + uses: ./action + with: + prompt: "Echo 'Hello from egg' and exit successfully" + anthropic-oauth-token: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + timeout: "5" diff --git a/action/action.yml b/action/action.yml new file mode 100644 index 0000000000..76117d55e4 --- /dev/null +++ b/action/action.yml @@ -0,0 +1,70 @@ +name: egg +description: Run egg (autonomous coding agent) in GitHub Actions +branding: + icon: terminal + color: orange + +inputs: + prompt: + description: Task prompt for Claude Code + required: true + anthropic-oauth-token: + description: Anthropic OAuth token for Claude API + required: true + github-token: + description: GitHub token for git operations + required: true + default: ${{ github.token }} + bot-github-token: + description: Bot GitHub App token (optional, for bot identity) + required: false + bot-username: + description: Bot GitHub username for filtering self-comments + required: false + default: egg + mode: + description: "Network mode: public, private, or auto (auto-detects from repo visibility)" + required: false + default: auto + timeout: + description: Timeout in minutes + required: false + default: "30" + model: + description: Claude model to use + required: false + default: opus + image-tag: + description: Docker image tag to use (e.g., v1.0.0 or latest) + required: false + default: latest + +outputs: + exit-code: + description: Sandbox container exit code (0 = success) + value: ${{ steps.run.outputs.exit-code }} + pr-url: + description: URL of created PR, if any + value: ${{ steps.run.outputs.pr-url }} + log-file: + description: Path to full Claude output log + value: ${{ steps.run.outputs.log-file }} + +runs: + using: composite + steps: + - name: Run egg + id: run + shell: bash + env: + INPUT_PROMPT: ${{ inputs.prompt }} + INPUT_ANTHROPIC_OAUTH_TOKEN: ${{ inputs.anthropic-oauth-token }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_BOT_GITHUB_TOKEN: ${{ inputs.bot-github-token }} + INPUT_BOT_USERNAME: ${{ inputs.bot-username }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_TIMEOUT: ${{ inputs.timeout }} + INPUT_MODEL: ${{ inputs.model }} + INPUT_IMAGE_TAG: ${{ inputs.image-tag }} + GITHUB_EVENT_REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }} + run: ${{ github.action_path }}/entrypoint.sh diff --git a/action/entrypoint.sh b/action/entrypoint.sh new file mode 100755 index 0000000000..f38c9c6c8f --- /dev/null +++ b/action/entrypoint.sh @@ -0,0 +1,527 @@ +#!/usr/bin/env bash +# entrypoint.sh — Orchestrate the egg stack (gateway + sandbox) inside GitHub Actions +# +# This script replicates the orchestration flow from sandbox/egg_lib/runtime.py +# and sandbox/egg_lib/gateway.py in bash, for use inside a GHA runner. +# +# Steps: +# 1. Pull pre-built images from GHCR +# 2. Create Docker networks with dynamic subnet allocation +# 3. Detect mode (auto/public/private) +# 4. Generate config (via generate-config.sh) +# 5. Start gateway container (dual-homed) +# 6. Health check +# 7. Allocate container IP +# 8. Create session via gateway API +# 9. Start sandbox container +# 10. Capture output +# 11. Cleanup (trap EXIT) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GATEWAY_PORT=9848 +GATEWAY_PROXY_PORT=3129 +CONTAINER_HOME="/home/egg" +RUN_ID="${GITHUB_RUN_ID:-$$}" + +GATEWAY_CONTAINER="egg-gha-gateway-${RUN_ID}" +SANDBOX_CONTAINER="egg-gha-sandbox-${RUN_ID}" +ISOLATED_NETWORK="egg-gha-isolated-${RUN_ID}" +EXTERNAL_NETWORK="egg-gha-external-${RUN_ID}" + +IMAGE_TAG="${INPUT_IMAGE_TAG:-latest}" +GATEWAY_IMAGE="ghcr.io/jwbron/egg-gateway:${IMAGE_TAG}" +SANDBOX_IMAGE="ghcr.io/jwbron/egg-sandbox:${IMAGE_TAG}" + +TIMEOUT_MINUTES="${INPUT_TIMEOUT:-30}" +MODEL="${INPUT_MODEL:-opus}" +LOG_FILE="${RUNNER_TEMP:-/tmp}/egg-output-${RUN_ID}.log" + +# State tracking for cleanup +GATEWAY_STARTED=false +SANDBOX_STARTED=false +NETWORKS_CREATED=false +SESSION_TOKEN="" +LAUNCHER_SECRET="" +GATEWAY_IP_ISOLATED="" +GATEWAY_IP_EXTERNAL="" + +# --------------------------------------------------------------------------- +# Cleanup handler +# --------------------------------------------------------------------------- + +cleanup() { + echo "=== Cleanup ===" + local exit_code=$? + + # Delete session if we have a token + if [[ -n "$SESSION_TOKEN" && -n "$GATEWAY_IP_ISOLATED" ]]; then + echo "Deleting session..." + curl -sf -X DELETE \ + "http://${GATEWAY_IP_ISOLATED}:${GATEWAY_PORT}/api/v1/sessions/${SESSION_TOKEN}" \ + -H "Authorization: Bearer ${LAUNCHER_SECRET}" \ + 2>/dev/null || echo "Session cleanup failed (non-fatal)" + fi + + # Stop and remove containers + if [[ "$SANDBOX_STARTED" == "true" ]]; then + echo "Stopping sandbox container..." + docker stop -t 10 "$SANDBOX_CONTAINER" 2>/dev/null || true + docker rm -f "$SANDBOX_CONTAINER" 2>/dev/null || true + fi + + if [[ "$GATEWAY_STARTED" == "true" ]]; then + echo "Stopping gateway container..." + docker stop -t 5 "$GATEWAY_CONTAINER" 2>/dev/null || true + docker rm -f "$GATEWAY_CONTAINER" 2>/dev/null || true + fi + + # Remove networks + if [[ "$NETWORKS_CREATED" == "true" ]]; then + echo "Removing networks..." + docker network rm "$ISOLATED_NETWORK" 2>/dev/null || true + docker network rm "$EXTERNAL_NETWORK" 2>/dev/null || true + fi + + echo "Cleanup complete (exit code: $exit_code)" + return "$exit_code" +} + +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Helper: find an unused 172.x.0.0/24 subnet +# --------------------------------------------------------------------------- + +allocate_subnet() { + # Collect subnets already in use by Docker into an associative array for O(1) lookups + local -A used_subnets + while read -r subnet; do + [[ -n "$subnet" ]] && used_subnets["$subnet"]=1 + done < <(docker network ls --format '{{.ID}}' | while read -r net_id; do + docker network inspect "$net_id" --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null + done) + + # Scan 172.28-172.63 for an unused /24 + for major in $(seq 28 63); do + for minor in $(seq 0 255); do + local candidate="172.${major}.${minor}.0/24" + if [[ -z "${used_subnets[$candidate]+x}" ]]; then + echo "$candidate" + return 0 + fi + done + done + + echo "ERROR: No unused subnet found" >&2 + return 1 +} + +# --------------------------------------------------------------------------- +# Helper: allocate next available IP from a Docker network +# --------------------------------------------------------------------------- + +allocate_container_ip() { + local network="$1" + local subnet + + subnet=$(docker network inspect "$network" --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}') + + # Get assigned IPs + local assigned_ips + assigned_ips=$(docker network inspect "$network" \ + --format '{{range .Containers}}{{.IPv4Address}} {{end}}' 2>/dev/null | tr ' ' '\n' | cut -d/ -f1) + + # Parse subnet base (e.g., 172.28.0) + local base + base=$(echo "$subnet" | cut -d. -f1-3) + + # .1 is typically the gateway, .2 is reserved for our gateway container + # Start allocating from .10 + for host in $(seq 10 254); do + local candidate="${base}.${host}" + if ! echo "$assigned_ips" | grep -qxF "$candidate"; then + echo "$candidate" + return 0 + fi + done + + echo "ERROR: No available IPs in $network" >&2 + return 1 +} + +# --------------------------------------------------------------------------- +# Step 1: Pull images +# --------------------------------------------------------------------------- + +echo "=== Step 1: Pull images ===" +docker pull "$GATEWAY_IMAGE" +docker pull "$SANDBOX_IMAGE" + +# --------------------------------------------------------------------------- +# Step 2: Create networks with dynamic subnets +# --------------------------------------------------------------------------- + +echo "=== Step 2: Create Docker networks ===" + +ISOLATED_SUBNET=$(allocate_subnet) +echo "Isolated network subnet: $ISOLATED_SUBNET" + +ISOLATED_BASE=$(echo "$ISOLATED_SUBNET" | cut -d. -f1-3) +GATEWAY_IP_ISOLATED="${ISOLATED_BASE}.2" + +# Create isolated network first so the next allocate_subnet sees it as used +docker network create \ + --driver bridge \ + --subnet "$ISOLATED_SUBNET" \ + "$ISOLATED_NETWORK" + +# Allocate external subnet (now sees isolated subnet as taken) +EXTERNAL_SUBNET=$(allocate_subnet) +echo "External network subnet: $EXTERNAL_SUBNET" + +EXTERNAL_BASE=$(echo "$EXTERNAL_SUBNET" | cut -d. -f1-3) +GATEWAY_IP_EXTERNAL="${EXTERNAL_BASE}.2" + +docker network create \ + --driver bridge \ + --subnet "$EXTERNAL_SUBNET" \ + "$EXTERNAL_NETWORK" + +NETWORKS_CREATED=true + +# --------------------------------------------------------------------------- +# Step 3: Detect mode +# --------------------------------------------------------------------------- + +echo "=== Step 3: Detect mode ===" + +MODE="${INPUT_MODE:-auto}" + +if [[ "$MODE" == "auto" ]]; then + REPO_VISIBILITY="${GITHUB_EVENT_REPOSITORY_VISIBILITY:-}" + if [[ -z "$REPO_VISIBILITY" ]]; then + echo "WARNING: Repository visibility not available in event context, defaulting to public" + REPO_VISIBILITY="public" + fi + + case "$REPO_VISIBILITY" in + private|internal) + MODE="private" + ;; + *) + MODE="public" + ;; + esac + echo "Auto-detected mode: $MODE (repo visibility: $REPO_VISIBILITY)" +else + echo "Using configured mode: $MODE" +fi + +# --------------------------------------------------------------------------- +# Step 4: Generate config +# --------------------------------------------------------------------------- + +echo "=== Step 4: Generate config ===" + +# Export inputs for generate-config.sh +export INPUT_ANTHROPIC_OAUTH_TOKEN="${INPUT_ANTHROPIC_OAUTH_TOKEN:?anthropic-oauth-token is required}" +export INPUT_GITHUB_TOKEN="${INPUT_GITHUB_TOKEN:?github-token is required}" +export INPUT_BOT_GITHUB_TOKEN="${INPUT_BOT_GITHUB_TOKEN:-}" +export INPUT_BOT_USERNAME="${INPUT_BOT_USERNAME:-egg}" + +"$SCRIPT_DIR/generate-config.sh" + +CONFIG_DIR="${RUNNER_TEMP:-/tmp}/egg-config-${RUN_ID}" +LAUNCHER_SECRET=$(cat "$CONFIG_DIR/launcher-secret") + +# --------------------------------------------------------------------------- +# Step 5: Start gateway container +# --------------------------------------------------------------------------- + +echo "=== Step 5: Start gateway ===" + +REPO_NAME="${GITHUB_REPOSITORY#*/}" +WORKSPACE="${GITHUB_WORKSPACE:-.}" +WORKTREES_DIR="${RUNNER_TEMP:-/tmp}/egg-worktrees-${RUN_ID}" +STATE_DIR="${RUNNER_TEMP:-/tmp}/egg-state-${RUN_ID}" +CERTS_DIR="${RUNNER_TEMP:-/tmp}/egg-certs-${RUN_ID}" + +mkdir -p "$WORKTREES_DIR" "$STATE_DIR" "$CERTS_DIR" + +# Start gateway on isolated network first +docker run -d \ + --name "$GATEWAY_CONTAINER" \ + --network "$ISOLATED_NETWORK" \ + --ip "$GATEWAY_IP_ISOLATED" \ + --security-opt label=disable \ + -v "$CONFIG_DIR/repositories.yaml:/config/repositories.yaml:ro" \ + -v "$CONFIG_DIR:${CONTAINER_HOME}/.config/egg:ro" \ + -v "$CONFIG_DIR:/secrets:ro" \ + -v "$WORKSPACE:${CONTAINER_HOME}/repos/${REPO_NAME}" \ + -v "$WORKTREES_DIR:${CONTAINER_HOME}/.egg-worktrees" \ + -v "$STATE_DIR:${CONTAINER_HOME}/.egg-state" \ + -v "$CERTS_DIR:/shared/certs" \ + -e "EGG_REPO_CONFIG=/config/repositories.yaml" \ + -e "HOME=${CONTAINER_HOME}" \ + -e "HOST_UID=$(id -u)" \ + -e "HOST_GID=$(id -g)" \ + -e "GITHUB_USER_TOKEN=${INPUT_GITHUB_TOKEN}" \ + -e "CLAUDE_CODE_OAUTH_TOKEN=${INPUT_ANTHROPIC_OAUTH_TOKEN}" \ + -e "GATEWAY_BOT_NAME=${INPUT_BOT_USERNAME:-egg}" \ + -e "GATEWAY_BOT_BRANCH_PREFIX=${INPUT_BOT_USERNAME:-egg}" \ + -e "EGG_USER_GIT_NAME=${GITHUB_ACTOR}" \ + -e "EGG_USER_GIT_EMAIL=${GITHUB_ACTOR_ID:-0}+${GITHUB_ACTOR}@users.noreply.github.com" \ + ${INPUT_BOT_GITHUB_TOKEN:+-e "BOT_GITHUB_TOKEN=${INPUT_BOT_GITHUB_TOKEN}"} \ + "$GATEWAY_IMAGE" + +GATEWAY_STARTED=true + +# Connect gateway to external network (dual-homed) +echo "Connecting gateway to external network..." +docker network connect \ + --ip "$GATEWAY_IP_EXTERNAL" \ + "$EXTERNAL_NETWORK" \ + "$GATEWAY_CONTAINER" + +# --------------------------------------------------------------------------- +# Step 6: Health check +# --------------------------------------------------------------------------- + +echo "=== Step 6: Health check ===" + +HEALTH_URL="http://${GATEWAY_IP_ISOLATED}:${GATEWAY_PORT}/api/v1/health" +HEALTH_TIMEOUT=60 +SECONDS=0 + +while (( SECONDS < HEALTH_TIMEOUT )); do + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + echo "Gateway healthy after ${SECONDS}s" + break + fi + sleep 1 +done + +if (( SECONDS >= HEALTH_TIMEOUT )); then + echo "ERROR: Gateway failed health check after ${HEALTH_TIMEOUT}s" + echo "Gateway logs:" + docker logs "$GATEWAY_CONTAINER" 2>&1 | tail -50 + exit 1 +fi + +# In private mode, also verify that the Squid proxy is ready. +# Claude Code's non-gateway traffic routes through Squid, so it must be +# accepting connections before the sandbox starts. Accept any HTTP response +# (including 403/407) as proof of connectivity. +if [[ "$MODE" == "private" ]]; then + echo "Checking proxy readiness..." + SECONDS=0 + while (( SECONDS < 15 )); do + if curl -sf --proxy "http://${GATEWAY_IP_ISOLATED}:${GATEWAY_PROXY_PORT}" \ + -o /dev/null -w '' https://api.anthropic.com/ 2>/dev/null; then + echo "Proxy healthy after ${SECONDS}s" + break + fi + # Squid may return 403/407 — any response means it's up + HTTP_CODE=$(curl -s --proxy "http://${GATEWAY_IP_ISOLATED}:${GATEWAY_PROXY_PORT}" \ + -o /dev/null -w '%{http_code}' https://api.anthropic.com/ 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" != "000" ]]; then + echo "Proxy healthy after ${SECONDS}s (HTTP $HTTP_CODE)" + break + fi + sleep 1 + done + if (( SECONDS >= 15 )); then + echo "WARNING: Proxy health check timed out — sandbox may experience connectivity issues" + fi +fi + +# --------------------------------------------------------------------------- +# Step 7: Allocate container IP +# --------------------------------------------------------------------------- + +echo "=== Step 7: Allocate container IP ===" + +if [[ "$MODE" == "private" ]]; then + SANDBOX_NETWORK="$ISOLATED_NETWORK" + SANDBOX_GATEWAY_IP="$GATEWAY_IP_ISOLATED" +else + SANDBOX_NETWORK="$EXTERNAL_NETWORK" + SANDBOX_GATEWAY_IP="$GATEWAY_IP_EXTERNAL" +fi + +CONTAINER_IP=$(allocate_container_ip "$SANDBOX_NETWORK") +echo "Allocated container IP: $CONTAINER_IP on $SANDBOX_NETWORK" + +# --------------------------------------------------------------------------- +# Step 8: Create session +# --------------------------------------------------------------------------- + +echo "=== Step 8: Create session ===" + +SESSION_RESPONSE=$(curl -sf -X POST \ + "http://${GATEWAY_IP_ISOLATED}:${GATEWAY_PORT}/api/v1/sessions/create" \ + -H "Authorization: Bearer ${LAUNCHER_SECRET}" \ + -H "Content-Type: application/json" \ + -d "{ + \"container_id\": \"${SANDBOX_CONTAINER}\", + \"container_ip\": \"${CONTAINER_IP}\", + \"mode\": \"${MODE}\", + \"repos\": [\"${GITHUB_REPOSITORY}\"], + \"uid\": $(id -u), + \"gid\": $(id -g) + }") + +SESSION_TOKEN=$(echo "$SESSION_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['session_token'])") +WORKTREE_PATH=$(echo "$SESSION_RESPONSE" | python3 -c " +import sys, json +data = json.load(sys.stdin)['data'] +worktrees = data.get('worktrees', {}) +# Get the first (and only) worktree path +for path in worktrees.values(): + print(path) + break +") + +echo "Session token: ${SESSION_TOKEN:0:8}..." +echo "Worktree path: $WORKTREE_PATH" + +# --------------------------------------------------------------------------- +# Step 9: Start sandbox container +# --------------------------------------------------------------------------- + +echo "=== Step 9: Start sandbox ===" + +# Build sandbox docker run command +SANDBOX_CMD=( + docker run + --name "$SANDBOX_CONTAINER" + --network "$SANDBOX_NETWORK" + --ip "$CONTAINER_IP" + --security-opt label=disable + --stop-timeout 30 + --add-host "egg-gateway:${SANDBOX_GATEWAY_IP}" + -e "RUNTIME_UID=$(id -u)" + -e "RUNTIME_GID=$(id -g)" + -e "CONTAINER_ID=${SANDBOX_CONTAINER}" + -e "EGG_SESSION_TOKEN=${SESSION_TOKEN}" + -e "GATEWAY_URL=http://egg-gateway:${GATEWAY_PORT}" + -e "ANTHROPIC_AUTH_METHOD=oauth" + -e "EGG_QUIET=1" +) + +# Mode-specific network settings +if [[ "$MODE" == "private" ]]; then + SANDBOX_CMD+=( + --dns 0.0.0.0 + -e "PRIVATE_MODE=true" + -e "NO_PROXY=localhost,127.0.0.1,egg-gateway" + -e "no_proxy=localhost,127.0.0.1,egg-gateway" + ) + # NOTE: We intentionally do NOT pass HTTP_PROXY/HTTPS_PROXY to the sandbox + # container. The sandbox entrypoint's run_exec() path (used by GHA) does not + # strip proxy vars before exec'ing the command, unlike run_interactive(). + # If set, Claude Code (Node.js) would route all traffic — including calls to + # ANTHROPIC_BASE_URL (egg-gateway:9848) — through Squid, which only allows + # api.anthropic.com. Network isolation is still enforced via --dns 0.0.0.0 + # and the isolated network topology. The proper fix is to add proxy stripping + # to run_exec() in sandbox/entrypoint.py (tracked separately). +else + SANDBOX_CMD+=(-e "PRIVATE_MODE=false") +fi + +# Mount worktree and shadow .git +SANDBOX_CMD+=( + -v "${WORKTREE_PATH}:${CONTAINER_HOME}/repos/${REPO_NAME}:rw" + --mount "type=bind,source=/dev/null,destination=${CONTAINER_HOME}/repos/${REPO_NAME}/.git,readonly" +) + +# Mount shared certs for CA trust +SANDBOX_CMD+=(-v "${CERTS_DIR}:/shared/certs:ro") + +# Add model configuration +if [[ -n "${MODEL:-}" ]]; then + SANDBOX_CMD+=(-e "CLAUDE_MODEL=${MODEL}") +fi + +# Image and command — run Claude Code in non-interactive exec mode +SANDBOX_CMD+=( + "$SANDBOX_IMAGE" + claude + --print + --output-format stream-json + --model "$MODEL" + "$INPUT_PROMPT" +) + +# Start timeout watchdog in background +( + sleep $((TIMEOUT_MINUTES * 60)) + echo "WARNING: Timeout reached (${TIMEOUT_MINUTES}m), stopping sandbox..." + docker stop -t 30 "$SANDBOX_CONTAINER" 2>/dev/null || true +) & +TIMEOUT_PID=$! + +# Run sandbox and capture output +echo "Running Claude Code with prompt: ${INPUT_PROMPT:0:100}..." +SANDBOX_STARTED=true + +set +e +"${SANDBOX_CMD[@]}" 2>&1 | tee "$LOG_FILE" +SANDBOX_EXIT_CODE=${PIPESTATUS[0]} +set -e + +# Kill timeout watchdog +kill "$TIMEOUT_PID" 2>/dev/null || true +wait "$TIMEOUT_PID" 2>/dev/null || true + +echo "Sandbox exited with code: $SANDBOX_EXIT_CODE" + +# --------------------------------------------------------------------------- +# Step 10: Capture output +# --------------------------------------------------------------------------- + +echo "=== Step 10: Capture output ===" + +# Write outputs to GITHUB_OUTPUT +{ + echo "exit-code=${SANDBOX_EXIT_CODE}" + echo "log-file=${LOG_FILE}" +} >> "${GITHUB_OUTPUT:-/dev/null}" + +# Extract PR URL from output (look for GitHub PR URLs in the log) +PR_URL=$(grep -oP 'https://github\.com/[^/]+/[^/]+/pull/\d+' "$LOG_FILE" | tail -1 || true) +if [[ -n "$PR_URL" ]]; then + echo "pr-url=${PR_URL}" >> "${GITHUB_OUTPUT:-/dev/null}" + echo "PR created: $PR_URL" +fi + +# Write to job summary +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "## egg Run Summary" + echo "" + echo "**Exit code:** \`${SANDBOX_EXIT_CODE}\`" + echo "**Mode:** ${MODE}" + echo "**Model:** ${MODEL}" + if [[ -n "$PR_URL" ]]; then + echo "**PR:** ${PR_URL}" + fi + echo "" + echo "
Output log" + echo "" + echo '```' + # Truncate to avoid exceeding step summary limits (1MB) + head -c 500000 "$LOG_FILE" + echo '```' + echo "
" + } >> "$GITHUB_STEP_SUMMARY" +fi + +# Exit with sandbox exit code +exit "$SANDBOX_EXIT_CODE" diff --git a/action/generate-config.sh b/action/generate-config.sh new file mode 100755 index 0000000000..742494636c --- /dev/null +++ b/action/generate-config.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# generate-config.sh — Generate ephemeral gateway configuration for GitHub Actions +# +# Creates a temp directory containing the three files the gateway needs: +# - repositories.yaml (repo config for config/repo_config.py) +# - secrets.env (Anthropic credentials for anthropic_credentials.py) +# - launcher-secret (auth token for launcher API calls) +# +# Required environment variables: +# GITHUB_REPOSITORY — owner/repo (e.g., "jwbron/egg") +# GITHUB_ACTOR — GitHub username triggering the workflow +# GITHUB_ACTOR_ID — Numeric ID for noreply email +# INPUT_ANTHROPIC_OAUTH_TOKEN — Anthropic OAuth token +# INPUT_GITHUB_TOKEN — GitHub token for git operations +# +# Optional environment variables: +# INPUT_BOT_GITHUB_TOKEN — Bot GitHub App token +# INPUT_BOT_USERNAME — Bot username (default: "egg") +# +# Outputs: +# EGG_CONFIG_DIR — path to the generated config directory (written to GITHUB_OUTPUT) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Validate required inputs +# --------------------------------------------------------------------------- + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_ACTOR:?GITHUB_ACTOR is required}" +: "${INPUT_ANTHROPIC_OAUTH_TOKEN:?anthropic-oauth-token input is required}" +: "${INPUT_GITHUB_TOKEN:?github-token input is required}" + +REPO_NAME="${GITHUB_REPOSITORY#*/}" +BOT_USERNAME="${INPUT_BOT_USERNAME:-egg}" +RUN_ID="${GITHUB_RUN_ID:-$$}" + +# --------------------------------------------------------------------------- +# Create config directory +# --------------------------------------------------------------------------- + +CONFIG_DIR="${RUNNER_TEMP:-/tmp}/egg-config-${RUN_ID}" +mkdir -p "$CONFIG_DIR" + +# --------------------------------------------------------------------------- +# Generate repositories.yaml +# --------------------------------------------------------------------------- + +# Determine auth_mode based on whether a bot token is provided +if [[ -n "${INPUT_BOT_GITHUB_TOKEN:-}" ]]; then + AUTH_MODE="bot" +else + AUTH_MODE="user" +fi + +cat > "$CONFIG_DIR/repositories.yaml" < "$CONFIG_DIR/secrets.env" <> "$CONFIG_DIR/secrets.env" +fi + +# Add bot identity config +echo "GATEWAY_BOT_NAME=${BOT_USERNAME}" >> "$CONFIG_DIR/secrets.env" +echo "GATEWAY_BOT_BRANCH_PREFIX=${BOT_USERNAME}" >> "$CONFIG_DIR/secrets.env" + +chmod 600 "$CONFIG_DIR/secrets.env" + +# --------------------------------------------------------------------------- +# Generate launcher-secret +# --------------------------------------------------------------------------- + +openssl rand -base64 32 > "$CONFIG_DIR/launcher-secret" +chmod 600 "$CONFIG_DIR/launcher-secret" + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + +echo "EGG_CONFIG_DIR=$CONFIG_DIR" >> "${GITHUB_OUTPUT:-/dev/null}" +echo "Config directory: $CONFIG_DIR" +echo " repositories.yaml: $(wc -l < "$CONFIG_DIR/repositories.yaml") lines" +echo " secrets.env: $(wc -l < "$CONFIG_DIR/secrets.env") lines" +echo " launcher-secret: generated" diff --git a/docs/adr/README.md b/docs/adr/README.md index 658091f72c..341b7c3cfc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ ADRs are organized by implementation status: | ADR | Summary | |-----|---------| | [Autonomous Software Engineer](in-progress/ADR-Autonomous-Software-Engineer.md) | Core system architecture, security model, operating principles | +| [GitHub Actions Support](in-progress/ADR-GitHub-Actions-Support.md) | Run egg as a GitHub Action with checked-out repo (#78, PR #111) | | [Internet Tool Access Lockdown](in-progress/ADR-Internet-Tool-Access-Lockdown.md) | Security restrictions on agent network access (Phase 1 implemented, Phase 2 planned) | ### Not Implemented diff --git a/docs/adr/in-progress/ADR-GitHub-Actions-Support.md b/docs/adr/in-progress/ADR-GitHub-Actions-Support.md new file mode 100644 index 0000000000..42e96c54b7 --- /dev/null +++ b/docs/adr/in-progress/ADR-GitHub-Actions-Support.md @@ -0,0 +1,262 @@ +# ADR: GitHub Actions Support + +**Status:** In Progress (PR #111 — Phase 1 MVP) +**Issue:** #78 + +## Context + +egg currently runs on a local machine with Docker, consisting of a gateway sidecar (policy enforcement, credential injection, worktree management) and a sandbox container (Claude Code CLI). Users want to run egg in GitHub Actions to automate code tasks like fixing issues, responding to PR review comments, or running on a schedule. + +GitHub Actions runners provide: +- A checked-out repository at `$GITHUB_WORKSPACE` +- Docker support (build and run containers) +- `GITHUB_TOKEN` for repository access +- Secrets for storing API keys +- Structured output via step outputs and job summaries + +The challenge is adapting egg's dual-container model to work inside a GHA runner while maintaining the security boundary between sandbox and credentials. + +## Decision + +Create a **composite GitHub Action** that orchestrates the full egg stack (gateway + sandbox) within a GHA runner, accepting the checked-out repo as the working repository. + +### Architecture + +``` +┌─ GitHub Actions Runner ────────────────────────────────┐ +│ │ +│ $GITHUB_WORKSPACE/ ← actions/checkout'd repo │ +│ │ +│ ┌─ egg-gateway container ───────────────────────────┐ │ +│ │ • Policy enforcement (branch push rules) │ │ +│ │ • GitHub token injection (from GHA secret) │ │ +│ │ • Worktree creation for mounted repo │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ egg-sandbox container ───────────────────────────┐ │ +│ │ • Claude Code CLI (non-interactive, --exec mode) │ │ +│ │ • Repo at /home/egg/repos/ │ │ +│ │ • git/gh wrappers → gateway │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ egg-isolated network (172.32.0.0/24) │ +└─────────────────────────────────────────────────────────┘ +``` + +### User-Facing Interface + +```yaml +# Example: user's workflow file +jobs: + egg-task: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: jwbron/egg@v1 + with: + prompt: "Fix the failing tests in src/auth/" + anthropic-oauth-token: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + timeout: 30 +``` + +### Action Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `prompt` | Yes | — | Task/prompt to pass to Claude Code | +| `anthropic-oauth-token` | Yes | — | Anthropic OAuth token for Claude API | +| `github-token` | Yes | — | GitHub token for git operations (user PAT or `GITHUB_TOKEN`) | +| `bot-github-token` | No | — | GitHub App token for bot identity (if using bot mode) | +| `bot-username` | No | `egg` | Bot GitHub username (for filtering self-comments) | +| `mode` | No | `auto` | Network mode: `public`, `private`, or `auto` (auto-detects from repo visibility) | +| `timeout` | No | `30` | Timeout in minutes | +| `model` | No | `opus` | Claude model to use | + +**Mode auto-detection:** When `mode` is `auto` (the default), the entrypoint queries the GitHub API to determine the repo's visibility. Private/internal repos default to `private` mode; public repos default to `public` mode. This matches the behavior users expect — private repos should not have internet access from the sandbox. + +**Bot account support:** The action supports the same dual-identity model as local egg. When `bot-github-token` is provided, the gateway uses the bot identity for git operations (push, PR creation). When omitted, the user's `github-token` is used directly. The `bot-username` input controls self-comment filtering in the gateway. + +### Action Outputs + +| Output | Description | +|--------|-------------| +| `exit-code` | Container exit code (0 = success) | +| `pr-url` | URL of created PR, if any | +| `log-file` | Path to full Claude output log | + +### Entrypoint Flow + +The action's entrypoint script (`action/entrypoint.sh`) orchestrates: + +1. **Pull images** — Pull pre-built gateway + sandbox images from GHCR (see Phase 1 note on build time) +2. **Create Docker network** — `egg-isolated` with a dynamically allocated subnet (avoids collisions with the runner's existing Docker networks by inspecting `docker network ls` and selecting an unused `172.x.0.0/24` range) +3. **Detect mode** — If `mode=auto`, query `gh api repos/{owner}/{repo}` for visibility; set `private` for private/internal repos, `public` for public repos +4. **Generate ephemeral config** — Write a temporary `repositories.yaml` with the full config format (see Config Generation below), generate a launcher secret +5. **Start gateway** — Mount `$GITHUB_WORKSPACE/.git/` at a known path (e.g., `/repos/{repo-name}/.git`), inject the GitHub token via `GITHUB_USER_TOKEN` (and `BOT_GITHUB_TOKEN` if provided), inject the Anthropic OAuth token via `CLAUDE_CODE_OAUTH_TOKEN`, mount the launcher secret +6. **Wait for health** — Poll `http://egg-gateway:9847/api/v1/health` +7. **Allocate container IP + create session** — Pre-allocate a container IP on the `egg-isolated` network, then call `POST /api/v1/sessions/create` with `{container_id, container_ip, mode, repos, uid, gid}`. This single API call atomically: queries repo visibility, filters repos by mode, creates worktrees, and registers the session. Returns `session_token` and `worktrees` dict. +8. **Start sandbox** — Mount the worktree path returned by the session API, shadow `.git`, inject `EGG_SESSION_TOKEN`, run Claude Code in `--exec` mode with `--print --output-format=stream-json`. The sandbox does **not** receive the Anthropic OAuth token directly — all API calls route through the gateway at `ANTHROPIC_BASE_URL=http://egg-gateway:9848`, where the gateway injects credentials. +9. **Capture output** — Stream container logs, write to `$GITHUB_STEP_SUMMARY`, extract PR URLs for step outputs +10. **Cleanup** — Delete session via gateway API, stop and remove containers, remove the Docker network. A `trap EXIT` handler ensures cleanup runs even if the runner is cancelled mid-execution (though a hard-killed runner may leave orphaned resources — these are ephemeral GHA runner resources and will be discarded when the runner VM is recycled). + +### Gateway Token Handling + +**GitHub tokens:** The gateway already supports PAT-based auth via `GITHUB_USER_TOKEN`. For GHA, the runner's `GITHUB_TOKEN` (or a PAT from secrets) is passed to the gateway container as `GITHUB_USER_TOKEN`. When `bot-github-token` is provided, it is passed as `BOT_GITHUB_TOKEN` for the bot identity, matching the local egg setup. No gateway code changes are needed. + +**Anthropic credentials:** The gateway handles all Anthropic API credential injection. The OAuth token is passed to the **gateway** container as `CLAUDE_CODE_OAUTH_TOKEN` (matching the gateway's credential priority: OAuth token > API key, per `gateway/anthropic_credentials.py`). The sandbox never sees the token — it sets `ANTHROPIC_BASE_URL=http://egg-gateway:9848` and the gateway's `/v1/messages` proxy injects credentials before forwarding to Anthropic's API. This preserves the same credential isolation as local egg. + +### Config Generation + +Since there's no `~/.config/egg/repositories.yaml` in GHA, the entrypoint generates one with the full config format required by `repo_parser.py`: + +```yaml +github_username: +bot_username: + +writable_repos: + - + +repo_settings: + : + auth_mode: <"bot" if bot-github-token provided, else "user"> + +user_mode: + github_user: + git_name: + git_email: @users.noreply.github.com + +local_repos: + paths: + - /repos/ +``` + +**Workspace path mapping:** The gateway's worktree manager creates worktrees from a source repo's `.git` directory. In GHA, `$GITHUB_WORKSPACE/.git` is bind-mounted into the gateway container at `/repos//.git`. The gateway's `create_worktree()` call uses this path as the source, creating worktrees at `~/.egg-worktrees/{container_id}/{repo_name}` as usual. The resulting worktree path is returned by the session create API and mounted into the sandbox container. + +### Security Model + +The full gateway stack runs in GHA, maintaining the same security boundary as local: + +- Sandbox never sees the GitHub token or Anthropic OAuth token directly +- Credentials are injected by the gateway at request time (GitHub tokens for git ops, Anthropic tokens for `/v1/messages` proxy) +- Branch push policies still apply (egg-prefixed branches only) +- Merge operations remain blocked +- `.git` directory is shadowed in the sandbox + +The main difference from local: the GHA runner itself has access to secrets (this is inherent to GHA and not a regression — the trust boundary is the GHA workflow file, which is version-controlled). + +## Implementation Phases + +### Phase 1: Core Action (MVP) + +**Deliverables:** +- `action/action.yml` — Composite action definition +- `action/entrypoint.sh` — Container orchestration script +- `action/generate-config.sh` — Dynamic config generation +- `.github/workflows/release-images.yml` — Build + push images to GHCR on release +- `.github/workflows/test-action.yml` — Integration test workflow + +**Scope:** +- Pre-built GHCR images (building from source on every run is impractical — the sandbox image includes Python packages, Node.js, Claude Code, and dev tools, resulting in 5-15 minute build times that would dominate the action's run time) +- Single repo support (the checked-out repo) +- Auto mode detection (public/private based on repo visibility) +- Basic output capture (logs + exit code) +- Bot account support via configurable inputs + +**Success criteria:** +- Action runs successfully in a GHA workflow +- Image pull completes in under 60 seconds on a standard runner +- Claude Code receives the prompt and can read/write the repo +- Claude Code can push branches and create PRs via the gateway +- Exit code is surfaced correctly + +### Phase 2: Optimization + +**Deliverables:** +- Docker layer caching via `actions/cache` for faster image pulls +- Output parsing (PR URL extraction, step summary) +- `$GITHUB_STEP_SUMMARY` integration with Claude's output + +**Success criteria:** +- PR URLs are available as step outputs +- Job summary contains Claude's output + +**Dependencies:** Phase 1 + +### Phase 3: Advanced Features + +**Deliverables:** +- Multi-repo support (additional repos via action input) +- Custom `CLAUDE.md` injection via action input +- Trigger templates (issue-comment trigger, PR-review trigger, schedule trigger) + +**Success criteria:** +- Multiple repos can be mounted simultaneously +- Example workflows provided for common trigger patterns + +**Dependencies:** Phase 2 + +## Alternatives Considered + +### A. Sandbox Only (No Gateway) + +Run only the sandbox container, skip the gateway, and use GHA's `GITHUB_TOKEN` directly in the container. + +**Pros:** Simpler, faster startup, fewer moving parts. +**Cons:** No credential isolation — the sandbox sees the GitHub token directly. No policy enforcement on git operations. Diverges from the local security model. + +**Rejected because:** The gateway is the core of egg's security model. Removing it for GHA creates a fundamentally different trust model that would need separate testing and reasoning. The added complexity of running the gateway is modest (one extra container) and the security benefits are significant. + +### B. Docker Compose + +Use `docker-compose.yml` to define the gateway + sandbox stack. + +**Pros:** Declarative, familiar, handles networking automatically. +**Cons:** Requires `docker-compose` in the runner (available but adds a dependency). Harder to dynamically configure (compose files are static). Output capture is more complex. + +**Deferred:** Could be added as an alternative entrypoint in Phase 3 for users who prefer compose. The shell script approach in Phase 1 gives more control over the lifecycle. + +### C. Run on Host (No Containers) + +Install Claude Code and egg's git wrappers directly on the GHA runner. + +**Pros:** No Docker overhead, fastest startup. +**Cons:** No isolation at all. The runner has full access to everything. Git wrappers would need to be adapted for non-container use. Fundamentally different execution model. + +**Rejected because:** This defeats the purpose of egg's sandbox model. Users who want to run Claude Code directly on a runner can do so without egg. + +## Consequences + +### Positive +- egg becomes usable in CI/CD pipelines, enabling automated code tasks +- Same security model as local (gateway-enforced credential isolation) +- Reuses existing gateway/sandbox infrastructure with minimal changes +- Users get a standard GitHub Action interface + +### Negative +- GHCR image pull adds cold-start overhead (mitigated by GitHub's CDN and layer caching) +- GHA runners have limited resources (7GB RAM, 2 vCPU) — see resource analysis below +- Two Docker containers consume more runner resources than a single process +- Action maintenance burden (testing across GHA runner updates) + +### Resource Analysis + +Standard `ubuntu-latest` runners provide 7GB RAM and 2 vCPU. Expected memory footprint: + +| Component | Estimated RSS | +|-----------|---------------| +| Gateway (Flask + Squid proxy) | ~150-250 MB | +| Sandbox (Claude Code / Node.js) | ~200-400 MB | +| OS + Docker overhead | ~500 MB | +| **Total baseline** | **~850 MB - 1.15 GB** | + +This leaves ~5.5-6 GB for Claude Code's working memory (file reads, tool results, context), which should be sufficient for typical tasks. The Squid proxy in the gateway could be dropped in GHA mode if memory becomes tight (the GHA runner's network can be constrained via Docker network policies instead), but this is not expected to be necessary. + +For memory-intensive tasks or large codebases, users can specify `runs-on: ubuntu-latest-xl` (or equivalent larger runners) in their workflow files. This is outside egg's control and documented as a recommendation. + +## Resolved Questions + +1. **Issue/PR-triggered prompts:** Yes — tracked separately in #82. Trigger templates will be delivered in Phase 3. +2. **Image registry:** GHCR — integrated with GitHub, free for public repos, and the natural choice for a GitHub Action. +3. **Branch strategy in GHA:** Support both creating new branches and pushing to the current branch. The existing gateway lockdowns (egg-prefixed branches, merge blocking) apply as-is. +4. **Rate limiting:** Not yet. Deferred until usage patterns in GHA are better understood. diff --git a/docs/plans/github-actions-implementation-plan.md b/docs/plans/github-actions-implementation-plan.md new file mode 100644 index 0000000000..d521003691 --- /dev/null +++ b/docs/plans/github-actions-implementation-plan.md @@ -0,0 +1,291 @@ +# Implementation Plan: GitHub Actions Support + +**ADR:** [GitHub Actions Support](../adr/in-progress/ADR-GitHub-Actions-Support.md) +**Issue:** #78 +**ADR PR:** #98 + +> **Dependency note:** The ADR (PR #98) is still under review and may +> change before merging. This plan should be re-validated against the +> final ADR before implementation begins. If the ADR's design changes +> materially, sections of this plan may need revision. + +## Overview + +This plan covers the Phase 1 (MVP) implementation of running egg as a +GitHub Action. The ADR proposes a composite GitHub Action that +orchestrates the full egg stack (gateway + sandbox) within a GHA runner, +accepting the checked-out repo as the working repository. + +## Codebase Observations + +Findings from reviewing the existing codebase that affect implementation +decisions. + +### 1. Port Discrepancy + +The gateway `Dockerfile` EXPOSE says 9847/3128, but the actual ports are +**9848** (API, per `sandbox/egg_lib/config.py:53` and +`gateway/entrypoint.sh:133`) and **3129** (proxy, per +`sandbox/egg_lib/config.py:54`). The ADR references 9847 in one place — +the implementation must use the correct ports (9848/3129). The Dockerfile +EXPOSE should be fixed as part of this work. + +### 2. Session Creation Is Atomic + +`POST /api/v1/sessions/create` takes +`{container_id, container_ip, mode, repos, uid, gid}` and atomically +creates session + worktrees + filters by visibility +(`sandbox/egg_lib/gateway.py:449-508`). The GHA entrypoint must replicate +this flow via `curl` rather than the existing Python client. + +### 3. Gateway Config Requirements + +The gateway's config layer expects a full `repositories.yaml` including +`writable_repos`, `bot_username`, `repo_settings`, `user_mode` (parsed by +`config/repo_config.py`), and `local_repos.paths` (parsed by +`shared/egg_config/config.py`). See `config/repositories.yaml.example` +for the complete schema. The config generator must produce all required +fields — a minimal config will cause parse failures. + +Note: `gateway/repo_parser.py` is a URL/path parsing utility for +extracting owner/repo from GitHub URLs — it does not parse the config +file. + +### 4. Credential File Layout + +The gateway reads Anthropic credentials from a `secrets.env` file mounted +at `~/.config/egg/secrets.env` (`gateway/anthropic_credentials.py:31-33`). +Launcher auth uses a separate `launcher-secret` file +(`gateway/entrypoint.sh:47-53`). The GHA entrypoint must create both files +and mount them into the gateway container. + +### 5. Build Context + +Both Dockerfiles use the **repo root** as build context: +- Gateway: `docker build -f gateway/Dockerfile .` +- Sandbox: `docker build -f sandbox/Dockerfile .` + (sandbox Dockerfile copies the entire repo into `/opt/egg-runtime/` and + sets `PYTHONPATH` to reference `sandbox/` and `shared/` subdirectories) + +### 6. Dual-Network Architecture + +Locally, the gateway is dual-homed on `egg-isolated` (172.32.0.0/24) and +`egg-external` (172.33.0.0/24). The GHA entrypoint must replicate this +for the full security model — private sandbox containers route through the +proxy on the isolated network, public containers use the external network +with direct internet access. + +### 7. No Gateway/Sandbox Code Changes Required + +The ADR was designed to reuse existing infrastructure. After reviewing the +codebase, this holds: +- Session management API already supports external callers via + `launcher_secret` auth +- Worktree creation works with any repo mounted at + `/home/egg/repos/` +- Anthropic credential injection reads from `secrets.env`, no changes + needed +- Policy enforcement (branch ownership, merge blocking) works as-is +- Config parsing (`config/repo_config.py`, `shared/egg_config/config.py`) + accepts the YAML format we generate + +The only change to existing code is the cosmetic EXPOSE fix in +`gateway/Dockerfile`. + +## Deliverables + +### 1. `action/action.yml` — Composite Action Definition + +Defines the GitHub Action interface with inputs, outputs, and a composite +`runs` block that invokes the entrypoint script. + +**Inputs:** + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `prompt` | Yes | — | Task prompt for Claude Code | +| `anthropic-oauth-token` | Yes | — | Anthropic OAuth token | +| `github-token` | Yes | `${{ github.token }}` | GitHub token for git ops | +| `bot-github-token` | No | — | Bot GitHub App token | +| `bot-username` | No | `egg` | Bot GitHub username | +| `mode` | No | `auto` | `public`, `private`, or `auto` | +| `timeout` | No | `30` | Timeout in minutes | +| `model` | No | `opus` | Claude model to use | + +**Outputs:** + +| Output | Description | +|--------|-------------| +| `exit-code` | Sandbox container exit code | +| `pr-url` | URL of created PR, if any | +| `log-file` | Path to full Claude output log | + +Inputs are passed to the entrypoint as environment variables following +GitHub's composite action convention (`INPUT_PROMPT`, etc.). + +### 2. `action/entrypoint.sh` — Container Orchestration (~300 lines) + +The most complex deliverable. Replicates the orchestration flow from +`sandbox/egg_lib/runtime.py` and `sandbox/egg_lib/gateway.py` in bash. + +**Step-by-step flow:** + +| Step | What | How | Existing reference | +|------|------|-----|-------------------| +| 1 | Pull images | `docker pull ghcr.io/jwbron/egg-gateway:` + sandbox | New | +| 2 | Create networks | Inspect existing Docker networks, allocate unused 172.x.0.0/24 subnets, create `egg-gha-isolated-$RUN_ID` and `egg-gha-external-$RUN_ID`. **Note:** The local dev setup uses hardcoded subnets (`172.32.0.0/24`, `172.33.0.0/24` per `config.py:59-65`), but GHA intentionally uses dynamic allocation to avoid collisions when multiple concurrent runs share a self-hosted runner (or when a runner also runs egg locally). | `config.py:59-65` | +| 3 | Detect mode | If `auto`: read `$GITHUB_EVENT_REPOSITORY_VISIBILITY` (set from `${{ github.event.repository.visibility }}` in `action.yml`) — `private`/`internal`→private, `public`→public. This avoids an extra API call since the visibility is already available in the workflow event context | ADR spec | +| 4 | Generate config | Call `generate-config.sh` to produce repositories.yaml, secrets.env, launcher-secret in temp dir | New (see deliverable #3) | +| 5 | Start gateway | `docker run -d` with mounts for config dir, workspace `.git`, worktrees dir, state dir, certs dir. Env vars: `GITHUB_USER_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `HOST_UID`, `HOST_GID`, `BOT_GITHUB_TOKEN` (if provided), gateway policy vars | `sandbox/egg_lib/gateway.py:808-831` | +| 6 | Health check | Poll `http://:9848/api/v1/health` via `curl` on Docker network (no port publishing needed) | `sandbox/egg_lib/gateway.py:681-754` | +| 7 | Allocate IP | `docker network inspect` to find assigned IPs, select next available in subnet | `sandbox/egg_lib/runtime.py:189-247` | +| 8 | Create session | `curl -X POST http://:9848/api/v1/sessions/create` with `Authorization: Bearer `. Body: `{container_id, container_ip, mode, repos, uid, gid}`. Parse response for `session_token` and `worktrees` | `sandbox/egg_lib/gateway.py:449-508` | +| 9 | Start sandbox | `docker run` with: worktree path from session response mounted at `/home/egg/repos/`, `.git` shadowed via bind-mount of `/dev/null` (prevents the sandbox from accessing the full repo history or manipulating the worktree's parent repo directly — the sandbox should only interact with its isolated worktree copy), `EGG_SESSION_TOKEN`, `ANTHROPIC_BASE_URL=http://egg-gateway:9848`, Claude Code in `--exec` mode with `--print --output-format=stream-json` | `sandbox/egg_lib/runtime.py:646-740` | +| 10 | Capture output | Tee sandbox container logs to file and `$GITHUB_STEP_SUMMARY`, extract PR URLs via regex for step outputs | New | +| 11 | Cleanup | `trap EXIT` handler: delete session via gateway API, stop+rm containers, remove networks | `sandbox/egg_lib/runtime.py:351-388` | + +**Implementation details:** + +- **Container naming:** Include `$GITHUB_RUN_ID` to avoid conflicts on + self-hosted runners: `egg-gha-gateway-${GITHUB_RUN_ID}`, + `egg-gha-sandbox-${GITHUB_RUN_ID}` +- **Network naming:** `egg-gha-isolated-${GITHUB_RUN_ID}` and + `egg-gha-external-${GITHUB_RUN_ID}` +- **No port publishing:** Containers communicate over Docker network. + Health checks use container IP on the network or `docker exec` +- **Timeout:** Run the sandbox container with + `docker run --stop-timeout 30 ...` and use a background timer that + calls `docker stop` after `$TIMEOUT_MINUTES`. This ensures Docker + sends SIGTERM to PID 1 inside the container and waits for graceful + shutdown, rather than `timeout(1)` sending SIGTERM to the `docker` + client process which may not propagate cleanly to the workload +- **Gateway dual-homing:** Start on isolated network, then + `docker network connect` to external network (matching + `sandbox/egg_lib/gateway.py:839-853`) +- **Exit code propagation:** Capture sandbox exit code and write to + `$GITHUB_OUTPUT` + +### 3. `action/generate-config.sh` — Config Generation (~100 lines) + +Creates a temp directory (`$RUNNER_TEMP/egg-config-$RUN_ID/`) containing +the three files the gateway needs. + +**`repositories.yaml`** (full format for `config/repo_config.py`): +```yaml +github_username: <$GITHUB_ACTOR> +bot_username: +writable_repos: + - <$GITHUB_REPOSITORY> +repo_settings: + <$GITHUB_REPOSITORY>: + auth_mode: <"bot" if bot-github-token provided, else "user"> +user_mode: + github_user: <$GITHUB_ACTOR> + git_name: <$GITHUB_ACTOR> + git_email: <$GITHUB_ACTOR_ID>+<$GITHUB_ACTOR>@users.noreply.github.com +local_repos: + paths: + - /home/egg/repos/ +``` + +**`secrets.env`:** +``` +CLAUDE_CODE_OAUTH_TOKEN= +``` + +**`launcher-secret`:** Generated via `openssl rand -base64 32`. + +**Workspace path mapping:** `$GITHUB_WORKSPACE` is bind-mounted into the +gateway container at `/home/egg/repos/` where `` is +derived from `$GITHUB_REPOSITORY` (strips owner prefix). The gateway's +worktree manager uses the `.git` directory at that path to create +worktrees at `~/.egg-worktrees//`. + +### 4. `.github/workflows/release-images.yml` — GHCR Publishing + +Builds and pushes gateway and sandbox Docker images to GHCR on release +events. + +**Triggers:** `release: [published]` + `workflow_dispatch` for manual +testing. + +**Images:** +- `ghcr.io/jwbron/egg-gateway:latest` and `:$TAG` +- `ghcr.io/jwbron/egg-sandbox:latest` and `:$TAG` + +**Build details:** +- Uses `docker/build-push-action@v6` with repo root as context +- Gateway: `-f gateway/Dockerfile` +- Sandbox: `-f sandbox/Dockerfile` +- amd64 only for Phase 1 (matches `ubuntu-latest` runners) +- Requires `packages: write` permission + +### 5. `.github/workflows/test-action.yml` — Integration Tests + +Runs the action on PRs that modify `action/**` files. + +**Test strategy:** +- Minimal test: verify config generation, container startup, gateway + health (no API key required) +- Full test: run with a simple prompt and verify exit code (requires + `ANTHROPIC_OAUTH_TOKEN` secret) +- Validate outputs: exit-code is set, log-file exists + +### 6. Gateway Dockerfile EXPOSE Fix + +Change `EXPOSE 9847 3128` to `EXPOSE 9848 3129` in `gateway/Dockerfile` +to match actual ports. + +## Implementation Sequence + +| Order | Deliverable | Depends on | Notes | +|-------|------------|------------|-------| +| 1 | Gateway Dockerfile EXPOSE fix | — | One-line change | +| 2 | `release-images.yml` | — | Must publish images before action can pull them | +| 3 | `action/generate-config.sh` | — | Self-contained, testable in isolation | +| 4 | `action/entrypoint.sh` | #2, #3 | Core logic, largest piece | +| 5 | `action/action.yml` | #4 | Thin wrapper around entrypoint | +| 6 | `test-action.yml` | #5 | Integration verification | + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| **Sandbox image size (2-3GB)** causes slow pulls | Cold start adds 30-60s | GitHub CDN + `actions/cache` for Docker layers (Phase 2) | +| **Gateway health check timing** | Action may timeout waiting for startup | Aggressive polling (0.5s interval), 60s max wait before failing | +| **`GITHUB_TOKEN` permission scope** insufficient | Push/PR operations fail | Document required permissions: `contents: write`, `pull-requests: write`. Recommend PAT for full functionality | +| **Self-hosted runner conflicts** | Container/network name collisions with concurrent runs | Include `$GITHUB_RUN_ID` in all Docker resource names | +| **Workspace `.git` ownership mismatch** | Gateway can't read `.git` directory | Use existing `HOST_UID`/`HOST_GID` mechanism in gateway entrypoint to match runner user | +| **Session API calls via curl** harder to debug than Python | Opaque failures | Verbose logging with `set -x` in debug mode, clear error messages on each API call failure | +| **ARM64 runners** not supported | Action fails on ARM self-hosted runners | Phase 1: amd64 only (matches `ubuntu-latest`). Document limitation. | +| **Network subnet collisions** | Docker network creation fails | Dynamic subnet allocation: inspect existing networks and pick unused range | + +## Open Items to Resolve During Implementation + +1. **Claude Code `--exec` invocation:** Verify exact CLI flags for + non-interactive mode. ADR specifies `--print --output-format=stream-json` + — validate against installed Claude Code version in sandbox image. + +2. **PR URL extraction from output:** Determine reliable pattern to + extract PR URLs from Claude Code's stream-json output for the `pr-url` + action output. May need to parse JSON lines for specific event types. + +3. **`$GITHUB_WORKSPACE` ownership:** The checkout action creates the repo + as the runner user (typically uid 1001). Verify the gateway's + `HOST_UID`/`HOST_GID` + `gosu` mechanism handles this correctly when + creating worktrees from the mounted `.git`. + +4. **Sandbox Dockerfile build context:** Confirm that + `sandbox/Dockerfile` builds correctly with repo root as context + (the `COPY . /opt/egg-runtime/` line copies the entire repo). + A `.dockerignore` to exclude unnecessary files (docs, tests, + `.github/`) would reduce image size but is a change to the existing + build — this should be handled in a separate PR. + +5. **Image tagging strategy:** `action/action.yml` should reference a + pinned version tag (e.g., `:v1.0.0`) rather than `:latest`. Using + `latest` in a GitHub Action is unreliable — users who pin `@v1` of + the action would still get unpredictable image versions. The release + workflow should tag images with the release version, and the + `action.yml` should be updated as part of each release to reference + the corresponding image tag. diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 2c8cc0a5d5..cd88a63651 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -76,7 +76,7 @@ RUN pip install --no-cache-dir flask waitress pyyaml requests PyJWT cryptography ENV PYTHONPATH="/app" # Expose both gateway API port and Squid proxy port -EXPOSE 9847 3128 +EXPOSE 9848 3129 COPY gateway/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh