fix(acp): preserve hosted base paths for external endpoints
This commit is contained in:
parent
a1a55d1cbd
commit
c77a6b649a
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user