diff --git a/CHANGELOG.md b/CHANGELOG.md index 92aa2d7..6150ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - Added helper scripts for smoke/dev lifecycle and rule validation - Added no-touch config rendering and integration docs - Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`) + - supports `--install` / `--uninstall` + - uninstall restores all recorded changes + - writes install/uninstall records under `~/.openclaw/whispergate-install-records/` - Added discord-control-api with: - `channel-private-create` (create private channel for allowlist) - `channel-private-update` (update allowlist/overwrites for existing channel) diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 3043de3..339762a 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -32,7 +32,16 @@ You can merge this snippet manually into your `openclaw.json`. For production-like install with automatic rollback on error: ```bash -./scripts/install-whispergate-openclaw.sh +./scripts/install-whispergate-openclaw.sh --install +``` + +Uninstall (revert all recorded config changes): + +```bash +./scripts/install-whispergate-openclaw.sh --uninstall +# or specify a record explicitly +# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \ +# ./scripts/install-whispergate-openclaw.sh --uninstall ``` Environment overrides: @@ -48,7 +57,10 @@ Environment overrides: The script: - writes via `openclaw config set ... --json` - creates config backup first -- restores backup automatically if any step fails +- restores backup automatically if any install step fails +- writes a change record for every install/uninstall: + - directory: `~/.openclaw/whispergate-install-records/` + - latest pointer: `~/.openclaw/whispergate-install-record-latest.json` ## Notes diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 9cb4946..8970312 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash set -Eeuo pipefail -# Install WhisperGate config into OpenClaw safely. -# - uses `openclaw config set ... --json` for writes -# - creates full config backup -# - rolls back automatically on failure +# 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/plugin}" @@ -15,74 +17,141 @@ NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-["561921120408698910","1474088632750047324"]}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-["🔚"]}" -BACKUP_PATH="${OPENCLAW_CONFIG_PATH}.bak-whispergate-install-$(date +%Y%m%d%H%M%S)" +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 -rollback() { - local ec=$? - if [[ $INSTALLED_OK -eq 1 ]]; then - return - fi - echo "[whispergate-install] ERROR (exit=$ec). Rolling back config from backup..." - if [[ -f "$BACKUP_PATH" ]]; then - cp -f "$BACKUP_PATH" "$OPENCLAW_CONFIG_PATH" - echo "[whispergate-install] rollback complete: $OPENCLAW_CONFIG_PATH restored" - else - echo "[whispergate-install] WARNING: backup file not found, cannot auto-rollback" - fi - exit "$ec" -} - -trap rollback ERR - require_cmd() { command -v "$1" >/dev/null 2>&1 || { - echo "[whispergate-install] missing command: $1" >&2 + 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 } -echo "[whispergate-install] checking prerequisites..." -require_cmd openclaw -require_cmd python3 +oc_unset() { + local path="$1" + openclaw config unset "$path" >/dev/null +} -if [[ ! -f "$OPENCLAW_CONFIG_PATH" ]]; then - echo "[whispergate-install] config not found: $OPENCLAW_CONFIG_PATH" >&2 - exit 1 -fi - -cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" -echo "[whispergate-install] backup created: $BACKUP_PATH" - -# 1) plugins.load.paths append plugin path (dedupe) -CURRENT_PATHS_JSON="[]" -if openclaw config get plugins.load.paths --json >/tmp/wg_paths.json 2>/dev/null; then - CURRENT_PATHS_JSON="$(cat /tmp/wg_paths.json)" -fi - -NEW_PATHS_JSON="$(python3 - <<'PY' +write_record() { + local mode="$1" + local prev_paths_json="$2" + local next_paths_json="$3" + mkdir -p "$STATE_DIR" + python3 - <<'PY' import json, os -cur=json.loads(os.environ['CURRENT_PATHS_JSON']) -pp=os.environ['PLUGIN_PATH'] -if not isinstance(cur,list): - cur=[] -if pp not in cur: - cur.append(pp) -print(json.dumps(cur, ensure_ascii=False)) +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" + + local prev_paths_json + prev_paths_json="$(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 )" -oc_set_json "plugins.load.paths" "$NEW_PATHS_JSON" -echo "[whispergate-install] set plugins.load.paths" + # 1) plugins.load.paths append and dedupe + local current_paths_json new_paths_json + if openclaw config get "$PATH_PLUGINS_LOAD" --json >/tmp/wg_paths.json 2>/dev/null; then + current_paths_json="$(cat /tmp/wg_paths.json)" + else + current_paths_json="[]" + fi -# 2) plugin entry -PLUGIN_ENTRY_JSON="$(python3 - <<'PY' + new_paths_json="$(CURRENT_PATHS_JSON="$current_paths_json" PLUGIN_PATH="$PLUGIN_PATH" python3 - <<'PY' +import json, os +cur=json.loads(os.environ['CURRENT_PATHS_JSON']) +if not isinstance(cur,list): + cur=[] +pp=os.environ['PLUGIN_PATH'] +if pp not in cur: + cur.append(pp) +print(json.dumps(cur,ensure_ascii=False)) +PY +)" + oc_set_json "$PATH_PLUGINS_LOAD" "$new_paths_json" + + # 2) plugin entry + local plugin_entry_json + plugin_entry_json="$(BYPASS_USER_IDS_JSON="$BYPASS_USER_IDS_JSON" 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 entry={ 'enabled': True, @@ -95,15 +164,14 @@ entry={ 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], } } -print(json.dumps(entry, ensure_ascii=False)) +print(json.dumps(entry,ensure_ascii=False)) PY )" + oc_set_json "$PATH_PLUGIN_ENTRY" "$plugin_entry_json" -oc_set_json "plugins.entries.whispergate" "$PLUGIN_ENTRY_JSON" -echo "[whispergate-install] set plugins.entries.whispergate" - -# 3) no-reply model provider (under models.providers) -PROVIDER_JSON="$(python3 - <<'PY' + # 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'], @@ -111,7 +179,7 @@ provider={ 'api': 'openai-completions', 'models': [{ 'id': os.environ['NO_REPLY_MODEL_ID'], - 'name': 'whispergate-no-reply-v1 (Custom Provider)', + 'name': f"{os.environ['NO_REPLY_MODEL_ID']} (Custom Provider)", 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, @@ -119,22 +187,123 @@ provider={ 'maxTokens': 4096, }] } -print(json.dumps(provider, ensure_ascii=False)) +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="$(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 )" -PROVIDER_PATH="models.providers[\"${NO_REPLY_PROVIDER_ID}\"]" -oc_set_json "$PROVIDER_PATH" "$PROVIDER_JSON" -echo "[whispergate-install] set ${PROVIDER_PATH}" + 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" -# 4) quick validation reads -openclaw config get plugins.entries.whispergate --json >/dev/null -openclaw config get "$PROVIDER_PATH" --json >/dev/null + INSTALLED_OK=1 + trap - ERR + echo "[whispergate] install ok" + echo "[whispergate] record: $RECORD_PATH" +} -INSTALLED_OK=1 -trap - ERR +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 -echo "[whispergate-install] install completed successfully" -echo "[whispergate-install] next steps:" -echo " 1) start no-reply api (port 8787)" -echo " 2) restart gateway: openclaw gateway restart" + 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',{}) + +for path, info in paths.items(): + exists=bool(info.get('exists')) + if 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) + raise SystemExit(1) + else: + p=subprocess.run(['openclaw','config','unset',path],capture_output=True,text=True) + if p.returncode!=0: + # path may already be absent; tolerate common "not found" style failures + 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) + 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