feat(portable): bug 一键上报 + 自动崩溃上报 + openclaw 升级 2026.6.6

Bug 上报(方便服务器侧排查便携版问题):
- 新增 lib/report-bug.mjs:算设备指纹→sk-uc apiKey,收集 openclaw 版本/
  系统信息/最近日志(gzip+base64),POST 到 api.u-claw.org/recharge/bug/submit。
  静默失败,绝不影响主流程。复用 fingerprint.mjs + xiapan-client.mjs。
- config-server 加 POST /api/report-bug:本地补齐信息后转发,配置中心加
  「🐛 报 Bug」表单(标题/描述/是否附日志)。
- Windows-Start.bat / Mac-Start.command:gateway 端口耗尽或异常退出时
  自动后台上报(Ctrl+C 正常停止不上报)。

openclaw 升级:
- OPENCLAW_VERSION 2026.4.29 → 2026.6.6(X 盘真实 USB 实测 gateway
  启动/ready、Dashboard 200、旧配置兼容、虾盘云 status 均通过)。

便携性改进(借鉴 OpenClaw-Windows-Portable):
- setup.sh / 启动脚本的 npm install 设 npm_config_cache 指向盘内
  app/.npm-cache,避免污染系统缓存,拔盘不留痕。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-06-15 10:52:17 +08:00
parent d9dd98d650
commit 6eb54f8ea3
7 changed files with 316 additions and 4 deletions

207
portable/lib/report-bug.mjs Normal file
View File

