'use strict'; const fs = require('fs'); const path = require('path'); const { getNotifySocket } = require('./systemd-env'); const WebSocket = require('ws'); const { execFileSync, exec } = require('child_process'); const config = require('./config'); const CLAWD_VERSION = require(path.join(__dirname, '..', 'package.json')).version; const log = require('./logger'); const { getBoxId } = require('./fingerprint'); const { collect } = require('./metrics'); const { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新 const headscale = require('./headscale'); const { ProvisionManager } = require('./provisioning'); const { BtMonitor } = require('./bt-monitor'); const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network'); const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider'); const led = require('./led'); const MAX_BACKOFF_MS = 60_000; /** 连续若干轮 ping 后仍无 pong 才判定死链(单轮易因调度/弱网误判) */ const PONG_MISS_MAX = 3; const PING_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态 const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒) /** 尚未连云时轮询外网(如 AP 模式下后接网线);短于 provision 监控,避免只靠 network-ready */ const LATE_NET_POLL_MS = 3_000; /** 内核是否暴露蓝牙适配器(hci*) */ function bluetoothAdapterPresent() { try { return fs.readdirSync('/sys/class/bluetooth').some((n) => n.startsWith('hci')); } catch (_) { return false; } } /** * 是否启动 BtMonitor(会周期性执行 bluetoothctl)。 * 默认关闭,仅拉代码即可与「不调 bluetoothctl」一致;需要蓝牙灯时设 CLAWD_ENABLE_BT=1。 * CLAWD_DISABLE_BT=1 仍可强制关闭(优先于 ENABLE)。 */ function btMonitorEnabled() { const dis = (process.env.CLAWD_DISABLE_BT || '').trim().toLowerCase(); if (dis === '1' || dis === 'true' || dis === 'yes') return false; const en = (process.env.CLAWD_ENABLE_BT || '').trim().toLowerCase(); if (!(en === '1' || en === 'true' || en === 'yes')) return false; return bluetoothAdapterPresent(); } class ClawClient { constructor() { this._cfg = config.load(); this._boxId = getBoxId(); this._ws = null; this._hbTimer = null; this._backoff = 1_000; this._stopped = false; this._frpc = new FrpcManager(); this._btMonitor = null; this._dashInfo = {}; this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息 this._externalIp = null; // 外网 IP this._location = null; // 地理位置(由 ipplus360 返回,如"北京市-北京市西城区") // WS 层活性检测 this._pingTimer = null; this._awaitingPong = false; this._pongMissCount = 0; // WS 连续失败计数(open 时清零) this._wsFailCount = 0; // 是否曾经成功连接过(首次成功前不显示 Err0/AP) this._hasEverConnected = false; // 最近一次 WS 错误是否是证书时间问题(NTP 未同步) this._certTimeError = false; // systemd watchdog(systemd-notify 子进程 + unit 里 NotifyAccess=all) this._sdTimer = null; /** setInterval:等外网出现后启动 _proceedWithConnection(与 network-ready 二选一) */ this._lateNetPollTimer = null; this._setupGlobalHandlers(); } // ── 全局异常兜底 ───────────────────────────────────────────────────────────── _setupGlobalHandlers() { process.on('uncaughtException', (err) => { log.error('process', '未捕获异常:', err); // 给日志写盘的时间,然后退出让 systemd 重启 setTimeout(() => process.exit(1), 1000); }); process.on('unhandledRejection', (reason) => { log.error('process', '未处理的 Promise 拒绝:', reason); }); } // ── 生命周期 ───────────────────────────────────────────────────────────────── async start() { log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`); if (this._cfg.claw_id) { this._setHostname(this._cfg.claw_id); } // 启动时关 alarm;若本地已记为已激活,立即亮 pwr(不等首包 connected) led.status.off(); if (this._cfg.activated) { led.status.setApps(); } this._startSdNotify(); // RJ45 链路轮询(OpenVFD play),与 WS 无关,进程起来即开始 led.lan.start(); // 蓝牙状态监控(bluetoothctl);默认不启用,见 btMonitorEnabled() if (btMonitorEnabled()) { this._btMonitor = new BtMonitor(); this._btMonitor.start(); } else { const dis = (process.env.CLAWD_DISABLE_BT || '').trim().toLowerCase(); const en = (process.env.CLAWD_ENABLE_BT || '').trim().toLowerCase(); const wantEn = en === '1' || en === 'true' || en === 'yes'; const forcedDis = dis === '1' || dis === 'true' || dis === 'yes'; let msg = '已跳过蓝牙监控(默认关闭;需要时请设 CLAWD_ENABLE_BT=1)'; if (forcedDis) msg = '已跳过蓝牙监控(CLAWD_DISABLE_BT 已开启)'; else if (wantEn && !bluetoothAdapterPresent()) { msg = '已跳过蓝牙监控(已设 CLAWD_ENABLE_BT 但未检测到 hci 适配器)'; } log.info('clawd', msg); } // 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP) this._provisionMgr = new ProvisionManager(this._cfg.claw_id); this._connectionStarted = false; // 网络就绪时连接云端(仅触发一次) this._provisionMgr.on('network-ready', () => { if (!this._connectionStarted) { this._clearLateNetPoll(); this._connectionStarted = true; this._proceedWithConnection().catch(e => { log.error('clawd', '连接启动失败:', e.message); }); } }); await this._provisionMgr.start(); // start() 返回后,如果已有网络且尚未启动连接 if (hasInternet() && !this._connectionStarted) { this._connectionStarted = true; await this._proceedWithConnection(); } else if (!hasInternet()) { log.info('clawd', '等待外网就绪(可配网;有线已接时将自动识别网卡,含非 eth0 口)...'); } if (!this._connectionStarted && !this._stopped) { this._lateNetPollTimer = setInterval(() => { if (this._stopped || this._connectionStarted) { this._clearLateNetPoll(); return; } if (hasInternet() || hasWiredInternetProbe()) { this._clearLateNetPoll(); this._connectionStarted = true; log.info('clawd', '轮询检测到外网,启动连云'); this._proceedWithConnection().catch(e => { log.error('clawd', '连接启动失败:', e.message); }); } }, LATE_NET_POLL_MS); } } _clearLateNetPoll() { if (this._lateNetPollTimer) { clearInterval(this._lateNetPollTimer); this._lateNetPollTimer = null; } } async _proceedWithConnection() { const [dashInfo] = await Promise.all([ getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }), startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)), ]); this._dashInfo = dashInfo || {}; // 查询外网 IP 和地理位置,失败不阻断连接 try { const https = require('https'); const fetchText = (url) => new Promise((resolve) => { const req = https.get(url, { timeout: 5000 }, (res) => { let body = ''; res.on('data', d => { body += d; }); res.on('end', () => resolve(body.trim())); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); }); const fetchJson = (url) => new Promise((resolve) => { const req = https.get(url, { timeout: 5000 }, (res) => { let body = ''; res.on('data', d => { body += d; }); res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(null); } }); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); }); // 外网 IP:checkip.amazonaws.com 返回纯文本 IP const ip = await fetchText('https://checkip.amazonaws.com'); if (ip && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { this._externalIp = ip; log.info('clawd', `外网 IP: ${this._externalIp}`); } // 地理位置:ipplus360(国内访问)返回 {"success":true,"data":"北京市-北京市西城区"} const geoResp = await fetchJson('https://www.ipplus360.com/getLocation'); if (geoResp && geoResp.success && geoResp.data) { this._location = geoResp.data; log.info('clawd', `地理位置: ${this._location}`); } } catch (e) { log.warn('clawd', '网络信息查询失败:', e.message); } this._connect(); } stop() { this._stopped = true; this._clearLateNetPoll(); this._clearHeartbeat(); this._clearPing(); if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; } led.lan.stop(); if (this._btMonitor) { this._btMonitor.stop(); this._btMonitor = null; } if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; } this._frpc.stop(); if (this._ws) this._ws.terminate(); led.status.off(); // 进程退出,两灯全灭 this._sdNotify('STOPPING=1'); log.info('clawd', '已停止'); log.close(); } // ── WebSocket 连接 ────────────────────────────────────────────────────────── _connect() { if (this._stopped) return; // AP 模式 + 无网:不建立 WS,5s 后重新检查(有线经 -I ping 仍通则建立,避免热点误挡 WS) if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet() && !hasWiredInternetProbe()) { led.display.showAP(); log.info('clawd', 'AP 模式无网络,5s 后重新检查...'); this._backoff = 1_000; // 有网时立即快速重连 this._wsFailCount = 0; // 不计入失败 setTimeout(() => this._connect(), 5_000); return; } if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn(); log.info('clawd', `正在连接 ${this._cfg.server} ...`); const ws = new WebSocket(this._cfg.server, { handshakeTimeout: 10_000, }); this._ws = ws; ws.on('open', () => { log.info('clawd', 'WebSocket 已连接'); this._backoff = 1_000; this._wsFailCount = 0; // 连接成功,重置失败计数 this._hasEverConnected = true; // 标记已成功连接过 if (this._cfg.activated) { led.status.setApps(); } this._sendConnect(); this._startPing(); // 显示由 _onConnected 根据 status 设置,不在此处提前 showTime }); ws.on('message', (data) => { try { this._handleMessage(JSON.parse(data.toString())); } catch (e) { log.error('clawd', '消息解析失败:', e.message); } }); ws.on('pong', () => { this._awaitingPong = false; this._pongMissCount = 0; }); ws.on('close', (code, reason) => { this._clearHeartbeat(); this._clearPing(); if (!this._stopped) { this._wsFailCount++; log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`); if (this._hasEverConnected && this._wsFailCount >= 3) { led.display.showAP(); } if (this._certTimeError) { // NTP 未同步:固定 5s 重试,等时钟校正 this._certTimeError = false; this._backoff = 5_000; log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...'); } else { this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS); } setTimeout(() => this._connect(), this._backoff); } }); ws.on('error', (err) => { log.error('clawd', '连接错误:', err.message); // 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避 this._certTimeError = !!( err.code === 'CERT_NOT_YET_VALID' || (err.message && err.message.includes('not yet valid')) ); }); } // ── WS 层 Ping/Pong 活性检测 ────────────────────────────────────────────── _startPing() { this._clearPing(); this._pingTimer = setInterval(() => { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; if (this._awaitingPong) { this._pongMissCount++; if (this._pongMissCount >= PONG_MISS_MAX) { log.warn('clawd', `Pong 连续 ${PONG_MISS_MAX} 次未响应,主动关闭重连`); this._ws.terminate(); return; } log.warn('clawd', `Pong 超时 (${this._pongMissCount}/${PONG_MISS_MAX}),继续探测...`); } this._awaitingPong = true; try { this._ws.ping(); } catch (_) {} }, PING_INTERVAL_MS); } _clearPing() { if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; } this._awaitingPong = false; this._pongMissCount = 0; } // ── 发送 connect ───────────────────────────────────────────────────────────── _sendConnect() { const msg = { type: 'connect', box_id: this._boxId, claw_id: this._cfg.claw_id ?? null, token: this._cfg.token ?? null, version: CLAWD_VERSION, ssh_secret_key: this._cfg.ssh_secret_key ?? null, share_key: this._cfg.share_key ?? null, headscale_joined: headscale.isInstalled() && headscale.isJoined(this._cfg.headscale_server || 'https://hs.claw.cutos.ai'), local_ip: getLocalIps(), local_networks: getLocalNetworks(), external_ip: this._externalIp ?? null, location: this._location ?? null, ...this._dashInfo, }; this._send(msg); } // ── 消息处理 ───────────────────────────────────────────────────────────────── _handleMessage(msg) { switch (msg.type) { case 'connected': this._onConnected(msg); break; case 'heartbeat_ack': break; case 'status_update': this._applyStatus(msg); break; case 'upgrade': this._handleUpgrade(msg); break; case 'headscale_logout': headscale.logout().catch(e => log.error('headscale', 'logout 失败:', e.message)); break; case 'error': log.error('clawd', `服务器错误: ${msg.msg}`); if (msg.msg === 'hardware_mismatch') { log.warn('clawd', '硬件指纹不符,清除凭证重新注册...'); this._cfg.claw_id = null; this._cfg.token = null; this._cfg.activated = false; config.save(this._cfg); led.status.setSetup(); } else if (msg.msg && msg.msg.includes('invalid')) { log.warn('clawd', '凭证无效,清除凭证重新注册...'); this._cfg.claw_id = null; this._cfg.token = null; this._cfg.activated = false; config.save(this._cfg); led.status.setSetup(); } break; default: log.warn('clawd', '未知消息类型:', msg.type); } } _onConnected(msg) { const isNew = !this._cfg.claw_id; this._cfg.claw_id = msg.claw_id; this._cfg.token = msg.token; config.save(this._cfg); this._setHostname(msg.claw_id); if (isNew) { log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`); } this._applyStatus(msg); if (msg.frp && msg.frp.server && msg.frp.auth_token) { this._frpc.start(msg.claw_id, msg.frp, this._cfg.ssh_secret_key ?? null).catch(e => { log.error('frpc', '启动失败:', e.message); }); } // headscale:收到 authkey 则加入 mesh if (msg.headscale_authkey && msg.headscale_server) { const hostname = msg.headscale_hostname || `claw-${msg.claw_id}`; this._cfg.headscale_server = msg.headscale_server; config.save(this._cfg); headscale.join(msg.headscale_server, msg.headscale_authkey, hostname) .then(ok => { if (ok) log.info('headscale', `已加入 mesh: ${hostname}`); }) .catch(e => log.error('headscale', '加入 mesh 失败:', e.message)); } this._startHeartbeat(); } _applyStatus(msg) { if (msg.status === 'inactive') { if (msg.provider && msg.provider.name) { removeProviderByName(String(msg.provider.name)); } this._cfg.activated = false; config.save(this._cfg); led.status.setSetup(); led.display.showPin(msg.pin); const id = String(this._cfg.claw_id || '').padEnd(6); const pin = String(msg.pin || ''); log.info('clawd', ''); log.info('clawd', '╔════════════════════════════════════╗'); log.info('clawd', `║ Claw ID : ${id} ║`); log.info('clawd', `║ PIN 码 : ${pin} ║`); log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║'); log.info('clawd', '╚════════════════════════════════════╝'); log.info('clawd', ''); log.info('clawd', '等待激活,心跳正常运行...'); this._updateOpenClawOrigin('0000'); } else { this._cfg.activated = true; config.save(this._cfg); led.status.setApps(); led.display.showTime(); log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`); const clawIdStr = String(this._cfg.claw_id); if (isFullProvider(msg.provider)) { applyFullProviderFromVps(msg.provider, () => { this._updateOpenClawOrigin(clawIdStr); }); } else { // 重连场景:检查模型列表是否有变化,有变化才写盘 refreshModelsIfChanged(() => { this._updateOpenClawOrigin(clawIdStr); }); } } } // ── Hostname ───────────────────────────────────────────────────────────────── _setHostname(clawId) { const hostname = `claw-${clawId}`; // 运行时 hostname(无需文件权限) exec(`hostname ${hostname}`, { shell: true }); // 写 /etc/hostname(fs 直接写文件,不需要目录可写) try { fs.writeFileSync('/etc/hostname', hostname + '\n', 'utf8'); } catch (e) { log.warn('clawd', `write /etc/hostname failed: ${e.message}`); } // 更新 /etc/hosts(fs 读写,绕过 sed -i 需要目录可写的限制) try { const content = fs.readFileSync('/etc/hosts', 'utf8'); const updated = content.replace(/^127\.0\.1\.1.*/m, `127.0.1.1 ${hostname}`); fs.writeFileSync('/etc/hosts', updated, 'utf8'); log.info('clawd', `hostname -> ${hostname}`); } catch (e) { log.warn('clawd', `write /etc/hosts failed: ${e.message}`); } } // ── OpenClaw 配置 ──────────────────────────────────────────────────────────── _updateOpenClawOrigin(targetId) { const { readFileSync, writeFileSync } = require('fs'); const configFile = resolveOpenclawConfigFile(); if (!configFile) { log.warn('clawd', 'openclaw 配置文件不存在(~/.openclaw/openclaw.json 等候选路径均未找到)'); return; } try { const raw = readFileSync(configFile, 'utf8'); const config = JSON.parse(raw); const newOrigin = `https://${targetId}.claw.cutos.ai`; const re = /https:\/\/[^"'\s]+\.claw\.cutos\.ai/g; const hasAnyClawCutosAi = /https:\/\/[^"'\s]+\.claw\.cutos\.ai/.test(JSON.stringify(config)); /** 与原 YAML 全文替换等价:遍历 JSON 内所有字符串并替换匹配的 origin */ const replaceOriginStrings = (node) => { let changed = false; if (typeof node === 'string') { return false; } if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) { const v = node[i]; if (typeof v === 'string') { const next = v.replace(re, newOrigin); if (next !== v) { node[i] = next; changed = true; } } else if (v && typeof v === 'object') { changed = replaceOriginStrings(v) || changed; } } return changed; } if (node && typeof node === 'object') { for (const k of Object.keys(node)) { const v = node[k]; if (typeof v === 'string') { const next = v.replace(re, newOrigin); if (next !== v) { node[k] = next; changed = true; } } else if (v && typeof v === 'object') { changed = replaceOriginStrings(v) || changed; } } return changed; } return false; }; let changed; if (!hasAnyClawCutosAi) { config.gateway.controlUi = config.gateway.controlUi || {}; const port = config.gateway.port ?? 18789; config.gateway.controlUi.allowedOrigins = [`http://0.0.0.0:${port}`, newOrigin]; config.gateway.controlUi.allowInsecureAuth = true; config.gateway.controlUi.dangerouslyDisableDeviceAuth = true; changed = true; } else { changed = replaceOriginStrings(config); } if (!changed) { log.info('clawd', `openclaw origin 已是 ${newOrigin},无需变更`); return; } writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); log.info('clawd', `openclaw config 已更新: ${newOrigin}`); } catch (e) { log.warn('clawd', `openclaw config 更新失败: ${e.message}`); } } // ── 心跳 ──────────────────────────────────────────────────────────────────── _startHeartbeat() { this._clearHeartbeat(); this._sendHeartbeat(); this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS); } async _sendHeartbeat() { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; try { this._hbCount++; // 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息 if (this._hbCount % 30 === 0) { const freshInfo = await getDashboardInfo().catch(() => null); if (freshInfo && Object.keys(freshInfo).length > 0) { this._dashInfo = freshInfo; } } // 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳 const msg = { type: 'heartbeat', claw_id: this._cfg.claw_id, token: this._cfg.token, version: CLAWD_VERSION, local_ip: getLocalIps(), local_networks: getLocalNetworks(), ...this._dashInfo, }; if (this._hbCount % METRICS_EVERY_N === 0) { msg.metrics = await collect(); } this._send(msg); } catch (e) { log.error('clawd', '心跳发送失败:', e.message); } } _clearHeartbeat() { if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null; } } // ── 升级 ──────────────────────────────────────────────────────────────────── _sendUpgradeProgress(progress, step, failed = false, errorMsg = null) { const msg = { type: 'upgrade_progress', claw_id: this._cfg.claw_id, token: this._cfg.token, progress, step, failed, }; if (errorMsg) msg.error = errorMsg; this._send(msg); log.info('upgrade', `进度 ${progress}% - ${step}${failed ? ` [失败: ${errorMsg}]` : ''}`); } async _handleUpgrade(msg) { const targetVersion = msg.version; const installDir = path.dirname(__dirname); // /opt/clawd 或同等安装目录 const scriptPath = path.join(installDir, 'tools', 'update-clawd.sh'); log.info('upgrade', `收到升级命令: ${CLAWD_VERSION} → ${targetVersion}`); this._sendUpgradeProgress(5, 'starting'); try { // Step 1: Node.js 负责 git pull,拿到最新代码(含最新 update-clawd.sh) this._sendUpgradeProgress(20, '拉取更新中'); await new Promise((resolve, reject) => { const gitCmd = [ `git config --global --add safe.directory "${installDir}" 2>/dev/null || true`, `cd "${installDir}"`, `git remote get-url origin 2>/dev/null | grep -q github.com && git remote set-url origin https://git.cutos.ai/claw-daemon/clawd.git || true`, `git fetch origin`, `git reset --hard origin/main`, `git clean -fd`, ].join(' && '); const child = exec(gitCmd, { timeout: 120_000 }); child.stdout.on('data', d => log.info('upgrade', d.toString().trim())); child.stderr.on('data', d => log.warn('upgrade', d.toString().trim())); child.on('close', code => code === 0 ? resolve() : reject(new Error(`git pull 失败,退出码: ${code}`))); child.on('error', reject); }); this._sendUpgradeProgress(50, '更新文件中'); // Step 2: 调用磁盘上已更新的 update-clawd.sh --no-pull(跳过 git,直接写 service 文件等) if (!fs.existsSync(scriptPath)) { throw new Error(`升级脚本不存在: ${scriptPath}`); } await new Promise((resolve, reject) => { const child = exec(`bash "${scriptPath}" --no-pull --no-restart`, { timeout: 180_000 }); child.stdout.on('data', (data) => { const line = data.toString().trim(); log.info('upgrade', line); if (line.includes('npm install')) this._sendUpgradeProgress(70, '安装依赖中'); else if (line.includes('No depend')) this._sendUpgradeProgress(80, '无需安装依赖'); else if (line.includes('Writing serv')) this._sendUpgradeProgress(85, '更新服务配置'); else if (line.includes('Current comm')) this._sendUpgradeProgress(90, '即将重启'); }); child.stderr.on('data', d => log.warn('upgrade', d.toString().trim())); child.on('close', code => code === 0 ? resolve() : reject(new Error(`脚本退出码: ${code}`))); child.on('error', reject); }); // 通知服务端完成,延迟 1.5 秒确保消息送达,再退出让 systemd 重启 this._sendUpgradeProgress(100, 'done'); log.info('upgrade', `升级至 v${targetVersion} 完成,即将重启...`); setTimeout(() => process.exit(0), 1500); } catch (e) { log.error('upgrade', `升级失败: ${e.message}`); this._sendUpgradeProgress(0, 'failed', true, e.message); } } // ── 工具 ──────────────────────────────────────────────────────────────────── _send(obj) { if (this._ws && this._ws.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(obj)); } } // ── systemd Watchdog ──────────────────────────────────────────────────────── _startSdNotify() { const raw = getNotifySocket(); if (!raw) return; // 部分嵌入式 systemd 有 WatchdogSec 但未注入 WATCHDOG_USEC;若此时不喂狗,约 1min 会 SIGABRT let usec = parseInt(process.env.WATCHDOG_USEC || '0', 10); if (usec <= 0) { usec = 60_000_000; log.info('clawd', 'WATCHDOG_USEC 未设置,按 60s 周期向 systemd 发送 WATCHDOG=1(与 unit WatchdogSec=60 一致)'); } const intervalMs = Math.max(1000, Math.floor(usec / 2 / 1000)); log.debug('clawd', `systemd watchdog 启用,通知间隔 ${intervalMs}ms`); this._sdNotify('READY=1'); this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), intervalMs); } /** * 通过 systemd-notify 写入 NOTIFY_SOCKET。嵌入式上 Node unix_dgram 对抽象套接字常无效; * 子进程发 notify 需在 unit 中设置 NotifyAccess=all(install.sh 已写)。 */ _sdNotify(msg) { const sock = getNotifySocket(); if (!sock) return; const arg = String(msg).replace(/\n+$/, ''); if (!arg) return; try { execFileSync('systemd-notify', [arg], { timeout: 3000, encoding: 'utf8', env: { PATH: process.env.PATH || '/usr/bin:/bin', NOTIFY_SOCKET: sock, }, }); } catch (e) { log.warn('clawd', 'systemd-notify 失败:', e.message); } } } module.exports = { ClawClient };