Unify bridge RPC aliases

This commit is contained in:
Haitao Pan 2026-04-20 17:51:55 +08:00
parent 0a07c03f85
commit 4c0e8c3d01
3 changed files with 310 additions and 1 deletions

View File

@ -84,7 +84,7 @@ func newProductionProviderCatalog() (map[string]syncedProvider, []string) {
label: "Codex",
yaml: config.Upstream.CodexURL,
envKeys: []string{"CODEX_RPC_URL"},
defaultURL: "http://127.0.0.1:9001/acp/rpc",
defaultURL: "ws://127.0.0.1:9001/acp",
},
{
id: "opencode",

View File

@ -9,6 +9,7 @@ import (
"io"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
@ -102,6 +103,18 @@ func NewServer() *Server {
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if providerID, ok := parseProviderACPRPCPath(r.URL.Path); ok {
s.HandleProviderRPC(w, r, providerID)
return
}
if providerID, ok := parseProviderBarePath(r.URL.Path); ok {
s.HandleProviderAlias(w, r, providerID)
return
}
if strings.TrimSpace(r.URL.Path) == "/gateway/openclaw" {
s.HandleGatewayAlias(w, r)
return
}
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@ -123,12 +136,173 @@ func (s *Server) Handler() http.Handler {
s.HandleRPC(w, r)
case "/acp":
s.HandleWebSocket(w, r)
case "/gateway/openclaw/acp/rpc":
s.HandleGatewayRPCAlias(w, r)
default:
http.NotFound(w, r)
}
})
}
func parseProviderBarePath(pathValue string) (string, bool) {
trimmed := strings.Trim(path.Clean(strings.TrimSpace(pathValue)), "/")
parts := strings.Split(trimmed, "/")
if len(parts) != 2 {
return "", false
}
if parts[0] != "acp-server" {
return "", false
}
switch parts[1] {
case "codex", "opencode", "gemini", "hermes":
return parts[1], true
default:
return "", false
}
}
func parseProviderACPRPCPath(path string) (string, bool) {
trimmed := strings.Trim(strings.TrimSpace(path), "/")
parts := strings.Split(trimmed, "/")
if len(parts) != 4 {
return "", false
}
if parts[0] != "acp-server" || parts[2] != "acp" || parts[3] != "rpc" {
return "", false
}
switch parts[1] {
case "codex", "opencode", "gemini", "hermes":
return parts[1], true
default:
return "", false
}
}
func (s *Server) HandleProviderAlias(w http.ResponseWriter, r *http.Request, providerID string) {
if r.Method == http.MethodGet {
s.writeAliasCapabilities(w, providerID, "agent")
return
}
s.HandleProviderRPC(w, r, providerID)
}
func (s *Server) HandleGatewayAlias(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.writeAliasCapabilities(w, "openclaw", "gateway")
return
}
s.HandleGatewayRPCAlias(w, r)
}
func (s *Server) writeAliasCapabilities(w http.ResponseWriter, providerID, target string) {
result, rpcErr := s.handleRequest(shared.RPCRequest{
JSONRPC: "2.0",
Method: "acp.capabilities",
Params: map[string]any{
"preferredExecutionTarget": target,
"preferredProviderId": providerID,
},
}, nil)
if rpcErr != nil {
s.writeJSONError(w, nil, http.StatusOK, rpcErr.Code, rpcErr.Message)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(shared.ResultEnvelope(nil, result))
}
func (s *Server) HandleGatewayRPCAlias(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway/openclaw" && r.URL.Path != "/gateway/openclaw/acp/rpc" {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
r = r.Clone(r.Context())
r.URL.Path = "/acp/rpc"
s.HandleRPC(w, r)
return
}
s.HandleRPC(w, r)
}
func (s *Server) HandleProviderRPC(w http.ResponseWriter, r *http.Request, providerID string) {
if r.Method == http.MethodGet {
http.NotFound(w, r)
return
}
s.applyCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
s.writeJSONError(
w,
nil,
http.StatusMethodNotAllowed,
-32600,
"method not allowed",
)
return
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if !s.originAllowed(origin) {
s.writeJSONError(
w,
nil,
http.StatusForbidden,
-32003,
fmt.Sprintf("origin not allowed: %s", origin),
)
return
}
if !s.authorized(r) {
s.writeJSONError(
w,
nil,
http.StatusUnauthorized,
-32001,
"missing bearer authorization",
)
return
}
payload, err := io.ReadAll(r.Body)
if err != nil {
s.writeJSONError(w, nil, http.StatusBadRequest, -32600, "invalid body")
return
}
request, err := shared.DecodeRPCRequest(payload)
if err != nil {
s.writeJSONError(w, nil, http.StatusBadRequest, -32700, err.Error())
return
}
params := request.Params
if params == nil {
params = map[string]any{}
}
params["routing"] = map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": providerID,
}
request.Params = injectInboundAuthorizationHeader(
params,
r.Header.Get("Authorization"),
)
response, rpcErr := s.handleRequest(request, nil)
if request.ID == nil {
return
}
if rpcErr != nil {
s.writeJSONError(w, request.ID, http.StatusOK, rpcErr.Code, rpcErr.Message)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(shared.ResultEnvelope(request.ID, response))
}
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if !s.originAllowed(origin) {

View File

@ -58,6 +58,141 @@ func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
}
}
func TestHTTPHandlerBareAliasPathsExposeCapabilities(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
handler := server.Handler()
cases := []struct {
name string
path string
wantMode string
wantID string
}{
{name: "gateway", path: "/gateway/openclaw", wantMode: "gateway", wantID: "openclaw"},
{name: "codex", path: "/acp-server/codex", wantMode: "agent", wantID: "codex"},
{name: "opencode", path: "/acp-server/opencode", wantMode: "agent", wantID: "opencode"},
{name: "gemini", path: "/acp-server/gemini", wantMode: "agent", wantID: "gemini"},
{name: "hermes", path: "/acp-server/hermes", wantMode: "agent", wantID: "hermes"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1"+tc.path, nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if got := recorder.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("expected application/json content type, got %q", got)
}
var envelope map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode capability alias response: %v", err)
}
result := asMap(envelope["result"])
if got := result["singleAgent"]; got != true {
t.Fatalf("expected singleAgent true, got %#v", got)
}
if got := result["resolvedExecutionTarget"]; got != nil {
t.Fatalf("did not expect resolvedExecutionTarget in alias response, got %#v", got)
}
if tc.wantMode == "gateway" {
gatewayProviders := mustObjectList(t, result["gatewayProviders"])
if len(gatewayProviders) != 1 {
t.Fatalf("expected one gateway provider, got %#v", gatewayProviders)
}
if got := gatewayProviders[0]["providerId"]; got != tc.wantID {
t.Fatalf("expected gateway provider %q, got %#v", tc.wantID, got)
}
return
}
providerCatalog := mustObjectList(t, result["providerCatalog"])
found := false
for _, provider := range providerCatalog {
if provider["providerId"] == tc.wantID {
found = true
break
}
}
if !found {
t.Fatalf("expected provider %q in providerCatalog, got %#v", tc.wantID, providerCatalog)
}
})
}
}
func TestHTTPHandlerBareAliasPathsServeRPC(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
handler := server.Handler()
cases := []struct {
name string
path string
wantMode string
wantID string
}{
{name: "gateway", path: "/gateway/openclaw", wantMode: "gateway", wantID: "openclaw"},
{name: "codex", path: "/acp-server/codex", wantMode: "agent", wantID: "codex"},
{name: "opencode", path: "/acp-server/opencode", wantMode: "agent", wantID: "opencode"},
{name: "gemini", path: "/acp-server/gemini", wantMode: "agent", wantID: "gemini"},
{name: "hermes", path: "/acp-server/hermes", wantMode: "agent", wantID: "hermes"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1"+tc.path, strings.NewReader(`{"jsonrpc":"2.0","id":"rpc-1","method":"acp.capabilities"}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer test-token")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if got := recorder.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("expected application/json content type, got %q", got)
}
var envelope map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode rpc alias response: %v", err)
}
if got := envelope["id"]; got != "rpc-1" {
t.Fatalf("expected rpc id rpc-1, got %#v", got)
}
result := asMap(envelope["result"])
if tc.wantMode == "gateway" {
gatewayProviders := mustObjectList(t, result["gatewayProviders"])
if len(gatewayProviders) != 1 {
t.Fatalf("expected one gateway provider, got %#v", gatewayProviders)
}
if got := gatewayProviders[0]["providerId"]; got != tc.wantID {
t.Fatalf("expected gateway provider %q, got %#v", tc.wantID, got)
}
return
}
providerCatalog := mustObjectList(t, result["providerCatalog"])
found := false
for _, provider := range providerCatalog {
if provider["providerId"] == tc.wantID {
found = true
break
}
}
if !found {
t.Fatalf("expected provider %q in providerCatalog, got %#v", tc.wantID, providerCatalog)
}
})
}
}
func TestParseImageVersionInfoHandlesTaggedImageRef(t *testing.T) {
info := parseImageVersionInfo("ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12")