diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3146d6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +plugin/node_modules/ +plugin/*.js +plugin/*.js.map +plugin/*.d.ts +plugin/*.d.ts.map diff --git a/README.md b/README.md index c3bd2b0..d61b5dc 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ HarborForge.OpenclawPlugin/ │ └───────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘ │ - ▼ HTTP/WebSocket + ▼ HTTP ┌─────────────────────┐ │ HarborForge Monitor │ └─────────────────────┘ @@ -76,21 +76,26 @@ node scripts/install.mjs --verbose ## 配置 -1. 在 HarborForge Monitor 中注册服务器,获取 `challengeUuid` +1. 在 HarborForge Monitor 中注册服务器,并生成 `apiKey` 2. 编辑 `~/.openclaw/openclaw.json`: ```json { "plugins": { - "harborforge-monitor": { - "enabled": true, - "backendUrl": "https://monitor.hangman-lab.top", - "identifier": "my-server-01", - "challengeUuid": "your-challenge-uuid-here", - "reportIntervalSec": 30, - "httpFallbackIntervalSec": 60, - "logLevel": "info" + "entries": { + "harborforge-monitor": { + "enabled": true, + "config": { + "enabled": true, + "backendUrl": "https://monitor.hangman-lab.top", + "identifier": "my-server-01", + "apiKey": "your-api-key-here", + "reportIntervalSec": 30, + "httpFallbackIntervalSec": 60, + "logLevel": "info" + } + } } } } @@ -109,7 +114,7 @@ openclaw gateway restart | `enabled` | boolean | `true` | 是否启用插件 | | `backendUrl` | string | `https://monitor.hangman-lab.top` | Monitor 后端地址 | | `identifier` | string | 自动检测 hostname | 服务器标识符 | -| `challengeUuid` | string | 必填 | 注册挑战 UUID | +| `apiKey` | string | 必填 | HarborForge Monitor 生成的服务器 API Key | | `reportIntervalSec` | number | `30` | 报告间隔(秒) | | `httpFallbackIntervalSec` | number | `60` | HTTP 回退间隔(秒) | | `logLevel` | string | `"info"` | 日志级别: debug/info/warn/error | @@ -151,7 +156,7 @@ npm run build ```bash cd server -HF_MONITOR_CHALLENGE_UUID=test-uuid \ +HF_MONITOR_API_KEY=test-api-key \ HF_MONITOR_BACKEND_URL=http://localhost:8000 \ HF_MONITOR_LOG_LEVEL=debug \ node telemetry.mjs diff --git a/docs/monitor-server-connector-plan.md b/docs/monitor-server-connector-plan.md index 398971f..291a9ee 100644 --- a/docs/monitor-server-connector-plan.md +++ b/docs/monitor-server-connector-plan.md @@ -1,112 +1,47 @@ -# HarborForge OpenClaw Server Connector Plugin — Project Plan +# Monitor Server Connector Plan -## 1) Goal -Provide a secure, lightweight plugin/agent that connects servers to HarborForge Monitor, streams telemetry in real time, and falls back to HTTP heartbeat when WebSocket is unavailable. +## Current design -## 2) Scope -- **Handshake + auth** using backend-issued challenge + RSA-OAEP encrypted payload. -- **WebSocket telemetry** to `/monitor/server/ws`. -- **HTTP heartbeat** to `/monitor/server/heartbeat` as fallback. -- **System metrics**: CPU/Mem/Disk/Swap/Uptime/OpenClaw version/Agents list. -- **Retry & backoff**, offline handling, and minimal local state. +The plugin uses: -## 3) Non-Goals -- No UI in the plugin. -- No provider billing calls from plugin. -- No multi-tenant auth beyond challenge + server identifier. +- **HTTP heartbeat** to `/monitor/server/heartbeat-v2` +- **API Key authentication** via `X-API-Key` +- **Gateway lifecycle hooks**: `gateway_start` / `gateway_stop` -## 4) Architecture -``` -plugin/ - config/ # load config & secrets - crypto/ # RSA-OAEP encrypt/decrypt helpers - collector/ # system + openclaw metrics - transport/ # ws + http heartbeat - state/ # retry/backoff, last sent, cache - main.ts|py # entry -``` +## No longer used -### 4.1 Config -- `backend_url` -- `identifier` -- `challenge_uuid` -- `report_interval_sec` (default: 20-30s) -- `http_fallback_interval_sec` (default: 60s) -- `log_level` +The following design has been retired: -### 4.2 Security -- Fetch public key: `GET /monitor/public/server-public-key` -- Encrypt payload with RSA-OAEP -- Include `nonce` + `ts` (UTC) to prevent replay -- **Challenge valid**: 10 minutes -- **Offline threshold**: 7 minutes +- challenge UUID +- RSA public key fetch +- encrypted handshake payload +- WebSocket telemetry -## 5) Communication Flow -### 5.1 Handshake (WS) -1. Plugin reads `identifier + challenge_uuid`. -2. Fetch RSA public key. -3. Encrypt payload: `{identifier, challenge_uuid, nonce, ts}`. -4. Connect WS `/monitor/server/ws` and send `encrypted_payload`. -5. On success: begin periodic telemetry push. +## Runtime flow -### 5.2 Fallback (HTTP) -If WS fails: -- POST telemetry to `/monitor/server/heartbeat` with same payload fields. -- Retry with exponential backoff (cap 5–10 min). +1. Gateway loads `harborforge-monitor` +2. Plugin reads config from OpenClaw plugin config +3. On `gateway_start`, plugin launches `server/telemetry.mjs` +4. Sidecar collects: + - system metrics + - OpenClaw version + - plugin version + - configured agents +5. Sidecar posts telemetry to backend with `X-API-Key` -## 6) Telemetry Schema (example) -``` +## Payload + +```json { - identifier: "vps.t1", - openclaw_version: "x.y.z", - cpu_pct: 12.5, - mem_pct: 41.2, - disk_pct: 62.0, - swap_pct: 0.0, - agents: [ { id: "a1", name: "agent", status: "running" } ], - last_seen_at: "2026-03-11T21:00:00Z" + "identifier": "vps.t1", + "openclaw_version": "OpenClaw 2026.3.13 (61d171a)", + "plugin_version": "0.1.0", + "agents": [], + "cpu_pct": 10.5, + "mem_pct": 52.1, + "disk_pct": 81.0, + "swap_pct": 0.0, + "load_avg": [0.12, 0.09, 0.03], + "uptime_seconds": 12345 } ``` - -## 7) Reliability -- Automatic reconnect on WS drop -- HTTP fallback if WS unavailable > 2 intervals -- Exponential backoff on failures -- Local cache for last successful payload - -## 8) Deployment Options -- **Systemd service** (preferred for VPS) -- **Docker container** (optional) -- Single-binary build if using Go/Rust - -## 9) Milestones -**M1 – POC (2–3 days)** -- CLI config loader + HTTP heartbeat -- See online + metrics in Monitor - -**M2 – WS realtime (2–3 days)** -- Full handshake + WS streaming -- Reconnect & fallback logic - -**M3 – Packaging (1–2 days)** -- systemd unit + sample config -- installation script - -**M4 – Hardening & Docs (1–2 days)** -- logging, metrics, docs -- troubleshooting guide - -## 10) Deliverables -- Plugin source -- Config template + systemd unit -- Integration docs -- Test script + example payloads - -## 11) Open Questions -- Preferred language (Go/Python/Node/Rust)? -- How to read OpenClaw agent list (API vs local state)? -- Required log format / retention? - ---- - -**Next step:** confirm preferred runtime (Go/Python/Node) and I will scaffold the project structure + first heartbeat implementation. diff --git a/plugin/core/live-config.d.ts b/plugin/core/live-config.d.ts new file mode 100644 index 0000000..244eb91 --- /dev/null +++ b/plugin/core/live-config.d.ts @@ -0,0 +1,15 @@ +export interface HarborForgeMonitorConfig { + enabled?: boolean; + backendUrl?: string; + identifier?: string; + apiKey?: string; + reportIntervalSec?: number; + httpFallbackIntervalSec?: number; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; +} +interface OpenClawPluginApiLike { + config?: Record; +} +export declare function getLivePluginConfig(api: OpenClawPluginApiLike, fallback: HarborForgeMonitorConfig): HarborForgeMonitorConfig; +export {}; +//# sourceMappingURL=live-config.d.ts.map \ No newline at end of file diff --git a/plugin/core/live-config.d.ts.map b/plugin/core/live-config.d.ts.map new file mode 100644 index 0000000..9188b63 --- /dev/null +++ b/plugin/core/live-config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"live-config.d.ts","sourceRoot":"","sources":["live-config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CAChD;AAED,UAAU,qBAAqB;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,qBAAqB,EAC1B,QAAQ,EAAE,wBAAwB,GACjC,wBAAwB,CAqB1B"} \ No newline at end of file diff --git a/plugin/core/live-config.js b/plugin/core/live-config.js new file mode 100644 index 0000000..a4e990e --- /dev/null +++ b/plugin/core/live-config.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getLivePluginConfig = getLivePluginConfig; +function getLivePluginConfig(api, fallback) { + const root = api.config || {}; + const plugins = root.plugins || {}; + const entries = plugins.entries || {}; + const entry = entries['harborforge-monitor'] || {}; + const cfg = entry.config || {}; + if (Object.keys(cfg).length > 0 || Object.keys(entry).length > 0) { + return { + ...fallback, + ...cfg, + enabled: typeof cfg.enabled === 'boolean' + ? cfg.enabled + : typeof entry.enabled === 'boolean' + ? entry.enabled + : fallback.enabled, + }; + } + return fallback; +} +//# sourceMappingURL=live-config.js.map \ No newline at end of file diff --git a/plugin/core/live-config.js.map b/plugin/core/live-config.js.map new file mode 100644 index 0000000..197010e --- /dev/null +++ b/plugin/core/live-config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"live-config.js","sourceRoot":"","sources":["live-config.ts"],"names":[],"mappings":";;AAcA,kDAwBC;AAxBD,SAAgB,mBAAmB,CACjC,GAA0B,EAC1B,QAAkC;IAElC,MAAM,IAAI,GAAI,GAAG,CAAC,MAAkC,IAAI,EAAE,CAAC;IAC3D,MAAM,OAAO,GAAI,IAAI,CAAC,OAAmC,IAAI,EAAE,CAAC;IAChE,MAAM,OAAO,GAAI,OAAO,CAAC,OAAmC,IAAI,EAAE,CAAC;IACnE,MAAM,KAAK,GAAI,OAAO,CAAC,qBAAqB,CAA6B,IAAI,EAAE,CAAC;IAChF,MAAM,GAAG,GAAI,KAAK,CAAC,MAAkC,IAAI,EAAE,CAAC;IAE5D,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACjE,OAAO;YACL,GAAG,QAAQ;YACX,GAAG,GAAG;YACN,OAAO,EACL,OAAO,GAAG,CAAC,OAAO,KAAK,SAAS;gBAC9B,CAAC,CAAC,GAAG,CAAC,OAAO;gBACb,CAAC,CAAC,OAAO,KAAK,CAAC,OAAO,KAAK,SAAS;oBAClC,CAAC,CAAC,KAAK,CAAC,OAAO;oBACf,CAAC,CAAC,QAAQ,CAAC,OAAO;SACG,CAAC;IAChC,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"} \ No newline at end of file diff --git a/plugin/core/live-config.ts b/plugin/core/live-config.ts new file mode 100644 index 0000000..86f60ef --- /dev/null +++ b/plugin/core/live-config.ts @@ -0,0 +1,39 @@ +export interface HarborForgeMonitorConfig { + enabled?: boolean; + backendUrl?: string; + identifier?: string; + apiKey?: string; + reportIntervalSec?: number; + httpFallbackIntervalSec?: number; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; +} + +interface OpenClawPluginApiLike { + config?: Record; +} + +export function getLivePluginConfig( + api: OpenClawPluginApiLike, + fallback: HarborForgeMonitorConfig +): HarborForgeMonitorConfig { + const root = (api.config as Record) || {}; + const plugins = (root.plugins as Record) || {}; + const entries = (plugins.entries as Record) || {}; + const entry = (entries['harborforge-monitor'] as Record) || {}; + const cfg = (entry.config as Record) || {}; + + if (Object.keys(cfg).length > 0 || Object.keys(entry).length > 0) { + return { + ...fallback, + ...cfg, + enabled: + typeof cfg.enabled === 'boolean' + ? cfg.enabled + : typeof entry.enabled === 'boolean' + ? entry.enabled + : fallback.enabled, + } as HarborForgeMonitorConfig; + } + + return fallback; +} diff --git a/plugin/index.ts b/plugin/index.ts index 4d70382..18d1577 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,25 +1,12 @@ /** * HarborForge Monitor Plugin for OpenClaw - * + * * Manages sidecar lifecycle and provides monitor-related tools. */ import { spawn } from 'child_process'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { join } from 'path'; import { existsSync } from 'fs'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -interface PluginConfig { - enabled?: boolean; - backendUrl?: string; - identifier?: string; - challengeUuid?: string; - reportIntervalSec?: number; - httpFallbackIntervalSec?: number; - logLevel?: 'debug' | 'info' | 'warn' | 'error'; -} +import { getLivePluginConfig, type HarborForgeMonitorConfig } from './core/live-config'; interface PluginAPI { logger: { @@ -29,150 +16,169 @@ interface PluginAPI { warn: (...args: any[]) => void; }; version?: string; - isRunning?: () => boolean; + config?: Record; + pluginConfig?: Record; on: (event: string, handler: () => void) => void; registerTool: (factory: (ctx: any) => any) => void; } -export default function register(api: PluginAPI, config: PluginConfig) { - const logger = api.logger || { - info: (...args: any[]) => console.log('[HF-Monitor]', ...args), - error: (...args: any[]) => console.error('[HF-Monitor]', ...args), - debug: (...args: any[]) => console.debug('[HF-Monitor]', ...args), - warn: (...args: any[]) => console.warn('[HF-Monitor]', ...args), - }; - - if (!config?.enabled) { - logger.info('HarborForge Monitor plugin disabled'); - return; - } - - if (!config.challengeUuid) { - logger.error('Missing required config: challengeUuid'); - logger.error('Please register server in HarborForge Monitor first'); - return; - } - - const serverPath = join(__dirname, '..', 'server', 'telemetry.mjs'); - - if (!existsSync(serverPath)) { - logger.error('Telemetry server not found:', serverPath); - return; - } - - let sidecar: ReturnType | null = null; - - function startSidecar() { - if (sidecar) { - logger.debug('Sidecar already running'); - return; - } - - logger.info('Starting HarborForge Monitor telemetry server...'); - - const env = { - ...process.env, - HF_MONITOR_BACKEND_URL: config.backendUrl || 'https://monitor.hangman-lab.top', - HF_MONITOR_IDENTIFIER: config.identifier || '', - HF_MONITOR_CHALLENGE_UUID: config.challengeUuid, - HF_MONITOR_REPORT_INTERVAL: String(config.reportIntervalSec || 30), - HF_MONITOR_HTTP_FALLBACK_INTERVAL: String(config.httpFallbackIntervalSec || 60), - HF_MONITOR_LOG_LEVEL: config.logLevel || 'info', - OPENCLAW_PATH: process.env.OPENCLAW_PATH || join(process.env.HOME || '/root', '.openclaw'), - OPENCLAW_VERSION: api.version || 'unknown', +export default { + id: 'harborforge-monitor', + name: 'HarborForge Monitor', + register(api: PluginAPI) { + const logger = api.logger || { + info: (...args: any[]) => console.log('[HF-Monitor]', ...args), + error: (...args: any[]) => console.error('[HF-Monitor]', ...args), + debug: (...args: any[]) => console.debug('[HF-Monitor]', ...args), + warn: (...args: any[]) => console.warn('[HF-Monitor]', ...args), }; - sidecar = spawn('node', [serverPath], { - env, - detached: false, - stdio: ['ignore', 'pipe', 'pipe'] - }); + const baseConfig: HarborForgeMonitorConfig = { + enabled: true, + backendUrl: 'https://monitor.hangman-lab.top', + identifier: '', + reportIntervalSec: 30, + httpFallbackIntervalSec: 60, + logLevel: 'info', + ...(api.pluginConfig || {}), + }; - sidecar.stdout?.on('data', (data: Buffer) => { - logger.info('[telemetry]', data.toString().trim()); - }); + const serverPath = join(__dirname, 'server', 'telemetry.mjs'); + let sidecar: ReturnType | null = null; - sidecar.stderr?.on('data', (data: Buffer) => { - logger.error('[telemetry]', data.toString().trim()); - }); - - sidecar.on('exit', (code, signal) => { - logger.info(`Telemetry server exited (code: ${code}, signal: ${signal})`); - sidecar = null; - }); - - sidecar.on('error', (err: Error) => { - logger.error('Failed to start telemetry server:', err.message); - sidecar = null; - }); - - logger.info('Telemetry server started with PID:', sidecar.pid); - } - - function stopSidecar() { - if (!sidecar) { - logger.debug('Telemetry server not running'); - return; + function resolveConfig() { + return getLivePluginConfig(api, baseConfig); } - logger.info('Stopping HarborForge Monitor telemetry server...'); - sidecar.kill('SIGTERM'); - - const timeout = setTimeout(() => { - if (sidecar && !sidecar.killed) { - logger.warn('Telemetry server did not exit gracefully, forcing kill'); - sidecar.kill('SIGKILL'); + function startSidecar() { + const live = resolveConfig(); + const enabled = live.enabled !== false; + + logger.info('HarborForge Monitor plugin config resolved', { + enabled, + hasApiKey: Boolean(live.apiKey), + backendUrl: live.backendUrl ?? null, + identifier: live.identifier ?? null, + }); + + if (!enabled) { + logger.info('HarborForge Monitor plugin disabled'); + return; } - }, 5000); - sidecar.on('exit', () => { - clearTimeout(timeout); - }); - } + if (sidecar) { + logger.debug('Sidecar already running'); + return; + } - // Hook into Gateway lifecycle - api.on('gateway:start', () => { - logger.info('Gateway starting, starting telemetry server...'); - startSidecar(); - }); + if (!live.apiKey) { + logger.warn('Missing config: apiKey'); + logger.warn('API authentication will fail. Generate apiKey from HarborForge Monitor admin.'); + } - api.on('gateway:stop', () => { - logger.info('Gateway stopping, stopping telemetry server...'); - stopSidecar(); - }); + if (!existsSync(serverPath)) { + logger.error('Telemetry server not found:', serverPath); + return; + } - // Handle process signals - process.on('SIGTERM', stopSidecar); - process.on('SIGINT', stopSidecar); + logger.info('Starting HarborForge Monitor telemetry server...'); - // Start immediately if Gateway is already running - if (api.isRunning?.()) { - startSidecar(); - } else { - setTimeout(() => startSidecar(), 1000); - } - - // Register status tool - api.registerTool(() => ({ - name: 'harborforge_monitor_status', - description: 'Get HarborForge Monitor plugin status', - parameters: { - type: 'object', - properties: {} - }, - async execute() { - return { - enabled: true, - sidecarRunning: sidecar !== null && sidecar.exitCode === null, - pid: sidecar?.pid || null, - config: { - backendUrl: config.backendUrl, - identifier: config.identifier || 'auto-detected', - reportIntervalSec: config.reportIntervalSec - } + const env = { + ...process.env, + HF_MONITOR_BACKEND_URL: live.backendUrl || 'https://monitor.hangman-lab.top', + HF_MONITOR_IDENTIFIER: live.identifier || '', + HF_MONITOR_API_KEY: live.apiKey || '', + HF_MONITOR_REPORT_INTERVAL: String(live.reportIntervalSec || 30), + HF_MONITOR_HTTP_FALLBACK_INTERVAL: String(live.httpFallbackIntervalSec || 60), + HF_MONITOR_LOG_LEVEL: live.logLevel || 'info', + OPENCLAW_PATH: process.env.OPENCLAW_PATH || join(process.env.HOME || '/root', '.openclaw'), + HF_MONITOR_PLUGIN_VERSION: api.version || 'unknown', }; - } - })); - logger.info('HarborForge Monitor plugin registered'); -} + sidecar = spawn('node', [serverPath], { + env, + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + sidecar.stdout?.on('data', (data: Buffer) => { + logger.info('[telemetry]', data.toString().trim()); + }); + + sidecar.stderr?.on('data', (data: Buffer) => { + logger.error('[telemetry]', data.toString().trim()); + }); + + sidecar.on('exit', (code, signal) => { + logger.info(`Telemetry server exited (code: ${code}, signal: ${signal})`); + sidecar = null; + }); + + sidecar.on('error', (err: Error) => { + logger.error('Failed to start telemetry server:', err.message); + sidecar = null; + }); + + logger.info('Telemetry server started with PID:', sidecar.pid); + } + + function stopSidecar() { + if (!sidecar) { + logger.debug('Telemetry server not running'); + return; + } + + logger.info('Stopping HarborForge Monitor telemetry server...'); + sidecar.kill('SIGTERM'); + + const timeout = setTimeout(() => { + if (sidecar && !sidecar.killed) { + logger.warn('Telemetry server did not exit gracefully, forcing kill'); + sidecar.kill('SIGKILL'); + } + }, 5000); + + sidecar.on('exit', () => { + clearTimeout(timeout); + }); + } + + api.on('gateway_start', () => { + logger.info('gateway_start received, starting telemetry server...'); + startSidecar(); + }); + + api.on('gateway_stop', () => { + logger.info('gateway_stop received, stopping telemetry server...'); + stopSidecar(); + }); + + process.on('SIGTERM', stopSidecar); + process.on('SIGINT', stopSidecar); + + api.registerTool(() => ({ + name: 'harborforge_monitor_status', + description: 'Get HarborForge Monitor plugin status', + parameters: { + type: 'object', + properties: {}, + }, + async execute() { + const live = resolveConfig(); + return { + enabled: live.enabled !== false, + sidecarRunning: sidecar !== null && sidecar.exitCode === null, + pid: sidecar?.pid || null, + config: { + backendUrl: live.backendUrl, + identifier: live.identifier || 'auto-detected', + reportIntervalSec: live.reportIntervalSec, + hasApiKey: Boolean(live.apiKey), + }, + }; + }, + })); + + logger.info('HarborForge Monitor plugin registered'); + }, +}; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 6186f53..816481d 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -22,9 +22,9 @@ "type": "string", "description": "Server identifier (auto-detected from hostname if not set)" }, - "challengeUuid": { + "apiKey": { "type": "string", - "description": "Registration challenge UUID from Monitor" + "description": "API Key from HarborForge Monitor admin panel (optional but required for authentication)" }, "reportIntervalSec": { "type": "number", @@ -42,7 +42,6 @@ "default": "info", "description": "Logging level" } - }, - "required": ["challengeUuid"] + } } } diff --git a/plugin/package-lock.json b/plugin/package-lock.json new file mode 100644 index 0000000..23f5869 --- /dev/null +++ b/plugin/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "harborforge-monitor-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harborforge-monitor-plugin", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json index 9ae7628..3d63e25 100644 --- a/plugin/tsconfig.json +++ b/plugin/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "CommonJS", + "moduleResolution": "node", "esModuleInterop": true, "strict": true, "skipLibCheck": true, @@ -12,6 +12,6 @@ "declarationMap": true, "sourceMap": true }, - "include": ["*.ts"], + "include": ["**/*.ts"], "exclude": ["node_modules"] } diff --git a/scripts/install.mjs b/scripts/install.mjs index a66e003..e343d54 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -240,8 +240,8 @@ async function configure() { } logOk(`plugins.allow includes ${PLUGIN_NAME}`); - // Note: challengeUuid must be configured manually by user - logOk('Plugin configured (remember to set challengeUuid in ~/.openclaw/openclaw.json)'); + // Note: apiKey must be configured manually by user + logOk('Plugin configured (remember to set apiKey in plugins.entries.harborforge-monitor.config)'); } catch (err) { logWarn(`Config failed: ${err.message}`); @@ -262,13 +262,18 @@ function summary() { console.log(''); log('Next steps:', 'blue'); - log(' 1. Register server in HarborForge Monitor to get challengeUuid', 'cyan'); - log(' 2. Edit ~/.openclaw/openclaw.json:', 'cyan'); + log(' 1. Register server in HarborForge Monitor to get apiKey', 'cyan'); + log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harborforge-monitor.config:', 'cyan'); log(' {', 'cyan'); log(' "plugins": {', 'cyan'); - log(' "harborforge-monitor": {', 'cyan'); - log(' "enabled": true,', 'cyan'); - log(' "challengeUuid": "your-challenge-uuid"', 'cyan'); + log(' "entries": {', 'cyan'); + log(' "harborforge-monitor": {', 'cyan'); + log(' "enabled": true,', 'cyan'); + log(' "config": {', 'cyan'); + log(' "enabled": true,', 'cyan'); + log(' "apiKey": "your-api-key"', 'cyan'); + log(' }', 'cyan'); + log(' }', 'cyan'); log(' }', 'cyan'); log(' }', 'cyan'); log(' }', 'cyan'); diff --git a/server/telemetry.mjs b/server/telemetry.mjs index 3434aac..fcb6e6d 100644 --- a/server/telemetry.mjs +++ b/server/telemetry.mjs @@ -4,24 +4,27 @@ * Runs as separate process from Gateway. * Collects system metrics and OpenClaw status, sends to Monitor. */ -import { readFile, access } from 'fs/promises'; +import { readFile, access, readdir } from 'fs/promises'; import { constants } from 'fs'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { platform, hostname, freemem, totalmem, uptime } from 'os'; +import { platform, hostname, freemem, totalmem, uptime, loadavg } from 'os'; const execAsync = promisify(exec); // Config from environment (set by plugin) +const openclawPath = process.env.OPENCLAW_PATH || `${process.env.HOME}/.openclaw`; const CONFIG = { backendUrl: process.env.HF_MONITOR_BACKEND_URL || 'https://monitor.hangman-lab.top', identifier: process.env.HF_MONITOR_IDENTIFIER || hostname(), - challengeUuid: process.env.HF_MONITOR_CHALLENGE_UUID, + apiKey: process.env.HF_MONITOR_API_KEY, reportIntervalSec: parseInt(process.env.HF_MONITOR_REPORT_INTERVAL || '30', 10), httpFallbackIntervalSec: parseInt(process.env.HF_MONITOR_HTTP_FALLBACK_INTERVAL || '60', 10), logLevel: process.env.HF_MONITOR_LOG_LEVEL || 'info', - openclawPath: process.env.OPENCLAW_PATH || `${process.env.HOME}/.openclaw`, - openclawVersion: process.env.OPENCLAW_VERSION || 'unknown', + openclawPath, + pluginVersion: process.env.HF_MONITOR_PLUGIN_VERSION || 'unknown', + cachePath: process.env.HF_MONITOR_CACHE_PATH || `${openclawPath}/telemetry_cache.json`, + maxCacheSize: parseInt(process.env.HF_MONITOR_MAX_CACHE_SIZE || '100', 10), }; // Logging @@ -37,6 +40,7 @@ let wsConnection = null; let lastSuccessfulSend = null; let consecutiveFailures = 0; let isShuttingDown = false; +let cachedOpenclawVersion = null; /** * Collect system metrics @@ -48,7 +52,7 @@ async function collectSystemMetrics() { const memFree = freemem(); const memUsed = memTotal - memFree; const diskInfo = await getDiskUsage(); - const loadAvg = platform() !== 'win32' ? require('os').loadavg() : [0, 0, 0]; + const loadAvg = platform() !== 'win32' ? loadavg() : [0, 0, 0]; return { cpu_pct: cpuUsage, @@ -59,8 +63,12 @@ async function collectSystemMetrics() { disk_used_gb: Math.round(diskInfo.usedGB * 10) / 10, disk_total_gb: Math.round(diskInfo.totalGB * 10) / 10, swap_pct: diskInfo.swapUsedPct || 0, - uptime_sec: Math.floor(uptime()), - load_avg_1m: Math.round(loadAvg[0] * 100) / 100, + uptime_seconds: Math.floor(uptime()), + load_avg: [ + Math.round(loadAvg[0] * 100) / 100, + Math.round(loadAvg[1] * 100) / 100, + Math.round(loadAvg[2] * 100) / 100, + ], platform: platform(), hostname: hostname(), }; @@ -129,14 +137,33 @@ function parseSizeToGB(size) { return num; } +async function resolveOpenclawVersion() { + if (cachedOpenclawVersion) return cachedOpenclawVersion; + + try { + const { stdout } = await execAsync('openclaw --version'); + const version = stdout.trim(); + cachedOpenclawVersion = version || 'unknown'; + return cachedOpenclawVersion; + } catch (err) { + log.debug('Failed to resolve OpenClaw version:', err.message); + cachedOpenclawVersion = 'unknown'; + return cachedOpenclawVersion; + } +} + /** * Collect OpenClaw status */ async function collectOpenclawStatus() { try { - const agents = await getOpenclawAgents(); + const [agents, openclawVersion] = await Promise.all([ + getOpenclawAgents(), + resolveOpenclawVersion(), + ]); return { - version: CONFIG.openclawVersion, + openclawVersion, + pluginVersion: CONFIG.pluginVersion, agent_count: agents.length, agents: agents.map(a => ({ id: a.id, @@ -146,23 +173,93 @@ async function collectOpenclawStatus() { }; } catch (err) { log.debug('Failed to collect OpenClaw status:', err.message); - return { version: CONFIG.openclawVersion, agent_count: 0, agents: [] }; + return { + openclawVersion: await resolveOpenclawVersion(), + pluginVersion: CONFIG.pluginVersion, + agent_count: 0, + agents: [], + }; } } /** * Get list of OpenClaw agents from local state */ +function extractJsonPrefix(text) { + const trimmed = text.trim(); + if (!trimmed) return null; + + const startsWith = trimmed[0]; + if (startsWith !== '[' && startsWith !== '{') return null; + + let depth = 0; + let inString = false; + let escape = false; + for (let i = 0; i < trimmed.length; i += 1) { + const ch = trimmed[i]; + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '[' || ch === '{') depth += 1; + if (ch === ']' || ch === '}') depth -= 1; + if (depth === 0) { + return trimmed.slice(0, i + 1); + } + } + return null; +} + async function getOpenclawAgents() { try { + try { + const { stdout } = await execAsync('openclaw agents list --json 2>/dev/null'); + const jsonPrefix = extractJsonPrefix(stdout); + if (jsonPrefix) { + const agents = JSON.parse(jsonPrefix); + if (Array.isArray(agents)) { + return agents.map((agent) => ({ + id: agent.id, + name: agent.name || agent.id, + status: agent.isDefault ? 'default' : 'configured', + })); + } + } + } catch (err) { + log.debug('Failed to get agents from `openclaw agents list --json`:', err.message); + } + const agentConfigPath = `${CONFIG.openclawPath}/agents.json`; try { await access(agentConfigPath, constants.R_OK); const data = JSON.parse(await readFile(agentConfigPath, 'utf8')); - return data.agents || []; + if (Array.isArray(data.agents) && data.agents.length > 0) { + return data.agents; + } } catch { - return []; + // fall through to directory-based discovery } + + const agentsDir = `${CONFIG.openclawPath}/agents`; + await access(agentsDir, constants.R_OK); + const entries = await readdir(agentsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.name !== 'main') + .map((entry) => ({ + id: entry.name, + name: entry.name, + status: 'configured', + })); } catch { return []; } @@ -177,12 +274,11 @@ async function buildPayload() { return { identifier: CONFIG.identifier, - challenge_uuid: CONFIG.challengeUuid, timestamp: new Date().toISOString(), ...system, - openclaw_version: openclaw.version, - openclaw_agents: openclaw.agents, - openclaw_agent_count: openclaw.agent_count, + openclaw_version: openclaw.openclawVersion, + plugin_version: openclaw.pluginVersion, + agents: openclaw.agents, }; } @@ -194,13 +290,18 @@ async function sendHttpHeartbeat() { const payload = await buildPayload(); log.debug('Sending HTTP heartbeat...'); - const response = await fetch(`${CONFIG.backendUrl}/monitor/server/heartbeat`, { + const headers = { + 'Content-Type': 'application/json', + 'X-Server-Identifier': CONFIG.identifier, + }; + + if (CONFIG.apiKey) { + headers['X-API-Key'] = CONFIG.apiKey; + } + + const response = await fetch(`${CONFIG.backendUrl}/monitor/server/heartbeat-v2`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Server-Identifier': CONFIG.identifier, - 'X-Challenge-UUID': CONFIG.challengeUuid, - }, + headers, body: JSON.stringify(payload), }); @@ -268,11 +369,12 @@ log.info('Config:', { identifier: CONFIG.identifier, backendUrl: CONFIG.backendUrl, reportIntervalSec: CONFIG.reportIntervalSec, + hasApiKey: !!CONFIG.apiKey, + pluginVersion: CONFIG.pluginVersion, }); -if (!CONFIG.challengeUuid) { - log.error('Missing HF_MONITOR_CHALLENGE_UUID environment variable'); - process.exit(1); +if (!CONFIG.apiKey) { + log.warn('Missing HF_MONITOR_API_KEY environment variable - API authentication will fail'); } reportingLoop();