mlclaw 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/mlclaw/SKILL.md +58 -19
- package/Dockerfile +46 -8
- package/README.md +75 -17
- package/assets/mlclaw-control-ui/assets/index-92PjW54g.js +9 -0
- package/assets/mlclaw-control-ui/assets/index-D1bHaYpf.css +1 -0
- package/assets/mlclaw-control-ui/index.html +2 -2
- package/dist/hf-state-sync.js +438 -76
- package/dist/hf-tooling-seed.js +64 -11
- package/dist/mlclaw-space-runtime.js +8403 -5935
- package/dist/mlclaw.mjs +406 -42
- package/entrypoint.sh +167 -17
- package/hf-broker.scope.json +38 -0
- package/package.json +26 -2
- package/space/README.md +14 -4
- package/src/hf-state-sync/archive.ts +15 -8
- package/src/hf-state-sync/cli.ts +33 -6
- package/src/hf-state-sync/hub.ts +82 -1
- package/src/hf-state-sync/paths.ts +23 -4
- package/src/hf-state-sync/prepare.ts +79 -0
- package/src/hf-state-sync/snapshot.ts +31 -9
- package/src/hf-state-sync/stage-worker.ts +165 -0
- package/src/hf-state-sync/supervise.ts +29 -10
- package/src/vendor/hfjs-xet/utils/ChunkCache.ts +1 -1
- package/src/vendor/hfjs-xet/utils/XetBlob.ts +2 -2
- package/src/vendor/hfjs-xet/utils/createXorbs.ts +2 -2
- package/assets/mlclaw-control-ui/assets/index-D2TFes32.js +0 -9
- package/assets/mlclaw-control-ui/assets/index-DP72PFuv.css +0 -1
package/entrypoint.sh
CHANGED
|
@@ -1,16 +1,138 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
|
-
LIVE_DIR="${OPENCLAW_LIVE_DIR:-/
|
|
4
|
+
LIVE_DIR="${OPENCLAW_LIVE_DIR:-/home/node/.local/share/mlclaw/live}"
|
|
5
|
+
OPENCLAW_UID="${MLCLAW_OPENCLAW_UID:-1000}"
|
|
6
|
+
OPENCLAW_GID="${MLCLAW_OPENCLAW_GID:-1000}"
|
|
7
|
+
OPENCLAW_IDENTITY="${OPENCLAW_UID}:${OPENCLAW_GID}"
|
|
8
|
+
export MLCLAW_OPENCLAW_UID="$OPENCLAW_UID"
|
|
9
|
+
export MLCLAW_OPENCLAW_GID="$OPENCLAW_GID"
|
|
10
|
+
HF_BROKER_ENABLED=0
|
|
11
|
+
HF_BROKER_RUN_DIR="/run/mlclaw-hf-broker"
|
|
12
|
+
STATE_HF_TOKEN=""
|
|
13
|
+
RESTORED_PROTECTED_STATE_DIR="$LIVE_DIR/.mlclaw-protected"
|
|
14
|
+
PROTECTED_STATE_DIR="/var/lib/mlclaw-protected"
|
|
15
|
+
HF_BROKER_STATE_DIR="$PROTECTED_STATE_DIR/hf-broker"
|
|
16
|
+
|
|
17
|
+
prepare_hf_broker() {
|
|
18
|
+
local broker_token="${MLCLAW_BROKER_HF_TOKEN:-${HF_TOKEN:-${HUGGINGFACE_HUB_TOKEN:-${MLCLAW_ROUTER_TOKEN:-${HF_ROUTER_TOKEN:-}}}}}"
|
|
19
|
+
if [ -z "$broker_token" ]; then
|
|
20
|
+
echo "[hf-broker] MLCLAW_BROKER_HF_TOKEN is not configured; broker disabled"
|
|
21
|
+
return
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
local token_file="$HF_BROKER_RUN_DIR/hf-token"
|
|
25
|
+
local agent_secret_file="$HF_BROKER_RUN_DIR/agent-secret"
|
|
26
|
+
local operator_secret_file="$HF_BROKER_RUN_DIR/operator-secret"
|
|
27
|
+
local broker_agent_secrets="$HF_BROKER_RUN_DIR/agent-secrets.conf"
|
|
28
|
+
local broker_operator_secrets="$HF_BROKER_RUN_DIR/operator-secrets.conf"
|
|
29
|
+
local operator_brokers_file="$HF_BROKER_RUN_DIR/operator-brokers.json"
|
|
30
|
+
local agent_secret operator_secret
|
|
31
|
+
|
|
32
|
+
install -d -m 0750 -o root -g hf-broker "$HF_BROKER_RUN_DIR"
|
|
33
|
+
agent_secret="$(od -An -N48 -tx1 /dev/urandom | tr -d ' \n')"
|
|
34
|
+
operator_secret="$(od -An -N48 -tx1 /dev/urandom | tr -d ' \n')"
|
|
35
|
+
printf '%s\n' "$broker_token" > "$token_file"
|
|
36
|
+
printf '%s\n' "$agent_secret" > "$agent_secret_file"
|
|
37
|
+
printf '%s\n' "$operator_secret" > "$operator_secret_file"
|
|
38
|
+
printf 'default = %s\n' "$agent_secret" > "$broker_agent_secrets"
|
|
39
|
+
printf 'mlclaw-control = %s\n' "$operator_secret" > "$broker_operator_secrets"
|
|
40
|
+
printf '{"version":1,"brokers":[{"id":"hf-broker","label":"Hugging Face","url":"http://127.0.0.1:7864","token_file":"%s"}]}\n' "$operator_secret_file" > "$operator_brokers_file"
|
|
41
|
+
chown hf-broker:hf-broker "$token_file" "$broker_agent_secrets" "$broker_operator_secrets"
|
|
42
|
+
chmod 0600 "$token_file" "$agent_secret_file" "$operator_secret_file" "$broker_agent_secrets" "$broker_operator_secrets" "$operator_brokers_file"
|
|
43
|
+
|
|
44
|
+
if [ -z "${MLCLAW_STATE_MOUNT_DIR:-}" ]; then
|
|
45
|
+
STATE_HF_TOKEN="${MLCLAW_BROKER_HF_TOKEN:-${HF_TOKEN:-${HUGGINGFACE_HUB_TOKEN:-$broker_token}}}"
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
export MLCLAW_HF_BROKER_URL="http://127.0.0.1:7863"
|
|
49
|
+
export MLCLAW_HF_BROKER_AGENT_SECRET_FILE="$agent_secret_file"
|
|
50
|
+
if [ "${MLCLAW_GATEWAY_LOCATION:-}" = "local" ]; then
|
|
51
|
+
export MLCLAW_TRUSTED_HF_TOKEN_FILE="$token_file"
|
|
52
|
+
fi
|
|
53
|
+
if [ -z "${MLCLAW_OPERATOR_BROKERS_FILE:-}" ]; then
|
|
54
|
+
export MLCLAW_OPERATOR_BROKERS_FILE="$operator_brokers_file"
|
|
55
|
+
fi
|
|
56
|
+
HF_BROKER_ENABLED=1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
restore_protected_state() {
|
|
60
|
+
install -d -m 0710 -o root -g hf-broker "$PROTECTED_STATE_DIR"
|
|
61
|
+
if [ -d "$RESTORED_PROTECTED_STATE_DIR" ]; then
|
|
62
|
+
find "$PROTECTED_STATE_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
|
63
|
+
cp -a "$RESTORED_PROTECTED_STATE_DIR/." "$PROTECTED_STATE_DIR/"
|
|
64
|
+
rm -rf "$RESTORED_PROTECTED_STATE_DIR"
|
|
65
|
+
fi
|
|
66
|
+
install -d -m 0700 -o root -g root "$PROTECTED_STATE_DIR/control"
|
|
67
|
+
install -d -m 0700 -o hf-broker -g hf-broker "$HF_BROKER_STATE_DIR"
|
|
68
|
+
chown -R root:root "$PROTECTED_STATE_DIR/control"
|
|
69
|
+
chown -R hf-broker:hf-broker "$HF_BROKER_STATE_DIR"
|
|
70
|
+
chmod 0710 "$PROTECTED_STATE_DIR"
|
|
71
|
+
chmod 0700 "$PROTECTED_STATE_DIR/control" "$HF_BROKER_STATE_DIR"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
start_hf_broker() {
|
|
75
|
+
if [ "$HF_BROKER_ENABLED" != "1" ]; then
|
|
76
|
+
return
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
install -d -m 0700 -o hf-broker -g hf-broker "$HF_BROKER_STATE_DIR"
|
|
80
|
+
install -d -m 0700 -o hf-broker -g hf-broker "$HF_BROKER_STATE_DIR/grants"
|
|
81
|
+
if [ ! -e "$HF_BROKER_STATE_DIR/grants/grants.json" ]; then
|
|
82
|
+
printf '{"grants":[]}\n' > "$HF_BROKER_STATE_DIR/grants/grants.json"
|
|
83
|
+
chown hf-broker:hf-broker "$HF_BROKER_STATE_DIR/grants/grants.json"
|
|
84
|
+
chmod 0600 "$HF_BROKER_STATE_DIR/grants/grants.json"
|
|
85
|
+
fi
|
|
86
|
+
chown -R hf-broker:hf-broker "$HF_BROKER_STATE_DIR"
|
|
87
|
+
chmod 0700 "$HF_BROKER_STATE_DIR"
|
|
88
|
+
|
|
89
|
+
HF_BROKER_HF_TOKEN_FILE="$HF_BROKER_RUN_DIR/hf-token" \
|
|
90
|
+
HF_BROKER_SECRETS_FILE="$HF_BROKER_RUN_DIR/agent-secrets.conf" \
|
|
91
|
+
HF_BROKER_OPERATOR_SECRETS_FILE="$HF_BROKER_RUN_DIR/operator-secrets.conf" \
|
|
92
|
+
HF_BROKER_BIND_ADDR=127.0.0.1 \
|
|
93
|
+
HF_BROKER_PORT=7863 \
|
|
94
|
+
HF_BROKER_OPERATOR_BIND_ADDR=127.0.0.1 \
|
|
95
|
+
HF_BROKER_OPERATOR_PORT=7864 \
|
|
96
|
+
HF_BROKER_SCOPE_FILE=/app/hf-broker.scope.json \
|
|
97
|
+
HF_BROKER_STATE_DIR="$HF_BROKER_STATE_DIR" \
|
|
98
|
+
gosu hf-broker:hf-broker /usr/local/bin/hf-broker &
|
|
99
|
+
HF_BROKER_PID=$!
|
|
100
|
+
|
|
101
|
+
for _ in $(seq 1 50); do
|
|
102
|
+
if ! kill -0 "$HF_BROKER_PID" 2>/dev/null; then
|
|
103
|
+
echo "[hf-broker] process exited during startup" >&2
|
|
104
|
+
wait "$HF_BROKER_PID"
|
|
105
|
+
return 1
|
|
106
|
+
fi
|
|
107
|
+
if node -e "fetch('http://127.0.0.1:7863/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; then
|
|
108
|
+
echo "[hf-broker] agent and operator listeners ready"
|
|
109
|
+
return
|
|
110
|
+
fi
|
|
111
|
+
sleep 0.1
|
|
112
|
+
done
|
|
113
|
+
echo "[hf-broker] startup timed out" >&2
|
|
114
|
+
kill "$HF_BROKER_PID" 2>/dev/null || true
|
|
115
|
+
return 1
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
chown_openclaw_live() {
|
|
119
|
+
chown "$OPENCLAW_IDENTITY" "$LIVE_DIR"
|
|
120
|
+
find "$LIVE_DIR" -mindepth 1 -maxdepth 1 ! -name .mlclaw-protected -exec chown -R "$OPENCLAW_IDENTITY" {} +
|
|
121
|
+
}
|
|
5
122
|
|
|
6
123
|
if [ "${MLCLAW_GATEWAY_DISABLED:-0}" = "1" ]; then
|
|
7
124
|
echo "[mlclaw] gateway disabled"
|
|
8
125
|
exit 0
|
|
9
126
|
fi
|
|
10
127
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
128
|
+
prepare_hf_broker
|
|
129
|
+
export MLCLAW_PROTECTED_STATE_DIR="$PROTECTED_STATE_DIR"
|
|
130
|
+
export MLCLAW_OPENAI_CREDENTIAL_STORE_FILE="$PROTECTED_STATE_DIR/control/openai-api-key.enc"
|
|
131
|
+
# The broad token and legacy Router token must not enter the control plane or
|
|
132
|
+
# OpenClaw. The token is already in the broker-owned runtime file before the
|
|
133
|
+
# environment is scrubbed; local bucket state sync receives a dedicated copy
|
|
134
|
+
# only around trusted restore and supervisor execution.
|
|
135
|
+
unset MLCLAW_BROKER_HF_TOKEN MLCLAW_ROUTER_TOKEN HF_ROUTER_TOKEN HF_TOKEN HUGGINGFACE_HUB_TOKEN
|
|
14
136
|
|
|
15
137
|
# State, workspace, and config paths are ALWAYS derived from the live dir,
|
|
16
138
|
# never inherited: older deployments set OPENCLAW_STATE_DIR=/data/... as Space
|
|
@@ -30,15 +152,25 @@ CONFIG_PATH="$OPENCLAW_CONFIG_PATH"
|
|
|
30
152
|
# silently start fresh then snapshot over a bucket that still holds data).
|
|
31
153
|
echo "[hf-state-sync] starting restore"
|
|
32
154
|
RESTORE_TIMEOUT_SECONDS="${MLCLAW_RESTORE_TIMEOUT_SECONDS:-180}"
|
|
155
|
+
node /app/hf-state-sync.js prepare-restore
|
|
33
156
|
if command -v timeout >/dev/null 2>&1; then
|
|
34
|
-
timeout "${RESTORE_TIMEOUT_SECONDS}s" node /app/hf-state-sync.js restore
|
|
157
|
+
env MLCLAW_STATE_HF_TOKEN="$STATE_HF_TOKEN" timeout "${RESTORE_TIMEOUT_SECONDS}s" gosu "$OPENCLAW_IDENTITY" node /app/hf-state-sync.js restore
|
|
35
158
|
else
|
|
36
|
-
node /app/hf-state-sync.js restore
|
|
159
|
+
env MLCLAW_STATE_HF_TOKEN="$STATE_HF_TOKEN" gosu "$OPENCLAW_IDENTITY" node /app/hf-state-sync.js restore
|
|
37
160
|
fi
|
|
38
161
|
echo "[hf-state-sync] restore complete"
|
|
162
|
+
restore_protected_state
|
|
163
|
+
|
|
164
|
+
if [ -n "${MLCLAW_STATE_MOUNT_DIR:-}" ]; then
|
|
165
|
+
chown root:root "$MLCLAW_STATE_MOUNT_DIR"
|
|
166
|
+
chmod 0700 "$MLCLAW_STATE_MOUNT_DIR"
|
|
167
|
+
fi
|
|
39
168
|
|
|
40
169
|
mkdir -p "$LIVE_DIR" "$WORKSPACE_DIR" "$STATE_DIR"
|
|
41
|
-
|
|
170
|
+
chown_openclaw_live
|
|
171
|
+
install -d -m 0710 -o root -g hf-broker "$PROTECTED_STATE_DIR"
|
|
172
|
+
install -d -m 0700 -o root -g root "$PROTECTED_STATE_DIR/control"
|
|
173
|
+
start_hf_broker
|
|
42
174
|
|
|
43
175
|
if [ -n "${OPENCLAW_AGENT_NAME:-}" ]; then
|
|
44
176
|
printf "%s\n" "$OPENCLAW_AGENT_NAME" > "$STATE_DIR/agent-name.txt"
|
|
@@ -47,17 +179,32 @@ fi
|
|
|
47
179
|
if [ ! -f "$CONFIG_PATH" ]; then
|
|
48
180
|
cp /app/openclaw.default.json "$CONFIG_PATH"
|
|
49
181
|
fi
|
|
50
|
-
|
|
182
|
+
chown_openclaw_live
|
|
183
|
+
|
|
184
|
+
# Let OpenClaw create its native workspace files. The ML Claw runtime waits for
|
|
185
|
+
# native onboarding to finish before adding workspace tooling; OpenClaw treats
|
|
186
|
+
# any preinstalled workspace skills as evidence that onboarding already ran.
|
|
187
|
+
echo "[openclaw-setup] initializing baseline workspace"
|
|
188
|
+
env \
|
|
189
|
+
-u MLCLAW_CREDENTIAL_KEY \
|
|
190
|
+
-u MLCLAW_SESSION_SECRET \
|
|
191
|
+
-u SESSION_SECRET \
|
|
192
|
+
-u OAUTH_CLIENT_SECRET \
|
|
193
|
+
-u HF_TOKEN \
|
|
194
|
+
-u HUGGINGFACE_HUB_TOKEN \
|
|
195
|
+
HOME=/home/node USER=node LOGNAME=node \
|
|
196
|
+
gosu "$OPENCLAW_IDENTITY" node /app/openclaw.mjs setup --baseline --workspace "$WORKSPACE_DIR"
|
|
197
|
+
echo "[openclaw-setup] baseline workspace ready"
|
|
51
198
|
|
|
52
199
|
if [ -n "${OPENCLAW_MODEL:-}" ]; then
|
|
53
200
|
echo "[huggingface-config] configuring selected Hugging Face model"
|
|
54
|
-
gosu
|
|
201
|
+
gosu "$OPENCLAW_IDENTITY" node /app/scripts/configure-huggingface-model.mjs "$CONFIG_PATH"
|
|
55
202
|
echo "[huggingface-config] Hugging Face model configured"
|
|
56
203
|
fi
|
|
57
204
|
|
|
58
205
|
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
|
59
206
|
echo "[telegram-config] configuring Telegram channel"
|
|
60
|
-
gosu
|
|
207
|
+
gosu "$OPENCLAW_IDENTITY" node /app/scripts/configure-telegram.mjs "$CONFIG_PATH" "$TELEGRAM_ALLOWED_USERS"
|
|
61
208
|
echo "[telegram-config] Telegram channel configured"
|
|
62
209
|
fi
|
|
63
210
|
|
|
@@ -73,7 +220,7 @@ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ "${OPENCLAW_TELEGRAM_CONNECTIVITY_PROBE
|
|
|
73
220
|
if curl -fsS --connect-timeout 20 --max-time 30 "${PROBE_PROXY[@]}" \
|
|
74
221
|
"${PROBE_API_ROOT}/bot${TELEGRAM_BOT_TOKEN}/getMe" \
|
|
75
222
|
-o "$PROBE_OUT"; then
|
|
76
|
-
gosu
|
|
223
|
+
gosu "$OPENCLAW_IDENTITY" node /app/scripts/report-telegram-probe.mjs "$PROBE_OUT" || true
|
|
77
224
|
else
|
|
78
225
|
echo "[telegram-probe] curl getMe failed"
|
|
79
226
|
fi
|
|
@@ -83,9 +230,12 @@ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ "${OPENCLAW_TELEGRAM_CONNECTIVITY_PROBE
|
|
|
83
230
|
fi
|
|
84
231
|
fi
|
|
85
232
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
233
|
+
chown_openclaw_live
|
|
234
|
+
# The wrapper remains the trusted root supervisor so its OAuth credentials and
|
|
235
|
+
# process environment are not readable by the unprivileged OpenClaw child. The
|
|
236
|
+
# state supervisor stages live files in a separate secret-free node process;
|
|
237
|
+
# only the trusted parent uploads the resulting archive.
|
|
238
|
+
if [ -n "$STATE_HF_TOKEN" ]; then
|
|
239
|
+
export MLCLAW_STATE_HF_TOKEN="$STATE_HF_TOKEN"
|
|
240
|
+
fi
|
|
241
|
+
exec node /app/hf-state-sync.js supervise -- node /app/mlclaw-space-runtime.js
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rules": [
|
|
3
|
+
{
|
|
4
|
+
"id": "mlclaw-read-repositories",
|
|
5
|
+
"effect": "allow",
|
|
6
|
+
"clients": ["default"],
|
|
7
|
+
"operations": ["repo.list", "repo.metadata.read", "repo.contents.read", "git.fetch"],
|
|
8
|
+
"targets": [
|
|
9
|
+
{"kind": "repo", "type": "*", "owner": "*", "name": "*"}
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "mlclaw-append-repositories",
|
|
14
|
+
"effect": "allow",
|
|
15
|
+
"clients": ["default"],
|
|
16
|
+
"operations": ["git.push.append"],
|
|
17
|
+
"targets": [
|
|
18
|
+
{"kind": "repo", "type": "*", "owner": "*", "name": "*"}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "mlclaw-request-protected-repository-writes",
|
|
23
|
+
"effect": "request",
|
|
24
|
+
"clients": ["default"],
|
|
25
|
+
"operations": ["git.push.force", "git.ref.delete", "git.tag.update"],
|
|
26
|
+
"targets": [
|
|
27
|
+
{"kind": "repo", "type": "*", "owner": "*", "name": "*"}
|
|
28
|
+
],
|
|
29
|
+
"grant_policy": {
|
|
30
|
+
"default_minutes": 5,
|
|
31
|
+
"max_minutes": 60,
|
|
32
|
+
"request_ttl_minutes": 15,
|
|
33
|
+
"default_max_uses": 1,
|
|
34
|
+
"max_uses": 3
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mlclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"config": {
|
|
6
|
-
"openclawVersion": "2026.7.1-beta.
|
|
6
|
+
"openclawVersion": "2026.7.1-beta.5",
|
|
7
|
+
"brokerkitVersion": "fcfc5f2304436d952b18e7c9583bb378d1952776",
|
|
8
|
+
"brokerkitPluginVersion": "0.1.0",
|
|
7
9
|
"runtimeImageRepository": "ghcr.io/osolmaz/mlclaw"
|
|
8
10
|
},
|
|
9
11
|
"repository": {
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
"dist/mlclaw-space-runtime.js",
|
|
29
31
|
"Dockerfile",
|
|
30
32
|
"entrypoint.sh",
|
|
33
|
+
"hf-broker.scope.json",
|
|
31
34
|
"mlclaw.ps1",
|
|
32
35
|
"mlclaw.sh",
|
|
33
36
|
"LICENSE",
|
|
@@ -53,6 +56,14 @@
|
|
|
53
56
|
"pack:check": "node scripts/check-package.mjs",
|
|
54
57
|
"prepack": "npm run build",
|
|
55
58
|
"check:secrets": "node scripts/check-secrets.mjs",
|
|
59
|
+
"format": "prettier --check eslint.config.mjs vitest.config.ts vitest.stryker.config.ts package.json README.md src/hf-state-sync/archive.ts src/hf-state-sync/cli.ts src/hf-state-sync/paths.ts src/hf-state-sync/prepare.ts src/hf-state-sync/stage-worker.ts src/hf-state-sync/supervise.ts src/mlclaw-space-runtime/delegated-brokerkit.ts src/mlclaw-space-runtime/operator-brokers.ts src/mlclaw-space-runtime/openclaw-config.ts src/mlclaw-space-runtime/app.ts src/mlclaw-space-runtime/config.ts src/mlclaw-space-runtime/openai-credentials.ts src/mlclaw-space-runtime/server.ts src/mlclaw-space-runtime/shell.ts src/mlclaw-control-ui/src/main.tsx src/mlclaw-control-ui/src/styles.css src/mlclaw-control-ui/src/components/ui/utils.ts src/mlclaw/git.ts test/hf-state-sync.archive.test.ts test/hf-state-sync.roundtrip.test.ts test/hf-state-sync.supervise.test.ts test/mlclaw.bundle.test.ts test/mlclaw.delegated-brokerkit.test.ts test/mlclaw.generate-space.test.ts test/mlclaw.operator-brokers.test.ts test/mlclaw.runtime-image.test.ts test/mlclaw.space-runtime.test.ts docs/operator-brokers-config.md",
|
|
60
|
+
"format:write": "prettier --write eslint.config.mjs vitest.config.ts vitest.stryker.config.ts package.json README.md src/hf-state-sync/archive.ts src/hf-state-sync/cli.ts src/hf-state-sync/paths.ts src/hf-state-sync/prepare.ts src/hf-state-sync/stage-worker.ts src/hf-state-sync/supervise.ts src/mlclaw-space-runtime/delegated-brokerkit.ts src/mlclaw-space-runtime/operator-brokers.ts src/mlclaw-space-runtime/openclaw-config.ts src/mlclaw-space-runtime/app.ts src/mlclaw-space-runtime/config.ts src/mlclaw-space-runtime/openai-credentials.ts src/mlclaw-space-runtime/server.ts src/mlclaw-space-runtime/shell.ts src/mlclaw-control-ui/src/main.tsx src/mlclaw-control-ui/src/styles.css src/mlclaw-control-ui/src/components/ui/utils.ts src/mlclaw/git.ts test/hf-state-sync.archive.test.ts test/hf-state-sync.roundtrip.test.ts test/hf-state-sync.supervise.test.ts test/mlclaw.bundle.test.ts test/mlclaw.delegated-brokerkit.test.ts test/mlclaw.generate-space.test.ts test/mlclaw.operator-brokers.test.ts test/mlclaw.runtime-image.test.ts test/mlclaw.space-runtime.test.ts docs/operator-brokers-config.md",
|
|
61
|
+
"lint": "eslint --max-warnings 0 src/mlclaw-space-runtime/delegated-brokerkit.ts src/mlclaw-space-runtime/operator-brokers.ts src/mlclaw-space-runtime/openclaw-config.ts",
|
|
62
|
+
"coverage": "vitest run --coverage",
|
|
63
|
+
"dry": "slophammer-ts dry .",
|
|
64
|
+
"mutate": "stryker run --dryRunOnly",
|
|
65
|
+
"slophammer": "slophammer-ts check .",
|
|
66
|
+
"check": "npm run format && npm run lint && npm run typecheck && npm test && npm run coverage && npm run build && npm run check:secrets && npm run pack:check && npm run dry && npm run slophammer",
|
|
56
67
|
"test": "vitest run",
|
|
57
68
|
"typecheck": "tsc --noEmit"
|
|
58
69
|
},
|
|
@@ -62,18 +73,31 @@
|
|
|
62
73
|
"@huggingface/xetchunk-wasm": "^0.0.6",
|
|
63
74
|
"commander": "^14.0.3",
|
|
64
75
|
"hono": "^4.12.28",
|
|
76
|
+
"lucide-react": "^0.468.0",
|
|
65
77
|
"react": "^19.2.7",
|
|
66
78
|
"react-dom": "^19.2.7",
|
|
67
79
|
"skillflag": "^0.2.0",
|
|
68
80
|
"zod": "^3.24.0"
|
|
69
81
|
},
|
|
70
82
|
"devDependencies": {
|
|
83
|
+
"@eslint/js": "^9.39.5",
|
|
84
|
+
"@stryker-mutator/core": "^9.6.1",
|
|
85
|
+
"@stryker-mutator/typescript-checker": "^9.6.1",
|
|
86
|
+
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
87
|
+
"@testing-library/react": "^16.3.2",
|
|
88
|
+
"@testing-library/user-event": "^14.6.1",
|
|
71
89
|
"@types/node": "^24.0.0",
|
|
72
90
|
"@types/react": "^19.2.17",
|
|
73
91
|
"@types/react-dom": "^19.2.3",
|
|
74
92
|
"@vitejs/plugin-react": "^5.2.0",
|
|
93
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
75
94
|
"esbuild": "^0.25.0",
|
|
95
|
+
"eslint": "^9.39.5",
|
|
96
|
+
"jsdom": "^29.1.1",
|
|
97
|
+
"prettier": "^3.9.5",
|
|
98
|
+
"slophammer-ts": "^0.3.0",
|
|
76
99
|
"typescript": "^5.7.0",
|
|
100
|
+
"typescript-eslint": "^8.63.0",
|
|
77
101
|
"vite": "^7.3.6",
|
|
78
102
|
"vitest": "^3.0.0"
|
|
79
103
|
}
|
package/space/README.md
CHANGED
|
@@ -7,10 +7,19 @@ sdk: docker
|
|
|
7
7
|
app_port: 7860
|
|
8
8
|
hf_oauth: true
|
|
9
9
|
hf_oauth_expiration_minutes: 43200
|
|
10
|
+
hf_oauth_scopes:
|
|
11
|
+
- read-mcp
|
|
12
|
+
- read-repos
|
|
13
|
+
- contribute-repos
|
|
14
|
+
- write-repos
|
|
15
|
+
- manage-repos
|
|
16
|
+
- inference-api
|
|
17
|
+
- jobs
|
|
10
18
|
pinned: false
|
|
11
19
|
secrets:
|
|
12
|
-
-
|
|
20
|
+
- MLCLAW_ROUTER_TOKEN
|
|
13
21
|
- MLCLAW_SESSION_SECRET
|
|
22
|
+
- MLCLAW_CREDENTIAL_KEY
|
|
14
23
|
- OPENAI_API_KEY
|
|
15
24
|
---
|
|
16
25
|
|
|
@@ -60,9 +69,10 @@ OpenClaw trusted-proxy auth. The browser never receives an OpenClaw gateway
|
|
|
60
69
|
token.
|
|
61
70
|
|
|
62
71
|
Durable state lives in a private Hugging Face Storage Bucket configured by
|
|
63
|
-
`OPENCLAW_HF_STATE_BUCKET
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
`OPENCLAW_HF_STATE_BUCKET` and mounted at `/data/mlclaw-state`. The mount is
|
|
73
|
+
only the snapshot store; live SQLite and workspace state stay on local
|
|
74
|
+
container disk. `hf-state-sync` restores verified snapshots on boot and uploads
|
|
75
|
+
new snapshots during runtime and shutdown.
|
|
66
76
|
|
|
67
77
|
Manage an existing deployment from your machine with:
|
|
68
78
|
|
|
@@ -19,6 +19,7 @@ const STATE_EXCLUDED_SUFFIXES = [".log"];
|
|
|
19
19
|
// are replaced by VACUUM INTO copies during staging.
|
|
20
20
|
const SIDECAR_SUFFIXES = [".sqlite-wal", ".sqlite-shm"];
|
|
21
21
|
const STATE_DIR_NAME = ".openclaw";
|
|
22
|
+
export const PROTECTED_STATE_DIR_NAME = ".mlclaw-protected";
|
|
22
23
|
|
|
23
24
|
function isExcluded(name: string, inStateDir: boolean): boolean {
|
|
24
25
|
if (SIDECAR_SUFFIXES.some((suffix) => name.endsWith(suffix))) {
|
|
@@ -27,10 +28,7 @@ function isExcluded(name: string, inStateDir: boolean): boolean {
|
|
|
27
28
|
if (!inStateDir) {
|
|
28
29
|
return false;
|
|
29
30
|
}
|
|
30
|
-
return (
|
|
31
|
-
STATE_EXCLUDED_NAMES.has(name) ||
|
|
32
|
-
STATE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
|
33
|
-
);
|
|
31
|
+
return STATE_EXCLUDED_NAMES.has(name) || STATE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
/**
|
|
@@ -46,11 +44,15 @@ async function copyTreeFiltered(params: {
|
|
|
46
44
|
rootDir: string;
|
|
47
45
|
inStateDir: boolean;
|
|
48
46
|
depth: number;
|
|
47
|
+
excludeProtectedState: boolean;
|
|
49
48
|
}): Promise<void> {
|
|
50
|
-
const { sourceDir, destDir, databases, rootDir, inStateDir, depth } = params;
|
|
49
|
+
const { sourceDir, destDir, databases, rootDir, inStateDir, depth, excludeProtectedState } = params;
|
|
51
50
|
await fs.mkdir(destDir, { recursive: true });
|
|
52
51
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
53
52
|
for (const entry of entries) {
|
|
53
|
+
if (excludeProtectedState && depth === 0 && entry.name === PROTECTED_STATE_DIR_NAME) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
54
56
|
if (isExcluded(entry.name, inStateDir)) {
|
|
55
57
|
continue;
|
|
56
58
|
}
|
|
@@ -66,6 +68,7 @@ async function copyTreeFiltered(params: {
|
|
|
66
68
|
// project may legitimately contain its own .openclaw directory.
|
|
67
69
|
inStateDir: inStateDir || (depth === 0 && entry.name === STATE_DIR_NAME),
|
|
68
70
|
depth: depth + 1,
|
|
71
|
+
excludeProtectedState,
|
|
69
72
|
});
|
|
70
73
|
} else if (entry.isFile()) {
|
|
71
74
|
if (entry.name.endsWith(".sqlite")) {
|
|
@@ -82,14 +85,17 @@ async function copyTreeFiltered(params: {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
export type StageResult =
|
|
85
|
-
|
|
86
|
-
| { kind: "corrupt-database"; database: string; detail: string };
|
|
88
|
+
{ kind: "staged"; databases: string[] } | { kind: "corrupt-database"; database: string; detail: string };
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
91
|
* Stage a snapshot of the live dir: filtered file copy plus a consistent,
|
|
90
92
|
* integrity-checked VACUUM INTO copy of every live SQLite DB.
|
|
91
93
|
*/
|
|
92
|
-
export async function stageLiveDir(
|
|
94
|
+
export async function stageLiveDir(
|
|
95
|
+
liveDir: string,
|
|
96
|
+
stagingDir: string,
|
|
97
|
+
options: { excludeProtectedState?: boolean } = {},
|
|
98
|
+
): Promise<StageResult> {
|
|
93
99
|
const databases: string[] = [];
|
|
94
100
|
await copyTreeFiltered({
|
|
95
101
|
sourceDir: liveDir,
|
|
@@ -98,6 +104,7 @@ export async function stageLiveDir(liveDir: string, stagingDir: string): Promise
|
|
|
98
104
|
rootDir: liveDir,
|
|
99
105
|
inStateDir: false,
|
|
100
106
|
depth: 0,
|
|
107
|
+
excludeProtectedState: options.excludeProtectedState ?? false,
|
|
101
108
|
});
|
|
102
109
|
for (const relative of databases) {
|
|
103
110
|
const staged = path.join(stagingDir, relative);
|
package/src/hf-state-sync/cli.ts
CHANGED
|
@@ -1,28 +1,49 @@
|
|
|
1
|
-
import { createHfBucketHub } from "./hub.js";
|
|
1
|
+
import { createHfBucketHub, createMountedBucketHub } from "./hub.js";
|
|
2
2
|
import type { BucketHub } from "./hub.js";
|
|
3
|
-
import { log, logError, resolveSyncConfig } from "./paths.js";
|
|
3
|
+
import { type SyncConfig, log, logError, resolveSyncConfig } from "./paths.js";
|
|
4
4
|
import { runRestore } from "./restore.js";
|
|
5
|
+
import { prepareRestore } from "./prepare.js";
|
|
5
6
|
import { runSnapshot } from "./snapshot.js";
|
|
6
7
|
import { supervise } from "./supervise.js";
|
|
8
|
+
import { runStageWorker, trustedStageArchive } from "./stage-worker.js";
|
|
7
9
|
|
|
8
10
|
const USAGE = `usage:
|
|
9
11
|
hf-state-sync restore
|
|
10
12
|
hf-state-sync snapshot
|
|
11
13
|
hf-state-sync supervise -- <command> [args...]`;
|
|
12
14
|
|
|
13
|
-
function makeHub(
|
|
14
|
-
|
|
15
|
+
function makeHub(config: SyncConfig): BucketHub | null {
|
|
16
|
+
if (!config.bucket) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (config.stateMountDir) {
|
|
20
|
+
log(`using mounted state bucket at ${config.stateMountDir}`);
|
|
21
|
+
return createMountedBucketHub({ mountDir: config.stateMountDir });
|
|
22
|
+
}
|
|
23
|
+
const token = process.env.MLCLAW_STATE_HF_TOKEN ?? process.env.HF_TOKEN;
|
|
24
|
+
return createHfBucketHub({ bucket: config.bucket, ...(token ? { token } : {}) });
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
async function main(argv: string[]): Promise<number> {
|
|
28
|
+
if (argv[0] === "stage-worker") {
|
|
29
|
+
const liveDir = argv[1];
|
|
30
|
+
if (!liveDir) {
|
|
31
|
+
logError("stage-worker: missing live directory");
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
return runStageWorker(liveDir);
|
|
35
|
+
}
|
|
18
36
|
const config = resolveSyncConfig();
|
|
19
|
-
const hub = makeHub(config
|
|
37
|
+
const hub = makeHub(config);
|
|
20
38
|
if (!hub) {
|
|
21
39
|
logError("OPENCLAW_HF_STATE_BUCKET is not set; state will NOT survive restarts");
|
|
22
40
|
}
|
|
23
41
|
const mode = argv[0];
|
|
24
42
|
|
|
25
43
|
switch (mode) {
|
|
44
|
+
case "prepare-restore":
|
|
45
|
+
await prepareRestore(config);
|
|
46
|
+
return 0;
|
|
26
47
|
case "restore": {
|
|
27
48
|
if (!hub) {
|
|
28
49
|
return 0;
|
|
@@ -48,7 +69,13 @@ async function main(argv: string[]): Promise<number> {
|
|
|
48
69
|
if (!hub) {
|
|
49
70
|
return 1;
|
|
50
71
|
}
|
|
51
|
-
const
|
|
72
|
+
const stageArchive = trustedStageArchive(config, process.argv[1]);
|
|
73
|
+
const outcome = await runSnapshot({
|
|
74
|
+
config,
|
|
75
|
+
hub,
|
|
76
|
+
bootTime: new Date().toISOString(),
|
|
77
|
+
...(stageArchive ? { stageArchive } : {}),
|
|
78
|
+
});
|
|
52
79
|
if (outcome.kind === "failed") {
|
|
53
80
|
logError(outcome.detail);
|
|
54
81
|
return 1;
|
package/src/hf-state-sync/hub.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { BucketClient, BucketHttpError } from "../hf-bucket-client/client.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -13,7 +14,8 @@ export interface BucketHub {
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* BucketHub backed by the shared TypeScript Storage Bucket client. Auth comes
|
|
16
|
-
* from HF_TOKEN in
|
|
17
|
+
* from HF_TOKEN in the local gateway environment or an explicit caller token.
|
|
18
|
+
* Space gateway mode should prefer createMountedBucketHub instead.
|
|
17
19
|
*/
|
|
18
20
|
export function createHfBucketHub(params: { bucket: string; token?: string }): BucketHub {
|
|
19
21
|
const token = params.token ?? process.env.HF_TOKEN;
|
|
@@ -79,3 +81,82 @@ export function createHfBucketHub(params: { bucket: string; token?: string }): B
|
|
|
79
81
|
},
|
|
80
82
|
};
|
|
81
83
|
}
|
|
84
|
+
|
|
85
|
+
/** BucketHub backed by a read-write Storage Bucket volume mounted in the Space. */
|
|
86
|
+
export function createMountedBucketHub(params: { mountDir: string }): BucketHub {
|
|
87
|
+
const root = path.resolve(params.mountDir);
|
|
88
|
+
const assertMountRoot = async (): Promise<void> => {
|
|
89
|
+
let stat;
|
|
90
|
+
try {
|
|
91
|
+
stat = await fs.stat(root);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (isNotFound(err)) {
|
|
94
|
+
throw new Error(`mounted bucket root is missing: ${root}`);
|
|
95
|
+
}
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
throw new Error(`mounted bucket root is not accessible: ${root}: ${message}`);
|
|
98
|
+
}
|
|
99
|
+
if (!stat.isDirectory()) {
|
|
100
|
+
throw new Error(`mounted bucket root is not a directory: ${root}`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const localPathFor = (remotePath: string): string => {
|
|
104
|
+
const normalized = remotePath.replace(/^\/+/, "");
|
|
105
|
+
const resolved = path.resolve(root, normalized);
|
|
106
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
107
|
+
throw new Error(`invalid bucket path outside mount: ${remotePath}`);
|
|
108
|
+
}
|
|
109
|
+
return resolved;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
async download(remotePath, localPath) {
|
|
114
|
+
await assertMountRoot();
|
|
115
|
+
const source = localPathFor(remotePath);
|
|
116
|
+
try {
|
|
117
|
+
await fs.copyFile(source, localPath);
|
|
118
|
+
return "downloaded";
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (isNotFound(err)) {
|
|
121
|
+
return "not-found";
|
|
122
|
+
}
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
throw new Error(`mounted bucket download failed for ${remotePath}: ${message}`);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async upload(localPath, remotePath) {
|
|
128
|
+
await assertMountRoot();
|
|
129
|
+
const target = localPathFor(remotePath);
|
|
130
|
+
const dir = path.dirname(target);
|
|
131
|
+
const tmp = path.join(dir, `.tmp-${path.basename(target)}-${process.pid}-${Date.now()}`);
|
|
132
|
+
try {
|
|
133
|
+
await fs.mkdir(dir, { recursive: true });
|
|
134
|
+
await fs.copyFile(localPath, tmp);
|
|
135
|
+
// The trusted supervisor uploads as root, while restore runs as the
|
|
136
|
+
// unprivileged OpenClaw user. State objects contain only snapshots and
|
|
137
|
+
// runtime metadata, so keep them world-readable but root-writable.
|
|
138
|
+
await fs.chmod(tmp, 0o644);
|
|
139
|
+
await fs.rename(tmp, target);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
throw new Error(`mounted bucket upload failed for ${remotePath}: ${message}`);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async delete(remotePaths) {
|
|
147
|
+
await assertMountRoot();
|
|
148
|
+
for (const remotePath of remotePaths) {
|
|
149
|
+
try {
|
|
150
|
+
await fs.rm(localPathFor(remotePath), { force: true });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
153
|
+
console.error(`[hf-state-sync] prune failed for ${remotePath}: ${message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isNotFound(err: unknown): boolean {
|
|
161
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
162
|
+
}
|