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-ctl CHANGED
@@ -11,6 +11,7 @@ usage() {
11
11
  Usage: $CMD_NAME <command> [instance]
12
12
 
13
13
  Commands:
14
+ doctor
14
15
  list
15
16
  status [id]
16
17
  start <id>
@@ -24,58 +25,119 @@ EOF
24
25
  }
25
26
 
26
27
  ssh_run() {
28
+ local id="$1"; shift
27
29
  local ip="$1"; shift
28
30
  local key="${SSH_KEY_PATH:-$HOME/.ssh/vmdemo_vm}"
29
- ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null "ubuntu@$ip" "$@"
31
+ ssh -i "$key" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$id")" "ubuntu@$ip" "$@"
32
+ }
33
+
34
+ _color() {
35
+ local val="$1"
36
+ local green=$'\033[32m' red=$'\033[31m' yellow=$'\033[33m' reset=$'\033[0m'
37
+ case "$val" in
38
+ active|up) printf '%s%s%s' "$green" "$val" "$reset" ;;
39
+ inactive|down) printf '%s%s%s' "$red" "$val" "$reset" ;;
40
+ failed) printf '%s%s%s' "$red" "$val" "$reset" ;;
41
+ *) printf '%s%s%s' "$yellow" "$val" "$reset" ;;
42
+ esac
43
+ }
44
+
45
+ # printf pads by byte count, so colorize after padding to keep columns aligned.
46
+ _color_cell() {
47
+ local width="$1"
48
+ local val="$2"
49
+ local pad=$(( width - ${#val} ))
50
+ (( pad > 0 )) || pad=0
51
+ printf '%b%*s' "$(_color "$val")" "$pad" ""
52
+ }
53
+
54
+ _print_status_table() {
55
+ local -a ids=() ips=() ports=() vms=() proxies=() healths=()
56
+ local id ip port vm proxy health
57
+
58
+ while IFS='|' read -r id ip port vm proxy health; do
59
+ ids+=("$id"); ips+=("$ip"); ports+=("$port")
60
+ vms+=("$vm"); proxies+=("$proxy"); healths+=("$health")
61
+ done
62
+
63
+ [[ ${#ids[@]} -gt 0 ]] || { echo "(no instances)"; return; }
64
+
65
+ local hdr=$'\033[1;37m' reset=$'\033[0m' dim=$'\033[2m'
66
+ printf "${hdr}%-14s %-14s %-7s %-10s %-10s %-8s${reset}\n" \
67
+ "INSTANCE" "IP" "PORT" "VM" "PROXY" "HEALTH"
68
+ printf "${dim}%-14s %-14s %-7s %-10s %-10s %-8s${reset}\n" \
69
+ "--------" "----------" "-----" "------" "-------" "------"
70
+
71
+ for i in "${!ids[@]}"; do
72
+ printf "%-14s %-14s %-7s %s %s %s\n" \
73
+ "${ids[$i]}" "${ips[$i]}" "${ports[$i]}" \
74
+ "$(_color_cell 10 "${vms[$i]}")" "$(_color_cell 10 "${proxies[$i]}")" "$(_color_cell 8 "${healths[$i]}")"
75
+ done
30
76
  }
31
77
 
32
78
  cmd_list() {
79
+ require_root
80
+ local nullglob_was_set=0
81
+ shopt -q nullglob && nullglob_was_set=1
33
82
  shopt -s nullglob
34
- local found="false"
83
+ local rows=()
35
84
  for d in "$STATE_ROOT"/.vm-*/; do
36
- found="true"
37
- local id
85
+ local id row
38
86
  id="$(basename "$d" | sed 's/^\.vm-//')"
39
87
  if [[ ! "$id" =~ ^[a-z0-9_-]+$ ]]; then
40
88
  warn "Skipping invalid instance state directory: $d"
41
89
  continue
42
90
  fi
43
- load_instance_env "$id"
44
- local ssh_key="${SSH_KEY_PATH:-$HOME/.ssh/vmdemo_vm}"
45
- local health="down"
46
- local host_health="down"
47
- local guest_health="down"
48
- curl -fsS "http://127.0.0.1:$HOST_PORT/health" >/dev/null 2>&1 && host_health="up"
49
- if check_guest_health "$id" "$VM_IP" "$ssh_key"; then
50
- guest_health="up"
51
- fi
52
- if [[ "$host_health" == "up" || "$guest_health" == "up" ]]; then
53
- health="up"
91
+ # Subshell so one corrupt .env degrades to an error row instead of
92
+ # killing the whole fleet view, and loaded values cannot leak across
93
+ # instances.
94
+ if row="$(
95
+ load_instance_env "$id" >/dev/null 2>&1 || exit 1
96
+ ssh_key="${SSH_KEY_PATH:-$HOME/.ssh/vmdemo_vm}"
97
+ health="down"
98
+ host_health="down"
99
+ guest_health="down"
100
+ curl -fsS "http://127.0.0.1:$HOST_PORT/health" >/dev/null 2>&1 && host_health="up"
101
+ if check_guest_health "$id" "$VM_IP" "$ssh_key"; then
102
+ guest_health="up"
103
+ fi
104
+ if [[ "$host_health" == "up" || "$guest_health" == "up" ]]; then
105
+ health="up"
106
+ fi
107
+ vm_state="$(systemctl is-active "$(vm_service "$id")" 2>/dev/null || true)"
108
+ proxy_state="$(systemctl is-active "$(proxy_service "$id")" 2>/dev/null || true)"
109
+ printf '%s|%s|%s|%s|%s|%s' "$id" "$VM_IP" "$HOST_PORT" "${vm_state:-inactive}" "${proxy_state:-inactive}" "$health"
110
+ )"; then
111
+ rows+=("$row")
112
+ else
113
+ warn "Unreadable instance state for '$id' (inspect: $d.env)"
114
+ rows+=("${id}|?|?|error|error|down")
54
115
  fi
55
- local vm_state proxy_state
56
- vm_state="$(systemctl is-active "$(vm_service "$id")" 2>/dev/null || echo inactive)"
57
- proxy_state="$(systemctl is-active "$(proxy_service "$id")" 2>/dev/null || echo inactive)"
58
- echo "$id ip=$VM_IP port=$HOST_PORT vm=$vm_state proxy=$proxy_state health=$health host_health=$host_health guest_health=$guest_health"
59
116
  done
60
- shopt -u nullglob
61
- [[ "$found" == "true" ]] || echo "(no instances)"
117
+ (( nullglob_was_set )) || shopt -u nullglob
118
+ if (( ${#rows[@]} == 0 )); then
119
+ _print_status_table < /dev/null
120
+ else
121
+ printf '%s\n' "${rows[@]}" | _print_status_table
122
+ fi
62
123
  }
63
124
 
64
125
  cmd_status_one() {
65
126
  local id="$1"
66
127
  validate_instance_id "$id"
128
+ require_root
67
129
  load_instance_env "$id"
68
130
  local ssh_key="${SSH_KEY_PATH:-$HOME/.ssh/vmdemo_vm}"
69
131
  local vm_state proxy_state health host_health guest_health guest
70
- vm_state="$(systemctl is-active "$(vm_service "$id")" 2>/dev/null || echo inactive)"
71
- proxy_state="$(systemctl is-active "$(proxy_service "$id")" 2>/dev/null || echo inactive)"
132
+ vm_state="$(systemctl is-active "$(vm_service "$id")" 2>/dev/null)" || vm_state="inactive"
133
+ proxy_state="$(systemctl is-active "$(proxy_service "$id")" 2>/dev/null)" || proxy_state="inactive"
72
134
  health="down"
73
135
  host_health="down"
74
136
  guest_health="down"
75
137
  curl -fsS "http://127.0.0.1:$HOST_PORT/health" >/dev/null 2>&1 && host_health="up"
76
138
  guest="unknown"
77
- if wait_for_ssh "$VM_IP" "$ssh_key" 1; then
78
- guest="$(ssh_run "$VM_IP" "systemctl is-active openclaw-$id.service" 2>/dev/null || echo unknown)"
139
+ if ssh_reachable "$VM_IP" "$ssh_key" "$id"; then
140
+ guest="$(ssh_run "$id" "$VM_IP" "systemctl is-active openclaw-$id.service" 2>/dev/null)" || guest="unknown"
79
141
  if check_guest_health "$id" "$VM_IP" "$ssh_key"; then
80
142
  guest_health="up"
81
143
  fi
@@ -83,7 +145,17 @@ cmd_status_one() {
83
145
  if [[ "$host_health" == "up" || "$guest_health" == "up" ]]; then
84
146
  health="up"
85
147
  fi
86
- echo "$id ip=$VM_IP port=$HOST_PORT vm=$vm_state proxy=$proxy_state guest=$guest health=$health host_health=$host_health guest_health=$guest_health"
148
+
149
+ local bold=$'\033[1m' dim=$'\033[2m' reset=$'\033[0m'
150
+ printf "${bold}%s${reset}\n" "$id"
151
+ printf " %-16s %s\n" "IP" "$VM_IP"
152
+ printf " %-16s %s\n" "Proxy port" "$HOST_PORT"
153
+ printf " %-16s %b\n" "VM" "$(_color "$vm_state")"
154
+ printf " %-16s %b\n" "Proxy" "$(_color "$proxy_state")"
155
+ printf " %-16s %b\n" "Guest service" "$(_color "$guest")"
156
+ printf " %-16s %b\n" "Health" "$(_color "$health")"
157
+ printf " %-16s %b\n" " Host health" "$(_color "$host_health")"
158
+ printf " %-16s %b\n" " Guest health" "$(_color "$guest_health")"
87
159
  }
88
160
 
89
161
  cmd_status() {
@@ -101,11 +173,16 @@ cmd_start() {
101
173
  load_instance_env "$id"
102
174
 
103
175
  systemctl enable --now "$(vm_service "$id")"
104
- wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 180 || die "VM started but SSH unreachable"
176
+ wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 180 "$id" || die "VM started but SSH unreachable"
105
177
 
106
- ssh_run "$VM_IP" "sudo systemctl enable --now openclaw-$id.service" || warn "Guest service start failed"
178
+ ssh_run "$id" "$VM_IP" "sudo systemctl enable --now openclaw-$id.service" || warn "Guest service start failed"
107
179
  systemctl enable --now "$(proxy_service "$id")"
108
180
 
181
+ if ! wait_for_instance_health "$id" "$VM_IP" "$HOST_PORT" "$SSH_KEY_PATH" 30; then
182
+ cmd_status_one "$id"
183
+ die "Health checks did not pass for $id after start"
184
+ fi
185
+
109
186
  cmd_status_one "$id"
110
187
  }
111
188
 
@@ -116,10 +193,15 @@ cmd_stop() {
116
193
  load_instance_env "$id"
117
194
 
118
195
  systemctl stop "$(proxy_service "$id")" 2>/dev/null || true
119
- if wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 1; then
120
- ssh_run "$VM_IP" "sudo systemctl stop openclaw-$id.service" || true
196
+ if ssh_reachable "$VM_IP" "$SSH_KEY_PATH" "$id"; then
197
+ ssh_run "$id" "$VM_IP" "sudo systemctl stop openclaw-$id.service" || true
198
+ else
199
+ warn "VM SSH unavailable; skipping guest service stop"
121
200
  fi
122
- systemctl stop "$(vm_service "$id")"
201
+ systemctl stop "$(vm_service "$id")" || warn "Failed to stop $(vm_service "$id")"
202
+ # Without disabling, a stopped instance silently resurrects on host reboot.
203
+ systemctl disable "$(proxy_service "$id")" 2>/dev/null || true
204
+ systemctl disable "$(vm_service "$id")" 2>/dev/null || true
123
205
  cmd_status_one "$id"
124
206
  }
125
207
 
@@ -132,43 +214,59 @@ cmd_logs() {
132
214
  local id="$1"
133
215
  local mode="${2:-guest}"
134
216
  validate_instance_id "$id"
217
+ require_root
135
218
  load_instance_env "$id"
219
+ [[ "$mode" == "guest" || "$mode" == "host" ]] || die "Usage: $CMD_NAME logs <id> [guest|host]"
136
220
 
137
221
  if [[ "$mode" == "host" ]]; then
138
222
  journalctl -u "$(vm_service "$id")" -u "$(proxy_service "$id")" -f
139
223
  return
140
224
  fi
141
225
 
142
- wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 30 || die "VM SSH unavailable"
143
- ssh_run "$VM_IP" "sudo journalctl -u openclaw-$id.service -f"
226
+ wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 30 "$id" || die "VM SSH unavailable"
227
+ ssh_run "$id" "$VM_IP" "sudo journalctl -u openclaw-$id.service -f"
144
228
  }
145
229
 
146
230
  cmd_shell() {
147
231
  local id="$1"
148
232
  shift || true
149
233
  validate_instance_id "$id"
234
+ require_root
150
235
  load_instance_env "$id"
151
- wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 30 || die "VM SSH unavailable"
236
+ wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 30 "$id" || die "VM SSH unavailable"
152
237
 
153
238
  if [[ $# -gt 0 ]]; then
154
- ssh_run "$VM_IP" "$*"
239
+ ssh_run "$id" "$VM_IP" "$*"
155
240
  else
156
- ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null "ubuntu@$VM_IP"
241
+ ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$id")" "ubuntu@$VM_IP"
157
242
  fi
158
243
  }
159
244
 
160
245
  cmd_token() {
161
246
  local id="$1"
162
247
  validate_instance_id "$id"
248
+ require_root
163
249
  cat "$(instance_token "$id")"
164
250
  }
165
251
 
166
252
  cmd_destroy() {
253
+ [[ $# -eq 1 || ( $# -eq 2 && "$2" == "--force" ) ]] || die "Usage: $CMD_NAME destroy <id> [--force]"
167
254
  local id="$1"
168
255
  local force="${2:-}"
169
256
  validate_instance_id "$id"
170
257
  require_root
171
- load_instance_env "$id"
258
+
259
+ local env_ok="true"
260
+ if ! (load_instance_env "$id") >/dev/null 2>&1; then
261
+ env_ok="false"
262
+ if [[ "$force" != "--force" ]]; then
263
+ die "Cannot read state for '$id'; use --force to remove its units and directories anyway"
264
+ fi
265
+ warn "State for '$id' is unreadable; best-effort cleanup of units and directories"
266
+ fi
267
+ if [[ "$env_ok" == "true" ]]; then
268
+ load_instance_env "$id"
269
+ fi
172
270
 
173
271
  if [[ "$force" != "--force" ]]; then
174
272
  read -r -p "Destroy '$id' and remove VM assets? [y/N] " confirm
@@ -183,27 +281,118 @@ cmd_destroy() {
183
281
  rm -f "/etc/systemd/system/$(proxy_service "$id")"
184
282
  rm -f "/etc/systemd/system/$(vm_service "$id")"
185
283
  systemctl daemon-reload
284
+ systemctl reset-failed "$(proxy_service "$id")" "$(vm_service "$id")" 2>/dev/null || true
285
+
286
+ if [[ "$env_ok" == "true" ]]; then
287
+ if [[ -n "${VM_TAP:-}" ]]; then
288
+ ip link set "$VM_TAP" down 2>/dev/null || true
289
+ ip link del "$VM_TAP" 2>/dev/null || true
290
+ fi
291
+ if [[ -n "${API_SOCK:-}" ]]; then
292
+ rm -f "$API_SOCK"
293
+ fi
294
+ fi
186
295
 
187
296
  rm -rf "$(instance_dir "$id")" "$(fc_instance_dir "$id")"
188
297
 
189
298
  echo "Destroyed: $id"
190
299
  }
191
300
 
301
+ cmd_doctor() {
302
+ local failures=0
303
+
304
+ _check() {
305
+ local label="$1"
306
+ local ok="$2"
307
+ local detail="${3:-}"
308
+ local green=$'\033[32m' red=$'\033[31m' yellow=$'\033[33m' reset=$'\033[0m'
309
+ if [[ "$ok" == "pass" ]]; then
310
+ printf '%s✓%s %s%s\n' "$green" "$reset" "$label" "${detail:+ ($detail)}"
311
+ elif [[ "$ok" == "skip" ]]; then
312
+ printf '%s-%s %s%s\n' "$yellow" "$reset" "$label" "${detail:+ ($detail)}"
313
+ else
314
+ printf '%s✗%s %s%s\n' "$red" "$reset" "$label" "${detail:+ ($detail)}"
315
+ failures=$((failures + 1))
316
+ fi
317
+ }
318
+
319
+ local c
320
+ for c in firecracker systemctl ip bridge iptables openssl jq cloud-localds ssh scp socat curl qemu-img install flock; do
321
+ if command -v "$c" >/dev/null 2>&1; then
322
+ _check "command: $c" pass
323
+ else
324
+ _check "command: $c" fail "not found on PATH"
325
+ fi
326
+ done
327
+
328
+ if [[ -e /dev/kvm ]]; then
329
+ if [[ -r /dev/kvm && -w /dev/kvm ]]; then
330
+ _check "/dev/kvm" pass
331
+ else
332
+ _check "/dev/kvm" fail "exists but not accessible by $(id -un)"
333
+ fi
334
+ else
335
+ _check "/dev/kvm" fail "missing (KVM required)"
336
+ fi
337
+
338
+ local img
339
+ for img in "${BASE_KERNEL:-${BASE_IMAGES_DIR:-/srv/firecracker/base/images}/vmlinux}" "${BASE_ROOTFS:-${BASE_IMAGES_DIR:-/srv/firecracker/base/images}/rootfs.ext4}"; do
340
+ if [[ -f "$img" ]]; then
341
+ _check "base image: $img" pass
342
+ else
343
+ _check "base image: $img" fail "missing"
344
+ fi
345
+ done
346
+
347
+ if ip link show "$BRIDGE_NAME" >/dev/null 2>&1; then
348
+ _check "bridge: $BRIDGE_NAME" pass "$(ip -4 -o addr show dev "$BRIDGE_NAME" | awk '{print $4}' | head -1)"
349
+ else
350
+ _check "bridge: $BRIDGE_NAME" skip "absent (setup creates it)"
351
+ fi
352
+
353
+ if [[ $EUID -eq 0 ]]; then
354
+ if iptables -t nat -C POSTROUTING -s "$SUBNET_CIDR" ! -o "$BRIDGE_NAME" -j MASQUERADE >/dev/null 2>&1; then
355
+ _check "NAT rule for $SUBNET_CIDR" pass
356
+ else
357
+ _check "NAT rule for $SUBNET_CIDR" skip "absent (setup adds it)"
358
+ fi
359
+ if [[ -d "$STATE_ROOT" && -w "$STATE_ROOT" ]]; then
360
+ _check "state root: $STATE_ROOT" pass
361
+ else
362
+ _check "state root: $STATE_ROOT" skip "absent (setup creates it)"
363
+ fi
364
+ else
365
+ _check "NAT rule / state root" skip "requires root"
366
+ fi
367
+
368
+ local mem_avail_mib disk_avail
369
+ mem_avail_mib="$(awk '/^MemAvailable:/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo "?")"
370
+ disk_avail="$(df -h --output=avail "$FC_ROOT" 2>/dev/null | tail -1 | tr -d ' ' || echo "?")"
371
+ _check "capacity" pass "MemAvailable: ${mem_avail_mib} MiB, free on $FC_ROOT: ${disk_avail:-?}"
372
+
373
+ echo
374
+ if (( failures > 0 )); then
375
+ die "$failures check(s) failed"
376
+ fi
377
+ echo "All checks passed"
378
+ }
379
+
192
380
  [[ $# -ge 1 ]] || { usage; exit 1; }
193
381
 
194
382
  cmd="$1"
195
383
  shift || true
196
384
 
197
385
  case "$cmd" in
386
+ doctor) [[ $# -eq 0 ]] || die "Usage: $CMD_NAME doctor"; cmd_doctor ;;
198
387
  list) cmd_list ;;
199
- status) cmd_status "$@" ;;
388
+ status) [[ $# -le 1 ]] || die "Usage: $CMD_NAME status [id]"; cmd_status "$@" ;;
200
389
  start) [[ $# -eq 1 ]] || die "Usage: $CMD_NAME start <id>"; cmd_start "$1" ;;
201
390
  stop) [[ $# -eq 1 ]] || die "Usage: $CMD_NAME stop <id>"; cmd_stop "$1" ;;
202
391
  restart) [[ $# -eq 1 ]] || die "Usage: $CMD_NAME restart <id>"; cmd_restart "$1" ;;
203
- logs) [[ $# -ge 1 ]] || die "Usage: $CMD_NAME logs <id> [guest|host]"; cmd_logs "$@" ;;
392
+ logs) [[ $# -ge 1 && $# -le 2 ]] || die "Usage: $CMD_NAME logs <id> [guest|host]"; cmd_logs "$@" ;;
204
393
  shell) [[ $# -ge 1 ]] || die "Usage: $CMD_NAME shell <id> [command...]"; id="$1"; shift; cmd_shell "$id" "$@" ;;
205
394
  token) [[ $# -eq 1 ]] || die "Usage: $CMD_NAME token <id>"; cmd_token "$1" ;;
206
- destroy) [[ $# -ge 1 ]] || die "Usage: $CMD_NAME destroy <id> [--force]"; cmd_destroy "$@" ;;
395
+ destroy) cmd_destroy "$@" ;;
207
396
  -h|--help|help) usage ;;
208
397
  *) die "Unknown command: $cmd" ;;
209
398
  esac
package/bin/vm-provision CHANGED
@@ -4,51 +4,185 @@ set -euo pipefail
4
4
  SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
5
5
  source "$SCRIPT_DIR/vm-common.sh"
6
6
 
7
+ CMD_NAME="${VM_PROVISION_CMD_NAME:-fireclaw provision}"
8
+
7
9
  usage() {
8
10
  cat <<EOF
9
- Usage: fireclaw provision <instance>
11
+ Usage: $CMD_NAME <instance> [options]
12
+
13
+ Each option updates the saved instance config, then guest provisioning reruns
14
+ with the result. With no options, provisioning reruns with the saved config.
15
+
16
+ Options:
17
+ --telegram-token <token>
18
+ --no-telegram (clear the saved token; disables Telegram in the guest)
19
+ --telegram-users <csv>
20
+ --model <id>
21
+ --skills <csv>
22
+ --openclaw-image <image>
23
+ --anthropic-api-key <key>
24
+ --openai-api-key <key>
25
+ --minimax-api-key <key>
26
+ --skip-browser-install
27
+ --browser-install (re-enable browser install)
28
+ -h|--help
10
29
  EOF
11
30
  }
12
31
 
13
- [[ $# -eq 1 ]] || { usage; exit 1; }
32
+ csv_values() {
33
+ printf '%s' "$1" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; /^$/d'
34
+ }
35
+
36
+ validate_no_newline() {
37
+ local name="$1"
38
+ local value="$2"
39
+ [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$name must not contain newlines"
40
+ }
41
+
42
+ require_option_value() {
43
+ local opt="$1"
44
+ local value="${2-}"
45
+ [[ -n "$value" && "$value" != --* ]] || die "Missing value for $opt"
46
+ }
47
+
48
+ set_kv() {
49
+ local file="$1"
50
+ local key="$2"
51
+ local value="$3"
52
+ local tmp
53
+ validate_no_newline "key" "$key"
54
+ validate_no_newline "$key" "$value"
55
+ tmp="$(mktemp)"
56
+ awk -F= -v key="$key" '$1 != key { print }' "$file" > "$tmp"
57
+ printf "%s=%s\n" "$key" "$value" >> "$tmp"
58
+ install -m 600 "$tmp" "$file"
59
+ rm -f "$tmp"
60
+ }
61
+
62
+ saved_value() {
63
+ local file="$1"
64
+ local key="$2"
65
+ grep "^$key=" "$file" | tail -n 1 | cut -d= -f2- || true
66
+ }
67
+
68
+ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || "${1:-}" == "help" ]]; then
69
+ usage
70
+ exit 0
71
+ fi
72
+
73
+ [[ $# -ge 1 ]] || { usage; exit 1; }
14
74
 
15
75
  INSTANCE="$1"
76
+ shift
77
+
78
+ declare -A OVERRIDES=()
79
+
80
+ while [[ $# -gt 0 ]]; do
81
+ case "$1" in
82
+ --telegram-token) require_option_value "$1" "${2-}"; OVERRIDES[TELEGRAM_TOKEN]="$2"; shift 2 ;;
83
+ --no-telegram) OVERRIDES[TELEGRAM_TOKEN]=""; shift ;;
84
+ --telegram-users) require_option_value "$1" "${2-}"; OVERRIDES[TELEGRAM_USERS]="$2"; shift 2 ;;
85
+ --model) require_option_value "$1" "${2-}"; OVERRIDES[MODEL]="$2"; shift 2 ;;
86
+ --skills) require_option_value "$1" "${2-}"; OVERRIDES[SKILLS]="$2"; shift 2 ;;
87
+ --openclaw-image) require_option_value "$1" "${2-}"; OVERRIDES[OPENCLAW_IMAGE]="$2"; shift 2 ;;
88
+ --anthropic-api-key) require_option_value "$1" "${2-}"; OVERRIDES[ANTHROPIC_API_KEY]="$2"; shift 2 ;;
89
+ --openai-api-key) require_option_value "$1" "${2-}"; OVERRIDES[OPENAI_API_KEY]="$2"; shift 2 ;;
90
+ --minimax-api-key) require_option_value "$1" "${2-}"; OVERRIDES[MINIMAX_API_KEY]="$2"; shift 2 ;;
91
+ --skip-browser-install) OVERRIDES[SKIP_BROWSER_INSTALL]="true"; shift ;;
92
+ --browser-install) OVERRIDES[SKIP_BROWSER_INSTALL]="false"; shift ;;
93
+ *)
94
+ die "Unknown option: $1"
95
+ ;;
96
+ esac
97
+ done
98
+
16
99
  validate_instance_id "$INSTANCE"
17
100
  require_root
18
101
 
102
+ # load_instance_env resets the key variables from saved state, so remember
103
+ # what the caller passed in the environment before it runs.
104
+ ENV_ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
105
+ ENV_OPENAI_API_KEY="${OPENAI_API_KEY:-}"
106
+ ENV_MINIMAX_API_KEY="${MINIMAX_API_KEY:-}"
107
+
19
108
  load_instance_env "$INSTANCE"
20
109
 
110
+ ENV_FILE="$(instance_env "$INSTANCE")"
21
111
  PROVISION_VARS="$(instance_dir "$INSTANCE")/provision.vars"
22
112
  [[ -f "$PROVISION_VARS" ]] || die "Missing provision vars: $PROVISION_VARS"
23
113
  [[ -f "$REPO_ROOT/scripts/provision-guest.sh" ]] || die "Missing: $REPO_ROOT/scripts/provision-guest.sh"
24
114
 
25
- if ! wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 180; then
115
+ if [[ -n "${OVERRIDES[TELEGRAM_USERS]:-}" ]]; then
116
+ [[ -n "$(csv_values "${OVERRIDES[TELEGRAM_USERS]}")" ]] || die "--telegram-users must include at least one allowed Telegram user ID"
117
+ fi
118
+
119
+ effective() {
120
+ local key="$1"
121
+ local fallback="$2"
122
+ if [[ -v "OVERRIDES[$key]" ]]; then
123
+ printf '%s' "${OVERRIDES[$key]}"
124
+ else
125
+ printf '%s' "$fallback"
126
+ fi
127
+ }
128
+
129
+ # Keys passed via the environment fill in for empty saved values, matching
130
+ # the behavior the provider-key error message advertises.
131
+ if [[ -z "$(effective ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}")" && -n "$ENV_ANTHROPIC_API_KEY" ]]; then
132
+ OVERRIDES[ANTHROPIC_API_KEY]="$ENV_ANTHROPIC_API_KEY"
133
+ fi
134
+ if [[ -z "$(effective OPENAI_API_KEY "${OPENAI_API_KEY:-}")" && -n "$ENV_OPENAI_API_KEY" ]]; then
135
+ OVERRIDES[OPENAI_API_KEY]="$ENV_OPENAI_API_KEY"
136
+ fi
137
+ if [[ -z "$(effective MINIMAX_API_KEY "${MINIMAX_API_KEY:-}")" && -n "$ENV_MINIMAX_API_KEY" ]]; then
138
+ OVERRIDES[MINIMAX_API_KEY]="$ENV_MINIMAX_API_KEY"
139
+ fi
140
+
141
+ # Validate the post-override view BEFORE persisting anything, so a rejected
142
+ # run leaves the saved config exactly as it was.
143
+ EFFECTIVE_TELEGRAM_TOKEN="$(effective TELEGRAM_TOKEN "$(saved_value "$PROVISION_VARS" TELEGRAM_TOKEN)")"
144
+ EFFECTIVE_TELEGRAM_USERS="$(effective TELEGRAM_USERS "${TELEGRAM_USERS:-}")"
145
+ if [[ -n "$EFFECTIVE_TELEGRAM_TOKEN" ]]; then
146
+ if [[ -z "$(csv_values "$EFFECTIVE_TELEGRAM_USERS")" ]]; then
147
+ die "Telegram is enabled but TELEGRAM_USERS is empty; rerun with: sudo $CMD_NAME $INSTANCE --telegram-users <csv>"
148
+ fi
149
+ fi
150
+
151
+ EFFECTIVE_MODEL="$(effective MODEL "${MODEL:-}")"
152
+ ANTHROPIC_API_KEY="$(effective ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}")"
153
+ OPENAI_API_KEY="$(effective OPENAI_API_KEY "${OPENAI_API_KEY:-}")"
154
+ MINIMAX_API_KEY="$(effective MINIMAX_API_KEY "${MINIMAX_API_KEY:-}")"
155
+ require_model_provider_key "$EFFECTIVE_MODEL"
156
+
157
+ # TELEGRAM_TOKEN lives only in provision.vars; every other key is mirrored
158
+ # into the .env state file that load_instance_env reads.
159
+ for key in "${!OVERRIDES[@]}"; do
160
+ set_kv "$PROVISION_VARS" "$key" "${OVERRIDES[$key]}"
161
+ if [[ "$key" != "TELEGRAM_TOKEN" ]]; then
162
+ set_kv "$ENV_FILE" "$key" "${OVERRIDES[$key]}"
163
+ fi
164
+ done
165
+
166
+ if ! wait_for_ssh "$VM_IP" "$SSH_KEY_PATH" 180 "$INSTANCE"; then
26
167
  die "VM SSH unreachable. Start it first: fireclaw start $INSTANCE"
27
168
  fi
28
169
 
29
- scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
170
+ scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$INSTANCE")" \
30
171
  "$REPO_ROOT/scripts/provision-guest.sh" "ubuntu@$VM_IP:/tmp/provision-guest.sh"
31
- scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
172
+ scp -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$INSTANCE")" \
32
173
  "$PROVISION_VARS" "ubuntu@$VM_IP:/tmp/provision.vars"
33
174
 
34
- ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
175
+ ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$(ssh_known_hosts_file "$INSTANCE")" \
35
176
  "ubuntu@$VM_IP" "sudo bash /tmp/provision-guest.sh /tmp/provision.vars"
36
177
 
37
- systemctl enable --now "$(proxy_service "$INSTANCE")" >/dev/null 2>&1 || true
178
+ systemctl enable --now "$(proxy_service "$INSTANCE")"
38
179
 
39
- health_ok="false"
40
- for _ in {1..30}; do
41
- if curl -fsS "http://127.0.0.1:$HOST_PORT/health" >/dev/null 2>&1; then
42
- health_ok="true"
43
- break
44
- fi
45
- sleep 2
46
- done
180
+ if ! wait_for_instance_health "$INSTANCE" "$VM_IP" "$HOST_PORT" "$SSH_KEY_PATH" 30; then
181
+ die "Health checks did not pass for $INSTANCE after provisioning"
182
+ fi
47
183
 
48
184
  echo "✓ VM provisioning complete"
49
185
  echo " Instance: $INSTANCE"
50
186
  echo " VM IP: $VM_IP"
51
187
  echo " Port: $HOST_PORT"
52
- if [[ "$health_ok" != "true" ]]; then
53
- echo " Health: pending (service may still be warming up)"
54
- fi
188
+ echo " Health: up (guest + proxy)"