somratpro Claude Opus 4.7 commited on
Commit
fe9380c
Β·
1 Parent(s): db85721

Harden config build, speed up startup, add healthcheck

Browse files

start.sh:
- CLOUDFLARE_PROXY_DEBUG defaults to false (Gemini path verified working;
per-request "Redirecting" log is no longer needed by default).
- Replace shell-interpolated jq calls with --arg/--argjson for
GATEWAY_TOKEN, LLM_MODEL, OPENCLAW_PASSWORD, BROWSER_EXECUTABLE_PATH,
SPACE_HOST, TELEGRAM_USER_ID. Prevents JSON breakage and jq filter
injection if those values contain quotes/backslashes/dollar signs.
- Combine ~6 sequential jq invocations (token + model + logging,
plugin allow/deny + entry toggles, controlUi + password) into single
pipelines. Saves several hundred ms of subprocess overhead on cold start.
- Document why device-pair/phone-control/talk-voice are pre-allowed and
why lmstudio/xai PLUGINS (not the xai model provider) are denied.
- Replace `sleep 3 && kill -0 $!` readiness check with a TCP poll on
127.0.0.1:7860 (configurable via GATEWAY_READY_TIMEOUT, default 90s)
that also bails out if the pipeline died. Old check tested the tee PID
in the pipeline, not openclaw, so late crashes went undetected.
- Drop remaining emoji prefixes from error/warning prints.

cloudflare-proxy.js:
- Tighten the require() hook so undici patching only fires for the bare
"undici" id or paths whose final package segment is undici. The old
substring check `id.includes("/undici/")` would also catch unrelated
packages like "super-undici-x".

cloudflare-proxy-setup.py:
- Always write CF_PROXY_ENV_FILE when CLOUDFLARE_PROXY_URL is provided,
even with an empty CLOUDFLARE_PROXY_SECRET, so start.sh's
`. $CF_PROXY_ENV_FILE` reliably exports the URL. Print a warning when
the URL is set without a secret (silent 401 is the worst failure mode).
- chmod 0600 the env file explicitly (umask 0077 should already cover
it; this is belt-and-suspenders since the file holds the worker
shared secret).

workspace-sync.py:
- After restoring WhatsApp credentials, walk the directory and chmod
0700 on dirs / 0600 on files so session secrets aren't world-readable.

Dockerfile:
- Add HEALTHCHECK against http://localhost:7861/health (health-server
proxies to the gateway). 90s start-period covers cold-start plugin
install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Files changed (5) hide show
  1. Dockerfile +5 -0
  2. cloudflare-proxy-setup.py +23 -7
  3. cloudflare-proxy.js +7 -1
  4. start.sh +111 -61
  5. workspace-sync.py +10 -0
Dockerfile CHANGED
@@ -85,4 +85,9 @@ WORKDIR /home/node/app
85
 
86
  EXPOSE 7861
87
 
 
 
 
 
 
88
  CMD ["/home/node/app/start.sh"]
 
85
 
86
  EXPOSE 7861
87
 
88
+ # health-server.js exposes /health on 7861 and proxies to the gateway on 7860.
89
+ # 90s start period covers OpenClaw's plugin install + gateway boot on cold start.
90
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
91
+ CMD curl -fsS http://localhost:7861/health || exit 1
92
+
93
  CMD ["/home/node/app/start.sh"]
cloudflare-proxy-setup.py CHANGED
@@ -154,6 +154,12 @@ def write_env(proxy_url: str, proxy_secret: str) -> None:
154
  + "\n",
155
  encoding="utf-8",
156
  )
 
 
 
 
 
 
157
 
158
 
159
  def main() -> int:
@@ -162,10 +168,20 @@ def main() -> int:
162
  api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
163
 
164
  if existing_url:
165
- if existing_secret:
 
 
 
166
  write_env(existing_url, existing_secret)
167
- print(f"☁️ Using configured Cloudflare proxy: {existing_url}")
168
- return 0
 
 
 
 
 
 
 
169
 
170
  if not api_token:
171
  return 0
@@ -214,22 +230,22 @@ def main() -> int:
214
 
215
  proxy_url = f"https://{worker_name}.{subdomain}.workers.dev"
216
  write_env(proxy_url, proxy_secret)
217
- print(f"☁️ Cloudflare proxy ready: {proxy_url}")
218
  return 0
219
  except urllib.error.HTTPError as error:
220
  detail = error.read().decode("utf-8", errors="replace")
221
  if error.code == 403 and '"code":9109' in detail:
222
  print(
223
- "☁️ Cloudflare proxy setup failed: invalid Workers token. "
224
  "Use a Cloudflare API Token in CLOUDFLARE_WORKERS_TOKEN "
225
  "(not a Global API Key, tunnel token, or worker secret). "
226
  "For auto-setup, it should have account-level 'Workers Scripts: Edit'. "
227
  "The setup can auto-discover your account; CLOUDFLARE_ACCOUNT_ID is not required."
228
  )
229
- print(f"☁️ Cloudflare proxy setup failed: HTTP {error.code} {detail}")
230
  return 1
231
  except Exception as error:
232
- print(f"☁️ Cloudflare proxy setup failed: {error}")
233
  return 1
234
 
235
 
 
154
  + "\n",
155
  encoding="utf-8",
156
  )
157
+ # Belt-and-suspenders: even with umask 0077 on the parent shell, force
158
+ # 0600 since the file holds the worker shared secret.
159
+ try:
160
+ ENV_FILE.chmod(0o600)
161
+ except OSError:
162
+ pass
163
 
164
 
165
  def main() -> int:
 
168
  api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
169
 
170
  if existing_url:
171
+ # Always write the env file so downstream `. $CF_PROXY_ENV_FILE` in
172
+ # start.sh has CLOUDFLARE_PROXY_URL set even when no secret was
173
+ # supplied. Empty secret means we send no x-proxy-key header β€” that
174
+ # only works if the deployed worker also has no secret baked in.
175
  write_env(existing_url, existing_secret)
176
+ if not existing_secret:
177
+ print(
178
+ "Warning: CLOUDFLARE_PROXY_URL is set but CLOUDFLARE_PROXY_SECRET "
179
+ "is empty. Requests will succeed only if the deployed worker "
180
+ "was built without PROXY_SHARED_SECRET; otherwise you'll see "
181
+ "401 Unauthorized."
182
+ )
183
+ print(f"Using configured Cloudflare proxy: {existing_url}")
184
+ return 0
185
 
186
  if not api_token:
187
  return 0
 
230
 
231
  proxy_url = f"https://{worker_name}.{subdomain}.workers.dev"
232
  write_env(proxy_url, proxy_secret)
233
+ print(f"Cloudflare proxy ready: {proxy_url}")
234
  return 0
235
  except urllib.error.HTTPError as error:
236
  detail = error.read().decode("utf-8", errors="replace")
237
  if error.code == 403 and '"code":9109' in detail:
238
  print(
239
+ "Cloudflare proxy setup failed: invalid Workers token. "
240
  "Use a Cloudflare API Token in CLOUDFLARE_WORKERS_TOKEN "
241
  "(not a Global API Key, tunnel token, or worker secret). "
242
  "For auto-setup, it should have account-level 'Workers Scripts: Edit'. "
243
  "The setup can auto-discover your account; CLOUDFLARE_ACCOUNT_ID is not required."
244
  )
245
+ print(f"Cloudflare proxy setup failed: HTTP {error.code} {detail}")
246
  return 1
247
  except Exception as error:
248
+ print(f"Cloudflare proxy setup failed: {error}")
249
  return 1
250
 
251
 
cloudflare-proxy.js CHANGED
@@ -337,11 +337,17 @@ if (PROXY_URL) {
337
  patchUndiciInstance(undici);
338
  } catch (e) {}
339
 
 
 
 
 
 
340
  const Module = require("module");
341
  const originalRequire = Module.prototype.require;
 
342
  Module.prototype.require = function (id) {
343
  const exports = originalRequire.apply(this, arguments);
344
- if (id === "undici" || id.includes("/undici/")) {
345
  try { patchUndiciInstance(exports); } catch (e) {}
346
  }
347
  return exports;
 
337
  patchUndiciInstance(undici);
338
  } catch (e) {}
339
 
340
+ // Hook require() to patch any undici instance the moment it loads.
341
+ // Match either the bare "undici" id or paths whose final package
342
+ // segment IS undici (e.g. "/foo/node_modules/undici/index.js"). The
343
+ // earlier substring check `id.includes("/undici/")` would also match
344
+ // unrelated packages like "super-undici-x".
345
  const Module = require("module");
346
  const originalRequire = Module.prototype.require;
