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>
This commit is contained in:
2026-08-18 18:45:03 +08:00
parent 0be8c3e3fe
commit dd8e0f55c3
35 changed files with 2673 additions and 34 deletions

View File

@@ -2,13 +2,144 @@
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 CONFIG_PATH = path.join(__dirname, '../data/.openclaw/openclaw.json');
const RUNTIME_PATH = path.join(__dirname, '../data/.openclaw/runtime.json');
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';
@@ -493,7 +624,7 @@ const server = http.createServer((req, res) => {
const apiKey = String(parsed.apiKey || '').trim();
if (!apiKey) return reply({ ok: false, reason: 'key.err_empty' });
const { detectProvider, classifyFailure } = await import('../lib/provider-detect.mjs');
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 || '');
@@ -505,7 +636,9 @@ const server = http.createServer((req, res) => {
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const candidates = modelsToTry(detected, model);
let lastStatus = null;
const tryChat = async (tryModel) => {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
@@ -516,12 +649,39 @@ const server = http.createServer((req, res) => {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
body: JSON.stringify({ model: tryModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
});
if (!response.ok) {
return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
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 }) });
}
}
return reply({ ok: true, provider: detected, baseUrl, model, latencyMs: Date.now() - startedAt });
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 {
@@ -624,18 +784,49 @@ const server = http.createServer((req, res) => {
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 {
const config = JSON.parse(body);
// 清除旧版废弃键,防止 OpenClaw 报 "agent.* was moved" 错误
delete config.agent;
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
writeConfigFile(JSON.parse(body));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {