fix: tighten bridge runtime diagnostics
This commit is contained in:
parent
52ef962251
commit
e4f8da2a67
@ -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`.
|
||||
|
||||
@ -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. 运维配置优化
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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'], '');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user