347
+ const UNDICI_PATH_RE = /(?:^|\/)node_modules\/undici(?:\/|$)/;
348
  Module.prototype.require = function (id) {
349
  const exports = originalRequire.apply(this, arguments);
350
+ if (id === "undici" || UNDICI_PATH_RE.test(id)) {
351
  try { patchUndiciInstance(exports); } catch (e) {}
352
  }
353
  return exports;
start.sh CHANGED
@@ -38,13 +38,13 @@ echo ""
38
  # ── Validate required secrets ──
39
  ERRORS=""
40
  if [ -z "$LLM_API_KEY" ]; then
41
- ERRORS="${ERRORS} ❌ LLM_API_KEY is not set\n"
42
  fi
43
  if [ -z "$LLM_MODEL" ]; then
44
- ERRORS="${ERRORS} ❌ LLM_MODEL is not set (e.g. google/gemini-2.5-flash, anthropic/claude-sonnet-4-5, openai/gpt-4)\n"
45
  fi
46
  if [ -z "$GATEWAY_TOKEN" ]; then
47
- ERRORS="${ERRORS} ❌ GATEWAY_TOKEN is not set (generate: openssl rand -hex 32)\n"
48
  fi
49
  if [ -n "$ERRORS" ]; then
50
  echo "Missing required secrets:"
@@ -73,7 +73,7 @@ fi
73
  # Auto-correct Gemini models to use google/ prefix if anthropic/ was mistakenly used
74
  if [[ "$LLM_MODEL" == "anthropic/gemini"* ]]; then
75
  LLM_MODEL=$(echo "$LLM_MODEL" | sed 's/^anthropic\//google\//')
76
- echo "⚠️ Corrected model from anthropic/gemini* to google/gemini*"
77
  fi
78
 
79
  # Extract provider prefix from model name (e.g. "google/gemini-2.5-flash" β†’ "google")
@@ -146,7 +146,9 @@ export CLOUDFLARE_WORKERS_TOKEN
146
  CF_PROXY_ENV_FILE="/tmp/huggingclaw-cloudflare-proxy.env"
147
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
148
  export CLOUDFLARE_PROXY_DOMAINS="${CLOUDFLARE_PROXY_DOMAINS:-api.telegram.org,web.whatsapp.com,googleapis.com}"
149
- export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-true}"
 
 
150
  echo "Preparing Cloudflare outbound proxy..."
151
  python3 /home/node/app/cloudflare-proxy-setup.py || true
152
  if [ -f "$CF_PROXY_ENV_FILE" ]; then
@@ -183,12 +185,20 @@ CONFIG_JSON=$(cat <<'CONFIGEOF'
183
  CONFIGEOF
184
  )
185
 
186
- # Gateway token
187
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.auth.token = \"$GATEWAY_TOKEN\"")
188
-
189
- # Model configuration at top level
190
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".agents.defaults.model = \"$LLM_MODEL\"")
191
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".logging.level = \"$OPENCLAW_FILE_LOG_LEVEL\" | .logging.consoleLevel = \"$OPENCLAW_CONSOLE_LOG_LEVEL\" | .logging.consoleStyle = \"$OPENCLAW_CONSOLE_LOG_STYLE\"")
 
 
 
 
 
 
 
 
192
 
193
  # Optional: dynamic custom OpenAI-compatible provider registration
194
  CUSTOM_PROVIDER_NAME="${CUSTOM_PROVIDER_NAME:-}"
@@ -206,29 +216,29 @@ if [ -n "$CUSTOM_PROVIDER_NAME" ] || [ -n "$CUSTOM_BASE_URL" ] || [ -n "$CUSTOM_
206
  CUSTOM_PROVIDER_OK=true
207
 
208
  if [ -z "$CUSTOM_PROVIDER_NAME" ] || [ -z "$CUSTOM_BASE_URL" ] || [ -z "$CUSTOM_MODEL_ID" ]; then
209
- echo "⚠️ Custom provider skipped: set CUSTOM_PROVIDER_NAME, CUSTOM_BASE_URL, and CUSTOM_MODEL_ID together."
210
  CUSTOM_PROVIDER_OK=false
211
  fi
212
 
213
  case "$CUSTOM_PROVIDER_NORMALIZED" in
214
  anthropic|openai|openai-codex|google|google-vertex|deepseek|opencode|opencode-go|openrouter|kilocode|vercel-ai-gateway|zai|z-ai|z.ai|zhipu|moonshot|kimi-coding|minimax|qwen|modelstudio|xiaomi|volcengine|volcengine-plan|byteplus|byteplus-plan|qianfan|mistral|mistralai|xai|x-ai|nvidia|cohere|groq|together|huggingface|cerebras|venice|synthetic|github-copilot)
215
- echo "⚠️ Custom provider skipped: CUSTOM_PROVIDER_NAME='$CUSTOM_PROVIDER_NAME' conflicts with a built-in provider."
216
  CUSTOM_PROVIDER_OK=false
