Rename Moltbot/Clawdbot to OpenClaw; drop back-compat; bump add-on to 0.5.0

This commit is contained in:
root
2026-01-30 17:21:19 +02:00
parent 75efb8c6c1
commit 00df355316
11 changed files with 39 additions and 46 deletions
+3
View File
@@ -0,0 +1,3 @@
*.log
*.sqlite
/data/
+54
View File
@@ -0,0 +1,54 @@
ARG BUILD_FROM=ghcr.io/hassio-addons/base:14.0.2
FROM ${BUILD_FROM}
# Base image is Alpine (HA add-ons base). It may only provide Node 18.x.
# OpenClaw requires Node 20+, so we install a Node 20 musl build directly.
ARG NODE_VERSION=22.12.0
RUN apk add --no-cache \
bash \
git \
openssh-client \
ca-certificates \
tzdata \
jq \
curl \
tar \
xz \
libstdc++ \
libgcc \
libatomic \
file \
pax-utils \
python3 \
nginx \
ttyd
# Install Node.js 22+ (musl build) from unofficial builds.
# Router/base image Alpine may ship an older libstdc++; we pull a newer libstdc++ from edge to satisfy Node's C++ symbols.
RUN set -eu; \
arch=$(uname -m); \
if [ "$arch" != "x86_64" ]; then echo "Unsupported arch: $arch"; exit 1; fi; \
url="https://unofficial-builds.nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64-musl.tar.xz"; \
echo "Downloading Node.js $NODE_VERSION (musl) from $url"; \
curl -fsSL "$url" -o /tmp/node.tar.xz; \
mkdir -p /usr/local; \
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1; \
rm -f /tmp/node.tar.xz; \
echo "Upgrading libstdc++/libgcc/libatomic from Alpine edge for Node compatibility"; \
apk add --no-cache --upgrade \
--repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \
--repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \
libstdc++ libgcc libatomic; \
/usr/local/bin/node -v; /usr/local/bin/npm -v
# Install OpenClaw globally
RUN npm config set fund false && npm config set audit false \
&& npm install -g openclaw@2026.1.29
COPY run.sh /run.sh
COPY nginx.conf.tpl /etc/nginx/nginx.conf.tpl
RUN chmod +x /run.sh \
&& mkdir -p /run/nginx
CMD [ "/run.sh" ]
+2
View File
@@ -0,0 +1,2 @@
build_from:
amd64: ghcr.io/hassio-addons/base:14.0.2
+98
View File
@@ -0,0 +1,98 @@
name: OpenClaw Assistant
version: "0.5.0"
slug: openclaw_assistant
description: Run OpenClaw Assistant (OpenClaw-compatible) as a Home Assistant add-on.
url: https://github.com/techartdev/OpenClawHomeAssistant
arch:
- amd64
startup: services
boot: auto
init: false
hassio_api: false
homeassistant_api: false
host_network: true
# Home Assistant Ingress (UI inside the add-on page)
ingress: true
ingress_port: 8099
panel_title: OpenClaw Assistant
panel_icon: mdi:robot
map:
- addon_config:rw
options:
telegram_bot_token: ""
timezone: "Europe/Sofia"
# Optional: lock DMs to specific Telegram user ids (comma-separated).
# If set, add-on will use dmPolicy=allowlist and allowFrom=<ids>.
telegram_allow_from: ""
# Model selection (string like "openai-codex/gpt-5.2" or "ollama/gpt-oss:20b")
model_primary: "openai-codex/gpt-5.2"
# Gateway UI exposure (LAN)
# - bind: loopback (default) or lan
# - port: default 18789
# - token: set to a fixed token so Control UI can connect; required if bind=lan
gateway_bind: "loopback"
gateway_port: 18789
gateway_token: ""
# Public base URL for opening the Gateway Web UI in a new tab (not embedded).
# Recommended: NO trailing slash.
# Example: "https://example.duckdns.org:12345" or "http://192.168.1.10:18789"
gateway_public_url: ""
# Run `openclaw doctor --fix` on startup (useful after upgrades; slows restarts)
run_doctor_on_start: false
homeassistant_token: ""
# Optional: Router SSH defaults (leave empty if you don't need router automation)
# This is a generic SSH configuration intended for a router/firewall or any network device
# reachable from inside the HA host LAN.
router_ssh_host: ""
router_ssh_user: ""
router_ssh_key_path: "/data/keys/router_ssh"
# Backward compatible (deprecated): older config names
mikrotik_host: ""
mikrotik_ssh_user: ""
mikrotik_ssh_key_path: "/data/keys/mikrotik"
# Brave Search API key (optional; enables Brave-backed web search tool)
brave_api_key: ""
# Enable web terminal inside Home Assistant (Ingress) via ttyd
enable_terminal: false
# Cleanup stale session lock files left after crashes/restarts
clean_session_locks_on_start: true
clean_session_locks_on_exit: true
schema:
telegram_bot_token: str
timezone: str
telegram_allow_from: str?
model_primary: str
gateway_bind: list(loopback|lan)
gateway_port: int
gateway_token: str?
gateway_public_url: str?
run_doctor_on_start: bool
homeassistant_token: str?
router_ssh_host: str
router_ssh_user: str
router_ssh_key_path: str
# Backward compatible (deprecated)
mikrotik_host: str?
mikrotik_ssh_user: str?
mikrotik_ssh_key_path: str?
brave_api_key: str?
enable_terminal: bool?
clean_session_locks_on_start: bool?
clean_session_locks_on_exit: bool?
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

