anurag008w commited on
Commit
ad8f514
Β·
1 Parent(s): 6b45d1f

Sync state before gateway reload restarts

Browse files
Files changed (5) hide show
  1. .env.example +6 -0
  2. README.md +3 -1
  3. multi-provider-key-rotator.cjs +8 -3
  4. openclaw-sync.py +147 -7
  5. start.sh +120 -53
.env.example CHANGED
@@ -285,6 +285,12 @@ KEEP_ALIVE_INTERVAL=300
285
  # Workspace auto-sync interval (seconds). Default: 180.
286
  SYNC_INTERVAL=180
287
 
 
 
 
 
 
 
288
  # Webhooks: Standard POST notifications for lifecycle events
289
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
290
 
 
285
  # Workspace auto-sync interval (seconds). Default: 180.
286
  SYNC_INTERVAL=180
287
 
288
+ # Check openclaw.json for settings changes this often (seconds). Default: 1.
289
+ OPENCLAW_CONFIG_WATCH_INTERVAL=1
290
+
291
+ # Wait for openclaw.json to stay valid and unchanged before syncing. Default: 3.
292
+ OPENCLAW_CONFIG_SETTLE_SECONDS=3
293
+
294
  # Webhooks: Standard POST notifications for lifecycle events
295
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
296
 
README.md CHANGED
@@ -162,7 +162,9 @@ HuggingClaw automatically syncs your workspace (chats, settings, sessions) to a
162
  | Variable | Default | Description |
163
  | :--- | :--- | :--- |
164
  | `HF_TOKEN` | β€” | HF token with **Write** access |
165
- | `SYNC_INTERVAL` | `180` | Backup frequency in seconds |
 
 
166
 
167
  ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
168
 
 
162
  | Variable | Default | Description |
163
  | :--- | :--- | :--- |
164
  | `HF_TOKEN` | β€” | HF token with **Write** access |
165
+ | `SYNC_INTERVAL` | `180` | Full backup frequency in seconds |
166
+ | `OPENCLAW_CONFIG_WATCH_INTERVAL` | `1` | How often to check `openclaw.json` for immediate settings sync |
167
+ | `OPENCLAW_CONFIG_SETTLE_SECONDS` | `3` | How long `openclaw.json` must stay valid and unchanged before syncing |
168
 
169
  ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
170
 
multi-provider-key-rotator.cjs CHANGED
@@ -19,6 +19,11 @@
19
  const http = require('node:http');
20
  const https = require('node:https');
21
 
 
 
 
 
 
22
  // ─── Provider definitions ────────────────────────────────────────────────────
23
  //
24
  // hostname – regex tested against the request hostname (case-insensitive)
@@ -185,7 +190,7 @@ const providerState = PROVIDERS.map(p => {
185
  : normalizeKeys(process.env.LLM_API_KEY || '');
186
 
187
  if (hasDedicated) {
188
- console.log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
189
  } else if (!keys.length) {
190
  console.warn(`[key-rotator] No keys for provider "${p.name}"`);
191
  }
@@ -202,7 +207,7 @@ const fallbackCount = providerState.filter(p => {
202
  return dedicated.length === 0 && p.keys.length > 0;
203
  }).length;
204
  if (fallbackCount > 0) {
205
- console.log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
206
  }
207
 
208
  // ─── Runtime helpers ─────────────────────────────────────────────────────────
@@ -332,4 +337,4 @@ patchFetch();
332
  patchHttpModule(http);
333
  patchHttpModule(https);
334
 
335
- console.log('[key-rotator] loaded β€” all providers active');
 
19
  const http = require('node:http');
20
  const https = require('node:https');
21
 
22
+ // This file is preloaded through NODE_OPTIONS, so it also runs inside npm and
23
+ // OpenClaw helper subprocesses that may emit machine-readable JSON on stdout.
24
+ // Keep rotator diagnostics on stderr to avoid corrupting those stdout streams.
25
+ const log = (...args) => console.error(...args);
26
+
27
  // ─── Provider definitions ────────────────────────────────────────────────────
28
  //
29
  // hostname – regex tested against the request hostname (case-insensitive)
 
190
  : normalizeKeys(process.env.LLM_API_KEY || '');
191
 
192
  if (hasDedicated) {
193
+ log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
194
  } else if (!keys.length) {
195
  console.warn(`[key-rotator] No keys for provider "${p.name}"`);
196
  }
 
207
  return dedicated.length === 0 && p.keys.length > 0;
208
  }).length;
