Compare commits

...

17 Commits

Author SHA1 Message Date
2e5912e73c Add refactoring note to TODO 2026-04-07 16:32:32 +00:00
27cb6508dc Extract shared VM lifecycle helpers into firecracker-vm.ts 2026-04-07 16:32:24 +00:00
a2cef20a89 v0.1.3 — deployment hardening
Bump to v0.1.3. Since v0.1.2:
- Install script with verbose output and error handling
- Uninstall script
- Alpine rootfs in setup.ts (was Ubuntu)
- DNS fix for all chroot operations
- Stale tap cleanup before every createTap
- Dynamic binary paths (no hardcoded /usr/local/bin)
- Node.js upgrade handling
- Shellcheck clean
- !status command and web search tool
- Battle-tested on Ubuntu GPU server deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:18:04 +00:00
e6a6fb263d Use dynamic path for fireclaw in overseer service file
$(which fireclaw) instead of hardcoded /usr/local/bin/fireclaw.
Fixes 203/EXEC on systems where npm link installs to /usr/bin/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:09:10 +00:00
cf2d2d31b7 Always clean stale taps before creating new ones
deleteTap before createTap in all four call sites:
snapshot restore, cold boot, agent start, snapshot create.
Prevents "Device or resource busy" from leftover taps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:07:24 +00:00
6485705d4b Remove hardcoded DNS — keep host resolv.conf in rootfs
Don't overwrite the host's resolv.conf with hardcoded 8.8.8.8.
The host's DNS config is already correct for both build and runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:04:15 +00:00
6e9725d358 Fix DNS in install script chroot
Copy host /etc/resolv.conf into chroot before apk install.
Set static nameserver after install for runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:02:48 +00:00
b5ad20ce51 Fix chroot DNS and mkfs.ext4 path for remote deployment
- Copy host /etc/resolv.conf into chroot before apk install (fixes DNS)
- Set static DNS (8.8.8.8) after chroot install for runtime
- Use PATH-based mkfs.ext4 instead of hardcoded /usr/sbin/
- Show chroot package install output (stdio: inherit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:00:16 +00:00
1fee80f1d7 Clean stale mounts before agent rootfs build
Unmount and remove leftover files from previous failed install
attempts before starting the agent rootfs build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:56:33 +00:00
e98f9af938 Remove output suppression from install script
- All commands now show their output for debugging
- Use PATH-based e2fsck/resize2fs instead of hardcoded /usr/sbin/
- Add error checks with meaningful messages at each step
- set -e in chroot to fail fast on errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:55:05 +00:00
b613c2db6f Switch setup.ts to Alpine rootfs, fix remote deployment
- setup.ts now downloads Alpine Linux minirootfs instead of Ubuntu squashfs
- Installs Alpine packages (openssh, python3, curl, ca-certificates) in chroot
- Fixes install script failing on non-Alpine base rootfs (adduser syntax)
- Clean up unused imports and lint warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:21:06 +00:00
d149319090 Improve install script with verbose progress output
- Step headers, checkmarks, skip indicators for each component
- Shows what's being installed vs already present
- Progress messages for long operations (model pulls, rootfs build)
- Banner at start and summary at end with disk usage and model count
- Per-package install status on Debian/Ubuntu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:09:08 +00:00
2c82b3f7ae Fix Node.js install — upgrade if version < 20
Install script now detects existing Node.js < 20 and upgrades it
instead of skipping. Supports apt, dnf, and apk package managers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:05:23 +00:00
bdd4c185bb Add uninstall script
scripts/uninstall.sh — clean removal of fireclaw:
- Stops all agents and overseer
- Removes bridge, taps, iptables rules
- Removes ~/.fireclaw data directory
- Unlinks global command
- Optionally removes deps (--keep-deps to preserve them)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:00:02 +00:00
4b01dfb51d Fix shellcheck warnings across all scripts
Quote all variable expansions in setup-bridge.sh, teardown-bridge.sh,
and install.sh. Fix redirect order and unused variable in test-suite.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:56:17 +00:00
129fd4d869 Add install script for one-command deployment
scripts/install.sh handles full fireclaw deployment on a fresh machine:
- System packages (curl, jq, git, ngircd)
- Node.js 20, Firecracker, Ollama
- ngircd config (nyx.fireclaw.local)
- Agent rootfs build (Alpine + Python + podman)
- VM snapshot, overseer systemd service, templates
- Optional --with-gpu flag for larger models

Usage: ./scripts/install.sh [--with-gpu]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:52:45 +00:00
5cc6a38c96 Add !status command and web search tool
- !status: shows agent count, load, RAM, disk, uptime, Ollama model loaded
- web_search tool: agents can search via SearXNG (searx.mymx.me)
  Works in both structured and text-based tool call paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:36:27 +00:00
17 changed files with 1019 additions and 323 deletions

View File

@@ -121,8 +121,11 @@ Export an agent's complete state (workspace, config, rootfs diff) as a tarball.
### Multi-host agents
Run agents on multiple machines (grogbox + odin). Overseer manages VMs across hosts via SSH. Agents on different hosts communicate via IRC federation.
### GPU passthrough
When/if grogbox gets a GPU: pass it through to a single agent VM for fast inference. That agent becomes the "smart" one, others stay on CPU. Or run Ollama with GPU on the host and all agents benefit.
### GPU deployment
Remote machine available: Xeon E5-1620 v4, 32GB RAM, Quadro P5000 (16GB VRAM). Enough for 14B-30B models at 2-5s inference. Standalone fireclaw deployment — its own ngircd, its own agents, completely independent from grogbox.
### Install script
`scripts/install.sh` — one-command deployment to new machines. Installs firecracker, ollama (with GPU if available), ngircd, Node.js, builds rootfs, configures everything. `curl -fsSL .../install.sh | bash` or just `./scripts/install.sh`. No Ansible dependency — plain bash.
## Fun & Experimental

View File

@@ -21,6 +21,8 @@
- [x] Systemd service (KillMode=process)
- [x] Regression test suite (20 tests)
- [ ] Refactor duplicated code — waitForSocket, boot sequence, tap setup, rootfs mount/inject are copy-pasted across vm.ts, snapshot.ts, agent-manager.ts. Extract shared helpers.
## Next up
- [ ] Network policies per agent — restrict internet access

View File

@@ -104,8 +104,31 @@ TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web using SearXNG. Returns titles, URLs, and snippets for the top results. Use this when you need current information or facts you're unsure about.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query",
},
"num_results": {
"type": "integer",
"description": "Number of results to return (default 5)",
},
},
"required": ["query"],
},
},
},
]
SEARX_URL = CONFIG.get("searx_url", "https://searx.mymx.me")
def log(msg):
print(f"[agent:{NICK}] {msg}", flush=True)
@@ -219,6 +242,32 @@ def save_memory(topic, content):
return f"Memory saved to {filepath}"
def web_search(query, num_results=5):
"""Search the web via SearXNG."""
log(f"Web search: {query[:60]}")
try:
import urllib.parse
params = urllib.parse.urlencode({"q": query, "format": "json"})
req = urllib.request.Request(
f"{SEARX_URL}/search?{params}",
headers={"User-Agent": "fireclaw-agent"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
results = data.get("results", [])[:num_results]
if not results:
return "No results found."
lines = []
for r in results:
title = r.get("title", "")
url = r.get("url", "")
snippet = r.get("content", "")[:150]
lines.append(f"- {title}\n {url}\n {snippet}")
return "\n".join(lines)
except Exception as e:
return f"[search error: {e}]"
def try_parse_tool_call(text):
"""Try to parse a text-based tool call from model output.
Handles formats like:
@@ -298,6 +347,12 @@ def query_ollama(messages):
log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})")
result = save_memory(topic, content)
messages.append({"role": "tool", "content": result})
elif fn_name == "web_search":
query = fn_args.get("query", "")
num = fn_args.get("num_results", 5)
log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: web_search({query[:60]})")
result = web_search(query, num)
messages.append({"role": "tool", "content": result})
else:
messages.append({
"role": "tool",
@@ -324,6 +379,12 @@ def query_ollama(messages):
log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})")
result = save_memory(topic, mem_content)
messages.append({"role": "user", "content": f"{result}\n\nNow respond to the user."})
elif fn_name == "web_search":
query = fn_args.get("query", "")
num = fn_args.get("num_results", 5)
log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: web_search({query[:60]})")
result = web_search(query, num)
messages.append({"role": "user", "content": f"Search results:\n{result}\n\nNow respond to the user based on these results."})
payload["messages"] = messages
continue
@@ -339,7 +400,8 @@ def build_messages(question, channel):
if TOOLS_ENABLED:
system += "\n\nYou have access to tools:"
system += "\n- run_command: Execute shell commands on your system."
system += "\n- save_memory: Save important information to your persistent workspace (/workspace/memory/). Use this to remember things across restarts — user preferences, learned facts, project context."
system += "\n- web_search: Search the web for current information."
system += "\n- save_memory: Save important information to your persistent workspace."
system += "\nUse tools when needed rather than guessing. Your workspace at /workspace persists across restarts."
if AGENT_MEMORY and AGENT_MEMORY != "# Agent Memory":
system += f"\n\nIMPORTANT - Your persistent memory (facts you saved previously, use these to answer questions):\n{AGENT_MEMORY}"

View File

@@ -1,6 +1,6 @@
{
"name": "fireclaw",
"version": "0.1.2",
"version": "0.1.3",
"description": "Multi-agent system powered by Firecracker microVMs",
"type": "module",
"bin": {

465
scripts/install.sh Executable file
View File

@@ -0,0 +1,465 @@
#!/bin/bash
# Fireclaw install script
# Installs everything needed to run fireclaw on a fresh Linux machine.
# Requires: root or sudo access, KVM support, internet access.
#
# Usage: ./scripts/install.sh [--with-gpu]
set -euo pipefail
WITH_GPU=false
[[ "${1:-}" == "--with-gpu" ]] && WITH_GPU=true
log() { echo -e "\033[1;34m[fireclaw]\033[0m $*"; }
step() { echo -e "\n\033[1;32m━━━ $* ━━━\033[0m"; }
ok() { echo -e " \033[0;32m✓\033[0m $*"; }
skip() { echo -e " \033[0;33m→\033[0m $* (already installed)"; }
err() { echo -e "\033[1;31m[error]\033[0m $*" >&2; exit 1; }
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ Fireclaw Installer v0.1.2 ║"
echo " ║ Multi-agent Firecracker system ║"
echo " ╚═══════════════════════════════════════╝"
echo ""
# ─── Preflight checks ────────────────────────────────────────────────
step "Preflight checks"
[[ $(uname) != "Linux" ]] && err "Linux required."
ok "Linux detected: $(uname -r)"
[[ ! -e /dev/kvm ]] && err "KVM not available. Enable virtualization in BIOS."
ok "KVM available"
if groups | grep -qw kvm; then
ok "User $(whoami) in kvm group"
else
log "Adding $(whoami) to kvm group (re-login required after install)..."
sudo usermod -aG kvm "$(whoami)"
ok "Added to kvm group"
fi
# ─── System packages ─────────────────────────────────────────────────
step "System packages"
if command -v apt-get &>/dev/null; then
log "Updating apt..."
sudo apt-get update -qq
for pkg in curl jq git ngircd e2fsprogs; do
if dpkg -l "$pkg" &>/dev/null; then
skip "$pkg"
else
log "Installing $pkg..."
sudo apt-get install -y -qq "$pkg" >/dev/null
ok "$pkg installed"
fi
done
elif command -v dnf &>/dev/null; then
for pkg in curl jq git ngircd e2fsprogs; do
if rpm -q "$pkg" &>/dev/null; then
skip "$pkg"
else
log "Installing $pkg..."
sudo dnf install -y -q "$pkg"
ok "$pkg installed"
fi
done
elif command -v apk &>/dev/null; then
sudo apk add --no-cache curl jq git ngircd e2fsprogs
ok "Packages installed"
else
err "Unsupported package manager. Install manually: curl, jq, git, ngircd, e2fsprogs"
fi
# ─── Node.js ──────────────────────────────────────────────────────────
step "Node.js"
NODE_VER=0
if command -v node &>/dev/null; then
NODE_VER=$(node -v | cut -d. -f1 | tr -d v)
fi
if [[ $NODE_VER -ge 20 ]]; then
skip "Node.js $(node -v)"
else
log "Installing Node.js 20 (current: ${NODE_VER:-not installed})..."
if command -v apt-get &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - >/dev/null 2>&1
sudo apt-get install -y -qq nodejs >/dev/null
elif command -v dnf &>/dev/null; then
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - >/dev/null 2>&1
sudo dnf install -y -q nodejs
elif command -v apk &>/dev/null; then
sudo apk add --no-cache nodejs npm
else
err "Cannot install Node.js 20+. Install it manually."
fi
NODE_VER=$(node -v | cut -d. -f1 | tr -d v)
[[ $NODE_VER -lt 20 ]] && err "Node.js 20+ required, found $(node -v). Install manually."
ok "Node.js $(node -v) installed"
fi
# ─── Firecracker ──────────────────────────────────────────────────────
step "Firecracker"
if command -v firecracker &>/dev/null; then
skip "Firecracker $(firecracker --version 2>&1 | head -1)"
else
ARCH=$(uname -m)
log "Fetching latest Firecracker release..."
FC_VERSION=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest | jq -r .tag_name)
log "Downloading Firecracker ${FC_VERSION} (${ARCH})..."
curl -fSL -o /tmp/firecracker.tgz \
"https://github.com/firecracker-microvm/firecracker/releases/download/${FC_VERSION}/firecracker-${FC_VERSION}-${ARCH}.tgz"
log "Extracting..."
tar xzf /tmp/firecracker.tgz -C /tmp
sudo cp "/tmp/release-${FC_VERSION}-${ARCH}/firecracker-${FC_VERSION}-${ARCH}" /usr/local/bin/firecracker
sudo cp "/tmp/release-${FC_VERSION}-${ARCH}/jailer-${FC_VERSION}-${ARCH}" /usr/local/bin/jailer
rm -rf /tmp/firecracker.tgz "/tmp/release-${FC_VERSION}-${ARCH}"
ok "Firecracker $(firecracker --version 2>&1 | head -1) installed"
fi
# ─── Ollama ───────────────────────────────────────────────────────────
step "Ollama"
if command -v ollama &>/dev/null; then
skip "Ollama $(ollama --version 2>&1 | grep -oP '[\d.]+' | head -1)"
else
log "Installing Ollama..."
curl -fsSL https://ollama.com/install.sh | sh 2>&1 | tail -3 || true
ok "Ollama installed"
fi
log "Configuring Ollama systemd service..."
sudo tee /etc/systemd/system/ollama.service > /dev/null << EOF
[Unit]
Description=Ollama LLM Server
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
User=$(whoami)
Group=$(id -gn)
Restart=always
RestartSec=3
Environment="OLLAMA_HOST=0.0.0.0"
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ollama >/dev/null 2>&1
sleep 2
ok "Ollama service running on 0.0.0.0:11434"
log "Pulling default model: qwen2.5-coder:7b (this may take a few minutes)..."
ollama pull qwen2.5-coder:7b 2>/dev/null || true
ok "qwen2.5-coder:7b ready"
if $WITH_GPU; then
log "GPU mode: pulling larger models..."
log " Pulling qwen2.5:14b..."
ollama pull qwen2.5:14b 2>/dev/null || true
ok "qwen2.5:14b ready"
log " Pulling qwen2.5-coder:14b..."
ollama pull qwen2.5-coder:14b 2>/dev/null || true
ok "qwen2.5-coder:14b ready"
fi
# ─── ngircd ───────────────────────────────────────────────────────────
step "IRC Server (ngircd)"
log "Backing up existing config..."
[[ -f /etc/ngircd/ngircd.conf ]] && sudo cp /etc/ngircd/ngircd.conf /etc/ngircd/ngircd.conf.bak 2>/dev/null || true
log "Writing fireclaw IRC config..."
sudo tee /etc/ngircd/ngircd.conf > /dev/null << EOF
[Global]
Name = nyx.fireclaw.local
AdminInfo1 = fireclaw
AdminEMail = admin@localhost
Info = nyx - fireclaw agent network
Listen = 127.0.0.1,172.16.0.1
Network = FireclawNet
PidFile = /run/ngircd/ngircd.pid
ServerGID = irc
ServerUID = irc
[Limits]
ConnectRetry = 60
MaxConnections = 100
MaxConnectionsIP = 20
MaxJoins = 20
PingTimeout = 120
PongTimeout = 20
[Options]
DNS = no
Ident = no
PAM = no
OperCanUseMode = yes
DefaultUserModes = CiFo
SyslogFacility = daemon
[Operator]
Name = admin
Password = fireclaw-oper
[Channel]
Name = #control
Topic = Overseer command channel
Modes = +tn
[Channel]
Name = #agents
Topic = Agent common room
Modes = +tnQN
EOF
sudo systemctl enable --now ngircd >/dev/null 2>&1
sudo systemctl restart ngircd
ok "ngircd running as nyx.fireclaw.local"
# ─── Fireclaw ─────────────────────────────────────────────────────────
step "Fireclaw"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
log "Installing npm dependencies..."
cd "$SCRIPT_DIR"
npm install || err "npm install failed"
ok "Dependencies installed"
log "Building TypeScript..."
npm run build || err "npm run build failed"
ok "Build complete"
log "Linking global command..."
sudo npm link || err "npm link failed"
ok "fireclaw command available globally"
log "Running fireclaw setup (kernel + base rootfs + bridge + SSH keys)..."
fireclaw setup
ok "Base setup complete"
# ─── Agent rootfs ─────────────────────────────────────────────────────
step "Agent rootfs"
FIRECLAW_DIR="$HOME/.fireclaw"
if [[ -f "$FIRECLAW_DIR/agent-rootfs.ext4" ]]; then
skip "Agent rootfs"
else
log "Creating 1G agent rootfs from Alpine base..."
# Clean up any stale mounts from previous failed runs
sudo umount /tmp/agent-build-mnt 2>/dev/null || true
sudo umount /tmp/fireclaw-alpine 2>/dev/null || true
rm -rf /tmp/agent-build-mnt /tmp/agent-build.ext4 2>/dev/null || true
cp "$FIRECLAW_DIR/base-rootfs.ext4" /tmp/agent-build.ext4
truncate -s 1G /tmp/agent-build.ext4
log " Checking filesystem..."
sudo e2fsck -fy /tmp/agent-build.ext4 || err "e2fsck failed. Is e2fsprogs installed?"
log " Resizing to 1G..."
sudo resize2fs /tmp/agent-build.ext4 || err "resize2fs failed."
ok "Image resized to 1G"
mkdir -p /tmp/agent-build-mnt
sudo mount /tmp/agent-build.ext4 /tmp/agent-build-mnt || err "Failed to mount agent rootfs"
# Copy host DNS so chroot can resolve packages
sudo cp /etc/resolv.conf /tmp/agent-build-mnt/etc/resolv.conf
log "Installing Alpine packages (openssh, python3, podman, curl, jq, bash)..."
log " This may take a minute..."
sudo chroot /tmp/agent-build-mnt sh -c '
set -e
apk update
apk add --no-cache openssh-server ca-certificates curl jq python3 bash openrc podman iptables
rc-update add sshd default
rc-update add cgroups boot
ssh-keygen -A
echo "PermitRootLogin prohibit-password" >> /etc/ssh/sshd_config
adduser -D -h /home/agent -s /bin/bash agent
echo "root:100000:65536" > /etc/subuid
echo "root:100000:65536" > /etc/subgid
echo "agent:100000:65536" >> /etc/subuid
echo "agent:100000:65536" >> /etc/subgid
mkdir -p /etc/containers
echo "[containers]" > /etc/containers/containers.conf
echo "netns = \"host\"" >> /etc/containers/containers.conf
echo "[storage]" > /etc/containers/storage.conf
echo "driver = \"vfs\"" >> /etc/containers/storage.conf
' || err "Failed to install packages in chroot"
ok "Alpine packages installed"
log "Installing agent script and config..."
sudo mkdir -p /tmp/agent-build-mnt/opt/agent /tmp/agent-build-mnt/etc/agent
sudo cp "$SCRIPT_DIR/agent/agent.py" /tmp/agent-build-mnt/opt/agent/agent.py
sudo chmod +x /tmp/agent-build-mnt/opt/agent/agent.py
echo '{"nick":"agent","model":"qwen2.5-coder:7b","trigger":"mention","server":"172.16.0.1","port":6667,"ollama_url":"http://172.16.0.1:11434"}' | \
sudo tee /tmp/agent-build-mnt/etc/agent/config.json > /dev/null
echo "You are a helpful assistant on IRC." | \
sudo tee /tmp/agent-build-mnt/etc/agent/persona.md > /dev/null
ok "Agent script installed"
log "Configuring init system..."
sudo tee /tmp/agent-build-mnt/etc/inittab > /dev/null << 'INITTAB'
::sysinit:/sbin/openrc sysinit
::sysinit:/sbin/openrc boot
::sysinit:/sbin/openrc default
ttyS0::respawn:/sbin/getty -L 115200 ttyS0 vt100
::respawn:/bin/su -s /bin/sh agent -c "/usr/bin/python3 /opt/agent/agent.py"
::ctrlaltdel:/sbin/reboot
::shutdown:/sbin/openrc shutdown
INITTAB
sudo tee /tmp/agent-build-mnt/etc/init.d/podman-setup > /dev/null << 'SVC'
#!/sbin/openrc-run
description="Set up podman prerequisites"
depend() { before sshd; after localmount; }
start() {
mkdir -p /sys/fs/cgroup /dev/shm
mount -t cgroup2 cgroup2 /sys/fs/cgroup 2>/dev/null
mount -t tmpfs tmpfs /dev/shm 2>/dev/null
return 0
}
SVC
sudo chmod +x /tmp/agent-build-mnt/etc/init.d/podman-setup
sudo chroot /tmp/agent-build-mnt rc-update add podman-setup boot 2>/dev/null
sudo tee /tmp/agent-build-mnt/etc/init.d/workspace > /dev/null << 'SVC'
#!/sbin/openrc-run
description="Mount agent workspace"
depend() { need localmount; before sshd; }
start() {
mkdir -p /workspace
if [ -b /dev/vdb ]; then
mount /dev/vdb /workspace
chown -R agent:agent /workspace
einfo "Workspace mounted at /workspace"
fi
return 0
}
stop() { umount /workspace 2>/dev/null; return 0; }
SVC
sudo chmod +x /tmp/agent-build-mnt/etc/init.d/workspace
sudo chroot /tmp/agent-build-mnt rc-update add workspace boot 2>/dev/null
sudo tee /tmp/agent-build-mnt/etc/init.d/networking > /dev/null << 'SVC'
#!/sbin/openrc-run
depend() { need localmount; }
start() { ip link set lo up; return 0; }
SVC
sudo chmod +x /tmp/agent-build-mnt/etc/init.d/networking
sudo chroot /tmp/agent-build-mnt rc-update add networking boot 2>/dev/null
ok "Init services configured"
sudo umount /tmp/agent-build-mnt
rmdir /tmp/agent-build-mnt
sudo mv /tmp/agent-build.ext4 "$FIRECLAW_DIR/agent-rootfs.ext4"
ok "Agent rootfs built ($(du -sh "$FIRECLAW_DIR/agent-rootfs.ext4" | cut -f1) on disk)"
fi
# ─── Snapshot ─────────────────────────────────────────────────────────
step "VM Snapshot"
if [[ -f "$FIRECLAW_DIR/snapshot.state" ]]; then
skip "Snapshot"
else
log "Creating VM snapshot for fast restores..."
fireclaw snapshot create
ok "Snapshot created"
fi
# ─── Overseer service ────────────────────────────────────────────────
step "Overseer service"
log "Configuring systemd service..."
sudo tee /etc/systemd/system/fireclaw-overseer.service > /dev/null << EOF
[Unit]
Description=Fireclaw Overseer — IRC agent lifecycle manager
After=network-online.target ngircd.service ollama.service
Wants=network-online.target
[Service]
ExecStart=$(which fireclaw) overseer
User=$(whoami)
Group=$(id -gn)
Restart=always
RestartSec=5
KillMode=process
WorkingDirectory=$SCRIPT_DIR
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now fireclaw-overseer >/dev/null 2>&1
ok "Overseer running in #control"
# ─── Templates ────────────────────────────────────────────────────────
step "Agent templates"
TMPL_DIR="$FIRECLAW_DIR/templates"
mkdir -p "$TMPL_DIR"
for tmpl in worker coder quick; do
if [[ -f "$TMPL_DIR/$tmpl.json" ]]; then
skip "$tmpl"
else
case $tmpl in
worker) echo '{"name":"worker","nick":"worker","model":"qwen2.5-coder:7b","trigger":"mention","persona":"You are a general-purpose assistant on IRC. Keep responses concise."}' > "$TMPL_DIR/$tmpl.json" ;;
coder) echo '{"name":"coder","nick":"coder","model":"qwen2.5-coder:7b","trigger":"mention","persona":"You are a code-focused assistant on IRC. Be direct and technical."}' > "$TMPL_DIR/$tmpl.json" ;;
quick) echo '{"name":"quick","nick":"quick","model":"phi4-mini","trigger":"mention","tools":false,"network":"none","persona":"You are a fast assistant on IRC. One sentence answers."}' > "$TMPL_DIR/$tmpl.json" ;;
esac
ok "$tmpl template created"
fi
done
# ─── Done ─────────────────────────────────────────────────────────────
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ Installation complete! ║"
echo " ╚═══════════════════════════════════════╝"
echo ""
log "Services:"
log " ngircd nyx.fireclaw.local :6667"
log " ollama 0.0.0.0:11434"
log " overseer IRC #control"
echo ""
log "Quick start:"
log " irssi -c localhost -n human"
log " /join #control"
log " !invoke worker"
echo ""
log "CLI:"
log " fireclaw run \"uname -a\""
log " fireclaw agent list"
log " fireclaw --help"
echo ""
if $WITH_GPU; then
log "GPU mode: larger models pulled (14b)"
fi
log "Disk usage: $(du -sh "$FIRECLAW_DIR" | cut -f1) in $FIRECLAW_DIR"
log "Models: $(ollama list 2>/dev/null | tail -n +2 | wc -l) available"
echo ""

View File

@@ -10,21 +10,21 @@ SUBNET="172.16.0.0/24"
EXT_IFACE=$(ip route show default | awk '{print $5; exit}')
echo "Creating bridge ${BRIDGE}..."
ip link add ${BRIDGE} type bridge 2>/dev/null || echo "Bridge already exists"
ip addr add ${BRIDGE_IP} dev ${BRIDGE} 2>/dev/null || echo "Address already set"
ip link set ${BRIDGE} up
ip link add "${BRIDGE}" type bridge 2>/dev/null || echo "Bridge already exists"
ip addr add "${BRIDGE_IP}" dev "${BRIDGE}" 2>/dev/null || echo "Address already set"
ip link set "${BRIDGE}" up
echo "Enabling IP forwarding..."
sysctl -w net.ipv4.ip_forward=1
echo "Setting up NAT via ${EXT_IFACE}..."
iptables -t nat -C POSTROUTING -s ${SUBNET} -o ${EXT_IFACE} -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s ${SUBNET} -o ${EXT_IFACE} -j MASQUERADE
iptables -t nat -C POSTROUTING -s "${SUBNET}" -o "${EXT_IFACE}" -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s "${SUBNET}" -o "${EXT_IFACE}" -j MASQUERADE
iptables -C FORWARD -i ${BRIDGE} -o ${EXT_IFACE} -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i ${BRIDGE} -o ${EXT_IFACE} -j ACCEPT
iptables -C FORWARD -i "${BRIDGE}" -o "${EXT_IFACE}" -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "${BRIDGE}" -o "${EXT_IFACE}" -j ACCEPT
iptables -C FORWARD -i ${EXT_IFACE} -o ${BRIDGE} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i ${EXT_IFACE} -o ${BRIDGE} -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -C FORWARD -i "${EXT_IFACE}" -o "${BRIDGE}" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "${EXT_IFACE}" -o "${BRIDGE}" -m state --state RELATED,ESTABLISHED -j ACCEPT
echo "Done. Bridge ${BRIDGE} ready."

View File

@@ -9,12 +9,12 @@ SUBNET="172.16.0.0/24"
EXT_IFACE=$(ip route show default | awk '{print $5; exit}')
echo "Removing NAT rules..."
iptables -t nat -D POSTROUTING -s ${SUBNET} -o ${EXT_IFACE} -j MASQUERADE 2>/dev/null || true
iptables -D FORWARD -i ${BRIDGE} -o ${EXT_IFACE} -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -i ${EXT_IFACE} -o ${BRIDGE} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -t nat -D POSTROUTING -s "${SUBNET}" -o "${EXT_IFACE}" -j MASQUERADE 2>/dev/null || true
iptables -D FORWARD -i "${BRIDGE}" -o "${EXT_IFACE}" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -i "${EXT_IFACE}" -o "${BRIDGE}" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
echo "Removing bridge ${BRIDGE}..."
ip link set ${BRIDGE} down 2>/dev/null || true
ip link del ${BRIDGE} 2>/dev/null || true
ip link set "${BRIDGE}" down 2>/dev/null || true
ip link del "${BRIDGE}" 2>/dev/null || true
echo "Done."

134
scripts/uninstall.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/bin/bash
# Fireclaw uninstall script
# Stops all services, removes all fireclaw data, and optionally removes dependencies.
#
# Usage: ./scripts/uninstall.sh [--keep-deps]
# --keep-deps Keep system packages (firecracker, ollama, ngircd, node)
set -euo pipefail
KEEP_DEPS=false
[[ "${1:-}" == "--keep-deps" ]] && KEEP_DEPS=true
log() { echo -e "\033[1;34m[fireclaw]\033[0m $*"; }
warn() { echo -e "\033[1;33m[warn]\033[0m $*"; }
FIRECLAW_DIR="$HOME/.fireclaw"
# ─── Stop agents ──────────────────────────────────────────────────────
log "Stopping all agents..."
if command -v fireclaw &>/dev/null; then
# Get list of running agents and stop them
if [[ -f "$FIRECLAW_DIR/agents.json" ]]; then
for name in $(python3 -c "import json; [print(k) for k in json.load(open('$FIRECLAW_DIR/agents.json'))]" 2>/dev/null); do
log " Stopping agent: $name"
fireclaw agent stop "$name" 2>/dev/null || true
done
fi
fi
# Kill any remaining firecracker processes
pkill -f "firecracker --api-sock /tmp/fireclaw/" 2>/dev/null || true
sleep 1
# ─── Stop services ───────────────────────────────────────────────────
log "Stopping services..."
if systemctl is-active fireclaw-overseer &>/dev/null; then
sudo systemctl stop fireclaw-overseer
sudo systemctl disable fireclaw-overseer 2>/dev/null || true
fi
sudo rm -f /etc/systemd/system/fireclaw-overseer.service
# ─── Network cleanup ─────────────────────────────────────────────────
log "Cleaning up network..."
# Remove all fireclaw tap devices
for tap in $(ip link show 2>/dev/null | grep -oP 'fctap\d+' | sort -u); do
sudo ip tuntap del "$tap" mode tap 2>/dev/null || true
done
# Remove bridge
if ip link show fcbr0 &>/dev/null; then
# Remove iptables rules
SUBNET="172.16.0.0/24"
EXT_IFACE=$(ip route show default | awk '{print $5; exit}')
sudo iptables -t nat -D POSTROUTING -s "${SUBNET}" -o "${EXT_IFACE}" -j MASQUERADE 2>/dev/null || true
sudo iptables -D FORWARD -i fcbr0 -o "${EXT_IFACE}" -j ACCEPT 2>/dev/null || true
sudo iptables -D FORWARD -i "${EXT_IFACE}" -o fcbr0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
# Remove any per-agent DROP/ACCEPT rules
for _ in $(seq 1 20); do
sudo iptables -D FORWARD -j DROP 2>/dev/null || break
done
for _ in $(seq 1 20); do
sudo iptables -D FORWARD -j ACCEPT 2>/dev/null || break
done
sudo ip link set fcbr0 down 2>/dev/null || true
sudo ip link del fcbr0 2>/dev/null || true
log " Bridge fcbr0 removed"
fi
# ─── Remove fireclaw data ────────────────────────────────────────────
log "Removing fireclaw data..."
rm -rf "$FIRECLAW_DIR"
rm -rf /tmp/fireclaw/
log " Removed $FIRECLAW_DIR"
# ─── Unlink global command ───────────────────────────────────────────
if command -v fireclaw &>/dev/null; then
log "Removing global fireclaw command..."
sudo npm unlink -g fireclaw 2>/dev/null || sudo rm -f /usr/local/bin/fireclaw
fi
# ─── Remove dependencies (optional) ──────────────────────────────────
if ! $KEEP_DEPS; then
log "Removing dependencies..."
# Ollama
if systemctl is-active ollama &>/dev/null; then
sudo systemctl stop ollama
sudo systemctl disable ollama 2>/dev/null || true
fi
sudo rm -f /etc/systemd/system/ollama.service
sudo rm -f /usr/local/bin/ollama
rm -rf "$HOME/.ollama" 2>/dev/null || true
log " Ollama removed"
# Firecracker
sudo rm -f /usr/local/bin/firecracker /usr/local/bin/jailer
log " Firecracker removed"
# ngircd — restore default config
if [[ -f /etc/ngircd/ngircd.conf.bak ]]; then
sudo cp /etc/ngircd/ngircd.conf.bak /etc/ngircd/ngircd.conf
sudo systemctl restart ngircd 2>/dev/null || true
log " ngircd config restored from backup"
fi
sudo systemctl daemon-reload
else
warn "Keeping dependencies (firecracker, ollama, ngircd). Use without --keep-deps to remove them."
fi
# ─── Done ─────────────────────────────────────────────────────────────
echo ""
log "═══════════════════════════════════════════════"
log " Fireclaw uninstalled."
log "═══════════════════════════════════════════════"
log ""
log " The project source code at $(pwd) was NOT removed."
log " Remove it manually if you want: rm -rf $(pwd)"
log ""
if ! $KEEP_DEPS; then
log " Dependencies removed: firecracker, ollama, ngircd config"
else
log " Dependencies kept: firecracker, ollama, ngircd"
fi
log "═══════════════════════════════════════════════"

View File

@@ -1,4 +1,3 @@
import { spawn, type ChildProcess } from "node:child_process";
import {
existsSync,
mkdirSync,
@@ -12,18 +11,18 @@ import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { CONFIG } from "./config.js";
import {
ensureBridge,
ensureNat,
allocateIp,
releaseIp,
createTap,
deleteTap,
macFromOctet,
applyNetworkPolicy,
removeNetworkPolicy,
type NetworkPolicy,
} from "./network.js";
import * as api from "./firecracker-api.js";
import {
setupNetwork,
spawnFirecracker,
bootVM,
} from "./firecracker-vm.js";
export interface AgentInfo {
name: string;
@@ -201,24 +200,6 @@ function ensureWorkspace(agentName: string): string {
return imgPath;
}
function waitForSocket(socketPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + 5_000;
const check = () => {
if (existsSync(socketPath)) {
setTimeout(resolve, 200);
return;
}
if (Date.now() > deadline) {
reject(new Error("Firecracker socket did not appear"));
return;
}
setTimeout(check, 50);
};
check();
});
}
export async function startAgent(
templateName: string,
overrides?: { name?: string; model?: string }
@@ -266,45 +247,18 @@ export async function startAgent(
const workspacePath = ensureWorkspace(name);
// Setup network
ensureBridge();
ensureNat();
createTap(tapDevice);
setupNetwork(tapDevice);
// Boot VM
const proc = spawn(
CONFIG.firecrackerBin,
["--api-sock", socketPath],
{ stdio: "pipe", detached: true }
);
proc.unref();
await waitForSocket(socketPath);
const bootArgs = [
"console=ttyS0",
"reboot=k",
"panic=1",
"pci=off",
"root=/dev/vda",
"rw",
`ip=${ip}::${CONFIG.bridge.gateway}:${CONFIG.bridge.netmask}::eth0:off`,
].join(" ");
await api.putBootSource(socketPath, CONFIG.kernelPath, bootArgs);
await api.putDrive(socketPath, "rootfs", rootfsPath);
await api.putDrive(socketPath, "workspace", workspacePath, false, false);
await api.putNetworkInterface(
const proc = await spawnFirecracker(socketPath, { detached: true });
await bootVM({
socketPath,
"eth0",
rootfsPath,
extraDrives: [{ id: "workspace", path: workspacePath }],
tapDevice,
macFromOctet(octet)
);
await api.putMachineConfig(
socketPath,
CONFIG.vm.vcpuCount,
CONFIG.vm.memSizeMib
);
await api.startInstance(socketPath);
ip,
octet,
});
// Apply network policy
const networkPolicy: NetworkPolicy = template.network ?? "full";

View File

@@ -16,7 +16,7 @@ export function createCli() {
program
.name("fireclaw")
.description("Run commands in ephemeral Firecracker microVMs")
.version("0.1.2");
.version("0.1.3");
program
.command("run")

View File

@@ -46,12 +46,8 @@ export const CONFIG = {
workspacesDir: join(HOME, ".fireclaw", "workspaces"),
workspaceSizeMib: 64,
// S3 URLs for Firecracker CI assets
assets: {
kernelUrl:
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.11/x86_64/vmlinux-5.10.225",
rootfsListUrl:
"http://spec.ccfc.min.s3.amazonaws.com/?prefix=firecracker-ci/v1.11/x86_64/ubuntu",
rootfsBaseUrl: "https://s3.amazonaws.com/spec.ccfc.min",
},
} as const;

164
src/firecracker-vm.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* Shared Firecracker VM lifecycle helpers.
* Used by vm.ts, snapshot.ts, and agent-manager.ts.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, unlinkSync, mkdirSync } from "node:fs";
import { CONFIG } from "./config.js";
import * as api from "./firecracker-api.js";
import {
ensureBridge,
ensureNat,
createTap,
deleteTap,
macFromOctet,
} from "./network.js";
export interface BootOptions {
socketPath: string;
kernelPath?: string;
rootfsPath: string;
extraDrives?: { id: string; path: string; readOnly?: boolean }[];
tapDevice: string;
ip: string;
octet: number;
vcpu?: number;
mem?: number;
}
/**
* Wait for a Firecracker API socket to appear.
*/
export function waitForSocket(
socketPath: string,
timeoutMs = 5_000
): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const check = () => {
if (existsSync(socketPath)) {
setTimeout(resolve, 200);
return;
}
if (Date.now() > deadline) {
reject(new Error("Firecracker socket did not appear"));
return;
}
setTimeout(check, 50);
};
check();
});
}
/**
* Set up network for a VM: ensure bridge, NAT, and create tap device.
* Cleans stale tap first.
*/
export function setupNetwork(tapDevice: string) {
ensureBridge();
ensureNat();
deleteTap(tapDevice);
createTap(tapDevice);
}
/**
* Spawn a Firecracker process and wait for the API socket.
*/
export async function spawnFirecracker(
socketPath: string,
opts?: { detached?: boolean }
): Promise<ChildProcess> {
// Clean stale socket
try {
unlinkSync(socketPath);
} catch {}
mkdirSync(CONFIG.socketDir, { recursive: true });
const proc = spawn(
CONFIG.firecrackerBin,
["--api-sock", socketPath],
{
stdio: "pipe",
detached: opts?.detached ?? false,
}
);
if (opts?.detached) proc.unref();
await waitForSocket(socketPath);
return proc;
}
/**
* Configure and start a Firecracker VM via its API.
*/
export async function bootVM(opts: BootOptions) {
const kernel = opts.kernelPath ?? CONFIG.kernelPath;
const vcpu = opts.vcpu ?? CONFIG.vm.vcpuCount;
const mem = opts.mem ?? CONFIG.vm.memSizeMib;
const bootArgs = [
"console=ttyS0",
"reboot=k",
"panic=1",
"pci=off",
"root=/dev/vda",
"rw",
`ip=${opts.ip}::${CONFIG.bridge.gateway}:${CONFIG.bridge.netmask}::eth0:off`,
].join(" ");
await api.putBootSource(opts.socketPath, kernel, bootArgs);
await api.putDrive(opts.socketPath, "rootfs", opts.rootfsPath);
if (opts.extraDrives) {
for (const drive of opts.extraDrives) {
await api.putDrive(
opts.socketPath,
drive.id,
drive.path,
drive.readOnly ?? false,
false
);
}
}
await api.putNetworkInterface(
opts.socketPath,
"eth0",
opts.tapDevice,
macFromOctet(opts.octet)
);
await api.putMachineConfig(opts.socketPath, vcpu, mem);
await api.startInstance(opts.socketPath);
}
/**
* Kill a Firecracker process and clean up its socket.
*/
export async function killFirecracker(
proc: ChildProcess | null,
socketPath: string,
signal: NodeJS.Signals = "SIGTERM"
) {
if (proc && !proc.killed) {
proc.kill(signal);
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (proc && !proc.killed) {
proc.kill("SIGKILL");
}
resolve();
}, 2_000);
proc.on("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
try {
unlinkSync(socketPath);
} catch {}
}

View File

@@ -147,14 +147,56 @@ export async function runOverseer(config: OverseerConfig) {
);
bot.say(event.target, `Models: ${lines.join(", ")}`);
}
} catch (e) {
} catch {
bot.say(event.target, "Error fetching models from Ollama.");
}
break;
}
case "!status": {
try {
const os = await import("node:os");
const { execFileSync } = await import("node:child_process");
const agents = listAgents();
const uptime = Math.floor(os.uptime() / 3600);
const totalMem = (os.totalmem() / 1e9).toFixed(0);
const freeMem = (os.freemem() / 1e9).toFixed(0);
const load = os.loadavg()[0].toFixed(2);
// Disk free
let diskFree = "?";
try {
const dfOut = execFileSync("df", ["-h", "/"], { encoding: "utf-8" });
const parts = dfOut.split("\n")[1]?.split(/\s+/);
if (parts) diskFree = `${parts[3]} free / ${parts[1]}`;
} catch {}
// Ollama model loaded
let ollamaModel = "none";
try {
const http = await import("node:http");
const psData = await new Promise<string>((resolve, reject) => {
http.get("http://localhost:11434/api/ps", (res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks).toString()));
}).on("error", reject);
});
const running = JSON.parse(psData).models;
if (running?.length > 0) {
ollamaModel = running.map((m: { name: string }) => m.name).join(", ");
}
} catch {}
bot.say(event.target, `Agents: ${agents.length} running | Load: ${load} | RAM: ${freeMem}/${totalMem} GB free | Disk: ${diskFree} | Uptime: ${uptime}h | Ollama: ${ollamaModel}`);
} catch {
bot.say(event.target, "Error getting status.");
}
break;
}
case "!help": {
bot.say(event.target, "Commands: !invoke <template> [name] | !destroy <name> | !list | !model <name> <model> | !models | !templates | !help");
bot.say(event.target, "Commands: !invoke <template> [name] | !destroy <name> | !list | !model <name> <model> | !models | !templates | !status | !help");
break;
}
}

View File

@@ -36,70 +36,104 @@ export async function runSetup() {
if (existsSync(CONFIG.baseRootfs)) {
log("Base rootfs already exists, skipping download.");
} else {
log("Downloading rootfs...");
log("Downloading Alpine Linux minirootfs...");
// Find latest rootfs key from S3 listing
const arch = execFileSync("uname", ["-m"], { encoding: "utf-8" }).trim();
const alpineTar = `${CONFIG.baseDir}/alpine-minirootfs.tar.gz`;
// Find latest Alpine version
const listing = execFileSync(
"curl",
["-fsSL", CONFIG.assets.rootfsListUrl],
["-fsSL", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/" + arch + "/"],
{ encoding: "utf-8", timeout: 30_000 }
);
const keys = [...listing.matchAll(/<Key>([^<]+)<\/Key>/g)].map(
(m) => m[1]
const match = listing.match(
new RegExp(`alpine-minirootfs-[\\d.]+-${arch}\\.tar\\.gz`, "g")
);
const rootfsKey = keys.sort().pop();
if (!rootfsKey) throw new Error("Could not find rootfs in S3 listing");
if (!match || match.length === 0)
throw new Error("Could not find Alpine minirootfs");
const filename = match.sort().pop()!;
const squashfsPath = `${CONFIG.baseDir}/rootfs.squashfs`;
download(`${CONFIG.assets.rootfsBaseUrl}/${rootfsKey}`, squashfsPath);
log("Rootfs downloaded. Converting squashfs to ext4...");
download(
`https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/${arch}/${filename}`,
alpineTar
);
log("Alpine downloaded. Building ext4 rootfs...");
// Convert squashfs to ext4
const squashMount = "/tmp/fireclaw-squash";
const ext4Mount = "/tmp/fireclaw-ext4";
mkdirSync(squashMount, { recursive: true });
// Create ext4 image and unpack Alpine
const ext4Mount = "/tmp/fireclaw-alpine";
mkdirSync(ext4Mount, { recursive: true });
try {
execFileSync(
"sudo",
["mount", "-t", "squashfs", squashfsPath, squashMount],
{ stdio: "pipe" }
);
execFileSync("truncate", ["-s", "1G", CONFIG.baseRootfs], {
execFileSync("truncate", ["-s", "256M", CONFIG.baseRootfs], {
stdio: "pipe",
});
execFileSync("sudo", ["/usr/sbin/mkfs.ext4", CONFIG.baseRootfs], {
execFileSync("sudo", ["mkfs.ext4", "-q", CONFIG.baseRootfs], {
stdio: "pipe",
});
execFileSync("sudo", ["mount", CONFIG.baseRootfs, ext4Mount], {
stdio: "pipe",
});
execFileSync("sudo", ["cp", "-a", `${squashMount}/.`, ext4Mount], {
execFileSync("sudo", ["tar", "xzf", alpineTar, "-C", ext4Mount], {
stdio: "pipe",
});
// Bake in DNS config
// DNS — copy host resolv.conf for chroot package install, then set static
execSync(
`echo "nameserver 8.8.8.8" | sudo tee ${ext4Mount}/etc/resolv.conf > /dev/null`
`sudo cp /etc/resolv.conf ${ext4Mount}/etc/resolv.conf`
);
log("Rootfs converted.");
// Inittab for serial console
execSync(`sudo tee ${ext4Mount}/etc/inittab > /dev/null << 'EOF'
::sysinit:/sbin/openrc sysinit
::sysinit:/sbin/openrc boot
::sysinit:/sbin/openrc default
ttyS0::respawn:/sbin/getty -L 115200 ttyS0 vt100
::ctrlaltdel:/sbin/reboot
::shutdown:/sbin/openrc shutdown
EOF`);
// Hostname
execSync(
`echo "fireclaw" | sudo tee ${ext4Mount}/etc/hostname > /dev/null`
);
// Allow root login (no password, SSH key auth only)
execSync(
`sudo sed -i 's/root:x:/root::/' ${ext4Mount}/etc/passwd`
);
// Install base packages
log("Installing Alpine packages (openssh, python3, curl, ca-certificates)...");
execFileSync(
"sudo",
["chroot", ext4Mount, "/bin/sh", "-c",
"apk update && apk add --no-cache openssh-server ca-certificates curl jq python3 bash openrc && " +
"rc-update add sshd default && ssh-keygen -A && " +
"echo 'PermitRootLogin prohibit-password' >> /etc/ssh/sshd_config && " +
"mkdir -p /run/openrc && touch /run/openrc/softlevel"],
{ stdio: "inherit", timeout: 120_000 }
);
// Networking init script
execSync(`sudo tee ${ext4Mount}/etc/init.d/networking > /dev/null << 'EOF'
#!/sbin/openrc-run
depend() { need localmount; }
start() { ip link set lo up; return 0; }
EOF`);
execFileSync("sudo", ["chmod", "+x", `${ext4Mount}/etc/init.d/networking`], { stdio: "pipe" });
execFileSync("sudo", ["chroot", ext4Mount, "rc-update", "add", "networking", "boot"], { stdio: "pipe" });
log("Alpine rootfs built.");
} finally {
try {
execFileSync("sudo", ["umount", squashMount], { stdio: "pipe" });
} catch {}
try {
execFileSync("sudo", ["umount", ext4Mount], { stdio: "pipe" });
} catch {}
try {
execFileSync("rmdir", [squashMount], { stdio: "pipe" });
} catch {}
try {
execFileSync("rmdir", [ext4Mount], { stdio: "pipe" });
} catch {}
try {
execFileSync("rm", ["-f", squashfsPath], { stdio: "pipe" });
execFileSync("rm", ["-f", alpineTar], { stdio: "pipe" });
} catch {}
}
}

View File

@@ -1,46 +1,22 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./config.js";
import * as api from "./firecracker-api.js";
import {
ensureBridge,
ensureNat,
createTap,
deleteTap,
macFromOctet,
} from "./network.js";
import {
ensureBaseImage,
ensureSshKeypair,
createRunCopy,
injectSshKey,
} from "./rootfs.js";
import { deleteTap } from "./network.js";
import { ensureBaseImage, ensureSshKeypair, injectSshKey } from "./rootfs.js";
import { waitForSsh } from "./ssh.js";
import { copyFileSync } from "node:fs";
import {
setupNetwork,
spawnFirecracker,
bootVM,
killFirecracker,
} from "./firecracker-vm.js";
function log(msg: string) {
process.stderr.write(`[snapshot] ${msg}\n`);
}
function waitForSocket(socketPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + 5_000;
const check = () => {
if (existsSync(socketPath)) {
setTimeout(resolve, 200);
return;
}
if (Date.now() > deadline) {
reject(new Error("Firecracker socket did not appear"));
return;
}
setTimeout(check, 50);
};
check();
});
}
export function snapshotExists(): boolean {
return (
existsSync(CONFIG.snapshot.statePath) &&
@@ -62,46 +38,21 @@ export async function createSnapshot() {
injectSshKey(snap.rootfsPath);
log("Setting up network...");
ensureBridge();
ensureNat();
createTap(snap.tapDevice);
setupNetwork(snap.tapDevice);
let proc: ChildProcess | null = null;
try {
log("Booting VM for snapshot...");
proc = spawn(CONFIG.firecrackerBin, ["--api-sock", socketPath], {
stdio: "pipe",
detached: false,
proc = await spawnFirecracker(socketPath);
await bootVM({
socketPath,
rootfsPath: snap.rootfsPath,
tapDevice: snap.tapDevice,
ip: snap.ip,
octet: snap.octet,
});
await waitForSocket(socketPath);
const bootArgs = [
"console=ttyS0",
"reboot=k",
"panic=1",
"pci=off",
"root=/dev/vda",
"rw",
`ip=${snap.ip}::${CONFIG.bridge.gateway}:${CONFIG.bridge.netmask}::eth0:off`,
].join(" ");
await api.putBootSource(socketPath, CONFIG.kernelPath, bootArgs);
await api.putDrive(socketPath, "rootfs", snap.rootfsPath);
await api.putNetworkInterface(
socketPath,
"eth0",
snap.tapDevice,
macFromOctet(snap.octet)
);
await api.putMachineConfig(
socketPath,
CONFIG.vm.vcpuCount,
CONFIG.vm.memSizeMib
);
await api.startInstance(socketPath);
log("Waiting for SSH...");
await waitForSsh(snap.ip);
@@ -116,13 +67,7 @@ export async function createSnapshot() {
log(` Memory: ${snap.memPath}`);
log(` Rootfs: ${snap.rootfsPath}`);
} finally {
if (proc && !proc.killed) {
proc.kill("SIGKILL");
}
try {
const { unlinkSync } = await import("node:fs");
unlinkSync(socketPath);
} catch {}
await killFirecracker(proc, socketPath, "SIGKILL");
deleteTap(snap.tapDevice);
}
}

155
src/vm.ts
View File

@@ -1,19 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { type ChildProcess } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { CONFIG } from "./config.js";
import type { VMConfig, RunResult, RunOptions } from "./types.js";
import * as api from "./firecracker-api.js";
import {
ensureBridge,
ensureNat,
allocateIp,
releaseIp,
createTap,
deleteTap,
macFromOctet,
} from "./network.js";
import { allocateIp, releaseIp, deleteTap } from "./network.js";
import {
ensureBaseImage,
ensureSshKeypair,
@@ -24,6 +16,12 @@ import {
import { waitForSsh, execCommand } from "./ssh.js";
import { registerVm, unregisterVm } from "./cleanup.js";
import { snapshotExists } from "./snapshot.js";
import {
setupNetwork,
spawnFirecracker,
bootVM,
killFirecracker,
} from "./firecracker-vm.js";
function log(verbose: boolean, msg: string) {
if (verbose) process.stderr.write(`[fireclaw] ${msg}\n`);
@@ -42,7 +40,6 @@ export class VMInstance {
command: string,
opts: RunOptions = {}
): Promise<RunResult> {
// Try snapshot path first unless disabled
if (!opts.noSnapshot && snapshotExists()) {
return VMInstance.runFromSnapshot(command, opts);
}
@@ -65,32 +62,20 @@ export class VMInstance {
guestIp: snap.ip,
tapDevice: snap.tapDevice,
socketPath: join(CONFIG.socketDir, `${id}.sock`),
rootfsPath: "", // shared, not per-run
rootfsPath: "",
timeoutMs,
verbose,
};
const vm = new VMInstance(config);
vm.octet = 0; // no IP pool allocation for snapshot runs
vm.octet = 0;
registerVm(vm);
try {
log(verbose, `VM ${id}: restoring from snapshot...`);
ensureBridge();
ensureNat();
createTap(snap.tapDevice);
setupNetwork(snap.tapDevice);
// Spawn firecracker and load snapshot
vm.process = spawn(
CONFIG.firecrackerBin,
["--api-sock", config.socketPath],
{ stdio: "pipe", detached: false }
);
vm.process.on("error", (err) => {
log(verbose, `Firecracker process error: ${err.message}`);
});
await vm.waitForSocket();
vm.process = await spawnFirecracker(config.socketPath);
await api.putSnapshotLoad(
config.socketPath,
snap.statePath,
@@ -123,16 +108,12 @@ export class VMInstance {
const verbose = opts.verbose ?? false;
const timeoutMs = opts.timeout ?? CONFIG.vm.defaultTimeoutMs;
// Pre-flight checks
ensureBaseImage();
ensureSshKeypair();
// Allocate resources
const { ip, octet } = allocateIp();
const tapDevice = `fctap${octet}`;
mkdirSync(CONFIG.socketDir, { recursive: true });
const config: VMConfig = {
id,
guestIp: ip,
@@ -153,12 +134,19 @@ export class VMInstance {
injectSshKey(config.rootfsPath);
log(verbose, `VM ${id}: creating tap ${tapDevice}...`);
ensureBridge();
ensureNat();
createTap(tapDevice);
setupNetwork(tapDevice);
log(verbose, `VM ${id}: booting...`);
await vm.boot(opts);
vm.process = await spawnFirecracker(config.socketPath);
await bootVM({
socketPath: config.socketPath,
rootfsPath: config.rootfsPath,
tapDevice,
ip,
octet,
vcpu: opts.vcpu,
mem: opts.mem,
});
log(verbose, `VM ${id}: waiting for SSH at ${ip}...`);
await waitForSsh(ip);
@@ -177,110 +165,17 @@ export class VMInstance {
}
}
private async boot(opts: RunOptions) {
const { config } = this;
const vcpu = opts.vcpu ?? CONFIG.vm.vcpuCount;
const mem = opts.mem ?? CONFIG.vm.memSizeMib;
// Spawn firecracker
this.process = spawn(
CONFIG.firecrackerBin,
["--api-sock", config.socketPath],
{
stdio: "pipe",
detached: false,
}
);
this.process.on("error", (err) => {
log(config.verbose, `Firecracker process error: ${err.message}`);
});
// Wait for socket
await this.waitForSocket();
// Configure via API
const bootArgs = [
"console=ttyS0",
"reboot=k",
"panic=1",
"pci=off",
"root=/dev/vda",
"rw",
`ip=${config.guestIp}::${CONFIG.bridge.gateway}:${CONFIG.bridge.netmask}::eth0:off`,
].join(" ");
await api.putBootSource(config.socketPath, CONFIG.kernelPath, bootArgs);
await api.putDrive(config.socketPath, "rootfs", config.rootfsPath);
await api.putNetworkInterface(
config.socketPath,
"eth0",
config.tapDevice,
macFromOctet(this.octet)
);
await api.putMachineConfig(config.socketPath, vcpu, mem);
await api.startInstance(config.socketPath);
}
private waitForSocket(): Promise<void> {
const socketPath = this.config.socketPath;
return new Promise((resolve, reject) => {
const deadline = Date.now() + 5_000;
const check = () => {
if (existsSync(socketPath)) {
setTimeout(resolve, 200);
return;
}
if (Date.now() > deadline) {
reject(new Error("Firecracker socket did not appear"));
return;
}
setTimeout(check, 50);
};
check();
});
}
async destroy() {
const { config } = this;
log(config.verbose, `VM ${config.id}: cleaning up...`);
// Kill firecracker
if (this.process && !this.process.killed) {
this.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill("SIGKILL");
}
resolve();
}, 2_000);
this.process!.on("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
// Clean up socket
try {
const { unlinkSync } = await import("node:fs");
unlinkSync(config.socketPath);
} catch {
// Already gone
}
// Clean up tap device
await killFirecracker(this.process, config.socketPath);
deleteTap(config.tapDevice);
// Release IP (skip for snapshot runs which don't allocate from pool)
if (this.octet > 0) {
releaseIp(this.octet);
}
// Delete rootfs copy (skip for snapshot runs which share rootfs)
if (config.rootfsPath) {
deleteRunCopy(config.rootfsPath);
}

View File

@@ -258,7 +258,7 @@ if [ -n "$OVERSEER_PID" ] && [ "$OVERSEER_PID" != "0" ]; then
} | nc -q 2 127.0.0.1 6667 2>&1)
assert_contains "$OUT" "worker" "overseer adopted worker after crash"
# Cleanup
OUT2=$({
{
echo -e "NICK fcrecov2\r\nUSER fcrecov2 0 * :t\r\n"
sleep 2
echo -e "JOIN #agents\r\n"
@@ -266,7 +266,7 @@ if [ -n "$OVERSEER_PID" ] && [ "$OVERSEER_PID" != "0" ]; then
echo -e "PRIVMSG #agents :!destroy worker\r\n"
sleep 5
echo -e "QUIT\r\n"
} | nc -q 2 127.0.0.1 6667 2>&1)
} | nc -q 2 127.0.0.1 6667 2>&1
else
echo " SKIP: overseer not running via systemd, skipping crash test" && ((SKIP++))
((SKIP++))
@@ -288,7 +288,7 @@ echo "--- Test 20: Graceful agent shutdown (IRC QUIT) ---"
echo -e "PRIVMSG #agents :!destroy worker\r\n"
sleep 5
echo -e "QUIT\r\n"
} | nc -q 2 127.0.0.1 6667 2>&1 > /tmp/fc-quit-test.txt
} | nc -q 2 127.0.0.1 6667 > /tmp/fc-quit-test.txt 2>&1
if grep -q "QUIT.*shutting down" /tmp/fc-quit-test.txt; then
echo " PASS: agent sent IRC QUIT on destroy" && ((PASS++))
else