217
  ;;
218
  esac
219
 
220
  if [[ "$CUSTOM_BASE_URL_NORMALIZED" == */chat/completions ]] || [[ "$CUSTOM_BASE_URL_NORMALIZED" == */completions ]]; then
221
- echo "⚠️ Custom provider skipped: CUSTOM_BASE_URL should be the API base URL, not a completions endpoint."
222
  CUSTOM_PROVIDER_OK=false
223
  fi
224
 
225
  if ! [[ "$CUSTOM_CONTEXT_WINDOW" =~ ^[0-9]+$ ]] || ! [[ "$CUSTOM_MAX_TOKENS" =~ ^[0-9]+$ ]]; then
226
- echo "⚠️ Custom provider skipped: CUSTOM_CONTEXT_WINDOW and CUSTOM_MAX_TOKENS must be whole numbers."
227
  CUSTOM_PROVIDER_OK=false
228
  fi
229
 
230
  if [ "$CUSTOM_PROVIDER_OK" = "true" ]; then
231
- echo "πŸ”§ Registering custom provider: $CUSTOM_PROVIDER_NAME β†’ $CUSTOM_BASE_URL_NORMALIZED"
232
  CONFIG_JSON=$(jq \
233
  --arg provider "$CUSTOM_PROVIDER_NAME" \
234
  --arg baseUrl "$CUSTOM_BASE_URL_NORMALIZED" \
@@ -252,7 +262,7 @@ if [ -n "$CUSTOM_PROVIDER_NAME" ] || [ -n "$CUSTOM_BASE_URL" ] || [ -n "$CUSTOM_
252
  }' <<<"$CONFIG_JSON")
253
 
254
  if [[ "$LLM_MODEL" != "$CUSTOM_PROVIDER_NAME/"* ]]; then
255
- echo "⚠️ Custom provider registered, but LLM_MODEL='$LLM_MODEL' does not start with '$CUSTOM_PROVIDER_NAME/'."
256
  fi
257
  fi
258
  fi
@@ -273,7 +283,15 @@ elif [ "$BROWSER_PLUGIN_MODE" = "auto" ] && [ -n "$BROWSER_EXECUTABLE_PATH" ] &&
273
  BROWSER_SHOULD_ENABLE=true
274
  fi
275
 
276
- # Restrict bundled plugin loading on HF Spaces so unrelated broken plugins do not crash the gateway after startup.
 
 
 
 
 
 
 
 
277
  PLUGIN_ALLOW_JSON='["device-pair","phone-control","talk-voice"]'
278
  if [ "$ACP_PLUGIN_MODE" = "enabled" ] || [ "$ACP_PLUGIN_MODE" = "auto" ]; then
279
  PLUGIN_ALLOW_JSON=$(jq '. + ["acpx"]' <<<"$PLUGIN_ALLOW_JSON")
@@ -287,41 +305,52 @@ fi
287
  if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
288
  PLUGIN_ALLOW_JSON=$(jq '. + ["whatsapp"]' <<<"$PLUGIN_ALLOW_JSON")
289
  fi
290
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".plugins.allow = $PLUGIN_ALLOW_JSON")
291
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.deny = ["lmstudio","xai"]')
292
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.lmstudio.enabled = false | .plugins.entries.xai.enabled = false')
293
 
294
- if [ "$ACP_PLUGIN_MODE" = "disabled" ]; then
295
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.acpx.enabled = false')
296
- fi
297
-
298
- if [ "$BROWSER_SHOULD_ENABLE" != "true" ]; then
299
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.browser.enabled = false | .browser.enabled = false')
300
- fi
 
 
 
 
 
 
 
 
 
 
 
301
 
302
  if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then
303
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq \
304
- ".browser = {
305
- \"enabled\": true,
306
- \"defaultProfile\": \"openclaw\",
307
- \"headless\": true,
308
- \"noSandbox\": true,
309
- \"executablePath\": \"$BROWSER_EXECUTABLE_PATH\"
310
- } | .agents.defaults.sandbox.browser.allowHostControl = true")
311
- fi
312
-
313
- # Control UI origin (allow HF Space URL for web UI access)
314
- if [ -n "${SPACE_HOST:-}" ]; then
315
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.allowedOrigins = [\"https://${SPACE_HOST}\"]")
316
- fi
317
-
318
- # Disable device auth (pairing) for headless Docker β€” token-only auth
319
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.dangerouslyDisableDeviceAuth = true")
320
-
321
- # Password auth (optional β€” simpler alternative to token for casual users)
322
- if [ -n "${OPENCLAW_PASSWORD:-}" ]; then
323
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.auth.mode = \"password\" | .gateway.auth.password = \"$OPENCLAW_PASSWORD\"")
324
- fi
 
 
 
