702 lines
25 KiB
Go
702 lines
25 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"xworkmate-bridge/internal/shared"
|
|
)
|
|
|
|
func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
|
|
t.Setenv("IMAGE", "ghcr.io/x-evor/xworkmate-bridge:0123456789abcdef0123456789abcdef01234567")
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
|
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
handler := server.Handler()
|
|
|
|
rootRecorder := httptest.NewRecorder()
|
|
rootRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/", nil)
|
|
handler.ServeHTTP(rootRecorder, rootRequest)
|
|
|
|
if rootRecorder.Code != http.StatusOK {
|
|
t.Fatalf("expected root 200, got %d", rootRecorder.Code)
|
|
}
|
|
if !strings.Contains(rootRecorder.Body.String(), "xworkmate-bridge is running") {
|
|
t.Fatalf("expected root body to contain service banner, got %q", rootRecorder.Body.String())
|
|
}
|
|
|
|
pingRecorder := httptest.NewRecorder()
|
|
pingRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/ping", nil)
|
|
handler.ServeHTTP(pingRecorder, pingRequest)
|
|
|
|
if pingRecorder.Code != http.StatusOK {
|
|
t.Fatalf("expected ping 200, got %d", pingRecorder.Code)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(pingRecorder.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode ping payload: %v", err)
|
|
}
|
|
|
|
if got := payload["status"]; got != "ok" {
|
|
t.Fatalf("expected status ok, got %#v", got)
|
|
}
|
|
if got := payload["image"]; got != "ghcr.io/x-evor/xworkmate-bridge:0123456789abcdef0123456789abcdef01234567" {
|
|
t.Fatalf("expected full image ref, got %#v", got)
|
|
}
|
|
if got := payload["tag"]; got != "0123456789abcdef0123456789abcdef01234567" {
|
|
t.Fatalf("expected full image tag, got %#v", got)
|
|
}
|
|
if got := payload["commit"]; got != "0123456789abcdef0123456789abcdef01234567" {
|
|
t.Fatalf("expected full image commit, got %#v", got)
|
|
}
|
|
if got := payload["version"]; got != "0123456789abcdef0123456789abcdef01234567" {
|
|
t.Fatalf("expected full image version, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerRejectsLegacyACPCodexPath(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.MethodPost, "http://127.0.0.1/acp-server/codex", nil)
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusGone {
|
|
t.Fatalf("expected 410, got %d", recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "PROVIDER_DIRECT_PATH_DISABLED") {
|
|
t.Fatalf("expected disabled provider path error, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerProviderDirectPathRequiresAuthorization(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.MethodPost, "http://127.0.0.1/acp-server/hermes", nil)
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerGatewayOpenClawRequiresAuthorization(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.MethodPost,
|
|
"http://127.0.0.1/gateway/openclaw",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test"}}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerGatewayOpenClawRejectsNonSessionMethods(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.MethodPost,
|
|
"http://127.0.0.1/gateway/openclaw",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"acp.capabilities","params":{}}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected JSON-RPC 200, got %d", recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED") {
|
|
t.Fatalf("expected method allowlist error, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerGatewayOpenClawAllowsOnlyTaskSubmitMethods(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
handler := server.Handler()
|
|
|
|
for _, method := range []string{"session.cancel", "session.close", "xworkmate.routing.resolve", "xworkmate.gateway.request"} {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"http://127.0.0.1/gateway/openclaw",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"`+method+`","params":{}}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("%s: expected JSON-RPC 200, got %d", method, recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED") {
|
|
t.Fatalf("%s: expected method allowlist error, got %q", method, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerGatewayOpenClawRejectsConflictingRouting(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
handler := server.Handler()
|
|
|
|
for _, payload := range []string{
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"multiAgent":true}}`,
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"provider":"codex"}}`,
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"executionTarget":"agent"}}`,
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"gatewayProviderId":"other"}}`,
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"explicitProviderId":"codex"}}}`,
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"explicitExecutionTarget":"agent"}}}`,
|
|
`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"preferredGatewayProviderId":"other"}}}`,
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"http://127.0.0.1/gateway/openclaw",
|
|
strings.NewReader(payload),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected JSON-RPC 200, got %d", recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "OPENCLAW_GATEWAY_CONFLICT") {
|
|
t.Fatalf("expected conflict error, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerCanonicalRPCRejectsOpenClawTaskSubmit(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.MethodPost,
|
|
"http://127.0.0.1/acp/rpc",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"routing":{"explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected JSON-RPC 200, got %d", recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "OPENCLAW_TASK_ENDPOINT_REQUIRED") {
|
|
t.Fatalf("expected dedicated endpoint error, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCanonicalRPCAllowsAgentTaskWithPreferredGatewayMetadata(t *testing.T) {
|
|
request := shared.RPCRequest{
|
|
Method: "session.start",
|
|
Params: map[string]any{
|
|
"provider": "codex",
|
|
"requestedExecutionTarget": "agent",
|
|
"routing": map[string]any{
|
|
"routingMode": "explicit",
|
|
"explicitExecutionTarget": "agent",
|
|
"explicitProviderId": "codex",
|
|
"preferredGatewayProviderId": "openclaw",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, rpcErr := rejectOpenClawTaskSubmitOnCanonicalRPC(request)
|
|
if rpcErr != nil {
|
|
t.Fatalf("expected agent task to stay on /acp/rpc, got %#v", rpcErr)
|
|
}
|
|
}
|
|
|
|
func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) {
|
|
gateway := newAcpFakeOpenClawGateway(t)
|
|
defer gateway.Close()
|
|
|
|
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
|
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
|
server := NewServer()
|
|
handler := server.Handler()
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"http://127.0.0.1/gateway/openclaw",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`"}}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), `"resolvedGatewayProviderId":"openclaw"`) {
|
|
t.Fatalf("expected forced OpenClaw gateway result, got %q", recorder.Body.String())
|
|
}
|
|
if gateway.ChatSendCount() != 1 {
|
|
t.Fatalf("expected one OpenClaw chat.send, got %d", gateway.ChatSendCount())
|
|
}
|
|
if gateway.AgentWaitCount() != 1 {
|
|
t.Fatalf("expected one OpenClaw agent.wait, got %d", gateway.AgentWaitCount())
|
|
}
|
|
}
|
|
|
|
func TestSafeSSEStreamDropsLateNotificationsAfterClose(t *testing.T) {
|
|
writer := &panicSSEWriter{header: http.Header{}}
|
|
stream := newSafeSSEStream(context.Background(), writer)
|
|
|
|
stream.close()
|
|
if stream.write(map[string]any{"method": "xworkmate.gateway.push"}) {
|
|
t.Fatal("expected closed stream to drop late notification")
|
|
}
|
|
if writer.writes != 0 {
|
|
t.Fatalf("expected no write after close, got %d", writer.writes)
|
|
}
|
|
|
|
openStream := newSafeSSEStream(context.Background(), writer)
|
|
writer.panicOnWrite = true
|
|
if openStream.write(map[string]any{"method": "xworkmate.gateway.push"}) {
|
|
t.Fatal("expected panic writer to be marked closed")
|
|
}
|
|
if openStream.write(map[string]any{"method": "xworkmate.gateway.push"}) {
|
|
t.Fatal("expected writes after panic to stay closed")
|
|
}
|
|
}
|
|
|
|
type panicSSEWriter struct {
|
|
header http.Header
|
|
writes int
|
|
panicOnWrite bool
|
|
}
|
|
|
|
func (w *panicSSEWriter) Header() http.Header {
|
|
return w.header
|
|
}
|
|
|
|
func (w *panicSSEWriter) Write(payload []byte) (int, error) {
|
|
w.writes++
|
|
if w.panicOnWrite {
|
|
panic("closed response writer")
|
|
}
|
|
return len(payload), nil
|
|
}
|
|
|
|
func (w *panicSSEWriter) WriteHeader(int) {}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestParseImageVersionInfoHandlesTaggedImageRef(t *testing.T) {
|
|
info := ParseImageVersionInfo("ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12")
|
|
|
|
if info.ImageRef != "ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12" {
|
|
t.Fatalf("expected full image ref, got %q", info.ImageRef)
|
|
}
|
|
if info.Tag != "main-2026-04-12" {
|
|
t.Fatalf("expected tag main-2026-04-12, got %q", info.Tag)
|
|
}
|
|
if info.Commit != "" {
|
|
t.Fatalf("expected empty commit for non-hex tag, got %q", info.Commit)
|
|
}
|
|
if info.Version != "main-2026-04-12" {
|
|
t.Fatalf("expected version main-2026-04-12, got %q", info.Version)
|
|
}
|
|
}
|
|
|
|
func TestHandleWebSocketRejectsUnknownOrigin(t *testing.T) {
|
|
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
|
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/acp", nil)
|
|
request.Header.Set("Origin", "https://evil.example.com")
|
|
|
|
server.HandleWebSocket(recorder, request)
|
|
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, 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)
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCAllowsPreflightForConfiguredOrigin(t *testing.T) {
|
|
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*")
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
|
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodOptions, "http://127.0.0.1/acp/rpc", nil)
|
|
request.Header.Set("Origin", "https://xworkmate.svc.plus")
|
|
request.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
server.HandleRPC(recorder, request)
|
|
|
|
if recorder.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", recorder.Code)
|
|
}
|
|
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "https://xworkmate.svc.plus" {
|
|
t.Fatalf("expected allow origin header, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_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.StatusOK {
|
|
t.Fatalf("expected 200 when BRIDGE_AUTH_TOKEN is unset and no header provided, got %d", recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
recorder := httptest.NewRecorder()
|
|
// session.start is a protected method that requires authentication
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"http://127.0.0.1/acp/rpc",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test"}}`),
|
|
)
|
|
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 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", "")
|
|
|
|
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("Origin", "https://evil.example.com")
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer test")
|
|
|
|
server.HandleRPC(recorder, request)
|
|
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", recorder.Code)
|
|
}
|
|
var envelope map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode error envelope: %v", err)
|
|
}
|
|
if _, ok := envelope["error"]; !ok {
|
|
t.Fatalf("expected JSON-RPC error envelope, got %v", envelope)
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCMethodErrorUsesJSONEnvelope(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/acp/rpc", nil)
|
|
request.Header.Set("Authorization", "Bearer test")
|
|
|
|
server.HandleRPC(recorder, request)
|
|
|
|
if recorder.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405, 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)
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCCapabilitiesStillReturnsJSONResult(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_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")
|
|
request.Header.Set("Authorization", "Bearer test")
|
|
|
|
server.HandleRPC(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)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), `"providerCatalog"`) {
|
|
t.Fatalf("expected capabilities response, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthorizedAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/acp", nil)
|
|
if !server.authorized(request) {
|
|
t.Fatal("expected unauthenticated request to be allowed if BRIDGE_AUTH_TOKEN is unset")
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCCapabilitiesReturnsCanonicalProviderContract(t *testing.T) {
|
|
t.Setenv("BRIDGE_AUTH_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":"cap-1","method":"acp.capabilities"}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer test")
|
|
|
|
server.HandleRPC(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
|
}
|
|
|
|
var envelope map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode capabilities response: %v", err)
|
|
}
|
|
|
|
result := shared.AsMap(envelope["result"])
|
|
if got := result["singleAgent"]; got != true {
|
|
t.Fatalf("expected singleAgent true, got %v", got)
|
|
}
|
|
availableTargets := mustStringList(t, result["availableExecutionTargets"])
|
|
if !reflect.DeepEqual(availableTargets, []string{"agent", "gateway"}) {
|
|
t.Fatalf("expected canonical execution targets, got %#v", availableTargets)
|
|
}
|
|
|
|
providerCatalog := mustObjectList(t, result["providerCatalog"])
|
|
if len(providerCatalog) != 4 {
|
|
t.Fatalf("expected 4 providers, got %#v", providerCatalog)
|
|
}
|
|
wantAgentIDs := []string{"codex", "opencode", "gemini", "hermes"}
|
|
wantAgentLabels := []string{"Codex", "OpenCode", "Gemini", "Hermes"}
|
|
for index, wantID := range wantAgentIDs {
|
|
if got := providerCatalog[index]["providerId"]; got != wantID {
|
|
t.Fatalf("expected provider %q at index %d, got %#v", wantID, index, providerCatalog)
|
|
}
|
|
if got := providerCatalog[index]["label"]; got != wantAgentLabels[index] {
|
|
t.Fatalf("expected label %q at index %d, got %#v", wantAgentLabels[index], index, providerCatalog)
|
|
}
|
|
if targets := mustStringList(t, providerCatalog[index]["targets"]); !reflect.DeepEqual(targets, []string{"agent"}) {
|
|
t.Fatalf("expected agent targets for %q, got %#v", wantID, targets)
|
|
}
|
|
}
|
|
|
|
gatewayProviders := mustObjectList(t, result["gatewayProviders"])
|
|
if len(gatewayProviders) != 1 {
|
|
t.Fatalf("expected exactly one gateway provider, got %#v", gatewayProviders)
|
|
}
|
|
if got := gatewayProviders[0]["providerId"]; got != "openclaw" {
|
|
t.Fatalf("expected gateway providerId openclaw, got %#v", gatewayProviders[0])
|
|
}
|
|
if got := gatewayProviders[0]["label"]; got != "OpenClaw" {
|
|
t.Fatalf("expected gateway label OpenClaw, got %#v", gatewayProviders[0])
|
|
}
|
|
if targets := mustStringList(t, gatewayProviders[0]["targets"]); !reflect.DeepEqual(targets, []string{"gateway"}) {
|
|
t.Fatalf("expected gateway targets, got %#v", targets)
|
|
}
|
|
}
|
|
|
|
func TestHandleRPCSessionStartSucceedsWithExplicitProvider(t *testing.T) {
|
|
externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer bridge-test-token" {
|
|
t.Fatalf("unexpected auth header: %q", got)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": "task-1",
|
|
"result": map[string]any{
|
|
"success": true,
|
|
"provider": "opencode",
|
|
"output": "pong",
|
|
},
|
|
})
|
|
}))
|
|
defer externalServer.Close()
|
|
|
|
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
|
|
|
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
|
server := NewServer()
|
|
setTestBridgeProvider(server, syncedProvider{
|
|
ProviderID: "opencode",
|
|
Label: "OpenCode",
|
|
Endpoint: externalServer.URL,
|
|
AuthorizationHeader: "Bearer bridge-test-token",
|
|
Enabled: true,
|
|
})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"http://127.0.0.1/acp/rpc",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply with exactly pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"opencode"}}}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
|
|
|
server.HandleRPC(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), `"output":"pong"`) {
|
|
t.Fatalf("expected pong output, got %q", recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), `"provider":"opencode"`) {
|
|
t.Fatalf("expected opencode provider, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func mustObjectList(t *testing.T, value any) []map[string]any {
|
|
t.Helper()
|
|
raw, ok := value.([]any)
|
|
if !ok {
|
|
t.Fatalf("expected object list, got %#v", value)
|
|
}
|
|
items := make([]map[string]any, 0, len(raw))
|
|
for _, item := range raw {
|
|
items = append(items, shared.AsMap(item))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func mustStringList(t *testing.T, value any) []string {
|
|
t.Helper()
|
|
switch typed := value.(type) {
|
|
case []string:
|
|
return typed
|
|
case []any:
|
|
items := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
items = append(items, strings.TrimSpace(item.(string)))
|
|
}
|
|
return items
|
|
default:
|
|
t.Fatalf("expected string list, got %#v", value)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func TestHandleWebSocketRequiresBearerAuthorization(t *testing.T) {
|
|
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")
|
|
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.MethodGet, "http://127.0.0.1/acp", nil)
|
|
request.Header.Set("Origin", "https://xworkmate.svc.plus")
|
|
|
|
server.HandleWebSocket(recorder, request)
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", recorder.Code)
|
|
}
|
|
}
|