feat(account): add secure remote sync and installed-skill e2e
This commit is contained in:
parent
c827f47ebf
commit
27ed4db18f
53
docs/reports/2026-03-30-installed-skill-e2e-harness.md
Normal file
53
docs/reports/2026-03-30-installed-skill-e2e-harness.md
Normal file
@ -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`
|
||||||
@ -16,6 +16,7 @@ import '../runtime/go_core.dart';
|
|||||||
import '../runtime/runtime_bootstrap.dart';
|
import '../runtime/runtime_bootstrap.dart';
|
||||||
import '../runtime/desktop_platform_service.dart';
|
import '../runtime/desktop_platform_service.dart';
|
||||||
import '../runtime/gateway_runtime.dart';
|
import '../runtime/gateway_runtime.dart';
|
||||||
|
import '../runtime/account_runtime_client.dart';
|
||||||
import '../runtime/runtime_controllers.dart';
|
import '../runtime/runtime_controllers.dart';
|
||||||
import '../runtime/runtime_models.dart';
|
import '../runtime/runtime_models.dart';
|
||||||
import '../runtime/secure_config_store.dart';
|
import '../runtime/secure_config_store.dart';
|
||||||
@ -121,6 +122,7 @@ class AppController extends ChangeNotifier {
|
|||||||
DesktopPlatformService? desktopPlatformService,
|
DesktopPlatformService? desktopPlatformService,
|
||||||
UiFeatureManifest? uiFeatureManifest,
|
UiFeatureManifest? uiFeatureManifest,
|
||||||
SkillDirectoryAccessService? skillDirectoryAccessService,
|
SkillDirectoryAccessService? skillDirectoryAccessService,
|
||||||
|
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
|
||||||
List<String>? singleAgentSharedSkillScanRootOverrides,
|
List<String>? singleAgentSharedSkillScanRootOverrides,
|
||||||
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
|
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
|
||||||
ArisBundleRepository? arisBundleRepository,
|
ArisBundleRepository? arisBundleRepository,
|
||||||
@ -153,7 +155,10 @@ class AppController extends ChangeNotifier {
|
|||||||
codeAgentBridgeRegistryInternal = AgentRegistry(
|
codeAgentBridgeRegistryInternal = AgentRegistry(
|
||||||
runtimeCoordinatorInternal.gateway,
|
runtimeCoordinatorInternal.gateway,
|
||||||
);
|
);
|
||||||
settingsControllerInternal = SettingsController(storeInternal);
|
settingsControllerInternal = SettingsController(
|
||||||
|
storeInternal,
|
||||||
|
accountClientFactory: accountClientFactory,
|
||||||
|
);
|
||||||
agentsControllerInternal = GatewayAgentsController(
|
agentsControllerInternal = GatewayAgentsController(
|
||||||
runtimeCoordinatorInternal.gateway,
|
runtimeCoordinatorInternal.gateway,
|
||||||
);
|
);
|
||||||
@ -499,9 +504,10 @@ class AppController extends ChangeNotifier {
|
|||||||
hasStoredGatewayTokenForProfile(activeGatewayProfileIndexInternal);
|
hasStoredGatewayTokenForProfile(activeGatewayProfileIndexInternal);
|
||||||
String? get storedGatewayTokenMask =>
|
String? get storedGatewayTokenMask =>
|
||||||
storedGatewayTokenMaskForProfile(activeGatewayProfileIndexInternal);
|
storedGatewayTokenMaskForProfile(activeGatewayProfileIndexInternal);
|
||||||
String get aiGatewayUrl => settings.aiGateway.baseUrl.trim();
|
String get aiGatewayUrl =>
|
||||||
|
settingsControllerInternal.effectiveAiGatewayBaseUrl.trim();
|
||||||
bool get hasStoredAiGatewayApiKey =>
|
bool get hasStoredAiGatewayApiKey =>
|
||||||
settingsControllerInternal.secureRefs.containsKey('ai_gateway_api_key');
|
settingsControllerInternal.hasEffectiveAiGatewayApiKey;
|
||||||
bool get isSingleAgentMode =>
|
bool get isSingleAgentMode =>
|
||||||
currentAssistantExecutionTarget == AssistantExecutionTarget.singleAgent;
|
currentAssistantExecutionTarget == AssistantExecutionTarget.singleAgent;
|
||||||
bool get isCodexBridgeBusy => isCodexBridgeBusyInternal;
|
bool get isCodexBridgeBusy => isCodexBridgeBusyInternal;
|
||||||
@ -605,18 +611,16 @@ class AppController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> get aiGatewayConversationModelChoices {
|
List<String> get aiGatewayConversationModelChoices {
|
||||||
|
final availableModels =
|
||||||
|
settingsControllerInternal.effectiveAiGatewayAvailableModels;
|
||||||
final selected = settings.aiGateway.selectedModels
|
final selected = settings.aiGateway.selectedModels
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.where(
|
.where((item) => item.isNotEmpty && availableModels.contains(item))
|
||||||
(item) =>
|
|
||||||
item.isNotEmpty &&
|
|
||||||
settings.aiGateway.availableModels.contains(item),
|
|
||||||
)
|
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
if (selected.isNotEmpty) {
|
if (selected.isNotEmpty) {
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
final available = settings.aiGateway.availableModels
|
final available = availableModels
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.where((item) => item.isNotEmpty)
|
.where((item) => item.isNotEmpty)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|||||||
@ -194,10 +194,20 @@ extension AppControllerDesktopGateway on AppController {
|
|||||||
String authTokenOverride = '',
|
String authTokenOverride = '',
|
||||||
String authPasswordOverride = '',
|
String authPasswordOverride = '',
|
||||||
}) async {
|
}) async {
|
||||||
|
final resolvedProfileIndex =
|
||||||
|
profileIndex ??
|
||||||
|
gatewayProfileIndexForExecutionTargetInternal(
|
||||||
|
assistantExecutionTargetForModeInternal(profile.mode),
|
||||||
|
);
|
||||||
|
final effectiveAuthTokenOverride = authTokenOverride.trim().isNotEmpty
|
||||||
|
? authTokenOverride.trim()
|
||||||
|
: await settingsControllerInternal.loadEffectiveGatewayToken(
|
||||||
|
profileIndex: resolvedProfileIndex,
|
||||||
|
);
|
||||||
await runtimeInternal.connectProfile(
|
await runtimeInternal.connectProfile(
|
||||||
profile,
|
profile,
|
||||||
profileIndex: profileIndex,
|
profileIndex: resolvedProfileIndex,
|
||||||
authTokenOverride: authTokenOverride,
|
authTokenOverride: effectiveAuthTokenOverride,
|
||||||
authPasswordOverride: authPasswordOverride,
|
authPasswordOverride: authPasswordOverride,
|
||||||
);
|
);
|
||||||
await refreshGatewayHealth();
|
await refreshGatewayHealth();
|
||||||
|
|||||||
@ -482,6 +482,11 @@ extension AppControllerDesktopSettingsRuntime on AppController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await settingsControllerInternal.restoreAccountSession();
|
||||||
|
} catch (_) {
|
||||||
|
// Keep initialization resilient when remote account restore fails.
|
||||||
|
}
|
||||||
restoreAssistantThreadsInternal(storedAssistantThreads);
|
restoreAssistantThreadsInternal(storedAssistantThreads);
|
||||||
await restoreSharedSingleAgentLocalSkillsCacheInternal();
|
await restoreSharedSingleAgentLocalSkillsCacheInternal();
|
||||||
if (disposedInternal) {
|
if (disposedInternal) {
|
||||||
|
|||||||
@ -319,9 +319,7 @@ extension AppControllerDesktopSingleAgent on AppController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final baseUrl = normalizeAiGatewayBaseUrlInternal(
|
final baseUrl = normalizeAiGatewayBaseUrlInternal(aiGatewayUrl);
|
||||||
settings.aiGateway.baseUrl,
|
|
||||||
);
|
|
||||||
if (baseUrl == null) {
|
if (baseUrl == null) {
|
||||||
appendAssistantThreadMessageInternal(
|
appendAssistantThreadMessageInternal(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
|||||||
@ -128,7 +128,8 @@ extension AppControllerDesktopThreadSessions on AppController {
|
|||||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
);
|
);
|
||||||
final existing = assistantThreadRecordsInternal[normalizedSessionKey]
|
final existing =
|
||||||
|
assistantThreadRecordsInternal[normalizedSessionKey]
|
||||||
?.workspaceBinding
|
?.workspaceBinding
|
||||||
.workspacePath
|
.workspacePath
|
||||||
.trim() ??
|
.trim() ??
|
||||||
@ -382,7 +383,7 @@ extension AppControllerDesktopThreadSessions on AppController {
|
|||||||
final fallbackReady = singleAgentUsesAiChatFallbackForSession(
|
final fallbackReady = singleAgentUsesAiChatFallbackForSession(
|
||||||
normalizedSessionKey,
|
normalizedSessionKey,
|
||||||
);
|
);
|
||||||
final host = aiGatewayHostLabelInternal(settings.aiGateway.baseUrl);
|
final host = aiGatewayHostLabelInternal(aiGatewayUrl);
|
||||||
final providerReady = resolvedProvider != null;
|
final providerReady = resolvedProvider != null;
|
||||||
final detail = providerReady
|
final detail = providerReady
|
||||||
? joinConnectionPartsInternal(<String>[resolvedProvider.label, model])
|
? joinConnectionPartsInternal(<String>[resolvedProvider.label, model])
|
||||||
|
|||||||
@ -51,7 +51,7 @@ import 'app_controller_desktop_runtime_helpers.dart';
|
|||||||
Future<String> loadAiGatewayApiKeyThreadSessionInternal(
|
Future<String> loadAiGatewayApiKeyThreadSessionInternal(
|
||||||
AppController controller,
|
AppController controller,
|
||||||
) async {
|
) async {
|
||||||
return (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
return controller.settingsControllerInternal.loadEffectiveAiGatewayApiKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveMultiAgentConfigThreadSessionInternal(
|
Future<void> saveMultiAgentConfigThreadSessionInternal(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import '../../app/app_controller.dart';
|
|||||||
import '../../app/app_metadata.dart';
|
import '../../app/app_metadata.dart';
|
||||||
import '../../i18n/app_language.dart';
|
import '../../i18n/app_language.dart';
|
||||||
import '../../models/app_models.dart';
|
import '../../models/app_models.dart';
|
||||||
|
import '../../runtime/runtime_controllers.dart';
|
||||||
import '../../runtime/runtime_models.dart';
|
import '../../runtime/runtime_models.dart';
|
||||||
import '../../widgets/section_tabs.dart';
|
import '../../widgets/section_tabs.dart';
|
||||||
import '../../widgets/surface_card.dart';
|
import '../../widgets/surface_card.dart';
|
||||||
@ -22,6 +23,8 @@ class _AccountPageState extends State<AccountPage> {
|
|||||||
AccountTab _tab = AccountTab.profile;
|
AccountTab _tab = AccountTab.profile;
|
||||||
late final TextEditingController _accountBaseUrlController;
|
late final TextEditingController _accountBaseUrlController;
|
||||||
late final TextEditingController _accountUsernameController;
|
late final TextEditingController _accountUsernameController;
|
||||||
|
late final TextEditingController _accountPasswordController;
|
||||||
|
late final TextEditingController _accountMfaCodeController;
|
||||||
late final TextEditingController _accountWorkspaceController;
|
late final TextEditingController _accountWorkspaceController;
|
||||||
String _lastSavedAccountBaseUrl = '';
|
String _lastSavedAccountBaseUrl = '';
|
||||||
String _lastSavedAccountUsername = '';
|
String _lastSavedAccountUsername = '';
|
||||||
@ -40,6 +43,8 @@ class _AccountPageState extends State<AccountPage> {
|
|||||||
_accountUsernameController = TextEditingController(
|
_accountUsernameController = TextEditingController(
|
||||||
text: _lastSavedAccountUsername,
|
text: _lastSavedAccountUsername,
|
||||||
);
|
);
|
||||||
|
_accountPasswordController = TextEditingController();
|
||||||
|
_accountMfaCodeController = TextEditingController();
|
||||||
_accountWorkspaceController = TextEditingController(
|
_accountWorkspaceController = TextEditingController(
|
||||||
text: _lastSavedAccountWorkspace,
|
text: _lastSavedAccountWorkspace,
|
||||||
);
|
);
|
||||||
@ -49,6 +54,8 @@ class _AccountPageState extends State<AccountPage> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_accountBaseUrlController.dispose();
|
_accountBaseUrlController.dispose();
|
||||||
_accountUsernameController.dispose();
|
_accountUsernameController.dispose();
|
||||||
|
_accountPasswordController.dispose();
|
||||||
|
_accountMfaCodeController.dispose();
|
||||||
_accountWorkspaceController.dispose();
|
_accountWorkspaceController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -89,14 +96,65 @@ class _AccountPageState extends State<AccountPage> {
|
|||||||
_lastSavedAccountWorkspace = nextSettings.accountWorkspace;
|
_lastSavedAccountWorkspace = nextSettings.accountWorkspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loginAccount(SettingsSnapshot settings) async {
|
||||||
|
await _saveProfile(settings);
|
||||||
|
await widget.controller.settingsController.loginAccount(
|
||||||
|
baseUrl: _accountBaseUrlController.text.trim(),
|
||||||
|
identifier: _accountUsernameController.text.trim(),
|
||||||
|
password: _accountPasswordController.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _verifyAccountMfa() async {
|
||||||
|
await widget.controller.settingsController.verifyAccountMfa(
|
||||||
|
baseUrl: _accountBaseUrlController.text.trim(),
|
||||||
|
code: _accountMfaCodeController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncAccountManagedSecrets(SettingsSnapshot settings) async {
|
||||||
|
await _saveProfile(settings);
|
||||||
|
await widget.controller.settingsController.syncAccountManagedSecrets(
|
||||||
|
baseUrl: _accountBaseUrlController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logoutAccount() async {
|
||||||
|
await widget.controller.settingsController.logoutAccount();
|
||||||
|
_accountPasswordController.clear();
|
||||||
|
_accountMfaCodeController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final controller = widget.controller;
|
final controller = widget.controller;
|
||||||
final settings = controller.settings;
|
|
||||||
_syncControllers(settings);
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: controller,
|
animation: Listenable.merge(<Listenable>[
|
||||||
|
controller,
|
||||||
|
controller.settingsController,
|
||||||
|
]),
|
||||||
builder: (context, _) {
|
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(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(32, 32, 32, 8),
|
padding: const EdgeInsets.fromLTRB(32, 32, 32, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -154,6 +212,18 @@ class _AccountPageState extends State<AccountPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
TextFormField(
|
||||||
key: const ValueKey('account-base-url-field'),
|
key: const ValueKey('account-base-url-field'),
|
||||||
controller: _accountBaseUrlController,
|
controller: _accountBaseUrlController,
|
||||||
@ -172,6 +242,63 @@ class _AccountPageState extends State<AccountPage> {
|
|||||||
onFieldSubmitted: (_) => _saveProfile(settings),
|
onFieldSubmitted: (_) => _saveProfile(settings),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
|
|||||||
230
lib/runtime/account_runtime_client.dart
Normal file
230
lib/runtime/account_runtime_client.dart
Normal file
@ -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<Map<String, dynamic>> login({
|
||||||
|
required String identifier,
|
||||||
|
required String password,
|
||||||
|
}) {
|
||||||
|
return _requestJson(
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: <String, Object?>{
|
||||||
|
'identifier': identifier.trim(),
|
||||||
|
'password': password,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> verifyMfa({
|
||||||
|
required String mfaToken,
|
||||||
|
required String code,
|
||||||
|
}) {
|
||||||
|
return _requestJson(
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/mfa/verify',
|
||||||
|
body: <String, Object?>{
|
||||||
|
'mfaToken': mfaToken.trim(),
|
||||||
|
'code': code.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AccountSessionSummary> 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<AccountRemoteProfile> 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<String> 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: <String, String>{
|
||||||
|
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<String, dynamic> 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<AccountSecretLocator> _decodeLocators(Map<String, dynamic> profile) {
|
||||||
|
final raw = profile['secretLocators'];
|
||||||
|
if (raw is! List) {
|
||||||
|
return const <AccountSecretLocator>[];
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => AccountSecretLocator.fromJson(item.cast<String, dynamic>()))
|
||||||
|
.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: <String>['v1', mount, 'data', ...path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _requestJson({
|
||||||
|
required String method,
|
||||||
|
String path = '',
|
||||||
|
Uri? uriOverride,
|
||||||
|
String bearerToken = '',
|
||||||
|
Map<String, Object?>? body,
|
||||||
|
Map<String, String> rawHeaders = const <String, String>{},
|
||||||
|
}) 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 <String, dynamic>{}
|
||||||
|
: _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<String, dynamic> _asMap(Object? value) {
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return value.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
return const <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _stringValue(Object? value) {
|
||||||
|
return value?.toString().trim() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,18 +4,27 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'account_runtime_client.dart';
|
||||||
import 'gateway_runtime.dart';
|
import 'gateway_runtime.dart';
|
||||||
import 'runtime_models.dart';
|
import 'runtime_models.dart';
|
||||||
import 'secure_config_store.dart';
|
import 'secure_config_store.dart';
|
||||||
import 'runtime_controllers_gateway.dart';
|
import 'runtime_controllers_gateway.dart';
|
||||||
import 'runtime_controllers_entities.dart';
|
import 'runtime_controllers_entities.dart';
|
||||||
import 'runtime_controllers_derived_tasks.dart';
|
import 'runtime_controllers_derived_tasks.dart';
|
||||||
|
import 'runtime_controllers_settings_account_impl.dart';
|
||||||
import 'runtime_controllers_settings_connectivity_impl.dart';
|
import 'runtime_controllers_settings_connectivity_impl.dart';
|
||||||
|
|
||||||
|
part 'runtime_controllers_settings_account.dart';
|
||||||
|
|
||||||
class SettingsController extends ChangeNotifier {
|
class SettingsController extends ChangeNotifier {
|
||||||
SettingsController(this.storeInternal);
|
SettingsController(
|
||||||
|
this.storeInternal, {
|
||||||
|
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
|
||||||
|
}) : accountClientFactoryInternal = accountClientFactory;
|
||||||
|
|
||||||
final SecureConfigStore storeInternal;
|
final SecureConfigStore storeInternal;
|
||||||
|
final AccountRuntimeClient Function(String baseUrl)?
|
||||||
|
accountClientFactoryInternal;
|
||||||
bool disposedInternal = false;
|
bool disposedInternal = false;
|
||||||
final List<StreamSubscription<FileSystemEvent>>
|
final List<StreamSubscription<FileSystemEvent>>
|
||||||
settingsWatchSubscriptionsInternal = <StreamSubscription<FileSystemEvent>>[];
|
settingsWatchSubscriptionsInternal = <StreamSubscription<FileSystemEvent>>[];
|
||||||
@ -30,6 +39,13 @@ class SettingsController extends ChangeNotifier {
|
|||||||
String ollamaStatusInternal = 'Idle';
|
String ollamaStatusInternal = 'Idle';
|
||||||
String vaultStatusInternal = 'Idle';
|
String vaultStatusInternal = 'Idle';
|
||||||
String aiGatewayStatusInternal = '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;
|
SettingsSnapshot get snapshot => snapshotInternal;
|
||||||
Map<String, String> get secureRefs => secureRefsInternal;
|
Map<String, String> get secureRefs => secureRefsInternal;
|
||||||
@ -186,7 +202,12 @@ class SettingsController extends ChangeNotifier {
|
|||||||
secureRefsInternal.containsKey(
|
secureRefsInternal.containsKey(
|
||||||
SecretStore.gatewayTokenRefKey(profileIndex),
|
SecretStore.gatewayTokenRefKey(profileIndex),
|
||||||
) ||
|
) ||
|
||||||
secureRefsInternal.containsKey('gateway_token');
|
secureRefsInternal.containsKey('gateway_token') ||
|
||||||
|
(!snapshotInternal.accountLocalMode &&
|
||||||
|
profileIndex == kGatewayRemoteProfileIndex &&
|
||||||
|
secureRefsInternal.containsKey(
|
||||||
|
kAccountManagedSecretTargetOpenclawGatewayToken,
|
||||||
|
));
|
||||||
|
|
||||||
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
|
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
|
||||||
secureRefsInternal.containsKey(
|
secureRefsInternal.containsKey(
|
||||||
@ -196,7 +217,11 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
|
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
|
||||||
secureRefsInternal[SecretStore.gatewayTokenRefKey(profileIndex)] ??
|
secureRefsInternal[SecretStore.gatewayTokenRefKey(profileIndex)] ??
|
||||||
secureRefsInternal['gateway_token'];
|
secureRefsInternal['gateway_token'] ??
|
||||||
|
(!snapshotInternal.accountLocalMode &&
|
||||||
|
profileIndex == kGatewayRemoteProfileIndex
|
||||||
|
? secureRefsInternal[kAccountManagedSecretTargetOpenclawGatewayToken]
|
||||||
|
: null);
|
||||||
|
|
||||||
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
|
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
|
||||||
secureRefsInternal[SecretStore.gatewayPasswordRefKey(profileIndex)] ??
|
secureRefsInternal[SecretStore.gatewayPasswordRefKey(profileIndex)] ??
|
||||||
@ -274,6 +299,12 @@ class SettingsController extends ChangeNotifier {
|
|||||||
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearAiGatewayApiKey() async {
|
||||||
|
await storeInternal.clearAiGatewayApiKey();
|
||||||
|
await reloadDerivedStateInternal();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> appendAudit(SecretAuditEntry entry) async {
|
Future<void> appendAudit(SecretAuditEntry entry) async {
|
||||||
await storeInternal.appendAudit(entry);
|
await storeInternal.appendAudit(entry);
|
||||||
auditTrailInternal = await storeInternal.loadAuditTrail();
|
auditTrailInternal = await storeInternal.loadAuditTrail();
|
||||||
@ -335,71 +366,6 @@ class SettingsController extends ChangeNotifier {
|
|||||||
apiKeyOverride: apiKeyOverride,
|
apiKeyOverride: apiKeyOverride,
|
||||||
);
|
);
|
||||||
|
|
||||||
List<SecretReferenceEntry> buildSecretReferences() {
|
|
||||||
final entries = <SecretReferenceEntry>[
|
|
||||||
...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<void> 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) {
|
Uri? normalizeAiGatewayBaseUrlInternal(String raw) {
|
||||||
final trimmed = raw.trim();
|
final trimmed = raw.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
|
|||||||
192
lib/runtime/runtime_controllers_settings_account.dart
Normal file
192
lib/runtime/runtime_controllers_settings_account.dart
Normal file
@ -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<String> 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 <String>[];
|
||||||
|
}
|
||||||
|
return accountProfileInternal?.aiGatewayAvailableModels ?? const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountRuntimeClient buildAccountClient(String baseUrl) {
|
||||||
|
return accountClientFactoryInternal?.call(baseUrl) ??
|
||||||
|
AccountRuntimeClient(baseUrl: baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> loadEffectiveAiGatewayApiKey() async {
|
||||||
|
final localValue = await loadAiGatewayApiKey();
|
||||||
|
if (localValue.trim().isNotEmpty) {
|
||||||
|
return localValue;
|
||||||
|
}
|
||||||
|
if (snapshotInternal.accountLocalMode) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return (await storeInternal.loadAccountManagedSecret(
|
||||||
|
target: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||||
|
))?.trim() ??
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> 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<void> loginAccount({
|
||||||
|
required String baseUrl,
|
||||||
|
required String identifier,
|
||||||
|
required String password,
|
||||||
|
}) => loginAccountSettingsInternal(
|
||||||
|
this,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
identifier: identifier,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> verifyAccountMfa({
|
||||||
|
required String baseUrl,
|
||||||
|
required String code,
|
||||||
|
}) => verifyAccountMfaSettingsInternal(this, baseUrl: baseUrl, code: code);
|
||||||
|
|
||||||
|
Future<void> restoreAccountSession({String baseUrl = ''}) =>
|
||||||
|
restoreAccountSessionSettingsInternal(this, baseUrl: baseUrl);
|
||||||
|
|
||||||
|
Future<AccountSyncResult> syncAccountManagedSecrets({String baseUrl = ''}) =>
|
||||||
|
syncAccountManagedSecretsSettingsInternal(this, baseUrl: baseUrl);
|
||||||
|
|
||||||
|
Future<void> logoutAccount() => logoutAccountSettingsInternal(this);
|
||||||
|
|
||||||
|
List<SecretReferenceEntry> buildSecretReferences() {
|
||||||
|
final entries = <SecretReferenceEntry>[
|
||||||
|
...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<void> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
427
lib/runtime/runtime_controllers_settings_account_impl.dart
Normal file
427
lib/runtime/runtime_controllers_settings_account_impl.dart
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import 'account_runtime_client.dart';
|
||||||
|
import 'runtime_controllers_settings.dart';
|
||||||
|
import 'runtime_models.dart';
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<void> completeAccountSignInSettingsInternal(
|
||||||
|
SettingsController controller, {
|
||||||
|
required String baseUrl,
|
||||||
|
required Map<String, dynamic> 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<void> 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<AccountSyncResult> 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: <String>[],
|
||||||
|
skippedTargets: <String>[],
|
||||||
|
);
|
||||||
|
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: <String>[],
|
||||||
|
skippedTargets: <String>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedTargets = <String>[];
|
||||||
|
final skippedTargets = <String>[];
|
||||||
|
final syncedValues = <String, String>{};
|
||||||
|
|
||||||
|
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 <String>[],
|
||||||
|
skippedTargets: const <String>[],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!quiet) {
|
||||||
|
controller.accountBusyInternal = false;
|
||||||
|
controller.notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(List<String>, String)>
|
||||||
|
loadAccountManagedAiGatewayModelsSettingsInternal(
|
||||||
|
SettingsController controller, {
|
||||||
|
required AccountRemoteProfile profile,
|
||||||
|
required Map<String, String> 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 <String>[], 'Model catalog not synced yet');
|
||||||
|
}
|
||||||
|
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
|
||||||
|
effectiveBaseUrl,
|
||||||
|
);
|
||||||
|
if (normalizedBaseUrl == null) {
|
||||||
|
return (const <String>[], '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 <String>[], controller.networkErrorLabelInternal(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic> _asMap(Object? value) {
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return value.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
return const <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringValue(Object? value) {
|
||||||
|
return value?.toString().trim() ?? '';
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
export 'runtime_models_connection.dart';
|
export 'runtime_models_connection.dart';
|
||||||
export 'runtime_models_profiles.dart';
|
export 'runtime_models_profiles.dart';
|
||||||
export 'runtime_models_configs.dart';
|
export 'runtime_models_configs.dart';
|
||||||
|
export 'runtime_models_account.dart';
|
||||||
export 'runtime_models_settings_snapshot.dart';
|
export 'runtime_models_settings_snapshot.dart';
|
||||||
export 'runtime_models_runtime_payloads.dart';
|
export 'runtime_models_runtime_payloads.dart';
|
||||||
export 'runtime_models_gateway_entities.dart';
|
export 'runtime_models_gateway_entities.dart';
|
||||||
|
|||||||
273
lib/runtime/runtime_models_account.dart
Normal file
273
lib/runtime/runtime_models_account.dart
Normal file
@ -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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'userId': userId,
|
||||||
|
'email': email,
|
||||||
|
'name': name,
|
||||||
|
'role': role,
|
||||||
|
'mfaEnabled': mfaEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AccountSessionSummary.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'provider': provider,
|
||||||
|
'secretPath': secretPath,
|
||||||
|
'secretKey': secretKey,
|
||||||
|
'target': target,
|
||||||
|
'required': required,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AccountSecretLocator.fromJson(Map<String, dynamic> 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<AccountSecretLocator> secretLocators;
|
||||||
|
final String syncState;
|
||||||
|
final String syncMessage;
|
||||||
|
final List<String> aiGatewayAvailableModels;
|
||||||
|
final String aiGatewaySyncMessage;
|
||||||
|
final int lastSyncedAtMs;
|
||||||
|
|
||||||
|
factory AccountRemoteProfile.defaults() {
|
||||||
|
return const AccountRemoteProfile(
|
||||||
|
openclawUrl: '',
|
||||||
|
openclawOrigin: '',
|
||||||
|
vaultUrl: '',
|
||||||
|
vaultNamespace: '',
|
||||||
|
apisixUrl: '',
|
||||||
|
secretLocators: <AccountSecretLocator>[],
|
||||||
|
syncState: 'idle',
|
||||||
|
syncMessage: 'Ready to sync',
|
||||||
|
aiGatewayAvailableModels: <String>[],
|
||||||
|
aiGatewaySyncMessage: 'Model catalog not synced yet',
|
||||||
|
lastSyncedAtMs: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountRemoteProfile copyWith({
|
||||||
|
String? openclawUrl,
|
||||||
|
String? openclawOrigin,
|
||||||
|
String? vaultUrl,
|
||||||
|
String? vaultNamespace,
|
||||||
|
String? apisixUrl,
|
||||||
|
List<AccountSecretLocator>? secretLocators,
|
||||||
|
String? syncState,
|
||||||
|
String? syncMessage,
|
||||||
|
List<String>? 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<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
List<AccountSecretLocator> decodeLocators(Object? value) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const <AccountSecretLocator>[];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => AccountSecretLocator.fromJson(item.cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> decodeModels(Object? value) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
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<String> storedTargets;
|
||||||
|
final List<String> skippedTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String kAccountManagedSecretTargetOpenclawGatewayToken =
|
||||||
|
'openclaw.gateway_token';
|
||||||
|
const String kAccountManagedSecretTargetAIGatewayAccessToken =
|
||||||
|
'ai_gateway.access_token';
|
||||||
|
const String kAccountManagedSecretTargetOllamaCloudApiKey =
|
||||||
|
'ollama_cloud.api_key';
|
||||||
|
const List<String> kAccountManagedSecretTargets = <String>[
|
||||||
|
kAccountManagedSecretTargetOpenclawGatewayToken,
|
||||||
|
kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||||
|
kAccountManagedSecretTargetOllamaCloudApiKey,
|
||||||
|
];
|
||||||
|
|
||||||
|
bool isSupportedAccountManagedSecretTarget(String target) {
|
||||||
|
return kAccountManagedSecretTargets.contains(target.trim());
|
||||||
|
}
|
||||||
@ -88,6 +88,11 @@ class SecretStore {
|
|||||||
static const String _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key';
|
static const String _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key';
|
||||||
static const String _vaultTokenKey = 'xworkmate.vault.token';
|
static const String _vaultTokenKey = 'xworkmate.vault.token';
|
||||||
static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key';
|
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 StoreLayoutResolver _layoutResolver;
|
||||||
final SecureStorageClient? _secureStorageOverride;
|
final SecureStorageClient? _secureStorageOverride;
|
||||||
@ -212,6 +217,69 @@ class SecretStore {
|
|||||||
|
|
||||||
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
|
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
|
||||||
|
|
||||||
|
Future<String?> loadAccountSessionToken() => _readSecure(_accountSessionTokenKey);
|
||||||
|
|
||||||
|
Future<void> saveAccountSessionToken(String value) =>
|
||||||
|
_writeSecure(_accountSessionTokenKey, value);
|
||||||
|
|
||||||
|
Future<void> clearAccountSessionToken() => _deleteSecure(_accountSessionTokenKey);
|
||||||
|
|
||||||
|
Future<AccountSessionSummary?> loadAccountSessionSummary() async {
|
||||||
|
final raw = await _readSecure(_accountSessionSummaryKey);
|
||||||
|
if ((raw ?? '').trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return AccountSessionSummary.fromJson(
|
||||||
|
(jsonDecode(raw!) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveAccountSessionSummary(AccountSessionSummary value) =>
|
||||||
|
_writeSecure(_accountSessionSummaryKey, jsonEncode(value.toJson()));
|
||||||
|
|
||||||
|
Future<void> clearAccountSessionSummary() =>
|
||||||
|
_deleteSecure(_accountSessionSummaryKey);
|
||||||
|
|
||||||
|
Future<AccountRemoteProfile?> loadAccountProfile() async {
|
||||||
|
final raw = await _readSecure(_accountProfileKey);
|
||||||
|
if ((raw ?? '').trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return AccountRemoteProfile.fromJson(
|
||||||
|
(jsonDecode(raw!) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveAccountProfile(AccountRemoteProfile value) =>
|
||||||
|
_writeSecure(_accountProfileKey, jsonEncode(value.toJson()));
|
||||||
|
|
||||||
|
Future<void> clearAccountProfile() => _deleteSecure(_accountProfileKey);
|
||||||
|
|
||||||
|
Future<String?> loadAccountManagedSecret({required String target}) =>
|
||||||
|
_readSecure(_accountManagedSecretKey(target));
|
||||||
|
|
||||||
|
Future<void> saveAccountManagedSecret({
|
||||||
|
required String target,
|
||||||
|
required String value,
|
||||||
|
}) => _writeSecure(_accountManagedSecretKey(target), value);
|
||||||
|
|
||||||
|
Future<void> clearAccountManagedSecret({required String target}) =>
|
||||||
|
_deleteSecure(_accountManagedSecretKey(target));
|
||||||
|
|
||||||
|
Future<void> clearAccountManagedSecrets() async {
|
||||||
|
for (final target in kAccountManagedSecretTargets) {
|
||||||
|
await clearAccountManagedSecret(target: target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, String>> loadSecureRefs() async {
|
Future<Map<String, String>> loadSecureRefs() async {
|
||||||
await initialize();
|
await initialize();
|
||||||
final secureRefs = <String, String>{};
|
final secureRefs = <String, String>{};
|
||||||
@ -257,6 +325,12 @@ class SecretStore {
|
|||||||
if (aiGatewayApiKey case final value?) {
|
if (aiGatewayApiKey case final value?) {
|
||||||
secureRefs['ai_gateway_api_key'] = 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;
|
return secureRefs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,6 +429,9 @@ class SecretStore {
|
|||||||
4,
|
4,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static String _accountManagedSecretKey(String target) =>
|
||||||
|
'xworkmate.account.managed.${target.trim()}';
|
||||||
|
|
||||||
Future<String?> _readSecure(String key) async {
|
Future<String?> _readSecure(String key) async {
|
||||||
await initialize();
|
await initialize();
|
||||||
final client = _secureStorage;
|
final client = _secureStorage;
|
||||||
|
|||||||
@ -165,6 +165,46 @@ class SecureConfigStore {
|
|||||||
|
|
||||||
Future<void> clearAiGatewayApiKey() => _secretStore.clearAiGatewayApiKey();
|
Future<void> clearAiGatewayApiKey() => _secretStore.clearAiGatewayApiKey();
|
||||||
|
|
||||||
|
Future<String?> loadAccountSessionToken() =>
|
||||||
|
_secretStore.loadAccountSessionToken();
|
||||||
|
|
||||||
|
Future<void> saveAccountSessionToken(String value) =>
|
||||||
|
_secretStore.saveAccountSessionToken(value);
|
||||||
|
|
||||||
|
Future<void> clearAccountSessionToken() =>
|
||||||
|
_secretStore.clearAccountSessionToken();
|
||||||
|
|
||||||
|
Future<AccountSessionSummary?> loadAccountSessionSummary() =>
|
||||||
|
_secretStore.loadAccountSessionSummary();
|
||||||
|
|
||||||
|
Future<void> saveAccountSessionSummary(AccountSessionSummary value) =>
|
||||||
|
_secretStore.saveAccountSessionSummary(value);
|
||||||
|
|
||||||
|
Future<void> clearAccountSessionSummary() =>
|
||||||
|
_secretStore.clearAccountSessionSummary();
|
||||||
|
|
||||||
|
Future<AccountRemoteProfile?> loadAccountProfile() =>
|
||||||
|
_secretStore.loadAccountProfile();
|
||||||
|
|
||||||
|
Future<void> saveAccountProfile(AccountRemoteProfile value) =>
|
||||||
|
_secretStore.saveAccountProfile(value);
|
||||||
|
|
||||||
|
Future<void> clearAccountProfile() => _secretStore.clearAccountProfile();
|
||||||
|
|
||||||
|
Future<String?> loadAccountManagedSecret({required String target}) =>
|
||||||
|
_secretStore.loadAccountManagedSecret(target: target);
|
||||||
|
|
||||||
|
Future<void> saveAccountManagedSecret({
|
||||||
|
required String target,
|
||||||
|
required String value,
|
||||||
|
}) => _secretStore.saveAccountManagedSecret(target: target, value: value);
|
||||||
|
|
||||||
|
Future<void> clearAccountManagedSecret({required String target}) =>
|
||||||
|
_secretStore.clearAccountManagedSecret(target: target);
|
||||||
|
|
||||||
|
Future<void> clearAccountManagedSecrets() =>
|
||||||
|
_secretStore.clearAccountManagedSecrets();
|
||||||
|
|
||||||
Future<LocalDeviceIdentity?> loadDeviceIdentity() {
|
Future<LocalDeviceIdentity?> loadDeviceIdentity() {
|
||||||
return _secretStore.loadDeviceIdentity();
|
return _secretStore.loadDeviceIdentity();
|
||||||
}
|
}
|
||||||
|
|||||||
299
test/features/account_page_auth_suite.dart
Normal file
299
test/features/account_page_auth_suite.dart
Normal file
@ -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<Text>(
|
||||||
|
find.byKey(const ValueKey('account-session-status')),
|
||||||
|
);
|
||||||
|
final syncStatus = tester.widget<Text>(
|
||||||
|
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<Text>(
|
||||||
|
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<Map<String, dynamic>> 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 <String, dynamic>{
|
||||||
|
'message': 'mfa required',
|
||||||
|
'mfaRequired': true,
|
||||||
|
'mfa_required': true,
|
||||||
|
'mfaToken': mfaTicket,
|
||||||
|
'mfaTicket': mfaTicket,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return <String, dynamic>{
|
||||||
|
'message': 'login successful',
|
||||||
|
'token': sessionToken,
|
||||||
|
'access_token': sessionToken,
|
||||||
|
'mfaRequired': false,
|
||||||
|
'mfa_required': false,
|
||||||
|
'user': _userPayload(mfaEnabled: false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> 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 <String, dynamic>{
|
||||||
|
'message': 'login successful',
|
||||||
|
'token': sessionToken,
|
||||||
|
'access_token': sessionToken,
|
||||||
|
'mfaRequired': false,
|
||||||
|
'mfa_required': false,
|
||||||
|
'user': _userPayload(mfaEnabled: true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountSessionSummary> 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<AccountRemoteProfile> 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>[
|
||||||
|
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<String> 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<String, dynamic> _userPayload({required bool mfaEnabled}) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': 'user-1',
|
||||||
|
'email': loginEmail,
|
||||||
|
'name': 'Account User',
|
||||||
|
'role': 'operator',
|
||||||
|
'mfaEnabled': mfaEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
122
test/features/assistant_page_installed_skill_e2e_suite.dart
Normal file
122
test/features/assistant_page_installed_skill_e2e_suite.dart
Normal file
@ -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,
|
||||||
|
),
|
||||||
|
<String>[selectedEntry.key],
|
||||||
|
);
|
||||||
|
|
||||||
|
final sendFuture = controller.sendChatMessage(
|
||||||
|
testCase.prompt,
|
||||||
|
selectedSkillLabels: <String>[selectedEntry.label],
|
||||||
|
);
|
||||||
|
await waitForConditionInternal(() => controller.sendCallCount == 1);
|
||||||
|
|
||||||
|
expect(controller.lastPromptInternal, testCase.prompt);
|
||||||
|
expect(controller.lastSelectedSkillLabelsInternal, <String>[
|
||||||
|
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, <String>[
|
||||||
|
'image-cog',
|
||||||
|
'wan-image-video-generation-editting',
|
||||||
|
'video-translator',
|
||||||
|
'image-resizer',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
skip:
|
||||||
|
'Deferred until the media skill packs are installed in the test environment.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
|
|||||||
import 'package:xworkmate/theme/app_theme.dart';
|
import 'package:xworkmate/theme/app_theme.dart';
|
||||||
import 'package:xworkmate/widgets/pane_resize_handle.dart';
|
import 'package:xworkmate/widgets/pane_resize_handle.dart';
|
||||||
import '../test_support.dart';
|
import '../test_support.dart';
|
||||||
|
import '../runtime/app_controller_thread_skills_suite_fixtures.dart';
|
||||||
import 'assistant_page_suite_core.dart';
|
import 'assistant_page_suite_core.dart';
|
||||||
import 'assistant_page_suite_composer.dart';
|
import 'assistant_page_suite_composer.dart';
|
||||||
|
|
||||||
@ -175,6 +176,150 @@ Future<void> 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<InstalledSkillE2ECaseInternal> installedSkillE2ECasesInternal =
|
||||||
|
<InstalledSkillE2ECaseInternal>[
|
||||||
|
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<String> installedSkillE2EDeferredCoverageInternal = <String>[
|
||||||
|
'image-cog',
|
||||||
|
'wan-image-video-generation-editting',
|
||||||
|
'video-translator',
|
||||||
|
'image-resizer',
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> 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<void> sendGate;
|
||||||
|
int sendCallCount = 0;
|
||||||
|
String lastPromptInternal = '';
|
||||||
|
List<String> lastSelectedSkillLabelsInternal = <String>[];
|
||||||
|
String lastWorkspacePathInternal = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendChatMessage(
|
||||||
|
String message, {
|
||||||
|
String thinking = 'off',
|
||||||
|
List<GatewayChatAttachmentPayload> attachments =
|
||||||
|
const <GatewayChatAttachmentPayload>[],
|
||||||
|
List<CollaborationAttachment> localAttachments =
|
||||||
|
const <CollaborationAttachment>[],
|
||||||
|
List<String> selectedSkillLabels = const <String>[],
|
||||||
|
}) 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<InstalledSkillE2EAppControllerInternal>
|
||||||
|
createInstalledSkillE2EControllerInternal({
|
||||||
|
required Directory tempDirectory,
|
||||||
|
required Directory skillsRoot,
|
||||||
|
}) async {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
final controller = InstalledSkillE2EAppControllerInternal(
|
||||||
|
store: await createStoreInternal(tempDirectory.path),
|
||||||
|
sendGate: Completer<void>(),
|
||||||
|
singleAgentSharedSkillScanRootOverrides: <String>[skillsRoot.path],
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await waitForConditionInternal(() => !controller.initializing);
|
||||||
|
await waitForConditionInternal(
|
||||||
|
() => controller
|
||||||
|
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||||
|
.isNotEmpty,
|
||||||
|
);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
class PendingSendAppControllerInternal extends AppController {
|
class PendingSendAppControllerInternal extends AppController {
|
||||||
PendingSendAppControllerInternal({
|
PendingSendAppControllerInternal({
|
||||||
required SecureConfigStore store,
|
required SecureConfigStore store,
|
||||||
|
|||||||
@ -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 <String>['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(
|
test(
|
||||||
'SecureConfigStore falls back to file-backed device identity and token across instances',
|
'SecureConfigStore falls back to file-backed device identity and token across instances',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
278
test/runtime/settings_controller_account_sync_suite.dart
Normal file
278
test/runtime/settings_controller_account_sync_suite.dart
Normal file
@ -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(<String, Object>{});
|
||||||
|
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(<String, Object>{});
|
||||||
|
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(<String, Object>{});
|
||||||
|
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(<String, Object>{});
|
||||||
|
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<void> _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<void>.delayed(const Duration(milliseconds: 80));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
test/runtime/settings_controller_account_sync_test.dart
Normal file
7
test/runtime/settings_controller_account_sync_test.dart
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:xworkmate/app/app_controller.dart';
|
import 'package:xworkmate/app/app_controller.dart';
|
||||||
import 'package:xworkmate/app/ui_feature_manifest.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/runtime/secure_config_store.dart';
|
||||||
import 'package:xworkmate/theme/app_theme.dart';
|
import 'package:xworkmate/theme/app_theme.dart';
|
||||||
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||||
@ -46,6 +47,7 @@ Future<AppController> createTestController(
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
DesktopPlatformService? desktopPlatformService,
|
DesktopPlatformService? desktopPlatformService,
|
||||||
UiFeatureManifest? uiFeatureManifest,
|
UiFeatureManifest? uiFeatureManifest,
|
||||||
|
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
|
||||||
List<String>? singleAgentSharedSkillScanRootOverrides,
|
List<String>? singleAgentSharedSkillScanRootOverrides,
|
||||||
}) async {
|
}) async {
|
||||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
@ -59,6 +61,7 @@ Future<AppController> createTestController(
|
|||||||
),
|
),
|
||||||
desktopPlatformService: desktopPlatformService,
|
desktopPlatformService: desktopPlatformService,
|
||||||
uiFeatureManifest: uiFeatureManifest,
|
uiFeatureManifest: uiFeatureManifest,
|
||||||
|
accountClientFactory: accountClientFactory,
|
||||||
singleAgentSharedSkillScanRootOverrides:
|
singleAgentSharedSkillScanRootOverrides:
|
||||||
singleAgentSharedSkillScanRootOverrides,
|
singleAgentSharedSkillScanRootOverrides,
|
||||||
);
|
);
|
||||||
|
|||||||
333
test/test_support_account_server.dart
Normal file
333
test/test_support_account_server.dart
Normal file
@ -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<FakeAccountVaultServer> 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<void> close() => _server.close(force: true);
|
||||||
|
|
||||||
|
Future<void> _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<void> _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,
|
||||||
|
<String, Object?>{
|
||||||
|
'error': 'invalid_credentials',
|
||||||
|
'message': 'invalid credentials',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireMfa) {
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{
|
||||||
|
'message': 'mfa required',
|
||||||
|
'mfaRequired': true,
|
||||||
|
'mfa_required': true,
|
||||||
|
'mfaToken': mfaTicket,
|
||||||
|
'mfaTicket': mfaTicket,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{
|
||||||
|
'message': 'login successful',
|
||||||
|
'token': sessionToken,
|
||||||
|
'access_token': sessionToken,
|
||||||
|
'mfaRequired': false,
|
||||||
|
'mfa_required': false,
|
||||||
|
'user': _userPayload(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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,
|
||||||
|
<String, Object?>{
|
||||||
|
'error': 'invalid_mfa_code',
|
||||||
|
'message': 'invalid totp code',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{
|
||||||
|
'message': 'login successful',
|
||||||
|
'token': sessionToken,
|
||||||
|
'access_token': sessionToken,
|
||||||
|
'mfaRequired': false,
|
||||||
|
'mfa_required': false,
|
||||||
|
'user': _userPayload(mfaEnabled: true),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSession(HttpRequest request) async {
|
||||||
|
if (!_isAuthorized(request)) {
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.unauthorized,
|
||||||
|
<String, Object?>{'error': 'session not found'},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{'user': _userPayload(mfaEnabled: requireMfa)},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleProfile(HttpRequest request) async {
|
||||||
|
if (!_isAuthorized(request)) {
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.unauthorized,
|
||||||
|
<String, Object?>{'error': 'session not found'},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final secretLocators = <Map<String, Object?>>[
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'locator-openclaw',
|
||||||
|
'provider': 'vault',
|
||||||
|
'secretPath': 'kv/openclaw',
|
||||||
|
'secretKey': 'OPENCLAW_GATEWAY_TOKEN',
|
||||||
|
'target': 'openclaw.gateway_token',
|
||||||
|
'required': true,
|
||||||
|
},
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'locator-ai-gateway',
|
||||||
|
'provider': 'vault',
|
||||||
|
'secretPath': 'kv/apisix',
|
||||||
|
'secretKey': 'AI_GATEWAY_ACCESS_TOKEN',
|
||||||
|
'target': 'ai_gateway.access_token',
|
||||||
|
'required': true,
|
||||||
|
},
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'locator-ollama',
|
||||||
|
'provider': 'vault',
|
||||||
|
'secretPath': 'kv/ollama',
|
||||||
|
'secretKey': 'OLLAMA_API_KEY',
|
||||||
|
'target': 'ollama_cloud.api_key',
|
||||||
|
'required': false,
|
||||||
|
},
|
||||||
|
if (includeUnmappedLocator)
|
||||||
|
<String, Object?>{
|
||||||
|
'id': 'locator-unmapped',
|
||||||
|
'provider': 'vault',
|
||||||
|
'secretPath': 'kv/unmapped',
|
||||||
|
'secretKey': 'UNMAPPED_KEY',
|
||||||
|
'target': 'unknown.target',
|
||||||
|
'required': false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{
|
||||||
|
'profile': <String, Object?>{
|
||||||
|
'openclawUrl': openclawUrl,
|
||||||
|
'openclawOrigin': openclawOrigin,
|
||||||
|
'vaultUrl': vaultBaseUrl,
|
||||||
|
'vaultNamespace': 'team-a',
|
||||||
|
'vaultSecretPath': 'kv/openclaw',
|
||||||
|
'vaultSecretKey': 'OPENCLAW_GATEWAY_TOKEN',
|
||||||
|
'apisixUrl': aiGatewayBaseUrl,
|
||||||
|
'secretLocators': secretLocators,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleModels(HttpRequest request) async {
|
||||||
|
lastAiGatewayAuthorization =
|
||||||
|
request.headers.value(HttpHeaders.authorizationHeader);
|
||||||
|
if (lastAiGatewayAuthorization != 'Bearer $aiGatewayAccessToken') {
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.unauthorized,
|
||||||
|
<String, Object?>{
|
||||||
|
'error': <String, Object?>{'message': 'invalid_api_key'},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{
|
||||||
|
'data': <Map<String, Object?>>[
|
||||||
|
<String, Object?>{'id': 'gpt-5.4', 'name': 'gpt-5.4'},
|
||||||
|
<String, Object?>{'id': 'o3-mini', 'name': 'o3-mini'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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,
|
||||||
|
<String, Object?>{'errors': <String>['permission denied']},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final path = request.uri.path.substring('/v1/kv/data/'.length);
|
||||||
|
final data = switch (path) {
|
||||||
|
'openclaw' => <String, Object?>{
|
||||||
|
'OPENCLAW_GATEWAY_TOKEN': openclawGatewayToken,
|
||||||
|
},
|
||||||
|
'apisix' => <String, Object?>{
|
||||||
|
'AI_GATEWAY_ACCESS_TOKEN': aiGatewayAccessToken,
|
||||||
|
},
|
||||||
|
'ollama' => <String, Object?>{
|
||||||
|
'OLLAMA_API_KEY': ollamaCloudApiKey,
|
||||||
|
},
|
||||||
|
_ => <String, Object?>{
|
||||||
|
'UNMAPPED_KEY': 'ignored-value',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await _writeJson(
|
||||||
|
request.response,
|
||||||
|
HttpStatus.ok,
|
||||||
|
<String, Object?>{
|
||||||
|
'data': <String, Object?>{
|
||||||
|
'data': data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAuthorized(HttpRequest request) {
|
||||||
|
final authorization = request.headers.value(HttpHeaders.authorizationHeader);
|
||||||
|
return authorization == 'Bearer $sessionToken';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _userPayload({bool mfaEnabled = false}) {
|
||||||
|
return <String, Object?>{
|
||||||
|
'id': 'user-1',
|
||||||
|
'name': 'Demo User',
|
||||||
|
'username': 'Demo User',
|
||||||
|
'email': loginEmail,
|
||||||
|
'role': 'user',
|
||||||
|
'mfaEnabled': mfaEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> _decodeJson(HttpRequest request) async {
|
||||||
|
final raw = await utf8.decoder.bind(request).join();
|
||||||
|
if (raw.trim().isEmpty) {
|
||||||
|
return const <String, Object?>{};
|
||||||
|
}
|
||||||
|
return (jsonDecode(raw) as Map).cast<String, Object?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeJson(
|
||||||
|
HttpResponse response,
|
||||||
|
int statusCode,
|
||||||
|
Map<String, Object?> payload,
|
||||||
|
) async {
|
||||||
|
response.statusCode = statusCode;
|
||||||
|
response.headers.contentType = ContentType.json;
|
||||||
|
response.write(jsonEncode(payload));
|
||||||
|
await response.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user