Files
clawpanel/src-tauri/src/commands/device.rs
晴天 7d75486a53 feat(gateway): surface negotiated handshake protocol version in UI
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.
2026-05-16 13:02:08 +08:00

174 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 设备密钥管理 + Gateway connect 握手签名
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
const DEVICE_KEY_FILE: &str = "clawpanel-device-key.json";
const SCOPES: &[&str] = &[
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
];
/// 获取或生成设备密钥
pub(crate) fn get_or_create_key() -> Result<(String, String, SigningKey), String> {
let dir = super::openclaw_dir();
let path = dir.join(DEVICE_KEY_FILE);
if path.exists() {
let content = fs::read_to_string(&path).map_err(|e| format!("读取设备密钥失败: {e}"))?;
let json: Value =
serde_json::from_str(&content).map_err(|e| format!("解析设备密钥失败: {e}"))?;
let device_id = json["deviceId"].as_str().unwrap_or("").to_string();
let pub_b64 = json["publicKey"].as_str().unwrap_or("").to_string();
let secret_hex = json["secretKey"].as_str().unwrap_or("");
let secret_bytes = hex::decode(secret_hex).map_err(|e| format!("解码密钥失败: {e}"))?;
if secret_bytes.len() != 32 {
return Err("密钥长度错误".into());
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&secret_bytes);
let signing_key = SigningKey::from_bytes(&key_bytes);
return Ok((device_id, pub_b64, signing_key));
}
// 生成新密钥
let mut rng = rand::thread_rng();
let signing_key = SigningKey::generate(&mut rng);
let verifying_key: VerifyingKey = (&signing_key).into();
let pub_bytes = verifying_key.to_bytes();
let device_id = {
let mut hasher = Sha256::new();
hasher.update(pub_bytes);
hex::encode(hasher.finalize())
};
let pub_b64 = base64_url_encode(&pub_bytes);
let secret_hex = hex::encode(signing_key.to_bytes());
let json = serde_json::json!({
"deviceId": device_id,
"publicKey": pub_b64,
"secretKey": secret_hex,
});
let _ = fs::create_dir_all(&dir);
fs::write(&path, serde_json::to_string_pretty(&json).unwrap())
.map_err(|e| format!("保存设备密钥失败: {e}"))?;
Ok((device_id, pub_b64, signing_key))
}
/// base64url 编码(无 padding
fn base64_url_encode(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
/// hex 编码ed25519_dalek 不自带 hex
mod hex {
pub fn encode(data: impl AsRef<[u8]>) -> String {
data.as_ref().iter().map(|b| format!("{b:02x}")).collect()
}
pub fn decode(s: &str) -> Result<Vec<u8>, String> {
if !s.len().is_multiple_of(2) {
return Err("奇数长度".into());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}
}
/// 生成 Gateway connect 帧(含 Ed25519 签名)
/// gateway_token: token 模式认证凭据(可为空)
/// gateway_password: password 模式认证凭据(可为空,新增)
#[tauri::command]
pub fn create_connect_frame(
nonce: String,
gateway_token: String,
gateway_password: Option<String>,
) -> Result<Value, String> {
let (device_id, pub_b64, signing_key) = get_or_create_key()?;
let signed_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let platform = std::env::consts::OS; // "windows" | "macos" | "linux"
let device_family = "desktop";
// v3 签名 payload 中 token 字段:优先 token其次 password最后空串
let auth_secret = if !gateway_token.is_empty() {
&gateway_token
} else {
gateway_password.as_deref().unwrap_or("")
};
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
//
// ⚠️ 注意:这里的 `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}"
);
let signature = signing_key.sign(payload_str.as_bytes());
let sig_b64 = base64_url_encode(&signature.to_bytes());
// 构建 auth 对象:根据有无 token/password 选择填充字段
let password = gateway_password.unwrap_or_default();
let auth = if !gateway_token.is_empty() {
serde_json::json!({ "token": gateway_token })
} else if !password.is_empty() {
serde_json::json!({ "password": password })
} else {
serde_json::json!({})
};
let frame = serde_json::json!({
"type": "req",
"id": format!("connect-{:08x}-{:04x}", signed_at as u32, rand::random::<u16>()),
"method": "connect",
"params": {
// 协议握手范围声明:下限 3 用于继续兼容历史内核,上限 4 启用新版增量 delta 协议。
"minProtocol": 3,
"maxProtocol": 4,
"client": {
"id": "openclaw-control-ui",
"version": env!("CARGO_PKG_VERSION"),
"platform": platform,
"deviceFamily": device_family,
"mode": "ui"
},
"role": "operator",
"scopes": SCOPES,
"caps": ["tool-events"],
"auth": auth,
"device": {
"id": device_id,
"publicKey": pub_b64,
"signedAt": signed_at as u64,
"nonce": nonce,
"signature": sig_b64,
},
"locale": "zh-CN",
"userAgent": format!("ClawPanel/{}", env!("CARGO_PKG_VERSION")),
}
});
Ok(frame)
}