Spaces:
Running
Running
Codex commited on
Commit Β·
14fd0fa
1
Parent(s): b617e46
Fix sync robustness, devdata activation, alias cleanup, and timing-safe auth compare
Browse files- .env.example +3 -0
- README.md +1 -0
- env-builder.js +9 -26
- health-server.js +5 -8
- jupyter-devdata-sync.py +36 -13
- openclaw-sync.py +110 -20
- 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 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 101 |
-
"
|
| 102 |
-
"
|
| 103 |
-
"
|
| 104 |
-
"
|
| 105 |
-
"
|
| 106 |
-
"
|
| 107 |
-
"
|
| 108 |
-
"*
|
| 109 |
-
"*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 159 |
-
#
|
| 160 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
if skipped_entries:
|
| 162 |
for name, entry_exc in skipped_entries:
|
| 163 |
-
print(f"Warning:
|
| 164 |
print(
|
| 165 |
-
"Warning: OpenClaw state snapshot
|
| 166 |
)
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 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(
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
if
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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:-
|
| 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
|