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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
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}')
|
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."
|
||||||
|
|||||||
@@ -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
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 {
|
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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/setup.ts
98
src/setup.ts
@@ -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 {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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...`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user