fireclaw 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/vm-setup CHANGED
@@ -8,13 +8,15 @@ CMD_NAME="${VM_SETUP_CMD_NAME:-fireclaw setup}"
8
8
 
9
9
  usage() {
10
10
  cat <<EOF
11
- Usage: $CMD_NAME --instance <id> --telegram-token <token> [options]
11
+ Usage: $CMD_NAME --instance <id> [options]
12
12
 
13
13
  Options:
14
- --telegram-users <csv>
15
- --model <id> (default: anthropic/claude-opus-4-6)
14
+ --telegram-token <token> (optional; omit for a local-only gateway with Telegram disabled)
15
+ --telegram-users <csv> (required with --telegram-token)
16
+ --model <id> (default: openai/gpt-5.5)
16
17
  --skills <csv> (default: github,tmux,coding-agent,session-logs,skill-creator)
17
18
  --openclaw-image <image>
19
+ --host-port <n> (default: first free port above BASE_PORT)
18
20
  --vm-vcpu <n> (default: 4)
19
21
  --vm-mem-mib <n> (default: 8192)
20
22
  --disk-size <size> (default: 40G)
@@ -30,16 +32,48 @@ Options:
30
32
  EOF
31
33
  }
32
34
 
35
+ require_option_value() {
36
+ local opt="$1"
37
+ local value="${2-}"
38
+ [[ -n "$value" && "$value" != --* ]] || die "Missing value for $opt"
39
+ }
40
+
41
+ validate_no_newline() {
42
+ local name="$1"
43
+ local value="$2"
44
+ [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$name must not contain newlines"
45
+ }
46
+
47
+ validate_positive_int() {
48
+ local name="$1"
49
+ local value="$2"
50
+ [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be numeric"
51
+ (( 10#$value > 0 )) || die "$name must be greater than zero"
52
+ }
53
+
54
+ csv_values() {
55
+ printf '%s' "$1" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; /^$/d'
56
+ }
57
+
58
+ write_kv() {
59
+ local file="$1"
60
+ local key="$2"
61
+ local value="$3"
62
+ validate_no_newline "$key" "$value"
63
+ printf "%s=%s\n" "$key" "$value" >> "$file"
64
+ }
65
+
33
66
  INSTANCE=""
34
67
  TELEGRAM_TOKEN=""
35
68
  TELEGRAM_USERS=""
36
- MODEL="${MODEL:-anthropic/claude-opus-4-6}"
69
+ MODEL="${MODEL:-openai/gpt-5.5}"
37
70
  SKILLS="${SKILLS:-github,tmux,coding-agent,session-logs,skill-creator}"
38
71
  OPENCLAW_IMAGE="$OPENCLAW_IMAGE_DEFAULT"
39
72
  VM_VCPU="${VM_VCPU:-4}"
40
73
  VM_MEM_MIB="${VM_MEM_MIB:-8192}"
41
74
  DISK_SIZE="${DISK_SIZE:-40G}"
42
75
  API_SOCK="${API_SOCK:-}"
76
+ HOST_PORT_OVERRIDE=""
43
77
  BASE_IMAGES_DIR="${BASE_IMAGES_DIR:-/srv/firecracker/base/images}"
44
78
  BASE_KERNEL="${BASE_KERNEL:-$BASE_IMAGES_DIR/vmlinux}"
45
79
  BASE_ROOTFS="${BASE_ROOTFS:-$BASE_IMAGES_DIR/rootfs.ext4}"
@@ -49,25 +83,63 @@ SKIP_BROWSER_INSTALL="false"
49
83
  ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
50
84
  OPENAI_API_KEY="${OPENAI_API_KEY:-}"
51
85
  MINIMAX_API_KEY="${MINIMAX_API_KEY:-}"
86
+ SETUP_COMPLETE="false"
87
+ CLEANUP_ARMED="false"
88
+
89
+ cleanup_failed_setup() {
90
+ local rc="$1"
91
+ [[ "$rc" -eq 0 || "$SETUP_COMPLETE" == "true" ]] && return 0
92
+ [[ "$CLEANUP_ARMED" == "true" ]] || return 0
93
+ set +e
94
+ if [[ -n "${INSTANCE:-}" && "$INSTANCE" =~ ^[a-z0-9_-]+$ ]]; then
95
+ warn "Setup failed; cleaning up partial instance: $INSTANCE"
96
+ local vm_svc proxy_svc
97
+ vm_svc="$(vm_service "$INSTANCE")"
98
+ proxy_svc="$(proxy_service "$INSTANCE")"
99
+ systemctl stop "$proxy_svc" 2>/dev/null || true
100
+ systemctl stop "$vm_svc" 2>/dev/null || true
101
+ systemctl disable "$proxy_svc" 2>/dev/null || true
102
+ systemctl disable "$vm_svc" 2>/dev/null || true
103
+ rm -f "/etc/systemd/system/$proxy_svc" "/etc/systemd/system/$vm_svc"
104
+ systemctl daemon-reload 2>/dev/null || true
105
+ systemctl reset-failed "$proxy_svc" "$vm_svc" 2>/dev/null || true
106
+ if [[ -n "${VM_TAP:-}" ]]; then
107
+ ip link set "$VM_TAP" down 2>/dev/null || true
108
+ ip link del "$VM_TAP" 2>/dev/null || true
109
+ fi
110
+ if [[ -n "${API_SOCK:-}" ]]; then
111
+ rm -f "$API_SOCK"
112
+ fi
113
+ if [[ -n "${inst_dir:-}" ]]; then
114
+ rm -rf "$inst_dir"
115
+ fi
116
+ if [[ -n "${fc_dir:-}" ]]; then
117
+ rm -rf "$fc_dir"
118
+ fi
119
+ fi
120
+ }
121
+
122
+ trap 'rc=$?; cleanup_failed_setup "$rc"; exit "$rc"' EXIT
52
123
 
53
124
  while [[ $# -gt 0 ]]; do
54
125
  case "$1" in
55
- --instance) INSTANCE="$2"; shift 2 ;;
56
- --telegram-token) TELEGRAM_TOKEN="$2"; shift 2 ;;
57
- --telegram-users) TELEGRAM_USERS="$2"; shift 2 ;;
58
- --model) MODEL="$2"; shift 2 ;;
59
- --skills) SKILLS="$2"; shift 2 ;;
60
- --openclaw-image) OPENCLAW_IMAGE="$2"; shift 2 ;;
61
- --vm-vcpu) VM_VCPU="$2"; shift 2 ;;
62
- --vm-mem-mib) VM_MEM_MIB="$2"; shift 2 ;;
63
- --disk-size) DISK_SIZE="$2"; shift 2 ;;
64
- --api-sock) API_SOCK="$2"; shift 2 ;;
65
- --base-kernel) BASE_KERNEL="$2"; shift 2 ;;
66
- --base-rootfs) BASE_ROOTFS="$2"; shift 2 ;;
67
- --base-initrd) BASE_INITRD="$2"; shift 2 ;;
68
- --anthropic-api-key) ANTHROPIC_API_KEY="$2"; shift 2 ;;
69
- --openai-api-key) OPENAI_API_KEY="$2"; shift 2 ;;
70
- --minimax-api-key) MINIMAX_API_KEY="$2"; shift 2 ;;
126
+ --instance) require_option_value "$1" "${2-}"; INSTANCE="$2"; shift 2 ;;
127
+ --telegram-token) require_option_value "$1" "${2-}"; TELEGRAM_TOKEN="$2"; shift 2 ;;
128
+ --telegram-users) require_option_value "$1" "${2-}"; TELEGRAM_USERS="$2"; shift 2 ;;
129
+ --model) require_option_value "$1" "${2-}"; MODEL="$2"; shift 2 ;;
130
+ --skills) require_option_value "$1" "${2-}"; SKILLS="$2"; shift 2 ;;
131
+ --openclaw-image) require_option_value "$1" "${2-}"; OPENCLAW_IMAGE="$2"; shift 2 ;;
132
+ --host-port) require_option_value "$1" "${2-}"; HOST_PORT_OVERRIDE="$2"; shift 2 ;;
133
+ --vm-vcpu) require_option_value "$1" "${2-}"; VM_VCPU="$2"; shift 2 ;;
134
+ --vm-mem-mib) require_option_value "$1" "${2-}"; VM_MEM_MIB="$2"; shift 2 ;;
135
+ --disk-size) require_option_value "$1" "${2-}"; DISK_SIZE="$2"; shift 2 ;;
136
+ --api-sock) require_option_value "$1" "${2-}"; API_SOCK="$2"; shift 2 ;;
137
+ --base-kernel) require_option_value "$1" "${2-}"; BASE_KERNEL="$2"; shift 2 ;;
138
+ --base-rootfs) require_option_value "$1" "${2-}"; BASE_ROOTFS="$2"; shift 2 ;;
139
+ --base-initrd) require_option_value "$1" "${2-}"; BASE_INITRD="$2"; shift 2 ;;
140
+ --anthropic-api-key) require_option_value "$1" "${2-}"; ANTHROPIC_API_KEY="$2"; shift 2 ;;
141
+ --openai-api-key) require_option_value "$1" "${2-}"; OPENAI_API_KEY="$2"; shift 2 ;;
142
+ --minimax-api-key) require_option_value "$1" "${2-}"; MINIMAX_API_KEY="$2"; shift 2 ;;
71
143
  --skip-browser-install) SKIP_BROWSER_INSTALL="true"; shift ;;
