refactor(acp): remove hardcoded endpoints and migrate to path-based routing

This commit is contained in:
Haitao Pan 2026-04-20 09:34:11 +08:00
parent 55bfda225e
commit 939a1b6c87
6 changed files with 93 additions and 76 deletions

View File

@ -2,10 +2,10 @@
This document records the post-deployment validation of the bridge public This document records the post-deployment validation of the bridge public
origin at `xworkmate-bridge.svc.plus` and the independent upstream ACP ingress 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 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 URLs in this document are upstream validation targets, not the preferred APP
entry points. entry points.
@ -29,9 +29,9 @@ Recommended APP-facing endpoint:
Verified public HTTP JSON-RPC endpoints: Verified public HTTP JSON-RPC endpoints:
- Codex: `https://acp-server.svc.plus/codex/acp/rpc` - Codex: `https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc`
- OpenCode: `https://acp-server.svc.plus/opencode/acp/rpc` - OpenCode: `https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc`
- Gemini: `https://acp-server.svc.plus/gemini/acp/rpc` - Gemini: `https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc`
The `.../acp` path remains reserved for WebSocket ACP. The `.../acp` path remains reserved for WebSocket ACP.

View File

@ -55,9 +55,9 @@ flowchart TD
end end
subgraph UPSTREAM["Independent upstream services"] subgraph UPSTREAM["Independent upstream services"]
C1["https://acp-server.svc.plus/codex/acp/rpc"] C1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"]
C2["https://acp-server.svc.plus/opencode/acp/rpc"] C2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"]
C3["https://acp-server.svc.plus/gemini/acp/rpc"] C3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"]
C4["wss://openclaw.svc.plus"] C4["wss://openclaw.svc.plus"]
end end
@ -107,9 +107,9 @@ flowchart LR
end end
subgraph L3["上游视角"] subgraph L3["上游视角"]
U1["https://acp-server.svc.plus/codex/acp/rpc"] U1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"]
U2["https://acp-server.svc.plus/opencode/acp/rpc"] U2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"]
U3["https://acp-server.svc.plus/gemini/acp/rpc"] U3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"]
U4["wss://openclaw.svc.plus<br/>reported as openclaw.svc.plus:443"] U4["wss://openclaw.svc.plus<br/>reported as openclaw.svc.plus:443"]
end end
@ -163,7 +163,7 @@ Important distinction:
## Invariants ## Invariants
- app traffic reaches upstream ACP and gateway services only through the bridge - 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: - upstream auth stays bridge-internal:
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN` - `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
- `acp.capabilities` is the provider / capability discovery source - `acp.capabilities` is the provider / capability discovery source

View File

