fix(acp): preserve hosted base paths for external endpoints

This commit is contained in:
Haitao Pan 2026-04-04 18:37:56 +08:00
parent a1a55d1cbd
commit c77a6b649a
5 changed files with 136 additions and 3 deletions

View File

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

View File

@ -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<String> _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 <String>[...segments, 'rpc'];
}
return segments;
}
return <String>[
...segments,
'acp',
if (includeRpc) 'rpc',
];
}
String _nextRequestId(String method) {

View File

@ -175,13 +175,30 @@ class WebAcpClient {
_ => 'ws',
};
return endpoint.replace(
path: '/acp',
pathSegments: _deriveAcpPathSegmentsInternal(endpoint),
query: null,
fragment: null,
scheme: wsScheme,
);
}
static List<String> _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 <String>[...segments, 'acp'];
}
void throwIfJsonRpcErrorInternal(Map<String, dynamic> response) {
final error = asMapInternal(response['error']);
if (error.isEmpty) {

View File

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

View File

@ -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<String> rpcMethods = <String>[];
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;
}