feat: 兼容 OpenClaw v2026.3.7 (SecretRef token, caps tool-events, 认证错误处理, Gateway 可见终端)

This commit is contained in:
晴天
2026-03-09 00:23:44 +08:00
parent b904fb2398
commit 69160c06f4
6 changed files with 96 additions and 51 deletions

View File

@@ -126,7 +126,7 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
},
"role": "operator",
"scopes": SCOPES,
"caps": [],
"caps": ["tool-events"],
"auth": { "token": gateway_token },
"device": {
"id": device_id,

View File

@@ -357,7 +357,9 @@ mod platform {
}
}
/// 以前台模式 spawn Gateway(不需要管理员权限)
const GATEWAY_WINDOW_TITLE: &str = "OpenClaw Gateway";
/// 在可见终端窗口中启动 Gateway用户可直接看到输出
pub async fn start_service_impl(_label: &str) -> Result<(), String> {
if !is_cli_installed() {
return Err(
@@ -369,29 +371,20 @@ mod platform {
return Ok(());
}
let log_dir = dirs::home_dir()
.unwrap_or_default()
.join(".openclaw")
.join("logs");
std::fs::create_dir_all(&log_dir).ok();
let enhanced = crate::commands::enhanced_path();
let stdout_log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_dir.join("gateway.log"))
.map_err(|e| format!("创建日志文件失败: {e}"))?;
// 用 cmd /c start 打开新的可见终端窗口运行 Gateway
// 父 cmd 用 CREATE_NO_WINDOW 避免自身闪窗,子窗口由 start 创建
const CREATE_NO_WINDOW: u32 = 0x08000000;
let start_cmd = format!(
"start \"{}\" cmd /k openclaw gateway",
GATEWAY_WINDOW_TITLE
);
let stderr_log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_dir.join("gateway.err.log"))
.map_err(|e| format!("创建错误日志文件失败: {e}"))?;
crate::utils::openclaw_command_async()
.arg("gateway")
.stdin(std::process::Stdio::null())
.stdout(stdout_log)
.stderr(stderr_log)
std::process::Command::new("cmd")
.args(["/c", &start_cmd])
.env("PATH", &enhanced)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
@@ -401,30 +394,39 @@ mod platform {
return Ok(());
}
}
Err("Gateway 启动超时,请检查日志".into())
Err("Gateway 启动超时,请检查终端窗口中的错误信息".into())
}
/// 关闭 Gateway 终端窗口
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
// 先尝试优雅停止
let _ = crate::utils::openclaw_command_async()
.args(["gateway", "stop"])
.output()
.await;
if check_service_status(0, "").0 {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = TokioCommand::new("cmd")
.args([
"/c",
"taskkill",
"/f",
"/im",
"node.exe",
"/fi",
"WINDOWTITLE eq openclaw*",
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await;
// 等一下看是否停了
for _ in 0..5 {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if !check_service_status(0, "").0 {
// 关闭残留终端窗口
let _ = TokioCommand::new("cmd")
.args(["/c", "taskkill", "/fi", &format!("WINDOWTITLE eq {}", GATEWAY_WINDOW_TITLE)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await;
return Ok(());
}
}
// 强制关闭终端窗口(会同时杀掉其中的 node 进程)
let _ = TokioCommand::new("cmd")
.args(["/c", "taskkill", "/f", "/fi", &format!("WINDOWTITLE eq {}", GATEWAY_WINDOW_TITLE)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await;
Ok(())
}

View File

@@ -152,6 +152,20 @@ pub fn run() {
update::rollback_frontend_update,
update::get_update_status,
])
.run(tauri::generate_context!())
.expect("启动 ClawPanel 失败");
.build(tauri::generate_context!())
.expect("启动 ClawPanel 失败")
.run(|_app, event| {
if let tauri::RunEvent::Exit = event {
#[cfg(target_os = "windows")]
{
// 退出时关闭 Gateway 终端窗口
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("cmd")
.args(["/c", "taskkill", "/fi", "WINDOWTITLE eq OpenClaw Gateway"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
});
}

View File

@@ -46,6 +46,7 @@ export class WsClient {
this._challengeTimer = null
this._wsId = 0
this._autoPairAttempts = 0
this._serverVersion = null
}
get connected() { return this._connected }
@@ -53,6 +54,7 @@ export class WsClient {
get snapshot() { return this._snapshot }
get hello() { return this._hello }
get sessionKey() { return this._sessionKey }
get serverVersion() { return this._serverVersion }
onStatusChange(fn) {
this._statusListeners.push(fn)
@@ -132,8 +134,8 @@ export class WsClient {
if (wsId !== this._wsId) return
this._ws = null
this._clearChallengeTimer()
if (e.code === 4001) {
this._setConnected(false, 'auth_failed', 'Token 认证失败')
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
this._intentionalClose = true
this._flushPending()
return
@@ -265,6 +267,7 @@ export class WsClient {
this._autoPairAttempts = 0
this._hello = payload || null
this._snapshot = payload?.snapshot || null
this._serverVersion = payload?.serverVersion || null
const defaults = this._snapshot?.sessionDefaults
if (defaults?.mainSessionKey) {
this._sessionKey = defaults.mainSessionKey

View File

@@ -97,7 +97,8 @@ async function loadDebugInfo(page) {
// 设备密钥检测(需要等配置加载完成)
try {
const token = info.config?.gateway?.auth?.token || ''
const rawToken = info.config?.gateway?.auth?.token
const token = (typeof rawToken === 'string') ? rawToken : ''
info.connectFrame = await api.createConnectFrame('test-nonce', token)
} catch (e) {
info.connectFrameError = String(e)
@@ -172,7 +173,7 @@ function renderDebugInfo(el, info) {
const gw = info.config.gateway || {}
html += `<table class="debug-table">
<tr><td>gateway.port</td><td>${gw.port || '(未设置)'}</td></tr>
<tr><td>gateway.auth.token</td><td>${gw.auth?.token ? `${statusIcon('ok')} 已设置` : `${statusIcon('warn')} 未设置`}</td></tr>
<tr><td>gateway.auth.token</td><td>${gw.auth?.token ? `${statusIcon('ok')} 已设置${typeof gw.auth.token === 'object' ? ' (SecretRef)' : ''}` : `${statusIcon('warn')} 未设置`}</td></tr>
<tr><td>gateway.enabled</td><td>${gw.enabled !== false ? statusIcon('ok') : statusIcon('err')}</td></tr>
<tr><td>gateway.mode</td><td>${gw.mode || 'local'}</td></tr>
</table>`
@@ -234,6 +235,8 @@ function renderDebugInfo(el, info) {
}
if (info.config && !info.config.gateway?.auth?.token) {
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} Gateway token 未设置(本地开发可选,生产环境建议设置)</li>`
} else if (info.config && typeof info.config.gateway?.auth?.token === 'object') {
html += `<li style="margin-bottom:6px">${statusIcon('ok')} Gateway token 通过环境变量/引用配置SecretRef</li>`
}
if (info.connectFrameError) {
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} 设备密钥生成失败,请检查 Rust 后端日志</li>`
@@ -292,7 +295,8 @@ function testWebSocket(page) {
// 读取配置
api.readOpenclawConfig().then(config => {
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
const rawToken = config?.gateway?.auth?.token
const token = (typeof rawToken === 'string') ? rawToken : ''
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
@@ -521,7 +525,8 @@ async function fixPairing(page) {
addLog(`${icon('plug', 14)} 测试 WebSocket 连接...`)
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
const rawToken = config?.gateway?.auth?.token
const token = (typeof rawToken === 'string') ? rawToken : ''
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`

View File

@@ -4,6 +4,21 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
// 兼容新版 SecretReftoken 可能是 string 或 { $env: "VAR" } / { $ref: "x/y" }
function _tokenDisplayStr(token) {
if (!token) return ''
if (typeof token === 'string') return token
if (typeof token === 'object') {
if (token.$env) return `\$env:${token.$env}`
if (token.$ref) return `\$ref:${token.$ref}`
return JSON.stringify(token)
}
return String(token)
}
function _isSecretRef(token) {
return token && typeof token === 'object' && ('$env' in token || '$ref' in token)
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
@@ -27,7 +42,7 @@ export async function render() {
</div>
`
const state = { config: null }
const state = { config: null, _origToken: null }
// 非阻塞:先返回 DOM后台加载数据
loadConfig(page, state)
page.querySelector('#btn-save-gw').onclick = async () => {
@@ -50,6 +65,7 @@ async function loadConfig(page, state) {
const el = page.querySelector('#gateway-config')
try {
state.config = await api.readOpenclawConfig()
state._origToken = state.config?.gateway?.auth?.token ?? null
renderConfig(page, state)
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:20px">加载配置失败: ' + e + '</div>'
@@ -166,10 +182,10 @@ function renderConfig(page, state) {
<div class="form-group" id="gw-auth-token-group" style="${gw.auth?.mode === 'password' ? 'display:none' : ''}">
<label class="form-label">访问密钥Token</label>
<div style="display:flex;gap:8px">
<input class="form-input" id="gw-token" type="password" value="${gw.auth?.token || gw.authToken || ''}" placeholder="不设置则任何人都能调用" style="flex:1">
<input class="form-input" id="gw-token" type="password" value="${_tokenDisplayStr(gw.auth?.token || gw.authToken)}" placeholder="不设置则任何人都能调用" style="flex:1" ${_isSecretRef(gw.auth?.token) ? 'readonly' : ''}>
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">显示</button>
</div>
<div class="form-hint">设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置</div>
<div class="form-hint">${_isSecretRef(gw.auth?.token) ? '当前 Token 通过环境变量/引用配置,如需改为明文请清空后输入' : '设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置'}</div>
</div>
<div class="form-group" id="gw-auth-password-group" style="${gw.auth?.mode === 'password' ? '' : 'display:none'}">
<label class="form-label">密码</label>
@@ -316,9 +332,14 @@ async function saveConfig(page, state) {
const authPassword = page.querySelector('#gw-password')?.value || ''
const tailscaleAddr = page.querySelector('#gw-tailscale')?.value || ''
// 兼容 SecretRef如果用户没改 token 显示值,保留原始对象
let resolvedToken = authToken
if (_isSecretRef(state._origToken) && authToken === _tokenDisplayStr(state._origToken)) {
resolvedToken = state._origToken
}
const auth = authMode === 'password'
? { mode: 'password', password: authPassword }
: authToken ? { mode: 'token', token: authToken } : {}
: resolvedToken ? { mode: 'token', token: resolvedToken } : {}
const toolsProfile = page.querySelector('input[name="gw-tools-profile"]:checked')?.value || 'full'
const sessionsVisibility = page.querySelector('#gw-sessions-visibility')?.value || 'all'