From eaf0a6255ffd6322d1a7a43df7509084446cf809 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 14 Apr 2026 13:56:58 +0800 Subject: [PATCH] test: lock bridge provider contract smoke checks --- .github/workflows/pipeline.yml | 5 + internal/acp/web_contract_test.go | 142 ++++++++++++++++ .../verify-public-rpc-contract.sh | 157 ++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100755 scripts/github-actions/verify-public-rpc-contract.sh diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index d1c5c0f..94bd038 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -276,3 +276,8 @@ jobs: - name: Validate deployed endpoints run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" "${BRIDGE_SERVER_URL}" "${OPENCLAW_URL}" "${CODEX_RPC_URL}" "${OPENCODE_RPC_URL}" "${GEMINI_RPC_URL}" "${INTERNAL_SERVICE_TOKEN}" + + - name: Validate public ACP contract + env: + BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }} + run: bash ./scripts/github-actions/verify-public-rpc-contract.sh diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 1d67843..19f13f2 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "strings" "testing" ) @@ -194,6 +195,147 @@ func TestHandleRPCCapabilitiesStillReturnsJSONResult(t *testing.T) { } } +func TestHandleRPCCapabilitiesReturnsCanonicalProviderContract(t *testing.T) { + 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 := asMap(envelope["result"]) + 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) != 3 { + t.Fatalf("expected 3 providers, got %#v", providerCatalog) + } + wantAgentIDs := []string{"codex", "opencode", "gemini"} + wantAgentLabels := []string{"Codex", "OpenCode", "Gemini"} + 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 internal-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("INTERNAL_SERVICE_TOKEN", "internal-test-token") + + server := NewServer() + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "opencode", + Label: "OpenCode", + Endpoint: externalServer.URL, + AuthorizationHeader: "Bearer internal-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-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, 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") diff --git a/scripts/github-actions/verify-public-rpc-contract.sh b/scripts/github-actions/verify-public-rpc-contract.sh new file mode 100755 index 0000000..ee6d9ca --- /dev/null +++ b/scripts/github-actions/verify-public-rpc-contract.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}" +AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-${INTERNAL_SERVICE_TOKEN:-}}" +HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}" +RPC_TIMEOUT_SECONDS="${RPC_TIMEOUT_SECONDS:-90}" + +if [[ -z "${AUTH_TOKEN}" ]]; then + echo "BRIDGE_AUTH_TOKEN or INTERNAL_SERVICE_TOKEN is required" >&2 + exit 1 +fi + +normalize_url() { + local raw="$1" + printf '%s\n' "${raw%/}" +} + +json_rpc_call() { + local payload="$1" + shift + curl \ + --silent \ + --show-error \ + --fail \ + --location \ + --max-time "${RPC_TIMEOUT_SECONDS}" \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + "$@" \ + --data "${payload}" \ + "${resolved_base_url}/acp/rpc" +} + +json_rpc_with_retry() { + local payload="$1" + shift + local attempt + for attempt in 1 2 3; do + if json_rpc_call "${payload}" "$@"; then + return 0 + fi + if (( attempt == 3 )); then + return 1 + fi + sleep 5 + done +} + +resolved_base_url="$(normalize_url "${BASE_URL}")" + +unauthorized_status="$( + curl \ + --silent \ + --show-error \ + --output /tmp/xworkmate-bridge-public-contract-unauthorized.json \ + --write-out '%{http_code}' \ + --location \ + --max-time "${HTTP_TIMEOUT_SECONDS}" \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":"cap-unauthorized","method":"acp.capabilities"}' \ + "${resolved_base_url}/acp/rpc" +)" + +if [[ "${unauthorized_status}" != "401" ]]; then + echo "expected unauthorized capabilities request to return 401, got ${unauthorized_status}" >&2 + exit 1 +fi + +capabilities_json="$( + json_rpc_with_retry \ + '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities"}' \ + -H "Authorization: Bearer ${AUTH_TOKEN}" +)" + +RESPONSE_JSON="${capabilities_json}" python3 - <<'PY' +import json +import os + +payload = json.loads(os.environ["RESPONSE_JSON"]) +if payload.get("jsonrpc") != "2.0": + raise SystemExit("bridge capabilities response missing jsonrpc envelope") + +result = payload.get("result") +if not isinstance(result, dict): + raise SystemExit("bridge capabilities response missing result payload") + +expected_targets = ["agent", "gateway"] +if result.get("availableExecutionTargets") != expected_targets: + raise SystemExit( + f"expected availableExecutionTargets {expected_targets!r}, got {result.get('availableExecutionTargets')!r}" + ) + +provider_catalog = result.get("providerCatalog") +gateway_providers = result.get("gatewayProviders") +if not isinstance(provider_catalog, list): + raise SystemExit("providerCatalog is missing or invalid") +if not isinstance(gateway_providers, list): + raise SystemExit("gatewayProviders is missing or invalid") + +expected_agent_ids = ["codex", "opencode", "gemini"] +expected_agent_labels = ["Codex", "OpenCode", "Gemini"] +if len(provider_catalog) != len(expected_agent_ids): + raise SystemExit(f"expected 3 agent providers, got {provider_catalog!r}") + +for index, (provider_id, label) in enumerate(zip(expected_agent_ids, expected_agent_labels)): + item = provider_catalog[index] + if item.get("providerId") != provider_id: + raise SystemExit(f"expected providerId {provider_id!r} at index {index}, got {item!r}") + if item.get("label") != label: + raise SystemExit(f"expected label {label!r} at index {index}, got {item!r}") + if item.get("targets") != ["agent"]: + raise SystemExit(f"expected agent targets for {provider_id!r}, got {item!r}") + +if len(gateway_providers) != 1: + raise SystemExit(f"expected one gateway provider, got {gateway_providers!r}") + +gateway = gateway_providers[0] +if gateway.get("providerId") != "openclaw": + raise SystemExit(f"expected gateway providerId 'openclaw', got {gateway!r}") +if gateway.get("label") != "OpenClaw": + raise SystemExit(f"expected gateway label 'OpenClaw', got {gateway!r}") +if gateway.get("targets") != ["gateway"]: + raise SystemExit(f"expected gateway targets ['gateway'], got {gateway!r}") +PY + +session_start_json="$( + json_rpc_with_retry \ + '{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"public-contract-smoke","threadId":"public-contract-smoke","taskPrompt":"Reply with exactly pong","workingDirectory":"/tmp","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"opencode"}}}' \ + -H "Authorization: Bearer ${AUTH_TOKEN}" +)" + +RESPONSE_JSON="${session_start_json}" python3 - <<'PY' +import json +import os + +payload = json.loads(os.environ["RESPONSE_JSON"]) +if payload.get("jsonrpc") != "2.0": + raise SystemExit("session.start response missing jsonrpc envelope") + +result = payload.get("result") +if not isinstance(result, dict): + raise SystemExit(f"session.start missing result payload: {payload!r}") + +if result.get("success") is not True: + raise SystemExit(f"session.start did not succeed: {result!r}") + +if result.get("provider") != "opencode": + raise SystemExit(f"expected provider 'opencode', got {result!r}") + +output = str(result.get("output", "")).strip().lower() +if output != "pong": + raise SystemExit(f"expected output 'pong', got {result!r}") +PY + +printf 'public bridge RPC contract verified via %s\n' "${resolved_base_url}"