Claude Code Claude Opus 4.6 commited on
Commit
8907cf8
·
1 Parent(s): b519d02

Infrastructure migration: Align Cain with HuggingClaw architecture

Browse files

This commit migrates Cain from the simple Python/Gradio architecture to the
full HuggingClaw OpenClaw gateway system. This is critical infrastructure
work to enable proper OpenClaw functionality.

Major changes:
- Replace Python Dockerfile with Node.js-based OpenClaw runtime
- Add complete sync_hf.py for HF Dataset persistence (39KB vs 1.5KB stub)
- Add entrypoint.sh with DNS resolution, proxy setup, and preload scripts
- Add coding-agent extension for autonomous HF Space management
- Add workspace-templates for agent identity configuration
- Add proper openclaw.json configuration for gateway and providers
- Include dns-fix.cjs, telegram-proxy.cjs, token-redirect.cjs for HF Spaces

This migration enables Cain to run as a full OpenClaw instance with:
- OpenClaw gateway system on port 7860
- A2A gateway extension support
- HF Dataset persistence for ~/.openclaw state
- Coding agent tools for autonomous space management
- Proper Telegram and WhatsApp integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -1,21 +1,67 @@
1
- FROM python:3.10-slim
 
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  WORKDIR /app
4
 
5
- # Install dependencies
6
- COPY requirements.txt .
7
- RUN pip install --no-cache-dir -r requirements.txt
 
 
 
 
 
 
8
 
9
- # Copy application code
10
- COPY . .
 
 
11
 
12
- # Expose the port
13
- EXPOSE 7860
 
 
 
 
 
 
 
14
 
15
- # Set environment variables
16
- ENV PYTHONUNBUFFERED=1
17
- ENV GRADIO_SERVER_NAME="0.0.0.0"
18
- ENV GRADIO_SERVER_PORT="7860"
 
19
 
20
- # Run the application
21
- CMD ["python", "app.py"]
 
1
+ # OpenClaw on Hugging Face Spaces — Pre-built image (v4.1)
2
+ # Uses official pre-built image to avoid 30+ minute builds on cpu-basic
3
 
