From 634052af0795bcf8b846e8db32ef01a04cb9c845 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 10 Apr 2026 15:36:05 +0800 Subject: [PATCH] Add bridge bootstrap consume endpoint --- internal/acp/bootstrap.go | 144 +++++++++++++++++++++++++++++++++ internal/acp/bootstrap_test.go | 64 +++++++++++++++ internal/acp/server.go | 17 ++-- 3 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 internal/acp/bootstrap.go create mode 100644 internal/acp/bootstrap_test.go diff --git a/internal/acp/bootstrap.go b/internal/acp/bootstrap.go new file mode 100644 index 0000000..e48be41 --- /dev/null +++ b/internal/acp/bootstrap.go @@ -0,0 +1,144 @@ +package acp + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "xworkmate-bridge/internal/shared" +) + +type bridgeBootstrapConsumeRequest struct { + Ticket string `json:"ticket"` + Bridge string `json:"bridge"` +} + +type accountsBridgeBootstrapConsumeResponse struct { + TicketID string `json:"ticketId"` + TargetBridge string `json:"targetBridge"` + OpenclawURL string `json:"openclawUrl"` + AuthMode string `json:"authMode"` + ExchangeToken string `json:"exchangeToken"` + ExpiresAt string `json:"expiresAt"` + Scopes []string `json:"scopes"` +} + +type bridgeBootstrapResponse struct { + SetupCode string `json:"setupCode"` + BridgeOrigin string `json:"bridgeOrigin"` + AuthMode string `json:"authMode"` + ExpiresAt string `json:"expiresAt"` + IssuedBy string `json:"issuedBy"` + Scopes []string `json:"scopes"` +} + +func (s *Server) HandleBridgeBootstrapHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "bridgeOrigin": bridgePublicBaseURL(), + "issuedBy": "xworkmate-bridge", + }) +} + +func (s *Server) HandleBridgeBootstrapConsume(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req bridgeBootstrapConsumeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + req.Ticket = strings.TrimSpace(req.Ticket) + req.Bridge = strings.TrimSpace(req.Bridge) + if req.Ticket == "" { + http.Error(w, "ticket is required", http.StatusBadRequest) + return + } + if req.Bridge == "" { + req.Bridge = bridgePublicBaseURL() + } + + payload, status, err := consumeBootstrapFromAccounts(req) + if err != nil { + http.Error(w, err.Error(), status) + return + } + + setupCodePayload := map[string]any{ + "url": payload.OpenclawURL, + "token": payload.ExchangeToken, + "exchangeToken": payload.ExchangeToken, + "authMode": payload.AuthMode, + "expiresAt": payload.ExpiresAt, + "bridgeOrigin": req.Bridge, + "issuedBy": "xworkmate-bridge", + } + setupCodeBytes, err := json.Marshal(setupCodePayload) + if err != nil { + http.Error(w, "failed to encode setup payload", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(bridgeBootstrapResponse{ + SetupCode: string(setupCodeBytes), + BridgeOrigin: req.Bridge, + AuthMode: payload.AuthMode, + ExpiresAt: payload.ExpiresAt, + IssuedBy: "xworkmate-bridge", + Scopes: append([]string(nil), payload.Scopes...), + }) +} + +func consumeBootstrapFromAccounts(req bridgeBootstrapConsumeRequest) (accountsBridgeBootstrapConsumeResponse, int, error) { + baseURL := strings.TrimRight(strings.TrimSpace(shared.EnvOrDefault("ACCOUNTS_BASE_URL", "https://accounts.svc.plus")), "/") + if baseURL == "" { + return accountsBridgeBootstrapConsumeResponse{}, http.StatusInternalServerError, fmt.Errorf("accounts base url is not configured") + } + serviceToken := strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", "")) + if serviceToken == "" { + return accountsBridgeBootstrapConsumeResponse{}, http.StatusInternalServerError, fmt.Errorf("internal service token is not configured") + } + body, err := json.Marshal(req) + if err != nil { + return accountsBridgeBootstrapConsumeResponse{}, http.StatusInternalServerError, fmt.Errorf("failed to encode consume request") + } + httpReq, err := http.NewRequest(http.MethodPost, baseURL+"/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader(body)) + if err != nil { + return accountsBridgeBootstrapConsumeResponse{}, http.StatusInternalServerError, fmt.Errorf("failed to create consume request") + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Service-Token", serviceToken) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return accountsBridgeBootstrapConsumeResponse{}, http.StatusBadGateway, fmt.Errorf("failed to contact accounts service") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return accountsBridgeBootstrapConsumeResponse{}, resp.StatusCode, fmt.Errorf("accounts bootstrap consume failed") + } + var payload accountsBridgeBootstrapConsumeResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return accountsBridgeBootstrapConsumeResponse{}, http.StatusBadGateway, fmt.Errorf("failed to decode accounts bootstrap response") + } + return payload, http.StatusOK, nil +} + +func bridgePublicBaseURL() string { + value := strings.TrimSpace(shared.EnvOrDefault("BRIDGE_PUBLIC_BASE_URL", "https://xworkmate-bridge.svc.plus")) + if value == "" { + return "https://xworkmate-bridge.svc.plus" + } + return strings.TrimRight(value, "/") +} diff --git a/internal/acp/bootstrap_test.go b/internal/acp/bootstrap_test.go new file mode 100644 index 0000000..adbbb49 --- /dev/null +++ b/internal/acp/bootstrap_test.go @@ -0,0 +1,64 @@ +package acp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleBridgeBootstrapConsumeReturnsSetupCode(t *testing.T) { + accounts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/internal/xworkmate/bridge/bootstrap/consume" { + http.NotFound(w, r) + return + } + if got := r.Header.Get("X-Service-Token"); got != "internal-test-token" { + http.Error(w, "missing service token", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(accountsBridgeBootstrapConsumeResponse{ + TicketID: "ticket-1", + TargetBridge: "https://xworkmate-bridge.svc.plus", + OpenclawURL: "wss://openclaw.svc.plus", + AuthMode: "shared-token", + ExchangeToken: "shared-token-value", + ExpiresAt: "2026-04-10T00:00:00Z", + Scopes: []string{"connect", "pairing.bootstrap"}, + }) + })) + defer accounts.Close() + + t.Setenv("ACCOUNTS_BASE_URL", accounts.URL) + t.Setenv("INTERNAL_SERVICE_TOKEN", "internal-test-token") + t.Setenv("BRIDGE_PUBLIC_BASE_URL", "https://xworkmate-bridge.svc.plus") + + server := NewServer() + body := bytes.NewBufferString(`{"ticket":"ticket-1","bridge":"https://xworkmate-bridge.svc.plus"}`) + request := httptest.NewRequest(http.MethodPost, "/bridge/bootstrap/consume", body) + recorder := httptest.NewRecorder() + + server.HandleBridgeBootstrapConsume(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected consume success, got %d: %s", recorder.Code, recorder.Body.String()) + } + var payload bridgeBootstrapResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.SetupCode == "" { + t.Fatalf("expected non-empty setup code") + } + var decoded map[string]any + if err := json.Unmarshal([]byte(payload.SetupCode), &decoded); err != nil { + t.Fatalf("decode setup code payload: %v", err) + } + if decoded["url"] != "wss://openclaw.svc.plus" { + t.Fatalf("expected openclaw url in setup payload, got %#v", decoded) + } + if decoded["token"] != "shared-token-value" { + t.Fatalf("expected exchange token in setup payload, got %#v", decoded) + } +} diff --git a/internal/acp/server.go b/internal/acp/server.go index d7c6145..b8ab5a1 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -75,6 +75,13 @@ func Serve(args []string) error { Addr: strings.TrimSpace(*listen), Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/": + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("xworkmate-bridge is running")) + case "/bridge/bootstrap/health": + server.HandleBridgeBootstrapHealth(w, r) + case "/bridge/bootstrap/consume": + server.HandleBridgeBootstrapConsume(w, r) case "/acp/rpc": server.HandleRPC(w, r) case "/acp": @@ -848,11 +855,11 @@ func (s *Server) runSingleAgent( return taskResult{ response: enrichSingleAgentResultArtifacts(map[string]any{ - "success": true, - "output": output, - "turnId": turnID, - "mode": "single-agent", - "provider": provider, + "success": true, + "output": output, + "turnId": turnID, + "mode": "single-agent", + "provider": provider, "effectiveWorkingDirectory": effectiveWorkingDirectory, }, params), }