209
  if (fallbackCount > 0) {
210
+ log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
211
  }
212
 
213
  // ─── Runtime helpers ─────────────────────────────────────────────────────────
 
337
  patchHttpModule(http);
338
  patchHttpModule(https);
339
 
340
+ log('[key-rotator] loaded β€” all providers active');
openclaw-sync.py CHANGED
@@ -7,6 +7,7 @@ credentials inside a private HF dataset without embedding HF tokens in git
7
  remotes or requiring a manual HF_USERNAME secret.
8
  """
9
 
 
10
  import hashlib
11
  import json
12
  import logging
@@ -34,10 +35,20 @@ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
34
  logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
35
 
36
  OPENCLAW_HOME = Path("/home/node/.openclaw")
 
37
  WORKSPACE = OPENCLAW_HOME / "workspace"
38
  STATUS_FILE = Path("/tmp/sync-status.json")
 
39
  INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
40
  INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
 
 
 
 
 
 
 
 
41
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
42
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
43
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
@@ -78,6 +89,13 @@ def write_status(status: str, message: str) -> None:
78
  tmp_path.replace(STATUS_FILE)
79
 
80
 
 
 
 
 
 
 
 
81
  def count_files(path: Path) -> int:
82
  if not path.exists():
83
  return 0
@@ -250,6 +268,18 @@ def _should_exclude(rel_posix: str, path: Path) -> bool:
250
  return False
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  def metadata_marker(root: Path) -> tuple[int, int, int]:
254
  if not root.exists():
255
  return (0, 0, 0)
@@ -364,7 +394,7 @@ def restore_workspace() -> bool:
364
  return False
365
 
366
 
367
- def sync_once(
368
  last_fingerprint: str | None = None,
369
  last_marker: tuple[int, int, int] | None = None,
370
  ) -> tuple[str, tuple[int, int, int]]:
@@ -412,14 +442,81 @@ def sync_once(
412
  return (current_fingerprint, current_marker)
413
 
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  def handle_signal(_sig, _frame) -> None:
416
  STOP_EVENT.set()
417
 
418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  def loop() -> int:
420
  signal.signal(signal.SIGTERM, handle_signal)
421
  signal.signal(signal.SIGINT, handle_signal)
422
 
 
 
423
  try:
424
  repo_id = resolve_backup_namespace()
425
  write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
@@ -431,24 +528,56 @@ def loop() -> int:
431
  time.sleep(INITIAL_DELAY)
432
  print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
433
 
434
- # Take a fingerprint of the workspace AS RESTORED (after snapshotting state)
435
- # so the first loop iteration only uploads if something genuinely changed.
436
- # Previously this was None, which forced an unconditional upload every restart
437
- # β€” even when restore had failed silently and the workspace was empty.
 
 
 
 
 
438
  snapshot_state_into_workspace()
439
  last_fingerprint = fingerprint_dir(WORKSPACE)
440
  last_marker = metadata_marker(WORKSPACE)
441
- print("Initial workspace fingerprint captured.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
  while not STOP_EVENT.is_set():
444
  try:
 
445
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
 
 
 
 
 
 
 
 
446
  except Exception as exc:
447
  write_status("error", f"Sync failed: {exc}")
448
  print(f"Workspace sync failed: {exc}")
 
449
 
450
- if STOP_EVENT.wait(INTERVAL):
 
451
  break
 
 
452
 
453
  return 0
454
 
@@ -469,6 +598,17 @@ def main() -> int:
469
  write_status("error", f"Shutdown sync failed: {exc}")
470
  print(f"Workspace sync: shutdown sync failed: {exc}")
471
  return 1
 
 
 
 
 
 
 
 
 
 
 
472
  if command == "loop":
473
  return loop()
474
 
 
7
  remotes or requiring a manual HF_USERNAME secret.
8
  """
9
 
10
+ import fcntl
11
  import hashlib
12
  import json
13
  import logging
 
35
  logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
36
 
37
  OPENCLAW_HOME = Path("/home/node/.openclaw")
