Spaces:
Sleeping
Infrastructure migration: Align Cain with HuggingClaw architecture
Browse filesThis 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 +60 -14
- extensions/coding-agent/index.ts +270 -0
- extensions/coding-agent/openclaw.plugin.json +38 -0
- extensions/coding-agent/package.json +11 -0
- openclaw.json +56 -0
- package.json +5 -0
- scripts/entrypoint.sh +70 -0
- scripts/openclaw.json.default +52 -11
- scripts/sync_hf.py +857 -45
- workspace-templates/IDENTITY.md +12 -0
- workspace-templates/SOUL.md +42 -0
|
@@ -1,21 +1,67 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
ENV
|
| 17 |
-
ENV
|
| 18 |
-
ENV
|
|
|
|
| 19 |
|
| 20 |
-
|
| 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"]
|
|
|
|
@@ -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;
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dependencies": {
|
| 3 |
+
"ws": "^8.19.0"
|
| 4 |
+
}
|
| 5 |
+
}
|
|
@@ -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
|
|
@@ -1,15 +1,56 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
| 3 |
-
"
|
| 4 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
},
|
| 6 |
-
"
|
| 7 |
-
|
| 8 |
-
"
|
| 9 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
},
|
| 11 |
-
"
|
| 12 |
-
|
| 13 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
|
@@ -1,54 +1,866 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import os
|
|
|
|
| 5 |
import time
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
#
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
try:
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
except Exception as e:
|
| 50 |
-
print(f"
|
|
|
|
| 51 |
sys.exit(1)
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
| 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()
|
|
@@ -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.
|
|
@@ -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
|