From 939a1b6c875ec6bb6f85057290876f6c80a3f6cd Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 20 Apr 2026 09:34:11 +0800 Subject: [PATCH] refactor(acp): remove hardcoded endpoints and migrate to path-based routing --- docs/acp-public-validation-2026-04-09.md | 10 +-- docs/architecture/acp-forwarding-topology.md | 14 ++-- .../adr-unified-bridge-entrypoints.md | 2 +- internal/acp/execution_test.go | 67 +++++++++++++------ internal/acp/provider_catalog.go | 66 ++++++++---------- .../github-actions/test-validate-deploy.sh | 10 +-- 6 files changed, 93 insertions(+), 76 deletions(-) diff --git a/docs/acp-public-validation-2026-04-09.md b/docs/acp-public-validation-2026-04-09.md index 5b7c2f6..bfe511c 100644 --- a/docs/acp-public-validation-2026-04-09.md +++ b/docs/acp-public-validation-2026-04-09.md @@ -2,10 +2,10 @@ This document records the post-deployment validation of the bridge public origin at `xworkmate-bridge.svc.plus` and the independent upstream ACP ingress -at `acp-server.svc.plus`. +at `xworkmate-bridge.svc.plus/acp-server`. For APP integration, the canonical public contract remains the bridge origin -and the `.../acp/rpc` path on that origin. The direct `acp-server.svc.plus` +and the `.../acp/rpc` path on that origin. The direct `xworkmate-bridge.svc.plus/acp-server` URLs in this document are upstream validation targets, not the preferred APP entry points. @@ -29,9 +29,9 @@ Recommended APP-facing endpoint: Verified public HTTP JSON-RPC endpoints: -- Codex: `https://acp-server.svc.plus/codex/acp/rpc` -- OpenCode: `https://acp-server.svc.plus/opencode/acp/rpc` -- Gemini: `https://acp-server.svc.plus/gemini/acp/rpc` +- Codex: `https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc` +- OpenCode: `https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc` +- Gemini: `https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc` The `.../acp` path remains reserved for WebSocket ACP. diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md index af6a374..a7ab665 100644 --- a/docs/architecture/acp-forwarding-topology.md +++ b/docs/architecture/acp-forwarding-topology.md @@ -55,9 +55,9 @@ flowchart TD end subgraph UPSTREAM["Independent upstream services"] - C1["https://acp-server.svc.plus/codex/acp/rpc"] - C2["https://acp-server.svc.plus/opencode/acp/rpc"] - C3["https://acp-server.svc.plus/gemini/acp/rpc"] + C1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"] + C2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"] + C3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"] C4["wss://openclaw.svc.plus"] end @@ -107,9 +107,9 @@ flowchart LR end subgraph L3["上游视角"] - U1["https://acp-server.svc.plus/codex/acp/rpc"] - U2["https://acp-server.svc.plus/opencode/acp/rpc"] - U3["https://acp-server.svc.plus/gemini/acp/rpc"] + U1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"] + U2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"] + U3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"] U4["wss://openclaw.svc.plus
reported as openclaw.svc.plus:443"] end @@ -163,7 +163,7 @@ Important distinction: ## Invariants - app traffic reaches upstream ACP and gateway services only through the bridge -- app does not call `acp-server.svc.plus/*` or `openclaw.svc.plus` directly +- app does not call `xworkmate-bridge.svc.plus/acp-server/*` or `openclaw.svc.plus` directly - upstream auth stays bridge-internal: - `Authorization: Bearer $INTERNAL_SERVICE_TOKEN` - `acp.capabilities` is the provider / capability discovery source diff --git a/docs/architecture/adr-unified-bridge-entrypoints.md b/docs/architecture/adr-unified-bridge-entrypoints.md index fcd7920..cc9c43f 100644 --- a/docs/architecture/adr-unified-bridge-entrypoints.md +++ b/docs/architecture/adr-unified-bridge-entrypoints.md @@ -127,7 +127,7 @@ Use these terms consistently: - `canonical app-facing path`: `/acp/rpc` and `/acp` - `gateway runtime method family`: `xworkmate.gateway.*` -- `independent upstream service`: `acp-server.svc.plus/*`, `wss://openclaw.svc.plus` +- `independent upstream service`: `xworkmate-bridge.svc.plus/acp-server/*`, `wss://openclaw.svc.plus` - `bridge-owned routing`: provider / gateway selection performed inside bridge - `routing metadata`: execution target and resolved provider/gateway identifiers returned to the app diff --git a/internal/acp/execution_test.go b/internal/acp/execution_test.go index eed9f0a..2864a24 100644 --- a/internal/acp/execution_test.go +++ b/internal/acp/execution_test.go @@ -1,8 +1,49 @@ package acp -import "testing" +import ( + "os" + "strings" + "testing" +) -func TestResolveSingleAgentForwardEndpoint(t *testing.T) { +func TestResolveSingleAgentForwardEndpointFromExampleConfig(t *testing.T) { + // Set the config path to example/config.yaml relative to this test file + os.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml") + defer os.Unsetenv("BRIDGE_CONFIG_PATH") + + catalog, order := newProductionProviderCatalog() + if len(order) == 0 { + t.Fatal("Expected non-empty provider order from example/config.yaml") + } + + expectedEndpoints := map[string]string{ + "codex": "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc", + "opencode": "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc", + "gemini": "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc", + } + + for _, id := range order { + id := id + t.Run(id, func(t *testing.T) { + provider, ok := catalog[id] + if !ok { + t.Errorf("Provider %s missing from catalog", id) + return + } + if !provider.Enabled { + t.Errorf("Provider %s should be enabled in example config", id) + } + + want := expectedEndpoints[id] + got := resolveSingleAgentForwardEndpoint(provider) + if got != want { + t.Errorf("resolveSingleAgentForwardEndpoint(%s) = %q, want %q (from example config)", id, got, want) + } + }) + } +} + +func TestResolveSingleAgentForwardEndpointManual(t *testing.T) { t.Parallel() cases := []struct { @@ -13,26 +54,10 @@ func TestResolveSingleAgentForwardEndpoint(t *testing.T) { { name: "preserves upstream endpoint", provider: syncedProvider{ - ProviderID: "opencode", - Endpoint: "https://acp-server.svc.plus/opencode/acp/rpc", + ProviderID: "custom", + Endpoint: "https://upstream-provider.example.com/acp/rpc", }, - want: "https://acp-server.svc.plus/opencode/acp/rpc", - }, - { - name: "does not rewrite bridge endpoint placeholder for codex", - provider: syncedProvider{ - ProviderID: "codex", - Endpoint: "https://xworkmate-bridge.svc.plus", - }, - want: "https://xworkmate-bridge.svc.plus", - }, - { - name: "does not rewrite bridge endpoint placeholder for gemini", - provider: syncedProvider{ - ProviderID: "gemini", - Endpoint: "https://xworkmate-bridge.svc.plus", - }, - want: "https://xworkmate-bridge.svc.plus", + want: "https://upstream-provider.example.com/acp/rpc", }, } diff --git a/internal/acp/provider_catalog.go b/internal/acp/provider_catalog.go index d67f1d4..6b07d93 100644 --- a/internal/acp/provider_catalog.go +++ b/internal/acp/provider_catalog.go @@ -8,14 +8,6 @@ import ( "xworkmate-bridge/internal/shared" ) -// 默认生产端点 -const ( - productionGatewayEndpointURL = "https://xworkmate-bridge.svc.plus/gateway/openclaw/" - productionCodexEndpointURL = "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc" - productionOpenCodeEndpointURL = "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc" - productionGeminiEndpointURL = "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc" -) - type syncedProvider struct { ProviderID string Label string @@ -46,17 +38,15 @@ func loadBridgeConfig() *BridgeConfig { return config } -func resolveURL(yamlVal, envKey, defaultVal string) string { +func resolveURL(yamlVal, envKey string) string { val := strings.TrimSpace(yamlVal) if val != "" { return val } - return strings.TrimSpace(shared.EnvOrDefault(envKey, defaultVal)) + return strings.TrimSpace(shared.EnvOrDefault(envKey, "")) } func bridgeUpstreamAuthorizationHeader() string { - // Original logic used firstNonEmptyString and normalizeAuthorizationHeader - // but let's keep it simple and match expected "Bearer token" if it exists. token := strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", "")) if token == "" { token = strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", "")) @@ -71,30 +61,32 @@ func newProductionProviderCatalog() (map[string]syncedProvider, []string) { config := loadBridgeConfig() authorizationHeader := bridgeUpstreamAuthorizationHeader() - catalog := map[string]syncedProvider{ - "codex": { - ProviderID: "codex", - Label: "Codex", - Endpoint: resolveURL(config.Upstream.CodexURL, "OPENCLAW_CODEX_URL", productionCodexEndpointURL), - AuthorizationHeader: authorizationHeader, - Enabled: true, - }, - "opencode": { - ProviderID: "opencode", - Label: "OpenCode", - Endpoint: resolveURL(config.Upstream.OpenCodeURL, "OPENCLAW_OPENCODE_URL", productionOpenCodeEndpointURL), - AuthorizationHeader: authorizationHeader, - Enabled: true, - }, - "gemini": { - ProviderID: "gemini", - Label: "Gemini", - Endpoint: resolveURL(config.Upstream.GeminiURL, "OPENCLAW_GEMINI_URL", productionGeminiEndpointURL), - AuthorizationHeader: authorizationHeader, - Enabled: true, - }, + providers := []struct { + id string + label string + yaml string + envKey string + }{ + {"codex", "Codex", config.Upstream.CodexURL, "OPENCLAW_CODEX_URL"}, + {"opencode", "OpenCode", config.Upstream.OpenCodeURL, "OPENCLAW_OPENCODE_URL"}, + {"gemini", "Gemini", config.Upstream.GeminiURL, "OPENCLAW_GEMINI_URL"}, } - order := []string{"codex", "opencode", "gemini"} + + catalog := make(map[string]syncedProvider) + var order []string + + for _, p := range providers { + endpoint := resolveURL(p.yaml, p.envKey) + catalog[p.id] = syncedProvider{ + ProviderID: p.id, + Label: p.label, + Endpoint: endpoint, + AuthorizationHeader: authorizationHeader, + Enabled: endpoint != "", + } + order = append(order, p.id) + } + return catalog, order } @@ -108,7 +100,7 @@ func (s *Server) syncedProviderByID(providerID string) (syncedProvider, bool) { func (s *Server) availableProviderCatalog() []Provider { s.mu.Lock() defer s.mu.Unlock() - + var catalog []Provider for _, id := range s.providerOrder { if p, ok := s.providerCatalog[id]; ok && p.Enabled { @@ -125,7 +117,7 @@ func (s *Server) availableProviderCatalog() []Provider { func (s *Server) availableProviders() []string { s.mu.Lock() defer s.mu.Unlock() - + var providers []string for _, id := range s.providerOrder { if p, ok := s.providerCatalog[id]; ok && p.Enabled { diff --git a/scripts/github-actions/test-validate-deploy.sh b/scripts/github-actions/test-validate-deploy.sh index 4121580..5b4e58f 100644 --- a/scripts/github-actions/test-validate-deploy.sh +++ b/scripts/github-actions/test-validate-deploy.sh @@ -109,7 +109,7 @@ case "${scenario}" in https://xworkmate-bridge.svc.plus/) printf 'xworkmate-bridge is running\n' ;; - https://acp-server.svc.plus/*/acp/rpc) + https://xworkmate-bridge.svc.plus/acp-server/*/acp/rpc) printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n' ;; https://xworkmate-bridge.svc.plus/acp/rpc) @@ -135,7 +135,7 @@ case "${scenario}" in https://xworkmate-bridge.svc.plus/) printf 'xworkmate-bridge is running\n' ;; - https://acp-server.svc.plus/*/acp/rpc) + https://xworkmate-bridge.svc.plus/acp-server/*/acp/rpc) printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n' ;; https://xworkmate-bridge.svc.plus/acp/rpc) @@ -191,9 +191,9 @@ run_validate_capture() { FAKE_CURL_STATE_DIR="${RUN_STATE_DIR}" \ BRIDGE_SERVER_URL="https://xworkmate-bridge.svc.plus" \ OPENCLAW_URL="wss://openclaw.svc.plus" \ - CODEX_RPC_URL="https://acp-server.svc.plus/codex/acp/rpc" \ - OPENCODE_RPC_URL="https://acp-server.svc.plus/opencode/acp/rpc" \ - GEMINI_RPC_URL="https://acp-server.svc.plus/gemini/acp/rpc" \ + CODEX_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc" \ + OPENCODE_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc" \ + GEMINI_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc" \ INTERNAL_SERVICE_TOKEN="test-token" \ bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1 )"