#!/usr/bin/env bash # Enforce OpenClaw memorySearch to use Ollama embeddings settings. # Idempotent: safe to run repeatedly. set -euo pipefail MODEL="" BASE_URL="http://127.0.0.1:11434/v1/" CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-${HOME}/.openclaw/openclaw.json}" CHECK_ONLY=0 QUIET=0 RESTART_ON_CHANGE=0 API_KEY_VALUE="ollama" usage() { cat <<'EOF' Usage: enforce.sh [options] Options: --model embedding model id (required unless already in config) --base-url Ollama OpenAI-compatible base URL (default: http://127.0.0.1:11434/v1/) --openclaw-config OpenClaw config path (default: ~/.openclaw/openclaw.json) --api-key-value apiKey to set if missing (default: ollama) --check-only exit non-zero if drift is detected, do not modify config --restart-on-change restart gateway if config was changed --quiet suppress non-error output --help show help Exit codes: 0 success (no drift or drift healed) 10 drift detected in --check-only mode 1 error EOF } while [ $# -gt 0 ]; do case "$1" in --model) MODEL="$2"; shift 2 ;; --base-url) BASE_URL="$2"; shift 2 ;; --openclaw-config) CONFIG_PATH="$2"; shift 2 ;; --api-key-value) API_KEY_VALUE="$2"; shift 2 ;; --check-only) CHECK_ONLY=1; shift ;; --restart-on-change) RESTART_ON_CHANGE=1; shift ;; --quiet) QUIET=1; shift ;; --help|-h) usage; exit 0 ;; *) echo "Unknown option: $1"; usage; exit 1 ;; esac done log() { [ "$QUIET" -eq 1 ] || echo "$@" } normalize_model() { local m="$1" if [[ "$m" != *:* ]]; then echo "${m}:latest" else echo "$m" fi } normalize_base_url() { local u="${1%/}" if [[ "$u" != */v1 ]]; then u="${u}/v1" fi echo "${u}/" } restart_gateway() { if ! command -v openclaw >/dev/null 2>&1; then log "NOTE: openclaw CLI not found; skip restart." return 0 fi if openclaw gateway restart 2>/dev/null; then log "Gateway restarted." return 0 fi log "WARNING: openclaw gateway restart failed; restart manually." return 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: '$1' not found in PATH." exit 1 } } require_cmd node mkdir -p "$(dirname "$CONFIG_PATH")" [ -f "$CONFIG_PATH" ] || echo "{}" > "$CONFIG_PATH" BASE_URL_NORM="$(normalize_base_url "$BASE_URL")" # If model omitted, try current config model; otherwise enforce requires explicit model. if [ -z "$MODEL" ]; then MODEL="$(node -e ' const fs=require("fs"); const p=process.argv[1]; const CANDIDATES = [ { label: "agents.defaults.memorySearch", path: ["agents", "defaults", "memorySearch"] }, { label: "memorySearch", path: ["memorySearch"] }, { label: "agents.memorySearch", path: ["agents", "memorySearch"] }, { label: "agents.defaults.memory.search", path: ["agents", "defaults", "memory", "search"] }, { label: "memory.search", path: ["memory", "search"] }, ]; function getAt(obj, path) { let cur = obj; for (const k of path) { if (!cur || typeof cur !== "object" || !(k in cur)) return undefined; cur = cur[k]; } return cur; } function resolveActive(cfg) { const canonical = CANDIDATES[0]; const canonicalVal = getAt(cfg, canonical.path); if (canonicalVal && typeof canonicalVal === "object" && !Array.isArray(canonicalVal)) return canonical; for (const c of CANDIDATES.slice(1)) { const v = getAt(cfg, c.path); if (v && typeof v === "object" && !Array.isArray(v)) return c; } return canonical; } try { const cfg=JSON.parse(fs.readFileSync(p,"utf8")); const active = resolveActive(cfg); const ms = getAt(cfg, active.path) || {}; const m=ms.model||""; process.stdout.write(m); } catch (_) {} ' "$CONFIG_PATH")" fi if [ -z "$MODEL" ]; then echo "ERROR: no model provided and no existing memorySearch.model in config." exit 1 fi MODEL_NORM="$(normalize_model "$MODEL")" if [ -z "$API_KEY_VALUE" ]; then echo "ERROR: --api-key-value must be non-empty." exit 1 fi export CONFIG_PATH MODEL_NORM BASE_URL_NORM API_KEY_VALUE if [ "$CHECK_ONLY" -eq 1 ]; then set +e node <<'EOF' const fs = require("fs"); const p = process.env.CONFIG_PATH; const model = process.env.MODEL_NORM; const base = process.env.BASE_URL_NORM; const CANDIDATES = [ { label: "agents.defaults.memorySearch", path: ["agents", "defaults", "memorySearch"] }, { label: "memorySearch", path: ["memorySearch"] }, { label: "agents.memorySearch", path: ["agents", "memorySearch"] }, { label: "agents.defaults.memory.search", path: ["agents", "defaults", "memory", "search"] }, { label: "memory.search", path: ["memory", "search"] }, ]; function getAt(obj, path) { let cur = obj; for (const k of path) { if (!cur || typeof cur !== "object" || !(k in cur)) return undefined; cur = cur[k]; } return cur; } function resolveActive(cfg) { const canonical = CANDIDATES[0]; const canonicalVal = getAt(cfg, canonical.path); if (canonicalVal && typeof canonicalVal === "object" && !Array.isArray(canonicalVal)) return canonical; for (const c of CANDIDATES.slice(1)) { const v = getAt(cfg, c.path); if (v && typeof v === "object" && !Array.isArray(v)) return c; } return canonical; } let cfg = {}; try { cfg = JSON.parse(fs.readFileSync(p, "utf8")); } catch { process.exit(10); } const active = resolveActive(cfg); const ms = getAt(cfg, active.path) || {}; const apiKey = ms?.remote?.apiKey || ""; const drift = ms.provider !== "openai" || (ms.model || "") !== model || (ms?.remote?.baseUrl || "") !== base || apiKey === ""; process.exit(drift ? 10 : 0); EOF status=$? set -e if [ "$status" -eq 0 ]; then log "No drift detected." exit 0 elif [ "$status" -eq 10 ]; then log "Drift detected." exit 10 else echo "ERROR: drift check failed." exit 1 fi fi set +e PLAN_OUT="$(node <<'EOF' const fs = require("fs"); const path = process.env.CONFIG_PATH; const model = process.env.MODEL_NORM; const base = process.env.BASE_URL_NORM; const desiredApiKey = process.env.API_KEY_VALUE; const CANDIDATES = [ { label: "agents.defaults.memorySearch", path: ["agents", "defaults", "memorySearch"] }, { label: "memorySearch", path: ["memorySearch"] }, { label: "agents.memorySearch", path: ["agents", "memorySearch"] }, { label: "agents.defaults.memory.search", path: ["agents", "defaults", "memory", "search"] }, { label: "memory.search", path: ["memory", "search"] }, ]; function getAt(obj, path) { let cur = obj; for (const k of path) { if (!cur || typeof cur !== "object" || !(k in cur)) return undefined; cur = cur[k]; } return cur; } function ensureAt(obj, path) { let cur = obj; for (const k of path) { if (!cur[k] || typeof cur[k] !== "object" || Array.isArray(cur[k])) cur[k] = {}; cur = cur[k]; } return cur; } function resolveActive(cfg) { const canonical = CANDIDATES[0]; const canonicalVal = getAt(cfg, canonical.path); if (canonicalVal && typeof canonicalVal === "object" && !Array.isArray(canonicalVal)) return canonical; for (const c of CANDIDATES.slice(1)) { const v = getAt(cfg, c.path); if (v && typeof v === "object" && !Array.isArray(v)) return c; } return canonical; } let cfg = {}; try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch (_) { cfg = {}; } const before = JSON.stringify(cfg); const canonical = CANDIDATES[0]; const active = resolveActive(cfg); const targets = [canonical]; if (active.label !== canonical.label) targets.push(active); for (const t of targets) { const ms = ensureAt(cfg, t.path); ms.provider = "openai"; ms.model = model; ms.remote = ms.remote || {}; ms.remote.baseUrl = base; // Preserve existing non-empty apiKey to avoid false drift with custom conventions. if (!ms.remote.apiKey) ms.remote.apiKey = desiredApiKey; } const afterObj = getAt(cfg, canonical.path) || {}; const changed = before !== JSON.stringify(cfg); console.log(changed ? "changed" : "unchanged"); console.log(afterObj.provider || ""); console.log(afterObj.model || ""); console.log((afterObj.remote && afterObj.remote.baseUrl) || ""); console.log((afterObj.remote && afterObj.remote.apiKey) ? "(set)" : "(missing)"); console.log(active.label); console.log(active.label !== canonical.label ? "yes" : "no"); EOF )" status=$? set -e if [ "$status" -ne 0 ]; then echo "ERROR: failed to plan memorySearch enforcement." exit 1 fi CHANGED="$(printf "%s\n" "$PLAN_OUT" | sed -n '1p')" PROVIDER_NOW="$(printf "%s\n" "$PLAN_OUT" | sed -n '2p')" MODEL_NOW="$(printf "%s\n" "$PLAN_OUT" | sed -n '3p')" BASE_NOW="$(printf "%s\n" "$PLAN_OUT" | sed -n '4p')" APIKEY_NOW="$(printf "%s\n" "$PLAN_OUT" | sed -n '5p')" ACTIVE_PATH="$(printf "%s\n" "$PLAN_OUT" | sed -n '6p')" MIRRORING_LEGACY="$(printf "%s\n" "$PLAN_OUT" | sed -n '7p')" if [ "$CHANGED" = "changed" ]; then BACKUP_PATH="${CONFIG_PATH}.bak.$(date -u +%Y-%m-%dT%H-%M-%SZ)" cp "$CONFIG_PATH" "$BACKUP_PATH" node <<'EOF' const fs = require("fs"); const path = process.env.CONFIG_PATH; const model = process.env.MODEL_NORM; const base = process.env.BASE_URL_NORM; const desiredApiKey = process.env.API_KEY_VALUE; const CANDIDATES = [ { label: "agents.defaults.memorySearch", path: ["agents", "defaults", "memorySearch"] }, { label: "memorySearch", path: ["memorySearch"] }, { label: "agents.memorySearch", path: ["agents", "memorySearch"] }, { label: "agents.defaults.memory.search", path: ["agents", "defaults", "memory", "search"] }, { label: "memory.search", path: ["memory", "search"] }, ]; function getAt(obj, path) { let cur = obj; for (const k of path) { if (!cur || typeof cur !== "object" || !(k in cur)) return undefined; cur = cur[k]; } return cur; } function ensureAt(obj, path) { let cur = obj; for (const k of path) { if (!cur[k] || typeof cur[k] !== "object" || Array.isArray(cur[k])) cur[k] = {}; cur = cur[k]; } return cur; } function resolveActive(cfg) { const canonical = CANDIDATES[0]; const canonicalVal = getAt(cfg, canonical.path); if (canonicalVal && typeof canonicalVal === "object" && !Array.isArray(canonicalVal)) return canonical; for (const c of CANDIDATES.slice(1)) { const v = getAt(cfg, c.path); if (v && typeof v === "object" && !Array.isArray(v)) return c; } return canonical; } let cfg = {}; try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch (_) { cfg = {}; } const canonical = CANDIDATES[0]; const active = resolveActive(cfg); const targets = [canonical]; if (active.label !== canonical.label) targets.push(active); for (const t of targets) { const ms = ensureAt(cfg, t.path); ms.provider = "openai"; ms.model = model; ms.remote = ms.remote || {}; ms.remote.baseUrl = base; if (!ms.remote.apiKey) ms.remote.apiKey = desiredApiKey; } fs.writeFileSync(path, JSON.stringify(cfg, null, 2)); EOF fi log "Config: ${CONFIG_PATH}" if [ "$CHANGED" = "changed" ]; then log "Backup: ${BACKUP_PATH}" fi log "provider=${PROVIDER_NOW}" log "model=${MODEL_NOW}" log "baseUrl=${BASE_NOW}" log "apiKey=${APIKEY_NOW}" if [ -n "$ACTIVE_PATH" ] && [ "$ACTIVE_PATH" != "agents.defaults.memorySearch" ]; then log "legacyPathDetected=${ACTIVE_PATH}" fi if [ "$MIRRORING_LEGACY" = "yes" ]; then log "legacyMirrored=yes" fi if [ "$CHANGED" = "changed" ]; then log "Drift healed: memorySearch settings updated." if [ "$RESTART_ON_CHANGE" -eq 1 ]; then restart_gateway || true fi else log "No changes required." fi