From 7d75486a53af486adbfdd8589e1427276b39b567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 16 May 2026 13:02:08 +0800 Subject: [PATCH] feat(gateway): surface negotiated handshake protocol version in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users have reported confusion about "when will ClawPanel update its gateway protocol to v4". This is actually a misreading: ClawPanel v0.15+ already advertises `minProtocol=3, maxProtocol=4` in its connect frame, and negotiates v4 transparently when the kernel is >= 2026.5.12. The `v3|` prefix users were seeing in dev-api.js is the device signature payload string schema version, which is a completely separate concept from the handshake protocol version. Make this visible and unambiguous: UI - Add a "Proto v4" badge next to the Gateway service name in /services once the WS handshake succeeds, with a tooltip explaining that this is the WS handshake protocol version (not the device signature payload v3 format). - Add the same protocol info to the WebSocket row in /chat-debug. API - WsClient now exposes `negotiatedProtocol` which prefers the explicit field from the hello payload (`protocol` / `protocolVersion` / `negotiatedProtocol`) and falls back to inferring from serverVersion: kernels >= 2026.5.12 are reported as v4, older as v3. This matches the panel's advertised range of [3, 4]. - KernelSnapshot grows a `protocol` field so feature gates and UIs that already consume the snapshot can read it without touching wsClient. Comments - Expand the KERNEL_TARGET comment in feature-catalog.js to spell out the two-distinct-version-numbers rule explicitly. - Add matching clarifying comments next to the `v3|...` payload string in both scripts/dev-api.js and src-tauri/src/commands/device.rs, so the next reader does not confuse payload schema with handshake. ## Verification - node --check on all touched JS files - npm run build - cargo fmt --check && cargo check (clippy errors that surface are pre-existing debt in config.rs, untouched here) - Playwright /services: mock wsClient state, observe `协议 v4` badge rendered with `rgba(99, 102, 241, 0.1)` background and accent color, for both the explicit-protocol path and the version-inferred path. --- scripts/dev-api.js | 4 ++++ src-tauri/src/commands/device.rs | 7 ++++++- src/lib/feature-catalog.js | 7 ++++++- src/lib/kernel.js | 3 +++ src/lib/ws-client.js | 29 +++++++++++++++++++++++++++++ src/locales/modules/services.js | 3 +++ src/pages/chat-debug.js | 6 +++++- src/pages/services.js | 6 +++++- src/style/pages.css | 15 +++++++++++++++ 9 files changed, 76 insertions(+), 4 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index a4ce575..35916e5 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -6484,6 +6484,10 @@ const handlers = { const signedAt = Date.now() const platform = process.platform === 'darwin' ? 'macos' : process.platform const scopesStr = SCOPES.join(',') + // 设备签名 payload 字符串格式:以 `v3|` 开头标识 payload schema 版本(device signature payload format = v3)。 + // 注意:这里的 `v3` 是 **设备签名 payload 字符串的 schema 版本**,与下面 `minProtocol/maxProtocol` 协商的 + // **Gateway WebSocket 握手帧协议版本**(v3 / v4)是两套独立的版本号。即使在 v4 握手协议下, + // 签名 payload 仍以 `v3|` 开头,两者互不影响。详见 src/lib/feature-catalog.js KERNEL_TARGET 注释。 const payloadStr = `v3|${deviceId}|openclaw-control-ui|ui|operator|${scopesStr}|${signedAt}|${gatewayToken || ''}|${nonce || ''}|${platform}|desktop` const signature = crypto.sign(null, Buffer.from(payloadStr), privateKey) const sigB64 = Buffer.from(signature).toString('base64url') diff --git a/src-tauri/src/commands/device.rs b/src-tauri/src/commands/device.rs index a953866..57990d7 100644 --- a/src-tauri/src/commands/device.rs +++ b/src-tauri/src/commands/device.rs @@ -115,7 +115,12 @@ pub fn create_connect_frame( let scopes_str = SCOPES.join(","); // v3 格式:v3|deviceId|clientId|clientMode|role|scopes|signedAt|token|nonce|platform|deviceFamily // 使用 openclaw-control-ui + ui 模式,使 Gateway 识别为 Control UI 客户端, - // 本地连接时触发静默自动配对(shouldAllowSilentLocalPairing = true) + // 本地连接时触发静默自动配对(shouldAllowSilentLocalPairing = true)。 + // + // ⚠️ 注意:这里的 `v3|` 前缀是 **device signature payload 字符串的 schema 版本**, + // 与下面 `params.minProtocol/maxProtocol` 协商的 **Gateway WebSocket 握手帧协议版本** + // (v3 / v4)是两套独立的版本号。即使在 v4 握手协议下,签名 payload 仍以 `v3|` 开头。 + // 详见 src/lib/feature-catalog.js KERNEL_TARGET 注释。 let payload_str = format!( "v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{auth_secret}|{nonce}|{platform}|{device_family}" ); diff --git a/src/lib/feature-catalog.js b/src/lib/feature-catalog.js index c257c98..c5e7dfe 100644 --- a/src/lib/feature-catalog.js +++ b/src/lib/feature-catalog.js @@ -190,7 +190,12 @@ export const KERNEL_FLOOR = { export const KERNEL_TARGET = { openclaw: { // 内核协议在 5.12 升级到 v4(MIN_CLIENT_PROTOCOL_VERSION=4,新增增量 chat delta payloads), - // 面板通过 connect frame `[minProtocol=3, maxProtocol=4]` 同时兼容新旧内核 + // 面板通过 connect frame `[minProtocol=3, maxProtocol=4]` 同时兼容新旧内核。 + // + // ⚠️ 警告:这里的"协议 v3/v4"指 **Gateway WebSocket 握手帧协议版本**。 + // 不要与 dev-api.js 中设备签名 payload 字符串前缀 `v3|deviceId|...` 混淆—— + // 后者是 **device signature payload 字符串格式版本**,两者完全独立、互不相关。 + // 即使在 v4 握手协议下,签名 payload 字符串仍以 `v3|` 开头(这是 payload schema 版本)。 official: '2026.5.12', chinese: '2026.5.12-zh.2', }, diff --git a/src/lib/kernel.js b/src/lib/kernel.js index 8e3992f..6dced96 100644 --- a/src/lib/kernel.js +++ b/src/lib/kernel.js @@ -29,6 +29,7 @@ const _listeners = [] * @property {boolean} isLatest 是否 >= target * @property {Set} features 当前启用的特性 id 集合 * @property {string} versionLabel 人类可读的版本显示,例如 "2026.5.6 汉化" + * @property {number|null} protocol 握手协商出的 Gateway WS 协议版本 (3 或 4),未握手为 null */ /** @@ -104,6 +105,7 @@ export function buildSnapshot(engineId, version) { versionLabel: version ? `${versionBase}${variant === 'chinese' ? ' 汉化' : ''}` : '', + protocol: wsClient.negotiatedProtocol, } } @@ -186,6 +188,7 @@ function refresh() { || _snapshot.engine !== next.engine || _snapshot.version !== next.version || _snapshot.features.size !== next.features.size + || _snapshot.protocol !== next.protocol _snapshot = next if (changed) { _listeners.forEach(fn => { diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 40146c9..ba985fd 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -128,6 +128,34 @@ export class WsClient { get hello() { return this._hello } get sessionKey() { return this._sessionKey } get serverVersion() { return this._serverVersion } + /** + * 当前 Gateway 与 ClawPanel 协商出的握手协议版本 (3 或 4)。 + * + * 注意:这里的 "协议版本" 指 Gateway WebSocket 握手帧协议 (kernel ws frame protocol), + * 不要与设备签名 payload 的前缀 `v3|deviceId|...` 混淆,后者是 device-signature + * payload 字符串格式版本,两者完全独立。 + * + * 取值优先级: + * 1. hello payload 中显式回传的 protocol / protocolVersion / negotiatedProtocol 字段 + * 2. 按 serverVersion 推断:OpenClaw 内核 >= 2026.5.12 → v4,否则 v3 + * (ClawPanel 客户端永远声明 minProtocol=3, maxProtocol=4,所以协商只会是 3 或 4) + * + * 未握手时返回 null。 + */ + get negotiatedProtocol() { + if (!this._hello) return null + const explicit = this._hello.protocol ?? this._hello.protocolVersion ?? this._hello.negotiatedProtocol + if (Number.isFinite(explicit)) return explicit + const ver = this._serverVersion + if (!ver) return null + const base = String(ver).replace(/-.*$/, '') + const parts = base.split('.').map(n => Number(n)) + if (parts.length >= 3 && parts.every(Number.isFinite)) { + const [y, mo, d] = parts + if (y > 2026 || (y === 2026 && (mo > 5 || (mo === 5 && d >= 12)))) return 4 + } + return 3 + } get reconnectState() { return this._reconnectState } get reconnectAttempts() { return this._reconnectAttempts } get lastConnectedAt() { return this._lastConnectedAt } @@ -145,6 +173,7 @@ export class WsClient { reconnectAttempts: this._reconnectAttempts, reconnectState: this._reconnectState, serverVersion: this._serverVersion, + negotiatedProtocol: this.negotiatedProtocol, missedHeartbeats: this._missedHeartbeats, pendingReconnect: this._pendingReconnect, } diff --git a/src/locales/modules/services.js b/src/locales/modules/services.js index 7483365..5f1eece 100644 --- a/src/locales/modules/services.js +++ b/src/locales/modules/services.js @@ -202,4 +202,7 @@ export default { cleanupConfirmUninstallConfig: _('确定要卸载所有 OpenClaw 并删除配置目录吗?\n⚠️ 这将删除所有配置、Agent 数据和会话记录,无法恢复!', 'Uninstall all OpenClaw AND delete config directory?\n⚠️ This will delete all config, agent data, and session history permanently!', '確定要卸載所有 OpenClaw 並刪除設定目錄嗎?\n⚠️ 這將刪除所有設定、Agent 資料和會話記錄,無法恢復!'), cleanupUninstalling: _('正在卸载,请稍候...', 'Uninstalling, please wait...', '正在卸載,請稍候...'), cleanupUninstallFailed: _('卸载失败', 'Uninstall failed', '卸載失敗'), + // 协议版本徽章 (Gateway 卡片 + 诊断页) + protocolBadge: _('协议 v{proto}', 'Proto v{proto}', '協議 v{proto}'), + protocolBadgeTitle: _('当前 Gateway 与 ClawPanel 协商出的 WebSocket 握手协议版本 (与设备签名 payload 的 v3 格式无关)', 'WebSocket handshake protocol version negotiated with Gateway (independent of the device signature payload v3 format)', '當前 Gateway 與 ClawPanel 協商出的 WebSocket 握手協議版本 (與設備簽名 payload 的 v3 格式無關)'), } diff --git a/src/pages/chat-debug.js b/src/pages/chat-debug.js index cbcef99..b361f0f 100644 --- a/src/pages/chat-debug.js +++ b/src/pages/chat-debug.js @@ -212,7 +212,11 @@ async function runScan(page) { // 5. WebSocket 连接 const wsOk = wsClient.connected && wsClient.gatewayReady - items.push({ label: 'WebSocket', ok: wsOk, detail: wsOk ? (wsClient.serverVersion ? `Gateway ${wsClient.serverVersion}` : t('chatDebug.connected')) : t('chatDebug.scanWsDown') }) + const proto = wsOk ? wsClient.negotiatedProtocol : null + const wsDetail = wsOk + ? `${wsClient.serverVersion ? `Gateway ${wsClient.serverVersion}` : t('chatDebug.connected')}${proto ? ` · ${t('services.protocolBadge', { proto })}` : ''}` + : t('chatDebug.scanWsDown') + items.push({ label: 'WebSocket', ok: wsOk, detail: wsDetail }) if (!wsOk && gwRunning) fixable = true // 6. Token 配置 diff --git a/src/pages/services.js b/src/pages/services.js index 38c4ca4..860c5e0 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -11,6 +11,7 @@ import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGateway import { diagnoseInstallError } from '../lib/error-diagnosis.js' import { icon, statusIcon } from '../lib/icons.js' import { t } from '../lib/i18n.js' +import { wsClient } from '../lib/ws-client.js' // HTML 转义,防止 XSS function escapeHtml(str) { @@ -476,13 +477,16 @@ function renderServices(container, services) { const cliMissing = gw.cli_installed === false const foreignGateway = !cliMissing && isForeignGatewayService(gw) const foreignPidText = gw.pid ? ` (PID: ${gw.pid})` : '' + // 协议版本徽章(仅在 Gateway 跑起来并完成 WS 握手时显示) + const proto = wsClient.gatewayReady ? wsClient.negotiatedProtocol : null + const protoBadge = proto ? `${t('services.protocolBadge', { proto })}` : '' html += `
-
${gw.label}
+
${gw.label}${protoBadge}
${cliMissing ? t('services.cliNotInstalled') : foreignGateway diff --git a/src/style/pages.css b/src/style/pages.css index 6e4ce33..554f569 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -218,6 +218,21 @@ font-size: var(--font-size-md); } +.service-name .proto-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + border-radius: 4px; + background: var(--accent-muted); + color: var(--accent); + border: 1px solid var(--accent-muted); + vertical-align: middle; + cursor: help; +} + .service-desc { font-size: var(--font-size-xs); color: var(--text-tertiary);