Files
u-claw/portable/config-server/server.js
zheng dd8e0f55c3 fix(portable): Gemini keys, Telegram pairing UI, and config durability
Improve first-run and channel setup for non-technical users: detect newer
Gemini key formats, pin Node 22.22.3, add Control Panel Telegram approve
flow, and keep channels/models when config is rewritten. Persist uclaw
wizard state via uclaw-meta.json so restarts skip language/persona prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:45:03 +08:00

909 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { deflateSync } = require('zlib');
const crypto = require('crypto');
const PORT_RANGE_START = 18788;
const PORT_RANGE_END = 18798;
const DATA_DIR = path.join(__dirname, '../data');
const CONFIG_PATH = path.join(DATA_DIR, '.openclaw/openclaw.json');
const RUNTIME_PATH = path.join(DATA_DIR, '.openclaw/runtime.json');
const UCLAW_META_PATH = path.join(DATA_DIR, '.openclaw/uclaw-meta.json');
function getStateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(DATA_DIR, '.openclaw');
}
function resolveNodeBinary() {
const runtimeRoot = path.join(__dirname, '../app/runtime');
if (process.platform === 'win32') {
return path.join(runtimeRoot, 'node-win-x64/node.exe');
}
if (process.platform === 'darwin') {
const dir = process.arch === 'arm64' ? 'node-mac-arm64' : 'node-mac-x64';
return path.join(runtimeRoot, dir, 'bin/node');
}
const dir = process.arch === 'arm64' ? 'node-linux-arm64' : 'node-linux-x64';
return path.join(runtimeRoot, dir, 'bin/node');
}
const OPENCLAW_ENTRY = path.join(__dirname, '../app/core/node_modules/openclaw/openclaw.mjs');
function openclawEnv() {
return {
...process.env,
OPENCLAW_HOME: DATA_DIR,
OPENCLAW_STATE_DIR: getStateDir(),
OPENCLAW_CONFIG_PATH: CONFIG_PATH,
};
}
function readConfigFile() {
if (!fs.existsSync(CONFIG_PATH)) return {};
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function extractUclawMeta(config) {
if (!config.uclaw) return config;
const uclaw = config.uclaw;
delete config.uclaw;
try {
const existing = fs.existsSync(UCLAW_META_PATH)
? JSON.parse(fs.readFileSync(UCLAW_META_PATH, 'utf8'))
: {};
fs.writeFileSync(UCLAW_META_PATH, `${JSON.stringify({ ...existing, ...uclaw }, null, 2)}\n`);
} catch (err) {
console.error('[uclaw-meta] write failed:', err.message);
}
return config;
}
function mergeConfigPreserve(existing, incoming) {
const next = { ...incoming };
if (existing.channels) {
next.channels = { ...existing.channels, ...(next.channels || {}) };
}
if (existing.commands?.ownerAllowFrom?.length) {
next.commands = {
...(existing.commands || {}),
...(next.commands || {}),
ownerAllowFrom: next.commands?.ownerAllowFrom?.length
? next.commands.ownerAllowFrom
: existing.commands.ownerAllowFrom,
};
}
if (existing.plugins && !next.plugins) next.plugins = existing.plugins;
return next;
}
function writeConfigFile(config) {
const existing = readConfigFile();
let next = mergeConfigPreserve(existing, config);
next = extractUclawMeta(next);
delete next.agent;
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
return next;
}
function runOpenclawCli(args) {
const node = resolveNodeBinary();
if (!fs.existsSync(node) || !fs.existsSync(OPENCLAW_ENTRY)) {
throw new Error('OpenClaw runtime not found on this drive');
}
return execFileSync(node, [OPENCLAW_ENTRY, ...args], {
env: openclawEnv(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function telegramPairingPath() {
return path.join(getStateDir(), 'credentials', 'telegram-pairing.json');
}
function readTelegramPairingRequests() {
const filePath = telegramPairingPath();
if (!fs.existsSync(filePath)) return [];
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return [];
}
const requests = Array.isArray(parsed.requests) ? parsed.requests : [];
const now = Date.now();
return requests.filter((req) => {
const created = Date.parse(req.createdAt || '');
return Number.isFinite(created) && now - created < 3600 * 1000;
}).map((req) => ({
code: req.code,
id: req.id,
createdAt: req.createdAt,
username: req.meta?.username || req.meta?.name || '',
firstName: req.meta?.firstName || '',
}));
}
function approveTelegramPairing(code) {
const snapshot = readConfigFile();
const channels = snapshot.channels ? { ...snapshot.channels } : null;
const out = runOpenclawCli(['pairing', 'approve', 'telegram', code, '--notify']);
const after = readConfigFile();
if (channels) {
after.channels = { ...channels, ...(after.channels || {}) };
writeConfigFile(after);
}
return out;
}
// ── WeChat Login State ──────────────────────────────────────────────────────
const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const DEFAULT_ILINK_BOT_TYPE = '3';
const ACTIVE_LOGIN_TTL_MS = 5 * 60000;
const QR_POLL_TIMEOUT_MS = 35000;
const MAX_QR_REFRESH_COUNT = 3;
// Resolve ~/.openclaw/ directory
const OPENCLAW_DIR = process.env.OPENCLAW_STATE_DIR ||
path.join(process.env.USERPROFILE || process.env.HOME || require('os').homedir(), '.openclaw');
const WECHAT_STATE_DIR = path.join(OPENCLAW_DIR, 'openclaw-weixin');
const WECHAT_ACCOUNTS_DIR = path.join(WECHAT_STATE_DIR, 'accounts');
const WECHAT_ACCOUNT_INDEX_FILE = path.join(WECHAT_STATE_DIR, 'accounts.json');
// Plugin source on USB
const USB_PLUGIN_DIR = path.join(__dirname, '../app/extensions/openclaw-weixin');
const INSTALLED_PLUGIN_DIR = path.join(OPENCLAW_DIR, 'extensions', 'openclaw-weixin');
const activeLogins = new Map();
// ── QR Code PNG Renderer (pure Node.js, no external deps) ───────────────────
function getQrRenderDeps() {
// Try to load QR lib from openclaw's bundled qrcode-terminal
const corePath = path.join(__dirname, '../app/core/node_modules');
const candidates = [
path.join(corePath, 'qrcode-terminal/vendor/QRCode/index.js'),
path.join(corePath, 'openclaw/node_modules/qrcode-terminal/vendor/QRCode/index.js'),
];
const errCandidates = [
path.join(corePath, 'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'),
path.join(corePath, 'openclaw/node_modules/qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'),
];
for (let i = 0; i < candidates.length; i++) {
if (fs.existsSync(candidates[i])) {
return { QRCode: require(candidates[i]), QRErrorCorrectLevel: require(errCandidates[i]) };
}
}
// Fallback: try WeChat plugin's own node_modules
const pluginQr = path.join(USB_PLUGIN_DIR, 'node_modules/qrcode-terminal/vendor/QRCode/index.js');
const pluginQrErr = path.join(USB_PLUGIN_DIR, 'node_modules/qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
if (fs.existsSync(pluginQr)) {
return { QRCode: require(pluginQr), QRErrorCorrectLevel: require(pluginQrErr) };
}
throw new Error('QR code library not found');
}
function createQrMatrix(input) {
const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps();
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
function fillPixel(buf, x, y, width, r, g, b, a) {
const idx = (y * width + x) * 4;
buf[idx] = r; buf[idx + 1] = g; buf[idx + 2] = b; buf[idx + 3] = (a === undefined ? 255 : a);
}
const CRC_TABLE = (function() {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const crc = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePngRgba(buffer, width, height) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let row = 0; row < height; row++) {
const offset = row * (stride + 1);
raw[offset] = 0;
buffer.copy(raw, offset + 1, row * stride, row * stride + stride);
}
const compressed = deflateSync(raw);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([signature, pngChunk('IHDR', ihdr), pngChunk('IDAT', compressed), pngChunk('IEND', Buffer.alloc(0))]);
}
function renderQrPngDataUrl(input) {
const scale = 6, margin = 4;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + margin * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row++) {
for (let col = 0; col < modules; col++) {
if (!qr.isDark(row, col)) continue;
const sx = (col + margin) * scale, sy = (row + margin) * scale;
for (let y = 0; y < scale; y++) for (let x = 0; x < scale; x++)
fillPixel(buf, sx + x, sy + y, size, 0, 0, 0, 255);
}
}
return 'data:image/png;base64,' + encodePngRgba(buf, size, size).toString('base64');
}
// ── WeChat API helpers ──────────────────────────────────────────────────────
async function fetchWeChatQrCode(apiBaseUrl) {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/';
const url = base + 'ilink/bot/get_bot_qrcode?bot_type=' + encodeURIComponent(DEFAULT_ILINK_BOT_TYPE);
const response = await fetch(url);
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error('Failed to fetch QR: ' + response.status + ' ' + body);
}
return await response.json();
}
async function pollWeChatQrStatus(apiBaseUrl, qrcode) {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/';
const url = base + 'ilink/bot/get_qrcode_status?qrcode=' + encodeURIComponent(qrcode);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QR_POLL_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'iLink-App-ClientVersion': '1' },
signal: controller.signal
});
clearTimeout(timer);
const text = await response.text();
if (!response.ok) throw new Error('Poll failed: ' + response.status + ' ' + text);
return JSON.parse(text);
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') return { status: 'wait' };
throw err;
}
}
function normalizeAccountId(raw) {
return String(raw).toLowerCase().replace(/[^a-z0-9._-]/g, '-');
}
async function saveWeChatAccount(rawAccountId, payload) {
const accountId = normalizeAccountId(rawAccountId);
fs.mkdirSync(WECHAT_ACCOUNTS_DIR, { recursive: true });
const filePath = path.join(WECHAT_ACCOUNTS_DIR, accountId + '.json');
const data = {
token: payload.token.trim(),
savedAt: new Date().toISOString(),
};
if (payload.baseUrl) data.baseUrl = payload.baseUrl.trim();
if (payload.userId) data.userId = payload.userId.trim();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
// Update account index
let accounts = [];
try { accounts = JSON.parse(fs.readFileSync(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8')); } catch {}
if (!Array.isArray(accounts)) accounts = [];
if (!accounts.includes(accountId)) {
accounts.push(accountId);
fs.mkdirSync(WECHAT_STATE_DIR, { recursive: true });
fs.writeFileSync(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accounts, null, 2));
}
return accountId;
}
function ensureWeChatPluginInstalled() {
if (!fs.existsSync(USB_PLUGIN_DIR) || !fs.existsSync(path.join(USB_PLUGIN_DIR, 'openclaw.plugin.json'))) {
return { installed: false, warning: 'WeChat plugin not found on USB' };
}
if (fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json'))) {
return { installed: true };
}
// Copy from USB to ~/.openclaw/extensions/
// 容错copy 失败不抛错中断整个 confirmed 流程(账号保存 + openclaw.json 已/将写好)。
try {
const extDir = path.join(OPENCLAW_DIR, 'extensions');
fs.mkdirSync(extDir, { recursive: true });
copyDirSync(USB_PLUGIN_DIR, INSTALLED_PLUGIN_DIR);
} catch (e) {
console.error('WeChat plugin copy failed:', e.message);
return { installed: false, warning: e.message };
}
return { installed: fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json')) };
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) copyDirSync(s, d);
else fs.copyFileSync(s, d);
}
}
// ── WeChat login session management ─────────────────────────────────────────
async function handleWeChatStart() {
const sessionKey = crypto.randomUUID();
const apiBaseUrl = DEFAULT_WECHAT_BASE_URL;
const qrResponse = await fetchWeChatQrCode(apiBaseUrl);
const qrDataUrl = renderQrPngDataUrl(qrResponse.qrcode_img_content);
activeLogins.set(sessionKey, {
sessionKey,
qrcode: qrResponse.qrcode,
qrcodeUrl: qrDataUrl,
startedAt: Date.now(),
apiBaseUrl,
});
return { sessionKey, qrcodeUrl: qrDataUrl };
}
async function handleWeChatStatus(sessionKey) {
const login = activeLogins.get(sessionKey);
if (!login) return { status: 'expired', message: 'No active session' };
if (Date.now() - login.startedAt > ACTIVE_LOGIN_TTL_MS) {
activeLogins.delete(sessionKey);
return { status: 'expired', message: 'Session expired' };
}
// 状态轮询用 pollBaseUrlIDC 重定向后会指向新主机);二维码获取/刷新始终用原始
// apiBaseUrl与官方插件一致refresh 回到固定主机,只有 status 轮询跟随重定向)。
const result = await pollWeChatQrStatus(login.pollBaseUrl || login.apiBaseUrl, login.qrcode);
// 微信登录状态流转日志(跳过高频的 wait便于排查"扫码卡死"类问题)。
if (result.status && result.status !== 'wait') {
console.log(`[wechat] status=${result.status}` + (result.redirect_host ? ` redirect_host=${result.redirect_host}` : ''));
}
if (result.status === 'expired') {
// Try to refresh QR code
if (!login.refreshCount) login.refreshCount = 1;
login.refreshCount++;
if (login.refreshCount > MAX_QR_REFRESH_COUNT) {
activeLogins.delete(sessionKey);
return { status: 'expired', message: 'QR expired too many times' };
}
const refreshed = await fetchWeChatQrCode(login.apiBaseUrl);
const newQr = renderQrPngDataUrl(refreshed.qrcode_img_content);
login.qrcode = refreshed.qrcode;
login.qrcodeUrl = newQr;
login.startedAt = Date.now();
// 新二维码来自原始主机,重置轮询主机,避免拿新码去轮询旧的重定向主机。
login.pollBaseUrl = null;
return { status: 'refreshed', qrcodeUrl: newQr };
}
if (result.status === 'confirmed') {
activeLogins.delete(sessionKey);
if (!result.ilink_bot_id || !result.bot_token) {
return { status: 'error', message: 'Server did not return credentials' };
}
// 1. Install plugin
const pluginResult = ensureWeChatPluginInstalled();
// 2. Save account
const accountId = await saveWeChatAccount(result.ilink_bot_id, {
token: result.bot_token,
baseUrl: result.baseurl,
userId: result.ilink_user_id,
});
// 3. Update openclaw.json to enable the plugin
try {
const configRaw = fs.existsSync(CONFIG_PATH) ? fs.readFileSync(CONFIG_PATH, 'utf-8') : '{}';
const config = JSON.parse(configRaw);
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
config.plugins.entries['openclaw-weixin'] = { enabled: true };
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
} catch (e) {
console.error('Failed to update config:', e.message);
}
return {
status: 'confirmed',
accountId,
pluginInstalled: pluginResult.installed,
message: 'WeChat connected! Restart Gateway to activate.',
};
}
// IDC 重定向用户扫码后ilink 服务端可能要求把后续轮询切换到另一个数据中心主机
// (status=scaned_but_redirect + redirect_host)。必须跟着切,否则一直轮询旧主机,
// 扫码后永远等不到 confirmed——表现为「扫了码却卡死不前进」。
// 同款逻辑见官方插件 openclaw-weixin/src/auth/login-qr.ts 的 scaned_but_redirect 分支。
if (result.status === 'scaned_but_redirect') {
if (result.redirect_host) {
login.pollBaseUrl = 'https://' + result.redirect_host;
}
// 对前端按「已扫码」处理:显示提示并继续轮询,下一轮已指向新主机。
return { status: 'scaned' };
}
return { status: result.status };
}
function handleWeChatCancel(sessionKey) {
if (sessionKey) activeLogins.delete(sessionKey);
else activeLogins.clear();
}
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// API: WeChat start login
if (req.url === '/api/wechat/start' && req.method === 'POST') {
handleWeChatStart()
.then(result => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// API: WeChat poll status
if (req.url && req.url.startsWith('/api/wechat/status') && req.method === 'GET') {
const urlObj = new URL(req.url, 'http://localhost');
const session = urlObj.searchParams.get('session');
if (!session) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing session parameter' }));
return;
}
handleWeChatStatus(session)
.then(result => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// API: WeChat cancel
if (req.url === '/api/wechat/cancel' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = body ? JSON.parse(body) : {};
handleWeChatCancel(data.session);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// API: WeChat plugin status
if (req.url === '/api/wechat/plugin-status' && req.method === 'GET') {
const hasPlugin = fs.existsSync(path.join(USB_PLUGIN_DIR, 'openclaw.plugin.json'));
const installed = fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hasPlugin, installed }));
return;
}
// API: what the first-run wizard offers, straight from the manifest.
if (req.url === '/api/personas' && req.method === 'GET') {
try {
const manifestPath = path.join(__dirname, '..', 'skills', 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const shipping = manifest.skills.filter((s) => s.status === 'shipping');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(manifest.personas.map((p) => ({
...p,
skills: shipping.filter((s) => (s.personas || []).includes(p.id)).map((s) => s.id),
}))));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: install the skills a persona needs. Runs the same installer the
// release build and the one-line installers use, so there is one code path.
if (req.url === '/api/install-skills' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => { body += chunk; if (body.length > 4096) req.destroy(); });
req.on('end', async () => {
const reply = (payload, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
let persona = 'general';
let locale = 'en';
try {
const parsed = JSON.parse(body || '{}');
if (parsed.persona) persona = String(parsed.persona);
if (parsed.locale) locale = String(parsed.locale);
} catch { /* fall back to the defaults */ }
const target = path.join(__dirname, '..', 'app', 'core', 'node_modules', 'openclaw', 'skills');
// On the drive the installer sits in lib/ next to us; in a source checkout
// it lives at the repo root. Same two-place lookup the setup scripts use.
const installer = [
path.join(__dirname, '..', 'lib', 'install-skills.mjs'),
path.join(__dirname, '..', '..', 'lib', 'install-skills.mjs'),
].find((candidate) => fs.existsSync(candidate));
if (!installer) return reply({ ok: false, reason: 'installer-missing' });
try {
fs.mkdirSync(target, { recursive: true });
const { execFile } = require('child_process');
await new Promise((resolve, reject) => {
execFile(process.execPath,
[installer, '--target', target, '--locale', locale, '--persona', persona, '--json'],
{ timeout: 30000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)));
});
reply({ ok: true, persona });
} catch (err) {
// Skills are a bonus, not a gate: the wizard continues either way.
reply({ ok: false, reason: err.message });
}
});
return;
}
// API: Identify an API key and check that it actually works.
//
// Runs server-side on purpose: the browser cannot call most model endpoints
// cross-origin, and doing it here means the key never leaves this machine by
// any path the page controls. Sends the smallest possible completion (1 token)
// and maps the outcome to a message key the page translates -- users get
// "that key was not accepted, here is what to do", not "401 Unauthorized".
if (req.url === '/api/test-key' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
// A key is short; anything large is not a key.
if (body.length > 8192) req.destroy();
});
req.on('end', async () => {
const reply = (payload, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
let parsed;
try { parsed = JSON.parse(body || '{}'); } catch { return reply({ ok: false, reason: 'key.err_unknown' }, 400); }
const apiKey = String(parsed.apiKey || '').trim();
if (!apiKey) return reply({ ok: false, reason: 'key.err_empty' });
const { detectProvider, classifyFailure, modelsToTry, discoverGoogleOpenAiModels } = await import('../lib/provider-detect.mjs');
const detected = detectProvider(apiKey);
const baseUrl = String(parsed.baseUrl || detected?.baseUrl || '').replace(/\/+$/, '');
const model = String(parsed.model || detected?.model || '');
if (!baseUrl || !model) {
return reply({ ok: false, provider: detected, reason: 'key.err_unrecognised' });
}
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const candidates = modelsToTry(detected, model);
let lastStatus = null;
const tryChat = async (tryModel) => {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
// Anthropic's endpoint wants its own header; sending both is harmless.
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({ model: tryModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
});
return response;
};
try {
for (const tryModel of candidates) {
const response = await tryChat(tryModel);
if (response.ok) {
return reply({ ok: true, provider: detected, baseUrl, model: tryModel, latencyMs: Date.now() - startedAt });
}
lastStatus = response.status;
// Only try the next Gemini model when this account cannot see the first one.
if (!(detected?.id === 'google' && response.status === 404)) {
return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
}
}
if (detected?.id === 'google' && lastStatus === 404) {
const discovered = await discoverGoogleOpenAiModels(baseUrl, apiKey);
const extra = discovered.filter((id) => !candidates.includes(id));
const rank = (id) => (id.includes('flash') ? 0 : 10) + (id.includes('2.0') ? 0 : 5) + (id.includes('lite') ? 1 : 0);
extra.sort((a, b) => rank(a) - rank(b));
for (const tryModel of extra) {
const response = await tryChat(tryModel);
if (response.ok) {
return reply({ ok: true, provider: detected, baseUrl, model: tryModel, latencyMs: Date.now() - startedAt });
}
lastStatus = response.status;
if (response.status !== 404) {
return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
}
}
}
return reply({ ok: false, provider: detected, status: lastStatus, reason: classifyFailure({ status: lastStatus }) });
} catch (err) {
return reply({ ok: false, provider: detected, reason: classifyFailure({ code: err?.cause?.code || err?.code || err?.name }) });
} finally {
clearTimeout(timer);
}
});
return;
}
// API: Get config
if (req.url === '/api/config' && req.method === 'GET') {
try {
const config = fs.existsSync(CONFIG_PATH)
? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
: {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: Update status — read update-available.json written by check-update.mjs
// Returns { available: false } if no info or stale; otherwise the manifest payload.
if (req.url === '/api/update-status' && req.method === 'GET') {
try {
const stateDir = process.env.OPENCLAW_STATE_DIR
|| path.join(__dirname, '../data/.openclaw');
const updateFile = path.join(stateDir, 'update-available.json');
if (!fs.existsSync(updateFile)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, reason: 'no-check-yet' }));
return;
}
const payload = JSON.parse(fs.readFileSync(updateFile, 'utf8'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
} catch (err) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, reason: 'read-failed', error: err.message }));
}
return;
}
// API: Trigger update check on demand (so users can press a "Check now" button)
if (req.url === '/api/update-check' && req.method === 'POST') {
(async () => {
try {
const mod = await import('../lib/check-update.mjs');
const portableRoot = path.join(__dirname, '..');
const versionFilePath = fs.existsSync(path.join(portableRoot, 'OPENCLAW_VERSION'))
? path.join(portableRoot, 'OPENCLAW_VERSION')
: path.join(portableRoot, '..', 'OPENCLAW_VERSION');
const stateDir = process.env.OPENCLAW_STATE_DIR
|| path.join(portableRoot, 'data/.openclaw');
const result = await mod.checkUpdate({ versionFilePath, stateDir });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
})();
return;
}
// API: Discover local models (Ollama / LM Studio)
// 借鉴 RealShocky/openclaw-windows自动探测本机已装的本地模型
// 用户无需手填 baseUrl/模型名,直接点选即可(便携版纯离线推理卖点)。
// 静默失败:探测不到就返回空数组,不影响 Config 页面。
if (req.url === '/api/local-models' && req.method === 'GET') {
(async () => {
const probes = [
{ provider: 'ollama', label: 'Ollama', base: 'http://127.0.0.1:11434/v1', api: 'http://127.0.0.1:11434/api/tags' },
{ provider: 'lmstudio', label: 'LM Studio', base: 'http://127.0.0.1:1234/v1', api: 'http://127.0.0.1:1234/v1/models' },
];
const found = [];
await Promise.all(probes.map(async (p) => {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1200);
const r = await fetch(p.api, { signal: ctrl.signal });
clearTimeout(t);
if (!r.ok) return;
const data = await r.json();
// Ollama: { models:[{name}] } | LM Studio (OpenAI-style): { data:[{id}] }
const models = Array.isArray(data.models)
? data.models.map(m => m.name).filter(Boolean)
: Array.isArray(data.data)
? data.data.map(m => m.id).filter(Boolean)
: [];
if (models.length) found.push({ provider: p.provider, label: p.label, base: p.base, models });
} catch { /* 探测失败:该 provider 未运行,跳过 */ }
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ providers: found }));
})();
return;
}
// API: Telegram pairing — list pending DM approvals (no CLI for the user)
if (req.url === '/api/telegram/pairing' && req.method === 'GET') {
try {
const requests = readTelegramPairingRequests();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ requests }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: Telegram pairing — one-click approve from the Control Panel
if (req.url === '/api/telegram/pairing/approve' && req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const { code } = JSON.parse(body || '{}');
if (!code || typeof code !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing pairing code' }));
return;
}
const message = approveTelegramPairing(code.trim().toUpperCase());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, message }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// API: Save config
if (req.url === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
writeConfigFile(JSON.parse(body));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// Serve the shared i18n runtime and the drive's locale from outside public/.
// Keeping one copy matters: the pages opened from file:// load these same
// files by relative path, and a second copy under public/ would drift.
if (req.url.startsWith('/lib/i18n/') || req.url === '/locale.js') {
const target = req.url === '/locale.js'
? path.join(__dirname, '../data/.openclaw/locale.js')
: path.join(__dirname, '..', 'lib', 'i18n', path.basename(req.url));
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(fs.readFileSync(target));
} else {
// locale.js is written by the launcher; before the first run it is absent
// and the page falls back to the browser language. Not an error.
res.writeHead(204);
res.end();
}
return;
}
// Serve static files
const filePath = req.url === '/'
? path.join(__dirname, 'public/index.html')
: path.join(__dirname, 'public', req.url);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json'
}[ext] || 'text/plain';
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
function listenWithFallback(port) {
server.once('error', (err) => {
if (err && err.code === 'EADDRINUSE' && port < PORT_RANGE_END) {
console.log(` Port ${port} busy, trying ${port + 1}`);
setImmediate(() => listenWithFallback(port + 1));
return;
}
console.error(`Config server failed to bind: ${err && err.message ? err.message : err}`);
process.exit(1);
});
server.listen(port, '127.0.0.1', () => {
console.log(`\n🦞 U-Claw Config Center`);
console.log(` http://127.0.0.1:${port}`);
console.log(`\n Config file: ${CONFIG_PATH}\n`);
// Persist the live port so Config.html / launchers can discover it after restarts.
try {
fs.mkdirSync(path.dirname(RUNTIME_PATH), { recursive: true });
const existing = fs.existsSync(RUNTIME_PATH) ? JSON.parse(fs.readFileSync(RUNTIME_PATH, 'utf8')) : {};
existing.configServerPort = port;
existing.configServerUpdatedAt = new Date().toISOString();
fs.writeFileSync(RUNTIME_PATH, JSON.stringify(existing, null, 2));
} catch (err) {
console.warn(` Warning: could not write ${RUNTIME_PATH}: ${err.message}`);
}
});
}
listenWithFallback(PORT_RANGE_START);