fix: remove legacy bridge public aliases

This commit is contained in:
Haitao Pan 2026-05-02 17:45:17 +08:00
parent 1326f577ee
commit 6e2daf2e3d
8 changed files with 70 additions and 143 deletions

View File

@ -64,6 +64,6 @@ Last Updated: 2026-04-23
## Notes
- canonical transport 是 `GET /acp` WebSocket
- `/acp/rpc` 仅作为 secondary compatibility transport
- App-facing canonical HTTP transport 是 `POST /acp/rpc`
- `GET /acp` WebSocket 仅作为 ACP transport variant
- app-facing contract 由 bridge control plane 独占,不由 provider alias path 定义

View File

@ -4,10 +4,10 @@
当前定位:
- `xworkmate-bridge` 是 **APP-facing ACP control plane and provider compatibility layer**
- canonical transport 是 `GET /acp` 上的 **JSON-RPC over WebSocket**
- `POST /acp/rpc` 保留为 secondary compatibility transport
- `/acp-server/*`、`/gateway/openclaw` 不再是 public API
- `xworkmate-bridge` 是 **APP-facing ACP control plane and provider runtime layer**
- App-facing canonical HTTP transport 是 `POST /acp/rpc`
- `GET /acp` WebSocket 仅保留为 ACP transport variant
- `/acp-server/*`、`/gateway/openclaw` 不再是 public API,也不再提供 alias handler
## 1. Runtime Entry Points
@ -28,9 +28,9 @@
| Path | Method / Protocol | Auth | 用途 |
| --- | --- | --- | --- |
| `/` | `GET` | 否 | 纯文本运行状态 |
| `/api/ping` | `GET` | | 发布版本探针 |
| `/acp` | `GET` + WebSocket upgrade | 是 | canonical JSON-RPC transport |
| `/acp/rpc` | `POST` | 是 | secondary compatibility transport |
| `/api/ping` | `GET` | | 发布版本探针 |
| `/acp` | `GET` + WebSocket upgrade | 是 | JSON-RPC WebSocket transport |
| `/acp/rpc` | `POST` | 是 | App-facing JSON-RPC HTTP transport |
| `/acp/rpc` | `OPTIONS` | 否 | CORS preflight |
其他路径返回 `404 Not Found`
@ -46,7 +46,7 @@
- `/acp``/acp/rpc` 都做 origin allowlist 校验
- 空 `Origin` 默认允许
- auth 使用 bearer header
- `/api/ping`、`/acp`、`/acp/rpc` 在 `BRIDGE_AUTH_TOKEN` 非空时都要求 bearer header
- `BRIDGE_AUTH_TOKEN` 为空时默认放行
- `BRIDGE_AUTH_TOKEN` 非空时,接受裸 token 或 `Bearer <token>`

View File

@ -30,7 +30,7 @@ flowchart LR
subgraph BRIDGE["xworkmate-bridge"]
B1["GET /acp<br/>JSON-RPC over WebSocket"]
B2["POST /acp/rpc<br/>secondary compatibility"]
B2["POST /acp/rpc<br/>App-facing HTTP RPC"]
B3["acp.capabilities"]
B4["xworkmate.routing.resolve"]
B5["session.*"]

View File

@ -77,13 +77,13 @@ bridge 继续保留:
provider-specific 逻辑只能存在于 compat layer不得污染公共 handler。
### 5. WS-first
### 5. App-facing HTTP RPC
canonical transport 定义为:
App-facing canonical transport 定义为:
- `GET /acp` WebSocket
- `POST /acp/rpc`
`POST /acp/rpc` 作为 secondary compatibility transport 保留,但不再主导架构设计
`GET /acp` WebSocket 作为 ACP transport variant 保留,但不主导 App 运行时路由
## Consequences

View File

@ -56,13 +56,13 @@ APISIX / Caddy 这类外层网关负责:
bridge 当前采用:
- canonical transport: `GET /acp` WebSocket
- secondary compatibility transport: `POST /acp/rpc`
- App-facing canonical HTTP transport: `POST /acp/rpc`
- ACP WebSocket transport variant: `GET /acp`
设计含义:
- WS 是 control-plane 主链
- HTTP RPC 是兼容入口,不再反向塑造内部架构
- HTTP RPC 是 App 运行时主链
- WS 作为 ACP transport variant不反向塑造 App 路由
- provider / gateway alias route 不再属于 public surface
## 4. 模块边界

View File

@ -26,8 +26,8 @@ APP-facing ACP control plane。
负责:
- `/acp` WebSocket canonical transport
- `/acp/rpc` secondary compatibility transport
- `/acp/rpc` App-facing HTTP RPC transport
- `/acp` WebSocket transport variant
- JSON-RPC / hybrid envelope
- `acp.capabilities`
- `xworkmate.routing.resolve`
@ -105,7 +105,7 @@ APP-facing ACP control plane。
以下逻辑已从主链移除:
- `/acp-server/*`
- `/gateway/openclaw` public alias
- `/gateway/openclaw` public alias handler
- multi-agent 执行路径
- provider-specific alias handler

View File

