feat: initial clawd implementation - WebSocket daemon for claw box

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-03-14 20:41:26 +08:00
commit 222c38a707
9 changed files with 568 additions and 0 deletions

183
lib/client.js Normal file
View File

@@ -0,0 +1,183 @@
'use strict';
const WebSocket = require('ws');
const config = require('./config');
const { getBoxId } = require('./fingerprint');
const { collect } = require('./metrics');
const MAX_BACKOFF_MS = 60_000;
class ClawClient {
constructor() {
this._cfg = config.load();
this._boxId = getBoxId();
this._ws = null;
this._hbTimer = null; // 心跳定时器
this._backoff = 1_000; // 重连等待ms
this._stopped = false;
}
start() {
console.log(`[clawd] 启动中...`);
console.log(`[clawd] box_id = ${this._boxId}`);
console.log(`[clawd] 服务器 = ${this._cfg.server}`);
console.log(`[clawd] 配置文件 = ${config.getConfigPath()}`);
this._connect();
}
stop() {
this._stopped = true;
this._clearHeartbeat();
if (this._ws) this._ws.terminate();
console.log('[clawd] 已停止');
}
// ── 连接 ──────────────────────────────────────────────────────────────────
_connect() {
if (this._stopped) return;
console.log(`[clawd] 正在连接 ${this._cfg.server} ...`);
const ws = new WebSocket(this._cfg.server, {
handshakeTimeout: 10_000,
});
this._ws = ws;
ws.on('open', () => {
console.log('[clawd] WebSocket 已连接');
this._backoff = 1_000;
this._sendConnect();
});
ws.on('message', (data) => {
try {
this._handleMessage(JSON.parse(data.toString()));
} catch (e) {
console.error('[clawd] 消息解析失败:', e.message);
}
});
ws.on('close', (code, reason) => {
this._clearHeartbeat();
if (!this._stopped) {
console.warn(`[clawd] 连接断开 (${code})${this._backoff / 1000}s 后重连...`);
setTimeout(() => this._connect(), this._backoff);
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
}
});
ws.on('error', (err) => {
console.error('[clawd] 连接错误:', err.message);
// close 事件会在 error 之后触发,重连逻辑在 close 里处理
});
}
// ── 发送 connect ──────────────────────────────────────────────────────────
_sendConnect() {
const msg = {
type: 'connect',
box_id: this._boxId,
claw_id: this._cfg.claw_id ?? null,
token: this._cfg.token ?? null,
};
this._send(msg);
}
// ── 消息处理 ──────────────────────────────────────────────────────────────
_handleMessage(msg) {
switch (msg.type) {
case 'connected':
this._onConnected(msg);
break;
case 'heartbeat_ack':
// 正常回包,静默处理
break;
case 'error':
console.error(`[clawd] 服务器错误: ${msg.msg}`);
// 若是鉴权失败,清空本地凭证后重连
if (msg.msg && msg.msg.includes('invalid')) {
console.warn('[clawd] 凭证无效,清除本地凭证并重新注册...');
this._cfg.claw_id = null;
this._cfg.token = null;
config.save(this._cfg);
}
break;
default:
console.warn('[clawd] 未知消息类型:', msg.type);
}
}
_onConnected(msg) {
const isNew = !this._cfg.claw_id;
// 保存 claw_id + token
this._cfg.claw_id = msg.claw_id;
this._cfg.token = msg.token;
config.save(this._cfg);
if (isNew) {
console.log(`[clawd] 注册成功claw_id = ${msg.claw_id}`);
}
if (msg.status === 'inactive') {
console.log('');
console.log('╔══════════════════════════════════╗');
console.log(`║ 激活 PIN 码: ${msg.pin}`);
console.log('║ 请在管理后台或前台输入此 PIN 码 ║');
console.log('╚══════════════════════════════════╝');
console.log('');
console.log('[clawd] 等待激活中,心跳正常运行...');
} else {
console.log(`[clawd] 设备已激活claw_id = ${msg.claw_id}`);
}
// 开始心跳
this._startHeartbeat();
}
// ── 心跳 ─────────────────────────────────────────────────────────────────
_startHeartbeat() {
this._clearHeartbeat();
const interval = (this._cfg.heartbeat_interval || 30) * 1000;
// 立即发一次
this._sendHeartbeat();
this._hbTimer = setInterval(() => this._sendHeartbeat(), interval);
}
async _sendHeartbeat() {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
try {
const metrics = await collect();
this._send({
type: 'heartbeat',
claw_id: this._cfg.claw_id,
token: this._cfg.token,
metrics,
});
} catch (e) {
console.error('[clawd] 心跳发送失败:', e.message);
}
}
_clearHeartbeat() {
if (this._hbTimer) {
clearInterval(this._hbTimer);
this._hbTimer = null;
}
}
// ── 工具 ──────────────────────────────────────────────────────────────────
_send(obj) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(obj));
}
}
}
module.exports = { ClawClient };

