Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2cef20a89 | |||
| e6a6fb263d | |||
| cf2d2d31b7 | |||
| 6485705d4b | |||
| 6e9725d358 | |||
| b5ad20ce51 | |||
| 1fee80f1d7 | |||
| e98f9af938 | |||
| b613c2db6f | |||
| d149319090 | |||
| 2c82b3f7ae | |||
| bdd4c185bb | |||
| 4b01dfb51d | |||
| 129fd4d869 | |||
| 5cc6a38c96 |
7
IDEAS.md
7
IDEAS.md
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
465
scripts/install.sh
Executable 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 ""
|
||||
@@ -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."
|
||||
|
||||
@@ -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
134
scripts/uninstall.sh
Executable 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 "═══════════════════════════════════════════════"
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
98
src/setup.ts
98
src/setup.ts
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user