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/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:-/tmp/openclaw-live}"
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
- if [ -z "${HUGGINGFACE_HUB_TOKEN:-}" ] && [ -n "${HF_TOKEN:-}" ]; then
12
- export HUGGINGFACE_HUB_TOKEN="$HF_TOKEN"
13
- fi
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
- chown -R node:node "$LIVE_DIR"
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
- chown -R node:node "$LIVE_DIR"
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 node node /app/scripts/configure-huggingface-model.mjs "$CONFIG_PATH"
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 node node /app/scripts/configure-telegram.mjs "$CONFIG_PATH" "$TELEGRAM_ALLOWED_USERS"
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 node node /app/scripts/report-telegram-probe.mjs "$PROBE_OUT" || true
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
- echo "[hf-tooling] seeding baseline Hugging Face tooling"
87
- node /app/hf-tooling-seed.js
88
- echo "[hf-tooling] baseline Hugging Face tooling ready"
89
-
90
- chown -R node:node "$LIVE_DIR"
91
- exec gosu node node /app/hf-state-sync.js supervise -- node /app/mlclaw-space-runtime.js
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.2.3",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "config": {
6
- "openclawVersion": "2026.7.1-beta.2",
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
- - HF_TOKEN
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`. The bucket is not mounted as a live filesystem;
64
- `hf-state-sync` restores verified snapshots on boot and uploads new snapshots
65
- during runtime and shutdown.
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
- | { kind: "staged"; databases: string[] }
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(liveDir: string, stagingDir: string): Promise<StageResult> {
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);
@@ -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(bucket: string | null): BucketHub | null {
14
- return bucket ? createHfBucketHub({ bucket }) : null;
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.bucket);
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 outcome = await runSnapshot({ config, hub, bootTime: new Date().toISOString() });
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;
@@ -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 Space Secrets.
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
+ }