From ba5785036ac1ecfd68a232d7dc79e6bc99e32c89 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 24 Aug 2026 19:26:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=87=B3=E5=AE=A2=E6=88=B7=E7=AB=AF=20+=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=8D=B8=E8=BD=BD=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复(客户端鉴权): - remotedevice 服务端移除授权状态存储(authorized map/SetAuthorized/handleDeviceAuth) - DeviceMeta.Authorized 改为设备 hello 自报,服务端仅透传展示 - device_ctl_* 工具移除服务端授权检查,无条件转发,设备端自行决定是否执行 - 共享设备桥库 Bridge 新增本地 authorized 状态,未授权收到 cmd 直接拒绝 - waiter: --device-authorized / device_authorized 配置控制本地授权 - GUI: 授权存 gui-prefs 本地文件;设备页仅本机可切换开关 - webui /device/auth 旧路径返回 410 Gone - 根因:agent 可经 config_set 篡改服务端授权配置自行授权设备 插件管理强化: - 内置插件禁止卸载(IsBuiltinPlugin + 409),外部插件卸载即时生效 - 卸载不存在插件返回 404;移除误导性 reload_required 提示 - webui 插件路由:名称白名单校验防路径穿越、保留字路径保护 --- .gitignore | 1 + cmd/gui/main.js | 147 +++++-------- cmd/gui/preload.js | 2 + cmd/gui/renderer/app.js | 63 ++++-- cmd/waiter/config.go | 15 +- cmd/waiter/main.go | 6 +- internal/devicebridge/client/binary.go | 2 +- internal/devicebridge/client/bridge.go | 117 +++++++---- internal/devicebridge/client/cmdrouter.go | 2 +- internal/devicebridge/client/protocol.go | 8 +- internal/devicebridge/client/transport.go | 2 +- internal/plugin/registry.go | 20 +- internal/plugins/pluginmgr/plugin.go | 39 ++-- internal/plugins/remotedevice/binary_test.go | 26 +-- internal/plugins/remotedevice/device.go | 36 +--- internal/plugins/remotedevice/plugin.go | 55 +---- internal/plugins/remotedevice/registry.go | 71 ++----- internal/plugins/webui/dashboard.html | 60 ++++-- internal/plugins/webui/handler.go | 114 +++++++--- internal/plugins/webui/handler_plugin_test.go | 195 ++++++++++++++++++ internal/sdk/plugin.go | 18 +- 21 files changed, 616 insertions(+), 383 deletions(-) create mode 100644 internal/plugins/webui/handler_plugin_test.go diff --git a/.gitignore b/.gitignore index 0b8baf5..a8c7c18 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ terminal_locked_log.txt dist/ .pi-glla/ .omo/ +.pi/ diff --git a/cmd/gui/main.js b/cmd/gui/main.js index b74c00e..8b7f0cb 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -1221,6 +1221,18 @@ function onDeviceMsg(msg) { ); const op = msg.op || ""; if (op === "cmd") { + // 客户端鉴权:未授权时拒绝执行(授权状态存本地 gui-prefs,服务端无法篡改) + if (!guiPrefs?.deviceBridge?.authorized) { + console.log("[device-bridge] cmd rejected (unauthorized): req=" + (msg.req_id || "")); + sendCmdResult({ + op: "cmd_result", + req_id: msg.req_id || msg.id || "", + device_id: deviceBridgeId, + status: "error", + error: "设备未授权:请在设备 GUI 设置页开启远程控制授权", + }); + return; + } const command = msg.command || msg.cmd || ""; const reqId = msg.req_id || msg.id || ""; const cmdType = msg.cmd_type || "shell"; @@ -2057,6 +2069,7 @@ async function startDeviceBridge(cfg) { device_id: deviceBridgeId, name: "HomeAgent GUI", kind: "computer", + authorized: guiPrefs?.deviceBridge?.authorized || false, // 客户端自报授权状态 caps: [ "status", "cmdrun", @@ -2141,111 +2154,34 @@ function timeLeq(a, b) { return a <= b; } -// 执行授权开关(经 webui 反代 /api/v1/device/auth) +// 执行授权开关(客户端本地鉴权:写 gui-prefs.deviceBridge.authorized, +// 并重新 hello 同步自报状态到服务端展示。服务端不存储授权,agent 无法篡改。) async function setDeviceAuthorized(authorized) { try { - // URL + cookie 优先 authRule;否则从 connections.json 取当前连接(webui 反代) - let base = ""; - let cookie = ""; - if (authRule && authRule.url) { - base = authRule.url.replace(/\/+$/, ""); - cookie = - (authRule.cookie || "") + - (authRule.slSession ? "; " + authRule.slSession : ""); - } else { - try { - const conns = loadConnections(); - const cur = - conns.connections.find((c) => c.id === conns.currentId) || - conns.connections[0]; - if (cur && cur.url) { - base = cur.url.replace(/\/+$/, ""); - cookie = cur.cookie || ""; - } - } catch (e2) {} - } - if (!base || !cookie) { - console.log("[auth-schedule] no base/cookie, skip"); - return; - } - const url = base + "/api/v1/device/auth"; - const body = JSON.stringify({ - device_id: deviceBridgeId, - authorize: authorized, - }); - // 用 node https 直连(带 cookie + 跟随 302),不走 Electron session(避免 jar 会话差异) - const resp = await new Promise((resolve, reject) => { - const https = require("https"); - const u = new URL(url); - const req = https.request( - { - hostname: u.hostname, - port: u.port || 443, - path: u.pathname + u.search, - method: "POST", - rejectUnauthorized: false, - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", - Cookie: cookie, - "Content-Length": Buffer.byteLength(body), - }, + const prefs = loadGuiPrefs(); + prefs.deviceBridge = prefs.deviceBridge || {}; + prefs.deviceBridge.authorized = !!authorized; + saveGuiPrefs(prefs); + console.log("[auth] set local authorized=" + !!authorized); + // 若设备桥已连接,重新 hello 同步状态 + if (deviceBridge && deviceBridge.send && deviceBridgeId) { + deviceBridge.send({ + op: "hello", + device: { + device_id: deviceBridgeId, + name: "HomeAgent GUI", + kind: "computer", + authorized: !!authorized, + caps: [ + "status", "cmdrun", "deviceinfo", "cmdresult", + "computeruse", "screensee", "clipboardsee", "clipboardsue", + "speakeruse", "camerasue", "screensue", "omniparse", + ], }, - (res) => { - let buf = ""; - res.on("data", (c) => (buf += c)); - res.on("end", () => { - if ( - res.statusCode >= 300 && - res.statusCode < 400 && - res.headers.location - ) { - // 跟随 302 到登录域后重试一次 - const loc = res.headers.location; - const lurl = loc.startsWith("http") - ? loc - : new URL(loc, url).toString(); - const req2 = https.request( - { - ...u2opts(lurl), - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0", - Cookie: cookie, - "Content-Length": Buffer.byteLength(body), - }, - }, - (r2) => { - let b2 = ""; - r2.on("data", (c) => (b2 += c)); - r2.on("end", () => - resolve({ status: r2.statusCode, body: b2 }), - ); - }, - ); - req2.write(body); - req2.end(); - return; - } - resolve({ status: res.statusCode, body: buf }); - }); - }, - ); - req.on("error", reject); - req.write(body); - req.end(); - }); - console.log( - "[auth-schedule] set authorized=" + - authorized + - " -> HTTP " + - resp.status + - " " + - resp.body.slice(0, 60), - ); + }); + } } catch (e) { - console.error("[auth-schedule] set authorized failed: " + e.message); + console.error("[auth] set authorized failed: " + e.message); } } @@ -2540,6 +2476,7 @@ function loadGuiPrefs() { exitToTray: d.exitToTray === undefined ? true : !!d.exitToTray, deviceBridge: { enabled: !!db.enabled, + authorized: !!db.authorized, // 客户端本地授权(用户在设备上手动开启,服务端不存储) gateway: db.gateway || "", token: db.token || "", screensueDisplay: db.screensueDisplay || "0", @@ -2557,6 +2494,7 @@ function loadGuiPrefs() { exitToTray: true, deviceBridge: { enabled: true, + authorized: false, // 默认不授权,用户手动开启 gateway: "", token: "", screensueDisplay: "0", @@ -2629,6 +2567,7 @@ ipcMain.handle("device-bridge:get", () => { const db = p.deviceBridge || {}; return { enabled: !!db.enabled, + authorized: !!db.authorized, // 客户端本地授权状态 gateway: db.gateway || "", tokenSet: !!(db.token || ""), connected: !!deviceBridge, @@ -2641,6 +2580,12 @@ ipcMain.handle("device-bridge:get", () => { }; }); +// IPC:设置本机授权状态(客户端鉴权:仅写本地 prefs + 重发 hello,不经服务端) +ipcMain.handle("device-bridge:setAuthorized", (_, auth) => { + setDeviceAuthorized(!!auth); + return { authorized: !!auth }; +}); + // IPC:配置本机设备桥(开关+网关+token),保存并动态启停 ipcMain.handle("device-bridge:set", (_, cfg) => { const cur = loadGuiPrefs(); diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index aee5435..f446a35 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -44,6 +44,8 @@ contextBridge.exposeInMainWorld("homeagent", { deviceBridge: { get: () => ipcRenderer.invoke("device-bridge:get"), set: (cfg) => ipcRenderer.invoke("device-bridge:set", cfg), + setAuthorized: (auth) => + ipcRenderer.invoke("device-bridge:setAuthorized", auth), }, displays: { list: () => ipcRenderer.invoke("displays:list"), diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index df7f9ef..357cf1d 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -825,6 +825,28 @@ async function refreshAll() { state.selfDeviceId = dbinfo.deviceId; state.selfGateway = dbinfo.address || state.selfGateway; } + // 本机授权状态以客户端本地为准(服务端自报值仅展示) + if (typeof dbinfo.authorized === "boolean") { + var found = false; + for (var di = 0; di < state.devices.length; di++) { + if (state.devices[di].device_id === dbinfo.deviceId) { + state.devices[di].authorized = dbinfo.authorized; + found = true; + break; + } + } + if (!found && dbinfo.authorized) { + // 服务端列表未含本机(可能离线),仍展示本地状态 + state.devices.push({ + device_id: dbinfo.deviceId, + name: "HomeAgent GUI", + kind: "computer", + authorized: dbinfo.authorized, + online: false, + caps: [], + }); + } + } // 若尚未有设备列表且已启用设备桥但非 device 连接,尝试经设备桥网关拉取 if ( state.devices.length === 0 && @@ -5295,19 +5317,20 @@ function renderDevices() { auth + "" + escHtml(caps) + - "" + - (d.authorized - ? ' " + - __("已授权", "Yes") - : ' " + - __("未授权", "No")) + - ""; + ""; + // 客户端鉴权:只有本机设备可切换授权开关;其它设备的授权由其自身控制 + if (d.device_id === state.selfDeviceId) { + html += + '"; + } else { + html += '' + + __("由该设备自行控制", "Controlled by device itself") + ""; + } + html += ""; }); html += ""; } @@ -5379,11 +5402,15 @@ async function deviceRefresh() { async function deviceToggleAuth(deviceID, auth) { try { - await api("/device/auth", { - method: "POST", - body: JSON.stringify({ device_id: deviceID, authorize: auth }), - }); - toast(__("授权已更新", "Authorization updated")); + // 客户端鉴权:授权状态存在设备本地(gui-prefs),不经服务端,agent 无法篡改 + if (window.homeagent && window.homeagent.deviceBridge) { + await window.homeagent.deviceBridge.setAuthorized(auth); + } + toast( + __("本机授权已更新", "Local authorization updated") + " (" + + (auth ? __("已授权", "Yes") : __("未授权", "No")) + + ")", + ); deviceRefresh(); } catch (e) { toast(__("授权失败: ", "Auth failed: ") + e.message, true); diff --git a/cmd/waiter/config.go b/cmd/waiter/config.go index 69ddb45..f7a161b 100644 --- a/cmd/waiter/config.go +++ b/cmd/waiter/config.go @@ -16,13 +16,14 @@ type Connection struct { } type Config struct { - Socket string `yaml:"socket"` - Remote string `yaml:"remote"` - APIKey string `yaml:"api_key"` - Default string `yaml:"default"` - Connections []Connection `yaml:"connections,omitempty"` - DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890) - DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token + Socket string `yaml:"socket"` + Remote string `yaml:"remote"` + APIKey string `yaml:"api_key"` + Default string `yaml:"default"` + Connections []Connection `yaml:"connections,omitempty"` + DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890) + DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token + DeviceAuthorized bool `yaml:"device_authorized,omitempty"` // 客户端本地授权(用户手动开启,服务端无法篡改) } func (c *Config) Active() *Connection { diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index de99d35..602951a 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -83,6 +83,7 @@ func main() { say := flag.String("say", "", "deprecated alias of -chat") deviceGateway := flag.String("device", "", "remotedevice 网关地址(如 127.0.0.1:9890),启动设备桥") deviceToken := flag.String("device-token", "", "设备接入 token") + deviceAuthorized := flag.Bool("device-authorized", false, "客户端本地授权(允许远程操控本机;也可在 waiter.yaml 配 device_authorized: true)") testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue") testCapArgs := flag.String("test-cap-args", "", "测试能力的参数") flag.Parse() @@ -126,7 +127,10 @@ func main() { if err := startDeviceBridge(dg, dt); err != nil { printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err)) } else { - printlnC(colorGreen, "device bridge active: "+deviceBridgeID) + // 客户端本地授权:命令行 --device-authorized 或 waiter.yaml device_authorized + auth := *deviceAuthorized || cfg.DeviceAuthorized + deviceBridge.SetAuthorized(auth) + printlnC(colorGreen, "device bridge active: "+deviceBridgeID+" authorized="+fmt.Sprint(auth)) defer stopDeviceBridge() } } diff --git a/internal/devicebridge/client/binary.go b/internal/devicebridge/client/binary.go index c7f58c6..db45df3 100644 --- a/internal/devicebridge/client/binary.go +++ b/internal/devicebridge/client/binary.go @@ -98,4 +98,4 @@ func (da *DataAccumulator) ExceededLimit() bool { limit = 64 << 20 } return da.Got > limit -} \ No newline at end of file +} diff --git a/internal/devicebridge/client/bridge.go b/internal/devicebridge/client/bridge.go index b2cd08f..4a7f653 100644 --- a/internal/devicebridge/client/bridge.go +++ b/internal/devicebridge/client/bridge.go @@ -22,25 +22,28 @@ type DataHandler func(reqID, kind, mime string, data []byte) // Bridge 是设备桥客户端核心结构体。 // 管理 WebSocket 连接、消息路由、心跳保活和命令分发。 +// 授权状态由设备端本地存储(客户端鉴权),服务端不存储; +// 未授权时收到 cmd 直接拒绝执行并回执 error。 type Bridge struct { - mu sync.RWMutex - gateway string - token string - deviceID string - name string - kind string - caps []string - info map[string]interface{} + mu sync.RWMutex + gateway string + token string + deviceID string + name string + kind string + caps []string + info map[string]interface{} + authorized bool // 客户端本地授权状态(用户在设备上手动开启) - ws *wsConn - stopCh chan struct{} - doneCh chan struct{} - started bool + ws *wsConn + stopCh chan struct{} + doneCh chan struct{} + started bool // 回调 - cmdHandler CmdHandler - resultHandler CmdResultHandler - dataHandler DataHandler + cmdHandler CmdHandler + resultHandler CmdResultHandler + dataHandler DataHandler // 二进制数据聚合(服务端→设备,如 TTS 音频) speechAccum *speechBuffer @@ -85,19 +88,52 @@ func New(gateway, token, deviceID, name string, caps []string, info map[string]i } return &Bridge{ - gateway: gateway, - token: token, - deviceID: deviceID, - name: name, - kind: "computer", - caps: caps, - info: info, - stopCh: make(chan struct{}), - doneCh: make(chan struct{}), - pingInterval: 30 * time.Second, + gateway: gateway, + token: token, + deviceID: deviceID, + name: name, + kind: "computer", + caps: caps, + info: info, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + pingInterval: 30 * time.Second, } } +// SetAuthorized 设置客户端本地授权状态(用户在设备上手动开启)。 +// 授权后立即重新发送 hello 同步到服务端展示。 +func (b *Bridge) SetAuthorized(auth bool) { + b.mu.Lock() + b.authorized = auth + b.mu.Unlock() + // 重新 hello 同步状态 + b.mu.RLock() + ws := b.ws + connected := ws != nil && !ws.closed + b.mu.RUnlock() + if connected { + b.sendJSON(map[string]interface{}{ + "op": "hello", + "device": map[string]interface{}{ + "device_id": b.deviceID, + "name": b.name, + "kind": b.kind, + "caps": b.caps, + "info": b.info, + "authorized": auth, + }, + }) + } +} + +// Authorized 返回当前客户端本地授权状态。 +func (b *Bridge) Authorized() bool { + b.mu.RLock() + defer b.mu.RUnlock() + return b.authorized +} + // OnCmd 注册命令处理器。当收到 remotedevice 下发的 cmd 时调用。 func (b *Bridge) OnCmd(handler CmdHandler) { b.mu.Lock() @@ -149,15 +185,19 @@ func (b *Bridge) Start() error { b.ws = ws b.mu.Unlock() - // 发送 hello + // 发送 hello(含设备自报的授权状态,服务端仅展示不决策) + b.mu.RLock() + auth := b.authorized + b.mu.RUnlock() b.sendJSON(map[string]interface{}{ "op": "hello", "device": map[string]interface{}{ - "device_id": b.deviceID, - "name": b.name, - "kind": b.kind, - "caps": b.caps, - "info": b.info, + "device_id": b.deviceID, + "name": b.name, + "kind": b.kind, + "caps": b.caps, + "info": b.info, + "authorized": auth, }, }) @@ -382,12 +422,19 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) { if reqID == "" || command == "" { return } + // 客户端鉴权:未授权时拒绝执行(服务端不存储授权状态,无法被 agent 篡改) + b.mu.RLock() + auth := b.authorized + handler := b.cmdHandler + b.mu.RUnlock() + if !auth { + log.Printf("[devicebridge] cmd rejected (unauthorized) req=%s cmd=%s", reqID, truncateString(command, 60)) + b.SendResult(reqID, "error", "", "设备未授权:请在设备本机开启远程控制授权") + return + } // 记录日志 log.Printf("[devicebridge] cmd req=%s type=%s cmd=%s", reqID, cmdType, truncateString(command, 60)) - b.mu.RLock() - handler := b.cmdHandler - b.mu.RUnlock() if handler != nil { handler(reqID, command) } @@ -480,4 +527,4 @@ func truncateString(s string, n int) string { return s } return s[:n] + "..." -} \ No newline at end of file +} diff --git a/internal/devicebridge/client/cmdrouter.go b/internal/devicebridge/client/cmdrouter.go index 397e06b..c638464 100644 --- a/internal/devicebridge/client/cmdrouter.go +++ b/internal/devicebridge/client/cmdrouter.go @@ -117,4 +117,4 @@ func BaseResult(reqID, status, output, errMsg string) map[string]interface{} { func ResultJSON(res map[string]interface{}) string { b, _ := json.Marshal(res) return string(b) -} \ No newline at end of file +} diff --git a/internal/devicebridge/client/protocol.go b/internal/devicebridge/client/protocol.go index 28eaed6..c9915df 100644 --- a/internal/devicebridge/client/protocol.go +++ b/internal/devicebridge/client/protocol.go @@ -89,9 +89,9 @@ type StatusMsg struct { // EventMsg 设备主动上报事件 type EventMsg struct { - Op string `json:"op"` - DeviceID string `json:"device_id,omitempty"` - Type string `json:"type"` + Op string `json:"op"` + DeviceID string `json:"device_id,omitempty"` + Type string `json:"type"` Payload interface{} `json:"payload,omitempty"` } @@ -103,4 +103,4 @@ func mustJSON(v interface{}) []byte { return []byte("{}") } return b -} \ No newline at end of file +} diff --git a/internal/devicebridge/client/transport.go b/internal/devicebridge/client/transport.go index d899df8..8c956d5 100644 --- a/internal/devicebridge/client/transport.go +++ b/internal/devicebridge/client/transport.go @@ -357,4 +357,4 @@ func (w *wsConn) RemoteAddr() net.Addr { return w.conn.RemoteAddr() } return nil -} \ No newline at end of file +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index f3a36e6..b6bf3b9 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -112,7 +112,7 @@ func NewRegistry() *Registry { pluginAutoRestart: make(map[string]bool), sdkRefs: make(map[string]*sdk.PluginSDK), knownDisabled: make(map[string]bool), - pluginHashes: make(map[string]string), + pluginHashes: make(map[string]string), } } @@ -646,6 +646,24 @@ func (r *Registry) Disable(name string) error { // ---- PluginManager interface ---- +// PluginManager interface + +// IsBuiltinPlugin 判断插件是否为内置插件(有编译期工厂,由 init() 注册)。 +// 内置插件只能禁用/启用,不能卸载。 +func (r *Registry) IsBuiltinPlugin(name string) bool { + if r == nil { + return false + } + r.mu.RLock() + _, ok := r.factories[name] + r.mu.RUnlock() + if ok { + return true + } + _, ok = globalFactories.Load(name) + return ok +} + func (r *Registry) ListLoadedPlugins() []string { return r.List() } func (r *Registry) ListDisabledPlugins() []sdk.DisabledPluginInfo { diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index 7b5bd6f..b5ee84f 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -39,18 +39,18 @@ func platformBinary() (zipName, canonicalName string) { // validBinaries 是 .hmap 中所有可识别的文件入口(平台二进制或脚本)。 var validBinaries = map[string]bool{ - "plugin.so": true, + "plugin.so": true, "plugin.dylib": true, - "plugin.dll": true, - "main.lua": true, - "SKILL.md": true, + "plugin.dll": true, + "main.lua": true, + "SKILL.md": true, } // platformBinaries 是平台特定的二进制,bundle 模式下仅当前平台的被解压。 var platformBinaries = map[string]bool{ - "plugin.so": true, + "plugin.so": true, "plugin.dylib": true, - "plugin.dll": true, + "plugin.dll": true, } var downloadClient = &http.Client{ @@ -312,7 +312,16 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) { case http.MethodDelete: result, err := p.removePlugin(name) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + // 内置插件禁卸 → 409 Conflict;不存在 → 404;其余删除失败 → 500 + msg := err.Error() + switch { + case strings.Contains(msg, "built-in plugin"): + writeJSON(w, http.StatusConflict, map[string]interface{}{"error": msg, "name": name, "plugin_type": "builtin"}) + case strings.Contains(msg, "not found"): + writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": msg, "name": name}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": msg, "name": name}) + } return } writeJSON(w, http.StatusOK, result) @@ -451,9 +460,14 @@ func (p *Plugin) listPlugins() (interface{}, error) { } func (p *Plugin) removePlugin(name string) (interface{}, error) { + // 内置插件只能禁用不能卸载:目录下无产物,且从注册表删除会破坏内核依赖。 + if p.sdk != nil && p.sdk.PluginMgr() != nil && p.sdk.PluginMgr().IsBuiltinPlugin(name) { + return nil, fmt.Errorf("plugin %s is a built-in plugin and cannot be unloaded", name) + } + dir := filepath.Join(p.pluginDir, name) if _, err := os.Stat(dir); os.IsNotExist(err) { - return map[string]interface{}{"error": "plugin not found", "name": name}, nil + return nil, fmt.Errorf("plugin %s not found", name) } // 先经内核卸载:停止插件(stop handlers + Stop)并执行插件注册的 onRemove 回调 @@ -464,7 +478,7 @@ func (p *Plugin) removePlugin(name string) (interface{}, error) { } if err := os.RemoveAll(dir); err != nil { - return map[string]interface{}{"error": err.Error()}, nil + return nil, fmt.Errorf("remove plugin dir: %w", err) } // 同步清理禁用表 @@ -474,10 +488,11 @@ func (p *Plugin) removePlugin(name string) (interface{}, error) { } } + // 卸载已即时生效(停止+注册表移除+目录删除),无需 reload return map[string]interface{}{ - "status": "removed", - "name": name, - "action": "reload_required", + "status": "removed", + "name": name, + "reload_required": false, }, nil } diff --git a/internal/plugins/remotedevice/binary_test.go b/internal/plugins/remotedevice/binary_test.go index 49eafe2..4b778e1 100644 --- a/internal/plugins/remotedevice/binary_test.go +++ b/internal/plugins/remotedevice/binary_test.go @@ -322,7 +322,6 @@ func TestScreenseeEndToEnd(t *testing.T) { if _, _, err := cli.readMsg(); err != nil { t.Fatalf("read hello_ack: %v", err) } - reg.SetAuthorized("see-dev", true) // 设备侧循环收命令并回执(模拟 GUI screensee 实现) go func() { @@ -363,10 +362,10 @@ func TestScreenseeEndToEnd(t *testing.T) { t.Fatalf("handler received bad dataURL: %s", gotDataURL) } - // 未授权设备应拒绝 - reg.SetAuthorized("see-dev", false) - if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"}); err == nil { - t.Fatal("expected unauthorized error") + // 客户端鉴权模式:服务端不拦截,总是转发(设备端自行决定是否执行)。 + // see-dev 未声明 screensee 之外的问题,此处仅验证服务端不再因授权状态报错。 + if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"}); err != nil { + t.Fatalf("server should forward regardless of authorization, got: %v", err) } } @@ -389,7 +388,6 @@ func TestComputeruseEndToEnd(t *testing.T) { if _, _, err := cli.readMsg(); err != nil { t.Fatalf("read hello_ack: %v", err) } - reg.SetAuthorized("cu-dev", true) // 设备侧收 computeruse 命令并回执 var receivedCmd string @@ -480,7 +478,6 @@ func TestClipboardEndToEnd(t *testing.T) { if _, _, err := cli.readMsg(); err != nil { t.Fatalf("read hello_ack: %v", err) } - reg.SetAuthorized("clip-dev", true) // 设备侧响应剪贴板命令 go func() { @@ -553,11 +550,11 @@ func TestClipboardEndToEnd(t *testing.T) { } }) - t.Run("unauthorized_device_rejected", func(t *testing.T) { - reg.SetAuthorized("clip-dev", false) - defer reg.SetAuthorized("clip-dev", true) - if _, err := dev.Execute("clipboardsee", map[string]interface{}{"device_id": "clip-dev"}); err == nil { - t.Fatal("unauthorized should error") + t.Run("server_forwards_regardless_of_authorization", func(t *testing.T) { + // 客户端鉴权模式:服务端不再拦截未授权设备,由设备端自行拒绝。 + // 这里验证服务端能正常查询设备(不因授权状态报错)。 + if _, ok := reg.Get("clip-dev"); !ok { + t.Fatal("device should be registered") } }) } @@ -603,7 +600,6 @@ func TestCapabilityMatrix(t *testing.T) { if _, _, err := cli.readMsg(); err != nil { t.Fatalf("read hello_ack: %v", err) } - reg.SetAuthorized("cam-only", true) if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil { t.Fatal("camera-only device should not support screensee") @@ -640,8 +636,8 @@ func TestDeviceEventReport(t *testing.T) { // 设备主动上报:识别到未知人员驻留 cli.sendText(mustJSON(map[string]interface{}{ "op": "event", "device_id": "cam-watch", - "type": "unknown_person_detected", - "detail": "后门区域检测到陌生面孔,驻留超过30秒", + "type": "unknown_person_detected", + "detail": "后门区域检测到陌生面孔,驻留超过30秒", })) // 不带 device_id 时应回退到当前连接的设备 cli.sendText(mustJSON(map[string]interface{}{ diff --git a/internal/plugins/remotedevice/device.go b/internal/plugins/remotedevice/device.go index b9f7458..febef98 100644 --- a/internal/plugins/remotedevice/device.go +++ b/internal/plugins/remotedevice/device.go @@ -12,10 +12,9 @@ import ( // devicectlDevice 把设备网关暴露为 IOManager 的一个 Device: // Tools() 提供 devicedetect / device_ctl_status / device_ctl_cmdrun / device_ctl_cmdresult / screensee, -// Execute() 检查授权并路由到 WS 在线设备。 +// Execute() 推送命令到设备,设备端自行鉴权(客户端存储授权)。 type devicectlDevice struct { - reg *Registry - persist func() // 授权变更后持久化 + reg *Registry // screensee 回调:设备截屏回传后由 agent 核心消费(视觉描述)。 // 由插件 Start 注入;nil 时退化为仅返回 base64 数据。 @@ -121,9 +120,9 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef { "properties": map[string]interface{}{ "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, "action": map[string]interface{}{ - "type": "string", + "type": "string", "description": "操作类型:click(单击) / doubleclick(双击) / rightclick(右键) / move(移动) / scroll(滚动) / keypress(按键) / type(输入文字)", - "enum": []interface{}{"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type"}, + "enum": []interface{}{"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type"}, }, "x": map[string]interface{}{"type": "integer", "description": "鼠标 X 坐标(像素)。click/doubleclick/rightclick/move 必填"}, "y": map[string]interface{}{"type": "integer", "description": "鼠标 Y 坐标(像素)。click/doubleclick/rightclick/move 必填"}, @@ -257,9 +256,7 @@ func (d *devicectlDevice) status(args map[string]interface{}) (interface{}, erro if !ok { return nil, fmt.Errorf("device %s 不存在", id) } - if !m.Authorized { - return nil, fmt.Errorf("device %s 未授权,无法查询状态(需先在设备管理页或经 bind 授权)", id) - } + // 服务端不检查授权;设备端收到请求后自行决定是否执行。 if !m.Online { return publicDevices([]DeviceMeta{m}), nil // 带 offline=true } @@ -277,9 +274,7 @@ func (d *devicectlDevice) cmdrun(args map[string]interface{}) (interface{}, erro if !ok { return nil, fmt.Errorf("device %s 不存在", id) } - if !m.Authorized { - return nil, fmt.Errorf("device %s 未授权,无法执行命令(请先在设备管理页授权)", id) - } + // 服务端不检查授权;设备端收到请求后自行决定是否执行。 if !m.Online { return nil, fmt.Errorf("device %s 不在线,无法执行命令", id) } @@ -328,13 +323,10 @@ func (d *devicectlDevice) cmdresult(args map[string]interface{}) (interface{}, e if deviceID != "" { // 返回该设备最近一次结果(简化:遍历 results 找 device_id 匹配的最近一条) // 说明:当前只按 req_id 查询;device_id 查询留给后续迭代。 - m, ok := d.reg.Get(deviceID) + _, ok := d.reg.Get(deviceID) if !ok { return nil, fmt.Errorf("device %s 不存在", deviceID) } - if !m.Authorized { - return nil, fmt.Errorf("device %s 未授权", deviceID) - } return map[string]interface{}{"device_id": deviceID, "note": "请用 device_ctl_cmdrun 返回的 req_id 查询命令结果"}, nil } return nil, fmt.Errorf("req_id 或 device_id 至少提供一个") @@ -350,9 +342,6 @@ func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error) if !ok { return nil, fmt.Errorf("device %s 不存在", id) } - if !m.Authorized { - return nil, fmt.Errorf("device %s 未授权,无法探查信息(请先在设备管理页授权)", id) - } out := map[string]interface{}{ "device_id": m.DeviceID, "name": m.Name, @@ -385,9 +374,7 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e if !ok { return nil, fmt.Errorf("device %s 不存在", id) } - if !m.Authorized { - return nil, fmt.Errorf("device %s 未授权,无法查看屏幕(请先在设备管理页授权)", id) - } + // 服务端不检查授权;设备端收到请求后自行决定是否执行。 if !m.Online { return nil, fmt.Errorf("device %s 不在线", id) } @@ -436,9 +423,7 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{}, if !ok { return nil, fmt.Errorf("device %s 不存在", id) } - if !m.Authorized { - return nil, fmt.Errorf("device %s 未授权,无法远程操控(请先在设备管理页授权)", id) - } + // 服务端不检查授权;设备端收到请求后自行决定是否执行。 if !m.Online { return nil, fmt.Errorf("device %s 不在线", id) } @@ -508,9 +493,6 @@ func (d *devicectlDevice) clipboardCheck(id, verb, tool string) error { if !ok { return fmt.Errorf("device %s 不存在", id) } - if !m.Authorized { - return fmt.Errorf("device %s 未授权,无法%s剪切板(请先在设备管理页授权)", id, verb) - } if !m.Online { return fmt.Errorf("device %s 不在线", id) } diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index 090f358..2c764c6 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -71,7 +71,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // ---- 设置 ---------------- s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890,仅本机)", Category: "remotedevice"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "ws_token", Default: "", Type: "password", DisplayName: "接入 Token", Description: "设备绑定/接入时使用的令牌;留空启动时自动生成", Category: "remotedevice"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "authorized_devices", Default: "", Type: "text", DisplayName: "已授权设备", Description: "逗号分隔的已授权设备 ID 列表(由系统维护)", Category: "remotedevice"}) + // 注意:不注册 authorized_devices 设置项 —— 鉴权在设备端执行(客户端存储), + // 服务端不保存授权状态,避免 agent 经 config_set 工具自行授权。 p.addr = defaultAddr if v, _ := s.Settings().Get("listen_addr"); v != nil { @@ -95,21 +96,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { return provided != "" && provided == p.token }) - // 恢复已授权设备集合 - if v, _ := s.Settings().Get("authorized_devices"); v != nil { - if s2, ok := v.(string); ok && s2 != "" { - var ids []string - for _, id := range strings.Split(s2, ",") { - if id = strings.TrimSpace(id); id != "" { - ids = append(ids, id) - } - } - p.registry.RestoreAuthorized(ids) - } - } - // ---- devicectl Device(agent 工具) ---------------- - p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized} + p.dev = &devicectlDevice{reg: p.registry} // screensee 视觉描述回调:截屏回传后用视觉模型描述屏幕内容 p.dev.SetSeeHandler(p.describeScreen) if err := s.RegisterChannel("devicectl", p.dev); err != nil { @@ -171,24 +159,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { return nil } -// persistAuthorized 在授权变更后写回设置(持久化重启不丢)。 -func (p *Plugin) persistAuthorized() { - if p.sdk == nil { - return - } - ids := p.registry.AuthorizedIDs() - _ = p.sdk.Settings().Set("authorized_devices", strings.Join(ids, ",")) -} - func (p *Plugin) registerRoutes() { // 设备通道(WS) p.mux.HandleFunc("/api/v1/device/ws", p.registry.ServeWS) // REST 管理面(全部需 token) + // 注意:/api/v1/device/auth 已移除 —— 授权由设备端控制,服务端不提供授权接口。 p.mux.HandleFunc("/api/v1/device", p.requireToken(p.handleDeviceList)) p.mux.HandleFunc("/api/v1/device/online", p.requireToken(p.handleDeviceOnline)) p.mux.HandleFunc("/api/v1/device/", p.requireToken(p.handleDeviceByID)) p.mux.HandleFunc("/api/v1/device/push", p.requireToken(p.handleDevicePush)) - p.mux.HandleFunc("/api/v1/device/auth", p.requireToken(p.handleDeviceAuth)) } // requireToken 校验 REST 请求的接入令牌(X-API-Key header 或 ?token=)。 @@ -270,32 +249,6 @@ func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"}) } -func (p *Plugin) handleDeviceAuth(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - var req struct { - DeviceID string `json:"device_id"` - Authorize bool `json:"authorize"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()}) - return - } - if req.DeviceID == "" { - writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"}) - return - } - if _, ok := p.registry.Get(req.DeviceID); !ok { - writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"}) - return - } - p.registry.SetAuthorized(req.DeviceID, req.Authorize) - p.persistAuthorized() - writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize}) -} - // describeScreen 用视觉模型描述设备屏幕截图(screensee 回调)。 // provider 为空时使用默认 LLM 源;模型不支持视觉时返回友好错误。 func (p *Plugin) describeScreen(dataURL string, provider string) string { diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go index 4437876..73c4bba 100644 --- a/internal/plugins/remotedevice/registry.go +++ b/internal/plugins/remotedevice/registry.go @@ -17,6 +17,8 @@ import ( ) // DeviceMeta 描述一台接入了网关的设备。 +// Authorized 设备自报(由客户端存储和声明),服务端仅报告不决策。 +// 鉴权在设备端执行:服务端推送命令后,设备自行决定是否执行。 type DeviceMeta struct { DeviceID string `json:"device_id"` Name string `json:"name"` @@ -35,11 +37,11 @@ type wconn struct { w *bufio.Writer } -// Registry 是设备接入网关的注册表:管理在线连接、设备元数据与已授权集合。线程安全。 +// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。 +// 鉴权在设备端执行,服务端不存储授权状态。 type Registry struct { mu sync.RWMutex devices map[string]*DeviceMeta // deviceID -> meta(在线/历史) - authorized map[string]bool // deviceID -> 是否已授权(持久化恢复) conns map[string]*wconn // deviceID -> 活跃连接(支持 push) onlineCh chan string onStatus func(msg map[string]interface{}) @@ -62,9 +64,9 @@ type resultEntry struct { // (如旧版 GUI/waiter)视为全能力,保持向后兼容。 var capabilityTools = map[string][]string{ // 屏幕显示/查看 - "screen": {"screensue", "screensee"}, - "screensue": {"screensue"}, - "screensee": {"screensee"}, + "screen": {"screensue", "screensee"}, + "screensue": {"screensue"}, + "screensee": {"screensee"}, // 鼠标键盘操控 "computeruse": {"computeruse"}, // 剪切板 @@ -121,7 +123,6 @@ func deviceSupportsTool(caps []string, tool string) bool { func NewRegistry() *Registry { return &Registry{ devices: make(map[string]*DeviceMeta), - authorized: make(map[string]bool), conns: make(map[string]*wconn), onlineCh: make(chan string, 16), cmdPending: make(map[string]chan map[string]interface{}), @@ -172,21 +173,15 @@ func (r *Registry) Online(id string) bool { return ok && m.Online } -// Authorized 返回设备是否已授权。 -func (r *Registry) Authorized(id string) bool { - r.mu.RLock() - defer r.mu.RUnlock() - return r.authorized[id] -} +// Authorized 已移除:授权状态由设备端自报(DeviceMeta.Authorized),服务端不存储。 -// List 返回全部设备(在线或历史),合并授权态。 +// List 返回全部设备(在线或历史)。 func (r *Registry) List() []DeviceMeta { r.mu.RLock() defer r.mu.RUnlock() out := make([]DeviceMeta, 0, len(r.devices)) for _, m := range r.devices { c := *m - c.Authorized = r.authorized[c.DeviceID] out = append(out, c) } return out @@ -200,7 +195,6 @@ func (r *Registry) OnlineList() []DeviceMeta { for _, m := range r.devices { if m.Online { c := *m - c.Authorized = r.authorized[c.DeviceID] out = append(out, c) } } @@ -215,44 +209,12 @@ func (r *Registry) Get(id string) (DeviceMeta, bool) { if !ok { return DeviceMeta{}, false } - c := *m - c.Authorized = r.authorized[id] - return c, true + return *m, true } // ============ 授权 ============ - -// SetAuthorized 标记某设备已授权/取消授权(持久化由插件负责)。 -func (r *Registry) SetAuthorized(id string, auth bool) { - r.mu.Lock() - defer r.mu.Unlock() - r.authorized[id] = auth - if m, ok := r.devices[id]; ok { - m.Authorized = auth - } -} - -// RestoreAuthorized 插件启动时从配置恢复已授权设备集合。 -func (r *Registry) RestoreAuthorized(ids []string) { - r.mu.Lock() - defer r.mu.Unlock() - for _, id := range ids { - r.authorized[id] = true - } -} - -// AuthorizedIDs 返回全部已授权设备 ID(供插件持久化)。 -func (r *Registry) AuthorizedIDs() []string { - r.mu.RLock() - defer r.mu.RUnlock() - var out []string - for id, ok := range r.authorized { - if ok { - out = append(out, id) - } - } - return out -} +// 已移除服务端授权存储:设备在 hello/status 中自报 authorized, +// 服务端仅透传展示;实际鉴权由设备端执行(收到 cmd 后自行决定是否执行)。 // ============ 在线状态维护 ============ @@ -260,7 +222,7 @@ func (r *Registry) register(meta DeviceMeta) { r.mu.Lock() meta.Online = true meta.LastSeen = time.Now().Unix() - meta.Authorized = r.authorized[meta.DeviceID] + // 保留设备自报的授权状态(客户端鉴权,服务端不覆盖) r.devices[meta.DeviceID] = &meta r.mu.Unlock() r.notifyChange(meta.DeviceID) @@ -335,7 +297,9 @@ func (r *Registry) PushCmd(deviceID, reqID, command, cmdType string) error { // PushData 向设备分块下发二进制数据(网关→设备,如 TTS 音频)。 // 协议(与 GUI 设备桥协商): -// 文本帧 cmd_speech_start {op, req_id, kind, mime, total} → N 个二进制帧(0x2, ≤8KB) → 文本帧 cmd_speech_end {op, req_id} +// +// 文本帧 cmd_speech_start {op, req_id, kind, mime, total} → N 个二进制帧(0x2, ≤8KB) → 文本帧 cmd_speech_end {op, req_id} +// // kind 为语义标记(如 speech),mime 为数据 MIME 类型。设备聚合后按自身能力处理(播放等)。 func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) error { r.mu.RLock() @@ -813,6 +777,9 @@ func metaFromMsg(msg map[string]interface{}) DeviceMeta { } } } + if v, ok := d["authorized"].(bool); ok { + meta.Authorized = v + } if info, ok := d["info"].(map[string]interface{}); ok { if len(info) > 0 { meta.Info = info diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index a62ebd5..246e67d 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -4667,10 +4667,21 @@ background: return; } try { - var r = await api("/plugins", { + var raw = await api("/plugins", { method: "POST", body: JSON.stringify({ url: url }), + raw: true, }); + var ct = raw.headers.get("content-type") || ""; + var r = ct.includes("json") ? await raw.json() : await raw.text(); + if (!raw.ok || (r && r.error)) { + toast( + __("安装失败: ", "Install failed: ") + + ((r && (r.error || r.details)) || "HTTP " + raw.status), + true, + ); + return; + } toast( __("安装结果: ", "Install result: ") + (r.status || JSON.stringify(r)), @@ -4683,7 +4694,7 @@ background: ), false, ); - loadInstalledPlugins(); + await loadInstalledPlugins(); renderPlugins(); } catch (e) { toast(__("安装失败: ", "Install failed: ") + e.message, true); @@ -4732,7 +4743,10 @@ background: renderPlugins(); } + var removingPlugins = {}; + async function removePlugin(name) { + if (removingPlugins[name]) return; // 防重复点击 if ( !confirm( __("确定卸载插件", "Are you sure to unload plugin") + @@ -4742,23 +4756,39 @@ background: ) ) return; + removingPlugins[name] = true; try { - var r = await api("/plugins/" + encodeURIComponent(name), { - method: "DELETE", - }); - toast(__("已卸载: ", "Unloaded: ") + (r.status || r.name)); - if (r.action === "reload_required") - toast( - __( - "已卸载,请点击「重载插件」生效", - 'Unloaded, click "Reload Plugins" to apply', - ), - false, - ); - loadInstalledPlugins(); + var raw = await api( + "/plugins/" + encodeURIComponent(name), + { method: "DELETE", raw: true }, + ); + var ct = raw.headers.get("content-type") || ""; + var body = ct.includes("json") + ? await raw.json() + : await raw.text(); + if (!raw.ok) { + var em = + (body && (body.error || body.details)) || + ("HTTP " + raw.status); + toast(__("卸载失败: ", "Unload failed: ") + em, true); + // 内置插件或路径错误时刷新一次列表保持状态一致 + loadInstalledPlugins(); + renderPlugins(); + return; + } + toast(__("已卸载: ", "Unloaded: ") + (body.name || body.status || name)); + await loadInstalledPlugins(); + // 同步内核插件/禁用列表,确保列表与工具立即消失 + try { + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + } catch (e2) {} renderPlugins(); } catch (e) { toast(__("卸载失败: ", "Unload failed: ") + e.message, true); + } finally { + delete removingPlugins[name]; } } diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 31635b6..33a9a01 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -136,13 +136,13 @@ type Handler struct { chatHistory []ChatMsg pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无 - chatMsgMu sync.Mutex - chatMsgCache map[string]*chatMsgEntry // client_msg_id -> 首次处理结果 - chatMsgOrder []string // FIFO 淘汰序 - cmdMu sync.Mutex - cmdHistory []CmdExec - termMu sync.Mutex - termStates map[string]*termState + chatMsgMu sync.Mutex + chatMsgCache map[string]*chatMsgEntry // client_msg_id -> 首次处理结果 + chatMsgOrder []string // FIFO 淘汰序 + cmdMu sync.Mutex + cmdHistory []CmdExec + termMu sync.Mutex + termStates map[string]*termState } type ChatMsg struct { @@ -243,23 +243,23 @@ func NewHandler(s *sdk.PluginSDK) *Handler { st, llm = s.Status(), s.LLM() } h := &Handler{ - sdk: s, - supervisor: sup, - memory: mem, - indexer: idx, - adapter: ad, - config: cfg, - startTime: time.Now(), - textMem: tm, - knowledge: ks, - tracker: tr, - settings: se, - pluginMgr: pm, - status: st, - llm: llm, - sessions: make(map[string]time.Time), - termStates: make(map[string]*termState), - pendingIdx: -1, + sdk: s, + supervisor: sup, + memory: mem, + indexer: idx, + adapter: ad, + config: cfg, + startTime: time.Now(), + textMem: tm, + knowledge: ks, + tracker: tr, + settings: se, + pluginMgr: pm, + status: st, + llm: llm, + sessions: make(map[string]time.Time), + termStates: make(map[string]*termState), + pendingIdx: -1, chatMsgCache: make(map[string]*chatMsgEntry), sseEvents: newSSEEventRing(200), } @@ -1247,8 +1247,8 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { } var body struct { Message string `json:"message"` - DeviceID string `json:"device_id"` // 消息来源设备(GUI/受控设备),可选 - DeviceName string `json:"device_name"` // 设备显示名,可选 + DeviceID string `json:"device_id"` // 消息来源设备(GUI/受控设备),可选 + DeviceName string `json:"device_name"` // 设备显示名,可选 ClientMsgID string `json:"client_msg_id"` // 客户端唯一消息 ID(防断线重放/超时重试) } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -1848,6 +1848,14 @@ func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Reques http.NotFound(w, r) return } + // 客户端鉴权模式:服务端不再提供授权接口(授权由设备端本地控制)。 + // 拒绝旧的 /device/auth 调用,避免误导。 + if strings.HasSuffix(r.URL.Path, "/device/auth") { + writeJSON(w, http.StatusGone, map[string]string{ + "error": "device authorization moved to client-side; the server no longer stores authorization state", + }) + return + } addr := deviceGatewayAddr if addr == "" { addr = "127.0.0.1:9890" @@ -2053,6 +2061,23 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/") path = strings.TrimSuffix(path, "/") + // 插件名白名单:仅允许单段安全名称,阻断路径穿越/空名/嵌套路径 + validPluginName := func(s string) bool { + if s == "" || len(s) > 128 { + return false + } + // 禁止路径分隔符、连续点(父目录穿越)、冒号、空格等危险字符 + if strings.Contains(s, "..") || strings.ContainsAny(s, "/\\: \t\r\n\x00") { + return false + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { + return false + } + } + return true + } + if path == "disabled" && r.Method == http.MethodGet { if h.pluginMgr == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) @@ -2062,16 +2087,21 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { return } - if path == "reload" && r.Method == http.MethodPost { - if h.pluginMgr == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin registry not available"}) + if path == "reload" { + if r.Method == http.MethodPost { + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin registry not available"}) + return + } + if _, err := h.pluginMgr.ReloadPlugins(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) return } - if _, err := h.pluginMgr.ReloadPlugins(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) + // reload/disabled 是保留字,不允许 DELETE/GET 等其它操作误把其当作插件名 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -2085,6 +2115,10 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) return } + if !validPluginName(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } if err := h.pluginMgr.DisablePlugin(name, "webui"); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -2097,6 +2131,10 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) return } + if !validPluginName(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } if err := h.pluginMgr.EnablePlugin(name); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -2105,6 +2143,14 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { return } } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // 单段插件名路径(GET 详情 / DELETE 卸载) + if !validPluginName(path) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return } switch r.Method { diff --git a/internal/plugins/webui/handler_plugin_test.go b/internal/plugins/webui/handler_plugin_test.go new file mode 100644 index 0000000..15e6771 --- /dev/null +++ b/internal/plugins/webui/handler_plugin_test.go @@ -0,0 +1,195 @@ +package webui + +import ( + "net/http" + "net/http/httptest" + "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// mockPluginMgr 是 sdk.PluginManager 的最小实现,用于 handler 路由层测试。 +type mockPluginMgr struct { + builtins map[string]bool + disabled []sdk.DisabledPluginInfo + isDisabled map[string]bool + + removed []string + reloadN int + removeErr error +} + +func (m *mockPluginMgr) ListLoadedPlugins() []string { return nil } +func (m *mockPluginMgr) ListDisabledPlugins() []sdk.DisabledPluginInfo { + return m.disabled +} +func (m *mockPluginMgr) IsPluginDisabled(name string) bool { + return m.isDisabled[name] +} +func (m *mockPluginMgr) IsBuiltinPlugin(name string) bool { + return m.builtins[name] +} +func (m *mockPluginMgr) DisablePlugin(name, by string) error { + if m.isDisabled == nil { + m.isDisabled = map[string]bool{} + } + m.isDisabled[name] = true + return nil +} +func (m *mockPluginMgr) EnablePlugin(name string) error { + delete(m.isDisabled, name) + return nil +} +func (m *mockPluginMgr) RemovePlugin(name string) error { + if m.removeErr != nil { + return m.removeErr + } + m.removed = append(m.removed, name) + return nil +} +func (m *mockPluginMgr) ReloadPlugins() (string, error) { + m.reloadN++ + return "reloaded", nil +} +func (m *mockPluginMgr) ReloadOne(name string) error { return nil } +func (m *mockPluginMgr) PluginMetas() map[string]sdk.PluginMeta { + return nil +} +func (m *mockPluginMgr) PluginDir() string { return "" } + +// newHandlerWithMock 构造带 mock PluginManager 的 Handler(绕过 SDK 组装)。 +func newHandlerWithMock(m *mockPluginMgr) *Handler { + h := NewHandler(nil) + h.pluginMgr = m + return h +} + +func TestHandlePluginByID_DisabledList(t *testing.T) { + m := &mockPluginMgr{ + disabled: []sdk.DisabledPluginInfo{{Name: "foo"}}, + } + h := newHandlerWithMock(m) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/disabled", nil) + w := httptest.NewRecorder() + h.handlePluginByID(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if body := w.Body.String(); len(body) == 0 || !contains(body, "foo") { + t.Fatalf("expected disabled list with foo, got %s", body) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} + +func TestHandlePluginByID_ReloadPost(t *testing.T) { + m := &mockPluginMgr{} + h := newHandlerWithMock(m) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/reload", nil) + w := httptest.NewRecorder() + h.handlePluginByID(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if m.reloadN != 1 { + t.Fatalf("expected ReloadPlugins called once, got %d", m.reloadN) + } +} + +func TestHandlePluginByID_ReloadDeleteRejected(t *testing.T) { + // DELETE /plugins/reload 不允许把保留字当插件名反代成卸载 + m := &mockPluginMgr{} + h := newHandlerWithMock(m) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/plugins/reload", nil) + w := httptest.NewRecorder() + h.handlePluginByID(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } + if len(m.removed) != 0 { + t.Fatalf("expected no plugin removed, got %v", m.removed) + } +} + +func TestHandlePluginByID_PathTraversalRejected(t *testing.T) { + cases := []string{"../evil", "a/b", "a..b-ok-but-dots-only-check", "..%2Fetc"} + for _, name := range cases { + req := httptest.NewRequest(http.MethodDelete, "/api/v1/plugins/"+name, nil) + w := httptest.NewRecorder() + h := newHandlerWithMock(&mockPluginMgr{}) + h.handlePluginByID(w, req) + // 含路径分隔符或以 .. 开头的名称必须被拒绝(400/405),绝不能反代到 pluginmgr + if w.Code != http.StatusBadRequest && w.Code != http.StatusMethodNotAllowed && w.Code != http.StatusNotFound { + t.Errorf("name %q: expected 4xx rejection, got %d", name, w.Code) + } + } +} + +func TestHandlePluginByID_InvalidNamesRejected(t *testing.T) { + cases := []string{"has%20space", "has%3Acolon", "back%5Cslash"} + for _, name := range cases { + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/"+name, nil) + w := httptest.NewRecorder() + h := newHandlerWithMock(&mockPluginMgr{}) + h.handlePluginByID(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("name %q: expected 400, got %d", name, w.Code) + } + } +} + +func TestHandlePluginByID_ValidNamePassesValidation(t *testing.T) { + // 合法插件名(含点/横线/下划线)不应被名称校验拦截; + // 这里 pluginmgr 未运行会得到 502 Bad Gateway,但绝不应该是 400。 + m := &mockPluginMgr{} + h := newHandlerWithMock(m) + + for _, name := range []string{"my-plugin", "plugin_v2", "weather.so"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/"+name, nil) + w := httptest.NewRecorder() + h.handlePluginByID(w, req) + if w.Code == http.StatusBadRequest { + t.Errorf("valid name %q should pass validation, got 400", name) + } + } +} + +func TestHandlePluginByID_DisableEnable(t *testing.T) { + m := &mockPluginMgr{builtins: map[string]bool{"webui": true}} + h := newHandlerWithMock(m) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/webui/disable", nil) + w := httptest.NewRecorder() + h.handlePluginByID(w, req) + if w.Code != http.StatusOK { + t.Fatalf("disable: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !m.isDisabled["webui"] { + t.Fatal("webui should be disabled in mock") + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/plugins/webui/enable", nil) + w = httptest.NewRecorder() + h.handlePluginByID(w, req) + if w.Code != http.StatusOK { + t.Fatalf("enable: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if m.isDisabled["webui"] { + t.Fatal("webui should be re-enabled") + } +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 2a2e7d8..601b7d5 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -4,9 +4,9 @@ import ( "log" "sync" - pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/events" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) // SDKVersion 是对外 SDK 版本号,与核心 meta.Version 保持一致。 @@ -47,6 +47,7 @@ const ( StageScopeGlobal = pubsdk.StageScopeGlobal StageScopeOwnTools = pubsdk.StageScopeOwnTools ) + type APIRegistrar = pubsdk.APIRegistrar type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar type InputChannelRegistrar = pubsdk.InputChannelRegistrar @@ -68,6 +69,9 @@ type PluginManager interface { ListLoadedPlugins() []string ListDisabledPlugins() []DisabledPluginInfo IsPluginDisabled(name string) bool + // IsBuiltinPlugin 判断插件是否为内置插件(编译期工厂,init() 自注册)。 + // 内置插件只能禁用/启用,不能卸载。 + IsBuiltinPlugin(name string) bool DisablePlugin(name, by string) error EnablePlugin(name string) error // RemovePlugin 卸载插件:先停止(stop handlers + Stop),再执行插件注册的 @@ -253,13 +257,13 @@ func (s *PluginSDK) SelftestReset(scope string) error { return nil } -func (s *PluginSDK) Status() StatusAPI { return s.status } +func (s *PluginSDK) Status() StatusAPI { return s.status } func (s *PluginSDK) Supervisor() SupervisorAPI { return s.supervisor } -func (s *PluginSDK) Adapter() AdapterAPI { return s.adapter } -func (s *PluginSDK) Tracker() TrackerAPI { return s.tracker } -func (s *PluginSDK) Config() ConfigAPI { return s.config } -func (s *PluginSDK) Tool() ToolAPI { return s.tool } -func (s *PluginSDK) Indexer() IndexerAPI { return s.indexer } +func (s *PluginSDK) Adapter() AdapterAPI { return s.adapter } +func (s *PluginSDK) Tracker() TrackerAPI { return s.tracker } +func (s *PluginSDK) Config() ConfigAPI { return s.config } +func (s *PluginSDK) Tool() ToolAPI { return s.tool } +func (s *PluginSDK) Indexer() IndexerAPI { return s.indexer } func (s *PluginSDK) InjectInput(source, channel, eventType string, payload map[string]interface{}) { if s.iom != nil {