name: Agent Tarballs on: schedule: # 5 AM UTC daily - cron: "0 5 * * *" workflow_dispatch: inputs: agent: description: "Single agent to build (leave empty for all)" required: false type: string permissions: contents: write jobs: matrix: runs-on: ubuntu-latest outputs: agents: ${{ steps.set-matrix.outputs.agents }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - id: set-matrix env: AGENT_INPUT: ${{ inputs.agent }} run: | if [ -n "${AGENT_INPUT:-}" ]; then # Validate: agent name must be alphanumeric/hyphens only if ! printf '%s' "${AGENT_INPUT}" | grep -qE '^[a-z][a-z0-9-]*$'; then echo "::error::Invalid agent name: ${AGENT_INPUT}" exit 1 fi echo "agents=$(jq -cn --arg a "${AGENT_INPUT}" '[$a]')" >> "$GITHUB_OUTPUT" else echo "agents=$(jq -c 'keys' packer/agents.json)" >> "$GITHUB_OUTPUT" fi build: needs: matrix runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} strategy: max-parallel: 4 fail-fast: false matrix: agent: ${{ fromJson(needs.matrix.outputs.agents) }} arch: [x86_64] # Native-binary agents need ARM builds too. # npm-based agents (codex, openclaw, kilocode) are arch-independent — x86_64 only. include: - agent: opencode arch: arm64 - agent: hermes arch: arm64 - agent: claude arch: arm64 - agent: cursor arch: arm64 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.11" - name: Install agent under /root env: AGENT_NAME: ${{ matrix.agent }} run: | set -eo pipefail # Validate agent exists in agents.json (prevents path traversal / injection) if ! jq -e --arg a "${AGENT_NAME}" 'has($a)' packer/agents.json > /dev/null; then echo "::error::Unknown agent: ${AGENT_NAME}" exit 1 fi # Read the agent's tier from packer/agents.json TIER=$(jq -r --arg a "${AGENT_NAME}" '.[$a].tier' packer/agents.json) echo "==> Agent: ${AGENT_NAME}, Tier: ${TIER}" # Run tier script (sets up node/bun/etc. under /root) if [ -f "packer/scripts/tier-${TIER}.sh" ]; then echo "==> Running tier script: tier-${TIER}.sh" sudo HOME=/root bash "packer/scripts/tier-${TIER}.sh" fi # TRUST BOUNDARY: packer/agents.json is version-controlled and requires # PR review to modify. Install commands are executed via bash -c, so any # change to agents.json MUST be reviewed carefully for command safety. # # Security layers: # 1. agents.json changes require PR review (branch protection) # 2. curl/wget targets validated against domain allowlist # 3. Suspicious command patterns rejected via blocklist # 4. Runs in ephemeral GitHub Actions container (destroyed after each run) echo "==> Installing agent..." # Allowed domains for curl/wget downloads (official agent vendor domains) ALLOWED_DOMAINS="claude.ai|cursor.com|opencode.ai|raw.githubusercontent.com|registry.npmjs.org|crates.io|github.com|dl.google.com" CMD_COUNT=$(jq -r --arg a "${AGENT_NAME}" '.[$a].install | length' packer/agents.json) i=0 while [ "$i" -lt "$CMD_COUNT" ]; do cmd=$(jq -r --arg a "${AGENT_NAME}" --argjson i "$i" '.[$a].install[$i]' packer/agents.json) # Safety layer 1: reject suspicious command patterns if printf '%s' "${cmd}" | grep -qE '(mktemp|eval |base64 -d|/dev/tcp|nc -[elp]|python[23]? -c|perl -e|ruby -e|\bdd\b|>[[:space:]]*/dev/|rm -rf)'; then echo "::error::Suspicious install command rejected: ${cmd}" exit 1 fi # Safety layer 2: validate curl/wget URLs against domain allowlist if printf '%s' "${cmd}" | grep -qE '(curl|wget)'; then urls=$(printf '%s' "${cmd}" | grep -oE 'https?://[^[:space:]"|'\'']+' || true) for url in ${urls}; do domain=$(printf '%s' "${url}" | sed -E 's|^https?://([^/]+).*|\1|') if ! printf '%s' "${domain}" | grep -qE "^(${ALLOWED_DOMAINS})$"; then echo "::error::curl/wget to unapproved domain '${domain}' in: ${cmd}" exit 1 fi done fi echo "==> Running: ${cmd}" sudo HOME=/root bash -c "${cmd}" < /dev/null i=$((i + 1)) done - name: Capture agent files into tarball env: AGENT_NAME: ${{ matrix.agent }} run: | set -eo pipefail sudo bash packer/scripts/capture-agent.sh "${AGENT_NAME}" - name: Create or update GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AGENT_NAME: ${{ matrix.agent }} run: | set -eo pipefail TAG="agent-${AGENT_NAME}-latest" DATE=$(date -u +%Y%m%d) ARCH="${{ matrix.arch }}" TARBALL="spawn-agent-${AGENT_NAME}-${ARCH}-${DATE}.tar.gz" # Move tarball to expected name (tarball is owned by root from sudo capture) sudo mv "/tmp/spawn-agent-${AGENT_NAME}.tar.gz" "${TARBALL}" sudo chown "$(id -u):$(id -g)" "${TARBALL}" # Create release if it doesn't exist, then upload the arch-specific tarball. # Multiple arch builds (x86_64, arm64) upload to the same release. if ! gh release view "${TAG}" > /dev/null 2>&1; then gh release create "${TAG}" \ --title "Agent tarball: ${AGENT_NAME} (${DATE})" \ --notes "Pre-built tarball for \`${AGENT_NAME}\` agent. Auto-generated nightly." \ --prerelease fi # Delete stale asset for this arch if present (from a previous build today) gh release delete-asset "${TAG}" "${TARBALL}" --yes 2>/dev/null || true # Also clean up any older-dated tarball for this arch # grep returns exit 1 when no matches — pipe through cat to avoid pipefail killing the step gh release view "${TAG}" --json assets --jq ".assets[].name" 2>/dev/null \ | { grep "spawn-agent-${AGENT_NAME}-${ARCH}-" || true; } \ | while IFS= read -r old; do gh release delete-asset "${TAG}" "${old}" --yes 2>/dev/null || true done gh release upload "${TAG}" "${TARBALL}"