diff --git a/core/management.go b/core/management.go index 1d39e1a..1667a82 100644 --- a/core/management.go +++ b/core/management.go @@ -109,9 +109,23 @@ func (m *ManagementServer) SetSaveGlobalSettings(fn func(map[string]any) error) m.saveGlobalSettings = fn } - func (m *ManagementServer) Start() { mux := http.NewServeMux() + handler := m.buildHandler(mux) + + m.server = &http.Server{ + Addr: fmt.Sprintf(":%d", m.port), + Handler: handler, + } + go func() { + if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("management api server error", "error", err) + } + }() + slog.Info("management api started", "port", m.port) +} + +func (m *ManagementServer) buildHandler(mux *http.ServeMux) http.Handler { prefix := "/api/v1" // System @@ -141,18 +155,7 @@ func (m *ManagementServer) Start() { mux.HandleFunc(prefix+"/bridge/adapters", m.wrap(m.handleBridgeAdapters)) // Static file serving for cc-connect-web (SPA) - handler := m.withStaticFallback(mux) - - m.server = &http.Server{ - Addr: fmt.Sprintf(":%d", m.port), - Handler: handler, - } - go func() { - if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("management api server error", "error", err) - } - }() - slog.Info("management api started", "port", m.port) + return m.withStaticFallback(mux) } func (m *ManagementServer) Stop() { @@ -170,6 +173,10 @@ func (m *ManagementServer) withStaticFallback(apiMux *http.ServeMux) http.Handle apiMux.ServeHTTP(w, r) return } + if m.bridgeServer != nil && r.URL.Path == m.bridgeServer.path { + m.bridgeServer.handleWS(w, r) + return + } assets := GetWebAssets() if assets == nil { apiMux.ServeHTTP(w, r) @@ -314,6 +321,7 @@ func (m *ManagementServer) handleStatus(w http.ResponseWriter, r *http.Request) "enabled": true, "port": m.bridgeServer.port, "path": m.bridgeServer.path, + "token": m.bridgeServer.token, "token_set": m.bridgeServer.token != "", } } @@ -592,14 +600,14 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re if r.Method == http.MethodPatch { var body struct { - Quiet *bool `json:"quiet"` - Language *string `json:"language"` - AdminFrom *string `json:"admin_from"` - DisabledCommands []string `json:"disabled_commands"` - WorkDir *string `json:"work_dir"` - Mode *string `json:"mode"` - ShowContextIndicator *bool `json:"show_context_indicator"` - PlatformAllowFrom map[string]string `json:"platform_allow_from"` + Quiet *bool `json:"quiet"` + Language *string `json:"language"` + AdminFrom *string `json:"admin_from"` + DisabledCommands []string `json:"disabled_commands"` + WorkDir *string `json:"work_dir"` + Mode *string `json:"mode"` + ShowContextIndicator *bool `json:"show_context_indicator"` + PlatformAllowFrom map[string]string `json:"platform_allow_from"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) diff --git a/core/management_test.go b/core/management_test.go index 4fe12e2..6dd52fe 100644 --- a/core/management_test.go +++ b/core/management_test.go @@ -191,6 +191,34 @@ func TestMgmt_Status(t *testing.T) { } } +func TestMgmt_StatusIncludesBridgeToken(t *testing.T) { + mgmt, ts, _ := testManagementServer(t, "tok") + mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", nil)) + + r := mgmtGet(t, ts.URL+"/api/v1/status", "tok") + if !r.OK { + t.Fatalf("status failed: %s", r.Error) + } + + var data struct { + Bridge struct { + Enabled bool `json:"enabled"` + Port int `json:"port"` + Path string `json:"path"` + Token string `json:"token"` + } `json:"bridge"` + } + if err := json.Unmarshal(r.Data, &data); err != nil { + t.Fatalf("unmarshal status data: %v", err) + } + if !data.Bridge.Enabled { + t.Fatal("expected bridge to be enabled") + } + if data.Bridge.Token != "bridge-secret" { + t.Fatalf("expected bridge token, got %q", data.Bridge.Token) + } +} + func TestMgmt_Projects(t *testing.T) { _, ts, _ := testManagementServer(t, "tok") @@ -482,6 +510,57 @@ func TestMgmt_CORS(t *testing.T) { } } +func TestMgmt_BridgeWebSocketPathProxiesToBridgeServer(t *testing.T) { + mgmt := NewManagementServer(0, "", []string{"*"}) + mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish)) + mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", []string{"*"})) + + mux := http.NewServeMux() + ts := httptest.NewServer(mgmt.buildHandler(mux)) + defer ts.Close() + + req, _ := http.NewRequest("GET", ts.URL+"/bridge/ws?token=bridge-secret", nil) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected websocket upgrade, got %d", resp.StatusCode) + } +} + +func TestMgmt_BridgeWebSocketPathWorksWhenBridgeServerSetAfterHandlerBuild(t *testing.T) { + mgmt := NewManagementServer(0, "", []string{"*"}) + mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish)) + + mux := http.NewServeMux() + ts := httptest.NewServer(mgmt.buildHandler(mux)) + defer ts.Close() + + mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", []string{"*"})) + + req, _ := http.NewRequest("GET", ts.URL+"/bridge/ws?token=bridge-secret", nil) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected websocket upgrade after late bridge setup, got %d", resp.StatusCode) + } +} + func TestMgmt_MethodNotAllowed(t *testing.T) { _, ts, _ := testManagementServer(t, "tok") diff --git a/web/src/pages/Chat/ChatView.tsx b/web/src/pages/Chat/ChatView.tsx index 4ab443c..e815a88 100644 --- a/web/src/pages/Chat/ChatView.tsx +++ b/web/src/pages/Chat/ChatView.tsx @@ -699,11 +699,16 @@ export default function ChatView() { {sending ? : } - ) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? ( + ) : !bridgeCfg ? (
{t('sessions.bridgeNotAvailable')}
+ ) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? ( +
+ + {t('sessions.bridgeDisconnected')} +
) : (
diff --git a/web/src/pages/Sessions/SessionChat.tsx b/web/src/pages/Sessions/SessionChat.tsx index 5ff2dcd..2582e1d 100644 --- a/web/src/pages/Sessions/SessionChat.tsx +++ b/web/src/pages/Sessions/SessionChat.tsx @@ -536,11 +536,16 @@ export default function SessionChat() { )}
- ) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? ( + ) : !bridgeCfg ? (
{t('sessions.bridgeNotAvailable', 'Bridge not available. Enable [bridge] in config.toml to chat from web.')}
+ ) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? ( +
+ + {t('sessions.bridgeDisconnected', 'Bridge disconnected.')} +
) : (