325
 
326
  # Trusted proxies (optional β€” fixes "Proxy headers detected from untrusted address" on HF Spaces)
327
  # Set TRUSTED_PROXIES as comma-separated IPs/CIDRs, e.g. "10.20.31.87,10.20.26.157"
@@ -365,12 +394,16 @@ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
365
  ')
366
 
367
  if [ -n "${TELEGRAM_USER_IDS:-}" ]; then
368
- # Convert comma-separated IDs to JSON array
369
  IDS_JSON=$(echo "$TELEGRAM_USER_IDS" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -R . | jq -s .)
370
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".channels.telegram += {\"dmPolicy\": \"allowlist\", \"allowFrom\": $IDS_JSON}")
 
 
371
  elif [ -n "${TELEGRAM_USER_ID:-}" ]; then
372
- # Single user (backward compatible)
373
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".channels.telegram += {\"dmPolicy\": \"allowlist\", \"allowFrom\": [\"$TELEGRAM_USER_ID\"]}")
 
 
374
  fi
375
  fi
376
 
@@ -445,13 +478,13 @@ warmup_browser() {
445
  for attempt in 1 2 3 4 5; do
446
  if openclaw browser --browser-profile openclaw start >/dev/null 2>&1; then
447
  openclaw browser --browser-profile openclaw open about:blank >/dev/null 2>&1 || true
448
- echo " βœ… Managed browser ready"
449
  return 0
450
  fi
451
  sleep 2
452
  done
453
 
454
- echo " ⚠️ Managed browser warm-up did not complete; first browser action may need a retry"
455
  ) &
456
  }
457
 
@@ -470,15 +503,32 @@ if [ "${GATEWAY_VERBOSE:-0}" = "1" ]; then
470
  echo "Gateway verbose logging enabled (GATEWAY_VERBOSE=1)"
471
  fi
472
 
473
- # Use stdbuf -oL -eL to ensure logs are not buffered and appear immediately in the console
 
 
 
474
  stdbuf -oL -eL openclaw "${GATEWAY_ARGS[@]}" 2>&1 | tee -a /home/node/.openclaw/gateway.log &
475
  GATEWAY_PID=$!
476
 
477
- # Wait a moment for startup errors
478
- sleep 3
479
- if ! kill -0 $GATEWAY_PID 2>/dev/null; then
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  echo ""
481
- echo "❌ Gateway failed to start. Last 30 lines of log:"
482
  echo "────────────────────────────────────────────"
483
  tail -30 /home/node/.openclaw/gateway.log
484
  exit 1
 
38
  # ── Validate required secrets ──
39
  ERRORS=""
40
  if [ -z "$LLM_API_KEY" ]; then
41
+ ERRORS="${ERRORS} - LLM_API_KEY is not set\n"
42
  fi
43
  if [ -z "$LLM_MODEL" ]; then
44
+ ERRORS="${ERRORS} - LLM_MODEL is not set (e.g. google/gemini-2.5-flash, anthropic/claude-sonnet-4-5, openai/gpt-4)\n"
45
  fi
46
  if [ -z "$GATEWAY_TOKEN" ]; then
47
+ ERRORS="${ERRORS} - GATEWAY_TOKEN is not set (generate: openssl rand -hex 32)\n"
48
  fi
49
  if [ -n "$ERRORS" ]; then
50
  echo "Missing required secrets:"
 
73
  # Auto-correct Gemini models to use google/ prefix if anthropic/ was mistakenly used
74
  if [[ "$LLM_MODEL" == "anthropic/gemini"* ]]; then
75
  LLM_MODEL=$(echo "$LLM_MODEL" | sed 's/^anthropic\//google\//')
76
+ echo "Note: corrected model from anthropic/gemini* to google/gemini*"
77
  fi
78
 
79
  # Extract provider prefix from model name (e.g. "google/gemini-2.5-flash" β†’ "google")
 
146
  CF_PROXY_ENV_FILE="/tmp/huggingclaw-cloudflare-proxy.env"
147
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
148
  export CLOUDFLARE_PROXY_DOMAINS="${CLOUDFLARE_PROXY_DOMAINS:-api.telegram.org,web.whatsapp.com,googleapis.com}"