@@ -0,0 +1,207 @@
// Bug 上报客户端(便携版)
//
// 流程:
// 1. 算设备指纹 -> sk-uc- apiKey用来在服务器侧关联同一台设备的多条上报
// 2. 收集 openclaw 版本号、设备类型、系统信息、最近日志gzip+base64
// 3. POST 到 https://api.u-claw.org/recharge/bug/submit
//
// 设计原则(对齐 check-update.mjs
// - 静默失败:上报失败只 console.error绝不影响 OpenClaw 主流程
// - 异步:调用方应 detach 跑Windows-Start.bat 用 start /B不阻塞启动
// - 无第三方依赖fetch + node:zlib 都是内置
//
// 两种用法:
// 手动(网页一键,由 config-server 转发import { submitBugReport } from './report-bug.mjs'
// 自动崩溃node lib/report-bug.mjs --auto --title "gateway-start-failed" [--log <文件>]
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { resolve, join } from 'node:path';
import { platform, release, arch, tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { getFingerprint } from './fingerprint.mjs';
import { buildApiKey } from './xiapan-client.mjs';
const DEFAULT_API_BASE = 'https://api.u-claw.org/v1';
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_LOG_BYTES = 256 * 1024; // 只取日志尾部 256KB避免上报体过大
function log(level, msg) {
const stream = level === 'error' ? process.stderr : process.stdout;
stream.write(`[report-bug] ${msg}\n`);
}
// API base 默认带 /v1虾盘云 API 中转bug 上报走独立的 /bug 路径,所以去掉尾部 /v1。
// 例https://api.u-claw.org/v1 -> https://api.u-claw.org/bug/submit
function getSubmitUrl() {
const base = (process.env.UCLAW_CLOUD_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const root = base.replace(/\/v1$/, '');
return `${root}/bug/submit`;
}
async function fetchWithTimeout(url, init) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
// 读 OPENCLAW_VERSION优先环境变量再找盘内文件
function readOpenclawVersion(appRoot) {
if (process.env.OPENCLAW_VERSION) return process.env.OPENCLAW_VERSION.trim();
const candidates = [
process.env.UCLAW_VERSION_FILE,
appRoot && resolve(appRoot, 'OPENCLAW_VERSION'),
appRoot && resolve(appRoot, '..', 'OPENCLAW_VERSION'),
].filter(Boolean);
for (const p of candidates) {
try {
if (existsSync(p)) {
const v = readFileSync(p, 'utf8').trim();
if (v) return v;
}
} catch { /* 静默 */ }
}
return null;
}
function collectSystemInfo() {
return JSON.stringify({
platform: platform(),
arch: arch(),
osRelease: release(),
node: process.version,
time: new Date().toISOString(),
});
}
// 读最近一份 openclaw 日志的尾部gzip+base64。失败返回 null。
// 日志默认在 %LOCALAPPDATA%\Temp\openclaw\ 或 $TMPDIR/openclaw/,文件名形如 openclaw-{date}.log
function collectLogsB64(explicitLogPath) {
try {
let logFile = null;
if (explicitLogPath && existsSync(explicitLogPath)) {
logFile = explicitLogPath;
} else {
const logDir = process.env.OPENCLAW_LOG_DIR || join(tmpdir(), 'openclaw');
if (existsSync(logDir)) {
const logs = readdirSync(logDir)
.filter((f) => /\.log$/i.test(f))
.map((f) => join(logDir, f))
.map((p) => ({ p, m: safeMtime(p) }))
.filter((x) => x.m > 0)
.sort((a, b) => b.m - a.m);
if (logs.length) logFile = logs[0].p;
}
}
if (!logFile) return null;
let raw = readFileSync(logFile);
if (raw.length > MAX_LOG_BYTES) raw = raw.subarray(raw.length - MAX_LOG_BYTES);
return gzipSync(raw).toString('base64');
} catch (err) {
log('error', `collect logs failed: ${err.message}`);
return null;
}
}
function safeMtime(p) {
try { return statSync(p).mtimeMs; } catch { return 0; }
}
/**
* 上报一个 bug。所有现场信息指纹/版本/系统/日志)由本函数自动补齐,
* 调用方只需给 title必填和 description可选
*
* @param {object} opts
* @param {string} opts.title 必填bug 标题
* @param {string} [opts.description] 描述(用户填写或堆栈)
* @param {string} [opts.appRoot] 便携版根目录,用于算指纹和找版本文件,默认 cwd
* @param {string} [opts.logPath] 指定日志文件;不给则自动找最近的 openclaw 日志
* @param {boolean}[opts.includeLogs] 是否附带日志,默认 true
* @returns {Promise<{ok:boolean, id?:number, reason?:string}>}
*/
export async function submitBugReport(opts = {}) {
const { title, description, appRoot, logPath, includeLogs = true } = opts;
if (!title || typeof title !== 'string' || title.trim().length < 3) {
return { ok: false, reason: 'title-required' };
}
const root = appRoot || process.cwd();
// 指纹失败不阻止上报,匿名提交即可
let api_key = null;
let device_source = null;
try {
const fp = await getFingerprint(root);
device_source = fp.source;
api_key = buildApiKey(fp.fingerprint);
} catch (err) {
log('error', `fingerprint failed (上报为匿名): ${err.message}`);
}
const payload = {
api_key,
device_source,
openclaw_version: readOpenclawVersion(root),
title: title.trim(),
description: description || null,
logs_b64: includeLogs ? collectLogsB64(logPath) : null,
system_info: collectSystemInfo(),
};
try {
const res = await fetchWithTimeout(getSubmitUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
log('error', `submit failed: HTTP ${res.status} ${data.error || ''}`);
return { ok: false, reason: data.error || `http-${res.status}` };
}
log('info', `submitted bug #${data.id}`);
return { ok: true, id: data.id };
} catch (err) {
log('error', `submit failed: ${err.message}`);
return { ok: false, reason: err.message };
}
}
// CLI 入口(自动崩溃上报用):
// node report-bug.mjs --auto --title "gateway-start-failed" [--desc "..."] [--log <文件>] [--root <便携版根>]
const isMain = (() => {
try {
if (!process.argv[1]) return false;
return import.meta.url === pathToFileURL(process.argv[1]).href;
} catch {
return false;
}
})();
if (isMain) {
const args = process.argv.slice(2);
const getArg = (name) => {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
};
const title = getArg('--title') || 'auto-report';
const description = getArg('--desc');
const logPath = getArg('--log');
const appRoot = getArg('--root') || process.env.UCLAW_APP_ROOT || process.cwd();
submitBugReport({ title, description, appRoot, logPath })
.then((res) => {
process.stdout.write(`${JSON.stringify(res)}\n`);
process.exit(res.ok ? 0 : 1);
})
.catch((err) => {
// 即便崩了也别让上报本身把启动脚本拖挂
process.stderr.write(`report-bug fatal: ${err && err.message ? err.message : err}\n`);
process.exit(1);
});
}