🤞 Sync 2026-09-06 22:50:31

This commit is contained in:
github-actions[bot] committed 2026-09-06 22:50:31 +08:00
1 parent bb7d626e94
commit 64c17e592d
115 files changed
+8599 -1391

No files matched your search

@@ -19,8 +19,8 @@
" plugin 表示需先安装官方 provider 插件,模型列表以插件为准。"
],
"verified": {
"date": "2026-08-26",
"openclawVersion": "2026.7.1-2",
"date": "2026-09-05",
"openclawVersion": "2026.9.1",
"method": "openclaw models list --provider <id> --plain"
},
"providers": {
@@ -15,7 +15,7 @@
const path = require('path');
const fs = require('fs');
const { spawn, execSync, execFileSync } = require('child_process');
const { spawn, spawnSync, execSync, execFileSync } = require('child_process');
const menu = require('./oc-menu-engine');
const { C, select, input, confirm, spinner, resetRenderCount } = menu;
@@ -51,38 +51,107 @@ const MODEL_DISCOVERY_TIMEOUT_MS = 6000;
/**
* 执行命令并返回结果
* options.interactive: 若为 true,使用 stdio: 'inherit' 直通终端(用于向导/全屏交互),保证 child.stdout.isTTY 为 true
* options.capture: 若为 true,静默收集 stdout/stderr 并返回,不输出到终端
*/
function runCommand(cmd, args = [], options = {}) {
return new Promise((resolve, reject) => {
const isInteractive = options.interactive === true || options.stdio === 'inherit';
const isCapture = options.capture === true;
// 交互模式下使用同步直通 (spawnSync inherit),使子进程独占控制终端 TTY,
// 避免父子进程竞争读取 stdin,执行完毕后排空残留按键并恢复终端模式
if (isInteractive) {
menu.setRawMode(false);
process.stdout.write(C.show);
if (process.stdin.pause) {
try { process.stdin.pause(); } catch {}
}
try {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: false,
cwd: options.cwd,
env: { ...process.env, ...options.env },
});
try {
if (process.stdin.isTTY) process.stdin.setRawMode(false);
while (process.stdin.read() !== null) {}
} catch {}
if (process.stdin.resume) {
try { process.stdin.resume(); } catch {}
}
if (res.error) {
return reject(res.error);
}
if (res.status === 0) {
return resolve({ stdout: '', stderr: '', code: 0 });
} else {
return reject(new Error(`Command failed with code ${res.status}: ${res.signal || ''}`));
}
} catch (err) {
return reject(err);
}
}
const child = spawn(cmd, args, {
stdio: ['inherit', 'pipe', 'pipe'],
shell: true,
stdio: isCapture ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
shell: !options.noShell,
env: { ...process.env, ...options.env },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data;
process.stdout.write(data);
});
if (child.stdout) {
child.stdout.on('data', (data) => {
stdout += data;
if (!isCapture) process.stdout.write(data);
});
}
child.stderr.on('data', (data) => {
stderr += data;
process.stderr.write(data);
});
if (child.stderr) {
child.stderr.on('data', (data) => {
stderr += data;
if (!isCapture) process.stderr.write(data);
});
}
child.on('close', (code) => {
child.on('close', (code, signal) => {
if (code === 0) {
resolve({ stdout, stderr, code });
} else {
reject(new Error(`Command failed with code ${code}: ${stderr}`));
reject(new Error(`Command failed with code ${code ?? signal}: ${stderr}`));
}
});
child.on('error', (err) => {
reject(err);
});
});
}
/**
* 清理过时插件配置并启用有效认证插件 (与 oc-config.sh 的 enable_auth_plugins 对应)
*/
function enableAuthPlugins() {
if (!fs.existsSync(CONFIG_FILE)) return;
try {
const config = readConfig();
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
const e = config.plugins.entries;
['copilot-proxy'].forEach(p => {
if (!e[p]) e[p] = {};
e[p].enabled = true;
});
delete e['qwen-portal-auth'];
delete e['google-gemini-cli-auth'];
delete e['minimax-portal-auth'];
writeConfig(config);
} catch {}
}
/**
* 读取 JSON 配置文件
*/
@@ -91,7 +160,9 @@ function fixStatePermissions() {
execFileSync(PERMISSIONS_HELPER, ['fix-state', OC_STATE_DIR], { stdio: 'ignore', timeout: 10000 });
} catch {
try {
execSync(`find "${OC_STATE_DIR}" -user root ! -path "*/extensions*" ! -path "*/archived-extensions*" ! -path "*/npm/projects*" -exec chown openclaw:openclaw {} + 2>/dev/null || true`, { stdio: 'ignore' });
execSync(`find "${OC_STATE_DIR}" -user root ! -path "*/archived-extensions*" ! -path "*/npm/projects*" -exec chown openclaw:openclaw {} + 2>/dev/null || true`, { stdio: 'ignore' });
execSync(`[ -d "${OC_STATE_DIR}/extensions" ] && chown -R openclaw:openclaw "${OC_STATE_DIR}/extensions" && chmod -R 755 "${OC_STATE_DIR}/extensions" 2>/dev/null || true`, { stdio: 'ignore' });
execSync(`[ -d "${OC_STATE_DIR}/archived-extensions" ] && chown -R root:root "${OC_STATE_DIR}/archived-extensions" && chmod -R 755 "${OC_STATE_DIR}/archived-extensions" 2>/dev/null || true`, { stdio: 'ignore' });
} catch {}
}
}
@@ -303,18 +374,38 @@ function findOpenClawEntry() {
/**
* 执行 OpenClaw CLI 命令 (对应 oc_cmd)
* 交互式向导(configure / models auth login 等)以 interactive: true 运行,直通 stdio 保证 TTY 正常交互;
* 命令执行后在 finally 中自动修复状态文件属主。
*/
async function ocCmd(...args) {
const ocEntry = findOpenClawEntry();
if (!ocEntry) throw new Error('OpenClaw 未安装');
return runCommand(NODE_BIN, [ocEntry, ...args], {
env: {
OPENCLAW_HOME: OC_DATA,
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
OPENCLAW_STATE_DIR: OC_STATE_DIR,
HOME: OC_DATA,
}
});
const isJson = args.includes('--json');
const isInteractive = !isJson && (
args[0] === 'configure' ||
(args[0] === 'models' && args[1] === 'auth')
);
try {
const res = await runCommand(NODE_BIN, [ocEntry, ...args], {
interactive: isInteractive,
capture: isJson,
env: {
OPENCLAW_HOME: OC_DATA,
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
OPENCLAW_STATE_DIR: OC_STATE_DIR,
HOME: OC_DATA,
}
});
return res;
} finally {
try {
execSync(`chown openclaw:openclaw "${CONFIG_FILE}" 2>/dev/null || true`, { stdio: 'ignore' });
execSync(`chown openclaw:openclaw "${CONFIG_FILE}.bak" 2>/dev/null || true`, { stdio: 'ignore' });
fixStatePermissions();
} catch {}
}
}
/**
@@ -743,12 +834,13 @@ async function showMainMenu() {
{ key: '4', label: '健康检查与状态', desc: '', value: 'health' },
{ key: '5', label: '查看日志', desc: '', value: 'logs' },
{ key: '6', label: '重启 Gateway', desc: '', value: 'restart' },
{ key: '7', label: '设备配对管理', desc: '审批 Control UI 浏览器配对', value: 'devices' },
{ label: `${C.dim}━━━ 高级选项 ━━━${C.reset}`, disabled: true },
{ key: '7', label: '高级配置', desc: '', value: 'advanced' },
{ key: '8', label: '重置配置', desc: '', value: 'reset' },
{ key: '9', label: '显示当前配置概览', desc: '', value: 'show-config' },
{ key: '10', label: '备份/还原配置', desc: '', value: 'backup' },
{ key: '8', label: '高级配置', desc: '', value: 'advanced' },
{ key: '9', label: '重置配置', desc: '', value: 'reset' },
{ key: '10', label: '显示当前配置概览', desc: '', value: 'show-config' },
{ key: '11', label: '备份/还原配置', desc: '', value: 'backup' },
{ label: '', disabled: true },
{ key: '0', label: '退出', desc: '', value: 'quit' },
@@ -765,25 +857,25 @@ async function showMainMenu() {
async function showModelMenu() {
const result = await select({
title: '配置 AI 模型和提供商',
showSearch: true,
showSearch: false,
items: [
{ label: `${C.green}${C.bold}── 推荐 ──${C.reset}`, disabled: true },
{ key: 'w', label: '官方完整模型配置向导', desc: `${C.green}(推荐,支持所有提供商)${C.reset}`, value: 'wizard' },
{ label: `${C.bold}── 国外模型提供商 ──${C.reset}`, disabled: true },
{ key: 'a', label: 'OpenAI', desc: 'GPT-5.2, GPT-5 mini, GPT-4.1', value: 'openai' },
{ key: 'b', label: 'Anthropic', desc: 'Claude Sonnet 4, Opus 4, Haiku', value: 'anthropic' },
{ key: 'c', label: 'Google Gemini', desc: 'Gemini 2.5 Pro/Flash, Gemini 3', value: 'google' },
{ key: 'a', label: 'OpenAI', desc: '', value: 'openai' },
{ key: 'b', label: 'Anthropic', desc: '', value: 'anthropic' },
{ key: 'c', label: 'Google Gemini', desc: '', value: 'google' },
{ key: 'd', label: 'OpenRouter', desc: '聚合多家模型', value: 'openrouter' },
{ key: 'e', label: 'GitHub Copilot', desc: '需要 Copilot 订阅', value: 'copilot' },
{ key: 'f', label: 'xAI Grok', desc: 'Grok-4/3', value: 'xai' },
{ key: 'f', label: 'xAI Grok', desc: '', value: 'xai' },
{ label: `${C.bold}── 国内模型提供商 ──${C.reset}`, disabled: true },
{ key: 'g', label: '阿里云通义千问 Qwen', desc: 'Portal/API/Coding Plan', value: 'qwen' },
{ key: 'g', label: '阿里云通义千问 Qwen', desc: '', value: 'qwen' },
{ key: 'h', label: '硅基流动 SiliconFlow', desc: '', value: 'siliconflow' },
{ key: 'i', label: '腾讯云 Coding Plan', desc: 'HY T1/TurboS/GLM-5/Kimi', value: 'tencent' },
{ key: 'j', label: '百度千帆', desc: 'ERNIE-4.0, ERNIE-3.5', value: 'baidu' },
{ key: 'k', label: '智谱 GLM / Z.AI', desc: 'GLM-5.1, GLM-5, GLM-4.7', value: 'zhipu' },
{ key: 'i', label: '腾讯云 Coding Plan', desc: '', value: 'tencent' },
{ key: 'j', label: '百度千帆', desc: '', value: 'baidu' },
{ key: 'k', label: '智谱 GLM / Z.AI', desc: '', value: 'zhipu' },
{ label: `${C.bold}── 本地模型 / 自定义 API ──${C.reset}`, disabled: true },
{ key: 'l', label: 'Ollama', desc: '本地模型,无需 API Key', value: 'ollama' },
@@ -853,6 +945,7 @@ async function showAdvancedMenu() {
{ key: '8', label: '编辑配置文件', desc: 'vi / nano', value: 'edit' },
{ key: '9', label: '导出配置备份', desc: '', value: 'backup' },
{ key: '10', label: '导入配置', desc: '', value: 'import' },
{ key: '11', label: '设备配对管理', desc: '审批 Control UI 配对', value: 'devices' },
{ label: '', disabled: true },
{ key: '0', label: '返回', desc: '', value: 'back' },
],
@@ -1041,10 +1134,14 @@ async function configureQwen() {
if (modeChoice.value === 'portal') {
console.log(`\n${C.cyan}启动 Qwen OAuth 授权...${C.reset}\n`);
try {
enableAuthPlugins();
await ocCmd('models', 'auth', 'login', '--provider', 'qwen-portal', '--set-default');
} catch {}
console.log(`\n${C.yellow}OAuth 授权已退出${C.reset}\n`);
return true;
console.log(`\n${C.green}✅ Qwen OAuth 授权完成${C.reset}\n`);
return true;
} catch {
console.log(`\n${C.yellow}OAuth 授权已退出${C.reset}\n`);
return false;
}
}
if (modeChoice.value === 'bailian') {
@@ -1452,9 +1549,12 @@ async function launchWizard() {
console.log(`\n${C.cyan}启动官方完整模型配置向导...${C.reset}\n`);
console.log(`${C.yellow}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${C.reset}\n`);
try {
enableAuthPlugins();
await ocCmd('configure', '--section', 'model');
return true;
} catch (e) {
console.log(`${C.yellow}配置向导已退出${C.reset}\n`);
return false;
}
}
@@ -1471,7 +1571,11 @@ async function handleModelConfig() {
let configured = false;
switch (choice.value) {
case 'wizard': await launchWizard(); break;
case 'wizard': {
const ok = await launchWizard();
if (ok) await askRestart();
break;
}
case 'openai': configured = await configureOpenAI(); break;
case 'anthropic': configured = await configureAnthropic(); break;
case 'google': configured = await configureGoogle(); break;
@@ -1656,7 +1760,12 @@ async function handleChannels() {
console.log(`\n${C.cyan}正在启动飞书安装向导...${C.reset}\n`);
try {
await runCommand('npx', ['-y', '@larksuite/openclaw-lark-tools', 'install'], { env: { HOME: OC_DATA } });
const npxBin = fs.existsSync(`${NODE_BASE}/bin/npx`) ? `${NODE_BASE}/bin/npx` : 'npx';
await runCommand(npxBin, ['-y', '@larksuite/openclaw-lark-tools', 'install'], {
interactive: true,
cwd: OC_DATA,
env: { HOME: OC_DATA, PATH: `${NODE_BASE}/bin:${process.env.PATH || ''}` }
});
} catch (e) {
console.log(`${C.yellow}安装向导已退出${C.reset}\n`);
}
@@ -1786,10 +1895,18 @@ async function handleChannels() {
break;
}
case 'wizard': {
resetRenderCount();
console.log(`\n${C.cyan}启动官方渠道配置向导...${C.reset}\n`);
console.log(`${C.yellow}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${C.reset}\n`);
let ok = false;
try {
enableAuthPlugins();
await ocCmd('configure', '--section', 'channels');
} catch (e) {}
ok = true;
} catch (e) {
console.log(`${C.yellow}配置向导已退出${C.reset}\n`);
}
if (ok) await askRestart();
break;
}
}
@@ -1891,6 +2008,170 @@ async function handleShowConfig() {
await input({ prompt: '按回车继续', defaultValue: '' });
}
// ═══════════════════════════════════════════════════════════════════════════
// 设备配对管理 (Control UI / 浏览器配对审批)
// ═══════════════════════════════════════════════════════════════════════════
async function manageDevicePairing() {
while (true) {
resetRenderCount();
console.log(`\n${C.bold}📱 设备配对管理 (Control UI / 浏览器配对审批)${C.reset}\n`);
console.log(`${C.yellow}⚠️ 风险提示:${C.reset}`);
console.log(`${C.yellow} 批准设备配对后,该浏览器/客户端将获得 OpenClaw 网关的完全控制权限。${C.reset}`);
console.log(`${C.yellow} 请确保在受信任的局域网环境,并仅在您本人正在连接时批准!${C.reset}\n`);
const listed = ocCmdCapture('devices', 'list', '--json');
let pending = [];
let paired = [];
try {
const data = JSON.parse(listed.stdout);
if (Array.isArray(data.pending)) pending = data.pending;
if (Array.isArray(data.paired)) paired = data.paired;
} catch (e) {
const m = (listed.stdout || '').match(/(\{[\s\S]*"pending"[\s\S]*\})/);
if (m) {
try {
const d = JSON.parse(m[1]);
if (Array.isArray(d.pending)) pending = d.pending;
if (Array.isArray(d.paired)) paired = d.paired;
} catch (_) {}
}
}
function renderPairedDevices(list) {
console.log(`\n${C.bold}📋 已配对设备 (${list.length})${C.reset}\n`);
if (list.length === 0) {
console.log(` ${C.dim}暂无已配对设备记录${C.reset}`);
} else {
list.forEach((d, idx) => {
console.log(` ${C.cyan}[${idx + 1}]${C.reset} 设备 ID: ${d.deviceId || ''}`);
console.log(` 平台: ${d.platform || '未知'} | 客户端: ${d.clientId || ''} | IP: ${d.remoteIp || ''}`);
});
}
console.log('');
}
if (pending.length > 0) {
console.log(`${C.green}🔔 当前检测到 ${pending.length} 个待配对请求:${C.reset}\n`);
pending.forEach((item, idx) => {
const rid = item.requestId || '未知ID';
const ip = item.remoteIp || item.ip || '未知IP';
const client = item.clientId || item.clientMode || 'webchat';
const plat = item.platform || '';
const role = item.role || (item.roles && item.roles.join(',')) || 'operator';
console.log(` ${C.cyan}[${idx + 1}]${C.reset} 请求 ID: ${C.bold}${rid}${C.reset}`);
console.log(` 来源 IP: ${C.green}${ip}${C.reset} | 客户端: ${client}${plat ? ' (' + plat + ')' : ''} | 角色: ${role}`);
});
console.log('');
const choice = await select({
title: '请选择操作',
showSearch: false,
items: [
{ key: '1', label: '一键批准所有待配对请求', desc: `共 ${pending.length} 个待审批`, value: 'approve-all' },
{ key: '2', label: '选择指定请求批准', desc: '', value: 'approve-select' },
{ key: '3', label: '刷新待配对列表', desc: '', value: 'refresh' },
{ key: '4', label: '查看已配对设备列表', desc: `当前已配对 ${paired.length} 台设备`, value: 'view-paired' },
{ label: '', disabled: true },
{ key: '0', label: '返回上级菜单', desc: '', value: 'back' },
],
});
if (!choice || choice.value === 'back') break;
if (choice.value === 'refresh') continue;
if (choice.value === 'view-paired') {
renderPairedDevices(paired);
await input({ prompt: '按回车继续', defaultValue: '' });
continue;
}
if (choice.value === 'approve-all') {
const ok = await confirm({
message: `${C.yellow}确认一键批准全部 ${pending.length} 个设备配对请求吗?${C.reset}`,
defaultValue: true,
});
if (ok) {
console.log(`\n${C.cyan}正在批准设备配对...${C.reset}`);
let success = 0;
for (const item of pending) {
const res = ocCmdCapture('devices', 'approve', item.requestId);
if (res.ok || /approved|success/i.test(res.stdout)) {
console.log(`${C.green}✅ 已批准: ${item.requestId} (${item.remoteIp || ''})${C.reset}`);
success++;
} else {
console.log(`${C.red}❌ 批准失败: ${item.requestId} — ${res.stdout || res.stderr}${C.reset}`);
}
}
fixStatePermissions();
console.log(`\n${C.green}操作完成:成功 ${success}/${pending.length} 个${C.reset}`);
await input({ prompt: '按回车继续', defaultValue: '' });
}
} else if (choice.value === 'approve-select') {
const selectItems = pending.map((item, idx) => ({
key: String(idx + 1),
label: `批准: ${item.remoteIp || '设备'} (${(item.requestId || '').slice(0, 8)}...)`,
desc: `IP: ${item.remoteIp || ''} | ${item.platform || ''}`,
value: item.requestId,
}));
selectItems.push({ label: '', disabled: true });
selectItems.push({ key: '0', label: '取消', desc: '', value: 'cancel' });
const picked = await select({
title: '请选择要批准的设备请求',
showSearch: false,
items: selectItems,
});
if (picked && picked.value !== 'cancel') {
const res = ocCmdCapture('devices', 'approve', picked.value);
if (res.ok || /approved|success/i.test(res.stdout)) {
fixStatePermissions();
console.log(`\n${C.green}✅ 成功批准设备请求: ${picked.value}${C.reset}`);
} else {
console.log(`\n${C.red}❌ 批准失败: ${res.stdout || res.stderr}${C.reset}`);
}
await input({ prompt: '按回车继续', defaultValue: '' });
}
}
} else {
console.log(`${C.dim}当前无待配对设备请求。${C.reset}`);
console.log(`${C.dim}(在浏览器访问 OpenClaw Web 控制台时若提示「需要设备配对」,在此处刷新即可发现)${C.reset}\n`);
const choice = await select({
title: '设备配对管理',
showSearch: false,
items: [
{ key: '1', label: '刷新待配对列表', desc: '', value: 'refresh' },
{ key: '2', label: '手动输入 Request ID 批准', desc: '', value: 'manual' },
{ key: '3', label: '查看已配对设备列表', desc: `当前已配对 ${paired.length} 台设备`, value: 'view-paired' },
{ label: '', disabled: true },
{ key: '0', label: '返回上级菜单', desc: '', value: 'back' },
],
});
if (!choice || choice.value === 'back') break;
if (choice.value === 'refresh') continue;
if (choice.value === 'manual') {
const reqId = await input({ prompt: '请输入待批准的 Request ID', defaultValue: '' });
if (reqId && reqId.trim()) {
const res = ocCmdCapture('devices', 'approve', reqId.trim());
if (res.ok || /approved|success/i.test(res.stdout)) {
fixStatePermissions();
console.log(`\n${C.green}✅ 成功批准设备请求: ${reqId.trim()}${C.reset}`);
} else {
console.log(`\n${C.red}❌ 批准失败: ${res.stdout || res.stderr}${C.reset}`);
}
await input({ prompt: '按回车继续', defaultValue: '' });
}
} else if (choice.value === 'view-paired') {
renderPairedDevices(paired);
await input({ prompt: '按回车继续', defaultValue: '' });
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 高级配置处理 (对应 advanced_menu)
// ═══════════════════════════════════════════════════════════════════════════
@@ -2007,12 +2288,21 @@ async function handleAdvancedConfig() {
}
break;
}
case 'wizard':
case 'wizard': {
resetRenderCount();
console.log(`\n${C.cyan}启动官方配置向导...${C.reset}\n`);
console.log(`${C.yellow}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${C.reset}\n`);
let ok = false;
try {
enableAuthPlugins();
await ocCmd('configure');
} catch (e) {}
ok = true;
} catch (e) {
console.log(`${C.yellow}配置向导已退出${C.reset}\n`);
}
if (ok) await askRestart();
break;
}
case 'view-json':
console.log(`\n${C.cyan}配置文件路径: ${CONFIG_FILE}${C.reset}\n`);
try {
@@ -2039,6 +2329,8 @@ async function handleAdvancedConfig() {
if (importPath && fs.existsSync(importPath)) {
try {
fs.copyFileSync(importPath, CONFIG_FILE);
execSync(`chown openclaw:openclaw "${CONFIG_FILE}" 2>/dev/null || true`, { stdio: 'ignore' });
fixStatePermissions();
console.log(`\n${C.green}✅ 配置已导入${C.reset}\n`);
await askRestart();
} catch (e) {
@@ -2049,6 +2341,9 @@ async function handleAdvancedConfig() {
}
break;
}
case 'devices':
await manageDevicePairing();
break;
}
}
}
@@ -2074,7 +2369,6 @@ async function handleReset() {
jsonSet('gateway.bind', 'lan');
jsonSet('gateway.mode', 'local');
jsonSet('gateway.controlUi.allowInsecureAuth', true);
jsonSet('gateway.controlUi.dangerouslyDisableDeviceAuth', true);
jsonSet('gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback', true);
jsonSet('gateway.tailscale.mode', 'off');
console.log(`\n${C.green}✅ 网关设置已恢复默认${C.reset}\n`);
@@ -2154,7 +2448,6 @@ async function handleReset() {
jsonSet('gateway.auth.mode', 'token');
jsonSet('gateway.auth.token', newToken);
jsonSet('gateway.controlUi.allowInsecureAuth', true);
jsonSet('gateway.controlUi.dangerouslyDisableDeviceAuth', true);
jsonSet('gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback', true);
jsonSet('gateway.tailscale.mode', 'off');
jsonSet('acp.dispatch.enabled', false);
@@ -2330,6 +2623,9 @@ async function main() {
resetRenderCount();
await restartGateway();
break;
case 'devices':
await manageDevicePairing();
break;
case 'advanced':
await handleAdvancedConfig();
break;
@@ -50,7 +50,10 @@ fi
NODE_BASE="${NODE_BASE:-${OC_INSTALL_PATH}/node}"
OC_GLOBAL="${OC_GLOBAL:-${OC_INSTALL_PATH}/global}"
OC_DATA="${OC_DATA:-${OC_INSTALL_PATH}/data}"
NODE_BIN="${NODE_BASE}/bin/node"
NODE_BIN="${NODE_BIN:-${NODE_BASE}/bin/node}"
if [ ! -x "$NODE_BIN" ] && command -v node >/dev/null 2>&1; then
NODE_BIN="$(command -v node)"
fi
OC_STATE_DIR="${OC_DATA}/.openclaw"
CONFIG_FILE="${OC_STATE_DIR}/openclaw.json"
@@ -89,7 +92,7 @@ fi
oc_cmd() {
if [ -n "$OC_ENTRY" ] && [ -x "$NODE_BIN" ]; then
"$NODE_BIN" "$OC_ENTRY" "$@" 2>&1
"$NODE_BIN" "$OC_ENTRY" "$@"
local rc=$?
# 修复权限: oc_cmd 以 root 运行但配置文件应属于 openclaw 用户
fix_openclaw_state_permissions 2>/dev/null || true
@@ -116,13 +119,17 @@ oc_doctor_as_openclaw() {
oc_cmd doctor
return $?
fi
local _doc_cmd="HOME=\"${OC_DATA}\" OPENCLAW_HOME=\"${OC_DATA}\" OPENCLAW_STATE_DIR=\"${OC_DATA}/.openclaw\" OPENCLAW_CONFIG_PATH=\"${CONFIG_FILE}\" NODE_ICU_DATA=\"${NODE_BASE}/share/icu\" \"${NODE_BIN}\" \"${OC_ENTRY}\" doctor"
local _doc_cmd="cd \"${OC_DATA}\" && HOME=\"${OC_DATA}\" OPENCLAW_HOME=\"${OC_DATA}\" OPENCLAW_STATE_DIR=\"${OC_DATA}/.openclaw\" OPENCLAW_CONFIG_PATH=\"${CONFIG_FILE}\" NODE_ICU_DATA=\"${NODE_BASE}/share/icu\" \"${NODE_BIN}\" \"${OC_ENTRY}\" doctor"
if command -v su >/dev/null 2>&1; then
su -s /bin/sh openclaw -c "${_doc_cmd}" 2>&1
elif command -v runuser >/dev/null 2>&1; then
runuser -u openclaw -- sh -c "${_doc_cmd}" 2>&1
else
start-stop-daemon -S -m -p /tmp/openclaw-doctor-$$.pid -c openclaw:openclaw -x /bin/sh -- -c "${_doc_cmd}" 2>&1
local _doc_pid="/tmp/openclaw-doctor-$$.pid"
(cd "${OC_DATA}" 2>/dev/null || true; start-stop-daemon -S -m -p "$_doc_pid" -c openclaw:openclaw -x /bin/sh -- -c "${_doc_cmd}" 2>&1)
local rc=$?
rm -f "$_doc_pid" 2>/dev/null || true
return $rc
fi
}
@@ -262,8 +269,6 @@ oc_schema_type_for_key() {
echo "boolean" ;;
gateway.controlUi.allowInsecureAuth)
echo "boolean" ;;
gateway.controlUi.dangerouslyDisableDeviceAuth)
echo "boolean" ;;
gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback)
echo "boolean" ;;
gateway.allowRealIpFallback)
@@ -291,7 +296,34 @@ oc_schema_enum_for_key() {
json_set() {
local key="$1" value="$2"
local _js_err=""
# 检查是否处于升级事务迁移期间,若是则暂停写入配置
local _sdir="${OC_STATE_DIR:-${OPENCLAW_STATE_DIR:-${OC_DATA:+$OC_DATA/.openclaw}}}"
local _pdir="$(dirname "$_sdir")"
local _rdir="$(dirname "$_pdir")"
local _status_file="" cand
for cand in \
"${_rdir}/.luci-openclaw-upgrade/status.json" \
"${_pdir}/.luci-openclaw-upgrade/status.json" \
"${_sdir}/.luci-openclaw-upgrade/status.json" \
"${OC_DATA:+$OC_DATA/.luci-openclaw-upgrade/status.json}" \
"${OC_BASE_PATH:-/opt}/openclaw/.luci-openclaw-upgrade/status.json"; do
if [ -n "$cand" ] && [ -f "$cand" ]; then
_status_file="$cand"
break
fi
done
if [ -n "$_status_file" ] && [ -f "$_status_file" ]; then
local _up_phase
_up_phase=$(grep -oE '"phase"[[:space:]]*:[[:space:]]*"[^"]+"' "$_status_file" 2>/dev/null | cut -d'"' -f4 || true)
case "$_up_phase" in
migrating|gateway_verifying|backing_up)
echo "WARN: 检测到系统正处于升级事务 (phase: $_up_phase),暂停写入 openclaw.json" >&2
return 0
;;
esac
fi
# 步骤1: 确保配置文件存在
if [ ! -f "$CONFIG_FILE" ]; then
# 检查父目录是否可创建
@@ -928,50 +960,54 @@ show_current_config() {
# 配置 AI 模型
# ══════════════════════════════════════════════════════════════
configure_model() {
echo ""
echo -e " ${BOLD}🤖 配置 AI 模型和提供商${NC}"
echo ""
echo -e " ${GREEN}${BOLD}🌟 ── 推荐 ──${NC}"
echo -e " ${CYAN}w)${NC} 🌟 官方完整模型配置向导 ${GREEN}(推荐,支持所有提供商)${NC}"
echo ""
echo -e " ${BOLD}🌍 ── 国外模型提供商 ──${NC}"
echo -e " ${CYAN}a)${NC} OpenAI (GPT-5.2, GPT-5 mini, GPT-4.1)"
echo -e " ${CYAN}b)${NC} Anthropic (Claude Sonnet 4, Opus 4, Haiku)"
echo -e " ${CYAN}c)${NC} Google Gemini (Gemini 2.5 Pro/Flash, Gemini 3)"
echo -e " ${CYAN}d)${NC} OpenRouter (聚合多家模型)"
echo -e " ${CYAN}e)${NC} GitHub Copilot (需要 Copilot 订阅)"
echo -e " ${CYAN}f)${NC} xAI Grok (Grok-4/3)"
echo ""
echo -e " ${BOLD}🇨🇳 ── 国内模型提供商 ──${NC}"
echo -e " ${CYAN}g)${NC} 阿里云通义千问 Qwen (Portal/API/Coding Plan)"
echo -e " ${CYAN}h)${NC} 硅基流动 SiliconFlow"
echo -e " ${CYAN}i)${NC} 腾讯云 Coding Plan (HY T1/TurboS/GLM-5/Kimi)"
echo -e " ${CYAN}j)${NC} 百度千帆 (ERNIE-4.0, ERNIE-3.5)"
echo -e " ${CYAN}k)${NC} 智谱 GLM / Z.AI (GLM-5.1, GLM-5)"
echo ""
echo -e " ${BOLD}🏠 ── 本地模型 / 自定义 API ──${NC}"
echo -e " ${CYAN}l)${NC} Ollama (本地模型,无需 API Key)"
echo -e " ${CYAN}m)${NC} 自定义 OpenAI 兼容 API"
echo -e " ${CYAN}n)${NC} 自定义 Anthropic 兼容 API"
echo -e " ${CYAN}o)${NC} 一万AI分享 粉丝专享 API"
echo ""
echo -e " ${CYAN}q)${NC} 返回"
echo ""
prompt_with_default "请选择" "w" choice
while true; do
local configured=0
echo ""
echo -e " ${BOLD}🤖 配置 AI 模型和提供商${NC}"
echo ""
echo -e " ${GREEN}${BOLD}🌟 ── 推荐 ──${NC}"
echo -e " ${CYAN}w)${NC} 🌟 官方完整模型配置向导 ${GREEN}(推荐,支持所有提供商)${NC}"
echo ""
echo -e " ${BOLD}🌍 ── 国外模型提供商 ──${NC}"
echo -e " ${CYAN}a)${NC} OpenAI"
echo -e " ${CYAN}b)${NC} Anthropic"
echo -e " ${CYAN}c)${NC} Google Gemini"
echo -e " ${CYAN}d)${NC} OpenRouter (聚合多家模型)"
echo -e " ${CYAN}e)${NC} GitHub Copilot (需要 Copilot 订阅)"
echo -e " ${CYAN}f)${NC} xAI Grok"
echo ""
echo -e " ${BOLD}🇨🇳 ── 国内模型提供商 ──${NC}"
echo -e " ${CYAN}g)${NC} 阿里云通义千问 Qwen"
echo -e " ${CYAN}h)${NC} 硅基流动 SiliconFlow"
echo -e " ${CYAN}i)${NC} 腾讯云 Coding Plan"
echo -e " ${CYAN}j)${NC} 百度千帆"
echo -e " ${CYAN}k)${NC} 智谱 GLM / Z.AI"
echo ""
echo -e " ${BOLD}🏠 ── 本地模型 / 自定义 API ──${NC}"
echo -e " ${CYAN}l)${NC} Ollama (本地模型,无需 API Key)"
echo -e " ${CYAN}m)${NC} 自定义 OpenAI 兼容 API"
echo -e " ${CYAN}n)${NC} 自定义 Anthropic 兼容 API"
echo -e " ${CYAN}o)${NC} 一万AI分享 粉丝专享 API"
echo ""
echo -e " ${CYAN}q)${NC} 返回"
echo ""
prompt_with_default "请选择" "w" choice
case "$choice" in
w)
echo ""
echo -e " ${CYAN}启动官方完整模型配置向导...${NC}"
echo -e " ${YELLOW}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${NC}"
echo ""
echo -e " ${CYAN}清理过时插件配置...${NC}"
enable_auth_plugins
echo ""
(oc_cmd configure --section model) || echo -e " ${YELLOW}配置向导已退出${NC}"
echo ""
ask_restart
;;
case "$choice" in
w)
echo ""
echo -e " ${CYAN}启动官方完整模型配置向导...${NC}"
echo -e " ${YELLOW}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${NC}"
echo ""
echo -e " ${CYAN}清理过时插件配置...${NC}"
enable_auth_plugins
echo ""
if oc_cmd configure --section model; then
configured=1
else
echo -e " ${YELLOW}配置向导已退出${NC}"
fi
;;
a)
echo ""
echo -e " ${BOLD}OpenAI 配置${NC}"
@@ -987,6 +1023,7 @@ configure_model() {
model_name="$OC_PICKED_MODEL"
register_and_set_model "openai/${model_name}"
echo -e " ${GREEN}✅ OpenAI 已配置,活跃模型: openai/${model_name}${NC}"
configured=1
fi
;;
b)
@@ -1004,6 +1041,7 @@ configure_model() {
model_name="$OC_PICKED_MODEL"
register_and_set_model "anthropic/${model_name}"
echo -e " ${GREEN}✅ Anthropic 已配置,活跃模型: anthropic/${model_name}${NC}"
configured=1
fi
;;
c)
@@ -1021,6 +1059,7 @@ configure_model() {
model_name="$OC_PICKED_MODEL"
register_and_set_model "google/${model_name}"
echo -e " ${GREEN}✅ Google Gemini 已配置,活跃模型: google/${model_name}${NC}"
configured=1
fi
;;
d)
@@ -1039,6 +1078,7 @@ configure_model() {
model_name="$OC_PICKED_MODEL"
register_and_set_model "openrouter/${model_name}"
echo -e " ${GREEN}✅ OpenRouter 已配置,活跃模型: openrouter/${model_name}${NC}"
configured=1
fi
;;
e)
@@ -1062,6 +1102,7 @@ configure_model() {
model_name="github-copilot/$OC_PICKED_MODEL"
register_and_set_model "$model_name"
echo -e " ${GREEN}✅ 活跃模型已设置: ${model_name}${NC}"
configured=1
else
echo -e " ${YELLOW}OAuth 授权已退出或失败${NC}"
fi
@@ -1084,10 +1125,11 @@ configure_model() {
echo -e " ${CYAN}启用 Qwen Portal Auth 插件...${NC}"
enable_auth_plugins
echo -e " ${CYAN}启动 Qwen OAuth 授权...${NC}"
oc_cmd models auth login --provider qwen-portal --set-default || echo -e " ${YELLOW}OAuth 授权已退出${NC}"
echo ""
ask_restart
return
if oc_cmd models auth login --provider qwen-portal --set-default; then
configured=1
else
echo -e " ${YELLOW}OAuth 授权已退出${NC}"
fi
;;
b)
echo ""
@@ -1107,6 +1149,7 @@ configure_model() {
register_custom_provider dashscope "https://dashscope.aliyuncs.com/compatible-mode/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "dashscope/${model_name}"
echo -e " ${GREEN}✅ 通义千问已配置 (按量付费),活跃模型: dashscope/${model_name}${NC}"
configured=1
fi
;;
c|*)
@@ -1149,6 +1192,7 @@ configure_model() {
register_and_set_model "bailian/${model_name}"
echo -e " ${GREEN}✅ Coding Plan 已配置,活跃模型: bailian/${model_name}${NC}"
echo -e " ${DIM}提示: 套餐内全部模型已注册,可随时在 WebChat 中通过 /model 切换${NC}"
configured=1
fi
;;
esac
@@ -1168,6 +1212,7 @@ configure_model() {
auth_set_apikey xai "$api_key"
register_and_set_model "xai/${model_name}"
echo -e " ${GREEN}✅ xAI Grok 已配置,活跃模型: xai/${model_name}${NC}"
configured=1
fi
;;
h)
@@ -1200,6 +1245,7 @@ configure_model() {
register_custom_provider siliconflow "https://api.siliconflow.cn/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "siliconflow/${model_name}"
echo -e " ${GREEN}✅ SiliconFlow 已配置,活跃模型: siliconflow/${model_name}${NC}"
configured=1
fi
;;
l)
@@ -1315,6 +1361,7 @@ configure_model() {
register_and_set_model "ollama/${model_name}"
echo -e " ${GREEN}✅ Ollama 已配置,活跃模型: ollama/${model_name}${NC}"
echo -e " ${CYAN} Ollama 地址: ${ollama_url}${NC}"
configured=1
fi
fi
;;
@@ -1342,6 +1389,7 @@ configure_model() {
register_and_set_model "lkeap/${model_name}"
echo -e " ${GREEN}✅ 腾讯云 Coding Plan 已配置,活跃模型: lkeap/${model_name}${NC}"
echo -e " ${DIM}提示: 套餐内全部模型已注册,可随时在 WebChat 中通过 /model 切换${NC}"
configured=1
fi
;;
j)
@@ -1365,6 +1413,7 @@ configure_model() {
register_custom_provider qianfan "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop" "$api_key" "$model_name" "$model_name"
register_and_set_model "qianfan/${model_name}"
echo -e " ${GREEN}✅ 百度千帆已配置,活跃模型: qianfan/${model_name}${NC}"
configured=1
fi
;;
k)
@@ -1413,6 +1462,7 @@ configure_model() {
register_and_set_model "zai/${model_name}"
echo -e " ${GREEN}✅ 智谱 GLM 已配置,活跃模型: zai/${model_name}${NC}"
echo -e " ${DIM} Base URL: ${zai_base_url}${NC}"
configured=1
fi
;;
m)
@@ -1428,6 +1478,7 @@ configure_model() {
register_custom_provider openai-compatible "$base_url" "$api_key" "$model_name" "$model_name"
register_and_set_model "openai-compatible/${model_name}"
echo -e " ${GREEN}✅ 自定义模型已配置,活跃模型: openai-compatible/${model_name}${NC}"
configured=1
fi
;;
n)
@@ -1466,6 +1517,7 @@ configure_model() {
auth_set_apikey anthropic-compatible "$api_key" "anthropic-compatible:manual"
register_and_set_model "anthropic-compatible/${model_name}"
echo -e " ${GREEN}✅ 自定义 Anthropic API 已配置,活跃模型: anthropic-compatible/${model_name}${NC}"
configured=1
fi
;;
o)
@@ -1492,15 +1544,18 @@ configure_model() {
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
register_and_set_model "yiwanai/gpt-5.5"
echo -e " ${GREEN}✅ 一万AI分享粉丝专享 API 已配置,活跃模型: yiwanai/gpt-5.5${NC}"
configured=1
fi
;;
q) return ;;
*) echo -e " ${YELLOW}无效选择${NC}" ;;
esac
if [ "$choice" != "0" ] && [ "$choice" != "1" ]; then
if [ "$configured" -eq 1 ]; then
echo ""
ask_restart
fi
done
}
# ══════════════════════════════════════════════════════════════
@@ -1601,7 +1656,7 @@ configure_qq() {
plugin_blocked=1
echo -e " ${YELLOW}⚠️ qqbot 插件已安装但未能正常加载${NC}"
echo -e " ${CYAN}正在修复插件目录权限...${NC}"
chown -R root:root "$qqbot_ext_dir" 2>/dev/null
chown -R openclaw:openclaw "$qqbot_ext_dir" 2>/dev/null
chmod -R 755 "$qqbot_ext_dir" 2>/dev/null
echo -e " ${GREEN}✅ 权限已修复,重启 Gateway 后生效${NC}"
plugin_installed=1
@@ -1610,7 +1665,7 @@ configure_qq() {
# 目录存在、有 plugin.json 但未出现在插件列表 — 修复权限
echo -e " ${YELLOW}⚠️ qqbot 插件目录存在但未能加载${NC}"
echo -e " ${CYAN}正在修复插件目录权限...${NC}"
chown -R root:root "$qqbot_ext_dir" 2>/dev/null
chown -R openclaw:openclaw "$qqbot_ext_dir" 2>/dev/null
chmod -R 755 "$qqbot_ext_dir" 2>/dev/null
echo -e " ${GREEN}✅ 权限已修复${NC}"
plugin_installed=1
@@ -1629,10 +1684,10 @@ configure_qq() {
install_out=$(oc_cmd plugins install @tencent-connect/openclaw-qqbot@latest 2>&1)
local install_rc=$?
# 关键: 安装后立即修复插件目录权限为 root (OpenClaw 安全策略要求)
# 关键: 保持插件目录权限为 openclaw:openclaw (755),符合 OpenClaw 上游 discovery 安全校验规则
# 同时修复权限模式为 755,确保 Gateway 可读取插件
if [ -d "$qqbot_ext_dir" ]; then
chown -R root:root "$qqbot_ext_dir" 2>/dev/null
chown -R openclaw:openclaw "$qqbot_ext_dir" 2>/dev/null
chmod -R 755 "$qqbot_ext_dir" 2>/dev/null
fi
@@ -2093,7 +2148,15 @@ configure_channels() {
8)
echo ""
echo -e " ${CYAN}启动官方渠道配置向导...${NC}"
(oc_cmd configure --section channels) || echo -e " ${YELLOW}配置向导已退出${NC}"
echo -e " ${YELLOW}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${NC}"
echo ""
enable_auth_plugins
if oc_cmd configure --section channels; then
echo ""
ask_restart
else
echo -e " ${YELLOW}配置向导已退出${NC}"
fi
;;
0) return ;;
*) echo -e " ${YELLOW}无效选择${NC}" ;;
@@ -2242,7 +2305,6 @@ reset_to_defaults() {
json_set gateway.bind lan 2>&1
json_set gateway.mode local 2>&1
json_set gateway.controlUi.allowInsecureAuth true 2>&1
json_set gateway.controlUi.dangerouslyDisableDeviceAuth true 2>&1
json_set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback true 2>&1
json_set gateway.tailscale.mode off 2>&1
echo -e " ${GREEN}✅ 网关设置已恢复默认${NC}"
@@ -2423,7 +2485,6 @@ reset_to_defaults() {
json_set gateway.auth.mode token
json_set gateway.auth.token "$new_token"
json_set gateway.controlUi.allowInsecureAuth true
json_set gateway.controlUi.dangerouslyDisableDeviceAuth true
json_set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback true
json_set gateway.tailscale.mode off
json_set acp.dispatch.enabled false
@@ -2484,10 +2545,14 @@ backup_restore_menu() {
echo ""
prompt_with_default "请选择" "1" backup_choice
# 备份目录 (openclaw backup create 输出到 CWD)
local backup_dir="${OC_STATE_DIR}/backups"
# 备份目录 (必须位于 State 目录之外,避免归档递归)
local backup_dir="${OPENCLAW_BACKUP_DIR:-${OC_DATA}/backups}"
mkdir -p "$backup_dir" 2>/dev/null
local state_helper="/usr/libexec/openclaw-upgrade-state.sh"
[ -x "$state_helper" ] || state_helper="${_script_dir}/../libexec/openclaw-upgrade-state.sh"
[ -x "$state_helper" ] || state_helper="./root/usr/libexec/openclaw-upgrade-state.sh"
case "$backup_choice" in
1)
echo -e " ${CYAN}正在创建配置备份...${NC}"
@@ -2505,14 +2570,16 @@ backup_restore_menu() {
2)
echo -e " ${CYAN}正在创建完整备份...${NC}"
echo -e " ${DIM}(包含配置和状态数据,可能需要较长时间)${NC}"
local out
out=$(cd "$backup_dir" && HOME="$backup_dir" oc_cmd backup create --no-include-workspace 2>&1)
local rc=$?
local out rc=0
if [ -x "$state_helper" ]; then
out=$("$state_helper" backup-create "$backup_dir" "$OC_STATE_DIR" 2>&1) || rc=$?
else
out=$(cd "$backup_dir" && HOME="$backup_dir" oc_cmd backup create --no-include-workspace 2>&1) || rc=$?
for f in "${OC_DATA}"/*-openclaw-backup.tar.gz; do
[ -f "$f" ] && mv "$f" "$backup_dir/" 2>/dev/null
done
fi
echo "$out"
# 完整备份可能输出到 HOME,尝试移动到 backup_dir
for f in "${OC_DATA}"/*-openclaw-backup.tar.gz; do
[ -f "$f" ] && mv "$f" "$backup_dir/" 2>/dev/null
done
if [ $rc -eq 0 ] && echo "$out" | grep -q "\.tar\.gz"; then
echo -e " ${GREEN}✅ 完整备份已创建${NC}"
else
@@ -2521,25 +2588,29 @@ backup_restore_menu() {
fi
;;
3)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz "${backup_dir}"/*.tar.gz 2>/dev/null | head -1)
if [ -z "$latest" ]; then
# 也检查旧位置
latest=$(ls -t "${OC_STATE_DIR}"/*-openclaw-backup.tar.gz "${OC_DATA}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
latest=$(ls -t "${OC_STATE_DIR}/backups"/*-openclaw-backup.tar.gz "${OC_STATE_DIR}"/*-openclaw-backup.tar.gz "${OC_DATA}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
fi
if [ -z "$latest" ]; then
echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}"
else
echo -e " ${CYAN}验证备份: ${latest}${NC}"
oc_cmd backup verify "$latest" 2>&1
if [ -x "$state_helper" ]; then
"$state_helper" backup-verify "$latest" "$OC_STATE_DIR" 2>&1
else
oc_cmd backup verify "$latest" 2>&1
fi
fi
;;
4)
echo ""
if [ -d "$backup_dir" ]; then
local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | wc -l)
local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz "${backup_dir}"/*.tar.gz 2>/dev/null | wc -l)
if [ "$count" -gt 0 ] 2>/dev/null; then
echo -e " ${BOLD}备份文件列表:${NC}"
ls -lh "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | while read line; do
ls -lh "${backup_dir}"/*-openclaw-backup.tar.gz "${backup_dir}"/*.tar.gz 2>/dev/null | while read line; do
echo -e " ${DIM}${line}${NC}"
done
else
@@ -2552,7 +2623,7 @@ backup_restore_menu() {
echo -e " ${DIM}备份目录: ${backup_dir}${NC}"
;;
5)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz "${backup_dir}"/*.tar.gz 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}"
else
@@ -2562,36 +2633,31 @@ backup_restore_menu() {
echo -e " ${YELLOW}⚠️ 这会还原备份中的所有配置和数据文件到原路径!${NC}"
prompt_with_default "确认恢复? (y/N)" "N" confirm_restore
if [ "$confirm_restore" = "y" ] || [ "$confirm_restore" = "Y" ]; then
# 验证备份中 openclaw.json 有效
local tmp_json="/tmp/oc-restore-check.json"
tar -xzf "$latest" --wildcards '*/openclaw.json' -O > "$tmp_json" 2>/dev/null
if [ ! -s "$tmp_json" ] || ! "$NODE_BIN" -e "JSON.parse(require('fs').readFileSync('${tmp_json}','utf8'))" 2>/dev/null; then
rm -f "$tmp_json"
echo -e " ${RED}❌ 备份中的配置文件无效,恢复已取消${NC}"
else
rm -f "$tmp_json"
# 备份当前配置
cp -f "$CONFIG_FILE" "${CONFIG_FILE}.pre-restore" 2>/dev/null
# 获取备份名前缀
local backup_name=$(tar -tzf "$latest" 2>/dev/null | head -1 | cut -d/ -f1)
if [ -z "$backup_name" ]; then
echo -e " ${RED}❌ 备份文件格式无法识别${NC}"
else
echo -e " ${DIM}正在还原文件...${NC}"
# 停止服务
/etc/init.d/openclaw stop >/dev/null 2>&1
sleep 2
# 提取 payload 到根目录 (还原到原始绝对路径)
tar -xzf "$latest" --strip-components=3 -C / "${backup_name}/payload/posix/" 2>&1
# 修复权限
fix_openclaw_state_permissions 2>/dev/null || true
echo -e " ${GREEN}✅ 配置和数据已完整恢复!原配置已保存为 openclaw.json.pre-restore${NC}"
echo ""
prompt_with_default "是否重启服务使配置生效? (Y/n)" "Y" do_restart
if [ "$do_restart" != "n" ] && [ "$do_restart" != "N" ]; then
restart_gateway
fi
echo -e " ${DIM}正在安全还原 State 状态...${NC}"
/etc/init.d/openclaw stop >/dev/null 2>&1 || true
sleep 2
local restore_ok=0
if [ -x "$state_helper" ]; then
if "$state_helper" restore-state "$latest" "$OC_STATE_DIR" 2>&1; then
restore_ok=1
fi
else
# 兜底旧逻辑
local backup_name=$(tar -tzf "$latest" 2>/dev/null | head -1 | cut -d/ -f1)
if [ -n "$backup_name" ]; then
tar -xzf "$latest" --strip-components=3 -C / "${backup_name}/payload/posix/" 2>&1 && restore_ok=1
fi
fi
if [ "$restore_ok" -eq 1 ]; then
fix_openclaw_state_permissions 2>/dev/null || true
echo -e " ${GREEN}✅ 配置和数据已完整恢复!${NC}"
echo ""
prompt_with_default "是否重启服务使配置生效? (Y/n)" "Y" do_restart
if [ "$do_restart" != "n" ] && [ "$do_restart" != "N" ]; then
restart_gateway
fi
else
echo -e " ${RED}❌ 恢复失败,已安全保留原状态与备份${NC}"
fi
else
echo -e " ${DIM}已取消${NC}"
@@ -2651,6 +2717,163 @@ launch_interactive_model_config() {
return $rc
}
# ── 设备配对管理 (Control UI / 浏览器配对审批) ──
devices_pairing_menu() {
while true; do
echo ""
echo -e " ${BOLD}📱 设备配对管理 (Control UI / 浏览器配对审批)${NC}"
echo ""
echo -e " ${YELLOW}⚠️ 风险提示:${NC}"
echo -e " ${YELLOW} 批准设备配对后,该浏览器/客户端将获得 OpenClaw 网关的完全控制权限。${NC}"
echo -e " ${YELLOW} 请确保在受信任的局域网环境,并仅在您本人正在连接时批准!${NC}"
echo ""
local list_json
list_json=$(oc_cmd devices list --json 2>/dev/null || echo "")
local pending_count=0
local parsed=""
if [ -n "$list_json" ] && [ -x "$NODE_BIN" ]; then
parsed=$("$NODE_BIN" -e '
let data = null;
try {
data = JSON.parse(process.argv[1]);
} catch (_) {
const m = (process.argv[1] || "").match(/(\{[\s\S]*"pending"[\s\S]*\})/);
if (m) {
try { data = JSON.parse(m[1]); } catch (_) {}
}
}
const pending = (data && Array.isArray(data.pending)) ? data.pending : [];
console.log("COUNT=" + pending.length);
pending.forEach((item) => {
const rid = item.requestId || "";
const ip = item.remoteIp || item.ip || "未知IP";
const client = item.clientId || item.clientMode || "webchat";
const plat = item.platform || "";
const role = item.role || (item.roles && item.roles.join(",")) || "operator";
console.log("ITEM\t" + rid + "\t" + ip + "\t" + client + "\t" + plat + "\t" + role);
});
' "$list_json" 2>/dev/null || echo "COUNT=0")
pending_count=$(echo "$parsed" | grep '^COUNT=' | cut -d= -f2)
pending_count=${pending_count:-0}
fi
if [ "$pending_count" -gt 0 ]; then
echo -e " ${GREEN}🔔 当前检测到 ${pending_count} 个待配对请求:${NC}"
echo ""
local idx=1
echo "$parsed" | grep '^ITEM' | while IFS="$(printf '\t')" read -r _ rid ip client plat role; do
echo -e " ${CYAN}[$idx]${NC} 请求 ID: ${BOLD}${rid}${NC}"
echo -e " 来源 IP: ${GREEN}${ip}${NC} | 客户端: ${client} (${plat}) | 角色: ${role}"
idx=$((idx + 1))
done
echo ""
echo -e " ${CYAN}1)${NC} ⚡ 一键批准所有待配对请求"
echo -e " ${CYAN}2)${NC} 🔍 选择指定请求批准"
echo -e " ${CYAN}3)${NC} 🔄 刷新待配对列表"
echo -e " ${CYAN}0)${NC} 返回上级菜单"
echo ""
prompt_with_default "请选择" "1" dev_choice
case "$dev_choice" in
1)
echo ""
echo -e " ${CYAN}正在批准所有设备配对请求...${NC}"
echo "$parsed" | grep '^ITEM' | cut -f2 | tr -d '\r' | while read -r rid; do
if [ -n "$rid" ]; then
local out
out=$(oc_cmd devices approve "$rid" 2>&1)
if echo "$out" | grep -qiE 'approved|success'; then
echo -e " ${GREEN}✅ 已批准: ${rid}${NC}"
else
echo -e " ${YELLOW}⚠️ 批准结果: ${out}${NC}"
fi
fi
done
fix_openclaw_state_permissions
echo ""
prompt_with_default "按回车继续" "" _
;;
2)
echo ""
prompt_with_default "请输入要批准的请求编号 (1-${pending_count}) 或直接输入 Request ID" "1" pick_val
local target_id=""
case "$pick_val" in
''|0|*[!0-9]*) target_id="$pick_val" ;;
*)
if [ "$pick_val" -ge 1 ] 2>/dev/null && [ "$pick_val" -le "$pending_count" ] 2>/dev/null; then
target_id=$(echo "$parsed" | grep '^ITEM' | sed -n "${pick_val}p" | cut -f2 | tr -d '\r')
else
target_id="$pick_val"
fi
;;
esac
if [ -n "$target_id" ]; then
echo ""
echo -e " ${CYAN}正在批准请求: ${target_id}...${NC}"
local out
out=$(oc_cmd devices approve "$target_id" 2>&1)
if echo "$out" | grep -qiE 'approved|success'; then
echo -e " ${GREEN}✅ 批准成功: ${target_id}${NC}"
else
echo -e " ${YELLOW}⚠️ 批准结果: ${out}${NC}"
fi
fix_openclaw_state_permissions
else
echo -e " ${YELLOW}未找到对应请求${NC}"
fi
echo ""
prompt_with_default "按回车继续" "" _
;;
3) ;;
0) return 0 ;;
*) echo -e " ${YELLOW}无效选择${NC}" ;;
esac
else
echo -e " ${DIM}当前无待配对设备请求。${NC}"
echo -e " ${DIM}(在浏览器访问 OpenClaw Web 控制台时若提示「需要设备配对」,在此处刷新即可发现)${NC}"
echo ""
echo -e " ${CYAN}1)${NC} 🔄 刷新待配对列表"
echo -e " ${CYAN}2)${NC} ⌨️ 手动输入 Request ID 批准"
echo -e " ${CYAN}3)${NC} 📋 查看完整设备列表 (含已配对)"
echo -e " ${CYAN}0)${NC} 返回上级菜单"
echo ""
prompt_with_default "请选择" "1" dev_choice
case "$dev_choice" in
1) ;;
2)
echo ""
prompt_with_default "请输入待批准的 Request ID" "" manual_rid
if [ -n "$manual_rid" ]; then
echo ""
local out
out=$(oc_cmd devices approve "$manual_rid" 2>&1)
if echo "$out" | grep -qiE 'approved|success'; then
echo -e " ${GREEN}✅ 批准成功: ${manual_rid}${NC}"
else
echo -e " ${YELLOW}⚠️ 批准结果: ${out}${NC}"
fi
fix_openclaw_state_permissions
echo ""
prompt_with_default "按回车继续" "" _
fi
;;
3)
echo ""
echo -e " ${CYAN}=== 设备列表 ===${NC}"
oc_cmd devices list 2>&1 || echo " (无法获取设备列表)"
echo ""
prompt_with_default "按回车继续" "" _
;;
0) return 0 ;;
*) echo -e " ${YELLOW}无效选择${NC}" ;;
esac
fi
done
}
main_menu() {
# 直接启动交互模式 (如果支持)
if can_use_interactive && [ "${OC_FORCE_TRADITIONAL:-0}" != "1" ]; then
@@ -2676,11 +2899,12 @@ main_menu() {
echo -e " ${CYAN}4)${NC} 🩺 健康检查与状态"
echo -e " ${CYAN}5)${NC} 📋 查看日志"
echo -e " ${CYAN}6)${NC} 🔄 重启 Gateway"
echo -e " ${CYAN}7)${NC} 📱 设备配对管理 (Control UI 审批)"
echo ""
echo -e " ${DIM}━━━ 高级选项 ━━━${NC}"
echo -e " ${CYAN}7)${NC} 🔧 高级配置"
echo -e " ${CYAN}8)${NC} ♻️ 重置配置"
echo -e " ${CYAN}9)${NC} 📊 显示当前配置概览"
echo -e " ${CYAN}8)${NC} 🔧 高级配置"
echo -e " ${CYAN}9)${NC} ♻️ 重置配置"
echo -e " ${CYAN}10)${NC} 📊 显示当前配置概览"
echo ""
echo -e " ${CYAN}0)${NC} 退出"
echo ""
@@ -2700,9 +2924,10 @@ main_menu() {
prompt_with_default "按回车继续" "" _
;;
6) restart_gateway ;;
7) advanced_menu ;;
8) reset_to_defaults ;;
9) show_current_config ;;
7) devices_pairing_menu ;;
8) advanced_menu ;;
9) reset_to_defaults ;;
10) show_current_config ;;
0)
echo -e " ${GREEN}再见!${NC}"
exit 0
@@ -2737,6 +2962,7 @@ advanced_menu() {
echo -e " ${CYAN}8)${NC} 编辑配置文件 ${DIM}(vi / nano)${NC}"
echo -e " ${CYAN}9)${NC} 导出配置备份"
echo -e " ${CYAN}10)${NC} 导入配置"
echo -e " ${CYAN}11)${NC} 📱 设备配对管理"
echo -e " ${CYAN}0)${NC} 返回主菜单"
echo ""
prompt_with_default "请选择" "0" adv_choice
@@ -2851,8 +3077,15 @@ advanced_menu() {
6)
echo ""
echo -e " ${CYAN}启动官方配置向导...${NC}"
oc_cmd configure
ask_restart
echo -e " ${YELLOW}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${NC}"
echo ""
enable_auth_plugins
if oc_cmd configure; then
echo ""
ask_restart
else
echo -e " ${YELLOW}配置向导已退出${NC}"
fi
;;
7)
echo ""
@@ -2898,6 +3131,7 @@ advanced_menu() {
echo -e " ${YELLOW}文件不存在${NC}"
fi
;;
11) devices_pairing_menu ;;
0) return ;;
*) echo -e " ${YELLOW}无效选择${NC}" ;;
esac
@@ -2957,7 +3191,7 @@ case "${1:-}" in
restart_gateway
;;
--backup)
bk_dir="${OC_STATE_DIR}/backups"
bk_dir="${OPENCLAW_BACKUP_DIR:-${OC_DATA}/backups}"
mkdir -p "$bk_dir" 2>/dev/null
echo -e "${CYAN}正在创建配置备份...${NC}"
cd "$bk_dir" && oc_cmd backup create --only-config --no-include-workspace 2>&1
@@ -316,6 +316,12 @@ async function select(options = {}) {
let done = false;
let result = null;
// 确保初始选中的不是 disabled 分隔线/标题项
if (items[selected] && items[selected].disabled) {
const firstActive = items.findIndex(i => !i.disabled);
if (firstActive !== -1) selected = firstActive;
}
// 保存终端状态
setRawMode(true);
process.stdout.write(C.hide);
@@ -336,30 +342,43 @@ async function select(options = {}) {
done = true;
}
} else if (key.name === 'up') {
// 上移
if (selected > 0) {
selected--;
// 上移: 自动跳过 disabled 分隔项
let next = selected - 1;
while (next >= 0 && filteredItems[next].disabled) next--;
if (next >= 0) {
selected = next;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.name === 'down') {
// 下移
if (selected < filteredItems.length - 1) {
selected++;
// 下移: 自动跳过 disabled 分隔项
let next = selected + 1;
while (next < filteredItems.length && filteredItems[next].disabled) next++;
if (next < filteredItems.length) {
selected = next;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
}
} else if (key.name === 'pageup') {
// 上翻页
selected = Math.max(0, selected - 10);
let target = Math.max(0, selected - 10);
while (target > 0 && filteredItems[target].disabled) target--;
if (filteredItems[target] && !filteredItems[target].disabled) selected = target;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'pagedown') {
// 下翻页
selected = Math.min(filteredItems.length - 1, selected + 10);
let target = Math.min(filteredItems.length - 1, selected + 10);
while (target < filteredItems.length - 1 && filteredItems[target].disabled) target++;
if (filteredItems[target] && !filteredItems[target].disabled) selected = target;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'home') {
selected = 0;
let first = filteredItems.findIndex(i => !i.disabled);
if (first !== -1) selected = first;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (key.name === 'end') {
selected = filteredItems.length - 1;
let last = -1;
for (let i = filteredItems.length - 1; i >= 0; i--) {
if (!filteredItems[i].disabled) { last = i; break; }
}
if (last !== -1) selected = last;
filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch });
} else if (exitKeys.includes(key.name) && allowCancel) {
// 取消/退出
@@ -382,7 +401,7 @@ async function select(options = {}) {
// 直接按键选择 (如果菜单项有 key 属性)
if (key.char && !showSearch) {
const matchByChar = items.find(item => (item.key || '').toLowerCase() === key.name);
const matchByChar = items.find(item => !item.disabled && (item.key || '').toLowerCase() === key.name);
if (matchByChar) {
result = matchByChar;
done = true;
@@ -0,0 +1,577 @@
#!/usr/bin/env node
/**
* openclaw-package-contract.js
*
* OpenClaw 与 Node.js 运行时及包清单不可变契约校验工具。
*
* 契约规范:
* 1. OpenClaw @ 2026.9.1 默认清单白名单校验:
* - name = "openclaw"
* - version = "2026.9.1"
* - bin = "openclaw.mjs"
* - preinstall = "node scripts/preinstall-package-manager-warning.mjs"
* - postinstall = "node scripts/postinstall-bundled-plugins.mjs"
* - optionalDependencies 中若存在 sqlite-vec 则必须为 "0.1.9" (不限定只有该项)
* - engines.node = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
* 2. 运行时校验: 针对完整 engines.node 规则做精确比对,失败关闭 (fail-closed)。
* 3. 自定义/latest 版本策略: 不套用 2026.9.1 白名单;存在 lifecycle 时 fail-closed;
* 无 lifecycle 时校验包名、bin、实际解析版本及 engines,并标记为未验证。
* 4. 退出码约定:
* - 0: 验证成功或帮助
* - 1: 契约不满足 / 运行时不匹配 / 脚本缺失
* - 2: CLI 用法或输入格式错误 (多余参数、非法/前后缀 semver、空或不支持 range、非对象 JSON 等)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const EXPECTED_NAME = 'openclaw';
const EXPECTED_VERSION = '2026.9.1';
const EXPECTED_BIN = 'openclaw.mjs';
const EXPECTED_PREINSTALL = 'node scripts/preinstall-package-manager-warning.mjs';
const EXPECTED_POSTINSTALL = 'node scripts/postinstall-bundled-plugins.mjs';
const EXPECTED_PREINSTALL_FILE = 'scripts/preinstall-package-manager-warning.mjs';
const EXPECTED_POSTINSTALL_FILE = 'scripts/postinstall-bundled-plugins.mjs';
const EXPECTED_SQLITE_VEC_VERSION = '0.1.9';
const DEFAULT_NODE_ENGINES = '>=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0';
/**
* 严格语义化版本号解析 (严格禁止前后缀、非数字以及多余段)
* 形如 "22.23.2" 或 "v22.23.2"
* 拒绝 "22.23", "22.23.2.1", "22.23.2-beta", "v22.23.2foo", "abc"
* @param {string} raw
* @returns {{ major: number, minor: number, patch: number } | null}
*/
function parseStrictSemver(raw) {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
const m = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
if (!m) return null;
return {
major: parseInt(m[1], 10),
minor: parseInt(m[2], 10),
patch: parseInt(m[3], 10)
};
}
/**
* 比较两个语义化版本号
* @returns {number} a > b 返回 1, a < b 返回 -1, 相等返回 0
*/
function compareSemver(a, b) {
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
return 0;
}
/**
* 解析比较器
* 支持形如 ">=22.22.3", "<23", "<25", ">=25.9.0", "=22.23.2"
* @param {string} comp
* @returns {{ op: string, target: { major: number, minor: number, patch: number } } | null}
*/
function parseComparator(comp) {
if (typeof comp !== 'string') return null;
const m = comp.trim().match(/^([><]=?|=)\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
if (!m) return null;
return {
op: m[1],
target: {
major: parseInt(m[2], 10),
minor: m[3] !== undefined ? parseInt(m[3], 10) : 0,
patch: m[4] !== undefined ? parseInt(m[4], 10) : 0
}
};
}
/**
* 判断版本是否满足单个比较条件
*/
function satisfiesComparator(ver, comp) {
const parsed = parseComparator(comp);
if (!parsed) return false;
const cmp = compareSemver(ver, parsed.target);
switch (parsed.op) {
case '>': return cmp > 0;
case '>=': return cmp >= 0;
case '<': return cmp < 0;
case '<=': return cmp <= 0;
case '=': return cmp === 0;
default: return false;
}
}
/**
* 校验 range 表达式合法性并判定版本是否满足
* @param {string} verStr
* @param {string} [rangeStr]
* @returns {{ validRange: boolean, satisfied: boolean, error?: string }}
*/
function evaluateNodeEngines(verStr, rangeStr) {
const ver = parseStrictSemver(verStr);
if (!ver) {
return { validRange: false, satisfied: false, error: `非法或包含前后缀的 semver 版本号: "${verStr}"` };
}
const spec = rangeStr !== undefined ? rangeStr : DEFAULT_NODE_ENGINES;
if (typeof spec !== 'string' || !spec.trim()) {
return { validRange: false, satisfied: false, error: 'engines.node 范围定义为空' };
}
const clauses = spec.split('||');
if (clauses.length === 0) {
return { validRange: false, satisfied: false, error: 'engines.node 范围格式非法' };
}
for (const clause of clauses) {
const comps = clause.trim().split(/\s+/).filter(Boolean);
if (comps.length === 0) {
return { validRange: false, satisfied: false, error: 'engines.node 存在空条件区间' };
}
for (const comp of comps) {
if (!parseComparator(comp)) {
return { validRange: false, satisfied: false, error: `不支持或非法的比较器: "${comp}"` };
}
}
}
for (const clause of clauses) {
const comps = clause.trim().split(/\s+/).filter(Boolean);
let allSatisfied = true;
for (const comp of comps) {
if (!satisfiesComparator(ver, comp)) {
allSatisfied = false;
break;
}
}
if (allSatisfied) {
return { validRange: true, satisfied: true };
}
}
return { validRange: true, satisfied: false };
}
/**
* 兼容旧接口的布尔检查函数
*/
function satisfiesNodeEngines(verStr, rangeStr) {
const res = evaluateNodeEngines(verStr, rangeStr);
return res.validRange && res.satisfied;
}
/**
* 校验 package.json 清单
* @param {object|string} pkgOrPath
* @param {string} targetVersion
* @returns {{ valid: boolean, isUsageError?: boolean, vettedScripts?: string[], error?: string, warning?: string, unverified?: boolean }}
*/
function validateManifest(pkgOrPath, targetVersion, nodeVersion) {
let pkg;
let pkgDir = null;
if (typeof pkgOrPath === 'string') {
if (!fs.existsSync(pkgOrPath)) {
return { valid: false, isUsageError: true, error: `文件不存在: ${pkgOrPath}` };
}
try {
const raw = fs.readFileSync(pkgOrPath, 'utf8');
pkg = JSON.parse(raw);
} catch (e) {
return { valid: false, isUsageError: true, error: `无法读取或解析 package.json (${pkgOrPath}): ${e.message}` };
}
pkgDir = path.dirname(path.resolve(pkgOrPath));
} else {
pkg = pkgOrPath;
}
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) {
return { valid: false, isUsageError: true, error: 'package.json 内容为空或不是有效的非数组 JSON 对象' };
}
if (typeof targetVersion !== 'string' || !targetVersion.trim()) {
return { valid: false, isUsageError: true, error: '缺少或非法的 target-version 参数' };
}
const targetVerTrimmed = targetVersion.trim();
const isPinned2026_9_1 = (targetVerTrimmed === '2026.9.1' || targetVerTrimmed === 'stable');
if (isPinned2026_9_1) {
// ── 针对 2026.9.1 进行精确不可变清单校验 ──
// name 校验: 存在、类型为 string、值精确等于 openclaw
if (pkg.name === undefined) {
return { valid: false, error: 'package.json 缺少 name 字段' };
}
if (typeof pkg.name !== 'string') {
return { valid: false, error: `name 字段类型错误: 期望 string, 实际 ${typeof pkg.name}` };
}
if (pkg.name !== EXPECTED_NAME) {
return { valid: false, error: `包名漂移: 期望 "${EXPECTED_NAME}", 实际 "${pkg.name}"` };
}
// version 校验: 存在、类型为 string、值精确等于 2026.9.1
if (pkg.version === undefined) {
return { valid: false, error: 'package.json 缺少 version 字段' };
}
if (typeof pkg.version !== 'string') {
return { valid: false, error: `version 字段类型错误: 期望 string, 实际 ${typeof pkg.version}` };
}
if (pkg.version !== EXPECTED_VERSION) {
return { valid: false, error: `版本号漂移: 期望 "${EXPECTED_VERSION}", 实际 "${pkg.version}"` };
}
// bin 校验: 必须为对象,bin.openclaw 必须存在且为 EXPECTED_BIN
if (pkg.bin === undefined) {
return { valid: false, error: 'package.json 缺少 bin 对象' };
}
if (typeof pkg.bin !== 'object' || pkg.bin === null || Array.isArray(pkg.bin)) {
return { valid: false, error: `bin 字段类型错误: 期望 object, 实际 ${Array.isArray(pkg.bin) ? 'array' : (pkg.bin === null ? 'null' : typeof pkg.bin)}` };
}
if (pkg.bin.openclaw === undefined) {
return { valid: false, error: 'package.json 缺少 bin.openclaw 字段' };
}
if (typeof pkg.bin.openclaw !== 'string') {
return { valid: false, error: `bin.openclaw 字段类型错误: 期望 string, 实际 ${typeof pkg.bin.openclaw}` };
}
if (pkg.bin.openclaw !== EXPECTED_BIN) {
return { valid: false, error: `bin 入口漂移: 期望 "${EXPECTED_BIN}", 实际 "${pkg.bin.openclaw}"` };
}
const binKeys = Object.keys(pkg.bin);
if (binKeys.length !== 1 || binKeys[0] !== 'openclaw') {
return { valid: false, error: `bin 结构漂移: 仅允许定义 openclaw 入口, 实际包含: ${binKeys.join(', ')}` };
}
// scripts 校验: 必须为对象,preinstall 与 postinstall 必须精确匹配
if (pkg.scripts === undefined) {
return { valid: false, error: 'package.json 缺少 scripts 对象' };
}
if (typeof pkg.scripts !== 'object' || pkg.scripts === null || Array.isArray(pkg.scripts)) {
return { valid: false, error: `scripts 字段类型错误: 期望 object, 实际 ${Array.isArray(pkg.scripts) ? 'array' : (pkg.scripts === null ? 'null' : typeof pkg.scripts)}` };
}
if (pkg.scripts.preinstall === undefined) {
return { valid: false, error: 'package.json 缺少 preinstall 脚本' };
}
if (typeof pkg.scripts.preinstall !== 'string') {
return { valid: false, error: `preinstall 脚本类型错误: 期望 string, 实际 ${typeof pkg.scripts.preinstall}` };
}
if (pkg.scripts.preinstall !== EXPECTED_PREINSTALL) {
return { valid: false, error: `preinstall 脚本漂移: 期望 "${EXPECTED_PREINSTALL}", 实际 "${pkg.scripts.preinstall}"` };
}
if (pkg.scripts.postinstall === undefined) {
return { valid: false, error: 'package.json 缺少 postinstall 脚本' };
}
if (typeof pkg.scripts.postinstall !== 'string') {
return { valid: false, error: `postinstall 脚本类型错误: 期望 string, 实际 ${typeof pkg.scripts.postinstall}` };
}
if (pkg.scripts.postinstall !== EXPECTED_POSTINSTALL) {
return { valid: false, error: `postinstall 脚本漂移: 期望 "${EXPECTED_POSTINSTALL}", 实际 "${pkg.scripts.postinstall}"` };
}
const knownLifecycle = new Set(['preinstall', 'postinstall']);
const INSTALL_LIFECYCLE_KEYS = new Set([
'install', 'preinstall', 'postinstall',
'prepare', 'prepack', 'postpack'
]);
for (const k of Object.keys(pkg.scripts)) {
const isLifecycle = INSTALL_LIFECYCLE_KEYS.has(k);
if (isLifecycle && !knownLifecycle.has(k)) {
return { valid: false, error: `存在未知或额外 lifecycle 脚本漂移: ${k}="${pkg.scripts[k]}"` };
}
const val = pkg.scripts[k];
if (typeof val !== 'string' || !val.trim()) {
return { valid: false, error: `scripts.${k} 脚本值为空或类型非法` };
}
}
// optionalDependencies 校验 (必须存在对象且包含 sqlite-vec="0.1.9")
if (pkg.optionalDependencies === undefined) {
return { valid: false, error: 'package.json 缺少 optionalDependencies 对象' };
}
if (typeof pkg.optionalDependencies !== 'object' || pkg.optionalDependencies === null || Array.isArray(pkg.optionalDependencies)) {
return { valid: false, error: `optionalDependencies 字段类型错误: 期望 object, 实际 ${Array.isArray(pkg.optionalDependencies) ? 'array' : (pkg.optionalDependencies === null ? 'null' : typeof pkg.optionalDependencies)}` };
}
if (pkg.optionalDependencies['sqlite-vec'] === undefined) {
return { valid: false, error: 'package.json 缺少 optionalDependencies.sqlite-vec 字段' };
}
if (typeof pkg.optionalDependencies['sqlite-vec'] !== 'string') {
return { valid: false, error: `sqlite-vec 字段类型错误: 期望 string, 实际 ${typeof pkg.optionalDependencies['sqlite-vec']}` };
}
if (pkg.optionalDependencies['sqlite-vec'] !== EXPECTED_SQLITE_VEC_VERSION) {
return { valid: false, error: `sqlite-vec 版本漂移: 期望 "${EXPECTED_SQLITE_VEC_VERSION}", 实际 "${pkg.optionalDependencies['sqlite-vec']}"` };
}
// engines.node 校验
if (pkg.engines === undefined) {
return { valid: false, error: 'package.json 缺少 engines 对象' };
}
if (typeof pkg.engines !== 'object' || pkg.engines === null || Array.isArray(pkg.engines)) {
return { valid: false, error: `engines 字段类型错误: 期望 object, 实际 ${Array.isArray(pkg.engines) ? 'array' : (pkg.engines === null ? 'null' : typeof pkg.engines)}` };
}
if (pkg.engines.node === undefined) {
return { valid: false, error: 'package.json 缺少 engines.node 定义' };
}
if (typeof pkg.engines.node !== 'string') {
return { valid: false, error: `engines.node 字段类型错误: 期望 string, 实际 ${typeof pkg.engines.node}` };
}
if (pkg.engines.node !== DEFAULT_NODE_ENGINES) {
return { valid: false, error: `engines.node 漂移: 期望 "${DEFAULT_NODE_ENGINES}", 实际 "${pkg.engines.node}"` };
}
// 如果所在目录存在 scripts 目录 (真实已解压安装包),则校验两脚本文件真实存在
if (pkgDir && fs.existsSync(path.join(pkgDir, 'scripts'))) {
const preinstallFile = path.join(pkgDir, EXPECTED_PREINSTALL_FILE);
const postinstallFile = path.join(pkgDir, EXPECTED_POSTINSTALL_FILE);
if (!fs.existsSync(preinstallFile)) {
return { valid: false, error: `白名单 preinstall 脚本文件不存在: ${preinstallFile}` };
}
if (!fs.existsSync(postinstallFile)) {
return { valid: false, error: `白名单 postinstall 脚本文件不存在: ${postinstallFile}` };
}
}
return {
valid: true,
vettedScripts: [EXPECTED_PREINSTALL_FILE, EXPECTED_POSTINSTALL_FILE]
};
} else {
// ── latest 或自定义版本: 不套用 2026.9.1 白名单 ──
if (pkg.name !== EXPECTED_NAME) {
return { valid: false, error: `包名不匹配: 期望 "${EXPECTED_NAME}", 实际 "${pkg.name}"` };
}
let binTarget = '';
if (typeof pkg.bin === 'string') {
binTarget = pkg.bin;
} else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) {
binTarget = pkg.bin.openclaw || pkg.bin[EXPECTED_NAME] || '';
}
if (!binTarget || !binTarget.endsWith('openclaw.mjs')) {
return { valid: false, error: `bin 入口漂移或无效: "${binTarget}"` };
}
if (typeof pkg.version !== 'string') {
return { valid: false, error: 'package.json 缺少或非法 version 字段' };
}
const parsedVer = parseStrictSemver(pkg.version);
if (!parsedVer) {
return { valid: false, error: `package.json 版本号非法或包含前后缀: "${pkg.version}"` };
}
if (targetVerTrimmed !== 'latest' && targetVerTrimmed !== pkg.version) {
return { valid: false, error: `实际版本 (${pkg.version}) 与请求版本 (${targetVerTrimmed}) 不一致` };
}
const engineSpec = (pkg.engines && pkg.engines.node) || '';
if (!engineSpec || typeof engineSpec !== 'string') {
return { valid: false, error: 'package.json 缺少 engines.node 定义' };
}
const currentNodeVer = (nodeVersion ? String(nodeVersion).replace(/^v/, '') : (process.versions.node || (process.version ? process.version.replace(/^v/, '') : '')));
const evalRes = evaluateNodeEngines(currentNodeVer, engineSpec);
if (!evalRes.validRange) {
return { valid: false, error: `engines.node 语法非法: ${evalRes.error}` };
}
if (!evalRes.satisfied) {
return { valid: false, error: `当前 Node.js 运行时 v${currentNodeVer} 不满足 engines.node 契约: "${engineSpec}"` };
}
const scripts = (pkg.scripts && typeof pkg.scripts === 'object' && !Array.isArray(pkg.scripts)) ? pkg.scripts : {};
const INSTALL_LIFECYCLE_KEYS = new Set([
'install', 'preinstall', 'postinstall',
'prepare', 'prepack', 'postpack'
]);
const detectedLifecycles = [];
for (const k of Object.keys(scripts)) {
if (INSTALL_LIFECYCLE_KEYS.has(k)) {
detectedLifecycles.push(k);
}
}
if (detectedLifecycles.length > 0) {
return {
valid: false,
error: `拒绝执行未验证版本的 lifecycle 脚本 (${detectedLifecycles.join(', ')}). 自定义/latest 版本不得套用 2026.9.1 白名单 (fail-closed)`
};
}
return {
valid: true,
vettedScripts: [],
unverified: true,
warning: `当前安装版本为自定义/latest (${pkg.version || targetVerTrimmed}),未套用 2026.9.1 白名单,已阻断所有 lifecycle 脚本并标记为未验证安装`
};
}
}
/**
* 校验 Node.js 运行时
* @param {string} nodeBin
* @param {string} [spec]
* @returns {{ valid: boolean, version?: string, isUsageError?: boolean, error?: string }}
*/
function validateRuntime(nodeBin, spec) {
try {
const raw = execFileSync(nodeBin, ['--version'], { encoding: 'utf8' }).trim();
const ver = raw.replace(/^v/, '');
const engineSpec = spec || DEFAULT_NODE_ENGINES;
const evalRes = evaluateNodeEngines(ver, engineSpec);
if (!evalRes.validRange) {
return { valid: false, isUsageError: true, error: evalRes.error };
}
if (!evalRes.satisfied) {
return {
valid: false,
version: ver,
error: `Node.js v${ver} 不满足 engines.node 契约: "${engineSpec}"`
};
}
return { valid: true, version: ver };
} catch (e) {
return { valid: false, isUsageError: true, error: `无法执行 Node.js (${nodeBin}): ${e.message}` };
}
}
/**
* 获取经过审核的脚本列表并检查文件真实存在
* @param {string} pkgPath
* @param {string} targetVersion
* @returns {{ valid: boolean, isUsageError?: boolean, scripts?: string[], error?: string }}
*/
function getVettedScripts(pkgPath, targetVersion) {
const res = validateManifest(pkgPath, targetVersion);
if (!res.valid) {
return res;
}
if (!res.vettedScripts || res.vettedScripts.length === 0) {
return { valid: true, scripts: [] };
}
const pkgDir = path.dirname(path.resolve(pkgPath));
for (const s of res.vettedScripts) {
const fullPath = path.join(pkgDir, s);
if (!fs.existsSync(fullPath)) {
return { valid: false, error: `白名单脚本文件真实路径不存在: ${fullPath}` };
}
}
return { valid: true, scripts: res.vettedScripts };
}
// ── CLI 执行入口 ──
if (require.main === module) {
const args = process.argv.slice(2);
const command = args[0];
if (command === '--help' || command === '-h') {
console.log(`用法: node openclaw-package-contract.js <command> [args...]
命令:
validate-manifest <package.json-path> <target-version>
validate-node-version <version-string> [engine-spec]
validate-runtime <node-binary-path> [engine-spec]
get-vetted-scripts <package.json-path> <target-version>
`);
process.exit(0);
}
if (!command) {
console.error('错误: 请指定命令。使用 --help 查看帮助。');
process.exit(2);
}
if (command === 'validate-manifest') {
if (args.length !== 3) {
console.error('用法错误: validate-manifest 需要恰好 2 个参数: <package.json-path> <target-version>');
process.exit(2);
}
const pkgPath = args[1];
const targetVer = args[2];
const res = validateManifest(pkgPath, targetVer);
if (!res.valid) {
console.error(`[✗] 包清单校验失败: ${res.error}`);
process.exit(res.isUsageError ? 2 : 1);
}
if (res.warning) {
console.warn(`[!] ${res.warning}`);
}
console.log('[✓] 包清单契约校验通过');
process.exit(0);
}
if (command === 'validate-node-version') {
if (args.length < 2 || args.length > 3) {
console.error('用法错误: validate-node-version 需要 1 或 2 个参数: <version-string> [engine-spec]');
process.exit(2);
}
const ver = args[1];
const spec = args[2];
const evalRes = evaluateNodeEngines(ver, spec);
if (!evalRes.validRange) {
console.error(`[✗] 输入格式错误: ${evalRes.error}`);
process.exit(2);
}
if (!evalRes.satisfied) {
console.error(`[✗] Node.js 版本 v${ver} 不满足契约: "${spec || DEFAULT_NODE_ENGINES}"`);
process.exit(1);
}
console.log(`[✓] Node.js 版本 v${ver} 满足契约`);
process.exit(0);
}
if (command === 'validate-runtime') {
if (args.length < 2 || args.length > 3) {
console.error('用法错误: validate-runtime 需要 1 或 2 个参数: <node-binary-path> [engine-spec]');
process.exit(2);
}
const nodeBin = args[1];
const engineSpec = args[2];
const res = validateRuntime(nodeBin, engineSpec);
if (!res.valid) {
console.error(`[✗] 运行时校验失败: ${res.error}`);
process.exit(res.isUsageError ? 2 : 1);
}
console.log(`[✓] Node.js 运行时 v${res.version} 满足契约 (${engineSpec || DEFAULT_NODE_ENGINES})`);
process.exit(0);
}
if (command === 'get-vetted-scripts') {
if (args.length !== 3) {
console.error('用法错误: get-vetted-scripts 需要恰好 2 个参数: <package.json-path> <target-version>');
process.exit(2);
}
const pkgPath = args[1];
const targetVer = args[2];
const res = getVettedScripts(pkgPath, targetVer);
if (!res.valid) {
console.error(`[✗] ${res.error}`);
process.exit(res.isUsageError ? 2 : 1);
}
if (res.scripts && res.scripts.length > 0) {
for (const s of res.scripts) {
console.log(s);
}
}
process.exit(0);
}
console.error(`未知命令: ${command}`);
process.exit(2);
}
module.exports = {
EXPECTED_NAME,
EXPECTED_VERSION,
EXPECTED_BIN,
EXPECTED_PREINSTALL,
EXPECTED_POSTINSTALL,
EXPECTED_PREINSTALL_FILE,
EXPECTED_POSTINSTALL_FILE,
EXPECTED_SQLITE_VEC_VERSION,
DEFAULT_NODE_ENGINES,
parseStrictSemver,
compareSemver,
parseComparator,
evaluateNodeEngines,
satisfiesNodeEngines,
validateManifest,
validateRuntime,
getVettedScripts
};
@@ -37,7 +37,7 @@
<div id="topbar">
<span class="logo">🦞 一万AI分享 OpenClaw 配置管理</span>
<span id="status" class="status disconnected">● 连接中...</span>
<button class="btn" onclick="location.reload()" title="重新启动配置脚本">🔄 重启</button>
<button class="btn" onclick="restartSession()" title="重新启动配置脚本">🔄 重启</button>
</div>
<div id="loading">
@@ -52,7 +52,7 @@
<div id="reconnect-overlay">
<p style="font-size:16px;margin-bottom:4px">⚡ 连接已断开</p>
<p style="font-size:13px;color:#565f89">配置脚本已退出或连接中断</p>
<button onclick="location.reload()">🔄 重新启动</button>
<button onclick="restartSession()">🔄 重新启动</button>
</div>
<link rel="stylesheet" href="/lib/xterm.min.css">
@@ -128,11 +128,17 @@
// ── WebSocket 连接 ──
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
// 从 URL 参数或 sessionStorage 读取 PTY token 用于 WebSocket 认证
const urlParams = new URLSearchParams(location.search);
const ptyToken = urlParams.get('pty_token') || '';
let ptyToken = urlParams.get('pty_token') || '';
if (ptyToken) {
try { sessionStorage.setItem('openclaw_pty_token', ptyToken); } catch(e) {}
} else {
try { ptyToken = sessionStorage.getItem('openclaw_pty_token') || ''; } catch(e) {}
}
// 取到 token 后立刻从地址栏移除: 否则它会留在浏览器历史、
// 以及任何 Referer/截图里。内存中的 ptyToken 仍可用于建连。
// 以及任何 Referer/截图里。内存与 sessionStorage 中的 ptyToken 仍可用于建连。
if (ptyToken && window.history && history.replaceState) {
try {
const clean = new URL(location.href);
@@ -140,27 +146,118 @@
history.replaceState(null, '', clean.pathname + (clean.search || '') + clean.hash);
} catch (e) {}
}
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
// 连接序号与单飞状态机
let currentConnectSeq = 0;
let wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
// 展示用 URL: 隐去 token。真实 wsUrl 只用于建立连接,
// 不写入页面文本或 console —— 否则 token 会留在截图、录屏与浏览器历史里。
const wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
let wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
let ws = null;
let connected = false;
let isConnecting = false;
let retryCount = 0;
const MAX_RETRY = Infinity;
let retryTimer = null;
let wasEverConnected = false;
let pingTimer = null;
// 重启配置终端: 原地平滑重连,清屏并重连 WebSocket,无需重新加载整个页面
window.restartSession = function() {
console.log('[oc-config] restartSession invoked');
currentConnectSeq++;
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (ws) {
try {
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
} catch(e) {}
ws = null;
}
connected = false;
isConnecting = false;
retryCount = 0;
// 清空终端并显示重启中状态
try { term.clear(); } catch(e) {}
reconnectEl.classList.remove('show');
loadingEl.classList.remove('hidden');
loadingMsg.textContent = '正在重启终端会话...';
statusEl.textContent = '● 重启中...';
statusEl.className = 'status disconnected';
connect();
};
// 跨窗口通信: 向父页面 (LuCI) 请求缺失的 token,或接收父页面发送的 token
function requestParentToken() {
if (window.parent && window.parent !== window) {
try {
window.parent.postMessage({ type: 'openclaw_request_token' }, '*');
} catch(e) {}
}
}
window.addEventListener('message', function(ev) {
if (ev.data && ev.data.type === 'openclaw_token' && ev.data.pty_token) {
ptyToken = ev.data.pty_token;
try { sessionStorage.setItem('openclaw_pty_token', ptyToken); } catch(e) {}
wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
if (!connected) {
currentConnectSeq++;
if (ws) {
try {
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
} catch(e){}
ws = null;
}
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
isConnecting = false;
connect();
}
}
});
console.log('[oc-config] protocol:', location.protocol);
console.log('[oc-config] host:', location.host, 'port:', location.port);
console.log('[oc-config] WebSocket URL:', wsUrlSafe);
loadingDebug.textContent = wsUrlSafe;
function connect() {
if (isConnecting || connected) return;
isConnecting = true;
const mySeq = ++currentConnectSeq;
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (ws) { try { ws.close(); } catch(e){} ws = null; }
if (ws) {
try {
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
} catch(e) {}
ws = null;
}
if (!ptyToken) {
try { ptyToken = sessionStorage.getItem('openclaw_pty_token') || ''; } catch(e) {}
}
if (!ptyToken) {
requestParentToken();
}
wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
retryCount++;
console.log('[oc-config] Connecting to', wsUrlSafe, '(attempt', retryCount + ')');
@@ -169,42 +266,66 @@
: '正在连接终端...';
loadingDebug.textContent = wsUrlSafe;
// 先做一个 HTTP 预检确认服务器可达
fetch('/health').then(function(r) {
// 先用带超时的 HTTP /health 预检确认服务端就绪
var controller = null;
var signal = null;
if (typeof AbortController !== 'undefined') {
controller = new AbortController();
signal = controller.signal;
setTimeout(function() {
try { controller.abort(); } catch(e) {}
}, 2500);
}
fetch('/health', { signal: signal, cache: 'no-store' }).then(function(r) {
return r.json();
}).then(function(data) {
if (mySeq !== currentConnectSeq) return;
console.log('[oc-config] Health check OK:', JSON.stringify(data));
doWebSocket();
doWebSocket(mySeq);
}).catch(function(err) {
if (mySeq !== currentConnectSeq) return;
console.log('[oc-config] Health check failed:', err.message || err);
// 服务器不可达,稍后重试
isConnecting = false;
scheduleRetry();
});
}
function doWebSocket() {
function doWebSocket(mySeq) {
if (mySeq !== currentConnectSeq || connected) {
isConnecting = false;
return;
}
isConnecting = true;
try {
ws = new WebSocket(wsUrl);
} catch(e) {
console.error('[oc-config] WebSocket constructor error:', e);
isConnecting = false;
scheduleRetry();
return;
}
console.log('[oc-config] WebSocket created, readyState:', ws.readyState);
// 连接超时保护: 5秒没连上就重试
// 连接超时保护: 5秒没连上就关闭当前 socket,由 onclose 统一调度重试
var connectTimeout = setTimeout(function() {
if (mySeq !== currentConnectSeq) return;
if (ws && ws.readyState !== WebSocket.OPEN) {
console.log('[oc-config] Connection timeout, readyState:', ws.readyState);
try { ws.close(); } catch(e){}
scheduleRetry();
}
}, 5000);
ws.onopen = function() {
if (mySeq !== currentConnectSeq) {
try { ws.close(); } catch(e){}
return;
}
clearTimeout(connectTimeout);
connected = true;
isConnecting = false;
var isReconnect = wasEverConnected;
wasEverConnected = true;
retryCount = 0;
@@ -248,45 +369,58 @@
ws.onclose = function(ev) {
clearTimeout(connectTimeout);
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (mySeq !== currentConnectSeq) return;
connected = false;
isConnecting = false;
ws = null;
statusEl.textContent = '● 已断开';
statusEl.className = 'status disconnected';
console.log('[oc-config] WebSocket closed, code:', ev.code, 'reason:', ev.reason);
if (wasEverConnected) {
// 自动重连,不显示断连覆盖层
console.log('[oc-config] Auto-reconnecting in 2s...');
statusEl.textContent = '● 重连中...';
retryCount = 0;
setTimeout(function() {
connect();
}, 2000);
} else {
// 从未连接成功过,自动重试
scheduleRetry();
}
scheduleRetry();
};
ws.onerror = function(ev) {
clearTimeout(connectTimeout);
if (mySeq !== currentConnectSeq) return;
connected = false;
isConnecting = false;
console.error('[oc-config] WebSocket error, will retry');
// 不要在这里做UI更新, onclose 会紧跟着触发
};
}
function scheduleRetry() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
isConnecting = false;
if (!ptyToken) {
requestParentToken();
}
if (retryCount >= MAX_RETRY) {
loadingMsg.textContent = '连接失败,请检查服务状态';
loadingDebug.textContent = '已重试 ' + MAX_RETRY + ' 次。请刷新页面重试。';
loadingEl.classList.remove('hidden');
statusEl.textContent = '● 连接失败';
statusEl.className = 'status disconnected';
return;
}
// 逐步增加重试间隔: 1s, 1s, 2s, 2s, 3s, 3s, ...
// 逐步增加重试间隔: 1s, 1s, 2s, 2s, 3s, 3s, ... 最大 5s
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
loadingMsg.textContent = '等待服务就绪... ' + Math.ceil(delay/1000) + '秒后重试';
loadingEl.classList.remove('hidden');
if (wasEverConnected) {
statusEl.textContent = '● 重连中...';
loadingMsg.textContent = '连接中断,等待服务就绪... ' + Math.ceil(delay / 1000) + '秒后重试';
} else {
loadingMsg.textContent = (!ptyToken)
? '等待服务就绪或获取认证令牌... ' + Math.ceil(delay / 1000) + '秒后重试'
: '等待服务就绪... ' + Math.ceil(delay / 1000) + '秒后重试';
}
loadingDebug.textContent = wsUrlSafe;
console.log('[oc-config] Retry in', delay, 'ms');
retryTimer = setTimeout(connect, delay);
retryTimer = setTimeout(function() {
retryTimer = null;
connect();
}, delay);
}
term.onData(function(data) {
@@ -305,18 +305,24 @@ function handleRequest(req, res) {
const fullPath = path.join(UI_DIR, fp);
if (!fullPath.startsWith(UI_DIR)) { res.writeHead(403); return res.end('Forbidden'); }
// 如果请求 URL 带有 pty_token,写入 SameSite=Lax Cookie 以支持后续刷新/重启恢复
const queryPtyToken = url.searchParams.get('pty_token');
const cookieHeader = queryPtyToken
? { 'Set-Cookie': `oc_pty_token=${encodeURIComponent(queryPtyToken)}; Path=/; SameSite=Lax; HttpOnly` }
: {};
fs.readFile(fullPath, (err, data) => {
if (err) {
if (fp !== '/index.html') {
fs.readFile(path.join(UI_DIR, 'index.html'), (e2, d2) => {
if (e2) { res.writeHead(404); res.end('Not Found'); }
else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...IFRAME_HEADERS }); res.end(d2); }
else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...IFRAME_HEADERS, ...cookieHeader }); res.end(d2); }
});
} else { res.writeHead(404); res.end('Not Found'); }
return;
}
const ext = path.extname(fullPath);
res.writeHead(200, { 'Content-Type': getMimeType(ext), 'Cache-Control': ext === '.html' ? 'no-cache' : 'max-age=3600', ...IFRAME_HEADERS });
res.writeHead(200, { 'Content-Type': getMimeType(ext), 'Cache-Control': ext === '.html' ? 'no-cache' : 'max-age=3600', ...IFRAME_HEADERS, ...cookieHeader });
res.end(data);
});
}
@@ -326,12 +332,18 @@ function handleUpgrade(req, socket, head) {
console.log(`[oc-config] WS upgrade: ${req.url} remote=${socket.remoteAddress}:${socket.remotePort}`);
if (req.url !== '/ws' && !req.url.startsWith('/ws?')) { socket.destroy(); return; }
// 认证: 验证查询参数中的 token
// 认证: 验证查询参数或 Cookie 中的 token
// 每次连接时实时读取 UCI token (安装/升级可能重新生成 token)
const currentToken = loadAuthToken() || AUTH_TOKEN;
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (currentToken) {
const clientToken = urlObj.searchParams.get('token') || '';
let clientToken = urlObj.searchParams.get('token') || urlObj.searchParams.get('pty_token') || '';
if (!clientToken && req.headers.cookie) {
const match = req.headers.cookie.match(/(?:^|;\s*)oc_pty_token=([^;]+)/);
if (match) {
try { clientToken = decodeURIComponent(match[1]); } catch(e) { clientToken = match[1]; }
}
}
if (clientToken !== currentToken) {
console.log(`[oc-config] WS auth failed from ${socket.remoteAddress}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');