Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5df8943a9 | ||
|
|
6488374da8 | ||
|
|
9ccdd73955 | ||
|
|
39d16d168c | ||
|
|
b53ef2a890 | ||
|
|
40ca8cd5af | ||
|
|
9489b334c5 | ||
|
|
782a8b6d88 | ||
|
|
586b978f81 |
@@ -104,9 +104,16 @@ node bin/clawd.js
|
||||
| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error |
|
||||
| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) |
|
||||
| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
|
||||
| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
|
||||
|
||||
systemd 安装后环境变量文件位于 `/etc/clawd/env`。
|
||||
| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
|
||||
| `AGENT_TYPE` | `openclaw` | Agent 类型:`openclaw` 或 `cutos-agent`;空值兼容为 `openclaw` |
|
||||
| `PI_MODELS_CONFIG` | `~/.pi/models.json` | CutOS Agent 的 Pi 模型配置路径 |
|
||||
|
||||
systemd 安装后环境变量文件位于 `/etc/clawd/env`。
|
||||
|
||||
切换 Agent 类型前,必须先在云端解绑设备,再修改 `/etc/clawd/env` 中的
|
||||
`AGENT_TYPE`,重启 `clawd` 后重新绑定。`cutos-agent` 模式会维护 Pi 的 `providers.cutos`
|
||||
模型配置,并将本地 `http://127.0.0.1:30141/` 控制台接入设备 Dashboard 子域名;它不会读写
|
||||
OpenClaw 配置、重启 OpenClaw Gateway 或调用 OpenClaw 微信能力。
|
||||
|
||||
## 服务管理
|
||||
|
||||
|
||||
+16
-13
@@ -11,12 +11,19 @@ const { ClawClient } = require('../lib/client');
|
||||
const config = require('../lib/config');
|
||||
const log = require('../lib/logger');
|
||||
const { pollSms } = require('../drivers/sim/sms-reader');
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
const { resolveAgentType } = require('../lib/agent-type');
|
||||
|
||||
try {
|
||||
resolveAgentType();
|
||||
} catch (err) {
|
||||
log.error('clawd', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 每次启动同步 3588s demo 到 /usr/bin/demo(idempotent,失败不影响主流程)
|
||||
const demoBin = path.join(__dirname, "..", "lib/resource/3588s/demo");
|
||||
const demoDst = "/usr/bin/demo";
|
||||
if (!IS_WINDOWS && fs.existsSync(demoBin)) {
|
||||
if (fs.existsSync(demoBin)) {
|
||||
exec(`install -m 0755 "${demoBin}" "${demoDst}"`, (err) => {
|
||||
if (err) log.warn("clawd", `demo sync failed: ${err.message}`);
|
||||
else log.info("clawd", "demo synced to /usr/bin/demo");
|
||||
@@ -25,16 +32,14 @@ if (!IS_WINDOWS && fs.existsSync(demoBin)) {
|
||||
|
||||
// 每次启动绑定 Quectel 串口驱动(失败不影响主流程)
|
||||
const bindScript = path.join(__dirname, '..', 'tools', 'bind-quectel-serial.sh');
|
||||
if (!IS_WINDOWS) {
|
||||
exec(`bash "${bindScript}"`, (err, stdout, stderr) => {
|
||||
if (err) log.warn('clawd', `bind-quectel-serial: ${stderr || err.message}`);
|
||||
else log.info('clawd', `bind-quectel-serial: ok`);
|
||||
});
|
||||
}
|
||||
exec(`bash "${bindScript}"`, (err, stdout, stderr) => {
|
||||
if (err) log.warn('clawd', `bind-quectel-serial: ${stderr || err.message}`);
|
||||
else log.info('clawd', `bind-quectel-serial: ok`);
|
||||
});
|
||||
|
||||
// 同步 Samba 共享密码(idempotent,失败不影响主流程)
|
||||
const cfg = config.load();
|
||||
if (!IS_WINDOWS && cfg.share_key) {
|
||||
if (cfg.share_key) {
|
||||
const shareKey = cfg.share_key.replace(/'/g, "'\\''");
|
||||
exec(`printf '%s\\n%s\\n' '${shareKey}' '${shareKey}' | smbpasswd -a sts -s 2>/dev/null`, (err) => {
|
||||
if (err) log.warn('clawd', `smbpasswd sync failed (samba not installed?): ${err.message}`);
|
||||
@@ -61,10 +66,8 @@ async function pollSmsSafe() {
|
||||
const client = new ClawClient();
|
||||
client.start();
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
pollSmsSafe();
|
||||
smsTimer = setInterval(pollSmsSafe, 15_000);
|
||||
}
|
||||
pollSmsSafe();
|
||||
smsTimer = setInterval(pollSmsSafe, 15_000);
|
||||
|
||||
let stopping = false;
|
||||
|
||||
|
||||
@@ -199,6 +199,10 @@ CLAWD_LOG_LEVEL=info
|
||||
CLAWD_LOG_FILE=1
|
||||
# Override server URL (default from config.json)
|
||||
# CLAWD_SERVER=wss://claw.cutos.ai/ws
|
||||
# Agent runtime: openclaw (default) or cutos-agent
|
||||
# AGENT_TYPE=openclaw
|
||||
# CutOS Agent Pi models config path
|
||||
# PI_MODELS_CONFIG=$HOME/.pi/agent/models.json
|
||||
# Enable Bluetooth monitor (bluetoothctl); disabled by default
|
||||
# CLAWD_ENABLE_BT=1
|
||||
# OpenVFD sysfs path (default: /sys/class/leds/openvfd)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const OPENCLAW = 'openclaw';
|
||||
const CUTOS_AGENT = 'cutos-agent';
|
||||
const SUPPORTED_AGENT_TYPES = new Set([OPENCLAW, CUTOS_AGENT]);
|
||||
|
||||
function resolveAgentType(raw = process.env.AGENT_TYPE) {
|
||||
const value = String(raw ?? '').trim().toLowerCase();
|
||||
const agentType = value || OPENCLAW;
|
||||
if (!SUPPORTED_AGENT_TYPES.has(agentType)) {
|
||||
throw new Error(`AGENT_TYPE must be one of: ${[...SUPPORTED_AGENT_TYPES].join(', ')}; received: ${raw}`);
|
||||
}
|
||||
return agentType;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OPENCLAW,
|
||||
CUTOS_AGENT,
|
||||
SUPPORTED_AGENT_TYPES,
|
||||
resolveAgentType,
|
||||
};
|
||||
+29
-32
@@ -17,9 +17,10 @@ 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 piProvider = require('./pi-provider');
|
||||
const sysCall = require('./sys-call');
|
||||
const led = require('./led');
|
||||
const { IS_WINDOWS } = require('./platform-paths');
|
||||
const { OPENCLAW, resolveAgentType } = require('./agent-type');
|
||||
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
/** 连续若干轮 ping 后仍无 pong 才判定死链(单轮易因调度/弱网误判) */
|
||||
@@ -55,6 +56,7 @@ function btMonitorEnabled() {
|
||||
class ClawClient {
|
||||
constructor() {
|
||||
this._cfg = config.load();
|
||||
this._agentType = resolveAgentType();
|
||||
this._boxId = getBoxId();
|
||||
this._ws = null;
|
||||
this._hbTimer = null;
|
||||
@@ -105,7 +107,7 @@ class ClawClient {
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async start() {
|
||||
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
|
||||
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}, agent = ${this._agentType}`);
|
||||
|
||||
if (this._cfg.claw_id) {
|
||||
this._setHostname(this._cfg.claw_id);
|
||||
@@ -126,7 +128,7 @@ class ClawClient {
|
||||
led.lan.start();
|
||||
|
||||
// 蓝牙状态监控(bluetoothctl);默认不启用,见 btMonitorEnabled()
|
||||
if (!IS_WINDOWS && btMonitorEnabled()) {
|
||||
if (btMonitorEnabled()) {
|
||||
this._btMonitor = new BtMonitor();
|
||||
this._btMonitor.start();
|
||||
} else {
|
||||
@@ -194,7 +196,7 @@ class ClawClient {
|
||||
|
||||
async _proceedWithConnection() {
|
||||
const [dashInfo] = await Promise.all([
|
||||
getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
|
||||
getDashboardInfo(this._agentType).catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
|
||||
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
|
||||
]);
|
||||
this._dashInfo = dashInfo || {};
|
||||
@@ -318,8 +320,7 @@ class ClawClient {
|
||||
this._wsFailCount++;
|
||||
log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`);
|
||||
if (this._hasEverConnected && this._wsFailCount >= 3) {
|
||||
if (IS_WINDOWS) led.display.showErr0();
|
||||
else led.display.showAP();
|
||||
led.display.showAP();
|
||||
}
|
||||
if (this._certTimeError) {
|
||||
// NTP 未同步:固定 5s 重试,等时钟校正
|
||||
@@ -379,6 +380,7 @@ class ClawClient {
|
||||
_sendConnect() {
|
||||
const msg = {
|
||||
type: 'connect',
|
||||
agent_type: this._agentType,
|
||||
box_id: this._boxId,
|
||||
claw_id: this._cfg.claw_id ?? null,
|
||||
token: this._cfg.token ?? null,
|
||||
@@ -390,16 +392,6 @@ class ClawClient {
|
||||
local_networks: getLocalNetworks(),
|
||||
external_ip: this._externalIp ?? null,
|
||||
location: this._location ?? null,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
device_model: IS_WINDOWS ? 'windows-x86' : null,
|
||||
capabilities: IS_WINDOWS ? {
|
||||
wifi_provisioning: false,
|
||||
display: 'terminal',
|
||||
dashboard_proxy: true,
|
||||
terminal_proxy: false,
|
||||
ap_mode: false,
|
||||
} : undefined,
|
||||
...this._dashInfo,
|
||||
};
|
||||
this._send(msg);
|
||||
@@ -428,7 +420,9 @@ class ClawClient {
|
||||
break;
|
||||
case 'error':
|
||||
log.error('clawd', `服务器错误: ${msg.msg}`);
|
||||
if (msg.msg === 'hardware_mismatch') {
|
||||
if (msg.msg && msg.msg.includes('agent_type_mismatch')) {
|
||||
log.error('clawd', `Agent 类型切换被拒绝:请先在云端解绑设备,再设置 AGENT_TYPE=${this._agentType} 并重启 clawd`);
|
||||
} else if (msg.msg === 'hardware_mismatch') {
|
||||
log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
|
||||
this._cfg.claw_id = null;
|
||||
this._cfg.token = null;
|
||||
@@ -464,7 +458,7 @@ class ClawClient {
|
||||
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 => {
|
||||
this._frpc.start(msg.claw_id, msg.frp, this._cfg.ssh_secret_key ?? null, true).catch(e => {
|
||||
log.error('frpc', '启动失败:', e.message);
|
||||
});
|
||||
}
|
||||
@@ -487,7 +481,8 @@ class ClawClient {
|
||||
_applyStatus(msg) {
|
||||
if (msg.status === 'inactive') {
|
||||
if (msg.provider && msg.provider.name) {
|
||||
removeProviderByName(String(msg.provider.name));
|
||||
if (this._isOpenClaw()) removeProviderByName(String(msg.provider.name));
|
||||
else piProvider.removeProviderByName(String(msg.provider.name));
|
||||
}
|
||||
this._cfg.activated = false;
|
||||
config.save(this._cfg);
|
||||
@@ -503,7 +498,7 @@ class ClawClient {
|
||||
log.info('clawd', '╚════════════════════════════════════╝');
|
||||
log.info('clawd', '');
|
||||
log.info('clawd', '等待激活,心跳正常运行...');
|
||||
this._updateOpenClawOrigin('0000');
|
||||
if (this._isOpenClaw()) this._updateOpenClawOrigin('0000');
|
||||
} else {
|
||||
this._cfg.activated = true;
|
||||
config.save(this._cfg);
|
||||
@@ -511,7 +506,13 @@ class ClawClient {
|
||||
led.display.showTime();
|
||||
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
||||
const clawIdStr = String(this._cfg.claw_id);
|
||||
if (isFullProvider(msg.provider)) {
|
||||
if (!this._isOpenClaw()) {
|
||||
if (piProvider.isFullProvider(msg.provider)) {
|
||||
piProvider.applyFullProviderFromVps(msg.provider);
|
||||
} else {
|
||||
piProvider.refreshModelsIfChanged();
|
||||
}
|
||||
} else if (isFullProvider(msg.provider)) {
|
||||
applyFullProviderFromVps(msg.provider, () => {
|
||||
this._updateOpenClawOrigin(clawIdStr);
|
||||
});
|
||||
@@ -527,11 +528,6 @@ class ClawClient {
|
||||
// ── Hostname ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_setHostname(clawId) {
|
||||
if (IS_WINDOWS) {
|
||||
log.info('clawd', `Windows/x86 mode: skip hostname change for claw-${clawId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = `claw-${clawId}`;
|
||||
|
||||
// 运行时 hostname(无需文件权限)
|
||||
@@ -558,6 +554,7 @@ class ClawClient {
|
||||
// ── OpenClaw 配置 ────────────────────────────────────────────────────────────
|
||||
|
||||
_updateOpenClawOrigin(targetId) {
|
||||
if (!this._isOpenClaw()) return;
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
const configFile = resolveOpenclawConfigFile();
|
||||
|
||||
@@ -650,7 +647,7 @@ class ClawClient {
|
||||
this._hbCount++;
|
||||
|
||||
// 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
|
||||
if (this._hbCount % 30 === 0) {
|
||||
if (this._isOpenClaw() && this._hbCount % 30 === 0) {
|
||||
const freshInfo = await getDashboardInfo().catch(() => null);
|
||||
if (freshInfo && Object.keys(freshInfo).length > 0) {
|
||||
this._dashInfo = freshInfo;
|
||||
@@ -660,6 +657,7 @@ class ClawClient {
|
||||
// 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳
|
||||
const msg = {
|
||||
type: 'heartbeat',
|
||||
agent_type: this._agentType,
|
||||
claw_id: this._cfg.claw_id,
|
||||
token: this._cfg.token,
|
||||
version: CLAWD_VERSION,
|
||||
@@ -683,6 +681,10 @@ class ClawClient {
|
||||
}
|
||||
}
|
||||
|
||||
_isOpenClaw() {
|
||||
return this._agentType === OPENCLAW;
|
||||
}
|
||||
|
||||
// ── 升级 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_sendUpgradeProgress(progress, step, failed = false, errorMsg = null) {
|
||||
@@ -700,11 +702,6 @@ class ClawClient {
|
||||
}
|
||||
|
||||
async _handleUpgrade(msg) {
|
||||
if (IS_WINDOWS) {
|
||||
this._sendUpgradeProgress(0, 'failed', true, 'Windows/x86 self-upgrade is not implemented yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetVersion = msg.version;
|
||||
const installDir = path.dirname(__dirname); // /opt/clawd 或同等安装目录
|
||||
const scriptPath = path.join(installDir, 'tools', 'update-clawd.sh');
|
||||
|
||||
+5
-2
@@ -2,10 +2,13 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getConfigDir } = require('./platform-paths');
|
||||
const os = require('os');
|
||||
|
||||
// 生产环境用 /etc/clawd/,开发环境用 ~/.clawd/
|
||||
const CONFIG_DIR = getConfigDir();
|
||||
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');
|
||||
|
||||
|
||||
+1
-57
@@ -4,8 +4,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const { getPersistentFile } = require('./platform-paths');
|
||||
|
||||
/**
|
||||
* 生成硬件唯一指纹作为 box_id。
|
||||
@@ -21,59 +19,7 @@ const { getPersistentFile } = require('./platform-paths');
|
||||
* 有线 MAC 适用于嵌入式设备(网卡焊在主板,由固件烧录,不会更换)。
|
||||
*/
|
||||
|
||||
const PERSIST_FILE = getPersistentFile('.box_id');
|
||||
|
||||
function readCommand(cmd, opts = {}) {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
timeout: opts.timeout || 5000,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowsMachineGuid() {
|
||||
const out = readCommand('reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid');
|
||||
const m = out && out.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/i);
|
||||
return m ? m[1].trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function getWindowsBiosUuid() {
|
||||
const ps = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"';
|
||||
const uuid = readCommand(ps);
|
||||
if (!uuid) return null;
|
||||
const clean = uuid.trim().toLowerCase();
|
||||
if (clean === '00000000-0000-0000-0000-000000000000') return null;
|
||||
if (clean === 'ffffffff-ffff-ffff-ffff-ffffffffffff') return null;
|
||||
return clean.replace(/-/g, '');
|
||||
}
|
||||
|
||||
function getWindowsMac() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const macs = [];
|
||||
for (const addrs of Object.values(ifaces)) {
|
||||
for (const addr of addrs || []) {
|
||||
const mac = String(addr.mac || '').replace(/:/g, '').toLowerCase();
|
||||
if (mac && mac.length === 12 && mac !== '000000000000') macs.push(mac);
|
||||
}
|
||||
}
|
||||
return macs.sort()[0] || null;
|
||||
}
|
||||
|
||||
function getWindowsBoxId() {
|
||||
const machineGuid = getWindowsMachineGuid();
|
||||
const biosUuid = getWindowsBiosUuid();
|
||||
const mac = getWindowsMac();
|
||||
if (machineGuid || biosUuid || mac) {
|
||||
const raw = [machineGuid || '', biosUuid || '', mac || ''].join(':');
|
||||
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
||||
}
|
||||
return getPersistentUUID();
|
||||
}
|
||||
const PERSIST_FILE = '/etc/clawd/.box_id';
|
||||
|
||||
// ── 1. /etc/machine-id ───────────────────────────────────────────────────────
|
||||
function getMachineId() {
|
||||
@@ -173,8 +119,6 @@ function getPersistentUUID() {
|
||||
|
||||
// ── 主函数 ────────────────────────────────────────────────────────────────────
|
||||
function getBoxId() {
|
||||
if (process.platform === 'win32') return getWindowsBoxId();
|
||||
|
||||
const machineId = getMachineId();
|
||||
const cpuSerial = getCpuSerial();
|
||||
const ethMac = getEthMac();
|
||||
|
||||
+47
-46
@@ -1,20 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
const { execSync, execFileSync, spawn } = require('child_process');
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const log = require('./logger');
|
||||
const { Watchdog } = require('./watchdog');
|
||||
const { IS_WINDOWS, getConfigDir, getOpenClawConfigCandidates } = require('./platform-paths');
|
||||
const { CUTOS_AGENT } = require('./agent-type');
|
||||
|
||||
const CONFIG_DIR = getConfigDir();
|
||||
const FRPC_BIN = path.join(CONFIG_DIR, IS_WINDOWS ? 'frpc.exe' : 'frpc');
|
||||
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|
||||
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
|
||||
const FRPC_BIN = path.join(CONFIG_DIR, 'frpc');
|
||||
const FRPC_CONFIG = path.join(CONFIG_DIR, 'frpc.toml');
|
||||
|
||||
const FRP_VERSION = '0.62.0';
|
||||
const TTYD_PORT = 7681;
|
||||
const CUTOS_DASHBOARD_PORT = 30141;
|
||||
|
||||
function findTtydBin() {
|
||||
const candidates = ['/usr/bin/ttyd', '/usr/local/bin/ttyd', path.join(CONFIG_DIR, 'ttyd')];
|
||||
@@ -26,7 +28,9 @@ function findTtydBin() {
|
||||
|
||||
/** openclaw 持久化配置(JSON),结构与原 YAML 解析结果一致。 */
|
||||
const OPENCLAW_JSON_CANDIDATES = [
|
||||
...getOpenClawConfigCandidates(),
|
||||
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
||||
'/home/sts/.openclaw/openclaw.json',
|
||||
'/root/.openclaw/openclaw.json',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -47,7 +51,10 @@ function resolveOpenclawConfigFile() {
|
||||
* 直接读取比执行命令更可靠(不依赖 PATH、不需要进程启动等待)。
|
||||
* systemd 服务的 ProtectHome=read-only 允许读取 /home 下的文件。
|
||||
*/
|
||||
function getDashboardInfo() {
|
||||
function getDashboardInfo(agentType) {
|
||||
if (agentType === CUTOS_AGENT) {
|
||||
return Promise.resolve({ dashboard_port: CUTOS_DASHBOARD_PORT });
|
||||
}
|
||||
for (const cfgPath of OPENCLAW_JSON_CANDIDATES) {
|
||||
try {
|
||||
const raw = fs.readFileSync(cfgPath, 'utf8');
|
||||
@@ -75,37 +82,23 @@ async function downloadFrpc() {
|
||||
};
|
||||
const frpArch = archMap[arch] || 'amd64';
|
||||
|
||||
const ext = IS_WINDOWS ? 'zip' : 'tar.gz';
|
||||
const filename = `frp_${FRP_VERSION}_${platform}_${frpArch}.${ext}`;
|
||||
const filename = `frp_${FRP_VERSION}_${platform}_${frpArch}.tar.gz`;
|
||||
const releaseBase = 'https://git.cutos.ai/claw-daemon/fatedier/releases/download';
|
||||
const attachmentMap = {
|
||||
'linux/arm64': 'https://git.cutos.ai/attachments/071748ed-955b-44c0-8dfb-38396b5dae6e',
|
||||
};
|
||||
const url = attachmentMap[`${platform}/${frpArch}`] || `${releaseBase}/v${FRP_VERSION}/${filename}`;
|
||||
const tmpFile = path.join(os.tmpdir(), filename);
|
||||
const tmpFile = `/tmp/${filename}`;
|
||||
|
||||
log.info('frpc', `下载 frpc ${FRP_VERSION} (${platform}/${frpArch})...`);
|
||||
|
||||
await downloadFile(url, tmpFile);
|
||||
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
const packageDir = `frp_${FRP_VERSION}_${platform}_${frpArch}`;
|
||||
if (IS_WINDOWS) {
|
||||
const extractDir = path.join(os.tmpdir(), `clawd-frp-${FRP_VERSION}-${Date.now()}`);
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
execFileSync('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-Command',
|
||||
`Expand-Archive -Force -LiteralPath '${tmpFile.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}'`,
|
||||
], { stdio: 'inherit', windowsHide: true });
|
||||
fs.copyFileSync(path.join(extractDir, packageDir, 'frpc.exe'), FRPC_BIN);
|
||||
} else {
|
||||
execSync(`tar -xzf ${tmpFile} -C /tmp && cp /tmp/${packageDir}/frpc ${FRPC_BIN}`, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
fs.chmodSync(FRPC_BIN, 0o755);
|
||||
}
|
||||
execSync(`tar -xzf ${tmpFile} -C /tmp && cp /tmp/frp_${FRP_VERSION}_${platform}_${frpArch}/frpc ${FRPC_BIN}`, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
fs.chmodSync(FRPC_BIN, 0o755);
|
||||
log.info('frpc', `frpc 已安装到 ${FRPC_BIN}`);
|
||||
}
|
||||
|
||||
@@ -115,11 +108,6 @@ async function downloadFrpc() {
|
||||
* ttyd 绑定 127.0.0.1:7681,供 frpc 代理。
|
||||
*/
|
||||
async function startTtyd() {
|
||||
if (IS_WINDOWS) {
|
||||
log.info('ttyd', 'Windows/x86 mode: terminal proxy is disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
const ttydBin = findTtydBin();
|
||||
if (!ttydBin) {
|
||||
log.warn('ttyd', '未找到 ttyd,请重新运行 install.sh');
|
||||
@@ -164,23 +152,35 @@ function downloadFile(url, dest) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeFrpcConfig(clawId, frpConfig, sshSecretKey) {
|
||||
const { auth_token, dashboard_local_port = 18789 } = frpConfig;
|
||||
function writeFrpcConfig(clawId, frpConfig, sshSecretKey, dashboardEnabled = true) {
|
||||
const { auth_token, dashboard_local_port = 18789, dashboard_host_header } = frpConfig;
|
||||
const ttyRemotePort = 10000 + Number(clawId);
|
||||
const stcpBlock = (!IS_WINDOWS && sshSecretKey) ? `
|
||||
const dashboardHostHeader = String(dashboard_host_header || '').trim();
|
||||
const dashboardHostHeaderLine = dashboardHostHeader
|
||||
? `hostHeaderRewrite = "${dashboardHostHeader.replace(/"/g, '\\"')}"`
|
||||
: '';
|
||||
const stcpBlock = sshSecretKey ? `
|
||||
[[proxies]]
|
||||
name = "ssh-${clawId}-secret"
|
||||
type = "stcp"
|
||||
secretKey = "${sshSecretKey}"
|
||||
localPort = 22
|
||||
` : '';
|
||||
const ttyBlock = IS_WINDOWS ? '' : `
|
||||
const ttyBlock = `
|
||||
[[proxies]]
|
||||
name = "tty-${clawId}"
|
||||
type = "tcp"
|
||||
localPort = ${TTYD_PORT}
|
||||
remotePort = ${ttyRemotePort}
|
||||
`;
|
||||
const dashboardBlock = dashboardEnabled ? `
|
||||
[[proxies]]
|
||||
name = "dashboard-${clawId}"
|
||||
type = "http"
|
||||
localPort = ${dashboard_local_port}
|
||||
subdomain = "${clawId}"
|
||||
${dashboardHostHeaderLine}
|
||||
` : '';
|
||||
const toml = `# 由 clawd 自动生成,请勿手动修改
|
||||
serverAddr = "frp.claw.cutos.ai"
|
||||
serverPort = 443
|
||||
@@ -191,16 +191,10 @@ token = "${auth_token}"
|
||||
|
||||
[transport]
|
||||
tls.enable = true
|
||||
|
||||
[[proxies]]
|
||||
name = "dashboard-${clawId}"
|
||||
type = "http"
|
||||
localPort = ${dashboard_local_port}
|
||||
subdomain = "${clawId}"
|
||||
${ttyBlock}${stcpBlock}`;
|
||||
${dashboardBlock}${ttyBlock}${stcpBlock}`;
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
|
||||
log.info('frpc', `frpc.toml 已写入: dashboard subdomain=${clawId}${IS_WINDOWS ? '' : `, tty tcp-port=${ttyRemotePort}`}${stcpBlock ? ', ssh stcp=enabled' : ''}`);
|
||||
log.info('frpc', `frpc.toml 已写入: dashboard=${dashboardEnabled ? `subdomain ${clawId}` : 'disabled'}, tty tcp-port=${ttyRemotePort}${stcpBlock ? ', ssh stcp=enabled' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,7 +206,7 @@ class FrpcManager {
|
||||
this._watchdog = null;
|
||||
}
|
||||
|
||||
async start(clawId, frpConfig, sshSecretKey) {
|
||||
async start(clawId, frpConfig, sshSecretKey, dashboardEnabled = true) {
|
||||
this.stop();
|
||||
|
||||
if (!fs.existsSync(FRPC_BIN)) {
|
||||
@@ -224,7 +218,7 @@ class FrpcManager {
|
||||
}
|
||||
}
|
||||
|
||||
writeFrpcConfig(clawId, frpConfig, sshSecretKey);
|
||||
writeFrpcConfig(clawId, frpConfig, sshSecretKey, dashboardEnabled);
|
||||
|
||||
this._watchdog = new Watchdog('frpc', FRPC_BIN, ['-c', FRPC_CONFIG], {
|
||||
maxRestarts: 10,
|
||||
@@ -242,4 +236,11 @@ class FrpcManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager };
|
||||
module.exports = {
|
||||
CUTOS_DASHBOARD_PORT,
|
||||
getDashboardInfo,
|
||||
resolveOpenclawConfigFile,
|
||||
startTtyd,
|
||||
writeFrpcConfig,
|
||||
FrpcManager,
|
||||
};
|
||||
|
||||
@@ -4,11 +4,6 @@ const log = require('./logger');
|
||||
const { isRK3566, isRK3588, readDeviceModel } = require('./led/detect');
|
||||
|
||||
function loadImpl() {
|
||||
if (process.platform === 'win32') {
|
||||
log.info('led', 'LED/VFD backend -> terminal (Windows/x86)');
|
||||
return require('./led/terminal');
|
||||
}
|
||||
|
||||
const forced = String(process.env.CLAWD_LED_IMPL || '').trim().toLowerCase();
|
||||
const model = readDeviceModel();
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const log = require('../logger');
|
||||
|
||||
function box(lines) {
|
||||
const width = Math.max(...lines.map((line) => line.length), 28);
|
||||
const top = `+${'-'.repeat(width + 2)}+`;
|
||||
const body = lines.map((line) => `| ${line.padEnd(width)} |`);
|
||||
return ['', top, ...body, top, ''].join('\n');
|
||||
}
|
||||
|
||||
class BasicLed {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this._current = null;
|
||||
}
|
||||
on() { this._set('on'); }
|
||||
off() { this._set('off'); }
|
||||
blink() { this._set('blink'); }
|
||||
destroy() { this._set('off'); }
|
||||
_set(next) {
|
||||
if (this._current === next) return;
|
||||
this._current = next;
|
||||
log.debug('led', `[terminal] ${this.name} ${next}`);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusLed {
|
||||
setSetup() { log.info('status', 'SETUP / waiting for activation'); }
|
||||
setApps() { log.info('status', 'APPS / activated'); }
|
||||
off() { log.debug('status', 'off'); }
|
||||
}
|
||||
|
||||
class Display {
|
||||
showAP() {
|
||||
log.info('display', box([
|
||||
'Claw Daemon',
|
||||
'Waiting for network',
|
||||
'Windows/x86 has no AP mode',
|
||||
]));
|
||||
}
|
||||
|
||||
showConn() {
|
||||
log.info('display', box([
|
||||
'Claw Daemon',
|
||||
'Connecting to claw cloud...',
|
||||
]));
|
||||
}
|
||||
|
||||
showErr0() {
|
||||
log.info('display', box([
|
||||
'Claw Daemon',
|
||||
'Connection error',
|
||||
]));
|
||||
}
|
||||
|
||||
showTime() {
|
||||
log.info('display', box([
|
||||
'Claw Daemon',
|
||||
'Running on Windows/x86',
|
||||
]));
|
||||
}
|
||||
|
||||
showPin(pin) {
|
||||
const s = String(pin || '').trim();
|
||||
log.info('display', box([
|
||||
'Claw Daemon Activation',
|
||||
`PIN: ${s}`,
|
||||
'Enter this PIN in the web console',
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class LanLed {
|
||||
start() { log.debug('led', '[terminal] LAN monitor ignored'); }
|
||||
stop() { log.debug('led', '[terminal] LAN monitor stopped'); }
|
||||
}
|
||||
|
||||
const led = new BasicLed('network');
|
||||
led.bt = new BasicLed('bt');
|
||||
led.status = new StatusLed();
|
||||
led.display = new Display();
|
||||
led.lan = new LanLed();
|
||||
|
||||
module.exports = led;
|
||||
+3
-2
@@ -2,11 +2,12 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getConfigDir } = require('./platform-paths');
|
||||
const os = require('os');
|
||||
|
||||
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
|
||||
const CONFIG_DIR = getConfigDir();
|
||||
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|
||||
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
|
||||
|
||||
const LOG_DIR = process.env.CLAWD_LOG_DIR || path.join(CONFIG_DIR, 'logs');
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
+38
-82
@@ -1,10 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
const { execSync, spawnSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const log = require('./logger');
|
||||
const { IS_WINDOWS } = require('./platform-paths');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const log = require('./logger');
|
||||
|
||||
const AP_SSID_PREFIX = 'ClawBox-';
|
||||
const AP_IP = '10.42.0.1';
|
||||
@@ -69,13 +68,8 @@ function _firstScanWiredIfaceWithCarrier() {
|
||||
* 返回当前可用于「有线 ping / 路由」的网卡名。
|
||||
* 优先级:CLAWD_ETH_IFACE → 存在 end0 则只用 end0 → 否则扫描 sysfs。
|
||||
*/
|
||||
function getWiredIfaceWithCarrier() {
|
||||
if (IS_WINDOWS) {
|
||||
const entry = _localNetworkEntries()[0];
|
||||
return entry ? entry.iface : null;
|
||||
}
|
||||
|
||||
const explicit = process.env.CLAWD_ETH_IFACE;
|
||||
function getWiredIfaceWithCarrier() {
|
||||
const explicit = process.env.CLAWD_ETH_IFACE;
|
||||
if (explicit) {
|
||||
return _netIfaceExists(explicit) && _sysfsCarrierUp(explicit) ? explicit : null;
|
||||
}
|
||||
@@ -85,18 +79,16 @@ function getWiredIfaceWithCarrier() {
|
||||
return _firstScanWiredIfaceWithCarrier();
|
||||
}
|
||||
|
||||
function hasWiredCarrier() {
|
||||
if (IS_WINDOWS) return _localNetworkEntries().length > 0;
|
||||
return getWiredIfaceWithCarrier() !== null;
|
||||
}
|
||||
function hasWiredCarrier() {
|
||||
return getWiredIfaceWithCarrier() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LAN 面板灯:只反映 RJ45 对应口,与 `cat /sys/class/net/end0/carrier 2>/dev/null` 同源(仅读 carrier)。
|
||||
* 若配置的接口在 sysfs 中不存在(常见为开发机无 end0),则退回与 hasWiredCarrier() 一致,避免灯永远灭。
|
||||
*/
|
||||
function hasLanCableCarrier() {
|
||||
if (IS_WINDOWS) return hasWiredCarrier();
|
||||
const iface = _ethIfaceEnvOrDefault();
|
||||
function hasLanCableCarrier() {
|
||||
const iface = _ethIfaceEnvOrDefault();
|
||||
if (_netIfaceExists(iface)) return _sysfsCarrierUp(iface);
|
||||
return hasWiredCarrier();
|
||||
}
|
||||
@@ -122,19 +114,16 @@ function _tryPingWiredInternet() {
|
||||
/**
|
||||
* 仅经有线口 ping 公网(不依赖默认路由)。
|
||||
*/
|
||||
function hasWiredInternetProbe() {
|
||||
if (IS_WINDOWS) return hasInternet();
|
||||
return _tryPingWiredInternet();
|
||||
}
|
||||
function hasWiredInternetProbe() {
|
||||
return _tryPingWiredInternet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否有真实互联网连接。
|
||||
* 注意:NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。
|
||||
*/
|
||||
function hasInternet() {
|
||||
if (IS_WINDOWS) return _localNetworkEntries().length > 0;
|
||||
|
||||
const wifiSta = isWifiStaConnected();
|
||||
function hasInternet() {
|
||||
const wifiSta = isWifiStaConnected();
|
||||
const wired = getWiredIfaceWithCarrier();
|
||||
|
||||
// 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 false(nmcli 有缓存,不可信)
|
||||
@@ -154,9 +143,8 @@ function hasInternet() {
|
||||
* 获取默认 WiFi 接口名(wlan0 等)。
|
||||
* 必须 TYPE 精确为 wifi,不能用 grep wifi(会误匹配 wifi-p2p,导致选到 p2p-dev-wlan0,STA/热点均失败)。
|
||||
*/
|
||||
function getWifiIface() {
|
||||
if (IS_WINDOWS) return '';
|
||||
if (AP_IFACE) return AP_IFACE;
|
||||
function getWifiIface() {
|
||||
if (AP_IFACE) return AP_IFACE;
|
||||
try {
|
||||
const out = run('nmcli -t -f DEVICE,TYPE device');
|
||||
let fallback = '';
|
||||
@@ -185,9 +173,8 @@ function getWifiIface() {
|
||||
/**
|
||||
* 扫描周围 WiFi,返回 [{ ssid, signal, security }]
|
||||
*/
|
||||
function scanWifi() {
|
||||
if (IS_WINDOWS) return [];
|
||||
const iface = getWifiIface();
|
||||
function scanWifi() {
|
||||
const iface = getWifiIface();
|
||||
try {
|
||||
// 先触发一次扫描
|
||||
try { run(`nmcli device wifi rescan ifname ${iface}`); } catch (_) {}
|
||||
@@ -278,11 +265,7 @@ function nmcliAsync(args, timeoutMs = 60000) {
|
||||
* 必须异步:同步 spawnSync + execSync(sleep) 会卡住主线程,导致 systemd WatchdogSec 内收不到 WATCHDOG=1。
|
||||
* @returns {Promise<{ success: boolean, error?: string }>}
|
||||
*/
|
||||
async function connectWifi(ssid, password) {
|
||||
if (IS_WINDOWS) {
|
||||
return { success: false, error: 'Windows/x86 clawd does not manage WiFi credentials' };
|
||||
}
|
||||
|
||||
async function connectWifi(ssid, password) {
|
||||
cancelHotspotRadioRetry(`准备连接 WiFi: ${ssid}`);
|
||||
const iface = getWifiIface();
|
||||
log.info('network', `尝试连接 WiFi: ${ssid}(ifname=${iface})`);
|
||||
@@ -538,11 +521,7 @@ function _activateHotspot(ssid, iface, timeoutMs = 8000) {
|
||||
/**
|
||||
* 启动 WiFi AP 热点
|
||||
*/
|
||||
function startAP(clawId) {
|
||||
if (IS_WINDOWS) {
|
||||
throw new Error('Windows/x86 clawd does not support AP provisioning');
|
||||
}
|
||||
|
||||
function startAP(clawId) {
|
||||
const iface = getWifiIface();
|
||||
const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`;
|
||||
|
||||
@@ -629,9 +608,7 @@ function _parseNmcliTerseLine(line) {
|
||||
/**
|
||||
* 列出已保存的 WiFi STA 连接(排除自身热点),按 autoconnect-priority 从高到低排序。
|
||||
*/
|
||||
function listSavedWifiConnections() {
|
||||
if (IS_WINDOWS) return [];
|
||||
|
||||
function listSavedWifiConnections() {
|
||||
const profiles = [];
|
||||
try {
|
||||
const out = run('nmcli -t -f NAME,UUID,TYPE,AUTOCONNECT,AUTOCONNECT-PRIORITY connection show');
|
||||
@@ -686,11 +663,7 @@ async function _ensureActiveWifiAutoconnect() {
|
||||
* 主动让 NetworkManager 尝试已保存 WiFi。
|
||||
* clawd 只做调度;真正的认证、DHCP、重连细节仍交给 NM。
|
||||
*/
|
||||
async function connectSavedWifiConnections() {
|
||||
if (IS_WINDOWS) {
|
||||
return { success: false, error: 'Windows/x86 clawd does not manage saved WiFi profiles' };
|
||||
}
|
||||
|
||||
async function connectSavedWifiConnections() {
|
||||
cancelHotspotRadioRetry('准备连接已保存 WiFi');
|
||||
const iface = getWifiIface();
|
||||
const profiles = listSavedWifiConnections();
|
||||
@@ -728,9 +701,7 @@ async function connectSavedWifiConnections() {
|
||||
* 是否已以 STA 连上某 WiFi(排除自身热点)。
|
||||
* 不用 device 列表按 `:` 拆字段(连接名含冒号会错;state 含 connecting 勿误匹配 connected)。
|
||||
*/
|
||||
function isWifiStaConnected() {
|
||||
if (IS_WINDOWS) return false;
|
||||
|
||||
function isWifiStaConnected() {
|
||||
const iface = getWifiIface();
|
||||
let state;
|
||||
let conn;
|
||||
@@ -747,37 +718,22 @@ function isWifiStaConnected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function _ifaceNetworkType(name) {
|
||||
if (IS_WINDOWS) {
|
||||
return /wi-?fi|wlan|wireless/i.test(name) ? 'wifi' : 'lan';
|
||||
}
|
||||
|
||||
function _ifaceNetworkType(name) {
|
||||
const wifi = getWifiIface();
|
||||
if (name === wifi || name.startsWith('wl')) return 'wifi';
|
||||
if (name === DEFAULT_ETH_IFACE || name.startsWith('en') || name.startsWith('eth')) return 'lan';
|
||||
return null;
|
||||
}
|
||||
|
||||
function _isWindowsExcludedIface(name) {
|
||||
return /tailscale|zerotier|vethernet|virtual|vmware|virtualbox|docker|wsl|hyper-v|loopback|npcap|meta/i.test(name);
|
||||
}
|
||||
|
||||
function _isWindowsExcludedAddress(ip) {
|
||||
return /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)
|
||||
|| /^198\.(18|19)\./.test(ip);
|
||||
}
|
||||
|
||||
function _localNetworkEntries() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const entries = [];
|
||||
for (const [name, addrs] of Object.entries(ifaces)) {
|
||||
if (!addrs) continue;
|
||||
if (IS_WINDOWS && _isWindowsExcludedIface(name)) continue;
|
||||
const type = _ifaceNetworkType(name);
|
||||
if (!type) continue;
|
||||
for (const addr of addrs) {
|
||||
if (addr.family !== 'IPv4' || addr.internal) continue;
|
||||
if (IS_WINDOWS && _isWindowsExcludedAddress(addr.address)) continue;
|
||||
if (name === DEFAULT_ETH_IFACE || name.startsWith('en') || name.startsWith('eth')) return 'lan';
|
||||
return null;
|
||||
}
|
||||
|
||||
function _localNetworkEntries() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const entries = [];
|
||||
for (const [name, addrs] of Object.entries(ifaces)) {
|
||||
if (!addrs) continue;
|
||||
const type = _ifaceNetworkType(name);
|
||||
if (!type) continue;
|
||||
for (const addr of addrs) {
|
||||
if (addr.family !== 'IPv4' || addr.internal) continue;
|
||||
// clawd-hotspot 的 AP 管理网段只用于配网,不上报为 BOX 可访问地址。
|
||||
if (addr.address.startsWith('10.42.')) continue;
|
||||
entries.push({ ip: addr.address, type, iface: name });
|
||||
|
||||
@@ -5,10 +5,9 @@ const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
const log = require('./logger');
|
||||
const { resolveOpenclawConfigFile } = require('./frpc');
|
||||
const { IS_WINDOWS } = require('./platform-paths');
|
||||
const { exec } = require('child_process');
|
||||
const log = require('./logger');
|
||||
const { resolveOpenclawConfigFile } = require('./frpc');
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.cutos.ai/v1';
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
@@ -90,21 +89,8 @@ function writeJsonFile(filePath, obj) {
|
||||
* 每次写盘 openclaw.json 成功后应调用一次。
|
||||
* 使用异步 exec,不阻塞 Node.js 事件循环,避免干扰 LED / VFD 等后续操作。
|
||||
*/
|
||||
function restartGateway() {
|
||||
if (IS_WINDOWS) {
|
||||
exec('taskkill /IM openclaw-gateway.exe /F', (err) => {
|
||||
if (err && err.code !== 128 && err.code !== 1) {
|
||||
log.warn('openclaw-provider', `restartGateway: ${err.message}`);
|
||||
} else if (!err) {
|
||||
log.info('openclaw-provider', 'openclaw-gateway.exe stopped; waiting for supervisor to restart it');
|
||||
} else {
|
||||
log.info('openclaw-provider', 'openclaw-gateway.exe is not running');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
exec('pkill -9 -x openclaw-gateway', (err) => {
|
||||
function restartGateway() {
|
||||
exec('pkill -9 -x openclaw-gateway', (err) => {
|
||||
if (err && err.code !== 1) {
|
||||
log.warn('openclaw-provider', `restartGateway: ${err.message}`);
|
||||
} else if (!err) {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const log = require('./logger');
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.cutos.ai/v1';
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
let busy = false;
|
||||
let operationId = 0;
|
||||
|
||||
function resolvePiModelsConfigFile(env = process.env) {
|
||||
const configured = String(env.PI_MODELS_CONFIG || '').trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
|
||||
const candidates = [];
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
candidates.push('/home/sts/.pi/agent/models.json');
|
||||
}
|
||||
candidates.push(path.join(os.homedir(), '.pi', 'agent', 'models.json'));
|
||||
candidates.push('/root/.pi/agent/models.json');
|
||||
|
||||
return candidates.find((candidate) => {
|
||||
try { return fs.existsSync(path.dirname(candidate)) || fs.existsSync(candidate); } catch (_) { return false; }
|
||||
}) || candidates[0];
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
let value = String(baseUrl || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
|
||||
if (!/\/v1$/i.test(value)) value = `${value}/v1`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function fetchModels(baseUrl, apiKey, callback) {
|
||||
const url = `${normalizeBaseUrl(baseUrl)}/models`;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (_) {
|
||||
callback(new Error(`invalid base-url: ${url}`));
|
||||
return;
|
||||
}
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
const req = transport.request({
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${apiKey || ''}`, 'Content-Type': 'application/json' },
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
if (!Array.isArray(payload.data)) {
|
||||
callback(new Error(payload.error?.message || `bad models response: ${body.slice(0, 200)}`));
|
||||
return;
|
||||
}
|
||||
callback(null, payload.data
|
||||
.filter((model) => model && typeof model.id === 'string' && model.id)
|
||||
.map((model) => ({ id: model.id, reasoning: true })));
|
||||
} catch (error) {
|
||||
callback(new Error(`parse models: ${error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', callback);
|
||||
req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy(new Error('models request timeout')));
|
||||
req.end();
|
||||
}
|
||||
|
||||
function readConfig(configFile) {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('root must be a JSON object');
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return { providers: {} };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfigAtomic(configFile, config) {
|
||||
const dir = path.dirname(configFile);
|
||||
const owner = resolveOwnerForPath(dir);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
chownIfPossible(dir, owner);
|
||||
const tempFile = path.join(dir, `.${path.basename(configFile)}.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
fs.writeFileSync(tempFile, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(tempFile, configFile);
|
||||
chownIfPossible(configFile, owner);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tempFile); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOwnerForPath(targetPath) {
|
||||
let current = targetPath;
|
||||
while (current && current !== path.dirname(current)) {
|
||||
try {
|
||||
const stat = fs.statSync(current);
|
||||
return { uid: stat.uid, gid: stat.gid };
|
||||
} catch (_) {
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function chownIfPossible(targetPath, owner) {
|
||||
if (!owner || !(process.getuid && process.getuid() === 0)) return;
|
||||
try {
|
||||
fs.chownSync(targetPath, owner.uid, owner.gid);
|
||||
} catch (error) {
|
||||
log.warn('pi-provider', `chown failed for ${targetPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sameModels(left, right) {
|
||||
const ids = (models) => (models || []).map((model) => model.id).sort();
|
||||
return JSON.stringify(ids(left)) === JSON.stringify(ids(right));
|
||||
}
|
||||
|
||||
function isFullProvider(provider) {
|
||||
return !!provider && typeof provider.name === 'string' && provider.name.length > 0
|
||||
&& (Object.prototype.hasOwnProperty.call(provider, 'base-url')
|
||||
|| Object.prototype.hasOwnProperty.call(provider, 'baseUrl'));
|
||||
}
|
||||
|
||||
function applyFullProviderFromVps(provider, onDone, options = {}) {
|
||||
if (busy) {
|
||||
log.warn('pi-provider', 'provider operation already in progress; skipping apply');
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
if (!isFullProvider(provider)) {
|
||||
log.warn('pi-provider', 'apply: invalid provider payload');
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
const configFile = options.configFile || resolvePiModelsConfigFile();
|
||||
const fetch = options.fetchModels || fetchModels;
|
||||
const name = provider.name;
|
||||
const baseUrl = normalizeBaseUrl(provider['base-url'] || provider.baseUrl);
|
||||
const apiKey = provider['api-key'] != null ? String(provider['api-key']) : '';
|
||||
const currentOperation = ++operationId;
|
||||
busy = true;
|
||||
fetch(baseUrl, apiKey, (fetchError, fetchedModels) => {
|
||||
try {
|
||||
if (currentOperation !== operationId) return;
|
||||
const config = readConfig(configFile);
|
||||
if (!config.providers || typeof config.providers !== 'object' || Array.isArray(config.providers)) config.providers = {};
|
||||
const existing = config.providers[name] || {};
|
||||
const models = fetchError ? (Array.isArray(existing.models) ? existing.models : []) : fetchedModels;
|
||||
if (fetchError) log.warn('pi-provider', `model refresh failed; retaining existing list: ${fetchError.message}`);
|
||||
const next = { baseUrl, api: 'openai-responses', apiKey, models };
|
||||
if (JSON.stringify(existing) !== JSON.stringify(next)) {
|
||||
config.providers[name] = next;
|
||||
writeConfigAtomic(configFile, config);
|
||||
log.info('pi-provider', `provider updated: ${name} (${models.length} models)`);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('pi-provider', `apply failed: ${error.message}`);
|
||||
} finally {
|
||||
if (currentOperation === operationId) busy = false;
|
||||
if (typeof onDone === 'function') onDone();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeProviderByName(providerName, options = {}) {
|
||||
const name = String(providerName || '');
|
||||
if (!name) return;
|
||||
operationId += 1;
|
||||
busy = false;
|
||||
const configFile = options.configFile || resolvePiModelsConfigFile();
|
||||
let config;
|
||||
try { config = readConfig(configFile); } catch (error) {
|
||||
log.warn('pi-provider', `remove failed: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (!config.providers || !Object.prototype.hasOwnProperty.call(config.providers, name)) return;
|
||||
delete config.providers[name];
|
||||
writeConfigAtomic(configFile, config);
|
||||
log.info('pi-provider', `provider removed: ${name}`);
|
||||
}
|
||||
|
||||
function refreshModelsIfChanged(onDone, options = {}) {
|
||||
if (busy) {
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
const configFile = options.configFile || resolvePiModelsConfigFile();
|
||||
let config;
|
||||
try { config = readConfig(configFile); } catch (error) {
|
||||
log.warn('pi-provider', `refresh failed: ${error.message}`);
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
const name = Object.keys(config.providers || {})[0];
|
||||
if (!name) {
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
const current = config.providers[name];
|
||||
const fetch = options.fetchModels || fetchModels;
|
||||
const currentOperation = ++operationId;
|
||||
busy = true;
|
||||
fetch(current.baseUrl, current.apiKey, (error, models) => {
|
||||
try {
|
||||
if (currentOperation !== operationId) return;
|
||||
if (error) log.warn('pi-provider', `model refresh failed: ${error.message}`);
|
||||
else if (!sameModels(current.models, models)) {
|
||||
current.models = models;
|
||||
writeConfigAtomic(configFile, config);
|
||||
log.info('pi-provider', `model list updated: ${name} (${models.length} models)`);
|
||||
}
|
||||
} catch (writeError) {
|
||||
log.error('pi-provider', `refresh failed: ${writeError.message}`);
|
||||
} finally {
|
||||
if (currentOperation === operationId) busy = false;
|
||||
if (typeof onDone === 'function') onDone();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyFullProviderFromVps,
|
||||
fetchModels,
|
||||
isFullProvider,
|
||||
normalizeBaseUrl,
|
||||
refreshModelsIfChanged,
|
||||
removeProviderByName,
|
||||
resolvePiModelsConfigFile,
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
function windowsProgramData() {
|
||||
return process.env.PROGRAMDATA || 'C:\\ProgramData';
|
||||
}
|
||||
|
||||
function getConfigDir() {
|
||||
if (process.env.CLAWD_CONFIG_DIR) return process.env.CLAWD_CONFIG_DIR;
|
||||
if (IS_WINDOWS) return path.join(windowsProgramData(), 'OpenClaw', 'clawd');
|
||||
return process.getuid && process.getuid() === 0
|
||||
? '/etc/clawd'
|
||||
: path.join(os.homedir(), '.clawd');
|
||||
}
|
||||
|
||||
function getPersistentFile(name) {
|
||||
return path.join(getConfigDir(), name);
|
||||
}
|
||||
|
||||
function getOpenClawConfigCandidates() {
|
||||
if (IS_WINDOWS) {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
||||
path.join(windowsProgramData(), 'OpenClaw', 'openclaw.json'),
|
||||
path.join(windowsProgramData(), 'OpenClaw', 'config', 'openclaw.json'),
|
||||
];
|
||||
if (process.env.OPENCLAW_CONFIG) candidates.unshift(process.env.OPENCLAW_CONFIG);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
return [
|
||||
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
||||
'/home/sts/.openclaw/openclaw.json',
|
||||
'/root/.openclaw/openclaw.json',
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
IS_WINDOWS,
|
||||
getConfigDir,
|
||||
getPersistentFile,
|
||||
getOpenClawConfigCandidates,
|
||||
};
|
||||
@@ -6,7 +6,6 @@ const { hasInternet, hasWiredInternetProbe, hasSavedWifiConnection, connectSaved
|
||||
const { DnsHijack } = require('./dns-hijack');
|
||||
const { CaptiveServer } = require('./captive-server');
|
||||
const led = require('./led');
|
||||
const { IS_WINDOWS } = require('./platform-paths');
|
||||
|
||||
const MONITOR_INTERVAL_MS = 15_000;
|
||||
const WIFI_RECONNECT_MAX_ROUNDS = 3;
|
||||
@@ -41,16 +40,6 @@ class ProvisionManager extends EventEmitter {
|
||||
isApMode() { return this._state === 'ap'; }
|
||||
|
||||
async start() {
|
||||
if (IS_WINDOWS) {
|
||||
led.off();
|
||||
this._state = hasInternet() ? 'wired' : 'idle';
|
||||
log.info('provision', 'Windows/x86 mode: AP provisioning is disabled; waiting for existing network');
|
||||
if (this._state === 'wired') this._emitNetworkReady();
|
||||
else led.display.showAP();
|
||||
this._startMonitor();
|
||||
return;
|
||||
}
|
||||
|
||||
led.off(); // WiFi 灯初始状态:熄灭
|
||||
|
||||
// WiFi STA 已连接 → 直接进入 STA 模式
|
||||
@@ -239,19 +228,6 @@ class ProvisionManager extends EventEmitter {
|
||||
}
|
||||
|
||||
async _monitorTick() {
|
||||
if (IS_WINDOWS) {
|
||||
if (hasInternet()) {
|
||||
if (this._state !== 'wired') {
|
||||
this._state = 'wired';
|
||||
this._emitNetworkReady();
|
||||
}
|
||||
} else if (this._state !== 'idle') {
|
||||
this._state = 'idle';
|
||||
led.display.showAP();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state === 'connecting') return;
|
||||
|
||||
const wifiUp = isWifiStaConnected();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
const log = require('./logger');
|
||||
const { OPENCLAW, resolveAgentType } = require('./agent-type');
|
||||
|
||||
// ── channel handlers ──────────────────────────────────────────────────────────
|
||||
const handlers = {
|
||||
@@ -39,6 +40,15 @@ function handle(msg, send) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (api === 'channel.weixin' && resolveAgentType() !== OPENCLAW) {
|
||||
send({
|
||||
id: callId, api, method,
|
||||
action: 'finish', event: 'failed',
|
||||
code: 404, message: `api unavailable for agent type: ${resolveAgentType()}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── cancel ────────────────────────────────────────────────────────────────
|
||||
if (action === 'cancel') {
|
||||
const task = running.get(callId);
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "clawd",
|
||||
"version": "1.5.7",
|
||||
"version": "1.6.0",
|
||||
"description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket",
|
||||
"main": "lib/client.js",
|
||||
"bin": {
|
||||
"clawd": "./bin/clawd.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node bin/clawd.js"
|
||||
"start": "node bin/clawd.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"claw",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { resolveAgentType } = require('../lib/agent-type');
|
||||
|
||||
test('missing and blank AGENT_TYPE default to openclaw', () => {
|
||||
assert.equal(resolveAgentType(undefined), 'openclaw');
|
||||
assert.equal(resolveAgentType(''), 'openclaw');
|
||||
assert.equal(resolveAgentType(' '), 'openclaw');
|
||||
});
|
||||
|
||||
test('supported AGENT_TYPE values are normalized', () => {
|
||||
assert.equal(resolveAgentType('OPENCLAW'), 'openclaw');
|
||||
assert.equal(resolveAgentType(' cutos-agent '), 'cutos-agent');
|
||||
});
|
||||
|
||||
test('unsupported AGENT_TYPE fails fast', () => {
|
||||
assert.throws(() => resolveAgentType('other'), /AGENT_TYPE must be one of/);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { CUTOS_DASHBOARD_PORT, getDashboardInfo } = require('../lib/frpc');
|
||||
|
||||
test('CutOS Agent dashboard uses the fixed local console port without a token', async () => {
|
||||
assert.equal(CUTOS_DASHBOARD_PORT, 30141);
|
||||
assert.deepEqual(await getDashboardInfo('cutos-agent'), { dashboard_port: 30141 });
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clawd-frpc-'));
|
||||
process.env.CLAWD_CONFIG_DIR = configDir;
|
||||
|
||||
const { writeFrpcConfig } = require('../lib/frpc');
|
||||
|
||||
test('dashboard proxy can rewrite Host header for CutOS pi-web', () => {
|
||||
writeFrpcConfig(1045, {
|
||||
auth_token: 'test-token',
|
||||
dashboard_local_port: 30141,
|
||||
dashboard_host_header: '127.0.0.1',
|
||||
}, null, true);
|
||||
|
||||
const toml = fs.readFileSync(path.join(configDir, 'frpc.toml'), 'utf8');
|
||||
assert.match(toml, /localPort = 30141/);
|
||||
assert.match(toml, /subdomain = "1045"/);
|
||||
assert.match(toml, /hostHeaderRewrite = "127\.0\.0\.1"/);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const piProvider = require('../lib/pi-provider');
|
||||
|
||||
function tempConfig() {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'clawd-pi-')), 'models.json');
|
||||
}
|
||||
|
||||
function apply(provider, options) {
|
||||
return new Promise((resolve) => piProvider.applyFullProviderFromVps(provider, resolve, options));
|
||||
}
|
||||
|
||||
function refresh(options) {
|
||||
return new Promise((resolve) => piProvider.refreshModelsIfChanged(resolve, options));
|
||||
}
|
||||
|
||||
function read(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: 'cutos',
|
||||
'base-url': 'https://api.example.test',
|
||||
'api-key': 'test-key',
|
||||
};
|
||||
|
||||
test('defaults to Pi agent models config path', () => {
|
||||
assert.equal(
|
||||
piProvider.resolvePiModelsConfigFile({}),
|
||||
path.join(os.homedir(), '.pi', 'agent', 'models.json'),
|
||||
);
|
||||
});
|
||||
|
||||
test('allows PI_MODELS_CONFIG to override Pi models config path', () => {
|
||||
const customPath = path.join(os.tmpdir(), 'custom-pi-models.json');
|
||||
assert.equal(piProvider.resolvePiModelsConfigFile({ PI_MODELS_CONFIG: customPath }), path.resolve(customPath));
|
||||
});
|
||||
|
||||
test('creates a Pi models file and maps models to reasoning entries', async () => {
|
||||
const configFile = tempConfig();
|
||||
await apply(payload, {
|
||||
configFile,
|
||||
fetchModels: (_url, _key, done) => done(null, [
|
||||
{ id: 'gpt-test', reasoning: true },
|
||||
{ id: 'glm-test', reasoning: true },
|
||||
]),
|
||||
});
|
||||
assert.deepEqual(read(configFile), {
|
||||
providers: {
|
||||
cutos: {
|
||||
baseUrl: 'https://api.example.test/v1',
|
||||
api: 'openai-responses',
|
||||
apiKey: 'test-key',
|
||||
models: [
|
||||
{ id: 'gpt-test', reasoning: true },
|
||||
{ id: 'glm-test', reasoning: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves unrelated top-level fields and providers when upserting', async () => {
|
||||
const configFile = tempConfig();
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
theme: 'dark',
|
||||
providers: { custom: { baseUrl: 'http://localhost:1234' } },
|
||||
}));
|
||||
await apply(payload, {
|
||||
configFile,
|
||||
fetchModels: (_url, _key, done) => done(null, [{ id: 'gpt-test', reasoning: true }]),
|
||||
});
|
||||
const config = read(configFile);
|
||||
assert.equal(config.theme, 'dark');
|
||||
assert.deepEqual(config.providers.custom, { baseUrl: 'http://localhost:1234' });
|
||||
assert.equal(config.providers.cutos.api, 'openai-responses');
|
||||
});
|
||||
|
||||
test('retains existing models when model refresh fails while updating credentials', async () => {
|
||||
const configFile = tempConfig();
|
||||
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {
|
||||
baseUrl: 'https://old.example/v1',
|
||||
api: 'openai-responses',
|
||||
apiKey: 'old-key',
|
||||
models: [{ id: 'existing-model', reasoning: true }],
|
||||
} } }));
|
||||
await apply(payload, {
|
||||
configFile,
|
||||
fetchModels: (_url, _key, done) => done(new Error('offline')),
|
||||
});
|
||||
const provider = read(configFile).providers.cutos;
|
||||
assert.equal(provider.apiKey, 'test-key');
|
||||
assert.deepEqual(provider.models, [{ id: 'existing-model', reasoning: true }]);
|
||||
});
|
||||
|
||||
test('removes only the named provider on unbind', () => {
|
||||
const configFile = tempConfig();
|
||||
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {}, custom: { keep: true } } }));
|
||||
piProvider.removeProviderByName('cutos', { configFile });
|
||||
assert.deepEqual(read(configFile), { providers: { custom: { keep: true } } });
|
||||
});
|
||||
|
||||
test('unbind cancels an in-flight provider write', async () => {
|
||||
const configFile = tempConfig();
|
||||
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {}, custom: { keep: true } } }));
|
||||
let finishFetch;
|
||||
const pending = apply(payload, {
|
||||
configFile,
|
||||
fetchModels: (_url, _key, done) => { finishFetch = done; },
|
||||
});
|
||||
piProvider.removeProviderByName('cutos', { configFile });
|
||||
finishFetch(null, [{ id: 'late-model', reasoning: true }]);
|
||||
await pending;
|
||||
assert.deepEqual(read(configFile), { providers: { custom: { keep: true } } });
|
||||
});
|
||||
|
||||
test('refresh updates models only when their ids change', async () => {
|
||||
const configFile = tempConfig();
|
||||
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {
|
||||
baseUrl: 'https://api.example.test/v1', api: 'openai-responses', apiKey: 'key',
|
||||
models: [{ id: 'old', reasoning: true }],
|
||||
} } }));
|
||||
await refresh({
|
||||
configFile,
|
||||
fetchModels: (_url, _key, done) => done(null, [{ id: 'new', reasoning: true }]),
|
||||
});
|
||||
assert.deepEqual(read(configFile).providers.cutos.models, [{ id: 'new', reasoning: true }]);
|
||||
});
|
||||
|
||||
test('fetchModels reads OpenAI model responses without exposing the key', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
assert.equal(req.url, '/v1/models');
|
||||
assert.equal(req.headers.authorization, 'Bearer private-test-key');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ data: [{ id: 'model-a' }, { id: 'model-b' }] }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const models = await new Promise((resolve, reject) => {
|
||||
piProvider.fetchModels(`http://127.0.0.1:${address.port}`, 'private-test-key', (error, result) => {
|
||||
if (error) reject(error); else resolve(result);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(models, [
|
||||
{ id: 'model-a', reasoning: true },
|
||||
{ id: 'model-b', reasoning: true },
|
||||
]);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user