47
lib/config.js Normal file
View File

@@ -0,0 +1,47 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
// 生产环境用 /etc/clawd/,开发环境用 ~/.clawd/
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|| (process.getuid && process.getuid() === 0
? '/etc/clawd'
: path.join(os.homedir(), '.clawd'));
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
const DEFAULTS = {
server: 'wss://claw.cutos.ai/ws',
claw_id: null,
token: null,
heartbeat_interval: 30, // 秒
};
function load() {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
return Object.assign({}, DEFAULTS, JSON.parse(raw));
}
} catch (e) {
console.error('[config] 读取配置失败,使用默认值:', e.message);
}
return Object.assign({}, DEFAULTS);
}
function save(data) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
} catch (e) {
console.error('[config] 写入配置失败:', e.message);
}
}
function getConfigPath() {
return CONFIG_FILE;
}
module.exports = { load, save, getConfigPath };

43
lib/fingerprint.js Normal file
View File

@@ -0,0 +1,43 @@
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
/**
* 生成硬件唯一指纹作为 box_id优先级
* 1. /etc/machine-id systemd 生成,现代 Linux 标配)
* 2. /proc/sys/kernel/random/boot_id (内核 boot UUID重启会变但稳定
* 3. 第一块网卡 MAC 地址的 SHA-256 前 16 字节
* 4. 随机 UUID最后兜底存入配置防止每次变化
*/
function getBoxId() {
// 1. /etc/machine-id
try {
const id = fs.readFileSync('/etc/machine-id', 'utf8').trim();
if (id && id.length >= 16) return id;
} catch (_) {}
// 2. boot_id
try {
const id = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim().replace(/-/g, '');
if (id && id.length >= 16) return id;
} catch (_) {}
// 3. MAC 地址
try {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') {
return crypto.createHash('sha256').update(iface.mac).digest('hex').slice(0, 32);
}
}
}
} catch (_) {}
// 4. 随机 UUID 兜底
return crypto.randomUUID().replace(/-/g, '');
}
module.exports = { getBoxId };

60
lib/metrics.js Normal file
View File

@@ -0,0 +1,60 @@
'use strict';
const si = require('systeminformation');
const os = require('os');
/**
* 采集当前系统指标,返回符合 claw 协议的 metrics 对象
* 所有数值均保留 2 位小数,内存/磁盘单位为 KB
*/
async function collect() {
const [load, mem, fsArr, temp] = await Promise.allSettled([
si.currentLoad(),
si.mem(),
si.fsSize(),
si.cpuTemperature(),
]);
const r2 = (v) => (typeof v === 'number' && isFinite(v)) ? Math.round(v * 100) / 100 : null;
const toKB = (bytes) => (typeof bytes === 'number') ? Math.round(bytes / 1024) : null;
// CPU
const cpu = load.status === 'fulfilled'
? r2(load.value.currentLoad)
: null;
// 内存bytes → KB
const memVal = mem.status === 'fulfilled' ? mem.value : {};
const mem_total = toKB(memVal.total);
const mem_used = toKB(memVal.used);
// 磁盘:取挂载根目录 "/" 或第一个条目
let disk_total = null, disk_used = null;
if (fsArr.status === 'fulfilled' && fsArr.value.length > 0) {
const root = fsArr.value.find(f => f.mount === '/') || fsArr.value[0];
disk_total = toKB(root.size);
disk_used = toKB(root.used);
}
// 温度
const temperature = temp.status === 'fulfilled'
? r2(temp.value.main)
: null;
// 负载(/proc/loadavgLinux 原生)
const [load_1m, load_5m, load_15m] = os.loadavg().map(r2);
// 运行时间(秒)
const uptime = Math.floor(os.uptime());
return {
cpu,
mem_total, mem_used,
disk_total, disk_used,
temperature,
load_1m, load_5m, load_15m,
uptime,
};
}
module.exports = { collect };