diff --git a/docs/acp-public-validation-2026-04-09.md b/docs/acp-public-validation-2026-04-09.md index 208d4db..269cb39 100644 --- a/docs/acp-public-validation-2026-04-09.md +++ b/docs/acp-public-validation-2026-04-09.md @@ -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 定义 diff --git a/docs/api-reference.md b/docs/api-reference.md index 2bbdea2..3969fdd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 ` diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md index f69d583..2e6bc6c 100644 --- a/docs/architecture/acp-forwarding-topology.md +++ b/docs/architecture/acp-forwarding-topology.md @@ -30,7 +30,7 @@ flowchart LR subgraph BRIDGE["xworkmate-bridge"] B1["GET /acp
JSON-RPC over WebSocket"] - B2["POST /acp/rpc
secondary compatibility"] + B2["POST /acp/rpc
App-facing HTTP RPC"] B3["acp.capabilities"] B4["xworkmate.routing.resolve"] B5["session.*"] diff --git a/docs/architecture/adr-refocus-bridge-as-control-plane.md b/docs/architecture/adr-refocus-bridge-as-control-plane.md index b591fdd..ded2b03 100644 --- a/docs/architecture/adr-refocus-bridge-as-control-plane.md +++ b/docs/architecture/adr-refocus-bridge-as-control-plane.md @@ -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 diff --git a/docs/architecture/bridge-runtime-design.md b/docs/architecture/bridge-runtime-design.md index 6b6a64d..35365e9 100644 --- a/docs/architecture/bridge-runtime-design.md +++ b/docs/architecture/bridge-runtime-design.md @@ -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. 模块边界 diff --git a/docs/internal-reference.md b/docs/internal-reference.md index 7b3f481..f447dfc 100644 --- a/docs/internal-reference.md +++ b/docs/internal-reference.md @@ -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 diff --git a/internal/acp/http_handler.go b/internal/acp/http_handler.go index 717d049..7358375 100644 --- a/internal/acp/http_handler.go +++ b/internal/acp/http_handler.go @@ -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") diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 312d42a..18f42d9 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -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", "")