+96
View File
@@ -0,0 +1,96 @@
worker_processes 1;
# Log to stderr/stdout (container-friendly)
error_log /dev/stderr notice;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Log to stdout/stderr (container-friendly)
access_log /dev/stdout;
error_log /dev/stderr notice;
sendfile on;
keepalive_timeout 65;
# Ingress note: keep redirects relative so we stay under HA Ingress.
server {
listen 8099;
# Web terminal (ttyd)
# ttyd base-path is configured as /terminal (no trailing slash).
# Some clients will hit /terminal first, so redirect to /terminal/.
location = /terminal {
return 302 /terminal/;
}
# Proxy everything under /terminal/ (including websocket /terminal/ws)
location ^~ /terminal/ {
# IMPORTANT: no trailing slash in proxy_pass so nginx preserves the full URI
proxy_pass http://127.0.0.1:7681;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Landing page (shown inside HA Ingress)
# - Shows the web terminal (if enabled)
# - Provides a button to open the Gateway Web UI in a separate tab (not embedded)
location = / {
default_type text/html;
# NOTE: __GATEWAY_PUBLIC_URL__ is configured via add-on option gateway_public_url.
# We keep it flexible because the right URL depends on how the user exposes HA/gateway
# (Nabu Casa, DuckDNS, LAN, etc.).
return 200 '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenClaw Assistant</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;margin:0;padding:16px;background:#0b0f14;color:#e6edf3}
a,button{font:inherit}
.card{max-width:1100px;margin:0 auto;background:#111827;border:1px solid #1f2937;border-radius:12px;padding:16px}
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
.btn{background:#2563eb;color:white;border:0;border-radius:10px;padding:10px 14px;cursor:pointer;text-decoration:none;display:inline-block}
.btn.secondary{background:#334155}
.muted{color:#9ca3af;font-size:14px}
.term{margin-top:14px;height:70vh;min-height:420px;border:1px solid #1f2937;border-radius:10px;overflow:hidden}
iframe{width:100%;height:100%;border:0;background:black}
code{background:#0b1220;padding:2px 6px;border-radius:6px}
</style>
</head><body>
<div class="card">
<h2 style="margin:0 0 8px 0">OpenClaw Assistant</h2>
<div class="row" style="margin-bottom:6px">
<a class="btn" href="__GATEWAY_PUBLIC_URL____GW_PUBLIC_URL_PATH__?token=__GATEWAY_TOKEN__" target="_blank" rel="noopener noreferrer">Open Gateway Web UI</a>
<a class="btn secondary" href="./terminal/" target="_self">Open Terminal (full page)</a>
</div>
<div class="muted">
Tip: The gateway UI is intentionally opened outside of Ingress to avoid websocket/proxy issues.
Configure <code>gateway_public_url</code> in the add-on options.
</div>
<div class="term">
<iframe src="./terminal/" title="Terminal"></iframe>
</div>
</div>
</body></html>';
}
# (Optional) Gateway UI via ingress has been intentionally removed.
# See landing page link that opens the gateway in a separate tab.
# Everything else: 404
location / {
return 404;
}
}
}
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env bash
set -euo pipefail
# Home Assistant add-on options are usually rendered to /data/options.json
OPTIONS_FILE="/data/options.json"
if [ ! -f "$OPTIONS_FILE" ]; then
echo "Missing $OPTIONS_FILE (add-on options)."
exit 1
fi
BOT_TOKEN=$(jq -r '.telegram_bot_token // empty' "$OPTIONS_FILE")
TZNAME=$(jq -r '.timezone // "Europe/Sofia"' "$OPTIONS_FILE")
ALLOW_FROM_RAW=$(jq -r '.telegram_allow_from // empty' "$OPTIONS_FILE")
MODEL_PRIMARY=$(jq -r '.model_primary // "openai-codex/gpt-5.2"' "$OPTIONS_FILE")
GW_BIND=$(jq -r '.gateway_bind // "loopback"' "$OPTIONS_FILE")
GW_PORT=$(jq -r '.gateway_port // 18789' "$OPTIONS_FILE")
GW_TOKEN=$(jq -r '.gateway_token // empty' "$OPTIONS_FILE")
GW_PUBLIC_URL=$(jq -r '.gateway_public_url // empty' "$OPTIONS_FILE")
HA_TOKEN=$(jq -r '.homeassistant_token // empty' "$OPTIONS_FILE")
BRAVE_KEY=$(jq -r '.brave_api_key // empty' "$OPTIONS_FILE")
ENABLE_TERMINAL=$(jq -r '.enable_terminal // false' "$OPTIONS_FILE")
# Generic router SSH settings (preferred)
ROUTER_HOST=$(jq -r '.router_ssh_host // empty' "$OPTIONS_FILE")
ROUTER_USER=$(jq -r '.router_ssh_user // empty' "$OPTIONS_FILE")
ROUTER_KEY=$(jq -r '.router_ssh_key_path // "/data/keys/router_ssh"' "$OPTIONS_FILE")
# Backward compatible (deprecated) MikroTik-named settings
MT_HOST=$(jq -r '.mikrotik_host // empty' "$OPTIONS_FILE")
MT_USER=$(jq -r '.mikrotik_ssh_user // empty' "$OPTIONS_FILE")
MT_KEY=$(jq -r '.mikrotik_ssh_key_path // "/data/keys/mikrotik"' "$OPTIONS_FILE")
# Prefer generic router_* values; fall back to mikrotik_* if router_* are empty
[ -z "$ROUTER_HOST" ] && ROUTER_HOST="$MT_HOST"
[ -z "$ROUTER_USER" ] && ROUTER_USER="$MT_USER"
[ "$ROUTER_KEY" = "/data/keys/router_ssh" ] && [ -n "$MT_KEY" ] && ROUTER_KEY="$MT_KEY"
# Optional: allow disabling lock cleanup if you ever need to debug
CLEAN_LOCKS_ON_START=$(jq -r '.clean_session_locks_on_start // true' "$OPTIONS_FILE")
CLEAN_LOCKS_ON_EXIT=$(jq -r '.clean_session_locks_on_exit // true' "$OPTIONS_FILE")
if [ -z "$BOT_TOKEN" ]; then
echo "You must set telegram_bot_token in the add-on configuration."
exit 1
fi
export TZ="$TZNAME"
# Reduce risk of secrets ending up in logs
set +x
# HA add-ons mount persistent storage at /config (maps to /addon_configs/<slug> on the host).
# Use /config as HOME so OpenClaw finds its auth store and config there.
export HOME=/config
mkdir -p /config/.openclaw /config/clawd /config/keys /config/secrets
# Back-compat: some docs/scripts assume /data; point it at /config.
if [ ! -e /data ]; then
ln -s /config /data || true
fi
# Ensure these exist so cleanup doesn't fail
mkdir -p /config/.openclaw/agents/main/sessions || true
# ------------------------------------------------------------------------------
# SINGLE-INSTANCE GUARD (prevents multiple gateway runs racing each other)
# ------------------------------------------------------------------------------
STARTUP_LOCK="/config/.openclaw/gateway.start.lock"
exec 9>"$STARTUP_LOCK"
if ! flock -n 9; then
echo "ERROR: Another instance appears to be running (could not acquire $STARTUP_LOCK)."
echo "If this is wrong, check for stuck processes or remove the lock file."
exit 1
fi
# ------------------------------------------------------------------------------
# Session lock cleanup helpers
# ------------------------------------------------------------------------------
# Returns 0 if a gateway process appears to be running, else 1
gateway_running() {
pgrep -f "openclaw.*gateway.*run" >/dev/null 2>&1
}
cleanup_session_locks() {
local sessions_dir="/config/.openclaw/agents/main/sessions"
local glob1="${sessions_dir}"/*.jsonl.lock
shopt -s nullglob
local locks=( $glob1 )
shopt -u nullglob
if [ ${#locks[@]} -eq 0 ]; then
return 0
fi
# If gateway is running, do NOT remove locks automatically (could be real).
if gateway_running; then
echo "INFO: Gateway appears to be running; leaving session lock files untouched."
echo "INFO: Locks present: ${#locks[@]}"
return 0
fi
echo "INFO: Removing stale session lock files (${#locks[@]}) from ${sessions_dir}"
rm -f "${sessions_dir}"/*.jsonl.lock || true
}
# Cleanup on start (stale locks after crashes/restarts)
if [ "$CLEAN_LOCKS_ON_START" = "true" ]; then
cleanup_session_locks
else
echo "INFO: clean_session_locks_on_start=false; skipping session lock cleanup."
fi
# ------------------------------------------------------------------------------
# Store tokens / export env vars (optional)
# ------------------------------------------------------------------------------
# Home Assistant long-lived token (for local HA API scripts/tools)
if [ -n "$HA_TOKEN" ]; then
umask 077
printf '%s' "$HA_TOKEN" > /config/secrets/homeassistant.token
fi
# Brave Search API key (for OpenClaw's web_search tool, which reads BRAVE_API_KEY)
if [ -n "$BRAVE_KEY" ]; then
export BRAVE_API_KEY="$BRAVE_KEY"
umask 077
printf '%s' "$BRAVE_KEY" > /config/secrets/brave_api_key
fi
# Decide Telegram DM access policy.
DM_POLICY="pairing"
ALLOW_FROM_JSON=""
if [ -n "$ALLOW_FROM_RAW" ]; then
DM_POLICY="allowlist"
# convert "1,2, 3" -> ["1","2","3"] using jq (no python dependency)
ALLOW_FROM_JSON=$(printf '%s' "$ALLOW_FROM_RAW" | jq -R 'split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length>0))')
fi
# Validate gateway exposure settings
if [ "$GW_BIND" = "lan" ] && [ -z "$GW_TOKEN" ]; then
echo "ERROR: gateway_bind=lan requires gateway_token to be set (do not expose an unauthenticated gateway)."
exit 1
fi
GW_AUTH_BLOCK="auth: { mode: \"token\", token: \"${GW_TOKEN}\" }"
if [ -z "$GW_TOKEN" ]; then
# Let doctor generate one (loopback-only is still protected by local access)
GW_AUTH_BLOCK="auth: { mode: \"token\" }"
fi
# Write gateway config (JSON5).
mkdir -p /config/.openclaw
cat > /config/.openclaw/openclaw.json <<EOF
{
discovery: { wideArea: { enabled: false } },
gateway: {
mode: "local",
bind: "${GW_BIND}",
port: ${GW_PORT},
controlUi: { allowInsecureAuth: true },
${GW_AUTH_BLOCK}
},
agents: {
defaults: {
workspace: "/config/clawd",
model: { primary: "${MODEL_PRIMARY}" },
models: {
"${MODEL_PRIMARY}": {}
}
},
list: [
{ id: "main" }
]
},
channels: {
telegram: {
enabled: true,
botToken: "${BOT_TOKEN}",
dmPolicy: "${DM_POLICY}"${ALLOW_FROM_RAW:+,
allowFrom: ${ALLOW_FROM_JSON}}
}
}
}
EOF
# OpenClaw reads its config from /config/.openclaw/openclaw.json
echo "Model primary=${MODEL_PRIMARY}"
echo "Gateway bind=${GW_BIND} port=${GW_PORT} token=${GW_TOKEN:+(set)}${GW_TOKEN:-(auto)}"
echo "Telegram dmPolicy=${DM_POLICY}${ALLOW_FROM_RAW:+ (allowFrom=${ALLOW_FROM_RAW})}"
echo "Telegram allowFrom JSON: ${ALLOW_FROM_JSON:-<none>}"
# Auth store debug (redacted): never print tokens
AUTH_STORE="/config/.openclaw/agents/main/agent/auth-profiles.json"
if [ -f "$AUTH_STORE" ]; then
echo "Auth store present at $AUTH_STORE"
echo "Auth store summary (redacted):"
jq -r '"version="+((.version//"?")|tostring),
"profiles="+(((.profiles//{})|keys|join(","))),
"providers="+(((.profiles//{})|to_entries|map(.value.provider // "?")|unique|join(",")))' "$AUTH_STORE" 2>/dev/null || echo "(could not parse auth store JSON)"
else
echo "Auth store not found at $AUTH_STORE"
fi
echo "Sanity check: DNS + Telegram API reachability"
if curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null; then
echo "OK: api.telegram.org reachable"
else
echo "WARN: api.telegram.org not reachable from add-on container"
fi
# Validate token (print only bot username)
BOT_USER=$(curl -fsS --max-time 10 "https://api.telegram.org/bot${BOT_TOKEN}/getMe" | jq -r '.result.username // empty' || true)
if [ -n "$BOT_USER" ]; then
echo "OK: Telegram token valid for @${BOT_USER}"
else
echo "WARN: Telegram token validation failed (getMe)"
fi
# Convenience info for later (router SSH access path & HA token file)
cat > /config/CONNECTION_NOTES.txt <<EOF
Home Assistant token (if set): /config/secrets/homeassistant.token
Router SSH (generic):
host=${ROUTER_HOST}
user=${ROUTER_USER}
key=${ROUTER_KEY}
EOF
RUN_DOCTOR=$(jq -r '.run_doctor_on_start // false' "$OPTIONS_FILE")
if [ "$RUN_DOCTOR" = "true" ]; then
echo "Running assistant doctor (auto-fix) ..."
(timeout 60s "${CLI_BIN:-openclaw}" doctor --fix --yes) || true
else
echo "Skipping doctor on startup (run_doctor_on_start=false)"
fi
# ------------------------------------------------------------------------------
# Graceful shutdown handling (PID 1 trap) to reduce stale locks
# ------------------------------------------------------------------------------
GW_PID=""
shutdown() {
echo "Shutdown requested; stopping services..."
if [ -n "${NGINX_PID}" ] && kill -0 "${NGINX_PID}" >/dev/null 2>&1; then
kill -TERM "${NGINX_PID}" >/dev/null 2>&1 || true
wait "${NGINX_PID}" || true
fi
if [ -n "${TTYD_PID}" ] && kill -0 "${TTYD_PID}" >/dev/null 2>&1; then
kill -TERM "${TTYD_PID}" >/dev/null 2>&1 || true
wait "${TTYD_PID}" || true
fi
if [ -n "${GW_PID}" ] && kill -0 "${GW_PID}" >/dev/null 2>&1; then
kill -TERM "${GW_PID}" >/dev/null 2>&1 || true
wait "${GW_PID}" || true
fi
if [ "$CLEAN_LOCKS_ON_EXIT" = "true" ]; then
cleanup_session_locks || true
fi
}
trap shutdown INT TERM
NGINX_PID=""
TTYD_PID=""
CLI_BIN=""
if command -v openclaw >/dev/null 2>&1; then
CLI_BIN="openclaw"
else
echo "ERROR: openclaw is not installed."
exit 1
fi
echo "Starting OpenClaw Assistant gateway (${CLI_BIN}-compatible)..."
"${CLI_BIN}" gateway run &
GW_PID=$!
# Start web terminal (optional)
if [ "$ENABLE_TERMINAL" = "true" ]; then
echo "Starting web terminal (ttyd) on 127.0.0.1:7681 ..."
# -W: allow clients to write (interactive)
# -p: port
# bind localhost only; exposed to HA via ingress reverse proxy
# ttyd is writable by default; use -R for read-only. (Some builds don't support -W)
# -b sets the base path so it works behind Ingress (/terminal/)
ttyd -i 127.0.0.1 -p 7681 -b /terminal bash &
TTYD_PID=$!
else
echo "Terminal disabled (enable_terminal=false)"
fi
# Start ingress reverse proxy (nginx). This provides the add-on UI inside HA.
# Token is injected server-side; never put it in the browser URL.
# Render nginx config from template with the gateway token.
# NOTE: This intentionally exposes the token in the browser URL via a redirect.
# This matches OpenClaw Control UI's current expectations.
GW_TOKEN="$GW_TOKEN" GW_PUBLIC_URL="$GW_PUBLIC_URL" python3 - <<'PY'
import os
from pathlib import Path
tpl = Path('/etc/nginx/nginx.conf.tpl').read_text()
token = os.environ.get('GW_TOKEN','')
public_url = os.environ.get('GW_PUBLIC_URL','')
# Normalize gateway_public_url for building the link: ensure there is exactly one slash
# between base URL and path. We keep the displayed base as-is, but compute a safe suffix.
# If base ends with '/', use empty suffix. Otherwise use '/'.
conf = tpl.replace('__GATEWAY_TOKEN__', token)
conf = conf.replace('__GATEWAY_PUBLIC_URL__', public_url)
conf = conf.replace('__GW_PUBLIC_URL_PATH__', '' if public_url.endswith('/') else '/')
Path('/etc/nginx/nginx.conf').write_text(conf)
PY
echo "Starting ingress proxy (nginx) on :8099 ..."
nginx -g 'daemon off;' &
NGINX_PID=$!
# Wait for gateway; if it exits, shut down others.
wait "${GW_PID}"