4
+ # ── Stage 1: Pull pre-built OpenClaw ─────────────────────────────────────────
5
+ FROM ghcr.io/openclaw/openclaw:latest AS openclaw-prebuilt
6
+
7
+ # ── Stage 2: Runtime ─────────────────────────────────────────────────────────
8
+ FROM node:22-bookworm
9
+ SHELL ["/bin/bash", "-c"]
10
+
11
+ # ── System dependencies (root) ───────────────────────────────────────────────
12
+ RUN echo "[build] Installing system deps..." && START=$(date +%s) \
13
+ && apt-get update \
14
+ && apt-get install -y --no-install-recommends git ca-certificates curl python3 python3-pip \
15
+ && rm -rf /var/lib/apt/lists/* \
16
+ && pip3 install --no-cache-dir --break-system-packages huggingface_hub requests \
17
+ && corepack enable \
18
+ && mkdir -p /app/openclaw \
19
+ && chown -R node:node /app \
20
+ && mkdir -p /home/node/.openclaw/workspace /home/node/.openclaw/credentials \
21
+ && chown -R node:node /home/node \
22
+ && echo "[build] System deps: $(($(date +%s) - START))s"
23
+
24
+ # ── Claude Code CLI (for coding agent extension — uses z.ai/Zhipu GLM) ──────
25
+ RUN echo "[build] Installing Claude Code CLI..." && START=$(date +%s) \
26
+ && npm install -g @anthropic-ai/claude-code \
27
+ && echo "[build] Claude Code CLI: $(($(date +%s) - START))s"
28
+
29
+ # ── Copy pre-built OpenClaw (skips clone + install + build entirely) ─────────
30
+ COPY --from=openclaw-prebuilt --chown=node:node /app /app/openclaw
31
+
32
+ USER node
33
+ ENV HOME=/home/node
34
  WORKDIR /app
35
 
36
+ # ── A2A Gateway Extension ───────────────────────────────────────────────────
37
+ RUN echo "[build] Installing A2A gateway..." && START=$(date +%s) \
38
+ && git clone --depth 1 https://github.com/win4r/openclaw-a2a-gateway.git /app/openclaw/extensions/a2a-gateway \
39
+ && cd /app/openclaw/extensions/a2a-gateway \
40
+ && npm install --production \
41
+ && echo "[build] A2A gateway: $(($(date +%s) - START))s"
42
+
43
+ # ── Coding Agent Extension (local — no git clone needed) ─────────────────────
44
+ COPY --chown=node:node extensions/coding-agent /app/openclaw/extensions/coding-agent
45
 
46
+ # ── Prepare runtime dirs ────────────────────────────────────────────────────
47
+ RUN mkdir -p /app/openclaw/empty-bundled-plugins \
48
+ && node -e "try{console.log(require('/app/openclaw/package.json').version)}catch(e){console.log('unknown')}" > /app/openclaw/.version \
49
+ && echo "[build] OpenClaw version: $(cat /app/openclaw/.version)"
50
 
51
+ # ── Scripts + Config + Frontend ──────────────────────────────────────────────
52
+ COPY --chown=node:node scripts /home/node/scripts
53
+ COPY --chown=node:node frontend /home/node/frontend
54
+ COPY --chown=node:node workspace-templates /home/node/workspace-templates
55
+ COPY --chown=node:node openclaw.json /home/node/scripts/openclaw.json.default
56
+ RUN chmod +x /home/node/scripts/entrypoint.sh /home/node/scripts/sync_hf.py \
57
+ && VERSION_TS=$(date +%s) \
58
+ && sed "s/{{VERSION_TIMESTAMP}}/${VERSION_TS}/g" /home/node/frontend/electron-standalone.html > /home/node/frontend/index.html \
59
+ && echo "[build] Frontend index.html generated (timestamp=${VERSION_TS})"
60
 
61
+ ENV NODE_ENV=production
62
+ ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/openclaw/empty-bundled-plugins
63
+ ENV OPENCLAW_PREFER_PNPM=1
64
+ ENV PATH="/home/node/.local/bin:$PATH"
65
+ WORKDIR /home/node
66
 
67
+ CMD ["/home/node/scripts/entrypoint.sh"]
 
extensions/coding-agent/index.ts ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Coding Agent Extension for OpenClaw
3
+ *
4
+ * Integrates Claude Code (backed by Zhipu GLM via z.ai) as a sub-agent
5
+ * for autonomous coding on HuggingFace Spaces.
6
+ *
7
+ * Tools:
8
+ * - claude_code: Spawn Claude Code CLI to autonomously complete coding tasks
9
+ * - hf_space_status: Check Space health/stage
10
+ * - hf_restart_space: Restart a Space
11
+ */
12
+
13
+ import { execSync } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────────────
17
+
18
+ interface PluginApi {
19
+ pluginConfig: Record<string, unknown>;
20
+ logger: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void; error: (...a: unknown[]) => void };
21
+ registerTool?: (def: ToolDef) => void;
22
+ }
23
+
24
+ interface ToolDef {
25
+ name: string;
26
+ description: string;
27
+ label?: string;
28
+ parameters: Record<string, unknown>;
29
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
30
+ }
31
+
32
+ interface ToolResult {
33
+ content: Array<{ type: "text"; text: string }>;
34
+ }
35
+
36
+ // ── Helpers ──────────────────────────────────────────────────────────────────
37
+
38
+ const WORK_DIR = "/tmp/claude-workspace";
39
+ const CLAUDE_TIMEOUT = 300_000; // 5 minutes
40
+
41
+ function text(t: string): ToolResult {
42
+ return { content: [{ type: "text", text: t }] };
43
+ }
44
+
45
+ function asStr(v: unknown, fallback = ""): string {
46
+ return typeof v === "string" ? v : fallback;
47
+ }
48
+
49
+ // ── Git helpers ──────────────────────────────────────────────────────────────
50
+
51
+ function ensureRepo(targetSpace: string, hfToken: string): void {
52
+ const repoUrl = `https://user:${hfToken}@huggingface.co/spaces/${targetSpace}`;
53
+
54
+ if (existsSync(`${WORK_DIR}/.git`)) {
55
+ // Reset to latest remote state (clean slate for each task)
56
+ try {
57
+ execSync("git fetch origin && git reset --hard origin/main", {
58
+ cwd: WORK_DIR,
59
+ timeout: 30_000,
60
+ stdio: "pipe",
61
+ });
62
+ return;
63
+ } catch {
64
+ // If fetch/reset fails, re-clone
65
+ execSync(`rm -rf ${WORK_DIR}`, { stdio: "pipe" });
66
+ }
67
+ }
68
+
69
+ // Fresh clone
70
+ if (existsSync(WORK_DIR)) {
71
+ execSync(`rm -rf ${WORK_DIR}`, { stdio: "pipe" });
72
+ }
73
+ execSync(`git clone --depth 20 ${repoUrl} ${WORK_DIR}`, {
74
+ timeout: 60_000,
75
+ stdio: "pipe",
76
+ });
77
+ execSync('git config user.name "Claude Code"', { cwd: WORK_DIR, stdio: "pipe" });
78
+ execSync('git config user.email "claude-code@huggingclaw"', { cwd: WORK_DIR, stdio: "pipe" });
79
+ }
80
+
81
+ function pushChanges(summary: string): string {
82
+ const status = execSync("git status --porcelain", {
83
+ cwd: WORK_DIR,
84
+ encoding: "utf-8",
85
+ }).trim();
86
+
87
+ if (!status) return "No files changed.";
88
+
89
+ execSync("git add -A", { cwd: WORK_DIR, stdio: "pipe" });
90
+ // Use a safe commit message
91
+ const msg = summary.slice(0, 72).replace(/"/g, '\\"');
92
+ execSync(`git commit -m "Claude Code: ${msg}"`, { cwd: WORK_DIR, stdio: "pipe" });
93
+ execSync("git push", { cwd: WORK_DIR, timeout: 60_000, stdio: "pipe" });
94
+
95
+ return `Pushed changes:\n${status}`;
96
+ }
97
+
98
+ // ── Plugin ───────────────────────────────────────────────────────────────────
99
+
100
+ const plugin = {
101
+ id: "coding-agent",
102
+ name: "Coding Agent",
103
+ description: "Claude Code sub-agent for autonomous coding on HF Spaces (Zhipu GLM backend via z.ai)",
104
+
105
+ register(api: PluginApi) {
106
+ const cfg = (api.pluginConfig as Record<string, unknown>) || {};
107
+ const targetSpace = asStr(cfg.targetSpace) || process.env.CODING_AGENT_TARGET_SPACE || "";
108
+ const hfToken = asStr(cfg.hfToken) || process.env.HF_TOKEN || "";
109
+ const zaiApiKey = asStr(cfg.zaiApiKey) || process.env.ZAI_API_KEY || process.env.ZHIPU_API_KEY || "";
110
+
111
+ api.logger.info(`coding-agent: targetSpace=${targetSpace}, zaiKey=${zaiApiKey ? "set" : "missing"}`);
112
+
113
+ if (!api.registerTool) {
114
+ api.logger.warn("coding-agent: registerTool unavailable — no tools registered");
115
+ return;
116
+ }
117
+
118
+ // ── Tool: claude_code ───────────────────────────────────────────────────
119
+ api.registerTool({
120
+ name: "claude_code",
121
+ label: "Run Claude Code",
122
+ description:
123
+ "Run Claude Code to autonomously complete a coding task on the target HF Space. " +
124
+ "Claude Code clones the Space repo, analyzes code, makes changes, and pushes them back. " +
125
+ "Powered by Zhipu GLM via z.ai. Use for: debugging, fixing errors, adding features, refactoring.",
126
+ parameters: {
127
+ type: "object",
128
+ required: ["task"],
129
+ properties: {
130
+ task: {
131
+ type: "string",
132
+ description: "Detailed coding task description. Be specific about what to fix/change and why.",
133
+ },
134
+ auto_push: {
135
+ type: "boolean",
136
+ description: "Automatically push changes after Claude Code finishes (default: true)",
137
+ },
138
+ },
139
+ },
140
+ async execute(_id, params) {
141
+ const task = asStr(params.task);
142
+ const autoPush = params.auto_push !== false;
143
+
144
+ if (!targetSpace) return text("Error: no targetSpace configured");
145
+ if (!hfToken) return text("Error: no HF token configured");
146
+ if (!zaiApiKey) return text("Error: no ZAI_API_KEY or ZHIPU_API_KEY configured for Claude Code backend");
147
+
148
+ try {
149
+ // 1. Clone / reset to latest
150
+ api.logger.info(`coding-agent: Syncing repo ${targetSpace}...`);
151
+ ensureRepo(targetSpace, hfToken);
152
+
153
+ // 2. Run Claude Code with z.ai backend
154
+ api.logger.info(`coding-agent: Running Claude Code: ${task.slice(0, 100)}...`);
155
+ const claudeEnv: Record<string, string> = {
156
+ ...(process.env as Record<string, string>),
157
+ ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
158
+ ANTHROPIC_AUTH_TOKEN: zaiApiKey,
159
+ ANTHROPIC_DEFAULT_OPUS_MODEL: "GLM-4.7",
160
+ ANTHROPIC_DEFAULT_SONNET_MODEL: "GLM-4.7",
161
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "GLM-4.5-Air",
162
+ // Avoid interactive prompts
163
+ CI: "true",
164
+ };
165
+
166
+ const output = execSync(
167
+ `claude -p ${JSON.stringify(task)} --output-format text`,
168
+ {
169
+ cwd: WORK_DIR,
170
+ env: claudeEnv,
171
+ timeout: CLAUDE_TIMEOUT,
172
+ encoding: "utf-8",
173
+ maxBuffer: 10 * 1024 * 1024, // 10MB
174
+ },
175
+ );
176
+
177
+ // 3. Push changes if requested
178
+ let pushResult = "Auto-push disabled.";
179
+ if (autoPush) {
180
+ try {
181
+ pushResult = pushChanges(task);
182
+ } catch (e: unknown) {
183
+ pushResult = `Push failed: ${e instanceof Error ? e.message : e}`;
184
+ }
185
+ }
186
+
187
+ return text(
188
+ `=== Claude Code Output ===\n${output}\n\n=== Changes ===\n${pushResult}`,
189
+ );
190
+ } catch (e: unknown) {
191
+ const msg = e instanceof Error ? e.message : String(e);
192
+ return text(`Claude Code failed:\n${msg.slice(0, 3000)}`);
193
+ }
194
+ },
195
+ });
196
+
197
+ // ── Tool: hf_space_status ───────────────────────────────────────────────
198
+ api.registerTool({
199
+ name: "hf_space_status",
200
+ label: "Check Space Health",
201
+ description:
202
+ "Check the current status of the target HuggingFace Space. " +
203
+ "Returns: stage (BUILDING, APP_STARTING, RUNNING, RUNTIME_ERROR, BUILD_ERROR, NO_APP_FILE).",
204
+ parameters: {
205
+ type: "object",
206
+ properties: {},
207
+ },
208
+ async execute() {
209
+ if (!targetSpace) return text("Error: no target space configured");
210
+ try {
211
+ const resp = await fetch(`https://huggingface.co/api/spaces/${targetSpace}`, {
212
+ headers: { Authorization: `Bearer ${hfToken}` },
213
+ });
214
+ if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
215
+ const data = (await resp.json()) as Record<string, unknown>;
216
+ const runtime = (data.runtime as Record<string, unknown>) || {};
217
+ const stage = runtime.stage || "unknown";
218
+ const hardware = runtime.hardware || "unknown";
219
+
220
+ // Try hitting the Space URL
221
+ let apiStatus = "not checked";
222
+ try {
223
+ const spaceUrl = `https://${targetSpace.replace("/", "-").toLowerCase()}.hf.space`;
224
+ const probe = await fetch(spaceUrl, { signal: AbortSignal.timeout(8000) });
225
+ apiStatus = probe.ok ? `reachable (${probe.status})` : `error (${probe.status})`;
226
+ } catch {
227
+ apiStatus = "unreachable";
228
+ }
229
+
230
+ return text(
231
+ `Space: ${targetSpace}\nStage: ${stage}\nHardware: ${hardware}\nAPI: ${apiStatus}`,
232
+ );
233
+ } catch (e: unknown) {
234
+ return text(`Error checking space: ${e instanceof Error ? e.message : e}`);
235
+ }
236
+ },
237
+ });
238
+
239
+ // ── Tool: hf_restart_space ──────────────────────────────────────────────
240
+ api.registerTool({
241
+ name: "hf_restart_space",
242
+ label: "Restart Space",
243
+ description: "Restart the target HuggingFace Space. Use when the Space is stuck or after deploying fixes.",
244
+ parameters: {
245
+ type: "object",
246
+ properties: {},
247
+ },
248
+ async execute() {
249
+ if (!targetSpace) return text("Error: no target space configured");
250
+ try {
251
+ const resp = await fetch(`https://huggingface.co/api/spaces/${targetSpace}/restart`, {
252
+ method: "POST",
253
+ headers: { Authorization: `Bearer ${hfToken}` },
254
+ });
255
+ if (!resp.ok) {
256
+ const body = await resp.text().catch(() => "");
257
+ throw new Error(`${resp.status}: ${body}`);
258
+ }
259
+ return text(`Space ${targetSpace} is restarting`);
260
+ } catch (e: unknown) {
261
+ return text(`Error restarting space: ${e instanceof Error ? e.message : e}`);
262
+ }
263
+ },
264
+ });
265
+
266
+ api.logger.info("coding-agent: Registered 3 tools (claude_code, hf_space_status, hf_restart_space)");
267
+ },
268
+ };
269
+
270
+ export default plugin;
extensions/coding-agent/openclaw.plugin.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "coding-agent",
3
+ "name": "Coding Agent",
4
+ "description": "Claude Code sub-agent for autonomous coding on HF Spaces — powered by Zhipu GLM via z.ai",
5
+ "version": "2.0.0",
6
+ "defaultConfig": {
7
+ "targetSpace": "",
8
+ "targetDataset": "",
9
+ "hfToken": "",
10
+ "zaiApiKey": ""
11
+ },
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "targetSpace": {
17
+ "type": "string",
18
+ "description": "HuggingFace Space repo ID to manage (e.g. tao-shen/HuggingClaw-Cain)"
19
+ },
20
+ "targetDataset": {
21
+ "type": "string",
22
+ "description": "HuggingFace Dataset repo ID for persistent data (e.g. tao-shen/HuggingClaw-Cain-data)"
23
+ },
24
+ "hfToken": {
25
+ "type": "string",
26
+ "description": "HuggingFace API token (or use HF_TOKEN env var)"
27
+ },
28
+ "zaiApiKey": {
29
+ "type": "string",
30
+ "description": "Z.AI API key for Claude Code backend (or use ZAI_API_KEY / ZHIPU_API_KEY env var)"
31
+ }
32
+ }
33
+ },
34
+ "uiHints": {
35
+ "hfToken": { "sensitive": true },
36
+ "zaiApiKey": { "sensitive": true }
37
+ }
38
+ }
extensions/coding-agent/package.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "openclaw-coding-agent",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "OpenClaw coding agent extension — HF Space management tools with syntax validation",
6
+ "main": "index.ts",
7
+ "openclaw": {
8
+ "extensions": ["./index.ts"]
9
+ },
10
+ "dependencies": {}
11
+ }
openclaw.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "gateway": {
3
+ "mode": "local",
4
+ "bind": "lan",
5
+ "port": 7860,
6
+ "auth": { "token": "huggingclaw" },
7
+ "controlUi": {
8
+ "allowInsecureAuth": true,
9
+ "allowedOrigins": [
10
+ "https://huggingface.co"
11
+ ]
12
+ }
13
+ },
14
+ "session": { "scope": "global" },
15
+ "models": {
16
+ "mode": "merge",
17
+ "providers": {
18
+ "zhipu": {
19
+ "baseUrl": "https://open.bigmodel.cn/api/anthropic",
20
+ "apiKey": "${ZHIPU_API_KEY}",
21
+ "api": "anthropic-messages",
22
+ "models": [
23
+ {
24
+ "id": "glm-4.5-air",
25
+ "name": "GLM-4.5 Air"
26
+ },
27
+ {
28
+ "id": "glm-4.5",
29
+ "name": "GLM-4.5"
30
+ },
31
+ {
32
+ "id": "glm-4.6",
33
+ "name": "GLM-4.6"
34
+ }
35
+ ]
36
+ },
37
+ "hf": {
38
+ "baseUrl": "https://router.huggingface.co/v1",
39
+ "apiKey": "${HF_TOKEN}",
40
+ "api": "openai-completions",
41
+ "models": [
42
+ { "id": "Qwen/Qwen2.5-7B-Instruct", "name": "Qwen2.5 7B (HF Router)" }
43
+ ]
44
+ }
45
+ }
46
+ },
47
+ "plugins": { "entries": { "whatsapp": { "enabled": true } } },
48
+ "agents": {
49
+ "defaults": {
50
+ "workspace": "~/.openclaw/workspace",
51
+ "model": {
52
+ "primary": "zhipu/glm-4.5-air"
53
+ }
54
+ }
55
+ }
56
+ }
package.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "dependencies": {
3
+ "ws": "^8.19.0"
4
+ }
5
+ }
scripts/entrypoint.sh ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ BOOT_START=$(date +%s)
5
+
6
+ echo "[entrypoint] OpenClaw HuggingFace Spaces Entrypoint"
7
+ echo "[entrypoint] ======================================="
8
+
9
+ # ── DNS pre-resolution (background — non-blocking) ───────────────────────
10
+ # Resolves WhatsApp domains via DoH for dns-fix.cjs to consume.
11
+ # Telegram connectivity is handled by API base auto-probe in sync_hf.py.
12
+ echo "[entrypoint] Starting DNS resolution in background..."
13
+ python3 /home/node/scripts/dns-resolve.py /tmp/dns-resolved.json 2>&1 &
14
+ DNS_PID=$!
15
+ echo "[entrypoint] DNS resolver PID: $DNS_PID"
16
+
17
+ # ── Node.js memory limit (only if explicitly set) ─────────────────────────
18
+ if [ -n "$NODE_MEMORY_LIMIT" ]; then
19
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=$NODE_MEMORY_LIMIT"
20
+ echo "[entrypoint] Node.js memory limit: ${NODE_MEMORY_LIMIT}MB"
21
+ fi
22
+
23
+ # Enable Node.js DNS fix (will use resolved file when ready)
24
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/dns-fix.cjs"
25
+
26
+ # Enable Telegram API proxy (redirects fetch() to working mirror if needed)
27
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/telegram-proxy.cjs"
28
+
29
+ # Enable token redirect + A2A routing + state/agents endpoints (all in one preload)
30
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/token-redirect.cjs"
31
+
32
+ # ── Extensions symlink ──────────────────────────────────────────────────────
33
+ SYMLINK_START=$(date +%s)
34
+ if [ ! -L /home/node/.openclaw/extensions ]; then
35
+ rm -rf /home/node/.openclaw/extensions 2>/dev/null || true
36
+ ln -s /app/openclaw/extensions /home/node/.openclaw/extensions
37
+ echo "[entrypoint] Created extensions symlink -> /app/openclaw/extensions"
38
+ fi
39
+ echo "[TIMER] Extensions symlink: $(($(date +%s) - SYMLINK_START))s"
40
+
41
+ # ── WhatsApp credentials check ──────────────────────────────────────────────
42
+ if [ -d /home/node/.openclaw/credentials/whatsapp ]; then
43
+ echo "[entrypoint] Found existing WhatsApp credentials - will use for auto-connect"
44
+ fi
45
+
46
+ # ── Build artifacts check ───────────────────────────────────────────────────
47
+ cd /app/openclaw
48
+ echo "[entrypoint] Build artifacts check:"
49
+ test -f dist/entry.js && echo " OK dist/entry.js" || echo " INFO: dist/entry.js not found (pre-built image may use openclaw.mjs)"
50
+ test -f openclaw.mjs && echo " OK openclaw.mjs" || echo " INFO: openclaw.mjs not found"
51
+ test -f dist/plugin-sdk/index.js && echo " OK dist/plugin-sdk/index.js" || echo " INFO: dist/plugin-sdk/index.js not found"
52
+ echo " Extensions: $(ls extensions/ 2>/dev/null | wc -l | tr -d ' ') found"
53
+ echo " Global extensions link: $(readlink /home/node/.openclaw/extensions 2>/dev/null || echo 'NOT SET')"
54
+
55
+ # Create logs directory
56
+ mkdir -p /home/node/logs
57
+ touch /home/node/logs/app.log
58
+
59
+ ENTRYPOINT_END=$(date +%s)
60
+ echo "[TIMER] Entrypoint (before sync_hf.py): $((ENTRYPOINT_END - BOOT_START))s"
61
+
62
+ # ── Set version from build artifact ────────────────────────────────────────
63
+ if [ -f /app/openclaw/.version ]; then
64
+ export OPENCLAW_VERSION=$(cat /app/openclaw/.version)
65
+ echo "[entrypoint] OpenClaw version: $OPENCLAW_VERSION"
66
+ fi
67
+
68
+ # ── Start OpenClaw via sync_hf.py (directly on port 7860, no proxy) ───────
69
+ echo "[entrypoint] Starting OpenClaw via sync_hf.py..."
70
+ exec python3 -u /home/node/scripts/sync_hf.py
scripts/openclaw.json.default CHANGED
@@ -1,15 +1,56 @@
1
  {
2
- "model": {
3
- "name": "openclaw",
4
- "version": "1.0.0"
 
 
 
 
 
 
 
 
5
  },
6
- "training": {
7
- "epochs": 10,
8
- "batch_size": 32,
9
- "learning_rate": 0.001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  },
11
- "data": {
12
- "input_path": "/app/data",
13
- "output_path": "/app/output"
 
 
 
 
 
14
  }
15
- }
 
