Merge pull request #2 from x-evor/codex/provider-selection-test-mainline
test: lock bridge provider contract smoke checks
This commit is contained in:
commit
1b5e2e68d2
5
.github/workflows/pipeline.yml
vendored
5
.github/workflows/pipeline.yml
vendored
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
157
scripts/github-actions/verify-public-rpc-contract.sh
Executable file
157
scripts/github-actions/verify-public-rpc-contract.sh
Executable file
@ -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}"
|
||||
Loading…
Reference in New Issue
Block a user