149
+ # Default debug off for production. Set CLOUDFLARE_PROXY_DEBUG=true in HF
150
+ # Space secrets to surface per-request "Redirecting" + error-cause logs.
151
+ export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
152
  echo "Preparing Cloudflare outbound proxy..."
153
  python3 /home/node/app/cloudflare-proxy-setup.py || true
154
  if [ -f "$CF_PROXY_ENV_FILE" ]; then
 
185
  CONFIGEOF
186
  )
187
 
188
+ # Apply gateway token, model, and logging in a single jq pass.
189
+ # Uses --arg so values containing quotes/backslashes can't break the JSON or
190
+ # inject jq filters (relevant for OPENCLAW_PASSWORD/GATEWAY_TOKEN below too).
191
+ CONFIG_JSON=$(jq \
192
+ --arg token "$GATEWAY_TOKEN" \
193
+ --arg model "$LLM_MODEL" \
194
+ --arg fileLevel "$OPENCLAW_FILE_LOG_LEVEL" \
195
+ --arg consoleLevel "$OPENCLAW_CONSOLE_LOG_LEVEL" \
196
+ --arg consoleStyle "$OPENCLAW_CONSOLE_LOG_STYLE" \
197
+ '.gateway.auth.token = $token
198
+ | .agents.defaults.model = $model
199
+ | .logging.level = $fileLevel
200
+ | .logging.consoleLevel = $consoleLevel
201
+ | .logging.consoleStyle = $consoleStyle' <<<"$CONFIG_JSON")
202
 
203
  # Optional: dynamic custom OpenAI-compatible provider registration
204
  CUSTOM_PROVIDER_NAME="${CUSTOM_PROVIDER_NAME:-}"
 
216
  CUSTOM_PROVIDER_OK=true
217
 
218
  if [ -z "$CUSTOM_PROVIDER_NAME" ] || [ -z "$CUSTOM_BASE_URL" ] || [ -z "$CUSTOM_MODEL_ID" ]; then
219
+ echo "Warning: custom provider skipped: set CUSTOM_PROVIDER_NAME, CUSTOM_BASE_URL, and CUSTOM_MODEL_ID together."
220
  CUSTOM_PROVIDER_OK=false
221
  fi
222
 
223
  case "$CUSTOM_PROVIDER_NORMALIZED" in
224
  anthropic|openai|openai-codex|google|google-vertex|deepseek|opencode|opencode-go|openrouter|kilocode|vercel-ai-gateway|zai|z-ai|z.ai|zhipu|moonshot|kimi-coding|minimax|qwen|modelstudio|xiaomi|volcengine|volcengine-plan|byteplus|byteplus-plan|qianfan|mistral|mistralai|xai|x-ai|nvidia|cohere|groq|together|huggingface|cerebras|venice|synthetic|github-copilot)
225
+ echo "Warning: custom provider skipped: CUSTOM_PROVIDER_NAME='$CUSTOM_PROVIDER_NAME' conflicts with a built-in provider."
226
  CUSTOM_PROVIDER_OK=false
227
  ;;
228
  esac
229
 
230
  if [[ "$CUSTOM_BASE_URL_NORMALIZED" == */chat/completions ]] || [[ "$CUSTOM_BASE_URL_NORMALIZED" == */completions ]]; then
231
+ echo "Warning: custom provider skipped: CUSTOM_BASE_URL should be the API base URL, not a completions endpoint."
232
  CUSTOM_PROVIDER_OK=false
233
  fi
234
 
235
  if ! [[ "$CUSTOM_CONTEXT_WINDOW" =~ ^[0-9]+$ ]] || ! [[ "$CUSTOM_MAX_TOKENS" =~ ^[0-9]+$ ]]; then
236
+ echo "Warning: custom provider skipped: CUSTOM_CONTEXT_WINDOW and CUSTOM_MAX_TOKENS must be whole numbers."
237
  CUSTOM_PROVIDER_OK=false
238
  fi
239
 
240
  if [ "$CUSTOM_PROVIDER_OK" = "true" ]; then
241
+ echo "Registering custom provider: $CUSTOM_PROVIDER_NAME -> $CUSTOM_BASE_URL_NORMALIZED"
242
  CONFIG_JSON=$(jq \
243
  --arg provider "$CUSTOM_PROVIDER_NAME" \
244
  --arg baseUrl "$CUSTOM_BASE_URL_NORMALIZED" \
 
262
  }' <<<"$CONFIG_JSON")
263
 
264
  if [[ "$LLM_MODEL" != "$CUSTOM_PROVIDER_NAME/"* ]]; then
