Simplify bridge login sync

This commit is contained in:
Haitao Pan 2026-04-11 17:32:30 +08:00
parent 46af5603ec
commit 512f4babbe
8 changed files with 379 additions and 379 deletions

View File

@ -731,17 +731,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
Uri? resolveBridgeAcpEndpointInternal() {
final rawEndpoint =
settings.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary
.endpoint
.trim();
final rawEndpoint = settings
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.endpoint
.trim();
if (rawEndpoint.isEmpty) {
return null;
}
final uri = Uri.tryParse(rawEndpoint);
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
if (uri == null ||
!kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
return null;
}
return uri.replace(query: null, fragment: null);
@ -789,33 +790,34 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
final normalizedHost = endpoint.host.trim().toLowerCase();
final bridgeHost =
Uri.tryParse(
settings.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary
settings
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.endpoint
.trim(),
)?.host
.trim()
.toLowerCase() ??
)?.host.trim().toLowerCase() ??
'';
if (bridgeHost.isNotEmpty && normalizedHost == bridgeHost) {
final accountToken =
(await storeInternal.loadAccountSessionToken())?.trim() ?? '';
if (accountToken.isNotEmpty) {
return 'Bearer $accountToken';
final bridgeToken =
(await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
))?.trim() ??
'';
if (bridgeToken.isNotEmpty) {
return 'Bearer $bridgeToken';
}
}
final profileIndex =
gatewayProfileIndexMatchingEndpointInternal(endpoint) ??
kGatewayRemoteProfileIndex;
final gatewayToken = await settingsControllerInternal.loadEffectiveGatewayToken(
profileIndex: profileIndex,
);
final gatewayToken = await settingsControllerInternal
.loadEffectiveGatewayToken(profileIndex: profileIndex);
if (gatewayToken.isNotEmpty) {
return 'Bearer $gatewayToken';
}
final gatewayPassword =
await settingsControllerInternal.loadEffectiveGatewayPassword(
profileIndex: profileIndex,
);
final gatewayPassword = await settingsControllerInternal
.loadEffectiveGatewayPassword(profileIndex: profileIndex);
if (gatewayPassword.isNotEmpty) {
final encoded = base64Encode(utf8.encode('operator:$gatewayPassword'));
return 'Basic $encoded';
@ -825,7 +827,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
int? gatewayProfileIndexMatchingEndpointInternal(Uri endpoint) {
final normalizedHost = endpoint.host.trim().toLowerCase();
final gateway = gatewayProfileBaseUriInternal(settings.primaryGatewayProfile);
final gateway = gatewayProfileBaseUriInternal(
settings.primaryGatewayProfile,
);
if (gateway != null &&
gateway.host.trim().toLowerCase() == normalizedHost &&
gateway.port == endpoint.port) {

View File

@ -162,30 +162,6 @@ class AccountRuntimeClient {
return _accountSessionSummaryFromUserJson(user);
}
Future<AccountProfileResponse> loadProfile({required String token}) async {
final payload = await _requestJson(
method: 'GET',
path: '/api/auth/xworkmate/profile',
bearerToken: token,
);
final profile = _asMap(payload['profile']);
final remoteProfile = 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),
);
return AccountProfileResponse(
profile: remoteProfile,
profileScope: _stringValue(payload['profileScope']),
tokenConfigured: AccountTokenConfigured.fromJson(
_asMap(payload['tokenConfigured']),
),
);
}
Future<BridgeBootstrapIssue> createBridgeBootstrapTicket({
required String token,
}) async {
@ -266,26 +242,6 @@ class AccountRuntimeClient {
);
}
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'^/+|/+$'), '');

View File

