#!/usr/bin/env bash set -Eeuo pipefail # WhisperGate installer/uninstaller for OpenClaw # Requirements: # - all writes via `openclaw config set ... --json` # - install supports rollback on failure # - uninstall reverts ALL recorded changes # - every install writes a change record OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/whispergate}" NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-whisper-gateway}" NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-no-reply}" NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" LIST_MODE="${LIST_MODE:-human-list}" HUMAN_LIST_JSON="${HUMAN_LIST_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" AGENT_LIST_JSON="${AGENT_LIST_JSON:-[]}" CHANNEL_POLICIES_FILE="${CHANNEL_POLICIES_FILE:-$HOME/.openclaw/whispergate-channel-policies.json}" CHANNEL_POLICIES_JSON="${CHANNEL_POLICIES_JSON:-{}}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"🔚\"]}" STATE_DIR="${STATE_DIR:-$HOME/.openclaw/whispergate-install-records}" LATEST_RECORD_LINK="${LATEST_RECORD_LINK:-$HOME/.openclaw/whispergate-install-record-latest.json}" MODE="" if [[ "${1:-}" == "--install" ]]; then MODE="install" elif [[ "${1:-}" == "--uninstall" ]]; then MODE="uninstall" else echo "Usage: $0 --install | --uninstall" exit 2 fi TIMESTAMP="$(date +%Y%m%d%H%M%S)" BACKUP_PATH="${OPENCLAW_CONFIG_PATH}.bak-whispergate-${MODE}-${TIMESTAMP}" RECORD_PATH="${STATE_DIR}/whispergate-${TIMESTAMP}.json" PROVIDER_PATH="models.providers[\"${NO_REPLY_PROVIDER_ID}\"]" PATH_PLUGINS_LOAD="plugins.load.paths" PATH_PLUGIN_ENTRY="plugins.entries.whispergate" INSTALLED_OK=0 require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "[whispergate] missing command: $1" >&2 exit 1 } } oc_get_json_or_missing() { local path="$1" if openclaw config get "$path" --json >/tmp/wg_get.json 2>/dev/null; then printf '{"exists":true,"value":%s}\n' "$(cat /tmp/wg_get.json)" else printf '{"exists":false}' fi } oc_set_json() { local path="$1" local json="$2" openclaw config set "$path" "$json" --json >/dev/null } oc_unset() { local path="$1" openclaw config unset "$path" >/dev/null } write_record() { local mode="$1" local prev_paths_json="$2" local next_paths_json="$3" mkdir -p "$STATE_DIR" python3 - <<'PY' import json, os record={ 'mode': os.environ['REC_MODE'], 'timestamp': os.environ['TIMESTAMP'], 'openclawConfigPath': os.environ['OPENCLAW_CONFIG_PATH'], 'backupPath': os.environ['BACKUP_PATH'], 'paths': json.loads(os.environ['PREV_PATHS_JSON']), 'applied': json.loads(os.environ['NEXT_PATHS_JSON']), } with open(os.environ['RECORD_PATH'],'w',encoding='utf-8') as f: json.dump(record,f,ensure_ascii=False,indent=2) PY cp -f "$RECORD_PATH" "$LATEST_RECORD_LINK" } rollback_install() { local ec=$? if [[ $INSTALLED_OK -eq 1 ]]; then return fi echo "[whispergate] install failed (exit=$ec), rolling back..." if [[ -f "$BACKUP_PATH" ]]; then cp -f "$BACKUP_PATH" "$OPENCLAW_CONFIG_PATH" echo "[whispergate] rollback complete" else echo "[whispergate] WARNING: backup missing; rollback skipped" fi exit "$ec" } run_install() { trap rollback_install ERR cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" echo "[whispergate] backup: $BACKUP_PATH" # initialize standalone channel policies file if missing CHANNEL_POLICIES_FILE_RESOLVED="$(CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" python3 - <<'PY' import os print(os.path.expanduser(os.environ['CHANNEL_POLICIES_FILE'])) PY )" if [[ ! -f "$CHANNEL_POLICIES_FILE_RESOLVED" ]]; then mkdir -p "$(dirname "$CHANNEL_POLICIES_FILE_RESOLVED")" printf '%s\n' "$CHANNEL_POLICIES_JSON" > "$CHANNEL_POLICIES_FILE_RESOLVED" echo "[whispergate] initialized channel policies file: $CHANNEL_POLICIES_FILE_RESOLVED" fi local prev_paths_json prev_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' import json, os, subprocess def get(path): p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) if p.returncode==0: return {'exists':True,'value':json.loads(p.stdout)} return {'exists':False} payload={ os.environ['PATH_PLUGINS_LOAD']: get(os.environ['PATH_PLUGINS_LOAD']), os.environ['PATH_PLUGIN_ENTRY']: get(os.environ['PATH_PLUGIN_ENTRY']), os.environ['PROVIDER_PATH']: get(os.environ['PROVIDER_PATH']), } print(json.dumps(payload,ensure_ascii=False)) PY )" # 1+2) set plugins object in one write (avoid transient schema failure) local current_plugins_json new_plugins_json if openclaw config get plugins --json >/tmp/wg_plugins.json 2>/dev/null; then current_plugins_json="$(cat /tmp/wg_plugins.json)" else current_plugins_json='{}' fi new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) if not isinstance(plugins,dict): plugins={} load=plugins.setdefault('load',{}) paths=load.get('paths') if not isinstance(paths,list): paths=[] pp=os.environ['PLUGIN_PATH'] if pp not in paths: paths.append(pp) load['paths']=paths entries=plugins.setdefault('entries',{}) entries['whispergate']={ 'enabled': True, 'config': { 'enabled': True, 'discordOnly': True, 'listMode': os.environ['LIST_MODE'], 'humanList': json.loads(os.environ['HUMAN_LIST_JSON']), 'agentList': json.loads(os.environ['AGENT_LIST_JSON']), 'channelPoliciesFile': os.environ['CHANNEL_POLICIES_FILE'], 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], } } print(json.dumps(plugins,ensure_ascii=False)) PY )" oc_set_json "plugins" "$new_plugins_json" # 3) provider local provider_json provider_json="$(NO_REPLY_BASE_URL="$NO_REPLY_BASE_URL" NO_REPLY_API_KEY="$NO_REPLY_API_KEY" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os provider={ 'baseUrl': os.environ['NO_REPLY_BASE_URL'], 'apiKey': os.environ['NO_REPLY_API_KEY'], 'api': 'openai-completions', 'models': [{ 'id': os.environ['NO_REPLY_MODEL_ID'], 'name': f"{os.environ['NO_REPLY_MODEL_ID']} (Custom Provider)", 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 4096, 'maxTokens': 4096, }] } print(json.dumps(provider,ensure_ascii=False)) PY )" oc_set_json "$PROVIDER_PATH" "$provider_json" # validate writes openclaw config get "$PATH_PLUGINS_LOAD" --json >/dev/null openclaw config get "$PATH_PLUGIN_ENTRY" --json >/dev/null openclaw config get "$PROVIDER_PATH" --json >/dev/null local next_paths_json next_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' import json, os, subprocess def get(path): p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) if p.returncode==0: return {'exists':True,'value':json.loads(p.stdout)} return {'exists':False} payload={ os.environ['PATH_PLUGINS_LOAD']: get(os.environ['PATH_PLUGINS_LOAD']), os.environ['PATH_PLUGIN_ENTRY']: get(os.environ['PATH_PLUGIN_ENTRY']), os.environ['PROVIDER_PATH']: get(os.environ['PROVIDER_PATH']), } print(json.dumps(payload,ensure_ascii=False)) PY )" REC_MODE="install" PREV_PATHS_JSON="$prev_paths_json" NEXT_PATHS_JSON="$next_paths_json" TIMESTAMP="$TIMESTAMP" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" BACKUP_PATH="$BACKUP_PATH" RECORD_PATH="$RECORD_PATH" write_record install "$prev_paths_json" "$next_paths_json" INSTALLED_OK=1 trap - ERR echo "[whispergate] install ok" echo "[whispergate] record: $RECORD_PATH" } run_uninstall() { local rec_file="" if [[ -n "${RECORD_FILE:-}" ]]; then rec_file="$RECORD_FILE" elif [[ -f "$LATEST_RECORD_LINK" ]]; then rec_file="$LATEST_RECORD_LINK" else echo "[whispergate] no record found. set RECORD_FILE= or install first." >&2 exit 1 fi if [[ ! -f "$rec_file" ]]; then echo "[whispergate] record file not found: $rec_file" >&2 exit 1 fi cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" echo "[whispergate] backup before uninstall: $BACKUP_PATH" python3 - <<'PY' import json, os, subprocess, sys rec=json.load(open(os.environ['REC_FILE'],encoding='utf-8')) paths=rec.get('paths',{}) PLUGINS_LOAD='plugins.load.paths' PLUGINS_ENTRY='plugins.entries.whispergate' PROVIDER_PATHS=[k for k in paths.keys() if k.startswith('models.providers[')] # 1) restore plugins atomically to avoid transient schema failures pcur=subprocess.run(['openclaw','config','get','plugins','--json'],capture_output=True,text=True) plugins={} if pcur.returncode==0: plugins=json.loads(pcur.stdout) if not isinstance(plugins,dict): plugins={} load=plugins.get('load') if isinstance(plugins.get('load'),dict) else {} entries=plugins.get('entries') if isinstance(plugins.get('entries'),dict) else {} # restore plugins.load.paths from record info=paths.get(PLUGINS_LOAD,{'exists':False}) if info.get('exists'): load['paths']=info.get('value') else: load.pop('paths',None) # restore plugins.entries.whispergate from record info=paths.get(PLUGINS_ENTRY,{'exists':False}) if info.get('exists'): entries['whispergate']=info.get('value') else: entries.pop('whispergate',None) plugins['load']=load plugins['entries']=entries pset=subprocess.run(['openclaw','config','set','plugins',json.dumps(plugins,ensure_ascii=False),'--json'],capture_output=True,text=True) if pset.returncode!=0: sys.stderr.write(pset.stderr or pset.stdout) raise SystemExit(1) # 2) restore provider paths (usually custom no-reply provider) for path in PROVIDER_PATHS: info=paths.get(path,{'exists':False}) if info.get('exists'): val=json.dumps(info.get('value'),ensure_ascii=False) p=subprocess.run(['openclaw','config','set',path,val,'--json'],capture_output=True,text=True) if p.returncode!=0: sys.stderr.write(p.stderr or p.stdout) raise SystemExit(1) else: p=subprocess.run(['openclaw','config','unset',path],capture_output=True,text=True) if p.returncode!=0: txt=(p.stderr or p.stdout or '').lower() if 'not found' not in txt and 'missing' not in txt and 'does not exist' not in txt: sys.stderr.write(p.stderr or p.stdout) raise SystemExit(1) print('ok') PY local next_paths_json next_paths_json="$(python3 - <<'PY' import json, os, subprocess paths=json.load(open(os.environ['REC_FILE'],encoding='utf-8')).get('paths',{}).keys() def get(path): p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) if p.returncode==0: return {'exists':True,'value':json.loads(p.stdout)} return {'exists':False} payload={k:get(k) for k in paths} print(json.dumps(payload,ensure_ascii=False)) PY )" local prev_paths_json prev_paths_json="$(python3 - <<'PY' import json, os print(json.dumps(json.load(open(os.environ['REC_FILE'],encoding='utf-8')).get('applied',{}),ensure_ascii=False)) PY )" REC_MODE="uninstall" PREV_PATHS_JSON="$prev_paths_json" NEXT_PATHS_JSON="$next_paths_json" TIMESTAMP="$TIMESTAMP" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" BACKUP_PATH="$BACKUP_PATH" RECORD_PATH="$RECORD_PATH" write_record uninstall "$prev_paths_json" "$next_paths_json" echo "[whispergate] uninstall ok" echo "[whispergate] record: $RECORD_PATH" } main() { require_cmd openclaw require_cmd python3 [[ -f "$OPENCLAW_CONFIG_PATH" ]] || { echo "[whispergate] config not found: $OPENCLAW_CONFIG_PATH"; exit 1; } if [[ "$MODE" == "install" ]]; then run_install else REC_FILE="${RECORD_FILE:-$LATEST_RECORD_LINK}" run_uninstall fi } main