Codex commited on
Commit
14fd0fa
Β·
1 Parent(s): b617e46

Fix sync robustness, devdata activation, alias cleanup, and timing-safe auth compare

Browse files
Files changed (7) hide show
  1. .env.example +3 -0
  2. README.md +1 -0
  3. env-builder.js +9 -26
  4. health-server.js +5 -8
  5. jupyter-devdata-sync.py +36 -13
  6. openclaw-sync.py +110 -20
  7. start.sh +6 -7
.env.example CHANGED
@@ -316,6 +316,9 @@ OPENCLAW_CONFIG_WATCH_INTERVAL=1
316
  # Wait for openclaw.json to stay valid and unchanged before syncing. Default: 3.
317
  OPENCLAW_CONFIG_SETTLE_SECONDS=3
318
 
 
 
 
319
  # Webhooks: Standard POST notifications for lifecycle events
320
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
321
 
 
316
  # Wait for openclaw.json to stay valid and unchanged before syncing. Default: 3.
317
  OPENCLAW_CONFIG_SETTLE_SECONDS=3
318
 
319
+ # Minimum gap between sessions-triggered immediate syncs (seconds). Default: 30.
320
+ SESSIONS_MIN_SYNC_GAP=30
321
+
322
  # Webhooks: Standard POST notifications for lifecycle events
323
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
324
 
README.md CHANGED
@@ -177,6 +177,7 @@ HuggingClaw automatically syncs your workspace (chats, settings, sessions) to a
177
  | `SYNC_INTERVAL` | `180` | Full backup frequency in seconds |
178
  | `OPENCLAW_CONFIG_WATCH_INTERVAL` | `1` | How often to check `openclaw.json` for immediate settings sync |
179
  | `OPENCLAW_CONFIG_SETTLE_SECONDS` | `3` | How long `openclaw.json` must stay valid and unchanged before syncing |
 
180
 
181
  ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
182
 
 
177
  | `SYNC_INTERVAL` | `180` | Full backup frequency in seconds |
178
  | `OPENCLAW_CONFIG_WATCH_INTERVAL` | `1` | How often to check `openclaw.json` for immediate settings sync |
179
  | `OPENCLAW_CONFIG_SETTLE_SECONDS` | `3` | How long `openclaw.json` must stay valid and unchanged before syncing |
180
+ | `SESSIONS_MIN_SYNC_GAP` | `30` | Minimum seconds between session-triggered immediate syncs |
181
 
182
  ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
183
 
env-builder.js CHANGED
@@ -972,6 +972,15 @@ const FIELDS = [
972
  "ph": "3",
973
  "tag": "advanced"
974
  },
 
 
 
 
 
 
 
 
 
