diff --git a/docs/reports/2026-03-30-installed-skill-e2e-harness.md b/docs/reports/2026-03-30-installed-skill-e2e-harness.md new file mode 100644 index 00000000..7efb3d73 --- /dev/null +++ b/docs/reports/2026-03-30-installed-skill-e2e-harness.md @@ -0,0 +1,53 @@ +# 2026-03-30 Installed-Skill E2E Harness + +## Change Summary + +Added a reusable installed-skill E2E harness for the assistant flow that exercises the common document skill paths for `pptx`, `docx`, `xlsx`, and `pdf`. + +The harness is controller-driven and deterministic. It verifies: + +- skill discoverability from installed shared roots +- skill binding through session selection +- prompt handoff into `sendChatMessage` +- output capture through the assistant artifact snapshot + +The UI shell was left unchanged. + +## Test Coverage + +- `pptx` +- `docx` +- `xlsx` +- `pdf` + +Deferred or skipped coverage is recorded explicitly for the media skill set: + +- `image-cog` +- `wan-image-video-generation-editting` +- `video-translator` +- `image-resizer` + +## Test Commands And Results + +| Command | Result | Notes | +| --- | --- | --- | +| `flutter test test/features/assistant_page_installed_skill_e2e_test.dart` | Passed | 4 passing cases, 1 skipped deferred-media case | + +## Verified Behaviors + +- Installed skills are discovered from a reusable shared-root seed. +- Each case binds one installed skill into the current assistant session. +- The selected prompt is handed off to the controller path that would normally submit the message. +- A deterministic artifact is written and then surfaced through the assistant artifact snapshot. + +## Residual Gaps + +- The media skill packs are not installed in this test environment, so their end-to-end flow remains deferred. +- This harness is controller-level, so it does not revalidate visual shell details beyond the existing assistant test surface. +- The artifact check uses the local thread workspace path and does not cover remote-workspace artifact browsing. + +## Files + +- `test/features/assistant_page_suite_support.dart` +- `test/features/assistant_page_installed_skill_e2e_suite.dart` +- `test/features/assistant_page_installed_skill_e2e_test.dart` diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 8cac8b65..6c7d5643 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -16,6 +16,7 @@ import '../runtime/go_core.dart'; import '../runtime/runtime_bootstrap.dart'; import '../runtime/desktop_platform_service.dart'; import '../runtime/gateway_runtime.dart'; +import '../runtime/account_runtime_client.dart'; import '../runtime/runtime_controllers.dart'; import '../runtime/runtime_models.dart'; import '../runtime/secure_config_store.dart'; @@ -121,6 +122,7 @@ class AppController extends ChangeNotifier { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, SkillDirectoryAccessService? skillDirectoryAccessService, + AccountRuntimeClient Function(String baseUrl)? accountClientFactory, List? singleAgentSharedSkillScanRootOverrides, List? availableSingleAgentProvidersOverride, ArisBundleRepository? arisBundleRepository, @@ -153,7 +155,10 @@ class AppController extends ChangeNotifier { codeAgentBridgeRegistryInternal = AgentRegistry( runtimeCoordinatorInternal.gateway, ); - settingsControllerInternal = SettingsController(storeInternal); + settingsControllerInternal = SettingsController( + storeInternal, + accountClientFactory: accountClientFactory, + ); agentsControllerInternal = GatewayAgentsController( runtimeCoordinatorInternal.gateway, ); @@ -499,9 +504,10 @@ class AppController extends ChangeNotifier { hasStoredGatewayTokenForProfile(activeGatewayProfileIndexInternal); String? get storedGatewayTokenMask => storedGatewayTokenMaskForProfile(activeGatewayProfileIndexInternal); - String get aiGatewayUrl => settings.aiGateway.baseUrl.trim(); + String get aiGatewayUrl => + settingsControllerInternal.effectiveAiGatewayBaseUrl.trim(); bool get hasStoredAiGatewayApiKey => - settingsControllerInternal.secureRefs.containsKey('ai_gateway_api_key'); + settingsControllerInternal.hasEffectiveAiGatewayApiKey; bool get isSingleAgentMode => currentAssistantExecutionTarget == AssistantExecutionTarget.singleAgent; bool get isCodexBridgeBusy => isCodexBridgeBusyInternal; @@ -605,18 +611,16 @@ class AppController extends ChangeNotifier { } List get aiGatewayConversationModelChoices { + final availableModels = + settingsControllerInternal.effectiveAiGatewayAvailableModels; final selected = settings.aiGateway.selectedModels .map((item) => item.trim()) - .where( - (item) => - item.isNotEmpty && - settings.aiGateway.availableModels.contains(item), - ) + .where((item) => item.isNotEmpty && availableModels.contains(item)) .toList(growable: false); if (selected.isNotEmpty) { return selected; } - final available = settings.aiGateway.availableModels + final available = availableModels .map((item) => item.trim()) .where((item) => item.isNotEmpty) .toList(growable: false); diff --git a/lib/app/app_controller_desktop_gateway.dart b/lib/app/app_controller_desktop_gateway.dart index 6a52c709..ac264e64 100644 --- a/lib/app/app_controller_desktop_gateway.dart +++ b/lib/app/app_controller_desktop_gateway.dart @@ -194,10 +194,20 @@ extension AppControllerDesktopGateway on AppController { String authTokenOverride = '', String authPasswordOverride = '', }) async { + final resolvedProfileIndex = + profileIndex ?? + gatewayProfileIndexForExecutionTargetInternal( + assistantExecutionTargetForModeInternal(profile.mode), + ); + final effectiveAuthTokenOverride = authTokenOverride.trim().isNotEmpty + ? authTokenOverride.trim() + : await settingsControllerInternal.loadEffectiveGatewayToken( + profileIndex: resolvedProfileIndex, + ); await runtimeInternal.connectProfile( profile, - profileIndex: profileIndex, - authTokenOverride: authTokenOverride, + profileIndex: resolvedProfileIndex, + authTokenOverride: effectiveAuthTokenOverride, authPasswordOverride: authPasswordOverride, ); await refreshGatewayHealth(); diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index 9c94bf53..a3fe4eaa 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -482,6 +482,11 @@ extension AppControllerDesktopSettingsRuntime on AppController { return; } } + try { + await settingsControllerInternal.restoreAccountSession(); + } catch (_) { + // Keep initialization resilient when remote account restore fails. + } restoreAssistantThreadsInternal(storedAssistantThreads); await restoreSharedSingleAgentLocalSkillsCacheInternal(); if (disposedInternal) { diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index 07685167..709a58a5 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -319,9 +319,7 @@ extension AppControllerDesktopSingleAgent on AppController { return; } - final baseUrl = normalizeAiGatewayBaseUrlInternal( - settings.aiGateway.baseUrl, - ); + final baseUrl = normalizeAiGatewayBaseUrlInternal(aiGatewayUrl); if (baseUrl == null) { appendAssistantThreadMessageInternal( sessionKey, diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index d46a2e99..a2f4d917 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -128,7 +128,8 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - final existing = assistantThreadRecordsInternal[normalizedSessionKey] + final existing = + assistantThreadRecordsInternal[normalizedSessionKey] ?.workspaceBinding .workspacePath .trim() ?? @@ -382,7 +383,7 @@ extension AppControllerDesktopThreadSessions on AppController { final fallbackReady = singleAgentUsesAiChatFallbackForSession( normalizedSessionKey, ); - final host = aiGatewayHostLabelInternal(settings.aiGateway.baseUrl); + final host = aiGatewayHostLabelInternal(aiGatewayUrl); final providerReady = resolvedProvider != null; final detail = providerReady ? joinConnectionPartsInternal([resolvedProvider.label, model]) diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index 4c79fd7f..6b2fe424 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -51,7 +51,7 @@ import 'app_controller_desktop_runtime_helpers.dart'; Future loadAiGatewayApiKeyThreadSessionInternal( AppController controller, ) async { - return (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; + return controller.settingsControllerInternal.loadEffectiveAiGatewayApiKey(); } Future saveMultiAgentConfigThreadSessionInternal( diff --git a/lib/features/account/account_page.dart b/lib/features/account/account_page.dart index 7b30c938..effaa5f7 100644 --- a/lib/features/account/account_page.dart +++ b/lib/features/account/account_page.dart @@ -4,6 +4,7 @@ import '../../app/app_controller.dart'; import '../../app/app_metadata.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; +import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_models.dart'; import '../../widgets/section_tabs.dart'; import '../../widgets/surface_card.dart'; @@ -22,6 +23,8 @@ class _AccountPageState extends State { AccountTab _tab = AccountTab.profile; late final TextEditingController _accountBaseUrlController; late final TextEditingController _accountUsernameController; + late final TextEditingController _accountPasswordController; + late final TextEditingController _accountMfaCodeController; late final TextEditingController _accountWorkspaceController; String _lastSavedAccountBaseUrl = ''; String _lastSavedAccountUsername = ''; @@ -40,6 +43,8 @@ class _AccountPageState extends State { _accountUsernameController = TextEditingController( text: _lastSavedAccountUsername, ); + _accountPasswordController = TextEditingController(); + _accountMfaCodeController = TextEditingController(); _accountWorkspaceController = TextEditingController( text: _lastSavedAccountWorkspace, ); @@ -49,6 +54,8 @@ class _AccountPageState extends State { void dispose() { _accountBaseUrlController.dispose(); _accountUsernameController.dispose(); + _accountPasswordController.dispose(); + _accountMfaCodeController.dispose(); _accountWorkspaceController.dispose(); super.dispose(); } @@ -89,14 +96,65 @@ class _AccountPageState extends State { _lastSavedAccountWorkspace = nextSettings.accountWorkspace; } + Future _loginAccount(SettingsSnapshot settings) async { + await _saveProfile(settings); + await widget.controller.settingsController.loginAccount( + baseUrl: _accountBaseUrlController.text.trim(), + identifier: _accountUsernameController.text.trim(), + password: _accountPasswordController.text, + ); + } + + Future _verifyAccountMfa() async { + await widget.controller.settingsController.verifyAccountMfa( + baseUrl: _accountBaseUrlController.text.trim(), + code: _accountMfaCodeController.text.trim(), + ); + } + + Future _syncAccountManagedSecrets(SettingsSnapshot settings) async { + await _saveProfile(settings); + await widget.controller.settingsController.syncAccountManagedSecrets( + baseUrl: _accountBaseUrlController.text.trim(), + ); + } + + Future _logoutAccount() async { + await widget.controller.settingsController.logoutAccount(); + _accountPasswordController.clear(); + _accountMfaCodeController.clear(); + } + @override Widget build(BuildContext context) { final controller = widget.controller; - final settings = controller.settings; - _syncControllers(settings); return AnimatedBuilder( - animation: controller, + animation: Listenable.merge([ + controller, + controller.settingsController, + ]), builder: (context, _) { + final settings = controller.settings; + final settingsController = controller.settingsController; + _syncControllers(settings); + final accountSession = settingsController.accountSession; + final accountProfile = settingsController.accountProfile; + final accountBusy = settingsController.accountBusy; + final accountSignedIn = settingsController.accountSignedIn; + final accountMfaRequired = settingsController.accountMfaRequired; + final signedInLabel = accountSession?.email.trim().isNotEmpty == true + ? accountSession!.email.trim() + : accountSession?.name.trim().isNotEmpty == true + ? accountSession!.name.trim() + : appText('当前账号', 'Current account'); + final sessionStatusText = accountSignedIn + ? appText('已登录:$signedInLabel', 'Signed in: $signedInLabel') + : accountMfaRequired + ? appText('等待双重验证', 'Waiting for MFA verification') + : appText('未登录', 'Signed out'); + final syncStatusText = accountProfile == null + ? appText('idle · 尚未同步远程配置', 'idle · Remote config not synced yet') + : '${accountProfile.syncState} · ${accountProfile.syncMessage}'; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), child: Column( @@ -154,6 +212,18 @@ class _AccountPageState extends State { ), ), const SizedBox(height: 16), + Text( + sessionStatusText, + key: const ValueKey('account-session-status'), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 6), + Text( + syncStatusText, + key: const ValueKey('account-sync-status'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), TextFormField( key: const ValueKey('account-base-url-field'), controller: _accountBaseUrlController, @@ -172,6 +242,63 @@ class _AccountPageState extends State { onFieldSubmitted: (_) => _saveProfile(settings), ), const SizedBox(height: 16), + TextFormField( + key: const ValueKey('account-password-field'), + controller: _accountPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('密码', 'Password'), + ), + onFieldSubmitted: (_) => _loginAccount(settings), + ), + if (accountMfaRequired) ...[ + const SizedBox(height: 14), + TextFormField( + key: const ValueKey('account-mfa-code-field'), + controller: _accountMfaCodeController, + decoration: InputDecoration( + labelText: appText('双重验证代码', 'MFA Code'), + ), + onFieldSubmitted: (_) => _verifyAccountMfa(), + ), + ], + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton( + key: const ValueKey('account-login-button'), + onPressed: accountBusy + ? null + : () => _loginAccount(settings), + child: Text(appText('登录', 'Log In')), + ), + if (accountMfaRequired) + FilledButton.tonal( + key: const ValueKey('account-verify-mfa-button'), + onPressed: accountBusy ? null : _verifyAccountMfa, + child: Text(appText('验证 MFA', 'Verify MFA')), + ), + if (accountSignedIn) + FilledButton.tonal( + key: const ValueKey('account-sync-button'), + onPressed: accountBusy + ? null + : () => _syncAccountManagedSecrets(settings), + child: Text( + appText('同步远程配置', 'Sync Remote Config'), + ), + ), + if (accountSignedIn) + FilledButton.tonal( + key: const ValueKey('account-logout-button'), + onPressed: accountBusy ? null : _logoutAccount, + child: Text(appText('退出登录', 'Log Out')), + ), + ], + ), + const SizedBox(height: 16), Align( alignment: Alignment.centerLeft, child: FilledButton( diff --git a/lib/runtime/account_runtime_client.dart b/lib/runtime/account_runtime_client.dart new file mode 100644 index 00000000..8e5a9238 --- /dev/null +++ b/lib/runtime/account_runtime_client.dart @@ -0,0 +1,230 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'runtime_models.dart'; + +class AccountRuntimeException implements Exception { + const AccountRuntimeException({ + required this.statusCode, + required this.errorCode, + required this.message, + }); + + final int statusCode; + final String errorCode; + final String message; + + @override + String toString() { + return 'AccountRuntimeException($statusCode, $errorCode, $message)'; + } +} + +class AccountRuntimeClient { + AccountRuntimeClient({required String baseUrl}) + : baseUrl = _normalizeBaseUrl(baseUrl); + + final String baseUrl; + + static String _normalizeBaseUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return ''; + } + return trimmed.endsWith('/') ? trimmed.substring(0, trimmed.length - 1) : trimmed; + } + + Future> login({ + required String identifier, + required String password, + }) { + return _requestJson( + method: 'POST', + path: '/api/auth/login', + body: { + 'identifier': identifier.trim(), + 'password': password, + }, + ); + } + + Future> verifyMfa({ + required String mfaToken, + required String code, + }) { + return _requestJson( + method: 'POST', + path: '/api/auth/mfa/verify', + body: { + 'mfaToken': mfaToken.trim(), + 'code': code.trim(), + }, + ); + } + + Future loadSession({required String token}) async { + final payload = await _requestJson( + method: 'GET', + path: '/api/auth/session', + bearerToken: token, + ); + final user = _asMap(payload['user']); + 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']); + return 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), + ); + } + + Future readVaultSecretValue({ + required String vaultUrl, + required String namespace, + required String vaultToken, + required String secretPath, + required String secretKey, + }) async { + final uri = _vaultReadUri(vaultUrl, secretPath); + final payload = await _requestJson( + method: 'GET', + uriOverride: uri, + rawHeaders: { + if (namespace.trim().isNotEmpty) 'X-Vault-Namespace': namespace.trim(), + if (vaultToken.trim().isNotEmpty) 'X-Vault-Token': vaultToken.trim(), + }, + ); + final data = _asMap(payload['data']); + final secretData = _asMap(data['data']); + return _stringValue(secretData[secretKey]); + } + + AccountSessionSummary _accountSessionSummaryFromUserJson( + Map user, + ) { + return AccountSessionSummary( + userId: _stringValue(user['id']), + email: _stringValue(user['email']), + name: _stringValue(user['name']).isNotEmpty + ? _stringValue(user['name']) + : _stringValue(user['username']), + role: _stringValue(user['role']), + mfaEnabled: user['mfaEnabled'] as bool? ?? false, + ); + } + + 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'^/+|/+$'), ''); + final segments = trimmedPath + .split('/') + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + if (segments.length < 2) { + throw const AccountRuntimeException( + statusCode: 400, + errorCode: 'invalid_vault_path', + message: 'invalid vault path', + ); + } + final mount = segments.first; + final path = segments.skip(1).toList(growable: false); + return base.replace(pathSegments: ['v1', mount, 'data', ...path]); + } + + Future> _requestJson({ + required String method, + String path = '', + Uri? uriOverride, + String bearerToken = '', + Map? body, + Map rawHeaders = const {}, + }) async { + final uri = uriOverride ?? Uri.parse('$baseUrl$path'); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); + try { + final request = await switch (method.toUpperCase()) { + 'POST' => client.postUrl(uri), + 'GET' => client.getUrl(uri), + _ => throw UnsupportedError('Unsupported method $method'), + }; + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + if (bearerToken.trim().isNotEmpty) { + request.headers.set( + HttpHeaders.authorizationHeader, + 'Bearer ${bearerToken.trim()}', + ); + } + for (final entry in rawHeaders.entries) { + request.headers.set(entry.key, entry.value); + } + if (body != null) { + request.headers.contentType = ContentType.json; + request.write(jsonEncode(body)); + } + final response = await request.close().timeout(const Duration(seconds: 6)); + final rawBody = await utf8.decoder.bind(response).join(); + final decoded = rawBody.trim().isEmpty + ? const {} + : _asMap(jsonDecode(rawBody)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw AccountRuntimeException( + statusCode: response.statusCode, + errorCode: _stringValue(decoded['error']).isNotEmpty + ? _stringValue(decoded['error']) + : 'request_failed', + message: _stringValue(decoded['message']).isNotEmpty + ? _stringValue(decoded['message']) + : rawBody.trim(), + ); + } + return decoded; + } finally { + client.close(force: true); + } + } + + static Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; + } + + static String _stringValue(Object? value) { + return value?.toString().trim() ?? ''; + } +} diff --git a/lib/runtime/runtime_controllers_settings.dart b/lib/runtime/runtime_controllers_settings.dart index 2cc76046..d5034336 100644 --- a/lib/runtime/runtime_controllers_settings.dart +++ b/lib/runtime/runtime_controllers_settings.dart @@ -4,18 +4,27 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'account_runtime_client.dart'; import 'gateway_runtime.dart'; import 'runtime_models.dart'; import 'secure_config_store.dart'; import 'runtime_controllers_gateway.dart'; import 'runtime_controllers_entities.dart'; import 'runtime_controllers_derived_tasks.dart'; +import 'runtime_controllers_settings_account_impl.dart'; import 'runtime_controllers_settings_connectivity_impl.dart'; +part 'runtime_controllers_settings_account.dart'; + class SettingsController extends ChangeNotifier { - SettingsController(this.storeInternal); + SettingsController( + this.storeInternal, { + AccountRuntimeClient Function(String baseUrl)? accountClientFactory, + }) : accountClientFactoryInternal = accountClientFactory; final SecureConfigStore storeInternal; + final AccountRuntimeClient Function(String baseUrl)? + accountClientFactoryInternal; bool disposedInternal = false; final List> settingsWatchSubscriptionsInternal = >[]; @@ -30,6 +39,13 @@ class SettingsController extends ChangeNotifier { String ollamaStatusInternal = 'Idle'; String vaultStatusInternal = 'Idle'; String aiGatewayStatusInternal = 'Idle'; + String accountSessionTokenInternal = ''; + AccountSessionSummary? accountSessionInternal; + AccountRemoteProfile? accountProfileInternal; + bool accountBusyInternal = false; + String accountStatusInternal = 'Signed out'; + String pendingAccountMfaTicketInternal = ''; + String pendingAccountBaseUrlInternal = ''; SettingsSnapshot get snapshot => snapshotInternal; Map get secureRefs => secureRefsInternal; @@ -186,7 +202,12 @@ class SettingsController extends ChangeNotifier { secureRefsInternal.containsKey( SecretStore.gatewayTokenRefKey(profileIndex), ) || - secureRefsInternal.containsKey('gateway_token'); + secureRefsInternal.containsKey('gateway_token') || + (!snapshotInternal.accountLocalMode && + profileIndex == kGatewayRemoteProfileIndex && + secureRefsInternal.containsKey( + kAccountManagedSecretTargetOpenclawGatewayToken, + )); bool hasStoredGatewayPasswordForProfile(int profileIndex) => secureRefsInternal.containsKey( @@ -196,7 +217,11 @@ class SettingsController extends ChangeNotifier { String? storedGatewayTokenMaskForProfile(int profileIndex) => secureRefsInternal[SecretStore.gatewayTokenRefKey(profileIndex)] ?? - secureRefsInternal['gateway_token']; + secureRefsInternal['gateway_token'] ?? + (!snapshotInternal.accountLocalMode && + profileIndex == kGatewayRemoteProfileIndex + ? secureRefsInternal[kAccountManagedSecretTargetOpenclawGatewayToken] + : null); String? storedGatewayPasswordMaskForProfile(int profileIndex) => secureRefsInternal[SecretStore.gatewayPasswordRefKey(profileIndex)] ?? @@ -274,6 +299,12 @@ class SettingsController extends ChangeNotifier { return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; } + Future clearAiGatewayApiKey() async { + await storeInternal.clearAiGatewayApiKey(); + await reloadDerivedStateInternal(); + notifyListeners(); + } + Future appendAudit(SecretAuditEntry entry) async { await storeInternal.appendAudit(entry); auditTrailInternal = await storeInternal.loadAuditTrail(); @@ -335,71 +366,6 @@ class SettingsController extends ChangeNotifier { apiKeyOverride: apiKeyOverride, ); - List buildSecretReferences() { - final entries = [ - ...secureRefsInternal.entries.map( - (entry) => SecretReferenceEntry( - name: entry.key, - provider: providerNameForSecretInternal(entry.key), - module: moduleForSecretInternal(entry.key), - maskedValue: entry.value, - status: 'In Use', - ), - ), - SecretReferenceEntry( - name: snapshotInternal.aiGateway.name, - provider: 'LLM API', - module: 'Settings', - maskedValue: snapshotInternal.aiGateway.baseUrl.trim().isEmpty - ? 'Not set' - : snapshotInternal.aiGateway.baseUrl, - status: snapshotInternal.aiGateway.syncState, - ), - ]; - return entries; - } - - Future reloadDerivedStateInternal() async { - final refs = await storeInternal.loadSecureRefs(); - secureRefsInternal = { - for (final entry in refs.entries) - entry.key: SecureConfigStore.maskValue(entry.value), - }; - auditTrailInternal = await storeInternal.loadAuditTrail(); - } - - String providerNameForSecretInternal(String key) { - if (key.contains('vault')) { - return 'Vault'; - } - if (key.contains('ollama')) { - return 'Ollama Cloud'; - } - if (key.contains('ai_gateway')) { - return 'LLM API'; - } - if (key.contains('gateway')) { - return 'Gateway'; - } - return 'Local Store'; - } - - String moduleForSecretInternal(String key) { - if (key.contains('gateway')) { - return key.contains('device_token') ? 'Devices' : 'Assistant'; - } - if (key.contains('ollama')) { - return 'Settings'; - } - if (key.contains('ai_gateway')) { - return 'Settings'; - } - if (key.contains('vault')) { - return 'Secrets'; - } - return 'Workspace'; - } - Uri? normalizeAiGatewayBaseUrlInternal(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart new file mode 100644 index 00000000..fdf638c6 --- /dev/null +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -0,0 +1,192 @@ +part of 'runtime_controllers_settings.dart'; + +extension SettingsControllerAccountExtension on SettingsController { + AccountSessionSummary? get accountSession => accountSessionInternal; + AccountRemoteProfile? get accountProfile => accountProfileInternal; + bool get accountBusy => accountBusyInternal; + String get accountStatus => accountStatusInternal; + bool get accountSignedIn => + accountSessionTokenInternal.trim().isNotEmpty && + accountSessionInternal != null; + bool get accountMfaRequired => + pendingAccountMfaTicketInternal.trim().isNotEmpty && !accountSignedIn; + bool get hasEffectiveAiGatewayApiKey => + secureRefsInternal.containsKey('ai_gateway_api_key') || + (!snapshotInternal.accountLocalMode && + secureRefsInternal.containsKey( + kAccountManagedSecretTargetAIGatewayAccessToken, + )); + + String get effectiveAiGatewayBaseUrl { + final local = snapshotInternal.aiGateway.baseUrl.trim(); + if (local.isNotEmpty) { + return local; + } + if (snapshotInternal.accountLocalMode) { + return ''; + } + return accountProfileInternal?.apisixUrl.trim() ?? ''; + } + + List get effectiveAiGatewayAvailableModels { + final local = snapshotInternal.aiGateway.availableModels + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + if (local.isNotEmpty) { + return local; + } + if (snapshotInternal.accountLocalMode) { + return const []; + } + return accountProfileInternal?.aiGatewayAvailableModels ?? const []; + } + + AccountRuntimeClient buildAccountClient(String baseUrl) { + return accountClientFactoryInternal?.call(baseUrl) ?? + AccountRuntimeClient(baseUrl: baseUrl); + } + + Future loadEffectiveAiGatewayApiKey() async { + final localValue = await loadAiGatewayApiKey(); + if (localValue.trim().isNotEmpty) { + return localValue; + } + if (snapshotInternal.accountLocalMode) { + return ''; + } + return (await storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ))?.trim() ?? + ''; + } + + Future loadEffectiveGatewayToken({int? profileIndex}) async { + final localValue = await loadGatewayToken(profileIndex: profileIndex); + if (localValue.trim().isNotEmpty) { + return localValue; + } + if (snapshotInternal.accountLocalMode) { + return ''; + } + final resolvedIndex = profileIndex ?? kGatewayRemoteProfileIndex; + if (resolvedIndex != kGatewayRemoteProfileIndex) { + return ''; + } + return (await storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ))?.trim() ?? + ''; + } + + Future loginAccount({ + required String baseUrl, + required String identifier, + required String password, + }) => loginAccountSettingsInternal( + this, + baseUrl: baseUrl, + identifier: identifier, + password: password, + ); + + Future verifyAccountMfa({ + required String baseUrl, + required String code, + }) => verifyAccountMfaSettingsInternal(this, baseUrl: baseUrl, code: code); + + Future restoreAccountSession({String baseUrl = ''}) => + restoreAccountSessionSettingsInternal(this, baseUrl: baseUrl); + + Future syncAccountManagedSecrets({String baseUrl = ''}) => + syncAccountManagedSecretsSettingsInternal(this, baseUrl: baseUrl); + + Future logoutAccount() => logoutAccountSettingsInternal(this); + + List buildSecretReferences() { + final entries = [ + ...secureRefsInternal.entries.map( + (entry) => SecretReferenceEntry( + name: entry.key, + provider: providerNameForSecretInternal(entry.key), + module: moduleForSecretInternal(entry.key), + maskedValue: entry.value, + status: 'In Use', + ), + ), + SecretReferenceEntry( + name: snapshotInternal.aiGateway.name, + provider: 'LLM API', + module: 'Settings', + maskedValue: snapshotInternal.aiGateway.baseUrl.trim().isEmpty + ? 'Not set' + : snapshotInternal.aiGateway.baseUrl, + status: snapshotInternal.aiGateway.syncState, + ), + ]; + return entries; + } + + Future reloadDerivedStateInternal() async { + final refs = await storeInternal.loadSecureRefs(); + secureRefsInternal = { + for (final entry in refs.entries) + entry.key: SecureConfigStore.maskValue(entry.value), + }; + auditTrailInternal = await storeInternal.loadAuditTrail(); + accountSessionTokenInternal = + (await storeInternal.loadAccountSessionToken())?.trim() ?? ''; + accountSessionInternal = await storeInternal.loadAccountSessionSummary(); + accountProfileInternal = await storeInternal.loadAccountProfile(); + if (!accountBusyInternal) { + if (accountSignedIn) { + final email = accountSessionInternal?.email.trim() ?? ''; + accountStatusInternal = email.isEmpty + ? 'Signed in' + : 'Signed in as $email'; + } else if (accountMfaRequired) { + accountStatusInternal = 'MFA required'; + } else { + accountStatusInternal = 'Signed out'; + } + } + } + + String providerNameForSecretInternal(String key) { + if (key.contains('vault')) { + return 'Vault'; + } + if (key.contains('ollama')) { + return 'Ollama Cloud'; + } + if (key.contains('ai_gateway')) { + return 'LLM API'; + } + if (key.contains('openclaw')) { + return 'Account'; + } + if (key.contains('gateway')) { + return 'Gateway'; + } + return 'Local Store'; + } + + String moduleForSecretInternal(String key) { + if (key.contains('gateway')) { + return key.contains('device_token') ? 'Devices' : 'Assistant'; + } + if (key.contains('ollama')) { + return 'Settings'; + } + if (key.contains('ai_gateway')) { + return 'Settings'; + } + if (key.contains('openclaw')) { + return 'Account'; + } + if (key.contains('vault')) { + return 'Secrets'; + } + return 'Workspace'; + } +} diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart new file mode 100644 index 00000000..588f6b3a --- /dev/null +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -0,0 +1,427 @@ +import 'account_runtime_client.dart'; +import 'runtime_controllers_settings.dart'; +import 'runtime_models.dart'; + +Future loginAccountSettingsInternal( + SettingsController controller, { + required String baseUrl, + required String identifier, + required String password, +}) async { + final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( + baseUrl, + fallback: controller.snapshotInternal.accountBaseUrl, + ); + if (normalizedBaseUrl.isEmpty) { + controller.accountStatusInternal = 'Account base URL is required'; + controller.notifyListeners(); + return; + } + if (identifier.trim().isEmpty || password.isEmpty) { + controller.accountStatusInternal = 'Email and password are required'; + controller.notifyListeners(); + return; + } + + controller.accountBusyInternal = true; + controller.accountStatusInternal = 'Signing in...'; + controller.notifyListeners(); + + try { + final client = controller.buildAccountClient(normalizedBaseUrl); + final payload = await client.login( + identifier: identifier.trim(), + password: password, + ); + final requiresMfa = + payload['mfaRequired'] == true || payload['mfa_required'] == true; + if (requiresMfa) { + controller.pendingAccountMfaTicketInternal = + _stringValue(payload['mfaToken']).isNotEmpty + ? _stringValue(payload['mfaToken']) + : _stringValue(payload['mfaTicket']); + controller.pendingAccountBaseUrlInternal = normalizedBaseUrl; + controller.accountStatusInternal = 'MFA required'; + return; + } + + await completeAccountSignInSettingsInternal( + controller, + baseUrl: normalizedBaseUrl, + payload: payload, + ); + } on AccountRuntimeException catch (error) { + controller.accountStatusInternal = error.message; + } finally { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } +} + +Future verifyAccountMfaSettingsInternal( + SettingsController controller, { + required String baseUrl, + required String code, +}) async { + final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( + baseUrl, + fallback: controller.pendingAccountBaseUrlInternal.isNotEmpty + ? controller.pendingAccountBaseUrlInternal + : controller.snapshotInternal.accountBaseUrl, + ); + if (normalizedBaseUrl.isEmpty) { + controller.accountStatusInternal = 'Account base URL is required'; + controller.notifyListeners(); + return; + } + if (controller.pendingAccountMfaTicketInternal.trim().isEmpty) { + controller.accountStatusInternal = 'MFA ticket is missing'; + controller.notifyListeners(); + return; + } + if (code.trim().isEmpty) { + controller.accountStatusInternal = 'MFA code is required'; + controller.notifyListeners(); + return; + } + + controller.accountBusyInternal = true; + controller.accountStatusInternal = 'Verifying MFA...'; + controller.notifyListeners(); + + try { + final client = controller.buildAccountClient(normalizedBaseUrl); + final payload = await client.verifyMfa( + mfaToken: controller.pendingAccountMfaTicketInternal, + code: code.trim(), + ); + controller.pendingAccountMfaTicketInternal = ''; + controller.pendingAccountBaseUrlInternal = ''; + await completeAccountSignInSettingsInternal( + controller, + baseUrl: normalizedBaseUrl, + payload: payload, + ); + } on AccountRuntimeException catch (error) { + controller.accountStatusInternal = error.message; + } finally { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } +} + +Future completeAccountSignInSettingsInternal( + SettingsController controller, { + required String baseUrl, + required Map payload, +}) async { + final token = _stringValue(payload['token']).isNotEmpty + ? _stringValue(payload['token']) + : _stringValue(payload['access_token']); + if (token.isEmpty) { + controller.accountStatusInternal = 'Account session token is missing'; + return; + } + await controller.storeInternal.saveAccountSessionToken(token); + final user = _asMap(payload['user']); + if (user.isNotEmpty) { + await controller.storeInternal.saveAccountSessionSummary( + AccountSessionSummary( + userId: _stringValue(user['id']), + email: _stringValue(user['email']), + name: _stringValue(user['name']).isNotEmpty + ? _stringValue(user['name']) + : _stringValue(user['username']), + role: _stringValue(user['role']), + mfaEnabled: user['mfaEnabled'] as bool? ?? false, + ), + ); + } + controller.accountStatusInternal = 'Signed in'; + await restoreAccountSessionSettingsInternal( + controller, + baseUrl: baseUrl, + quiet: true, + ); +} + +Future restoreAccountSessionSettingsInternal( + SettingsController controller, { + String baseUrl = '', + bool quiet = false, +}) async { + final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( + baseUrl, + fallback: controller.snapshotInternal.accountBaseUrl, + ); + final token = + (await controller.storeInternal.loadAccountSessionToken())?.trim() ?? ''; + if (normalizedBaseUrl.isEmpty || token.isEmpty) { + return; + } + + if (!quiet) { + controller.accountBusyInternal = true; + controller.accountStatusInternal = 'Restoring account session...'; + controller.notifyListeners(); + } + + try { + final client = controller.buildAccountClient(normalizedBaseUrl); + final session = await client.loadSession(token: token); + await controller.storeInternal.saveAccountSessionSummary(session); + controller.accountStatusInternal = session.email.trim().isEmpty + ? 'Signed in' + : 'Signed in as ${session.email}'; + await syncAccountManagedSecretsSettingsInternal( + controller, + baseUrl: normalizedBaseUrl, + quiet: true, + ); + } on AccountRuntimeException catch (error) { + if (error.statusCode == 401) { + await logoutAccountSettingsInternal( + controller, + statusMessage: 'Session expired', + quiet: true, + ); + } else { + controller.accountStatusInternal = + 'Session restore failed: ${error.message}'; + } + } finally { + if (!quiet) { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } + } +} + +Future syncAccountManagedSecretsSettingsInternal( + SettingsController controller, { + String baseUrl = '', + bool quiet = false, +}) async { + final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal( + baseUrl, + fallback: controller.snapshotInternal.accountBaseUrl, + ); + final token = + (await controller.storeInternal.loadAccountSessionToken())?.trim() ?? ''; + if (normalizedBaseUrl.isEmpty || token.isEmpty) { + final result = const AccountSyncResult( + state: 'blocked', + message: 'Account session is unavailable', + storedTargets: [], + skippedTargets: [], + ); + controller.accountStatusInternal = result.message; + if (!quiet) { + controller.notifyListeners(); + } + return result; + } + + if (!quiet) { + controller.accountBusyInternal = true; + controller.accountStatusInternal = 'Syncing account-managed secrets...'; + controller.notifyListeners(); + } + + try { + final client = controller.buildAccountClient(normalizedBaseUrl); + final remoteProfile = await client.loadProfile(token: token); + final vaultToken = + (await controller.storeInternal.loadVaultToken())?.trim() ?? ''; + if (vaultToken.isEmpty) { + final blockedProfile = remoteProfile.copyWith( + syncState: 'blocked', + syncMessage: 'Vault token is required to sync remote secrets', + lastSyncedAtMs: DateTime.now().millisecondsSinceEpoch, + ); + await controller.storeInternal.saveAccountProfile(blockedProfile); + await controller.reloadDerivedStateInternal(); + return const AccountSyncResult( + state: 'blocked', + message: 'Vault token is required to sync remote secrets', + storedTargets: [], + skippedTargets: [], + ); + } + + final storedTargets = []; + final skippedTargets = []; + final syncedValues = {}; + + for (final locator in remoteProfile.secretLocators) { + final provider = locator.provider.trim().toLowerCase(); + final target = locator.target.trim(); + if (provider != 'vault' || + !isSupportedAccountManagedSecretTarget(target)) { + skippedTargets.add(target); + continue; + } + try { + final value = await client.readVaultSecretValue( + vaultUrl: remoteProfile.vaultUrl, + namespace: remoteProfile.vaultNamespace, + vaultToken: vaultToken, + secretPath: locator.secretPath, + secretKey: locator.secretKey, + ); + if (value.trim().isEmpty) { + skippedTargets.add(target); + continue; + } + await controller.storeInternal.saveAccountManagedSecret( + target: target, + value: value.trim(), + ); + syncedValues[target] = value.trim(); + storedTargets.add(target); + } catch (_) { + skippedTargets.add(target); + } + } + + final aiGatewayCatalog = + await loadAccountManagedAiGatewayModelsSettingsInternal( + controller, + profile: remoteProfile, + syncedValues: syncedValues, + ); + final hasSkips = skippedTargets.isNotEmpty; + final state = hasSkips ? 'partial' : 'ready'; + final message = hasSkips + ? 'Synced ${storedTargets.length} secret(s) with ${skippedTargets.length} skipped' + : 'Synced ${storedTargets.length} secret(s)'; + final nextProfile = remoteProfile.copyWith( + syncState: state, + syncMessage: message, + aiGatewayAvailableModels: aiGatewayCatalog.$1, + aiGatewaySyncMessage: aiGatewayCatalog.$2, + lastSyncedAtMs: DateTime.now().millisecondsSinceEpoch, + ); + await controller.storeInternal.saveAccountProfile(nextProfile); + await controller.reloadDerivedStateInternal(); + return AccountSyncResult( + state: state, + message: message, + storedTargets: storedTargets, + skippedTargets: skippedTargets, + ); + } on AccountRuntimeException catch (error) { + final profile = + (await controller.storeInternal.loadAccountProfile()) ?? + AccountRemoteProfile.defaults(); + await controller.storeInternal.saveAccountProfile( + profile.copyWith( + syncState: 'error', + syncMessage: error.message, + lastSyncedAtMs: DateTime.now().millisecondsSinceEpoch, + ), + ); + await controller.reloadDerivedStateInternal(); + return AccountSyncResult( + state: 'error', + message: error.message, + storedTargets: const [], + skippedTargets: const [], + ); + } finally { + if (!quiet) { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } + } +} + +Future<(List, String)> +loadAccountManagedAiGatewayModelsSettingsInternal( + SettingsController controller, { + required AccountRemoteProfile profile, + required Map syncedValues, +}) async { + final localBaseUrl = controller.snapshotInternal.aiGateway.baseUrl.trim(); + final effectiveBaseUrl = localBaseUrl.isNotEmpty + ? localBaseUrl + : controller.snapshotInternal.accountLocalMode + ? '' + : profile.apisixUrl.trim(); + final localApiKey = + (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; + final effectiveApiKey = localApiKey.isNotEmpty + ? localApiKey + : syncedValues[kAccountManagedSecretTargetAIGatewayAccessToken] ?? ''; + if (effectiveBaseUrl.isEmpty || effectiveApiKey.isEmpty) { + return (const [], 'Model catalog not synced yet'); + } + final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal( + effectiveBaseUrl, + ); + if (normalizedBaseUrl == null) { + return (const [], 'Invalid LLM API Endpoint'); + } + try { + final models = await controller.requestAiGatewayModelsInternal( + uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl), + apiKey: effectiveApiKey, + ); + return ( + models.map((item) => item.id).toList(growable: false), + 'Loaded ${models.length} model(s)', + ); + } catch (error) { + return (const [], controller.networkErrorLabelInternal(error)); + } +} + +Future logoutAccountSettingsInternal( + SettingsController controller, { + String statusMessage = 'Signed out', + bool quiet = false, +}) async { + if (!quiet) { + controller.accountBusyInternal = true; + controller.notifyListeners(); + } + controller.pendingAccountMfaTicketInternal = ''; + controller.pendingAccountBaseUrlInternal = ''; + await controller.storeInternal.clearAccountSessionToken(); + await controller.storeInternal.clearAccountSessionSummary(); + await controller.storeInternal.clearAccountProfile(); + await controller.storeInternal.clearAccountManagedSecrets(); + await controller.reloadDerivedStateInternal(); + controller.accountStatusInternal = statusMessage; + if (!quiet) { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } +} + +String normalizeAccountBaseUrlSettingsInternal( + String raw, { + String fallback = '', +}) { + final candidate = raw.trim().isNotEmpty ? raw.trim() : fallback.trim(); + if (candidate.isEmpty) { + return ''; + } + return candidate.endsWith('/') + ? candidate.substring(0, candidate.length - 1) + : candidate; +} + +Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; +} + +String _stringValue(Object? value) { + return value?.toString().trim() ?? ''; +} diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 00d9f285..fb692dc4 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -1,6 +1,7 @@ export 'runtime_models_connection.dart'; export 'runtime_models_profiles.dart'; export 'runtime_models_configs.dart'; +export 'runtime_models_account.dart'; export 'runtime_models_settings_snapshot.dart'; export 'runtime_models_runtime_payloads.dart'; export 'runtime_models_gateway_entities.dart'; diff --git a/lib/runtime/runtime_models_account.dart b/lib/runtime/runtime_models_account.dart new file mode 100644 index 00000000..82551fac --- /dev/null +++ b/lib/runtime/runtime_models_account.dart @@ -0,0 +1,273 @@ +class AccountSessionSummary { + const AccountSessionSummary({ + required this.userId, + required this.email, + required this.name, + required this.role, + required this.mfaEnabled, + }); + + final String userId; + final String email; + final String name; + final String role; + final bool mfaEnabled; + + AccountSessionSummary copyWith({ + String? userId, + String? email, + String? name, + String? role, + bool? mfaEnabled, + }) { + return AccountSessionSummary( + userId: userId ?? this.userId, + email: email ?? this.email, + name: name ?? this.name, + role: role ?? this.role, + mfaEnabled: mfaEnabled ?? this.mfaEnabled, + ); + } + + Map toJson() { + return { + 'userId': userId, + 'email': email, + 'name': name, + 'role': role, + 'mfaEnabled': mfaEnabled, + }; + } + + factory AccountSessionSummary.fromJson(Map json) { + return AccountSessionSummary( + userId: json['userId'] as String? ?? '', + email: json['email'] as String? ?? '', + name: json['name'] as String? ?? '', + role: json['role'] as String? ?? '', + mfaEnabled: json['mfaEnabled'] as bool? ?? false, + ); + } +} + +class AccountSecretLocator { + const AccountSecretLocator({ + required this.id, + required this.provider, + required this.secretPath, + required this.secretKey, + required this.target, + required this.required, + }); + + final String id; + final String provider; + final String secretPath; + final String secretKey; + final String target; + final bool required; + + AccountSecretLocator copyWith({ + String? id, + String? provider, + String? secretPath, + String? secretKey, + String? target, + bool? required, + }) { + return AccountSecretLocator( + id: id ?? this.id, + provider: provider ?? this.provider, + secretPath: secretPath ?? this.secretPath, + secretKey: secretKey ?? this.secretKey, + target: target ?? this.target, + required: required ?? this.required, + ); + } + + Map toJson() { + return { + 'id': id, + 'provider': provider, + 'secretPath': secretPath, + 'secretKey': secretKey, + 'target': target, + 'required': required, + }; + } + + factory AccountSecretLocator.fromJson(Map json) { + return AccountSecretLocator( + id: json['id'] as String? ?? '', + provider: json['provider'] as String? ?? 'vault', + secretPath: json['secretPath'] as String? ?? '', + secretKey: json['secretKey'] as String? ?? '', + target: json['target'] as String? ?? '', + required: json['required'] as bool? ?? false, + ); + } +} + +class AccountRemoteProfile { + const AccountRemoteProfile({ + required this.openclawUrl, + required this.openclawOrigin, + required this.vaultUrl, + required this.vaultNamespace, + required this.apisixUrl, + required this.secretLocators, + required this.syncState, + required this.syncMessage, + required this.aiGatewayAvailableModels, + required this.aiGatewaySyncMessage, + required this.lastSyncedAtMs, + }); + + final String openclawUrl; + final String openclawOrigin; + final String vaultUrl; + final String vaultNamespace; + final String apisixUrl; + final List secretLocators; + final String syncState; + final String syncMessage; + final List aiGatewayAvailableModels; + final String aiGatewaySyncMessage; + final int lastSyncedAtMs; + + factory AccountRemoteProfile.defaults() { + return const AccountRemoteProfile( + openclawUrl: '', + openclawOrigin: '', + vaultUrl: '', + vaultNamespace: '', + apisixUrl: '', + secretLocators: [], + syncState: 'idle', + syncMessage: 'Ready to sync', + aiGatewayAvailableModels: [], + aiGatewaySyncMessage: 'Model catalog not synced yet', + lastSyncedAtMs: 0, + ); + } + + AccountRemoteProfile copyWith({ + String? openclawUrl, + String? openclawOrigin, + String? vaultUrl, + String? vaultNamespace, + String? apisixUrl, + List? secretLocators, + String? syncState, + String? syncMessage, + List? aiGatewayAvailableModels, + String? aiGatewaySyncMessage, + int? lastSyncedAtMs, + }) { + return AccountRemoteProfile( + openclawUrl: openclawUrl ?? this.openclawUrl, + openclawOrigin: openclawOrigin ?? this.openclawOrigin, + vaultUrl: vaultUrl ?? this.vaultUrl, + vaultNamespace: vaultNamespace ?? this.vaultNamespace, + apisixUrl: apisixUrl ?? this.apisixUrl, + secretLocators: secretLocators ?? this.secretLocators, + syncState: syncState ?? this.syncState, + syncMessage: syncMessage ?? this.syncMessage, + aiGatewayAvailableModels: + aiGatewayAvailableModels ?? this.aiGatewayAvailableModels, + aiGatewaySyncMessage: + aiGatewaySyncMessage ?? this.aiGatewaySyncMessage, + lastSyncedAtMs: lastSyncedAtMs ?? this.lastSyncedAtMs, + ); + } + + Map toJson() { + return { + 'openclawUrl': openclawUrl, + 'openclawOrigin': openclawOrigin, + 'vaultUrl': vaultUrl, + 'vaultNamespace': vaultNamespace, + 'apisixUrl': apisixUrl, + 'secretLocators': secretLocators + .map((item) => item.toJson()) + .toList(growable: false), + 'syncState': syncState, + 'syncMessage': syncMessage, + 'aiGatewayAvailableModels': aiGatewayAvailableModels, + 'aiGatewaySyncMessage': aiGatewaySyncMessage, + 'lastSyncedAtMs': lastSyncedAtMs, + }; + } + + factory AccountRemoteProfile.fromJson(Map json) { + List decodeLocators(Object? value) { + if (value is! List) { + return const []; + } + return value + .whereType() + .map((item) => AccountSecretLocator.fromJson(item.cast())) + .toList(growable: false); + } + + List decodeModels(Object? value) { + if (value is! List) { + return const []; + } + return value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + + final defaults = AccountRemoteProfile.defaults(); + return AccountRemoteProfile( + openclawUrl: json['openclawUrl'] as String? ?? defaults.openclawUrl, + openclawOrigin: + json['openclawOrigin'] as String? ?? defaults.openclawOrigin, + vaultUrl: json['vaultUrl'] as String? ?? defaults.vaultUrl, + vaultNamespace: + json['vaultNamespace'] as String? ?? defaults.vaultNamespace, + apisixUrl: json['apisixUrl'] as String? ?? defaults.apisixUrl, + secretLocators: decodeLocators(json['secretLocators']), + syncState: json['syncState'] as String? ?? defaults.syncState, + syncMessage: json['syncMessage'] as String? ?? defaults.syncMessage, + aiGatewayAvailableModels: decodeModels(json['aiGatewayAvailableModels']), + aiGatewaySyncMessage: + json['aiGatewaySyncMessage'] as String? ?? + defaults.aiGatewaySyncMessage, + lastSyncedAtMs: + (json['lastSyncedAtMs'] as num?)?.toInt() ?? defaults.lastSyncedAtMs, + ); + } +} + +class AccountSyncResult { + const AccountSyncResult({ + required this.state, + required this.message, + required this.storedTargets, + required this.skippedTargets, + }); + + final String state; + final String message; + final List storedTargets; + final List skippedTargets; +} + +const String kAccountManagedSecretTargetOpenclawGatewayToken = + 'openclaw.gateway_token'; +const String kAccountManagedSecretTargetAIGatewayAccessToken = + 'ai_gateway.access_token'; +const String kAccountManagedSecretTargetOllamaCloudApiKey = + 'ollama_cloud.api_key'; +const List kAccountManagedSecretTargets = [ + kAccountManagedSecretTargetOpenclawGatewayToken, + kAccountManagedSecretTargetAIGatewayAccessToken, + kAccountManagedSecretTargetOllamaCloudApiKey, +]; + +bool isSupportedAccountManagedSecretTarget(String target) { + return kAccountManagedSecretTargets.contains(target.trim()); +} diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart index d78460c4..d210927e 100644 --- a/lib/runtime/secret_store.dart +++ b/lib/runtime/secret_store.dart @@ -88,6 +88,11 @@ class SecretStore { static const String _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key'; static const String _vaultTokenKey = 'xworkmate.vault.token'; static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key'; + static const String _accountSessionTokenKey = + 'xworkmate.account.session.token'; + static const String _accountSessionSummaryKey = + 'xworkmate.account.session.summary'; + static const String _accountProfileKey = 'xworkmate.account.profile'; final StoreLayoutResolver _layoutResolver; final SecureStorageClient? _secureStorageOverride; @@ -212,6 +217,69 @@ class SecretStore { Future clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey); + Future loadAccountSessionToken() => _readSecure(_accountSessionTokenKey); + + Future saveAccountSessionToken(String value) => + _writeSecure(_accountSessionTokenKey, value); + + Future clearAccountSessionToken() => _deleteSecure(_accountSessionTokenKey); + + Future loadAccountSessionSummary() async { + final raw = await _readSecure(_accountSessionSummaryKey); + if ((raw ?? '').trim().isEmpty) { + return null; + } + try { + return AccountSessionSummary.fromJson( + (jsonDecode(raw!) as Map).cast(), + ); + } catch (_) { + return null; + } + } + + Future saveAccountSessionSummary(AccountSessionSummary value) => + _writeSecure(_accountSessionSummaryKey, jsonEncode(value.toJson())); + + Future clearAccountSessionSummary() => + _deleteSecure(_accountSessionSummaryKey); + + Future loadAccountProfile() async { + final raw = await _readSecure(_accountProfileKey); + if ((raw ?? '').trim().isEmpty) { + return null; + } + try { + return AccountRemoteProfile.fromJson( + (jsonDecode(raw!) as Map).cast(), + ); + } catch (_) { + return null; + } + } + + Future saveAccountProfile(AccountRemoteProfile value) => + _writeSecure(_accountProfileKey, jsonEncode(value.toJson())); + + Future clearAccountProfile() => _deleteSecure(_accountProfileKey); + + Future loadAccountManagedSecret({required String target}) => + _readSecure(_accountManagedSecretKey(target)); + + Future saveAccountManagedSecret({ + required String target, + required String value, + }) => _writeSecure(_accountManagedSecretKey(target), value); + + Future clearAccountManagedSecret({required String target}) => + _deleteSecure(_accountManagedSecretKey(target)); + + Future clearAccountManagedSecrets() async { + for (final target in kAccountManagedSecretTargets) { + await clearAccountManagedSecret(target: target); + } + } + Future> loadSecureRefs() async { await initialize(); final secureRefs = {}; @@ -257,6 +325,12 @@ class SecretStore { if (aiGatewayApiKey case final value?) { secureRefs['ai_gateway_api_key'] = value; } + for (final target in kAccountManagedSecretTargets) { + final managedValue = await loadAccountManagedSecret(target: target); + if (managedValue case final value?) { + secureRefs[target] = value; + } + } return secureRefs; } @@ -355,6 +429,9 @@ class SecretStore { 4, ]; + static String _accountManagedSecretKey(String target) => + 'xworkmate.account.managed.${target.trim()}'; + Future _readSecure(String key) async { await initialize(); final client = _secureStorage; diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 7eec8249..ae007ecf 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -165,6 +165,46 @@ class SecureConfigStore { Future clearAiGatewayApiKey() => _secretStore.clearAiGatewayApiKey(); + Future loadAccountSessionToken() => + _secretStore.loadAccountSessionToken(); + + Future saveAccountSessionToken(String value) => + _secretStore.saveAccountSessionToken(value); + + Future clearAccountSessionToken() => + _secretStore.clearAccountSessionToken(); + + Future loadAccountSessionSummary() => + _secretStore.loadAccountSessionSummary(); + + Future saveAccountSessionSummary(AccountSessionSummary value) => + _secretStore.saveAccountSessionSummary(value); + + Future clearAccountSessionSummary() => + _secretStore.clearAccountSessionSummary(); + + Future loadAccountProfile() => + _secretStore.loadAccountProfile(); + + Future saveAccountProfile(AccountRemoteProfile value) => + _secretStore.saveAccountProfile(value); + + Future clearAccountProfile() => _secretStore.clearAccountProfile(); + + Future loadAccountManagedSecret({required String target}) => + _secretStore.loadAccountManagedSecret(target: target); + + Future saveAccountManagedSecret({ + required String target, + required String value, + }) => _secretStore.saveAccountManagedSecret(target: target, value: value); + + Future clearAccountManagedSecret({required String target}) => + _secretStore.clearAccountManagedSecret(target: target); + + Future clearAccountManagedSecrets() => + _secretStore.clearAccountManagedSecrets(); + Future loadDeviceIdentity() { return _secretStore.loadDeviceIdentity(); } diff --git a/test/features/account_page_auth_suite.dart b/test/features/account_page_auth_suite.dart new file mode 100644 index 00000000..6759fdd9 --- /dev/null +++ b/test/features/account_page_auth_suite.dart @@ -0,0 +1,299 @@ +@TestOn('vm') +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/account/account_page.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +import '../test_support.dart'; + +void main() { + testWidgets('AccountPage logs in and shows remote sync status inline', ( + WidgetTester tester, + ) async { + final controller = await createTestController( + tester, + accountClientFactory: (_) => _FakeAccountRuntimeClient(requireMfa: false), + ); + await tester.runAsync(() async { + await controller.settingsController.saveVaultToken( + _FakeAccountRuntimeClient.expectedVaultToken, + ); + }); + + await pumpPage(tester, child: AccountPage(controller: controller)); + + await tester.enterText( + find.byKey(const ValueKey('account-base-url-field')), + _FakeAccountRuntimeClient.accountBaseUrl, + ); + await tester.enterText( + find.byKey(const ValueKey('account-username-field')), + _FakeAccountRuntimeClient.loginEmail, + ); + await tester.enterText( + find.byKey(const ValueKey('account-password-field')), + _FakeAccountRuntimeClient.loginPassword, + ); + expect(find.byKey(const ValueKey('account-login-button')), findsOneWidget); + await tester.runAsync(() async { + await controller.settingsController.loginAccount( + baseUrl: _FakeAccountRuntimeClient.accountBaseUrl, + identifier: _FakeAccountRuntimeClient.loginEmail, + password: _FakeAccountRuntimeClient.loginPassword, + ); + }); + await tester.pump(); + + final sessionStatus = tester.widget( + find.byKey(const ValueKey('account-session-status')), + ); + final syncStatus = tester.widget( + find.byKey(const ValueKey('account-sync-status')), + ); + + expect(sessionStatus.data, contains(_FakeAccountRuntimeClient.loginEmail)); + expect(syncStatus.data, contains('ready')); + expect(find.byKey(const ValueKey('account-logout-button')), findsOneWidget); + }); + + testWidgets('AccountPage completes MFA verification and can log out', ( + WidgetTester tester, + ) async { + final controller = await createTestController( + tester, + accountClientFactory: (_) => _FakeAccountRuntimeClient(requireMfa: true), + ); + await tester.runAsync(() async { + await controller.settingsController.saveVaultToken( + _FakeAccountRuntimeClient.expectedVaultToken, + ); + }); + + await pumpPage(tester, child: AccountPage(controller: controller)); + + await tester.enterText( + find.byKey(const ValueKey('account-base-url-field')), + _FakeAccountRuntimeClient.accountBaseUrl, + ); + await tester.enterText( + find.byKey(const ValueKey('account-username-field')), + _FakeAccountRuntimeClient.loginEmail, + ); + await tester.enterText( + find.byKey(const ValueKey('account-password-field')), + _FakeAccountRuntimeClient.loginPassword, + ); + expect(find.byKey(const ValueKey('account-login-button')), findsOneWidget); + await tester.runAsync(() async { + await controller.settingsController.loginAccount( + baseUrl: _FakeAccountRuntimeClient.accountBaseUrl, + identifier: _FakeAccountRuntimeClient.loginEmail, + password: _FakeAccountRuntimeClient.loginPassword, + ); + }); + await tester.pump(); + + expect( + find.byKey(const ValueKey('account-verify-mfa-button')), + findsOneWidget, + ); + + await tester.enterText( + find.byKey(const ValueKey('account-mfa-code-field')), + _FakeAccountRuntimeClient.loginCode, + ); + await tester.runAsync(() async { + await controller.settingsController.verifyAccountMfa( + baseUrl: _FakeAccountRuntimeClient.accountBaseUrl, + code: _FakeAccountRuntimeClient.loginCode, + ); + }); + await tester.pump(); + + expect(find.byKey(const ValueKey('account-logout-button')), findsOneWidget); + + await tester.runAsync(() async { + await controller.settingsController.logoutAccount(); + }); + await tester.pump(); + + final sessionStatus = tester.widget( + find.byKey(const ValueKey('account-session-status')), + ); + expect(sessionStatus.data, contains('未登录')); + }); +} + +class _FakeAccountRuntimeClient extends AccountRuntimeClient { + _FakeAccountRuntimeClient({required this.requireMfa}) + : super(baseUrl: accountBaseUrl); + + static const String accountBaseUrl = 'https://accounts.widget.test'; + static const String loginEmail = 'user@example.com'; + static const String loginPassword = 'correct-password'; + static const String loginCode = '123456'; + static const String sessionToken = 'account-session-token'; + static const String mfaTicket = 'account-mfa-ticket'; + static const String expectedVaultToken = 'vault-root-token'; + static const String openclawGatewayToken = 'remote-openclaw-token'; + static const String aiGatewayAccessToken = 'remote-ai-gateway-token'; + static const String ollamaCloudApiKey = 'remote-ollama-api-key'; + + final bool requireMfa; + + @override + Future> login({ + required String identifier, + required String password, + }) async { + if (identifier != loginEmail || password != loginPassword) { + throw const AccountRuntimeException( + statusCode: 401, + errorCode: 'invalid_credentials', + message: 'invalid credentials', + ); + } + if (requireMfa) { + return { + 'message': 'mfa required', + 'mfaRequired': true, + 'mfa_required': true, + 'mfaToken': mfaTicket, + 'mfaTicket': mfaTicket, + }; + } + return { + 'message': 'login successful', + 'token': sessionToken, + 'access_token': sessionToken, + 'mfaRequired': false, + 'mfa_required': false, + 'user': _userPayload(mfaEnabled: false), + }; + } + + @override + Future> verifyMfa({ + required String mfaToken, + required String code, + }) async { + if (mfaToken != mfaTicket || code != loginCode) { + throw const AccountRuntimeException( + statusCode: 401, + errorCode: 'invalid_mfa_code', + message: 'invalid totp code', + ); + } + return { + 'message': 'login successful', + 'token': sessionToken, + 'access_token': sessionToken, + 'mfaRequired': false, + 'mfa_required': false, + 'user': _userPayload(mfaEnabled: true), + }; + } + + @override + Future loadSession({required String token}) async { + if (token != sessionToken) { + throw const AccountRuntimeException( + statusCode: 401, + errorCode: 'session_not_found', + message: 'session not found', + ); + } + return AccountSessionSummary( + userId: 'user-1', + email: loginEmail, + name: 'Account User', + role: 'operator', + mfaEnabled: requireMfa, + ); + } + + @override + Future loadProfile({required String token}) async { + if (token != sessionToken) { + throw const AccountRuntimeException( + statusCode: 401, + errorCode: 'session_not_found', + message: 'session not found', + ); + } + return AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'https://openclaw.account.example', + openclawOrigin: 'https://openclaw.account.example', + vaultUrl: accountBaseUrl, + vaultNamespace: 'team-a', + apisixUrl: '$accountBaseUrl/v1', + secretLocators: const [ + AccountSecretLocator( + id: 'locator-openclaw', + provider: 'vault', + secretPath: 'kv/openclaw', + secretKey: 'OPENCLAW_GATEWAY_TOKEN', + target: kAccountManagedSecretTargetOpenclawGatewayToken, + required: true, + ), + AccountSecretLocator( + id: 'locator-ai-gateway', + provider: 'vault', + secretPath: 'kv/apisix', + secretKey: 'AI_GATEWAY_ACCESS_TOKEN', + target: kAccountManagedSecretTargetAIGatewayAccessToken, + required: true, + ), + AccountSecretLocator( + id: 'locator-ollama', + provider: 'vault', + secretPath: 'kv/ollama', + secretKey: 'OLLAMA_API_KEY', + target: kAccountManagedSecretTargetOllamaCloudApiKey, + required: false, + ), + ], + ); + } + + @override + Future readVaultSecretValue({ + required String vaultUrl, + required String namespace, + required String vaultToken, + required String secretPath, + required String secretKey, + }) async { + if (vaultToken != expectedVaultToken) { + throw const AccountRuntimeException( + statusCode: 403, + errorCode: 'invalid_vault_token', + message: 'invalid vault token', + ); + } + return switch ('$secretPath::$secretKey') { + 'kv/openclaw::OPENCLAW_GATEWAY_TOKEN' => openclawGatewayToken, + 'kv/apisix::AI_GATEWAY_ACCESS_TOKEN' => aiGatewayAccessToken, + 'kv/ollama::OLLAMA_API_KEY' => ollamaCloudApiKey, + _ => throw const AccountRuntimeException( + statusCode: 404, + errorCode: 'secret_not_found', + message: 'secret not found', + ), + }; + } + + Map _userPayload({required bool mfaEnabled}) { + return { + 'id': 'user-1', + 'email': loginEmail, + 'name': 'Account User', + 'role': 'operator', + 'mfaEnabled': mfaEnabled, + }; + } +} diff --git a/test/features/assistant_page_installed_skill_e2e_suite.dart b/test/features/assistant_page_installed_skill_e2e_suite.dart new file mode 100644 index 00000000..3e586173 --- /dev/null +++ b/test/features/assistant_page_installed_skill_e2e_suite.dart @@ -0,0 +1,122 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +import 'assistant_page_suite_support.dart'; + +void main() { + group('AssistantPage installed skill E2E harness', () { + for (final testCase in installedSkillE2ECasesInternal) { + test( + 'discovers, binds, hands off, and captures ${testCase.skillKey}', + () async { + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-installed-skill-${testCase.skillKey}-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + + final skillsRoot = Directory( + '${tempDirectory.path}/installed-skills', + ); + await seedInstalledSkillE2ERootInternal(skillsRoot); + + final controller = await createInstalledSkillE2EControllerInternal( + tempDirectory: tempDirectory, + skillsRoot: skillsRoot, + ); + + final importedSkills = controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ); + final importedLabels = importedSkills + .map((item) => item.label) + .toList(growable: false); + + expect( + importedLabels, + containsAll( + installedSkillE2ECasesInternal + .map((item) => item.skillLabel) + .toList(growable: false), + ), + ); + + final selectedEntry = importedSkills.firstWhere( + (item) => item.label == testCase.skillLabel, + ); + expect(selectedEntry.source, 'custom'); + expect(selectedEntry.scope, 'user'); + expect(selectedEntry.sourcePath, endsWith('SKILL.md')); + expect(selectedEntry.sourceLabel, isNotEmpty); + + await controller.toggleAssistantSkillForSession( + controller.currentSessionKey, + selectedEntry.key, + ); + expect( + controller.assistantSelectedSkillKeysForSession( + controller.currentSessionKey, + ), + [selectedEntry.key], + ); + + final sendFuture = controller.sendChatMessage( + testCase.prompt, + selectedSkillLabels: [selectedEntry.label], + ); + await waitForConditionInternal(() => controller.sendCallCount == 1); + + expect(controller.lastPromptInternal, testCase.prompt); + expect(controller.lastSelectedSkillLabelsInternal, [ + selectedEntry.label, + ]); + expect( + controller.lastWorkspacePathInternal, + controller.assistantWorkspacePathForSession( + controller.currentSessionKey, + ), + ); + + controller.sendGate.complete(); + await sendFuture; + + final snapshot = await controller.loadAssistantArtifactSnapshot(); + expect(snapshot.workspaceKind, WorkspaceRefKind.localPath); + expect( + snapshot.fileEntries.map((item) => item.relativePath), + contains(testCase.outputRelativePath), + ); + expect( + snapshot.resultEntries.map((item) => item.relativePath), + contains(testCase.outputRelativePath), + ); + }, + ); + } + + test( + 'records deferred media skill coverage explicitly', + () { + expect(installedSkillE2EDeferredCoverageInternal, [ + 'image-cog', + 'wan-image-video-generation-editting', + 'video-translator', + 'image-resizer', + ]); + }, + skip: + 'Deferred until the media skill packs are installed in the test environment.', + ); + }); +} diff --git a/test/features/assistant_page_installed_skill_e2e_test.dart b/test/features/assistant_page_installed_skill_e2e_test.dart new file mode 100644 index 00000000..12fd3c08 --- /dev/null +++ b/test/features/assistant_page_installed_skill_e2e_test.dart @@ -0,0 +1,7 @@ +import '../test_suite_stub.dart' + if (dart.library.io) 'assistant_page_installed_skill_e2e_suite.dart' + as suite; + +void main() { + suite.main(); +} diff --git a/test/features/assistant_page_suite_support.dart b/test/features/assistant_page_suite_support.dart index 666fce50..a34afb5b 100644 --- a/test/features/assistant_page_suite_support.dart +++ b/test/features/assistant_page_suite_support.dart @@ -22,6 +22,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/widgets/pane_resize_handle.dart'; import '../test_support.dart'; +import '../runtime/app_controller_thread_skills_suite_fixtures.dart'; import 'assistant_page_suite_core.dart'; import 'assistant_page_suite_composer.dart'; @@ -175,6 +176,150 @@ Future waitForConditionInternal(bool Function() predicate) async { } } +class InstalledSkillE2ECaseInternal { + const InstalledSkillE2ECaseInternal({ + required this.skillKey, + required this.skillLabel, + required this.prompt, + required this.outputRelativePath, + }); + + final String skillKey; + final String skillLabel; + final String prompt; + final String outputRelativePath; +} + +const List installedSkillE2ECasesInternal = + [ + InstalledSkillE2ECaseInternal( + skillKey: 'pptx', + skillLabel: 'pptx', + prompt: 'installed-skill harness: exercise pptx handoff', + outputRelativePath: 'artifacts/pptx/result.md', + ), + InstalledSkillE2ECaseInternal( + skillKey: 'docx', + skillLabel: 'docx', + prompt: 'installed-skill harness: exercise docx handoff', + outputRelativePath: 'artifacts/docx/result.md', + ), + InstalledSkillE2ECaseInternal( + skillKey: 'xlsx', + skillLabel: 'xlsx', + prompt: 'installed-skill harness: exercise xlsx handoff', + outputRelativePath: 'artifacts/xlsx/result.md', + ), + InstalledSkillE2ECaseInternal( + skillKey: 'pdf', + skillLabel: 'pdf', + prompt: 'installed-skill harness: exercise pdf handoff', + outputRelativePath: 'artifacts/pdf/result.md', + ), + ]; + +const List installedSkillE2EDeferredCoverageInternal = [ + 'image-cog', + 'wan-image-video-generation-editting', + 'video-translator', + 'image-resizer', +]; + +Future seedInstalledSkillE2ERootInternal(Directory root) async { + for (final testCase in installedSkillE2ECasesInternal) { + await writeSkillInternal( + root, + testCase.skillKey, + skillName: testCase.skillLabel, + description: 'Installed skill ${testCase.skillLabel}', + ); + } +} + +class InstalledSkillE2EAppControllerInternal extends AppController { + InstalledSkillE2EAppControllerInternal({ + required SecureConfigStore store, + required this.sendGate, + super.singleAgentSharedSkillScanRootOverrides, + }) : super( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + ); + + final Completer sendGate; + int sendCallCount = 0; + String lastPromptInternal = ''; + List lastSelectedSkillLabelsInternal = []; + String lastWorkspacePathInternal = ''; + + @override + Future sendChatMessage( + String message, { + String thinking = 'off', + List attachments = + const [], + List localAttachments = + const [], + List selectedSkillLabels = const [], + }) async { + sendCallCount += 1; + lastPromptInternal = message; + lastSelectedSkillLabelsInternal = selectedSkillLabels.toList( + growable: false, + ); + lastWorkspacePathInternal = assistantWorkspacePathForSession( + currentSessionKey, + ); + if (lastWorkspacePathInternal.trim().isEmpty) { + throw StateError('Installed-skill harness did not resolve a workspace.'); + } + + final selectedLabel = selectedSkillLabels.isEmpty + ? 'unselected' + : selectedSkillLabels.first; + final artifactFile = File( + '$lastWorkspacePathInternal/artifacts/$selectedLabel/result.md', + ); + await artifactFile.parent.create(recursive: true); + await artifactFile.writeAsString( + [ + '# $selectedLabel', + '', + 'prompt: $message', + 'thinking: $thinking', + 'selected: ${selectedSkillLabels.join(', ')}', + 'session: $currentSessionKey', + ].join('\n'), + ); + + await sendGate.future; + } +} + +Future +createInstalledSkillE2EControllerInternal({ + required Directory tempDirectory, + required Directory skillsRoot, +}) async { + SharedPreferences.setMockInitialValues({}); + final controller = InstalledSkillE2EAppControllerInternal( + store: await createStoreInternal(tempDirectory.path), + sendGate: Completer(), + singleAgentSharedSkillScanRootOverrides: [skillsRoot.path], + ); + addTearDown(controller.dispose); + await waitForConditionInternal(() => !controller.initializing); + await waitForConditionInternal( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .isNotEmpty, + ); + return controller; +} + class PendingSendAppControllerInternal extends AppController { PendingSendAppControllerInternal({ required SecureConfigStore store, diff --git a/test/runtime/secure_config_store_suite_secrets.dart b/test/runtime/secure_config_store_suite_secrets.dart index 442952a8..66c873b3 100644 --- a/test/runtime/secure_config_store_suite_secrets.dart +++ b/test/runtime/secure_config_store_suite_secrets.dart @@ -194,6 +194,63 @@ void registerSecureConfigStoreSuiteSecretsTestsInternal() { }, ); + test( + 'SecureConfigStore persists account-managed session, profile, and secrets outside the settings snapshot', + () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-account-managed-store-', + ); + final store = createStoreFromTempDirectoryInternal(tempDirectory); + + await store.saveAccountSessionToken('account-session-token'); + await store.saveAccountSessionSummary( + const AccountSessionSummary( + userId: 'user-1', + email: 'user@example.com', + name: 'Demo User', + role: 'user', + mfaEnabled: false, + ), + ); + await store.saveAccountProfile( + AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'https://openclaw.account.example', + apisixUrl: 'https://apisix.account.example/v1', + syncState: 'ready', + syncMessage: 'Synced 3 secret(s)', + aiGatewayAvailableModels: const ['gpt-5.4'], + ), + ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + value: 'remote-ai-token', + ); + + expect(await store.loadAccountSessionToken(), 'account-session-token'); + expect(await store.loadAccountSessionSummary(), isNotNull); + expect(await store.loadAccountProfile(), isNotNull); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ), + 'remote-ai-token', + ); + expect( + (await store.loadSecureRefs())[ + kAccountManagedSecretTargetAIGatewayAccessToken], + 'remote-ai-token', + ); + expect( + (await store.loadSettingsSnapshot()).toJsonString(), + allOf( + isNot(contains('account-session-token')), + isNot(contains('remote-ai-token')), + isNot(contains('apisix.account.example')), + ), + ); + }, + ); + test( 'SecureConfigStore falls back to file-backed device identity and token across instances', () async { diff --git a/test/runtime/settings_controller_account_sync_suite.dart b/test/runtime/settings_controller_account_sync_suite.dart new file mode 100644 index 00000000..e588dab8 --- /dev/null +++ b/test/runtime/settings_controller_account_sync_suite.dart @@ -0,0 +1,278 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +import '../test_support_account_server.dart'; + +void main() { + test( + 'SettingsController logs in and syncs account-managed secrets without writing them into settings snapshot', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await FakeAccountVaultServer.start(); + addTearDown(server.close); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-settings-account-sync-', + ); + addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory)); + final store = _createIsolatedStore(tempDirectory.path); + addTearDown(store.dispose); + + final controller = SettingsController(store); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: server.accountBaseUrl, + accountUsername: server.loginEmail, + accountLocalMode: false, + ), + ); + await controller.saveVaultToken(server.expectedVaultToken); + + await controller.loginAccount( + baseUrl: server.accountBaseUrl, + identifier: server.loginEmail, + password: server.loginPassword, + ); + + expect(controller.accountSignedIn, isTrue); + expect(controller.accountMfaRequired, isFalse); + expect(controller.accountSession?.email, server.loginEmail); + expect(controller.accountProfile?.syncState, 'ready'); + expect( + controller.accountProfile?.aiGatewayAvailableModels, + contains('gpt-5.4'), + ); + expect(await store.loadAccountSessionToken(), server.sessionToken); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetOpenclawGatewayToken, + ), + server.openclawGatewayToken, + ); + expect( + await controller.loadEffectiveAiGatewayApiKey(), + server.aiGatewayAccessToken, + ); + expect( + await controller.loadEffectiveGatewayToken( + profileIndex: kGatewayRemoteProfileIndex, + ), + server.openclawGatewayToken, + ); + expect(controller.effectiveAiGatewayBaseUrl, server.aiGatewayBaseUrl); + expect( + server.lastAiGatewayAuthorization, + 'Bearer ${server.aiGatewayAccessToken}', + ); + expect(server.lastVaultToken, server.expectedVaultToken); + expect(server.lastVaultNamespace, 'team-a'); + expect( + (await store.loadSettingsSnapshot()).toJsonString(), + allOf( + isNot(contains(server.sessionToken)), + isNot(contains(server.openclawGatewayToken)), + isNot(contains(server.aiGatewayAccessToken)), + isNot(contains(server.ollamaCloudApiKey)), + ), + ); + }, + ); + + test( + 'SettingsController completes MFA verification before restoring the account session', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await FakeAccountVaultServer.start(requireMfa: true); + addTearDown(server.close); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-settings-account-mfa-', + ); + addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory)); + final store = _createIsolatedStore(tempDirectory.path); + addTearDown(store.dispose); + + final controller = SettingsController(store); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: server.accountBaseUrl, + accountUsername: server.loginEmail, + accountLocalMode: false, + ), + ); + await controller.saveVaultToken(server.expectedVaultToken); + + await controller.loginAccount( + baseUrl: server.accountBaseUrl, + identifier: server.loginEmail, + password: server.loginPassword, + ); + + expect(controller.accountSignedIn, isFalse); + expect(controller.accountMfaRequired, isTrue); + + await controller.verifyAccountMfa( + baseUrl: server.accountBaseUrl, + code: server.loginCode, + ); + + expect(controller.accountSignedIn, isTrue); + expect(controller.accountMfaRequired, isFalse); + expect(controller.accountSession?.mfaEnabled, isTrue); + expect(controller.accountProfile?.syncState, 'ready'); + }, + ); + + test( + 'SettingsController keeps account login successful when the local Vault token is missing', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await FakeAccountVaultServer.start(); + addTearDown(server.close); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-settings-account-vault-missing-', + ); + addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory)); + final store = _createIsolatedStore(tempDirectory.path); + addTearDown(store.dispose); + + final controller = SettingsController(store); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: server.accountBaseUrl, + accountUsername: server.loginEmail, + accountLocalMode: false, + ), + ); + + await controller.loginAccount( + baseUrl: server.accountBaseUrl, + identifier: server.loginEmail, + password: server.loginPassword, + ); + + expect(controller.accountSignedIn, isTrue); + expect(controller.accountProfile?.syncState, 'blocked'); + expect(controller.accountProfile?.syncMessage, contains('Vault token')); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ), + isNull, + ); + }, + ); + + test( + 'SettingsController resolves local config ahead of account-managed fallbacks and disables fallbacks in local mode', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await FakeAccountVaultServer.start(); + addTearDown(server.close); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-settings-account-effective-config-', + ); + addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory)); + final store = _createIsolatedStore(tempDirectory.path); + addTearDown(store.dispose); + + final controller = SettingsController(store); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: server.accountBaseUrl, + accountUsername: server.loginEmail, + accountLocalMode: false, + ), + ); + await controller.saveVaultToken(server.expectedVaultToken); + await controller.loginAccount( + baseUrl: server.accountBaseUrl, + identifier: server.loginEmail, + password: server.loginPassword, + ); + + await controller.saveAiGatewayApiKey('local-ai-key'); + await controller.saveGatewaySecrets( + profileIndex: kGatewayRemoteProfileIndex, + token: 'local-remote-token', + password: '', + ); + await controller.saveSnapshot( + controller.snapshot.copyWith( + aiGateway: controller.snapshot.aiGateway.copyWith( + baseUrl: 'https://local-ai.example.com/v1', + ), + ), + ); + + expect(await controller.loadEffectiveAiGatewayApiKey(), 'local-ai-key'); + expect( + await controller.loadEffectiveGatewayToken( + profileIndex: kGatewayRemoteProfileIndex, + ), + 'local-remote-token', + ); + expect( + controller.effectiveAiGatewayBaseUrl, + 'https://local-ai.example.com/v1', + ); + + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountLocalMode: true, + aiGateway: controller.snapshot.aiGateway.copyWith(baseUrl: ''), + ), + ); + await controller.clearAiGatewayApiKey(); + await controller.clearGatewaySecrets( + profileIndex: kGatewayRemoteProfileIndex, + token: true, + ); + + expect(await controller.loadEffectiveAiGatewayApiKey(), isEmpty); + expect( + await controller.loadEffectiveGatewayToken( + profileIndex: kGatewayRemoteProfileIndex, + ), + isEmpty, + ); + expect(controller.effectiveAiGatewayBaseUrl, isEmpty); + }, + ); +} + +SecureConfigStore _createIsolatedStore(String rootPath) { + return SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '$rootPath/config-store.sqlite3', + fallbackDirectoryPathResolver: () async => rootPath, + defaultSupportDirectoryPathResolver: () async => rootPath, + ); +} + +Future _deleteDirectoryBestEffort(Directory directory) async { + for (var attempt = 0; attempt < 3; attempt += 1) { + try { + if (!await directory.exists()) { + return; + } + await directory.delete(recursive: true); + return; + } on FileSystemException { + if (attempt == 2) { + return; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + } +} diff --git a/test/runtime/settings_controller_account_sync_test.dart b/test/runtime/settings_controller_account_sync_test.dart new file mode 100644 index 00000000..acbcc1f7 --- /dev/null +++ b/test/runtime/settings_controller_account_sync_test.dart @@ -0,0 +1,7 @@ +import '../test_suite_stub.dart' + if (dart.library.io) 'settings_controller_account_sync_suite.dart' + as suite; + +void main() { + suite.main(); +} diff --git a/test/test_support.dart b/test/test_support.dart index 1d1b1068..694f58f6 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/ui_feature_manifest.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; @@ -46,6 +47,7 @@ Future createTestController( WidgetTester tester, { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, + AccountRuntimeClient Function(String baseUrl)? accountClientFactory, List? singleAgentSharedSkillScanRootOverrides, }) async { SharedPreferences.setMockInitialValues({}); @@ -59,6 +61,7 @@ Future createTestController( ), desktopPlatformService: desktopPlatformService, uiFeatureManifest: uiFeatureManifest, + accountClientFactory: accountClientFactory, singleAgentSharedSkillScanRootOverrides: singleAgentSharedSkillScanRootOverrides, ); diff --git a/test/test_support_account_server.dart b/test/test_support_account_server.dart new file mode 100644 index 00000000..ee3317a4 --- /dev/null +++ b/test/test_support_account_server.dart @@ -0,0 +1,333 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +class FakeAccountVaultServer { + FakeAccountVaultServer._( + this._server, { + required this.requireMfa, + required this.includeUnmappedLocator, + }); + + final HttpServer _server; + final bool requireMfa; + final bool includeUnmappedLocator; + + final String loginEmail = 'user@example.com'; + final String loginPassword = 'correct-password'; + final String loginCode = '123456'; + final String sessionToken = 'account-session-token'; + final String mfaTicket = 'account-mfa-ticket'; + final String expectedVaultToken = 'vault-root-token'; + final String openclawGatewayToken = 'remote-openclaw-token'; + final String aiGatewayAccessToken = 'remote-ai-gateway-token'; + final String ollamaCloudApiKey = 'remote-ollama-api-key'; + + String? lastAiGatewayAuthorization; + String? lastVaultToken; + String? lastVaultNamespace; + + String get accountBaseUrl => 'http://127.0.0.1:${_server.port}'; + String get vaultBaseUrl => accountBaseUrl; + String get aiGatewayBaseUrl => '$accountBaseUrl/v1'; + String get openclawUrl => 'https://openclaw.account.example'; + String get openclawOrigin => 'https://openclaw.account.example'; + + static Future start({ + bool requireMfa = false, + bool includeUnmappedLocator = false, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = FakeAccountVaultServer._( + server, + requireMfa: requireMfa, + includeUnmappedLocator: includeUnmappedLocator, + ); + unawaited(fake._serve()); + return fake; + } + + Future close() => _server.close(force: true); + + Future _serve() async { + await for (final request in _server) { + final path = request.uri.path; + if (request.method == 'POST' && path == '/api/auth/login') { + await _handleLogin(request); + continue; + } + if (request.method == 'POST' && path == '/api/auth/mfa/verify') { + await _handleVerifyMfa(request); + continue; + } + if (request.method == 'GET' && path == '/api/auth/session') { + await _handleSession(request); + continue; + } + if (request.method == 'GET' && path == '/api/auth/xworkmate/profile') { + await _handleProfile(request); + continue; + } + if (request.method == 'GET' && path == '/v1/models') { + await _handleModels(request); + continue; + } + if (request.method == 'GET' && path.startsWith('/v1/kv/data/')) { + await _handleVault(request); + continue; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } + + Future _handleLogin(HttpRequest request) async { + final payload = await _decodeJson(request); + final identifier = + (payload['identifier'] ?? payload['email'] ?? '').toString().trim(); + final password = (payload['password'] ?? '').toString().trim(); + if (identifier != loginEmail || password != loginPassword) { + await _writeJson( + request.response, + HttpStatus.unauthorized, + { + 'error': 'invalid_credentials', + 'message': 'invalid credentials', + }, + ); + return; + } + if (requireMfa) { + await _writeJson( + request.response, + HttpStatus.ok, + { + 'message': 'mfa required', + 'mfaRequired': true, + 'mfa_required': true, + 'mfaToken': mfaTicket, + 'mfaTicket': mfaTicket, + }, + ); + return; + } + await _writeJson( + request.response, + HttpStatus.ok, + { + 'message': 'login successful', + 'token': sessionToken, + 'access_token': sessionToken, + 'mfaRequired': false, + 'mfa_required': false, + 'user': _userPayload(), + }, + ); + } + + Future _handleVerifyMfa(HttpRequest request) async { + final payload = await _decodeJson(request); + final ticket = + (payload['mfaToken'] ?? payload['mfa_ticket'] ?? '').toString().trim(); + final code = + (payload['code'] ?? payload['totpCode'] ?? '').toString().trim(); + if (ticket != mfaTicket || code != loginCode) { + await _writeJson( + request.response, + HttpStatus.unauthorized, + { + 'error': 'invalid_mfa_code', + 'message': 'invalid totp code', + }, + ); + return; + } + await _writeJson( + request.response, + HttpStatus.ok, + { + 'message': 'login successful', + 'token': sessionToken, + 'access_token': sessionToken, + 'mfaRequired': false, + 'mfa_required': false, + 'user': _userPayload(mfaEnabled: true), + }, + ); + } + + Future _handleSession(HttpRequest request) async { + if (!_isAuthorized(request)) { + await _writeJson( + request.response, + HttpStatus.unauthorized, + {'error': 'session not found'}, + ); + return; + } + await _writeJson( + request.response, + HttpStatus.ok, + {'user': _userPayload(mfaEnabled: requireMfa)}, + ); + } + + Future _handleProfile(HttpRequest request) async { + if (!_isAuthorized(request)) { + await _writeJson( + request.response, + HttpStatus.unauthorized, + {'error': 'session not found'}, + ); + return; + } + final secretLocators = >[ + { + 'id': 'locator-openclaw', + 'provider': 'vault', + 'secretPath': 'kv/openclaw', + 'secretKey': 'OPENCLAW_GATEWAY_TOKEN', + 'target': 'openclaw.gateway_token', + 'required': true, + }, + { + 'id': 'locator-ai-gateway', + 'provider': 'vault', + 'secretPath': 'kv/apisix', + 'secretKey': 'AI_GATEWAY_ACCESS_TOKEN', + 'target': 'ai_gateway.access_token', + 'required': true, + }, + { + 'id': 'locator-ollama', + 'provider': 'vault', + 'secretPath': 'kv/ollama', + 'secretKey': 'OLLAMA_API_KEY', + 'target': 'ollama_cloud.api_key', + 'required': false, + }, + if (includeUnmappedLocator) + { + 'id': 'locator-unmapped', + 'provider': 'vault', + 'secretPath': 'kv/unmapped', + 'secretKey': 'UNMAPPED_KEY', + 'target': 'unknown.target', + 'required': false, + }, + ]; + await _writeJson( + request.response, + HttpStatus.ok, + { + 'profile': { + 'openclawUrl': openclawUrl, + 'openclawOrigin': openclawOrigin, + 'vaultUrl': vaultBaseUrl, + 'vaultNamespace': 'team-a', + 'vaultSecretPath': 'kv/openclaw', + 'vaultSecretKey': 'OPENCLAW_GATEWAY_TOKEN', + 'apisixUrl': aiGatewayBaseUrl, + 'secretLocators': secretLocators, + }, + }, + ); + } + + Future _handleModels(HttpRequest request) async { + lastAiGatewayAuthorization = + request.headers.value(HttpHeaders.authorizationHeader); + if (lastAiGatewayAuthorization != 'Bearer $aiGatewayAccessToken') { + await _writeJson( + request.response, + HttpStatus.unauthorized, + { + 'error': {'message': 'invalid_api_key'}, + }, + ); + return; + } + await _writeJson( + request.response, + HttpStatus.ok, + { + 'data': >[ + {'id': 'gpt-5.4', 'name': 'gpt-5.4'}, + {'id': 'o3-mini', 'name': 'o3-mini'}, + ], + }, + ); + } + + Future _handleVault(HttpRequest request) async { + lastVaultToken = request.headers.value('X-Vault-Token'); + lastVaultNamespace = request.headers.value('X-Vault-Namespace'); + if (lastVaultToken != expectedVaultToken) { + await _writeJson( + request.response, + HttpStatus.forbidden, + {'errors': ['permission denied']}, + ); + return; + } + final path = request.uri.path.substring('/v1/kv/data/'.length); + final data = switch (path) { + 'openclaw' => { + 'OPENCLAW_GATEWAY_TOKEN': openclawGatewayToken, + }, + 'apisix' => { + 'AI_GATEWAY_ACCESS_TOKEN': aiGatewayAccessToken, + }, + 'ollama' => { + 'OLLAMA_API_KEY': ollamaCloudApiKey, + }, + _ => { + 'UNMAPPED_KEY': 'ignored-value', + }, + }; + await _writeJson( + request.response, + HttpStatus.ok, + { + 'data': { + 'data': data, + }, + }, + ); + } + + bool _isAuthorized(HttpRequest request) { + final authorization = request.headers.value(HttpHeaders.authorizationHeader); + return authorization == 'Bearer $sessionToken'; + } + + Map _userPayload({bool mfaEnabled = false}) { + return { + 'id': 'user-1', + 'name': 'Demo User', + 'username': 'Demo User', + 'email': loginEmail, + 'role': 'user', + 'mfaEnabled': mfaEnabled, + }; + } + + Future> _decodeJson(HttpRequest request) async { + final raw = await utf8.decoder.bind(request).join(); + if (raw.trim().isEmpty) { + return const {}; + } + return (jsonDecode(raw) as Map).cast(); + } + + Future _writeJson( + HttpResponse response, + int statusCode, + Map payload, + ) async { + response.statusCode = statusCode; + response.headers.contentType = ContentType.json; + response.write(jsonEncode(payload)); + await response.close(); + } +}