chore(portable): 回归纯开源 — 移除指纹/自动开户/崩溃上报
去掉不适合开源的商业逻辑,保持一个纯粹的开源 U 盘工具: - 删除设备指纹 (fingerprint.mjs)、虾盘云自动开户 (bootstrap-xiapan.mjs / xiapan-client.mjs)、自动崩溃上报 (report-bug.mjs),portable 和 Electron 两份都删 - 启动脚本去掉绑定指纹调用 + 全部 --auto 自动上报;保留 bonjour 禁用与括号转义 - config-server 删掉 /api/xiapan/* 和 /api/report-bug 端点 - Config.html:虾盘云改为普通「首选」卡片,强调中转站 / 国内外全部大模型, 和其它 provider 一样需用户自填 Key(去 u-claw.org/cloud.html 注册),不再自动开户 - 报 Bug 表单改为指向 GitHub Issue,不再上传日志 - README / release notes / CLAUDE.md 文案改为「选模型填 Key,不绑定设备、不打指纹、不上传数据」 - OPENCLAW_VERSION 跟进最新龙虾版本 2026.6.6 → 2026.6.8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -262,9 +262,9 @@ jobs:
|
||||
body: |
|
||||
## U-Claw ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- 内置虾盘云:首次启动自动绑定本机/U盘指纹,生成 `sk-uc-...` apiKey
|
||||
- 开箱即配:打开 Config 选模型填 Key 即可,支持虾盘云中转站及 DeepSeek / 通义 / Claude / GPT 等国内外大模型
|
||||
- OpenClaw runtime: `${{ steps.openclaw_version.outputs.version }}`
|
||||
- 充值入口:https://u-claw.org/cloud.html
|
||||
- 虾盘云注册:https://u-claw.org/cloud.html
|
||||
|
||||
### 下载
|
||||
|
||||
|
||||
61
CLAUDE.md
61
CLAUDE.md
@@ -26,8 +26,8 @@ Four distribution forms:
|
||||
|
||||
```bash
|
||||
# Portable version — build dev copy
|
||||
cd portable && bash setup.sh # Downloads Node.js v22 + OpenClaw + QQ plugin to app/
|
||||
bash Mac-Start.command # Launch (Mac ARM64 only currently)
|
||||
cd portable && bash setup.sh # Downloads Node.js v22 + OpenClaw + WeChat/QQ plugin to app/
|
||||
bash Mac-Start.command # Launch (Mac ARM64). Windows: Windows-Start.bat
|
||||
|
||||
# Copy to USB drive
|
||||
cp -R portable/ /Volumes/YOUR_USB/U-Claw/
|
||||
@@ -46,17 +46,41 @@ cd bootable
|
||||
.\4-copy-to-usb.ps1 # Copy ISO + persistence + scripts to USB
|
||||
```
|
||||
|
||||
Testing should be done in a separate folder or directly on USB. This repo stays clean (no node_modules, no app/ runtime).
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
node --test tests/ # Run all tests (node:test, no test framework dep)
|
||||
node --test tests/windows-launchers.test.mjs # Run one test file
|
||||
```
|
||||
|
||||
Tests assert on the **text/behavior of the launcher scripts** (`.bat`/`.command`) — e.g. that
|
||||
`Windows-Start.bat` escapes `^(...^)` parens inside IF blocks and disables OpenClaw bonjour
|
||||
discovery. They read repo files as strings; they do **not** spawn OpenClaw. There is no root
|
||||
`package.json` — tests are not run by the release CI (`.github/workflows/release.yml` only
|
||||
builds and publishes). Run them locally before pushing launcher changes.
|
||||
|
||||
Testing of the actual runtime should be done in a separate folder or directly on USB. This repo
|
||||
stays clean (no node_modules, no app/ runtime).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
portable/ THE USB content (= repo + setup.sh downloads)
|
||||
Scripts, HTML pages, setup.sh
|
||||
setup.sh / setup.bat / setup.ps1 — fill in app/ (Node + OpenClaw + plugins)
|
||||
{Mac,Windows}-Start — launch gateway + config-server, open dashboard/Config
|
||||
{Mac,Windows}-Menu — interactive CLI launcher (pick start/config/CLI/diagnose)
|
||||
{Mac,Windows}-Install — copy USB → computer (~/.uclaw/ or %USERPROFILE%)
|
||||
{Mac,Windows}-Diagnose — health check / collect logs for bug reports
|
||||
{Mac-OpenClaw-CLI,OpenClaw-CLI.bat} — drop into raw `openclaw` CLI
|
||||
*.html (Welcome, Config, U-Claw, SkillHub) — local UI pages
|
||||
config-server/server.js — local HTTP server (port 18788-18798) backing
|
||||
Config.html: writes openclaw.json, WeChat QR login, update-status API
|
||||
lib/ — Node helpers (see "lib/ helpers" below)
|
||||
default-config.json — seed config copied to data/.openclaw/ on first run
|
||||
app/core/ (OpenClaw) + app/runtime/ (Node.js) — downloaded by setup.sh
|
||||
data/.openclaw/openclaw.json — user config (on USB, portable)
|
||||
Mac-Install.command / Windows-Install.bat — install to computer from USB
|
||||
skills-cn/ — 10 个中国本地化技能(小红书、微博、B站等)
|
||||
skills-cn/ — 13 个中国本地化技能(小红书/微博/B站/抖音/知乎/
|
||||
微信公众号/Word/Excel/PPT/天气/搜索/翻译/DeepSeek)
|
||||
|
||||
u-claw-app/ Electron desktop app (main.js ~400 lines)
|
||||
setup.sh / setup.bat for one-click dev environment
|
||||
@@ -85,10 +109,35 @@ Both portable and desktop versions auto-find a free port in range 18789–18799
|
||||
|
||||
- **Node.js discovery**: Portable looks at `app/runtime/node-mac-arm64/bin/node`; Electron looks at `resources/runtime/node-{platform}-{arch}` then falls back to system `node`
|
||||
- **China mirrors**: All downloads use `npmmirror.com` — Node.js binaries from `npmmirror.com/mirrors/node`, npm packages from `registry.npmmirror.com`
|
||||
- **`OPENCLAW_VERSION` file**: single source of truth for the bundled OpenClaw runtime version (e.g. `2026.6.6`). CI reads it to pin the npm install; it's copied into `portable/` so USB users / `check-update.mjs` can compare installed vs latest. Bump this file to upgrade.
|
||||
- **Environment variables**: `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH` control where OpenClaw reads config
|
||||
- **macOS quarantine**: Mac scripts run `xattr -rd com.apple.quarantine` to remove Gatekeeper blocks
|
||||
- **Config format**: `{"gateway":{"mode":"local","auth":{"token":"uclaw"}},"models":{"mode":"merge","providers":{"xxx":{...}}},"agents":{"defaults":{"model":{"primary":"provider/model"}}}}`
|
||||
- **Config hot-reload**: OpenClaw watches `openclaw.json` and applies changes without restart
|
||||
- **Two local servers on startup**: launchers start the OpenClaw **gateway** (18789–18799) AND
|
||||
the **config-server** (`config-server/server.js`, 18788–18798). The config-server backs
|
||||
`Config.html` — it writes `openclaw.json`, drives WeChat QR login, and exposes update-status.
|
||||
|
||||
## lib/ Helpers (portable)
|
||||
|
||||
Pure-Node, zero-dependency `.mjs` modules (use `fetch` + `node:zlib` only). All are designed to
|
||||
**fail silently** and **run detached** so they never block or break OpenClaw startup.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `check-update.mjs` / `publish-latest.mjs` | Portable self-update: check installed vs latest `OPENCLAW_VERSION`; publish helper. |
|
||||
| `wait-gateway.bat` | Windows helper: poll until gateway port is LISTENING before opening the dashboard (fixes startup race). |
|
||||
| `maintain.sh` | Maintenance/diagnostics script. |
|
||||
|
||||
> **纯开源,无追踪**: 这个开源版**不含**设备指纹 (`fingerprint.mjs`)、自动开户 (`bootstrap-xiapan.mjs`/`xiapan-client.mjs`)、崩溃上报 (`report-bug.mjs`) 等商业版逻辑——这些已在 2026-06-17 移除。U-Claw 不绑定设备、不打指纹、不向 `api.u-claw.org` 上传任何数据。
|
||||
|
||||
### 模型配置 ("选模型填 Key")
|
||||
|
||||
Config.html 列出国内外大模型供用户挑选。首选卡片是 **虾盘云** (Xiapan Cloud 中转站,`api.u-claw.org/v1`)
|
||||
——一个 Key 调用 DeepSeek / Claude / GPT / 通义 等全部模型,但和其它 provider 一样**需要用户自己去
|
||||
`https://u-claw.org/cloud.html` 注册拿 Key**,不再自动开户。其余 provider (DeepSeek/通义/Kimi/智谱/
|
||||
豆包/MiniMax/OpenAI/Claude/Groq/硅基流动/自定义) 填各家官方 Key 即可。配置只写本地
|
||||
`data/.openclaw/openclaw.json`。
|
||||
|
||||
## What NOT to Commit
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026.6.6
|
||||
2026.6.8
|
||||
|
||||
14
README.md
14
README.md
@@ -22,17 +22,13 @@ U-Claw(虾盘)是一个**制作教程 + 全套源代码**,教你把 [OpenC
|
||||
|
||||
> 📖 **[完整教程](https://u-claw.org/tutorial.html)** — 从零开始的手工安装指南、模型配置、聊天平台接入,小白也能看懂。
|
||||
|
||||
### 🔗 内置虾盘云:开箱即用
|
||||
### 🧩 模型随便选:国内外大模型都能用
|
||||
|
||||
每个 U-Claw 实例首次启动会**自动**根据当前 U 盘 / 硬盘的指纹生成一个 `sk-...` 形式的虾盘云 apiKey,并写入 `data/.openclaw/openclaw.json`。打开 Config.html 就能看到这张「已绑定虾盘云」横幅(指纹来源、Key、余额)。
|
||||
U-Claw 是纯开源工具,**不绑定设备、不打指纹、不上传任何数据**。首次启动会自动打开 Config 页面,选一个模型、填入自己的 API Key 即可一键启动:
|
||||
|
||||
- **不送 token**:余额初始为 0,需自行充值([u-claw.org/cloud.html](https://u-claw.org/cloud.html))
|
||||
- **指纹规则**:
|
||||
- 从 U 盘根目录启动 → 绑定该 U 盘的硬件指纹(USB Serial + PNPDeviceID)
|
||||
- 从硬盘启动(电脑安装版 / Electron App)→ 绑定主板 + 系统盘指纹
|
||||
- Mac/Linux 走 Hardware UUID / `/etc/machine-id` + 启动盘 UUID
|
||||
- **换机 / 换盘**:换 U 盘或换电脑后,新的指纹会生成新的 Key;旧 Key 的余额仍归属原指纹(在 Config.html 点「解绑」可触发重新绑定)
|
||||
- **隐私**:指纹只用于本地生成 Key,不上传,无登录
|
||||
- **虾盘云(首选 · 中转站)**:一个 Key 调用 DeepSeek / Claude / GPT / 通义 等国内外全部大模型,无需翻墙。注册并充值:[u-claw.org/cloud.html](https://u-claw.org/cloud.html)
|
||||
- **各家官方 Key 也行**:DeepSeek、通义千问、Kimi、智谱 GLM、豆包、MiniMax(国内)/ OpenAI、Claude、Groq(国外)/ 硅基流动、任意 OpenAI 兼容地址(自定义)
|
||||
- **配置只存在本地** `data/.openclaw/openclaw.json`,拔盘不留痕
|
||||
|
||||
### 一键安装(推荐)
|
||||
|
||||
|
||||
@@ -151,26 +151,6 @@ input:focus { border-color: #ff6b35; }
|
||||
<h1><span class="lobster">🦞</span> U-Claw Pro</h1>
|
||||
<p class="subtitle">便携版 — 选择模型,填入 API Key,一键启动</p>
|
||||
|
||||
<!-- Xiapan Cloud bound banner: shown when bootstrap has injected uclaw-cloud -->
|
||||
<div class="xp-banner" id="xpBanner" style="display:none; background:linear-gradient(135deg,#2a1f1f,#1f2a1f); border:1px solid #ff6b35; border-radius:10px; padding:16px 18px; margin-bottom:18px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:240px;">
|
||||
<div style="font-size:0.95em; color:#ff6b35; font-weight:600;">🔗 已绑定虾盘云 · 开箱即用</div>
|
||||
<div style="font-size:0.82em; color:#bbb; margin-top:4px;">
|
||||
指纹来源: <span id="xpSource">—</span>
|
||||
· Key: <code id="xpKeyShort" style="background:#222; padding:2px 6px; border-radius:4px;">—</code>
|
||||
· 余额: <span id="xpBalance">—</span>
|
||||
</div>
|
||||
<div id="xpHint" style="font-size:0.75em; color:#888; margin-top:4px;">余额为 0 时无法调用 AI,请先充值</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn" id="xpRecharge" style="background:#ff6b35; color:#fff; padding:8px 16px; font-size:0.85em;">前往充值 →</button>
|
||||
<button class="btn" id="xpRefresh" style="background:#333; color:#ccc; padding:8px 12px; font-size:0.85em;">刷新</button>
|
||||
<button class="btn" id="xpRebind" style="background:#222; color:#888; padding:8px 12px; font-size:0.8em;" title="清除绑定后重启 U-Claw 即可重新生成 Key">解绑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="steps">
|
||||
<div class="step active" data-step="1"><span class="num">1</span>选模型</div>
|
||||
@@ -181,13 +161,14 @@ input:focus { border-color: #ff6b35; }
|
||||
<!-- Step 1: Select Model -->
|
||||
<div class="section active" id="step1">
|
||||
<h2>选择 AI 模型</h2>
|
||||
<p class="desc">最省心:直接用「虾盘云」一个 Key 用全部模型,插上即用、已含试用额度。也可用下面各家自己的 Key。</p>
|
||||
<p class="desc">最省心:用「虾盘云」一个 Key 调用国内外全部大模型(中转站)。也可用下面各家自己的官方 Key。</p>
|
||||
|
||||
<div class="model-grid">
|
||||
<div class="model-card" data-provider="uclaw-cloud" data-base="https://api.u-claw.org/v1" data-model="deepseek-v4-flash" data-zerokey="1">
|
||||
<div class="model-card" data-provider="uclaw-cloud" data-base="https://api.u-claw.org/v1" data-model="deepseek-v4-flash">
|
||||
<span class="check">✓</span>
|
||||
<h4>虾盘云 <span class="tag hot">首选</span><span class="tag free">含试用额度</span></h4>
|
||||
<p>插上即用,无需申请 Key。一个账号用 DeepSeek / Claude / GPT / 通义等全部模型</p>
|
||||
<h4>虾盘云 <span class="tag hot">首选</span><span class="tag">中转站</span></h4>
|
||||
<p>一个 Key 用 DeepSeek / Claude / GPT / 通义 等国内外全部大模型,无需翻墙</p>
|
||||
<a class="buy-link" href="https://u-claw.org/cloud.html" target="_blank">→ 注册并获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="deepseek" data-base="https://api.deepseek.com/v1" data-model="deepseek-chat">
|
||||
<span class="check">✓</span>
|
||||
@@ -413,55 +394,10 @@ input:focus { border-color: #ff6b35; }
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// --- Xiapan Cloud banner ---
|
||||
async function loadXiapanStatus() {
|
||||
const banner = document.getElementById('xpBanner');
|
||||
try {
|
||||
const res = await fetch('/api/xiapan/status');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.apiKey) return;
|
||||
|
||||
document.getElementById('xpSource').textContent = ({
|
||||
usb: 'U 盘', disk: '硬盘', mac: 'Mac', linux: 'Linux', seed: '本机种子', test: '测试',
|
||||
})[data.source] || data.source;
|
||||
document.getElementById('xpKeyShort').textContent = data.apiKey.slice(0, 14) + '…';
|
||||
const bal = data.balance || {};
|
||||
if (bal.ok) {
|
||||
const usd = bal.remainingUsd != null ? bal.remainingUsd.toFixed(2) : '0.00';
|
||||
const tokens = (bal.remainingTokens || 0).toLocaleString();
|
||||
document.getElementById('xpBalance').textContent = `$${usd} (${tokens} tokens)`;
|
||||
document.getElementById('xpHint').textContent = bal.remainingUsd > 0
|
||||
? '已含试用额度,可直接用 deepseek-v4 / claude / gpt / 通义 等全部模型'
|
||||
: '试用额度已用完,点击右侧充值即可继续,秒到账';
|
||||
} else if (bal.reason && bal.reason.includes('401')) {
|
||||
document.getElementById('xpBalance').textContent = '未注册';
|
||||
document.getElementById('xpHint').textContent = '首次使用:点「前往充值」即可在虾盘云页面注册并充值,秒到账';
|
||||
} else {
|
||||
document.getElementById('xpBalance').textContent = '查询失败';
|
||||
document.getElementById('xpHint').textContent = '请检查网络或 api.u-claw.org 服务状态';
|
||||
}
|
||||
|
||||
document.getElementById('xpRecharge').onclick = () => window.open(data.rechargeUrl, '_blank');
|
||||
document.getElementById('xpRefresh').onclick = () => loadXiapanStatus();
|
||||
document.getElementById('xpRebind').onclick = async () => {
|
||||
if (!confirm('解绑后下次启动 U-Claw 会重新生成 Key(基于当前设备指纹)。\n现有 Key 的余额仍归属当前指纹,更换 U 盘 / 电脑后才需要解绑。\n\n确认解绑?')) return;
|
||||
await fetch('/api/xiapan/unbind', { method: 'POST' });
|
||||
showToast('已解绑,请重启 U-Claw');
|
||||
};
|
||||
|
||||
banner.style.display = 'block';
|
||||
} catch (err) {
|
||||
// network error or endpoint missing — leave banner hidden
|
||||
}
|
||||
}
|
||||
loadXiapanStatus();
|
||||
|
||||
// --- State ---
|
||||
let selectedProvider = null;
|
||||
let selectedBase = '';
|
||||
let selectedModel = '';
|
||||
let selectedZeroKey = false; // 虾盘云等已自动开户、无需用户填 Key 的 provider
|
||||
|
||||
// --- Model cards ---
|
||||
document.querySelectorAll('#step1 .model-card').forEach(card => {
|
||||
@@ -472,21 +408,12 @@ document.querySelectorAll('#step1 .model-card').forEach(card => {
|
||||
selectedProvider = card.dataset.provider;
|
||||
selectedBase = card.dataset.base;
|
||||
selectedModel = card.dataset.model;
|
||||
selectedZeroKey = card.dataset.zerokey === '1';
|
||||
const nextBtn = document.getElementById('nextStep1');
|
||||
nextBtn.disabled = false;
|
||||
// 虾盘云已自动开户,跳过填 Key,按钮直接变「启动」
|
||||
nextBtn.textContent = selectedZeroKey ? '直接启动 🚀' : '下一步 →';
|
||||
document.getElementById('nextStep1').disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('nextStep1').addEventListener('click', () => {
|
||||
if (selectedZeroKey) {
|
||||
// 虾盘云:已有自动开户的 key(在 uclaw-cloud provider 里),无需填,直接保存启动
|
||||
saveZeroKeyConfig();
|
||||
} else {
|
||||
goStep(2);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Steps ---
|
||||
@@ -508,7 +435,7 @@ function setupStep2() {
|
||||
document.getElementById('customFields').style.display = isCustom ? 'block' : 'none';
|
||||
|
||||
const providerNames = {
|
||||
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||
'uclaw-cloud': '虾盘云', minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
||||
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
|
||||
siliconflow: '硅基流动', custom: '自定义'
|
||||
@@ -585,42 +512,6 @@ function buildOpenClawConfig(provider, base, model, apiKey) {
|
||||
};
|
||||
}
|
||||
|
||||
// --- 虾盘云:已自动开户,无需填 Key。只把默认模型指回 uclaw-cloud 并保存 ---
|
||||
async function saveZeroKeyConfig() {
|
||||
try {
|
||||
// 读现有配置(bootstrap 已写入 uclaw-cloud provider + 自动开户的 key)
|
||||
const cur = await fetch('/api/config').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||
const config = (cur && typeof cur === 'object') ? cur : {};
|
||||
|
||||
// 确保 uclaw-cloud provider 存在;若缺(极少数 bootstrap 失败),提示去充值页
|
||||
const hasCloud = config.models && config.models.providers && config.models.providers['uclaw-cloud'] && config.models.providers['uclaw-cloud'].apiKey;
|
||||
if (!hasCloud) {
|
||||
showToast('虾盘云尚未就绪,请重启 U-Claw 或检查网络后重试', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 把默认模型指回虾盘云
|
||||
config.agents = config.agents || {};
|
||||
config.agents.defaults = config.agents.defaults || {};
|
||||
config.agents.defaults.model = config.agents.defaults.model || {};
|
||||
config.agents.defaults.model.primary = 'uclaw-cloud/' + (selectedModel || 'deepseek-v4-flash');
|
||||
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
if (!res.ok) throw new Error('保存失败');
|
||||
|
||||
showToast('已选择虾盘云,启动中…');
|
||||
goStep(3);
|
||||
const port = getPort();
|
||||
document.getElementById('gatewayUrl').textContent = 'http://127.0.0.1:' + port;
|
||||
} catch (e) {
|
||||
showToast('保存失败: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Save config ---
|
||||
async function saveConfig() {
|
||||
const apiKey = document.getElementById('apiKey').value.trim();
|
||||
|
||||
@@ -110,12 +110,7 @@ if [ ! -d "$CORE_DIR/node_modules" ]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ---- 7b. Bind device fingerprint and inject Xiapan Cloud apiKey ----
|
||||
echo -e " ${CYAN}Binding device fingerprint to Xiapan Cloud...${NC}"
|
||||
UCLAW_APP_ROOT="$UCLAW_DIR" "$NODE_BIN" "$UCLAW_DIR/lib/bootstrap-xiapan.mjs" "$CONFIG_FILE" || true
|
||||
echo ""
|
||||
|
||||
# ---- 7c. Async update check (non-blocking, 5s timeout, silent failure) ----
|
||||
# ---- 7b. Async update check (non-blocking, 5s timeout, silent failure) ----
|
||||
# Writes data/.openclaw/update-available.json if a newer version is on OSS.
|
||||
# Welcome.html / Config.html read this file and show a banner.
|
||||
# Version file lookup: portable/OPENCLAW_VERSION (USB) → ../OPENCLAW_VERSION (dev)
|
||||
@@ -132,9 +127,6 @@ while lsof -i :$PORT >/dev/null 2>&1; do
|
||||
PORT=$((PORT + 1))
|
||||
if [ $PORT -gt 18799 ]; then
|
||||
echo -e " ${RED}No available port (18789-18799)${NC}"
|
||||
# 自动上报:端口全占,gateway 无法启动(后台、静默、失败不影响)
|
||||
UCLAW_APP_ROOT="$UCLAW_DIR" "$NODE_BIN" "$UCLAW_DIR/lib/report-bug.mjs" \
|
||||
--auto --title "gateway-no-free-port" --desc "Ports 18789-18799 all in use" --root "$UCLAW_DIR" >/dev/null 2>&1 &
|
||||
read -p " Press Enter to exit..."
|
||||
exit 1
|
||||
fi
|
||||
@@ -190,12 +182,9 @@ trap cleanup INT TERM
|
||||
wait $GW_PID
|
||||
GW_EXIT=$?
|
||||
|
||||
# 自动上报:gateway 异常退出。Ctrl+C 走 trap cleanup(exit 0)不会到这;
|
||||
# 走到这里说明 gateway 自己退了。退出码非 0 才上报,避免噪音。后台、静默、失败不影响。
|
||||
# Ctrl+C 走 trap cleanup(exit 0)不会到这;走到这里说明 gateway 自己退了。
|
||||
if [ "$GW_EXIT" -ne 0 ]; then
|
||||
echo -e " ${YELLOW}OpenClaw exited unexpectedly (code $GW_EXIT), reporting...${NC}"
|
||||
UCLAW_APP_ROOT="$UCLAW_DIR" "$NODE_BIN" "$UCLAW_DIR/lib/report-bug.mjs" \
|
||||
--auto --title "gateway-exited-code-$GW_EXIT" --desc "Gateway exited with code $GW_EXIT on port $PORT" --root "$UCLAW_DIR" >/dev/null 2>&1 &
|
||||
echo -e " ${YELLOW}OpenClaw exited unexpectedly (code $GW_EXIT)${NC}"
|
||||
fi
|
||||
kill $CONFIG_PID 2>/dev/null
|
||||
echo ""
|
||||
|
||||
1
portable/OPENCLAW_VERSION
Normal file
1
portable/OPENCLAW_VERSION
Normal file
@@ -0,0 +1 @@
|
||||
2026.6.8
|
||||
@@ -86,12 +86,6 @@ if not exist "%CORE_DIR%\node_modules" (
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Bind device fingerprint and inject Xiapan Cloud apiKey into openclaw.json
|
||||
echo Binding device fingerprint to Xiapan Cloud...
|
||||
set "UCLAW_APP_ROOT=%UCLAW_DIR%"
|
||||
"%NODE_BIN%" "%UCLAW_DIR%lib\bootstrap-xiapan.mjs" "%STATE_DIR%\openclaw.json"
|
||||
echo.
|
||||
|
||||
REM Async update check (non-blocking, 5s timeout, silent failure)
|
||||
REM Writes data\.openclaw\update-available.json if a newer version is on OSS.
|
||||
REM Welcome.html / Config.html read this file and show a banner.
|
||||
@@ -125,8 +119,6 @@ if %errorlevel%==0 (
|
||||
set /a PORT+=1
|
||||
if %PORT% gtr 18799 (
|
||||
echo No available port 18789-18799
|
||||
REM 自动上报:端口全被占,gateway 无法启动(detach、静默、失败不影响)
|
||||
start /B "" "%NODE_BIN%" "%UCLAW_DIR%lib\report-bug.mjs" --auto --title "gateway-no-free-port" --desc "Ports 18789-18799 all in use" --root "%UCLAW_DIR%." >nul 2>&1
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
@@ -170,11 +162,8 @@ set "OPENCLAW_MJS=%CORE_DIR%\node_modules\openclaw\openclaw.mjs"
|
||||
set "GW_EXIT=%errorlevel%"
|
||||
|
||||
echo.
|
||||
REM 自动上报:gateway 异常退出(退出码非 0 且非 Ctrl+C/0xC000013A=-1073741510)
|
||||
REM 用户正常 Ctrl+C 停止不上报,避免噪音。detach、静默、失败不影响。
|
||||
if not "%GW_EXIT%"=="0" if not "%GW_EXIT%"=="-1073741510" (
|
||||
echo OpenClaw exited unexpectedly (code %GW_EXIT%), reporting...
|
||||
start /B "" "%NODE_BIN%" "%UCLAW_DIR%lib\report-bug.mjs" --auto --title "gateway-exited-code-%GW_EXIT%" --desc "Gateway exited with code %GW_EXIT% on port %PORT%" --root "%UCLAW_DIR%." >nul 2>&1
|
||||
echo OpenClaw exited unexpectedly ^(code %GW_EXIT%^)
|
||||
)
|
||||
echo OpenClaw stopped.
|
||||
pause
|
||||
|
||||
@@ -413,21 +413,9 @@ input:focus { border-color: #ff6b35; }
|
||||
|
||||
<div class="section active skills-section" style="display:block; margin-top: 8px;">
|
||||
<h2>🐛 反馈 / 报 Bug</h2>
|
||||
<p class="desc">遇到问题?一键反馈给我们,会自动附带版本号和最近日志(不含你的 API Key),方便我们排查。</p>
|
||||
<div class="form-group">
|
||||
<label>问题标题 <span style="color:#888;font-weight:normal">(必填,简短描述)</span></label>
|
||||
<input type="text" id="bugTitle" placeholder="例如:点击打开 Dashboard 没反应" maxlength="200">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>详细描述 <span style="color:#888;font-weight:normal">(可选)</span></label>
|
||||
<textarea id="bugDesc" rows="4" placeholder="发生了什么?做了哪些操作?" style="width:100%;padding:12px;border-radius:8px;background:#1a1a1a;border:1px solid #333;color:#eee;font-family:inherit;font-size:0.95em;box-sizing:border-box;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="bugLogs" checked style="width:auto;margin:0">
|
||||
<label for="bugLogs" style="margin:0;font-weight:normal">附带最近运行日志(推荐,帮助定位问题)</label>
|
||||
</div>
|
||||
<p class="desc">U-Claw 是开源项目。遇到问题欢迎到 GitHub 提 Issue —— 我们不会自动收集或上传你的任何数据。</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" id="bugSubmitBtn" onclick="submitBug()">提交反馈</button>
|
||||
<a class="btn btn-primary" href="https://github.com/dongsheng123132/u-claw/issues/new" target="_blank">→ 在 GitHub 提交 Issue</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,32 +714,6 @@ async function viewConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bug report ---
|
||||
async function submitBug() {
|
||||
var title = document.getElementById('bugTitle').value.trim();
|
||||
if (title.length < 3) { showToast('请填写问题标题(至少 3 个字)', true); return; }
|
||||
var desc = document.getElementById('bugDesc').value.trim();
|
||||
var includeLogs = document.getElementById('bugLogs').checked;
|
||||
var btn = document.getElementById('bugSubmitBtn');
|
||||
btn.disabled = true; btn.textContent = '提交中…';
|
||||
try {
|
||||
var res = await fetch('/api/report-bug', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title, description: desc, includeLogs: includeLogs })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.reason || data.error || '提交失败');
|
||||
showToast('反馈已提交,编号 #' + data.id + ',谢谢!');
|
||||
document.getElementById('bugTitle').value = '';
|
||||
document.getElementById('bugDesc').value = '';
|
||||
} catch (e) {
|
||||
showToast('提交失败:' + e.message, true);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '提交反馈';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toast ---
|
||||
function showToast(msg, isError) {
|
||||
var t = document.getElementById('toast');
|
||||
|
||||
@@ -393,32 +393,6 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Xiapan Cloud status (fingerprint + apiKey + balance)
|
||||
if (req.url === '/api/xiapan/status' && req.method === 'GET') {
|
||||
(async () => {
|
||||
try {
|
||||
const fpMod = await import('../lib/fingerprint.mjs');
|
||||
const xpMod = await import('../lib/xiapan-client.mjs');
|
||||
const portableRoot = path.join(__dirname, '..');
|
||||
const fp = await fpMod.getFingerprint(portableRoot);
|
||||
const apiKey = xpMod.buildApiKey(fp.fingerprint);
|
||||
const balance = await xpMod.getBalance(apiKey);
|
||||
const rechargeUrl = xpMod.getRechargeUrl(apiKey);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
source: fp.source,
|
||||
apiKey,
|
||||
rechargeUrl,
|
||||
balance,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Update status — read update-available.json written by check-update.mjs
|
||||
// Returns { available: false } if no info or stale; otherwise the manifest payload.
|
||||
if (req.url === '/api/update-status' && req.method === 'GET') {
|
||||
@@ -463,45 +437,6 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Re-run bootstrap to inject the uclaw-cloud provider
|
||||
if (req.url === '/api/xiapan/bind' && req.method === 'POST') {
|
||||
(async () => {
|
||||
try {
|
||||
const mod = await import('../lib/bootstrap-xiapan.mjs');
|
||||
const portableRoot = path.join(__dirname, '..');
|
||||
const result = await mod.bootstrapXiapan({
|
||||
configPath: CONFIG_PATH,
|
||||
appRoot: portableRoot,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Remove uclaw-cloud provider so the next bind regenerates it
|
||||
if (req.url === '/api/xiapan/unbind' && req.method === 'POST') {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
if (config.models && config.models.providers && config.models.providers['uclaw-cloud']) {
|
||||
delete config.models.providers['uclaw-cloud'];
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Save config
|
||||
if (req.url === '/api/config' && req.method === 'POST') {
|
||||
let body = '';
|
||||
@@ -524,37 +459,6 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Report a bug — user fills title/description in the web UI; this endpoint
|
||||
// attaches fingerprint/version/logs locally and forwards to api.u-claw.org.
|
||||
if (req.url === '/api/report-bug' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk;
|
||||
if (body.length > 2 * 1024 * 1024) req.destroy(); // 2MB cap on user input
|
||||
});
|
||||
req.on('end', () => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = body ? JSON.parse(body) : {};
|
||||
const mod = await import('../lib/report-bug.mjs');
|
||||
const portableRoot = path.join(__dirname, '..');
|
||||
const result = await mod.submitBugReport({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
appRoot: portableRoot,
|
||||
includeLogs: data.includeLogs !== false, // 默认带日志,用户可勾掉
|
||||
});
|
||||
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
})();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve static files
|
||||
const filePath = req.url === '/'
|
||||
? path.join(__dirname, 'public/index.html')
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Bootstrap: ensure data/.openclaw/openclaw.json contains the uclaw-cloud provider
|
||||
// pointing to the device-bound apiKey derived from the local fingerprint.
|
||||
//
|
||||
// Idempotent: if the provider already exists with the correct apiKey, do nothing.
|
||||
// If it exists but the apiKey differs (USB swapped, machine changed), leave the
|
||||
// existing entry alone and log a hint — never overwrite user data silently.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { getFingerprint } from './fingerprint.mjs';
|
||||
import { buildApiKey, provisionApiKey } from './xiapan-client.mjs';
|
||||
|
||||
const PROVIDER_ID = 'uclaw-cloud';
|
||||
|
||||
// Mirror ClawX commercial schema: keep models[] empty and steer model selection
|
||||
// via agents.defaults.model.primary + fallbacks. OpenClaw 2026.4.x accepts both
|
||||
// shapes, but the empty-models form lets the runtime auto-discover model ids
|
||||
// from /v1/models without us hard-coding a list that drifts from the backend.
|
||||
const DEFAULT_PROVIDER_TEMPLATE = {
|
||||
baseUrl: 'https://api.u-claw.org/v1',
|
||||
api: 'openai-completions',
|
||||
models: [],
|
||||
};
|
||||
|
||||
const DEFAULT_PRIMARY_MODEL = `${PROVIDER_ID}/deepseek-v4-flash`;
|
||||
const DEFAULT_FALLBACK_MODELS = [
|
||||
`${PROVIDER_ID}/deepseek-chat`,
|
||||
`${PROVIDER_ID}/qwen-plus`,
|
||||
`${PROVIDER_ID}/qwen-turbo`,
|
||||
];
|
||||
|
||||
function readJsonSafe(filePath) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
process.stderr.write(`[bootstrap-xiapan] Cannot parse ${filePath}: ${err.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, data) {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function ensureModelsContainer(config) {
|
||||
if (!config.models || typeof config.models !== 'object') {
|
||||
config.models = { mode: 'merge', providers: {} };
|
||||
}
|
||||
if (!config.models.mode) config.models.mode = 'merge';
|
||||
if (!config.models.providers || typeof config.models.providers !== 'object') {
|
||||
config.models.providers = {};
|
||||
}
|
||||
return config.models.providers;
|
||||
}
|
||||
|
||||
// Set agents.defaults.model.primary to uclaw-cloud only when the user has not
|
||||
// configured a primary model already. If they've picked a different provider
|
||||
// (e.g. DeepSeek BYOK), leave their choice untouched.
|
||||
function ensureAgentsDefaults(config) {
|
||||
if (!config.agents || typeof config.agents !== 'object') {
|
||||
config.agents = {};
|
||||
}
|
||||
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') {
|
||||
config.agents.defaults = {};
|
||||
}
|
||||
const defaults = config.agents.defaults;
|
||||
if (!defaults.model || typeof defaults.model !== 'object') {
|
||||
defaults.model = {};
|
||||
}
|
||||
let changed = false;
|
||||
if (!defaults.model.primary) {
|
||||
defaults.model.primary = DEFAULT_PRIMARY_MODEL;
|
||||
changed = true;
|
||||
}
|
||||
if (!Array.isArray(defaults.model.fallbacks) || defaults.model.fallbacks.length === 0) {
|
||||
defaults.model.fallbacks = [...DEFAULT_FALLBACK_MODELS];
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function bootstrapXiapan({ configPath, appRoot, log = console } = {}) {
|
||||
if (!configPath) {
|
||||
throw new Error('bootstrapXiapan: configPath is required.');
|
||||
}
|
||||
const root = appRoot || process.cwd();
|
||||
|
||||
let fingerprintInfo;
|
||||
try {
|
||||
fingerprintInfo = await getFingerprint(root);
|
||||
} catch (err) {
|
||||
log.warn?.(`[bootstrap-xiapan] Fingerprint detection failed: ${err.message}`);
|
||||
return { ok: false, reason: 'fingerprint-failed' };
|
||||
}
|
||||
|
||||
const config = readJsonSafe(configPath) || { gateway: { mode: 'local', auth: { token: 'uclaw' } } };
|
||||
const providers = ensureModelsContainer(config);
|
||||
const existing = providers[PROVIDER_ID];
|
||||
|
||||
// 幂等:配置里已有 uclaw-cloud apiKey → 保持不动,不再打网络 provision。
|
||||
// (后端 provision 本身也按 deviceId 幂等,但客户端先短路可避免每次启动都请求。)
|
||||
if (existing && typeof existing === 'object' && existing.apiKey) {
|
||||
const changedDefaults = ensureAgentsDefaults(config);
|
||||
if (changedDefaults) {
|
||||
writeJson(configPath, config);
|
||||
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action: changedDefaults ? 'agents-defaults-added' : 'noop',
|
||||
source: fingerprintInfo.source,
|
||||
apiKey: existing.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// 首启(配置里还没有 cloud key):静默开户。把指纹送到虾盘云后台自动开一个带
|
||||
// 试用额度的 token。成功 → 用后端真正注册过的 key(可直接聊天,解决 Invalid token)。
|
||||
// 失败(离线/后端不可达)→ 回退到指纹派生 key,至少配置不空、不阻塞启动。
|
||||
let apiKey = buildApiKey(fingerprintInfo.fingerprint);
|
||||
let provisioned = false;
|
||||
try {
|
||||
const prov = await provisionApiKey(fingerprintInfo.fingerprint, { source: 'uclaw-portable' });
|
||||
if (prov.ok && prov.apiKey) {
|
||||
apiKey = prov.apiKey;
|
||||
provisioned = true;
|
||||
log.info?.(`[bootstrap-xiapan] Provisioned cloud token (reused=${prov.reused})`);
|
||||
} else {
|
||||
log.info?.(`[bootstrap-xiapan] Provision skipped (${prov.reason}); using fingerprint key`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.info?.(`[bootstrap-xiapan] Provision failed (${err.message}); using fingerprint key`);
|
||||
}
|
||||
|
||||
providers[PROVIDER_ID] = {
|
||||
...DEFAULT_PROVIDER_TEMPLATE,
|
||||
...(existing && typeof existing === 'object' ? existing : {}),
|
||||
baseUrl: existing?.baseUrl || DEFAULT_PROVIDER_TEMPLATE.baseUrl,
|
||||
api: existing?.api || DEFAULT_PROVIDER_TEMPLATE.api,
|
||||
apiKey,
|
||||
models: existing?.models ?? DEFAULT_PROVIDER_TEMPLATE.models,
|
||||
};
|
||||
ensureAgentsDefaults(config);
|
||||
|
||||
writeJson(configPath, config);
|
||||
log.info?.(
|
||||
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, `
|
||||
+ `provisioned=${provisioned}, key=${apiKey.slice(0, 12)}…)`,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'created',
|
||||
provisioned,
|
||||
source: fingerprintInfo.source,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// CLI:
|
||||
// node bootstrap-xiapan.mjs <config-path>
|
||||
// env UCLAW_CONFIG_PATH=... node bootstrap-xiapan.mjs
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const isMain = (() => {
|
||||
try {
|
||||
if (!process.argv[1]) return false;
|
||||
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
const configPath = process.argv[2] || process.env.UCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
process.stderr.write('Usage: node bootstrap-xiapan.mjs <openclaw.json path>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const appRoot = process.env.UCLAW_APP_ROOT || resolve(configPath, '../../..');
|
||||
bootstrapXiapan({ configPath, appRoot })
|
||||
.then((res) => {
|
||||
process.stdout.write(`${JSON.stringify(res)}\n`);
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`bootstrap-xiapan error: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
// Cross-platform device fingerprint for U-Claw / Xiapan Cloud apiKey binding.
|
||||
// Output: { source: 'usb' | 'disk' | 'mac' | 'linux' | 'seed' | 'test', fingerprint: '<64-hex>' }
|
||||
//
|
||||
// Order of preference:
|
||||
// Windows: USB drive (when running from a USB volume) -> system disk -> seed file
|
||||
// Mac: Hardware UUID + boot volume UUID -> seed file
|
||||
// Linux: /etc/machine-id + lsblk SERIAL of root -> seed file
|
||||
//
|
||||
// Adapted from v2/u-clawx-openclaw-dev/electron/utils/{license,disk-fingerprint}.ts
|
||||
// but simplified: no Ed25519 signing, no .license file, just a stable hash.
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { dirname, parse, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const POWERSHELL_CANDIDATES = [
|
||||
'powershell.exe',
|
||||
'powershell',
|
||||
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'C:\\Windows\\Sysnative\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'pwsh.exe',
|
||||
];
|
||||
|
||||
const TEST_FINGERPRINT_SOURCE = 'TEST:UCLAW_DEVELOPMENT_FIXED_FINGERPRINT';
|
||||
|
||||
function sha256Hex(input) {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
function shouldUseTestFingerprint() {
|
||||
return (
|
||||
process.env.UCLAW_SKIP_FINGERPRINT === '1'
|
||||
|| process.env.OPENCLAW_SKIP_USB_CHECK === '1'
|
||||
|| process.env.CLAWX_SKIP_USB_CHECK === '1'
|
||||
);
|
||||
}
|
||||
|
||||
function readEnvOverride() {
|
||||
const override = (process.env.UCLAW_FINGERPRINT_OVERRIDE || '').trim();
|
||||
if (!override || !/^[0-9a-f]{64}$/i.test(override)) return null;
|
||||
return { source: 'test', fingerprint: override.toLowerCase() };
|
||||
}
|
||||
|
||||
function getSeedPath(appRoot) {
|
||||
if (process.env.UCLAW_SEED_PATH) {
|
||||
return resolve(process.env.UCLAW_SEED_PATH);
|
||||
}
|
||||
const home = homedir();
|
||||
if (home) return resolve(home, '.uclaw', '.usb_seed');
|
||||
return resolve(appRoot, '.usb_seed');
|
||||
}
|
||||
|
||||
function readOrCreateSeedFingerprint(appRoot) {
|
||||
const seedPath = getSeedPath(appRoot);
|
||||
let seedHex;
|
||||
if (existsSync(seedPath)) {
|
||||
seedHex = readFileSync(seedPath, 'utf8').trim();
|
||||
}
|
||||
if (!seedHex || !/^[0-9a-f]{64}$/i.test(seedHex)) {
|
||||
seedHex = randomBytes(32).toString('hex');
|
||||
mkdirSync(dirname(seedPath), { recursive: true });
|
||||
writeFileSync(seedPath, seedHex + '\n', 'utf8');
|
||||
}
|
||||
return { source: 'seed', fingerprint: seedHex.toLowerCase() };
|
||||
}
|
||||
|
||||
async function runPowerShell(script) {
|
||||
const wrapped = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${script}`;
|
||||
let lastError = null;
|
||||
for (const candidate of POWERSHELL_CANDIDATES) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(candidate, ['-NoProfile', '-Command', wrapped], {
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') continue;
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('PowerShell is not available on this system.');
|
||||
}
|
||||
|
||||
function normalizeSerial(value) {
|
||||
if (!value) return '';
|
||||
return String(value).trim().replace(/[\s.]+$/g, '').replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function getDriveDepth(targetDir) {
|
||||
const parsed = parse(resolve(targetDir));
|
||||
if (!parsed.root) return null;
|
||||
const rel = relative(parsed.root, resolve(targetDir));
|
||||
const depth = rel.split(sep).map((s) => s.trim()).filter(Boolean).length;
|
||||
return { driveRoot: parsed.root.replace(/[\\/]$/, '').toUpperCase(), depth };
|
||||
}
|
||||
|
||||
async function tryWindowsUsbFingerprint(appRoot) {
|
||||
const drive = getDriveDepth(appRoot);
|
||||
if (!drive) return null;
|
||||
// Only attempt USB fingerprint when running from a drive root or first-level subfolder
|
||||
if (drive.depth > 2) return null;
|
||||
const driveLetter = drive.driveRoot.endsWith(':') ? drive.driveRoot : `${drive.driveRoot}:`;
|
||||
|
||||
const driveMappingScript = [
|
||||
`$p = Get-WmiObject -Query "ASSOCIATORS OF {Win32_LogicalDisk.DeviceID='${driveLetter}'} WHERE AssocClass=Win32_LogicalDiskToPartition"`,
|
||||
'$p0 = if ($p -is [System.Array]) { $p[0] } else { $p }',
|
||||
'$d = if ($p0) { Get-WmiObject -Query "ASSOCIATORS OF {Win32_DiskPartition.DeviceID=\'$($p0.DeviceID)\'} WHERE AssocClass=Win32_DiskDriveToDiskPartition" } else { $null }',
|
||||
'$d0 = if ($d -is [System.Array]) { $d[0] } else { $d }',
|
||||
"if ($d0) { $d0.PNPDeviceID } else { '' }",
|
||||
].join('; ');
|
||||
|
||||
let targetPnpId = '';
|
||||
try {
|
||||
targetPnpId = (await runPowerShell(driveMappingScript)).trim().toUpperCase();
|
||||
} catch {
|
||||
targetPnpId = '';
|
||||
}
|
||||
|
||||
let rawDiskJson;
|
||||
try {
|
||||
rawDiskJson = await runPowerShell(
|
||||
'Get-WmiObject Win32_DiskDrive | Select-Object Model, SerialNumber, PNPDeviceID | ConvertTo-Json -Compress',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let disks;
|
||||
try {
|
||||
const parsed = JSON.parse(rawDiskJson.trim() || '[]');
|
||||
disks = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!disks.length) return null;
|
||||
|
||||
const exactMatch = targetPnpId
|
||||
? disks.find((d) => (d.PNPDeviceID || '').toUpperCase() === targetPnpId)
|
||||
: null;
|
||||
|
||||
const usbDisk = exactMatch || disks.find((d) => {
|
||||
const pnp = (d.PNPDeviceID || '').toUpperCase();
|
||||
return pnp.includes('USB') || pnp.includes('USBSTOR');
|
||||
});
|
||||
|
||||
if (!usbDisk) return null;
|
||||
|
||||
const model = (usbDisk.Model || 'Unknown').trim();
|
||||
const serial = (usbDisk.SerialNumber || 'Unknown').trim();
|
||||
const pnp = (usbDisk.PNPDeviceID || 'Unknown').trim();
|
||||
return {
|
||||
source: 'usb',
|
||||
fingerprint: sha256Hex(`${model}:${serial}:${pnp}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryWindowsDiskFingerprint() {
|
||||
let physicalDiskInfo = null;
|
||||
try {
|
||||
const physicalScript = [
|
||||
"$systemDrive = ($env:SystemDrive -replace ':','')",
|
||||
"if (-not $systemDrive) { $systemDrive = 'C' }",
|
||||
'$disk = Get-Partition -DriveLetter $systemDrive -ErrorAction SilentlyContinue | Get-Disk -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
'if (-not $disk) { return }',
|
||||
'$pd = Get-PhysicalDisk -DeviceNumber $disk.Number -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
'if (-not $pd) { return }',
|
||||
'[pscustomobject]@{ Serial = $pd.SerialNumber; Model = $pd.FriendlyName; BusType = $pd.BusType } | ConvertTo-Json -Compress',
|
||||
].join('; ');
|
||||
const raw = (await runPowerShell(physicalScript)).trim();
|
||||
if (raw) physicalDiskInfo = JSON.parse(raw);
|
||||
} catch {
|
||||
physicalDiskInfo = null;
|
||||
}
|
||||
|
||||
if (!physicalDiskInfo) {
|
||||
try {
|
||||
const win32Script = [
|
||||
"$systemDrive = $env:SystemDrive -replace ':',''",
|
||||
"if (-not $systemDrive) { $systemDrive = 'C' }",
|
||||
'$letter = "$systemDrive`:"',
|
||||
"$lp = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_LogicalDisk.DeviceID='$letter'} WHERE AssocClass=Win32_LogicalDiskToPartition\"",
|
||||
'$lp0 = if ($lp -is [System.Array]) { $lp[0] } else { $lp }',
|
||||
'if (-not $lp0) { return }',
|
||||
"$dd = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_DiskPartition.DeviceID='$($lp0.DeviceID)'} WHERE AssocClass=Win32_DiskDriveToDiskPartition\"",
|
||||
'$dd0 = if ($dd -is [System.Array]) { $dd[0] } else { $dd }',
|
||||
'if (-not $dd0) { return }',
|
||||
'[pscustomobject]@{ Serial = $dd0.SerialNumber; Model = $dd0.Model; BusType = $dd0.InterfaceType } | ConvertTo-Json -Compress',
|
||||
].join('; ');
|
||||
const raw = (await runPowerShell(win32Script)).trim();
|
||||
if (raw) physicalDiskInfo = JSON.parse(raw);
|
||||
} catch {
|
||||
physicalDiskInfo = null;
|
||||
}
|
||||
}
|
||||
if (!physicalDiskInfo) return null;
|
||||
|
||||
let boardSerial = 'NoBoard';
|
||||
try {
|
||||
const raw = await runPowerShell('(Get-CimInstance Win32_BaseBoard | Select-Object -First 1).SerialNumber');
|
||||
boardSerial = normalizeSerial(raw) || 'NoBoard';
|
||||
} catch {
|
||||
// keep default
|
||||
}
|
||||
|
||||
const diskSerial = normalizeSerial(physicalDiskInfo.Serial);
|
||||
const diskModel = (physicalDiskInfo.Model || 'Unknown').toString().trim();
|
||||
return {
|
||||
source: 'disk',
|
||||
fingerprint: sha256Hex(`DISK:${diskSerial}:${diskModel}:${boardSerial}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryMacFingerprint() {
|
||||
let hardwareUuid = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/sbin/system_profiler', ['SPHardwareDataType'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = stdout.match(/Hardware UUID:\s*([0-9A-F-]+)/i);
|
||||
if (match) hardwareUuid = match[1].trim().toUpperCase();
|
||||
} catch {
|
||||
hardwareUuid = '';
|
||||
}
|
||||
|
||||
let bootVolumeUuid = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/sbin/diskutil', ['info', '/'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = stdout.match(/Volume UUID:\s*([0-9A-F-]+)/i);
|
||||
if (match) bootVolumeUuid = match[1].trim().toUpperCase();
|
||||
} catch {
|
||||
bootVolumeUuid = '';
|
||||
}
|
||||
|
||||
if (!hardwareUuid && !bootVolumeUuid) return null;
|
||||
return {
|
||||
source: 'mac',
|
||||
fingerprint: sha256Hex(`MAC:${hardwareUuid}:${bootVolumeUuid}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryLinuxFingerprint() {
|
||||
let machineId = '';
|
||||
for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
||||
try {
|
||||
machineId = readFileSync(path, 'utf8').trim();
|
||||
if (machineId) break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
let rootSerial = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/bin/lsblk', ['-no', 'SERIAL,MOUNTPOINT'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
for (const line of stdout.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && parts[1] === '/') {
|
||||
rootSerial = parts[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
rootSerial = '';
|
||||
}
|
||||
|
||||
if (!machineId && !rootSerial) return null;
|
||||
return {
|
||||
source: 'linux',
|
||||
fingerprint: sha256Hex(`LINUX:${machineId}:${rootSerial}`),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedPromise = null;
|
||||
|
||||
export async function getFingerprint(appRoot) {
|
||||
if (cachedPromise) return cachedPromise;
|
||||
cachedPromise = computeFingerprint(appRoot || process.cwd()).catch((err) => {
|
||||
cachedPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
async function computeFingerprint(appRoot) {
|
||||
const override = readEnvOverride();
|
||||
if (override) return override;
|
||||
|
||||
if (shouldUseTestFingerprint()) {
|
||||
return { source: 'test', fingerprint: sha256Hex(TEST_FINGERPRINT_SOURCE) };
|
||||
}
|
||||
|
||||
const plat = platform();
|
||||
|
||||
if (plat === 'win32') {
|
||||
const usb = await tryWindowsUsbFingerprint(appRoot).catch(() => null);
|
||||
if (usb) return usb;
|
||||
const disk = await tryWindowsDiskFingerprint().catch(() => null);
|
||||
if (disk) return disk;
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
if (plat === 'darwin') {
|
||||
const mac = await tryMacFingerprint().catch(() => null);
|
||||
if (mac) return mac;
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
if (plat === 'linux') {
|
||||
const linux = await tryLinuxFingerprint().catch(() => null);
|
||||
if (linux) return linux;
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
// CLI entrypoint: prints JSON when run directly.
|
||||
// node fingerprint.mjs -> {"source":"...","fingerprint":"..."}
|
||||
// node fingerprint.mjs apiKey -> sk-<fingerprint>
|
||||
const isMain = (() => {
|
||||
try {
|
||||
if (!process.argv[1]) return false;
|
||||
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
const appRoot = process.env.UCLAW_APP_ROOT || process.cwd();
|
||||
getFingerprint(appRoot)
|
||||
.then((result) => {
|
||||
const arg = process.argv[2];
|
||||
if (arg === 'apiKey') {
|
||||
process.stdout.write(`sk-${result.fingerprint}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`fingerprint error: ${err && err.message ? err.message : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Xiapan Cloud (虾盘云) client for U-Claw open-source edition.
|
||||
// Provides only: apiKey derivation, balance lookup, recharge URL.
|
||||
// Intentionally does NOT call /recharge/activate — open-source users do not get free quota.
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.u-claw.org/v1';
|
||||
const DEFAULT_RECHARGE_PAGE = 'https://u-claw.org/cloud.html';
|
||||
const QUOTA_PER_USD = 500_000; // 1 USD = 500k tokens (matches new-api convention)
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
// sk-uc- prefix marks keys generated by the u-claw open-source edition.
|
||||
// ClawX commercial keys use plain sk-<hash> and the cloud.html flow uses sk-xp-,
|
||||
// so the three namespaces never collide and the backend can audit by prefix.
|
||||
export function buildApiKey(fingerprint) {
|
||||
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
|
||||
throw new Error('Fingerprint must be 64-character hex.');
|
||||
}
|
||||
return `sk-uc-${fingerprint.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return (process.env.UCLAW_CLOUD_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getRechargePage() {
|
||||
return process.env.UCLAW_CLOUD_RECHARGE_PAGE || DEFAULT_RECHARGE_PAGE;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBalance(apiKey) {
|
||||
if (!apiKey) throw new Error('apiKey is required.');
|
||||
const base = getApiBase();
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
|
||||
const [subRes, usageRes] = await Promise.all([
|
||||
fetchWithTimeout(`${base}/dashboard/billing/subscription`, { headers }).catch(() => null),
|
||||
fetchWithTimeout(
|
||||
`${base}/dashboard/billing/usage?start_date=2020-01-01&end_date=${new Date().toISOString().slice(0, 10)}`,
|
||||
{ headers },
|
||||
).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!subRes || !subRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: subRes ? `subscription HTTP ${subRes.status}` : 'subscription request failed',
|
||||
hardLimitUsd: 0,
|
||||
usedUsd: 0,
|
||||
remainingUsd: 0,
|
||||
remainingTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const subscription = await subRes.json().catch(() => ({}));
|
||||
let usedUsd = 0;
|
||||
if (usageRes && usageRes.ok) {
|
||||
const usage = await usageRes.json().catch(() => ({}));
|
||||
// total_usage is in cents (USD * 100), per new-api convention
|
||||
usedUsd = Number(usage.total_usage || 0) / 100;
|
||||
}
|
||||
|
||||
const hardLimitUsd = Number(subscription.hard_limit_usd || 0);
|
||||
const remainingUsd = Math.max(0, hardLimitUsd - usedUsd);
|
||||
const remainingTokens = Math.round(remainingUsd * QUOTA_PER_USD);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
hardLimitUsd,
|
||||
usedUsd,
|
||||
remainingUsd,
|
||||
remainingTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRechargeUrl(apiKey) {
|
||||
if (!apiKey) throw new Error('apiKey is required.');
|
||||
const page = getRechargePage();
|
||||
const url = new URL(page);
|
||||
url.searchParams.set('key', apiKey);
|
||||
// Page already has #recharge anchor; preserve it
|
||||
return `${url.toString()}#recharge`;
|
||||
}
|
||||
|
||||
// 首启静默开户:把设备指纹送到虾盘云后台,自动创建一个 token 并附带试用额度。
|
||||
// 后端按 deviceId 幂等(同一台机器永远拿同一个 token,刷不出额度),不重启容器。
|
||||
// 返回 { ok, apiKey, reused } —— apiKey 是后端真正注册过的 key(可直接聊天)。
|
||||
// 失败时返回 { ok:false, reason },调用方应回退到指纹派生 key(至少配置不空)。
|
||||
export async function provisionApiKey(fingerprint, { source = 'uclaw-portable' } = {}) {
|
||||
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
|
||||
return { ok: false, reason: 'bad-fingerprint' };
|
||||
}
|
||||
const base = getApiBase(); // https://api.u-claw.org/v1
|
||||
const root = base.replace(/\/v1$/, ''); // https://api.u-claw.org
|
||||
const url = `${root}/internal/token/provision`;
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId: fingerprint.toLowerCase(), source }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.success || !data.data || !data.data.apiKey) {
|
||||
return { ok: false, reason: data.message || `http-${res.status}` };
|
||||
}
|
||||
return { ok: true, apiKey: data.data.apiKey, reused: !!data.reused };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
}
|
||||
@@ -151,26 +151,6 @@ input:focus { border-color: #ff6b35; }
|
||||
<h1><span class="lobster">🦞</span> U-Claw Pro</h1>
|
||||
<p class="subtitle">便携版 — 选择模型,填入 API Key,一键启动</p>
|
||||
|
||||
<!-- Xiapan Cloud bound banner: shown when bootstrap has injected uclaw-cloud -->
|
||||
<div class="xp-banner" id="xpBanner" style="display:none; background:linear-gradient(135deg,#2a1f1f,#1f2a1f); border:1px solid #ff6b35; border-radius:10px; padding:16px 18px; margin-bottom:18px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:240px;">
|
||||
<div style="font-size:0.95em; color:#ff6b35; font-weight:600;">🔗 已绑定虾盘云 · 开箱即用</div>
|
||||
<div style="font-size:0.82em; color:#bbb; margin-top:4px;">
|
||||
指纹来源: <span id="xpSource">—</span>
|
||||
· Key: <code id="xpKeyShort" style="background:#222; padding:2px 6px; border-radius:4px;">—</code>
|
||||
· 余额: <span id="xpBalance">—</span>
|
||||
</div>
|
||||
<div id="xpHint" style="font-size:0.75em; color:#888; margin-top:4px;">余额为 0 时无法调用 AI,请先充值</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn" id="xpRecharge" style="background:#ff6b35; color:#fff; padding:8px 16px; font-size:0.85em;">前往充值 →</button>
|
||||
<button class="btn" id="xpRefresh" style="background:#333; color:#ccc; padding:8px 12px; font-size:0.85em;">刷新</button>
|
||||
<button class="btn" id="xpRebind" style="background:#222; color:#888; padding:8px 12px; font-size:0.8em;" title="清除绑定后重启 U-Claw 即可重新生成 Key">解绑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="steps">
|
||||
<div class="step active" data-step="1"><span class="num">1</span>选模型</div>
|
||||
@@ -181,55 +161,61 @@ input:focus { border-color: #ff6b35; }
|
||||
<!-- Step 1: Select Model -->
|
||||
<div class="section active" id="step1">
|
||||
<h2>选择 AI 模型</h2>
|
||||
<p class="desc">选择你要使用的模型,推荐国内用户用 MiniMax / Kimi / DeepSeek</p>
|
||||
<p class="desc">最省心:用「虾盘云」一个 Key 调用国内外全部大模型(中转站)。也可用下面各家自己的官方 Key。</p>
|
||||
|
||||
<div class="model-grid">
|
||||
<div class="model-card" data-provider="minimax" data-base="https://api.minimax.chat/v1" data-model="MiniMax-Text-01">
|
||||
<div class="model-card" data-provider="uclaw-cloud" data-base="https://api.u-claw.org/v1" data-model="deepseek-v4-flash">
|
||||
<span class="check">✓</span>
|
||||
<h4>MiniMax <span class="tag hot">推荐</span><span class="tag cn">国内</span></h4>
|
||||
<p>MiniMax-Text-01,速度快,性价比高</p>
|
||||
<a class="buy-link" href="https://platform.minimaxi.com/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="kimi" data-base="https://api.moonshot.cn/v1" data-model="moonshot-v1-auto">
|
||||
<span class="check">✓</span>
|
||||
<h4>Kimi (月之暗面) <span class="tag cn">国内</span><span class="tag fast">快</span></h4>
|
||||
<p>moonshot-v1-auto,智能选模型</p>
|
||||
<a class="buy-link" href="https://platform.moonshot.cn/" target="_blank">→ 获取 API Key</a>
|
||||
<h4>虾盘云 <span class="tag hot">首选</span><span class="tag">中转站</span></h4>
|
||||
<p>一个 Key 用 DeepSeek / Claude / GPT / 通义 等国内外全部大模型,无需翻墙</p>
|
||||
<a class="buy-link" href="https://u-claw.org/cloud.html" target="_blank">→ 注册并获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="deepseek" data-base="https://api.deepseek.com/v1" data-model="deepseek-chat">
|
||||
<span class="check">✓</span>
|
||||
<h4>DeepSeek <span class="tag cn">国内</span><span class="tag cheap">便宜</span></h4>
|
||||
<p>deepseek-chat,超低价格</p>
|
||||
<p>deepseek-chat / deepseek-v4,超低价格</p>
|
||||
<a class="buy-link" href="https://platform.deepseek.com/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="minimax" data-base="https://api.minimax.chat/v1" data-model="MiniMax-M2">
|
||||
<span class="check">✓</span>
|
||||
<h4>MiniMax <span class="tag cn">国内</span></h4>
|
||||
<p>MiniMax-M2,速度快,性价比高</p>
|
||||
<a class="buy-link" href="https://platform.minimaxi.com/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="kimi" data-base="https://api.moonshot.cn/v1" data-model="kimi-k2-turbo-preview">
|
||||
<span class="check">✓</span>
|
||||
<h4>Kimi (月之暗面) <span class="tag cn">国内</span><span class="tag fast">快</span></h4>
|
||||
<p>kimi-k2,长上下文强</p>
|
||||
<a class="buy-link" href="https://platform.moonshot.cn/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="zai" data-base="" data-model="glm-5">
|
||||
<span class="check">✓</span>
|
||||
<h4>智谱 GLM <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
|
||||
<p>glm-5(zai provider),免费额度可用</p>
|
||||
<a class="buy-link" href="https://open.bigmodel.cn/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="qwen" data-base="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-turbo">
|
||||
<div class="model-card" data-provider="qwen" data-base="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-plus">
|
||||
<span class="check">✓</span>
|
||||
<h4>通义千问 <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
|
||||
<p>qwen-turbo,阿里云出品</p>
|
||||
<p>qwen-plus / qwen3-max,阿里云出品</p>
|
||||
<a class="buy-link" href="https://dashscope.console.aliyun.com/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="doubao" data-base="https://ark.cn-beijing.volces.com/api/v3" data-model="doubao-1.5-pro-32k">
|
||||
<div class="model-card" data-provider="doubao" data-base="https://ark.cn-beijing.volces.com/api/v3" data-model="doubao-seed-1-6-250615">
|
||||
<span class="check">✓</span>
|
||||
<h4>豆包 (字节) <span class="tag cn">国内</span><span class="tag fast">快</span></h4>
|
||||
<p>doubao-1.5-pro,字节跳动</p>
|
||||
<p>doubao-seed-1.6,字节跳动</p>
|
||||
<a class="buy-link" href="https://console.volcengine.com/ark" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="openai" data-base="https://api.openai.com/v1" data-model="gpt-4o">
|
||||
<div class="model-card" data-provider="openai" data-base="https://api.openai.com/v1" data-model="gpt-5.4">
|
||||
<span class="check">✓</span>
|
||||
<h4>OpenAI <span class="tag hot">强</span></h4>
|
||||
<p>GPT-4o,需翻墙或中转</p>
|
||||
<p>GPT-5.4,需翻墙或中转</p>
|
||||
<a class="buy-link" href="https://platform.openai.com/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="anthropic" data-base="https://api.anthropic.com/v1" data-model="claude-sonnet-4-20250514">
|
||||
<div class="model-card" data-provider="anthropic" data-base="https://api.anthropic.com/v1" data-model="claude-opus-4-6">
|
||||
<span class="check">✓</span>
|
||||
<h4>Claude <span class="tag hot">强</span></h4>
|
||||
<p>Claude Sonnet 4,需翻墙或中转</p>
|
||||
<p>Claude Opus 4.6 / Sonnet 4.6,需翻墙或中转</p>
|
||||
<a class="buy-link" href="https://console.anthropic.com/" target="_blank">→ 获取 API Key</a>
|
||||
</div>
|
||||
<div class="model-card" data-provider="groq" data-base="https://api.groq.com/openai/v1" data-model="llama-3.3-70b-versatile">
|
||||
@@ -408,50 +394,6 @@ input:focus { border-color: #ff6b35; }
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// --- Xiapan Cloud banner ---
|
||||
async function loadXiapanStatus() {
|
||||
const banner = document.getElementById('xpBanner');
|
||||
try {
|
||||
const res = await fetch('/api/xiapan/status');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.apiKey) return;
|
||||
|
||||
document.getElementById('xpSource').textContent = ({
|
||||
usb: 'U 盘', disk: '硬盘', mac: 'Mac', linux: 'Linux', seed: '本机种子', test: '测试',
|
||||
})[data.source] || data.source;
|
||||
document.getElementById('xpKeyShort').textContent = data.apiKey.slice(0, 14) + '…';
|
||||
const bal = data.balance || {};
|
||||
if (bal.ok) {
|
||||
const usd = bal.remainingUsd != null ? bal.remainingUsd.toFixed(2) : '0.00';
|
||||
const tokens = (bal.remainingTokens || 0).toLocaleString();
|
||||
document.getElementById('xpBalance').textContent = `$${usd} (${tokens} tokens)`;
|
||||
document.getElementById('xpHint').textContent = bal.remainingUsd > 0
|
||||
? '可直接使用 deepseek-chat / qwen-plus / qwen-turbo'
|
||||
: '余额为 0,点击右侧前往充值';
|
||||
} else if (bal.reason && bal.reason.includes('401')) {
|
||||
document.getElementById('xpBalance').textContent = '未注册';
|
||||
document.getElementById('xpHint').textContent = '首次使用:点「前往充值」即可在虾盘云页面注册并充值,秒到账';
|
||||
} else {
|
||||
document.getElementById('xpBalance').textContent = '查询失败';
|
||||
document.getElementById('xpHint').textContent = '请检查网络或 api.u-claw.org 服务状态';
|
||||
}
|
||||
|
||||
document.getElementById('xpRecharge').onclick = () => window.open(data.rechargeUrl, '_blank');
|
||||
document.getElementById('xpRefresh').onclick = () => loadXiapanStatus();
|
||||
document.getElementById('xpRebind').onclick = async () => {
|
||||
if (!confirm('解绑后下次启动 U-Claw 会重新生成 Key(基于当前设备指纹)。\n现有 Key 的余额仍归属当前指纹,更换 U 盘 / 电脑后才需要解绑。\n\n确认解绑?')) return;
|
||||
await fetch('/api/xiapan/unbind', { method: 'POST' });
|
||||
showToast('已解绑,请重启 U-Claw');
|
||||
};
|
||||
|
||||
banner.style.display = 'block';
|
||||
} catch (err) {
|
||||
// network error or endpoint missing — leave banner hidden
|
||||
}
|
||||
}
|
||||
loadXiapanStatus();
|
||||
|
||||
// --- State ---
|
||||
let selectedProvider = null;
|
||||
let selectedBase = '';
|
||||
@@ -470,7 +412,9 @@ document.querySelectorAll('#step1 .model-card').forEach(card => {
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('nextStep1').addEventListener('click', () => goStep(2));
|
||||
document.getElementById('nextStep1').addEventListener('click', () => {
|
||||
goStep(2);
|
||||
});
|
||||
|
||||
// --- Steps ---
|
||||
function goStep(n) {
|
||||
@@ -491,7 +435,7 @@ function setupStep2() {
|
||||
document.getElementById('customFields').style.display = isCustom ? 'block' : 'none';
|
||||
|
||||
const providerNames = {
|
||||
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||
'uclaw-cloud': '虾盘云', minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
||||
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
|
||||
siliconflow: '硅基流动', custom: '自定义'
|
||||
|
||||
@@ -10,9 +10,6 @@ const APP_DIR = path.resolve(__dirname, '..');
|
||||
const PORTABLE_DIR = path.resolve(APP_DIR, '..', 'portable');
|
||||
|
||||
const COPIES = [
|
||||
{ from: path.join(PORTABLE_DIR, 'lib', 'fingerprint.mjs'), to: path.join(APP_DIR, 'src', 'lib', 'fingerprint.mjs') },
|
||||
{ from: path.join(PORTABLE_DIR, 'lib', 'xiapan-client.mjs'), to: path.join(APP_DIR, 'src', 'lib', 'xiapan-client.mjs') },
|
||||
{ from: path.join(PORTABLE_DIR, 'lib', 'bootstrap-xiapan.mjs'), to: path.join(APP_DIR, 'src', 'lib', 'bootstrap-xiapan.mjs') },
|
||||
{ from: path.join(PORTABLE_DIR, 'Config.html'), to: path.join(APP_DIR, 'resources', 'Config.html') },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
// Bootstrap: ensure data/.openclaw/openclaw.json contains the uclaw-cloud provider
|
||||
// pointing to the device-bound apiKey derived from the local fingerprint.
|
||||
//
|
||||
// Idempotent: if the provider already exists with the correct apiKey, do nothing.
|
||||
// If it exists but the apiKey differs (USB swapped, machine changed), leave the
|
||||
// existing entry alone and log a hint — never overwrite user data silently.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { getFingerprint } from './fingerprint.mjs';
|
||||
import { buildApiKey } from './xiapan-client.mjs';
|
||||
|
||||
const PROVIDER_ID = 'uclaw-cloud';
|
||||
|
||||
// Mirror ClawX commercial schema: keep models[] empty and steer model selection
|
||||
// via agents.defaults.model.primary + fallbacks. OpenClaw 2026.4.x accepts both
|
||||
// shapes, but the empty-models form lets the runtime auto-discover model ids
|
||||
// from /v1/models without us hard-coding a list that drifts from the backend.
|
||||
const DEFAULT_PROVIDER_TEMPLATE = {
|
||||
baseUrl: 'https://api.u-claw.org/v1',
|
||||
api: 'openai-completions',
|
||||
models: [],
|
||||
};
|
||||
|
||||
const DEFAULT_PRIMARY_MODEL = `${PROVIDER_ID}/deepseek-v4-flash`;
|
||||
const DEFAULT_FALLBACK_MODELS = [
|
||||
`${PROVIDER_ID}/deepseek-chat`,
|
||||
`${PROVIDER_ID}/qwen-plus`,
|
||||
`${PROVIDER_ID}/qwen-turbo`,
|
||||
];
|
||||
|
||||
function readJsonSafe(filePath) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
process.stderr.write(`[bootstrap-xiapan] Cannot parse ${filePath}: ${err.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, data) {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function ensureModelsContainer(config) {
|
||||
if (!config.models || typeof config.models !== 'object') {
|
||||
config.models = { mode: 'merge', providers: {} };
|
||||
}
|
||||
if (!config.models.mode) config.models.mode = 'merge';
|
||||
if (!config.models.providers || typeof config.models.providers !== 'object') {
|
||||
config.models.providers = {};
|
||||
}
|
||||
return config.models.providers;
|
||||
}
|
||||
|
||||
// Set agents.defaults.model.primary to uclaw-cloud only when the user has not
|
||||
// configured a primary model already. If they've picked a different provider
|
||||
// (e.g. DeepSeek BYOK), leave their choice untouched.
|
||||
function ensureAgentsDefaults(config) {
|
||||
if (!config.agents || typeof config.agents !== 'object') {
|
||||
config.agents = {};
|
||||
}
|
||||
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') {
|
||||
config.agents.defaults = {};
|
||||
}
|
||||
const defaults = config.agents.defaults;
|
||||
if (!defaults.model || typeof defaults.model !== 'object') {
|
||||
defaults.model = {};
|
||||
}
|
||||
let changed = false;
|
||||
if (!defaults.model.primary) {
|
||||
defaults.model.primary = DEFAULT_PRIMARY_MODEL;
|
||||
changed = true;
|
||||
}
|
||||
if (!Array.isArray(defaults.model.fallbacks) || defaults.model.fallbacks.length === 0) {
|
||||
defaults.model.fallbacks = [...DEFAULT_FALLBACK_MODELS];
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function bootstrapXiapan({ configPath, appRoot, log = console } = {}) {
|
||||
if (!configPath) {
|
||||
throw new Error('bootstrapXiapan: configPath is required.');
|
||||
}
|
||||
const root = appRoot || process.cwd();
|
||||
|
||||
let fingerprintInfo;
|
||||
try {
|
||||
fingerprintInfo = await getFingerprint(root);
|
||||
} catch (err) {
|
||||
log.warn?.(`[bootstrap-xiapan] Fingerprint detection failed: ${err.message}`);
|
||||
return { ok: false, reason: 'fingerprint-failed' };
|
||||
}
|
||||
|
||||
const apiKey = buildApiKey(fingerprintInfo.fingerprint);
|
||||
|
||||
const config = readJsonSafe(configPath) || { gateway: { mode: 'local', auth: { token: 'uclaw' } } };
|
||||
const providers = ensureModelsContainer(config);
|
||||
const existing = providers[PROVIDER_ID];
|
||||
|
||||
if (existing && typeof existing === 'object') {
|
||||
if (existing.apiKey && existing.apiKey !== apiKey) {
|
||||
log.info?.(
|
||||
`[bootstrap-xiapan] uclaw-cloud apiKey already configured (different fingerprint). `
|
||||
+ `Current source=${fingerprintInfo.source}. Use Config UI to rebind if needed.`,
|
||||
);
|
||||
// Still ensure agents.defaults exists so the runtime knows to use uclaw-cloud
|
||||
const changedDefaults = ensureAgentsDefaults(config);
|
||||
if (changedDefaults) writeJson(configPath, config);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'kept',
|
||||
source: fingerprintInfo.source,
|
||||
apiKey: existing.apiKey,
|
||||
};
|
||||
}
|
||||
if (existing.apiKey === apiKey) {
|
||||
const changedDefaults = ensureAgentsDefaults(config);
|
||||
if (changedDefaults) {
|
||||
writeJson(configPath, config);
|
||||
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action: changedDefaults ? 'agents-defaults-added' : 'noop',
|
||||
source: fingerprintInfo.source,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
providers[PROVIDER_ID] = {
|
||||
...DEFAULT_PROVIDER_TEMPLATE,
|
||||
...(existing && typeof existing === 'object' ? existing : {}),
|
||||
baseUrl: existing?.baseUrl || DEFAULT_PROVIDER_TEMPLATE.baseUrl,
|
||||
api: existing?.api || DEFAULT_PROVIDER_TEMPLATE.api,
|
||||
apiKey,
|
||||
models: existing?.models ?? DEFAULT_PROVIDER_TEMPLATE.models,
|
||||
};
|
||||
ensureAgentsDefaults(config);
|
||||
|
||||
writeJson(configPath, config);
|
||||
log.info?.(
|
||||
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, key=${apiKey.slice(0, 12)}…)`,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
action: existing ? 'updated' : 'created',
|
||||
source: fingerprintInfo.source,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// CLI:
|
||||
// node bootstrap-xiapan.mjs <config-path>
|
||||
// env UCLAW_CONFIG_PATH=... node bootstrap-xiapan.mjs
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const isMain = (() => {
|
||||
try {
|
||||
if (!process.argv[1]) return false;
|
||||
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
const configPath = process.argv[2] || process.env.UCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
process.stderr.write('Usage: node bootstrap-xiapan.mjs <openclaw.json path>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const appRoot = process.env.UCLAW_APP_ROOT || resolve(configPath, '../../..');
|
||||
bootstrapXiapan({ configPath, appRoot })
|
||||
.then((res) => {
|
||||
process.stdout.write(`${JSON.stringify(res)}\n`);
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`bootstrap-xiapan error: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
// Cross-platform device fingerprint for U-Claw / Xiapan Cloud apiKey binding.
|
||||
// Output: { source: 'usb' | 'disk' | 'mac' | 'linux' | 'seed' | 'test', fingerprint: '<64-hex>' }
|
||||
//
|
||||
// Order of preference:
|
||||
// Windows: USB drive (when running from a USB volume) -> system disk -> seed file
|
||||
// Mac: Hardware UUID + boot volume UUID -> seed file
|
||||
// Linux: /etc/machine-id + lsblk SERIAL of root -> seed file
|
||||
//
|
||||
// Adapted from v2/u-clawx-openclaw-dev/electron/utils/{license,disk-fingerprint}.ts
|
||||
// but simplified: no Ed25519 signing, no .license file, just a stable hash.
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { dirname, parse, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const POWERSHELL_CANDIDATES = [
|
||||
'powershell.exe',
|
||||
'powershell',
|
||||
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'C:\\Windows\\Sysnative\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'pwsh.exe',
|
||||
];
|
||||
|
||||
const TEST_FINGERPRINT_SOURCE = 'TEST:UCLAW_DEVELOPMENT_FIXED_FINGERPRINT';
|
||||
|
||||
function sha256Hex(input) {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
function shouldUseTestFingerprint() {
|
||||
return (
|
||||
process.env.UCLAW_SKIP_FINGERPRINT === '1'
|
||||
|| process.env.OPENCLAW_SKIP_USB_CHECK === '1'
|
||||
|| process.env.CLAWX_SKIP_USB_CHECK === '1'
|
||||
);
|
||||
}
|
||||
|
||||
function readEnvOverride() {
|
||||
const override = (process.env.UCLAW_FINGERPRINT_OVERRIDE || '').trim();
|
||||
if (!override || !/^[0-9a-f]{64}$/i.test(override)) return null;
|
||||
return { source: 'test', fingerprint: override.toLowerCase() };
|
||||
}
|
||||
|
||||
function getSeedPath(appRoot) {
|
||||
if (process.env.UCLAW_SEED_PATH) {
|
||||
return resolve(process.env.UCLAW_SEED_PATH);
|
||||
}
|
||||
const home = homedir();
|
||||
if (home) return resolve(home, '.uclaw', '.usb_seed');
|
||||
return resolve(appRoot, '.usb_seed');
|
||||
}
|
||||
|
||||
function readOrCreateSeedFingerprint(appRoot) {
|
||||
const seedPath = getSeedPath(appRoot);
|
||||
let seedHex;
|
||||
if (existsSync(seedPath)) {
|
||||
seedHex = readFileSync(seedPath, 'utf8').trim();
|
||||
}
|
||||
if (!seedHex || !/^[0-9a-f]{64}$/i.test(seedHex)) {
|
||||
seedHex = randomBytes(32).toString('hex');
|
||||
mkdirSync(dirname(seedPath), { recursive: true });
|
||||
writeFileSync(seedPath, seedHex + '\n', 'utf8');
|
||||
}
|
||||
return { source: 'seed', fingerprint: seedHex.toLowerCase() };
|
||||
}
|
||||
|
||||
async function runPowerShell(script) {
|
||||
const wrapped = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${script}`;
|
||||
let lastError = null;
|
||||
for (const candidate of POWERSHELL_CANDIDATES) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(candidate, ['-NoProfile', '-Command', wrapped], {
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') continue;
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('PowerShell is not available on this system.');
|
||||
}
|
||||
|
||||
function normalizeSerial(value) {
|
||||
if (!value) return '';
|
||||
return String(value).trim().replace(/[\s.]+$/g, '').replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function getDriveDepth(targetDir) {
|
||||
const parsed = parse(resolve(targetDir));
|
||||
if (!parsed.root) return null;
|
||||
const rel = relative(parsed.root, resolve(targetDir));
|
||||
const depth = rel.split(sep).map((s) => s.trim()).filter(Boolean).length;
|
||||
return { driveRoot: parsed.root.replace(/[\\/]$/, '').toUpperCase(), depth };
|
||||
}
|
||||
|
||||
async function tryWindowsUsbFingerprint(appRoot) {
|
||||
const drive = getDriveDepth(appRoot);
|
||||
if (!drive) return null;
|
||||
// Only attempt USB fingerprint when running from a drive root or first-level subfolder
|
||||
if (drive.depth > 2) return null;
|
||||
const driveLetter = drive.driveRoot.endsWith(':') ? drive.driveRoot : `${drive.driveRoot}:`;
|
||||
|
||||
const driveMappingScript = [
|
||||
`$p = Get-WmiObject -Query "ASSOCIATORS OF {Win32_LogicalDisk.DeviceID='${driveLetter}'} WHERE AssocClass=Win32_LogicalDiskToPartition"`,
|
||||
'$p0 = if ($p -is [System.Array]) { $p[0] } else { $p }',
|
||||
'$d = if ($p0) { Get-WmiObject -Query "ASSOCIATORS OF {Win32_DiskPartition.DeviceID=\'$($p0.DeviceID)\'} WHERE AssocClass=Win32_DiskDriveToDiskPartition" } else { $null }',
|
||||
'$d0 = if ($d -is [System.Array]) { $d[0] } else { $d }',
|
||||
"if ($d0) { $d0.PNPDeviceID } else { '' }",
|
||||
].join('; ');
|
||||
|
||||
let targetPnpId = '';
|
||||
try {
|
||||
targetPnpId = (await runPowerShell(driveMappingScript)).trim().toUpperCase();
|
||||
} catch {
|
||||
targetPnpId = '';
|
||||
}
|
||||
|
||||
let rawDiskJson;
|
||||
try {
|
||||
rawDiskJson = await runPowerShell(
|
||||
'Get-WmiObject Win32_DiskDrive | Select-Object Model, SerialNumber, PNPDeviceID | ConvertTo-Json -Compress',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let disks;
|
||||
try {
|
||||
const parsed = JSON.parse(rawDiskJson.trim() || '[]');
|
||||
disks = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!disks.length) return null;
|
||||
|
||||
const exactMatch = targetPnpId
|
||||
? disks.find((d) => (d.PNPDeviceID || '').toUpperCase() === targetPnpId)
|
||||
: null;
|
||||
|
||||
const usbDisk = exactMatch || disks.find((d) => {
|
||||
const pnp = (d.PNPDeviceID || '').toUpperCase();
|
||||
return pnp.includes('USB') || pnp.includes('USBSTOR');
|
||||
});
|
||||
|
||||
if (!usbDisk) return null;
|
||||
|
||||
const model = (usbDisk.Model || 'Unknown').trim();
|
||||
const serial = (usbDisk.SerialNumber || 'Unknown').trim();
|
||||
const pnp = (usbDisk.PNPDeviceID || 'Unknown').trim();
|
||||
return {
|
||||
source: 'usb',
|
||||
fingerprint: sha256Hex(`${model}:${serial}:${pnp}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryWindowsDiskFingerprint() {
|
||||
let physicalDiskInfo = null;
|
||||
try {
|
||||
const physicalScript = [
|
||||
"$systemDrive = ($env:SystemDrive -replace ':','')",
|
||||
"if (-not $systemDrive) { $systemDrive = 'C' }",
|
||||
'$disk = Get-Partition -DriveLetter $systemDrive -ErrorAction SilentlyContinue | Get-Disk -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
'if (-not $disk) { return }',
|
||||
'$pd = Get-PhysicalDisk -DeviceNumber $disk.Number -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
'if (-not $pd) { return }',
|
||||
'[pscustomobject]@{ Serial = $pd.SerialNumber; Model = $pd.FriendlyName; BusType = $pd.BusType } | ConvertTo-Json -Compress',
|
||||
].join('; ');
|
||||
const raw = (await runPowerShell(physicalScript)).trim();
|
||||
if (raw) physicalDiskInfo = JSON.parse(raw);
|
||||
} catch {
|
||||
physicalDiskInfo = null;
|
||||
}
|
||||
|
||||
if (!physicalDiskInfo) {
|
||||
try {
|
||||
const win32Script = [
|
||||
"$systemDrive = $env:SystemDrive -replace ':',''",
|
||||
"if (-not $systemDrive) { $systemDrive = 'C' }",
|
||||
'$letter = "$systemDrive`:"',
|
||||
"$lp = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_LogicalDisk.DeviceID='$letter'} WHERE AssocClass=Win32_LogicalDiskToPartition\"",
|
||||
'$lp0 = if ($lp -is [System.Array]) { $lp[0] } else { $lp }',
|
||||
'if (-not $lp0) { return }',
|
||||
"$dd = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_DiskPartition.DeviceID='$($lp0.DeviceID)'} WHERE AssocClass=Win32_DiskDriveToDiskPartition\"",
|
||||
'$dd0 = if ($dd -is [System.Array]) { $dd[0] } else { $dd }',
|
||||
'if (-not $dd0) { return }',
|
||||
'[pscustomobject]@{ Serial = $dd0.SerialNumber; Model = $dd0.Model; BusType = $dd0.InterfaceType } | ConvertTo-Json -Compress',
|
||||
].join('; ');
|
||||
const raw = (await runPowerShell(win32Script)).trim();
|
||||
if (raw) physicalDiskInfo = JSON.parse(raw);
|
||||
} catch {
|
||||
physicalDiskInfo = null;
|
||||
}
|
||||
}
|
||||
if (!physicalDiskInfo) return null;
|
||||
|
||||
let boardSerial = 'NoBoard';
|
||||
try {
|
||||
const raw = await runPowerShell('(Get-CimInstance Win32_BaseBoard | Select-Object -First 1).SerialNumber');
|
||||
boardSerial = normalizeSerial(raw) || 'NoBoard';
|
||||
} catch {
|
||||
// keep default
|
||||
}
|
||||
|
||||
const diskSerial = normalizeSerial(physicalDiskInfo.Serial);
|
||||
const diskModel = (physicalDiskInfo.Model || 'Unknown').toString().trim();
|
||||
return {
|
||||
source: 'disk',
|
||||
fingerprint: sha256Hex(`DISK:${diskSerial}:${diskModel}:${boardSerial}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryMacFingerprint() {
|
||||
let hardwareUuid = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/sbin/system_profiler', ['SPHardwareDataType'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = stdout.match(/Hardware UUID:\s*([0-9A-F-]+)/i);
|
||||
if (match) hardwareUuid = match[1].trim().toUpperCase();
|
||||
} catch {
|
||||
hardwareUuid = '';
|
||||
}
|
||||
|
||||
let bootVolumeUuid = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/sbin/diskutil', ['info', '/'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = stdout.match(/Volume UUID:\s*([0-9A-F-]+)/i);
|
||||
if (match) bootVolumeUuid = match[1].trim().toUpperCase();
|
||||
} catch {
|
||||
bootVolumeUuid = '';
|
||||
}
|
||||
|
||||
if (!hardwareUuid && !bootVolumeUuid) return null;
|
||||
return {
|
||||
source: 'mac',
|
||||
fingerprint: sha256Hex(`MAC:${hardwareUuid}:${bootVolumeUuid}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryLinuxFingerprint() {
|
||||
let machineId = '';
|
||||
for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
||||
try {
|
||||
machineId = readFileSync(path, 'utf8').trim();
|
||||
if (machineId) break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
let rootSerial = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/bin/lsblk', ['-no', 'SERIAL,MOUNTPOINT'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
for (const line of stdout.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && parts[1] === '/') {
|
||||
rootSerial = parts[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
rootSerial = '';
|
||||
}
|
||||
|
||||
if (!machineId && !rootSerial) return null;
|
||||
return {
|
||||
source: 'linux',
|
||||
fingerprint: sha256Hex(`LINUX:${machineId}:${rootSerial}`),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedPromise = null;
|
||||
|
||||
export async function getFingerprint(appRoot) {
|
||||
if (cachedPromise) return cachedPromise;
|
||||
cachedPromise = computeFingerprint(appRoot || process.cwd()).catch((err) => {
|
||||
cachedPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
async function computeFingerprint(appRoot) {
|
||||
const override = readEnvOverride();
|
||||
if (override) return override;
|
||||
|
||||
if (shouldUseTestFingerprint()) {
|
||||
return { source: 'test', fingerprint: sha256Hex(TEST_FINGERPRINT_SOURCE) };
|
||||
}
|
||||
|
||||
const plat = platform();
|
||||
|
||||
if (plat === 'win32') {
|
||||
const usb = await tryWindowsUsbFingerprint(appRoot).catch(() => null);
|
||||
if (usb) return usb;
|
||||
const disk = await tryWindowsDiskFingerprint().catch(() => null);
|
||||
if (disk) return disk;
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
if (plat === 'darwin') {
|
||||
const mac = await tryMacFingerprint().catch(() => null);
|
||||
if (mac) return mac;
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
if (plat === 'linux') {
|
||||
const linux = await tryLinuxFingerprint().catch(() => null);
|
||||
if (linux) return linux;
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
return readOrCreateSeedFingerprint(appRoot);
|
||||
}
|
||||
|
||||
// CLI entrypoint: prints JSON when run directly.
|
||||
// node fingerprint.mjs -> {"source":"...","fingerprint":"..."}
|
||||
// node fingerprint.mjs apiKey -> sk-<fingerprint>
|
||||
const isMain = (() => {
|
||||
try {
|
||||
if (!process.argv[1]) return false;
|
||||
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
const appRoot = process.env.UCLAW_APP_ROOT || process.cwd();
|
||||
getFingerprint(appRoot)
|
||||
.then((result) => {
|
||||
const arg = process.argv[2];
|
||||
if (arg === 'apiKey') {
|
||||
process.stdout.write(`sk-${result.fingerprint}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`fingerprint error: ${err && err.message ? err.message : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Xiapan Cloud (虾盘云) client for U-Claw open-source edition.
|
||||
// Provides only: apiKey derivation, balance lookup, recharge URL.
|
||||
// Intentionally does NOT call /recharge/activate — open-source users do not get free quota.
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.u-claw.org/v1';
|
||||
const DEFAULT_RECHARGE_PAGE = 'https://u-claw.org/cloud.html';
|
||||
const QUOTA_PER_USD = 500_000; // 1 USD = 500k tokens (matches new-api convention)
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
// sk-uc- prefix marks keys generated by the u-claw open-source edition.
|
||||
// ClawX commercial keys use plain sk-<hash> and the cloud.html flow uses sk-xp-,
|
||||
// so the three namespaces never collide and the backend can audit by prefix.
|
||||
export function buildApiKey(fingerprint) {
|
||||
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
|
||||
throw new Error('Fingerprint must be 64-character hex.');
|
||||
}
|
||||
return `sk-uc-${fingerprint.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return (process.env.UCLAW_CLOUD_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getRechargePage() {
|
||||
return process.env.UCLAW_CLOUD_RECHARGE_PAGE || DEFAULT_RECHARGE_PAGE;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBalance(apiKey) {
|
||||
if (!apiKey) throw new Error('apiKey is required.');
|
||||
const base = getApiBase();
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
|
||||
const [subRes, usageRes] = await Promise.all([
|
||||
fetchWithTimeout(`${base}/dashboard/billing/subscription`, { headers }).catch(() => null),
|
||||
fetchWithTimeout(
|
||||
`${base}/dashboard/billing/usage?start_date=2020-01-01&end_date=${new Date().toISOString().slice(0, 10)}`,
|
||||
{ headers },
|
||||
).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!subRes || !subRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: subRes ? `subscription HTTP ${subRes.status}` : 'subscription request failed',
|
||||
hardLimitUsd: 0,
|
||||
usedUsd: 0,
|
||||
remainingUsd: 0,
|
||||
remainingTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const subscription = await subRes.json().catch(() => ({}));
|
||||
let usedUsd = 0;
|
||||
if (usageRes && usageRes.ok) {
|
||||
const usage = await usageRes.json().catch(() => ({}));
|
||||
// total_usage is in cents (USD * 100), per new-api convention
|
||||
usedUsd = Number(usage.total_usage || 0) / 100;
|
||||
}
|
||||
|
||||
const hardLimitUsd = Number(subscription.hard_limit_usd || 0);
|
||||
const remainingUsd = Math.max(0, hardLimitUsd - usedUsd);
|
||||
const remainingTokens = Math.round(remainingUsd * QUOTA_PER_USD);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
hardLimitUsd,
|
||||
usedUsd,
|
||||
remainingUsd,
|
||||
remainingTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRechargeUrl(apiKey) {
|
||||
if (!apiKey) throw new Error('apiKey is required.');
|
||||
const page = getRechargePage();
|
||||
const url = new URL(page);
|
||||
url.searchParams.set('key', apiKey);
|
||||
// Page already has #recharge anchor; preserve it
|
||||
return `${url.toString()}#recharge`;
|
||||
}
|
||||
@@ -89,20 +89,6 @@ function ensureConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function bindXiapanCloud() {
|
||||
try {
|
||||
const mod = await import(path.join(__dirname, 'lib/bootstrap-xiapan.mjs'));
|
||||
const result = await mod.bootstrapXiapan({
|
||||
configPath,
|
||||
appRoot: userDataPath,
|
||||
log: console,
|
||||
});
|
||||
console.log(`[${APP_NAME}] xiapan bind: ${result.action || 'noop'} (source=${result.source})`);
|
||||
} catch (err) {
|
||||
console.warn(`[${APP_NAME}] xiapan bind failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
@@ -463,7 +449,6 @@ app.whenReady().then(async () => {
|
||||
|
||||
// Setup
|
||||
ensureConfig();
|
||||
await bindXiapanCloud();
|
||||
createMenu();
|
||||
setupIPC();
|
||||
createWindow();
|
||||
|
||||
Reference in New Issue
Block a user