72
144
  -h|--help) usage; exit 0 ;;
73
145
  *) die "Unknown option: $1" ;;
@@ -75,13 +147,47 @@ while [[ $# -gt 0 ]]; do
75
147
  done
76
148
 
77
149
  [[ -n "$INSTANCE" ]] || die "Missing --instance"
78
- [[ -n "$TELEGRAM_TOKEN" ]] || die "Missing --telegram-token"
150
+ if [[ -n "$TELEGRAM_TOKEN" ]]; then
151
+ [[ -n "$TELEGRAM_USERS" ]] || die "Missing --telegram-users (required with --telegram-token)"
152
+ [[ -n "$(csv_values "$TELEGRAM_USERS")" ]] || die "--telegram-users must include at least one allowed Telegram user ID"
153
+ elif [[ -n "$TELEGRAM_USERS" ]]; then
154
+ die "--telegram-users requires --telegram-token"
155
+ fi
79
156
  validate_instance_id "$INSTANCE"
157
+ validate_positive_int "VM_VCPU" "$VM_VCPU"
158
+ validate_positive_int "VM_MEM_MIB" "$VM_MEM_MIB"
159
+ validate_no_newline "TELEGRAM_TOKEN" "$TELEGRAM_TOKEN"
160
+ validate_no_newline "TELEGRAM_USERS" "$TELEGRAM_USERS"
161
+ validate_no_newline "MODEL" "$MODEL"
162
+ validate_no_newline "SKILLS" "$SKILLS"
163
+ validate_no_newline "OPENCLAW_IMAGE" "$OPENCLAW_IMAGE"
164
+ validate_no_newline "DISK_SIZE" "$DISK_SIZE"
165
+ validate_no_newline "API_SOCK" "$API_SOCK"
166
+ validate_no_newline "BASE_KERNEL" "$BASE_KERNEL"
167
+ validate_no_newline "BASE_ROOTFS" "$BASE_ROOTFS"
168
+ validate_no_newline "BASE_INITRD" "$BASE_INITRD"
169
+ validate_no_newline "ANTHROPIC_API_KEY" "$ANTHROPIC_API_KEY"
170
+ validate_no_newline "OPENAI_API_KEY" "$OPENAI_API_KEY"
171
+ validate_no_newline "MINIMAX_API_KEY" "$MINIMAX_API_KEY"
172
+ if [[ -n "$HOST_PORT_OVERRIDE" ]]; then
173
+ validate_no_newline "HOST_PORT" "$HOST_PORT_OVERRIDE"
174
+ fi
175
+ VM_VCPU=$((10#$VM_VCPU))
176
+ VM_MEM_MIB=$((10#$VM_MEM_MIB))
177
+ require_model_provider_key "$MODEL"
80
178
 
81
179
  require_root
82
- for c in firecracker systemctl ip iptables openssl jq cloud-localds ssh scp socat curl qemu-img; do
180
+ for c in firecracker systemctl ip bridge iptables openssl jq cloud-localds ssh scp socat curl qemu-img install flock; do
83
181
  require_cmd "$c"
84
182
  done
183
+ FIRECRACKER_BIN="$(command -v firecracker)"
184
+ [[ "$FIRECRACKER_BIN" = /* ]] || die "firecracker must resolve to an absolute executable path"
185
+ [[ -e /dev/kvm && -r /dev/kvm && -w /dev/kvm ]] || die "/dev/kvm is not available (KVM is required to run Firecracker VMs)"
186
+
187
+ mem_avail_mib="$(awk '/^MemAvailable:/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
188
+ if (( mem_avail_mib > 0 && VM_MEM_MIB > mem_avail_mib )); then
189
+ warn "Requested VM memory (${VM_MEM_MIB} MiB) exceeds host MemAvailable (${mem_avail_mib} MiB); the VM may OOM the host"
190
+ fi
85
191
 
86
192
  [[ -f "$BASE_KERNEL" ]] || die "Kernel not found: $BASE_KERNEL"
87
193
  [[ -f "$BASE_ROOTFS" ]] || die "Rootfs not found: $BASE_ROOTFS"
@@ -99,11 +205,30 @@ fc_dir="$(fc_instance_dir "$INSTANCE")"
99
205
  if [[ -z "$API_SOCK" ]]; then
100
206
  API_SOCK="$fc_dir/firecracker.socket"
101
207
  fi
208
+ VM_SVC="$(vm_service "$INSTANCE")"
209
+ PROXY_SVC="$(proxy_service "$INSTANCE")"
102
210
  [[ "$API_SOCK" = /* ]] || die "--api-sock must be an absolute path"
103
211
  [[ ! -e "$inst_dir" ]] || die "Instance already exists: $inst_dir"
104
-
105
- HOST_PORT="$(next_port)"
212
+ [[ ! -e "$fc_dir" ]] || die "Firecracker assets already exist: $fc_dir"
213
+ [[ ! -e "$API_SOCK" ]] || die "Firecracker API socket already exists: $API_SOCK"
214
+ [[ ! -e "/etc/systemd/system/$VM_SVC" ]] || die "VM unit already exists: /etc/systemd/system/$VM_SVC"
215
+ [[ ! -e "/etc/systemd/system/$PROXY_SVC" ]] || die "Proxy unit already exists: /etc/systemd/system/$PROXY_SVC"
216
+ CLEANUP_ARMED="true"
217
+
218
+ # Hold the allocation lock until the reservation is visible in the .env file,
219
+ # so concurrent setups cannot pick the same port/IP.
220
+ exec {ALLOC_LOCK_FD}>"$STATE_ROOT/.alloc.lock"
221
+ flock "$ALLOC_LOCK_FD"
222
+
223
+ if [[ -n "$HOST_PORT_OVERRIDE" ]]; then
224
+ ensure_host_port_available "$HOST_PORT_OVERRIDE"
225
+ HOST_PORT="$((10#$HOST_PORT_OVERRIDE))"
226
+ else
227
+ HOST_PORT="$(next_port)"
228
+ fi
106
229
  VM_IP="$(next_ip)"
230
+ SUBNET_MASK_BITS="$(subnet_mask_bits)"
231
+ BRIDGE_GATEWAY_IP="$(bridge_gateway_ip)"
107
232
  VM_OCTET="${VM_IP##*.}"
108
233
  SHORT_ID="$(echo "$INSTANCE" | tr -cd 'a-z0-9' | cut -c1-6)"
109
234
  [[ -n "$SHORT_ID" ]] || SHORT_ID="vm"
@@ -111,40 +236,50 @@ VM_TAP="t${SHORT_ID}${VM_OCTET}"
111
236
  VM_MAC="$(printf '06:fc:00:10:00:%02x' "$VM_OCTET")"
112
237
  GATEWAY_TOKEN="$(openssl rand -hex 32)"
113
238
 
239
+ if ip link show "$VM_TAP" >/dev/null 2>&1; then
240
+ die "TAP device already exists: $VM_TAP (leftover from a previous instance? remove it with: sudo ip link del $VM_TAP)"
241
+ fi
242
+
114
243
  mkdir -p "$inst_dir/config" "$inst_dir/workspace"
244
+ chmod 700 "$inst_dir" "$inst_dir/config" "$inst_dir/workspace"
115
245
  mkdir -p "$fc_dir/images" "$fc_dir/config" "$fc_dir/logs"
246
+ chmod 700 "$fc_dir" "$fc_dir/images" "$fc_dir/config" "$fc_dir/logs"
116
247
  mkdir -p "$(dirname "$API_SOCK")"
117
248
 
118
- cat > "$inst_dir/.env" <<EOF
119
- INSTANCE_ID=$INSTANCE
120
- HOST_PORT=$HOST_PORT
121
- VM_IP=$VM_IP
122
- VM_TAP=$VM_TAP
123
- VM_MAC=$VM_MAC
124
- GATEWAY_TOKEN=$GATEWAY_TOKEN
125
- MODEL=$MODEL
126
- SKILLS=$SKILLS
127
- TELEGRAM_USERS=$TELEGRAM_USERS
128
- OPENCLAW_IMAGE=$OPENCLAW_IMAGE
129
- VM_VCPU=$VM_VCPU
130
- VM_MEM_MIB=$VM_MEM_MIB
131
- DISK_SIZE=$DISK_SIZE
132
- API_SOCK=$API_SOCK
133
- SSH_KEY_PATH=$SSH_KEY_PATH
134
- SKIP_BROWSER_INSTALL=$SKIP_BROWSER_INSTALL
135
- ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY
136
- OPENAI_API_KEY=$OPENAI_API_KEY
137
- MINIMAX_API_KEY=$MINIMAX_API_KEY
138
- EOF
139
-
140
- echo "$GATEWAY_TOKEN" > "$inst_dir/.token"
141
- chmod 600 "$inst_dir/.token"
249
+ ENV_FILE="$(instance_env "$INSTANCE")"
250
+ install -m 600 /dev/null "$ENV_FILE"
251
+ write_kv "$ENV_FILE" FIRECLAW_STATE_FORMAT "plain-v1"
252
+ write_kv "$ENV_FILE" INSTANCE_ID "$INSTANCE"
253
+ write_kv "$ENV_FILE" HOST_PORT "$HOST_PORT"
254
+ write_kv "$ENV_FILE" VM_IP "$VM_IP"
255
+ write_kv "$ENV_FILE" VM_TAP "$VM_TAP"
256
+ write_kv "$ENV_FILE" VM_MAC "$VM_MAC"
257
+ write_kv "$ENV_FILE" GATEWAY_TOKEN "$GATEWAY_TOKEN"
258
+ write_kv "$ENV_FILE" MODEL "$MODEL"
259
+ write_kv "$ENV_FILE" SKILLS "$SKILLS"
260
+ write_kv "$ENV_FILE" TELEGRAM_USERS "$TELEGRAM_USERS"
261
+ write_kv "$ENV_FILE" OPENCLAW_IMAGE "$OPENCLAW_IMAGE"
262
+ write_kv "$ENV_FILE" VM_VCPU "$VM_VCPU"
263
+ write_kv "$ENV_FILE" VM_MEM_MIB "$VM_MEM_MIB"
264
+ write_kv "$ENV_FILE" DISK_SIZE "$DISK_SIZE"
265
+ write_kv "$ENV_FILE" API_SOCK "$API_SOCK"
266
+ write_kv "$ENV_FILE" SSH_KEY_PATH "$SSH_KEY_PATH"
267
+ write_kv "$ENV_FILE" SKIP_BROWSER_INSTALL "$SKIP_BROWSER_INSTALL"
268
+ write_kv "$ENV_FILE" ANTHROPIC_API_KEY "$ANTHROPIC_API_KEY"
269
+ write_kv "$ENV_FILE" OPENAI_API_KEY "$OPENAI_API_KEY"
270
+ write_kv "$ENV_FILE" MINIMAX_API_KEY "$MINIMAX_API_KEY"
271
+
272
+ exec {ALLOC_LOCK_FD}>&-
273
+
274
+ install -m 600 /dev/null "$inst_dir/.token"
275
+ printf '%s\n' "$GATEWAY_TOKEN" > "$inst_dir/.token"
142
276
 
143
277
  log "Preparing VM disk and assets"
144
278
  cp --reflink=auto "$BASE_ROOTFS" "$fc_dir/images/rootfs.ext4" 2>/dev/null || cp "$BASE_ROOTFS" "$fc_dir/images/rootfs.ext4"
279
+ chmod 600 "$fc_dir/images/rootfs.ext4"
145
280
  if [[ -n "$DISK_SIZE" ]]; then
146
281
  log "Resizing rootfs to $DISK_SIZE"
147
- qemu-img resize "$fc_dir/images/rootfs.ext4" "$DISK_SIZE" >/dev/null
282
+ qemu-img resize -f raw "$fc_dir/images/rootfs.ext4" "$DISK_SIZE" >/dev/null
148
283
  fi
149
284
  cp "$BASE_KERNEL" "$fc_dir/images/vmlinux"
150
285
 
@@ -168,12 +303,8 @@ users:
168
303
  - $SSH_PUB_KEY
169
304
  package_update: true
170
305
  packages:
171
- - docker.io
172
306
  - jq
173
307
  - curl
174
- runcmd:
175
- - [ systemctl, enable, docker ]
176
- - [ systemctl, start, docker ]
177
308
  EOF
178
309
 
179
310
  cat > "$fc_dir/config/meta-data" <<EOF
@@ -190,10 +321,10 @@ ethernets:
190
321
  set-name: eth0
191
322
  dhcp4: false
192
323
  addresses:
193
- - $VM_IP/24
324
+ - $VM_IP/$SUBNET_MASK_BITS
194
325
  routes:
195
326
  - to: 0.0.0.0/0
196
- via: 172.16.0.1
327
+ via: $BRIDGE_GATEWAY_IP
197
328
  nameservers:
198
329
  addresses: [1.1.1.1,8.8.8.8]
199
330
  EOF
@@ -237,21 +368,29 @@ SUBNET_CIDR="$SUBNET_CIDR"
237
368
  VM_TAP="$VM_TAP"
238
369
  API_SOCK="$API_SOCK"
239
370
  CONFIG_JSON="$fc_dir/config/vm-config.json"
371
+ FIRECRACKER_BIN="$FIRECRACKER_BIN"
240
372
 
241
373
  if ! ip link show "\$BRIDGE_NAME" >/dev/null 2>&1; then
242
374
  ip link add "\$BRIDGE_NAME" type bridge
243
375
  fi
244
- ip addr add "\$BRIDGE_ADDR" dev "\$BRIDGE_NAME" 2>/dev/null || true
376
+ ip -4 addr show dev "\$BRIDGE_NAME" | grep -Fq " \$BRIDGE_ADDR " || ip addr add "\$BRIDGE_ADDR" dev "\$BRIDGE_NAME"
245
377
  ip link set "\$BRIDGE_NAME" up
246
378
  sysctl -w net.ipv4.ip_forward=1 >/dev/null
247
379
  iptables -t nat -C POSTROUTING -s "\$SUBNET_CIDR" ! -o "\$BRIDGE_NAME" -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s "\$SUBNET_CIDR" ! -o "\$BRIDGE_NAME" -j MASQUERADE
380
+ # Defense-in-depth only: matches solely where br_netfilter is enabled.
381
+ iptables -C FORWARD -i "\$BRIDGE_NAME" -o "\$BRIDGE_NAME" -j DROP 2>/dev/null || iptables -I FORWARD 1 -i "\$BRIDGE_NAME" -o "\$BRIDGE_NAME" -j DROP
248
382
 
249
- ip tuntap add dev "\$VM_TAP" mode tap user root 2>/dev/null || true
383
+ if ! ip link show "\$VM_TAP" >/dev/null 2>&1; then
384
+ ip tuntap add dev "\$VM_TAP" mode tap user root
385
+ fi
250
386
  ip link set "\$VM_TAP" master "\$BRIDGE_NAME"
387
+ # Isolated bridge ports cannot exchange frames (IP or ARP) with each other,
388
+ # only with the bridge/host itself — this is the actual VM-to-VM isolation.
389
+ bridge link set dev "\$VM_TAP" isolated on
251
390
  ip link set "\$VM_TAP" up
252
391
 
253
392
  rm -f "\$API_SOCK"
254
- exec /usr/local/bin/firecracker --api-sock "\$API_SOCK" --config-file "\$CONFIG_JSON"
393
+ exec "\$FIRECRACKER_BIN" --api-sock "\$API_SOCK" --config-file "\$CONFIG_JSON"
255
394
  EOF
256
395
  chmod +x "$fc_dir/start-vm.sh"
257
396
 
@@ -259,14 +398,27 @@ cat > "$fc_dir/stop-vm.sh" <<EOF
259
398
  #!/usr/bin/env bash
260
399
  set -euo pipefail
261
400
  VM_TAP="$VM_TAP"
401
+ API_SOCK="$API_SOCK"
402
+
403
+ # Ask the guest to shut down cleanly (systemd maps Ctrl-Alt-Del to a clean
404
+ # reboot, which exits Firecracker) before systemd kills the VMM, so the
405
+ # rootfs ext4 is not torn down mid-write.
406
+ if [[ -S "\$API_SOCK" ]]; then
407
+ if curl -fsS --max-time 2 --unix-socket "\$API_SOCK" -X PUT http://localhost/actions \\
408
+ -H 'Content-Type: application/json' -d '{"action_type": "SendCtrlAltDel"}' >/dev/null 2>&1; then
409
+ for _ in \$(seq 1 20); do
410
+ curl -fsS --max-time 1 --unix-socket "\$API_SOCK" http://localhost/ >/dev/null 2>&1 || break
411
+ sleep 1
412
+ done
413
+ fi
414
+ fi
415
+
262
416
  ip link set "\$VM_TAP" down 2>/dev/null || true
263
417
  ip link del "\$VM_TAP" 2>/dev/null || true
418
+ rm -f "\$API_SOCK"
264
419
  EOF
265
420
  chmod +x "$fc_dir/stop-vm.sh"
266
421
 
267
- VM_SVC="$(vm_service "$INSTANCE")"
268
- PROXY_SVC="$(proxy_service "$INSTANCE")"
269
-
270
422
  cat > "/etc/systemd/system/$VM_SVC" <<EOF
271
423
  [Unit]
272
424
  Description=Firecracker VM ($INSTANCE)
@@ -277,7 +429,8 @@ Wants=network-online.target
277
429
  Type=simple
278
430
  ExecStart=$fc_dir/start-vm.sh
279
431
  ExecStop=$fc_dir/stop-vm.sh
280
- Restart=on-failure
432
+ TimeoutStopSec=45
433
+ Restart=always
281
434
  RestartSec=2
282
435
 
283
436
  [Install]
@@ -289,29 +442,33 @@ cat > "/etc/systemd/system/$PROXY_SVC" <<EOF
289
442
  Description=Localhost proxy for VM ($INSTANCE)
290
443
  After=$VM_SVC
291
444
  Requires=$VM_SVC
445
+ PartOf=$VM_SVC
292
446
 
293
447
  [Service]
294
448
  Type=simple
449
+ DynamicUser=yes
295
450
  ExecStart=/usr/bin/socat TCP-LISTEN:$HOST_PORT,bind=127.0.0.1,reuseaddr,fork TCP:$VM_IP:18789
451
+ SuccessExitStatus=143
296
452
  Restart=always
297
453
  RestartSec=2
298
454
 
299
455
  [Install]
300
- WantedBy=multi-user.target
456
+ WantedBy=multi-user.target $VM_SVC
301
457
  EOF
302
458
 
303
459
  systemctl daemon-reload
304
460
  systemctl enable --now "$VM_SVC"
305
461
 
306
462
  log "Waiting for SSH on $VM_IP"
307
- if ! wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 180; then
463
+ if ! wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 180 "$INSTANCE"; then
308
464
  die "VM booted but SSH was not reachable"
309
465
  fi
310
466
 
311
467
  PROVISION_VARS="$inst_dir/provision.vars"
312
- : > "$PROVISION_VARS"
313
- kv() { printf "%s=%q\n" "$1" "$2" >> "$PROVISION_VARS"; }
468
+ install -m 600 /dev/null "$PROVISION_VARS"
469
+ kv() { write_kv "$PROVISION_VARS" "$1" "$2"; }
314
470
 
471
+ kv FIRECLAW_STATE_FORMAT "plain-v1"
315
472
  kv INSTANCE_ID "$INSTANCE"
316
473
  kv TELEGRAM_TOKEN "$TELEGRAM_TOKEN"
317
474
  kv TELEGRAM_USERS "$TELEGRAM_USERS"
@@ -327,44 +484,30 @@ kv MINIMAX_API_KEY "$MINIMAX_API_KEY"
327
484
 
328
485
  [[ -f "$REPO_ROOT/scripts/provision-guest.sh" ]] || die "Missing: $REPO_ROOT/scripts/provision-guest.sh"
329
486
 
330
- scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
487
+ scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$INSTANCE")" \
331
488
  "$REPO_ROOT/scripts/provision-guest.sh" "ubuntu@$VM_IP:/tmp/provision-guest.sh"
332
- scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
489
+ scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$INSTANCE")" \
333
490
  "$PROVISION_VARS" "ubuntu@$VM_IP:/tmp/provision.vars"
334
491
 
335
- ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
492
+ ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$INSTANCE")" \
336
493
  "ubuntu@$VM_IP" "sudo bash /tmp/provision-guest.sh /tmp/provision.vars"
337
494
 
495
+ # Guest provisioning succeeded: from here on a failure must not destroy the
496
+ # instance — it is recoverable with `fireclaw provision` / `fireclaw start`.
497
+ CLEANUP_ARMED="false"
498
+
338
499
  systemctl enable --now "$PROXY_SVC"
339
500
 
340
- host_health_ok="false"
341
- guest_health_ok="false"
342
- GUEST_HEALTH_SCRIPT="$(guest_health_script "$INSTANCE")"
343
- for _ in {1..30}; do
344
- if [[ "$guest_health_ok" != "true" ]] && check_guest_health "$INSTANCE" "$VM_IP" "$SSH_KEY_PATH"; then
345
- guest_health_ok="true"
346
- fi
347
- if [[ "$host_health_ok" != "true" ]] && curl -fsS "http://127.0.0.1:$HOST_PORT/health" >/dev/null 2>&1; then
348
- host_health_ok="true"
349
- fi
350
- if [[ "$guest_health_ok" == "true" && "$host_health_ok" == "true" ]]; then
351
- break
352
- fi
353
- sleep 2
354
- done
501
+ if ! wait_for_instance_health "$INSTANCE" "$VM_IP" "$HOST_PORT" "$SSH_KEY_PATH" 30; then
502
+ warn "The instance was kept for inspection: sudo fireclaw status $INSTANCE"
503
+ die "Health checks did not pass for $INSTANCE after provisioning (retry with: sudo fireclaw provision $INSTANCE)"
504
+ fi
505
+ SETUP_COMPLETE="true"
355
506
 
356
507
  echo "✓ VM instance configured"
357
508
  echo " Instance: $INSTANCE"
358
509
  echo " VM IP: $VM_IP"
359
510
  echo " Port: $HOST_PORT"
360
- echo " Token: $GATEWAY_TOKEN"
361
- if [[ "$guest_health_ok" == "true" && "$host_health_ok" == "true" ]]; then
362
- echo " Health: up (guest + proxy)"
363
- elif [[ "$guest_health_ok" == "true" ]]; then
364
- echo " Health: guest up, proxy pending"
365
- elif [[ "$host_health_ok" == "true" ]]; then
366
- echo " Health: proxy up (guest check pending)"
367
- else
368
- echo " Health: pending (guest script: $GUEST_HEALTH_SCRIPT)"
369
- fi
511
+ echo " Token: (print with: sudo fireclaw token $INSTANCE)"
512
+ echo " Health: up (guest + proxy)"
370
513
  echo " Status: $(systemctl is-active "$VM_SVC") / $(systemctl is-active "$PROXY_SVC")"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fireclaw",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Firecracker microVM control plane for isolated OpenClaw instances",
5
5
  "bin": {
6
6
  "fireclaw": "bin/fireclaw"
@@ -11,8 +11,9 @@
11
11
  "README.md"
12
12
  ],
13
13
  "scripts": {
14
- "lint:shell": "bash -n bin/fireclaw && bash -n bin/vm-common.sh && bash -n bin/vm-setup && bash -n bin/vm-ctl && bash -n scripts/provision-guest.sh",
15
- "test": "npm run lint:shell",
14
+ "lint:shell": "bash -n bin/fireclaw && bash -n bin/vm-common.sh && bash -n bin/vm-setup && bash -n bin/vm-ctl && bash -n bin/vm-provision && bash -n scripts/provision-guest.sh && bash -n tests/unit/vm-common.test.sh",
15
+ "test:unit": "bash tests/unit/vm-common.test.sh",
16
+ "test": "npm run lint:shell && npm run test:unit",
16
17
  "pack:check": "npm pack --dry-run",
17
18
  "prepublishOnly": "npm test && npm run pack:check"
18
19
  },
@@ -31,7 +32,7 @@
31
32
  "bugs": {
32
33
  "url": "https://github.com/bchewy/fireclaw/issues"
33
34
  },
34
- "license": "UNLICENSED",
35
+ "license": "MIT",
35
36
  "author": "bchewy",
36
37
  "engines": {
37
38
  "node": ">=18"