diff --git a/README.md b/README.md
index 46d4266..10e8e19 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,19 @@ cd /Volumes/Data/openclaw-setups/openclaw-setup-max
cat README.md
```
+Existing install upgrade (in-place):
+
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+
+This update workflow also reapplies schedule/budget guardrails and self-heals gateway LaunchAgent issues for external-drive state dirs.
+
Primary docs:
- `openclaw-setup-max/README.md`
- `openclaw-setup-max/PRD.md`
+- `openclaw-setup-max/docs/operations/UPGRADING.md`
### Copilot setup
diff --git a/openclaw-setup-max/PRD.md b/openclaw-setup-max/PRD.md
index 55967ae..e18a063 100644
--- a/openclaw-setup-max/PRD.md
+++ b/openclaw-setup-max/PRD.md
@@ -36,7 +36,7 @@ Operators need an easy way to:
- As an operator, I can run one command to switch to free local models immediately.
- As an operator, I can switch back to paid model for deep tasks.
-- As an operator, I can set free mode from 9pm-7am automatically.
+- As an operator, I can set free mode from 10pm-7am automatically.
- As an operator, I get warned if expensive model remains active too long.
- As a maintainer, I can copy this folder to another Mac and follow docs to bootstrap.
@@ -104,6 +104,21 @@ Implementation:
Implementation:
- `README.md`
+### FR-7 Existing-Install Upgrade Workflow
+- Must provide one command for in-place upgrades on existing machines.
+- Must back up OpenClaw config/session files before migration/update.
+- Must run config migration (`openclaw doctor --fix`) before attempting update.
+- Must update CLI using built-in updater with timeout + npm fallback.
+- Must emit timestamped update logs for operator traceability.
+- Must stop gateway before update and bring gateway back after update.
+- Must ensure gateway LaunchAgent is loaded/running after update.
+- If `~/.openclaw` is symlinked to `/Volumes/...`, must ensure gateway LaunchAgent logs use `/tmp/openclaw-gateway.launchd*.log` to avoid launchd `EX_CONFIG`.
+- Must reinstall and kickstart budget/schedule guard LaunchAgents after update.
+
+Implementation:
+- `scripts/update_openclaw.sh`
+- `setup/setup_openclaw_ollama.sh`
+
## 7) Non-Functional Requirements
- NFR-1 Reliability: scripts should be safe to run repeatedly.
@@ -154,11 +169,14 @@ Outputs:
## 10) Acceptance Criteria
- AC-1: Running `bash ./scripts/model_profile_switch.sh free` changes default to `openrouter/qwen/qwen3-coder:free`.
-- AC-2: Running `bash ./scripts/model_profile_switch.sh paid` restores default to `openrouter/qwen/qwen3-coder:free`.
-- AC-3: With schedule enabled (`21`/`7`), profile changes correctly by local time window.
+- AC-2: Running `bash ./scripts/model_profile_switch.sh paid` restores default to `openrouter/moonshotai/kimi-k2.5`.
+- AC-3: With schedule enabled (`22`/`7`), profile changes correctly by local time window.
- AC-4: If operator manually drifts model during scheduled window, schedule guard re-aligns on next run.
- AC-5: Budget guard warns and reverts when high model remains active beyond thresholds.
- AC-6: New operator can follow README from zero knowledge and complete setup without editing scripts.
+- AC-7: Running `bash ./scripts/update_openclaw.sh` upgrades an existing install and prints a healthy gateway/model status at completion.
+- AC-8: After update, schedule + budget LaunchAgents are loaded and can run immediately when kickstarted.
+- AC-9: Upgrade output lines include timestamps to support restart/audit troubleshooting.
## 11) Risks and Mitigations
@@ -170,6 +188,8 @@ Outputs:
- Mitigation: prerequisite checks in README.
- Risk: user confusion between ChatGPT subscription and API billing.
- Mitigation: clear FAQ note in README.
+- Risk: gateway LaunchAgent exits with `EX_CONFIG` when logs point into external-volume symlink paths.
+ - Mitigation: force gateway LaunchAgent stdout/stderr to `/tmp/openclaw-gateway.launchd*.log` when `~/.openclaw` resolves to `/Volumes/...`.
## 12) Rollout Plan
diff --git a/openclaw-setup-max/README.md b/openclaw-setup-max/README.md
index 80daed0..e13be5f 100644
--- a/openclaw-setup-max/README.md
+++ b/openclaw-setup-max/README.md
@@ -1,13 +1,13 @@
# OpenClaw Setup Max (Paid + Free Model Switching)
This workspace runs OpenClaw with:
-- A paid high-quality model profile (`openrouter/qwen/qwen3-coder:free`)
+- A paid high-quality model profile (`openrouter/moonshotai/kimi-k2.5`)
- A free OpenRouter profile
- One-command live switching
-- Optional automatic day/night switching (example: free from 9pm-7am)
+- Optional automatic day/night switching (example: free from 10pm-7am)
- Optional budget guard that warns and auto-reverts from expensive models
-This guide is written for first-time users.
+This guide covers both first-time setup and in-place upgrades.
## 0) Workspace Layout
@@ -27,7 +27,7 @@ Everything else is grouped by function:
## 1) What You Get
- `paid` profile:
- - Primary: `openrouter/qwen/qwen3-coder:free`
+ - Primary: `openrouter/moonshotai/kimi-k2.5`
- Fallbacks: `openrouter/qwen/qwen3-coder-next`, `openrouter/qwen/qwen3-14b`
- `free` profile:
- Primary: `openrouter/qwen/qwen3-coder:free`
@@ -49,6 +49,9 @@ Profiles are defined in `config/model-profiles.config.json`.
- `scripts/install_model_schedule_guard_launchd.sh`: installs schedule LaunchAgent
- `scripts/install_model_budget_guard_launchd.sh`: installs budget LaunchAgent
- `scripts/install_local_model_guardrails.sh`: installs both LaunchAgents
+- `scripts/update_openclaw.sh`: in-place OpenClaw upgrade + migration workflow
+- `docs/operations/UPGRADING.md`: upgrade runbook for existing installs
+- `docs/operations/troubleshooting.md`: known issue recovery commands
## 3) Prerequisites
@@ -70,6 +73,7 @@ Script behavior:
- `openclaw`
- `jq`
- `python3`
+- If `~/.openclaw` is symlinked to an external volume (`/Volumes/...`), setup patches gateway LaunchAgent logs to `/tmp/openclaw-gateway.launchd*.log` to avoid launchd `EX_CONFIG` failures.
- It will prompt before:
- Installing Ollama (if missing)
- Pulling local Ollama models (large downloads)
@@ -84,6 +88,45 @@ AUTO_YES=true bash ./setup/setup_openclaw_ollama.sh
PULL_LOCAL_MODELS=false bash ./setup/setup_openclaw_ollama.sh
```
+## 3A) Update Existing Install (In-place)
+
+Use this for upgrades on an already-working machine:
+
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+
+What it handles:
+- Backup of `~/.openclaw/openclaw.json` + main `sessions.json`
+- Config migration (`openclaw doctor --fix --non-interactive`)
+- CLI update with timeout + npm fallback
+- Timestamped step logging in terminal output
+- Explicit gateway stop/start
+- Gateway LaunchAgent self-heal (install if missing)
+- External-drive launchd log-path patch (`/tmp/openclaw-gateway.launchd*.log`)
+- Reinstall + immediate kickstart of budget/schedule guards
+- Final model/profile/gateway verification output
+
+Useful options:
+
+```bash
+# Switch update channel
+bash ./scripts/update_openclaw.sh --channel beta
+
+# Pin one update run to a version/tag
+bash ./scripts/update_openclaw.sh --tag 2026.2.24
+
+# Increase built-in updater timeout (seconds)
+UPDATE_MAX_SECONDS=900 bash ./scripts/update_openclaw.sh
+
+# Save a timestamped run log file
+bash ./scripts/update_openclaw.sh 2>&1 | tee "/tmp/openclaw-update-$(date +%Y%m%d-%H%M%S).log"
+```
+
+Detailed runbook:
+- `docs/operations/UPGRADING.md`
+
## 4) Quick Start (Most Common)
Use env-driven sync + hygiene workflow (recommended after any model changes):
@@ -145,7 +188,7 @@ If you want config-only change (no live session message), use:
bash ./scripts/model_profile_switch.sh free --no-live
```
-## 6) Enable Schedule (Example: Free 9pm-7am)
+## 6) Enable Schedule (Example: Free 10pm-7am)
1. Edit config:
@@ -162,7 +205,7 @@ open config/model-schedule.config.json
"dayProfile": "paid",
"nightProfile": "free",
"dayStartHour": 7,
- "nightStartHour": 21
+ "nightStartHour": 22
}
```
@@ -224,7 +267,7 @@ bash ./scripts/install_local_model_guardrails.sh
Budget behavior from `config/model-budget-guard.config.json`:
- Warn after 2 minutes on high-cost model
-- Auto-revert to `ollama/qwen3:14b` after 45 minutes
+- Auto-revert to `openrouter/qwen/qwen3-coder:free` after 45 minutes
- Prevent repeated spam with minimum warning interval
## 9) View Current LaunchAgents
@@ -239,6 +282,7 @@ Logs:
```bash
tail -f /tmp/openclaw-model-schedule-guard.log /tmp/openclaw-model-schedule-guard.err.log
tail -f /tmp/openclaw-model-budget-guard.log /tmp/openclaw-model-budget-guard.err.log
+tail -f /tmp/openclaw-gateway.launchd.log /tmp/openclaw-gateway.launchd.err.log
```
## 10) Manual Switching Tips
diff --git a/openclaw-setup-max/docs/openclaw-setup-guide.md b/openclaw-setup-max/docs/openclaw-setup-guide.md
index 9111e65..cb43fb7 100644
--- a/openclaw-setup-max/docs/openclaw-setup-guide.md
+++ b/openclaw-setup-max/docs/openclaw-setup-guide.md
@@ -49,6 +49,13 @@ openclaw gateway stop
openclaw gateway restart
```
+**Repo-specific in-place upgrade (existing install):**
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+This script stops gateway, upgrades OpenClaw, reapplies schedule/budget guardrails, and restarts gateway.
+
### Directory Structure
**Workspace Location:**
diff --git a/openclaw-setup-max/docs/operations/MODEL_SWITCHING.md b/openclaw-setup-max/docs/operations/MODEL_SWITCHING.md
index 7ee1194..0892ecf 100644
--- a/openclaw-setup-max/docs/operations/MODEL_SWITCHING.md
+++ b/openclaw-setup-max/docs/operations/MODEL_SWITCHING.md
@@ -2,7 +2,7 @@
This setup adds two model profiles:
-- `paid` -> `openrouter/qwen/qwen3-coder:free` with OpenRouter fallbacks
+- `paid` -> `openrouter/moonshotai/kimi-k2.5` with OpenRouter fallbacks
- `free` -> `openrouter/qwen/qwen3-coder:free` with OpenRouter fallback
## Recommended workflow (prevents legacy drift)
@@ -40,7 +40,7 @@ Notes:
bash ./scripts/model_profile_switch.sh status
```
-## Schedule auto-switching (example: free from 9pm-7am)
+## Schedule auto-switching (example: free from 10pm-7am)
1. Edit config:
@@ -57,7 +57,7 @@ Ensure:
"dayProfile": "paid",
"nightProfile": "free",
"dayStartHour": 7,
- "nightStartHour": 21
+ "nightStartHour": 22
}
```
@@ -100,3 +100,14 @@ bash ./scripts/install_local_model_guardrails.sh
launchctl bootout gui/$(id -u)/ai.openclaw.local.model-schedule-guard 2>/dev/null || true
launchctl bootout gui/$(id -u)/ai.openclaw.local.model-budget-guard 2>/dev/null || true
```
+
+## After OpenClaw upgrades
+
+Use the update workflow so guards/profile state are reapplied automatically:
+
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+
+The update script reinstalls both guard LaunchAgents and kickstarts them immediately, so budget/profile logic is re-evaluated in the same run.
diff --git a/openclaw-setup-max/docs/operations/UPGRADING.md b/openclaw-setup-max/docs/operations/UPGRADING.md
new file mode 100644
index 0000000..81d6ea1
--- /dev/null
+++ b/openclaw-setup-max/docs/operations/UPGRADING.md
@@ -0,0 +1,66 @@
+# OpenClaw Upgrade Runbook (Existing Install)
+
+Use this when OpenClaw is already installed and you want to upgrade in place.
+
+## Recommended command
+
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+
+## What `update_openclaw.sh` does
+
+- Backs up:
+ - `~/.openclaw/openclaw.json`
+ - `~/.openclaw/agents/main/sessions/sessions.json`
+- Stops local model guard LaunchAgents and gateway before update
+- Runs `openclaw doctor --fix --non-interactive`
+- Runs `openclaw update` with timeout protection
+- Falls back to `npm install -g openclaw@...` if updater hangs/fails
+- Prints timestamped step logs for restart/update traceability
+- Starts gateway and installs service if missing
+- If `~/.openclaw` is symlinked to `/Volumes/...`, patches gateway LaunchAgent logs to:
+ - `/tmp/openclaw-gateway.launchd.log`
+ - `/tmp/openclaw-gateway.launchd.err.log`
+- Reinstalls schedule + budget guard LaunchAgents
+- Kickstarts both guards immediately so profile/budget logic is applied now
+- Prints gateway + model/guard status summary
+
+## Useful options
+
+```bash
+# switch update channel
+bash ./scripts/update_openclaw.sh --channel beta
+
+# one-off version/tag
+bash ./scripts/update_openclaw.sh --tag 2026.2.24
+
+# increase built-in update timeout (seconds)
+UPDATE_MAX_SECONDS=900 bash ./scripts/update_openclaw.sh
+
+# capture a timestamped run log file
+bash ./scripts/update_openclaw.sh 2>&1 | tee "/tmp/openclaw-update-$(date +%Y%m%d-%H%M%S).log"
+```
+
+## Verify after upgrade
+
+```bash
+openclaw --version
+openclaw gateway status
+openclaw gateway health --json
+bash ./scripts/model_profile_switch.sh status
+launchctl print gui/$(id -u)/ai.openclaw.local.model-schedule-guard | rg 'last exit code|state'
+launchctl print gui/$(id -u)/ai.openclaw.local.model-budget-guard | rg 'last exit code|state'
+ls -lT /tmp/openclaw-gateway.launchd.log /tmp/openclaw-gateway.launchd.err.log
+```
+
+## If gateway is loaded but not running
+
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+
+If still failing, see:
+- `docs/operations/troubleshooting.md`
diff --git a/openclaw-setup-max/docs/operations/troubleshooting.md b/openclaw-setup-max/docs/operations/troubleshooting.md
index dd97317..cf26717 100644
--- a/openclaw-setup-max/docs/operations/troubleshooting.md
+++ b/openclaw-setup-max/docs/operations/troubleshooting.md
@@ -106,30 +106,30 @@ openclaw gateway run --force
- Launchd reports exit code `78: EX_CONFIG`.
### Fix that worked
-- Ensure LaunchAgent command includes `gateway run`.
-- Keep logs in `/tmp` (avoids potential external-volume path issues).
+- Preferred: run the upgrade workflow, which self-heals LaunchAgent + guards.
+- `update_openclaw.sh` prints timestamped logs so restart timing is visible during the run.
+
+```bash
+cd /Volumes/Data/openclaw-setups/openclaw-setup-max
+bash ./scripts/update_openclaw.sh
+```
+
+- Manual fallback:
+ 1. Keep LaunchAgent logs in `/tmp` (avoids external-volume symlink path issues).
+ 2. Reinstall and restart the gateway service.
File:
- `~/Library/LaunchAgents/ai.openclaw.gateway.plist`
-Expected ProgramArguments section:
-```xml
-
- /opt/homebrew/bin/node
- /opt/homebrew/lib/node_modules/openclaw/dist/index.js
- gateway
- run
- --port
- 18789
-
+Set log paths:
+```bash
+/usr/libexec/PlistBuddy -c "Set :StandardOutPath /tmp/openclaw-gateway.launchd.log" ~/Library/LaunchAgents/ai.openclaw.gateway.plist
+/usr/libexec/PlistBuddy -c "Set :StandardErrorPath /tmp/openclaw-gateway.launchd.err.log" ~/Library/LaunchAgents/ai.openclaw.gateway.plist
```
-Expected log paths:
-- `/tmp/openclaw-gateway.launchd.log`
-- `/tmp/openclaw-gateway.launchd.err.log`
-
Reload service:
```bash
+openclaw gateway install --force
launchctl bootout gui/$(id -u)/ai.openclaw.gateway 2>/dev/null || true
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.gateway.plist
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
@@ -137,8 +137,10 @@ launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
Verify:
```bash
-openclaw status --deep
+launchctl print gui/$(id -u)/ai.openclaw.gateway | rg 'state =|last exit code|runs ='
+openclaw gateway status
openclaw gateway health --json
+ls -lT /tmp/openclaw-gateway.launchd.log /tmp/openclaw-gateway.launchd.err.log
```
## 6) docs/context/BOOT.md hook setup
diff --git a/openclaw-setup-max/scripts/update_openclaw.sh b/openclaw-setup-max/scripts/update_openclaw.sh
new file mode 100755
index 0000000..7f7306d
--- /dev/null
+++ b/openclaw-setup-max/scripts/update_openclaw.sh
@@ -0,0 +1,365 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ bash ./scripts/update_openclaw.sh [options]
+
+Options:
+ --channel Update channel (default: stable)
+ --tag One-off npm tag/version (example: beta, 2026.2.24)
+ --skip-guard-stop Do not stop local model guard LaunchAgents before upgrade
+ --skip-guard-reinstall Do not reinstall local model guard LaunchAgents after upgrade
+ -h, --help Show this help
+
+Examples:
+ bash ./scripts/update_openclaw.sh
+ bash ./scripts/update_openclaw.sh --channel beta
+ bash ./scripts/update_openclaw.sh --tag 2026.2.24
+USAGE
+}
+
+now_ts() { date '+%Y-%m-%d %H:%M:%S%z'; }
+log() { printf '[%s] [update-openclaw] %s\n' "$(now_ts)" "$*"; }
+warn() { printf '[%s] [update-openclaw] WARN: %s\n' "$(now_ts)" "$*" >&2; }
+err() { printf '[%s] [update-openclaw] ERROR: %s\n' "$(now_ts)" "$*" >&2; }
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ err "Required command missing: $1"
+ exit 1
+ fi
+}
+
+run_with_timeout() {
+ local timeout_s="$1"
+ shift
+
+ local start_ts elapsed pid
+ start_ts="$(date +%s)"
+ "$@" &
+ pid=$!
+
+ while kill -0 "$pid" 2>/dev/null; do
+ elapsed="$(( $(date +%s) - start_ts ))"
+ if (( elapsed >= timeout_s )); then
+ warn "Command timed out after ${timeout_s}s: $*"
+ kill -INT "$pid" 2>/dev/null || true
+ sleep 2
+ kill -TERM "$pid" 2>/dev/null || true
+ sleep 1
+ kill -KILL "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ return 124
+ fi
+ sleep 1
+ done
+
+ wait "$pid"
+}
+
+extract_version() {
+ local raw
+ raw="$("$1" --version 2>&1 || true)"
+ printf '%s\n' "$raw" | grep -Eo '[0-9]{4}\.[0-9]+\.[0-9]+' | head -n1
+}
+
+print_guard_status() {
+ local label="$1"
+ launchctl print "gui/$(id -u)/$label" 2>/dev/null | \
+ grep -E 'state =|last exit code|runs =|path =|program =' || true
+}
+
+is_launchagent_loaded() {
+ local label="$1"
+ launchctl print "gui/$(id -u)/$label" >/dev/null 2>&1
+}
+
+is_gateway_launchagent_running() {
+ local label="$1"
+ launchctl print "gui/$(id -u)/$label" 2>/dev/null | grep -q 'state = running'
+}
+
+fix_gateway_launchagent_log_paths_for_external_state() {
+ local label="$1"
+ local plist="$HOME/Library/LaunchAgents/$label.plist"
+ local state_dir_link_target=""
+
+ if [[ ! -f "$plist" ]]; then
+ return 0
+ fi
+
+ # launchd may fail with EX_CONFIG when stdout/stderr paths point into
+ # ~/.openclaw on an external-volume symlink. Force local /tmp logs.
+ if [[ -L "$HOME/.openclaw" ]]; then
+ state_dir_link_target="$(readlink "$HOME/.openclaw" || true)"
+ fi
+ if [[ "$state_dir_link_target" != /Volumes/* ]]; then
+ return 0
+ fi
+
+ if ! command -v /usr/libexec/PlistBuddy >/dev/null 2>&1; then
+ warn "PlistBuddy not found; skipping LaunchAgent log-path patch."
+ return 0
+ fi
+
+ local desired_out="/tmp/openclaw-gateway.launchd.log"
+ local desired_err="/tmp/openclaw-gateway.launchd.err.log"
+ local current_out=""
+ local current_err=""
+
+ current_out="$(/usr/libexec/PlistBuddy -c 'Print :StandardOutPath' "$plist" 2>/dev/null || true)"
+ current_err="$(/usr/libexec/PlistBuddy -c 'Print :StandardErrorPath' "$plist" 2>/dev/null || true)"
+
+ if [[ "$current_out" == "$desired_out" && "$current_err" == "$desired_err" ]]; then
+ return 0
+ fi
+
+ log "Patching gateway LaunchAgent logs to local /tmp paths (external state dir detected)"
+ /usr/libexec/PlistBuddy -c "Set :StandardOutPath $desired_out" "$plist"
+ /usr/libexec/PlistBuddy -c "Set :StandardErrorPath $desired_err" "$plist"
+
+ launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true
+ launchctl bootstrap "gui/$(id -u)" "$plist"
+ launchctl kickstart -k "gui/$(id -u)/$label"
+}
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+CHANNEL="stable"
+TAG=""
+STOP_GUARDS="true"
+REINSTALL_GUARDS="true"
+UPDATE_MAX_SECONDS="${UPDATE_MAX_SECONDS:-600}"
+
+while (( "$#" )); do
+ case "$1" in
+ --channel)
+ shift
+ if [[ $# -eq 0 ]]; then
+ err "--channel requires a value"
+ usage
+ exit 1
+ fi
+ CHANNEL="$1"
+ ;;
+ --tag)
+ shift
+ if [[ $# -eq 0 ]]; then
+ err "--tag requires a value"
+ usage
+ exit 1
+ fi
+ TAG="$1"
+ ;;
+ --skip-guard-stop)
+ STOP_GUARDS="false"
+ ;;
+ --skip-guard-reinstall)
+ REINSTALL_GUARDS="false"
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ err "Unknown argument: $1"
+ usage
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+case "$CHANNEL" in
+ stable|beta|dev) ;;
+ *)
+ err "Invalid --channel '$CHANNEL'. Expected stable, beta, or dev."
+ exit 1
+ ;;
+esac
+
+if ! [[ "$UPDATE_MAX_SECONDS" =~ ^[0-9]+$ ]] || [[ "$UPDATE_MAX_SECONDS" -lt 1 ]]; then
+ err "UPDATE_MAX_SECONDS must be a positive integer."
+ exit 1
+fi
+
+require_cmd openclaw
+require_cmd launchctl
+require_cmd cp
+require_cmd mkdir
+require_cmd date
+
+if ! command -v npm >/dev/null 2>&1; then
+ warn "npm not found; fallback npm upgrade path will be unavailable."
+fi
+
+ts="$(date +%Y%m%d-%H%M%S)"
+backup_dir="$HOME/.openclaw/backups/update-$ts"
+mkdir -p "$backup_dir"
+
+config_file="$HOME/.openclaw/openclaw.json"
+sessions_file="$HOME/.openclaw/agents/main/sessions/sessions.json"
+
+log "Backing up OpenClaw files to: $backup_dir"
+if [[ -f "$config_file" ]]; then
+ cp "$config_file" "$backup_dir/openclaw.json"
+else
+ warn "Config not found: $config_file"
+fi
+if [[ -f "$sessions_file" ]]; then
+ cp "$sessions_file" "$backup_dir/sessions.json"
+else
+ warn "Sessions file not found: $sessions_file"
+fi
+
+if [[ "$STOP_GUARDS" == "true" ]]; then
+ log "Stopping local model guard LaunchAgents to avoid config races during update"
+ launchctl bootout "gui/$(id -u)/ai.openclaw.local.model-budget-guard" 2>/dev/null || true
+ launchctl bootout "gui/$(id -u)/ai.openclaw.local.model-schedule-guard" 2>/dev/null || true
+fi
+
+log "Stopping OpenClaw gateway before update"
+if ! openclaw gateway stop; then
+ warn "Gateway stop returned non-zero (it may already be stopped). Continuing."
+fi
+
+current_version="$(extract_version openclaw || true)"
+if [[ -n "$current_version" ]]; then
+ log "Current OpenClaw version: $current_version"
+else
+ warn "Could not parse current OpenClaw version from 'openclaw --version'."
+fi
+
+if command -v npm >/dev/null 2>&1; then
+ latest_npm_version="$(npm view openclaw version 2>/dev/null || true)"
+ if [[ -n "$latest_npm_version" ]]; then
+ log "Latest npm OpenClaw version: $latest_npm_version"
+ fi
+fi
+
+log "Running doctor fix for schema/config migration"
+if ! openclaw doctor --fix --non-interactive; then
+ warn "Doctor fix returned non-zero. Continuing with upgrade."
+fi
+
+update_cmd=(openclaw update --yes --no-restart --channel "$CHANNEL")
+if [[ -n "$TAG" ]]; then
+ update_cmd+=(--tag "$TAG")
+fi
+
+log "Running OpenClaw updater: ${update_cmd[*]}"
+if ! run_with_timeout "$UPDATE_MAX_SECONDS" "${update_cmd[@]}"; then
+ warn "Built-in updater failed."
+ if ! command -v npm >/dev/null 2>&1; then
+ err "npm not available for fallback update."
+ exit 1
+ fi
+
+ pkg="openclaw@latest"
+ if [[ -n "$TAG" ]]; then
+ pkg="openclaw@$TAG"
+ fi
+
+ log "Falling back to npm global install: npm install -g $pkg"
+ npm install -g "$pkg"
+fi
+
+new_version="$(extract_version openclaw || true)"
+if [[ -n "$new_version" ]]; then
+ log "Updated OpenClaw version: $new_version"
+else
+ warn "Could not parse updated version from 'openclaw --version'."
+fi
+
+log "Starting OpenClaw gateway after update"
+if ! openclaw gateway start; then
+ err "Gateway failed to start after update."
+ exit 1
+fi
+
+# Some OpenClaw versions return success from "gateway start" even when the
+# LaunchAgent is not installed/loaded; enforce a service-managed gateway.
+gateway_label="ai.openclaw.gateway"
+if ! launchctl print "gui/$(id -u)/$gateway_label" >/dev/null 2>&1; then
+ warn "Gateway LaunchAgent is not loaded after start; installing and retrying."
+ if ! openclaw gateway install --force; then
+ err "Failed to install gateway service."
+ exit 1
+ fi
+ if ! openclaw gateway start; then
+ err "Gateway failed to start after installing service."
+ exit 1
+ fi
+ if ! launchctl print "gui/$(id -u)/$gateway_label" >/dev/null 2>&1; then
+ err "Gateway LaunchAgent is still not loaded after install/start."
+ exit 1
+ fi
+fi
+
+fix_gateway_launchagent_log_paths_for_external_state "$gateway_label"
+
+if ! is_gateway_launchagent_running "$gateway_label"; then
+ warn "Gateway LaunchAgent is loaded but not running; attempting kickstart."
+ launchctl kickstart -k "gui/$(id -u)/$gateway_label" || true
+ sleep 2
+fi
+if ! is_gateway_launchagent_running "$gateway_label"; then
+ err "Gateway LaunchAgent is still not running."
+ launchctl print "gui/$(id -u)/$gateway_label" | grep -E 'state =|last exit code|runs =|path =|program =' || true
+ exit 1
+fi
+
+log "Running post-upgrade health checks"
+openclaw doctor --non-interactive || true
+openclaw gateway status || true
+openclaw models status || true
+
+if [[ "$REINSTALL_GUARDS" == "true" ]]; then
+ installer="$SCRIPT_DIR/install_local_model_guardrails.sh"
+ if [[ -x "$installer" ]]; then
+ log "Reinstalling local model guard LaunchAgents"
+ bash "$installer"
+
+ # Run both periodic guards immediately after reinstall so profile/budget
+ # state is refreshed in this update run (not only on the next interval).
+ launchctl kickstart -k "gui/$(id -u)/ai.openclaw.local.model-schedule-guard" 2>/dev/null || true
+ launchctl kickstart -k "gui/$(id -u)/ai.openclaw.local.model-budget-guard" 2>/dev/null || true
+ sleep 2
+
+ if ! is_launchagent_loaded "ai.openclaw.local.model-schedule-guard"; then
+ err "Schedule guard LaunchAgent is not loaded after reinstall."
+ exit 1
+ fi
+ if ! is_launchagent_loaded "ai.openclaw.local.model-budget-guard"; then
+ err "Budget guard LaunchAgent is not loaded after reinstall."
+ exit 1
+ fi
+ else
+ warn "Guard installer missing or not executable: $installer"
+ fi
+fi
+
+log "Local guard status summary"
+print_guard_status "ai.openclaw.local.model-budget-guard"
+print_guard_status "ai.openclaw.local.model-schedule-guard"
+
+if [[ -x "$SCRIPT_DIR/model_profile_switch.sh" ]]; then
+ log "Profile status"
+ bash "$SCRIPT_DIR/model_profile_switch.sh" status || true
+fi
+
+cat </dev/null | sed 's/^v//' | cut -d. -f1
}
+fix_gateway_launchagent_logs_for_external_state() {
+ local plist="$HOME/Library/LaunchAgents/ai.openclaw.gateway.plist"
+ local desired_out="/tmp/openclaw-gateway.launchd.log"
+ local desired_err="/tmp/openclaw-gateway.launchd.err.log"
+
+ if [[ ! -L "$HOME/.openclaw" ]]; then
+ return 0
+ fi
+ local state_dir_link_target
+ state_dir_link_target="$(readlink "$HOME/.openclaw" || true)"
+ if [[ "$state_dir_link_target" != /Volumes/* ]]; then
+ return 0
+ fi
+
+ if [[ ! -f "$plist" ]]; then
+ return 0
+ fi
+ if ! command -v /usr/libexec/PlistBuddy >/dev/null 2>&1; then
+ log_warn "PlistBuddy not found; skipping gateway LaunchAgent log-path patch."
+ return 0
+ fi
+
+ local current_out current_err
+ current_out="$(/usr/libexec/PlistBuddy -c 'Print :StandardOutPath' "$plist" 2>/dev/null || true)"
+ current_err="$(/usr/libexec/PlistBuddy -c 'Print :StandardErrorPath' "$plist" 2>/dev/null || true)"
+
+ if [[ "$current_out" == "$desired_out" && "$current_err" == "$desired_err" ]]; then
+ return 0
+ fi
+
+ log_warn "Patching gateway LaunchAgent logs to /tmp (external OpenClaw state dir detected)."
+ /usr/libexec/PlistBuddy -c "Set :StandardOutPath $desired_out" "$plist"
+ /usr/libexec/PlistBuddy -c "Set :StandardErrorPath $desired_err" "$plist"
+
+ launchctl bootout "gui/$(id -u)/ai.openclaw.gateway" 2>/dev/null || true
+ launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null || true
+ launchctl kickstart -k "gui/$(id -u)/ai.openclaw.gateway" 2>/dev/null || true
+}
+
link_data_dir() {
local source="$1"
local target="$2"
local label="$3"
+ # Safety: target must be a real directory on the external volume, not a symlink.
+ if [ -L "$target" ]; then
+ local target_link
+ target_link="$(readlink "$target" || true)"
+ log_err "ERROR: ${label} target is a symlink: ${target} -> ${target_link}"
+ log_err "Expected a real directory on external storage. Remove/fix target symlink and re-run."
+ exit 1
+ fi
+
mkdir -p "$target"
if [ -L "$source" ]; then
@@ -150,6 +198,12 @@ link_data_dir() {
ln -s "$target" "$source"
log_info "${label}: symlink created ${source} -> ${target}"
+
+ # Verify direction is exactly source -> target.
+ if [ ! -L "$source" ] || [ "$(readlink "$source")" != "$target" ]; then
+ log_err "ERROR: ${label} symlink direction check failed for ${source} -> ${target}"
+ exit 1
+ fi
}
ensure_ollama_running() {
@@ -201,6 +255,7 @@ fi
mkdir -p "$OLLAMA_DATA_TARGET" "$OPENCLAW_DATA_TARGET"
link_data_dir "$HOME/.ollama" "$OLLAMA_DATA_TARGET" "Ollama"
link_data_dir "$HOME/.openclaw" "$OPENCLAW_DATA_TARGET" "OpenClaw"
+fix_gateway_launchagent_logs_for_external_state
# Step 2: Install / verify Ollama
if ! command -v ollama >/dev/null 2>&1; then