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
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.

View File

@ -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<br/>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

View File

@ -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

View File

@ -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",
},
}

View File

@ -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 {

View File

@ -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
)"