package gatewayruntime import ( "encoding/json" "fmt" "net" "net/http" "strings" "sync" "sync/atomic" "testing" "time" "github.com/gorilla/websocket" ) func TestManagerConnectAndRequest(t *testing.T) { server := newFakeGatewayServer(t) defer server.Close() manager := NewManager() manager.ReconnectDelay = 20 * time.Millisecond notifications := make([]map[string]any, 0, 8) var mu sync.Mutex notify := func(message map[string]any) { mu.Lock() defer mu.Unlock() notifications = append(notifications, message) } result := manager.Connect(buildTestConnectRequest(server.Port()), notify) if !result.OK { t.Fatalf("expected connect success, got %#v", result.Error) } if result.ReturnedDeviceToken != "device-token-1" { t.Fatalf("expected returned device token, got %#v", result.ReturnedDeviceToken) } requestResult := manager.Request( "runtime-1", "health", map[string]any{}, 2*time.Second, notify, ) if !requestResult.OK { t.Fatalf("expected health success, got %#v", requestResult.Error) } payload, ok := requestResult.Payload.(map[string]any) if !ok || payload["status"] != "ok" { t.Fatalf("unexpected health payload %#v", requestResult.Payload) } mu.Lock() defer mu.Unlock() if len(notifications) == 0 { t.Fatalf("expected notifications during connect") } } 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) defer server.Close() manager := NewManager() manager.ReconnectDelay = 25 * time.Millisecond reconnected := make(chan struct{}, 1) notify := func(message map[string]any) { params := asMap(message["params"]) if strings.TrimSpace(stringValue(message["method"])) != "xworkmate.gateway.snapshot" { return } snapshot := asMap(params["snapshot"]) if snapshot["status"] == "connected" && server.ConnectCount() >= 2 { select { case reconnected <- struct{}{}: default: } } } result := manager.Connect(buildTestConnectRequest(server.Port()), notify) if !result.OK { t.Fatalf("expected connect success, got %#v", result.Error) } select { case <-reconnected: case <-time.After(3 * time.Second): t.Fatalf("expected reconnect to complete; connect count=%d", server.ConnectCount()) } } func TestManagerSerializesConcurrentConnectReuseBeforeRequests(t *testing.T) { server := newFakeGatewayServer(t) server.enforceConnectFirst.Store(true) defer server.Close() manager := NewManager() manager.ReconnectDelay = 20 * time.Millisecond const workers = 5 start := make(chan struct{}) errs := make(chan string, workers) var wg sync.WaitGroup for index := 0; index < workers; index++ { wg.Add(1) go func() { defer wg.Done() <-start connectResult := manager.Connect( buildTestConnectRequest(server.Port()), func(map[string]any) {}, ) if !connectResult.OK { errs <- "connect failed: " + stringValue(connectResult.Error["message"]) return } requestResult := manager.Request( "runtime-1", "chat.send", map[string]any{"message": "pong"}, 2*time.Second, func(map[string]any) {}, ) if !requestResult.OK { errs <- "request failed: " + stringValue(requestResult.Error["message"]) return } }() } close(start) wg.Wait() close(errs) for err := range errs { t.Fatal(err) } if got := server.InvalidHandshakeCount(); got != 0 { t.Fatalf("expected no invalid handshake, got %d", got) } if got := server.ConnectCount(); got != 1 { t.Fatalf("expected concurrent connect calls to reuse one established gateway session, got %d", got) } } func TestManagerDropsConnectionAfterInvalidHandshake(t *testing.T) { server := newFakeGatewayServer(t) 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) } server.invalidNextRequest.Store(true) failed := manager.Request( "runtime-1", "chat.send", map[string]any{"message": "pong"}, 2*time.Second, func(map[string]any) {}, ) if failed.OK { t.Fatalf("expected invalid handshake failure") } if got := stringValue(failed.Error["code"]); got != "INVALID_HANDSHAKE" { t.Fatalf("expected invalid handshake code, got %#v", failed.Error) } reconnected := manager.Connect(buildTestConnectRequest(server.Port()), func(map[string]any) {}) if !reconnected.OK { t.Fatalf("expected reconnect success, got %#v", reconnected.Error) } requestResult := manager.Request( "runtime-1", "chat.send", map[string]any{"message": "pong"}, 2*time.Second, func(map[string]any) {}, ) if !requestResult.OK { t.Fatalf("expected request after reconnect to succeed, got %#v", requestResult.Error) } if got := server.ConnectCount(); got != 2 { t.Fatalf("expected reconnect after invalid handshake, got %d connects", got) } } func TestManagerSuppressesReconnectForPairingRequired(t *testing.T) { server := newFakeGatewayServer(t) server.connectErrorCode = "NOT_PAIRED" server.connectErrorDetailCode = "PAIRING_REQUIRED" defer server.Close() manager := NewManager() manager.ReconnectDelay = 20 * time.Millisecond result := manager.Connect(buildTestConnectRequest(server.Port()), func(map[string]any) {}) if result.OK { t.Fatalf("expected connect failure") } time.Sleep(120 * time.Millisecond) if server.ConnectCount() != 1 { t.Fatalf("expected reconnect suppression, got %d connect attempts", server.ConnectCount()) } } func TestSessionEmitsNormalizedChatRunPushEvents(t *testing.T) { manager := NewManager() session := newSession(manager, "runtime-1") notifications := make([]map[string]any, 0, 8) session.setNotify(func(message map[string]any) { notifications = append(notifications, message) }) session.handleEvent( "chat", map[string]any{"seq": 7}, map[string]any{ "runId": "run-1", "sessionKey": "main", "state": "final", "message": map[string]any{ "role": "assistant", "content": []any{ map[string]any{"type": "text", "text": "XWORKMATE_OK"}, }, }, }, ) session.handleEvent( "agent", map[string]any{"seq": 8}, map[string]any{ "runId": "run-1", "stream": "assistant", "data": map[string]any{ "text": "DELTA_TEXT", }, }, ) normalized := make([]map[string]any, 0, 2) for _, notification := range notifications { if strings.TrimSpace(stringValue(notification["method"])) != "xworkmate.gateway.push" { continue } params := asMap(notification["params"]) event := asMap(params["event"]) if strings.TrimSpace(stringValue(event["event"])) != "chat.run" { continue } normalized = append(normalized, asMap(event["payload"])) } if len(normalized) != 2 { t.Fatalf("expected 2 normalized chat.run notifications, got %#v", normalized) } if normalized[0]["runId"] != "run-1" || normalized[0]["state"] != "final" { t.Fatalf("unexpected normalized chat payload %#v", normalized[0]) } if normalized[0]["assistantText"] != "XWORKMATE_OK" { t.Fatalf("expected final assistant text, got %#v", normalized[0]) } if normalized[0]["terminal"] != true { t.Fatalf("expected terminal final chat.run, got %#v", normalized[0]) } if normalized[1]["assistantText"] != "DELTA_TEXT" || normalized[1]["state"] != "delta" { t.Fatalf("unexpected normalized agent payload %#v", normalized[1]) } } type fakeGatewayServer struct { server *http.Server listener net.Listener connectCount atomic.Int32 invalidHandshakeCount atomic.Int32 closeAfterConnect atomic.Bool enforceConnectFirst atomic.Bool invalidNextRequest atomic.Bool connectErrorCode string connectErrorDetailCode string expectedProtocol int expectedPath string lastConnectParams atomic.Value } func newFakeGatewayServer(t *testing.T) *fakeGatewayServer { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } fake := &fakeGatewayServer{listener: listener} 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 } defer func() { _ = conn.Close() }() _ = conn.WriteJSON(map[string]any{ "type": "event", "event": "connect.challenge", "payload": map[string]any{ "nonce": "nonce-1", }, }) connected := false for { _, payload, err := conn.ReadMessage() if err != nil { return } var frame map[string]any if err := json.Unmarshal(payload, &frame); err != nil { continue } if frame["type"] != "req" { continue } id := frame["id"] method := stringValue(frame["method"]) if fake.enforceConnectFirst.Load() && !connected && method != "connect" { fake.invalidHandshakeCount.Add(1) _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "INVALID_HANDSHAKE", "message": "invalid handshake: first request must be connect", }, }) continue } if fake.invalidNextRequest.Swap(false) && method != "connect" { fake.invalidHandshakeCount.Add(1) _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "INVALID_HANDSHAKE", "message": "invalid handshake: first request must be connect", }, }) continue } switch method { 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", "id": id, "ok": false, "error": map[string]any{ "code": fake.connectErrorCode, "message": "connect failed", "details": map[string]any{ "code": fake.connectErrorDetailCode, }, }, }) continue } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "server": map[string]any{"host": "127.0.0.1"}, "snapshot": map[string]any{ "sessionDefaults": map[string]any{"mainSessionKey": "main"}, }, "auth": map[string]any{ "role": "operator", "scopes": defaultOperatorScopes, "deviceToken": "device-token-1", }, }, }) if fake.closeAfterConnect.Load() && fake.connectCount.Load() == 1 { go func() { time.Sleep(20 * time.Millisecond) _ = conn.Close() }() } case "health": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "status": "ok", }, }) default: _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{}, }) } } }) fake.server = &http.Server{Handler: mux} go func() { _ = fake.server.Serve(listener) }() return fake } 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()) } func (f *fakeGatewayServer) InvalidHandshakeCount() int { return int(f.invalidHandshakeCount.Load()) } func (f *fakeGatewayServer) Close() { _ = f.server.Close() } func buildTestConnectRequest(port int) ConnectRequest { return ConnectRequest{ RuntimeID: "runtime-1", Mode: "openclaw", ClientID: "openclaw-macos", Locale: "en_US", UserAgent: "XWorkmate/1.0.0", Endpoint: Endpoint{ Host: "127.0.0.1", Port: port, TLS: false, }, ConnectAuthMode: "shared-token", ConnectAuthFields: []string{"token"}, ConnectAuthSources: []string{"shared:form"}, HasSharedAuth: true, HasDeviceToken: false, PackageInfo: PackageInfo{ AppName: "XWorkmate", Version: "1.0.0", }, DeviceInfo: DeviceInfo{ Platform: "macos", PlatformVersion: "14.0", DeviceFamily: "Mac", ModelIdentifier: "Mac14,5", }, Identity: DeviceIdentity{ DeviceID: "device-1", PublicKeyBase64URL: "tl4fnKW7VLD0Cl4lQTu2CEgHPs4PWAX7eVgWfWQWk2Q", PrivateKeyBase64URL: "dr7GfMKoO-lJBtgA0dE5m6f_X4kEFsxChDc7mW8mkXu2Xh-cpbsUsPQKXiVBO7YISAc-zg9YBft5WBZ9ZBaTZA", }, Auth: AuthConfig{ Token: "shared-token", }, } }