diff --git a/docs/architecture/bridge-runtime-routing-map.md b/docs/architecture/bridge-runtime-routing-map.md index 98be108a..db70e956 100644 --- a/docs/architecture/bridge-runtime-routing-map.md +++ b/docs/architecture/bridge-runtime-routing-map.md @@ -4,7 +4,7 @@ Last Updated: 2026-04-21 本文记录 `xworkmate-app` 当前对 `xworkmate-bridge` 的运行时路由合同。UI 不直接承载这些路径;Assistant UI 仍由 `acp.capabilities` 返回的 `providerCatalog`、`gatewayProviders`、`availableExecutionTargets` 驱动。 -App 侧任务发送只调用 bridge 主入口 `/acp/rpc`,不再拼接 provider-specific 直连 URL。下列 provider public mapping 是 bridge-owned 后端事实,用于说明 bridge 如何把 catalog / routing 解析到实际 provider 或 gateway。 +App 侧任务发送只调用 bridge 主入口 `/acp/rpc`,不再拼接 provider-specific 直连 URL。Provider 与 gateway 的实际执行地址是 bridge 内部运行时事实,不属于 App contract。 ## App Runtime Flow @@ -26,23 +26,18 @@ flowchart TD P --> R["provider / requestedExecutionTarget params"] R --> S["bridge-owned routing"] - S --> K["Hermes map
https://xworkmate-bridge.svc.plus/acp-server/hermes"] - S --> L["Codex map
https://xworkmate-bridge.svc.plus/acp-server/codex"] - S --> M["OpenCode map
https://xworkmate-bridge.svc.plus/acp-server/opencode"] - S --> N["Gemini map
https://xworkmate-bridge.svc.plus/acp-server/gemini"] - S --> O["OpenClaw map
https://xworkmate-bridge.svc.plus/gateway/openclaw"] + S --> K["Hermes internal runtime"] + S --> L["Codex internal runtime"] + S --> M["OpenCode internal runtime"] + S --> N["Gemini internal runtime"] + S --> O["OpenClaw internal runtime"] ``` ## Routing Rules - App runtime requests use `https://xworkmate-bridge.svc.plus/acp/rpc`. - Provider and gateway selection are passed as request params, including `provider`, `routing`, and `requestedExecutionTarget`. -- Bridge-owned internal routing (Backend internal only): - - `Hermes` -> `/acp-server/hermes` - - `Codex` -> `/acp-server/codex` - - `OpenCode` -> `/acp-server/opencode` - - `Gemini` -> `/acp-server/gemini` - - `OpenClaw` -> `/gateway/openclaw` +- Bridge-owned internal routing is opaque to the App; it is not represented as public provider paths. - The app must not route managed bridge tasks to local or LAN endpoints such as `127.0.0.1:*` or `192.168.*:*`. - The app must not route managed bridge tasks by directly constructing `/acp-server/*` or `/gateway/*` URLs. - All App-side requests go through `https://xworkmate-bridge.svc.plus/acp/rpc`. diff --git a/docs/architecture/unified-routing-architecture.md b/docs/architecture/unified-routing-architecture.md index 2ae2739a..39673cf2 100644 --- a/docs/architecture/unified-routing-architecture.md +++ b/docs/architecture/unified-routing-architecture.md @@ -2,7 +2,7 @@ ## 1. 架构概览 (Unified Routing Architecture) -当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧只通过 managed bridge ACP 主入口发送任务,provider / gateway 的 public mapping 由 bridge 后端拥有。 +当前系统采用 `xworkmate-bridge.svc.plus` 作为统一入口。App 侧只通过 managed bridge ACP 主入口发送任务,provider / gateway 的执行地址由 bridge 后端内部拥有,不暴露为 App-facing public mapping。 ```mermaid graph TD @@ -16,10 +16,10 @@ graph TD subgraph "Bridge-owned Routing" ManagedBridge["Managed Bridge ACP
/acp/rpc"] - CodexProvider["Codex map
/acp-server/codex"] - OpenCodeProvider["OpenCode map
/acp-server/opencode"] - GeminiAdapter["Gemini map
/acp-server/gemini"] - OpenClawGateway["OpenClaw map
/gateway/openclaw"] + CodexProvider["Codex internal runtime"] + OpenCodeProvider["OpenCode internal runtime"] + GeminiAdapter["Gemini internal runtime"] + OpenClawGateway["OpenClaw internal runtime"] end %% Routing Rules @@ -40,10 +40,8 @@ graph TD | Bridge-owned mapping | App 侧行为 | 备注 | | :--- | :--- | :--- | | `/acp/rpc` | 直接调用 | Managed Bridge ACP 主入口,提供能力发现与任务发送 | -| `/acp-server/codex` | 不直连 | Bridge 后端映射至 Codex Provider | -| `/acp-server/opencode` | 不直连 | Bridge 后端映射至 OpenCode Provider | -| `/acp-server/gemini` | 不直连 | Bridge 后端映射至 Gemini Adapter | -| `/gateway/openclaw` | 不直连 | Bridge 后端映射至 OpenClaw Gateway | +| provider runtime | 不直连 | Bridge 后端内部解析 provider | +| gateway runtime | 不直连 | Bridge 后端内部解析 gateway provider | ## 3. 运维配置优化 diff --git a/lib/runtime/acp_endpoint_paths.dart b/lib/runtime/acp_endpoint_paths.dart index 9bd10ee7..b147db91 100644 --- a/lib/runtime/acp_endpoint_paths.dart +++ b/lib/runtime/acp_endpoint_paths.dart @@ -23,6 +23,27 @@ class AcpEndpointPaths { ); } + static bool isProviderMappingPath(String rawPath) { + var path = rawPath.trim(); + if (path.isEmpty || path == '/') { + return false; + } + if (!path.startsWith('/')) { + path = '/$path'; + } + path = path.replaceFirst(RegExp(r'/+$'), ''); + if (path.endsWith('/acp/rpc')) { + path = path.substring(0, path.length - '/acp/rpc'.length); + } else if (path.endsWith('/acp')) { + path = path.substring(0, path.length - '/acp'.length); + } + path = path.replaceFirst(RegExp(r'/+$'), ''); + return path == '/acp-server' || + path.startsWith('/acp-server/') || + path == '/gateway' || + path.startsWith('/gateway/'); + } + static String _normalizeBasePath(String rawPath) { var path = rawPath.trim(); if (path.isEmpty || path == '/') { @@ -44,12 +65,6 @@ class AcpEndpointPaths { } path = path.replaceFirst(RegExp(r'/+$'), ''); - if (path == '/acp-server' || - path.startsWith('/acp-server/') || - path == '/gateway' || - path.startsWith('/gateway/')) { - return ''; - } return path == '/' ? '' : path; } } @@ -58,6 +73,9 @@ Uri? resolveAcpWebSocketEndpoint(Uri? endpoint) { if (endpoint == null || endpoint.host.trim().isEmpty) { return null; } + if (AcpEndpointPaths.isProviderMappingPath(endpoint.path)) { + return null; + } final scheme = endpoint.scheme.trim().toLowerCase(); final wsScheme = switch (scheme) { 'https' || 'wss' => 'wss', @@ -76,6 +94,9 @@ Uri? resolveAcpHttpRpcEndpoint(Uri? endpoint) { if (endpoint == null || endpoint.host.trim().isEmpty) { return null; } + if (AcpEndpointPaths.isProviderMappingPath(endpoint.path)) { + return null; + } final scheme = endpoint.scheme.trim().toLowerCase(); if (scheme != 'http' && scheme != 'https') { return null; diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 37c0ec85..9373cf7d 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -680,12 +680,15 @@ class GatewayAcpClient { }) { final base = 'ACP HTTP request failed ($statusCode)'; final normalizedType = contentType.trim(); + final detail = _extractErrorDetail(body); if (normalizedType.isNotEmpty && !_contentTypeLooksJsonOrSse(normalizedType)) { + if (detail.isNotEmpty) { + return '$base · $detail · unexpected content type: $normalizedType'; + } return '$base · unexpected content type: $normalizedType'; } - final detail = _extractErrorDetail(body); if (detail.isNotEmpty) { return '$base · $detail'; } diff --git a/test/features/settings/settings_about_bridge_metadata_test.dart b/test/features/settings/settings_about_bridge_metadata_test.dart index 0e0e1f6e..df9a9852 100644 --- a/test/features/settings/settings_about_bridge_metadata_test.dart +++ b/test/features/settings/settings_about_bridge_metadata_test.dart @@ -64,10 +64,7 @@ void main() { ..statusCode = HttpStatus.ok ..headers.contentType = ContentType.json ..write( - jsonEncode({ - 'status': 'ok', - 'version': '991ecb0', - }), + jsonEncode({'status': 'ok', 'version': '991ecb0'}), ); await request.response.close(); }); @@ -135,5 +132,36 @@ void main() { expect(metadata['image'], ''); expect(metadata['buildDate'], ''); }); + + test( + 'returns unavailable when authorized bridge ping returns 502', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async { + await server.close(force: true); + }); + + server.listen((request) async { + request.response + ..statusCode = HttpStatus.badGateway + ..headers.contentType = ContentType.json + ..write(jsonEncode({'message': 'upstream down'})); + await request.response.close(); + }); + + final metadata = await loadBridgeMetadataForSettingsAbout( + bridgeEndpoint: Uri.parse( + 'http://${server.address.address}:${server.port}', + ), + authorizationResolver: (_) async => 'bridge-token', + ); + + expect(metadata['status'], 'unavailable'); + expect(metadata['version'], ''); + expect(metadata['commit'], ''); + expect(metadata['image'], ''); + expect(metadata['buildDate'], ''); + }, + ); }); } diff --git a/test/runtime/acp_endpoint_paths_test.dart b/test/runtime/acp_endpoint_paths_test.dart index b012a8f6..5d9b8909 100644 --- a/test/runtime/acp_endpoint_paths_test.dart +++ b/test/runtime/acp_endpoint_paths_test.dart @@ -11,7 +11,7 @@ void main() { expect(endpoint.toString(), 'https://xworkmate-bridge.svc.plus/acp/rpc'); }); - test('does not preserve provider mapping paths as app RPC bases', () { + test('rejects provider mapping paths as app RPC bases', () { final codexEndpoint = resolveAcpHttpRpcEndpoint( Uri.parse('https://xworkmate-bridge.svc.plus/acp-server/codex'), ); @@ -19,30 +19,16 @@ void main() { Uri.parse('https://xworkmate-bridge.svc.plus/gateway/openclaw'), ); - expect( - codexEndpoint.toString(), - 'https://xworkmate-bridge.svc.plus/acp/rpc', - ); - expect( - gatewayEndpoint.toString(), - 'https://xworkmate-bridge.svc.plus/acp/rpc', - ); + expect(codexEndpoint, isNull); + expect(gatewayEndpoint, isNull); }); - test( - 'normalizes provider mapping paths even when ACP suffix is present', - () { - final endpoint = resolveAcpHttpRpcEndpoint( - Uri.parse( - 'https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc', - ), - ); + test('rejects provider mapping paths even when ACP suffix is present', () { + final endpoint = resolveAcpHttpRpcEndpoint( + Uri.parse('https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc'), + ); - expect( - endpoint.toString(), - 'https://xworkmate-bridge.svc.plus/acp/rpc', - ); - }, - ); + expect(endpoint, isNull); + }); }); } diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index f23d49f0..4f2bdad2 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -273,6 +273,43 @@ void main() { ); }); + test('surfaces plain-text bridge HTTP 502 diagnostics', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + await utf8.decoder.bind(request).join(); + request.response + ..statusCode = HttpStatus.badGateway + ..headers.contentType = ContentType.text + ..write('openclaw upstream returned empty response'); + await request.response.close(); + }); + final client = GatewayAcpClient( + endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), + ); + + await expectLater( + client.request( + method: 'session.start', + params: const {}, + ), + throwsA( + isA() + .having((error) => error.code, 'code', 'ACP_HTTP_502') + .having( + (error) => error.message, + 'message', + contains('openclaw upstream returned empty response'), + ) + .having( + (error) => error.message, + 'content type', + contains('unexpected content type: text/plain'), + ), + ), + ); + }); + test('desktop bridge auth resolver skips unrelated endpoints', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-acp-auth-unrelated-', @@ -508,7 +545,7 @@ void main() { ); test( - 'desktop task execution normalizes provider endpoint paths back to bridge RPC', + 'desktop task execution rejects provider endpoint paths as bridge RPC bases', () async { final capture = await _startAcpHttpServer(); addTearDown(capture.close); @@ -524,17 +561,24 @@ void main() { capture.baseEndpoint.replace(path: '/acp-server/codex'), ); - await transport.executeTask( - _taskRequest( - target: AssistantExecutionTarget.agent, - provider: SingleAgentProvider.codex, + await expectLater( + transport.executeTask( + _taskRequest( + target: AssistantExecutionTarget.agent, + provider: SingleAgentProvider.codex, + ), + onUpdate: (_) {}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'ACP_HTTP_ENDPOINT_MISSING', + ), ), - onUpdate: (_) {}, ); - expect(capture.authorizationHeader, 'Bearer bridge-token'); - expect(capture.requestPath, '/acp/rpc'); - expect(capture.requestPath, isNot(contains('/acp-server'))); + expect(capture.requestBodies, isEmpty); }, );