From 9086f0483fafa5a58821fba3c0deed79085a2b81 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:34:18 +0000 Subject: [PATCH 1/2] fix: resolve WebSocket connection issues on Windows The WebSocket endpoint /pico/ws was previously requiring dashboard session cookies, which are not always sent by browsers (especially on Windows) during the WebSocket handshake. This change makes /pico/ws a public path in the dashboard middleware, allowing it to fall back to its own subprotocol-based token authentication. Additionally, standardize all occurrences of 'Sec-WebSocket-Protocol' to the canonical Go form 'Sec-Websocket-Protocol' in pkg/channels/pico/pico.go to ensure header consistency and avoid potential protocol mismatches in different network stacks. - Modified web/backend/middleware/launcher_dashboard_auth.go to allow /pico/ws. - Updated pkg/channels/pico/pico.go to use canonical header casing. - Verified changes with existing tests and code review. Co-authored-by: nglmercer <128845117+nglmercer@users.noreply.github.com> --- pkg/channels/pico/pico.go | 6 +++--- web/backend/middleware/launcher_dashboard_auth.go | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index e22da1ba1..c346ac094 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -349,7 +349,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { // Echo the matched subprotocol back so the browser accepts the upgrade. var responseHeader http.Header if proto := c.matchedSubprotocol(r); proto != "" { - responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}} + responseHeader = http.Header{"Sec-Websocket-Protocol": {proto}} } conn, err := c.upgrader.Upgrade(w, r, responseHeader) @@ -387,7 +387,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { // authenticate checks the request for a valid token: // 1. Authorization: Bearer header -// 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) +// 2. Sec-Websocket-Protocol "token." (for browsers that can't set headers) // 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { token := c.config.Token.String() @@ -403,7 +403,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } } - // Check Sec-WebSocket-Protocol subprotocol ("token.") + // Check Sec-Websocket-Protocol subprotocol ("token.") if c.matchedSubprotocol(r) != "" { return true } diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index 7e92fca22..f1c90f8da 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -166,6 +166,9 @@ func isPublicLauncherDashboardPath(method, p string) bool { if isPublicLauncherDashboardStatic(method, p) { return true } + if p == "/pico/ws" && method == http.MethodGet { + return true + } switch p { case "/api/auth/login": return method == http.MethodPost From cb745aebb9645ef6433c42e81ba083c14dd8315e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:39:36 +0000 Subject: [PATCH 2/2] fix: resolve WebSocket connection issues and add connection test script The WebSocket endpoint /pico/ws was previously blocked by dashboard session cookie requirements, which often fail on Windows during handshakes. This change allows /pico/ws to bypass the dashboard middleware and rely on its own subprotocol-based token authentication. Also standardizes 'Sec-Websocket-Protocol' casing for Go canonical header consistency and adds a standalone example script to demonstrate connecting with an AUTH token. - Modified web/backend/middleware/launcher_dashboard_auth.go to allow /pico/ws. - Standardized header casing in pkg/channels/pico/pico.go. - Added TestPicoChannel_AuthenticateSubprotocol in pkg/channels/pico/pico_test.go. - Created examples/ws_connect.go for testing and documentation. Co-authored-by: nglmercer <128845117+nglmercer@users.noreply.github.com> --- examples/ws_connect.go | 60 ++++++++++++++++++++++++++++++++++ pkg/channels/pico/pico_test.go | 48 +++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 examples/ws_connect.go diff --git a/examples/ws_connect.go b/examples/ws_connect.go new file mode 100644 index 000000000..6405457e8 --- /dev/null +++ b/examples/ws_connect.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/gorilla/websocket" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: go run examples/ws_connect.go ") + fmt.Println("Example: go run examples/ws_connect.go ws://localhost:18800/pico/ws my-secret-token") + return + } + + wsURL := os.Args[1] + token := os.Args[2] + + // The token is passed via the Sec-Websocket-Protocol header as "token.". + // This is the standard way to pass an auth token for WebSockets in a browser-compatible way. + header := http.Header{} + header.Set("Sec-Websocket-Protocol", "token."+token) + + fmt.Printf("Connecting to %s with token...\n", wsURL) + dialer := websocket.DefaultDialer + conn, resp, err := dialer.Dial(wsURL, header) + if err != nil { + if resp != nil { + log.Fatalf("Handshake failed with status %d: %v", resp.StatusCode, err) + } + log.Fatalf("Connection failed: %v", err) + } + defer conn.Close() + + // On success, the server echoes the subprotocol back. + fmt.Printf("Connected successfully! Server subprotocol: %s\n", conn.Subprotocol()) + + // Send a simple ping message using the Pico protocol format + fmt.Println("Sending ping message...") + pingMsg := map[string]any{ + "type": "ping", + "id": "msg-1", + } + if err := conn.WriteJSON(pingMsg); err != nil { + log.Fatalf("Failed to send ping: %v", err) + } + + // Read response + fmt.Println("Waiting for pong...") + var pongMsg map[string]any + if err := conn.ReadJSON(&pongMsg); err != nil { + log.Fatalf("Failed to read pong: %v", err) + } + + fmt.Printf("Received response: %+v\n", pongMsg) + fmt.Println("Test completed successfully.") +} diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index e712767ad..d1cc62f07 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "net/http" + "strings" "sync" "testing" @@ -142,3 +144,49 @@ func (c *PicoChannel) addConnForTest(pc *picoConn) { } bySession[pc.id] = pc } + +func TestPicoChannel_AuthenticateSubprotocol(t *testing.T) { + cfg := config.PicoConfig{} + cfg.SetToken("secret-token") + ch, _ := NewPicoChannel(cfg, bus.NewMessageBus()) + + tests := []struct { + name string + subprotocols []string + want bool + }{ + { + name: "Correct subprotocol", + subprotocols: []string{"token.secret-token"}, + want: true, + }, + { + name: "Incorrect subprotocol", + subprotocols: []string{"token.wrong-token"}, + want: false, + }, + { + name: "Multiple subprotocols with correct one", + subprotocols: []string{"other-proto", "token.secret-token"}, + want: true, + }, + { + name: "No subprotocols", + subprotocols: []string{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := make(http.Header) + if len(tt.subprotocols) > 0 { + header.Set("Sec-Websocket-Protocol", strings.Join(tt.subprotocols, ", ")) + } + req := &http.Request{Header: header} + if got := ch.authenticate(req); got != tt.want { + t.Errorf("authenticate() = %v, want %v", got, tt.want) + } + }) + } +}