fix: tighten bridge runtime diagnostics

This commit is contained in:
Haitao Pan 2026-05-02 17:45:17 +08:00
parent 52ef962251
commit e4f8da2a67
7 changed files with 139 additions and 64 deletions

View File

@ -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<br/>https://xworkmate-bridge.svc.plus/acp-server/hermes"]
S --> L["Codex map<br/>https://xworkmate-bridge.svc.plus/acp-server/codex"]
S --> M["OpenCode map<br/>https://xworkmate-bridge.svc.plus/acp-server/opencode"]
S --> N["Gemini map<br/>https://xworkmate-bridge.svc.plus/acp-server/gemini"]
S --> O["OpenClaw map<br/>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`.

View File

@ -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<br/>/acp/rpc"]
CodexProvider["Codex map<br/>/acp-server/codex"]
OpenCodeProvider["OpenCode map<br/>/acp-server/opencode"]
GeminiAdapter["Gemini map<br/>/acp-server/gemini"]
OpenClawGateway["OpenClaw map<br/>/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. 运维配置优化

View File

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

View File

@ -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';
}

View File

@ -64,10 +64,7 @@ void main() {
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(
jsonEncode(<String, dynamic>{
'status': 'ok',
'version': '991ecb0',
}),
jsonEncode(<String, dynamic>{'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(<String, dynamic>{'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'], '');
},
);
});
}

View File

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

View File

@ -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 <String, dynamic>{},
),
throwsA(
isA<GatewayAcpException>()
.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<GatewayAcpException>().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);
},
);