From 024f485a3647ebfa1a3c33e76ae53e2fe47339f5 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 19:20:49 +0800 Subject: [PATCH] Isolate gateway secrets per profile slot --- lib/app/app_controller_desktop.dart | 124 +++++-- lib/features/settings/settings_page.dart | 193 +++++++---- lib/runtime/gateway_runtime.dart | 11 +- lib/runtime/runtime_controllers.dart | 60 +++- lib/runtime/secret_store.dart | 70 +++- test/features/ai_gateway_page_suite.dart | 1 + test/features/assistant_page_suite.dart | 1 + test/features/settings_page_suite.dart | 16 + test/runtime/agent_registry_suite.dart | 1 + .../app_controller_ai_gateway_chat_suite.dart | 127 ++++---- .../app_controller_codex_bridge_suite.dart | 308 +++++++++--------- ...troller_execution_target_switch_suite.dart | 1 + ..._controller_gateway_token_state_suite.dart | 31 +- .../code_agent_node_orchestrator_suite.dart | 1 + test/runtime/codex_integration_suite.dart | 1 + test/runtime/mode_switcher_suite.dart | 1 + test/runtime/runtime_coordinator_suite.dart | 1 + test/runtime/secure_config_store_suite.dart | 80 ++++- 18 files changed, 668 insertions(+), 360 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 7a3ffc80..20dd5db2 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -94,8 +94,9 @@ class AppController extends ChangeNotifier { (_isFlutterTestEnvironment ? const [] : _defaultGatewayOnlySkillScanRoots); - _gatewayAcpClient = - GatewayAcpClient(endpointResolver: _resolveGatewayAcpEndpoint); + _gatewayAcpClient = GatewayAcpClient( + endpointResolver: _resolveGatewayAcpEndpoint, + ); _singleAgentAppServerClient = DirectSingleAgentAppServerClient( endpointResolver: _resolveSingleAgentEndpoint, ); @@ -283,15 +284,15 @@ class AppController extends ChangeNotifier { AssistantPermissionLevel get assistantPermissionLevel => settings.assistantPermissionLevel; bool get hasStoredGatewayCredential => - _settingsController.secureRefs.containsKey('gateway_token') || - _settingsController.secureRefs.containsKey('gateway_password') || + hasStoredGatewayTokenForProfile(_activeGatewayProfileIndex) || + hasStoredGatewayPasswordForProfile(_activeGatewayProfileIndex) || _settingsController.secureRefs.containsKey( 'gateway_device_token_operator', ); bool get hasStoredGatewayToken => - _settingsController.secureRefs.containsKey('gateway_token'); + hasStoredGatewayTokenForProfile(_activeGatewayProfileIndex); String? get storedGatewayTokenMask => - _settingsController.secureRefs['gateway_token']; + storedGatewayTokenMaskForProfile(_activeGatewayProfileIndex); String get aiGatewayUrl => settings.aiGateway.baseUrl.trim(); bool get hasStoredAiGatewayApiKey => _settingsController.secureRefs.containsKey('ai_gateway_api_key'); @@ -311,8 +312,6 @@ class AppController extends ChangeNotifier { bool get isMultiAgentRunPending => _multiAgentRunPending; bool _desktopPlatformBusy = false; - static const String _draftGatewayTokenKey = 'gateway_token'; - static const String _draftGatewayPasswordKey = 'gateway_password'; static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key'; static const String _draftVaultTokenKey = 'vault_token'; static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key'; @@ -325,6 +324,26 @@ class AppController extends ChangeNotifier { hasStoredAiGatewayApiKey && resolvedAiGatewayModel.isNotEmpty; + int get _activeGatewayProfileIndex { + final target = currentAssistantExecutionTarget; + if (target == AssistantExecutionTarget.singleAgent) { + return kGatewayRemoteProfileIndex; + } + return _gatewayProfileIndexForExecutionTarget(target); + } + + bool hasStoredGatewayTokenForProfile(int profileIndex) => + _settingsController.hasStoredGatewayTokenForProfile(profileIndex); + + bool hasStoredGatewayPasswordForProfile(int profileIndex) => + _settingsController.hasStoredGatewayPasswordForProfile(profileIndex); + + String? storedGatewayTokenMaskForProfile(int profileIndex) => + _settingsController.storedGatewayTokenMaskForProfile(profileIndex); + + String? storedGatewayPasswordMaskForProfile(int profileIndex) => + _settingsController.storedGatewayPasswordMaskForProfile(profileIndex); + List get availableSingleAgentProviders => (_availableSingleAgentProvidersOverride ?? const [SingleAgentProvider.codex]) @@ -1342,7 +1361,15 @@ class AppController extends ChangeNotifier { final resolvedPassword = password.trim().isNotEmpty ? password.trim() : (decoded?.password.trim() ?? ''); + final resolvedProfileIndex = _gatewayProfileIndexForExecutionTarget( + _assistantExecutionTargetForMode( + _modeFromHost( + decoded?.host ?? settings.primaryRemoteGatewayProfile.host, + ), + ), + ); await _settingsController.saveGatewaySecrets( + profileIndex: resolvedProfileIndex, token: resolvedToken, password: resolvedPassword, ); @@ -1378,6 +1405,7 @@ class AppController extends ChangeNotifier { ); await _connectProfile( nextProfile, + profileIndex: resolvedProfileIndex, authTokenOverride: resolvedToken, authPasswordOverride: resolvedPassword, ); @@ -1392,7 +1420,10 @@ class AppController extends ChangeNotifier { String token = '', String password = '', }) async { + final nextTarget = _assistantExecutionTargetForMode(mode); + final nextProfileIndex = _gatewayProfileIndexForExecutionTarget(nextTarget); await _settingsController.saveGatewaySecrets( + profileIndex: nextProfileIndex, token: token.trim(), password: password.trim(), ); @@ -1403,7 +1434,6 @@ class AppController extends ChangeNotifier { final resolvedPort = mode == RuntimeConnectionMode.local && port <= 0 ? 18789 : port; - final nextTarget = _assistantExecutionTargetForMode(mode); final nextProfile = _gatewayProfileForAssistantExecutionTarget(nextTarget) .copyWith( mode: mode, @@ -1429,6 +1459,7 @@ class AppController extends ChangeNotifier { ); await _connectProfile( nextProfile, + profileIndex: nextProfileIndex, authTokenOverride: token.trim(), authPasswordOverride: password.trim(), ); @@ -1456,11 +1487,17 @@ class AppController extends ChangeNotifier { if (target == AssistantExecutionTarget.singleAgent) { return; } - await _connectProfile(_gatewayProfileForAssistantExecutionTarget(target)); + await _connectProfile( + _gatewayProfileForAssistantExecutionTarget(target), + profileIndex: _gatewayProfileIndexForExecutionTarget(target), + ); } - Future clearStoredGatewayToken() async { - await _settingsController.clearGatewaySecrets(token: true); + Future clearStoredGatewayToken({int? profileIndex}) async { + await _settingsController.clearGatewaySecrets( + profileIndex: profileIndex, + token: true, + ); } Future refreshGatewayHealth() async { @@ -1808,7 +1845,10 @@ class AppController extends ChangeNotifier { resolvedTarget, ); try { - await _connectProfile(targetProfile); + await _connectProfile( + targetProfile, + profileIndex: _gatewayProfileIndexForExecutionTarget(resolvedTarget), + ); } catch (_) { // Keep the selected execution target even when the immediate reconnect // fails so the user can retry or adjust gateway settings manually. @@ -2147,12 +2187,12 @@ class AppController extends ChangeNotifier { notifyListeners(); } - void saveGatewayTokenDraft(String value) { - _saveSecretDraft(_draftGatewayTokenKey, value); + void saveGatewayTokenDraft(String value, {required int profileIndex}) { + _saveSecretDraft(_draftGatewayTokenKey(profileIndex), value); } - void saveGatewayPasswordDraft(String value) { - _saveSecretDraft(_draftGatewayPasswordKey, value); + void saveGatewayPasswordDraft(String value, {required int profileIndex}) { + _saveSecretDraft(_draftGatewayPasswordKey(profileIndex), value); } void saveAiGatewayApiKeyDraft(String value) { @@ -2682,7 +2722,10 @@ class AppController extends ChangeNotifier { startupProfile.setupCode.trim().isNotEmpty; if (shouldAutoConnect) { try { - await _connectProfile(startupProfile); + await _connectProfile( + startupProfile, + profileIndex: _gatewayProfileIndexForExecutionTarget(startupTarget), + ); } catch (_) { // Keep the shell usable when auto-connect fails. } @@ -2711,11 +2754,13 @@ class AppController extends ChangeNotifier { Future _connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { await _runtime.connectProfile( profile, + profileIndex: profileIndex, authTokenOverride: authTokenOverride, authPasswordOverride: authPasswordOverride, ); @@ -2755,6 +2800,9 @@ class AppController extends ChangeNotifier { SettingsSnapshot previous, SettingsSnapshot next, ) { + final hasGatewaySecretDraft = _draftSecretValues.keys.any( + (key) => _isGatewayDraftKey(key), + ); final gatewayChanged = jsonEncode( previous.gatewayProfiles.map((item) => item.toJson()).toList(), @@ -2763,8 +2811,7 @@ class AppController extends ChangeNotifier { next.gatewayProfiles.map((item) => item.toJson()).toList(), ) || previous.assistantExecutionTarget != next.assistantExecutionTarget || - _draftSecretValues.containsKey(_draftGatewayTokenKey) || - _draftSecretValues.containsKey(_draftGatewayPasswordKey); + hasGatewaySecretDraft; final aiGatewayChanged = previous.aiGateway.toJson().toString() != next.aiGateway.toJson().toString() || @@ -2775,13 +2822,18 @@ class AppController extends ChangeNotifier { } Future _persistDraftSecrets() async { - final gatewayToken = _draftSecretValues[_draftGatewayTokenKey]; - final gatewayPassword = _draftSecretValues[_draftGatewayPasswordKey]; - if ((gatewayToken ?? '').isNotEmpty || (gatewayPassword ?? '').isNotEmpty) { - await _settingsController.saveGatewaySecrets( - token: gatewayToken ?? '', - password: gatewayPassword ?? '', - ); + for (var index = 0; index < kGatewayProfileListLength; index += 1) { + final gatewayToken = _draftSecretValues[_draftGatewayTokenKey(index)]; + final gatewayPassword = + _draftSecretValues[_draftGatewayPasswordKey(index)]; + if ((gatewayToken ?? '').isNotEmpty || + (gatewayPassword ?? '').isNotEmpty) { + await _settingsController.saveGatewaySecrets( + profileIndex: index, + token: gatewayToken ?? '', + password: gatewayPassword ?? '', + ); + } } final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey]; if ((aiGatewayApiKey ?? '').isNotEmpty) { @@ -2798,6 +2850,15 @@ class AppController extends ChangeNotifier { _draftSecretValues.clear(); } + static String _draftGatewayTokenKey(int profileIndex) => + 'gateway_token_$profileIndex'; + + static String _draftGatewayPasswordKey(int profileIndex) => + 'gateway_password_$profileIndex'; + + static bool _isGatewayDraftKey(String key) => + key.startsWith('gateway_token_') || key.startsWith('gateway_password_'); + Future _persistSettingsSnapshot(SettingsSnapshot snapshot) async { final sanitized = _sanitizeFeatureFlagSettings( _sanitizeMultiAgentSettings( @@ -4252,10 +4313,7 @@ class AppController extends ChangeNotifier { return appText('无法连接到 LLM API。', 'Unable to reach the LLM API.'); } if (error is HandshakeException) { - return appText( - 'LLM API TLS 握手失败。', - 'LLM API TLS handshake failed.', - ); + return appText('LLM API TLS 握手失败。', 'LLM API TLS handshake failed.'); } if (error is TimeoutException) { return appText('LLM API 请求超时。', 'LLM API request timed out.'); @@ -4746,7 +4804,9 @@ class AppController extends ChangeNotifier { _sessionsController.currentSessionKey, ); if (target == AssistantExecutionTarget.singleAgent) { - final remote = _gatewayProfileBaseUri(settings.primaryRemoteGatewayProfile); + final remote = _gatewayProfileBaseUri( + settings.primaryRemoteGatewayProfile, + ); if (remote != null) { return remote; } diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 3d2dbeb2..98672913 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -49,8 +49,8 @@ class _SettingsPageState extends State { late final TextEditingController _gatewaySetupCodeController; late final TextEditingController _gatewayHostController; late final TextEditingController _gatewayPortController; - late final TextEditingController _gatewayTokenController; - late final TextEditingController _gatewayPasswordController; + late final List _gatewayTokenControllers; + late final List _gatewayPasswordControllers; late final TextEditingController _vaultTokenController; late final TextEditingController _ollamaApiKeyController; late final TextEditingController _runtimeLogFilterController; @@ -65,8 +65,8 @@ class _SettingsPageState extends State { String _gatewaySetupCodeSyncedValue = ''; String _gatewayHostSyncedValue = ''; String _gatewayPortSyncedValue = ''; - _SecretFieldUiState _gatewayTokenState = const _SecretFieldUiState(); - _SecretFieldUiState _gatewayPasswordState = const _SecretFieldUiState(); + late final List<_SecretFieldUiState> _gatewayTokenStates; + late final List<_SecretFieldUiState> _gatewayPasswordStates; bool _aiGatewayTesting = false; String _aiGatewayTestState = 'idle'; String _aiGatewayTestMessage = ''; @@ -94,8 +94,26 @@ class _SettingsPageState extends State { _gatewaySetupCodeController = TextEditingController(); _gatewayHostController = TextEditingController(); _gatewayPortController = TextEditingController(); - _gatewayTokenController = TextEditingController(); - _gatewayPasswordController = TextEditingController(); + _gatewayTokenControllers = List.generate( + kGatewayProfileListLength, + (_) => TextEditingController(), + growable: false, + ); + _gatewayPasswordControllers = List.generate( + kGatewayProfileListLength, + (_) => TextEditingController(), + growable: false, + ); + _gatewayTokenStates = List<_SecretFieldUiState>.filled( + kGatewayProfileListLength, + const _SecretFieldUiState(), + growable: false, + ); + _gatewayPasswordStates = List<_SecretFieldUiState>.filled( + kGatewayProfileListLength, + const _SecretFieldUiState(), + growable: false, + ); _vaultTokenController = TextEditingController(); _ollamaApiKeyController = TextEditingController(); _runtimeLogFilterController = TextEditingController(); @@ -125,8 +143,12 @@ class _SettingsPageState extends State { _gatewaySetupCodeController.dispose(); _gatewayHostController.dispose(); _gatewayPortController.dispose(); - _gatewayTokenController.dispose(); - _gatewayPasswordController.dispose(); + for (final controller in _gatewayTokenControllers) { + controller.dispose(); + } + for (final controller in _gatewayPasswordControllers) { + controller.dispose(); + } _vaultTokenController.dispose(); _ollamaApiKeyController.dispose(); _runtimeLogFilterController.dispose(); @@ -864,10 +886,7 @@ class _SettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - appText( - '自定义连接源 ${_selectedLlmEndpointIndex + 1}', - 'Custom source ${_selectedLlmEndpointIndex + 1}', - ), + appText('连接源详情', 'Source details'), style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), @@ -889,14 +908,16 @@ class _SettingsPageState extends State { SettingsSnapshot settings, int index, ) { - final configured = _isLlmEndpointSlotConfigured( - controller, - settings, - _llmEndpointSlots[index], - ); + final slot = _llmEndpointSlots[index]; + final configured = _isLlmEndpointSlotConfigured(controller, settings, slot); + final label = switch (slot) { + _LlmEndpointSlot.aiGateway => appText('主 LLM API', 'Primary LLM API'), + _LlmEndpointSlot.ollamaLocal => appText('Ollama 本地', 'Ollama Local'), + _LlmEndpointSlot.ollamaCloud => appText('Ollama Cloud', 'Ollama Cloud'), + }; return appText( - '自定义连接源 ${index + 1}(${configured ? '已配置' : '空'})', - 'Custom source ${index + 1} (${configured ? 'Configured' : 'Empty'})', + configured ? label : '$label(空)', + configured ? label : '$label (empty)', ); } @@ -1001,6 +1022,12 @@ class _SettingsPageState extends State { selectedProfileIndex, gatewayProfile, ); + final gatewayTokenController = + _gatewayTokenControllers[selectedProfileIndex]; + final gatewayPasswordController = + _gatewayPasswordControllers[selectedProfileIndex]; + final gatewayTokenState = _gatewayTokenStates[selectedProfileIndex]; + final gatewayPasswordState = _gatewayPasswordStates[selectedProfileIndex]; final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); @@ -1011,9 +1038,11 @@ class _SettingsPageState extends State { final gatewayTls = gatewayMode == RuntimeConnectionMode.local ? false : gatewayProfile.tls; - final hasStoredGatewayToken = controller.hasStoredGatewayToken; - final hasStoredGatewayPassword = - controller.settingsController.secureRefs['gateway_password'] != null; + final hasStoredGatewayToken = controller.hasStoredGatewayTokenForProfile( + selectedProfileIndex, + ); + final hasStoredGatewayPassword = controller + .hasStoredGatewayPasswordForProfile(selectedProfileIndex); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1163,13 +1192,19 @@ class _SettingsPageState extends State { const SizedBox(height: 16), _buildSecureField( fieldKey: const ValueKey('gateway-shared-token-field'), - controller: _gatewayTokenController, + controller: gatewayTokenController, label: appText('共享 Token', 'Shared Token'), hasStoredValue: hasStoredGatewayToken, - fieldState: _gatewayTokenState, - onStateChanged: (value) => setState(() => _gatewayTokenState = value), - loadValue: controller.settingsController.loadGatewayToken, - onSubmitted: (value) async => controller.saveGatewayTokenDraft(value), + fieldState: gatewayTokenState, + onStateChanged: (value) => + setState(() => _gatewayTokenStates[selectedProfileIndex] = value), + loadValue: () => controller.settingsController.loadGatewayToken( + profileIndex: selectedProfileIndex, + ), + onSubmitted: (value) async => controller.saveGatewayTokenDraft( + value, + profileIndex: selectedProfileIndex, + ), storedHelperText: appText( '已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。', 'Stored securely. Test directly or submit with local Save / Apply actions.', @@ -1182,15 +1217,20 @@ class _SettingsPageState extends State { const SizedBox(height: 12), _buildSecureField( fieldKey: const ValueKey('gateway-password-field'), - controller: _gatewayPasswordController, + controller: gatewayPasswordController, label: appText('密码', 'Password'), hasStoredValue: hasStoredGatewayPassword, - fieldState: _gatewayPasswordState, - onStateChanged: (value) => - setState(() => _gatewayPasswordState = value), - loadValue: controller.settingsController.loadGatewayPassword, - onSubmitted: (value) async => - controller.saveGatewayPasswordDraft(value), + fieldState: gatewayPasswordState, + onStateChanged: (value) => setState( + () => _gatewayPasswordStates[selectedProfileIndex] = value, + ), + loadValue: () => controller.settingsController.loadGatewayPassword( + profileIndex: selectedProfileIndex, + ), + onSubmitted: (value) async => controller.saveGatewayPasswordDraft( + value, + profileIndex: selectedProfileIndex, + ), storedHelperText: appText( '已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。', 'Stored securely. Test directly or submit with local Save / Apply actions.', @@ -2813,19 +2853,24 @@ XWorkmate Privacy Policy } Future _captureVisibleSecretDrafts(AppController controller) async { - final gatewayToken = _secretOverride( - _gatewayTokenController, - _gatewayTokenState, - ); - if (gatewayToken.isNotEmpty) { - controller.saveGatewayTokenDraft(gatewayToken); - } - final gatewayPassword = _secretOverride( - _gatewayPasswordController, - _gatewayPasswordState, - ); - if (gatewayPassword.isNotEmpty) { - controller.saveGatewayPasswordDraft(gatewayPassword); + for (var index = 0; index < kGatewayProfileListLength; index += 1) { + final gatewayToken = _secretOverride( + _gatewayTokenControllers[index], + _gatewayTokenStates[index], + ); + if (gatewayToken.isNotEmpty) { + controller.saveGatewayTokenDraft(gatewayToken, profileIndex: index); + } + final gatewayPassword = _secretOverride( + _gatewayPasswordControllers[index], + _gatewayPasswordStates[index], + ); + if (gatewayPassword.isNotEmpty) { + controller.saveGatewayPasswordDraft( + gatewayPassword, + profileIndex: index, + ); + } } final aiGatewayApiKey = _secretOverride( _aiGatewayApiKeyController, @@ -2848,10 +2893,6 @@ XWorkmate Privacy Policy } void _resetSecureFieldUiAfterPersist(AppController controller) { - final hasStoredGatewayToken = - controller.settingsController.secureRefs['gateway_token'] != null; - final hasStoredGatewayPassword = - controller.settingsController.secureRefs['gateway_password'] != null; final hasStoredAiGatewayApiKey = controller.settingsController.secureRefs['ai_gateway_api_key'] != null; final hasStoredVaultToken = @@ -2859,21 +2900,23 @@ XWorkmate Privacy Policy final hasStoredOllamaApiKey = controller.settingsController.secureRefs['ollama_cloud_api_key'] != null; - _gatewayTokenState = const _SecretFieldUiState(); - _gatewayPasswordState = const _SecretFieldUiState(); + for (var index = 0; index < kGatewayProfileListLength; index += 1) { + _gatewayTokenStates[index] = const _SecretFieldUiState(); + _gatewayPasswordStates[index] = const _SecretFieldUiState(); + _primeSecureFieldController( + _gatewayTokenControllers[index], + hasStoredValue: controller.hasStoredGatewayTokenForProfile(index), + fieldState: _gatewayTokenStates[index], + ); + _primeSecureFieldController( + _gatewayPasswordControllers[index], + hasStoredValue: controller.hasStoredGatewayPasswordForProfile(index), + fieldState: _gatewayPasswordStates[index], + ); + } _aiGatewayApiKeyState = const _SecretFieldUiState(); _vaultTokenState = const _SecretFieldUiState(); _ollamaApiKeyState = const _SecretFieldUiState(); - _primeSecureFieldController( - _gatewayTokenController, - hasStoredValue: hasStoredGatewayToken, - fieldState: _gatewayTokenState, - ); - _primeSecureFieldController( - _gatewayPasswordController, - hasStoredValue: hasStoredGatewayPassword, - fieldState: _gatewayPasswordState, - ); _primeSecureFieldController( _aiGatewayApiKeyController, hasStoredValue: hasStoredAiGatewayApiKey, @@ -3176,21 +3219,35 @@ XWorkmate Privacy Policy ) async { final messenger = ScaffoldMessenger.of(context); final gatewayDraft = _buildGatewayDraftProfile(settings); + final selectedProfileIndex = _selectedGatewayProfileIndex.clamp( + 0, + settings.gatewayProfiles.length - 1, + ); + final gatewayTokenController = + _gatewayTokenControllers[selectedProfileIndex]; + final gatewayPasswordController = + _gatewayPasswordControllers[selectedProfileIndex]; + final gatewayTokenState = _gatewayTokenStates[selectedProfileIndex]; + final gatewayPasswordState = _gatewayPasswordStates[selectedProfileIndex]; final executionTarget = switch (gatewayDraft.mode) { RuntimeConnectionMode.local => AssistantExecutionTarget.local, RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.remote, }; - var token = _secretOverride(_gatewayTokenController, _gatewayTokenState); + var token = _secretOverride(gatewayTokenController, gatewayTokenState); var password = _secretOverride( - _gatewayPasswordController, - _gatewayPasswordState, + gatewayPasswordController, + gatewayPasswordState, ); if (token.isEmpty) { - token = await controller.settingsController.loadGatewayToken(); + token = await controller.settingsController.loadGatewayToken( + profileIndex: selectedProfileIndex, + ); } if (password.isEmpty) { - password = await controller.settingsController.loadGatewayPassword(); + password = await controller.settingsController.loadGatewayPassword( + profileIndex: selectedProfileIndex, + ); } setState(() => _gatewayTesting = true); try { diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index 27664c5b..5be91786 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -120,6 +120,7 @@ class GatewayRuntime extends ChangeNotifier { Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { @@ -130,8 +131,14 @@ class GatewayRuntime extends ChangeNotifier { final endpoint = _resolveEndpoint(profile); final setupPayload = decodeGatewaySetupCode(profile.setupCode); - final storedToken = (await _store.loadGatewayToken())?.trim() ?? ''; - final storedPassword = (await _store.loadGatewayPassword())?.trim() ?? ''; + final storedToken = + (await _store.loadGatewayToken(profileIndex: profileIndex))?.trim() ?? + ''; + final storedPassword = + (await _store.loadGatewayPassword( + profileIndex: profileIndex, + ))?.trim() ?? + ''; final explicitToken = authTokenOverride.trim(); final explicitPassword = authPasswordOverride.trim(); final sharedTokenSource = explicitToken.isNotEmpty diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index d958c700..81ed6298 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -67,32 +67,36 @@ class SettingsController extends ChangeNotifier { } Future saveGatewaySecrets({ + int? profileIndex, required String token, required String password, }) async { final trimmedToken = token.trim(); final trimmedPassword = password.trim(); if (trimmedToken.isNotEmpty) { - await _store.saveGatewayToken(trimmedToken); + await _store.saveGatewayToken(trimmedToken, profileIndex: profileIndex); await appendAudit( SecretAuditEntry( timeLabel: _timeLabel(), action: 'Updated', provider: 'Gateway', - target: 'gateway_token', + target: _gatewaySecretTarget('gateway_token', profileIndex), module: 'Assistant', status: 'Success', ), ); } if (trimmedPassword.isNotEmpty) { - await _store.saveGatewayPassword(trimmedPassword); + await _store.saveGatewayPassword( + trimmedPassword, + profileIndex: profileIndex, + ); await appendAudit( SecretAuditEntry( timeLabel: _timeLabel(), action: 'Updated', provider: 'Gateway', - target: 'gateway_password', + target: _gatewaySecretTarget('gateway_password', profileIndex), module: 'Assistant', status: 'Success', ), @@ -103,30 +107,31 @@ class SettingsController extends ChangeNotifier { } Future clearGatewaySecrets({ + int? profileIndex, bool token = false, bool password = false, }) async { if (token) { - await _store.clearGatewayToken(); + await _store.clearGatewayToken(profileIndex: profileIndex); await appendAudit( SecretAuditEntry( timeLabel: _timeLabel(), action: 'Cleared', provider: 'Gateway', - target: 'gateway_token', + target: _gatewaySecretTarget('gateway_token', profileIndex), module: 'Assistant', status: 'Success', ), ); } if (password) { - await _store.clearGatewayPassword(); + await _store.clearGatewayPassword(profileIndex: profileIndex); await appendAudit( SecretAuditEntry( timeLabel: _timeLabel(), action: 'Cleared', provider: 'Gateway', - target: 'gateway_password', + target: _gatewaySecretTarget('gateway_password', profileIndex), module: 'Assistant', status: 'Success', ), @@ -136,14 +141,38 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } - Future loadGatewayToken() async { - return (await _store.loadGatewayToken())?.trim() ?? ''; + Future loadGatewayToken({int? profileIndex}) async { + return (await _store.loadGatewayToken( + profileIndex: profileIndex, + ))?.trim() ?? + ''; } - Future loadGatewayPassword() async { - return (await _store.loadGatewayPassword())?.trim() ?? ''; + Future loadGatewayPassword({int? profileIndex}) async { + return (await _store.loadGatewayPassword( + profileIndex: profileIndex, + ))?.trim() ?? + ''; } + bool hasStoredGatewayTokenForProfile(int profileIndex) => + _secureRefs.containsKey(SecretStore.gatewayTokenRefKey(profileIndex)) || + _secureRefs.containsKey('gateway_token'); + + bool hasStoredGatewayPasswordForProfile(int profileIndex) => + _secureRefs.containsKey( + SecretStore.gatewayPasswordRefKey(profileIndex), + ) || + _secureRefs.containsKey('gateway_password'); + + String? storedGatewayTokenMaskForProfile(int profileIndex) => + _secureRefs[SecretStore.gatewayTokenRefKey(profileIndex)] ?? + _secureRefs['gateway_token']; + + String? storedGatewayPasswordMaskForProfile(int profileIndex) => + _secureRefs[SecretStore.gatewayPasswordRefKey(profileIndex)] ?? + _secureRefs['gateway_password']; + Future saveOllamaCloudApiKey(String value) async { final trimmed = value.trim(); if (trimmed.isEmpty) { @@ -754,6 +783,13 @@ class SettingsController extends ChangeNotifier { final now = DateTime.now(); return '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; } + + String _gatewaySecretTarget(String base, int? profileIndex) { + if (profileIndex == null) { + return base; + } + return '$base.$profileIndex'; + } } class _AiGatewayResponseException implements Exception { diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart index 3a785965..eb786255 100644 --- a/lib/runtime/secret_store.dart +++ b/lib/runtime/secret_store.dart @@ -140,10 +140,14 @@ class SecretStore { const FlutterSecureStorage(), ); } catch (_) { - _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); + _secureStorage = FileSecureStorageClient( + () => _resolveFallbackDirectory(), + ); } } else { - _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); + _secureStorage = FileSecureStorageClient( + () => _resolveFallbackDirectory(), + ); } _initialized = true; } @@ -171,8 +175,19 @@ class SecretStore { if ((scopedValue ?? '').trim().isNotEmpty) { return scopedValue; } + return _readSecure(_legacyGatewayTokenKey); } - return _readSecure(_legacyGatewayTokenKey); + final legacyValue = await _readSecure(_legacyGatewayTokenKey); + if ((legacyValue ?? '').trim().isNotEmpty) { + return legacyValue; + } + for (final index in _gatewayProfileFallbackOrder) { + final scopedValue = await _readSecure(_gatewayTokenKeyForProfile(index)); + if ((scopedValue ?? '').trim().isNotEmpty) { + return scopedValue; + } + } + return null; } Future saveGatewayToken(String value, {int? profileIndex}) => @@ -183,12 +198,11 @@ class SecretStore { value, ); - Future clearGatewayToken({int? profileIndex}) => - _deleteSecure( - profileIndex == null - ? _legacyGatewayTokenKey - : _gatewayTokenKeyForProfile(profileIndex), - ); + Future clearGatewayToken({int? profileIndex}) => _deleteSecure( + profileIndex == null + ? _legacyGatewayTokenKey + : _gatewayTokenKeyForProfile(profileIndex), + ); Future loadGatewayPassword({int? profileIndex}) async { if (profileIndex != null) { @@ -198,8 +212,21 @@ class SecretStore { if ((scopedValue ?? '').trim().isNotEmpty) { return scopedValue; } + return _readSecure(_legacyGatewayPasswordKey); } - return _readSecure(_legacyGatewayPasswordKey); + final legacyValue = await _readSecure(_legacyGatewayPasswordKey); + if ((legacyValue ?? '').trim().isNotEmpty) { + return legacyValue; + } + for (final index in _gatewayProfileFallbackOrder) { + final scopedValue = await _readSecure( + _gatewayPasswordKeyForProfile(index), + ); + if ((scopedValue ?? '').trim().isNotEmpty) { + return scopedValue; + } + } + return null; } Future saveGatewayPassword(String value, {int? profileIndex}) => @@ -210,12 +237,11 @@ class SecretStore { value, ); - Future clearGatewayPassword({int? profileIndex}) => - _deleteSecure( - profileIndex == null - ? _legacyGatewayPasswordKey - : _gatewayPasswordKeyForProfile(profileIndex), - ); + Future clearGatewayPassword({int? profileIndex}) => _deleteSecure( + profileIndex == null + ? _legacyGatewayPasswordKey + : _gatewayPasswordKeyForProfile(profileIndex), + ); Future loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey); @@ -604,6 +630,14 @@ class SecretStore { static String _gatewayPasswordRefKey(int profileIndex) => 'gateway_password_$profileIndex'; + static const List _gatewayProfileFallbackOrder = [ + kGatewayRemoteProfileIndex, + kGatewayLocalProfileIndex, + 2, + 3, + 4, + ]; + static List _base64UrlDecode(String value) { final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); @@ -619,7 +653,9 @@ class SecretStore { if (promoted && _secureStorage != null) { return _secureStorage!; } - throw StateError('Durable secret storage unavailable: no persistent secure storage client.'); + throw StateError( + 'Durable secret storage unavailable: no persistent secure storage client.', + ); } Future _resolvePath(Future Function()? resolver) async { diff --git a/test/features/ai_gateway_page_suite.dart b/test/features/ai_gateway_page_suite.dart index 56eea123..e0585ae3 100644 --- a/test/features/ai_gateway_page_suite.dart +++ b/test/features/ai_gateway_page_suite.dart @@ -33,6 +33,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async {} diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index a25449b0..9cd3fcf8 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -827,6 +827,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 118e7cfc..01793bbf 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -505,6 +505,22 @@ void main() { await tester.tap(find.byKey(const ValueKey('llm-endpoint-add-button'))); await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const ValueKey('llm-endpoint-chip-0')), + matching: find.textContaining('主 LLM API'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('llm-endpoint-chip-1')), + matching: find.textContaining('Ollama 本地'), + ), + findsOneWidget, + ); + expect(find.text('连接源详情'), findsOneWidget); + expect(find.textContaining('自定义连接源'), findsNothing); expect(find.byKey(const ValueKey('llm-endpoint-chip-1')), findsOneWidget); expect( find.byKey(const ValueKey('llm-endpoint-panel-ollamaLocal')), diff --git a/test/runtime/agent_registry_suite.dart b/test/runtime/agent_registry_suite.dart index 829551da..8d5cfbe5 100644 --- a/test/runtime/agent_registry_suite.dart +++ b/test/runtime/agent_registry_suite.dart @@ -73,6 +73,7 @@ class MockGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async {} diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index fb45ac1b..2b1bd486 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -175,76 +175,73 @@ void main() { }, ); - test( - 'AppController falls back when LLM API ignores stream mode', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-ai-gateway-json-fallback-', - ); - final server = await _FakeAiGatewayServer.start( - responseMode: _AiGatewayResponseMode.json, - ); - addTearDown(() async { - await server.close(); - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); + test('AppController falls back when LLM API ignores stream mode', () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-ai-gateway-json-fallback-', + ); + final server = await _FakeAiGatewayServer.start( + responseMode: _AiGatewayResponseMode.json, + ); + addTearDown(() async { + await server.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - ); - final controller = AppController( - store: store, - availableSingleAgentProvidersOverride: const [], - runtimeCoordinator: RuntimeCoordinator( - gateway: _FakeGatewayRuntime(store: store), - codex: _FakeCodexRuntime(), + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [], + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + singleAgentRunner: _FallbackOnlySingleAgentRunner(), + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + await controller.settingsController.saveAiGatewayApiKey('live-key'); + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: server.baseUrl, + availableModels: const ['moonshotai/kimi-k2.5'], + selectedModels: const ['moonshotai/kimi-k2.5'], ), - singleAgentRunner: _FallbackOnlySingleAgentRunner(), - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - await controller.settingsController.saveAiGatewayApiKey('live-key'); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUrl, - availableModels: const ['moonshotai/kimi-k2.5'], - selectedModels: const ['moonshotai/kimi-k2.5'], - ), - defaultModel: 'moonshotai/kimi-k2.5', - multiAgent: controller.settings.multiAgent.copyWith( - autoSync: false, - mountTargets: _withAvailableMountTargets( - controller.settings.multiAgent.mountTargets, - const [], - ), + defaultModel: 'moonshotai/kimi-k2.5', + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + mountTargets: _withAvailableMountTargets( + controller.settings.multiAgent.mountTargets, + const [], ), ), - refreshAfterSave: false, - ); - await controller.setAssistantExecutionTarget( - AssistantExecutionTarget.singleAgent, - ); + ), + refreshAfterSave: false, + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); - await controller.sendChatMessage('你好', thinking: 'low'); + await controller.sendChatMessage('你好', thinking: 'low'); - await _waitFor( - () => controller.chatMessages.any( - (message) => - message.role == 'assistant' && message.text == 'FIRST_REPLY', - ), - ); + await _waitFor( + () => controller.chatMessages.any( + (message) => + message.role == 'assistant' && message.text == 'FIRST_REPLY', + ), + ); - expect(server.requests.single['stream'], isTrue); - expect(controller.chatMessages.last.pending, isFalse); - }, - ); + expect(server.requests.single['stream'], isTrue); + expect(controller.chatMessages.last.pending, isFalse); + }); test( 'AppController abortRun stops Single Agent streaming requests', @@ -380,7 +377,8 @@ void main() { expect(runner.lastRequest?.provider, SingleAgentProvider.codex); expect( controller.chatMessages.any( - (message) => message.role == 'assistant' && message.text == 'CODEX_REPLY', + (message) => + message.role == 'assistant' && message.text == 'CODEX_REPLY', ), isTrue, ); @@ -569,6 +567,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { diff --git a/test/runtime/app_controller_codex_bridge_suite.dart b/test/runtime/app_controller_codex_bridge_suite.dart index bab70dc6..4e938131 100644 --- a/test/runtime/app_controller_codex_bridge_suite.dart +++ b/test/runtime/app_controller_codex_bridge_suite.dart @@ -56,6 +56,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { @@ -130,181 +131,168 @@ class _FakeCodexRuntime extends CodexRuntime { } void main() { - group( - 'Manual Codex bridge validation', - () { - test( - 'AppController enables external Codex bridge and registers to gateway', - () async { - SharedPreferences.setMockInitialValues({}); - final store = createIsolatedTestStore(); - final gateway = _FakeGatewayRuntime(connected: true); - final codex = _FakeCodexRuntime(); - final coordinator = RuntimeCoordinator( - gateway: gateway, - codex: codex, - ); - final controller = AppController( - store: store, - runtimeCoordinator: coordinator, - ); - addTearDown(controller.dispose); + group('Manual Codex bridge validation', () { + test( + 'AppController enables external Codex bridge and registers to gateway', + () async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(); + final gateway = _FakeGatewayRuntime(connected: true); + final codex = _FakeCodexRuntime(); + final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex); + final controller = AppController( + store: store, + runtimeCoordinator: coordinator, + ); + addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); + await _waitFor(() => !controller.initializing); - final tempDir = await Directory.systemTemp.createTemp('codex-bridge-'); - addTearDown(() async { - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - }); - final codexBinary = File('${tempDir.path}/codex'); - await codexBinary.writeAsString('#!/bin/sh\nexit 0\n'); + final tempDir = await Directory.systemTemp.createTemp('codex-bridge-'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + final codexBinary = File('${tempDir.path}/codex'); + await codexBinary.writeAsString('#!/bin/sh\nexit 0\n'); - await controller.settingsController.saveAiGatewayApiKey( - 'bridge-secret', - ); - await controller.saveSettings( - controller.settings.copyWith( - workspacePath: tempDir.path, - codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, - codexCliPath: codexBinary.path, - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: 'https://gateway.example.com', - ), + await controller.settingsController.saveAiGatewayApiKey( + 'bridge-secret', + ); + await controller.saveSettings( + controller.settings.copyWith( + workspacePath: tempDir.path, + codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, + codexCliPath: codexBinary.path, + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: 'https://gateway.example.com', ), - ); + ), + ); - await controller.enableCodexBridge(); + await controller.enableCodexBridge(); - expect(controller.isCodexBridgeEnabled, isTrue); - expect( - controller.codexCooperationState, - CodexCooperationState.registered, - ); - expect(codex.startCalled, isTrue); - expect(codex.startedCodexPath, codexBinary.path); - expect(codex.startedCwd, tempDir.path); + expect(controller.isCodexBridgeEnabled, isTrue); + expect( + controller.codexCooperationState, + CodexCooperationState.registered, + ); + expect(codex.startCalled, isTrue); + expect(codex.startedCodexPath, codexBinary.path); + expect(codex.startedCwd, tempDir.path); - final registrationCall = gateway.requests.firstWhere( + final registrationCall = gateway.requests.firstWhere( + (request) => request['method'] == 'agent/register', + ); + final params = registrationCall['params'] as Map; + expect(params['transport'], 'stdio-bridge'); + expect(params['metadata'], containsPair('providerId', 'codex')); + expect(params['metadata'], containsPair('runtimeMode', 'externalCli')); + expect( + (params['metadata']['node'] as Map)['kind'], + 'app-mediated-cooperative-node', + ); + }, + ); + + test( + 'AppController keeps bridge running when gateway registration is unavailable', + () async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(); + final gateway = _FakeGatewayRuntime(connected: false); + final codex = _FakeCodexRuntime(); + final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex); + final controller = AppController( + store: store, + runtimeCoordinator: coordinator, + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + + final tempDir = await Directory.systemTemp.createTemp( + 'codex-bridge-offline-', + ); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + final codexBinary = File('${tempDir.path}/codex'); + await codexBinary.writeAsString('#!/bin/sh\nexit 0\n'); + + await controller.saveSettings( + controller.settings.copyWith( + codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, + codexCliPath: codexBinary.path, + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: 'https://gateway.example.com', + ), + ), + ); + + await controller.enableCodexBridge(); + + expect(controller.isCodexBridgeEnabled, isTrue); + expect( + controller.codexCooperationState, + CodexCooperationState.bridgeOnly, + ); + expect(codex.startCalled, isTrue); + expect( + gateway.requests.where( (request) => request['method'] == 'agent/register', - ); - final params = registrationCall['params'] as Map; - expect(params['transport'], 'stdio-bridge'); - expect(params['metadata'], containsPair('providerId', 'codex')); - expect(params['metadata'], containsPair('runtimeMode', 'externalCli')); - expect( - (params['metadata']['node'] as Map)['kind'], - 'app-mediated-cooperative-node', - ); - }, - ); + ), + isEmpty, + ); + }, + ); - test( - 'AppController keeps bridge running when gateway registration is unavailable', - () async { - SharedPreferences.setMockInitialValues({}); - final store = createIsolatedTestStore(); - final gateway = _FakeGatewayRuntime(connected: false); - final codex = _FakeCodexRuntime(); - final coordinator = RuntimeCoordinator( - gateway: gateway, - codex: codex, - ); - final controller = AppController( - store: store, - runtimeCoordinator: coordinator, - ); - addTearDown(controller.dispose); + test( + 'AppController preserves built-in mode and does not require external codex binary', + () async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(); + final gateway = _FakeGatewayRuntime(connected: false); + final codex = _FakeCodexRuntime(); + final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex); + final controller = AppController( + store: store, + runtimeCoordinator: coordinator, + ); + addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); + await _waitFor(() => !controller.initializing); - final tempDir = await Directory.systemTemp.createTemp( - 'codex-bridge-offline-', - ); - addTearDown(() async { - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - }); - final codexBinary = File('${tempDir.path}/codex'); - await codexBinary.writeAsString('#!/bin/sh\nexit 0\n'); - - await controller.saveSettings( - controller.settings.copyWith( - codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli, - codexCliPath: codexBinary.path, - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: 'https://gateway.example.com', - ), + await controller.saveSettings( + controller.settings.copyWith( + codeAgentRuntimeMode: CodeAgentRuntimeMode.builtIn, + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: 'https://gateway.example.com', ), - ); + ), + ); - await controller.enableCodexBridge(); + expect( + controller.settings.codeAgentRuntimeMode, + CodeAgentRuntimeMode.builtIn, + ); + expect(controller.codexRuntimeWarning, isNotNull); - expect(controller.isCodexBridgeEnabled, isTrue); - expect( - controller.codexCooperationState, - CodexCooperationState.bridgeOnly, - ); - expect(codex.startCalled, isTrue); - expect( - gateway.requests.where( - (request) => request['method'] == 'agent/register', - ), - isEmpty, - ); - }, - ); + await controller.enableCodexBridge(); - test( - 'AppController preserves built-in mode and does not require external codex binary', - () async { - SharedPreferences.setMockInitialValues({}); - final store = createIsolatedTestStore(); - final gateway = _FakeGatewayRuntime(connected: false); - final codex = _FakeCodexRuntime(); - final coordinator = RuntimeCoordinator( - gateway: gateway, - codex: codex, - ); - final controller = AppController( - store: store, - runtimeCoordinator: coordinator, - ); - addTearDown(controller.dispose); - - await _waitFor(() => !controller.initializing); - - await controller.saveSettings( - controller.settings.copyWith( - codeAgentRuntimeMode: CodeAgentRuntimeMode.builtIn, - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: 'https://gateway.example.com', - ), - ), - ); - - expect( - controller.settings.codeAgentRuntimeMode, - CodeAgentRuntimeMode.builtIn, - ); - expect(controller.codexRuntimeWarning, isNotNull); - - await controller.enableCodexBridge(); - - expect(controller.isCodexBridgeEnabled, isTrue); - expect( - controller.codexCooperationState, - CodexCooperationState.bridgeOnly, - ); - expect(codex.startCalled, isFalse); - expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn); - }, - ); - }, - skip: _manualCodexBridgeSkipReason, - ); + expect(controller.isCodexBridgeEnabled, isTrue); + expect( + controller.codexCooperationState, + CodexCooperationState.bridgeOnly, + ); + expect(codex.startCalled, isFalse); + expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn); + }, + ); + }, skip: _manualCodexBridgeSkipReason); } Future _waitFor( diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index e2506a50..c1c32eed 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -38,6 +38,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { diff --git a/test/runtime/app_controller_gateway_token_state_suite.dart b/test/runtime/app_controller_gateway_token_state_suite.dart index 76be616a..9ecb62d5 100644 --- a/test/runtime/app_controller_gateway_token_state_suite.dart +++ b/test/runtime/app_controller_gateway_token_state_suite.dart @@ -5,12 +5,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; +import '../test_support.dart'; + void main() { test( 'AppController tracks stored shared-token mask and clear action', () async { SharedPreferences.setMockInitialValues({}); - final controller = AppController(); + final controller = AppController(store: createIsolatedTestStore()); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); @@ -32,6 +34,33 @@ void main() { expect(controller.storedGatewayTokenMask, isNull); }, ); + + test( + 'AppController keeps gateway token masks independent per profile slot', + () async { + SharedPreferences.setMockInitialValues({}); + final controller = AppController(store: createIsolatedTestStore()); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + + await controller.settingsController.saveGatewaySecrets( + profileIndex: 0, + token: 'local-secret', + password: '', + ); + await controller.settingsController.saveGatewaySecrets( + profileIndex: 1, + token: 'remote-secret', + password: '', + ); + + expect(controller.hasStoredGatewayTokenForProfile(0), isTrue); + expect(controller.hasStoredGatewayTokenForProfile(1), isTrue); + expect(controller.storedGatewayTokenMaskForProfile(0), 'loc••••ret'); + expect(controller.storedGatewayTokenMaskForProfile(1), 'rem••••ret'); + }, + ); } Future _waitFor(bool Function() predicate) async { diff --git a/test/runtime/code_agent_node_orchestrator_suite.dart b/test/runtime/code_agent_node_orchestrator_suite.dart index e8bf6a6f..cd606f87 100644 --- a/test/runtime/code_agent_node_orchestrator_suite.dart +++ b/test/runtime/code_agent_node_orchestrator_suite.dart @@ -23,6 +23,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async {} diff --git a/test/runtime/codex_integration_suite.dart b/test/runtime/codex_integration_suite.dart index 81847eb8..2c1dca68 100644 --- a/test/runtime/codex_integration_suite.dart +++ b/test/runtime/codex_integration_suite.dart @@ -59,6 +59,7 @@ class MockGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { diff --git a/test/runtime/mode_switcher_suite.dart b/test/runtime/mode_switcher_suite.dart index f1a3be92..c90113c9 100644 --- a/test/runtime/mode_switcher_suite.dart +++ b/test/runtime/mode_switcher_suite.dart @@ -81,6 +81,7 @@ class MockGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { diff --git a/test/runtime/runtime_coordinator_suite.dart b/test/runtime/runtime_coordinator_suite.dart index f03b167a..6a0d89c7 100644 --- a/test/runtime/runtime_coordinator_suite.dart +++ b/test/runtime/runtime_coordinator_suite.dart @@ -41,6 +41,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { @override Future connectProfile( GatewayConnectionProfile profile, { + int? profileIndex, String authTokenOverride = '', String authPasswordOverride = '', }) async { diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index 49929acc..3e577b2e 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -87,6 +87,69 @@ void main() { }, ); + test( + 'SecureConfigStore keeps gateway secrets isolated per profile slot', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-profiles-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final store = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + + await store.saveGatewayToken( + 'local-token', + profileIndex: kGatewayLocalProfileIndex, + ); + await store.saveGatewayToken( + 'remote-token', + profileIndex: kGatewayRemoteProfileIndex, + ); + await store.saveGatewayPassword( + 'custom-password', + profileIndex: kGatewayCustomProfileStartIndex, + ); + + final secureRefs = await store.loadSecureRefs(); + + expect( + await store.loadGatewayToken(profileIndex: kGatewayLocalProfileIndex), + 'local-token', + ); + expect( + await store.loadGatewayToken(profileIndex: kGatewayRemoteProfileIndex), + 'remote-token', + ); + expect( + await store.loadGatewayPassword( + profileIndex: kGatewayCustomProfileStartIndex, + ), + 'custom-password', + ); + expect( + secureRefs['gateway_token_$kGatewayLocalProfileIndex'], + 'local-token', + ); + expect( + secureRefs['gateway_token_$kGatewayRemoteProfileIndex'], + 'remote-token', + ); + expect( + secureRefs['gateway_password_$kGatewayCustomProfileStartIndex'], + 'custom-password', + ); + expect(await store.loadGatewayToken(), 'remote-token'); + }, + ); + test( 'SecureConfigStore persists sqlite-backed settings across instances', () async { @@ -191,20 +254,29 @@ void main() { await tempDirectory.delete(recursive: true); } }); - final existingSecretsDirectory = Directory('${tempDirectory.path}/secrets'); + final existingSecretsDirectory = Directory( + '${tempDirectory.path}/secrets', + ); await existingSecretsDirectory.create(recursive: true); final explicitSettingsPath = '${tempDirectory.path}/settings/${SettingsStore.databaseFileName}'; final store = SecureConfigStore( databasePathResolver: () async => explicitSettingsPath, - fallbackDirectoryPathResolver: () async => existingSecretsDirectory.path, + fallbackDirectoryPathResolver: () async => + existingSecretsDirectory.path, ); final snapshot = await store.loadSettingsSnapshot(); - expect(snapshot.accountUsername, SettingsSnapshot.defaults().accountUsername); - expect(await Directory('${tempDirectory.path}/settings').exists(), isTrue); + expect( + snapshot.accountUsername, + SettingsSnapshot.defaults().accountUsername, + ); + expect( + await Directory('${tempDirectory.path}/settings').exists(), + isTrue, + ); expect(await File(explicitSettingsPath).exists(), isTrue); }, );