38
+ OPENCLAW_CONFIG_FILE = OPENCLAW_HOME / "openclaw.json"
39
  WORKSPACE = OPENCLAW_HOME / "workspace"
40
  STATUS_FILE = Path("/tmp/sync-status.json")
41
+ SYNC_LOCK_FILE = Path("/tmp/huggingclaw-sync.lock")
42
  INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
43
  INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
44
+ CONFIG_WATCH_INTERVAL = max(
45
+ 0.5,
46
+ float(os.environ.get("OPENCLAW_CONFIG_WATCH_INTERVAL", "1")),
47
+ )
48
+ CONFIG_SETTLE_SECONDS = max(
49
+ 0.0,
50
+ float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
51
+ )
52
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
53
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
54
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
 
89
  tmp_path.replace(STATUS_FILE)
90
 
91
 
92
+ def read_status() -> dict[str, str]:
93
+ try:
94
+ return json.loads(STATUS_FILE.read_text(encoding="utf-8"))
95
+ except Exception:
96
+ return {}
97
+
98
+
99
  def count_files(path: Path) -> int:
100
  if not path.exists():
101
  return 0
 
268
  return False
269
 
270
 
271
+ def file_marker(path: Path) -> tuple[int, int, int]:
272
+ try:
273
+ stat = path.stat()
274
+ except OSError:
275
+ return (0, 0, 0)
276
+
277
+ if not path.is_file():
278
+ return (0, 0, 0)
279
+
280
+ return (1, int(stat.st_size), int(stat.st_mtime_ns))
281
+
282
+
283
  def metadata_marker(root: Path) -> tuple[int, int, int]:
284
  if not root.exists():
285
  return (0, 0, 0)
 
394
  return False
395
 
396
 
397
+ def _sync_once_unlocked(
398
  last_fingerprint: str | None = None,
399
  last_marker: tuple[int, int, int] | None = None,
400
  ) -> tuple[str, tuple[int, int, int]]:
 
442
  return (current_fingerprint, current_marker)
443
 
444
 
445
+ def sync_once(
446
+ last_fingerprint: str | None = None,
447
+ last_marker: tuple[int, int, int] | None = None,
448
+ ) -> tuple[str, tuple[int, int, int]]:
449
+ SYNC_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
450
+ with SYNC_LOCK_FILE.open("w", encoding="utf-8") as lock_handle:
451
+ fcntl.flock(lock_handle, fcntl.LOCK_EX)
452
+ try:
453
+ return _sync_once_unlocked(last_fingerprint, last_marker)
454
+ finally:
455
+ fcntl.flock(lock_handle, fcntl.LOCK_UN)
456
+
457
+
458
  def handle_signal(_sig, _frame) -> None:
459
  STOP_EVENT.set()
460
 
461
 