265
+ echo "Warning: custom provider registered, but LLM_MODEL='$LLM_MODEL' does not start with '$CUSTOM_PROVIDER_NAME/'."
266
  fi
267
  fi
268
  fi
 
283
  BROWSER_SHOULD_ENABLE=true
284
  fi
285
 
286
+ # Plugin allow/deny rationale:
287
+ # ALLOW: device-pair, phone-control, talk-voice are the minimum bundled
288
+ # plugins that the Control UI/dashboard needs to render correctly
289
+ # on HF Spaces. Without these the UI shows blank panels.
290
+ # telegram/whatsapp/browser/acpx are added conditionally below.
291
+ # DENY: lmstudio crashes on boot when no local server is reachable;
292
+ # xai PLUGIN (separate from the xai model PROVIDER) is broken in
293
+ # current OpenClaw releases and prevents gateway start. Disabling
294
+ # the plugin does NOT affect xai-as-a-model-provider.
295
  PLUGIN_ALLOW_JSON='["device-pair","phone-control","talk-voice"]'
296
  if [ "$ACP_PLUGIN_MODE" = "enabled" ] || [ "$ACP_PLUGIN_MODE" = "auto" ]; then
297
  PLUGIN_ALLOW_JSON=$(jq '. + ["acpx"]' <<<"$PLUGIN_ALLOW_JSON")
 
305
  if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
306
  PLUGIN_ALLOW_JSON=$(jq '. + ["whatsapp"]' <<<"$PLUGIN_ALLOW_JSON")
307
  fi
 
 
 
308
 
309
+ # Apply plugin allow/deny + per-entry toggles in one jq pass.
310
+ ACPX_DISABLED=false
311
+ if [ "$ACP_PLUGIN_MODE" = "disabled" ]; then ACPX_DISABLED=true; fi
312
+ BROWSER_DISABLED=true
313
+ if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then BROWSER_DISABLED=false; fi
314
+
315
+ CONFIG_JSON=$(jq \
316
+ --argjson allow "$PLUGIN_ALLOW_JSON" \
317
+ --argjson acpxDisabled "$ACPX_DISABLED" \
318
+ --argjson browserDisabled "$BROWSER_DISABLED" \
319
+ '.plugins.allow = $allow
320
+ | .plugins.deny = ["lmstudio","xai"]
321
+ | .plugins.entries.lmstudio.enabled = false
322
+ | .plugins.entries.xai.enabled = false
323
+ | (if $acpxDisabled then .plugins.entries.acpx.enabled = false else . end)
324
+ | (if $browserDisabled then
325
+ .plugins.entries.browser.enabled = false | .browser.enabled = false
326
+ else . end)' <<<"$CONFIG_JSON")
327
 
328
  if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then
329
+ CONFIG_JSON=$(jq \
330
+ --arg execPath "$BROWSER_EXECUTABLE_PATH" \
331
+ '.browser = {
332
+ "enabled": true,
333
+ "defaultProfile": "openclaw",
334
+ "headless": true,
335
+ "noSandbox": true,
336
+ "executablePath": $execPath
337
+ }
338
+ | .agents.defaults.sandbox.browser.allowHostControl = true' <<<"$CONFIG_JSON")
339
+ fi
340
+
341
+ # Control UI origin (allow HF Space URL for web UI access).
342
+ # Disable device auth (pairing) for headless Docker β€” token-only auth.
343
+ # Combined into one jq pass; --arg keeps password/host injection-safe.
344
+ CONFIG_JSON=$(jq \
345
+ --arg spaceHost "${SPACE_HOST:-}" \
346
+ --arg password "${OPENCLAW_PASSWORD:-}" \
347
+ '.gateway.controlUi.dangerouslyDisableDeviceAuth = true
348
+ | (if $spaceHost != "" then
349
+ .gateway.controlUi.allowedOrigins = ["https://" + $spaceHost]
350
+ else . end)
351
+ | (if $password != "" then
352
+ .gateway.auth.mode = "password" | .gateway.auth.password = $password
353
+ else . end)' <<<"$CONFIG_JSON")
354
 
355
  # Trusted proxies (optional β€” fixes "Proxy headers detected from untrusted address" on HF Spaces)
356
  # Set TRUSTED_PROXIES as comma-separated IPs/CIDRs, e.g. "10.20.31.87,10.20.26.157"
 
394
  ')
395
 
396
  if [ -n "${TELEGRAM_USER_IDS:-}" ]; then
397
+ # Convert comma-separated IDs to JSON array (already safe β€” jq -R parses).
398
  IDS_JSON=$(echo "$TELEGRAM_USER_IDS" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -R . | jq -s .)
399
+ CONFIG_JSON=$(jq \
400
+ --argjson ids "$IDS_JSON" \
401
+ '.channels.telegram += {"dmPolicy": "allowlist", "allowFrom": $ids}' <<<"$CONFIG_JSON")
402
  elif [ -n "${TELEGRAM_USER_ID:-}" ]; then
403
+ # Single user (backward compatible). --arg keeps quotes/odd chars safe.
404
+ CONFIG_JSON=$(jq \
405
+ --arg userId "$TELEGRAM_USER_ID" \
406
+ '.channels.telegram += {"dmPolicy": "allowlist", "allowFrom": [$userId]}' <<<"$CONFIG_JSON")
407
  fi
408
  fi
409
 
 
478
  for attempt in 1 2 3 4 5; do
479
  if openclaw browser --browser-profile openclaw start >/dev/null 2>&1; then
480
  openclaw browser --browser-profile openclaw open about:blank >/dev/null 2>&1 || true
481
+ echo "Managed browser ready."
482
  return 0
483
  fi
484
  sleep 2
485
  done
486
 
487
+ echo "Warning: managed browser warm-up did not complete; first browser action may need a retry."
488
  ) &
489
  }