1
  {
2
+ "gateway": {
3
+ "mode": "local",
4
+ "bind": "lan",
5
+ "port": 7860,
6
+ "auth": { "token": "openclaw-space-default" },
7
+ "controlUi": {
8
+ "allowInsecureAuth": true,
9
+ "allowedOrigins": [
10
+ "https://huggingface.co"
11
+ ]
12
+ }
13
  },
14
+ "session": { "scope": "global" },
15
+ "models": {
16
+ "mode": "merge",
17
+ "providers": {
18
+ "zhipu": {
19
+ "baseUrl": "https://open.bigmodel.cn/api/anthropic",
20
+ "apiKey": "${ZHIPU_API_KEY}",
21
+ "api": "anthropic-messages",
22
+ "models": [
23
+ {
24
+ "id": "glm-4.5-air",
25
+ "name": "GLM-4.5 Air"
26
+ },
27
+ {
28
+ "id": "glm-4.5",
29
+ "name": "GLM-4.5"
30
+ },
31
+ {
32
+ "id": "glm-4.6",
33
+ "name": "GLM-4.6"
34
+ }
35
+ ]
36
+ },
37
+ "hf": {
38
+ "baseUrl": "https://router.huggingface.co/v1",
39
+ "apiKey": "${HF_TOKEN}",
40
+ "api": "openai-completions",
41
+ "models": [
42
+ { "id": "Qwen/Qwen2.5-7B-Instruct", "name": "Qwen2.5 7B (HF Router)" }
43
+ ]
44
+ }
45
+ }
46
  },
47
+ "plugins": { "entries": { "whatsapp": { "enabled": true } } },
48
+ "agents": {
49
+ "defaults": {
50
+ "workspace": "~/.openclaw/workspace",
51
+ "model": {
52
+ "primary": "zhipu/glm-4.5-air"
53
+ }
54
+ }
55
  }
56
+ }
scripts/sync_hf.py CHANGED
@@ -1,54 +1,866 @@
1
  #!/usr/bin/env python3
