Compare commits

..
1 Commits
Author SHA1 Message Date
yankun 67c995db83 feat: add simplified windows x86 support 2026-06-27 18:33:59 +08:00
22 changed files with 419 additions and 645 deletions
-7
View File
@@ -105,16 +105,9 @@ node bin/clawd.js
| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald |
| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
| `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 微信能力。
## 服务管理
```bash
+7 -10
View File
@@ -11,19 +11,12 @@ const { ClawClient } = require('../lib/client');
const config = require('../lib/config');
const log = require('../lib/logger');
const { pollSms } = require('../drivers/sim/sms-reader');
const { resolveAgentType } = require('../lib/agent-type');
try {
resolveAgentType();
} catch (err) {
log.error('clawd', err.message);
process.exit(1);
}
const IS_WINDOWS = process.platform === 'win32';
// 每次启动同步 3588s demo 到 /usr/bin/demoidempotent,失败不影响主流程)
const demoBin = path.join(__dirname, "..", "lib/resource/3588s/demo");
const demoDst = "/usr/bin/demo";
if (fs.existsSync(demoBin)) {
if (!IS_WINDOWS && 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");
@@ -32,14 +25,16 @@ if (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`);
});
}
// 同步 Samba 共享密码(idempotent,失败不影响主流程)
const cfg = config.load();
if (cfg.share_key) {
if (!IS_WINDOWS && 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}`);
@@ -66,8 +61,10 @@ async function pollSmsSafe() {
const client = new ClawClient();
client.start();
if (!IS_WINDOWS) {
pollSmsSafe();
smsTimer = setInterval(pollSmsSafe, 15_000);
}
let stopping = false;
-4
View File
@@ -199,10 +199,6 @@ 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)
-21
View File
@@ -1,21 +0,0 @@
'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,
};
+32 -29
View File
@@ -17,10 +17,9 @@ 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 { OPENCLAW, resolveAgentType } = require('./agent-type');
const { IS_WINDOWS } = require('./platform-paths');
const MAX_BACKOFF_MS = 60_000;
/** 连续若干轮 ping 后仍无 pong 才判定死链(单轮易因调度/弱网误判) */
@@ -56,7 +55,6 @@ function btMonitorEnabled() {
class ClawClient {
constructor() {
this._cfg = config.load();
this._agentType = resolveAgentType();
this._boxId = getBoxId();
this._ws = null;
this._hbTimer = null;
@@ -107,7 +105,7 @@ class ClawClient {
// ── 生命周期 ─────────────────────────────────────────────────────────────────
async start() {
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}, agent = ${this._agentType}`);
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
if (this._cfg.claw_id) {
this._setHostname(this._cfg.claw_id);
@@ -128,7 +126,7 @@ class ClawClient {
led.lan.start();
// 蓝牙状态监控(bluetoothctl);默认不启用,见 btMonitorEnabled()
if (btMonitorEnabled()) {
if (!IS_WINDOWS && btMonitorEnabled()) {
this._btMonitor = new BtMonitor();
this._btMonitor.start();
} else {
@@ -196,7 +194,7 @@ class ClawClient {
async _proceedWithConnection() {
const [dashInfo] = await Promise.all([
getDashboardInfo(this._agentType).catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
]);
this._dashInfo = dashInfo || {};
@@ -320,7 +318,8 @@ class ClawClient {
this._wsFailCount++;
log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount}${this._backoff / 1000}s 后重连...`);
if (this._hasEverConnected && this._wsFailCount >= 3) {
led.display.showAP();
if (IS_WINDOWS) led.display.showErr0();
else led.display.showAP();
}
if (this._certTimeError) {
// NTP 未同步:固定 5s 重试,等时钟校正
@@ -380,7 +379,6 @@ 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,
@@ -392,6 +390,16 @@ 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);
@@ -420,9 +428,7 @@ class ClawClient {
break;
case 'error':
log.error('clawd', `服务器错误: ${msg.msg}`);
if (msg.msg && msg.msg.includes('agent_type_mismatch')) {
log.error('clawd', `Agent 类型切换被拒绝:请先在云端解绑设备,再设置 AGENT_TYPE=${this._agentType} 并重启 clawd`);
} else if (msg.msg === 'hardware_mismatch') {
if (msg.msg === 'hardware_mismatch') {
log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
this._cfg.claw_id = null;
this._cfg.token = null;
@@ -458,7 +464,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, true).catch(e => {
this._frpc.start(msg.claw_id, msg.frp, this._cfg.ssh_secret_key ?? null).catch(e => {
log.error('frpc', '启动失败:', e.message);
});
}
@@ -481,8 +487,7 @@ class ClawClient {
_applyStatus(msg) {
if (msg.status === 'inactive') {
if (msg.provider && msg.provider.name) {
if (this._isOpenClaw()) removeProviderByName(String(msg.provider.name));
else piProvider.removeProviderByName(String(msg.provider.name));
removeProviderByName(String(msg.provider.name));
}
this._cfg.activated = false;
config.save(this._cfg);
@@ -498,7 +503,7 @@ class ClawClient {
log.info('clawd', '╚════════════════════════════════════╝');
log.info('clawd', '');
log.info('clawd', '等待激活,心跳正常运行...');
if (this._isOpenClaw()) this._updateOpenClawOrigin('0000');
this._updateOpenClawOrigin('0000');
} else {
this._cfg.activated = true;
config.save(this._cfg);
@@ -506,13 +511,7 @@ class ClawClient {
led.display.showTime();
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
const clawIdStr = String(this._cfg.claw_id);
if (!this._isOpenClaw()) {
if (piProvider.isFullProvider(msg.provider)) {
piProvider.applyFullProviderFromVps(msg.provider);
} else {
piProvider.refreshModelsIfChanged();
}
} else if (isFullProvider(msg.provider)) {
if (isFullProvider(msg.provider)) {
applyFullProviderFromVps(msg.provider, () => {
this._updateOpenClawOrigin(clawIdStr);
});
@@ -528,6 +527,11 @@ 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(无需文件权限)
@@ -554,7 +558,6 @@ class ClawClient {
// ── OpenClaw 配置 ────────────────────────────────────────────────────────────
_updateOpenClawOrigin(targetId) {
if (!this._isOpenClaw()) return;
const { readFileSync, writeFileSync } = require('fs');
const configFile = resolveOpenclawConfigFile();
@@ -647,7 +650,7 @@ class ClawClient {
this._hbCount++;
// 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
if (this._isOpenClaw() && this._hbCount % 30 === 0) {
if (this._hbCount % 30 === 0) {
const freshInfo = await getDashboardInfo().catch(() => null);
if (freshInfo && Object.keys(freshInfo).length > 0) {
this._dashInfo = freshInfo;
@@ -657,7 +660,6 @@ 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,
@@ -681,10 +683,6 @@ class ClawClient {
}
}
_isOpenClaw() {
return this._agentType === OPENCLAW;
}
// ── 升级 ────────────────────────────────────────────────────────────────────
_sendUpgradeProgress(progress, step, failed = false, errorMsg = null) {
@@ -702,6 +700,11 @@ 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');
+2 -5
View File
@@ -2,13 +2,10 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getConfigDir } = require('./platform-paths');
// 生产环境用 /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_DIR = getConfigDir();
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
+57 -1
View File
@@ -4,6 +4,8 @@ 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。
@@ -19,7 +21,59 @@ const { execSync } = require('child_process');
* 有线 MAC 适用于嵌入式设备(网卡焊在主板,由固件烧录,不会更换)。
*/
const PERSIST_FILE = '/etc/clawd/.box_id';
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();
}
// ── 1. /etc/machine-id ───────────────────────────────────────────────────────
function getMachineId() {
@@ -119,6 +173,8 @@ function getPersistentUUID() {
// ── 主函数 ────────────────────────────────────────────────────────────────────
function getBoxId() {
if (process.platform === 'win32') return getWindowsBoxId();
const machineId = getMachineId();
const cpuSerial = getCpuSerial();
const ethMac = getEthMac();
+43 -44
View File
@@ -1,22 +1,20 @@
'use strict';
const { execSync, spawn } = require('child_process');
const { execSync, execFileSync, 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 { CUTOS_AGENT } = require('./agent-type');
const { IS_WINDOWS, getConfigDir, getOpenClawConfigCandidates } = require('./platform-paths');
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 CONFIG_DIR = getConfigDir();
const FRPC_BIN = path.join(CONFIG_DIR, IS_WINDOWS ? 'frpc.exe' : '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')];
@@ -28,9 +26,7 @@ function findTtydBin() {
/** openclaw 持久化配置(JSON),结构与原 YAML 解析结果一致。 */
const OPENCLAW_JSON_CANDIDATES = [
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
'/home/sts/.openclaw/openclaw.json',
'/root/.openclaw/openclaw.json',
...getOpenClawConfigCandidates(),
];
/**
@@ -51,10 +47,7 @@ function resolveOpenclawConfigFile() {
* 直接读取比执行命令更可靠(不依赖 PATH、不需要进程启动等待)。
* systemd 服务的 ProtectHome=read-only 允许读取 /home 下的文件。
*/
function getDashboardInfo(agentType) {
if (agentType === CUTOS_AGENT) {
return Promise.resolve({ dashboard_port: CUTOS_DASHBOARD_PORT });
}
function getDashboardInfo() {
for (const cfgPath of OPENCLAW_JSON_CANDIDATES) {
try {
const raw = fs.readFileSync(cfgPath, 'utf8');
@@ -82,23 +75,37 @@ async function downloadFrpc() {
};
const frpArch = archMap[arch] || 'amd64';
const filename = `frp_${FRP_VERSION}_${platform}_${frpArch}.tar.gz`;
const ext = IS_WINDOWS ? 'zip' : 'tar.gz';
const filename = `frp_${FRP_VERSION}_${platform}_${frpArch}.${ext}`;
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 = `/tmp/${filename}`;
const tmpFile = path.join(os.tmpdir(), filename);
log.info('frpc', `下载 frpc ${FRP_VERSION} (${platform}/${frpArch})...`);
await downloadFile(url, tmpFile);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
execSync(`tar -xzf ${tmpFile} -C /tmp && cp /tmp/frp_${FRP_VERSION}_${platform}_${frpArch}/frpc ${FRPC_BIN}`, {
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);
}
log.info('frpc', `frpc 已安装到 ${FRPC_BIN}`);
}
@@ -108,6 +115,11 @@ 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');
@@ -152,35 +164,23 @@ function downloadFile(url, dest) {
});
}
function writeFrpcConfig(clawId, frpConfig, sshSecretKey, dashboardEnabled = true) {
const { auth_token, dashboard_local_port = 18789, dashboard_host_header } = frpConfig;
function writeFrpcConfig(clawId, frpConfig, sshSecretKey) {
const { auth_token, dashboard_local_port = 18789 } = frpConfig;
const ttyRemotePort = 10000 + Number(clawId);
const dashboardHostHeader = String(dashboard_host_header || '').trim();
const dashboardHostHeaderLine = dashboardHostHeader
? `hostHeaderRewrite = "${dashboardHostHeader.replace(/"/g, '\\"')}"`
: '';
const stcpBlock = sshSecretKey ? `
const stcpBlock = (!IS_WINDOWS && sshSecretKey) ? `
[[proxies]]
name = "ssh-${clawId}-secret"
type = "stcp"
secretKey = "${sshSecretKey}"
localPort = 22
` : '';
const ttyBlock = `
const ttyBlock = IS_WINDOWS ? '' : `
[[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,10 +191,16 @@ token = "${auth_token}"
[transport]
tls.enable = true
${dashboardBlock}${ttyBlock}${stcpBlock}`;
[[proxies]]
name = "dashboard-${clawId}"
type = "http"
localPort = ${dashboard_local_port}
subdomain = "${clawId}"
${ttyBlock}${stcpBlock}`;
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
log.info('frpc', `frpc.toml 已写入: dashboard=${dashboardEnabled ? `subdomain ${clawId}` : 'disabled'}, tty tcp-port=${ttyRemotePort}${stcpBlock ? ', ssh stcp=enabled' : ''}`);
log.info('frpc', `frpc.toml 已写入: dashboard subdomain=${clawId}${IS_WINDOWS ? '' : `, tty tcp-port=${ttyRemotePort}`}${stcpBlock ? ', ssh stcp=enabled' : ''}`);
}
/**
@@ -206,7 +212,7 @@ class FrpcManager {
this._watchdog = null;
}
async start(clawId, frpConfig, sshSecretKey, dashboardEnabled = true) {
async start(clawId, frpConfig, sshSecretKey) {
this.stop();
if (!fs.existsSync(FRPC_BIN)) {
@@ -218,7 +224,7 @@ class FrpcManager {
}
}
writeFrpcConfig(clawId, frpConfig, sshSecretKey, dashboardEnabled);
writeFrpcConfig(clawId, frpConfig, sshSecretKey);
this._watchdog = new Watchdog('frpc', FRPC_BIN, ['-c', FRPC_CONFIG], {
maxRestarts: 10,
@@ -236,11 +242,4 @@ class FrpcManager {
}
}
module.exports = {
CUTOS_DASHBOARD_PORT,
getDashboardInfo,
resolveOpenclawConfigFile,
startTtyd,
writeFrpcConfig,
FrpcManager,
};
module.exports = { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager };
+5
View File
@@ -4,6 +4,11 @@ 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();
+85
View File
@@ -0,0 +1,85 @@
'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;
+2 -3
View File
@@ -2,12 +2,11 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getConfigDir } = require('./platform-paths');
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
const CONFIG_DIR = getConfigDir();
const LOG_DIR = process.env.CLAWD_LOG_DIR || path.join(CONFIG_DIR, 'logs');
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
+44
View File
@@ -4,6 +4,7 @@ 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 AP_SSID_PREFIX = 'ClawBox-';
const AP_IP = '10.42.0.1';
@@ -69,6 +70,11 @@ function _firstScanWiredIfaceWithCarrier() {
* 优先级: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;
if (explicit) {
return _netIfaceExists(explicit) && _sysfsCarrierUp(explicit) ? explicit : null;
@@ -80,6 +86,7 @@ function getWiredIfaceWithCarrier() {
}
function hasWiredCarrier() {
if (IS_WINDOWS) return _localNetworkEntries().length > 0;
return getWiredIfaceWithCarrier() !== null;
}
@@ -88,6 +95,7 @@ function hasWiredCarrier() {
* 若配置的接口在 sysfs 中不存在(常见为开发机无 end0),则退回与 hasWiredCarrier() 一致,避免灯永远灭。
*/
function hasLanCableCarrier() {
if (IS_WINDOWS) return hasWiredCarrier();
const iface = _ethIfaceEnvOrDefault();
if (_netIfaceExists(iface)) return _sysfsCarrierUp(iface);
return hasWiredCarrier();
@@ -115,6 +123,7 @@ function _tryPingWiredInternet() {
* 仅经有线口 ping 公网(不依赖默认路由)。
*/
function hasWiredInternetProbe() {
if (IS_WINDOWS) return hasInternet();
return _tryPingWiredInternet();
}
@@ -123,6 +132,8 @@ function hasWiredInternetProbe() {
* 注意:NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。
*/
function hasInternet() {
if (IS_WINDOWS) return _localNetworkEntries().length > 0;
const wifiSta = isWifiStaConnected();
const wired = getWiredIfaceWithCarrier();
@@ -144,6 +155,7 @@ function hasInternet() {
* 必须 TYPE 精确为 wifi,不能用 grep wifi(会误匹配 wifi-p2p,导致选到 p2p-dev-wlan0STA/热点均失败)。
*/
function getWifiIface() {
if (IS_WINDOWS) return '';
if (AP_IFACE) return AP_IFACE;
try {
const out = run('nmcli -t -f DEVICE,TYPE device');
@@ -174,6 +186,7 @@ function getWifiIface() {
* 扫描周围 WiFi,返回 [{ ssid, signal, security }]
*/
function scanWifi() {
if (IS_WINDOWS) return [];
const iface = getWifiIface();
try {
// 先触发一次扫描
@@ -266,6 +279,10 @@ function nmcliAsync(args, timeoutMs = 60000) {
* @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' };
}
cancelHotspotRadioRetry(`准备连接 WiFi: ${ssid}`);
const iface = getWifiIface();
log.info('network', `尝试连接 WiFi: ${ssid}ifname=${iface}`);
@@ -522,6 +539,10 @@ 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');
}
const iface = getWifiIface();
const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`;
@@ -609,6 +630,8 @@ function _parseNmcliTerseLine(line) {
* 列出已保存的 WiFi STA 连接(排除自身热点),按 autoconnect-priority 从高到低排序。
*/
function listSavedWifiConnections() {
if (IS_WINDOWS) return [];
const profiles = [];
try {
const out = run('nmcli -t -f NAME,UUID,TYPE,AUTOCONNECT,AUTOCONNECT-PRIORITY connection show');
@@ -664,6 +687,10 @@ async function _ensureActiveWifiAutoconnect() {
* clawd 只做调度;真正的认证、DHCP、重连细节仍交给 NM。
*/
async function connectSavedWifiConnections() {
if (IS_WINDOWS) {
return { success: false, error: 'Windows/x86 clawd does not manage saved WiFi profiles' };
}
cancelHotspotRadioRetry('准备连接已保存 WiFi');
const iface = getWifiIface();
const profiles = listSavedWifiConnections();
@@ -702,6 +729,8 @@ async function connectSavedWifiConnections() {
* 不用 device 列表按 `:` 拆字段(连接名含冒号会错;state 含 connecting 勿误匹配 connected)。
*/
function isWifiStaConnected() {
if (IS_WINDOWS) return false;
const iface = getWifiIface();
let state;
let conn;
@@ -719,21 +748,36 @@ function isWifiStaConnected() {
}
function _ifaceNetworkType(name) {
if (IS_WINDOWS) {
return /wi-?fi|wlan|wireless/i.test(name) ? 'wifi' : 'lan';
}
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;
// clawd-hotspot 的 AP 管理网段只用于配网,不上报为 BOX 可访问地址。
if (addr.address.startsWith('10.42.')) continue;
entries.push({ ip: addr.address, type, iface: name });
+14
View File
@@ -8,6 +8,7 @@ const crypto = require('crypto');
const { exec } = require('child_process');
const log = require('./logger');
const { resolveOpenclawConfigFile } = require('./frpc');
const { IS_WINDOWS } = require('./platform-paths');
const DEFAULT_BASE_URL = 'https://api.cutos.ai/v1';
const FETCH_TIMEOUT_MS = 10_000;
@@ -90,6 +91,19 @@ function writeJsonFile(filePath, obj) {
* 使用异步 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) => {
if (err && err.code !== 1) {
log.warn('openclaw-provider', `restartGateway: ${err.message}`);
-240
View File
@@ -1,240 +0,0 @@
'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,
};
+47
View File
@@ -0,0 +1,47 @@
'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,
};
+24
View File
@@ -6,6 +6,7 @@ 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;
@@ -40,6 +41,16 @@ 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 模式
@@ -228,6 +239,19 @@ 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();
-10
View File
@@ -15,7 +15,6 @@
*/
const log = require('./logger');
const { OPENCLAW, resolveAgentType } = require('./agent-type');
// ── channel handlers ──────────────────────────────────────────────────────────
const handlers = {
@@ -40,15 +39,6 @@ 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);
+2 -3
View File
@@ -1,14 +1,13 @@
{
"name": "clawd",
"version": "1.6.0",
"version": "1.5.7",
"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",
"test": "node --test test/*.test.js"
"start": "node bin/clawd.js"
},
"keywords": [
"claw",
-20
View File
@@ -1,20 +0,0 @@
'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/);
});
-10
View File
@@ -1,10 +0,0 @@
'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 });
});
-25
View File
@@ -1,25 +0,0 @@
'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"/);
});
-158
View File
@@ -1,158 +0,0 @@
'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));
}
});