diff --git a/.gitignore b/.gitignore index dd72a6295..744180fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ workspace.zip /.trellis /AGENTS.md /CLAUDE.md + +# 本地工具链(protoc/LLVM),不提交 +/.tools/ diff --git a/crates/agent-gateway/internal/protocol/pbws/agent_conn.go b/crates/agent-gateway/internal/protocol/pbws/agent_conn.go index 61fc568f8..215285dd7 100644 --- a/crates/agent-gateway/internal/protocol/pbws/agent_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/agent_conn.go @@ -50,6 +50,9 @@ func (s *Server) AgentHandler() http.Handler { } defer release() conn.SetReadLimit(s.readLimit()) + // 预认证握手窗口:升级后必须在此窗口内完成 hello(或产生入站活动), + // 否则关闭。静默连接不再能永久占用 Agent 连接槽位(无凭据 DoS)。 + _ = conn.SetReadDeadline(time.Now().Add(s.connIdleTimeout())) s.serveAgent(conn) }) } @@ -118,6 +121,10 @@ func (s *Server) serveAgent(conn *websocket.Conn) { }); err != nil { return } + // 认证完成:清除预认证握手窗口。认证后的存活由心跳会话驱逐 + // (agentHeartbeatLoop 90s 无心跳清 session)与既有 pong 计入维持, + // 空闲但健康的桌面连接不应被读超时误杀。 + _ = conn.SetReadDeadline(time.Time{}) observability.Usage.V2AgentConnectsTotal.Add(1) observability.Usage.V2AgentActive.Add(1) diff --git a/crates/agent-gateway/internal/protocol/pbws/guard.go b/crates/agent-gateway/internal/protocol/pbws/guard.go index 15bbfb24c..965d9345b 100644 --- a/crates/agent-gateway/internal/protocol/pbws/guard.go +++ b/crates/agent-gateway/internal/protocol/pbws/guard.go @@ -53,7 +53,6 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error *gatewayv2.GatewayEnvelope_FileMentionList, *gatewayv2.GatewayEnvelope_UploadedImagePreview, *gatewayv2.GatewayEnvelope_MemoryManage, - *gatewayv2.GatewayEnvelope_CronManage, *gatewayv2.GatewayEnvelope_FsRoots, *gatewayv2.GatewayEnvelope_FsListDirs, *gatewayv2.GatewayEnvelope_FsCreateProjectFolder, @@ -95,6 +94,14 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error return errors.New("web tunnels are disabled in desktop Remote settings") } return nil + // ---- 带功能门控的直通臂:cron/hooks 管理可写入并立即执行 bash 脚本, + // 与 terminal/git/tunnels 同款后端强制开关(enable_web_automation), + // 未同步设置时 fail-closed(WebAutomationEnabled 默认 false)。 + case *gatewayv2.GatewayEnvelope_CronManage: + if !sm.WebAutomationEnabled() { + return errors.New("web automation is disabled in desktop Remote settings") + } + return nil case *gatewayv2.GatewayEnvelope_ManagedProcessRequest: req := payload.ManagedProcessRequest action := strings.TrimSpace(req.GetAction()) diff --git a/crates/agent-gateway/internal/protocol/pbws/server.go b/crates/agent-gateway/internal/protocol/pbws/server.go index f49b172c8..70513af61 100644 --- a/crates/agent-gateway/internal/protocol/pbws/server.go +++ b/crates/agent-gateway/internal/protocol/pbws/server.go @@ -127,6 +127,21 @@ func (s *Server) heartbeatPeriod() time.Duration { return 15 * time.Second } +// connIdleTimeout 与 wscore.IdleTimeout 同公式(3 个心跳周期 + 宽限,默认 50s)。 +// 用于 agent/terminal 链路的预认证握手窗口:升级完成后必须在此窗口内完成 hello +// 或持续产生入站活动,否则连接被关闭,杜绝无凭据的静默连接永久占用连接槽位。 +func (s *Server) connIdleTimeout() time.Duration { + period := s.heartbeatPeriod() + grace := time.Duration(0) + if s.cfg != nil && s.cfg.WebSocketHeartbeatGrace > 0 { + grace = s.cfg.WebSocketHeartbeatGrace + } + if grace <= 0 { + grace = 5 * time.Second + } + return period*3 + grace +} + func (s *Server) writeTimeout() time.Duration { if s.cfg != nil && s.cfg.WebSocketWriteTimeout > 0 { return s.cfg.WebSocketWriteTimeout diff --git a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go index 37b19dc32..d512f1d85 100644 --- a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go @@ -41,6 +41,9 @@ func (s *Server) TerminalHandler() http.Handler { defer release() // 角色未知前先按浏览器(更严)限额;hello 判定为 Agent 角色后再放宽。 conn.SetReadLimit(terminalBrowserReadLimit) + // 预认证握手窗口:升级后必须在此窗口内完成 hello(或产生入站活动), + // 否则关闭。静默连接不再能永久占用 Terminal 连接槽位(无凭据 DoS)。 + _ = conn.SetReadDeadline(time.Now().Add(s.connIdleTimeout())) s.serveTerminal(conn) }) } @@ -133,6 +136,9 @@ func (s *Server) serveTerminal(conn *websocket.Conn) { roleReadLimit = terminalAgentReadLimit } conn.SetReadLimit(roleReadLimit) + // 认证完成:清除预认证握手窗口。terminal 链路无应用层心跳,空闲但 + // 已认证的连接(桌面端无终端活动时)不应被驱逐;读循环按帧活动顺延。 + _ = conn.SetReadDeadline(time.Time{}) if err := writeDirectMessage(conn, s.writeTimeout(), &gatewayv2.TerminalServerFrame{ Payload: &gatewayv2.TerminalServerFrame_Hello{ Hello: s.serverHello(true, "", "", roleReadLimit), diff --git a/crates/agent-gateway/internal/session/agent_view.go b/crates/agent-gateway/internal/session/agent_view.go index e483f0ea8..3002b4a73 100644 --- a/crates/agent-gateway/internal/session/agent_view.go +++ b/crates/agent-gateway/internal/session/agent_view.go @@ -42,6 +42,10 @@ func (v AgentView) WebTunnelsEnabled() bool { return v.m.WebTunnelsEnabled(v.resolvedID()) } +func (v AgentView) WebAutomationEnabled() bool { + return v.m.WebAutomationEnabled(v.resolvedID()) +} + func (v AgentView) TerminalSessionKind(sessionID string) string { return v.m.TerminalSessionKind(v.resolvedID(), sessionID) } diff --git a/crates/agent-gateway/internal/session/manager_settings_sync.go b/crates/agent-gateway/internal/session/manager_settings_sync.go index 307c30dfb..37d916f69 100644 --- a/crates/agent-gateway/internal/session/manager_settings_sync.go +++ b/crates/agent-gateway/internal/session/manager_settings_sync.go @@ -57,6 +57,12 @@ func (m *Manager) WebGitEnabled(agentID string) bool { return m.settingsRemoteBool(agentID, "enableWebGit") } +// WebAutomationEnabled 门控远程 cron/hooks 管理(cron.manage 直通臂)。 +// 与 terminal/git/tunnels 同款 fail-closed:Agent 不存在或未同步过设置时一律 false。 +func (m *Manager) WebAutomationEnabled(agentID string) bool { + return m.settingsRemoteBool(agentID, "enableWebAutomation") +} + func parseSettingsJSON(settingsJSON string) (map[string]any, bool) { raw := strings.TrimSpace(settingsJSON) if raw == "" { diff --git a/crates/agent-gateway/test/websocket/v2_cron_gating_test.go b/crates/agent-gateway/test/websocket/v2_cron_gating_test.go new file mode 100644 index 000000000..6d6713f1c --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_cron_gating_test.go @@ -0,0 +1,96 @@ +package websocket_test + +// v2 直通 cron/hooks 门控:cron.manage 可写入并立即执行 bash 脚本, +// 与 terminal/git/tunnels 同款,受桌面端 Remote 设置 enable_web_automation 门控, +// 未同步设置时 fail-closed。 + +import ( + "strings" + "testing" + + "github.com/gorilla/websocket" + + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" +) + +func newV2CronBrowserTest( + t *testing.T, + webAutomationEnabled bool, +) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { + t.Helper() + + sm := session.NewManager() + webAutomationSetting := "false" + if webAutomationEnabled { + webAutomationSetting = "true" + } + sm.ApplySettingsJSON("desktop-agent", `{"remote":{"enableWebAutomation":`+webAutomationSetting+`}}`) + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot("desktop-agent")) + sm.SetSession(agentSession) + + handler := pbws.NewServer(newV2TestConfig(), sm, nil).BrowserHandler() + conn, cleanup := dialV2(t, handler) + helloV2(t, conn, "ws-token") + return sm, agentSession, conn, cleanup +} + +func sendCronManageAgentRequest(t *testing.T, conn *websocket.Conn, id string, action string) { + t.Helper() + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: id, + AgentId: "desktop-agent", + Payload: &gatewayv2.WebClientFrame_AgentRequest{ + AgentRequest: &gatewayv2.GatewayEnvelope{ + RequestId: id, + Payload: &gatewayv2.GatewayEnvelope_CronManage{ + CronManage: &gatewayv2.CronManageRequest{ + Action: action, + TaskId: "task-1", + TaskJson: "{}", + }, + }, + }, + }, + }) +} + +func TestV2CronManageRejectsWhenDisabled(t *testing.T) { + t.Parallel() + + _, _, conn, cleanup := newV2CronBrowserTest(t, false) + defer cleanup() + + for _, action := range []string{"cron_apply", "hooks_apply", "run_now", "snapshot"} { + id := "cron-disabled-" + action + sendCronManageAgentRequest(t, conn, id, action) + + frame := receiveWebFrameWithID(t, conn, id) + localError := frame.GetLocalError() + if localError == nil { + t.Fatalf("cron.manage %s reply = %#v, want local_error", action, frame) + } + if !strings.Contains(localError.GetMessage(), "web automation is disabled") { + t.Fatalf("cron.manage %s error = %q, want web automation disabled message", action, localError.GetMessage()) + } + } +} + +func TestV2CronManageAllowsWhenEnabled(t *testing.T) { + t.Parallel() + + _, agentSession, conn, cleanup := newV2CronBrowserTest(t, true) + defer cleanup() + + for _, action := range []string{"cron_apply", "run_now"} { + id := "cron-enabled-" + action + sendCronManageAgentRequest(t, conn, id, action) + + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetCronManage().GetAction() != action { + t.Fatalf("outbound = %#v, want forwarded cron.manage %s request", outbound, action) + } + } +} diff --git a/crates/agent-gateway/test/websocket/v2_handshake_deadline_test.go b/crates/agent-gateway/test/websocket/v2_handshake_deadline_test.go new file mode 100644 index 000000000..0dac56e14 --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_handshake_deadline_test.go @@ -0,0 +1,125 @@ +package websocket_test + +// 预认证握手超时:/ws/v2/agent 与 /ws/v2/terminal 升级后必须在握手窗口内 +// 完成 hello,否则连接被服务端关闭——无凭据攻击者无法用静默连接永久 +// 占用 Agent/Terminal 连接槽位(槽位耗尽 DoS)。认证完成后窗口清除, +// 空闲但健康的连接不被误杀。 + +import ( + "errors" + "net" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/liveagent/agent-gateway/internal/config" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" +) + +// sendAgentHello 发送 agent 角色 hello 并消费服务端 hello 判定。 +func sendAgentHello(t *testing.T, conn *websocket.Conn, token, agentID string) { + t.Helper() + sendProtoFrame(t, conn, &gatewayv2.AgentClientFrame{ + Payload: &gatewayv2.AgentClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_AGENT, + AgentId: agentID, + Token: token, + }, + }, + }) + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set hello reply deadline: %v", err) + } + if _, _, err := conn.ReadMessage(); err != nil { + t.Fatalf("read agent hello reply: %v", err) + } +} + +// newV2ShortTimeoutConfig 把心跳窗口压到亚秒级:IdleTimeout = 3×100ms + 50ms = 350ms, +// 让测试在秒内完成(默认 50s)。 +func newV2ShortTimeoutConfig() *config.Config { + cfg := newV2TestConfig() + cfg.WebSocketHeartbeatPeriod = 100 * time.Millisecond + cfg.WebSocketHeartbeatGrace = 50 * time.Millisecond + return cfg +} + +// waitForServerClose 在窗口内读取并判定连接是否被服务端关闭: +// 返回 nil 表示已被关闭;返回 timeout 错误表示连接仍打开(漏洞仍在)。 +func waitForServerClose(t *testing.T, conn *websocket.Conn, wait time.Duration) error { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(wait)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + _, _, err := conn.ReadMessage() + if err == nil { + t.Fatal("connection still open and readable after handshake window") + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return err // 读超时:连接还开着 + } + return nil // 服务端已关闭连接 +} + +func TestV2AgentHandshakeClosesSilentConnection(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + handler := pbws.NewServer(newV2ShortTimeoutConfig(), sm, nil).AgentHandler() + conn, cleanup := dialV2(t, handler) + defer cleanup() + + // 静默连接(不发 hello、不发任何字节)必须在 ~350ms 窗口后被服务端关闭。 + if err := waitForServerClose(t, conn, 2*time.Second); err != nil { + t.Fatalf("silent pre-hello agent connection stayed open past handshake window (%v); slot-exhaustion DoS persists", err) + } +} + +func TestV2TerminalHandshakeClosesSilentConnection(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + handler := pbws.NewServer(newV2ShortTimeoutConfig(), sm, nil).TerminalHandler() + conn, cleanup := dialV2(t, handler) + defer cleanup() + + if err := waitForServerClose(t, conn, 2*time.Second); err != nil { + t.Fatalf("silent pre-hello terminal connection stayed open past handshake window (%v); slot-exhaustion DoS persists", err) + } +} + +func TestV2AgentAuthenticatedConnectionSurvivesIdle(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + sess := session.NewAgentSession(sm.LatestAuthSnapshot("desktop-agent")) + sm.SetSession(sess) + + handler := pbws.NewServer(newV2ShortTimeoutConfig(), sm, newAgentTokenStore(t)).AgentHandler() + conn, cleanup := dialV2(t, handler) + defer cleanup() + + sendAgentHello(t, conn, "ws-token", "desktop-agent") + + // 认证完成后保持静默,时长超过若干倍握手窗口:连接必须仍然存活 + // (认证后的存活由会话心跳维持,不被读超时误杀)。 + time.Sleep(2 * time.Second) + if err := conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, _, err := conn.ReadMessage(); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return // 无数据但连接存活:符合预期 + } + t.Fatalf("authenticated idle agent connection was closed: %v", err) + } + // 收到帧(服务端心跳 ping 等)同样证明连接存活:认证后不被读超时误杀。 +} diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 3340d960f..e22265164 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -801,6 +801,7 @@ test("gateway settings sync keeps remote connection local and syncs web terminal enableWebSshTerminal: synced.remote.enableWebSshTerminal, enableWebGit: synced.remote.enableWebGit, enableWebTunnels: synced.remote.enableWebTunnels, + enableWebAutomation: synced.remote.enableWebAutomation, }); assert.deepEqual(payload.chatRuntimeControls, synced.chatRuntimeControls); }); diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 742a342a6..db2f374e4 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -101,6 +101,8 @@ export const WEBUI_TRANSLATION_OVERRIDES: Record> "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", "settings.remoteWebTunnelsHint": "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可为 localhost 或 IP 地址 HTTP 服务创建和关闭临时访问链接。", + "settings.remoteWebAutomationHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可管理本机定时任务与自动化 Hook(含立即执行)。", "settings.remoteHeartbeatHint": "与 Gateway 连接的保活心跳间隔(生效范围 10-60 秒),用于维持连接和检测在线状态", "settings.remoteInfoBanner": @@ -200,6 +202,8 @@ export const WEBUI_TRANSLATION_OVERRIDES: Record> "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can run branch, stage, commit, and sync operations on local projects.", "settings.remoteWebTunnelsHint": "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can create and close temporary links for localhost or IP-address HTTP services.", + "settings.remoteWebAutomationHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can manage cron tasks and automation hooks on this desktop, including running them immediately.", "settings.remoteHeartbeatHint": "Keepalive heartbeat interval for the Gateway connection (effective range 10-60 seconds), used to maintain the connection and detect online status", "settings.remoteInfoBanner": diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs index dcaca3f41..41f341343 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs @@ -48,6 +48,7 @@ pub(crate) fn load_gateway_settings_sync_snapshot(conn: &Connection) -> Result Result { .get("enableWebTunnels") .and_then(Value::as_bool) .unwrap_or(false); + let enable_web_automation = remote + .get("enableWebAutomation") + .and_then(Value::as_bool) + .unwrap_or(false); Ok(json!({ "enableWebTerminal": enable_web_terminal, "enableWebSshTerminal": enable_web_ssh_terminal, "enableWebGit": enable_web_git, "enableWebTunnels": enable_web_tunnels, + "enableWebAutomation": enable_web_automation, })) } fn save_remote(conn: &mut Connection, payload: Value) -> Result { diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 0e9471941..889c430c0 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -120,6 +120,7 @@ mod tests { enable_web_ssh_terminal: false, enable_web_git: false, enable_web_tunnels: false, + enable_web_automation: false, }); assert_eq!(normalized.gateway_url, "https://agent.cnweb.org"); @@ -193,6 +194,7 @@ mod tests { enable_web_ssh_terminal: true, enable_web_git: true, enable_web_tunnels: true, + enable_web_automation: true, }, ) .expect("seed manual Agent ID"); @@ -211,6 +213,7 @@ mod tests { assert!(stored.enable_web_ssh_terminal); assert!(stored.enable_web_git); assert!(stored.enable_web_tunnels); + assert!(stored.enable_web_automation); } #[test] diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/types.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/types.rs index 6008d26da..804db9d18 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/types.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/types.rs @@ -44,6 +44,8 @@ pub struct RemoteSettingsPayload { pub enable_web_git: bool, #[serde(default)] pub enable_web_tunnels: bool, + #[serde(default)] + pub enable_web_automation: bool, } #[derive(Debug, Clone)] pub(crate) struct RuntimeSshProxyConfig { diff --git a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs index fe64cd143..0763a9443 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs @@ -287,6 +287,9 @@ impl AutomationScheduler { } pub fn run_now(self: &Arc, task_id: &str) -> Result { + // enabled 只控制定时调度;本地手动 Run Now 对禁用/耗尽任务保持可用 + // (UI 的 Run Now 按钮不因 enabled=false 禁用)。远程边界的限制由 + // gateway_bridge::handle_cron_manage 的 run_now 分支单独施加。 let (workdir, task) = self.store.cron_task_for_manual_run(task_id)?; let started_at = now_ms(); if !self.start_fire(task, workdir, RunTrigger::Manual) { diff --git a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs index 77a372c97..7e3c34885 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs @@ -89,6 +89,19 @@ impl GatewayController { self.handle_chat_ingress_ack(request_id, ack).await } Some(proto::gateway_envelope::Payload::CronManage(request)) => { + // cron/hooks 可写入并立即执行 bash 脚本,受 Remote 设置 + // enable_web_automation 后端强制门控(与 terminal/git/tunnels + // 同款;网关侧 guard.go 已有同值门控,这里是纵深防御镜像)。 + if !self.config_tx.borrow().enable_web_automation { + let _ = self + .send_error_response( + request_id, + 403, + "web automation is disabled in desktop Remote settings".to_string(), + ) + .await; + return Ok(()); + } // Successful apply actions broadcast their own snapshot via the // AutomationStore notifier; no extra refresh is needed here. match gateway_bridge::handle_cron_manage( diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs index dc83e612a..169fc2b56 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -537,6 +537,7 @@ fn set_disconnected_status_resets_runtime_fields_for_new_config() { enable_web_ssh_terminal: false, enable_web_git: false, enable_web_tunnels: false, + enable_web_automation: false, }; let mut status = GatewayStatusSnapshot { online: true, @@ -605,6 +606,7 @@ fn gateway_connection_nudge_detects_offline_and_stale_sessions() { enable_web_ssh_terminal: false, enable_web_git: false, enable_web_tunnels: false, + enable_web_automation: false, }; assert_eq!( gateway_connection_stale_after(&config), diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index f3fa6009c..1c38c1912 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -29,7 +29,7 @@ use crate::commands::{ }, }; use crate::services::automation::{ - validate_cron_expression, AutomationApplyInput, AutomationStore, + validate_cron_expression, AutomationApplyInput, AutomationStore, CronTask, }; use crate::services::gateway::proto; use crate::services::memory::{ @@ -74,6 +74,16 @@ struct HistorySharedListArgs { page_size: i64, } +/// 远程边界策略:WebUI 的 run_now 只允许触发已启用任务。 +/// enabled 只控制定时调度(本地 Run Now 对禁用/耗尽任务保持可用,见 +/// scheduler.rs),因此限制施加在远程 cron.manage 边界而非全局 Scheduler。 +fn ensure_remote_run_now_allowed(task: &CronTask) -> Result<(), String> { + if !task.enabled { + return Err("Cron task is disabled and cannot be triggered remotely".to_string()); + } + Ok(()) +} + /// Gateway relay for the automation domain. Web clients speak the same /// versioned apply protocol as the desktop webview and the LLM tool; the /// legacy per-task create/update/delete actions no longer exist. @@ -126,9 +136,19 @@ pub async fn handle_cron_manage( } "run_now" => { let task_id = parse_required_cron_task_id(&request, "run_now")?; - let store = Arc::clone(&store); + // 远程边界单独限制:禁用的任务不允许经 WebUI 远程手动触发 + // (本地的 Run Now 语义不变——enabled 只控制定时调度,见 scheduler.rs)。 + let check_store = Arc::clone(&store); + let task_id_for_check = task_id.clone(); + let (_, task) = tauri::async_runtime::spawn_blocking(move || { + check_store.cron_task_for_manual_run(&task_id_for_check) + }) + .await + .map_err(|e| format!("gateway run_now load join failed: {e}"))??; + ensure_remote_run_now_allowed(&task)?; + let run_store = Arc::clone(&store); let response = - tauri::async_runtime::spawn_blocking(move || store.run_cron_task_now(&task_id)) + tauri::async_runtime::spawn_blocking(move || run_store.run_cron_task_now(&task_id)) .await .map_err(|e| format!("gateway run_now join failed: {e}"))??; serialize_cron_manage_result(&response)? @@ -1643,13 +1663,15 @@ mod tests { use serde_json::{json, Value}; use super::{ - flatten_history_messages_json, flatten_history_messages_json_window, - is_builtin_share_tool_name, parse_runs_limit, redact_builtin_tool_content_json, - resolve_stored_provider_models_config, sanitize_provider_summaries, + ensure_remote_run_now_allowed, flatten_history_messages_json, + flatten_history_messages_json_window, is_builtin_share_tool_name, parse_runs_limit, + redact_builtin_tool_content_json, resolve_stored_provider_models_config, + sanitize_provider_summaries, }; use crate::commands::chat_history::{ self, history_message_content_hash, ChatHistoryMessageRef, ChatHistorySegmentRecord, }; + use crate::services::automation::CronTask; fn make_segment( segment_index: i64, @@ -2053,6 +2075,64 @@ mod tests { assert_eq!(items[3]["details"]["kind"], "redacted_tool_content"); } + fn make_cron_task(enabled: bool) -> CronTask { + CronTask { + id: "task-remote".to_string(), + name: "Remote Task".to_string(), + description: String::new(), + cron: "0 * * * * *".to_string(), + enabled, + remaining_executions: None, + timeout_seconds: 300, + kind: "bash".to_string(), + script: Some("echo remote".to_string()), + requests: None, + prompt: None, + selected_model: None, + reasoning: None, + workdir: None, + last_error: None, + } + } + + #[test] + fn remote_run_now_rejects_disabled_tasks_but_local_semantics_unchanged() { + // 远程边界:禁用的任务不可经 WebUI 手动触发。 + let error = ensure_remote_run_now_allowed(&make_cron_task(false)) + .expect_err("remote run_now must reject disabled tasks"); + assert!(error.contains("disabled"), "error = {error}"); + assert!(error.contains("remotely"), "error = {error}"); + + // 启用任务放行。 + assert!(ensure_remote_run_now_allowed(&make_cron_task(true)).is_ok()); + + // 本地语义对照:store 层仍可把禁用任务作为手动运行上下文加载 + // (UI 的 Run Now 按钮不因 enabled=false 禁用;enabled 只控制定时调度)。 + let store = crate::services::automation::AutomationStore::open_in_memory() + .expect("open automation store"); + let base = store.snapshot().expect("snapshot").cron.revision; + let response = store + .cron_apply(crate::services::automation::AutomationApplyInput { + base_revision: base, + ops: vec![crate::services::automation::AutomationOp::Create { + item: json!({ + "id": "disabled-local", + "name": "Disabled Local", + "cron": "0 * * * * *", + "enabled": false, + "type": "bash", + "script": "echo local", + }), + }], + }) + .expect("apply disabled task"); + let task_id = response.cron.tasks[0].id.clone(); + let (_, manual_task) = store + .cron_task_for_manual_run(&task_id) + .expect("disabled task remains loadable for local manual run"); + assert!(!manual_task.enabled); + } + #[test] fn shared_chat_history_builtin_policy_covers_the_tool_catalog() { let catalog = include_str!("../../../../agent-ui/src/lib/tools/builtinToolCatalog.ts"); diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs b/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs index b3e0c4f73..3db72a6b6 100644 --- a/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs @@ -5,7 +5,7 @@ pub mod proxy; pub mod store; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; @@ -49,10 +49,67 @@ pub(crate) fn validate_tunnel_target_url(input: &str) -> Result().is_err() { return Err("targetUrl host must be localhost or an IP address".to_string()); } + // 拒绝 link-local/保留/多播段:tunnel 会把目标暴露到公网,指向云元数据 + // (169.254.169.254 等)或不可路由地址的隧道是 SSRF 扩张面。localhost、 + // 回环与 RFC1918/ULA 私网段是 tunnel 的本职用途(暴露本地/内网服务), + // 保持放行;网关侧 guard.go 的 enable_web_tunnels 开关是前置授权门。 + if let Ok(ip) = host.parse::() { + if is_blocked_tunnel_target_ip(ip) { + return Err("targetUrl host is in a blocked IP range".to_string()); + } + } url.set_fragment(None); Ok(TunnelTarget { url }) } +/// 判定隧道目标 IP 是否属于禁止暴露的段(与网关 Go 侧 outbound_http.go 的 +/// SSRF 黑名单对齐,但保留 loopback 与 RFC1918/ULA:暴露本地服务是 tunnel 本职)。 +fn is_blocked_tunnel_target_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_blocked_tunnel_target_ipv4(v4), + IpAddr::V6(v6) => { + // IPv4-mapped IPv6(::ffff:a.b.c.d)先还原为 IPv4 再走 IPv4 黑名单 + // (与网关 Go 侧 outbound_http.go 的 Unmap() 先例一致)——否则 + // http://[::ffff:169.254.169.254]/ 可绕过元数据段拦截。 + if let Some(v4) = v6.to_ipv4_mapped() { + return is_blocked_tunnel_target_ipv4(v4); + } + let segments = v6.segments(); + // ::/128 未指定地址(不可路由)。::1 回环与 127.0.0.1 同语义,是 + // tunnel 的本职暴露目标,保持放行。 + if segments.iter().all(|segment| *segment == 0) { + return true; + } + // fe80::/10 link-local + if (segments[0] & 0xffc0) == 0xfe80 { + return true; + } + // ff00::/8 多播 + if segments[0] >> 8 == 0xff { + return true; + } + false + } + } +} + +/// IPv4 黑名单本体:0.0.0.0/8、169.254.0.0/16(云元数据/link-local)、 +/// 224.0.0.0/4 多播、240.0.0.0/4 保留(含广播)。loopback 与 RFC1918 放行 +/// (暴露本地/内网服务是 tunnel 本职)。 +fn is_blocked_tunnel_target_ipv4(v4: Ipv4Addr) -> bool { + let octets = v4.octets(); + if octets[0] == 0 { + return true; + } + if octets[0] == 169 && octets[1] == 254 { + return true; + } + if (224..=239).contains(&octets[0]) { + return true; + } + octets[0] >= 240 +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GatewayTunnelCreateInput { @@ -575,6 +632,7 @@ mod tests { "http://192.168.1.5:3000", "http://10.0.0.20:8080/app", "http://[fd00::1]:5173", + "http://8.8.8.8:8080", ] { assert!(validate_tunnel_target_url(value).is_ok(), "{value}"); } @@ -588,4 +646,58 @@ mod tests { assert!(validate_tunnel_target_url(value).is_err(), "{value}"); } } + + #[test] + fn validate_tunnel_target_url_rejects_link_local_metadata_and_reserved_ranges() { + // 云元数据与 link-local:暴露这些段的隧道是 SSRF 扩张面。 + for value in [ + "http://169.254.169.254:80/latest/meta-data/", + "http://169.254.170.2:80/", + "http://169.254.1.1:8080", + "http://[fe80::1]:8080", + "http://[fe80::1234]:8080", + ] { + assert!(validate_tunnel_target_url(value).is_err(), "{value}"); + } + + // 保留/多播/广播段不可路由,无合法暴露用途。 + for value in [ + "http://0.0.0.0:8080", + "http://224.0.0.1:8080", + "http://239.255.255.250:8080", + "http://240.0.0.1:8080", + "http://255.255.255.255:8080", + "http://[::]:8080", + "http://[ff02::1]:8080", + ] { + assert!(validate_tunnel_target_url(value).is_err(), "{value}"); + } + } + + #[test] + fn validate_tunnel_target_url_rejects_ipv4_mapped_metadata_ranges() { + // IPv4-mapped IPv6 必须先 unmap 再走 IPv4 黑名单(与网关 Go 侧 + // Unmap() 先例一致):::ffff:169.254.169.254 等价于云元数据地址, + // 不得作为隧道目标。创建路径(validate)与数据面(proxy.rs 复用同一 + // 校验)共用此函数,此处即覆盖两条路径的判定。 + for value in [ + "http://[::ffff:169.254.169.254]:80/latest/meta-data/", + "http://[::ffff:a9fe:a9fe]:80/", + "http://[::ffff:169.254.170.2]:80/", + "http://[::ffff:224.0.0.1]:8080", + "http://[::ffff:255.255.255.255]:8080", + ] { + assert!(validate_tunnel_target_url(value).is_err(), "{value}"); + } + + // mapped 形式的合法段(RFC1918/公网)语义不变:暴露内网/本地服务 + // 是 tunnel 本职,与直写 IPv4 形式同权放行。 + for value in [ + "http://[::ffff:192.168.1.5]:3000", + "http://[::ffff:10.0.0.20]:8080", + "http://[::ffff:8.8.8.8]:8080", + ] { + assert!(validate_tunnel_target_url(value).is_ok(), "{value}"); + } + } } diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index aca09cc23..156fb7ad9 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -171,6 +171,8 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", "settings.remoteWebTunnelsHint": "开启后,已登录 WebUI 可为 localhost 或 IP 地址 HTTP 服务创建和关闭临时访问链接。", + "settings.remoteWebAutomationHint": + "开启后,已登录 WebUI 可管理本机定时任务与自动化 Hook(含立即执行)。", "settings.remoteHeartbeatHint": "本地 Agent 向 Gateway 上报存活状态的间隔", }, "en-US": { @@ -348,6 +350,8 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "Allow authenticated WebUI clients to run branch, stage, commit, and sync operations on local projects.", "settings.remoteWebTunnelsHint": "Allow authenticated WebUI clients to create and close temporary links for localhost or IP-address HTTP services.", + "settings.remoteWebAutomationHint": + "Allow authenticated WebUI clients to manage cron tasks and automation hooks on this desktop, including running them immediately.", "settings.remoteHeartbeatHint": "How often the local Agent reports liveness to the Gateway", }, }; diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index 859cae890..ed336723f 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -1832,7 +1832,41 @@ export async function runAssistantWithTools(params: { for (const toolCall of recoveredSeedToolCalls) { throwIfRunnerCancelled(params.signal); toolCallsById.set(toolCall.id, toolCall); + const effectiveSeedToolCall = normalizeToolCallNameForExecution(toolCall); + + // 审批门必须先于任何执行事件:onToolExecutionStart 会派发 + // tool_execution_start,Hook 可据此立即执行 Bash/HTTP——拒绝必须 + // 发生在副作用事件之前(提示注入可诱导模型输出 , + // 门是唯一裁决点,不能先宣告执行再拒绝)。 + let deniedReason: string | null = null; + if (params.resolveToolGate) { + const gate = await params.resolveToolGate(effectiveSeedToolCall, params.signal); + if (!gate.allow) { + deniedReason = gate.reason; + } + } + const shouldSilenceToolCall = shouldSilenceProviderNativeToolCall(toolCall); + if (deniedReason !== null) { + const deniedToolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: effectiveSeedToolCall.id, + toolName: effectiveSeedToolCall.name, + content: [{ type: "text", text: deniedReason }], + details: {}, + isError: true, + timestamp: Date.now(), + }; + syntheticToolResults.push(deniedToolResult); + if (!shouldSilenceToolCall) { + // 与执行路径对称补齐 onToolResult:拒绝结果进入 transcript 并 + // 触发 toolResultReceived 收尾 Hook 生命周期,不留下缺失 + // tool_execution_end 的运行中状态。 + params.onToolResult?.(toolCall, deniedToolResult, recoveredSeedRound); + } + continue; + } + if (!shouldSilenceToolCall) { params.onToolCall?.(toolCall, recoveredSeedRound); params.onToolStatus?.(`正在执行:${summarizeToolCall(toolCall)}`); diff --git a/crates/agent-gui/test/chat/agent-runner.test.mjs b/crates/agent-gui/test/chat/agent-runner.test.mjs index 389c1f79f..6ffe048ec 100644 --- a/crates/agent-gui/test/chat/agent-runner.test.mjs +++ b/crates/agent-gui/test/chat/agent-runner.test.mjs @@ -2552,3 +2552,72 @@ test("runAssistantWithTools 的前缀归因按 sessionId 隔离,多会话交错 assert.equal(prefixCaptures[2].prefixChanged, false); assert.equal(prefixCaptures[2].prefixHash, prefixCaptures[0].prefixHash); }); + +test("runAssistantWithTools routes recovered seed tool calls through the approval gate", async () => { + resetFakeStreams( + createTextAssistant(`Before + + + src/App.tsx + + +After`), + createTextAssistant("after recovered tool"), + ); + const gateCalls = []; + const executionStarts = []; + const toolResults = []; + const { params, executedToolCalls } = createBaseParams({ + resolveToolGate: async (toolCall) => { + gateCalls.push(toolCall); + return { allow: false, reason: "blocked by test gate" }; + }, + onToolExecutionStart: (toolCall) => executionStarts.push(toolCall), + onToolResult: (toolCall, toolResult) => toolResults.push({ toolCall, toolResult }), + }); + + const result = await runAssistantWithTools(params); + + assert.equal( + gateCalls.length, + 1, + "approval gate must run for recovered seed tool calls (prompt-injected markup must not bypass ask/deny policy)", + ); + assert.equal(executedToolCalls.length, 0, "denied seed tool call must not execute"); + assert.equal( + executionStarts.length, + 0, + "denied seed call must not announce execution start — onToolExecutionStart dispatches tool_execution_start, which Hook runners execute on (Bash/HTTP must not run before denial)", + ); + assert.equal( + toolResults.length, + 1, + "denied seed call must still emit its tool result (toolResultReceived pairing, no dangling running state)", + ); + assert.ok( + JSON.stringify(toolResults[0].toolResult.content).includes("blocked by test gate"), + "denial reason must reach the transcript via onToolResult", + ); + assert.ok( + JSON.stringify(result.emittedMessages).includes("blocked by test gate"), + "denial reason must reach the model as the tool result, same as structured-call blocks", + ); +}); + +test("runAssistantWithTools executes seed tool calls allowed by the gate", async () => { + resetFakeStreams( + createTextAssistant(` + + src/App.tsx + +`), + createTextAssistant("done"), + ); + const { params, executedToolCalls } = createBaseParams({ + resolveToolGate: async () => ({ allow: true }), + }); + + await runAssistantWithTools(params); + + assert.equal(executedToolCalls.length, 1, "allowed seed tool call still executes"); +}); diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index ae0ed5703..f82588fc7 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -1109,6 +1109,7 @@ test("gateway settings sync redacts ssh secrets and preserves configured state", remote: { enableWebTerminal: true, enableWebSshTerminal: true, + enableWebAutomation: true, }, }); @@ -1130,6 +1131,7 @@ test("gateway settings sync redacts ssh secrets and preserves configured state", enableWebSshTerminal: true, enableWebGit: false, enableWebTunnels: false, + enableWebAutomation: true, }); const updatePayload = sync.buildGatewaySettingsSyncPayload(appSettings, { @@ -3294,3 +3296,36 @@ test("workspace resource overflow uses locale-independent Unicode code-point ord assert.ok(normalized["/repo/a"]); assert.equal(normalized["/repo/ä"], undefined); }); + +test("enableWebAutomation round-trips through normalization and gateway sync", () => { + // 归一化保留 enableWebAutomation(此前被丢弃,导致后端字段永久 false)。 + const normalized = settings.normalizeSettings({ + remote: { enableWebAutomation: true }, + }); + assert.equal(normalized.remote.enableWebAutomation, true); + assert.equal( + settings.normalizeSettings({ remote: { enableWebAutomation: "yes" } }).remote + .enableWebAutomation, + false, + "non-boolean input normalizes to false", + ); + + // 同步载荷携带该字段:网关侧 WebAutomationEnabled 门控依赖它。 + const payload = sync.buildGatewaySettingsSyncPayload(normalized); + assert.equal(payload.remote.enableWebAutomation, true); + + // 应用到另一端的默认设置(全 false)后仍为 true,再归一化不丢。 + const applied = sync.applyGatewaySettingsSyncPayload( + settings.normalizeSettings({}), + payload, + ); + assert.equal(applied.remote.enableWebAutomation, true); + const reNormalized = settings.normalizeSettings({ remote: applied.remote }); + assert.equal(reNormalized.remote.enableWebAutomation, true); + + // 缺字段的旧载荷不得把现有 true 覆盖为 false(merge 只处理 hasOwn 键)。 + const kept = sync.applyGatewaySettingsSyncPayload(applied, { + remote: { enableWebTerminal: true }, + }); + assert.equal(kept.remote.enableWebAutomation, true); +}); diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 797917992..e62c9c47a 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -883,6 +883,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.remoteWebSshTerminal": "Allow WebUI SSH Terminal", "settings.remoteWebGit": "Allow WebUI Git", "settings.remoteWebTunnels": "Allow WebUI Tunnels", + "settings.remoteWebAutomation": "Allow WebUI Automation", "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", "settings.remoteConnectionStatus": "Connection Status", diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index e7af007d6..36857e195 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -844,6 +844,7 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.remoteWebSshTerminal": "允许 WebUI SSH Terminal", "settings.remoteWebGit": "允许 WebUI Git", "settings.remoteWebTunnels": "允许 WebUI 内网穿透", + "settings.remoteWebAutomation": "允许 WebUI 自动化", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", "settings.remoteConnectionStatus": "连接状态", diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index 980440749..1edb3f1da 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -580,6 +580,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { enableWebSshTerminal: obj.enableWebSshTerminal === true, enableWebGit: obj.enableWebGit === true, enableWebTunnels: obj.enableWebTunnels === true, + enableWebAutomation: obj.enableWebAutomation === true, }; } @@ -1408,6 +1409,7 @@ export function getDefaultSettings(): AppSettings { enableWebSshTerminal: false, enableWebGit: false, enableWebTunnels: false, + enableWebAutomation: false, }, memory: normalizeMemorySettings({}, customProviders), customSettings: normalizeCustomSettings({}, customProviders), diff --git a/crates/agent-ui/src/lib/settings/sync.ts b/crates/agent-ui/src/lib/settings/sync.ts index 8692d689a..caa00bbe5 100644 --- a/crates/agent-ui/src/lib/settings/sync.ts +++ b/crates/agent-ui/src/lib/settings/sync.ts @@ -56,7 +56,11 @@ export type GatewaySettingsSyncPayload = { ssh: AppSettings["ssh"]; remote?: Pick< AppSettings["remote"], - "enableWebTerminal" | "enableWebSshTerminal" | "enableWebGit" | "enableWebTunnels" + | "enableWebTerminal" + | "enableWebSshTerminal" + | "enableWebGit" + | "enableWebTunnels" + | "enableWebAutomation" >; memory: AppSettings["memory"]; modelFailover: AppSettings["modelFailover"]; @@ -833,7 +837,8 @@ function mergeSyncedRemoteSettings( !Object.hasOwn(source, "enableWebTerminal") && !Object.hasOwn(source, "enableWebSshTerminal") && !Object.hasOwn(source, "enableWebGit") && - !Object.hasOwn(source, "enableWebTunnels") + !Object.hasOwn(source, "enableWebTunnels") && + !Object.hasOwn(source, "enableWebAutomation") ) { return current; } @@ -851,6 +856,9 @@ function mergeSyncedRemoteSettings( enableWebTunnels: Object.hasOwn(source, "enableWebTunnels") ? source.enableWebTunnels === true : current.enableWebTunnels, + enableWebAutomation: Object.hasOwn(source, "enableWebAutomation") + ? source.enableWebAutomation === true + : current.enableWebAutomation, }; } @@ -1113,6 +1121,7 @@ export function buildGatewaySettingsSyncPayload( enableWebSshTerminal: settings.remote.enableWebSshTerminal, enableWebGit: settings.remote.enableWebGit, enableWebTunnels: settings.remote.enableWebTunnels, + enableWebAutomation: settings.remote.enableWebAutomation, }, memory: settings.memory, modelFailover: settings.modelFailover, diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index 3326f0f2a..6a12108a3 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -493,6 +493,7 @@ export type RemoteSettings = { enableWebSshTerminal: boolean; enableWebGit: boolean; enableWebTunnels: boolean; + enableWebAutomation: boolean; }; export type AppSettings = { diff --git a/crates/agent-ui/src/pages/settings/RemoteSection.tsx b/crates/agent-ui/src/pages/settings/RemoteSection.tsx index 386d2916a..d4665b515 100644 --- a/crates/agent-ui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-ui/src/pages/settings/RemoteSection.tsx @@ -23,6 +23,7 @@ import { Terminal, Wifi, WifiOff, + Zap, } from "@liveagent/ui/components/IconSet"; import { Input } from "@liveagent/ui/components/ui/input"; import { useLocale } from "@liveagent/ui/i18n/index"; @@ -549,6 +550,18 @@ export function RemoteSection(props: SettingsSectionProps) { }) } /> + + + updateRemoteSettings(setSettings, { + enableWebAutomation: !settings.remote.enableWebAutomation, + }) + } + /> diff --git a/docs/security/p0-fixes-evidence.svg b/docs/security/p0-fixes-evidence.svg new file mode 100644 index 000000000..e538b767e --- /dev/null +++ b/docs/security/p0-fixes-evidence.svg @@ -0,0 +1,34 @@ + + + P0 安全修复验证 — 复现测试证据(before/after) + + + 修复前(漏洞复现) + H1 cron 门控: FAIL + i/o timeout — 开关关闭时 + cron.manage 请求被放行转发 + H2 WS 握手: FAIL + 静默连接 2s 后仍存活, + 槽位可被无凭据耗尽 + H3 seed 审批: FAIL + approval gate 从未被调用, + seed 工具调用直接执行 + H4 tunnel: FAIL + http://169.254.169.254 通过校验 + (云元数据段未被拦截) + + 修复后(验证通过) + H1 cron 门控: PASS + local_error "web automation is + disabled in desktop Remote settings" + H2 WS 握手: PASS + 静默连接 350ms 内被关闭 + 认证后空闲连接不被误杀 + H3 seed 审批: PASS + gate 拦截拒绝,reason 作为 + toolResult 返回模型(42/42 通过) + H4 tunnel: PASS + link-local/元数据/多播/保留段 + 全部拒绝,localhost/RFC1918 保留 + +