From 19f1ce306f5a7099b296d3a749b912c201c9771b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 22 Apr 2026 00:49:41 +0800 Subject: [PATCH] Refine bridge routing and settings cleanup --- ...ettings-integration-configuration-model.md | 20 +-- ...pp_controller_desktop_runtime_helpers.dart | 55 +----- lib/features/settings/settings_page_core.dart | 156 +++++++++++------- lib/runtime/runtime_controllers_settings.dart | 2 +- .../runtime_controllers_settings_account.dart | 15 +- ...ime_controllers_settings_account_impl.dart | 54 +----- .../settings_about_bridge_metadata_test.dart | 106 ++++++++++++ test/runtime/bridge_runtime_cleanup_test.dart | 57 ++----- .../runtime/gateway_acp_client_auth_test.dart | 154 +++-------------- ...ime_controllers_settings_account_test.dart | 21 +-- 10 files changed, 266 insertions(+), 374 deletions(-) create mode 100644 test/features/settings/settings_about_bridge_metadata_test.dart diff --git a/docs/architecture/settings-integration-configuration-model.md b/docs/architecture/settings-integration-configuration-model.md index e0a12502..6f46c4d4 100644 --- a/docs/architecture/settings-integration-configuration-model.md +++ b/docs/architecture/settings-integration-configuration-model.md @@ -3,17 +3,17 @@ Last Updated: 2026-04-19 本文件记录当前 `Settings -> Integrations` 在主链中的职责边界,以及 -`acpBridgeServerModeConfig` 的有效配置仲裁规则。 +`acpBridgeServerModeConfig` 在 settings surface 中的配置仲裁规则。 ## Current Rule - Settings 只管理 Bridge 连接参数、account sync 元数据和本地编辑态 -- `AcpBridgeServerModeConfig.effective` 是运行时实际生效配置 +- `AcpBridgeServerModeConfig.effective` 只用于 settings surface 的连接与展示语义 - `selfHosted` 优先级高于 `cloudSynced` -- `cloudSynced` 只在 manual Bridge 未配置时作为有效回退来源 +- `cloudSynced` 只在 manual Bridge 未配置时作为 settings metadata 回退来源 - app 不从本地 endpoint preset、旧 module 配置、历史 fallback 恢复 provider catalog - `xworkmate-bridge` 仍然是 provider catalog、gateway capability、routing resolve 的唯一真源 -- `BRIDGE_SERVER_URL` 只属于 `AccountSyncState` 元数据 +- `BRIDGE_SERVER_URL` 只属于 `AccountSyncState` 元数据,不参与 assistant runtime endpoint 选择 - `BRIDGE_AUTH_TOKEN` 只进入 secure storage / managed secret ## Canonical State Model @@ -119,18 +119,18 @@ stateDiagram-v2 DefaultEffective --> CloudEffective: cloud sync 恢复 note right of BridgeEffective - source = bridge - effective.endpoint = selfHosted.serverUrl + source = bridge metadata + used by settings only end note note right of CloudEffective - source = cloud - effective.endpoint = accountSyncState.syncedDefaults.bridgeServerUrl + source = cloud metadata + used by settings only end note note right of DefaultEffective - source = default - effective.endpoint = kManagedBridgeServerUrl + source = managed bridge origin + assistant runtime fixed to kManagedBridgeServerUrl end note ``` diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index c5b30df3..62b14346 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -636,27 +636,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveBridgeAcpEndpointInternal() { - final modeConfig = settings.acpBridgeServerModeConfig; - - final cloudEndpoint = _activeCloudSyncedBridgeEndpointInternal(); - if (cloudEndpoint.isNotEmpty) { - final uri = Uri.tryParse(cloudEndpoint); - if (uri != null) return uri.replace(query: null, fragment: null); - } - - if (modeConfig.usesSelfHostedBase) { - final candidate = modeConfig.selfHosted.serverUrl.trim(); - if (candidate.isNotEmpty) { - final uri = Uri.tryParse(candidate); - final scheme = uri?.scheme.trim().toLowerCase() ?? ''; - if (uri != null && - kSupportedExternalAcpEndpointSchemes.contains(scheme)) { - return uri.replace(query: null, fragment: null); - } - } - } - - return null; + final uri = Uri.parse(kManagedBridgeServerUrl); + return uri.replace(query: null, fragment: null); } Uri? resolveExternalAcpEndpointForTargetInternal(AssistantExecutionTarget _) { @@ -664,11 +645,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } bool isBridgeAcpRuntimeConfiguredInternal() { - final modeConfig = settings.acpBridgeServerModeConfig; - if (modeConfig.usesSelfHostedBase) { - return modeConfig.selfHosted.isConfigured; - } - return _activeCloudSyncedBridgeEndpointInternal().isNotEmpty; + return true; } Uri? resolveExternalAcpEndpointForRequestInternal( @@ -677,22 +654,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return resolveBridgeAcpEndpointInternal(); } - String _activeCloudSyncedBridgeEndpointInternal() { - final syncState = settingsControllerInternal.accountSyncState; - final syncedEndpoint = - syncState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; - - if (syncState?.syncState.trim().toLowerCase() == 'ready' && - syncState?.tokenConfigured.bridge == true && - syncedEndpoint.isNotEmpty) { - return isSupportedExternalAcpEndpoint(syncedEndpoint) - ? syncedEndpoint - : ''; - } - - return ''; - } - Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) { final host = profile.host.trim(); if (host.isEmpty || profile.port <= 0) { @@ -722,16 +683,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return envToken; } - final modeConfig = settings.acpBridgeServerModeConfig; - if (modeConfig.usesSelfHostedBase) { - final manualToken = await settingsControllerInternal - .loadSecretValueByRef(modeConfig.selfHosted.passwordRef); - if (manualToken.trim().isNotEmpty) { - return manualToken.trim(); - } - return null; - } - final bridgeToken = (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ))?.trim(); diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index e623b39d..57fa0079 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -16,6 +16,78 @@ import '../../widgets/surface_card.dart'; import 'settings_account_panel.dart'; import 'settings_about_panel.dart'; +Future> loadBridgeMetadataForSettingsAbout({ + required Uri bridgeEndpoint, + required Future Function(Uri endpoint) authorizationResolver, + HttpClient Function()? clientFactory, +}) async { + final pingEndpoint = bridgeEndpoint.replace( + path: '/api/ping', + query: null, + fragment: null, + ); + final authorizationHeader = await authorizationResolver(pingEndpoint); + if (authorizationHeader == null || authorizationHeader.trim().isEmpty) { + return const { + 'status': 'unavailable', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } + + final client = (clientFactory ?? HttpClient.new)() + ..connectionTimeout = const Duration(seconds: 4); + try { + final request = await client + .getUrl(pingEndpoint) + .timeout(const Duration(seconds: 4)); + request.headers.set( + HttpHeaders.authorizationHeader, + 'Bearer $authorizationHeader', + ); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + final response = await request.close().timeout(const Duration(seconds: 4)); + final body = await utf8 + .decodeStream(response) + .timeout(const Duration(seconds: 4)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return const { + 'status': 'unavailable', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } + final decoded = jsonDecode(body); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + } catch (_) { + return const { + 'status': 'unavailable', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } finally { + client.close(force: true); + } + return const { + 'status': 'unavailable', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; +} + class SettingsPage extends StatefulWidget { const SettingsPage({ super.key, @@ -54,7 +126,8 @@ class _SettingsPageState extends State { final settings = widget.controller.settings; _lastSavedAccountBaseUrl = settings.accountBaseUrl; _lastSavedAccountIdentifier = settings.accountUsername; - _lastSavedBridgeUrl = settings.acpBridgeServerModeConfig.selfHosted.serverUrl; + _lastSavedBridgeUrl = + settings.acpBridgeServerModeConfig.selfHosted.serverUrl; _accountBaseUrlController = TextEditingController( text: _lastSavedAccountBaseUrl, ); @@ -83,7 +156,14 @@ class _SettingsPageState extends State { Future _loadBridgeToken() async { final token = await widget.controller.settingsController - .loadSecretValueByRef(widget.controller.settings.acpBridgeServerModeConfig.selfHosted.passwordRef); + .loadSecretValueByRef( + widget + .controller + .settings + .acpBridgeServerModeConfig + .selfHosted + .passwordRef, + ); if (mounted) { _bridgeTokenController.text = token; } @@ -109,7 +189,7 @@ class _SettingsPageState extends State { _lastSavedBridgeUrl = bridgeConfig.selfHosted.serverUrl; } - Future _saveAccountProfile( + Future _persistAccountProfileSettings( SettingsSnapshot settings, { required bool isManualBridge, }) async { @@ -121,11 +201,8 @@ class _SettingsPageState extends State { ), ); - // Resolve the effective config based on the new sources - final nextEffective = widget.controller.settingsController.resolveAcpBridgeServerEffectiveConfig( - config: nextBridgeConfig, - accountSyncState: widget.controller.settingsController.accountSyncState, - ); + final nextEffective = widget.controller.settingsController + .resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig); final nextSettings = settings.copyWith( accountBaseUrl: _accountBaseUrlController.text.trim(), @@ -146,14 +223,15 @@ class _SettingsPageState extends State { _lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; _lastSavedAccountIdentifier = nextSettings.accountUsername; - _lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl; + _lastSavedBridgeUrl = + nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl; } Future _loginAccount(SettingsSnapshot settings) async { final baseUrl = _accountBaseUrlController.text.trim(); final identifier = _accountIdentifierController.text.trim(); try { - await _saveAccountProfile(settings, isManualBridge: false); + await _persistAccountProfileSettings(settings, isManualBridge: false); await widget.controller.settingsController.loginAccount( baseUrl: baseUrl, identifier: identifier, @@ -166,7 +244,7 @@ class _SettingsPageState extends State { } Future _syncAccount(SettingsSnapshot settings) async { - await _saveAccountProfile(settings, isManualBridge: false); + await _persistAccountProfileSettings(settings, isManualBridge: false); await widget.controller.settingsController.syncAccountSettings( baseUrl: _accountBaseUrlController.text.trim(), ); @@ -176,7 +254,7 @@ class _SettingsPageState extends State { Future _verifyAccountMfa(SettingsSnapshot settings) async { try { - await _saveAccountProfile(settings, isManualBridge: false); + await _persistAccountProfileSettings(settings, isManualBridge: false); await widget.controller.settingsController.verifyAccountMfa( baseUrl: _accountBaseUrlController.text.trim(), code: _accountMfaCodeController.text.trim(), @@ -251,50 +329,11 @@ class _SettingsPageState extends State { } Future> _loadBridgeMetadata() async { - final client = HttpClient()..connectionTimeout = const Duration(seconds: 4); - try { - final request = await client - .getUrl(Uri.parse('$kManagedBridgeServerUrl/api/ping')) - .timeout(const Duration(seconds: 4)); - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - final response = await request.close().timeout(const Duration(seconds: 4)); - final body = await utf8 - .decodeStream(response) - .timeout(const Duration(seconds: 4)); - if (response.statusCode < 200 || response.statusCode >= 300) { - return { - 'status': 'error', - 'version': '', - 'commit': '', - 'image': '', - 'buildDate': '', - }; - } - final decoded = jsonDecode(body); - if (decoded is Map) { - return decoded; - } - if (decoded is Map) { - return decoded.cast(); - } - } catch (_) { - return const { - 'status': 'unavailable', - 'version': '', - 'commit': '', - 'image': '', - 'buildDate': '', - }; - } finally { - client.close(force: true); - } - return const { - 'status': 'unavailable', - 'version': '', - 'commit': '', - 'image': '', - 'buildDate': '', - }; + return loadBridgeMetadataForSettingsAbout( + bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl), + authorizationResolver: + widget.controller.resolveGatewayAcpAuthorizationHeaderInternal, + ); } @override @@ -357,12 +396,13 @@ class _SettingsPageState extends State { bridgeUrlController: _bridgeUrlController, bridgeTokenController: _bridgeTokenController, onSaveAccountProfile: ({required bool isManualBridge}) => - _saveAccountProfile( + _persistAccountProfileSettings( widget.controller.settings, isManualBridge: isManualBridge, ), onLogin: () => _loginAccount(widget.controller.settings), - onVerifyMfa: () => _verifyAccountMfa(widget.controller.settings), + onVerifyMfa: () => + _verifyAccountMfa(widget.controller.settings), onCancelMfa: _cancelAccountMfa, onSync: () => _syncAccount(widget.controller.settings), onLogout: _logoutAccount, diff --git a/lib/runtime/runtime_controllers_settings.dart b/lib/runtime/runtime_controllers_settings.dart index 11a83e76..742e9f61 100644 --- a/lib/runtime/runtime_controllers_settings.dart +++ b/lib/runtime/runtime_controllers_settings.dart @@ -538,7 +538,7 @@ class SettingsController extends ChangeNotifier { ); } } catch (_) { - // Best effort only. Directory watch below remains as a fallback. + // Best effort only. If file watching fails, directory watching may still work. } } if (directory != null) { diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index cd498197..3a990e59 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -51,8 +51,7 @@ extension SettingsControllerAccountExtension on SettingsController { Future loadEffectiveGatewayToken({int? profileIndex}) async { final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex) .clamp(0, kGatewayProfileListLength - 1); - - // Use the Single Source of Truth from the effective configuration for the primary profile + if (resolvedProfileIndex == kGatewayRemoteProfileIndex) { final effective = snapshotInternal.acpBridgeServerModeConfig.effective; if (effective.tokenRef.isNotEmpty) { @@ -63,7 +62,6 @@ extension SettingsControllerAccountExtension on SettingsController { } } - // Local Override / Vault / Cloud Sync (Fallback if effective token is missing or for other profiles) return resolveSecretValueInternal( refName: gatewayTokenRefForProfileInternal(resolvedProfileIndex), fallbackRefName: SecretStore.gatewayTokenRefKey(resolvedProfileIndex), @@ -77,10 +75,6 @@ extension SettingsControllerAccountExtension on SettingsController { final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex) .clamp(0, kGatewayProfileListLength - 1); - // Manual bridge usually uses a single token/key, but we check if the effective configuration - // points to bridge and if a password override is actually needed. - // For now, we fall back to the standard resolution logic. - return resolveSecretValueInternal( refName: gatewayPasswordRefForProfileInternal(resolvedProfileIndex), fallbackRefName: SecretStore.gatewayPasswordRefKey(resolvedProfileIndex), @@ -120,12 +114,7 @@ extension SettingsControllerAccountExtension on SettingsController { AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfig({ required AcpBridgeServerModeConfig config, - AccountSyncState? accountSyncState, - }) => resolveAcpBridgeServerEffectiveConfigInternal( - this, - config: config, - accountSyncState: accountSyncState, - ); + }) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config); List buildSecretReferences() { final entries = [ diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 06a45004..8aa6d489 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -284,18 +284,7 @@ Future syncAccountSettingsInternal( target: kAccountManagedSecretTargetBridgeAuthToken, value: bridgeToken, ); - final syncedBridgeServerUrl = _resolveBridgeServerUrl(syncPayload); - if (!isSupportedExternalAcpEndpoint(syncedBridgeServerUrl)) { - return _persistAccountSyncContractFailureInternal( - controller, - message: 'Bridge endpoint is unavailable', - quiet: quiet, - ); - } - final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl( - controller, - bridgeServerUrlOverride: syncedBridgeServerUrl, - ); + final syncedBridgeServerUrl = _extractBridgeServerUrlMetadata(syncPayload); await controller.storeInternal.clearAccountManagedSecret( target: kAccountManagedSecretTargetAIGatewayAccessToken, ); @@ -305,12 +294,12 @@ Future syncAccountSettingsInternal( final nextState = AccountSyncState.defaults().copyWith( syncedDefaults: AccountRemoteProfile.defaults().copyWith( - bridgeServerUrl: resolvedBridgeServerUrl, + bridgeServerUrl: syncedBridgeServerUrl, ), syncState: 'ready', syncMessage: 'Bridge access synced', lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncSource: resolvedBridgeServerUrl, + lastSyncSource: syncedBridgeServerUrl, lastSyncError: '', profileScope: 'bridge', tokenConfigured: const AccountTokenConfigured( @@ -326,7 +315,6 @@ Future syncAccountSettingsInternal( final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal( controller, config: currentModeConfig, - accountSyncState: nextState, ); final identifier = @@ -347,7 +335,7 @@ Future syncAccountSettingsInternal( lastSyncAt: nextState.lastSyncAtMs, remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary .copyWith( - endpoint: nextEffective.endpoint, + endpoint: syncedBridgeServerUrl, hasAdvancedOverrides: false, ), ), @@ -578,7 +566,7 @@ Future _persistAccountSyncContractFailureInternal( ); } -String _resolveBridgeServerUrl(Map payload) { +String _extractBridgeServerUrlMetadata(Map payload) { final explicit = _stringValue(payload['BRIDGE_SERVER_URL']); if (explicit.isNotEmpty) { return explicit; @@ -593,10 +581,7 @@ String _resolveBridgeServerUrl(Map payload) { AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( SettingsController controller, { required AcpBridgeServerModeConfig config, - AccountSyncState? accountSyncState, }) { - // Priority 1: Manual Bridge (Self-Hosted) - // Logic: Must have a valid URL and be explicitly intended (we assume if it's configured, it's intended) if (config.selfHosted.isConfigured) { return AcpBridgeServerEffectiveConfig( endpoint: config.selfHosted.serverUrl, @@ -606,20 +591,6 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( ); } - // Priority 2: Cloud Sync (svc.plus) - // Logic: Check the synced state for a valid endpoint and token - final syncedUrl = - accountSyncState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; - final hasSyncedToken = accountSyncState?.tokenConfigured.bridge == true; - if (isSupportedExternalAcpEndpoint(syncedUrl) && hasSyncedToken) { - return AcpBridgeServerEffectiveConfig( - endpoint: syncedUrl, - tokenRef: kAccountManagedSecretTargetBridgeAuthToken, - source: 'cloud', - reason: 'Synced cloud configuration from svc.plus is active', - ); - } - return AcpBridgeServerEffectiveConfig( endpoint: '', tokenRef: '', @@ -628,21 +599,6 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( ); } -String _resolveCurrentBridgeServerUrl( - SettingsController controller, { - String bridgeServerUrlOverride = '', -}) { - final override = bridgeServerUrlOverride.trim(); - if (override.isNotEmpty) { - return override; - } - return controller - .snapshotInternal - .acpBridgeServerModeConfig - .effective - .endpoint; -} - int _parseExpiresAtMs(Object? value) { if (value is int) { return value; diff --git a/test/features/settings/settings_about_bridge_metadata_test.dart b/test/features/settings/settings_about_bridge_metadata_test.dart new file mode 100644 index 00000000..c1a808e8 --- /dev/null +++ b/test/features/settings/settings_about_bridge_metadata_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/settings/settings_page_core.dart'; + +void main() { + group('settings about bridge metadata', () { + test('loads bridge metadata with bearer authorization', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async { + await server.close(force: true); + }); + + late Map requestHeaders; + server.listen((request) async { + requestHeaders = { + 'authorization': + request.headers.value(HttpHeaders.authorizationHeader) ?? '', + 'accept': request.headers.value(HttpHeaders.acceptHeader) ?? '', + }; + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'status': 'ok', + 'version': '991ecb0', + 'commit': '991ecb0', + 'image': 'ghcr.io/x-evor/xworkmate-bridge:991ecb0', + 'buildDate': '2026-04-21', + }), + ); + await request.response.close(); + }); + + final metadata = await loadBridgeMetadataForSettingsAbout( + bridgeEndpoint: Uri.parse( + 'http://${server.address.address}:${server.port}', + ), + authorizationResolver: (_) async => 'bridge-token', + ); + + expect(requestHeaders['authorization'], 'Bearer bridge-token'); + expect(requestHeaders['accept'], 'application/json'); + expect(metadata['status'], 'ok'); + expect(metadata['version'], '991ecb0'); + expect(metadata['commit'], '991ecb0'); + expect(metadata['image'], 'ghcr.io/x-evor/xworkmate-bridge:991ecb0'); + expect(metadata['buildDate'], '2026-04-21'); + }); + + test('returns unavailable when bridge authorization is missing', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async { + await server.close(force: true); + }); + + var receivedRequest = false; + server.listen((request) async { + receivedRequest = true; + request.response.statusCode = HttpStatus.ok; + await request.response.close(); + }); + + final metadata = await loadBridgeMetadataForSettingsAbout( + bridgeEndpoint: Uri.parse( + 'http://${server.address.address}:${server.port}', + ), + authorizationResolver: (_) async => null, + ); + + expect(receivedRequest, isFalse); + expect(metadata['status'], 'unavailable'); + expect(metadata['version'], ''); + expect(metadata['commit'], ''); + expect(metadata['image'], ''); + expect(metadata['buildDate'], ''); + }); + + test('returns unavailable when authorized bridge ping fails', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async { + await server.close(force: true); + }); + + server.listen((request) async { + request.response.statusCode = HttpStatus.unauthorized; + 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'], ''); + }); + }); +} diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index 4b39171c..3287bfbe 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -9,7 +9,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('Bridge runtime cleanup', () { test( - 'uses synced bridge endpoint only when account sync has a bridge token', + 'keeps the managed bridge endpoint fixed even when account sync carries a bridge URL', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-bridge-runtime-cleanup-', @@ -61,7 +61,7 @@ void main() { expect( controller.resolveBridgeAcpEndpointInternal()?.toString(), - 'https://xworkmate-bridge-alt.svc.plus', + kManagedBridgeServerUrl, ); expect( controller @@ -69,7 +69,7 @@ void main() { AssistantExecutionTarget.gateway, ) ?.toString(), - 'https://xworkmate-bridge-alt.svc.plus', + kManagedBridgeServerUrl, ); expect(await store.loadAccountSyncState(), isNotNull); expect( @@ -80,7 +80,7 @@ void main() { ); test( - 'does not fallback to the managed bridge endpoint when signed out', + 'keeps the managed bridge endpoint fixed when signed out', () { final controller = AppController( environmentOverride: const { @@ -89,7 +89,10 @@ void main() { ); addTearDown(controller.dispose); - expect(controller.resolveBridgeAcpEndpointInternal(), isNull); + expect( + controller.resolveBridgeAcpEndpointInternal()?.toString(), + kManagedBridgeServerUrl, + ); }, ); @@ -139,10 +142,9 @@ void main() { addTearDown(controller.dispose); await controller.settingsControllerInternal.initialize(); - final bridgeHeader = await controller - .resolveGatewayAcpAuthorizationHeaderInternal( - Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'), - ); + final bridgeHeader = await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ); final unrelatedHeader = await controller .resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('https://unrelated.example.com/acp/rpc'), @@ -153,43 +155,6 @@ void main() { }, ); - test( - 'ignores legacy INTERNAL_SERVICE_TOKEN for managed bridge auth resolution', - () async { - final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-bridge-auth-resolver-legacy-', - ); - addTearDown(() async { - if (await storeRoot.exists()) { - await storeRoot.delete(recursive: true); - } - }); - - final store = SecureConfigStore( - secretRootPathResolver: () async => '${storeRoot.path}/secrets', - appDataRootPathResolver: () async => '${storeRoot.path}/app-data', - supportRootPathResolver: () async => '${storeRoot.path}/support', - enableSecureStorage: false, - ); - await store.initialize(); - - final controller = AppController( - store: store, - environmentOverride: const { - 'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token', - }, - ); - addTearDown(controller.dispose); - - final bridgeHeader = await controller - .resolveGatewayAcpAuthorizationHeaderInternal( - Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'), - ); - - expect(bridgeHeader, isNull); - }, - ); - test( 'runtime coordinator only exposes remote and offline gateway modes', () { diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index 0e98c817..85f435e3 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -152,7 +152,7 @@ void main() { ); test( - 'desktop bridge auth resolver sends managed bridge bearer for capabilities HTTP', + 'desktop bridge auth resolver sends bearer when the caller asks for managed bridge auth', () async { final capture = await _startAcpHttpServer(); addTearDown(capture.close); @@ -182,26 +182,14 @@ void main() { target: kAccountManagedSecretTargetBridgeAuthToken, value: 'bridge-token', ); - await store.saveAccountSyncState( - AccountSyncState.defaults().copyWith( - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - bridgeServerUrl: capture.baseEndpoint.toString(), - ), - syncState: 'ready', - tokenConfigured: const AccountTokenConfigured( - bridge: true, - vault: false, - apisix: false, - ), - ), + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + authorizationResolver: (_) async => 'bridge-token', ); - final controller = AppController(store: store); - addTearDown(controller.dispose); - await controller.settingsControllerInternal.initialize(); - - await controller.gatewayAcpClientInternal.loadCapabilities( - forceRefresh: true, + await client.request( + method: 'acp.capabilities', + params: const {}, ); expect(capture.authorizationHeader, 'Bearer bridge-token'); @@ -258,80 +246,20 @@ void main() { }, ); - test( - 'desktop bridge auth resolver resolves manual bridge token when configured', - () async { - final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-acp-auth-bridge-manual-', - ); - addTearDown(() async { - if (await storeRoot.exists()) { - try { - await storeRoot.delete(recursive: true); - } on FileSystemException { - // Temp cleanup is best effort here. - } - } - }); - - final store = SecureConfigStore( - secretRootPathResolver: () async => '${storeRoot.path}/secrets', - appDataRootPathResolver: () async => '${storeRoot.path}/app-data', - supportRootPathResolver: () async => '${storeRoot.path}/support', - enableSecureStorage: false, - ); - await store.initialize(); - - final settings = SettingsSnapshot.defaults().copyWith( - acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() - .copyWith( - effective: const AcpBridgeServerEffectiveConfig( - endpoint: 'https://manual-bridge.example.com', - tokenRef: 'acp_bridge_server_password', - source: 'bridge', - reason: 'Manual test configuration', - ), - selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith( - serverUrl: 'https://manual-bridge.example.com', - username: 'admin', - ), - ), - ); - await store.saveSettingsSnapshot(settings); - await store.saveSecretValueByRef( - settings.acpBridgeServerModeConfig.selfHosted.passwordRef, - 'manual-token', - ); - - final controller = AppController(store: store); - addTearDown(controller.dispose); - await controller.settingsControllerInternal.initialize(); - - final header = await controller - .resolveGatewayAcpAuthorizationHeaderInternal( - Uri.parse('https://manual-bridge.example.com/acp/rpc'), - ); - - expect(header, 'manual-token'); - }, - ); - test( 'desktop task execution routes Hermes through bridge RPC with provider params', () async { final capture = await _startAcpHttpServer(); addTearDown(capture.close); - final controller = await _syncedControllerForBridgeEndpoint( - capture.baseEndpoint, + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + authorizationResolver: (_) async => 'bridge-token', ); - addTearDown(controller.dispose); final transport = ExternalCodeAgentAcpDesktopTransport( - client: controller.gatewayAcpClientInternal, - endpointResolver: - controller.resolveExternalAcpEndpointForTargetInternal, - taskEndpointResolver: - controller.resolveExternalAcpEndpointForRequestInternal, + client: client, + endpointResolver: (_) => capture.baseEndpoint, + taskEndpointResolver: (_) => capture.baseEndpoint, ); await transport.executeTask( @@ -359,17 +287,15 @@ void main() { () async { final capture = await _startAcpHttpServer(); addTearDown(capture.close); - final controller = await _syncedControllerForBridgeEndpoint( - capture.baseEndpoint, + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + authorizationResolver: (_) async => 'bridge-token', ); - addTearDown(controller.dispose); final transport = ExternalCodeAgentAcpDesktopTransport( - client: controller.gatewayAcpClientInternal, - endpointResolver: - controller.resolveExternalAcpEndpointForTargetInternal, - taskEndpointResolver: - controller.resolveExternalAcpEndpointForRequestInternal, + client: client, + endpointResolver: (_) => capture.baseEndpoint, + taskEndpointResolver: (_) => capture.baseEndpoint, ); await transport.executeTask( @@ -416,48 +342,6 @@ GoTaskServiceRequest _taskRequest({ ); } -Future _syncedControllerForBridgeEndpoint(Uri endpoint) async { - final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-acp-auth-provider-endpoint-', - ); - addTearDown(() async { - if (await storeRoot.exists()) { - try { - await storeRoot.delete(recursive: true); - } on FileSystemException { - // Temp cleanup is best effort here. - } - } - }); - final store = SecureConfigStore( - secretRootPathResolver: () async => '${storeRoot.path}/secrets', - appDataRootPathResolver: () async => '${storeRoot.path}/app-data', - supportRootPathResolver: () async => '${storeRoot.path}/support', - enableSecureStorage: false, - ); - await store.initialize(); - await store.saveAccountSyncState( - AccountSyncState.defaults().copyWith( - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - bridgeServerUrl: endpoint.toString(), - ), - syncState: 'ready', - tokenConfigured: const AccountTokenConfigured( - bridge: true, - vault: false, - apisix: false, - ), - ), - ); - await store.saveAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - value: 'bridge-token', - ); - final controller = AppController(store: store); - await controller.settingsControllerInternal.initialize(); - return controller; -} - Future<_CapturedAcpHttpServer> _startAcpHttpServer() async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final capture = _CapturedAcpHttpServer._( diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 80b88d90..0e46d20c 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -289,7 +289,7 @@ void main() { ); test( - 'syncAccountSettings refreshes managed bridge contract from protected account profile', + 'syncAccountSettings refreshes managed bridge metadata from protected account profile', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-account-managed-bridge-refresh-', @@ -365,7 +365,7 @@ void main() { ); test( - 'synced bridge url becomes runtime endpoint only with a configured bridge token', + 'managed bridge endpoint stays fixed regardless of synced bridge url metadata', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-account-managed-bridge-runtime-', @@ -412,28 +412,28 @@ void main() { expect( controller.resolveGatewayAcpEndpointInternal()?.toString(), - 'https://xworkmate-bridge-alt.svc.plus', + kManagedBridgeServerUrl, ); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('https://xworkmate-bridge-alt.svc.plus/acp/rpc'), ), - 'bridge-token', + isNull, ); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), ), - isNull, + 'bridge-token', ); }, ); test( - 'syncAccountSettings blocks and clears stale token when bridge endpoint is unavailable', + 'syncAccountSettings succeeds when bridge url metadata is missing', () async { final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-account-managed-bridge-missing-url-', + 'xworkmate-account-managed-bridge-missing-metadata-', ); addTearDown(() async { if (await storeRoot.exists()) { @@ -483,14 +483,15 @@ void main() { baseUrl: 'https://accounts.svc.plus', ); - expect(result.state, 'blocked'); - expect(result.message, 'Bridge endpoint is unavailable'); + expect(result.state, 'ready'); + expect(result.message, 'Bridge access synced'); expect( await store.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ), - isNull, + 'fresh-bridge-token', ); + expect(controller.accountSyncState!.syncedDefaults.bridgeServerUrl, ''); }, );