diff --git a/docs/api-reference.md b/docs/api-reference.md index e930b1e..52fb046 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -86,7 +86,6 @@ The bridge currently handles these methods: | `session.close` | Close session state | | `xworkmate.dispatch.resolve` | Resolve provider choice from candidate providers and requirements | | `xworkmate.routing.resolve` | Resolve execution target / provider / skills from routing metadata | -| `xworkmate.providers.sync` | Sync external single-agent provider catalog into the bridge | | `xworkmate.mounts.reconcile` | Reconcile managed MCP configuration and mount-related settings | | `xworkmate.gateway.connect` | Connect bridge runtime to gateway | | `xworkmate.gateway.request` | Send a request to the connected gateway runtime | @@ -119,16 +118,16 @@ Response shape: "multiAgent": true, "providerCatalog": [ { "providerId": "codex", "label": "Codex" }, - { "providerId": "gemini", "label": "Gemini" }, - { "providerId": "opencode", "label": "OpenCode" } + { "providerId": "opencode", "label": "OpenCode" }, + { "providerId": "gemini", "label": "Gemini" } ], "capabilities": { "single_agent": true, "multi_agent": true, "providerCatalog": [ { "providerId": "codex", "label": "Codex" }, - { "providerId": "gemini", "label": "Gemini" }, - { "providerId": "opencode", "label": "OpenCode" } + { "providerId": "opencode", "label": "OpenCode" }, + { "providerId": "gemini", "label": "Gemini" } ] } } @@ -137,9 +136,12 @@ Response shape: Notes: -- `providerCatalog` comes from the synced external provider catalog registered - through `xworkmate.providers.sync` -- provider order is bridge-owned and preserves the sync order +- `providerCatalog` is bridge-owned and built in at startup +- production provider map is fixed to: + - `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` +- upstream ACP auth uses `Authorization: Bearer $INTERNAL_SERVICE_TOKEN` - `multiAgent` is controlled by `ACP_MULTI_AGENT_ENABLED`, default `true` ### 3.2 `session.start` @@ -343,58 +345,7 @@ Representative response fields: - `skillCandidates` - `memorySources` -### 3.8 `xworkmate.providers.sync` - -Purpose: - -- register external ACP single-agent providers into the bridge - -Request example: - -```json -{ - "jsonrpc": "2.0", - "id": "sync-1", - "method": "xworkmate.providers.sync", - "params": { - "providers": [ - { - "providerId": "opencode", - "label": "OpenCode", - "endpoint": "https://acp-server.svc.plus/opencode/acp/rpc", - "authorizationHeader": "Bearer ${OPENCODE_AUTH_TOKEN}", - "enabled": true - } - ] - } -} -``` - -Provider fields: - -- `providerId` -- `label` -- `endpoint` -- `authorizationHeader` -- `enabled` - -Response shape: - -```json -{ - "ok": true, - "providers": [ - { - "providerId": "opencode", - "label": "OpenCode", - "endpoint": "https://acp-server.svc.plus/opencode/acp/rpc", - "enabled": true - } - ] -} -``` - -### 3.9 `xworkmate.mounts.reconcile` +### 3.8 `xworkmate.mounts.reconcile` Purpose: @@ -423,13 +374,13 @@ Managed MCP server item shape: } ``` -### 3.10 Gateway runtime methods +### 3.9 Gateway runtime methods #### `xworkmate.gateway.connect` Purpose: -- connect a bridge runtime session to the gateway runtime +- connect a bridge runtime session to the bridge-owned production gateway route Key params: @@ -459,6 +410,13 @@ Response fields: - `returnedDeviceToken` - `error` +Notes: + +- for `mode=remote`, the bridge overrides runtime endpoint selection to + `wss://openclaw.svc.plus` +- upstream gateway auth uses `Authorization: Bearer $INTERNAL_SERVICE_TOKEN` +- the app does not provide production openclaw endpoint truth + #### `xworkmate.gateway.request` Purpose: diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md index edff0c7..b5c705d 100644 --- a/docs/architecture/acp-forwarding-topology.md +++ b/docs/architecture/acp-forwarding-topology.md @@ -1,55 +1,50 @@ # ACP Forwarding Topology -This document describes how `xworkmate-bridge.svc.plus` forwards requests to the public ACP and gateway endpoints. +This document describes the bridge-only production forwarding model for `xworkmate-bridge.svc.plus`. ## Topology ```mermaid flowchart TD - U[Client / App] --> B[xworkmate-bridge.svc.plus] + U["xworkmate-app"] --> B["https://xworkmate-bridge.svc.plus"] - B -->|HTTP POST /acp/rpc| ACPRPC[ACP HTTP RPC handler] - B -->|WebSocket /acp| ACPWS[ACP WebSocket handler] + B -->|POST /acp/rpc| RPC["ACP RPC handler"] + B -->|WS /acp| WS["ACP WebSocket handler"] - ACPRPC --> R{Method?} - ACPWS --> R + RPC --> R{"method"} + WS --> R - R -->|acp.capabilities| CAP[Return available provider list] - R -->|session.start / session.message| ENQ[Resolve routing and enqueue turn] - R -->|session.cancel / session.close| LIFE[Session lifecycle control] - R -->|xworkmate.providers.sync| SYNC[Sync external provider catalog] - R -->|xworkmate.gateway.*| GWAPI[Gateway control methods] - R -->|xworkmate.dispatch.resolve| DISPATCH[Dispatch resolution] - R -->|xworkmate.routing.resolve| ROUTE[Routing resolution] + R -->|acp.capabilities| CAP["built-in provider catalog"] + R -->|xworkmate.routing.resolve| ROUTE["bridge-owned routing resolve"] + R -->|session.start / session.message| RUN["bridge-owned execution"] + R -->|xworkmate.gateway.*| GWAPI["gateway runtime proxy"] + R -->|session.cancel / session.close| LIFE["session lifecycle"] - ENQ --> D{Resolved execution target} - D -->|gateway / openclaw| GW[gatewayruntime.Manager] - D -->|singleAgent + codex| C[codex provider] - D -->|singleAgent + opencode| O[opencode provider] - D -->|singleAgent + gemini| G[gemini provider] + RUN --> ACP1["codex -> https://acp-server.svc.plus/codex/acp/rpc"] + RUN --> ACP2["opencode -> https://acp-server.svc.plus/opencode/acp/rpc"] + RUN --> ACP3["gemini -> https://acp-server.svc.plus/gemini/acp/rpc"] - GW --> OCLAW[wss://openclaw.svc.plus] - C --> CODR[https://acp-server.svc.plus/codex/acp/rpc] - O --> OPR[https://acp-server.svc.plus/opencode/acp/rpc] - G --> GMR[https://acp-server.svc.plus/gemini/acp/rpc] - - SYNC --> CAT[providerCatalog] - CAT --> C - CAT --> O - CAT --> G + GWAPI --> GW["wss://openclaw.svc.plus"] ``` -## Request Flow +## Production Truth -The bridge accepts ACP JSON-RPC over `POST /acp/rpc` and ACP WebSocket traffic over `/acp`. +Bridge owns the production map: -For `session.start` and `session.message`, the server resolves routing metadata, selects either the gateway runtime or a single-agent provider, and then forwards the turn to the resolved endpoint. +- `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` +- gateway -> `wss://openclaw.svc.plus` -For the public single-agent ACP providers, `http` and `https` endpoints are forwarded as JSON-RPC `POST .../acp/rpc` requests, while `ws` and `wss` endpoints are forwarded as WebSocket ACP sessions on `/acp`. +Upstream auth is bridge-internal: -## Current Public Endpoints +- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN` -- `wss://openclaw.svc.plus` -- `https://acp-server.svc.plus/codex/acp/rpc` -- `https://acp-server.svc.plus/opencode/acp/rpc` -- `https://acp-server.svc.plus/gemini/acp/rpc` +## Invariants + +- app-facing cloud entry is only `https://xworkmate-bridge.svc.plus` +- `acp.capabilities` returns the built-in production catalog +- no production `xworkmate.providers.sync` +- no app direct call to `acp-server.svc.plus/*` +- no app direct call to `openclaw.svc.plus` +- remote gateway runtime status is reported as `openclaw.svc.plus:443`, but the app still talks only to the bridge diff --git a/docs/gemini-acp-adapter.md b/docs/gemini-acp-adapter.md index f55e1e8..893bedc 100644 --- a/docs/gemini-acp-adapter.md +++ b/docs/gemini-acp-adapter.md @@ -103,14 +103,16 @@ xworkmate app / agent manager -> stdio child process: gemini --experimental-acp ``` -The adapter should be registered in bridge provider sync as an external ACP provider: +The adapter should be registered in the bridge-owned provider catalog as the +`gemini` single-agent ACP backend: - `providerId: "gemini"` - `label: "Gemini"` - `endpoint: http://127.0.0.1:/acp/rpc` or `ws://127.0.0.1:/acp` - `enabled: true` -`xworkmate-bridge` already supports this provider shape through `xworkmate.providers.sync`. +In production, `xworkmate-bridge` exposes `gemini` from its built-in provider +catalog rather than from app-driven sync. ## Adapter Responsibilities @@ -302,25 +304,17 @@ If Gemini ACP later gains a compatible conversation method, you can override the export GEMINI_ADAPTER_UPSTREAM_METHOD=your-discovered-gemini-method ``` -## Bridge Provider Sync Example +## Bridge Provider Catalog Example -Once the adapter is running, register it as a normal external provider: +Once the adapter is running, expose it from the bridge-owned provider catalog +as the `gemini` backend: ```json { - "jsonrpc": "2.0", - "id": "providers-sync-1", - "method": "xworkmate.providers.sync", - "params": { - "providers": [ - { - "providerId": "gemini", - "label": "Gemini", - "endpoint": "http://127.0.0.1:8791/acp/rpc", - "enabled": true - } - ] - } + "providerId": "gemini", + "label": "Gemini", + "endpoint": "http://127.0.0.1:8791/acp/rpc", + "enabled": true } ``` @@ -333,23 +327,15 @@ Example local startup and sync flow: --gemini-args="--experimental-acp" ``` -Then sync this provider into the bridge: +Then wire the bridge startup/runtime config so the built-in `gemini` catalog +entry points at this adapter endpoint: ```json { - "jsonrpc": "2.0", - "id": "providers-sync-gemini", - "method": "xworkmate.providers.sync", - "params": { - "providers": [ - { - "providerId": "gemini", - "label": "Gemini", - "endpoint": "http://127.0.0.1:8791", - "enabled": true - } - ] - } + "providerId": "gemini", + "label": "Gemini", + "endpoint": "http://127.0.0.1:8791", + "enabled": true } ``` diff --git a/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md b/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md index 07e42d4..292a519 100644 --- a/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md +++ b/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md @@ -19,7 +19,7 @@ - `resolvedExecutionTarget` - `resolvedProviderId` - `resolvedEndpointTarget` -- `xworkmate.providers.sync` 能把 `accounts.svc.plus` 同步来的外部 provider 注入 bridge,并参与后续路由选择。 +- `acp.capabilities` 暴露 bridge 内建的生产 provider catalog,并参与后续路由选择。 ### 2. 典型 Case 层 @@ -96,7 +96,7 @@ flutter test test/runtime/app_controller_single_agent_workspace_binding_regressi ### 路由发现层断言 - `acp.capabilities` 的 provider 列表来自 bridge 当前环境,而不是本地写死。 -- `xworkmate.providers.sync` 后,新增 provider 能进入能力面与路由面。 +- bridge 内建生产 catalog 包含 `codex / opencode / gemini`,且不依赖 app 侧预同步。 - `xworkmate.routing.resolve` 在 skill / prompt / target 组合下,返回合理的 provider 与 endpoint target。 ### 执行层断言 diff --git a/internal/acp/execution.go b/internal/acp/execution.go index 0acba2a..f48ef7d 100644 --- a/internal/acp/execution.go +++ b/internal/acp/execution.go @@ -21,10 +21,7 @@ import ( ) const ( - externalProviderEndpointKey = "externalProviderEndpoint" - externalProviderAuthorizationHeaderKey = "externalProviderAuthorizationHeader" - externalProviderLabelKey = "externalProviderLabel" - inboundAuthorizationHeaderKey = "bridgeAuthorizationHeader" + inboundAuthorizationHeaderKey = "bridgeAuthorizationHeader" ) func buildResolvedExecutionParams( @@ -61,25 +58,6 @@ func buildResolvedExecutionParams( return next } -func injectResolvedExternalProviderParams( - params map[string]any, - provider syncedProvider, -) map[string]any { - if params == nil { - params = map[string]any{} - } - if endpoint := strings.TrimSpace(provider.Endpoint); endpoint != "" { - params[externalProviderEndpointKey] = endpoint - } - if authorization := strings.TrimSpace(provider.AuthorizationHeader); authorization != "" { - params[externalProviderAuthorizationHeaderKey] = authorization - } - if label := strings.TrimSpace(provider.Label); label != "" { - params[externalProviderLabelKey] = label - } - return params -} - func injectInboundAuthorizationHeader(params map[string]any, authorization string) map[string]any { if params == nil { params = map[string]any{} @@ -184,18 +162,7 @@ func (s *Server) runSingleAgentViaExternalProvider( } func resolveSingleAgentForwardEndpoint(provider syncedProvider) string { - endpoint := strings.TrimSpace(provider.Endpoint) - if endpoint == "" { - return "" - } - if !strings.Contains(strings.ToLower(endpoint), "xworkmate-bridge.svc.plus") { - return endpoint - } - providerID := strings.TrimSpace(strings.ToLower(provider.ProviderID)) - if providerID == "" { - return endpoint - } - return fmt.Sprintf("https://acp-server.svc.plus/%s/acp/rpc", providerID) + return strings.TrimSpace(provider.Endpoint) } func sanitizeExternalACPParams(method string, params map[string]any) map[string]any { @@ -213,9 +180,6 @@ func sanitizeExternalACPParams(method string, params map[string]any) map[string] delete(next, "resolvedProviderId") delete(next, "resolvedModel") delete(next, "resolvedSkills") - delete(next, externalProviderEndpointKey) - delete(next, externalProviderAuthorizationHeaderKey) - delete(next, externalProviderLabelKey) delete(next, inboundAuthorizationHeaderKey) // Gateway-only fields are irrelevant in ACP single-agent forwarding. normalizedMethod := strings.TrimSpace(method) @@ -226,22 +190,6 @@ func sanitizeExternalACPParams(method string, params map[string]any) map[string] return next } -func externalProviderFromParams(params map[string]any) (syncedProvider, bool) { - endpoint := strings.TrimSpace(shared.StringArg(params, externalProviderEndpointKey, "")) - if endpoint == "" { - return syncedProvider{}, false - } - return syncedProvider{ - ProviderID: strings.TrimSpace(shared.StringArg(params, "provider", "")), - Label: strings.TrimSpace(shared.StringArg(params, externalProviderLabelKey, "")), - Endpoint: endpoint, - AuthorizationHeader: strings.TrimSpace( - shared.StringArg(params, externalProviderAuthorizationHeaderKey, ""), - ), - Enabled: true, - }, true -} - func requestExternalACP( ctx context.Context, endpoint, diff --git a/internal/acp/execution_test.go b/internal/acp/execution_test.go index 5b4ace9..eed9f0a 100644 --- a/internal/acp/execution_test.go +++ b/internal/acp/execution_test.go @@ -19,20 +19,20 @@ func TestResolveSingleAgentForwardEndpoint(t *testing.T) { want: "https://acp-server.svc.plus/opencode/acp/rpc", }, { - name: "rewrites bridge discovery endpoint to codex upstream", + name: "does not rewrite bridge endpoint placeholder for codex", provider: syncedProvider{ ProviderID: "codex", Endpoint: "https://xworkmate-bridge.svc.plus", }, - want: "https://acp-server.svc.plus/codex/acp/rpc", + want: "https://xworkmate-bridge.svc.plus", }, { - name: "rewrites bridge discovery endpoint to gemini upstream", + name: "does not rewrite bridge endpoint placeholder for gemini", provider: syncedProvider{ ProviderID: "gemini", Endpoint: "https://xworkmate-bridge.svc.plus", }, - want: "https://acp-server.svc.plus/gemini/acp/rpc", + want: "https://xworkmate-bridge.svc.plus", }, } diff --git a/internal/acp/gateway_runtime.go b/internal/acp/gateway_runtime.go index d49417c..287c8e1 100644 --- a/internal/acp/gateway_runtime.go +++ b/internal/acp/gateway_runtime.go @@ -53,6 +53,7 @@ func handleGatewayConnect( Password: strings.TrimSpace(shared.StringArg(asMap(params["auth"]), "password", "")), }, } + request = applyProductionGatewayRouting(request) request.ReportedRemoteAddress = resolveGatewayReportedRemoteAddress(server, request) result := server.gateway.Connect(request, notify) return map[string]any{ @@ -64,6 +65,28 @@ func handleGatewayConnect( } } +func applyProductionGatewayRouting( + request gatewayruntime.ConnectRequest, +) gatewayruntime.ConnectRequest { + if strings.TrimSpace(strings.ToLower(request.Mode)) != "remote" { + return request + } + request.Endpoint = gatewayruntime.Endpoint{ + Host: "openclaw.svc.plus", + Port: 443, + TLS: true, + } + request.Auth.Token = strings.TrimSpace( + shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", ""), + ) + request.Auth.Password = "" + request.ConnectAuthMode = "shared-token" + request.ConnectAuthFields = []string{"token"} + request.ConnectAuthSources = []string{"bridge"} + request.HasSharedAuth = request.Auth.Token != "" + return request +} + func handleGatewayRequest( server *Server, params map[string]any, @@ -167,28 +190,8 @@ func resolveGatewayReportedRemoteAddress( if strings.TrimSpace(strings.ToLower(request.Mode)) != "remote" { return "" } - if !shouldOverrideGatewayReportedRemoteAddress(request.Endpoint.Host) { - return "" - } - if server != nil { - if provider, ok := server.syncedProviderByID("openclaw"); ok { - if reported := publicEndpointAddressLabel(provider.Endpoint); reported != "" { - return reported - } - } - } - return publicEndpointAddressLabel( - shared.EnvOrDefault("OPENCLAW_URL", "wss://openclaw.svc.plus"), - ) -} - -func shouldOverrideGatewayReportedRemoteAddress(host string) bool { - switch strings.TrimSpace(strings.ToLower(host)) { - case "127.0.0.1", "localhost", "::1", "xworkmate-bridge.svc.plus": - return true - default: - return false - } + _ = server + return publicEndpointAddressLabel(productionGatewayEndpointURL) } func publicEndpointAddressLabel(raw string) string { diff --git a/internal/acp/gateway_runtime_test.go b/internal/acp/gateway_runtime_test.go index e5541a4..1839c72 100644 --- a/internal/acp/gateway_runtime_test.go +++ b/internal/acp/gateway_runtime_test.go @@ -6,18 +6,10 @@ import ( "xworkmate-bridge/internal/gatewayruntime" ) -func TestResolveGatewayReportedRemoteAddressUsesSyncedOpenClawEndpoint(t *testing.T) { +func TestResolveGatewayReportedRemoteAddressUsesBuiltInOpenClawEndpoint(t *testing.T) { t.Parallel() server := NewServer() - server.syncProviders([]syncedProvider{ - { - ProviderID: "openclaw", - Label: "OpenClaw", - Endpoint: "wss://gateway.example.com", - Enabled: true, - }, - }) got := resolveGatewayReportedRemoteAddress(server, gatewayruntime.ConnectRequest{ Mode: "remote", @@ -28,13 +20,15 @@ func TestResolveGatewayReportedRemoteAddressUsesSyncedOpenClawEndpoint(t *testin }, }) - const want = "gateway.example.com:443" + const want = "openclaw.svc.plus:443" if got != want { t.Fatalf("resolveGatewayReportedRemoteAddress() = %q, want %q", got, want) } } -func TestResolveGatewayReportedRemoteAddressPreservesExplicitPublicRemoteHost(t *testing.T) { +func TestResolveGatewayReportedRemoteAddressNormalizesExplicitPublicRemoteHost( + t *testing.T, +) { t.Parallel() server := NewServer() @@ -48,7 +42,8 @@ func TestResolveGatewayReportedRemoteAddressPreservesExplicitPublicRemoteHost(t }, }) - if got != "" { - t.Fatalf("expected explicit public remote host to bypass override, got %q", got) + const want = "openclaw.svc.plus:443" + if got != want { + t.Fatalf("resolveGatewayReportedRemoteAddress() = %q, want %q", got, want) } } diff --git a/internal/acp/providers_sync.go b/internal/acp/provider_catalog.go similarity index 50% rename from internal/acp/providers_sync.go rename to internal/acp/provider_catalog.go index ef679d0..66e00d3 100644 --- a/internal/acp/providers_sync.go +++ b/internal/acp/provider_catalog.go @@ -2,8 +2,19 @@ package acp import ( "strings" + + "xworkmate-bridge/internal/shared" ) +const ( + productionGatewayEndpointURL = "wss://openclaw.svc.plus" + productionCodexEndpointURL = "https://acp-server.svc.plus/codex/acp/rpc" + productionOpenCodeEndpointURL = "https://acp-server.svc.plus/opencode/acp/rpc" + productionGeminiEndpointURL = "https://acp-server.svc.plus/gemini/acp/rpc" +) + +var productionProviderOrder = []string{"codex", "opencode", "gemini"} + type syncedProvider struct { ProviderID string Label string @@ -12,47 +23,40 @@ type syncedProvider struct { Enabled bool } -func parseSyncedProviders(raw any) []syncedProvider { - list, ok := raw.([]any) - if !ok { - return nil +func newProductionProviderCatalog() (map[string]syncedProvider, []string) { + authorizationHeader := normalizeAuthorizationHeader( + strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", "")), + ) + providers := []syncedProvider{ + { + ProviderID: "codex", + Label: "Codex", + Endpoint: productionCodexEndpointURL, + AuthorizationHeader: authorizationHeader, + Enabled: true, + }, + { + ProviderID: "opencode", + Label: "OpenCode", + Endpoint: productionOpenCodeEndpointURL, + AuthorizationHeader: authorizationHeader, + Enabled: true, + }, + { + ProviderID: "gemini", + Label: "Gemini", + Endpoint: productionGeminiEndpointURL, + AuthorizationHeader: authorizationHeader, + Enabled: true, + }, } - providers := make([]syncedProvider, 0, len(list)) - for _, item := range list { - entry := asMap(item) - providerID := strings.TrimSpace(sharedString(entry, "providerId")) - if providerID == "" { - continue - } - providers = append(providers, syncedProvider{ - ProviderID: providerID, - Label: strings.TrimSpace(sharedString(entry, "label")), - Endpoint: strings.TrimSpace(sharedString(entry, "endpoint")), - AuthorizationHeader: strings.TrimSpace(sharedString(entry, "authorizationHeader")), - Enabled: parseBool(entry["enabled"]), - }) - } - return providers -} - -func (s *Server) syncProviders(providers []syncedProvider) map[string]any { - s.mu.Lock() - defer s.mu.Unlock() - s.providerCatalog = make(map[string]syncedProvider, len(providers)) - s.providerOrder = make([]string, 0, len(providers)) + catalog := make(map[string]syncedProvider, len(providers)) + order := make([]string, 0, len(providers)) for _, provider := range providers { - providerID := strings.TrimSpace(provider.ProviderID) - if providerID == "" { - continue - } - provider.ProviderID = providerID - s.providerCatalog[providerID] = provider - s.providerOrder = append(s.providerOrder, providerID) - } - return map[string]any{ - "ok": true, - "providers": syncedProvidersResult(providers), + catalog[provider.ProviderID] = provider + order = append(order, provider.ProviderID) } + return catalog, order } func (s *Server) syncedProviderByID(providerID string) (syncedProvider, bool) { @@ -102,19 +106,6 @@ func (s *Server) availableProviderCatalog() []map[string]any { return result } -func syncedProvidersResult(providers []syncedProvider) []map[string]any { - result := make([]map[string]any, 0, len(providers)) - for _, provider := range providers { - result = append(result, map[string]any{ - "providerId": provider.ProviderID, - "label": providerLabel(provider), - "endpoint": provider.Endpoint, - "enabled": provider.Enabled, - }) - } - return result -} - func providerLabel(provider syncedProvider) string { if label := strings.TrimSpace(provider.Label); label != "" { return label diff --git a/internal/acp/providers_sync_test.go b/internal/acp/providers_sync_test.go index a976f51..ff975ff 100644 --- a/internal/acp/providers_sync_test.go +++ b/internal/acp/providers_sync_test.go @@ -13,81 +13,20 @@ import ( "xworkmate-bridge/internal/shared" ) -func TestProvidersSyncUpdatesCapabilities(t *testing.T) { - server := NewServer() - - _, rpcErr := server.handleRequest(shared.RPCRequest{ - Method: "xworkmate.providers.sync", - Params: map[string]any{ - "providers": []any{ - map[string]any{ - "providerId": "claude", - "label": "Claude", - "endpoint": "http://127.0.0.1:9999", - "authorizationHeader": "Bearer test", - "enabled": true, - }, - }, - }, - }, func(map[string]any) {}) - if rpcErr != nil { - t.Fatalf("expected sync success, got %v", rpcErr) - } - - result, rpcErr := server.handleRequest(shared.RPCRequest{ - Method: "acp.capabilities", - Params: map[string]any{}, - }, func(map[string]any) {}) - if rpcErr != nil { - t.Fatalf("expected capabilities success, got %v", rpcErr) - } - providerCatalog, ok := result["providerCatalog"].([]map[string]any) - if !ok || len(providerCatalog) == 0 { - t.Fatalf("expected synced provider in capabilities, got %#v", result) - } - if providerCatalog[0]["providerId"] != "claude" { - t.Fatalf("expected claude provider after sync, got %#v", providerCatalog) - } - if providerCatalog[0]["label"] != "Claude" { - t.Fatalf("expected Claude label after sync, got %#v", providerCatalog) +func setTestBridgeProvider(server *Server, provider syncedProvider) { + server.mu.Lock() + defer server.mu.Unlock() + if server.providerCatalog == nil { + server.providerCatalog = map[string]syncedProvider{} } + providerID := strings.TrimSpace(provider.ProviderID) + provider.ProviderID = providerID + server.providerCatalog[providerID] = provider } -func TestProvidersSyncPreservesProviderCatalogOrder(t *testing.T) { +func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) { server := NewServer() - _, rpcErr := server.handleRequest(shared.RPCRequest{ - Method: "xworkmate.providers.sync", - Params: map[string]any{ - "providers": []any{ - map[string]any{ - "providerId": "gemini", - "label": "Gemini", - "endpoint": "http://127.0.0.1:9001", - "authorizationHeader": "Bearer gemini", - "enabled": true, - }, - map[string]any{ - "providerId": "codex", - "label": "Codex", - "endpoint": "http://127.0.0.1:9002", - "authorizationHeader": "Bearer codex", - "enabled": true, - }, - map[string]any{ - "providerId": "opencode", - "label": "OpenCode", - "endpoint": "http://127.0.0.1:9003", - "authorizationHeader": "Bearer opencode", - "enabled": true, - }, - }, - }, - }, func(map[string]any) {}) - if rpcErr != nil { - t.Fatalf("expected sync success, got %v", rpcErr) - } - result, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "acp.capabilities", Params: map[string]any{}, @@ -100,22 +39,37 @@ func TestProvidersSyncPreservesProviderCatalogOrder(t *testing.T) { t.Fatalf("expected providerCatalog array, got %#v", result) } if len(providerCatalog) != 3 { - t.Fatalf("expected 3 catalog entries, got %#v", providerCatalog) + t.Fatalf("expected 3 built-in providers, got %#v", providerCatalog) } - gotOrder := []string{ - providerCatalog[0]["providerId"].(string), - providerCatalog[1]["providerId"].(string), - providerCatalog[2]["providerId"].(string), - } - wantOrder := []string{"gemini", "codex", "opencode"} + wantOrder := []string{"codex", "opencode", "gemini"} + wantLabels := []string{"Codex", "OpenCode", "Gemini"} for index, want := range wantOrder { - if gotOrder[index] != want { - t.Fatalf("expected provider order %#v, got %#v", wantOrder, gotOrder) + if got := providerCatalog[index]["providerId"]; got != want { + t.Fatalf("expected provider %q at index %d, got %#v", want, index, providerCatalog) + } + if got := providerCatalog[index]["label"]; got != wantLabels[index] { + t.Fatalf("expected label %q at index %d, got %#v", wantLabels[index], index, providerCatalog) } } } -func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) { +func TestProvidersSyncMethodIsRemovedFromProductionFlow(t *testing.T) { + server := NewServer() + _, rpcErr := server.handleRequest(shared.RPCRequest{ + Method: "xworkmate.providers.sync", + }, func(map[string]any) {}) + if rpcErr == nil { + t.Fatalf("expected xworkmate.providers.sync to be unavailable") + } + if rpcErr.Code != -32601 { + t.Fatalf("expected unknown method error, got %#v", rpcErr) + } + if !strings.Contains(rpcErr.Message, "xworkmate.providers.sync") { + t.Fatalf("expected method name in error, got %#v", rpcErr) + } +} + +func TestExecuteSessionTaskUsesBuiltInProductionProvider(t *testing.T) { var lastForwardedParams map[string]any externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/acp/rpc" { @@ -140,7 +94,7 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) { "success": true, "output": "external-provider-ok", "turnId": "turn-external", - "provider": "claude", + "provider": "codex", "mode": "single-agent", }, }) @@ -155,14 +109,13 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) { defer externalServer.Close() server := NewServer() - server.syncProviders([]syncedProvider{ - { - ProviderID: "claude", - Label: "Claude", - Endpoint: externalServer.URL, - AuthorizationHeader: "Bearer test", - Enabled: true, - }, + t.Setenv("INTERNAL_SERVICE_TOKEN", "internal-test-token") + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "codex", + Label: "Codex", + Endpoint: externalServer.URL, + AuthorizationHeader: "Bearer internal-test-token", + Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ @@ -176,7 +129,7 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) { "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", - "explicitProviderId": "claude", + "explicitProviderId": "codex", }, }, }, @@ -187,15 +140,12 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) { if got := response["output"]; got != "external-provider-ok" { t.Fatalf("expected external provider output, got %#v", response) } - if got := response["resolvedProviderId"]; got != "claude" { - t.Fatalf("expected resolved provider claude, got %#v", response) + if got := response["resolvedProviderId"]; got != "codex" { + t.Fatalf("expected resolved provider codex, got %#v", response) } if _, exists := lastForwardedParams["metadata"]; exists { t.Fatalf("expected metadata to be stripped for external provider request, got %#v", lastForwardedParams) } - if _, exists := lastForwardedParams[externalProviderEndpointKey]; exists { - t.Fatalf("expected internal endpoint key to be stripped, got %#v", lastForwardedParams) - } } func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteMetadata(t *testing.T) { @@ -240,14 +190,12 @@ func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteM defer externalServer.Close() server := NewServer() - server.syncProviders([]syncedProvider{ - { - ProviderID: "claude", - Label: "Claude", - Endpoint: externalServer.URL, - AuthorizationHeader: "Bearer test", - Enabled: true, - }, + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "codex", + Label: "Codex", + Endpoint: externalServer.URL, + AuthorizationHeader: "Bearer internal-test-token", + Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ @@ -261,7 +209,7 @@ func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteM "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", - "explicitProviderId": "claude", + "explicitProviderId": "codex", }, }, }, @@ -298,58 +246,6 @@ func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteM } } -func TestRunSingleAgentUsesFrozenExternalProviderParams(t *testing.T) { - externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/acp/rpc" { - http.NotFound(w, r) - return - } - defer func() { - _ = r.Body.Close() - }() - var request map[string]any - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Fatalf("decode request: %v", err) - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "jsonrpc": "2.0", - "id": request["id"], - "result": map[string]any{ - "success": true, - "output": "frozen-provider-ok", - "turnId": "turn-frozen", - "provider": "custom-agent-1", - "mode": "single-agent", - }, - }) - })) - defer externalServer.Close() - - server := NewServer() - session := server.getOrCreateSession("session-frozen", "thread-frozen") - result := server.runSingleAgent( - context.Background(), - "session.start", - session, - map[string]any{ - "provider": "custom-agent-1", - "taskPrompt": "hello", - "workingDirectory": t.TempDir(), - externalProviderEndpointKey: externalServer.URL, - externalProviderAuthorizationHeaderKey: "Bearer test", - externalProviderLabelKey: "Codex", - }, - "turn-frozen", - func(map[string]any) {}, - ) - if result.err != nil { - t.Fatalf("expected success, got rpc error: %v", result.err) - } - if got := result.response["output"]; got != "frozen-provider-ok" { - t.Fatalf("expected frozen provider output, got %#v", result.response) - } -} - func TestRunSingleAgentRequiresAdvertisedProvider(t *testing.T) { server := NewServer() session := server.getOrCreateSession("session-local", "thread-local") @@ -358,7 +254,7 @@ func TestRunSingleAgentRequiresAdvertisedProvider(t *testing.T) { "session.start", session, map[string]any{ - "provider": "opencode", + "provider": "claude", "taskPrompt": "hello", "workingDirectory": filepath.Join(t.TempDir(), "missing"), }, @@ -392,14 +288,15 @@ func TestHandleRPCRequiresExplicitBearerForExternalProvider(t *testing.T) { })) defer externalServer.Close() + t.Setenv("INTERNAL_SERVICE_TOKEN", "synced-provider-token") server := NewServer() - server.syncProviders([]syncedProvider{{ + setTestBridgeProvider(server, syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: externalServer.URL, AuthorizationHeader: "Bearer synced-provider-token", Enabled: true, - }}) + }) recorder := httptest.NewRecorder() request := httptest.NewRequest( diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index 4f5915c..4c13e30 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -175,20 +175,20 @@ func TestExecuteSessionTaskAutoRoutingRecordsProjectMemory(t *testing.T) { t.Setenv("HOME", homeDir) server := NewServer() - providerServer := newExternalSingleAgentProvider(t, "claude", "done") + providerServer := newExternalSingleAgentProvider(t, "codex", "done") defer providerServer.Close() - server.syncProviders([]syncedProvider{{ - ProviderID: "claude", - Label: "Claude", + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "codex", + Label: "Codex", Endpoint: providerServer.URL, Enabled: true, - }}) + }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Params: map[string]any{ "sessionId": "session-auto", "threadId": "thread-auto", - "provider": "claude", + "provider": "codex", "taskPrompt": "create a powerpoint deck for launch", "workingDirectory": workspaceDir, "routing": map[string]any{ @@ -246,26 +246,26 @@ func TestExecuteSessionTaskExplicitRoutingDoesNotRecordProjectMemory(t *testing. t.Setenv("HOME", homeDir) server := NewServer() - providerServer := newExternalSingleAgentProvider(t, "claude", "done") + providerServer := newExternalSingleAgentProvider(t, "codex", "done") defer providerServer.Close() - server.syncProviders([]syncedProvider{{ - ProviderID: "claude", - Label: "Claude", + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "codex", + Label: "Codex", Endpoint: providerServer.URL, Enabled: true, - }}) + }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Params: map[string]any{ "sessionId": "session-explicit", "threadId": "thread-explicit", - "provider": "claude", + "provider": "codex", "taskPrompt": "create a powerpoint deck for launch", "workingDirectory": workspaceDir, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", - "explicitProviderId": "claude", + "explicitProviderId": "codex", "availableSkills": []any{ map[string]any{ "id": "pptx", @@ -330,7 +330,7 @@ func TestExecuteSessionTaskExplicitProviderRequiresAdvertisedBridgeProvider(t *t } } -func TestExecuteSessionTaskAutoRoutingUsesBridgeSyncOrderForProviderResolution(t *testing.T) { +func TestExecuteSessionTaskAutoRoutingUsesBridgeProductionProviderOrder(t *testing.T) { workspaceDir := filepath.Join(t.TempDir(), "workspace") if err := os.MkdirAll(workspaceDir, 0o755); err != nil { t.Fatalf("create workspace: %v", err) @@ -341,19 +341,17 @@ func TestExecuteSessionTaskAutoRoutingUsesBridgeSyncOrderForProviderResolution(t defer geminiProvider.Close() codexProvider := newExternalSingleAgentProvider(t, "codex", "codex-output") defer codexProvider.Close() - server.syncProviders([]syncedProvider{ - { - ProviderID: "gemini", - Label: "Gemini", - Endpoint: geminiProvider.URL, - Enabled: true, - }, - { - ProviderID: "codex", - Label: "Codex", - Endpoint: codexProvider.URL, - Enabled: true, - }, + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "gemini", + Label: "Gemini", + Endpoint: geminiProvider.URL, + Enabled: true, + }) + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "codex", + Label: "Codex", + Endpoint: codexProvider.URL, + Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ @@ -382,8 +380,8 @@ func TestExecuteSessionTaskAutoRoutingUsesBridgeSyncOrderForProviderResolution(t if rpcErr != nil { t.Fatalf("expected success, got rpc error: %v", rpcErr) } - if got := response["resolvedProviderId"]; got != "gemini" { - t.Fatalf("expected resolved provider gemini from bridge order, got %#v", response) + if got := response["resolvedProviderId"]; got != "codex" { + t.Fatalf("expected resolved provider codex from built-in bridge order, got %#v", response) } } diff --git a/internal/acp/server.go b/internal/acp/server.go index 3b133bb..d421e43 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -104,12 +104,13 @@ func Serve(args []string) error { } func NewServer() *Server { + providerCatalog, providerOrder := newProductionProviderCatalog() return &Server{ sessions: make(map[string]*session), queues: make(map[string]chan task), gateway: gatewayruntime.NewManager(), - providerCatalog: make(map[string]syncedProvider), - providerOrder: nil, + providerCatalog: providerCatalog, + providerOrder: providerOrder, authService: service.NewStaticTokenAuthService(strings.TrimSpace(shared.EnvOrDefault("ACP_AUTH_TOKEN", ""))), } } @@ -372,8 +373,6 @@ func (s *Server) handleRequest( s.availableProviders(), ) return mergeRoutingResponse(map[string]any{"ok": true}, result), nil - case "xworkmate.providers.sync": - return s.syncProviders(parseSyncedProviders(request.Params["providers"])), nil case "xworkmate.mounts.reconcile": return handleMountReconcile(request.Params), nil case "xworkmate.gateway.connect": @@ -616,14 +615,6 @@ func (s *Server) executeSessionTask(task task) (map[string]any, *shared.RPCError executionParams := buildResolvedExecutionParams(params, resolvedRouting) mode := strings.TrimSpace(shared.StringArg(executionParams, "mode", "single-agent")) provider := strings.TrimSpace(shared.StringArg(executionParams, "provider", "")) - if provider != "" { - if syncedProvider, ok := s.syncedProviderByID(provider); ok { - executionParams = injectResolvedExternalProviderParams( - executionParams, - syncedProvider, - ) - } - } session := s.getOrCreateSession(sessionID, threadID) session.mode = mode @@ -716,51 +707,6 @@ func (s *Server) runSingleAgent( workingDirectory, ) - if syncedProvider, ok := externalProviderFromParams(params); ok { - response, err := s.runSingleAgentViaExternalProvider( - ctx, - syncedProvider, - method, - params, - notify, - ) - if err == nil { - result := asMap(response["result"]) - if len(result) == 0 { - result = response - } - if _, exists := result["provider"]; !exists { - result["provider"] = provider - } - if _, exists := result["mode"]; !exists { - result["mode"] = "single-agent" - } - if _, exists := result["turnId"]; !exists { - result["turnId"] = turnID - } - if _, exists := result["effectiveWorkingDirectory"]; !exists && effectiveWorkingDirectory != "" { - result["effectiveWorkingDirectory"] = effectiveWorkingDirectory - } - return taskResult{response: enrichSingleAgentResultArtifacts(result, params)} - } - s.emitSessionUpdate(session, notify, turnID, map[string]any{ - "type": "status", - "event": "completed", - "message": err.Error(), - "pending": false, - "error": true, - }) - return taskResult{ - response: map[string]any{ - "success": false, - "error": err.Error(), - "turnId": turnID, - "mode": "single-agent", - "provider": provider, - }, - } - } - if syncedProvider, ok := s.syncedProviderByID(provider); ok { response, err := s.runSingleAgentViaExternalProvider( ctx, @@ -795,13 +741,6 @@ func (s *Server) runSingleAgent( "pending": false, "error": true, }) - s.emitSessionUpdate(session, notify, turnID, map[string]any{ - "type": "status", - "event": "completed", - "message": err.Error(), - "pending": false, - "error": true, - }) return taskResult{ response: map[string]any{ "success": false,