15 Commits

Author SHA1 Message Date
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
15 changed files with 801 additions and 62 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

@@ -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,4 @@
import { spawn, type ChildProcess } from "node:child_process";
import { spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
@@ -268,6 +268,7 @@ export async function startAgent(
// Setup network
ensureBridge();
ensureNat();
deleteTap(tapDevice); // clean stale tap from previous run
createTap(tapDevice);
// Boot VM

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;

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

@@ -13,7 +13,6 @@ import {
import {
ensureBaseImage,
ensureSshKeypair,
createRunCopy,
injectSshKey,
} from "./rootfs.js";
import { waitForSsh } from "./ssh.js";
@@ -64,6 +63,7 @@ export async function createSnapshot() {
log("Setting up network...");
ensureBridge();
ensureNat();
deleteTap(snap.tapDevice); // clean stale tap from previous run
createTap(snap.tapDevice);
let proc: ChildProcess | null = null;

View File

@@ -78,6 +78,7 @@ export class VMInstance {
log(verbose, `VM ${id}: restoring from snapshot...`);
ensureBridge();
ensureNat();
deleteTap(snap.tapDevice); // clean stale tap from previous run
createTap(snap.tapDevice);
// Spawn firecracker and load snapshot
@@ -155,6 +156,7 @@ export class VMInstance {
log(verbose, `VM ${id}: creating tap ${tapDevice}...`);
ensureBridge();
ensureNat();
deleteTap(tapDevice); // clean stale tap from previous run
createTap(tapDevice);
log(verbose, `VM ${id}: booting...`);

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