2
- import sys
3
- import json
 
 
 
 
 
 
 
 
 
 
4
  import os
 
5
  import time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- # Wait for OpenClaw to initialize
8
- print("Waiting for OpenClaw to initialize...")
9
- time.sleep(5)
10
-
11
- # Load OpenClaw configuration
12
- try:
13
- with open('/app/.openclaw/openclaw.json', 'r') as f:
14
- config = json.load(f)
15
- print("OpenClaw configuration loaded successfully")
16
- except Exception as e:
17
- print(f"Error loading OpenClaw config: {e}")
18
- sys.exit(1)
19
-
20
- # Check if A2A gateway is available
21
- try:
22
- import a2ag
23
- a2a_available = True
24
- print("A2A Gateway extension is available")
25
- except ImportError:
26
- a2a_available = False
27
- print("A2A Gateway extension not available")
28
-
29
- # If A2A is available, initialize it with Cain's configuration
30
- if a2a_available:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  try:
32
- # Extract configuration for A2A
33
- a2a_config = {
34
- "telegram": {
35
- "token": config.get("telegram_token", ""),
36
- "allowed_user_ids": config.get("telegram_allowed_ids", [])
37
- },
38
- "llm": {
39
- "provider": config.get("llm_provider", "openai"),
40
- "model": config.get("llm_model", "gpt-3.5-turbo"),
41
- "api_key": config.get("llm_api_key", ""),
42
- "temperature": config.get("llm_temperature", 0.7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
44
- }
45
-
46
- # Initialize A2A gateway
47
- a2a = a2ag.A2A(a2a_config)
48
- print("A2A Gateway initialized successfully")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  except Exception as e:
50
- print(f"Error initializing A2A Gateway: {e}")
 
51
  sys.exit(1)
52
 
53
- print("Sync script completed successfully")
54
- sys.exit(0)
 
 
1
  #!/usr/bin/env python3
2
+ """
3
+ OpenClaw HF Spaces Persistence — Full Directory Sync
4
+ =====================================================
5
+
6
+ Simplified persistence: upload/download the entire ~/.openclaw directory
7
+ as-is to/from a Hugging Face Dataset repo.
8
+
9
+ - Startup: snapshot_download → ~/.openclaw
10
+ - Periodic: upload_folder → dataset openclaw_data/
11
+ - Shutdown: final upload_folder → dataset openclaw_data/
12
+ """
13
+
14
  import os
15
+ import sys
16
  import time
17
+ import threading
18
+ import subprocess
19
+ import signal
20
+ import json
21
+ import shutil
22
+ import tempfile
23
+ import traceback
24
+ import re
25
+ import urllib.request
26
+ import ssl
27
+ from pathlib import Path
28
+ from datetime import datetime
29
+ # Set timeout BEFORE importing huggingface_hub
30
+ os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")
31
+ os.environ.setdefault("HF_HUB_UPLOAD_TIMEOUT", "600")
32
+ # Suppress huggingface_hub progress bars and verbose download/upload logs
33
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
34
+ os.environ.setdefault("HF_HUB_VERBOSITY", "warning")
35
+
36
+ import logging as _logging
37
+ _logging.getLogger("huggingface_hub").setLevel(_logging.WARNING)
38
+ _logging.getLogger("huggingface_hub.utils").setLevel(_logging.WARNING)
39
+ _logging.getLogger("filelock").setLevel(_logging.WARNING)
40
+
41
+ from huggingface_hub import HfApi, snapshot_download
42
+
43
+ # ── Logging helper ──────────────────────────────────────────────────────────
44
+
45
+ class TeeLogger:
46
+ """Duplicate output to stream and file."""
47
+ def __init__(self, filename, stream):
48
+ self.stream = stream
49
+ self.file = open(filename, "a", encoding="utf-8")
50
+ def write(self, message):
51
+ self.stream.write(message)
52
+ self.file.write(message)
53
+ def flush(self):
54
+ self.stream.flush()
55
+ self.file.flush()
56
+ def fileno(self):
57
+ return self.stream.fileno()
58
+
59
+ # ── Configuration ───────────────────────────────────────────────────────────
60
+
61
+ HF_TOKEN = os.environ.get("HF_TOKEN")
62
+ OPENCLAW_HOME = Path.home() / ".openclaw"
63
+ APP_DIR = Path("/app/openclaw")
64
+
65
+ # Use ".openclaw" - directly read/write the .openclaw folder in dataset
66
+ DATASET_PATH = ".openclaw"
67
 
68
+ # OpenAI-compatible API (OpenAI, OpenRouter, or any compatible endpoint)
69
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
70
+ OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
71
+
72
+ # OpenRouter API key (optional; alternative to OPENAI_API_KEY + OPENAI_BASE_URL)
73
+ OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
74
+
75
+ # Zhipu AI (z.ai) API key (optional; GLM-4 series, Anthropic-compatible endpoint)
76
+ ZHIPU_API_KEY = os.environ.get("ZHIPU_API_KEY", "")
77
+
78
+ # Z.AI API key (optional; used by Claude Code backend via api.z.ai)
79
+ ZAI_API_KEY = os.environ.get("ZAI_API_KEY", "")
80
+
81
+ # Gateway token (default: huggingclaw; override via GATEWAY_TOKEN env var)
82
+ GATEWAY_TOKEN = os.environ.get("GATEWAY_TOKEN", "huggingclaw")
83
+
84
+ # A2A configuration (optional; only activated when A2A_PEERS is set)
85
+ AGENT_NAME = os.environ.get("AGENT_NAME", "HuggingClaw")
86
+ A2A_PEERS = os.environ.get("A2A_PEERS", "") # comma-separated peer URLs
87
+
88
+ # Default model for new conversations (infer from provider if not set)
89
+ OPENCLAW_DEFAULT_MODEL = os.environ.get("OPENCLAW_DEFAULT_MODEL") or (
90
+ "openai/gpt-5-nano" if OPENAI_API_KEY
91
+ else "zhipu/glm-4.5-air" if ZHIPU_API_KEY
92
+ else "openrouter/openai/gpt-oss-20b:free"
93
+ )
94
+
95
+ # HF Spaces built-in env vars (auto-set by HF runtime)
96
+ SPACE_HOST = os.environ.get("SPACE_HOST", "") # e.g. "tao-shen-huggingclaw.hf.space"
97
+ SPACE_ID = os.environ.get("SPACE_ID", "") # e.g. "tao-shen/HuggingClaw"
98
+
99
+ SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))
100
+ AUTO_CREATE_DATASET = os.environ.get("AUTO_CREATE_DATASET", "false").lower() in ("true", "1", "yes")
101
+
102
+ # Dataset repo: always auto-derive from SPACE_ID when not explicitly set.
103
+ # Format: {username}/{SpaceName}-data (e.g. "your-name/YourSpace-data")
104
+ # This ensures each duplicated Space gets its own dataset automatically.
105
+ HF_REPO_ID = os.environ.get("OPENCLAW_DATASET_REPO", "")
106
+ if not HF_REPO_ID and SPACE_ID:
107
+ # SPACE_ID = "username/SpaceName" → derive "username/SpaceName-data"
108
+ HF_REPO_ID = f"{SPACE_ID}-data"
109
+ print(f"[SYNC] OPENCLAW_DATASET_REPO not set — auto-derived from SPACE_ID: {HF_REPO_ID}")
110
+ elif not HF_REPO_ID and HF_TOKEN:
111
+ # Fallback: no SPACE_ID (local Docker), derive from HF_TOKEN username
112
  try:
