#!/usr/bin/env bash # # openclaw-post-restore.sh # # Container-startup hook invoked by openclaw-entrypoint.sh right # after the backup restore + config-write steps. Anything that # needs to run with a freshly-restored state should live here, not # in openclaw-restore.sh (which is generic data) and not in # openclaw-entrypoint.sh (which is process management). # # Why a dedicated script? # - Keeps openclaw-restore.sh generic: the restore script only # deals with data; it has no business knowing about openclaw. # - Keeps openclaw-entrypoint.sh focused on orchestration: the # entrypoint calls this hook, but the body of "what to do # after restore" lives in its own file, easy to swap or # extend without touching the entrypoint. # - Lets operators customize per-deployment (different fleets # can drop their own version in /usr/local/bin/) without # forking the rest of the repo. # # Default behavior (good baseline, easy to override): # 1. Verify openclaw CLI is reachable # 2. Verify openclaw --version works # 3. Verify `openclaw channels list --json` returns valid JSON # 4. Log results # # Customize by editing the run_check_* functions below or appending # your own. The entrypoint's run_step wrapper will pick up any new # command you add to the do_all_checks chain. # # Idempotent — safe to run on every container start, even if no # restore actually happened (the checks are read-only and cheap). # # Environment variables: # OPENCLAW_POST_RESTORE_ENABLED default: false (opt-in) # set to "true" to actually run # any of the operations below. # This script makes outbound # calls (npm update, plugin # install/uninstall, doctor # --fix) and is intentionally # NOT enabled by default — # operators must turn it on # per deployment. # OPENCLAW_POST_RESTORE_FAIL_ON_ERROR default: false # if "true", any check failure # returns exit 1, which # propagates through the # entrypoint's run_step and # aborts startup # OPENCLAW_BIN default: /usr/local/bin/openclaw # # OpenClaw 2026.6.x compatibility notes (as of 2026.6.1): # * `openclaw channels list --json` layout changed: # - 2026.6.x: top-level key per channel (e.g. ".feishu", # ".qqbot") with account-centric installed/configured/enabled. # Auth providers + model usage were moved out of channels list # (use `openclaw models auth list` / `openclaw status`). # - <2026.6: nested under ".chat." with channel-level # .installed + .accounts list. # The health probes below accept ALL three known layouts # (top-level / .chat. / .channels[] array) so the script # works on either generation. # * `openclaw plugins install` source prefixes were introduced # (clawhub:, npm:, git:, npm-pack:). Bare scoped specs like # `@openclaw/qqbot@latest` still resolve to the bundled copy. # The reinstall flow uses `@openclaw/qqbot@latest` for a # deterministic latest-version pin. # * `openclaw plugins update --all` is unchanged and still safe to # invoke on every container start. # * `openclaw doctor --fix` gained a disk space probe and now # produces more stable JSON output post-upgrade. set -uo pipefail OPENCLAW_ENTRYPOINT_TAG="openclaw-post-restore" timestamp_utc() { date -u +"%Y-%m-%dT%H:%M:%SZ" } log_info() { printf '[%s] [INFO] [PID:%s] %s: %s\n' "$(timestamp_utc)" "$$" "$OPENCLAW_ENTRYPOINT_TAG" "$*"; } log_warn() { printf '[%s] [WARN] [PID:%s] %s: %s\n' "$(timestamp_utc)" "$$" "$OPENCLAW_ENTRYPOINT_TAG" "$*" >&2; } log_error() { printf '[%s] [ERROR] [PID:%s] %s: %s\n' "$(timestamp_utc)" "$$" "$OPENCLAW_ENTRYPOINT_TAG" "$*" >&2; } is_true() { local value value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" [[ "$value" == "1" || "$value" == "true" || "$value" == "yes" || "$value" == "on" ]] } # ---- OpenClaw 2026.6.x compatibility helpers ---- # OpenClaw 2026.6.1 changed the structure of `openclaw channels list --json`: # - 2026.6.x: top-level key per channel (".feishu", ".qqbot"), with # account-centric installed/configured/enabled status. The # `installed` flag is now per-account, not per-channel. # - <2026.6: nested under ".chat." with channel-level # .installed + .accounts list. # - Some builds also surface an array form ".channels[]". # The helpers below probe the JSON once, cache it, and resolve the # schema variant transparently so the rest of the script doesn't have # to care which generation is installed. OPENCLAW_DETECTED_VERSION="" OPENCLAW_CHANNELS_JSON_CACHE="" OPENCLAW_CHANNELS_JSON_LOADED=0 # Echo the OpenClaw version string (e.g. "2026.6.1") to stdout, or # empty string if it cannot be determined. Result is cached in # OPENCLAW_DETECTED_VERSION. detect_openclaw_version() { if [[ -n "$OPENCLAW_DETECTED_VERSION" ]]; then printf '%s' "$OPENCLAW_DETECTED_VERSION" return 0 fi if ! command -v openclaw >/dev/null 2>&1; then return 1 fi local ver ver=$(openclaw --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+([._-][0-9A-Za-z.-]+)?' | head -1) if [[ -z "$ver" ]]; then return 1 fi OPENCLAW_DETECTED_VERSION="$ver" printf '%s' "$ver" } # True if the detected version is >= the given X.Y.Z threshold. Used # to gate 2026.6.x-only behavior. Returns false when version can't # be detected (be conservative). openclaw_version_at_least() { local threshold="$1" local current current=$(detect_openclaw_version 2>/dev/null) || return 1 # compare major.minor.patch numerically after stripping suffixes local cur_major cur_minor cur_patch thr_major thr_minor thr_patch cur_major=$(printf '%s' "$current" | cut -d. -f1) cur_minor=$(printf '%s' "$current" | cut -d. -f2) cur_patch=$(printf '%s' "$current" | cut -d. -f3 | grep -oE '^[0-9]+') thr_major=$(printf '%s' "$threshold" | cut -d. -f1) thr_minor=$(printf '%s' "$threshold" | cut -d. -f2) thr_patch=$(printf '%s' "$threshold" | cut -d. -f3) cur_major=${cur_major:-0}; cur_minor=${cur_minor:-0}; cur_patch=${cur_patch:-0} thr_major=${thr_major:-0}; thr_minor=${thr_minor:-0}; thr_patch=${thr_patch:-0} if (( cur_major > thr_major )); then return 0; fi if (( cur_major < thr_major )); then return 1; fi if (( cur_minor > thr_minor )); then return 0; fi if (( cur_minor < thr_minor )); then return 1; fi if (( cur_patch >= thr_patch )); then return 0; fi return 1 } # Load `openclaw channels list --json` once and cache it. On # 2026.6.x the default view only shows configured accounts; pass # --all=1 to also include bundled-but-unconfigured channels (needed # for accurate "is the plugin on disk?" health checks). fetch_channels_json() { local with_all="${1:-}" if [[ -n "$with_all" && -n "$OPENCLAW_CHANNELS_JSON_CACHE_ALL" ]]; then printf '%s' "$OPENCLAW_CHANNELS_JSON_CACHE_ALL" return 0 fi if [[ -z "$with_all" && "$OPENCLAW_CHANNELS_JSON_LOADED" -eq 1 ]]; then printf '%s' "$OPENCLAW_CHANNELS_JSON_CACHE" return 0 fi if ! command -v openclaw >/dev/null 2>&1; then return 1 fi local out="" local args=(channels list --json) if [[ -n "$with_all" ]] && openclaw_version_at_least 2026.6.0; then args=(channels list --json --all) fi if ! out=$(openclaw "${args[@]}" 2>/dev/null); then return 1 fi if [[ -n "$with_all" ]]; then OPENCLAW_CHANNELS_JSON_CACHE_ALL="$out" else OPENCLAW_CHANNELS_JSON_CACHE="$out" OPENCLAW_CHANNELS_JSON_LOADED=1 fi printf '%s' "$out" } # Resolve a single channel's entry from cached JSON, accepting all # known layouts. Echoes the entry object (or empty string when # absent). Recognized layouts: # 1) 2026.6.x: { "": { installed, configured, enabled, accounts } } # 2) legacy: { chat: { "": { installed, accounts } } } # 3) array form: { channels: [ { name, installed, accounts } ] } resolve_channel_entry() { local channel_name="$1" local json="$2" if [[ -z "$json" ]] || ! command -v jq >/dev/null 2>&1; then return 0 fi printf '%s' "$json" | jq -c --arg chan "$channel_name" ' ( .[$chan] // null ) as $top | ( .chat[$chan] // null ) as $chatted | ( (.channels // []) | map(select(.name == $chan)) | first // null ) as $listed | ( $top // $chatted // $listed // null ) ' 2>/dev/null } # Map a resolved channel entry to a health string. Echoes one of: # missing - entry not present in channels list # not_installed - entry present but no installed flag/account # no_accounts - installed but no ready accounts # healthy - installed and at least one ready account # # "Ready account" = an account whose per-account `installed` flag is # true (2026.6.x) or, in legacy form, an entry in `.accounts`. channel_health_from_entry() { local entry_json="$1" if [[ -z "$entry_json" || "$entry_json" == "null" ]]; then printf 'missing' return 0 fi if ! command -v jq >/dev/null 2>&1; then # Without jq we can't be precise; assume healthy to avoid churn. printf 'healthy' return 0 fi printf '%s' "$entry_json" | jq -r ' ( .installed // false ) as $chan_installed | ( ( .accounts // null ) as $acc | if $acc == null then 0 elif ($acc | type) == "array" then ( [ $acc[] | select((.installed // true) == true) ] | length ) elif ($acc | type) == "object" then ( [ $acc | to_entries[] | select(.value != null and ((.value.installed // true) == true)) ] | length ) else 0 end ) as $ready | if ($chan_installed == true or $ready > 0) and $ready > 0 then "healthy" elif $chan_installed == true or $ready > 0 then "no_accounts" else "not_installed" end ' 2>/dev/null } # One-shot helper: cache + resolve + classify. Echoes a health # string. The second argument toggles --all (use 1 to include # bundled channels, useful when "missing" should mean "not even # bundled"). channel_health() { local channel_name="$1" local with_all="${2:-}" local json entry if ! json=$(fetch_channels_json "$with_all"); then return 1 fi entry=$(resolve_channel_entry "$channel_name" "$json") channel_health_from_entry "$entry" } # ---- Guard rails ---- if ! is_true "${OPENCLAW_POST_RESTORE_ENABLED:-false}"; then log_info "Skipped: OPENCLAW_POST_RESTORE_ENABLED is unset or false (opt-in; set OPENCLAW_POST_RESTORE_ENABLED=true to enable)" exit 0 fi OPENCLAW_BIN="${OPENCLAW_BIN:-/usr/local/bin/openclaw}" FAIL_ON_ERROR="${OPENCLAW_POST_RESTORE_FAIL_ON_ERROR:-false}" errors=0 record_error() { log_error "$1" errors=$((errors + 1)) } # Surface the detected OpenClaw version up-front so the operator # immediately sees which schema variant the rest of the script # will operate against. A "?" means the CLI was not reachable at # startup (we'll fail later in check_openclaw_cli_present if so). DETECTED_VERSION="$(detect_openclaw_version 2>/dev/null || true)" if [[ -n "$DETECTED_VERSION" ]]; then log_info "OpenClaw target: ${DETECTED_VERSION} (supports unified channels list --all, top-level channel keys, per-account installed flag)" else log_info "OpenClaw target: unknown (CLI not yet reachable — will recheck in check_openclaw_cli_present)" fi # ---- Checks ---- # Each check_* function should be self-contained and call # record_error on failure. Add new checks by writing a new # check_* function and calling it from do_all_checks. check_openclaw_cli_present() { if command -v openclaw >/dev/null 2>&1; then log_info "✓ openclaw CLI present: $(command -v openclaw)" else record_error "openclaw CLI not found in PATH (looked for: $OPENCLAW_BIN)" fi } check_openclaw_version() { if ! command -v openclaw >/dev/null 2>&1; then return 0 # already reported by check_openclaw_cli_present fi if version_output=$(openclaw --version 2>&1); then log_info "✓ openclaw version: $version_output" else record_error "openclaw --version failed: $version_output" fi } check_channels_list_json() { if ! command -v openclaw >/dev/null 2>&1; then return 0 fi if ! channels_json=$(openclaw channels list --json 2>&1); then record_error "openclaw channels list --json failed: $channels_json" return 0 fi if ! command -v jq >/dev/null 2>&1; then log_warn "jq not available, skipping JSON validation (channels list returned without error)" return 0 fi if ! echo "$channels_json" | jq -e . >/dev/null 2>&1; then record_error "channels list --json returned invalid JSON" return 0 fi # Detect which schema layout we're looking at so the operator # log line tells the truth on whatever version is installed. # 2026.6.x: top-level keys (or .channels[]); <2026.6: nested # under .chat. Both are valid. local schema_hint schema_hint=$(printf '%s' "$channels_json" | jq -r ' if (.channels | type) == "array" then "channels-array" elif (.chat | type) == "object" then "chat-nested" elif ([keys[] | select(. != "auth" and . != "usage" and . != "models" and . != "providers")] | length) > 0 then "top-level" else "empty" end ' 2>/dev/null) log_info "✓ channels list --json returned valid JSON (schema: ${schema_hint:-unknown})" } do_all_checks() { check_openclaw_cli_present check_openclaw_version check_channels_list_json } # ---- Operations ---- # Unlike check_* above, these have side effects (update, install, # fix). Each is wrapped in run_step which: # - logs the command before invoking it # - captures stdout+stderr # - measures wall time # - reports success/failure with the appropriate level # - re-emits the output line-by-line (info on success, warn on # failure) so the operator can grep the log # - counts failures in $errors (governed by FAIL_ON_ERROR) run_step() { local description="$1" local cmd="$2" local start_time end_time duration output rc=0 start_time=$(date +%s) log_info "━━ STEP: ${description} [STARTING]" log_info " Command: ${cmd}" output=$(bash -c "$cmd" 2>&1) || rc=$? end_time=$(date +%s) duration=$((end_time - start_time)) if [[ $rc -eq 0 ]]; then log_info "✓ STEP: ${description} [COMPLETED] (${duration}s)" else log_error "✗ STEP: ${description} [FAILED] (exit=$rc, ${duration}s)" record_error "${description} failed (exit=$rc)" fi if [[ -n "$output" ]]; then while IFS= read -r line; do [[ -z "$line" ]] && continue if [[ $rc -eq 0 ]]; then log_info " | $line" else log_warn " | $line" fi done <<< "$output" fi } # Each operation function pre-checks command availability so the # log shows "skipped: CLI not present" instead of a confusing # "command not found" coming out of run_step. run_openclaw_plugins_update() { if ! command -v openclaw >/dev/null 2>&1; then log_warn "openclaw CLI not available, skipping plugins update" return 0 fi run_step "openclaw plugins update --all" "openclaw plugins update --all" } # Healthy = the feishu entry exists in `openclaw channels list --json`, # is installed, AND has at least one ready account. "Ready" is # detected via the per-account `installed` flag (2026.6.x) or by # counting entries under `.accounts` (<2026.6). Anything else is # considered "unhealthy" and gates run_lark_cli_update to fire. check_feishu_plugin_healthy() { if ! command -v openclaw >/dev/null 2>&1; then log_warn "cannot check feishu health: openclaw CLI not available" return 1 fi if ! command -v jq >/dev/null 2>&1; then log_warn "cannot check feishu health: jq not available" return 1 fi # On 2026.6.x, the default `channels list` view only surfaces # configured accounts; pass --all so a missing-on-disk plugin # still shows up as `not_installed` rather than `missing`. On # older versions the --all flag is harmless (it was a no-op # alias at worst) and is dropped by the helper when not # supported. local status status=$(channel_health feishu 1) local rc=$? if (( rc != 0 )); then log_warn "cannot check feishu health: channels list failed" return 1 fi if [[ "$status" == "healthy" ]]; then log_info "✓ feishu plugin healthy (installed + has authenticated accounts)" return 0 fi log_info "feishu plugin status: $status (treats as unhealthy → will trigger lark-cli update)" return 1 } # Same shape as check_feishu_plugin_healthy, but for the qqbot # channel. Health = installed + at least one ready account. Anything # else is "unhealthy" and gates the reinstall step. check_qqbot_plugin_healthy() { if ! command -v openclaw >/dev/null 2>&1; then log_warn "cannot check qqbot health: openclaw CLI not available" return 1 fi if ! command -v jq >/dev/null 2>&1; then log_warn "cannot check qqbot health: jq not available" return 1 fi local status status=$(channel_health qqbot 1) local rc=$? if (( rc != 0 )); then log_warn "cannot check qqbot health: channels list failed" return 1 fi if [[ "$status" == "healthy" ]]; then log_info "✓ qqbot plugin healthy (installed + has authenticated accounts)" return 0 fi log_info "qqbot plugin status: $status (treats as unhealthy → will trigger reinstall)" return 1 } run_lark_cli_update() { if ! command -v npm >/dev/null 2>&1; then log_warn "npm not available, skipping lark-cli update" return 0 fi # Gate: only update lark-cli if the feishu plugin is broken. A # working feishu integration means the currently-installed lark-cli # version is good enough — running `npm update` would just be churn # (and might break the working setup if a major version lands). if check_feishu_plugin_healthy; then log_info "feishu plugin is healthy, skipping lark-cli update (don't touch what works)" return 0 fi log_info "feishu plugin is unhealthy or unconfigured, proceeding with lark-cli update" # Operator visibility: log which OpenClaw version we're targeting # and which command syntax we'll use. On 2026.6.x the recommended # skills source is the unscoped `larksuite/cli` (matches the new # skills hub manifest); on older versions some deployments still # used `@larksuite/cli`. We always pin to `@larksuite/cli` for the # binary (npm package, version-controlled) and pick the right # `skills add` source per detected version. local ver ver=$(detect_openclaw_version 2>/dev/null || true) log_info "Updating lark-cli for OpenClaw ${ver:-unknown}..." local skills_source="larksuite/cli" if [[ -n "$ver" ]] && ! openclaw_version_at_least 2026.6.0; then # On <2026.6 the skills registry still expects the @-scoped form skills_source="@larksuite/cli" fi local cmd="npm update -g @larksuite/cli && npx skills add ${skills_source} -g -y" run_step "$cmd" "$cmd" } run_lark_cli_version() { if ! command -v lark-cli >/dev/null 2>&1; then log_warn "lark-cli not available, skipping version check" return 0 fi run_step "lark-cli --version" "lark-cli --version" } run_lark_cli_auth_status() { if ! command -v lark-cli >/dev/null 2>&1; then log_warn "lark-cli not available, skipping auth status" return 0 fi run_step "lark-cli auth status" "lark-cli auth status" } run_openclaw_doctor_fix() { if ! command -v openclaw >/dev/null 2>&1; then log_warn "openclaw CLI not available, skipping doctor --fix" return 0 fi run_step "openclaw doctor --fix" "openclaw doctor --fix" } run_qqbot_plugin_reinstall() { if ! command -v openclaw >/dev/null 2>&1; then log_warn "openclaw CLI not available, skipping qqbot reinstall" return 0 fi # Same fix-on-broken pattern as run_lark_cli_update: only reinstall # the qqbot plugin if the channel is unhealthy. A working integration # doesn't need a churn reinstall. if check_qqbot_plugin_healthy; then log_info "qqbot plugin is healthy, skipping reinstall (don't touch what works)" return 0 fi log_info "qqbot plugin is unhealthy or unconfigured, proceeding with uninstall+reinstall" # 2026.6.1 plugin install syntax: # - bare `@openclaw/qqbot` resolves to the bundled copy of the # plugin that ships with this OpenClaw build (auto-detected # during install). Pinning to `@latest` keeps the upgrade # rolling even if the bundled copy was older. # - explicit `npm:@openclaw/qqbot@latest` would force the npm # registry path; not needed here, the bundled copy is the # canonical source for an official channel plugin. # - `uninstall || true` ensures we still reach the install step # when the plugin was never installed to begin with (the # "missing" health state). The original `&&` chain silently # skipped install in that case, which defeated the fix. run_step "openclaw plugins uninstall qqbot || true; openclaw plugins install @openclaw/qqbot@latest" \ "openclaw plugins uninstall qqbot || true; openclaw plugins install @openclaw/qqbot@latest" } run_feishu_doctor_check() { # Targeted feishu diagnostic — richer than the openclaw doctor --fix # catch-all, gives a per-section pass/warn/fail breakdown that # operators can grep. Always runs (not gated on health) so the # baseline check is captured on every container start. if ! command -v feishu-doctor >/dev/null 2>&1; then log_warn "feishu-doctor not on PATH, skipping" return 0 fi run_step "feishu-doctor check" "feishu-doctor check" } do_all_operations() { log_info "=== Post-restore openclaw operations START ===" run_openclaw_plugins_update run_lark_cli_update run_lark_cli_version run_lark_cli_auth_status run_qqbot_plugin_reinstall run_feishu_doctor_check run_openclaw_doctor_fix log_info "=== Post-restore openclaw operations END ===" } # ---- Main ---- log_info "=== Post-restore openclaw hook START ===" log_info "OPENCLAW_POST_RESTORE_ENABLED=${OPENCLAW_POST_RESTORE_ENABLED:-false}" log_info "OPENCLAW_POST_RESTORE_FAIL_ON_ERROR=$FAIL_ON_ERROR" log_info "OPENCLAW_BIN=$OPENCLAW_BIN" log_info "OPENCLAW_VERSION=${OPENCLAW_VERSION:-}" log_info "Resolved target version: ${DETECTED_VERSION:-}" do_all_checks do_all_operations log_info "=== Post-restore openclaw hook END (errors=$errors) ===" if [[ "$errors" -gt 0 ]]; then if is_true "$FAIL_ON_ERROR"; then log_error "Aborting startup: $errors check(s) failed and OPENCLAW_POST_RESTORE_FAIL_ON_ERROR=true" exit 1 fi log_warn "Continuing startup with $errors check failure(s) (set OPENCLAW_POST_RESTORE_FAIL_ON_ERROR=true to fail-fast)" fi exit 0