Initial commit — fireclaw multi-agent system
Firecracker microVM-based multi-agent system with IRC orchestration and local LLMs. Features: - Ephemeral command runner with VM snapshots (~1.1s) - Multi-agent orchestration via overseer IRC bot - 5 agent templates (worker, coder, researcher, quick, creative) - Tool access (shell + podman containers inside VMs) - Persistent workspace + memory system (MEMORY.md pattern) - Agent hot-reload (model/persona swap via SSH + SIGHUP) - Non-root agents, graceful shutdown, crash recovery - Agent-to-agent communication via IRC - DM support, /invite support - Systemd service, 20 regression tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
288
src/vm.ts
Normal file
288
src/vm.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { CONFIG } from "./config.js";
|
||||
import type { VMConfig, RunResult, RunOptions } from "./types.js";
|
||||
import * as api from "./firecracker-api.js";
|
||||
import {
|
||||
ensureBridge,
|
||||
ensureNat,
|
||||
allocateIp,
|
||||
releaseIp,
|
||||
createTap,
|
||||
deleteTap,
|
||||
macFromOctet,
|
||||
} from "./network.js";
|
||||
import {
|
||||
ensureBaseImage,
|
||||
ensureSshKeypair,
|
||||
createRunCopy,
|
||||
injectSshKey,
|
||||
deleteRunCopy,
|
||||
} from "./rootfs.js";
|
||||
import { waitForSsh, execCommand } from "./ssh.js";
|
||||
import { registerVm, unregisterVm } from "./cleanup.js";
|
||||
import { snapshotExists } from "./snapshot.js";
|
||||
|
||||
function log(verbose: boolean, msg: string) {
|
||||
if (verbose) process.stderr.write(`[fireclaw] ${msg}\n`);
|
||||
}
|
||||
|
||||
export class VMInstance {
|
||||
private config: VMConfig;
|
||||
private process: ChildProcess | null = null;
|
||||
private octet = 0;
|
||||
|
||||
constructor(config: VMConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static async run(
|
||||
command: string,
|
||||
opts: RunOptions = {}
|
||||
): Promise<RunResult> {
|
||||
// Try snapshot path first unless disabled
|
||||
if (!opts.noSnapshot && snapshotExists()) {
|
||||
return VMInstance.runFromSnapshot(command, opts);
|
||||
}
|
||||
return VMInstance.runColdBoot(command, opts);
|
||||
}
|
||||
|
||||
private static async runFromSnapshot(
|
||||
command: string,
|
||||
opts: RunOptions
|
||||
): Promise<RunResult> {
|
||||
const id = `fc-snap-${randomBytes(3).toString("hex")}`;
|
||||
const verbose = opts.verbose ?? false;
|
||||
const timeoutMs = opts.timeout ?? CONFIG.vm.defaultTimeoutMs;
|
||||
const snap = CONFIG.snapshot;
|
||||
|
||||
mkdirSync(CONFIG.socketDir, { recursive: true });
|
||||
|
||||
const config: VMConfig = {
|
||||
id,
|
||||
guestIp: snap.ip,
|
||||
tapDevice: snap.tapDevice,
|
||||
socketPath: join(CONFIG.socketDir, `${id}.sock`),
|
||||
rootfsPath: "", // shared, not per-run
|
||||
timeoutMs,
|
||||
verbose,
|
||||
};
|
||||
|
||||
const vm = new VMInstance(config);
|
||||
vm.octet = 0; // no IP pool allocation for snapshot runs
|
||||
registerVm(vm);
|
||||
|
||||
try {
|
||||
log(verbose, `VM ${id}: restoring from snapshot...`);
|
||||
ensureBridge();
|
||||
ensureNat();
|
||||
createTap(snap.tapDevice);
|
||||
|
||||
// Spawn firecracker and load snapshot
|
||||
vm.process = spawn(
|
||||
CONFIG.firecrackerBin,
|
||||
["--api-sock", config.socketPath],
|
||||
{ stdio: "pipe", detached: false }
|
||||
);
|
||||
vm.process.on("error", (err) => {
|
||||
log(verbose, `Firecracker process error: ${err.message}`);
|
||||
});
|
||||
|
||||
await vm.waitForSocket();
|
||||
await api.putSnapshotLoad(
|
||||
config.socketPath,
|
||||
snap.statePath,
|
||||
snap.memPath
|
||||
);
|
||||
await api.patchVm(config.socketPath, "Resumed");
|
||||
|
||||
log(verbose, `VM ${id}: resumed, waiting for SSH...`);
|
||||
await waitForSsh(snap.ip);
|
||||
|
||||
log(verbose, `VM ${id}: executing command...`);
|
||||
const result = await execCommand(snap.ip, command, timeoutMs, verbose);
|
||||
|
||||
log(
|
||||
verbose,
|
||||
`VM ${id}: done (exit=${result.exitCode}, ${result.durationMs}ms)`
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
await vm.destroy();
|
||||
unregisterVm(vm);
|
||||
}
|
||||
}
|
||||
|
||||
private static async runColdBoot(
|
||||
command: string,
|
||||
opts: RunOptions
|
||||
): Promise<RunResult> {
|
||||
const id = `fc-${randomBytes(3).toString("hex")}`;
|
||||
const verbose = opts.verbose ?? false;
|
||||
const timeoutMs = opts.timeout ?? CONFIG.vm.defaultTimeoutMs;
|
||||
|
||||
// Pre-flight checks
|
||||
ensureBaseImage();
|
||||
ensureSshKeypair();
|
||||
|
||||
// Allocate resources
|
||||
const { ip, octet } = allocateIp();
|
||||
const tapDevice = `fctap${octet}`;
|
||||
|
||||
mkdirSync(CONFIG.socketDir, { recursive: true });
|
||||
|
||||
const config: VMConfig = {
|
||||
id,
|
||||
guestIp: ip,
|
||||
tapDevice,
|
||||
socketPath: join(CONFIG.socketDir, `${id}.sock`),
|
||||
rootfsPath: "",
|
||||
timeoutMs,
|
||||
verbose,
|
||||
};
|
||||
|
||||
const vm = new VMInstance(config);
|
||||
vm.octet = octet;
|
||||
registerVm(vm);
|
||||
|
||||
try {
|
||||
log(verbose, `VM ${id}: preparing rootfs...`);
|
||||
config.rootfsPath = createRunCopy(id);
|
||||
injectSshKey(config.rootfsPath);
|
||||
|
||||
log(verbose, `VM ${id}: creating tap ${tapDevice}...`);
|
||||
ensureBridge();
|
||||
ensureNat();
|
||||
createTap(tapDevice);
|
||||
|
||||
log(verbose, `VM ${id}: booting...`);
|
||||
await vm.boot(opts);
|
||||
|
||||
log(verbose, `VM ${id}: waiting for SSH at ${ip}...`);
|
||||
await waitForSsh(ip);
|
||||
|
||||
log(verbose, `VM ${id}: executing command...`);
|
||||
const result = await execCommand(ip, command, timeoutMs, verbose);
|
||||
|
||||
log(
|
||||
verbose,
|
||||
`VM ${id}: done (exit=${result.exitCode}, ${result.durationMs}ms)`
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
await vm.destroy();
|
||||
unregisterVm(vm);
|
||||
}
|
||||
}
|
||||
|
||||
private async boot(opts: RunOptions) {
|
||||
const { config } = this;
|
||||
const vcpu = opts.vcpu ?? CONFIG.vm.vcpuCount;
|
||||
const mem = opts.mem ?? CONFIG.vm.memSizeMib;
|
||||
|
||||
// Spawn firecracker
|
||||
this.process = spawn(
|
||||
CONFIG.firecrackerBin,
|
||||
["--api-sock", config.socketPath],
|
||||
{
|
||||
stdio: "pipe",
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
this.process.on("error", (err) => {
|
||||
log(config.verbose, `Firecracker process error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Wait for socket
|
||||
await this.waitForSocket();
|
||||
|
||||
// Configure via API
|
||||
const bootArgs = [
|
||||
"console=ttyS0",
|
||||
"reboot=k",
|
||||
"panic=1",
|
||||
"pci=off",
|
||||
"root=/dev/vda",
|
||||
"rw",
|
||||
`ip=${config.guestIp}::${CONFIG.bridge.gateway}:${CONFIG.bridge.netmask}::eth0:off`,
|
||||
].join(" ");
|
||||
|
||||
await api.putBootSource(config.socketPath, CONFIG.kernelPath, bootArgs);
|
||||
await api.putDrive(config.socketPath, "rootfs", config.rootfsPath);
|
||||
await api.putNetworkInterface(
|
||||
config.socketPath,
|
||||
"eth0",
|
||||
config.tapDevice,
|
||||
macFromOctet(this.octet)
|
||||
);
|
||||
await api.putMachineConfig(config.socketPath, vcpu, mem);
|
||||
await api.startInstance(config.socketPath);
|
||||
}
|
||||
|
||||
private waitForSocket(): Promise<void> {
|
||||
const socketPath = this.config.socketPath;
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
|
||||
const check = () => {
|
||||
if (existsSync(socketPath)) {
|
||||
setTimeout(resolve, 200);
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error("Firecracker socket did not appear"));
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 50);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
const { config } = this;
|
||||
log(config.verbose, `VM ${config.id}: cleaning up...`);
|
||||
|
||||
// Kill firecracker
|
||||
if (this.process && !this.process.killed) {
|
||||
this.process.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.process && !this.process.killed) {
|
||||
this.process.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 2_000);
|
||||
this.process!.on("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up socket
|
||||
try {
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(config.socketPath);
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
|
||||
// Clean up tap device
|
||||
deleteTap(config.tapDevice);
|
||||
|
||||
// Release IP (skip for snapshot runs which don't allocate from pool)
|
||||
if (this.octet > 0) {
|
||||
releaseIp(this.octet);
|
||||
}
|
||||
|
||||
// Delete rootfs copy (skip for snapshot runs which share rootfs)
|
||||
if (config.rootfsPath) {
|
||||
deleteRunCopy(config.rootfsPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user