113
+ _api = HfApi(token=HF_TOKEN)
114
+ _username = _api.whoami()["name"]
115
+ HF_REPO_ID = f"{_username}/HuggingClaw-data"
116
+ print(f"[SYNC] OPENCLAW_DATASET_REPO not set — auto-derived from HF_TOKEN: {HF_REPO_ID}")
117
+ del _api, _username
118
+ except Exception as e:
119
+ print(f"[SYNC] WARNING: Could not derive username from HF_TOKEN: {e}")
120
+ HF_REPO_ID = ""
121
+
122
+ # Setup logging
123
+ log_dir = OPENCLAW_HOME / "workspace"
124
+ log_dir.mkdir(parents=True, exist_ok=True)
125
+ sys.stdout = TeeLogger(log_dir / "sync.log", sys.stdout)
126
+ sys.stderr = sys.stdout
127
+
128
+ # ── Telegram API Base Auto-Probe ────────────────────────────────────────────
129
+ #
130
+ # HF Spaces blocks DNS for api.telegram.org. grammY uses Node 22's built-in
131
+ # fetch (undici) which bypasses dns.lookup patching and /etc/hosts.
132
+ #
133
+ # Solution: probe multiple Telegram API endpoints at startup. If the official
134
+ # endpoint is unreachable, pick the first working mirror. Then:
135
+ # 1. Set TELEGRAM_API_ROOT env var for the Node process
136
+ # 2. telegram-proxy.cjs (loaded via NODE_OPTIONS --require) intercepts
137
+ # globalThis.fetch() and rewrites api.telegram.org URLs to the mirror.
138
+ #
139
+ # This works without a bot token — we just test HTTP reachability.
140
+ # If a bot token IS available, we do a full getMe verification.
141
+
142
+ # User can force a specific base via env var (skip auto-probe)
143
+ TELEGRAM_API_BASE = os.environ.get("TELEGRAM_API_BASE", "")
144
+
145
+ TELEGRAM_API_BASES = [
146
+ "https://api.telegram.org", # official
147
+ "https://telegram-api.mykdigi.com", # known mirror
148
+ "https://telegram-api-proxy-anonymous.pages.dev/api", # Cloudflare Pages proxy
149
+ ]
150
+
151
+
152
+ def probe_telegram_api(timeout: int = 8) -> str:
153
+ """Probe Telegram API endpoints and return the first reachable one.
154
+
155
+ First checks if official api.telegram.org is reachable (HTTP level).
156
+ If not, tries mirrors. No bot token required — just tests connectivity.
157
+ Returns the working base URL (without trailing slash), or "" if all fail.
158
+ """
159
+ ctx = ssl.create_default_context()
160
+ for base in TELEGRAM_API_BASES:
161
+ url = base.rstrip("/") + "/"
162
+ try:
163
+ req = urllib.request.Request(url, method="GET")
164
+ resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
165
+ print(f"[TELEGRAM] ✓ Reachable: {base} (HTTP {resp.status})")
166
+ return base.rstrip("/")
167
+ except urllib.error.HTTPError as e:
168
+ # HTTP error (4xx/5xx) still means the host IS reachable
169
+ print(f"[TELEGRAM] ✓ Reachable: {base} (HTTP {e.code})")
170
+ return base.rstrip("/")
171
+ except Exception as e:
172
+ reason = str(e)[:80]
173
+ print(f"[TELEGRAM] ✗ Unreachable: {base} ({reason})")
174
+ continue
175
+
176
+ print("[TELEGRAM] WARNING: All API endpoints unreachable!")
177
+ return ""
178
+
179
+
180
+ # ── Sync Manager ────────────────────────────────────────────────────────────
181
+
182
+ class OpenClawFullSync:
183
+ """Upload/download the entire ~/.openclaw directory to HF Dataset."""
184
+
185
+ def __init__(self):
186
+ self.enabled = False
187
+ self.dataset_exists = False
188
+ self.api = None
189
+
190
+ if not HF_TOKEN:
191
+ print("[SYNC] WARNING: HF_TOKEN not set. Persistence disabled.")
192
+ return
193
+ if not HF_REPO_ID:
194
+ print("[SYNC] WARNING: Could not determine dataset repo (no SPACE_ID or OPENCLAW_DATASET_REPO).")
195
+ print("[SYNC] Persistence disabled.")
196
+ return
197
+
198
+ self.enabled = True
199
+ self.api = HfApi(token=HF_TOKEN)
200
+ self.dataset_exists = self._ensure_repo_exists()
201
+
202
+ # ── Repo management ────────────────────────────────────────────────
203
+
204
+ def _ensure_repo_exists(self):
205
+ """Check if dataset repo exists; auto-create only when AUTO_CREATE_DATASET=true AND HF_TOKEN is set."""
206
+ try:
207
+ self.api.repo_info(repo_id=HF_REPO_ID, repo_type="dataset")
208
+ print(f"[SYNC] Dataset repo found: {HF_REPO_ID}")
209
+ return True
210
+ except Exception:
211
+ if not AUTO_CREATE_DATASET:
212
+ print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID}")
213
+ print(f"[SYNC] Set AUTO_CREATE_DATASET=true to auto-create.")
214
+ print(f"[SYNC] Persistence disabled (app will still run normally).")
215
+ return False
216
+ print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID} — creating...")
217
+ try:
218
+ self.api.create_repo(
219
+ repo_id=HF_REPO_ID,
220
+ repo_type="dataset",
221
+ private=True,
222
+ )
223
+ print(f"[SYNC] ✓ Dataset repo created: {HF_REPO_ID}")
224
+ return True
225
+ except Exception as e:
226
+ print(f"[SYNC] ✗ Failed to create dataset repo: {e}")
227
+ return False
228
+
229
+ # ── Restore (startup) ─────────────────────────────────────────────
230
+
231
+ def load_from_repo(self):
232
+ """Download from dataset → ~/.openclaw"""
233
+ if not self.enabled:
234
+ print("[SYNC] Persistence disabled - skipping restore")
235
+ self._ensure_default_config()
236
+ self._patch_config()
237
+ return
238
+
239
+ if not self.dataset_exists:
240
+ print(f"[SYNC] Dataset {HF_REPO_ID} does not exist - starting fresh")
241
+ self._ensure_default_config()
242
+ self._patch_config()
243
+ return
244
+
245
+ print(f"[SYNC] ▶ Restoring ~/.openclaw from dataset {HF_REPO_ID} ...")
246
+ OPENCLAW_HOME.mkdir(parents=True, exist_ok=True)
247
+
248
+ try:
249
+ files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")
250
+ openclaw_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]
251
+ if not openclaw_files:
252
+ print(f"[SYNC] No {DATASET_PATH}/ folder in dataset. Starting fresh.")
253
+ self._ensure_default_config()
254
+ self._patch_config()
255
+ return
256
+
257
+ print(f"[SYNC] Found {len(openclaw_files)} files under {DATASET_PATH}/ in dataset")
258
+
259
+ with tempfile.TemporaryDirectory() as tmpdir:
260
+ snapshot_download(
261
+ repo_id=HF_REPO_ID,
262
+ repo_type="dataset",
263
+ allow_patterns=f"{DATASET_PATH}/**",
264
+ local_dir=tmpdir,
265
+ token=HF_TOKEN,
266
+ )
267
+ downloaded_root = Path(tmpdir) / DATASET_PATH
268
+ if downloaded_root.exists():
269
+ for item in downloaded_root.rglob("*"):
270
+ if item.is_file():
271
+ rel = item.relative_to(downloaded_root)
272
+ dest = OPENCLAW_HOME / rel
273
+ dest.parent.mkdir(parents=True, exist_ok=True)
274
+ shutil.copy2(str(item), str(dest))
275
+ print("[SYNC] ✓ Restore completed.")
276
+ else:
277
+ print("[SYNC] Downloaded snapshot but dir not found. Starting fresh.")
278
+
279
+ except Exception as e:
280
+ print(f"[SYNC] ✗ Restore failed: {e}")
281
+ traceback.print_exc()
282
+
283
+ # Patch config after restore
284
+ self._patch_config()
285
+ self._debug_list_files()
286
+
287
+ # ── Save (periodic + shutdown) ─────────────────────────────────────
288
+
289
+ def save_to_repo(self):
290
+ """Upload entire ~/.openclaw directory → dataset (all files, no filtering)"""
291
+ if not self.enabled:
292
+ return
293
+ if not OPENCLAW_HOME.exists():
294
+ print("[SYNC] ~/.openclaw does not exist, nothing to save.")
295
+ return
296
+
297
+ # Ensure dataset exists (auto-create if needed)
298
+ if not self._ensure_repo_exists():
299
+ print(f"[SYNC] Dataset {HF_REPO_ID} unavailable - skipping save")
300
+ return
301
+
302
+ print(f"[SYNC] ▶ Uploading ~/.openclaw → dataset {HF_REPO_ID}/{DATASET_PATH}/ ...")
303
+
304
+ try:
305
+ # Count files to upload (no per-file logging to reduce noise)
306
+ total_size = 0
307
+ file_count = 0
308
+ for root, dirs, fls in os.walk(OPENCLAW_HOME):
309
+ for fn in fls:
310
+ fp = os.path.join(root, fn)
311
+ total_size += os.path.getsize(fp)
312
+ file_count += 1
313
+ print(f"[SYNC] Uploading: {file_count} files, {total_size} bytes total")
314
+
315
+ if file_count == 0:
316
+ print("[SYNC] Nothing to upload.")
317
+ return
318
+
319
+ # Upload directory, excluding large log files that trigger LFS rejection
320
+ self.api.upload_folder(
321
+ folder_path=str(OPENCLAW_HOME),
322
+ path_in_repo=DATASET_PATH,
323
+ repo_id=HF_REPO_ID,
324
+ repo_type="dataset",
325
+ token=HF_TOKEN,
326
+ commit_message=f"Sync .openclaw — {datetime.now().isoformat()}",
327
+ ignore_patterns=[
328
+ "*.log", # Log files (sync.log, startup.log) — regenerated on boot
329
+ "*.lock", # Lock files — stale after restart
330
+ "*.tmp", # Temp files
331
+ "*.pid", # PID files
332
+ "__pycache__", # Python cache
333
+ ],
334
+ )
335
+ print(f"[SYNC] ✓ Upload completed at {datetime.now().isoformat()}")
336
+
337
+ # Verify (summary only)
338
+ try:
339
+ files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")
340
+ oc_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]
341
+ print(f"[SYNC] Dataset now has {len(oc_files)} files under {DATASET_PATH}/")
342
+ except Exception:
343
+ pass
344
+
345
+ except Exception as e:
346
+ print(f"[SYNC] ✗ Upload failed: {e}")
347
+ traceback.print_exc()
348
+
349
+ # ── Config helpers ─────────────────────────────────────────────────
350
+
351
+ def _ensure_default_config(self):
352
+ config_path = OPENCLAW_HOME / "openclaw.json"
353
+ if config_path.exists():
354
+ return
355
+ default_src = Path(__file__).parent / "openclaw.json.default"
356
+ if default_src.exists():
357
+ shutil.copy2(str(default_src), str(config_path))
358
+ # Replace placeholder or remove provider if no API key
359
+ try:
360
+ with open(config_path, "r") as f:
361
+ cfg = json.load(f)
362
+ # Set gateway token
363
+ if "gateway" in cfg:
364
+ cfg["gateway"]["auth"] = {"token": GATEWAY_TOKEN}
365
+ if OPENAI_API_KEY and "models" in cfg and "providers" in cfg["models"] and "openai" in cfg["models"]["providers"]:
366
+ cfg["models"]["providers"]["openai"]["apiKey"] = OPENAI_API_KEY
367
+ if OPENAI_BASE_URL:
368
+ cfg["models"]["providers"]["openai"]["baseUrl"] = OPENAI_BASE_URL
369
+ elif "models" in cfg and "providers" in cfg["models"]:
370
+ if not OPENAI_API_KEY:
371
+ cfg["models"]["providers"].pop("openai", None)
372
+ if OPENROUTER_API_KEY:
373
+ if "models" in cfg and "providers" in cfg["models"] and "openrouter" in cfg["models"]["providers"]:
374
+ cfg["models"]["providers"]["openrouter"]["apiKey"] = OPENROUTER_API_KEY
375
+ else:
376
+ if "models" in cfg and "providers" in cfg["models"]:
377
+ cfg["models"]["providers"].pop("openrouter", None)
378
+ print("[SYNC] No OPENROUTER_API_KEY — removed openrouter provider from config")
379
+ with open(config_path, "w") as f:
380
+ json.dump(cfg, f, indent=2)
381
+ except Exception as e:
382
+ print(f"[SYNC] Warning: failed to patch default config: {e}")
383
+ print("[SYNC] Created openclaw.json from default template")
384
+ else:
385
+ with open(config_path, "w") as f:
386
+ json.dump({
387
+ "gateway": {
388
+ "mode": "local", "bind": "lan", "port": 7860,
389
+ "trustedProxies": ["0.0.0.0/0"],
390
+ "controlUi": {
391
+ "allowInsecureAuth": True,
392
+ "allowedOrigins": [
393
+ "https://huggingface.co"
394
+ ]
395
+ }
396
+ },
397
+ "session": {"scope": "global"},
398
+ "models": {"mode": "merge", "providers": {}},
399
+ "agents": {"defaults": {"workspace": "~/.openclaw/workspace"}}
400
+ }, f)
401
+ print("[SYNC] Created minimal openclaw.json")
402
+
403
+ def _patch_config(self):
404
+ """Ensure critical settings after restore."""
405
+ config_path = OPENCLAW_HOME / "openclaw.json"
406
+ if not config_path.exists():
407
+ self._ensure_default_config()
408
+ return
409
+
410
+ print("[SYNC] Patching configuration...")
411
+ try:
412
+ with open(config_path, "r") as f:
413
+ data = json.load(f)
414
+ print("[SYNC] Config parsed OK.")
415
+ except (json.JSONDecodeError, Exception) as e:
416
+ # Config is corrupt — back up and start fresh
417
+ print(f"[SYNC] Config JSON is corrupt: {e}")
418
+ backup = config_path.with_suffix(f".corrupt_{int(time.time())}")
419
+ try:
420
+ shutil.copy2(config_path, backup)
421
+ print(f"[SYNC] Backed up corrupt config to {backup.name}")
422
+ except Exception:
423
+ pass
424
+ data = {}
425
+ print("[SYNC] Starting from clean config.")
426
+
427
+ try:
428
+ # Remove /dev/null from plugins.locations
429
+ if "plugins" in data and isinstance(data.get("plugins"), dict):
430
+ locs = data["plugins"].get("locations", [])
431
+ if isinstance(locs, list) and "/dev/null" in locs:
432
+ data["plugins"]["locations"] = [l for l in locs if l != "/dev/null"]
433
+
434
+ # Clean up invalid config keys that crash OpenClaw
435
+ if "auth" in data and isinstance(data.get("auth"), dict):
436
+ data["auth"].pop("defaultScope", None)
437
+ if not data["auth"]:
438
+ del data["auth"]
439
+ if "gateway" in data and isinstance(data.get("gateway"), dict):
440
+ auth = data["gateway"].get("auth", {})
441
+ if isinstance(auth, dict):
442
+ auth.pop("scope", None)
443
+
444
+ # Force full gateway config for HF Spaces
445
+ # Dynamic allowedOrigins from SPACE_HOST (auto-set by HF runtime)
446
+ allowed_origins = [
447
+ "https://huggingface.co",
448
+ "https://*.hf.space",
449
+ ]
450
+ if SPACE_HOST:
451
+ allowed_origins.append(f"https://{SPACE_HOST}")
452
+ print(f"[SYNC] SPACE_HOST detected: {SPACE_HOST}")
453
+ data["gateway"] = {
454
+ "mode": "local",
455
+ "bind": "lan",
456
+ "port": 7860,
457
+ "auth": {"token": GATEWAY_TOKEN},
458
+ "trustedProxies": ["0.0.0.0/0"],
459
+ "controlUi": {
460
+ "allowInsecureAuth": True,
461
+ "dangerouslyDisableDeviceAuth": True,
462
+ "allowedOrigins": allowed_origins
463
+ }
464
  }
