Simplify bridge login sync
This commit is contained in:
parent
46af5603ec
commit
512f4babbe
@ -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) {
|
||||
|
||||
@ -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'^/+|/+$'), '');
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user