fix: remove legacy bridge public aliases
This commit is contained in:
parent
1326f577ee
commit
6e2daf2e3d
@ -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 定义
|
||||
|
||||
@ -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>`
|
||||
|
||||
|
||||
@ -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.*"]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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. 模块边界
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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", "")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user