From c77a6b649a3e065cfd3d01d3fc80d1cf71204818 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 4 Apr 2026 18:37:56 +0800 Subject: [PATCH] fix(acp): preserve hosted base paths for external endpoints --- .../settings/settings_page_gateway_acp.dart | 10 ++++ lib/runtime/gateway_acp_client.dart | 38 ++++++++++++++- lib/web/web_acp_client.dart | 19 +++++++- ...tings_page_gateway_acp_messages_suite.dart | 24 ++++++++++ test/runtime/gateway_acp_client_suite.dart | 48 +++++++++++++++++++ 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/lib/features/settings/settings_page_gateway_acp.dart b/lib/features/settings/settings_page_gateway_acp.dart index 91974fd7..291c635f 100644 --- a/lib/features/settings/settings_page_gateway_acp.dart +++ b/lib/features/settings/settings_page_gateway_acp.dart @@ -48,6 +48,16 @@ String describeExternalAcpTestFailure(Object error, {Uri? endpoint}) { ); } + if (lowered.contains('handshakeexception') || + lowered.contains('tlsv1_alert_internal_error') || + lowered.contains('ssl alert number 80') || + lowered.contains('tls handshake failed')) { + return appText( + 'TLS 握手失败。当前更像是服务端 HTTPS/TLS 配置异常,而不是 ACP JSON-RPC 本身报错。请先用 curl 或 openssl 直接探测该域名;如果基地址带子路径,应用会自动派生到该子路径下的 /acp 与 /acp/rpc。', + 'TLS handshake failed. This looks more like a server-side HTTPS/TLS configuration issue than an ACP JSON-RPC failure. Probe the host directly with curl or openssl first; if the base URL includes a subpath, the app derives /acp and /acp/rpc under that subpath automatically.', + ); + } + return raw; } diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index ea778f99..5ab9d3b1 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -762,7 +762,7 @@ class GatewayAcpClient { final secure = base.scheme.toLowerCase() == 'https'; return base.replace( scheme: secure ? 'wss' : 'ws', - path: '/acp', + pathSegments: _deriveAcpPathSegments(base, includeRpc: false), query: null, fragment: null, ); @@ -777,7 +777,41 @@ class GatewayAcpClient { if (scheme != 'http' && scheme != 'https') { return null; } - return base.replace(path: '/acp/rpc', query: null, fragment: null); + return base.replace( + pathSegments: _deriveAcpPathSegments(base, includeRpc: true), + query: null, + fragment: null, + ); + } + + List _deriveAcpPathSegments(Uri base, {required bool includeRpc}) { + final segments = base.pathSegments + .where((segment) => segment.isNotEmpty) + .toList(growable: true); + final endsWithRpc = + segments.length >= 2 && + segments[segments.length - 2] == 'acp' && + segments.last == 'rpc'; + final endsWithAcp = segments.isNotEmpty && segments.last == 'acp'; + + if (endsWithRpc) { + if (includeRpc) { + return segments; + } + return segments.sublist(0, segments.length - 1); + } + if (endsWithAcp) { + if (includeRpc) { + return [...segments, 'rpc']; + } + return segments; + } + + return [ + ...segments, + 'acp', + if (includeRpc) 'rpc', + ]; } String _nextRequestId(String method) { diff --git a/lib/web/web_acp_client.dart b/lib/web/web_acp_client.dart index 8e6f39e9..49d861db 100644 --- a/lib/web/web_acp_client.dart +++ b/lib/web/web_acp_client.dart @@ -175,13 +175,30 @@ class WebAcpClient { _ => 'ws', }; return endpoint.replace( - path: '/acp', + pathSegments: _deriveAcpPathSegmentsInternal(endpoint), query: null, fragment: null, scheme: wsScheme, ); } + static List _deriveAcpPathSegmentsInternal(Uri endpoint) { + final segments = endpoint.pathSegments + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + final endsWithRpc = + segments.length >= 2 && + segments[segments.length - 2] == 'acp' && + segments.last == 'rpc'; + if (endsWithRpc) { + return segments.sublist(0, segments.length - 1); + } + if (segments.isNotEmpty && segments.last == 'acp') { + return segments; + } + return [...segments, 'acp']; + } + void throwIfJsonRpcErrorInternal(Map response) { final error = asMapInternal(response['error']); if (error.isEmpty) { diff --git a/test/features/settings_page_gateway_acp_messages_suite.dart b/test/features/settings_page_gateway_acp_messages_suite.dart index 0a8707f4..84d31efa 100644 --- a/test/features/settings_page_gateway_acp_messages_suite.dart +++ b/test/features/settings_page_gateway_acp_messages_suite.dart @@ -16,6 +16,16 @@ void main() { expect(text, contains('/acp/rpc')); }); + test('example copy still applies when hosted ACP uses a base path', () { + setActiveAppLanguage(AppLanguage.en); + addTearDown(() => setActiveAppLanguage(AppLanguage.zh)); + + final text = externalAcpEndpointExamplesText(); + + expect(text, contains('base URL')); + expect(text, contains('/acp')); + }); + test( 'websocket-only error suggests using https base URL for hosted ACP', () { @@ -49,5 +59,19 @@ void main() { expect(text, contains('HTTP ACP bridge')); }, ); + + test('tls handshake errors explain server-side tls diagnosis', () { + setActiveAppLanguage(AppLanguage.en); + addTearDown(() => setActiveAppLanguage(AppLanguage.zh)); + + final text = describeExternalAcpTestFailure( + 'HandshakeException: Handshake error in client (OS Error: TLSV1_ALERT_INTERNAL_ERROR)', + endpoint: Uri.parse('https://acp-server.example.com/opencode'), + ); + + expect(text, contains('TLS handshake failed')); + expect(text, contains('curl or openssl')); + expect(text, contains('subpath')); + }); }); } diff --git a/test/runtime/gateway_acp_client_suite.dart b/test/runtime/gateway_acp_client_suite.dart index aba5c369..b64a8e37 100644 --- a/test/runtime/gateway_acp_client_suite.dart +++ b/test/runtime/gateway_acp_client_suite.dart @@ -103,6 +103,34 @@ void main() { }, ); + test('preserves hosted ACP base path for websocket requests', () async { + final server = await _AcpFakeServer.start(); + addTearDown(server.close); + + final client = GatewayAcpClient( + endpointResolver: () => server.baseHttpUri.replace(path: '/opencode'), + ); + + final capabilities = await client.loadCapabilities(forceRefresh: true); + + expect(capabilities.singleAgent, isTrue); + expect(server.lastWebSocketRequestPath, '/opencode/acp'); + }); + + test('preserves hosted ACP base path for HTTP fallback requests', () async { + final server = await _AcpFakeServer.start(disableWebSocket: true); + addTearDown(server.close); + + final client = GatewayAcpClient( + endpointResolver: () => server.baseHttpUri.replace(path: '/opencode'), + ); + + final capabilities = await client.loadCapabilities(forceRefresh: true); + + expect(capabilities.singleAgent, isTrue); + expect(server.lastHttpRequestPath, '/opencode/acp/rpc'); + }); + test( 'streams multi-agent events and supports cancel/close session', () async { @@ -163,6 +191,8 @@ class _AcpFakeServer { final List rpcMethods = []; String? lastWebSocketAuthorization; String? lastHttpAuthorization; + String? lastWebSocketRequestPath; + String? lastHttpRequestPath; Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}'); @@ -189,6 +219,18 @@ class _AcpFakeServer { if (!disableWebSocket && request.uri.path == '/acp' && WebSocketTransformer.isUpgradeRequest(request)) { + lastWebSocketRequestPath = request.uri.path; + lastWebSocketAuthorization = request.headers.value( + HttpHeaders.authorizationHeader, + ); + final socket = await WebSocketTransformer.upgrade(request); + unawaited(_handleWebSocket(socket)); + continue; + } + if (!disableWebSocket && + request.uri.path == '/opencode/acp' && + WebSocketTransformer.isUpgradeRequest(request)) { + lastWebSocketRequestPath = request.uri.path; lastWebSocketAuthorization = request.headers.value( HttpHeaders.authorizationHeader, ); @@ -197,6 +239,12 @@ class _AcpFakeServer { continue; } if (request.uri.path == '/acp/rpc' && request.method == 'POST') { + lastHttpRequestPath = request.uri.path; + await _handleHttpRpc(request); + continue; + } + if (request.uri.path == '/opencode/acp/rpc' && request.method == 'POST') { + lastHttpRequestPath = request.uri.path; await _handleHttpRpc(request); continue; }