@ -144,12 +144,17 @@ Future<void> completeAccountSignInSettingsInternal(
);
await controller.storeInternal.saveAccountSessionIdentifier(identifier);
await controller.storeInternal.saveAccountSessionSummary(sessionSummary);
controller.accountStatusInternal = 'Signed in';
await restoreAccountSessionSettingsInternal(
await syncAccountSettingsInternal(
controller,
baseUrl: baseUrl,
bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload),
quiet: true,
);
await controller.reloadDerivedStateInternal();
final email = controller.accountSessionInternal?.email.trim() ?? '';
controller.accountStatusInternal = email.isEmpty
? 'Signed in'
: 'Signed in as $email';
}
Future<void> restoreAccountSessionSettingsInternal(
@ -219,20 +224,18 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
SettingsController controller, {
String baseUrl = '',
bool quiet = false,
String bridgeTokenOverride = '',
}) async {
final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal(
baseUrl,
fallback: controller.snapshotInternal.accountBaseUrl,
);
final token =
final sessionToken =
(await controller.storeInternal.loadAccountSessionToken())?.trim() ?? '';
if (normalizedBaseUrl.isEmpty || token.isEmpty) {
if (sessionToken.isEmpty) {
const result = AccountSyncResult(
state: 'blocked',
message: 'Account session is unavailable',
);
controller.accountStatusInternal = result.message;
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return result;
@ -240,172 +243,90 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
if (!quiet) {
controller.accountBusyInternal = true;
controller.accountStatusInternal = 'Syncing remote defaults...';
controller.accountStatusInternal = 'Syncing bridge access...';
controller.notifyListeners();
}
try {
final client = controller.buildAccountClient(normalizedBaseUrl);
final response = await client.loadProfile(token: token);
final previousState =
await controller.storeInternal.loadAccountSyncState() ??
AccountSyncState.defaults();
final nextState = previousState.copyWith(
syncedDefaults: response.profile,
syncState: 'ready',
syncMessage: 'Remote defaults synced',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: normalizedBaseUrl,
lastSyncError: '',
profileScope: response.profileScope,
tokenConfigured: response.tokenConfigured,
final bridgeToken = bridgeTokenOverride.trim().isNotEmpty
? bridgeTokenOverride.trim()
: ((await controller.storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
))?.trim() ??
'');
if (bridgeToken.isEmpty) {
const result = AccountSyncResult(
state: 'blocked',
message: 'Bridge authorization is unavailable',
);
await controller.storeInternal.saveAccountSyncState(nextState);
final currentSettings = controller.snapshotInternal;
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
final nextModeConfig = currentModeConfig.copyWith(
cloudSynced: currentModeConfig.cloudSynced.copyWith(
accountBaseUrl: normalizedBaseUrl,
accountIdentifier: currentSettings.accountUsername.trim().isNotEmpty
? currentSettings.accountUsername.trim()
: controller.accountSessionInternal?.email.trim() ?? '',
lastSyncAt: nextState.lastSyncAtMs,
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
.copyWith(
endpoint: _kProductionBridgeEndpoint,
hasAdvancedOverrides: false,
),
),
);
if (nextModeConfig.toJson().toString() !=
currentModeConfig.toJson().toString()) {
await controller.saveSnapshot(
currentSettings.copyWith(
accountLocalMode: false,
acpBridgeServerModeConfig: nextModeConfig,
),
);
}
await applyAccountSyncedDefaultsSettingsInternal(
controller,
state: nextState,
);
await controller.reloadDerivedStateInternal();
final email = controller.accountSessionInternal?.email.trim() ?? '';
controller.accountStatusInternal = email.isEmpty
? 'Signed in'
: 'Signed in as $email';
return const AccountSyncResult(
state: 'ready',
message: 'Remote defaults synced',
);
} on AccountRuntimeException catch (error) {
final previousState =
await controller.storeInternal.loadAccountSyncState() ??
AccountSyncState.defaults();
if (_isNonBlockingAccountProfileSyncError(error)) {
final fallbackState = previousState.copyWith(
syncState: 'ready',
syncMessage: 'Remote defaults unavailable; using existing settings',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: normalizedBaseUrl,
lastSyncError: error.message,
);
await controller.storeInternal.saveAccountSyncState(fallbackState);
await controller.reloadDerivedStateInternal();
final email = controller.accountSessionInternal?.email.trim() ?? '';
controller.accountStatusInternal = email.isEmpty
? 'Signed in'
: 'Signed in as $email';
return const AccountSyncResult(
state: 'ready',
message: 'Remote defaults unavailable; using existing settings',
);
}
final errorState = previousState.copyWith(
syncState: 'error',
syncMessage: error.message,
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: normalizedBaseUrl,
lastSyncError: error.message,
);
await controller.storeInternal.saveAccountSyncState(errorState);
await controller.reloadDerivedStateInternal();
controller.accountStatusInternal = error.message;
return AccountSyncResult(state: 'error', message: error.message);
} finally {
controller.accountStatusInternal = result.message;
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
}
}
bool _isNonBlockingAccountProfileSyncError(AccountRuntimeException error) {
return error.errorCode == 'xworkmate_secret_read_failed';
}
Future<void> applyAccountSyncedDefaultsSettingsInternal(
SettingsController controller, {
required AccountSyncState state,
}) async {
final previous = controller.snapshotInternal;
var next = previous;
final defaults = state.syncedDefaults;
if (defaults.vaultUrl.trim().isNotEmpty) {
next = next.copyWith(
vault: next.vault.copyWith(address: defaults.vaultUrl.trim()),
);
return result;
}
if (defaults.vaultNamespace.trim().isNotEmpty) {
next = next.copyWith(
vault: next.vault.copyWith(namespace: defaults.vaultNamespace.trim()),
);
}
final aiGatewayLocator = defaults.locatorForTarget(
kAccountManagedSecretTargetAIGatewayAccessToken,
await controller.storeInternal.saveAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
value: bridgeToken,
);
if (aiGatewayLocator != null) {
next = next.copyWith(
aiGateway: next.aiGateway.copyWith(apiKeyRef: aiGatewayLocator.target),
);
}
final ollamaLocator = defaults.locatorForTarget(
kAccountManagedSecretTargetOllamaCloudApiKey,
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
);
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetOllamaCloudApiKey,
);
if (ollamaLocator != null) {
next = next.copyWith(
ollamaCloud: next.ollamaCloud.copyWith(apiKeyRef: ollamaLocator.target),
);
}
if (next.accountLocalMode) {
next = next.copyWith(accountLocalMode: false);
}
next = next.copyWith(
acpBridgeServerModeConfig: next.acpBridgeServerModeConfig.copyWith(
cloudSynced: next.acpBridgeServerModeConfig.cloudSynced.copyWith(
accountBaseUrl: next.accountBaseUrl,
accountIdentifier: next.accountUsername,
lastSyncAt: state.lastSyncAtMs,
remoteServerSummary: next
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.copyWith(
endpoint: _kProductionBridgeEndpoint,
hasAdvancedOverrides: false,
),
),
final nextState = AccountSyncState.defaults().copyWith(
syncState: 'ready',
syncMessage: 'Bridge access synced',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: _kProductionBridgeEndpoint,
lastSyncError: '',
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
openclaw: true,
vault: false,
apisix: false,
),
);
if (next.toJsonString() != previous.toJsonString()) {
await controller.saveSnapshot(next);
await controller.storeInternal.saveAccountSyncState(nextState);
final currentSettings = controller.snapshotInternal;
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
final nextModeConfig = currentModeConfig.copyWith(
cloudSynced: currentModeConfig.cloudSynced.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
lastSyncAt: nextState.lastSyncAtMs,
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
.copyWith(
endpoint: _kProductionBridgeEndpoint,
hasAdvancedOverrides: false,
),
),
);
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
currentSettings.copyWith(
accountLocalMode: false,
acpBridgeServerModeConfig: nextModeConfig,
),
);
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
await controller.saveSnapshot(sanitizedSettings);
}
await controller.reloadDerivedStateInternal();
final email = controller.accountSessionInternal?.email.trim() ?? '';
controller.accountStatusInternal = email.isEmpty
? 'Signed in'
: 'Signed in as $email';
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return const AccountSyncResult(
state: 'ready',
message: 'Bridge access synced',
);
}
Future<void> logoutAccountSettingsInternal(
@ -429,6 +350,7 @@ Future<void> logoutAccountSettingsInternal(
final currentSnapshot = controller.snapshotInternal;
final clearedCloudSync = currentSnapshot.acpBridgeServerModeConfig.cloudSynced
.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
lastSyncAt: 0,
remoteServerSummary: currentSnapshot
@ -502,6 +424,39 @@ String normalizeAccountBaseUrlSettingsInternal(
: candidate;
}
SettingsSnapshot _sanitizeBridgeOnlyAccountSyncSettings(
SettingsSnapshot settings,
) {
final normalizedAiGatewayRef =
settings.aiGateway.apiKeyRef.trim() ==
kAccountManagedSecretTargetAIGatewayAccessToken
? AiGatewayProfile.defaults().apiKeyRef
: settings.aiGateway.apiKeyRef;
final normalizedOllamaRef =
settings.ollamaCloud.apiKeyRef.trim() ==
kAccountManagedSecretTargetOllamaCloudApiKey
? OllamaCloudConfig.defaults().apiKeyRef
: settings.ollamaCloud.apiKeyRef;
return settings.copyWith(
aiGateway: settings.aiGateway.copyWith(apiKeyRef: normalizedAiGatewayRef),
ollamaCloud: settings.ollamaCloud.copyWith(apiKeyRef: normalizedOllamaRef),
);
}
String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
final explicit = _stringValue(payload['internalServiceToken']).isNotEmpty
? _stringValue(payload['internalServiceToken'])
: _stringValue(payload['internal_service_token']).isNotEmpty
? _stringValue(payload['internal_service_token'])
: _stringValue(payload['bridgeAuthToken']).isNotEmpty
? _stringValue(payload['bridgeAuthToken'])
: _stringValue(payload['bridge_auth_token']);
if (explicit.isNotEmpty) {
return explicit;
}
return '';
}
int _parseExpiresAtMs(Object? value) {
if (value is int) {
return value;

View File

@ -593,18 +593,6 @@ class AcpBridgeServerModeConfig {
}
}
class AccountProfileResponse {
const AccountProfileResponse({
required this.profile,
required this.profileScope,
required this.tokenConfigured,
});
final AccountRemoteProfile profile;
final String profileScope;
final AccountTokenConfigured tokenConfigured;
}
class AccountSyncState {
const AccountSyncState({
required this.syncedDefaults,

View File

@ -8,12 +8,14 @@ import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/runtime/account_runtime_client.dart';
import 'package:xworkmate/runtime/codex_config_bridge.dart';
import 'package:xworkmate/runtime/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_controllers.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
@ -330,6 +332,68 @@ void main() {
);
});
group('resolveGatewayAcpAuthorizationHeaderInternal', () {
test(
'prefers the synced bridge bearer token over the account session token',
() async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-bridge-auth-header-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
accountClientFactory: (_) => _BridgeSyncAccountRuntimeClient(),
);
addTearDown(() async {
controller.dispose();
if (await root.exists()) {
try {
await root.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best-effort on macOS when sqlite/watch handles lag.
}
}
});
await store.initialize();
await controller.settingsController.initialize();
await controller.settingsController.saveSnapshot(
controller.settings.copyWith(
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
),
);
await controller.settingsController.loginAccount(
baseUrl: 'https://accounts.svc.plus',
identifier: 'review@svc.plus',
password: '***REMOVED-CREDENTIAL***',
);
await controller.settingsController.saveGatewaySecrets(
profileIndex: kGatewayRemoteProfileIndex,
token: 'local-token',
password: '',
);
final bridgeAuthorization = await controller
.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('https://xworkmate-bridge.svc.plus/acp'),
);
final nonBridgeAuthorization = await controller
.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('https://remote.example.com/acp'),
);
expect(bridgeAuthorization, 'Bearer bridge-token');
expect(nonBridgeAuthorization, 'Bearer local-token');
},
);
});
group('selected working directory', () {
test(
'persists thread project directory without changing local workspace binding',
@ -418,3 +482,27 @@ class _FakeGatewayRuntimeDeps {
final SecureConfigStore store;
final DeviceIdentityStore identityStore;
}
class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient {
_BridgeSyncAccountRuntimeClient()
: super(baseUrl: 'https://accounts.svc.plus');
@override
Future<Map<String, dynamic>> login({
required String identifier,
required String password,
}) async {
return <String, dynamic>{
'token': 'session-token',
'internalServiceToken': 'bridge-token',
'expiresAt': '2026-04-12T00:00:00Z',
'user': <String, dynamic>{
'id': 'u-1',
'email': identifier,
'name': 'Review',
'role': 'member',
'mfaEnabled': false,
},
};
}
}

View File

@ -11,7 +11,7 @@ void main() {
group('syncAccountSettings overwrite policy', () {
test(
'always overwrites sync-owned fields and stores metadata only',
'rewrites only bridge-owned auth metadata and removes old synced secret refs',
() async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-account-sync-overwrite-',
@ -59,13 +59,25 @@ void main() {
),
aiGateway: controller.snapshot.aiGateway.copyWith(
baseUrl: 'https://local-apisix.example.com',
apiKeyRef: 'local_ai_ref',
apiKeyRef: kAccountManagedSecretTargetAIGatewayAccessToken,
),
ollamaCloud: controller.snapshot.ollamaCloud.copyWith(
apiKeyRef: 'local_ollama_ref',
apiKeyRef: kAccountManagedSecretTargetOllamaCloudApiKey,
),
),
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
value: 'stale-ai-token',
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetOllamaCloudApiKey,
value: 'stale-ollama-token',
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
value: 'bridge-token',
);
final first = await controller.syncAccountSettings(
baseUrl: 'https://accounts.svc.plus',
@ -82,21 +94,61 @@ void main() {
.tokenRef,
'local_ref',
);
expect(controller.snapshot.vault.address, 'https://vault.svc.plus');
expect(controller.snapshot.vault.namespace, 'prod');
expect(
controller.snapshot.vault.address,
'https://local-vault.example.com',
);
expect(controller.snapshot.vault.namespace, 'local');
expect(
controller.snapshot.aiGateway.baseUrl,
'https://local-apisix.example.com',
);
expect(
controller.snapshot.aiGateway.apiKeyRef,
kAccountManagedSecretTargetAIGatewayAccessToken,
AiGatewayProfile.defaults().apiKeyRef,
);
expect(
controller.snapshot.ollamaCloud.apiKeyRef,
kAccountManagedSecretTargetOllamaCloudApiKey,
OllamaCloudConfig.defaults().apiKeyRef,
);
expect(controller.snapshot.accountLocalMode, isFalse);
expect(controller.accountSyncState?.profileScope, 'bridge');
expect(controller.accountSyncState?.tokenConfigured.openclaw, isTrue);
expect(controller.accountSyncState?.tokenConfigured.apisix, isFalse);
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
),
'bridge-token',
);
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
),
isNull,
);
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetOllamaCloudApiKey,
),
isNull,
);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.accountBaseUrl,
isEmpty,
);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.accountIdentifier,
isEmpty,
);
await controller.saveSnapshot(
controller.snapshot.copyWith(
@ -113,7 +165,7 @@ void main() {
baseUrl: 'https://accounts.svc.plus',
);
expect(second.state, 'ready');
expect(controller.snapshot.vault.address, 'https://vault.svc.plus');
expect(controller.snapshot.vault.address, 'https://edited.example.com');
expect(
controller.snapshot.aiGateway.baseUrl,
'https://edited-apisix.example.com',
@ -142,49 +194,4 @@ void main() {
class _FakeAccountRuntimeClient extends AccountRuntimeClient {
_FakeAccountRuntimeClient() : super(baseUrl: 'https://accounts.svc.plus');
@override
Future<AccountProfileResponse> loadProfile({required String token}) async {
expect(token, 'session-token');
return AccountProfileResponse(
profile: AccountRemoteProfile.defaults().copyWith(
openclawUrl: 'wss://remote.gateway.svc.plus',
vaultUrl: 'https://vault.svc.plus',
vaultNamespace: 'prod',
apisixUrl: 'https://apisix.svc.plus',
secretLocators: const <AccountSecretLocator>[
AccountSecretLocator(
id: 'gateway',
provider: 'vault',
secretPath: 'kv/xworkmate',
secretKey: 'gateway_token',
target: kAccountManagedSecretTargetOpenclawGatewayToken,
required: true,
),
AccountSecretLocator(
id: 'ai',
provider: 'vault',
secretPath: 'kv/xworkmate',
secretKey: 'ai_gateway_token',
target: kAccountManagedSecretTargetAIGatewayAccessToken,
required: true,
),
AccountSecretLocator(
id: 'ollama',
provider: 'vault',
secretPath: 'kv/xworkmate',
secretKey: 'ollama_key',
target: kAccountManagedSecretTargetOllamaCloudApiKey,
required: true,
),
],
),
profileScope: 'workspace',
tokenConfigured: const AccountTokenConfigured(
openclaw: true,
vault: true,
apisix: true,
),
);
}
}

View File

@ -48,15 +48,21 @@ void main() {
target: kAccountManagedSecretTargetAIGatewayAccessToken,
value: 'managed-secret',
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
value: 'bridge-token',
);
await store.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncState: 'ready',
syncMessage: 'Remote defaults synced',
syncMessage: 'Bridge access synced',
lastSyncAtMs: 123456789,
lastSyncSource: 'https://accounts.svc.plus',
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
openclawUrl: 'wss://gateway.svc.plus',
apisixUrl: 'https://apisix.svc.plus',
lastSyncSource: 'https://xworkmate-bridge.svc.plus',
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
openclaw: true,
vault: false,
apisix: false,
),
),
);
@ -98,12 +104,26 @@ void main() {
),
isNull,
);
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
),
isNull,
);
expect(await store.loadAccountSyncState(), isNull);
expect(controller.accountSignedIn, isFalse);
expect(controller.accountStatus, 'Signed out');
expect(controller.accountSyncState, isNull);
expect(controller.snapshot.accountLocalMode, isTrue);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.accountBaseUrl,
isEmpty,
);
expect(
controller
.snapshot

View File

@ -10,65 +10,71 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SettingsController account auth flow', () {
test(
'login persists session summary and synced profile metadata',
() async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-account-auth-login-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => root.path,
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = SettingsController(
store,
accountClientFactory: (_) => _SuccessfulAccountRuntimeClient(),
);
addTearDown(() async {
controller.dispose();
store.dispose();
if (await root.exists()) {
await root.delete(recursive: true);
}
});
test('login persists session summary and bridge sync metadata', () async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-account-auth-login-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => root.path,
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final client = _SuccessfulAccountRuntimeClient();
final controller = SettingsController(
store,
accountClientFactory: (_) => client,
);
addTearDown(() async {
controller.dispose();
store.dispose();
if (await root.exists()) {
await root.delete(recursive: true);
}
});
await store.initialize();
await controller.initialize();
await controller.saveSnapshot(
controller.snapshot.copyWith(
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
),
);
await store.initialize();
await controller.initialize();
await controller.saveSnapshot(
controller.snapshot.copyWith(
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
),
);
await controller.loginAccount(
baseUrl: 'https://accounts.svc.plus',
identifier: 'review@svc.plus',
password: '***REMOVED-CREDENTIAL***',
);
await controller.loginAccount(
baseUrl: 'https://accounts.svc.plus',
identifier: 'review@svc.plus',
password: '***REMOVED-CREDENTIAL***',
);
expect(controller.accountSignedIn, isTrue);
expect(controller.accountStatus, 'Signed in as review@svc.plus');
expect(controller.accountSession?.email, 'review@svc.plus');
expect(controller.accountSession?.totpEnabled, isTrue);
expect(controller.accountSession?.totpPending, isFalse);
expect(controller.accountSyncState?.syncState, 'ready');
expect(controller.accountSyncState?.profileScope, 'tenant-shared');
expect(controller.accountSyncState?.tokenConfigured.apisix, isTrue);
expect(await store.loadAccountSessionToken(), 'session-token');
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.endpoint,
'https://xworkmate-bridge.svc.plus',
);
},
);
expect(controller.accountSignedIn, isTrue);
expect(controller.accountStatus, 'Signed in as review@svc.plus');
expect(controller.accountSession?.email, 'review@svc.plus');
expect(controller.accountSession?.totpEnabled, isTrue);
expect(controller.accountSession?.totpPending, isFalse);
expect(controller.accountSyncState?.syncState, 'ready');
expect(controller.accountSyncState?.profileScope, 'bridge');
expect(controller.accountSyncState?.tokenConfigured.openclaw, isTrue);
expect(controller.accountSyncState?.tokenConfigured.apisix, isFalse);
expect(await store.loadAccountSessionToken(), 'session-token');
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetOpenclawGatewayToken,
),
'bridge-token',
);
expect(client.loadSessionCalls, 0);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.endpoint,
'https://xworkmate-bridge.svc.plus',
);
});
test('mfa challenge transitions to verified signed-in session', () async {
final root = await Directory.systemTemp.createTemp(
@ -130,6 +136,8 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient {
_SuccessfulAccountRuntimeClient()
: super(baseUrl: 'https://accounts.svc.plus');
int loadSessionCalls = 0;
@override
Future<Map<String, dynamic>> login({
required String identifier,
@ -139,6 +147,7 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient {
expect(password, '***REMOVED-CREDENTIAL***');
return <String, dynamic>{
'token': 'session-token',
'internalServiceToken': 'bridge-token',
'expiresAt': '2026-04-12T00:00:00Z',
'user': <String, dynamic>{
'id': 'u-1',
@ -153,6 +162,7 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient {
@override
Future<AccountSessionSummary> loadSession({required String token}) async {
loadSessionCalls += 1;
expect(token, 'session-token');
return const AccountSessionSummary(
userId: 'u-1',
@ -164,22 +174,6 @@ class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient {
totpPending: false,
);
}
@override
Future<AccountProfileResponse> loadProfile({required String token}) async {
expect(token, 'session-token');
return AccountProfileResponse(
profile: AccountRemoteProfile.defaults().copyWith(
apisixUrl: 'https://apisix.svc.plus',
),
profileScope: 'tenant-shared',
tokenConfigured: const AccountTokenConfigured(
openclaw: true,
vault: false,
apisix: true,
),
);
}
}
class _MfaAccountRuntimeClient extends AccountRuntimeClient {
@ -204,6 +198,7 @@ class _MfaAccountRuntimeClient extends AccountRuntimeClient {
lastVerifiedCode = code;
return <String, dynamic>{
'token': 'session-token',
'internalServiceToken': 'bridge-token',
'expiresAt': '2026-04-12T00:00:00Z',
'user': <String, dynamic>{
'id': 'u-1',
@ -228,17 +223,4 @@ class _MfaAccountRuntimeClient extends AccountRuntimeClient {
totpPending: false,
);
}
@override
Future<AccountProfileResponse> loadProfile({required String token}) async {
return AccountProfileResponse(
profile: AccountRemoteProfile.defaults(),
profileScope: 'tenant-shared',
tokenConfigured: const AccountTokenConfigured(
openclaw: true,
vault: false,
apisix: true,
),
);
}
}