Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ workspace.zip
/.trellis
/AGENTS.md
/CLAUDE.md

# 本地工具链(protoc/LLVM),不提交
/.tools/
7 changes: 7 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/agent_conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion crates/agent-gateway/internal/protocol/pbws/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
15 changes: 15 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/terminal_conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions crates/agent-gateway/internal/session/agent_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
96 changes: 96 additions & 0 deletions crates/agent-gateway/test/websocket/v2_cron_gating_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
125 changes: 125 additions & 0 deletions crates/agent-gateway/test/websocket/v2_handshake_deadline_test.go
Original file line number Diff line number Diff line change
@@ -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 等)同样证明连接存活:认证后不被读超时误杀。
}
1 change: 1 addition & 0 deletions crates/agent-gateway/test/webui/web-settings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
4 changes: 4 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export const WEBUI_TRANSLATION_OVERRIDES: Record<Locale, Record<string, string>>
"由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。",
"settings.remoteWebTunnelsHint":
"由桌面端 Remote 设置控制。开启后,已登录 WebUI 可为 localhost 或 IP 地址 HTTP 服务创建和关闭临时访问链接。",
"settings.remoteWebAutomationHint":
"由桌面端 Remote 设置控制。开启后,已登录 WebUI 可管理本机定时任务与自动化 Hook(含立即执行)。",
"settings.remoteHeartbeatHint":
"与 Gateway 连接的保活心跳间隔(生效范围 10-60 秒),用于维持连接和检测在线状态",
"settings.remoteInfoBanner":
Expand Down Expand Up @@ -200,6 +202,8 @@ export const WEBUI_TRANSLATION_OVERRIDES: Record<Locale, Record<string, string>>
"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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub(crate) fn load_gateway_settings_sync_snapshot(conn: &Connection) -> Result<V
"enableWebSshTerminal": remote.enable_web_ssh_terminal,
"enableWebGit": remote.enable_web_git,
"enableWebTunnels": remote.enable_web_tunnels,
"enableWebAutomation": remote.enable_web_automation,
}),
);
// UI-only fields (theme, locale, selectedModel, skills, chatRuntimeControls,
Expand Down
Loading
Loading