/** * IT0 Bridge — runs alongside the OpenClaw gateway inside the container. * Exposes a REST API on port 3000 that: * - Forwards task submissions to OpenClaw via WebSocket RPC * - Returns status, session list, and metrics * - Reports heartbeat to IT0 agent-service * - Injects SKILL.md files into the container volume (POST /skill-inject) * * Skill injection flow: * iAgent (agent-service) receives user request → calls POST /api/v1/agent/instances/:id/skills * → agent-service forwards to this bridge's POST /skill-inject * → bridge writes SKILL.md to ~/.openclaw/skills/{name}/ * → OpenClaw gateway file watcher picks it up within 250ms * → bridge deletes the current OpenClaw session (sessions.delete RPC) so the * next user message opens a fresh session that loads the new skill directory * * Skill directory layout (inside the container volume): * $OPENCLAW_HOME/skills/ * └── {skill-slug}/ * └── SKILL.md ← YAML frontmatter (name, description) + markdown body */ import 'dotenv/config'; import express from 'express'; import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { OpenClawClient } from './openclaw-client'; // Skills live in the mounted OpenClaw home dir const OPENCLAW_HOME = process.env.OPENCLAW_HOME ?? '/home/node/.openclaw'; const SKILLS_DIR = path.join(OPENCLAW_HOME, 'skills'); const PORT = parseInt(process.env.BRIDGE_PORT ?? '3000', 10); const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789'; const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? ''; const INSTANCE_ID = process.env.IT0_INSTANCE_ID ?? 'unknown'; const IT0_AGENT_URL = process.env.IT0_AGENT_SERVICE_URL ?? ''; const app = express(); app.use(express.json()); // ── OpenClaw client ────────────────────────────────────────────────────────── const ocClient = new OpenClawClient(OPENCLAW_GATEWAY, OPENCLAW_TOKEN); let startTime = Date.now(); let gatewayReady = false; async function connectWithRetry(maxRetries = 20, delayMs = 3000): Promise { for (let i = 0; i < maxRetries; i++) { try { await ocClient.connect(); gatewayReady = true; console.log('[bridge] Connected to OpenClaw gateway'); return; } catch (err) { console.warn(`[bridge] Gateway not ready (attempt ${i + 1}/${maxRetries}), retrying...`); await new Promise((r) => setTimeout(r, delayMs)); } } throw new Error('Could not connect to OpenClaw gateway after retries'); } // ── Routes ─────────────────────────────────────────────────────────────────── // Health check — used by IT0 monitoring app.get('/health', (_req, res) => { res.json({ status: gatewayReady ? 'ok' : 'starting', instanceId: INSTANCE_ID, uptime: Math.floor((Date.now() - startTime) / 1000), gatewayConnected: ocClient.isConnected(), }); }); // Status — detailed instance info app.get('/status', (_req, res) => { res.json({ instanceId: INSTANCE_ID, gatewayConnected: ocClient.isConnected(), uptime: Math.floor((Date.now() - startTime) / 1000), }); }); // Submit a task to OpenClaw and wait for the agent's final reply. // // OpenClaw Protocol v3 flow: // 1. POST /task → bridge calls chat.send → gets { runId, status:"started" } ack // 2. Agent processes, pushes WS events (type:"event", event:"chat") // 3. Final event { state:"final", message:{content:[{type:"text",text:"..."}]} } // is captured by the bridge's event listener and returned as the HTTP response. // // Request body: // sessionKey — OpenClaw session key, e.g. "agent:main:dt-" // prompt — message text to send // idempotencyKey — caller-supplied unique key (DingTalk msgId etc.) for dedup // timeoutSeconds — max seconds to wait for agent reply (default 25) app.post('/task', async (req, res) => { if (!ocClient.isConnected()) { res.status(503).json({ error: 'Gateway not connected' }); return; } try { const sessionKey = req.body.sessionKey ?? `agent:main:${req.body.sessionId ?? 'main'}`; const timeoutSeconds: number = req.body.timeoutSeconds ?? 25; // idempotencyKey is mandatory in chat.send (Protocol v3). Use caller-supplied value // (so DingTalk msgId is preserved for dedup), or generate a UUID for ad-hoc calls. const idempotencyKey: string = req.body.idempotencyKey ?? crypto.randomUUID(); const reply = await ocClient.chatSendAndWait({ sessionKey, message: req.body.prompt, idempotencyKey, timeoutMs: timeoutSeconds * 1000, }); res.json({ ok: true, result: reply }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // Submit a task asynchronously — returns immediately, POSTs result to callbackUrl when done. // // Request body (same as /task, plus): // callbackUrl — URL to POST the result to when the agent replies // callbackData — opaque object forwarded unchanged in the callback body // // Response: { ok: true, pending: true } // Callback body (POST to callbackUrl): // { ok: true, result: string, callbackData } — on success // { ok: false, error: string, callbackData } — on LLM/timeout error // { ok: false, error: "callback_url_missing", ... } — config error (logged only) app.post('/task-async', async (req, res) => { const callbackUrl: string | undefined = req.body.callbackUrl; if (!callbackUrl) { res.status(400).json({ error: 'callbackUrl is required' }); return; } if (!ocClient.isConnected()) { res.status(503).json({ error: 'Gateway not connected' }); return; } const sessionKey = req.body.sessionKey ?? `agent:main:${req.body.sessionId ?? 'main'}`; const timeoutSeconds: number = req.body.timeoutSeconds ?? 120; // 2 min default for async tasks const idempotencyKey: string = req.body.idempotencyKey ?? crypto.randomUUID(); const callbackData = req.body.callbackData ?? {}; // Optional attachments — passed through to OpenClaw chat.send unchanged. // Expected format: [{ name, mimeType, media }] where media is a data-URI. const attachments: Array<{ name: string; mimeType: string; media: string }> | undefined = Array.isArray(req.body.attachments) && req.body.attachments.length > 0 ? req.body.attachments : undefined; // Return immediately — LLM runs in background res.json({ ok: true, pending: true }); const postCallback = (body: object) => { fetch(callbackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).catch((e: Error) => console.error('[bridge] callback POST failed:', e.message)); }; ocClient.chatSendAndWait({ sessionKey, message: req.body.prompt, idempotencyKey, timeoutMs: timeoutSeconds * 1000, attachments, }).then((reply: string) => { postCallback({ ok: true, result: reply, callbackData }); }).catch((err: Error) => { const isTimeout = err.message.toLowerCase().includes('timeout'); postCallback({ ok: false, error: err.message, isTimeout, callbackData }); }); }); // Inject a skill into this OpenClaw instance. // // Writes SKILL.md to ~/.openclaw/skills/{name}/ so the gateway's file watcher // picks it up within 250ms. Then optionally deletes the caller's current session // so the next message starts a fresh session — new sessions pick up newly added // skill directories immediately, giving the user a seamless experience. // // Request body: // name — skill directory name (slug, e.g. "it0-weather") // content — full SKILL.md content (markdown with YAML frontmatter) // sessionKey — (optional) current session key to delete after injection // // Response: { ok, injected, sessionCleared } app.post('/skill-inject', async (req, res) => { const { name, content, sessionKey } = req.body ?? {}; if (!name || typeof name !== 'string' || !content || typeof content !== 'string') { res.status(400).json({ error: 'name and content are required strings' }); return; } // Prevent path traversal if (name.includes('/') || name.includes('..') || name.includes('\\')) { res.status(400).json({ error: 'invalid skill name' }); return; } const skillDir = path.join(SKILLS_DIR, name); try { await fs.mkdir(skillDir, { recursive: true }); await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf8'); console.log(`[bridge] Skill injected: ${name} → ${skillDir}`); } catch (err: any) { console.error(`[bridge] Failed to write skill ${name}:`, err.message); res.status(500).json({ error: `Failed to write skill: ${err.message}` }); return; } // Delete the caller's current session so the next message opens a fresh session // that picks up the newly added skill directory. let sessionCleared = false; if (sessionKey && typeof sessionKey === 'string' && ocClient.isConnected()) { try { await ocClient.rpc('sessions.delete', { sessionKey }, 5_000); sessionCleared = true; console.log(`[bridge] Session cleared after skill inject: ${sessionKey}`); } catch (err: any) { // Non-fatal — skill is injected, session will naturally expire or be replaced console.warn(`[bridge] Could not delete session ${sessionKey}: ${err.message}`); } } res.json({ ok: true, injected: true, sessionCleared }); }); // List sessions app.get('/sessions', async (_req, res) => { if (!ocClient.isConnected()) { res.status(503).json({ error: 'Gateway not connected' }); return; } try { const result = await ocClient.rpc('sessions.list', { limit: 100 }); res.json(result); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // Gateway status (metrics.get does not exist in OpenClaw Protocol v3) app.get('/metrics', async (_req, res) => { if (!ocClient.isConnected()) { res.status(503).json({ error: 'Gateway not connected' }); return; } try { const result = await ocClient.rpc('gateway.status'); res.json(result); } catch (err: any) { res.status(500).json({ error: err.message }); } }); // ── Heartbeat to IT0 agent-service ─────────────────────────────────────────── async function sendHeartbeat(): Promise { if (!IT0_AGENT_URL || !INSTANCE_ID || INSTANCE_ID === 'unknown') return; try { await fetch(`${IT0_AGENT_URL}/api/v1/agent/instances/${INSTANCE_ID}/heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gatewayConnected: ocClient.isConnected(), uptime: Math.floor((Date.now() - startTime) / 1000), }), }); } catch { // Best-effort, don't crash on network error } } // ── Start ───────────────────────────────────────────────────────────────────── app.listen(PORT, () => { console.log(`[bridge] IT0 Bridge listening on port ${PORT}`); startTime = Date.now(); }); connectWithRetry().catch((err) => { console.error('[bridge] Fatal: could not connect to OpenClaw gateway:', err.message); // Keep bridge running for health-check endpoint even if gateway failed }); // Heartbeat every 60 seconds setInterval(sendHeartbeat, 60_000);