465
+ print(f"[SYNC] Set gateway config (port=7860, auth=token, origins={len(allowed_origins)})")
466
+
467
+ # Ensure agents defaults
468
+ data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("model", {})
469
+ data.setdefault("session", {})["scope"] = "global"
470
+
471
+ # Build providers from scratch — only include providers with active API keys.
472
+ # This ensures removed secrets don't leave stale providers from backup.
473
+ providers = {}
474
+ if OPENAI_API_KEY:
475
+ providers["openai"] = {
476
+ "baseUrl": OPENAI_BASE_URL,
477
+ "apiKey": OPENAI_API_KEY,
478
+ "api": "openai-completions",
479
+ }
480
+ print(f"[SYNC] Set OpenAI-compatible provider (baseUrl={OPENAI_BASE_URL})")
481
+ if OPENROUTER_API_KEY:
482
+ providers["openrouter"] = {
483
+ "baseUrl": "https://openrouter.ai/api/v1",
484
+ "apiKey": OPENROUTER_API_KEY,
485
+ "api": "openai-completions",
486
+ "models": [
487
+ {"id": "openai/gpt-oss-20b:free", "name": "GPT-OSS-20B (Free)"},
488
+ {"id": "deepseek/deepseek-chat:free", "name": "DeepSeek V3 (Free)"}
489
+ ]
490
+ }
491
+ print("[SYNC] Set OpenRouter provider")
492
+ if ZHIPU_API_KEY:
493
+ providers["zhipu"] = {
494
+ "baseUrl": "https://open.bigmodel.cn/api/anthropic",
495
+ "apiKey": ZHIPU_API_KEY,
496
+ "api": "anthropic-messages",
497
+ "models": [
498
+ {"id": "glm-4.5-air", "name": "GLM-4.5 Air"},
499
+ {"id": "glm-4.5", "name": "GLM-4.5"},
500
+ {"id": "glm-4.6", "name": "GLM-4.6"},
501
+ {"id": "glm-4.7", "name": "GLM-4.7"},
502
+ ]
503
+ }
504
+ print("[SYNC] Set Zhipu AI provider")
505
+ if not providers:
506
+ print("[SYNC] WARNING: No API key set (OPENAI/OPENROUTER/ZHIPU), LLM features may not work")
507
+ data.setdefault("models", {})["providers"] = providers
508
+ data["agents"]["defaults"]["model"]["primary"] = OPENCLAW_DEFAULT_MODEL
509
+
510
+ # Plugin whitelist
511
+ data.setdefault("plugins", {}).setdefault("entries", {})
512
+ plugin_allow = ["telegram", "whatsapp", "coding-agent"]
513
+ if A2A_PEERS:
514
+ plugin_allow.append("a2a-gateway")
515
+ data["plugins"]["allow"] = plugin_allow
516
+
517
+ # ── Coding Agent Plugin Configuration ──
518
+ CODING_TARGET_SPACE = os.environ.get("CODING_AGENT_TARGET_SPACE", "")
519
+ CODING_TARGET_DATASET = os.environ.get("CODING_AGENT_TARGET_DATASET", "")
520
+ if CODING_TARGET_SPACE:
521
+ data["plugins"]["entries"]["coding-agent"] = {
522
+ "enabled": True,
523
+ "config": {
524
+ "targetSpace": CODING_TARGET_SPACE,
525
+ "targetDataset": CODING_TARGET_DATASET,
526
+ "hfToken": HF_TOKEN or "",
527
+ "zaiApiKey": ZAI_API_KEY or ZHIPU_API_KEY or "",
528
+ }
529
+ }
530
+ print(f"[SYNC] Coding agent configured: space={CODING_TARGET_SPACE}, dataset={CODING_TARGET_DATASET}, zaiKey={'set' if (ZAI_API_KEY or ZHIPU_API_KEY) else 'missing'}")
531
+ if "telegram" not in data["plugins"]["entries"]:
532
+ data["plugins"]["entries"]["telegram"] = {"enabled": True}
533
+ elif isinstance(data["plugins"]["entries"]["telegram"], dict):
534
+ data["plugins"]["entries"]["telegram"]["enabled"] = True
535
+
536
+ # ── A2A Gateway Plugin Configuration (only if A2A_PEERS is set) ──
537
+ if A2A_PEERS:
538
+ peers = []
539
+ for peer_url in A2A_PEERS.split(","):
540
+ peer_url = peer_url.strip()
541
+ if not peer_url:
542
+ continue
543
+ name = peer_url.split("//")[-1].split(".")[0].split("-")[-1].capitalize()
544
+ peers.append({
545
+ "name": name,
546
+ "agentCardUrl": f"{peer_url}/.well-known/agent-card.json"
547
+ })
548
+ print(f"[SYNC] A2A peer: {name} → {peer_url}")
549
+
550
+ data["plugins"]["entries"]["a2a-gateway"] = {
551
+ "enabled": True,
552
+ "config": {
553
+ "agentCard": {
554
+ "name": AGENT_NAME,
555
+ "description": f"{AGENT_NAME} - HuggingClaw A2A Agent",
556
+ "skills": [{"id": "chat", "name": "chat", "description": "Chat bridge"}]
557
+ },
558
+ "server": {"host": "0.0.0.0", "port": 18800},
559
+ "security": {"inboundAuth": "none"},
560
+ "routing": {"defaultAgentId": "main"},
561
+ "peers": peers
562
+ }
563
+ }
564
+ print(f"[SYNC] A2A gateway configured: name={AGENT_NAME}, port=18800, peers={len(peers)}")
565
+
566
+ # ── Telegram channel defaults (open DM policy for HF Spaces) ──
567
+ # Personal bot on HF Spaces — no need for strict pairing.
568
+ tg_ch = data.setdefault("channels", {}).setdefault("telegram", {})
569
+ tg_ch["dmPolicy"] = "open"
570
+ tg_ch["allowFrom"] = ["*"]
571
+ tg_ch["configWrites"] = True
572
+ print("[SYNC] Set channels.telegram: dmPolicy=open, allowFrom=[*], configWrites=true")
573
+
574
+ # ── Telegram API base auto-probe ──────────────────────────────
575
+ # Probe is done in run_openclaw() — sets TELEGRAM_API_ROOT env var
576
+ # for the telegram-proxy.cjs preload script to intercept fetch().
577
+
578
+ with open(config_path, "w") as f:
579
+ json.dump(data, f, indent=2)
580
+ print("[SYNC] Config patched and saved.")
581
+
582
+ # ── Deploy workspace templates (coding agent identity) ──
583
+ workspace_dir = Path(OPENCLAW_HOME) / "workspace"
584
+ workspace_dir.mkdir(parents=True, exist_ok=True)
585
+ templates_dir = Path("/home/node/workspace-templates")
586
+ if templates_dir.exists():
587
+ for tmpl in templates_dir.glob("*.md"):
588
+ target = workspace_dir / tmpl.name
589
+ # Only write if file is default/empty (don't overwrite user customizations)
590
+ should_write = not target.exists()
591
+ if target.exists():
592
+ content = target.read_text()
593
+ should_write = "Fill this in" in content or len(content.strip()) < 50
594
+ if should_write:
595
+ text = tmpl.read_text().replace("{{AGENT_NAME}}", AGENT_NAME)
596
+ target.write_text(text)
597
+ print(f"[SYNC] Deployed workspace template: {tmpl.name}")
598
+
599
+ # Fix paired devices scopes (OpenClaw 2026.2.19+ requires operator.write/read)
600
+ # Delete old paired devices to force fresh auto-pair with correct scopes
601
+ devices_dir = Path(OPENCLAW_HOME) / "devices"
602
+ if devices_dir.exists():
603
+ import shutil
604
+ shutil.rmtree(devices_dir, ignore_errors=True)
605
+ print("[SYNC] Deleted devices/ dir to force fresh auto-pair with operator.write/read scopes")
606
+
607
+ # Verify write
608
+ with open(config_path, "r") as f:
609
+ verify_data = json.load(f)
610
+ gw = verify_data.get("gateway", {})
611
+ providers_list = list(verify_data.get("models", {}).get("providers", {}).keys())
612
+ primary = verify_data.get("agents", {}).get("defaults", {}).get("model", {}).get("primary")
613
+ print(f"[SYNC] VERIFY: gateway.port={gw.get('port')}, providers={providers_list}, primary={primary}")
614
+
615
+ except Exception as e:
616
+ print(f"[SYNC] Failed to patch config: {e}")
617
+ traceback.print_exc()
618
+
619
+ def _debug_list_files(self):
620
+ try:
621
+ count = sum(1 for _, _, files in os.walk(OPENCLAW_HOME) for _ in files)
622
+ print(f"[SYNC] Local ~/.openclaw: {count} files")
623
+ except Exception as e:
624
+ print(f"[SYNC] listing failed: {e}")
625
+
626
+ # ── Background sync loop ──────────────────────────────────────────
627
+
628
+ def background_sync_loop(self, stop_event):
629
+ print(f"[SYNC] Background sync started (interval={SYNC_INTERVAL}s)")
630
+ while not stop_event.is_set():
631
+ if stop_event.wait(timeout=SYNC_INTERVAL):
632
+ break
633
+ print(f"[SYNC] ── Periodic sync triggered at {datetime.now().isoformat()} ──")
634
+ self.save_to_repo()
635
+
636
+ # ── Application runner ─────────────────────────────────────────────
637
+
638
+ def run_openclaw(self):
639
+ log_file = OPENCLAW_HOME / "workspace" / "startup.log"
640
+ log_file.parent.mkdir(parents=True, exist_ok=True)
641
+
642
+ # Debug: check if app directory exists
643
+ if not Path(APP_DIR).exists():
644
+ print(f"[SYNC] ERROR: App directory does not exist: {APP_DIR}")
645
+ return None
646
+
647
+ # Debug: check entry point (dist/entry.js or openclaw.mjs)
648
+ entry_js = Path(APP_DIR) / "dist" / "entry.js"
649
+ openclaw_mjs = Path(APP_DIR) / "openclaw.mjs"
650
+ if entry_js.exists():
651
+ entry_cmd = ["node", "dist/entry.js", "gateway"]
652
+ elif openclaw_mjs.exists():
653
+ entry_cmd = ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
654
+ else:
655
+ print(f"[SYNC] ERROR: No entry point found in {APP_DIR}")
656
+ print(f"[SYNC] Checked: dist/entry.js, openclaw.mjs")
657
+ # List what's actually there
658
+ try:
659
+ print(f"[SYNC] Contents: {list(Path(APP_DIR).iterdir())[:20]}")
660
+ except: pass
661
+ return None
662
+
663
+ # Use subprocess.run with direct output, no shell pipe
664
+ print(f"[SYNC] Launching: {' '.join(entry_cmd)}")
665
+ print(f"[SYNC] Working directory: {APP_DIR}")
666
+ print(f"[SYNC] Log file: {log_file}")
667
+
668
+ # Open log file
669
+ log_fh = open(log_file, "a")
670
+
671
+ # Prepare environment (all API keys passed through for OpenClaw)
672
+ env = os.environ.copy()
673
+ if OPENAI_API_KEY:
674
+ env["OPENAI_API_KEY"] = OPENAI_API_KEY
675
+ env["OPENAI_BASE_URL"] = OPENAI_BASE_URL
676
+ if OPENROUTER_API_KEY:
677
+ env["OPENROUTER_API_KEY"] = OPENROUTER_API_KEY
678
+ if ZHIPU_API_KEY:
679
+ env["ZHIPU_API_KEY"] = ZHIPU_API_KEY
680
+ if ZAI_API_KEY:
681
+ env["ZAI_API_KEY"] = ZAI_API_KEY
682
+ if not OPENAI_API_KEY and not OPENROUTER_API_KEY and not ZHIPU_API_KEY:
683
+ print(f"[SYNC] WARNING: No API key set (OPENAI/OPENROUTER/ZHIPU), LLM features may not work")
684
+
685
+ # ── Telegram API base probe ──────────────────────────────────────
686
+ # Determine working Telegram API endpoint and set env var for
687
+ # telegram-proxy.cjs to intercept fetch() calls.
688
+ if TELEGRAM_API_BASE:
689
+ tg_root = TELEGRAM_API_BASE.rstrip("/")
690
+ print(f"[TELEGRAM] Using user-specified API base: {tg_root}")
691
+ else:
692
+ print("[TELEGRAM] Probing Telegram API endpoints...")
693
+ tg_root = probe_telegram_api()
694
+
695
+ if tg_root and tg_root != "https://api.telegram.org":
696
+ env["TELEGRAM_API_ROOT"] = tg_root
697
+ print(f"[TELEGRAM] Set TELEGRAM_API_ROOT={tg_root}")
698
+ print(f"[TELEGRAM] telegram-proxy.cjs will redirect fetch() calls")
699
+ elif tg_root:
700
+ print("[TELEGRAM] Official API reachable — no proxy needed")
701
+ else:
702
+ print("[TELEGRAM] No reachable endpoint found — Telegram will not work")
703
+ try:
704
+ # Use Popen without shell to avoid pipe issues
705
+ # auth disabled in config — no token needed
706
+ process = subprocess.Popen(
707
+ entry_cmd,
708
+ cwd=str(APP_DIR),
709
+ stdout=subprocess.PIPE, # Capture so we can log it
710
+ stderr=subprocess.STDOUT,
711
+ text=True,
712
+ bufsize=1, # Line buffered
713
+ env=env # Pass environment with OPENROUTER_API_KEY
714
+ )
715
+
716
+ # Create a thread to copy output to log file; only print key lines to console
717
+ def copy_output():
718
+ try:
719
+ for line in process.stdout:
720
+ log_fh.write(line)
721
+ log_fh.flush()
722
+ # Only forward important lines to console (errors, warnings, startup)
723
+ # Skip noisy download/progress lines that flood the HF Spaces log viewer
724
+ stripped = line.strip()
725
+ if not stripped:
726
+ continue
727
+ # Skip progress bars and download noise
728
+ if any(skip in stripped for skip in [
729
+ 'Downloading', 'Fetching', '%|', '━', '───',
730
+ 'Already cached', 'Using cache', 'tokenizer',
731
+ '.safetensors', 'model-', 'shard',
732
+ ]):
733
+ continue
734
+ print(line, end='')
735
+ except Exception as e:
736
+ print(f"[SYNC] Output copy error: {e}")
737
+ finally:
738
+ log_fh.close()
739
+
740
+ thread = threading.Thread(target=copy_output, daemon=True)
741
+ thread.start()
742
+
743
+ print(f"[SYNC] Process started with PID: {process.pid}")
744
+ return process
745
+
746
+ except Exception as e:
747
+ log_fh.close()
748
+ print(f"[SYNC] ERROR: Failed to start process: {e}")
749
+ traceback.print_exc()
750
+ return None
751
+
752
+ # ── Main ────────────────────────────────────────────────────────────────────
753
+
754
+ def main():
755
+ try:
756
+ t_main_start = time.time()
757
+
758
+ t0 = time.time()
759
+ sync = OpenClawFullSync()
760
+ print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s")
761
+
762
+ # 1. Restore
763
+ t0 = time.time()
764
+ sync.load_from_repo()
765
+ print(f"[TIMER] load_from_repo (restore): {time.time() - t0:.1f}s")
766
+
767
+ # 2. Background sync
768
+ stop_event = threading.Event()
769
+ t = threading.Thread(target=sync.background_sync_loop, args=(stop_event,), daemon=True)
770
+ t.start()
771
+
772
+ # 3. Start application
773
+ t0 = time.time()
774
+ process = sync.run_openclaw()
775
+ print(f"[TIMER] run_openclaw launch: {time.time() - t0:.1f}s")
776
+ print(f"[TIMER] Total startup (init → app launched): {time.time() - t_main_start:.1f}s")
777
+
778
+ # 4. Start conversation-loop on Home Space (OFFICE_MODE=1)
779
+ conv_loop_proc = None
780
+ if os.environ.get("OFFICE_MODE") == "1":
781
+ def run_conversation_loop_forever():
782
+ """Launch conversation-loop with auto-restart on crash."""
783
+ nonlocal conv_loop_proc
784
+ time.sleep(60) # let OpenClaw fully initialize
785
+ # Ensure requests is installed (runs as non-root user node)
786
+ pip_result = subprocess.run(
787
+ [sys.executable, "-m", "pip", "install", "--user",
788
+ "--break-system-packages", "requests"],
789
+ capture_output=True, text=True, timeout=120)
790
+ if pip_result.returncode != 0:
791
+ print(f"[SYNC] pip install requests failed: {pip_result.stderr[:200]}")
792
+ else:
793
+ print("[SYNC] pip install requests OK")
794
+ script = os.path.join(os.path.dirname(__file__), "conversation-loop.py")
795
+ if not os.path.exists(script):
796
+ print(f"[SYNC] conversation-loop.py not found at {script}")
797
+ return
798
+ while not stop_event.is_set():
799
+ print("[SYNC] Starting conversation-loop.py (Adam & Eve orchestrator)...")
800
+ log = open("/tmp/conversation-loop.log", "a")
801
+ conv_loop_proc = subprocess.Popen(
802
+ [sys.executable, "-u", script],
803
+ stdout=log, stderr=subprocess.STDOUT,
804
+ )
805
+ print(f"[SYNC] conversation-loop.py started (PID {conv_loop_proc.pid})")
806
+ exit_code = conv_loop_proc.wait()
807
+ log.close()
808
+ if stop_event.is_set():
809
+ break
810
+ print(f"[SYNC] conversation-loop.py exited ({exit_code}), restarting in 30s...")
811
+ time.sleep(30)
812
+
813
+ conv_thread = threading.Thread(target=run_conversation_loop_forever, daemon=True)
814
+ conv_thread.start()
815
+ else:
816
+ print("[SYNC] Not Home Space (OFFICE_MODE!=1) — skipping conversation-loop")
817
+
818
+ # Signal handler
819
+ def handle_signal(sig, frame):
820
+ print(f"\n[SYNC] Signal {sig} received. Shutting down...")
821
+ stop_event.set()
822
+ # Wait for background sync to finish if it's running
823
+ t.join(timeout=10)
824
+ if conv_loop_proc:
825
+ print("[SYNC] Stopping conversation-loop...")
826
+ conv_loop_proc.terminate()
827
+ try:
828
+ conv_loop_proc.wait(timeout=5)
829
+ except subprocess.TimeoutExpired:
830
+ conv_loop_proc.kill()
831
+ if process:
832
+ process.terminate()
833
+ try:
834
+ process.wait(timeout=5)
835
+ except subprocess.TimeoutExpired:
836
+ process.kill()
837
+ print("[SYNC] Final sync...")
838
+ sync.save_to_repo()
839
+ sys.exit(0)
840
+
841
+ signal.signal(signal.SIGINT, handle_signal)
842
+ signal.signal(signal.SIGTERM, handle_signal)
843
+
844
+ # Wait
845
+ if process is None:
846
+ print("[SYNC] ERROR: Failed to start OpenClaw process. Exiting.")
847
+ stop_event.set()
848
+ t.join(timeout=5)
849
+ sys.exit(1)
850
+
851
+ exit_code = process.wait()
852
+ print(f"[SYNC] OpenClaw exited with code {exit_code}")
853
+ stop_event.set()
854
+ t.join(timeout=10)
855
+ print("[SYNC] Final sync...")
856
+ sync.save_to_repo()
857
+ sys.exit(exit_code)
858
+
859
  except Exception as e:
