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);
},
);