975
  {
976
  "g": "Runtime",
977
  "icon": "βš™οΈ",
@@ -999,15 +1008,6 @@ const FIELDS = [
999
  "common": 0,
1000
  "tag": "credential"
1001
  },
1002
- {
1003
- "g": "Provider Keys",
1004
- "icon": "πŸ”‘",
1005
- "k": "GOOGLE_API_KEY",
1006
- "lbl": "Google AI Studio",
1007
- "type": "password",
1008
- "common": 0,
1009
- "tag": "credential"
1010
- },
1011
  {
1012
  "g": "Provider Keys",
1013
  "icon": "πŸ”‘",
@@ -1242,15 +1242,6 @@ const FIELDS = [
1242
  "common": 0,
1243
  "tag": "credential"
1244
  },
1245
- {
1246
- "g": "Provider Keys",
1247
- "icon": "πŸ”‘",
1248
- "k": "CLOUDFLARE_API_TOKEN",
1249
- "lbl": "Cloudflare API token",
1250
- "type": "password",
1251
- "common": 0,
1252
- "tag": "credential"
1253
- },
1254
  {
1255
  "g": "Rotation Pools",
1256
  "icon": "πŸ”„",
@@ -1275,14 +1266,6 @@ const FIELDS = [
1275
  "type": "text",
1276
  "tag": "advanced"
1277
  },
1278
- {
1279
- "g": "Rotation Pools",
1280
- "icon": "πŸ”„",
1281
- "k": "GOOGLE_API_KEYS",
1282
- "lbl": "Google pool",
1283
- "type": "text",
1284
- "tag": "advanced"
1285
- },
1286
  {
1287
  "g": "Rotation Pools",
1288
  "icon": "πŸ”„",
 
972
  "ph": "3",
973
  "tag": "advanced"
974
  },
975
+ {
976
+ "g": "Runtime",
977
+ "icon": "βš™οΈ",
978
+ "k": "SESSIONS_MIN_SYNC_GAP",
979
+ "lbl": "Sessions min sync gap (seconds)",
980
+ "type": "number",
981
+ "ph": "30",
982
+ "tag": "advanced"
983
+ },
984
  {
985
  "g": "Runtime",
986
  "icon": "βš™οΈ",
 
1008
  "common": 0,
1009
  "tag": "credential"
1010
  },
 
 
 
 
 
 
 
 
 
1011
  {
1012
  "g": "Provider Keys",
1013
  "icon": "πŸ”‘",
 
1242
  "common": 0,
1243
  "tag": "credential"
1244
  },
 
 
 
 
 
 
 
 
 
1245
  {
1246
  "g": "Rotation Pools",
1247
  "icon": "πŸ”„",
 
1266
  "type": "text",
1267
  "tag": "advanced"
1268
  },
 
 
 
 
 
 
 
 
1269
  {
1270
  "g": "Rotation Pools",
1271
  "icon": "πŸ”„",
health-server.js CHANGED
@@ -3,6 +3,7 @@ const http = require("http");
3
  const https = require("https");
4
  const fs = require("fs");
5
  const net = require("net");
 
6
 
7
  function isTrue(value) {
8
  return /^(true|1|yes|on)$/i.test(String(value || "").trim());
@@ -258,14 +259,10 @@ function parseCookies(req) {
258
  // Constant-time comparison β€” prevent timing attacks on token check
259
  function safeEqual(a, b) {
260
  if (typeof a !== "string" || typeof b !== "string") return false;
261
- // Pad both to the same length so the loop always takes constant time,
262
- // preventing token length from being leaked via early-return timing.
263
- const len = Math.max(a.length, b.length, 1);
264
- const pa = a.padEnd(len, "\0");
265
- const pb = b.padEnd(len, "\0");
266
- let d = a.length === b.length ? 0 : 1; // length mismatch β†’ always fail
267
- for (let i = 0; i < len; i++) d |= pa.charCodeAt(i) ^ pb.charCodeAt(i);
268
- return d === 0;
269
  }
270
 
271
  function isEnvBuilderAuthed(req) {
 
3
  const https = require("https");
4
  const fs = require("fs");
5
  const net = require("net");
6
+ const { timingSafeEqual } = require("crypto");
7
 
8
  function isTrue(value) {
9
  return /^(true|1|yes|on)$/i.test(String(value || "").trim());
 
259
  // Constant-time comparison β€” prevent timing attacks on token check
260
  function safeEqual(a, b) {
261
  if (typeof a !== "string" || typeof b !== "string") return false;
262
+ const ba = Buffer.from(a);
263
+ const bb = Buffer.from(b);
264
+ if (ba.length !== bb.length) return false;
265
+ return timingSafeEqual(ba, bb);
 
 
 
 
266
  }
267
 
268
  function isEnvBuilderAuthed(req) {
jupyter-devdata-sync.py CHANGED
@@ -61,7 +61,11 @@ EXCLUDE = {
61
 
62
 
63
  def enabled():
64
- dev = is_true(os.environ.get("DEV_MODE", ""))
 
 
 
 
65
  separate_dataset = DATASET_NAME != BACKUP_DATASET_NAME
66
  if ENABLE and dev and HF_TOKEN and not separate_dataset:
67
  print("DevData sync disabled: DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME.")
@@ -97,22 +101,41 @@ import fnmatch as _fnmatch
97
  SECRET_FILENAME_PATTERNS = {
98
  ".env", # dotenv files β€” almost always contain API keys
99
  ".env.*", # .env.local, .env.production, etc.
100
- "*secret*", # any file/dir whose name contains "secret"
101
- "*secrets*",
102
- "*_secret*",
103
- "*-secret*",
104
- "*key*", # private keys, API key files
105
- "*_key*",
106
- "*-key*",
107
- "*token*", # token files
108
- "*_token*",
109
- "*-token*",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  "*.pem", # TLS/SSH private keys
111
  "*.key", # generic key files
112
  "*.p12", # PKCS#12 bundles
113
  "*.pfx",
114
- "credentials", # common credential file names
115
- "credentials.*",
116
  ".netrc", # stores plaintext passwords
117
  ".htpasswd",
118
  }
 
61
 
62
 
63
  def enabled():
64
+ jupyter_override = os.environ.get("HUGGINGCLAW_JUPYTER_ENABLED", "")
65
+ if jupyter_override.strip():
66
+ dev = is_true(jupyter_override)
67
+ else:
68
+ dev = is_true(os.environ.get("DEV_MODE", ""))
69
  separate_dataset = DATASET_NAME != BACKUP_DATASET_NAME
70
  if ENABLE and dev and HF_TOKEN and not separate_dataset:
71
  print("DevData sync disabled: DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME.")
 
101
  SECRET_FILENAME_PATTERNS = {
102
  ".env", # dotenv files β€” almost always contain API keys
103
  ".env.*", # .env.local, .env.production, etc.
104
+ "id_rsa",
105
+ "id_dsa",
106
+ "id_ecdsa",
107
+ "id_ed25519",
108
+ "authorized_keys",
109
+ "known_hosts",
110
+ "secret",
111
+ "secrets",
112
+ "secret.*",
113
+ "*.secret",
114
+ "*_secret",
115
+ "*_secret.*",
116
+ "*-secret",
117
+ "*-secret.*",
118
+ "token",
119
+ "token.*",
120
+ "*.token",
121
+ "*_token",
122
+ "*_token.*",
123
+ "*-token",
124
+ "*-token.*",
125
+ "api_token",
126
+ "access_token",
127
+ "refresh_token",
128
+ "credentials", # common credential file names
129
+ "credentials.*",
130
+ "auth.json",
131
+ "auth.yaml",
132
+ "auth.yml",
133
+ "auth.toml",
134
+ "auth.ini",
135
  "*.pem", # TLS/SSH private keys
136
  "*.key", # generic key files
137
  "*.p12", # PKCS#12 bundles
138
  "*.pfx",
 
 
139
  ".netrc", # stores plaintext passwords
140
  ".htpasswd",
141
  }
openclaw-sync.py CHANGED
@@ -50,6 +50,7 @@ CONFIG_SETTLE_SECONDS = max(
50
  0.0,
51
  float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
52
  )
 
53
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
54
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
55
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
@@ -82,6 +83,7 @@ RESET_MARKER = WORKSPACE / ".reset_credentials"
82
  HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
83
  STOP_EVENT = threading.Event()
84
  _REPO_ID_CACHE: str | None = None
 
85
  WorkspaceMarker: TypeAlias = tuple[int, int, int, str]
86
 
87
 
@@ -114,6 +116,11 @@ def copy_state_entry_with_retry(source_path: Path, backup_path: Path, attempts:
114
  last_exc: Exception | None = None
115
  for attempt in range(1, attempts + 1):
116
  try:
 
 
 
 
 
117
  if source_path.is_dir():
118
  shutil.copytree(source_path, backup_path)
119
  return
@@ -142,9 +149,13 @@ def snapshot_state_into_workspace() -> None:
142
  staging_dir = STATE_DIR / ".openclaw-staging"
143
  if staging_dir.exists():
144
  shutil.rmtree(staging_dir, ignore_errors=True)
145
- staging_dir.mkdir(parents=True, exist_ok=True)
 
 
 
146
 
147
  skipped_entries: list[tuple[str, Exception]] = []
 
148
  for source_path in OPENCLAW_HOME.iterdir():
149
  if source_path.name in EXCLUDED_STATE_NAMES:
150
  continue
@@ -152,24 +163,37 @@ def snapshot_state_into_workspace() -> None:
152
  backup_path = staging_dir / source_path.name
153
  try:
154
  copy_state_entry_with_retry(source_path, backup_path)
 
155
  except Exception as entry_exc:
156
  skipped_entries.append((source_path.name, entry_exc))
157
 
158
- # If any top-level state entries could not be copied, keep the
159
- # previous known-good snapshot instead of replacing it with a partial
160
- # backup. We'll retry next pass.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  if skipped_entries:
162
  for name, entry_exc in skipped_entries:
163
- print(f"Warning: skipping state entry {name}: {entry_exc}")
164
  print(
165
- "Warning: OpenClaw state snapshot incomplete; keeping previous backup and retrying next sync."
166
  )
167
- shutil.rmtree(staging_dir, ignore_errors=True)
168
- else:
169
- # Atomically swap staging β†’ real backup dir
170
- if OPENCLAW_STATE_BACKUP_DIR.exists():
171
- shutil.rmtree(OPENCLAW_STATE_BACKUP_DIR, ignore_errors=True)
172
- staging_dir.rename(OPENCLAW_STATE_BACKUP_DIR)
173
  except Exception as exc:
174
  # Clean up staging on failure so it doesn't interfere next time
175
  staging_dir = STATE_DIR / ".openclaw-staging"
@@ -577,19 +601,68 @@ def sessions_marker() -> tuple[int, int, int, str]:
577
  newest_mtime = 0
578
  metadata_hasher = hashlib.sha256()
579
 
 
 
 
580
  for profile_dir in sorted(SESSIONS_ROOT.iterdir()):
581
  if not profile_dir.is_dir():
582
  continue
583
  sessions_dir = profile_dir / "sessions"
 
 
 
 
 
584
  marker = metadata_marker(sessions_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  file_count += marker[0]
586
  total_size += marker[1]
587
  newest_mtime = max(newest_mtime, marker[2])
588
  metadata_hasher.update(profile_dir.name.encode("utf-8"))
589
  metadata_hasher.update(b"\0")
590
- metadata_hasher.update(marker[3].encode("ascii"))
591
  metadata_hasher.update(b"\0")
592
 
 
593
  return (file_count, total_size, newest_mtime, metadata_hasher.hexdigest())
594
 
595
 
@@ -615,7 +688,10 @@ def wait_for_config_settle(config_marker: tuple[int, int, int]) -> tuple[str, tu
615
  return ("stopped", current_marker)
616
 
617
 
618
- def wait_for_sync_trigger(config_marker: tuple[int, int, int]) -> tuple[str, tuple[int, int, int]]:
 
 
 
619
  deadline = time.monotonic() + max(0, INTERVAL)
620
  # BUG FIX: also watch sessions directory so new/updated sessions
621
  # trigger an immediate sync instead of waiting the full interval.
@@ -628,11 +704,15 @@ def wait_for_sync_trigger(config_marker: tuple[int, int, int]) -> tuple[str, tup
628
  if current_config_marker != config_marker:
629
  return wait_for_config_settle(current_config_marker)
630
 
631
- # Sessions changed -> trigger sync immediately (no settle needed;
632
- # session files are written atomically by OpenClaw).
633
- current_sessions_marker = sessions_marker()
634
- if current_sessions_marker != last_sessions_marker:
635
- return ("sessions", current_config_marker)
 
 
 
 
636
 
637
  remaining = deadline - time.monotonic()
638
  if remaining <= 0:
@@ -689,11 +769,16 @@ def loop() -> int:
689
  print("Initial workspace fingerprint captured.")
690
 
691
  config_marker = file_marker(OPENCLAW_CONFIG_FILE)
 
 
 
692
 
693
  while not STOP_EVENT.is_set():
694
  try:
695
  sync_started_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
696
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
 
 
697
  config_marker = file_marker(OPENCLAW_CONFIG_FILE)
698
 
699
  if config_marker != sync_started_config_marker:
@@ -706,14 +791,19 @@ def loop() -> int:
706
  write_status("error", f"Sync failed: {exc}")
707
  print(f"Workspace sync failed: {exc}")
708
  config_marker = file_marker(OPENCLAW_CONFIG_FILE)
 
709
 
710
- trigger, config_marker = wait_for_sync_trigger(config_marker)
 
 
 
711
  if trigger == "stopped":
712
  break
713
  if trigger == "settled":
714
  print("OpenClaw config changed and settled; syncing immediately.")
715
  if trigger == "sessions":
716
  print("Session files changed; syncing immediately.")
 
717
 
718
  return 0
719
 
 
50
  0.0,
51
  float(os.environ.get("OPENCLAW_CONFIG_SETTLE_SECONDS", "3")),
52
  )
53
+ SESSIONS_MIN_SYNC_GAP = int(os.environ.get("SESSIONS_MIN_SYNC_GAP", "30"))
54
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
55
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
56
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
 
83
  HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
84
  STOP_EVENT = threading.Event()
85
  _REPO_ID_CACHE: str | None = None
86
+ _SESSIONS_FILE_DIGEST_CACHE: dict[str, tuple[int, int, int, str]] = {}
87
  WorkspaceMarker: TypeAlias = tuple[int, int, int, str]
88
 
89
 
 
116
  last_exc: Exception | None = None
117
  for attempt in range(1, attempts + 1):
118
  try:
119
+ if backup_path.exists():
120
+ if backup_path.is_dir():
121
+ shutil.rmtree(backup_path, ignore_errors=True)
122
+ else:
123
+ backup_path.unlink(missing_ok=True)
124
  if source_path.is_dir():
125
  shutil.copytree(source_path, backup_path)
126
  return
 
149
  staging_dir = STATE_DIR / ".openclaw-staging"
150
  if staging_dir.exists():
151
  shutil.rmtree(staging_dir, ignore_errors=True)
152
+ if OPENCLAW_STATE_BACKUP_DIR.exists():
153
+ shutil.copytree(OPENCLAW_STATE_BACKUP_DIR, staging_dir)
154
+ else:
155
+ staging_dir.mkdir(parents=True, exist_ok=True)
156
 
157
  skipped_entries: list[tuple[str, Exception]] = []
158
+ copied_entry_names: set[str] = set()
159
  for source_path in OPENCLAW_HOME.iterdir():
160
  if source_path.name in EXCLUDED_STATE_NAMES:
161
  continue
 
163
  backup_path = staging_dir / source_path.name
164
  try:
165
  copy_state_entry_with_retry(source_path, backup_path)
166
+ copied_entry_names.add(source_path.name)
167
  except Exception as entry_exc:
168
  skipped_entries.append((source_path.name, entry_exc))
169
 
170
+ # If staging was seeded from a previous backup, remove entries that no
171
+ # longer exist in OPENCLAW_HOME so the backup remains a true mirror of
172
+ # current state (except entries intentionally excluded from sync).
173
+ for staged_path in list(staging_dir.iterdir()):
174
+ if staged_path.name in EXCLUDED_STATE_NAMES:
175
+ continue
176
+ if staged_path.name in copied_entry_names:
177
+ continue
178
+ if staged_path.exists():
179
+ if staged_path.is_dir():
180
+ shutil.rmtree(staged_path, ignore_errors=True)
181
+ else:
182
+ staged_path.unlink(missing_ok=True)
183
+
184
+ # If any top-level state entries could not be copied, keep the last
185
+ # known-good version for only those entries (staging was seeded from
186
+ # previous backup). This preserves forward progress for the rest.
187
  if skipped_entries:
188
  for name, entry_exc in skipped_entries:
189
+ print(f"Warning: keeping previous state entry {name}: {entry_exc}")
190
  print(
191
+ "Warning: OpenClaw state snapshot had copy failures; updated remaining state entries."
192
  )
193
+ # Atomically swap staging β†’ real backup dir
194
+ if OPENCLAW_STATE_BACKUP_DIR.exists():
195
+ shutil.rmtree(OPENCLAW_STATE_BACKUP_DIR, ignore_errors=True)
196
+ staging_dir.rename(OPENCLAW_STATE_BACKUP_DIR)
 
 
197
  except Exception as exc:
198
  # Clean up staging on failure so it doesn't interfere next time
199
  staging_dir = STATE_DIR / ".openclaw-staging"
 
601
  newest_mtime = 0
602
  metadata_hasher = hashlib.sha256()
603
 
604
+ global _SESSIONS_FILE_DIGEST_CACHE
605
+ next_cache: dict[str, tuple[int, int, int, str]] = {}
606
+
607
  for profile_dir in sorted(SESSIONS_ROOT.iterdir()):
608
  if not profile_dir.is_dir():
609
  continue
610
  sessions_dir = profile_dir / "sessions"
611
+ if not sessions_dir.exists():
612
+ continue
613
+ # Use content fingerprinting for sessions so we detect changes even
614
+ # when file size + mtime metadata appear unchanged across quick writes.
615
+ # (Some tooling can rewrite files in-place with preserved timestamps.)
616
  marker = metadata_marker(sessions_dir)
617
+ digest = hashlib.sha256()
618
+ for path in sorted(p for p in sessions_dir.rglob("*") if p.is_file()):
619
+ rel = path.relative_to(sessions_dir).as_posix()
620
+ try:
621
+ stat = path.stat()
622
+ except OSError:
623
+ continue
624
+ size = int(stat.st_size)
625
+ mtime_ns = int(stat.st_mtime_ns)
626
+ ctime_ns = int(stat.st_ctime_ns)
627
+ cache_key = f"{profile_dir.name}\0{rel}"
628
+ digest.update(rel.encode("utf-8"))
629
+ digest.update(b"\0")
630
+ digest.update(str(size).encode("ascii"))
631
+ digest.update(b"\0")
632
+ digest.update(str(mtime_ns).encode("ascii"))
633
+ digest.update(b"\0")
634
+ digest.update(str(ctime_ns).encode("ascii"))
635
+ digest.update(b"\0")
636
+ cached = _SESSIONS_FILE_DIGEST_CACHE.get(cache_key)
637
+ if (
638
+ cached is not None
639
+ and cached[0] == size
640
+ and cached[1] == mtime_ns
641
+ and cached[2] == ctime_ns
642
+ ):
643
+ file_digest = cached[3]
644
+ else:
645
+ file_hasher = hashlib.sha256()
646
+ try:
647
+ with path.open("rb") as handle:
648
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
649
+ file_hasher.update(chunk)
650
+ except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
651
+ continue
652
+ file_digest = file_hasher.hexdigest()
653
+ next_cache[cache_key] = (size, mtime_ns, ctime_ns, file_digest)
654
+ digest.update(file_digest.encode("ascii"))
655
+ digest.update(b"\0")
656
+
657
  file_count += marker[0]
658
  total_size += marker[1]
659
  newest_mtime = max(newest_mtime, marker[2])
660
  metadata_hasher.update(profile_dir.name.encode("utf-8"))
661
  metadata_hasher.update(b"\0")
662
+ metadata_hasher.update(digest.hexdigest().encode("ascii"))
663
  metadata_hasher.update(b"\0")
664
 
665
+ _SESSIONS_FILE_DIGEST_CACHE = next_cache
666
  return (file_count, total_size, newest_mtime, metadata_hasher.hexdigest())
667
 
668
 
 
688
  return ("stopped", current_marker)
689
 
690
 
691
+ def wait_for_sync_trigger(
692
+ config_marker: tuple[int, int, int],
693
+ last_sessions_sync_time: float = 0.0,
694
+ ) -> tuple[str, tuple[int, int, int]]:
695
  deadline = time.monotonic() + max(0, INTERVAL)
696
  # BUG FIX: also watch sessions directory so new/updated sessions
697
  # trigger an immediate sync instead of waiting the full interval.
 
704
  if current_config_marker != config_marker:
705
  return wait_for_config_settle(current_config_marker)
706
 
707
+ sessions_gap_elapsed = (
708
+ time.monotonic() - last_sessions_sync_time >= SESSIONS_MIN_SYNC_GAP
709
+ )
710
+ if sessions_gap_elapsed:
711
+ # Sessions changed -> trigger sync immediately (no settle needed;
712
+ # session files are written atomically by OpenClaw).
713
+ current_sessions_marker = sessions_marker()
714
+ if current_sessions_marker != last_sessions_marker:
715
+ return ("sessions", current_config_marker)
716
 
717
  remaining = deadline - time.monotonic()
718
  if remaining <= 0:
 
769
  print("Initial workspace fingerprint captured.")
770
 
771
  config_marker = file_marker(OPENCLAW_CONFIG_FILE)
772
+ last_sessions_sync_time = 0.0
773
+
774
+ sync_trigger = "startup"
775
 
776
  while not STOP_EVENT.is_set():
777
  try:
778
  sync_started_config_marker = file_marker(OPENCLAW_CONFIG_FILE)
779
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
780
+ if sync_trigger == "sessions":
781
+ last_sessions_sync_time = time.monotonic()
782
  config_marker = file_marker(OPENCLAW_CONFIG_FILE)
783
 
784
  if config_marker != sync_started_config_marker:
 
791
  write_status("error", f"Sync failed: {exc}")
792
  print(f"Workspace sync failed: {exc}")
793
  config_marker = file_marker(OPENCLAW_CONFIG_FILE)
794
+ STOP_EVENT.wait(min(30, SESSIONS_MIN_SYNC_GAP))
795
 
796
+ trigger, config_marker = wait_for_sync_trigger(
797
+ config_marker,
798
+ last_sessions_sync_time=last_sessions_sync_time,
799
+ )
800
  if trigger == "stopped":
801
  break
802
  if trigger == "settled":
803
  print("OpenClaw config changed and settled; syncing immediately.")
804
  if trigger == "sessions":
805
  print("Session files changed; syncing immediately.")
806
+ sync_trigger = trigger
807
 
808
  return 0
809
 
start.sh CHANGED
@@ -99,6 +99,11 @@ if [ "$DEV_MODE_ENABLED" != "true" ] && [ -z "${DEV_MODE:-}" ] && [ -n "${GATEWA
99
  DEV_MODE_ENABLED=true
100
  : # auto-enable is silent; set DEV_MODE=false to opt out
101
  fi
 
 
 
 
 
102
  SYNC_INTERVAL="$(trim_var "${SYNC_INTERVAL:-180}")"
103
  DEVDATA_DATASET_NAME="$(trim_var "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}")"
104
  DEVDATA_SYNC_INTERVAL="$(trim_var "${DEVDATA_SYNC_INTERVAL:-180}")"
@@ -250,7 +255,6 @@ promote_first_pool_key() {
250
  promote_first_pool_key "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"
251
  promote_first_pool_key "OPENAI_API_KEY" "OPENAI_API_KEYS"
252
  promote_first_pool_key "GEMINI_API_KEY" "GEMINI_API_KEYS"
253
- promote_first_pool_key "GEMINI_API_KEY" "GOOGLE_API_KEYS"
254
  promote_first_pool_key "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"
255
  promote_first_pool_key "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"
256
  promote_first_pool_key "KILOCODE_API_KEY" "KILOCODE_API_KEYS"
@@ -284,11 +288,6 @@ if [ -z "${MOONSHOT_API_KEY:-}" ] && [ -n "${KIMI_API_KEY:-}" ]; then
284
  fi
285
  promote_first_pool_key "HUGGINGFACE_HUB_TOKEN" "HUGGINGFACE_HUB_TOKENS"
286
 
287
- # Compatibility aliases for Google provider secrets some users already have.
288
- if [ -z "${GEMINI_API_KEY:-}" ] && [ -n "${GOOGLE_API_KEY:-}" ]; then
289
- export GEMINI_API_KEY="$GOOGLE_API_KEY"
290
- fi
291
-
292
  # ── Setup directories ──
293
  mkdir -p /home/node/.openclaw/agents/main/sessions
294
  mkdir -p /home/node/.openclaw/credentials
@@ -323,7 +322,7 @@ else
323
  echo "HF_TOKEN not set β€” running without dataset persistence."
324
  fi
325
 
326
- CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
327
  export CLOUDFLARE_WORKERS_TOKEN
328
  CF_PROXY_ENV_FILE="/tmp/huggingclaw-cloudflare-proxy.env"
329
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
 
99
  DEV_MODE_ENABLED=true
100
  : # auto-enable is silent; set DEV_MODE=false to opt out
101
  fi
102
+ if [ "$DEV_MODE_ENABLED" = "true" ]; then
103
+ export DEV_MODE=true
104
+ else
105
+ export DEV_MODE=false
106
+ fi
107
  SYNC_INTERVAL="$(trim_var "${SYNC_INTERVAL:-180}")"
108
  DEVDATA_DATASET_NAME="$(trim_var "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}")"
109
  DEVDATA_SYNC_INTERVAL="$(trim_var "${DEVDATA_SYNC_INTERVAL:-180}")"
 
255
  promote_first_pool_key "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"
256
  promote_first_pool_key "OPENAI_API_KEY" "OPENAI_API_KEYS"
257
  promote_first_pool_key "GEMINI_API_KEY" "GEMINI_API_KEYS"
 
258
  promote_first_pool_key "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"
259
  promote_first_pool_key "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"
260
  promote_first_pool_key "KILOCODE_API_KEY" "KILOCODE_API_KEYS"
 
288
  fi
289
  promote_first_pool_key "HUGGINGFACE_HUB_TOKEN" "HUGGINGFACE_HUB_TOKENS"
290
 
 
 
 
 
 
291
  # ── Setup directories ──
292
  mkdir -p /home/node/.openclaw/agents/main/sessions
293
  mkdir -p /home/node/.openclaw/credentials
 
322
  echo "HF_TOKEN not set β€” running without dataset persistence."
323
  fi
324
 
325
+ CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-}"
326
  export CLOUDFLARE_WORKERS_TOKEN
327
  CF_PROXY_ENV_FILE="/tmp/huggingclaw-cloudflare-proxy.env"
328
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then