462
+ def is_valid_json_file(path: Path) -> bool:
463
+ if not path.exists():
464
+ return True
465
+
466
+ try:
467
+ json.loads(path.read_text(encoding="utf-8"))
468
+ return True
469
+ except Exception:
470
+ return False
471
+
472
+
473
+ def wait_for_config_settle(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
474
+ stable_since = time.monotonic()
475
+ current_marker = config_marker
476
+
477
+ while not STOP_EVENT.is_set():
478
+ latest_marker = file_marker(OPENCLAW_CONFIG_FILE)
479
+ if latest_marker != current_marker:
480
+ current_marker = latest_marker
481
+ stable_since = time.monotonic()
482
+
483
+ if (
484
+ time.monotonic() - stable_since >= CONFIG_SETTLE_SECONDS
485
+ and is_valid_json_file(OPENCLAW_CONFIG_FILE)
486
+ ):
487
+ return ("settled", current_marker)
488
+
489
+ if STOP_EVENT.wait(CONFIG_WATCH_INTERVAL):
490
+ return ("stopped", current_marker)
491
+
492
+ return ("stopped", current_marker)
493
+
494
+
495
+ def wait_for_sync_trigger(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
496
+ deadline = time.monotonic() + max(0, INTERVAL)
497
+
498
+ while not STOP_EVENT.is_set():
499
+ current_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
500
+ if current_config_marker != config_marker:
501
+ return wait_for_config_settle(current_config_marker)
502
+
503
+ remaining = deadline - time.monotonic()
504
+ if remaining <= 0:
505
+ return ("interval", current_config_marker)
506
+
507
+ wait_seconds = min(CONFIG_WATCH_INTERVAL, remaining)
508
+ if STOP_EVENT.wait(wait_seconds):
509
+ return ("stopped", current_config_marker)
510
+
511
+ return ("stopped", config_marker)
512
+
513
+
514
  def loop() -> int:
515
  signal.signal(signal.SIGTERM, handle_signal)
516
  signal.signal(signal.SIGINT, handle_signal)
517
 
518
+ previous_status = read_status().get("status", "")
519
+
520
  try:
521
  repo_id = resolve_backup_namespace()
522
  write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
 
528
  time.sleep(INITIAL_DELAY)
529
  print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
530
 
531
+ # Capture the restored dataset state before refreshing the embedded
532
+ # /home/node/.openclaw backup. Startup may have patched openclaw.json
533
+ # after restore (token/model/logging/channel toggles), and that patch only
534
+ # becomes part of the dataset once snapshot_state_into_workspace() copies it
535
+ # into workspace/huggingclaw-state/openclaw/. If the snapshot changes the
536
+ # workspace, seed the first sync with the pre-snapshot fingerprint so the
537
+ # updated openclaw.json is uploaded instead of being treated as the baseline.
538
+ pre_snapshot_fingerprint = fingerprint_dir(WORKSPACE)
539
+ pre_snapshot_marker = metadata_marker(WORKSPACE)
540
  snapshot_state_into_workspace()
541
  last_fingerprint = fingerprint_dir(WORKSPACE)
542
  last_marker = metadata_marker(WORKSPACE)
543
+
544
+ if last_fingerprint != pre_snapshot_fingerprint:
545
+ if previous_status == "error":
546
+ print(
547
+ "Initial state snapshot changed, but restore previously failed; "
548
+ "keeping current state as baseline to avoid overwriting the remote backup."
549
+ )
550
+ else:
551
+ last_fingerprint = pre_snapshot_fingerprint
552
+ last_marker = pre_snapshot_marker
553
+ print("Initial state snapshot changed; first sync will upload refreshed OpenClaw state.")
554
+ else:
555
+ print("Initial workspace fingerprint captured.")
556
+
557
+ config_marker = file_marker(OPENCLAW_CONFIG_FILE)
558
 
559
  while not STOP_EVENT.is_set():
560
  try:
561
+ sync_started_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
562
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
563
+ config_marker = file_marker(OPENCLAW_CONFIG_FILE)
564
+
565
+ if config_marker != sync_started_config_marker:
566
+ trigger, config_marker = wait_for_config_settle(config_marker)
567
+ if trigger == "stopped":
568
+ break
569
+ print("OpenClaw config changed during sync; syncing again after it settled.")
570
+ continue
571
  except Exception as exc:
572
  write_status("error", f"Sync failed: {exc}")
573
  print(f"Workspace sync failed: {exc}")
574
+ config_marker = file_marker(OPENCLAW_CONFIG_FILE)
575
 
576
+ trigger, config_marker = wait_for_sync_trigger(config_marker)
577
+ if trigger == "stopped":
578
  break
579
+ if trigger == "settled":
580
+ print("OpenClaw config changed and settled; syncing immediately.")
581
 
582
  return 0
583
 
 
598
  write_status("error", f"Shutdown sync failed: {exc}")
599
  print(f"Workspace sync: shutdown sync failed: {exc}")
600
  return 1
601
+ if command == "sync-once-settled":
602
+ try:
603
+ trigger, _ = wait_for_config_settle(file_marker(OPENCLAW_CONFIG_FILE))
604
+ if trigger == "stopped":
605
+ return 1
606
+ sync_once()
607
+ return 0
608
+ except Exception as exc:
609
+ write_status("error", f"Settled sync failed: {exc}")
610
+ print(f"Workspace sync: settled sync failed: {exc}")
611
+ return 1
612
  if command == "loop":
613
  return loop()
614
 
start.sh CHANGED
@@ -11,6 +11,14 @@ umask 0077
11
  OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
12
  OPENCLAW_APP_DIR="/home/node/.openclaw/openclaw-app"
13
  OPENCLAW_RUNTIME_VERSION=""
 
 
 
 
 
 
 
 
14
  WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
15
  WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
16
  SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
@@ -445,22 +453,32 @@ if [ -f "$EXISTING_CONFIG" ]; then
445
  --arg consoleLevel "$OPENCLAW_CONSOLE_LOG_LEVEL" \
446
  --arg consoleStyle "$OPENCLAW_CONSOLE_LOG_STYLE" \
447
  --argjson desired "$CONFIG_JSON" \
 
 
 
 
448
  --argjson whatsappEnabled "$WHATSAPP_CONFIG_ENABLED" \
449
- '.gateway.auth.token = $token
 
450
  | .agents.defaults.model = $model
451
- | .logging.level = $fileLevel
452
- | .logging.consoleLevel = $consoleLevel
453
- | .logging.consoleStyle = $consoleStyle
454
  | .channels = ((.channels // {}) * ($desired.channels // {}))
455
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
456
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
457
- | .plugins.entries = ((.plugins.entries // {}) * ($desired.plugins.entries // {}))
458
  | if $whatsappEnabled then
459
- .plugins.entries.whatsapp.enabled = true
460
- | .channels.whatsapp = ($desired.channels.whatsapp // {"dmPolicy": "pairing"})
461
- else
 
 
 
462
  .plugins.entries.whatsapp.enabled = false
463
  | del(.channels.whatsapp)
 
 
464
  end' \
465
  "$EXISTING_CONFIG" 2>/dev/null)
466
 
@@ -1000,59 +1018,108 @@ hc_finish_startup_commands
1000
  sync_installed_plugins_into_allow
1001
 
1002
  # ── Launch gateway ──
1003
- echo "Launching OpenClaw gateway on port 7860..."
 
 
 
 
 
 
 
 
 
 
 
 
 
1004
 
1005
- GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
1006
- if [ "${GATEWAY_VERBOSE:-0}" = "1" ]; then
1007
- GATEWAY_ARGS+=(--verbose)
1008
- echo "Gateway verbose logging enabled (GATEWAY_VERBOSE=1)"
1009
- fi
1010
 
1011
- # Use stdbuf -oL -eL to ensure logs are not buffered and appear immediately
1012
- # in the console. NOTE: $! captures the LAST pipeline element (tee), not
1013
- # openclaw β€” fine for passing to `wait` (waits for the whole pipeline to
1014
- # finish), but kill -0 on it is uninformative. We probe TCP instead.
1015
- stdbuf -oL -eL openclaw "${GATEWAY_ARGS[@]}" 2>&1 | tee -a /home/node/.openclaw/gateway.log &
1016
- GATEWAY_PID=$!
1017
-
1018
- # Poll for the gateway to start listening on 7860. OpenClaw can take 20-30s
1019
- # on cold start (plugin install + auto-restore). Bail out early if the
1020
- # pipeline died.
1021
- GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-90}"
1022
- ready=false
1023
- for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
1024
- if (echo > /dev/tcp/127.0.0.1/7860) 2>/dev/null; then
1025
- ready=true
1026
- break
1027
- fi
1028
- if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
1029
- break
1030
  fi
1031
- sleep 1
1032
- done
1033
 
1034
- if [ "$ready" != "true" ]; then
1035
- echo ""
1036
- echo "Gateway failed to start. Last 30 lines of log:"
1037
- echo "────────────────────────────────────────────"
1038
- tail -30 /home/node/.openclaw/gateway.log
1039
- exit 1
1040
- fi
 
 
 
1041
 
1042
- # 11. Start WhatsApp Guardian after the gateway is accepting connections
1043
- if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
1044
  node /home/node/app/wa-guardian.js &
1045
  GUARDIAN_PID=$!
1046
  echo "WhatsApp Guardian started (PID: $GUARDIAN_PID)"
1047
- fi
1048
 
1049
- # 11.5 Warm up the managed browser so first browser actions have a live tab
1050
- warmup_browser
1051
 
1052
- # 12. Start Workspace Sync after startup settles
1053
- if [ -n "${HF_TOKEN:-}" ]; then
1054
- python3 -u /home/node/app/openclaw-sync.py loop &
1055
- fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
 
1057
- # Wait for gateway (allows trap to fire)
1058
- wait $GATEWAY_PID
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
12
  OPENCLAW_APP_DIR="/home/node/.openclaw/openclaw-app"
13
  OPENCLAW_RUNTIME_VERSION=""
14
+ OPENCLAW_FILE_LOG_LEVEL_CONFIGURED=false
15
+ OPENCLAW_CONSOLE_LOG_LEVEL_CONFIGURED=false
16
+ OPENCLAW_CONSOLE_LOG_STYLE_CONFIGURED=false
17
+ WHATSAPP_ENABLED_CONFIGURED=false
18
+ [ "${OPENCLAW_FILE_LOG_LEVEL+x}" = "x" ] && OPENCLAW_FILE_LOG_LEVEL_CONFIGURED=true
19
+ [ "${OPENCLAW_CONSOLE_LOG_LEVEL+x}" = "x" ] && OPENCLAW_CONSOLE_LOG_LEVEL_CONFIGURED=true
20
+ [ "${OPENCLAW_CONSOLE_LOG_STYLE+x}" = "x" ] && OPENCLAW_CONSOLE_LOG_STYLE_CONFIGURED=true
21
+ [ "${WHATSAPP_ENABLED+x}" = "x" ] && WHATSAPP_ENABLED_CONFIGURED=true
22
  WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
23
  WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
24
  SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
 
453
  --arg consoleLevel "$OPENCLAW_CONSOLE_LOG_LEVEL" \
454
  --arg consoleStyle "$OPENCLAW_CONSOLE_LOG_STYLE" \
455
  --argjson desired "$CONFIG_JSON" \
456
+ --argjson fileLogConfigured "$OPENCLAW_FILE_LOG_LEVEL_CONFIGURED" \
457
+ --argjson consoleLogConfigured "$OPENCLAW_CONSOLE_LOG_LEVEL_CONFIGURED" \
458
+ --argjson consoleStyleConfigured "$OPENCLAW_CONSOLE_LOG_STYLE_CONFIGURED" \
459
+ --argjson whatsappConfigured "$WHATSAPP_ENABLED_CONFIGURED" \
460
  --argjson whatsappEnabled "$WHATSAPP_CONFIG_ENABLED" \
461
+ '(.channels.whatsapp // {}) as $existingWhatsapp
462
+ | .gateway.auth.token = $token
463
  | .agents.defaults.model = $model
464
+ | if $fileLogConfigured then .logging.level = $fileLevel else . end
465
+ | if $consoleLogConfigured then .logging.consoleLevel = $consoleLevel else . end
466
+ | if $consoleStyleConfigured then .logging.consoleStyle = $consoleStyle else . end
467
  | .channels = ((.channels // {}) * ($desired.channels // {}))
468
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
469
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
470
+ | .plugins.entries = (($desired.plugins.entries // {}) * (.plugins.entries // {}))
471
  | if $whatsappEnabled then
472
+ ($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
473
+ | .plugins.entries.whatsapp.enabled = true
474
+ | .channels.whatsapp = (($existingWhatsapp * $desiredWhatsapp)
475
+ | if ($existingWhatsapp | has("dmPolicy")) then .dmPolicy = $existingWhatsapp.dmPolicy else . end
476
+ | if ($existingWhatsapp | has("allowFrom")) then .allowFrom = $existingWhatsapp.allowFrom else . end)
477
+ elif $whatsappConfigured then
478
  .plugins.entries.whatsapp.enabled = false
479
  | del(.channels.whatsapp)
480
+ else
481
+ .
482
  end' \
483
  "$EXISTING_CONFIG" 2>/dev/null)
484
 
 
1018
  sync_installed_plugins_into_allow
1019
 
1020
  # ── Launch gateway ──
1021
+ GATEWAY_RESTART_DELAY="${GATEWAY_RESTART_DELAY:-2}"
1022
+ GATEWAY_MAX_RESTARTS="${GATEWAY_MAX_RESTARTS:-0}"
1023
+ GATEWAY_RESTART_COUNT=0
1024
+ SYNC_LOOP_PID=""
1025
+ GUARDIAN_PID=""
1026
+
1027
+ sync_before_gateway_restart() {
1028
+ [ -n "${HF_TOKEN:-}" ] || return 0
1029
+ [ -f "/home/node/app/openclaw-sync.py" ] || return 0
1030
+
1031
+ echo "Gateway stopped; saving latest OpenClaw state before restart..."
1032
+ python3 /home/node/app/openclaw-sync.py sync-once-settled || \
1033
+ echo "Warning: could not sync settled state before gateway restart"
1034
+ }
1035
 
1036
+ start_background_sync_once() {
1037
+ [ -n "${HF_TOKEN:-}" ] || return 0
 
 
 
1038
 
1039
+ if [ -n "$SYNC_LOOP_PID" ] && kill -0 "$SYNC_LOOP_PID" 2>/dev/null; then
1040
+ return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1041
  fi
 
 
1042
 
1043
+ python3 -u /home/node/app/openclaw-sync.py loop &
1044
+ SYNC_LOOP_PID=$!
1045
+ }
1046
+
1047
+ start_guardian_once() {
1048
+ [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ] || return 0
1049
+
1050
+ if [ -n "$GUARDIAN_PID" ] && kill -0 "$GUARDIAN_PID" 2>/dev/null; then
1051
+ return 0
1052
+ fi
1053
 
 
 
1054
  node /home/node/app/wa-guardian.js &
1055
  GUARDIAN_PID=$!
1056
  echo "WhatsApp Guardian started (PID: $GUARDIAN_PID)"
1057
+ }
1058
 
1059
+ while true; do
1060
+ echo "Launching OpenClaw gateway on port 7860..."
1061
 
1062
+ GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
1063
+ if [ "${GATEWAY_VERBOSE:-0}" = "1" ]; then
1064
+ GATEWAY_ARGS+=(--verbose)
1065
+ echo "Gateway verbose logging enabled (GATEWAY_VERBOSE=1)"
1066
+ fi
1067
+
1068
+ # Use stdbuf -oL -eL to ensure logs are not buffered and appear immediately
1069
+ # in the console. NOTE: $! captures the LAST pipeline element (tee), not
1070
+ # openclaw β€” fine for passing to `wait` (waits for the whole pipeline to
1071
+ # finish), but kill -0 on it is uninformative. We probe TCP instead.
1072
+ stdbuf -oL -eL openclaw "${GATEWAY_ARGS[@]}" 2>&1 | tee -a /home/node/.openclaw/gateway.log &
1073
+ GATEWAY_PID=$!
1074
+
1075
+ # Poll for the gateway to start listening on 7860. OpenClaw can take 20-30s
1076
+ # on cold start (plugin install + auto-restore). Bail out early if the
1077
+ # pipeline died.
1078
+ GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-90}"
1079
+ ready=false
1080
+ for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
1081
+ if (echo > /dev/tcp/127.0.0.1/7860) 2>/dev/null; then
1082
+ ready=true
1083
+ break
1084
+ fi
1085
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
1086
+ break
1087
+ fi
1088
+ sleep 1
1089
+ done
1090
+
1091
+ if [ "$ready" != "true" ]; then
1092
+ echo ""
1093
+ echo "Gateway failed to start. Last 30 lines of log:"
1094
+ echo "────────────────────────────────────────────"
1095
+ tail -30 /home/node/.openclaw/gateway.log
1096
+ exit 1
1097
+ fi
1098
 
1099
+ # 11. Start WhatsApp Guardian after the gateway is accepting connections
1100
+ start_guardian_once
1101
+
1102
+ # 11.5 Warm up the managed browser so first browser actions have a live tab
1103
+ warmup_browser
1104
+
1105
+ # 12. Start Workspace Sync after startup settles. Keep only one loop active;
1106
+ # config edits can make OpenClaw exit/reload, and the gateway loop below will
1107
+ # relaunch it without rerunning all startup code.
1108
+ start_background_sync_once
1109
+
1110
+ set +e
1111
+ wait "$GATEWAY_PID"
1112
+ GATEWAY_EXIT_CODE=$?
1113
+ set -e
1114
+
1115
+ sync_before_gateway_restart
1116
+
1117
+ GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))
1118
+ if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then
1119
+ echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restart limit (${GATEWAY_MAX_RESTARTS}) reached."
1120
+ exit "$GATEWAY_EXIT_CODE"
1121
+ fi
1122
+
1123
+ echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restarting in ${GATEWAY_RESTART_DELAY}s..."
1124
+ sleep "$GATEWAY_RESTART_DELAY"
1125
+ done