diff --git a/docs/architecture/account-sync-settings-bridge-state-model.md b/docs/architecture/account-sync-settings-bridge-state-model.md index d71aec38..716ff602 100644 --- a/docs/architecture/account-sync-settings-bridge-state-model.md +++ b/docs/architecture/account-sync-settings-bridge-state-model.md @@ -101,14 +101,14 @@ flowchart TD D --> C C --> E["bridge runtime"] - note1["Priority order\n1. selfHosted when explicitly configured\n2. cloudSynced when account sync is ready and token exists\n3. disconnected"] --> C + note1["Priority order\n1. cloudSynced when account sync is ready and token exists\n2. selfHosted when explicitly configured\n3. disconnected or managed default"] --> C ``` ### Runtime Invariants -- `selfHosted` always wins when it is configured. -- `cloudSynced` is valid only when account sync is ready and the managed bridge token exists. -- Signed-out state is disconnected: runtime must not use a default managed endpoint, stale managed secret, gateway profile token, or loopback ACP endpoint. +- `cloudSynced` wins when account sync is ready and the managed bridge token exists. +- `selfHosted` wins only when cloud sync is not ready. +- Signed-out state is disconnected for authorization decisions: runtime must not use a stale managed secret, gateway profile token, or loopback ACP endpoint. - Missing `BRIDGE_AUTH_TOKEN` is disconnected for the managed cloud-sync path. - `BRIDGE_SERVER_URL` may be retained in `AccountSyncState.syncedDefaults.bridgeServerUrl`, but it is metadata only. - `BRIDGE_AUTH_TOKEN` is written to secure storage only, never to normal settings. diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 38616f3d..314eb738 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1248,6 +1248,17 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveBridgeAcpEndpointInternal() { + final accountSyncState = settingsControllerInternal.accountSyncState; + final managedBridgeReady = + settingsControllerInternal.accountSessionTokenInternal + .trim() + .isNotEmpty && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && + accountSyncState?.tokenConfigured.bridge == true; + if (managedBridgeReady) { + return Uri.parse(kManagedBridgeServerUrl); + } + final selfHosted = settingsControllerInternal .snapshot .acpBridgeServerModeConfig @@ -1260,8 +1271,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } } - final uri = Uri.parse(kManagedBridgeServerUrl); - return uri.replace(query: null, fragment: null); + return Uri.parse(kManagedBridgeServerUrl); } Uri? resolveExternalAcpEndpointForTargetInternal(AssistantExecutionTarget _) { @@ -1281,12 +1291,16 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return true; } final accountSyncState = settingsControllerInternal.accountSyncState; - if (settingsControllerInternal.accountSignedIn && + if (settingsControllerInternal.accountSessionTokenInternal + .trim() + .isNotEmpty && accountSyncState?.syncState.trim().toLowerCase() == 'ready' && accountSyncState?.tokenConfigured.bridge == true) { return true; } - if (settingsControllerInternal.accountSignedIn) { + if (settingsControllerInternal.accountSessionTokenInternal + .trim() + .isNotEmpty) { return false; } final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); @@ -1322,19 +1336,28 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; final bridgePort = bridgeEndpoint?.port ?? 0; + final accountSyncState = settingsControllerInternal.accountSyncState; + final managedBridgeReady = + settingsControllerInternal.accountSessionTokenInternal + .trim() + .isNotEmpty && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && + accountSyncState?.tokenConfigured.bridge == true; final matchesBridgeEndpoint = bridgeHost.isNotEmpty && normalizedHost == bridgeHost && (bridgePort <= 0 || endpoint.port == bridgePort); if (matchesBridgeEndpoint) { + if (managedBridgeReady) { + final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); + if (bridgeToken != null && bridgeToken.isNotEmpty) { + return bridgeToken; + } + } final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal(); if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { return manualBridgeToken; } - final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); - if (bridgeToken != null && bridgeToken.isNotEmpty) { - return bridgeToken; - } } return null; } @@ -1345,18 +1368,28 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final normalizedHost = endpoint.host.trim().toLowerCase(); final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; + final accountSyncState = settingsControllerInternal.accountSyncState; + final managedBridgeReady = + settingsControllerInternal.accountSessionTokenInternal + .trim() + .isNotEmpty && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && + accountSyncState?.tokenConfigured.bridge == true; if (bridgeHost.isEmpty || normalizedHost != bridgeHost) { return null; } + if (managedBridgeReady) { + final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); + if (bridgeToken != null && bridgeToken.isNotEmpty) { + return _normalizeAuthorizationHeaderInternal(bridgeToken); + } + } + final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal(); if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { return _normalizeAuthorizationHeaderInternal(manualBridgeToken); } - final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); - if (bridgeToken != null && bridgeToken.isNotEmpty) { - return _normalizeAuthorizationHeaderInternal(bridgeToken); - } return null; } @@ -1379,15 +1412,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Future _resolveManagedBridgeAuthTokenInternal() async { - if (settingsControllerInternal.accountSignedIn) { + if (settingsControllerInternal.accountSessionTokenInternal + .trim() + .isNotEmpty) { final bridgeToken = (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ))?.trim(); return bridgeToken?.isNotEmpty == true ? bridgeToken : null; } - if (settingsControllerInternal.accountSignedIn) { - return null; - } final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); return envToken?.isNotEmpty == true ? envToken : null; diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 6dd587a1..a7c8c2e2 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -49,8 +49,8 @@ class _DesktopViewState extends State { text: '2000', ); - bool _useGpu = false; - bool _adaptiveResolution = false; + final bool _useGpu = false; + final bool _adaptiveResolution = false; bool _showControlPanel = true; String _connectionState = 'disconnected'; bool _hasStream = false; diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 866b146d..b06177b6 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -603,6 +603,20 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( SettingsController controller, { required AcpBridgeServerModeConfig config, }) { + final accountSyncState = controller.accountSyncState; + final managedBridgeReady = + controller.accountSessionTokenInternal.trim().isNotEmpty && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && + accountSyncState?.tokenConfigured.bridge == true; + if (managedBridgeReady) { + return const AcpBridgeServerEffectiveConfig( + endpoint: kManagedBridgeServerUrl, + tokenRef: '', + source: 'cloud', + reason: 'Account sync is ready and the managed bridge token is available', + ); + } + if (config.selfHosted.isConfigured) { return AcpBridgeServerEffectiveConfig( endpoint: config.selfHosted.serverUrl, diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index 4f87d05d..9f8aef36 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -13,7 +13,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('Bridge runtime cleanup', () { test( - 'keeps the managed bridge endpoint fixed even when account sync carries a bridge URL', + 'keeps the managed bridge endpoint fixed even when account sync carries a bridge URL and stale manual bridge config exists', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-bridge-runtime-cleanup-', @@ -48,6 +48,28 @@ void main() { ), ), ); + await store.saveAccountSessionToken('session-token'); + await store.saveAccountSessionSummary( + const AccountSessionSummary( + userId: 'user-1', + email: 'review@svc.plus', + name: 'Review User', + role: 'reviewer', + mfaEnabled: true, + ), + ); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted + .copyWith( + serverUrl: 'https://acp-bridge.onwalk.net', + username: 'admin', + ), + ), + ), + ); await store.saveAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, value: 'bridge-token', @@ -66,6 +88,12 @@ void main() { controller.resolveBridgeAcpEndpointInternal()?.toString(), kManagedBridgeServerUrl, ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + 'bridge-token', + ); expect( controller .resolveExternalAcpEndpointForTargetInternal( diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 7e178b68..089b8887 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -490,6 +490,14 @@ void main() { ), 'fresh-bridge-token', ); + expect( + controller.snapshot.acpBridgeServerModeConfig.effective.source, + 'cloud', + ); + expect( + controller.snapshot.acpBridgeServerModeConfig.effective.endpoint, + kManagedBridgeServerUrl, + ); expect( controller.accountSyncState!.syncedDefaults.bridgeServerUrl, 'https://xworkmate-bridge-new.svc.plus',