490
 
 
503
  echo "Gateway verbose logging enabled (GATEWAY_VERBOSE=1)"
504
  fi
505
 
506
+ # Use stdbuf -oL -eL to ensure logs are not buffered and appear immediately
507
+ # in the console. NOTE: $! captures the LAST pipeline element (tee), not
508
+ # openclaw β€” fine for passing to `wait` (waits for the whole pipeline to
509
+ # finish), but kill -0 on it is uninformative. We probe TCP instead.
510
  stdbuf -oL -eL openclaw "${GATEWAY_ARGS[@]}" 2>&1 | tee -a /home/node/.openclaw/gateway.log &
511
  GATEWAY_PID=$!
512
 
513
+ # Poll for the gateway to start listening on 7860. OpenClaw can take 20-30s
514
+ # on cold start (plugin install + auto-restore). Bail out early if the
515
+ # pipeline died.
516
+ GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-90}"
517
+ ready=false
518
+ for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
519
+ if (echo > /dev/tcp/127.0.0.1/7860) 2>/dev/null; then
520
+ ready=true
521
+ break
522
+ fi
523
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
524
+ break
525
+ fi
526
+ sleep 1
527
+ done
528
+
529
+ if [ "$ready" != "true" ]; then
530
  echo ""
531
+ echo "Gateway failed to start. Last 30 lines of log:"
532
  echo "────────────────────────────────────────────"
533
  tail -30 /home/node/.openclaw/gateway.log
534
  exit 1
workspace-sync.py CHANGED
@@ -161,7 +161,17 @@ def restore_embedded_state() -> None:
161
  shutil.rmtree(WHATSAPP_CREDS_DIR, ignore_errors=True)
162
  WHATSAPP_CREDS_DIR.parent.mkdir(parents=True, exist_ok=True)
163
  shutil.copytree(WHATSAPP_BACKUP_DIR, WHATSAPP_CREDS_DIR)
 
 
164
  os.chmod(OPENCLAW_HOME / "credentials", 0o700)
 
 
 
 
 
 
 
 
165
  print("WhatsApp credentials restored.")
166
  else:
167
  print(f"Warning: saved WhatsApp credentials incomplete ({file_count} files), skipping restore.")
 
161
  shutil.rmtree(WHATSAPP_CREDS_DIR, ignore_errors=True)
162
  WHATSAPP_CREDS_DIR.parent.mkdir(parents=True, exist_ok=True)
163
  shutil.copytree(WHATSAPP_BACKUP_DIR, WHATSAPP_CREDS_DIR)
164
+ # Lock down dir tree: 0700 on directories, 0600 on every file
165
+ # so the WhatsApp session secrets can't be read by other users.
166
  os.chmod(OPENCLAW_HOME / "credentials", 0o700)
167
+ for path in WHATSAPP_CREDS_DIR.rglob("*"):
168
+ try:
169
+ if path.is_dir():
170
+ os.chmod(path, 0o700)
171
+ elif path.is_file():
172
+ os.chmod(path, 0o600)
173
+ except OSError:
174
+ pass
175
  print("WhatsApp credentials restored.")
176
  else:
177
  print(f"Warning: saved WhatsApp credentials incomplete ({file_count} files), skipping restore.")