mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-07 05:42:53 +08:00
feat: 兼容 OpenClaw v2026.3.7 (SecretRef token, caps tool-events, 认证错误处理, Gateway 可见终端)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
// 兼容新版 SecretRef:token 可能是 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'
|
||||
|
||||
Reference in New Issue
Block a user