@ -127,7 +127,7 @@ Use these terms consistently:
- `canonical app-facing path`: `/acp/rpc` and `/acp` - `canonical app-facing path`: `/acp/rpc` and `/acp`
- `gateway runtime method family`: `xworkmate.gateway.*` - `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 - `bridge-owned routing`: provider / gateway selection performed inside bridge
- `routing metadata`: execution target and resolved provider/gateway identifiers returned to the app - `routing metadata`: execution target and resolved provider/gateway identifiers returned to the app

View File

@ -1,8 +1,49 @@
package acp 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() t.Parallel()
cases := []struct { cases := []struct {
@ -13,26 +54,10 @@ func TestResolveSingleAgentForwardEndpoint(t *testing.T) {
{ {
name: "preserves upstream endpoint", name: "preserves upstream endpoint",
provider: syncedProvider{ provider: syncedProvider{
ProviderID: "opencode", ProviderID: "custom",
Endpoint: "https://acp-server.svc.plus/opencode/acp/rpc", Endpoint: "https://upstream-provider.example.com/acp/rpc",
}, },
want: "https://acp-server.svc.plus/opencode/acp/rpc", want: "https://upstream-provider.example.com/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",
}, },
} }

View File

@ -8,14 +8,6 @@ import (
"xworkmate-bridge/internal/shared" "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 { type syncedProvider struct {
ProviderID string ProviderID string
Label string Label string
@ -46,17 +38,15 @@ func loadBridgeConfig() *BridgeConfig {
return config return config
} }
func resolveURL(yamlVal, envKey, defaultVal string) string { func resolveURL(yamlVal, envKey string) string {
val := strings.TrimSpace(yamlVal) val := strings.TrimSpace(yamlVal)
if val != "" { if val != "" {
return val return val
} }
return strings.TrimSpace(shared.EnvOrDefault(envKey, defaultVal)) return strings.TrimSpace(shared.EnvOrDefault(envKey, ""))
} }
func bridgeUpstreamAuthorizationHeader() string { 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", "")) token := strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
if token == "" { if token == "" {
token = strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", "")) token = strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", ""))
@ -71,30 +61,32 @@ func newProductionProviderCatalog() (map[string]syncedProvider, []string) {
config := loadBridgeConfig() config := loadBridgeConfig()
authorizationHeader := bridgeUpstreamAuthorizationHeader() authorizationHeader := bridgeUpstreamAuthorizationHeader()
catalog := map[string]syncedProvider{ providers := []struct {
"codex": { id string
ProviderID: "codex", label string
Label: "Codex", yaml string
Endpoint: resolveURL(config.Upstream.CodexURL, "OPENCLAW_CODEX_URL", productionCodexEndpointURL), envKey string
AuthorizationHeader: authorizationHeader, }{
Enabled: true, {"codex", "Codex", config.Upstream.CodexURL, "OPENCLAW_CODEX_URL"},
}, {"opencode", "OpenCode", config.Upstream.OpenCodeURL, "OPENCLAW_OPENCODE_URL"},
"opencode": { {"gemini", "Gemini", config.Upstream.GeminiURL, "OPENCLAW_GEMINI_URL"},
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,
},
} }
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 return catalog, order
} }
@ -108,7 +100,7 @@ func (s *Server) syncedProviderByID(providerID string) (syncedProvider, bool) {
func (s *Server) availableProviderCatalog() []Provider { func (s *Server) availableProviderCatalog() []Provider {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
var catalog []Provider var catalog []Provider
for _, id := range s.providerOrder { for _, id := range s.providerOrder {
if p, ok := s.providerCatalog[id]; ok && p.Enabled { if p, ok := s.providerCatalog[id]; ok && p.Enabled {
@ -125,7 +117,7 @@ func (s *Server) availableProviderCatalog() []Provider {
func (s *Server) availableProviders() []string { func (s *Server) availableProviders() []string {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
var providers []string var providers []string
for _, id := range s.providerOrder { for _, id := range s.providerOrder {
if p, ok := s.providerCatalog[id]; ok && p.Enabled { if p, ok := s.providerCatalog[id]; ok && p.Enabled {

View File

@ -109,7 +109,7 @@ case "${scenario}" in
https://xworkmate-bridge.svc.plus/) https://xworkmate-bridge.svc.plus/)
printf 'xworkmate-bridge is running\n' 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' printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;; ;;
https://xworkmate-bridge.svc.plus/acp/rpc) https://xworkmate-bridge.svc.plus/acp/rpc)
@ -135,7 +135,7 @@ case "${scenario}" in
https://xworkmate-bridge.svc.plus/) https://xworkmate-bridge.svc.plus/)
printf 'xworkmate-bridge is running\n' 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' printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;; ;;
https://xworkmate-bridge.svc.plus/acp/rpc) https://xworkmate-bridge.svc.plus/acp/rpc)
@ -191,9 +191,9 @@ run_validate_capture() {
FAKE_CURL_STATE_DIR="${RUN_STATE_DIR}" \ FAKE_CURL_STATE_DIR="${RUN_STATE_DIR}" \
BRIDGE_SERVER_URL="https://xworkmate-bridge.svc.plus" \ BRIDGE_SERVER_URL="https://xworkmate-bridge.svc.plus" \
OPENCLAW_URL="wss://openclaw.svc.plus" \ OPENCLAW_URL="wss://openclaw.svc.plus" \
CODEX_RPC_URL="https://acp-server.svc.plus/codex/acp/rpc" \ CODEX_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc" \
OPENCODE_RPC_URL="https://acp-server.svc.plus/opencode/acp/rpc" \ OPENCODE_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc" \
GEMINI_RPC_URL="https://acp-server.svc.plus/gemini/acp/rpc" \ GEMINI_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc" \
INTERNAL_SERVICE_TOKEN="test-token" \ INTERNAL_SERVICE_TOKEN="test-token" \
bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1 bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1
)" )"