diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..819c543
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,209 @@
+# Builds the prebuilt Colonizer apps and, for a version tag, publishes them as a GitHub release together
+# with the one-command installer (scripts/install-release.sh, published as install.sh). docs/install.md
+# tells people how to use them.
+#
+# - linux-binaries: colonizer-agentd and rtk for each colony architecture, and the harness for Linux, as
+# static musl binaries built inside rust:1-alpine. That is `docker run` from an ordinary job, because
+# GitHub's JavaScript actions don't run inside Alpine containers.
+# - bundle: scripts/install.sh --bundle for each platform, then a smoke test: the app starts from the
+# unpacked archive, finds its assets there, and serves its API and web UI.
+# - release: tags only. The tag has to match the harness crate's version.
+#
+# A release contains no Anthropic code: the Claude Agent SDK and Claude Code are fetched where the app
+# is installed (scripts/install.sh --bundle and scripts/install-release.sh say how).
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+ pull_request:
+ paths:
+ - .github/workflows/release.yml
+ - scripts/install.sh
+ - scripts/install-release.sh
+ - scripts/record-fetch-at-install.mjs
+ - scripts/build-agentd.sh
+ - scripts/build-rtk.sh
+ - scripts/fetch-vendor.sh
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ linux-binaries:
+ strategy:
+ fail-fast: true
+ matrix:
+ include:
+ - arch: x86_64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Fetch rtk's pinned source
+ run: VENDOR_KINDS=source scripts/fetch-vendor.sh
+
+ - name: Build inside rust:1-alpine
+ env:
+ ARCH: ${{ matrix.arch }}
+ run: |
+ docker run --rm -e ARCH -e COLONIZER_BUILD_HERE=1 -e OWNER="$(id -u):$(id -g)" \
+ -v "$PWD:/src" -w /src rust:1-alpine sh -euc '
+ apk add --no-cache musl-dev file >/dev/null
+ scripts/build-agentd.sh
+ scripts/build-rtk.sh
+ # The harness runs on the host, so only the Linux app needs a Linux build of it.
+ if [ "$ARCH" = x86_64 ]; then
+ cargo build --release --locked -p colonizer
+ install -m 755 target/release/colonizer dist/bin/colonizer
+ fi
+ chown -R "$OWNER" dist target
+ '
+ mkdir -p prebuilt
+ cp dist/bin/colonizer-agentd dist/bin/rtk prebuilt/
+ [ "$ARCH" != x86_64 ] || cp dist/bin/colonizer prebuilt/
+ file prebuilt/*
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: linux-binaries-${{ matrix.arch }}
+ path: prebuilt/
+ if-no-files-found: error
+ retention-days: 7
+
+ bundle:
+ needs: linux-binaries
+ strategy:
+ fail-fast: true
+ matrix:
+ include:
+ - platform: linux-x86_64
+ runner: ubuntu-24.04
+ guest: x86_64
+ - platform: darwin-arm64
+ runner: macos-15
+ guest: aarch64
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ env:
+ PLATFORM: ${{ matrix.platform }}
+ VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('dev-{0}', github.sha) }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24
+
+ - name: Rust
+ if: matrix.platform == 'darwin-arm64'
+ run: rustup toolchain install stable --profile minimal && rustup default stable
+
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: linux-binaries-${{ matrix.guest }}
+ path: prebuilt
+
+ - name: Build the app
+ run: |
+ chmod 755 prebuilt/*
+ COLONIZER_PREBUILT="$PWD/prebuilt" scripts/install.sh --bundle
+
+ - name: Package
+ run: |
+ printf '%s\n' "$VERSION" > dist/VERSION
+ cp LICENSE NOTICE dist/
+ # Anthropic's code stays out: install-release.sh fetches it where the app is installed.
+ if find dist -path '*/node_modules/@anthropic-ai/claude-agent-sdk*' | grep -q .; then
+ echo "the Claude Agent SDK is in the bundle" >&2
+ exit 1
+ fi
+ mkdir -p stage out
+ cp -R dist stage/colonizer
+ tar -C stage -czf "out/colonizer-$PLATFORM.tar.gz" colonizer
+ ls -lh out
+
+ - name: Smoke test
+ run: |
+ mkdir -p smoke/home
+ tar -xzf "out/colonizer-$PLATFORM.tar.gz" -C smoke
+ HOME="$PWD/smoke/home" COLONIZER_BIND=127.0.0.1:17878 COLONIZER_GATEWAY_BIND=127.0.0.1:17879 \
+ smoke/colonizer/bin/colonizer > smoke/log 2>&1 &
+ pid=$!
+ for _ in $(seq 60); do
+ curl -fsS -o /dev/null http://127.0.0.1:17878/api/status && break
+ sleep 1
+ done
+ cat smoke/log
+ grep -q "^assets: .*/smoke/colonizer$" smoke/log
+ curl -fsS http://127.0.0.1:17878/api/status | tee smoke/status.json
+ echo
+ # The app found its own parts: the in-VM daemon, the web UI and the Claude Code module.
+ grep -q '"agentd":true' smoke/status.json
+ grep -q '"web":true' smoke/status.json
+ grep -q '"claude-code"' smoke/status.json
+ curl -fsS http://127.0.0.1:17878/ | grep -qi '&2; exit 1; }
+
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: colonizer-*
+ path: release
+ merge-multiple: true
+
+ - name: Publish
+ run: |
+ cp scripts/install-release.sh release/install.sh
+ cd release
+ sha256sum colonizer-*.tar.gz install.sh > SHA256SUMS
+ cat SHA256SUMS
+ {
+ echo "Prebuilt Colonizer for Linux x86_64 (with KVM) and Apple Silicon Macs, built from ${{ github.sha }}."
+ echo
+ echo '```sh'
+ echo 'curl -fsSL https://colonizer.dev/install.sh | sh'
+ echo '```'
+ echo
+ echo "The installer checks the archive against SHA256SUMS. It fetches the Claude Agent SDK from the npm registry, and on a Mac the Linux build of Claude Code, from Anthropic's own channels: neither is in the release. Guide: https://colonizer.dev/docs/install"
+ echo
+ echo '```'
+ cat SHA256SUMS
+ echo '```'
+ } > notes.md
+ gh release create "$TAG" colonizer-*.tar.gz install.sh SHA256SUMS \
+ --repo "${{ github.repository }}" --target "${{ github.sha }}" \
+ --title "Colonizer $TAG" --notes-file notes.md
diff --git a/scripts/build-agentd.sh b/scripts/build-agentd.sh
index 99e7f27..2ae918f 100755
--- a/scripts/build-agentd.sh
+++ b/scripts/build-agentd.sh
@@ -5,6 +5,9 @@
#
# scripts/build-agentd.sh build
# scripts/build-agentd.sh --smoke build, then run it inside a node:24-bookworm microVM
+#
+# COLONIZER_BUILD_HERE=1 builds in the current environment instead of a microVM. The release workflow
+# sets it when it is already running inside rust:1-alpine, where no microVM can start.
set -eu
REPO=$(cd "$(dirname "$0")/.." && pwd)
@@ -31,13 +34,18 @@ case "$(uname -m)" in
arm64|aarch64) target="aarch64-unknown-linux-musl" ;;
*) target="x86_64-unknown-linux-musl" ;;
esac
-echo "building colonizer-agentd ($target) in a rust:1-alpine microVM..."
-"$MSB" run --no-tty -q -m 4G -c 8 \
- -v "$SRC:/src" \
- -v "$REPO/target/alpine:/build-target" \
- -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \
- -w /src \
- rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release -p colonizer-agentd --target-dir /build-target'
+if [ "${COLONIZER_BUILD_HERE:-}" = 1 ]; then
+ echo "building colonizer-agentd ($target) here..."
+ (cd "$SRC" && cargo build --release -p colonizer-agentd --target-dir "$REPO/target/alpine")
+else
+ echo "building colonizer-agentd ($target) in a rust:1-alpine microVM..."
+ "$MSB" run --no-tty -q -m 4G -c 8 \
+ -v "$SRC:/src" \
+ -v "$REPO/target/alpine:/build-target" \
+ -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \
+ -w /src \
+ rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release -p colonizer-agentd --target-dir /build-target'
+fi
install -m 755 "$REPO/target/alpine/release/colonizer-agentd" "$OUT"
file "$OUT"
diff --git a/scripts/build-rtk.sh b/scripts/build-rtk.sh
index bf28b35..e043b0a 100755
--- a/scripts/build-rtk.sh
+++ b/scripts/build-rtk.sh
@@ -8,6 +8,8 @@
#
# scripts/build-rtk.sh build (skipped when dist/bin/rtk is already built from this source)
# scripts/build-rtk.sh --smoke build, then run it inside a node:24-bookworm microVM
+#
+# COLONIZER_BUILD_HERE=1 builds in the current environment instead of a microVM, as build-agentd.sh does.
set -eu
REPO=$(cd "$(dirname "$0")/.." && pwd)
@@ -31,15 +33,23 @@ if [ -x "$OUT" ] && [ -f "$stamp" ]; then
echo "rtk $version ($target) already built"
else
SRC="$REPO/target/rtk-src"
+ # Built in place, the source has to sit outside this repository: under it, cargo would take rtk for a
+ # member of the harness workspace and refuse to build it.
+ [ "${COLONIZER_BUILD_HERE:-}" != 1 ] || SRC="${TMPDIR:-/tmp}/colonizer-rtk-src"
rm -rf "$SRC" && mkdir -p "$SRC" "$REPO/target/rtk-build" "$REPO/target/alpine-rtk" "$REPO/target/alpine-cargo-registry" "$REPO/dist/bin"
tar -xzf "$archive" -C "$SRC" --strip-components 1
- echo "building rtk $version ($target) in a rust:1-alpine microVM..."
- "$MSB" run --no-tty -q -m 4G -c 8 \
- -v "$SRC:/src" \
- -v "$REPO/target/alpine-rtk:/build-target" \
- -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \
- -w /src \
- rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release --locked --target-dir /build-target'
+ if [ "${COLONIZER_BUILD_HERE:-}" = 1 ]; then
+ echo "building rtk $version ($target) here..."
+ (cd "$SRC" && cargo build --release --locked --target-dir "$REPO/target/alpine-rtk")
+ else
+ echo "building rtk $version ($target) in a rust:1-alpine microVM..."
+ "$MSB" run --no-tty -q -m 4G -c 8 \
+ -v "$SRC:/src" \
+ -v "$REPO/target/alpine-rtk:/build-target" \
+ -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \
+ -w /src \
+ rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release --locked --target-dir /build-target'
+ fi
install -m 755 "$REPO/target/alpine-rtk/release/rtk" "$OUT"
rm -f "$REPO/target/rtk-build/"*
touch "$stamp"
diff --git a/scripts/install-release.sh b/scripts/install-release.sh
new file mode 100644
index 0000000..ef76d15
--- /dev/null
+++ b/scripts/install-release.sh
@@ -0,0 +1,175 @@
+#!/bin/sh
+# Installs a prebuilt Colonizer release. Published with every release as `install.sh`, and served at
+# https://colonizer.dev/install.sh:
+#
+# curl -fsSL https://colonizer.dev/install.sh | sh
+# curl -fsSL https://colonizer.dev/install.sh | sh -s -- --pull-image
+#
+# It downloads the app for this machine from the GitHub release, checks it against the release's
+# SHA256SUMS, installs it to ~/.local/share/colonizer/app and links ~/.local/bin/colonizer. Running it
+# again updates the app in place. Settings (~/.config/colonizer) and colonies (~/.local/share/colonizer)
+# are never touched.
+#
+# Anthropic's code is not in a release, because it is not ours to redistribute, so two things come from
+# Anthropic's own channels instead, each checked before it is used:
+# - the Claude Agent SDK the agent module runs, from the npm registry, against the checksum the release
+# recorded from package-lock.json (fetch-at-install);
+# - on a Mac, the Linux build of Claude Code that colonies run, against Anthropic's manifest. A colony is
+# a Linux microVM, so the Mac's own binary cannot run in it.
+#
+# COLONIZER_VERSION=v0.1.0 install that release instead of the latest
+# COLONIZER_APP=
install the app there instead of ~/.local/share/colonizer/app
+# --pull-image also download the default colony image now (several gigabytes), so the
+# first colony boots instead of waiting on it
+#
+# Everything is inside main(), so a download cut short by the network runs nothing.
+set -eu
+
+main() {
+ repo="Colonizer-dev/harness"
+ docs="https://colonizer.dev/docs/install"
+ pull_image=0
+ for arg in "$@"; do
+ case "$arg" in
+ --pull-image) pull_image=1 ;;
+ *) fail "unknown option: $arg" ;;
+ esac
+ done
+
+ # One build per platform, and nothing pretends to work where it cannot.
+ case "$(uname -s)-$(uname -m)" in
+ Linux-x86_64)
+ platform=linux-x86_64
+ { [ -r /dev/kvm ] && [ -w /dev/kvm ]; } ||
+ fail "/dev/kvm is not readable and writable by $(id -un); colonies are KVM microVMs"
+ ;;
+ Darwin-arm64) platform=darwin-arm64 ;;
+ Darwin-x86_64) fail "Apple Silicon only: microsandbox's libkrun backend has no Intel Mac support" ;;
+ *) fail "no prebuilt Colonizer for $(uname -s) $(uname -m); see $docs" ;;
+ esac
+
+ for c in curl tar; do need "$c"; done
+ command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || fail "missing sha256sum or shasum"
+
+ version=${COLONIZER_VERSION:-latest}
+ if [ -n "${COLONIZER_RELEASE_URL:-}" ]; then
+ base=$COLONIZER_RELEASE_URL
+ elif [ "$version" = latest ]; then
+ base="https://github.com/$repo/releases/latest/download"
+ else
+ base="https://github.com/$repo/releases/download/$version"
+ fi
+ app=${COLONIZER_APP:-$HOME/.local/share/colonizer/app}
+ archive="colonizer-$platform.tar.gz"
+
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT INT TERM
+
+ say "downloading Colonizer ($version, $platform)"
+ fetch "$base/$archive" "$tmp/$archive"
+ fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS"
+ expected=$(awk -v f="$archive" '$2 == f || $2 == "*" f { print $1 }' "$tmp/SHA256SUMS")
+ [ -n "$expected" ] || fail "$archive is not listed in the release's SHA256SUMS"
+ [ "$(sha256_of "$tmp/$archive")" = "$expected" ] || fail "checksum mismatch for $archive; nothing was installed"
+
+ mkdir -p "$tmp/unpack"
+ tar -xzf "$tmp/$archive" -C "$tmp/unpack"
+ [ -x "$tmp/unpack/colonizer/bin/colonizer" ] || fail "$archive has no colonizer/bin/colonizer"
+ installed=$(cat "$tmp/unpack/colonizer/VERSION" 2>/dev/null || echo "$version")
+
+ for record in "$tmp"/unpack/colonizer/modules/agents/*/fetch-at-install; do
+ if [ -f "$record" ]; then fetch_at_install "$(dirname "$record")"; fi
+ done
+ if [ "$platform" = darwin-arm64 ]; then
+ guest_claude "$tmp/unpack/colonizer/bin/claude-guest" "$app/bin/claude-guest"
+ fi
+
+ # Swap the whole directory, so a colonizer started mid-install finds either the old app or the new one.
+ mkdir -p "$(dirname "$app")"
+ rm -rf "$app.new" "$app.old"
+ mv "$tmp/unpack/colonizer" "$app.new"
+ [ ! -e "$app" ] || mv "$app" "$app.old"
+ mv "$app.new" "$app"
+ rm -rf "$app.old"
+
+ mkdir -p "$HOME/.local/bin"
+ ln -sf "$app/bin/colonizer" "$HOME/.local/bin/colonizer"
+ say "installed Colonizer $installed to $app"
+
+ if [ "$pull_image" = 1 ]; then
+ image=${COLONIZER_IMAGE:-node:24-bookworm}
+ say "pulling colony image $image"
+ "$app/vendor/microsandbox/bin/msb" pull "$image"
+ fi
+
+ echo
+ missing=""
+ for c in git gh; do command -v "$c" >/dev/null 2>&1 || missing="$missing $c"; done
+ [ -z "$missing" ] || echo "Colonies also need:$missing (install them before launching one)."
+ if [ "$platform" = linux-x86_64 ] && ! command -v claude >/dev/null 2>&1; then
+ echo "Colonies run your native Claude Code install, and there is none on PATH: https://claude.com/claude-code"
+ fi
+ case ":$PATH:" in
+ *":$HOME/.local/bin:"*) run="colonizer" ;;
+ *) run="$HOME/.local/bin/colonizer" ;;
+ esac
+ echo "Run '$run' and open http://127.0.0.1:7878. If it was already running, restart it."
+ echo "Guide: $docs"
+}
+
+say() { printf '==> %s\n' "$1"; }
+fail() { printf 'colonizer install: %s\n' "$1" >&2; exit 1; }
+need() { command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"; }
+fetch() { curl -fsSL --retry 3 -o "$2" "$1" || fail "could not download $1"; }
+
+sha256_of() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | cut -d' ' -f1
+ else
+ shasum -a 256 "$1" | cut -d' ' -f1
+ fi
+}
+
+# Packages a module's release left out (scripts/record-fetch-at-install.mjs): each line of fetch-at-install
+# is ` `, and the tarball's package/ directory becomes /.
+fetch_at_install() {
+ module=$1
+ while read -r path url sha; do
+ [ -n "$path" ] || continue
+ name=$(basename "$path")
+ say "downloading $name for the $(basename "$module") module"
+ fetch "$url" "$tmp/$name.tgz"
+ [ "$(sha256_of "$tmp/$name.tgz")" = "$sha" ] || fail "checksum mismatch for $url; nothing was installed"
+ rm -rf "$tmp/$name.unpack" && mkdir -p "$tmp/$name.unpack"
+ tar -xzf "$tmp/$name.tgz" -C "$tmp/$name.unpack"
+ [ -d "$tmp/$name.unpack/package" ] || fail "$url has no package/ directory"
+ mkdir -p "$(dirname "$module/$path")"
+ rm -rf "${module:?}/$path"
+ mv "$tmp/$name.unpack/package" "$module/$path"
+ done < "$module/fetch-at-install"
+}
+
+# The Linux build of Claude Code for the guest, as scripts/fetch-agent-binary.sh fetches it for a source
+# build: the `stable` channel, checked against the checksum in Anthropic's manifest. The copy from the
+# previous install is reused when it is already that build. plutil reads the manifest, so a prebuilt
+# install needs no Node.js.
+guest_claude() {
+ out=$1 previous=$2
+ cc="https://downloads.claude.ai/claude-code-releases"
+ cc_version=$(curl -fsSL --retry 3 "$cc/stable") || fail "could not read Claude Code's stable channel"
+ case "$cc_version" in [0-9]*) ;; *) fail "unexpected Claude Code version: $cc_version" ;; esac
+ fetch "$cc/$cc_version/manifest.json" "$tmp/claude-manifest.json"
+ cc_sha=$(plutil -extract "platforms.linux-arm64.checksum" raw -o - "$tmp/claude-manifest.json" 2>/dev/null) ||
+ fail "no linux-arm64 checksum in the Claude Code $cc_version manifest"
+ if [ -f "$previous" ] && [ "$(sha256_of "$previous")" = "$cc_sha" ]; then
+ cp "$previous" "$out"
+ say "Claude Code $cc_version for colonies is already here"
+ else
+ say "downloading Claude Code $cc_version for colonies (linux-arm64)"
+ fetch "$cc/$cc_version/linux-arm64/claude" "$out"
+ [ "$(sha256_of "$out")" = "$cc_sha" ] || fail "checksum mismatch for Claude Code $cc_version; nothing was installed"
+ fi
+ chmod 755 "$out"
+}
+
+main "$@"
diff --git a/scripts/install.sh b/scripts/install.sh
index a9fdb32..410c6d7 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -6,23 +6,35 @@
# link ~/.local/bin/colonizer
# scripts/install.sh --pull-image also download the default colony image now, so the
# first colony boots instead of waiting on a download
+# scripts/install.sh --bundle build ./dist for a prebuilt release (.github/workflows/release.yml)
+#
+# A bundle is built on one machine and run on another, so --bundle skips the KVM check and the Claude Code
+# fetch (scripts/install-release.sh fetches it where the app is installed). Binaries already in
+# $COLONIZER_PREBUILT (colonizer, colonizer-agentd, rtk) are used as they are instead of being built:
+# the release workflow builds the Linux ones as static musl binaries inside rust:1-alpine.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
dist="$root/dist"
install_app=0
pull_image=0
+bundle=0
+prebuilt=${COLONIZER_PREBUILT:-}
# Opt-in on purpose: the colony image is gigabytes, and an installer that
# downloads that much without being asked is not a good guest on a laptop.
for arg in "$@"; do
case "$arg" in
--install) install_app=1 ;;
--pull-image) pull_image=1 ;;
+ --bundle) bundle=1 ;;
*) echo "unknown option: $arg" >&2; exit 1 ;;
esac
done
need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; }
-for c in cargo npm node git gh curl tar; do need "$c"; done
+# The build needs these; gh is for running the app, and a bundle is not run where it is built.
+for c in npm node git curl tar; do need "$c"; done
+[ "$bundle" = 1 ] || need gh
+[ -n "$prebuilt" ] && [ -x "$prebuilt/colonizer" ] || need cargo
# GNU calls it sha256sum, macOS ships shasum; either will do.
command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 ||
{ echo "missing required command: sha256sum or shasum" >&2; exit 1; }
@@ -30,7 +42,7 @@ command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 ||
# One rule per platform, and nothing pretends to work where it cannot.
case "$(uname -s)" in
Linux)
- [ -r /dev/kvm ] && [ -w /dev/kvm ] || { echo "/dev/kvm is not accessible; microVMs need KVM" >&2; exit 1; }
+ [ "$bundle" = 1 ] || { [ -r /dev/kvm ] && [ -w /dev/kvm ]; } || { echo "/dev/kvm is not accessible; microVMs need KVM" >&2; exit 1; }
;;
Darwin)
[ "$(uname -m)" = "arm64" ] ||
@@ -47,7 +59,7 @@ echo "==> vendored binaries (pinned, sha256-verified)"
# A colony is a Linux microVM, so the agent binary mounted into it has to be a Linux one. On Linux that
# is the host's own install; a Mac's is Mach-O and cannot run in the guest, so fetch the Linux build.
-if [ "$(uname -s)" = "Darwin" ]; then
+if [ "$(uname -s)" = "Darwin" ] && [ "$bundle" = 0 ]; then
echo "==> Claude Code for the guest (Linux build, sha256-verified)"
"$root/scripts/fetch-agent-binary.sh"
fi
@@ -61,11 +73,19 @@ for plugin in $(awk '$1 !~ /^#/ && $4 == "plugin" { print $1 }' "$root/vendor/ve
[ -d "$dist/plugins/$plugin" ] || { echo "vendored plugin $plugin is missing from $dist/plugins after vendoring" >&2; exit 1; }
done
+# A prebuilt binary is used as it is; anything missing from $COLONIZER_PREBUILT is built here.
+prebuilt_bin() {
+ [ -n "$prebuilt" ] && [ -x "$prebuilt/$1" ] || return 1
+ mkdir -p "$dist/bin"
+ install -m 755 "$prebuilt/$1" "$dist/bin/$1"
+ echo "using prebuilt $1 from $prebuilt"
+}
+
echo "==> colonizer-agentd (static musl build inside a microVM)"
-MSB="$msb" "$root/scripts/build-agentd.sh"
+prebuilt_bin colonizer-agentd || MSB="$msb" "$root/scripts/build-agentd.sh"
echo "==> rtk (static musl build inside a microVM, for colonies that switch on compact command output)"
-MSB="$msb" "$root/scripts/build-rtk.sh"
+prebuilt_bin rtk || MSB="$msb" "$root/scripts/build-rtk.sh"
echo "==> agent modules"
mkdir -p "$dist/modules/agents"
@@ -75,7 +95,14 @@ for module in "$root"/modules/agents/*/; do
rm -rf "$target"
mkdir -p "$target"
(cd "$module" && tar --exclude=./node_modules --exclude=./test -cf - .) | tar -xf - -C "$target"
- if [ -f "$target/package.json" ]; then
+ if [ -f "$target/package.json" ] && [ "$bundle" = 1 ]; then
+ # A release carries no Anthropic code. The Agent SDK is "all rights reserved", and its optional
+ # platform packages are Claude Code itself. Colonies run the Claude Code binary the install provides
+ # (pathToClaudeCodeExecutable in the runner), so the platform packages are left out, and the SDK is
+ # recorded in fetch-at-install for scripts/install-release.sh to fetch from the npm registry.
+ (cd "$target" && npm ci --omit=dev --omit=optional --no-audit --no-fund --silent)
+ (cd "$target" && node "$root/scripts/record-fetch-at-install.mjs" node_modules/@anthropic-ai/claude-agent-sdk)
+ elif [ -f "$target/package.json" ]; then
(cd "$target" && npm ci --omit=dev --no-audit --no-fund --silent)
fi
echo "installed agent module $id"
@@ -87,9 +114,11 @@ rm -rf "$dist/web"
cp -r "$root/web/dist" "$dist/web"
echo "==> harness"
-cargo build --release -p colonizer --manifest-path "$root/Cargo.toml"
-mkdir -p "$dist/bin"
-install -m 755 "$root/target/release/colonizer" "$dist/bin/colonizer"
+if ! prebuilt_bin colonizer; then
+ cargo build --release -p colonizer --manifest-path "$root/Cargo.toml"
+ mkdir -p "$dist/bin"
+ install -m 755 "$root/target/release/colonizer" "$dist/bin/colonizer"
+fi
if [ "$install_app" = 1 ]; then
app="$HOME/.local/share/colonizer/app"
diff --git a/scripts/record-fetch-at-install.mjs b/scripts/record-fetch-at-install.mjs
new file mode 100644
index 0000000..9bf0204
--- /dev/null
+++ b/scripts/record-fetch-at-install.mjs
@@ -0,0 +1,28 @@
+// For a release bundle (scripts/install.sh --bundle): takes the named packages out of an installed module's
+// node_modules and records where scripts/install-release.sh fetches them, so the release does not
+// redistribute them. Run from the module directory:
+//
+// node record-fetch-at-install.mjs node_modules/@anthropic-ai/claude-agent-sdk ...
+//
+// Each line of fetch-at-install is ` `. The tarball is the one package-lock.json
+// resolves, checked against the lockfile's integrity before its sha256 is written down, so the installer
+// verifies exactly what npm would have installed.
+import { createHash } from 'node:crypto';
+import { readFileSync, rmSync, writeFileSync } from 'node:fs';
+
+const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
+const lines = [];
+for (const path of process.argv.slice(2)) {
+ const entry = lock.packages?.[path];
+ if (!entry?.resolved || !entry?.integrity) throw new Error(`${path} has no resolved tarball and integrity in package-lock.json`);
+ const [algorithm, expected] = entry.integrity.split(/-(.*)/s);
+ const response = await fetch(entry.resolved);
+ if (!response.ok) throw new Error(`${entry.resolved}: HTTP ${response.status}`);
+ const tarball = Buffer.from(await response.arrayBuffer());
+ const actual = createHash(algorithm).update(tarball).digest('base64');
+ if (actual !== expected) throw new Error(`${path}: the tarball does not match package-lock.json's integrity`);
+ lines.push(`${path} ${entry.resolved} ${createHash('sha256').update(tarball).digest('hex')}`);
+ rmSync(path, { recursive: true, force: true });
+ console.log(`recorded ${path} ${entry.version} for install time`);
+}
+writeFileSync('fetch-at-install', `${lines.join('\n')}\n`);