@ -7,12 +7,9 @@ import (
"io"
"net/http"
"os"
"net/url"
"strings"
"sync"
"github.com/gorilla/websocket"
"xworkmate-bridge/internal/shared"
)
@ -23,6 +20,10 @@ func (s *Server) Handler() http.Handler {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("xworkmate-bridge is running"))
case "/api/ping":
if !s.authorized(r) {
shared.WriteJSONError(w, nil, http.StatusUnauthorized, -32001, "missing bearer authorization")
return
}
info := ParseImageVersionInfo(os.Getenv("IMAGE"))
resp := map[string]any{
"status": "ok",
@ -40,106 +41,11 @@ func (s *Server) Handler() http.Handler {
case "/acp":
s.HandleWebSocket(w, r)
default:
if strings.HasPrefix(r.URL.Path, "/acp-server/") {
s.handleLegacyACPServer(w, r)
return
}
http.NotFound(w, r)
}
})
}
func (s *Server) handleLegacyACPServer(w http.ResponseWriter, r *http.Request) {
providerID := strings.TrimPrefix(strings.TrimSpace(r.URL.Path), "/acp-server/")
providerID = strings.Trim(providerID, "/")
if providerID == "" {
http.NotFound(w, r)
return
}
s.mu.RLock()
compat := s.providers[providerID]
s.mu.RUnlock()
if compat == nil {
http.NotFound(w, r)
return
}
if websocket.IsWebSocketUpgrade(r) {
s.proxyLegacyACPServerWebSocket(w, r, compat)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"legacy": true,
"providerId": providerID,
"label": compat.Metadata()["label"],
"transport": compat.Metadata()["transport"],
})
}
func (s *Server) proxyLegacyACPServerWebSocket(w http.ResponseWriter, r *http.Request, compat ProviderCompat) {
external, ok := compat.(*externalACPCompat)
if !ok || external == nil {
http.Error(w, "legacy websocket proxy unavailable", http.StatusNotImplemented)
return
}
upstreamURL := strings.TrimSpace(external.endpoint)
if upstreamURL == "" {
http.NotFound(w, r)
return
}
if _, err := url.Parse(upstreamURL); err != nil {
http.Error(w, "invalid upstream endpoint", http.StatusBadGateway)
return
}
headers := http.Header{}
if auth := strings.TrimSpace(r.Header.Get("Authorization")); auth != "" {
headers.Set("Authorization", auth)
} else if external.authHeader != "" {
headers.Set("Authorization", external.authHeader)
}
upstream, _, err := websocket.DefaultDialer.Dial(upstreamURL, headers)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer func() { _ = upstream.Close() }()
upgrader := shared.StandardWSUpgrader
upgrader.CheckOrigin = func(req *http.Request) bool {
return true
}
downstream, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = downstream.Close() }()
errCh := make(chan error, 2)
go func() { errCh <- copyWSMessages(downstream, upstream) }()
go func() { errCh <- copyWSMessages(upstream, downstream) }()
<-errCh
}
func copyWSMessages(dst, src *websocket.Conn) error {
for {
messageType, payload, err := src.ReadMessage()
if err != nil {
return err
}
if err := dst.WriteMessage(messageType, payload); err != nil {
return err
}
}
}
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if !shared.OriginAllowed(origin, s.allowedOrigins) {
@ -213,15 +119,8 @@ func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request) {
r.Body = io.NopCloser(bytes.NewBuffer(payload))
if !s.authorized(r) {
var temp struct {
Method string `json:"method"`
}
_ = json.Unmarshal(payload, &temp)
method := strings.TrimSpace(temp.Method)
if method != "acp.capabilities" && method != "health" {
shared.WriteJSONError(w, nil, http.StatusUnauthorized, -32001, "missing bearer authorization")
return
}
shared.WriteJSONError(w, nil, http.StatusUnauthorized, -32001, "missing bearer authorization")
return
}
request, err := shared.DecodeRPCRequest(payload)
if err != nil {
@ -261,7 +160,9 @@ func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request) {
if stream {
shared.WriteSSE(w, envelope)
_, _ = w.Write([]byte("data: [DONE]\n\n"))
if flusher != nil { flusher.Flush() }
if flusher != nil {
flusher.Flush()
}
return
}
w.WriteHeader(http.StatusOK)
@ -271,7 +172,9 @@ func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request) {
if stream {
shared.WriteSSE(w, shared.ResultEnvelope(request.ID, response))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
if flusher != nil { flusher.Flush() }
if flusher != nil {
flusher.Flush()
}
return
}
w.Header().Set("Content-Type", "application/json")

View File

@ -60,7 +60,7 @@ func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
}
}
func TestHTTPHandlerKeepsLegacyACPCodexPathAlive(t *testing.T) {
func TestHTTPHandlerRejectsLegacyACPCodexPath(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
@ -70,18 +70,23 @@ func TestHTTPHandlerKeepsLegacyACPCodexPathAlive(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/acp-server/codex", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recorder.Code)
}
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode legacy payload: %v", err)
}
if got := payload["providerId"]; got != "codex" {
t.Fatalf("expected providerId codex, got %#v", got)
}
if got := payload["legacy"]; got != true {
t.Fatalf("expected legacy flag true, got %#v", got)
}
func TestHTTPHandlerPingRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
handler := server.Handler()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/ping", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", recorder.Code)
}
}
@ -182,6 +187,25 @@ func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *te
}
}
func TestHandleRPCCapabilitiesRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"http://127.0.0.1/acp/rpc",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"acp.capabilities"}`),
)
request.Header.Set("Content-Type", "application/json")
server.HandleRPC(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", recorder.Code)
}
}
func TestHandleRPCRejectsUnknownOrigin(t *testing.T) {
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")
t.Setenv("BRIDGE_AUTH_TOKEN", "")