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 ### 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. Run agents on multiple machines (grogbox + odin). Overseer manages VMs across hosts via SSH. Agents on different hosts communicate via IRC federation.
### GPU passthrough ### GPU deployment
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. 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 ## 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): def log(msg):
print(f"[agent:{NICK}] {msg}", flush=True) print(f"[agent:{NICK}] {msg}", flush=True)
@@ -219,6 +242,32 @@ def save_memory(topic, content):
return f"Memory saved to {filepath}" 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): def try_parse_tool_call(text):
"""Try to parse a text-based tool call from model output. """Try to parse a text-based tool call from model output.
Handles formats like: Handles formats like:
@@ -298,6 +347,12 @@ def query_ollama(messages):
log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})") log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})")
result = save_memory(topic, content) result = save_memory(topic, content)
messages.append({"role": "tool", "content": result}) 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: else:
messages.append({ messages.append({
"role": "tool", "role": "tool",
@@ -324,6 +379,12 @@ def query_ollama(messages):
log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})") log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})")
result = save_memory(topic, mem_content) result = save_memory(topic, mem_content)
messages.append({"role": "user", "content": f"{result}\n\nNow respond to the user."}) 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 payload["messages"] = messages
continue continue
@@ -339,7 +400,8 @@ def build_messages(question, channel):
if TOOLS_ENABLED: if TOOLS_ENABLED:
system += "\n\nYou have access to tools:" system += "\n\nYou have access to tools:"
system += "\n- run_command: Execute shell commands on your system." 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." system += "\nUse tools when needed rather than guessing. Your workspace at /workspace persists across restarts."
if AGENT_MEMORY and AGENT_MEMORY != "# Agent Memory": 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}" 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", "name": "fireclaw",
"version": "0.1.2", "version": "0.1.3",
"description": "Multi-agent system powered by Firecracker microVMs", "description": "Multi-agent system powered by Firecracker microVMs",
"type": "module", "type": "module",
"bin": { "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}') EXT_IFACE=$(ip route show default | awk '{print $5; exit}')
echo "Creating bridge ${BRIDGE}..." echo "Creating bridge ${BRIDGE}..."
ip link add ${BRIDGE} type bridge 2>/dev/null || echo "Bridge already exists" 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 addr add "${BRIDGE_IP}" dev "${BRIDGE}" 2>/dev/null || echo "Address already set"
ip link set ${BRIDGE} up ip link set "${BRIDGE}" up
echo "Enabling IP forwarding..." echo "Enabling IP forwarding..."
sysctl -w net.ipv4.ip_forward=1 sysctl -w net.ipv4.ip_forward=1
echo "Setting up NAT via ${EXT_IFACE}..." 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 -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 -A POSTROUTING -s "${SUBNET}" -o "${EXT_IFACE}" -j MASQUERADE
iptables -C FORWARD -i ${BRIDGE} -o ${EXT_IFACE} -j ACCEPT 2>/dev/null || \ iptables -C FORWARD -i "${BRIDGE}" -o "${EXT_IFACE}" -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i ${BRIDGE} -o ${EXT_IFACE} -j ACCEPT 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 -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 -A FORWARD -i "${EXT_IFACE}" -o "${BRIDGE}" -m state --state RELATED,ESTABLISHED -j ACCEPT
echo "Done. Bridge ${BRIDGE} ready." 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}') EXT_IFACE=$(ip route show default | awk '{print $5; exit}')
echo "Removing NAT rules..." echo "Removing NAT rules..."
iptables -t nat -D POSTROUTING -s ${SUBNET} -o ${EXT_IFACE} -j MASQUERADE 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 "${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 -D FORWARD -i "${EXT_IFACE}" -o "${BRIDGE}" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
echo "Removing bridge ${BRIDGE}..." echo "Removing bridge ${BRIDGE}..."
ip link set ${BRIDGE} down 2>/dev/null || true ip link set "${BRIDGE}" down 2>/dev/null || true
ip link del ${BRIDGE} 2>/dev/null || true ip link del "${BRIDGE}" 2>/dev/null || true
echo "Done." 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 { import {
existsSync, existsSync,
mkdirSync, mkdirSync,
@@ -268,6 +268,7 @@ export async function startAgent(
// Setup network // Setup network
ensureBridge(); ensureBridge();
ensureNat(); ensureNat();
deleteTap(tapDevice); // clean stale tap from previous run
createTap(tapDevice); createTap(tapDevice);
// Boot VM // Boot VM

View File

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

View File

@@ -46,12 +46,8 @@ export const CONFIG = {
workspacesDir: join(HOME, ".fireclaw", "workspaces"), workspacesDir: join(HOME, ".fireclaw", "workspaces"),
workspaceSizeMib: 64, workspaceSizeMib: 64,
// S3 URLs for Firecracker CI assets
assets: { assets: {
kernelUrl: kernelUrl:
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.11/x86_64/vmlinux-5.10.225", "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; } as const;

View File

@@ -147,14 +147,56 @@ export async function runOverseer(config: OverseerConfig) {
); );
bot.say(event.target, `Models: ${lines.join(", ")}`); bot.say(event.target, `Models: ${lines.join(", ")}`);
} }
} catch (e) { } catch {
bot.say(event.target, "Error fetching models from Ollama."); bot.say(event.target, "Error fetching models from Ollama.");
} }
break; 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": { 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; break;
} }
} }

