diff --git a/.claude/rules/discovery.md b/.claude/rules/discovery.md index 2c241d33..fe024087 100644 --- a/.claude/rules/discovery.md +++ b/.claude/rules/discovery.md @@ -59,6 +59,12 @@ Do NOT add agents speculatively. Only add one if there's **real community buzz** - Accepts API keys via env vars (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENROUTER_API_KEY`) - Works with OpenRouter (natively or via `OPENAI_BASE_URL` override) +**ARM builds for native binary agents:** +Agents that ship compiled binaries (Rust, Go, etc.) need separate ARM (aarch64) tarball builds. npm-based agents are arch-independent and only need x86_64 builds. When adding a new agent: +- If it installs via `npm install -g` → x86_64 tarball only (Node handles arch) +- If it installs a pre-compiled binary (curl download, cargo install, go install) → add an ARM entry in `.github/workflows/agent-tarballs.yml` matrix `include` section +- Current native binary agents needing ARM: zeroclaw (Rust), opencode (Go), hermes, claude + To add: same steps as before (manifest.json entry, matrix entries, implement on 1+ cloud, README). ## 4. Respond to GitHub issues diff --git a/.github/workflows/agent-tarballs.yml b/.github/workflows/agent-tarballs.yml index 7ba0ee53..31d4ea98 100644 --- a/.github/workflows/agent-tarballs.yml +++ b/.github/workflows/agent-tarballs.yml @@ -39,12 +39,24 @@ jobs: build: needs: matrix - runs-on: ubuntu-latest + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} strategy: - max-parallel: 3 + 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: zeroclaw + arch: arm64 + - agent: opencode + arch: arm64 + - agent: hermes + arch: arm64 + - agent: claude + arch: arm64 steps: - uses: actions/checkout@v4 @@ -132,17 +144,29 @@ jobs: set -eo pipefail TAG="agent-${AGENT_NAME}-latest" DATE=$(date -u +%Y%m%d) - TARBALL="spawn-agent-${AGENT_NAME}-x86_64-${DATE}.tar.gz" + 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}" - # Delete existing release if present (rolling release) - gh release delete "${TAG}" --yes 2>/dev/null || true + # 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 - # Create fresh release with the tarball - gh release create "${TAG}" "${TARBALL}" \ - --title "Agent tarball: ${AGENT_NAME} (${DATE})" \ - --notes "Pre-built tarball for \`${AGENT_NAME}\` agent. Auto-generated nightly." \ - --prerelease + # 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 + gh release view "${TAG}" --json assets --jq ".assets[].name" 2>/dev/null \ + | grep "spawn-agent-${AGENT_NAME}-${ARCH}-" \ + | while IFS= read -r old; do + gh release delete-asset "${TAG}" "${old}" --yes 2>/dev/null || true + done + + gh release upload "${TAG}" "${TARBALL}" diff --git a/packages/cli/package.json b/packages/cli/package.json index bfaa4ca2..3f9af12e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.15.1", + "version": "0.15.2", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index 555206d9..dd7d16c3 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -54,30 +54,48 @@ export async function tryTarballInstall( return false; } - // Find the .tar.gz asset - const asset = parsed.output.assets.find((a) => a.name.endsWith(".tar.gz")); - if (!asset) { + // Find both arch-specific .tar.gz assets and let the remote VM pick the right one. + // We try x86_64 first (most common), and include arm64 fallback in the remote script. + const x86Asset = parsed.output.assets.find((a) => a.name.includes("-x86_64-") && a.name.endsWith(".tar.gz")); + const armAsset = parsed.output.assets.find((a) => a.name.includes("-arm64-") && a.name.endsWith(".tar.gz")); + + if (!x86Asset && !armAsset) { logWarn("No tarball asset found in release"); return false; } - const url = asset.browser_download_url; + // Build arch-aware download: remote VM detects its own arch and picks the right URL + const x86Url = x86Asset?.browser_download_url || ""; + const armUrl = armAsset?.browser_download_url || ""; + const url = x86Url || armUrl; - // SECURITY: Validate URL matches expected GitHub releases pattern. + // SECURITY: Validate URLs match expected GitHub releases pattern. // Prevents shell injection via crafted API responses. - if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/releases\/download\/[^\s'"`;|&$()]+$/.test(url)) { + const urlPattern = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/releases\/download\/[^\s'"`;|&$()]+$/; + if ((x86Url && !urlPattern.test(x86Url)) || (armUrl && !urlPattern.test(armUrl))) { logWarn("Tarball URL failed safety validation"); return false; } logStep("Downloading pre-built agent tarball..."); + // Build arch-aware download command: remote VM picks the right URL based on uname -m + // Use sudo for tar extraction — on clouds like AWS Lightsail, SSH user is 'ubuntu' (non-root) + // but tarballs extract to /root/. The ubuntu user has passwordless sudo. + const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")'; + let downloadCmd: string; + if (x86Url && armUrl) { + downloadCmd = + "_arch=$(uname -m); " + + `if [ "$_arch" = "aarch64" ] || [ "$_arch" = "arm64" ]; then ` + + `_url='${armUrl}'; else _url='${x86Url}'; fi; ` + + `curl -fsSL --connect-timeout 10 --max-time 120 "$_url" | ${sudo} tar xz -C / && ${sudo} test -f /root/.spawn-tarball`; + } else { + downloadCmd = `curl -fsSL --connect-timeout 10 --max-time 120 '${url}' | ${sudo} tar xz -C / && ${sudo} test -f /root/.spawn-tarball`; + } + // Download and extract on the remote VM - // --connect-timeout 10s, --max-time 120s, -L to follow redirects (GitHub releases redirect) - await runner.runServer( - `curl -fsSL --connect-timeout 10 --max-time 120 '${url}' | tar xz -C / && [ -f /root/.spawn-tarball ]`, - 150, // 2.5 min total timeout for the SSH command - ); + await runner.runServer(downloadCmd, 150); logInfo("Agent installed from pre-built tarball"); return true;