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/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
import '../runtime/account_runtime_client.dart';
|
||||
import '../runtime/runtime_controllers.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import '../runtime/secure_config_store.dart';
|
||||
@ -121,6 +122,7 @@ class AppController extends ChangeNotifier {
|
||||
DesktopPlatformService? desktopPlatformService,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
SkillDirectoryAccessService? skillDirectoryAccessService,
|
||||
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
|
||||
List<String>? singleAgentSharedSkillScanRootOverrides,
|
||||
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
|
||||
ArisBundleRepository? arisBundleRepository,
|
||||
@ -153,7 +155,10 @@ class AppController extends ChangeNotifier {
|
||||
codeAgentBridgeRegistryInternal = AgentRegistry(
|
||||
runtimeCoordinatorInternal.gateway,
|
||||
);
|
||||
settingsControllerInternal = SettingsController(storeInternal);
|
||||
settingsControllerInternal = SettingsController(
|
||||
storeInternal,
|
||||
accountClientFactory: accountClientFactory,
|
||||
);
|
||||
agentsControllerInternal = GatewayAgentsController(
|
||||
runtimeCoordinatorInternal.gateway,
|
||||
);
|
||||
@ -499,9 +504,10 @@ class AppController extends ChangeNotifier {
|
||||
hasStoredGatewayTokenForProfile(activeGatewayProfileIndexInternal);
|
||||
String? get storedGatewayTokenMask =>
|
||||
storedGatewayTokenMaskForProfile(activeGatewayProfileIndexInternal);
|
||||
String get aiGatewayUrl => settings.aiGateway.baseUrl.trim();
|
||||
String get aiGatewayUrl =>
|
||||
settingsControllerInternal.effectiveAiGatewayBaseUrl.trim();
|
||||
bool get hasStoredAiGatewayApiKey =>
|
||||
settingsControllerInternal.secureRefs.containsKey('ai_gateway_api_key');
|
||||
settingsControllerInternal.hasEffectiveAiGatewayApiKey;
|
||||
bool get isSingleAgentMode =>
|
||||
currentAssistantExecutionTarget == AssistantExecutionTarget.singleAgent;
|
||||
bool get isCodexBridgeBusy => isCodexBridgeBusyInternal;
|
||||
@ -605,18 +611,16 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
List<String> get aiGatewayConversationModelChoices {
|
||||
final availableModels =
|
||||
settingsControllerInternal.effectiveAiGatewayAvailableModels;
|
||||
final selected = settings.aiGateway.selectedModels
|
||||
.map((item) => item.trim())
|
||||
.where(
|
||||
(item) =>
|
||||
item.isNotEmpty &&
|
||||
settings.aiGateway.availableModels.contains(item),
|
||||
)
|
||||
.where((item) => item.isNotEmpty && availableModels.contains(item))
|
||||
.toList(growable: false);
|
||||
if (selected.isNotEmpty) {
|
||||
return selected;
|
||||
}
|
||||
final available = settings.aiGateway.availableModels
|
||||
final available = availableModels
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
|
||||
@ -194,10 +194,20 @@ extension AppControllerDesktopGateway on AppController {
|
||||
String authTokenOverride = '',
|
||||
String authPasswordOverride = '',
|
||||
}) async {
|
||||
final resolvedProfileIndex =
|
||||
profileIndex ??
|
||||
gatewayProfileIndexForExecutionTargetInternal(
|
||||
assistantExecutionTargetForModeInternal(profile.mode),
|
||||
);
|
||||
final effectiveAuthTokenOverride = authTokenOverride.trim().isNotEmpty
|
||||
? authTokenOverride.trim()
|
||||
: await settingsControllerInternal.loadEffectiveGatewayToken(
|
||||
profileIndex: resolvedProfileIndex,
|
||||
);
|
||||
await runtimeInternal.connectProfile(
|
||||
profile,
|
||||
profileIndex: profileIndex,
|
||||
authTokenOverride: authTokenOverride,
|
||||
profileIndex: resolvedProfileIndex,
|
||||
authTokenOverride: effectiveAuthTokenOverride,
|
||||
authPasswordOverride: authPasswordOverride,
|
||||
);
|
||||
await refreshGatewayHealth();
|
||||
|
||||
@ -482,6 +482,11 @@ extension AppControllerDesktopSettingsRuntime on AppController {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await settingsControllerInternal.restoreAccountSession();
|
||||
} catch (_) {
|
||||
// Keep initialization resilient when remote account restore fails.
|
||||
}
|
||||
restoreAssistantThreadsInternal(storedAssistantThreads);
|
||||
await restoreSharedSingleAgentLocalSkillsCacheInternal();
|
||||
if (disposedInternal) {
|
||||
|
||||
@ -319,9 +319,7 @@ extension AppControllerDesktopSingleAgent on AppController {
|
||||
return;
|
||||
}
|
||||
|
||||
final baseUrl = normalizeAiGatewayBaseUrlInternal(
|
||||
settings.aiGateway.baseUrl,
|
||||
);
|
||||
final baseUrl = normalizeAiGatewayBaseUrlInternal(aiGatewayUrl);
|
||||
if (baseUrl == null) {
|
||||
appendAssistantThreadMessageInternal(
|
||||
sessionKey,
|
||||
|
||||
@ -128,7 +128,8 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
);
|
||||
final existing = assistantThreadRecordsInternal[normalizedSessionKey]
|
||||
final existing =
|
||||
assistantThreadRecordsInternal[normalizedSessionKey]
|
||||
?.workspaceBinding
|
||||
.workspacePath
|
||||
.trim() ??
|
||||
@ -382,7 +383,7 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
final fallbackReady = singleAgentUsesAiChatFallbackForSession(
|
||||
normalizedSessionKey,
|
||||
);
|
||||
final host = aiGatewayHostLabelInternal(settings.aiGateway.baseUrl);
|
||||
final host = aiGatewayHostLabelInternal(aiGatewayUrl);
|
||||
final providerReady = resolvedProvider != null;
|
||||
final detail = providerReady
|
||||
? joinConnectionPartsInternal(<String>[resolvedProvider.label, model])
|
||||
|
||||
@ -51,7 +51,7 @@ import 'app_controller_desktop_runtime_helpers.dart';
|
||||
Future<String> loadAiGatewayApiKeyThreadSessionInternal(
|
||||
AppController controller,
|
||||
) async {
|
||||
return (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
return controller.settingsControllerInternal.loadEffectiveAiGatewayApiKey();
|
||||
}
|
||||
|
||||
Future<void> saveMultiAgentConfigThreadSessionInternal(
|
||||
|
||||
@ -4,6 +4,7 @@ import '../../app/app_controller.dart';
|
||||
import '../../app/app_metadata.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../models/app_models.dart';
|
||||
import '../../runtime/runtime_controllers.dart';
|
||||
import '../../runtime/runtime_models.dart';
|
||||
import '../../widgets/section_tabs.dart';
|
||||
import '../../widgets/surface_card.dart';
|
||||
@ -22,6 +23,8 @@ class _AccountPageState extends State<AccountPage> {
|
||||
AccountTab _tab = AccountTab.profile;
|
||||
late final TextEditingController _accountBaseUrlController;
|
||||
late final TextEditingController _accountUsernameController;
|
||||
late final TextEditingController _accountPasswordController;
|
||||
late final TextEditingController _accountMfaCodeController;
|
||||
late final TextEditingController _accountWorkspaceController;
|
||||
String _lastSavedAccountBaseUrl = '';
|
||||
String _lastSavedAccountUsername = '';
|
||||
@ -40,6 +43,8 @@ class _AccountPageState extends State<AccountPage> {
|
||||
_accountUsernameController = TextEditingController(
|
||||
text: _lastSavedAccountUsername,
|
||||
);
|
||||
_accountPasswordController = TextEditingController();
|
||||
_accountMfaCodeController = TextEditingController();
|
||||
_accountWorkspaceController = TextEditingController(
|
||||
text: _lastSavedAccountWorkspace,
|
||||
);
|
||||
@ -49,6 +54,8 @@ class _AccountPageState extends State<AccountPage> {
|
||||
void dispose() {
|
||||
_accountBaseUrlController.dispose();
|
||||
_accountUsernameController.dispose();
|
||||
_accountPasswordController.dispose();
|
||||
_accountMfaCodeController.dispose();
|
||||
_accountWorkspaceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@ -89,14 +96,65 @@ class _AccountPageState extends State<AccountPage> {
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
final settings = controller.settings;
|
||||
_syncControllers(settings);
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
animation: Listenable.merge(<Listenable>[
|
||||
controller,
|
||||
controller.settingsController,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final settings = controller.settings;
|
||||
final settingsController = controller.settingsController;
|
||||
_syncControllers(settings);
|
||||
final accountSession = settingsController.accountSession;
|
||||
final accountProfile = settingsController.accountProfile;
|
||||
final accountBusy = settingsController.accountBusy;
|
||||
final accountSignedIn = settingsController.accountSignedIn;
|
||||
final accountMfaRequired = settingsController.accountMfaRequired;
|
||||
final signedInLabel = accountSession?.email.trim().isNotEmpty == true
|
||||
? accountSession!.email.trim()
|
||||
: accountSession?.name.trim().isNotEmpty == true
|
||||
? accountSession!.name.trim()
|
||||
: appText('当前账号', 'Current account');
|
||||
final sessionStatusText = accountSignedIn
|
||||
? appText('已登录:$signedInLabel', 'Signed in: $signedInLabel')
|
||||
: accountMfaRequired
|
||||
? appText('等待双重验证', 'Waiting for MFA verification')
|
||||
: appText('未登录', 'Signed out');
|
||||
final syncStatusText = accountProfile == null
|
||||
? appText('idle · 尚未同步远程配置', 'idle · Remote config not synced yet')
|
||||
: '${accountProfile.syncState} · ${accountProfile.syncMessage}';
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(32, 32, 32, 8),
|
||||
child: Column(
|
||||
@ -154,6 +212,18 @@ class _AccountPageState extends State<AccountPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
sessionStatusText,
|
||||
key: const ValueKey('account-session-status'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
syncStatusText,
|
||||
key: const ValueKey('account-sync-status'),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('account-base-url-field'),
|
||||
controller: _accountBaseUrlController,
|
||||
@ -172,6 +242,63 @@ class _AccountPageState extends State<AccountPage> {
|
||||
onFieldSubmitted: (_) => _saveProfile(settings),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('account-password-field'),
|
||||
controller: _accountPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('密码', 'Password'),
|
||||
),
|
||||
onFieldSubmitted: (_) => _loginAccount(settings),
|
||||
),
|
||||
if (accountMfaRequired) ...[
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
key: const ValueKey('account-mfa-code-field'),
|
||||
controller: _accountMfaCodeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('双重验证代码', 'MFA Code'),
|
||||
),
|
||||
onFieldSubmitted: (_) => _verifyAccountMfa(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton(
|
||||
key: const ValueKey('account-login-button'),
|
||||
onPressed: accountBusy
|
||||
? null
|
||||
: () => _loginAccount(settings),
|
||||
child: Text(appText('登录', 'Log In')),
|
||||
),
|
||||
if (accountMfaRequired)
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('account-verify-mfa-button'),
|
||||
onPressed: accountBusy ? null : _verifyAccountMfa,
|
||||
child: Text(appText('验证 MFA', 'Verify MFA')),
|
||||
),
|
||||
if (accountSignedIn)
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('account-sync-button'),
|
||||
onPressed: accountBusy
|
||||
? null
|
||||
: () => _syncAccountManagedSecrets(settings),
|
||||
child: Text(
|
||||
appText('同步远程配置', 'Sync Remote Config'),
|
||||
),
|
||||
),
|
||||
if (accountSignedIn)
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('account-logout-button'),
|
||||
onPressed: accountBusy ? null : _logoutAccount,
|
||||
child: Text(appText('退出登录', 'Log Out')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton(
|
||||
|
||||
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:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'account_runtime_client.dart';
|
||||
import 'gateway_runtime.dart';
|
||||
import 'runtime_models.dart';
|
||||
import 'secure_config_store.dart';
|
||||
import 'runtime_controllers_gateway.dart';
|
||||
import 'runtime_controllers_entities.dart';
|
||||
import 'runtime_controllers_derived_tasks.dart';
|
||||
import 'runtime_controllers_settings_account_impl.dart';
|
||||
import 'runtime_controllers_settings_connectivity_impl.dart';
|
||||
|
||||
part 'runtime_controllers_settings_account.dart';
|
||||
|
||||
class SettingsController extends ChangeNotifier {
|
||||
SettingsController(this.storeInternal);
|
||||
SettingsController(
|
||||
this.storeInternal, {
|
||||
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
|
||||
}) : accountClientFactoryInternal = accountClientFactory;
|
||||
|
||||
final SecureConfigStore storeInternal;
|
||||
final AccountRuntimeClient Function(String baseUrl)?
|
||||
accountClientFactoryInternal;
|
||||
bool disposedInternal = false;
|
||||
final List<StreamSubscription<FileSystemEvent>>
|
||||
settingsWatchSubscriptionsInternal = <StreamSubscription<FileSystemEvent>>[];
|
||||
@ -30,6 +39,13 @@ class SettingsController extends ChangeNotifier {
|
||||
String ollamaStatusInternal = 'Idle';
|
||||
String vaultStatusInternal = 'Idle';
|
||||
String aiGatewayStatusInternal = 'Idle';
|
||||
String accountSessionTokenInternal = '';
|
||||
AccountSessionSummary? accountSessionInternal;
|
||||
AccountRemoteProfile? accountProfileInternal;
|
||||
bool accountBusyInternal = false;
|
||||
String accountStatusInternal = 'Signed out';
|
||||
String pendingAccountMfaTicketInternal = '';
|
||||
String pendingAccountBaseUrlInternal = '';
|
||||
|
||||
SettingsSnapshot get snapshot => snapshotInternal;
|
||||
Map<String, String> get secureRefs => secureRefsInternal;
|
||||
@ -186,7 +202,12 @@ class SettingsController extends ChangeNotifier {
|
||||
secureRefsInternal.containsKey(
|
||||
SecretStore.gatewayTokenRefKey(profileIndex),
|
||||
) ||
|
||||
secureRefsInternal.containsKey('gateway_token');
|
||||
secureRefsInternal.containsKey('gateway_token') ||
|
||||
(!snapshotInternal.accountLocalMode &&
|
||||
profileIndex == kGatewayRemoteProfileIndex &&
|
||||
secureRefsInternal.containsKey(
|
||||
kAccountManagedSecretTargetOpenclawGatewayToken,
|
||||
));
|
||||
|
||||
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
|
||||
secureRefsInternal.containsKey(
|
||||
@ -196,7 +217,11 @@ class SettingsController extends ChangeNotifier {
|
||||
|
||||
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
|
||||
secureRefsInternal[SecretStore.gatewayTokenRefKey(profileIndex)] ??
|
||||
secureRefsInternal['gateway_token'];
|
||||
secureRefsInternal['gateway_token'] ??
|
||||
(!snapshotInternal.accountLocalMode &&
|
||||
profileIndex == kGatewayRemoteProfileIndex
|
||||
? secureRefsInternal[kAccountManagedSecretTargetOpenclawGatewayToken]
|
||||
: null);
|
||||
|
||||
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
|
||||
secureRefsInternal[SecretStore.gatewayPasswordRefKey(profileIndex)] ??
|
||||
@ -274,6 +299,12 @@ class SettingsController extends ChangeNotifier {
|
||||
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
}
|
||||
|
||||
Future<void> clearAiGatewayApiKey() async {
|
||||
await storeInternal.clearAiGatewayApiKey();
|
||||
await reloadDerivedStateInternal();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> appendAudit(SecretAuditEntry entry) async {
|
||||
await storeInternal.appendAudit(entry);
|
||||
auditTrailInternal = await storeInternal.loadAuditTrail();
|
||||
@ -335,71 +366,6 @@ class SettingsController extends ChangeNotifier {
|
||||
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) {
|
||||
final trimmed = raw.trim();
|
||||
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_profiles.dart';
|
||||
export 'runtime_models_configs.dart';
|
||||
export 'runtime_models_account.dart';
|
||||
export 'runtime_models_settings_snapshot.dart';
|
||||
export 'runtime_models_runtime_payloads.dart';
|
||||
export 'runtime_models_gateway_entities.dart';
|
||||
|
||||
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 _vaultTokenKey = 'xworkmate.vault.token';
|
||||
static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key';
|
||||
static const String _accountSessionTokenKey =
|
||||
'xworkmate.account.session.token';
|
||||
static const String _accountSessionSummaryKey =
|
||||
'xworkmate.account.session.summary';
|
||||
static const String _accountProfileKey = 'xworkmate.account.profile';
|
||||
|
||||
final StoreLayoutResolver _layoutResolver;
|
||||
final SecureStorageClient? _secureStorageOverride;
|
||||
@ -212,6 +217,69 @@ class SecretStore {
|
||||
|
||||
Future<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 {
|
||||
await initialize();
|
||||
final secureRefs = <String, String>{};
|
||||
@ -257,6 +325,12 @@ class SecretStore {
|
||||
if (aiGatewayApiKey case final value?) {
|
||||
secureRefs['ai_gateway_api_key'] = value;
|
||||
}
|
||||
for (final target in kAccountManagedSecretTargets) {
|
||||
final managedValue = await loadAccountManagedSecret(target: target);
|
||||
if (managedValue case final value?) {
|
||||
secureRefs[target] = value;
|
||||
}
|
||||
}
|
||||
return secureRefs;
|
||||
}
|
||||
|
||||
@ -355,6 +429,9 @@ class SecretStore {
|
||||
4,
|
||||
];
|
||||
|
||||
static String _accountManagedSecretKey(String target) =>
|
||||
'xworkmate.account.managed.${target.trim()}';
|
||||
|
||||
Future<String?> _readSecure(String key) async {
|
||||
await initialize();
|
||||
final client = _secureStorage;
|
||||
|
||||
@ -165,6 +165,46 @@ class SecureConfigStore {
|
||||
|
||||
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() {
|
||||
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/widgets/pane_resize_handle.dart';
|
||||
import '../test_support.dart';
|
||||
import '../runtime/app_controller_thread_skills_suite_fixtures.dart';
|
||||
import 'assistant_page_suite_core.dart';
|
||||
import 'assistant_page_suite_composer.dart';
|
||||
|
||||
@ -175,6 +176,150 @@ Future<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 {
|
||||
PendingSendAppControllerInternal({
|
||||
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(
|
||||
'SecureConfigStore falls back to file-backed device identity and token across instances',
|
||||
() 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:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/runtime/account_runtime_client.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||
@ -46,6 +47,7 @@ Future<AppController> createTestController(
|
||||
WidgetTester tester, {
|
||||
DesktopPlatformService? desktopPlatformService,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
|
||||
List<String>? singleAgentSharedSkillScanRootOverrides,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
@ -59,6 +61,7 @@ Future<AppController> createTestController(
|
||||
),
|
||||
desktopPlatformService: desktopPlatformService,
|
||||
uiFeatureManifest: uiFeatureManifest,
|
||||
accountClientFactory: accountClientFactory,
|
||||
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