#!/bin/bash
set -euo pipefail
# ── Keep PREINSTALLED.md and the public docs in sync ─────────────────
# This script and terraform/packer/scripts/PREINSTALLED.md must reflect
# each other. When you add or remove an install command below, mirror the
# change in PREINSTALLED.md — that file is read by terraform and shipped
# to the murmur catalog as the "What's on" summary that's surfaced in the
# Recipes UI and the Copy-for-LLM prompt template.
#
# This script is also published verbatim in the customer docs:
# prassoai/murmuration-docs configuration/vm-environment.mdx, under
# "The base image provisioning script". Whenever you change this file,
# update that page in the same change.
# ──────────────────────────────────────────────────────────────────────
# Provision the murmur base image (layer 1).
#
# This image ships everything murmur needs to run: OS packages, Node.js
# (Claude Code requirement), Claude Code, gh CLI, SSH known hosts,
# cloud-init, service user, and the systemd units.
#
# Language runtimes (Go, Python, etc.) belong in a customer image (layer 2)
# built on top of this base. See terraform/packer/customer-example/.
#
# Per-deployment config travels as cloud-init user data on both GCE and AWS.
# Cloud-init writes /etc/murmur/boot.sh which downloads binaries, writes
# config to disk, and starts murmur-boot.service.
export DEBIAN_FRONTEND=noninteractive
# ── System packages ──────────────────────────────────────────────────────────
apt-get update -qq
# Codex CLI uses bwrap (Debian: bubblewrap) for its default
# `workspace-write` sandbox on Linux — required for any codex-backend agent
# that executes shell commands.
apt-get install -y -qq git git-lfs jq ripgrep curl unzip tmux cloud-init bubblewrap sudo > /dev/null
# An EC2 instance built from this image obtains its first credential pair by
# signing a GetCallerIdentity request with SigV4 (DESIGN_VM_ATTESTED_BOOTSTRAP),
# and curl is what signs it. A curl without --aws-sigv4 produces a VM that boots,
# fails attestation, and crash-loops with nothing having complained — so the
# bake is where that is found out. (Added in curl 7.75; Debian 12 ships 7.88.)
if ! curl --help all | grep -q -- --aws-sigv4; then
echo "FATAL: curl lacks --aws-sigv4; EC2 instances from this image cannot attest for credentials" >&2
exit 1
fi
# ── Git LFS ──────────────────────────────────────────────────────────────────
# git-lfs needs two halves and they install in different places.
#
# The smudge/clean filters MATERIALIZE files. They are git config, so --system
# covers every repository at once; without them a clone of an LFS repository
# leaves pointer files on disk and the agent reads a hash where it expects the
# file. --skip-repo because the bake has no repository to install hooks into.
git lfs install --system --skip-repo
# The hooks carry everything the filters cannot: pre-push UPLOADS newly
# committed objects, and post-checkout/post-merge/post-commit fix up the
# working tree after a ref moves. They live in a repository's own hooks
# directory, and `git lfs install` can only write them into a repository that
# already exists — a plain `git clone` never adds them. Without pre-push a
# push publishes pointers whose blobs were never uploaded, leaving the remote
# branch referencing objects that do not exist.
#
# Git copies its init template into every repository it creates, so the hooks
# go there: workspace clones, plugin clones, and anything the agent clones
# itself all get them. The template is seeded from git's own so nothing it
# normally provides is lost, and the hooks are taken from git-lfs rather than
# written out here, so whichever set this git-lfs version installs is the set
# that ships.
GIT_TEMPLATE_DIR=/usr/local/share/git-core/murmur-templates
mkdir -p "${GIT_TEMPLATE_DIR}/hooks"
cp -r /usr/share/git-core/templates/. "${GIT_TEMPLATE_DIR}/"
LFS_HOOK_REPO="$(mktemp -d)"
git init -q "${LFS_HOOK_REPO}"
git -C "${LFS_HOOK_REPO}" lfs install --local
find "${LFS_HOOK_REPO}/.git/hooks" -maxdepth 1 -type f ! -name '*.sample' \
-exec cp {} "${GIT_TEMPLATE_DIR}/hooks/" \;
rm -rf "${LFS_HOOK_REPO}"
git config --system init.templateDir "${GIT_TEMPLATE_DIR}"
# OpenSnitch is the pinned transparent network interception data plane. Only the
# daemon package is installed; the PyQt desktop UI is intentionally absent.
OPENSNITCH_VERSION=1.8.0-1
case "$(dpkg --print-architecture)" in
amd64) OPENSNITCH_SHA256=9f517926877f07761ca95ec43dc9daefecac582574f9856079e1064be3fa1c8a ;;
arm64) OPENSNITCH_SHA256=7d80a1f6649d7f132bf19f860823ce9df4bcc86d2a7547ec03381165f76dfb3a ;;
armhf) OPENSNITCH_SHA256=c426df6106a2376c6c04f8394f7bef63f3fdf0d054f68481f7d0478d10aacf7a ;;
*) echo "unsupported OpenSnitch architecture: $(dpkg --print-architecture)" >&2; exit 1 ;;
esac
OPENSNITCH_DEB="opensnitch_${OPENSNITCH_VERSION}_$(dpkg --print-architecture).deb"
curl -fsSLo "/tmp/${OPENSNITCH_DEB}" "https://github.com/evilsocket/opensnitch/releases/download/v1.8.0/${OPENSNITCH_DEB}"
echo "${OPENSNITCH_SHA256} /tmp/${OPENSNITCH_DEB}" | sha256sum -c -
# The package post-install script starts opensnitch immediately. Starting and
# then stopping its nftables/NFQUEUE rules can sever Packer's active SSH
# connection. Debian's policy-rc.d contract suppresses service startup while
# preserving any policy supplied by the base image.
POLICY_RC_D=/usr/sbin/policy-rc.d
POLICY_RC_D_BACKUP_DIR=
if [ -e "$POLICY_RC_D" ]; then
POLICY_RC_D_BACKUP_DIR=$(mktemp -d)
mv "$POLICY_RC_D" "$POLICY_RC_D_BACKUP_DIR/policy-rc.d"
fi
restore_policy_rc_d() {
rm -f "$POLICY_RC_D"
if [ -n "$POLICY_RC_D_BACKUP_DIR" ]; then
mv "$POLICY_RC_D_BACKUP_DIR/policy-rc.d" "$POLICY_RC_D"
rmdir "$POLICY_RC_D_BACKUP_DIR"
fi
}
trap restore_policy_rc_d EXIT
printf '#!/bin/sh\nexit 101\n' > "$POLICY_RC_D"
chmod 0755 "$POLICY_RC_D"
apt-get install -y -qq "/tmp/${OPENSNITCH_DEB}" > /dev/null
restore_policy_rc_d
trap - EXIT
rm "/tmp/${OPENSNITCH_DEB}"
systemctl disable opensnitch.service
# ── SSM Agent (optional, platform images only) ──────────────────────────────
# Enables `aws ssm start-session` for debugging without SSH keys or ingress
# rules. Only installed when INSTALL_SSM_AGENT=1 — customer base images must
# not include it (trust boundary: we don't get shell access to customer VMs).
#
# Distro split: Debian's AWS AMI ships no agent, so install the .deb. Ubuntu's
# AWS AMI preinstalls amazon-ssm-agent as a snap (AWS recommends snap-only on
# Ubuntu >= 18.04); a `dpkg -i` on top collides ("installed by snap, use snap")
# and, under `set -euo pipefail`, would abort the bake — so just enable the
# preinstalled snap unit instead.
if [ "${INSTALL_SSM_AGENT:-}" = "1" ]; then
# shellcheck disable=SC1091 # /etc/os-release is provided by the base image
. /etc/os-release
if [ "${ID:-}" = "ubuntu" ]; then
systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service
else
SSM_DEB="amazon-ssm-agent.deb"
SSM_ARCH="amd64"
if [ "$(uname -m)" = "aarch64" ]; then SSM_ARCH="arm64"; fi
curl -fsSLo "$SSM_DEB" "https://amazon-ssm-us-east-1.s3.us-east-1.amazonaws.com/latest/debian_${SSM_ARCH}/amazon-ssm-agent.deb"
dpkg -i "$SSM_DEB"
rm "$SSM_DEB"
systemctl stop amazon-ssm-agent || true
systemctl enable amazon-ssm-agent
fi
fi
# ── GitHub CLI ───────────────────────────────────────────────────────────────
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null
apt-get update -qq
apt-get install -y -qq gh > /dev/null
# ── Claude Code ──────────────────────────────────────────────────────────────
# Direct binary download — pinned version, no npm, no auto-update.
# The baked image is immutable; auto-update is irrelevant.
CLAUDE_ARCH="linux-x64"
if [ "$(uname -m)" = "aarch64" ]; then CLAUDE_ARCH="linux-arm64"; fi
curl -fsSLo /usr/local/bin/claude "https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION}/${CLAUDE_ARCH}/claude"
chmod +x /usr/local/bin/claude
# ── Codex CLI ────────────────────────────────────────────────────────────────
# Standalone binary from GitHub Releases — pinned version, no npm.
CODEX_ARCH="x86_64"
if [ "$(uname -m)" = "aarch64" ]; then CODEX_ARCH="aarch64"; fi
for CODEX_BINARY in codex codex-code-mode-host; do
curl -fsSL "https://github.com/openai/codex/releases/download/${CODEX_VERSION}/${CODEX_BINARY}-${CODEX_ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin
mv "/usr/local/bin/${CODEX_BINARY}-${CODEX_ARCH}-unknown-linux-musl" "/usr/local/bin/${CODEX_BINARY}"
chmod +x "/usr/local/bin/${CODEX_BINARY}"
done
/usr/local/bin/codex --version
/usr/local/bin/codex-code-mode-host --help > /dev/null
# Codex exposes its structured patch parser through argv0 aliases. Keep both
# documented spellings on PATH so agent shells can apply focused file edits.
#
# These track the BAKED codex deliberately, and are not re-pointed at a build
# installed on demand. Nothing in murmuration invokes either name: codex is
# always launched by explicit path (internal/codex.Run), the multi-file
# dependency it does have is resolved beside its own executable rather than
# through PATH, and the `apply_patch` a codex model uses is a freeform tool
# call of its own — its instructions tell the model never to reach for the
# `applypatch` spelling at all. They are a convenience for an interactive
# agent shell, so pointing them at a versioned install would buy nothing and
# would mean writing into /usr/local/bin, where a turn already in flight is
# running the file being replaced.
ln -sf /usr/local/bin/codex /usr/local/bin/apply_patch
ln -sf /usr/local/bin/codex /usr/local/bin/applypatch
# ── opencode CLI ─────────────────────────────────────────────────────────────
# Standalone binary from GitHub Releases — pinned version, no npm. The
# release tarball unpacks to a bare `opencode` binary.
OPENCODE_ARCH="x64"
if [ "$(uname -m)" = "aarch64" ]; then OPENCODE_ARCH="arm64"; fi
curl -fsSL "https://github.com/sst/opencode/releases/download/${OPENCODE_VERSION}/opencode-linux-${OPENCODE_ARCH}.tar.gz" | tar -xz -C /usr/local/bin
chmod +x /usr/local/bin/opencode
# ── Baked harness record ─────────────────────────────────────────────────────
# What this image pinned, declared by murmur.vm.v1.BakedHarnesses and read as
# protojson (internal/vmharness) — the same shape /etc/murmur/baked-image.json
# uses for the bake stamp beside it. The field names below are that message's;
# nothing else about this file is free-form.
#
# The versions are in the same identifiers murmur's harness manifest uses — both this script and `make harness-vendor` fetch from these very URLs
# parameterised by this very string, so "the baked build satisfies the selected
# version" is an exact string comparison and never a parse of `--version`
# output, whose shape is each vendor's to change.
#
# A turn whose selection names a version this image already carries runs the
# baked binary and downloads nothing (internal/vmharness). An image without
# this file simply installs on demand, which is why writing it is an
# optimisation rather than a contract.
mkdir -p /etc/murmur
cat > /etc/murmur/baked-harnesses.json <<EOF
{"dir":"/usr/local/bin","harnesses":{"claude":"${CLAUDE_CODE_VERSION}","codex":"${CODEX_VERSION}","opencode":"${OPENCODE_VERSION}"}}
EOF
chmod 0644 /etc/murmur/baked-harnesses.json
# ── yq (YAML parser for systemd boot script) ────────────────────────────────
YQ_VERSION="v4.52.4"
curl -fsSLo /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
chmod +x /usr/local/bin/yq
# ── SSH known hosts ──────────────────────────────────────────────────────────
mkdir -p /etc/ssh
ssh-keyscan -t ed25519,rsa github.com >> /etc/ssh/ssh_known_hosts || { echo "WARNING: ssh-keyscan failed, GitHub SSH may not work" >&2; }
# ── SSH key-based auth ───────────────────────────────────────────────────────
# Developer isolation: the workflow's InstallSSHKeys activity writes each
# developer's GitHub SSH keys to ~murmur/.ssh/authorized_keys post-boot.
# OS Login is disabled (no enable-oslogin metadata) so these keys are the
# auth gate.
# Harden sshd: pubkey only, no password auth. Write a low-numbered drop-in
# instead of editing /etc/ssh/sshd_config directly. Both Debian 12 and Ubuntu
# 24.04 `Include /etc/ssh/sshd_config.d/*.conf` at the top of the main file,
# and sshd uses the first value seen for each keyword — so a main-file edit is
# overridden by any drop-in. Ubuntu cloud images ship 60-cloudimg-settings.conf;
# the 00- prefix sorts ahead of it so our hardening actually wins on both distros.
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/00-murmur-hardening.conf << 'SSHD'
PasswordAuthentication no
PubkeyAuthentication yes
SSHD
# ── Service user ─────────────────────────────────────────────────────────────
useradd --create-home --shell /bin/bash murmur 2>/dev/null || true
# Phase 1 allows every connection while OpenSnitch records the decision. The
# first rule gives murmur traffic a stable rule identity; the final rule keeps
# all other queued traffic off the interactive AskRule path.
MURMUR_UID=$(id -u murmur)
install -d -m 0755 /etc/opensnitchd/rules
rm -f /etc/opensnitchd/rules/*.json
cat > /etc/opensnitchd/default-config.json <<'OPENSNITCH_CONFIG'
{
"Server": {
"Address": "unix:///run/murmur-network/opensnitch.sock",
"Authentication": {"Type": "simple", "TLSOptions": {"CACert": "", "ServerCert": "", "ClientCert": "", "ClientKey": "", "SkipVerify": false, "ClientAuthType": "no-client-cert"}},
"LogFile": "/var/log/opensnitchd.log"
},
"DefaultAction": "allow",
"DefaultDuration": "once",
"InterceptUnknown": true,
"ProcMonitorMethod": "ebpf",
"LogLevel": 2,
"LogUTC": true,
"LogMicro": true,
"Firewall": "nftables",
"FwOptions": {"ConfigPath": "/etc/opensnitchd/system-fw.json", "MonitorInterval": "15s", "QueueBypass": true},
"Rules": {"Path": "/etc/opensnitchd/rules/", "EnableChecksums": false},
"Ebpf": {"EventsWorkers": 8, "QueueEventsSize": 0},
"Stats": {"MaxEvents": 1000, "MaxStats": 1000, "Workers": 6},
"Internal": {"GCPercent": 100, "FlushConnsOnStart": false}
}
OPENSNITCH_CONFIG
cat > /etc/opensnitchd/rules/100-murmur-phase1-allow.json <<EOF
{"name":"murmur-phase1-allow","enabled":true,"precedence":true,"nolog":false,"action":"allow","duration":"always","operator":{"type":"simple","operand":"user.id","data":"${MURMUR_UID}","sensitive":false,"list":[]}}
EOF
cat > /etc/opensnitchd/rules/999-phase1-catchall-allow.json <<'EOF'
{"name":"phase1-catchall-allow","enabled":true,"precedence":false,"nolog":true,"action":"allow","duration":"always","operator":{"type":"simple","operand":"true","data":"","sensitive":false,"list":[]}}
EOF
# Passwordless sudo: the agent has root on its own VM by design. The VM is
# untrusted, ephemeral, and single-tenant, and all real isolation is enforced
# server-side (proxy admission, identity-scoped API, per-tenant KMS the VM
# never touches) — root lets agents self-provision system dependencies at
# runtime (browser libraries, compilers, ...). Customers who want a non-root
# agent can remove this drop-in in their own bake. See docs/TRUST_BOUNDARIES.md.
echo "murmur ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/murmur
chmod 0440 /etc/sudoers.d/murmur
visudo -cf /etc/sudoers.d/murmur
cat > /home/murmur/.bashrc << 'BASHRC'
[[ $- == *i* ]] || return
if tmux has-session -t claude 2>/dev/null; then
echo " tmux attach -t claude — attach to Claude"
fi
echo " tmux ls — list sessions"
echo ""
BASHRC
chown murmur:murmur /home/murmur/.bashrc
# Prep SSH directory for InstallSSHKeys to write authorized_keys post-boot.
install -d -m 0700 -o murmur -g murmur /home/murmur/.ssh
# ── System environment ───────────────────────────────────────────────────────
# Prepare the /etc/murmur directory for cloud-init to write config into.
mkdir -p /etc/murmur
# Stamp the base image with its bake time. Opaque debug info surfaced by the
# Identify activity so a running VM reveals how stale its base image is.
# chmod: written by root, read by the murmur worker.
date -u +%Y-%m-%dT%H:%M:%SZ > /etc/murmur/base-image-baked-at
chmod 0644 /etc/murmur/base-image-baked-at
# Stamp the base image with its capability version: which revision of this
# script built it. Monotonic — a higher version provisions everything a lower
# one does. Bump it in the same commit as the provisioning change that adds a
# capability something else gates on, and declare that capability in
# internal/basecap with the bumped value; the two are held equal by
# TestBaseImageCapabilityVersion in this directory. Identify reports the number
# so a feature can ask what the image under it provides — the murmur-vm binary
# version answers nothing about that.
BASE_IMAGE_CAPABILITY_VERSION=1
printf '%s\n' "${BASE_IMAGE_CAPABILITY_VERSION}" > /etc/murmur/base-image-capability-version
chmod 0644 /etc/murmur/base-image-capability-version
# No systemd services are installed here. Cloud-init user data writes the
# murmur-worker.service unit, enables it, and starts it on first boot.
# PrepareSnapshot deletes everything cloud-init wrote so baked images are
# clean. See internal/activityimpl/bootscript for the user data template.