fix: remove stale ACP gateway fallback routing

This commit is contained in:
Haitao Pan 2026-04-10 15:37:50 +08:00
parent 2f81f53f9d
commit 8e07f87b70
10 changed files with 118 additions and 48 deletions

View File

@ -50,27 +50,11 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
if (providerId.isEmpty || endpoint.isEmpty) {
continue;
}
var authorizationHeader = effectiveProfile.authRef.trim().isEmpty
final authorizationHeader = effectiveProfile.authRef.trim().isEmpty
? ''
: await settingsControllerInternal.resolveSecretValueInternal(
refName: effectiveProfile.authRef.trim(),
);
if (authorizationHeader.isEmpty &&
builtinProvider != null &&
settings.acpBridgeServerModeConfig.usesSelfHostedBase) {
final selfHosted = settings.acpBridgeServerModeConfig.selfHosted;
final username = selfHosted.username.trim();
final passwordRef = selfHosted.passwordRef.trim();
final password = passwordRef.isEmpty
? ''
: await settingsControllerInternal.loadSecretValueByRef(
passwordRef,
);
if (username.isNotEmpty && password.trim().isNotEmpty) {
authorizationHeader =
'Basic ${base64Encode(utf8.encode('$username:${password.trim()}'))}';
}
}
providers.add(
ExternalCodeAgentAcpSyncedProvider(
providerId: providerId,

View File

@ -53,8 +53,28 @@ Future<void> refreshAcpCapabilitiesRuntimeInternal(
bool persistMountTargets = false,
}) async {
try {
final target = controller.assistantExecutionTargetForSession(
controller.sessionsControllerInternal.currentSessionKey,
);
final resolvedProvider =
target == AssistantExecutionTarget.singleAgent
? (controller.singleAgentResolvedProviderForSession(
controller.sessionsControllerInternal.currentSessionKey,
) ??
controller.currentSingleAgentResolvedProvider)
: null;
final endpointOverride = resolvedProvider == null
? null
: controller.resolveSingleAgentEndpointInternal(resolvedProvider);
final authorizationOverride = resolvedProvider == null
? ''
: await controller.resolveSingleAgentAuthorizationHeaderForProviderInternal(
resolvedProvider,
);
await controller.gatewayAcpClientInternal.loadCapabilities(
forceRefresh: forceRefresh,
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
} catch (_) {
// Keep mount refresh resilient when ACP is temporarily unavailable.

View File

@ -702,18 +702,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return '';
}
Future<String> resolveSingleAgentAuthorizationHeaderForProviderInternal(
SingleAgentProvider provider,
) async {
final endpoint = resolveSingleAgentEndpointInternal(provider);
if (endpoint == null) {
return '';
}
return resolveSingleAgentAuthorizationHeaderInternal(endpoint);
}
Uri? resolveGatewayAcpEndpointInternal() {
final target = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
);
if (target == AssistantExecutionTarget.singleAgent) {
final remote = gatewayProfileBaseUriInternal(
settings.primaryRemoteGatewayProfile,
);
if (remote != null) {
return remote;
}
return gatewayProfileBaseUriInternal(settings.primaryLocalGatewayProfile);
return null;
}
return gatewayProfileBaseUriInternal(
gatewayProfileForAssistantExecutionTargetInternal(target),

View File

@ -396,6 +396,16 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
);
return;
}
final endpointOverride = resolveSingleAgentEndpointInternal(provider);
if (endpointOverride == null) {
await replaceSingleAgentThreadSkillsInternal(
normalizedSessionKey,
localSkills,
);
return;
}
final authorizationOverride =
await resolveSingleAgentAuthorizationHeaderForProviderInternal(provider);
await replaceSingleAgentThreadSkillsInternal(
normalizedSessionKey,
localSkills,
@ -410,6 +420,8 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
'mode': 'single-agent',
'provider': provider.providerId,
},
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
final result = asMap(response['result']);
final payload = result.isNotEmpty ? result : response;

View File

@ -516,21 +516,8 @@ class SettingsSnapshot {
ExternalAcpEndpointProfile externalAcpEndpointForProvider(
SingleAgentProvider provider,
) {
final profile =
externalAcpEndpointForProviderId(provider.providerId) ??
return externalAcpEndpointForProviderId(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider);
final bridgeBaseUrl = acpBridgeBuiltinEndpointBaseUrl;
if (provider.isAuto || bridgeBaseUrl.isEmpty) {
return profile;
}
return profile.copyWith(endpoint: bridgeBaseUrl);
}
String get acpBridgeBuiltinEndpointBaseUrl {
if (!acpBridgeServerModeConfig.usesSelfHostedBase) {
return '';
}
return acpBridgeServerModeConfig.selfHosted.serverUrl.trim();
}
ExternalAcpEndpointProfile? externalAcpEndpointForProviderId(

View File

@ -1,8 +1,6 @@
@TestOn('vm')
library;
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
@ -16,7 +14,7 @@ import 'app_controller_ai_gateway_chat_suite_fakes.dart';
void main() {
group('ACP bridge provider hub', () {
test(
'self-hosted ACP bridge base makes builtin single-agent providers visible without per-provider endpoints',
'self-hosted ACP bridge base does not override builtin single-agent endpoints',
() {
final snapshot = SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
@ -33,13 +31,13 @@ void main() {
snapshot
.externalAcpEndpointForProvider(SingleAgentProvider.codex)
.endpoint,
'https://bridge.example.com',
'',
);
},
);
test(
'builtin provider sync uses bridge base endpoint and self-hosted basic auth when endpoint auth is empty',
'builtin provider sync does not inject self-hosted bridge endpoint or auth fallback',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore(enableSecureStorage: false);
@ -69,6 +67,13 @@ void main() {
username: 'review@example.com',
),
),
externalAcpEndpoints: replaceExternalAcpEndpointForProvider(
controller.settings.externalAcpEndpoints,
SingleAgentProvider.opencode,
controller.settings
.externalAcpEndpointForProvider(SingleAgentProvider.opencode)
.copyWith(endpoint: 'https://acp.example.com/opencode'),
),
),
refreshAfterSave: false,
);
@ -79,11 +84,8 @@ void main() {
(item) => item.providerId == 'opencode',
);
expect(opencode.endpoint, 'https://bridge.example.com');
expect(
opencode.authorizationHeader,
'Basic ${base64Encode(utf8.encode('review@example.com:top-secret'))}',
);
expect(opencode.endpoint, 'https://acp.example.com/opencode');
expect(opencode.authorizationHeader, '');
},
);

View File

@ -85,6 +85,8 @@ void registerThreadSkillsAcpTests() {
singleAgentTestSettingsInternal(
workspacePath: tempDirectory.path,
gatewayPort: acpServer.port,
singleAgentProviderEndpoint:
'http://127.0.0.1:${acpServer.port}/opencode',
),
);
await store.saveTaskThreads(<TaskThread>[
@ -218,6 +220,8 @@ void registerThreadSkillsAcpTests() {
singleAgentTestSettingsInternal(
workspacePath: tempDirectory.path,
gatewayPort: acpServer.port,
singleAgentProviderEndpoint:
'http://127.0.0.1:${acpServer.port}/opencode',
),
);

View File

@ -66,6 +66,8 @@ class AcpSkillsStatusServerInternal {
final HttpServer serverInternal;
List<Map<String, dynamic>> skills;
Map<String, dynamic>? skillsError;
String? lastAuthorizationHeader;
String? lastRequestPath;
int get port => serverInternal.port;
@ -97,6 +99,10 @@ class AcpSkillsStatusServerInternal {
}
Future<void> handleRpcInternal(HttpRequest request) async {
lastRequestPath = request.uri.path;
lastAuthorizationHeader = request.headers.value(
HttpHeaders.authorizationHeader,
);
final body = await utf8.decodeStream(request);
final envelope = jsonDecode(body) as Map<String, dynamic>;
final id = envelope['id'];

View File

@ -56,6 +56,8 @@ Future<SecureConfigStore> createStoreInternal(String rootPath) async {
SettingsSnapshot singleAgentTestSettingsInternal({
required String workspacePath,
int gatewayPort = 9,
String singleAgentProviderEndpoint = '',
String singleAgentProviderAuthRef = '',
}) {
final defaults = SettingsSnapshot.defaults();
return defaults.copyWith(
@ -78,5 +80,15 @@ SettingsSnapshot singleAgentTestSettingsInternal({
),
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
workspacePath: workspacePath,
externalAcpEndpoints: replaceExternalAcpEndpointForProvider(
defaults.externalAcpEndpoints,
SingleAgentProvider.opencode,
defaults.externalAcpEndpointForProvider(
SingleAgentProvider.opencode,
).copyWith(
endpoint: singleAgentProviderEndpoint,
authRef: singleAgentProviderAuthRef,
),
),
);
}

View File

@ -187,6 +187,28 @@ void main() {
},
);
test(
'routes generic ACP requests through the explicit hosted provider endpoint without fallback',
() async {
final server = await _AcpFakeServer.start(pathPrefix: '/gemini');
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => Uri.parse('http://127.0.0.1:9'),
);
await client.request(
method: 'skills.status',
params: const <String, dynamic>{'provider': 'gemini'},
endpointOverride: server.baseHttpUri,
authorizationOverride: 'Bearer provider-secret',
);
expect(server.lastHttpRequestPath, '/gemini/acp/rpc');
expect(server.lastHttpAuthorization, 'Bearer provider-secret');
},
);
test('preserves hosted ACP base path for websocket requests', () async {
final server = await _AcpFakeServer.start(pathPrefix: '/opencode');
addTearDown(server.close);
@ -498,6 +520,23 @@ class _AcpFakeServer {
),
);
return;
case 'skills.status':
await respond(
_resultEnvelope(
id: id,
result: const <String, dynamic>{
'skills': <Map<String, dynamic>>[
<String, dynamic>{
'skillKey': 'gemini-remote',
'name': 'Gemini Remote',
'description': 'Hosted ACP skill payload',
'source': 'acp',
},
],
},
),
);
return;
case 'session.cancel':
await respond(
_resultEnvelope(