diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index f7875d44..cffb3dd8 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -731,17 +731,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveBridgeAcpEndpointInternal() { - final rawEndpoint = - settings.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary - .endpoint - .trim(); + final rawEndpoint = settings + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .endpoint + .trim(); if (rawEndpoint.isEmpty) { return null; } final uri = Uri.tryParse(rawEndpoint); final scheme = uri?.scheme.trim().toLowerCase() ?? ''; - if (uri == null || - !kSupportedExternalAcpEndpointSchemes.contains(scheme)) { + if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) { return null; } return uri.replace(query: null, fragment: null); @@ -789,33 +790,34 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final normalizedHost = endpoint.host.trim().toLowerCase(); final bridgeHost = Uri.tryParse( - settings.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary + settings + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary .endpoint .trim(), - )?.host - .trim() - .toLowerCase() ?? + )?.host.trim().toLowerCase() ?? ''; if (bridgeHost.isNotEmpty && normalizedHost == bridgeHost) { - final accountToken = - (await storeInternal.loadAccountSessionToken())?.trim() ?? ''; - if (accountToken.isNotEmpty) { - return 'Bearer $accountToken'; + final bridgeToken = + (await storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ))?.trim() ?? + ''; + if (bridgeToken.isNotEmpty) { + return 'Bearer $bridgeToken'; } } final profileIndex = gatewayProfileIndexMatchingEndpointInternal(endpoint) ?? kGatewayRemoteProfileIndex; - final gatewayToken = await settingsControllerInternal.loadEffectiveGatewayToken( - profileIndex: profileIndex, - ); + final gatewayToken = await settingsControllerInternal + .loadEffectiveGatewayToken(profileIndex: profileIndex); if (gatewayToken.isNotEmpty) { return 'Bearer $gatewayToken'; } - final gatewayPassword = - await settingsControllerInternal.loadEffectiveGatewayPassword( - profileIndex: profileIndex, - ); + final gatewayPassword = await settingsControllerInternal + .loadEffectiveGatewayPassword(profileIndex: profileIndex); if (gatewayPassword.isNotEmpty) { final encoded = base64Encode(utf8.encode('operator:$gatewayPassword')); return 'Basic $encoded'; @@ -825,7 +827,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController { int? gatewayProfileIndexMatchingEndpointInternal(Uri endpoint) { final normalizedHost = endpoint.host.trim().toLowerCase(); - final gateway = gatewayProfileBaseUriInternal(settings.primaryGatewayProfile); + final gateway = gatewayProfileBaseUriInternal( + settings.primaryGatewayProfile, + ); if (gateway != null && gateway.host.trim().toLowerCase() == normalizedHost && gateway.port == endpoint.port) { diff --git a/lib/runtime/account_runtime_client.dart b/lib/runtime/account_runtime_client.dart index 23d917de..b6614ff3 100644 --- a/lib/runtime/account_runtime_client.dart +++ b/lib/runtime/account_runtime_client.dart @@ -162,30 +162,6 @@ class AccountRuntimeClient { return _accountSessionSummaryFromUserJson(user); } - Future loadProfile({required String token}) async { - final payload = await _requestJson( - method: 'GET', - path: '/api/auth/xworkmate/profile', - bearerToken: token, - ); - final profile = _asMap(payload['profile']); - final remoteProfile = AccountRemoteProfile.defaults().copyWith( - openclawUrl: _stringValue(profile['openclawUrl']), - openclawOrigin: _stringValue(profile['openclawOrigin']), - vaultUrl: _stringValue(profile['vaultUrl']), - vaultNamespace: _stringValue(profile['vaultNamespace']), - apisixUrl: _stringValue(profile['apisixUrl']), - secretLocators: _decodeLocators(profile), - ); - return AccountProfileResponse( - profile: remoteProfile, - profileScope: _stringValue(payload['profileScope']), - tokenConfigured: AccountTokenConfigured.fromJson( - _asMap(payload['tokenConfigured']), - ), - ); - } - Future createBridgeBootstrapTicket({ required String token, }) async { @@ -266,26 +242,6 @@ class AccountRuntimeClient { ); } - List _decodeLocators(Map profile) { - final raw = profile['secretLocators']; - if (raw is! List) { - return const []; - } - return raw - .whereType() - .map( - (item) => AccountSecretLocator.fromJson(item.cast()), - ) - .where( - (item) => - item.provider.trim().isNotEmpty && - item.secretPath.trim().isNotEmpty && - item.secretKey.trim().isNotEmpty && - item.target.trim().isNotEmpty, - ) - .toList(growable: false); - } - Uri _vaultReadUri(String rawBaseUrl, String secretPath) { final base = Uri.parse(_normalizeBaseUrl(rawBaseUrl)); final trimmedPath = secretPath.trim().replaceAll(RegExp(r'^/+|/+$'), ''); diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index d0e578f3..51a09cde 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -144,12 +144,17 @@ Future completeAccountSignInSettingsInternal( ); await controller.storeInternal.saveAccountSessionIdentifier(identifier); await controller.storeInternal.saveAccountSessionSummary(sessionSummary); - controller.accountStatusInternal = 'Signed in'; - await restoreAccountSessionSettingsInternal( + await syncAccountSettingsInternal( controller, baseUrl: baseUrl, + bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload), quiet: true, ); + await controller.reloadDerivedStateInternal(); + final email = controller.accountSessionInternal?.email.trim() ?? ''; + controller.accountStatusInternal = email.isEmpty + ? 'Signed in' + : 'Signed in as $email'; } Future restoreAccountSessionSettingsInternal( @@ -219,20 +224,18 @@ Future syncAccountSettingsInternal( SettingsController controller, { String baseUrl = '', bool quiet = false, + String bridgeTokenOverride = '', }) async { - final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( - baseUrl, - fallback: controller.snapshotInternal.accountBaseUrl, - ); - final token = + final sessionToken = (await controller.storeInternal.loadAccountSessionToken())?.trim() ?? ''; - if (normalizedBaseUrl.isEmpty || token.isEmpty) { + if (sessionToken.isEmpty) { const result = AccountSyncResult( state: 'blocked', message: 'Account session is unavailable', ); controller.accountStatusInternal = result.message; if (!quiet) { + controller.accountBusyInternal = false; controller.notifyListeners(); } return result; @@ -240,172 +243,90 @@ Future syncAccountSettingsInternal( if (!quiet) { controller.accountBusyInternal = true; - controller.accountStatusInternal = 'Syncing remote defaults...'; + controller.accountStatusInternal = 'Syncing bridge access...'; controller.notifyListeners(); } - try { - final client = controller.buildAccountClient(normalizedBaseUrl); - final response = await client.loadProfile(token: token); - final previousState = - await controller.storeInternal.loadAccountSyncState() ?? - AccountSyncState.defaults(); - final nextState = previousState.copyWith( - syncedDefaults: response.profile, - syncState: 'ready', - syncMessage: 'Remote defaults synced', - lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncSource: normalizedBaseUrl, - lastSyncError: '', - profileScope: response.profileScope, - tokenConfigured: response.tokenConfigured, + final bridgeToken = bridgeTokenOverride.trim().isNotEmpty + ? bridgeTokenOverride.trim() + : ((await controller.storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ))?.trim() ?? + ''); + if (bridgeToken.isEmpty) { + const result = AccountSyncResult( + state: 'blocked', + message: 'Bridge authorization is unavailable', ); - await controller.storeInternal.saveAccountSyncState(nextState); - final currentSettings = controller.snapshotInternal; - final currentModeConfig = currentSettings.acpBridgeServerModeConfig; - final nextModeConfig = currentModeConfig.copyWith( - cloudSynced: currentModeConfig.cloudSynced.copyWith( - accountBaseUrl: normalizedBaseUrl, - accountIdentifier: currentSettings.accountUsername.trim().isNotEmpty - ? currentSettings.accountUsername.trim() - : controller.accountSessionInternal?.email.trim() ?? '', - lastSyncAt: nextState.lastSyncAtMs, - remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary - .copyWith( - endpoint: _kProductionBridgeEndpoint, - hasAdvancedOverrides: false, - ), - ), - ); - if (nextModeConfig.toJson().toString() != - currentModeConfig.toJson().toString()) { - await controller.saveSnapshot( - currentSettings.copyWith( - accountLocalMode: false, - acpBridgeServerModeConfig: nextModeConfig, - ), - ); - } - await applyAccountSyncedDefaultsSettingsInternal( - controller, - state: nextState, - ); - await controller.reloadDerivedStateInternal(); - final email = controller.accountSessionInternal?.email.trim() ?? ''; - controller.accountStatusInternal = email.isEmpty - ? 'Signed in' - : 'Signed in as $email'; - return const AccountSyncResult( - state: 'ready', - message: 'Remote defaults synced', - ); - } on AccountRuntimeException catch (error) { - final previousState = - await controller.storeInternal.loadAccountSyncState() ?? - AccountSyncState.defaults(); - if (_isNonBlockingAccountProfileSyncError(error)) { - final fallbackState = previousState.copyWith( - syncState: 'ready', - syncMessage: 'Remote defaults unavailable; using existing settings', - lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncSource: normalizedBaseUrl, - lastSyncError: error.message, - ); - await controller.storeInternal.saveAccountSyncState(fallbackState); - await controller.reloadDerivedStateInternal(); - final email = controller.accountSessionInternal?.email.trim() ?? ''; - controller.accountStatusInternal = email.isEmpty - ? 'Signed in' - : 'Signed in as $email'; - return const AccountSyncResult( - state: 'ready', - message: 'Remote defaults unavailable; using existing settings', - ); - } - final errorState = previousState.copyWith( - syncState: 'error', - syncMessage: error.message, - lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, - lastSyncSource: normalizedBaseUrl, - lastSyncError: error.message, - ); - await controller.storeInternal.saveAccountSyncState(errorState); - await controller.reloadDerivedStateInternal(); - controller.accountStatusInternal = error.message; - return AccountSyncResult(state: 'error', message: error.message); - } finally { + controller.accountStatusInternal = result.message; if (!quiet) { controller.accountBusyInternal = false; controller.notifyListeners(); } - } -} - -bool _isNonBlockingAccountProfileSyncError(AccountRuntimeException error) { - return error.errorCode == 'xworkmate_secret_read_failed'; -} - -Future applyAccountSyncedDefaultsSettingsInternal( - SettingsController controller, { - required AccountSyncState state, -}) async { - final previous = controller.snapshotInternal; - var next = previous; - final defaults = state.syncedDefaults; - if (defaults.vaultUrl.trim().isNotEmpty) { - next = next.copyWith( - vault: next.vault.copyWith(address: defaults.vaultUrl.trim()), - ); + return result; } - if (defaults.vaultNamespace.trim().isNotEmpty) { - next = next.copyWith( - vault: next.vault.copyWith(namespace: defaults.vaultNamespace.trim()), - ); - } - - final aiGatewayLocator = defaults.locatorForTarget( - kAccountManagedSecretTargetAIGatewayAccessToken, + await controller.storeInternal.saveAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + value: bridgeToken, ); - if (aiGatewayLocator != null) { - next = next.copyWith( - aiGateway: next.aiGateway.copyWith(apiKeyRef: aiGatewayLocator.target), - ); - } - - final ollamaLocator = defaults.locatorForTarget( - kAccountManagedSecretTargetOllamaCloudApiKey, + await controller.storeInternal.clearAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ); + await controller.storeInternal.clearAccountManagedSecret( + target: kAccountManagedSecretTargetOllamaCloudApiKey, ); - if (ollamaLocator != null) { - next = next.copyWith( - ollamaCloud: next.ollamaCloud.copyWith(apiKeyRef: ollamaLocator.target), - ); - } - if (next.accountLocalMode) { - next = next.copyWith(accountLocalMode: false); - } - next = next.copyWith( - acpBridgeServerModeConfig: next.acpBridgeServerModeConfig.copyWith( - cloudSynced: next.acpBridgeServerModeConfig.cloudSynced.copyWith( - accountBaseUrl: next.accountBaseUrl, - accountIdentifier: next.accountUsername, - lastSyncAt: state.lastSyncAtMs, - remoteServerSummary: next - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .copyWith( - endpoint: _kProductionBridgeEndpoint, - hasAdvancedOverrides: false, - ), - ), + final nextState = AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Bridge access synced', + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncSource: _kProductionBridgeEndpoint, + lastSyncError: '', + profileScope: 'bridge', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: false, + apisix: false, ), ); - - if (next.toJsonString() != previous.toJsonString()) { - await controller.saveSnapshot(next); + await controller.storeInternal.saveAccountSyncState(nextState); + final currentSettings = controller.snapshotInternal; + final currentModeConfig = currentSettings.acpBridgeServerModeConfig; + final nextModeConfig = currentModeConfig.copyWith( + cloudSynced: currentModeConfig.cloudSynced.copyWith( + accountBaseUrl: '', + accountIdentifier: '', + lastSyncAt: nextState.lastSyncAtMs, + remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary + .copyWith( + endpoint: _kProductionBridgeEndpoint, + hasAdvancedOverrides: false, + ), + ), + ); + final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings( + currentSettings.copyWith( + accountLocalMode: false, + acpBridgeServerModeConfig: nextModeConfig, + ), + ); + if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) { + await controller.saveSnapshot(sanitizedSettings); } + await controller.reloadDerivedStateInternal(); + final email = controller.accountSessionInternal?.email.trim() ?? ''; + controller.accountStatusInternal = email.isEmpty + ? 'Signed in' + : 'Signed in as $email'; + if (!quiet) { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } + return const AccountSyncResult( + state: 'ready', + message: 'Bridge access synced', + ); } Future logoutAccountSettingsInternal( @@ -429,6 +350,7 @@ Future logoutAccountSettingsInternal( final currentSnapshot = controller.snapshotInternal; final clearedCloudSync = currentSnapshot.acpBridgeServerModeConfig.cloudSynced .copyWith( + accountBaseUrl: '', accountIdentifier: '', lastSyncAt: 0, remoteServerSummary: currentSnapshot @@ -502,6 +424,39 @@ String normalizeAccountBaseUrlSettingsInternal( : candidate; } +SettingsSnapshot _sanitizeBridgeOnlyAccountSyncSettings( + SettingsSnapshot settings, +) { + final normalizedAiGatewayRef = + settings.aiGateway.apiKeyRef.trim() == + kAccountManagedSecretTargetAIGatewayAccessToken + ? AiGatewayProfile.defaults().apiKeyRef + : settings.aiGateway.apiKeyRef; + final normalizedOllamaRef = + settings.ollamaCloud.apiKeyRef.trim() == + kAccountManagedSecretTargetOllamaCloudApiKey + ? OllamaCloudConfig.defaults().apiKeyRef + : settings.ollamaCloud.apiKeyRef; + return settings.copyWith( + aiGateway: settings.aiGateway.copyWith(apiKeyRef: normalizedAiGatewayRef), + ollamaCloud: settings.ollamaCloud.copyWith(apiKeyRef: normalizedOllamaRef), + ); +} + +String _resolveBridgeAuthorizationToken(Map payload) { + final explicit = _stringValue(payload['internalServiceToken']).isNotEmpty + ? _stringValue(payload['internalServiceToken']) + : _stringValue(payload['internal_service_token']).isNotEmpty + ? _stringValue(payload['internal_service_token']) + : _stringValue(payload['bridgeAuthToken']).isNotEmpty + ? _stringValue(payload['bridgeAuthToken']) + : _stringValue(payload['bridge_auth_token']); + if (explicit.isNotEmpty) { + return explicit; + } + return ''; +} + int _parseExpiresAtMs(Object? value) { if (value is int) { return value; diff --git a/lib/runtime/runtime_models_account.dart b/lib/runtime/runtime_models_account.dart index 789c79ca..6f65e6a0 100644 --- a/lib/runtime/runtime_models_account.dart +++ b/lib/runtime/runtime_models_account.dart @@ -593,18 +593,6 @@ class AcpBridgeServerModeConfig { } } -class AccountProfileResponse { - const AccountProfileResponse({ - required this.profile, - required this.profileScope, - required this.tokenConfigured, - }); - - final AccountRemoteProfile profile; - final String profileScope; - final AccountTokenConfigured tokenConfigured; -} - class AccountSyncState { const AccountSyncState({ required this.syncedDefaults, diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index e3e585f5..331ba0ad 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -8,12 +8,14 @@ import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart'; import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/codex_config_bridge.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -330,6 +332,68 @@ void main() { ); }); + group('resolveGatewayAcpAuthorizationHeaderInternal', () { + test( + 'prefers the synced bridge bearer token over the account session token', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-auth-header-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = AppController( + store: store, + accountClientFactory: (_) => _BridgeSyncAccountRuntimeClient(), + ); + addTearDown(() async { + controller.dispose(); + if (await root.exists()) { + try { + await root.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best-effort on macOS when sqlite/watch handles lag. + } + } + }); + + await store.initialize(); + await controller.settingsController.initialize(); + await controller.settingsController.saveSnapshot( + controller.settings.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + ), + ); + await controller.settingsController.loginAccount( + baseUrl: 'https://accounts.svc.plus', + identifier: 'review@svc.plus', + password: '***REMOVED-CREDENTIAL***', + ); + await controller.settingsController.saveGatewaySecrets( + profileIndex: kGatewayRemoteProfileIndex, + token: 'local-token', + password: '', + ); + + final bridgeAuthorization = await controller + .resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://xworkmate-bridge.svc.plus/acp'), + ); + final nonBridgeAuthorization = await controller + .resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://remote.example.com/acp'), + ); + + expect(bridgeAuthorization, 'Bearer bridge-token'); + expect(nonBridgeAuthorization, 'Bearer local-token'); + }, + ); + }); + group('selected working directory', () { test( 'persists thread project directory without changing local workspace binding', @@ -418,3 +482,27 @@ class _FakeGatewayRuntimeDeps { final SecureConfigStore store; final DeviceIdentityStore identityStore; } + +class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient { + _BridgeSyncAccountRuntimeClient() + : super(baseUrl: 'https://accounts.svc.plus'); + + @override + Future> login({ + required String identifier, + required String password, + }) async { + return { + 'token': 'session-token', + 'internalServiceToken': 'bridge-token', + 'expiresAt': '2026-04-12T00:00:00Z', + 'user': { + 'id': 'u-1', + 'email': identifier, + 'name': 'Review', + 'role': 'member', + 'mfaEnabled': false, + }, + }; + } +} diff --git a/test/runtime/account_sync_overwrite_test.dart b/test/runtime/account_sync_overwrite_test.dart index 36e14608..2573daf7 100644 --- a/test/runtime/account_sync_overwrite_test.dart +++ b/test/runtime/account_sync_overwrite_test.dart @@ -11,7 +11,7 @@ void main() { group('syncAccountSettings overwrite policy', () { test( - 'always overwrites sync-owned fields and stores metadata only', + 'rewrites only bridge-owned auth metadata and removes old synced secret refs', () async { final root = await Directory.systemTemp.createTemp( 'xworkmate-account-sync-overwrite-', @@ -59,13 +59,25 @@ void main() { ), aiGateway: controller.snapshot.aiGateway.copyWith( baseUrl: 'https://local-apisix.example.com', - apiKeyRef: 'local_ai_ref', + apiKeyRef: kAccountManagedSecretTargetAIGatewayAccessToken, ), ollamaCloud: controller.snapshot.ollamaCloud.copyWith( - apiKeyRef: 'local_ollama_ref', + apiKeyRef: kAccountManagedSecretTargetOllamaCloudApiKey, ), ), ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + value: 'stale-ai-token', + ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetOllamaCloudApiKey, + value: 'stale-ollama-token', + ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + value: 'bridge-token', + ); final first = await controller.syncAccountSettings( baseUrl: 'https://accounts.svc.plus', @@ -82,21 +94,61 @@ void main() { .tokenRef, 'local_ref', ); - expect(controller.snapshot.vault.address, 'https://vault.svc.plus'); - expect(controller.snapshot.vault.namespace, 'prod'); + expect( + controller.snapshot.vault.address, + 'https://local-vault.example.com', + ); + expect(controller.snapshot.vault.namespace, 'local'); expect( controller.snapshot.aiGateway.baseUrl, 'https://local-apisix.example.com', ); expect( controller.snapshot.aiGateway.apiKeyRef, - kAccountManagedSecretTargetAIGatewayAccessToken, + AiGatewayProfile.defaults().apiKeyRef, ); expect( controller.snapshot.ollamaCloud.apiKeyRef, - kAccountManagedSecretTargetOllamaCloudApiKey, + OllamaCloudConfig.defaults().apiKeyRef, ); expect(controller.snapshot.accountLocalMode, isFalse); + expect(controller.accountSyncState?.profileScope, 'bridge'); + expect(controller.accountSyncState?.tokenConfigured.openclaw, isTrue); + expect(controller.accountSyncState?.tokenConfigured.apisix, isFalse); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ), + 'bridge-token', + ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ), + isNull, + ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOllamaCloudApiKey, + ), + isNull, + ); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountBaseUrl, + isEmpty, + ); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountIdentifier, + isEmpty, + ); await controller.saveSnapshot( controller.snapshot.copyWith( @@ -113,7 +165,7 @@ void main() { baseUrl: 'https://accounts.svc.plus', ); expect(second.state, 'ready'); - expect(controller.snapshot.vault.address, 'https://vault.svc.plus'); + expect(controller.snapshot.vault.address, 'https://edited.example.com'); expect( controller.snapshot.aiGateway.baseUrl, 'https://edited-apisix.example.com', @@ -142,49 +194,4 @@ void main() { class _FakeAccountRuntimeClient extends AccountRuntimeClient { _FakeAccountRuntimeClient() : super(baseUrl: 'https://accounts.svc.plus'); - - @override - Future loadProfile({required String token}) async { - expect(token, 'session-token'); - return AccountProfileResponse( - profile: AccountRemoteProfile.defaults().copyWith( - openclawUrl: 'wss://remote.gateway.svc.plus', - vaultUrl: 'https://vault.svc.plus', - vaultNamespace: 'prod', - apisixUrl: 'https://apisix.svc.plus', - secretLocators: const [ - AccountSecretLocator( - id: 'gateway', - provider: 'vault', - secretPath: 'kv/xworkmate', - secretKey: 'gateway_token', - target: kAccountManagedSecretTargetOpenclawGatewayToken, - required: true, - ), - AccountSecretLocator( - id: 'ai', - provider: 'vault', - secretPath: 'kv/xworkmate', - secretKey: 'ai_gateway_token', - target: kAccountManagedSecretTargetAIGatewayAccessToken, - required: true, - ), - AccountSecretLocator( - id: 'ollama', - provider: 'vault', - secretPath: 'kv/xworkmate', - secretKey: 'ollama_key', - target: kAccountManagedSecretTargetOllamaCloudApiKey, - required: true, - ), - ], - ), - profileScope: 'workspace', - tokenConfigured: const AccountTokenConfigured( - openclaw: true, - vault: true, - apisix: true, - ), - ); - } } diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 02999824..cfb94ada 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -48,15 +48,21 @@ void main() { target: kAccountManagedSecretTargetAIGatewayAccessToken, value: 'managed-secret', ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + value: 'bridge-token', + ); await store.saveAccountSyncState( AccountSyncState.defaults().copyWith( syncState: 'ready', - syncMessage: 'Remote defaults synced', + syncMessage: 'Bridge access synced', lastSyncAtMs: 123456789, - lastSyncSource: 'https://accounts.svc.plus', - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - openclawUrl: 'wss://gateway.svc.plus', - apisixUrl: 'https://apisix.svc.plus', + lastSyncSource: 'https://xworkmate-bridge.svc.plus', + profileScope: 'bridge', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: false, + apisix: false, ), ), ); @@ -98,12 +104,26 @@ void main() { ), isNull, ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ), + isNull, + ); expect(await store.loadAccountSyncState(), isNull); expect(controller.accountSignedIn, isFalse); expect(controller.accountStatus, 'Signed out'); expect(controller.accountSyncState, isNull); expect(controller.snapshot.accountLocalMode, isTrue); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountBaseUrl, + isEmpty, + ); expect( controller .snapshot diff --git a/test/runtime/settings_account_auth_flow_test.dart b/test/runtime/settings_account_auth_flow_test.dart index 05edba4e..5f3de8ae 100644 --- a/test/runtime/settings_account_auth_flow_test.dart +++ b/test/runtime/settings_account_auth_flow_test.dart @@ -10,65 +10,71 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('SettingsController account auth flow', () { - test( - 'login persists session summary and synced profile metadata', - () async { - final root = await Directory.systemTemp.createTemp( - 'xworkmate-account-auth-login-', - ); - final store = SecureConfigStore( - enableSecureStorage: false, - appDataRootPathResolver: () async => root.path, - secretRootPathResolver: () async => root.path, - supportRootPathResolver: () async => root.path, - ); - final controller = SettingsController( - store, - accountClientFactory: (_) => _SuccessfulAccountRuntimeClient(), - ); - addTearDown(() async { - controller.dispose(); - store.dispose(); - if (await root.exists()) { - await root.delete(recursive: true); - } - }); + test('login persists session summary and bridge sync metadata', () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-account-auth-login-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final client = _SuccessfulAccountRuntimeClient(); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); - await store.initialize(); - await controller.initialize(); - await controller.saveSnapshot( - controller.snapshot.copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - accountUsername: 'review@svc.plus', - ), - ); + await store.initialize(); + await controller.initialize(); + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + ), + ); - await controller.loginAccount( - baseUrl: 'https://accounts.svc.plus', - identifier: 'review@svc.plus', - password: '***REMOVED-CREDENTIAL***', - ); + await controller.loginAccount( + baseUrl: 'https://accounts.svc.plus', + identifier: 'review@svc.plus', + password: '***REMOVED-CREDENTIAL***', + ); - expect(controller.accountSignedIn, isTrue); - expect(controller.accountStatus, 'Signed in as review@svc.plus'); - expect(controller.accountSession?.email, 'review@svc.plus'); - expect(controller.accountSession?.totpEnabled, isTrue); - expect(controller.accountSession?.totpPending, isFalse); - expect(controller.accountSyncState?.syncState, 'ready'); - expect(controller.accountSyncState?.profileScope, 'tenant-shared'); - expect(controller.accountSyncState?.tokenConfigured.apisix, isTrue); - expect(await store.loadAccountSessionToken(), 'session-token'); - expect( - controller - .snapshot - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint, - 'https://xworkmate-bridge.svc.plus', - ); - }, - ); + expect(controller.accountSignedIn, isTrue); + expect(controller.accountStatus, 'Signed in as review@svc.plus'); + expect(controller.accountSession?.email, 'review@svc.plus'); + expect(controller.accountSession?.totpEnabled, isTrue); + expect(controller.accountSession?.totpPending, isFalse); + expect(controller.accountSyncState?.syncState, 'ready'); + expect(controller.accountSyncState?.profileScope, 'bridge'); + expect(controller.accountSyncState?.tokenConfigured.openclaw, isTrue); + expect(controller.accountSyncState?.tokenConfigured.apisix, isFalse); + expect(await store.loadAccountSessionToken(), 'session-token'); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ), + 'bridge-token', + ); + expect(client.loadSessionCalls, 0); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .endpoint, + 'https://xworkmate-bridge.svc.plus', + ); + }); test('mfa challenge transitions to verified signed-in session', () async { final root = await Directory.systemTemp.createTemp( @@ -130,6 +136,8 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient { _SuccessfulAccountRuntimeClient() : super(baseUrl: 'https://accounts.svc.plus'); + int loadSessionCalls = 0; + @override Future> login({ required String identifier, @@ -139,6 +147,7 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient { expect(password, '***REMOVED-CREDENTIAL***'); return { 'token': 'session-token', + 'internalServiceToken': 'bridge-token', 'expiresAt': '2026-04-12T00:00:00Z', 'user': { 'id': 'u-1', @@ -153,6 +162,7 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient { @override Future loadSession({required String token}) async { + loadSessionCalls += 1; expect(token, 'session-token'); return const AccountSessionSummary( userId: 'u-1', @@ -164,22 +174,6 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient { totpPending: false, ); } - - @override - Future loadProfile({required String token}) async { - expect(token, 'session-token'); - return AccountProfileResponse( - profile: AccountRemoteProfile.defaults().copyWith( - apisixUrl: 'https://apisix.svc.plus', - ), - profileScope: 'tenant-shared', - tokenConfigured: const AccountTokenConfigured( - openclaw: true, - vault: false, - apisix: true, - ), - ); - } } class _MfaAccountRuntimeClient extends AccountRuntimeClient { @@ -204,6 +198,7 @@ class _MfaAccountRuntimeClient extends AccountRuntimeClient { lastVerifiedCode = code; return { 'token': 'session-token', + 'internalServiceToken': 'bridge-token', 'expiresAt': '2026-04-12T00:00:00Z', 'user': { 'id': 'u-1', @@ -228,17 +223,4 @@ class _MfaAccountRuntimeClient extends AccountRuntimeClient { totpPending: false, ); } - - @override - Future loadProfile({required String token}) async { - return AccountProfileResponse( - profile: AccountRemoteProfile.defaults(), - profileScope: 'tenant-shared', - tokenConfigured: const AccountTokenConfigured( - openclaw: true, - vault: false, - apisix: true, - ), - ); - } }