Compare commits
9
Commits
7de0777748
...
main
| 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 微信能力。
|
||||
|
||||
## 服务管理
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ 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);
|
||||
}
|
||||
|
||||
// 每次启动同步 3588s demo 到 /usr/bin/demo(idempotent,失败不影响主流程)
|
||||
const demoBin = path.join(__dirname, "..", "lib/resource/3588s/demo");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+27
-8
@@ -17,8 +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 { OPENCLAW, resolveAgentType } = require('./agent-type');
|
||||
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
/** 连续若干轮 ping 后仍无 pong 才判定死链(单轮易因调度/弱网误判) */
|
||||
@@ -54,6 +56,7 @@ function btMonitorEnabled() {
|
||||
class ClawClient {
|
||||
constructor() {
|
||||
this._cfg = config.load();
|
||||
this._agentType = resolveAgentType();
|
||||
this._boxId = getBoxId();
|
||||
this._ws = null;
|
||||
this._hbTimer = null;
|
||||
@@ -104,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);
|
||||
@@ -193,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 || {};
|
||||
@@ -377,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,
|
||||
@@ -416,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;
|
||||
@@ -452,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);
|
||||
});
|
||||
}
|
||||
@@ -475,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);
|
||||
@@ -491,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);
|
||||
@@ -499,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);
|
||||
});
|
||||
@@ -541,6 +554,7 @@ class ClawClient {
|
||||
// ── OpenClaw 配置 ────────────────────────────────────────────────────────────
|
||||
|
||||
_updateOpenClawOrigin(targetId) {
|
||||
if (!this._isOpenClaw()) return;
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
const configFile = resolveOpenclawConfigFile();
|
||||
|
||||
@@ -633,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;
|
||||
@@ -643,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,
|
||||
@@ -666,6 +681,10 @@ class ClawClient {
|
||||
}
|
||||
}
|
||||
|
||||
_isOpenClaw() {
|
||||
return this._agentType === OPENCLAW;
|
||||
}
|
||||
|
||||
// ── 升级 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_sendUpgradeProgress(progress, step, failed = false, errorMsg = null) {
|
||||
|
||||
+39
-20
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const https = require('https');
|
||||
const log = require('./logger');
|
||||
const { Watchdog } = require('./watchdog');
|
||||
const { CUTOS_AGENT } = require('./agent-type');
|
||||
|
||||
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|
||||
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
|
||||
@@ -15,6 +16,7 @@ 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')];
|
||||
@@ -49,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');
|
||||
@@ -147,15 +152,34 @@ 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 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 = `
|
||||
[[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"
|
||||
@@ -167,22 +191,10 @@ token = "${auth_token}"
|
||||
|
||||
[transport]
|
||||
tls.enable = true
|
||||
|
||||
[[proxies]]
|
||||
name = "dashboard-${clawId}"
|
||||
type = "http"
|
||||
localPort = ${dashboard_local_port}
|
||||
subdomain = "${clawId}"
|
||||
|
||||
[[proxies]]
|
||||
name = "tty-${clawId}"
|
||||
type = "tcp"
|
||||
localPort = ${TTYD_PORT}
|
||||
remotePort = ${ttyRemotePort}
|
||||
${stcpBlock}`;
|
||||
${dashboardBlock}${ttyBlock}${stcpBlock}`;
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
|
||||
log.info('frpc', `frpc.toml 已写入: dashboard subdomain=${clawId}, tty tcp-port=${ttyRemotePort}${sshSecretKey ? ', ssh stcp=enabled' : ''}`);
|
||||
log.info('frpc', `frpc.toml 已写入: dashboard=${dashboardEnabled ? `subdomain ${clawId}` : 'disabled'}, tty tcp-port=${ttyRemotePort}${stcpBlock ? ', ssh stcp=enabled' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,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)) {
|
||||
@@ -206,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,
|
||||
@@ -224,4 +236,11 @@ class FrpcManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager };
|
||||
module.exports = {
|
||||
CUTOS_DASHBOARD_PORT,
|
||||
getDashboardInfo,
|
||||
resolveOpenclawConfigFile,
|
||||
startTtyd,
|
||||
writeFrpcConfig,
|
||||
FrpcManager,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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