diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859a7b7..0cf6703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,11 @@ jobs: - name: 签出代码 uses: actions/checkout@v4 - # 安装 Node.js 22 + # 安装 OpenClaw 当前最低要求的 Node.js 版本 - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.19.0 cache: npm # 安装前端依赖 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0562448..e5c3f56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,7 +65,7 @@ jobs: - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.19.0 cache: npm - name: 安装前端依赖 @@ -195,7 +195,7 @@ jobs: - name: 安装 Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.19.0 cache: npm - name: 构建前端并上传热更新包 diff --git a/CHANGELOG.md b/CHANGELOG.md index ac644e7..e83415f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.18.3] - 2026-06-11 + +### 修复 (Fixes) + +- **最新版 OpenClaw 的 Node.js 门槛兼容** — 继续保留当前稳定推荐版不变;当用户已安装 OpenClaw `2026.6.5` 或更高版本时,面板会按新版运行要求检测 Node.js `>=22.19.0`,旧稳定版不会被误拦截 +- **standalone OpenClaw Node.js 检测误判** — standalone 安装会优先使用同目录捆绑的 `node` / `node.exe` 做兼容检测,避免被 PATH 中较旧的系统 Node.js 误判 +- **配对修复覆盖配置风险** — Gateway 配对流程只增量写入 `gateway.controlUi.allowedOrigins`,避免用旧的全量配置快照覆盖用户刚保存的模型、认证或安全配置 +- **配置写回保留深层字段** — `openclaw.json` 写回改为对象递归合并,数组与标量仍显式替换,减少升级或自动修复时丢失未知配置字段的风险 + +### 兼容性 (Compatibility) + +- **兼容 Parallel Web Search 插件** — Web 搜索后端白名单补充 `parallel-free` 与 `parallel`,并保留 `plugins.entries.parallel.config.webSearch.apiKey` / `baseUrl` 等插件配置 +- **保留 OpenClaw 新增配置项** — 支持并保留 `memory.qmd.rerank` 与 `security.installPolicy`;校准/重置配置时会继承 `memory`、`security` 等高级配置块 +- **Linux 部署默认仍走稳定版** — 一键部署脚本默认安装 `@qingchencloud/openclaw-zh@2026.5.18-zh.1`,仅当检测到 OpenClaw `2026.6.5+` 时才提升 Node.js 运行门槛 + +### 改进 (Improvements) + +- **Hermes 配置页补充 QMD 与 Install Policy** — 记忆配置新增 QMD rerank 开关,安全配置新增 Install Policy JSON 编辑入口,并保留其他高级 YAML 字段 +- **第三方服务商接入规范更清晰** — 贡献指南新增 provider 接入规范,要求服务商 PR 保持中性技术接入,不夹带默认推广位或未经确认的商业宣传 + +### 测试与验证 (Testing) + +- 已通过 `git diff --check` +- 已通过 `bash -n scripts/linux-deploy.sh` +- 已通过 `npm run build` +- 已通过 `node --test tests/patch-gateway-origins.test.js tests/node-runtime-detection-policy.test.js tests/hermes-web-config.test.js tests/hermes-memory-config.test.js tests/hermes-security-config.test.js tests/hermes-config-page-ui.test.js` +- 已通过 `cd src-tauri && cargo fmt --check` +- 已通过 `cd src-tauri && cargo check` +- 已通过 `cd src-tauri && cargo test --lib` +- 已通过 `cd src-tauri && cargo clippy --all-targets -- -D warnings` + ## [0.18.2] - 2026-06-08 ### 修复 (Fixes) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f30c95..5cac6a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ - [部署模式](#部署模式) - [分支与提交规范](#分支与提交规范) - [PR 流程](#pr-流程) +- [服务商接入规范](#服务商接入规范) - [代码规范](#代码规范) - [问题反馈](#问题反馈) @@ -31,7 +32,7 @@ | 依赖 | 最低版本 | 说明 | |------|----------|------| -| Node.js | 18+ | 前端构建与 Web 后端(推荐 22 LTS);运行 OpenClaw Gateway 时按当前 OpenClaw 的 `engines.node` 检测 | +| Node.js | 18+ | 前端构建与 Web 后端;运行 OpenClaw Gateway 时按当前 OpenClaw 的 `engines.node` 检测,OpenClaw 2026.6.5+ 通常需要 22.19.0+ | | Rust | stable | Tauri 后端编译 | | Tauri CLI | v2 | `cargo install tauri-cli --version "^2"` | @@ -513,6 +514,21 @@ curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/script --- +## 服务商接入规范 + +ClawPanel 欢迎中性的模型服务商、聚合网关和 OpenAI-compatible provider 接入贡献,但这类 PR 必须保持技术范围清晰: + +1. **只做必要接入**:优先限制在 provider registry、配置字段、模型探测和必要测试,不要夹带无关 README 重排、品牌露出或营销内容。 +2. **不接受默认推广位**:README 首屏、Logo、推荐位、赞助位、UTM/tracking 链接、折扣或商业导流内容,必须先与维护者达成明确合作或赞助协议。 +3. **文案保持中性**:可说明 provider 的基础能力、API 类型、base URL、环境变量和使用方式,不使用“最佳”“官方推荐”“独家”等未经项目确认的宣传表述。 +4. **能力不能夸大**:只列 ClawPanel 当前实际支持的能力。若接入的是 Chat/LLM transport,不要把图片、视频、语音等未实现路径写成已支持功能。 +5. **避免破坏自动推断**:新增静态模型名时要检查是否与已有 provider 冲突;如果冲突会影响 provider 反查或默认模型切换,需要同步调整逻辑和测试。 +6. **测试要覆盖边界**:至少覆盖 provider 注册、API key/base URL 环境变量、managed env keys,以及模型名冲突或 provider 推断相关行为。 + +不符合以上要求的 PR,维护者会要求拆分、改为中性技术接入,或直接关闭。 + +--- + ## 致谢名单维护 README 中的“致谢 / Acknowledgements”用于展示历史代码贡献者和社区反馈者。 diff --git a/Dockerfile b/Dockerfile index 4ceda78..06ee37f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ # ----------------------------------------------------------------------------- # 阶段 1: 构建阶段 (builder) # ----------------------------------------------------------------------------- -FROM node:22-alpine AS builder +FROM node:22.19.0-alpine AS builder # 安装构建依赖 RUN apk add --no-cache \ @@ -41,7 +41,7 @@ RUN npm ci --prefer-offline --registry https://registry.npmmirror.com && \ # ----------------------------------------------------------------------------- # 阶段 2: 生产阶段 (production) # ----------------------------------------------------------------------------- -FROM node:22-alpine AS production +FROM node:22.19.0-alpine AS production # 安装运行时依赖 RUN apk add --no-cache \ diff --git a/README.md b/README.md index 92d019d..8558d6c 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ ClawPanel 提供**纯 Web 版部署模式**(零 GUI 依赖),天然兼容 A - **Orange Pi / 树莓派 / RK3588** 等 ARM64 板子 — `npm run serve` 即可运行 - **Docker ARM64 镜像** — `docker run ghcr.io/qingchencloud/openclaw:latest` 开箱即用 - **Armbian / Debian / Ubuntu Server** — 一键部署脚本自动检测架构 -- 无需 Rust / Tauri / 图形界面;ClawPanel Web 后端需要 **Node.js 18+**,运行 OpenClaw Gateway 时会按当前 OpenClaw 的 `engines.node` 自动检测,建议 **Node.js 22.19.0+** +- 无需 Rust / Tauri / 图形界面;ClawPanel Web 后端需要 **Node.js 18+**,运行 OpenClaw Gateway 时会按当前 OpenClaw 的 `engines.node` 自动检测(OpenClaw 2026.6.5+ 通常需要 **Node.js 22.19.0+**) > 📖 详见 [Armbian 部署指南](docs/armbian-deploy.md) | [Web 版开发说明](#web-开发版无需-rusttauri) @@ -213,7 +213,7 @@ curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/script ```bash docker run -d --name clawpanel --restart unless-stopped \ -p 1420:1420 -v clawpanel-data:/root/.openclaw \ - node:22-slim \ + node:22.19.0-slim \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ @@ -290,7 +290,7 @@ docker rm clawpanel # 重新启动新容器 docker run -d --name clawpanel --restart unless-stopped \ -p 1420:1420 -v clawpanel-data:/root/.openclaw \ - node:22-slim \ + node:22.19.0-slim \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ @@ -617,7 +617,7 @@ clawpanel/ ### 前置条件 -- [Node.js](https://nodejs.org/) >= 18(从源码构建 ClawPanel;运行 OpenClaw Gateway 建议 22.19.0+) +- [Node.js](https://nodejs.org/) >= 18(从源码构建 ClawPanel;运行 OpenClaw Gateway 按当前 OpenClaw 的 `engines.node` 检测) - [Rust](https://www.rust-lang.org/tools/install) (stable) - Tauri v2 系统依赖(参考 [Tauri 官方文档](https://v2.tauri.app/start/prerequisites/)) @@ -751,7 +751,7 @@ Web 版适用于 Linux 服务器(无桌面环境),通过浏览器远程管 ### 环境要求 -- **Node.js** >= 18(ClawPanel Web 后端);运行 OpenClaw Gateway 建议 **22.19.0+**,面板会按当前 OpenClaw 要求自动检测 +- **Node.js** >= 18(ClawPanel Web 后端);运行 OpenClaw Gateway 会按当前 OpenClaw 要求自动检测,OpenClaw 2026.6.5+ 通常需要 **22.19.0+** - **Git**(用于 OpenClaw 依赖安装) - **端口** 1420(ClawPanel)+ 18789(Gateway) diff --git a/docker-compose.yml b/docker-compose.yml index da57e83..b0c0e34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,7 +125,7 @@ services: # OpenClaw Gateway 服务(可选,取消注释以启用) # --------------------------------------------------------------------------- # gateway: - # image: node:22-alpine + # image: node:22.19.0-alpine # container_name: openclaw-gateway # hostname: openclaw-gateway # network_mode: host diff --git a/docs/armbian-deploy.md b/docs/armbian-deploy.md index f3a976c..0ee39f0 100644 --- a/docs/armbian-deploy.md +++ b/docs/armbian-deploy.md @@ -10,7 +10,7 @@ ClawPanel 支持在 ARM 开发板(如 Orange Pi、Raspberry Pi、RK3588 等) | 内存 | 1GB | 2GB+ | | 存储 | 2GB 可用空间 | 4GB+ | | 系统 | Armbian / Debian / Ubuntu | Armbian 24+ | -| Node.js | 18+ | ClawPanel Web 后端;运行 OpenClaw Gateway 建议 22.19.0+ | +| Node.js | 18+ | ClawPanel Web 后端;运行 OpenClaw Gateway 时按当前 OpenClaw `engines.node` 检测 | > ⚠️ 当前不支持 ARM 32 位 (armv7) 的 Docker 镜像。Web 模式在 armv7 上可用(只要 Node.js 支持)。 @@ -130,7 +130,7 @@ docker run -d \ A: 不建议。Tauri 需要 WebKitGTK + 图形界面,ARM 板通常是 headless 环境。请使用 Web 模式。 **Q: armv7 (32位) 板子能用吗?** -A: Web 模式可以(ClawPanel Web 后端需要 Node.js 18+;运行 OpenClaw Gateway 建议 Node.js 22.19.0+)。Docker 模式目前只提供 arm64 镜像。 +A: Web 模式可以(ClawPanel Web 后端需要 Node.js 18+;运行 OpenClaw Gateway 时按当前 OpenClaw `engines.node` 检测)。Docker 模式目前只提供 arm64 镜像。 **Q: 树莓派 Zero / Pi 1 能跑吗?** A: 这些是 armv6,内存也只有 256-512MB,不推荐。建议至少树莓派 3B+ 或更新的 ARM64 板子。 diff --git a/docs/docker-deploy.md b/docs/docker-deploy.md index 3eece23..0830e6a 100644 --- a/docs/docker-deploy.md +++ b/docs/docker-deploy.md @@ -48,7 +48,7 @@ docker run -d \ --restart unless-stopped \ -p 1420:1420 \ -v clawpanel-data:/root/.openclaw \ - node:22-slim \ + node:22.19.0-slim \ sh -c "\ apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ @@ -86,7 +86,7 @@ services: - NODE_ENV=production gateway: - image: node:22-slim + image: node:22.19.0-slim container_name: openclaw-gateway restart: unless-stopped ports: @@ -110,7 +110,7 @@ volumes: 同目录下创建 `Dockerfile.clawpanel`: ```dockerfile -FROM node:22-slim +FROM node:22.19.0-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* @@ -140,7 +140,7 @@ docker compose up -d 如果只需要 ClawPanel Web(Gateway 在宿主机或其他地方运行): ```dockerfile -FROM node:22-slim +FROM node:22.19.0-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* @@ -336,7 +336,7 @@ docker logs clawpanel 2. **在 Dockerfile 中预装**:构建镜像时就安装好 OpenClaw,避免运行时下载: ```dockerfile - FROM node:22-slim + FROM node:22.19.0-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* # 预装 OpenClaw CLI(使用国内镜像源加速) RUN npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md index 51ec62d..a3eeea2 100644 --- a/docs/linux-deploy.md +++ b/docs/linux-deploy.md @@ -49,7 +49,7 @@ | 依赖 | 最低版本 | 说明 | |------|----------|------| -| Node.js | 18+ | ClawPanel Web 后端;运行 OpenClaw Gateway 建议 22.19.0+,实际要求按当前 OpenClaw `engines.node` 检测 | +| Node.js | 18+ | ClawPanel Web 后端;运行 OpenClaw Gateway 时按当前 OpenClaw `engines.node` 检测,OpenClaw 2026.6.5+ 通常需要 22.19.0+ | | npm | 随 Node.js | 包管理器 | | Git | 任意 | 克隆仓库 | | OpenClaw | 最新 | ClawPanel 管理的对象 | @@ -167,7 +167,7 @@ docker run -d \ --restart unless-stopped \ -p 1420:1420 \ -v clawpanel-data:/root/.openclaw \ - node:22-slim \ + node:22.19.0-slim \ sh -c "apt-get update && apt-get install -y git && \ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \ git clone https://github.com/qingchencloud/clawpanel.git /app && \ diff --git a/openclaw-version-policy.json b/openclaw-version-policy.json index 6aec46a..733c393 100644 --- a/openclaw-version-policy.json +++ b/openclaw-version-policy.json @@ -170,4 +170,3 @@ } } } - diff --git a/package-lock.json b/package-lock.json index 401aeba..649c0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.18.2", + "version": "0.18.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.18.2", + "version": "0.18.3", "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/package.json b/package.json index 5ba9a6e..c87d0cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.18.2", + "version": "0.18.3", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 62bea01..ea6b10a 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -267,6 +267,8 @@ const PANEL_VERSION = (() => { })() const SITE_BASE_URL = 'https://claw.qt.cool' const VERSION_POLICY_PATH = path.join(__dev_dirname, '..', 'openclaw-version-policy.json') +const OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR = '2026.6.5' +const OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME = '>=22.19.0' function ensureArrayContains(value, required) { const current = Array.isArray(value) @@ -665,13 +667,21 @@ function findOpenclawPackageJson(cliPath) { function openclawNodeRequirement() { const cliPath = resolveOpenclawCliPath() const pkgPath = cliPath ? findOpenclawPackageJson(cliPath) : null - if (!pkgPath) return null - try { - const requirement = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))?.engines?.node - return typeof requirement === 'string' && requirement.trim() ? requirement.trim() : null - } catch { - return null + let installedVersion = null + if (pkgPath) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + const requirement = pkg?.engines?.node + if (typeof requirement === 'string' && requirement.trim()) return requirement.trim() + installedVersion = typeof pkg?.version === 'string' ? pkg.version : null + } catch { + installedVersion = null + } } + if (!installedVersion && cliPath) installedVersion = readVersionFromInstallation(cliPath) + return installedVersion && versionGe(baseVersion(installedVersion), OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR) + ? OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME + : null } function parseNodeVersionTriplet(value) { @@ -730,6 +740,12 @@ function decorateNodeDetection(base) { } } +function standaloneBundledNodePath(cliPath) { + if (!cliPath) return null + const nodeBin = path.join(path.dirname(cliPath), isWindows ? 'node.exe' : 'node') + return fs.existsSync(nodeBin) ? nodeBin : null +} + function ensureNodeRuntimeCompatibleWeb() { const node = handlers.check_node() if (!node.installed) throw new Error('Node.js 未安装或未检测到,请先安装 Node.js 后重新检测') @@ -2346,8 +2362,10 @@ const CALIBRATION_RESET_INHERIT_KEYS = [ 'commands', 'env', 'hooks', + 'memory', 'models', 'plugins', + 'security', 'session', 'skills', 'wizard', @@ -2687,10 +2705,18 @@ function patchGatewayOrigins() { const merged = [...new Set([...existing, ...origins])] // 幂等:已包含所有需要的 origin 时跳过写入 if (origins.every(o => existing.includes(o))) return false - if (!config.gateway) config.gateway = {} - if (!config.gateway.controlUi) config.gateway.controlUi = {} - config.gateway.controlUi.allowedOrigins = merged - writeOpenclawConfigFile(config) + // 只写入 allowedOrigins 增量,避免用陈旧全量快照覆盖并发保存的其它配置字段。 + const latest = readJsonFileRelaxed(CONFIG_PATH) + if (!latest || typeof latest !== 'object' || Array.isArray(latest)) return false + const partial = { + gateway: { + controlUi: { + allowedOrigins: merged, + }, + }, + } + const mergedConfig = mergeConfigsPreservingFields(latest, partial) + writeOpenclawConfigFile(mergedConfig) return true } @@ -3842,7 +3868,7 @@ const HERMES_TERMINAL_MODAL_MODES = new Set(['auto', 'managed', 'direct']) const HERMES_TERMINAL_VERCEL_RUNTIMES = new Set(['node24', 'node22', 'python3.13']) const HERMES_BROWSER_ENGINES = new Set(['auto', 'lightpanda', 'chrome']) const HERMES_BROWSER_DIALOG_POLICIES = new Set(['must_respond', 'auto_dismiss', 'auto_accept']) -const HERMES_WEB_BACKENDS = new Set(['tavily', 'firecrawl', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native']) +const HERMES_WEB_BACKENDS = new Set(['tavily', 'firecrawl', 'parallel-free', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native']) const HERMES_LSP_WAIT_MODES = new Set(['document', 'full']) const HERMES_LSP_INSTALL_STRATEGIES = new Set(['auto', 'manual', 'off']) const HERMES_MODEL_CATALOG_DEFAULT_URL = 'https://hermes-agent.nousresearch.com/docs/api/model-catalog.json' @@ -4012,7 +4038,7 @@ function normalizeHermesWebBackend(value, key, strict = false) { const backend = String(value ?? '').trim().toLowerCase() if (!backend) return '' if (HERMES_WEB_BACKENDS.has(backend)) return backend - if (strict) throw new Error(`${key} 必须为空或 tavily、firecrawl、parallel、exa、searxng、brave、brave_free、ddgs、xai、native`) + if (strict) throw new Error(`${key} 必须为空或 tavily、firecrawl、parallel-free、parallel、exa、searxng、brave、brave_free、ddgs、xai、native`) return '' } @@ -4936,6 +4962,9 @@ export function buildHermesMemoryConfigValues(config = {}) { const memory = root.memory && typeof root.memory === 'object' && !Array.isArray(root.memory) ? root.memory : {} + const qmd = memory.qmd && typeof memory.qmd === 'object' && !Array.isArray(memory.qmd) + ? memory.qmd + : {} return { memoryEnabled: readHermesBool(memory.memory_enabled, true), userProfileEnabled: readHermesBool(memory.user_profile_enabled, true), @@ -4943,6 +4972,7 @@ export function buildHermesMemoryConfigValues(config = {}) { userCharLimit: parseHermesInteger(memory.user_char_limit, 'memory.user_char_limit', 1375, 100, 200000, false), nudgeInterval: parseHermesInteger(memory.nudge_interval, 'memory.nudge_interval', 10, 0, 1000, false), flushMinTurns: parseHermesInteger(memory.flush_min_turns, 'memory.flush_min_turns', 6, 0, 1000, false), + qmdRerank: readHermesBool(qmd.rerank, true), } } @@ -4958,6 +4988,11 @@ export function mergeHermesMemoryConfig(config = {}, form = {}) { memory.user_char_limit = parseHermesInteger(Object.hasOwn(form, 'userCharLimit') ? form.userCharLimit : currentValues.userCharLimit, 'memory.user_char_limit', 1375, 100, 200000, true) memory.nudge_interval = parseHermesInteger(Object.hasOwn(form, 'nudgeInterval') ? form.nudgeInterval : currentValues.nudgeInterval, 'memory.nudge_interval', 10, 0, 1000, true) memory.flush_min_turns = parseHermesInteger(Object.hasOwn(form, 'flushMinTurns') ? form.flushMinTurns : currentValues.flushMinTurns, 'memory.flush_min_turns', 6, 0, 1000, true) + const qmd = memory.qmd && typeof memory.qmd === 'object' && !Array.isArray(memory.qmd) + ? mergeConfigsPreservingFields(memory.qmd, {}) + : {} + qmd.rerank = formHermesBool(form, 'qmdRerank', currentValues.qmdRerank) + memory.qmd = qmd next.memory = memory return next } @@ -5855,9 +5890,27 @@ export function buildHermesSecurityConfigValues(config = {}) { tirithPath, tirithTimeout: parseHermesInteger(security.tirith_timeout, 'security.tirith_timeout', 5, 1, 300, false), tirithFailOpen: readHermesBool(security.tirith_fail_open, true), + installPolicyJson: security.installPolicy && typeof security.installPolicy === 'object' && !Array.isArray(security.installPolicy) + ? JSON.stringify(security.installPolicy, null, 2) + : '', } } +function parseHermesInstallPolicyJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return null + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`security.installPolicy JSON 格式错误: ${err.message}`) + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('security.installPolicy 必须是 JSON 对象') + } + return value +} + export function mergeHermesSecurityConfig(config = {}, form = {}) { const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) const currentValues = buildHermesSecurityConfigValues(next) @@ -5870,6 +5923,9 @@ export function mergeHermesSecurityConfig(config = {}, form = {}) { security.tirith_path = tirithPath security.tirith_timeout = parseHermesInteger(Object.hasOwn(form, 'tirithTimeout') ? form.tirithTimeout : currentValues.tirithTimeout, 'security.tirith_timeout', 5, 1, 300, true) security.tirith_fail_open = formHermesBool(form, 'tirithFailOpen', currentValues.tirithFailOpen) + const installPolicy = parseHermesInstallPolicyJson(Object.hasOwn(form, 'installPolicyJson') ? form.installPolicyJson : currentValues.installPolicyJson) + if (installPolicy) security.installPolicy = installPolicy + else delete security.installPolicy next.security = security return next } @@ -10851,6 +10907,21 @@ const handlers = { check_node() { try { + const cliPath = resolveOpenclawCliPath() + if (cliPath && classifyCliSource(cliPath) === 'standalone') { + const bundled = standaloneBundledNodePath(cliPath) + if (bundled) { + const result = spawnSync(bundled, ['--version'], { windowsHide: true, encoding: 'utf8' }) + if (result.status === 0) { + return decorateNodeDetection({ + installed: true, + version: String(result.stdout || '').trim(), + path: bundled, + detectedFrom: 'standalone-bundled', + }) + } + } + } const ver = execSync('node --version 2>&1', { windowsHide: true }).toString().trim() return decorateNodeDetection({ installed: true, version: ver, path: findCommandPath('node') }) } catch { diff --git a/scripts/linux-deploy.sh b/scripts/linux-deploy.sh index 2c9caf8..14e9c21 100644 --- a/scripts/linux-deploy.sh +++ b/scripts/linux-deploy.sh @@ -11,6 +11,11 @@ PANEL_PORT=1420 REPO_URL="https://github.com/qingchencloud/clawpanel.git" REPO_URL_GITEE="https://gitee.com/QtCodeCreators/clawpanel.git" NPM_REGISTRY="https://registry.npmmirror.com" +PANEL_NODE_MIN_VERSION="18.0.0" +OPENCLAW_RECOMMENDED_VERSION="2026.5.18-zh.1" +OPENCLAW_NODE_RUNTIME_FLOOR_VERSION="2026.6.5" +OPENCLAW_NEW_NODE_MIN_VERSION="22.19.0" +NODE_MIN_VERSION="$PANEL_NODE_MIN_VERSION" # 检测权限模式 if [ "$(id -u)" = "0" ]; then @@ -67,19 +72,52 @@ detect_os() { esac } +# 比较语义版本,要求 actual >= min +version_ge() { + local actual="${1#v}" + local min="${2#v}" + local actual_major actual_minor actual_patch min_major min_minor min_patch + actual=$(printf '%s' "$actual" | grep -Eo '[0-9]+(\.[0-9]+){0,2}' | head -1 || true) + min=$(printf '%s' "$min" | grep -Eo '[0-9]+(\.[0-9]+){0,2}' | head -1 || true) + if [ -z "$actual" ] || [ -z "$min" ]; then return 1; fi + IFS=. read -r actual_major actual_minor actual_patch <<< "$actual" + IFS=. read -r min_major min_minor min_patch <<< "$min" + actual_major=${actual_major:-0} + actual_minor=${actual_minor:-0} + actual_patch=${actual_patch:-0} + min_major=${min_major:-0} + min_minor=${min_minor:-0} + min_patch=${min_patch:-0} + if [ "$actual_major" -gt "$min_major" ]; then return 0; fi + if [ "$actual_major" -lt "$min_major" ]; then return 1; fi + if [ "$actual_minor" -gt "$min_minor" ]; then return 0; fi + if [ "$actual_minor" -lt "$min_minor" ]; then return 1; fi + [ "$actual_patch" -ge "$min_patch" ] +} + +node_version_ge_min() { + version_ge "$1" "$NODE_MIN_VERSION" +} + +openclaw_version_needs_new_node() { + local base_version="${1%%-*}" + [ -n "$base_version" ] && version_ge "$base_version" "$OPENCLAW_NODE_RUNTIME_FLOOR_VERSION" +} + # 安装 Node.js install_node() { if command -v node &> /dev/null; then - local node_major=$(node -v | sed 's/v//' | cut -d. -f1) - if [ "$node_major" -ge 18 ]; then + local node_version + node_version=$(node -v) + if node_version_ge_min "$node_version"; then echo "✅ Node.js $(node -v) 已安装" return 0 else - echo "⚠️ Node.js $(node -v) 版本过低,需要 18+" + echo "⚠️ Node.js $(node -v) 版本过低,需要 >=${NODE_MIN_VERSION}" fi fi - echo "📦 安装 Node.js 22 LTS..." + echo "📦 安装 Node.js LTS(要求 >=${NODE_MIN_VERSION})..." case "$OS" in ubuntu|debian|linuxmint|pop) curl -fsSL https://deb.nodesource.com/setup_22.x | run_pkg_cmd bash - @@ -101,9 +139,23 @@ install_node() { exit 1 ;; esac + if ! node_version_ge_min "$(node -v)"; then + echo "❌ Node.js $(node -v) 仍低于 OpenClaw 要求 >=${NODE_MIN_VERSION}" + echo " 请手动安装 Node.js ${NODE_MIN_VERSION} 或更高版本后重试" + exit 1 + fi echo "✅ Node.js $(node -v) 安装完成" } +ensure_node_for_openclaw_version() { + local openclaw_version="$1" + if openclaw_version_needs_new_node "$openclaw_version"; then + NODE_MIN_VERSION="$OPENCLAW_NEW_NODE_MIN_VERSION" + echo "ℹ️ OpenClaw ${openclaw_version} 需要 Node.js >=${NODE_MIN_VERSION}" + install_node + fi +} + # 安装 Git install_git() { if command -v git &> /dev/null; then @@ -171,8 +223,9 @@ detect_openclaw_source() { # 安装 OpenClaw install_openclaw() { local oc_path=$(find_openclaw) + local oc_ver="" if [ -n "$oc_path" ]; then - local oc_ver=$("$oc_path" --version 2>/dev/null || echo "未知版本") + oc_ver=$("$oc_path" --version 2>/dev/null || echo "未知版本") local oc_src=$(detect_openclaw_source "$oc_path") if [ "$oc_src" = "chinese" ]; then echo "✅ OpenClaw 汉化版已安装: $oc_ver (${oc_path})" @@ -185,17 +238,26 @@ install_openclaw() { echo "ℹ️ 已将 $(dirname "$oc_path") 加入 PATH" fi else - echo "📦 安装 OpenClaw 汉化版..." + echo "📦 安装 OpenClaw 汉化版稳定版 ${OPENCLAW_RECOMMENDED_VERSION}..." + local openclaw_spec="@qingchencloud/openclaw-zh@${OPENCLAW_RECOMMENDED_VERSION}" if [ "$IS_ROOT" = true ]; then - npm install -g @qingchencloud/openclaw-zh --registry "$NPM_REGISTRY" || \ - npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmjs.org + npm install -g "$openclaw_spec" --registry "$NPM_REGISTRY" || \ + npm install -g "$openclaw_spec" --registry https://registry.npmjs.org else - sudo -E npm install -g @qingchencloud/openclaw-zh --registry "$NPM_REGISTRY" || \ - sudo -E npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmjs.org + sudo -E npm install -g "$openclaw_spec" --registry "$NPM_REGISTRY" || \ + sudo -E npm install -g "$openclaw_spec" --registry https://registry.npmjs.org fi echo "✅ OpenClaw 安装完成" + oc_path=$(find_openclaw) + if [ -n "$oc_path" ]; then + oc_ver=$("$oc_path" --version 2>/dev/null || echo "$OPENCLAW_RECOMMENDED_VERSION") + else + oc_ver="$OPENCLAW_RECOMMENDED_VERSION" + fi fi + ensure_node_for_openclaw_version "$oc_ver" + # 初始化配置(如果不存在) if [ ! -f "$HOME/.openclaw/openclaw.json" ]; then echo "🔧 初始化 OpenClaw 配置..." diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 76785ae..0021115 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.18.2" +version = "0.18.3" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23c86e4..fbeb040 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.18.2" +version = "0.18.3" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 1fe31b0..4738ff5 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -210,6 +210,13 @@ fn read_package_json_field(path: &std::path::Path, pointer: &str) -> Option bool { + parse_version(&base_version(version)) >= parse_version(OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR) +} + fn find_openclaw_package_json(cli_path: &std::path::Path) -> Option { let dir = cli_path.parent()?; let cli_source = crate::utils::classify_cli_source(&cli_path.to_string_lossy()); @@ -246,8 +253,66 @@ fn find_openclaw_package_json(cli_path: &std::path::Path) -> Option { pub(crate) fn openclaw_node_requirement() -> Option { let cli_path = crate::utils::resolve_openclaw_cli_path()?; - let pkg_json = find_openclaw_package_json(std::path::Path::new(&cli_path))?; - read_package_json_field(&pkg_json, "/engines/node") + let cli_path_ref = std::path::Path::new(&cli_path); + let pkg_json = find_openclaw_package_json(cli_path_ref); + if let Some(pkg_json) = pkg_json.as_ref() { + if let Some(requirement) = read_package_json_field(pkg_json, "/engines/node") + .filter(|requirement| !requirement.trim().is_empty()) + { + return Some(requirement); + } + } + let installed_version = pkg_json + .as_ref() + .and_then(|pkg| read_package_json_field(pkg, "/version")) + .or_else(|| read_version_from_installation(cli_path_ref)); + installed_version + .filter(|version| openclaw_version_requires_node_22_19(version)) + .map(|_| OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME.to_string()) +} + +fn standalone_bundled_node_bin(cli_path: &str) -> Option { + let dir = std::path::Path::new(cli_path).parent()?; + #[cfg(target_os = "windows")] + let node_bin = dir.join("node.exe"); + #[cfg(not(target_os = "windows"))] + let node_bin = dir.join("node"); + node_bin.is_file().then_some(node_bin) +} + +fn node_version_from_bin(node_bin: &std::path::Path) -> Option { + let mut cmd = Command::new(node_bin); + cmd.arg("--version"); + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + let output = cmd.output().ok()?; + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } +} + +fn populate_node_detection_result( + result: &mut serde_json::Map, + version: String, + path: String, + detected_from: String, +) { + let required_version = openclaw_node_requirement(); + let compatible = required_version + .as_deref() + .map(|req| node_version_satisfies_requirement(&version, req)) + .unwrap_or(true); + result.insert("installed".into(), Value::Bool(true)); + result.insert("version".into(), Value::String(version)); + result.insert("path".into(), Value::String(path)); + result.insert("detectedFrom".into(), Value::String(detected_from)); + result.insert("compatible".into(), Value::Bool(compatible)); + result.insert( + "requiredVersion".into(), + required_version.map(Value::String).unwrap_or(Value::Null), + ); } pub(crate) fn ensure_node_runtime_compatible() -> Result<(), String> { @@ -1027,7 +1092,7 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> { // 即使这些字段不在前端传入的配置对象中 let existing_config = fs::read_to_string(&path) .ok() - .and_then(|c| serde_json::from_str::(&c).ok()); + .and_then(|c| parse_json_relaxed(&c)); // 备份 let bak = super::openclaw_dir().join("openclaw.json.bak"); @@ -1060,8 +1125,8 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> { } const CALIBRATION_RESET_INHERIT_KEYS: &[&str] = &[ - "agents", "auth", "bindings", "browser", "channels", "commands", "env", "hooks", "models", - "plugins", "session", "skills", "wizard", + "agents", "auth", "bindings", "browser", "channels", "commands", "env", "hooks", "memory", + "models", "plugins", "security", "session", "skills", "wizard", ]; fn calibration_required_origins() -> Vec { @@ -1558,7 +1623,8 @@ pub fn calibrate_openclaw_config(mode: String) -> Result { /// /// Issue #127: 修复配置合并时丢失 browser.* 等合法字段的问题 /// -/// 策略:对所有顶级 Object 类型字段做浅合并(新值覆盖旧值,旧值中新配置没有的字段保留)。 +/// 策略:对 Object 类型字段递归合并(新值覆盖旧值,旧值中新配置没有的字段保留)。 +/// 数组与标量显式替换,避免把模型列表、Agent 列表等顺序集合错误拼接。 /// 这样用户通过 CLI / 手动编辑添加的自定义子字段不会被前端的部分配置所覆盖掉。 /// /// 清理的字段: @@ -1575,14 +1641,16 @@ fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value { if let (Value::Object(existing_sub), Value::Object(new_sub)) = (existing_value, new_value) { - // 两边都是对象:浅合并(新值覆盖,旧值保留未覆盖的 key) - let mut sub_merged = existing_sub.clone(); - for (sub_key, sub_value) in new_sub { - sub_merged.insert(sub_key.clone(), sub_value.clone()); - } - merged.insert(key.clone(), Value::Object(sub_merged)); + // 两边都是对象:递归合并(新值覆盖,旧值保留未覆盖的 key) + merged.insert( + key.clone(), + merge_configs_preserving_fields( + &Value::Object(existing_sub.clone()), + &Value::Object(new_sub.clone()), + ), + ); } else { - // 类型不同或不是对象,直接使用新值 + // 类型不同、数组或标量,直接使用新值 merged.insert(key.clone(), new_value.clone()); } } else { @@ -4771,6 +4839,24 @@ pub fn check_node() -> Result { let mut result = serde_json::Map::new(); let enhanced = super::enhanced_path(); + // standalone 安装会在 openclaw 启动脚本中优先使用同目录 Node.js。 + // 这里按实际运行时检测,避免被 PATH 中较旧的系统 Node.js 误判拦截。 + if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() { + if crate::utils::classify_cli_source(&cli_path) == "standalone" { + if let Some(bundled) = standalone_bundled_node_bin(&cli_path) { + if let Some(ver) = node_version_from_bin(&bundled) { + populate_node_detection_result( + &mut result, + ver, + bundled.to_string_lossy().to_string(), + "standalone-bundled".into(), + ); + return Ok(Value::Object(result)); + } + } + } + } + // 尝试通过 which/where 命令找到 node 的实际路径 let node_path = find_node_path(&enhanced); @@ -4783,20 +4869,7 @@ pub fn check_node() -> Result { Ok(o) if o.status.success() => { let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); let detected_from = detect_node_source(&path); - let required_version = openclaw_node_requirement(); - let compatible = required_version - .as_deref() - .map(|req| node_version_satisfies_requirement(&ver, req)) - .unwrap_or(true); - result.insert("installed".into(), Value::Bool(true)); - result.insert("version".into(), Value::String(ver)); - result.insert("path".into(), Value::String(path)); - result.insert("detectedFrom".into(), Value::String(detected_from)); - result.insert("compatible".into(), Value::Bool(compatible)); - result.insert( - "requiredVersion".into(), - required_version.map(Value::String).unwrap_or(Value::Null), - ); + populate_node_detection_result(&mut result, ver, path, detected_from); } _ => { result.insert("installed".into(), Value::Bool(false)); @@ -7458,8 +7531,8 @@ pub async fn auto_install_node(app: tauri::AppHandle) -> Result .wait() .map_err(|e| format!("等待 winget 安装 Node.js 失败: {e}"))?; if !install_status.success() { - let requirement = - openclaw_node_requirement().unwrap_or_else(|| "22.19.0 或更高版本".to_string()); + let requirement = openclaw_node_requirement() + .unwrap_or_else(|| "当前 OpenClaw 要求的版本".to_string()); return Err(format!( "winget 安装/升级 Node.js 失败,请手动安装满足 {requirement} 的 Node.js:https://nodejs.org/" )); @@ -7527,15 +7600,16 @@ pub fn invalidate_path_cache() -> Result<(), String> { #[cfg(test)] mod write_openclaw_config_merge_tests { + use super::apply_reset_inheritance; use super::merge_configs_preserving_fields; use super::node_version_satisfies_requirement; + use super::openclaw_version_requires_node_22_19; #[cfg(target_os = "windows")] use super::resolve_openclaw_cli_input_path; + use super::standalone_bundled_node_bin; use serde_json::json; - #[cfg(target_os = "windows")] use std::path::PathBuf; - #[cfg(target_os = "windows")] fn unique_temp_dir(name: &str) -> PathBuf { let suffix = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -7580,6 +7654,61 @@ mod write_openclaw_config_merge_tests { assert_eq!(prov["a"]["baseUrl"], json!("http://example")); } + #[test] + fn calibration_reset_inherits_memory_and_security_extensions() { + let baseline = json!({}); + let seed = json!({ + "memory": { + "qmd": { "rerank": false }, + }, + "security": { + "installPolicy": { + "enabled": true, + "targets": ["skill", "plugin"] + } + } + }); + + let (next, inherited) = apply_reset_inheritance(baseline, &seed); + + assert!(inherited.contains(&"memory".to_string())); + assert!(inherited.contains(&"security".to_string())); + assert_eq!(next["memory"]["qmd"]["rerank"], json!(false)); + assert_eq!( + next["security"]["installPolicy"]["targets"][1], + json!("plugin") + ); + } + + #[test] + fn partial_gateway_patch_preserves_auth_token() { + let existing = json!({ + "gateway": { + "auth": { "token": "secret-new" }, + "controlUi": { "allowedOrigins": ["http://localhost:3000"] } + } + }); + let patch = json!({ + "gateway": { + "controlUi": { + "allowedOrigins": ["http://localhost:3000", "tauri://localhost"] + } + } + }); + + let merged = merge_configs_preserving_fields(&existing, &patch); + + assert_eq!( + merged.pointer("/gateway/auth/token"), + Some(&json!("secret-new")) + ); + let origins = merged + .pointer("/gateway/controlUi/allowedOrigins") + .and_then(|v| v.as_array()) + .expect("allowedOrigins"); + assert!(origins.iter().any(|v| v == "tauri://localhost")); + } + #[cfg(target_os = "windows")] #[test] fn windows_cli_input_rejects_extensionless_openclaw_shim() { @@ -7624,6 +7753,14 @@ mod write_openclaw_config_merge_tests { assert!(node_version_satisfies_requirement("v24.0.0", ">=22.19.0")); } + #[test] + fn openclaw_node_requirement_floor_starts_at_2026_6_5() { + assert!(!openclaw_version_requires_node_22_19("2026.6.4")); + assert!(openclaw_version_requires_node_22_19("2026.6.5")); + assert!(openclaw_version_requires_node_22_19("2026.6.5-zh.1")); + assert!(openclaw_version_requires_node_22_19("2026.7.1")); + } + #[test] fn node_requirement_supports_common_or_ranges() { assert!(node_version_satisfies_requirement( @@ -7639,4 +7776,23 @@ mod write_openclaw_config_merge_tests { "^22.19.0 || >=24.0.0" )); } + + #[test] + fn standalone_bundled_node_bin_resolves_next_to_cli() { + let dir = unique_temp_dir("standalone-bundled-node"); + std::fs::create_dir_all(&dir).unwrap(); + let cli_path = dir.join("openclaw.cmd"); + std::fs::write(&cli_path, "@echo off\r\n").unwrap(); + #[cfg(target_os = "windows")] + let node_name = "node.exe"; + #[cfg(not(target_os = "windows"))] + let node_name = "node"; + let node_bin = dir.join(node_name); + std::fs::write(&node_bin, "").unwrap(); + + let resolved = standalone_bundled_node_bin(&cli_path.to_string_lossy()); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(resolved, Some(node_bin)); + } } diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 2a66653..b59c0a9 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4944,6 +4944,10 @@ fn build_hermes_memory_config_values(config: &serde_yaml::Value) -> Value { let flush_min_turns = memory .map(|map| bounded_hermes_i64(yaml_i64_field(map, "flush_min_turns"), 6, 0, 1000)) .unwrap_or(6); + let qmd = memory.and_then(|map| yaml_get_mapping(map, "qmd")); + let qmd_rerank = qmd + .and_then(|map| yaml_bool_field(map, "rerank")) + .unwrap_or(true); serde_json::json!({ "memoryEnabled": memory_enabled, @@ -4952,6 +4956,7 @@ fn build_hermes_memory_config_values(config: &serde_yaml::Value) -> Value { "userCharLimit": user_char_limit, "nudgeInterval": nudge_interval, "flushMinTurns": flush_min_turns, + "qmdRerank": qmd_rerank, }) } @@ -5005,6 +5010,8 @@ fn merge_hermes_memory_config(config: &mut serde_yaml::Value, form: &Value) -> R 0, 1000, )?; + let qmd_rerank = form_bool(form, "qmdRerank") + .unwrap_or_else(|| current["qmdRerank"].as_bool().unwrap_or(true)); let root = ensure_yaml_object(config)?; let memory = yaml_child_object(root, "memory")?; @@ -5032,6 +5039,8 @@ fn merge_hermes_memory_config(config: &mut serde_yaml::Value, form: &Value) -> R yaml_key("flush_min_turns"), serde_yaml::Value::Number(flush_min_turns.into()), ); + let qmd = yaml_child_object(memory, "qmd")?; + qmd.insert(yaml_key("rerank"), serde_yaml::Value::Bool(qmd_rerank)); Ok(()) } @@ -6816,15 +6825,39 @@ fn build_hermes_security_config_values(config: &serde_yaml::Value) -> Value { let tirith_fail_open = security .and_then(|map| yaml_bool_field(map, "tirith_fail_open")) .unwrap_or(true); + let install_policy_json = security + .and_then(|map| yaml_get(map, "installPolicy")) + .and_then(|value| serde_json::to_value(value).ok()) + .filter(|value| value.is_object()) + .and_then(|value| serde_json::to_string_pretty(&value).ok()) + .unwrap_or_default(); serde_json::json!({ "tirithEnabled": tirith_enabled, "tirithPath": tirith_path, "tirithTimeout": tirith_timeout, "tirithFailOpen": tirith_fail_open, + "installPolicyJson": install_policy_json, }) } +fn parse_hermes_install_policy_json( + raw: Option, +) -> Result, String> { + let text = raw.unwrap_or_default().trim().to_string(); + if text.is_empty() { + return Ok(None); + } + let value: Value = serde_json::from_str(&text) + .map_err(|err| format!("security.installPolicy JSON 格式错误: {err}"))?; + if !value.is_object() { + return Err("security.installPolicy 必须是 JSON 对象".to_string()); + } + serde_yaml::to_value(value) + .map(Some) + .map_err(|err| format!("security.installPolicy 转换 YAML 失败: {err}")) +} + fn merge_hermes_security_config( config: &mut serde_yaml::Value, form: &Value, @@ -6851,6 +6884,14 @@ fn merge_hermes_security_config( 1, 300, )?; + let install_policy = + parse_hermes_install_policy_json(if form.get("installPolicyJson").is_some() { + form_string(form, "installPolicyJson") + } else { + current["installPolicyJson"] + .as_str() + .map(ToString::to_string) + })?; let security = yaml_child_object(root, "security")?; security.insert( yaml_key("tirith_enabled"), @@ -6874,6 +6915,11 @@ fn merge_hermes_security_config( .unwrap_or_else(|| current["tirithFailOpen"].as_bool().unwrap_or(true)), ), ); + if let Some(install_policy) = install_policy { + security.insert(yaml_key("installPolicy"), install_policy); + } else { + security.remove(yaml_key("installPolicy")); + } Ok(()) } @@ -7999,6 +8045,7 @@ fn normalize_hermes_web_backend( backend.as_str(), "tavily" | "firecrawl" + | "parallel-free" | "parallel" | "exa" | "searxng" @@ -8011,7 +8058,7 @@ fn normalize_hermes_web_backend( return Ok(backend); } if strict { - Err(format!("{key} 必须为空或 tavily、firecrawl、parallel、exa、searxng、brave、brave_free、ddgs、xai、native")) + Err(format!("{key} 必须为空或 tavily、firecrawl、parallel-free、parallel、exa、searxng、brave、brave_free、ddgs、xai、native")) } else { Ok(String::new()) } @@ -19407,7 +19454,7 @@ streaming: merge_hermes_web_config( &mut config, &json!({ - "webBackend": "parallel", + "webBackend": "parallel-free", "webSearchBackend": "exa", "webExtractBackend": "native", }), @@ -19416,7 +19463,7 @@ streaming: assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); - assert_eq!(config["web"]["backend"].as_str(), Some("parallel")); + assert_eq!(config["web"]["backend"].as_str(), Some("parallel-free")); assert_eq!(config["web"]["search_backend"].as_str(), Some("exa")); assert_eq!(config["web"]["extract_backend"].as_str(), Some("native")); assert_eq!(config["web"]["custom_flag"].as_str(), Some("keep-web")); @@ -21510,6 +21557,7 @@ mod hermes_memory_config_tests { assert_eq!(values["userCharLimit"], 1375); assert_eq!(values["nudgeInterval"], 10); assert_eq!(values["flushMinTurns"], 6); + assert_eq!(values["qmdRerank"], true); } #[test] @@ -21523,6 +21571,9 @@ memory: provider: honcho custom_flag: keep-me flush_min_turns: 9 + qmd: + provider: qmd + rerank: true streaming: enabled: true "#, @@ -21538,6 +21589,7 @@ streaming: "userCharLimit": "1500", "nudgeInterval": "0", "flushMinTurns": "7", + "qmdRerank": false, }), ) .unwrap(); @@ -21553,6 +21605,8 @@ streaming: assert_eq!(config["memory"]["user_char_limit"].as_i64(), Some(1500)); assert_eq!(config["memory"]["nudge_interval"].as_i64(), Some(0)); assert_eq!(config["memory"]["flush_min_turns"].as_i64(), Some(7)); + assert_eq!(config["memory"]["qmd"]["rerank"].as_bool(), Some(false)); + assert_eq!(config["memory"]["qmd"]["provider"].as_str(), Some("qmd")); assert_eq!(config["memory"]["provider"].as_str(), Some("honcho")); assert_eq!(config["memory"]["custom_flag"].as_str(), Some("keep-me")); } @@ -24195,6 +24249,7 @@ mod hermes_security_config_tests { assert_eq!(values["tirithPath"], "tirith"); assert_eq!(values["tirithTimeout"], 5); assert_eq!(values["tirithFailOpen"], true); + assert_eq!(values["installPolicyJson"], ""); } #[test] @@ -24206,6 +24261,11 @@ security: tirith_path: C:/tools/tirith.exe tirith_timeout: 12 tirith_fail_open: false + installPolicy: + enabled: true + targets: + - skill + - plugin "#, ) .unwrap(); @@ -24214,6 +24274,10 @@ security: assert_eq!(values["tirithPath"], "C:/tools/tirith.exe"); assert_eq!(values["tirithTimeout"], 12); assert_eq!(values["tirithFailOpen"], false); + let install_policy: serde_json::Value = + serde_json::from_str(values["installPolicyJson"].as_str().unwrap()).unwrap(); + assert_eq!(install_policy["enabled"], true); + assert_eq!(install_policy["targets"][0], "skill"); } #[test] @@ -24228,6 +24292,10 @@ security: enabled: true domains: - example.com + installPolicy: + enabled: false + targets: + - skill custom_flag: keep-security terminal: backend: docker @@ -24242,6 +24310,7 @@ terminal: "tirithPath": "~/bin/tirith", "tirithTimeout": 9, "tirithFailOpen": false, + "installPolicyJson": r#"{"enabled":true,"targets":["skill","plugin"],"exec":{"source":"exec","command":"tirith","args":["scan"]}}"#, }), ) .unwrap(); @@ -24252,6 +24321,18 @@ terminal: config["security"]["custom_flag"].as_str(), Some("keep-security") ); + assert_eq!( + config["security"]["installPolicy"]["enabled"].as_bool(), + Some(true) + ); + assert_eq!( + config["security"]["installPolicy"]["targets"][1].as_str(), + Some("plugin") + ); + assert_eq!( + config["security"]["installPolicy"]["exec"]["command"].as_str(), + Some("tirith") + ); assert_eq!(config["security"]["tirith_enabled"].as_bool(), Some(false)); assert_eq!( config["security"]["tirith_path"].as_str(), @@ -24274,6 +24355,10 @@ terminal: let err = merge_hermes_security_config(&mut config, &json!({ "tirithPath": "" })).unwrap_err(); assert!(err.contains("security.tirith_path")); + + let err = merge_hermes_security_config(&mut config, &json!({ "installPolicyJson": "[]" })) + .unwrap_err(); + assert!(err.contains("security.installPolicy")); } } diff --git a/src-tauri/src/commands/pairing.rs b/src-tauri/src/commands/pairing.rs index 8c9e933..23d82b0 100644 --- a/src-tauri/src/commands/pairing.rs +++ b/src-tauri/src/commands/pairing.rs @@ -297,7 +297,7 @@ pub fn auto_pair_device() -> Result { /// 将 Tauri 应用的 origin 写入 gateway.controlUi.allowedOrigins /// 避免 Gateway 因 origin not allowed 拒绝 WebSocket 握手 fn patch_gateway_origins() { - let Ok(mut config) = super::config::load_openclaw_json() else { + let Ok(config) = super::config::load_openclaw_json() else { return; }; @@ -310,37 +310,39 @@ fn patch_gateway_origins() { "http://127.0.0.1:1420".into(), ]; - if let Some(obj) = config.as_object_mut() { - let gateway = obj - .entry("gateway") - .or_insert_with(|| serde_json::json!({})); - if let Some(gw) = gateway.as_object_mut() { - let control_ui = gw - .entry("controlUi") - .or_insert_with(|| serde_json::json!({})); - if let Some(cui) = control_ui.as_object_mut() { - // 合并:保留用户已有的 origin,追加缺失的 Tauri origin - let existing: Vec = cui - .get("allowedOrigins") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|s| s.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let mut merged = existing; - for r in &required { - if !merged.iter().any(|e| e == r) { - merged.push(r.clone()); - } - } - cui.insert("allowedOrigins".to_string(), serde_json::json!(merged)); - } + let existing: Vec = config + .pointer("/gateway/controlUi/allowedOrigins") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + if required + .iter() + .all(|origin| existing.iter().any(|item| item == origin)) + { + return; + } + + let mut merged = existing; + for origin in &required { + if !merged.iter().any(|item| item == origin) { + merged.push(origin.clone()); } } - let _ = super::config::save_openclaw_json(&config); + // 只写入 allowedOrigins 增量,避免用陈旧全量快照覆盖并发保存的其它配置字段。 + let patch = serde_json::json!({ + "gateway": { + "controlUi": { + "allowedOrigins": merged + } + } + }); + let _ = super::config::save_openclaw_json(&patch); } #[tauri::command] diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 086e9f3..bf01785 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -152,7 +152,10 @@ fn parse_lsof_pid_output(text: &str) -> Option { #[cfg(test)] mod gateway_pid_parse_tests { - use super::{parse_lsof_pid_output, parse_ss_listen_pid_output}; + use super::{ + parse_lsof_pid_output, parse_ss_listen_pid_output, + should_preserve_plugin_config_on_generic_strip, + }; #[test] fn parses_linux_ss_listener_pid() { @@ -167,6 +170,15 @@ LISTEN 0 511 127.0.0.1:18789 0.0.0.0:* users:((\"node\",pid= assert_eq!(parse_lsof_pid_output("4242\n"), Some(4242)); assert_eq!(parse_lsof_pid_output("not-a-pid\n4242\n"), Some(4242)); } + + #[test] + fn generic_config_strip_preserves_parallel_plugin_config() { + assert!(should_preserve_plugin_config_on_generic_strip("parallel")); + assert!(should_preserve_plugin_config_on_generic_strip("Parallel")); + assert!(!should_preserve_plugin_config_on_generic_strip( + "legacy-plugin" + )); + } } fn read_gateway_owner() -> Option { @@ -326,6 +338,14 @@ fn looks_like_gateway_config_mismatch(reason: &str) -> bool { /// 直接修复 openclaw.json 中 plugins.entries.*.config 的多余属性 /// 当 `openclaw doctor --fix` 无法修复时作为二级回退 +const GENERIC_CONFIG_STRIP_PROTECTED_PLUGINS: &[&str] = &["parallel"]; + +fn should_preserve_plugin_config_on_generic_strip(name: &str) -> bool { + GENERIC_CONFIG_STRIP_PROTECTED_PLUGINS + .iter() + .any(|id| id.eq_ignore_ascii_case(name)) +} + fn try_direct_config_strip() -> Result { let config_path = crate::commands::openclaw_dir().join("openclaw.json"); let raw = @@ -372,6 +392,12 @@ fn try_direct_config_strip() -> Result { { let entry_names: Vec = entries.keys().cloned().collect(); for name in &entry_names { + if should_preserve_plugin_config_on_generic_strip(name) { + guardian_log(&format!( + "直接修复(通用回退): 保留 plugins.entries.{name}.config" + )); + continue; + } if let Some(entry) = entries.get_mut(name) { if let Some(obj) = entry.as_object_mut() { if obj.contains_key("config") { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8415c75..5bed7ae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.18.2", + "version": "0.18.3", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 2d6f8c9..15f3e00 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -86,6 +86,7 @@ const MEMORY_DEFAULTS = { userCharLimit: 1375, nudgeInterval: 10, flushMinTurns: 6, + qmdRerank: true, } const SKILLS_DEFAULTS = { @@ -185,6 +186,7 @@ const SECURITY_DEFAULTS = { tirithPath: 'tirith', tirithTimeout: 5, tirithFailOpen: true, + installPolicyJson: '', } const DISPLAY_DEFAULTS = { @@ -407,7 +409,7 @@ const TERMINAL_MODAL_MODES = ['auto', 'managed', 'direct'] const TERMINAL_VERCEL_RUNTIMES = ['node24', 'node22', 'python3.13'] const BROWSER_ENGINES = ['auto', 'lightpanda', 'chrome'] const BROWSER_DIALOG_POLICIES = ['must_respond', 'auto_dismiss', 'auto_accept'] -const WEB_BACKENDS = ['', 'tavily', 'firecrawl', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native'] +const WEB_BACKENDS = ['', 'tavily', 'firecrawl', 'parallel-free', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native'] const LSP_WAIT_MODES = ['document', 'full'] const LSP_INSTALL_STRATEGIES = ['auto', 'manual', 'off'] const STT_PROVIDERS = ['auto', 'local', 'groq', 'openai', 'mistral'] @@ -1115,6 +1117,10 @@ export function render() { ${t('engine.hermesMemoryConfigUserProfileEnabled')} +
+
${t('engine.hermesSecurityConfigFootnote')}
@@ -4071,6 +4081,7 @@ export function render() { userCharLimit: el.querySelector('#hm-memory-user-char-limit')?.value || '1375', nudgeInterval: el.querySelector('#hm-memory-nudge-interval')?.value || '10', flushMinTurns: el.querySelector('#hm-memory-flush-min-turns')?.value || '6', + qmdRerank: !!el.querySelector('#hm-memory-qmd-rerank')?.checked, } memorySaving = true memoryError = null @@ -4506,6 +4517,7 @@ export function render() { tirithPath: el.querySelector('#hm-security-tirith-path')?.value || 'tirith', tirithTimeout: el.querySelector('#hm-security-tirith-timeout')?.value || '5', tirithFailOpen: !!el.querySelector('#hm-security-tirith-fail-open')?.checked, + installPolicyJson: el.querySelector('#hm-security-install-policy-json')?.value || '', } securitySaving = true securityError = null diff --git a/src/lib/openclaw-kb.js b/src/lib/openclaw-kb.js index f4f3a14..8ed719b 100644 --- a/src/lib/openclaw-kb.js +++ b/src/lib/openclaw-kb.js @@ -67,7 +67,7 @@ OpenClaw 是开源个人 AI 助手平台,核心组件: | \`openclaw channels login\` | 登录渠道(如 WhatsApp QR) | | \`openclaw pairing list \` | 列出配对请求 | | \`openclaw pairing approve \` | 批准配对 | -| \`openclaw configure --section web\` | 配置 Web 搜索(Brave API) | +| \`openclaw configure --section web\` | 配置 Web 搜索(Brave / Parallel 等) | | \`openclaw config set \` | 设置单个配置项 | | \`openclaw logs\` | 查看日志 | | \`openclaw service start/stop/restart\` | 管理后台服务 | @@ -77,6 +77,13 @@ OpenClaw 是开源个人 AI 助手平台,核心组件: 配置位于 \`~/.openclaw/openclaw.json\`,JSON5 格式(支持注释和尾逗号)。 不存在时使用安全默认值。严格 schema 验证,未知键会阻止启动。 +### Web 搜索插件 +- Parallel Web Search 插件 id 为 \`parallel\`,目录为 \`extensions/parallel\` +- 免费候选 provider 为 \`parallel-free\`;正式 Parallel Search provider 为 \`parallel\` +- API Key 环境变量:\`PARALLEL_API_KEY\` +- 配置路径:\`plugins.entries.parallel.config.webSearch.apiKey\` / \`plugins.entries.parallel.config.webSearch.baseUrl\` +- QMD 查询重排序开关:\`memory.qmd.rerank\` + ### 最小配置示例 \`\`\`json5 { @@ -185,7 +192,7 @@ iwr -useb https://openclaw.ai/install.ps1 | iex \`\`\`bash npm install -g openclaw@latest \`\`\` -**前置条件:** Node.js >= 22 +**前置条件:** Node.js >= 22.19.0 ## 九、后台服务 - **macOS**:launchd 服务(openclaw 应用管理) diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 3057fe5..55cd870 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -738,6 +738,7 @@ export default { hermesWebConfigBackend_auto: _('自动选择', 'Auto select', '自動選擇'), hermesWebConfigBackend_tavily: _('Tavily', 'Tavily', 'Tavily'), hermesWebConfigBackend_firecrawl: _('Firecrawl', 'Firecrawl', 'Firecrawl'), + 'hermesWebConfigBackend_parallel-free': _('Parallel Free(免 Key)', 'Parallel Free (no key)', 'Parallel Free(免 Key)'), hermesWebConfigBackend_parallel: _('Parallel', 'Parallel', 'Parallel'), hermesWebConfigBackend_exa: _('Exa', 'Exa', 'Exa'), hermesWebConfigBackend_searxng: _('SearXNG', 'SearXNG', 'SearXNG'), @@ -982,7 +983,8 @@ export default { hermesMemoryConfigUserCharLimit: _('用户画像字符上限', 'User profile character limit', '使用者画像字元上限'), hermesMemoryConfigNudgeInterval: _('整理提醒间隔', 'Review nudge interval', '整理提醒間隔'), hermesMemoryConfigFlushMinTurns: _('退出/重置前最少轮数', 'Minimum turns before flush', '退出/重置前最少輪數'), - hermesMemoryConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭提醒。flush 最小轮数会影响退出、重置和压缩前是否先写入记忆。外部记忆 provider 等高级字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable nudges. flush minimum turns controls whether memory is written before exit, reset, or compression. Advanced fields such as external memory provider are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉提醒。flush 最小輪數會影響退出、重置和壓縮前是否先寫入記憶。外部記憶 provider 等進階欄位會保留在 raw YAML 中。'), + hermesMemoryConfigQmdRerank: _('QMD 查询重排序', 'QMD query rerank', 'QMD 查詢重排序'), + hermesMemoryConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭提醒。QMD 查询重排序写入 memory.qmd.rerank,关闭后可在 query 模式下减少延迟。外部记忆 provider 等高级字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable nudges. QMD query rerank writes memory.qmd.rerank; turning it off can reduce latency in query mode. Advanced fields such as external memory provider are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉提醒。QMD 查詢重排序寫入 memory.qmd.rerank,關閉後可在 query 模式下降低延遲。外部記憶 provider 等進階欄位會保留在 raw YAML 中。'), hermesSkillsConfigTitle: _('技能沉淀', 'Skill capture', '技能沉澱'), hermesSkillsConfigDesc: _('控制 Hermes 何时提醒把可复用经验沉淀为 Skills,并指定可共享的外部技能目录。适合长跑任务和团队复用。', 'Control when Hermes nudges users to turn reusable experience into Skills and which external skill directories are shared. Useful for long-running work and team reuse.', '控制 Hermes 何時提醒把可重複使用經驗沉澱為 Skills,並指定可共享的外部技能目錄。適合長跑任務和團隊複用。'), hermesSkillsConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), @@ -1262,7 +1264,8 @@ export default { hermesSecurityConfigTirithPath: _('Tirith 可执行文件路径', 'Tirith executable path', 'Tirith 可執行檔路徑'), hermesSecurityConfigTirithTimeout: _('扫描超时(秒)', 'Scan timeout (s)', '掃描逾時(秒)'), hermesSecurityConfigTirithFailOpen: _('Tirith 不可用时放行', 'Allow when Tirith is unavailable', 'Tirith 不可用時放行'), - hermesSecurityConfigFootnote: _('启用后,Hermes 会在终端命令执行前调用 Tirith 进行内容级安全扫描;Tirith 不可用时是否放行取决于 fail-open。Windows 平台通常会静默跳过 Tirith,真实执行能力仍受宿主环境影响。', 'When enabled, Hermes runs Tirith before terminal commands for content-level scanning. Whether commands pass when Tirith is unavailable depends on fail-open. On Windows, Tirith is usually skipped silently, and actual execution still depends on the host environment.', '啟用後,Hermes 會在終端命令執行前呼叫 Tirith 進行內容級安全掃描;Tirith 不可用時是否放行取決於 fail-open。Windows 平台通常會靜默跳過 Tirith,真實執行能力仍受主機環境影響。'), + hermesSecurityConfigInstallPolicyJson: _('Install Policy JSON', 'Install Policy JSON', 'Install Policy JSON'), + hermesSecurityConfigFootnote: _('启用后,Hermes 会在终端命令执行前调用 Tirith 进行内容级安全扫描;Install Policy 写入 security.installPolicy,空白会删除该覆盖。其他 security 高级字段会保留在 raw YAML 中。', 'When enabled, Hermes runs Tirith before terminal commands for content-level scanning. Install Policy writes security.installPolicy; leaving it blank removes that override. Other advanced security fields stay in raw YAML.', '啟用後,Hermes 會在終端命令執行前呼叫 Tirith 進行內容級安全掃描;Install Policy 寫入 security.installPolicy,空白會刪除該覆蓋。其他 security 進階欄位會保留在 raw YAML 中。'), // Batch 1 §E: 会话导出 sessionsExport: _('导出', 'Export', '匯出'), sessionsExportSuccess: _('已导出', 'Exported', '已匯出'), diff --git a/src/locales/modules/setup.js b/src/locales/modules/setup.js index ab2cc02..159aadf 100644 --- a/src/locales/modules/setup.js +++ b/src/locales/modules/setup.js @@ -128,10 +128,10 @@ export default { promptNodeUnsupported: _('Node.js 版本过低: 当前 {version},要求 {required}', 'Node.js version is too old: current {version}, required {required}', 'Node.js 版本過低:目前 {version},要求 {required}'), nodeUpgradeHint: _('当前 Node.js {version} 版本过低,OpenClaw 要求 {required}。请先升级 Node.js,否则 Gateway 无法启动。', 'Current Node.js {version} is too old; OpenClaw requires {required}. Upgrade Node.js first or Gateway cannot start.', '目前 Node.js {version} 版本過低,OpenClaw 要求 {required}。請先升級 Node.js,否則 Gateway 無法啟動。'), nodeUnsupportedTitle: _('已检测到 Node.js,但版本不满足要求', 'Node.js was detected, but the version is not supported', '已檢測到 Node.js,但版本不符合要求'), - winNodeUpgradeHint: _('Windows 可尝试一键升级;如果失败,请手动安装 Node.js 22.19.0 或更高版本。升级后点击「重新检测」。', 'On Windows, try one-click upgrade. If it fails, manually install Node.js 22.19.0 or newer. Click "Re-detect" after upgrading.', 'Windows 可嘗試一鍵升級;如果失敗,請手動安裝 Node.js 22.19.0 或更高版本。升級後點擊「重新檢測」。'), - macNodeUpgradeHint: _('macOS 请通过官网、Homebrew、nvm 或 fnm 升级 Node.js 22.19.0 或更高版本。升级后建议重启 ClawPanel;如果从 Finder 启动仍检测到旧版本,可从终端重新打开:', 'On macOS, upgrade Node.js to 22.19.0 or newer via the official installer, Homebrew, nvm, or fnm. Restart ClawPanel after upgrading; if Finder still detects the old version, reopen it from Terminal:', 'macOS 請透過官網、Homebrew、nvm 或 fnm 升級 Node.js 22.19.0 或更高版本。升級後建議重啟 ClawPanel;如果從 Finder 啟動仍檢測到舊版本,可從終端重新開啟:'), - linuxNodeUpgradeHint: _('Linux 请使用系统包管理器、NodeSource、nvm 或 fnm 升级 Node.js 22.19.0 或更高版本。升级后点击「重新检测」,必要时重启 ClawPanel Web/桌面进程。', 'On Linux, upgrade Node.js to 22.19.0 or newer using your package manager, NodeSource, nvm, or fnm. Click "Re-detect" after upgrading, and restart the ClawPanel Web/desktop process if needed.', 'Linux 請使用系統套件管理器、NodeSource、nvm 或 fnm 升級 Node.js 22.19.0 或更高版本。升級後點擊「重新檢測」,必要時重啟 ClawPanel Web/桌面進程。'), - genericNodeUpgradeHint: _('请升级 Node.js 22.19.0 或更高版本,升级后点击「重新检测」。如果仍检测到旧版本,请检查 PATH 优先级或重启 ClawPanel。', 'Upgrade Node.js to 22.19.0 or newer, then click "Re-detect". If the old version is still detected, check PATH priority or restart ClawPanel.', '請升級 Node.js 22.19.0 或更高版本,升級後點擊「重新檢測」。如果仍檢測到舊版本,請檢查 PATH 優先級或重啟 ClawPanel。'), + winNodeUpgradeHint: _('Windows 可尝试一键升级;如果失败,请手动安装满足当前 OpenClaw 要求的 Node.js。升级后点击「重新检测」。', 'On Windows, try one-click upgrade. If it fails, manually install a Node.js version that satisfies the current OpenClaw requirement. Click "Re-detect" after upgrading.', 'Windows 可嘗試一鍵升級;如果失敗,請手動安裝滿足目前 OpenClaw 要求的 Node.js。升級後點擊「重新檢測」。'), + macNodeUpgradeHint: _('macOS 请通过官网、Homebrew、nvm 或 fnm 升级到满足当前 OpenClaw 要求的 Node.js。升级后建议重启 ClawPanel;如果从 Finder 启动仍检测到旧版本,可从终端重新打开:', 'On macOS, upgrade Node.js to a version that satisfies the current OpenClaw requirement via the official installer, Homebrew, nvm, or fnm. Restart ClawPanel after upgrading; if Finder still detects the old version, reopen it from Terminal:', 'macOS 請透過官網、Homebrew、nvm 或 fnm 升級到滿足目前 OpenClaw 要求的 Node.js。升級後建議重啟 ClawPanel;如果從 Finder 啟動仍檢測到舊版本,可從終端重新開啟:'), + linuxNodeUpgradeHint: _('Linux 请使用系统包管理器、NodeSource、nvm 或 fnm 升级到满足当前 OpenClaw 要求的 Node.js。升级后点击「重新检测」,必要时重启 ClawPanel Web/桌面进程。', 'On Linux, upgrade Node.js to a version that satisfies the current OpenClaw requirement using your package manager, NodeSource, nvm, or fnm. Click "Re-detect" after upgrading, and restart the ClawPanel Web/desktop process if needed.', 'Linux 請使用系統套件管理器、NodeSource、nvm 或 fnm 升級到滿足目前 OpenClaw 要求的 Node.js。升級後點擊「重新檢測」,必要時重啟 ClawPanel Web/桌面進程。'), + genericNodeUpgradeHint: _('请升级到满足当前 OpenClaw 要求的 Node.js,升级后点击「重新检测」。如果仍检测到旧版本,请检查 PATH 优先级或重启 ClawPanel。', 'Upgrade Node.js to a version that satisfies the current OpenClaw requirement, then click "Re-detect". If the old version is still detected, check PATH priority or restart ClawPanel.', '請升級到滿足目前 OpenClaw 要求的 Node.js,升級後點擊「重新檢測」。如果仍檢測到舊版本,請檢查 PATH 優先級或重啟 ClawPanel。'), autoUpgradeNodeBtn: _('一键升级 Node.js', 'Upgrade Node.js', '一鍵升級 Node.js'), upgradingNode: _('升级中...', 'Upgrading...', '升級中...'), downloadLatestNode: _('下载新版 Node.js', 'Download latest Node.js', '下載新版 Node.js'), @@ -151,7 +151,7 @@ export default { nodeUpgradeStarting: _('开始检查 Windows winget 和 Node.js LTS 安装状态...', 'Checking Windows winget and Node.js LTS install state...', '開始檢查 Windows winget 和 Node.js LTS 安裝狀態...'), nodeUpgradeRedetecting: _('已触发重新检测;如果仍显示旧版本,请重启 ClawPanel 或检查 PATH 优先级。', 'Re-detecting now. If the old version still appears, restart ClawPanel or check PATH priority.', '已觸發重新檢測;如果仍顯示舊版本,請重啟 ClawPanel 或檢查 PATH 優先級。'), nodeUpgradeStartGatewayHint: _('检测通过后,回到仪表盘或服务页启动 Gateway。', 'After the check passes, return to Dashboard or Services and start Gateway.', '檢測通過後,回到儀表盤或服務頁啟動 Gateway。'), - nodeManualInstallHint: _('请手动安装 Node.js 22.19.0 或更高版本:', 'Please manually install Node.js 22.19.0 or newer:', '請手動安裝 Node.js 22.19.0 或更高版本:'), + nodeManualInstallHint: _('请手动安装满足当前 OpenClaw 要求的 Node.js:', 'Please manually install a Node.js version that satisfies the current OpenClaw requirement:', '請手動安裝滿足目前 OpenClaw 要求的 Node.js:'), nodeUpgradeRestartHint: _('安装后回到 ClawPanel 点击「重新检测」。如果仍检测到旧版本,重启 ClawPanel 或把新版 Node.js 路径放到 PATH 更靠前的位置。', 'After installing, return to ClawPanel and click "Re-detect". If the old version is still detected, restart ClawPanel or move the newer Node.js path earlier in PATH.', '安裝後回到 ClawPanel 點擊「重新檢測」。如果仍檢測到舊版本,請重啟 ClawPanel 或把新版 Node.js 路徑放到 PATH 更靠前的位置。'), promptGitMissing: _('Git 未安装', 'Git not installed', 'Git 未安裝'), promptGitOk: _('Git 已安装: {version}', 'Git installed: {version}', 'Git 已安裝: {version}'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 3dc9a93..a0ef15e 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -786,7 +786,7 @@ const BUILTIN_SKILLS = [ 具体操作: 1. 调用 get_system_info 获取 OS、架构、Node.js 版本等基础信息 -2. 用 run_command 检查 Node.js 版本(node -v),要求 >= 18 +2. 用 run_command 检查 Node.js 版本(node -v),并按当前 OpenClaw 的 engines.node 判断是否兼容 3. 用 run_command 检查 npm 版本(npm -v) 4. 用 run_command 检查 OpenClaw CLI(openclaw --version) 5. 用 check_port 检查 Gateway 端口 18789 diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index f65eaf8..56763be 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -71,6 +71,7 @@ test('Hermes 配置页会暴露记忆结构化配置字段', () => { 'hm-memory-user-char-limit', 'hm-memory-nudge-interval', 'hm-memory-flush-min-turns', + 'hm-memory-qmd-rerank', ]) { assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) } @@ -243,6 +244,7 @@ test('Hermes 配置页会暴露 Tirith 安全扫描结构化配置字段', () => 'hm-security-tirith-path', 'hm-security-tirith-timeout', 'hm-security-tirith-fail-open', + 'hm-security-install-policy-json', ]) { assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) } diff --git a/tests/hermes-memory-config.test.js b/tests/hermes-memory-config.test.js index b97fd7f..a5d2d0b 100644 --- a/tests/hermes-memory-config.test.js +++ b/tests/hermes-memory-config.test.js @@ -16,6 +16,7 @@ test('Hermes 记忆配置读取会提供上游默认值', () => { userCharLimit: 1375, nudgeInterval: 10, flushMinTurns: 6, + qmdRerank: true, }) }) @@ -28,6 +29,9 @@ test('Hermes 记忆配置读取会回显 YAML 中的记忆字段', () => { user_char_limit: 1800, nudge_interval: 12, flush_min_turns: 8, + qmd: { + rerank: false, + }, }, }) @@ -37,6 +41,7 @@ test('Hermes 记忆配置读取会回显 YAML 中的记忆字段', () => { assert.equal(values.userCharLimit, 1800) assert.equal(values.nudgeInterval, 12) assert.equal(values.flushMinTurns, 8) + assert.equal(values.qmdRerank, false) }) test('Hermes 记忆配置保存会保留无关 YAML 并写入 snake_case 字段', () => { @@ -47,6 +52,10 @@ test('Hermes 记忆配置保存会保留无关 YAML 并写入 snake_case 字段' provider: 'honcho', custom_flag: 'keep-me', flush_min_turns: 9, + qmd: { + provider: 'qmd', + rerank: true, + }, }, streaming: { enabled: true }, }, { @@ -56,6 +65,7 @@ test('Hermes 记忆配置保存会保留无关 YAML 并写入 snake_case 字段' userCharLimit: '1500', nudgeInterval: '0', flushMinTurns: '7', + qmdRerank: false, }) assert.deepEqual(next.model, { provider: 'anthropic' }) @@ -66,6 +76,8 @@ test('Hermes 记忆配置保存会保留无关 YAML 并写入 snake_case 字段' assert.equal(next.memory.user_char_limit, 1500) assert.equal(next.memory.nudge_interval, 0) assert.equal(next.memory.flush_min_turns, 7) + assert.equal(next.memory.qmd.rerank, false) + assert.equal(next.memory.qmd.provider, 'qmd') assert.equal(next.memory.provider, 'honcho') assert.equal(next.memory.custom_flag, 'keep-me') }) diff --git a/tests/hermes-security-config.test.js b/tests/hermes-security-config.test.js index 2679e57..f31606f 100644 --- a/tests/hermes-security-config.test.js +++ b/tests/hermes-security-config.test.js @@ -14,6 +14,7 @@ test('Hermes 安全扫描配置读取会提供 Tirith 默认值', () => { tirithPath: 'tirith', tirithTimeout: 5, tirithFailOpen: true, + installPolicyJson: '', }) }) @@ -24,6 +25,10 @@ test('Hermes 安全扫描配置读取会规范化已有值', () => { tirith_path: 'C:/tools/tirith.exe', tirith_timeout: 12, tirith_fail_open: false, + installPolicy: { + enabled: true, + targets: ['skill', 'plugin'], + }, }, }) @@ -31,6 +36,10 @@ test('Hermes 安全扫描配置读取会规范化已有值', () => { assert.equal(values.tirithPath, 'C:/tools/tirith.exe') assert.equal(values.tirithTimeout, 12) assert.equal(values.tirithFailOpen, false) + assert.deepEqual(JSON.parse(values.installPolicyJson), { + enabled: true, + targets: ['skill', 'plugin'], + }) }) test('Hermes 安全扫描配置保存会保留未知字段并写入 security.tirith', () => { @@ -39,6 +48,10 @@ test('Hermes 安全扫描配置保存会保留未知字段并写入 security.tir security: { allow_private_urls: false, website_blocklist: { enabled: true, domains: ['example.com'] }, + installPolicy: { + enabled: false, + targets: ['skill'], + }, custom_flag: 'keep-security', }, terminal: { backend: 'docker' }, @@ -47,6 +60,15 @@ test('Hermes 安全扫描配置保存会保留未知字段并写入 security.tir tirithPath: '~/bin/tirith', tirithTimeout: '9', tirithFailOpen: false, + installPolicyJson: JSON.stringify({ + enabled: true, + targets: ['skill', 'plugin'], + exec: { + source: 'exec', + command: 'tirith', + args: ['scan'], + }, + }), }) assert.deepEqual(next.model, { provider: 'anthropic' }) @@ -54,6 +76,15 @@ test('Hermes 安全扫描配置保存会保留未知字段并写入 security.tir assert.equal(next.security.allow_private_urls, false) assert.deepEqual(next.security.website_blocklist, { enabled: true, domains: ['example.com'] }) assert.equal(next.security.custom_flag, 'keep-security') + assert.deepEqual(next.security.installPolicy, { + enabled: true, + targets: ['skill', 'plugin'], + exec: { + source: 'exec', + command: 'tirith', + args: ['scan'], + }, + }) assert.equal(next.security.tirith_enabled, false) assert.equal(next.security.tirith_path, '~/bin/tirith') assert.equal(next.security.tirith_timeout, 9) @@ -69,4 +100,8 @@ test('Hermes 安全扫描配置保存会拒绝非法超时和空路径', () => { () => mergeHermesSecurityConfig({}, { tirithPath: '' }), /security\.tirith_path/, ) + assert.throws( + () => mergeHermesSecurityConfig({}, { installPolicyJson: '[]' }), + /security\.installPolicy/, + ) }) diff --git a/tests/hermes-web-config.test.js b/tests/hermes-web-config.test.js index db56ee3..2eeb692 100644 --- a/tests/hermes-web-config.test.js +++ b/tests/hermes-web-config.test.js @@ -20,13 +20,13 @@ test('Hermes Web 工具配置读取会回显 YAML 字段', () => { const values = buildHermesWebConfigValues({ web: { backend: 'tavily', - search_backend: 'searxng', + search_backend: 'parallel-free', extract_backend: 'firecrawl', }, }) assert.equal(values.webBackend, 'tavily') - assert.equal(values.webSearchBackend, 'searxng') + assert.equal(values.webSearchBackend, 'parallel-free') assert.equal(values.webExtractBackend, 'firecrawl') }) @@ -41,14 +41,14 @@ test('Hermes Web 工具配置保存会保留未知字段并写入上游结构', }, streaming: { enabled: true }, }, { - webBackend: 'parallel', + webBackend: 'parallel-free', webSearchBackend: 'exa', webExtractBackend: 'native', }) assert.deepEqual(next.model, { provider: 'anthropic' }) assert.deepEqual(next.streaming, { enabled: true }) - assert.equal(next.web.backend, 'parallel') + assert.equal(next.web.backend, 'parallel-free') assert.equal(next.web.search_backend, 'exa') assert.equal(next.web.extract_backend, 'native') assert.equal(next.web.custom_flag, 'keep-web') diff --git a/tests/node-runtime-detection-policy.test.js b/tests/node-runtime-detection-policy.test.js new file mode 100644 index 0000000..c6aabfd --- /dev/null +++ b/tests/node-runtime-detection-policy.test.js @@ -0,0 +1,39 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const devApi = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), 'utf8') +const rustConfig = readFileSync(new URL('../src-tauri/src/commands/config.rs', import.meta.url), 'utf8') + +test('web check_node prefers standalone bundled Node when available', () => { + const start = devApi.indexOf('check_node() {') + const end = devApi.indexOf('get_status_summary()', start) + const fn = start >= 0 && end > start ? devApi.slice(start, end) : '' + + assert.ok(fn, 'check_node handler must exist') + assert.match(fn, /classifyCliSource\(cliPath\) === 'standalone'/) + assert.match(fn, /standaloneBundledNodePath\(cliPath\)/) + assert.match(fn, /detectedFrom: 'standalone-bundled'/) +}) + +test('desktop check_node prefers standalone bundled Node before PATH lookup', () => { + const start = rustConfig.indexOf('pub fn check_node()') + const pathLookup = rustConfig.indexOf('let node_path = find_node_path', start) + const bundledLookup = rustConfig.indexOf('standalone_bundled_node_bin(&cli_path)', start) + + assert.ok(start >= 0, 'check_node must exist') + assert.ok(bundledLookup > start, 'standalone bundled Node lookup must exist') + assert.ok(pathLookup > bundledLookup, 'bundled Node lookup must run before PATH lookup') + assert.match(rustConfig, /"standalone-bundled"/) +}) + +test('Node 22.19 fallback is gated by OpenClaw 2026.6.5 or newer', () => { + assert.match(devApi, /OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR = '2026\.6\.5'/) + assert.match(devApi, /OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME = '>=22\.19\.0'/) + assert.match(devApi, /versionGe\(baseVersion\(installedVersion\), OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR\)/) + assert.doesNotMatch(devApi, /DEFAULT_OPENCLAW_NODE_REQUIREMENT/) + + assert.match(rustConfig, /OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR: &str = "2026\.6\.5"/) + assert.match(rustConfig, /OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME: &str = ">=22\.19\.0"/) + assert.match(rustConfig, /openclaw_version_requires_node_22_19/) +}) diff --git a/tests/patch-gateway-origins.test.js b/tests/patch-gateway-origins.test.js new file mode 100644 index 0000000..275d426 --- /dev/null +++ b/tests/patch-gateway-origins.test.js @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const devApi = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), 'utf8') +const pairing = readFileSync(new URL('../src-tauri/src/commands/pairing.rs', import.meta.url), 'utf8') + +test('patchGatewayOrigins writes only allowedOrigins through merge path', () => { + const start = devApi.indexOf('function patchGatewayOrigins()') + const end = devApi.indexOf('function readOpenclawConfigOptional()', start) + const fn = start >= 0 && end > start ? devApi.slice(start, end) : '' + + assert.ok(fn, 'patchGatewayOrigins must exist') + assert.match(fn, /只写入 allowedOrigins 增量/) + assert.match(fn, /const partial = \{\s*gateway: \{\s*controlUi: \{\s*allowedOrigins: merged,/s) + assert.match(fn, /mergeConfigsPreservingFields\(latest, partial\)/) + assert.doesNotMatch(fn, /writeOpenclawConfigFile\(config\)/) +}) + +test('patch_gateway_origins writes only allowedOrigins patch in Rust', () => { + const start = pairing.indexOf('fn patch_gateway_origins()') + const end = pairing.indexOf('#[tauri::command]', start) + const fn = start >= 0 && end > start ? pairing.slice(start, end) : '' + + assert.ok(fn, 'patch_gateway_origins must exist') + assert.match(fn, /只写入 allowedOrigins 增量/) + assert.match(fn, /let patch = serde_json::json!\(\{/) + assert.match(fn, /save_openclaw_json\(&patch\)/) +})