feat(account): add secure remote sync and installed-skill e2e

This commit is contained in:
Haitao Pan 2026-03-30 19:35:53 +08:00
parent c827f47ebf
commit 27ed4db18f
25 changed files with 2743 additions and 88 deletions

View 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`

View File

@ -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);

View File

@ -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();

View File

@ -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) {

View File

@ -319,9 +319,7 @@ extension AppControllerDesktopSingleAgent on AppController {
return;
}
final baseUrl = normalizeAiGatewayBaseUrlInternal(
settings.aiGateway.baseUrl,
);
final baseUrl = normalizeAiGatewayBaseUrlInternal(aiGatewayUrl);
if (baseUrl == null) {
appendAssistantThreadMessageInternal(
sessionKey,

View File

@ -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])

View File

@ -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(

View File

@ -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(

View 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() ?? '';
}
}

View File

@ -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) {

View 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';
}
}

View 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() ?? '';
}

View File

@ -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';

View 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());
}

View File

@ -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;

View File

@ -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();
}

View 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,
};
}
}

View 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.',
);
});
}

View File

@ -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();
}

View File

@ -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,

View File

@ -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 {

View 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));
}
}
}

View 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();
}

View File

@ -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,
);

View 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();
}
}