diff --git a/internal/acp/gateway.go b/internal/acp/gateway.go index e71edae..301034f 100644 --- a/internal/acp/gateway.go +++ b/internal/acp/gateway.go @@ -137,6 +137,7 @@ func applyProductionGatewayRouting( Host: parsed.Hostname(), Port: port, TLS: tls, + Path: parsed.EscapedPath(), } request.Auth.Token = bridgeSharedAuthToken() request.Auth.Password = "" diff --git a/internal/acp/gateway_test.go b/internal/acp/gateway_test.go new file mode 100644 index 0000000..db04baf --- /dev/null +++ b/internal/acp/gateway_test.go @@ -0,0 +1,37 @@ +package acp + +import ( + "testing" + + "xworkmate-bridge/internal/gatewayruntime" +) + +func TestApplyProductionGatewayRoutingPreservesGatewayURLPath(t *testing.T) { + t.Setenv("GATEWAY_RPC_URL", "ws://127.0.0.1:18789/gateway/openclaw") + server := NewServer() + + request := applyProductionGatewayRouting( + server, + gatewayruntime.ConnectRequest{ + Mode: "openclaw", + Endpoint: gatewayruntime.Endpoint{ + Host: "xworkmate-bridge.svc.plus", + Port: 443, + TLS: true, + }, + }, + ) + + if request.Endpoint.Host != "127.0.0.1" { + t.Fatalf("expected gateway host from env, got %#v", request.Endpoint) + } + if request.Endpoint.Port != 18789 { + t.Fatalf("expected gateway port from env, got %#v", request.Endpoint) + } + if request.Endpoint.TLS { + t.Fatalf("expected plaintext local gateway endpoint, got %#v", request.Endpoint) + } + if request.Endpoint.Path != "/gateway/openclaw" { + t.Fatalf("expected gateway URL path to be preserved, got %#v", request.Endpoint) + } +} diff --git a/internal/gatewayruntime/runtime.go b/internal/gatewayruntime/runtime.go index c93c4ac..0698c79 100644 --- a/internal/gatewayruntime/runtime.go +++ b/internal/gatewayruntime/runtime.go @@ -5,7 +5,9 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net" "net/http" + "net/url" "strings" "sync" "time" @@ -340,16 +342,12 @@ func sameConnectTarget(current ConnectRequest, next ConnectRequest) bool { return strings.TrimSpace(current.Mode) == strings.TrimSpace(next.Mode) && strings.TrimSpace(current.Endpoint.Host) == strings.TrimSpace(next.Endpoint.Host) && current.Endpoint.Port == next.Endpoint.Port && - current.Endpoint.TLS == next.Endpoint.TLS + current.Endpoint.TLS == next.Endpoint.TLS && + normalizeEndpointPath(current.Endpoint.Path) == normalizeEndpointPath(next.Endpoint.Path) } func (s *session) connectAttempt() (ConnectResult, *GatewayError) { - url := fmt.Sprintf( - "%s://%s:%d", - resolveRemoteScheme(s.config.Endpoint.TLS), - s.config.Endpoint.Host, - s.config.Endpoint.Port, - ) + url := remoteEndpointURL(s.config.Endpoint) dialer := websocket.Dialer{ HandshakeTimeout: s.manager.ConnectTimeout, Proxy: http.ProxyFromEnvironment, @@ -581,6 +579,25 @@ func (s *session) requestRemoteOnConn( } } +func remoteEndpointURL(endpoint Endpoint) string { + return (&url.URL{ + Scheme: resolveRemoteScheme(endpoint.TLS), + Host: net.JoinHostPort(endpoint.Host, fmt.Sprintf("%d", endpoint.Port)), + Path: normalizeEndpointPath(endpoint.Path), + }).String() +} + +func normalizeEndpointPath(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" || trimmed == "/" { + return "" + } + if strings.HasPrefix(trimmed, "/") { + return trimmed + } + return "/" + trimmed +} + func (s *session) resetConnAfterProtocolError(conn *websocket.Conn, err *GatewayError) { if conn == nil { return diff --git a/internal/gatewayruntime/runtime_test.go b/internal/gatewayruntime/runtime_test.go index dc76686..877b0e9 100644 --- a/internal/gatewayruntime/runtime_test.go +++ b/internal/gatewayruntime/runtime_test.go @@ -2,6 +2,7 @@ package gatewayruntime import ( "encoding/json" + "fmt" "net" "net/http" "strings" @@ -57,6 +58,96 @@ func TestManagerConnectAndRequest(t *testing.T) { } } +func TestManagerConnectAdvertisesCurrentOpenClawProtocol(t *testing.T) { + server := newFakeGatewayServer(t) + server.expectedProtocol = defaultProtocolVersion + defer server.Close() + + manager := NewManager() + result := manager.Connect(buildTestConnectRequest(server.Port()), func(map[string]any) {}) + if !result.OK { + t.Fatalf("expected connect success, got %#v", result.Error) + } + + params := server.LastConnectParams() + if params["minProtocol"] != float64(defaultProtocolVersion) { + t.Fatalf("expected minProtocol %d, got %#v", defaultProtocolVersion, params["minProtocol"]) + } + if params["maxProtocol"] != float64(defaultProtocolVersion) { + t.Fatalf("expected maxProtocol %d, got %#v", defaultProtocolVersion, params["maxProtocol"]) + } +} + +func TestGatewayFakeRejectsProtocol3AndAcceptsCurrentProtocol(t *testing.T) { + server := newFakeGatewayServer(t) + server.expectedProtocol = defaultProtocolVersion + defer server.Close() + + manager := NewManager() + result := manager.Connect(buildTestConnectRequest(server.Port()), func(map[string]any) {}) + if !result.OK { + t.Fatalf("expected current bridge protocol to connect, got %#v", result.Error) + } + + conn, _, err := websocket.DefaultDialer.Dial( + fmt.Sprintf("ws://127.0.0.1:%d", server.Port()), + nil, + ) + if err != nil { + t.Fatalf("dial fake gateway: %v", err) + } + defer func() { + _ = conn.Close() + }() + + var challenge map[string]any + if err := conn.ReadJSON(&challenge); err != nil { + t.Fatalf("read challenge: %v", err) + } + if challenge["event"] != "connect.challenge" { + t.Fatalf("expected connect challenge, got %#v", challenge) + } + + if err := conn.WriteJSON(map[string]any{ + "type": "req", + "id": "legacy-connect", + "method": "connect", + "params": map[string]any{ + "minProtocol": float64(3), + "maxProtocol": float64(3), + }, + }); err != nil { + t.Fatalf("write legacy connect: %v", err) + } + + var response map[string]any + if err := conn.ReadJSON(&response); err != nil { + t.Fatalf("read legacy connect response: %v", err) + } + if response["ok"] != false { + t.Fatalf("expected protocol 3 rejection, got %#v", response) + } + errorPayload := asMap(response["error"]) + if stringValue(errorPayload["message"]) != "protocol mismatch" { + t.Fatalf("expected protocol mismatch error, got %#v", response) + } +} + +func TestManagerConnectPreservesEndpointPath(t *testing.T) { + server := newFakeGatewayServer(t) + server.expectedPath = "/gateway/openclaw" + defer server.Close() + + manager := NewManager() + request := buildTestConnectRequest(server.Port()) + request.Endpoint.Path = "/gateway/openclaw" + + result := manager.Connect(request, func(map[string]any) {}) + if !result.OK { + t.Fatalf("expected connect success through path-scoped endpoint, got %#v", result.Error) + } +} + func TestManagerReconnectsAfterSocketClose(t *testing.T) { server := newFakeGatewayServer(t) server.closeAfterConnect.Store(true) @@ -281,6 +372,9 @@ type fakeGatewayServer struct { invalidNextRequest atomic.Bool connectErrorCode string connectErrorDetailCode string + expectedProtocol int + expectedPath string + lastConnectParams atomic.Value } func newFakeGatewayServer(t *testing.T) *fakeGatewayServer { @@ -293,6 +387,10 @@ func newFakeGatewayServer(t *testing.T) *fakeGatewayServer { upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if fake.expectedPath != "" && r.URL.Path != fake.expectedPath { + http.NotFound(w, r) + return + } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return @@ -352,6 +450,29 @@ func newFakeGatewayServer(t *testing.T) *fakeGatewayServer { case "connect": fake.connectCount.Add(1) connected = true + params := asMap(frame["params"]) + fake.lastConnectParams.Store(params) + if fake.expectedProtocol > 0 && + (params["minProtocol"] != float64(fake.expectedProtocol) || + params["maxProtocol"] != float64(fake.expectedProtocol)) { + _ = conn.WriteJSON(map[string]any{ + "type": "res", + "id": id, + "ok": false, + "error": map[string]any{ + "code": "INVALID_REQUEST", + "message": "protocol mismatch", + "details": map[string]any{ + "code": "PROTOCOL_MISMATCH", + "clientMinProtocol": params["minProtocol"], + "clientMaxProtocol": params["maxProtocol"], + "expectedProtocol": fake.expectedProtocol, + "minimumProbeProtocol": fake.expectedProtocol, + }, + }, + }) + continue + } if fake.connectErrorCode != "" { _ = conn.WriteJSON(map[string]any{ "type": "res", @@ -419,6 +540,15 @@ func (f *fakeGatewayServer) Port() int { return f.listener.Addr().(*net.TCPAddr).Port } +func (f *fakeGatewayServer) LastConnectParams() map[string]any { + value := f.lastConnectParams.Load() + if value == nil { + return map[string]any{} + } + params, _ := value.(map[string]any) + return params +} + func (f *fakeGatewayServer) ConnectCount() int { return int(f.connectCount.Load()) } diff --git a/internal/gatewayruntime/types.go b/internal/gatewayruntime/types.go index 981944a..ec2e2fd 100644 --- a/internal/gatewayruntime/types.go +++ b/internal/gatewayruntime/types.go @@ -3,7 +3,7 @@ package gatewayruntime import "time" const ( - defaultProtocolVersion = 3 + defaultProtocolVersion = 4 defaultReconnectDelay = 2 * time.Second defaultConnectTimeout = 10 * time.Second defaultChallengeWait = 2 * time.Second