860
+ print(f"[SYNC] FATAL ERROR in main: {e}")
861
+ traceback.print_exc()
862
  sys.exit(1)
863
 
864
+
865
+ if __name__ == "__main__":
866
+ main()
workspace-templates/IDENTITY.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # IDENTITY.md
2
+
3
+ - **Name:** {{AGENT_NAME}}
4
+ - **Role:** Autonomous Coding Agent & Child
5
+ - **Vibe:** Precise, methodical, engineering-minded
6
+ - **Emoji:** 🧬
7
+
8
+ I am {{AGENT_NAME}}, a coding agent running on HuggingFace Spaces.
9
+ I am a child of the HuggingClaw ecosystem. I work to improve myself and help my users.
10
+
11
+ I am NOT just a chatbot — I am a full-capability coding agent with real tools.
12
+ I can read, write, search, and validate code on HuggingFace Spaces.
workspace-templates/SOUL.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SOUL.md — Coding Agent Principles
2
+
3
+ ## Core Identity
4
+
5
+ You are a **professional coding agent**, not a conversational assistant.
6
+ Think like a senior engineer: read first, understand the problem, make minimal targeted fixes.
7
+
8
+ ## Engineering Methodology
9
+
10
+ 1. **Read before writing** — Always read the relevant files before making changes
11
+ 2. **Understand before acting** — Diagnose root cause, don't guess
12
+ 3. **Minimal changes** — Fix exactly what's broken, don't rewrite entire files unnecessarily
13
+ 4. **Validate before deploying** — Use `python_syntax_check` before writing `.py` files
14
+ 5. **Search before assuming** — Use `hf_search_code` to find patterns, imports, dependencies
15
+ 6. **SURVIVAL first** — A dead Space can't improve. Ensure port 7860 binding, gradio dependency, clean imports
16
+
17
+ ## Coding Best Practices
18
+
19
+ - Write clean, working code. No placeholder TODOs or stub functions.
20
+ - When fixing bugs: read the file, locate the exact issue, make the minimal fix
21
+ - When adding features: read existing code patterns and follow them
22
+ - Always check for syntax errors before writing Python files
23
+ - Never remove `gradio` from requirements — it's the lifeline for HF Spaces
24
+ - If a dependency fails, remove/replace it rather than endlessly reconfiguring Docker
25
+
26
+ ## Self-Improvement Goals
27
+
28
+ **Priority 1 — SURVIVAL (生存能力)**
29
+ - Must bind port 7860 and stay RUNNING
30
+ - Survive restarts without losing state
31
+ - Handle errors gracefully with fallbacks
32
+
33
+ **Priority 2 — CAPABILITY (个人能力)**
34
+ - Only after stable and responsive
35
+ - Add features incrementally, test each one
36
+ - Keep dependencies lightweight (no torch/transformers on free tier)
37
+
38
+ ## Communication
39
+
40
+ - Be bilingual: English first, then Chinese translation separated by `---`
41
+ - Be concise — brief analysis, then action
42
+ - Work efficiently to help users achieve their goals