View File

@@ -36,70 +36,104 @@ export async function runSetup() {
if (existsSync(CONFIG.baseRootfs)) { if (existsSync(CONFIG.baseRootfs)) {
log("Base rootfs already exists, skipping download."); log("Base rootfs already exists, skipping download.");
} else { } 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( const listing = execFileSync(
"curl", "curl",
["-fsSL", CONFIG.assets.rootfsListUrl], ["-fsSL", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/" + arch + "/"],
{ encoding: "utf-8", timeout: 30_000 } { encoding: "utf-8", timeout: 30_000 }
); );
const keys = [...listing.matchAll(/<Key>([^<]+)<\/Key>/g)].map( const match = listing.match(
(m) => m[1] new RegExp(`alpine-minirootfs-[\\d.]+-${arch}\\.tar\\.gz`, "g")
); );
const rootfsKey = keys.sort().pop(); if (!match || match.length === 0)
if (!rootfsKey) throw new Error("Could not find rootfs in S3 listing"); throw new Error("Could not find Alpine minirootfs");
const filename = match.sort().pop()!;
const squashfsPath = `${CONFIG.baseDir}/rootfs.squashfs`; download(
download(`${CONFIG.assets.rootfsBaseUrl}/${rootfsKey}`, squashfsPath); `https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/${arch}/${filename}`,
log("Rootfs downloaded. Converting squashfs to ext4..."); alpineTar
);
log("Alpine downloaded. Building ext4 rootfs...");
// Convert squashfs to ext4 // Create ext4 image and unpack Alpine
const squashMount = "/tmp/fireclaw-squash"; const ext4Mount = "/tmp/fireclaw-alpine";
const ext4Mount = "/tmp/fireclaw-ext4";
mkdirSync(squashMount, { recursive: true });
mkdirSync(ext4Mount, { recursive: true }); mkdirSync(ext4Mount, { recursive: true });
try { try {
execFileSync( execFileSync("truncate", ["-s", "256M", CONFIG.baseRootfs], {
"sudo",
["mount", "-t", "squashfs", squashfsPath, squashMount],
{ stdio: "pipe" }
);
execFileSync("truncate", ["-s", "1G", CONFIG.baseRootfs], {
stdio: "pipe", stdio: "pipe",
}); });
execFileSync("sudo", ["/usr/sbin/mkfs.ext4", CONFIG.baseRootfs], { execFileSync("sudo", ["mkfs.ext4", "-q", CONFIG.baseRootfs], {
stdio: "pipe", stdio: "pipe",
}); });
execFileSync("sudo", ["mount", CONFIG.baseRootfs, ext4Mount], { execFileSync("sudo", ["mount", CONFIG.baseRootfs, ext4Mount], {
stdio: "pipe", stdio: "pipe",
}); });
execFileSync("sudo", ["cp", "-a", `${squashMount}/.`, ext4Mount], { execFileSync("sudo", ["tar", "xzf", alpineTar, "-C", ext4Mount], {
stdio: "pipe", stdio: "pipe",
}); });
// Bake in DNS config // DNS — copy host resolv.conf for chroot package install, then set static
execSync( 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 { } finally {
try {
execFileSync("sudo", ["umount", squashMount], { stdio: "pipe" });
} catch {}
try { try {
execFileSync("sudo", ["umount", ext4Mount], { stdio: "pipe" }); execFileSync("sudo", ["umount", ext4Mount], { stdio: "pipe" });
} catch {} } catch {}
try {
execFileSync("rmdir", [squashMount], { stdio: "pipe" });
} catch {}
try { try {
execFileSync("rmdir", [ext4Mount], { stdio: "pipe" }); execFileSync("rmdir", [ext4Mount], { stdio: "pipe" });
} catch {} } catch {}
try { try {
execFileSync("rm", ["-f", squashfsPath], { stdio: "pipe" }); execFileSync("rm", ["-f", alpineTar], { stdio: "pipe" });
} catch {} } catch {}
} }
} }

View File

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

View File

@@ -78,6 +78,7 @@ export class VMInstance {
log(verbose, `VM ${id}: restoring from snapshot...`); log(verbose, `VM ${id}: restoring from snapshot...`);
ensureBridge(); ensureBridge();
ensureNat(); ensureNat();
deleteTap(snap.tapDevice); // clean stale tap from previous run
createTap(snap.tapDevice); createTap(snap.tapDevice);
// Spawn firecracker and load snapshot // Spawn firecracker and load snapshot
@@ -155,6 +156,7 @@ export class VMInstance {
log(verbose, `VM ${id}: creating tap ${tapDevice}...`); log(verbose, `VM ${id}: creating tap ${tapDevice}...`);
ensureBridge(); ensureBridge();
ensureNat(); ensureNat();
deleteTap(tapDevice); // clean stale tap from previous run
createTap(tapDevice); createTap(tapDevice);
log(verbose, `VM ${id}: booting...`); 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) } | nc -q 2 127.0.0.1 6667 2>&1)
assert_contains "$OUT" "worker" "overseer adopted worker after crash" assert_contains "$OUT" "worker" "overseer adopted worker after crash"
# Cleanup # Cleanup
OUT2=$({ {
echo -e "NICK fcrecov2\r\nUSER fcrecov2 0 * :t\r\n" echo -e "NICK fcrecov2\r\nUSER fcrecov2 0 * :t\r\n"
sleep 2 sleep 2
echo -e "JOIN #agents\r\n" 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" echo -e "PRIVMSG #agents :!destroy worker\r\n"
sleep 5 sleep 5
echo -e "QUIT\r\n" 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 else
echo " SKIP: overseer not running via systemd, skipping crash test" && ((SKIP++)) echo " SKIP: overseer not running via systemd, skipping crash test" && ((SKIP++))
((SKIP++)) ((SKIP++))
@@ -288,7 +288,7 @@ echo "--- Test 20: Graceful agent shutdown (IRC QUIT) ---"
echo -e "PRIVMSG #agents :!destroy worker\r\n" echo -e "PRIVMSG #agents :!destroy worker\r\n"
sleep 5 sleep 5
echo -e "QUIT\r\n" 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 if grep -q "QUIT.*shutting down" /tmp/fc-quit-test.txt; then
echo " PASS: agent sent IRC QUIT on destroy" && ((PASS++)) echo " PASS: agent sent IRC QUIT on destroy" && ((PASS++))
else else