Refine bridge routing and settings cleanup
This commit is contained in:
parent
a05d84bbeb
commit
19f1ce306f
@ -3,17 +3,17 @@
|
||||
Last Updated: 2026-04-19
|
||||
|
||||
本文件记录当前 `Settings -> Integrations` 在主链中的职责边界,以及
|
||||
`acpBridgeServerModeConfig` 的有效配置仲裁规则。
|
||||
`acpBridgeServerModeConfig` 在 settings surface 中的配置仲裁规则。
|
||||
|
||||
## Current Rule
|
||||
|
||||
- Settings 只管理 Bridge 连接参数、account sync 元数据和本地编辑态
|
||||
- `AcpBridgeServerModeConfig.effective` 是运行时实际生效配置
|
||||
- `AcpBridgeServerModeConfig.effective` 只用于 settings surface 的连接与展示语义
|
||||
- `selfHosted` 优先级高于 `cloudSynced`
|
||||
- `cloudSynced` 只在 manual Bridge 未配置时作为有效回退来源
|
||||
- `cloudSynced` 只在 manual Bridge 未配置时作为 settings metadata 回退来源
|
||||
- app 不从本地 endpoint preset、旧 module 配置、历史 fallback 恢复 provider catalog
|
||||
- `xworkmate-bridge` 仍然是 provider catalog、gateway capability、routing resolve 的唯一真源
|
||||
- `BRIDGE_SERVER_URL` 只属于 `AccountSyncState` 元数据
|
||||
- `BRIDGE_SERVER_URL` 只属于 `AccountSyncState` 元数据,不参与 assistant runtime endpoint 选择
|
||||
- `BRIDGE_AUTH_TOKEN` 只进入 secure storage / managed secret
|
||||
|
||||
## Canonical State Model
|
||||
@ -119,18 +119,18 @@ stateDiagram-v2
|
||||
DefaultEffective --> CloudEffective: cloud sync 恢复
|
||||
|
||||
note right of BridgeEffective
|
||||
source = bridge
|
||||
effective.endpoint = selfHosted.serverUrl
|
||||
source = bridge metadata
|
||||
used by settings only
|
||||
end note
|
||||
|
||||
note right of CloudEffective
|
||||
source = cloud
|
||||
effective.endpoint = accountSyncState.syncedDefaults.bridgeServerUrl
|
||||
source = cloud metadata
|
||||
used by settings only
|
||||
end note
|
||||
|
||||
note right of DefaultEffective
|
||||
source = default
|
||||
effective.endpoint = kManagedBridgeServerUrl
|
||||
source = managed bridge origin
|
||||
assistant runtime fixed to kManagedBridgeServerUrl
|
||||
end note
|
||||
```
|
||||
|
||||
|
||||
@ -636,27 +636,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}
|
||||
|
||||
Uri? resolveBridgeAcpEndpointInternal() {
|
||||
final modeConfig = settings.acpBridgeServerModeConfig;
|
||||
|
||||
final cloudEndpoint = _activeCloudSyncedBridgeEndpointInternal();
|
||||
if (cloudEndpoint.isNotEmpty) {
|
||||
final uri = Uri.tryParse(cloudEndpoint);
|
||||
if (uri != null) return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
|
||||
if (modeConfig.usesSelfHostedBase) {
|
||||
final candidate = modeConfig.selfHosted.serverUrl.trim();
|
||||
if (candidate.isNotEmpty) {
|
||||
final uri = Uri.tryParse(candidate);
|
||||
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
|
||||
if (uri != null &&
|
||||
kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
final uri = Uri.parse(kManagedBridgeServerUrl);
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
|
||||
Uri? resolveExternalAcpEndpointForTargetInternal(AssistantExecutionTarget _) {
|
||||
@ -664,11 +645,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}
|
||||
|
||||
bool isBridgeAcpRuntimeConfiguredInternal() {
|
||||
final modeConfig = settings.acpBridgeServerModeConfig;
|
||||
if (modeConfig.usesSelfHostedBase) {
|
||||
return modeConfig.selfHosted.isConfigured;
|
||||
}
|
||||
return _activeCloudSyncedBridgeEndpointInternal().isNotEmpty;
|
||||
return true;
|
||||
}
|
||||
|
||||
Uri? resolveExternalAcpEndpointForRequestInternal(
|
||||
@ -677,22 +654,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return resolveBridgeAcpEndpointInternal();
|
||||
}
|
||||
|
||||
String _activeCloudSyncedBridgeEndpointInternal() {
|
||||
final syncState = settingsControllerInternal.accountSyncState;
|
||||
final syncedEndpoint =
|
||||
syncState?.syncedDefaults.bridgeServerUrl.trim() ?? '';
|
||||
|
||||
if (syncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||
syncState?.tokenConfigured.bridge == true &&
|
||||
syncedEndpoint.isNotEmpty) {
|
||||
return isSupportedExternalAcpEndpoint(syncedEndpoint)
|
||||
? syncedEndpoint
|
||||
: '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) {
|
||||
final host = profile.host.trim();
|
||||
if (host.isEmpty || profile.port <= 0) {
|
||||
@ -722,16 +683,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return envToken;
|
||||
}
|
||||
|
||||
final modeConfig = settings.acpBridgeServerModeConfig;
|
||||
if (modeConfig.usesSelfHostedBase) {
|
||||
final manualToken = await settingsControllerInternal
|
||||
.loadSecretValueByRef(modeConfig.selfHosted.passwordRef);
|
||||
if (manualToken.trim().isNotEmpty) {
|
||||
return manualToken.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final bridgeToken = (await storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
))?.trim();
|
||||
|
||||
@ -16,6 +16,78 @@ import '../../widgets/surface_card.dart';
|
||||
import 'settings_account_panel.dart';
|
||||
import 'settings_about_panel.dart';
|
||||
|
||||
Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
|
||||
required Uri bridgeEndpoint,
|
||||
required Future<String?> Function(Uri endpoint) authorizationResolver,
|
||||
HttpClient Function()? clientFactory,
|
||||
}) async {
|
||||
final pingEndpoint = bridgeEndpoint.replace(
|
||||
path: '/api/ping',
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
final authorizationHeader = await authorizationResolver(pingEndpoint);
|
||||
if (authorizationHeader == null || authorizationHeader.trim().isEmpty) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
|
||||
final client = (clientFactory ?? HttpClient.new)()
|
||||
..connectionTimeout = const Duration(seconds: 4);
|
||||
try {
|
||||
final request = await client
|
||||
.getUrl(pingEndpoint)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $authorizationHeader',
|
||||
);
|
||||
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
|
||||
final response = await request.close().timeout(const Duration(seconds: 4));
|
||||
final body = await utf8
|
||||
.decodeStream(response)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.cast<String, dynamic>();
|
||||
}
|
||||
} catch (_) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({
|
||||
super.key,
|
||||
@ -54,7 +126,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final settings = widget.controller.settings;
|
||||
_lastSavedAccountBaseUrl = settings.accountBaseUrl;
|
||||
_lastSavedAccountIdentifier = settings.accountUsername;
|
||||
_lastSavedBridgeUrl = settings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
_lastSavedBridgeUrl =
|
||||
settings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
_accountBaseUrlController = TextEditingController(
|
||||
text: _lastSavedAccountBaseUrl,
|
||||
);
|
||||
@ -83,7 +156,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
Future<void> _loadBridgeToken() async {
|
||||
final token = await widget.controller.settingsController
|
||||
.loadSecretValueByRef(widget.controller.settings.acpBridgeServerModeConfig.selfHosted.passwordRef);
|
||||
.loadSecretValueByRef(
|
||||
widget
|
||||
.controller
|
||||
.settings
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted
|
||||
.passwordRef,
|
||||
);
|
||||
if (mounted) {
|
||||
_bridgeTokenController.text = token;
|
||||
}
|
||||
@ -109,7 +189,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_lastSavedBridgeUrl = bridgeConfig.selfHosted.serverUrl;
|
||||
}
|
||||
|
||||
Future<void> _saveAccountProfile(
|
||||
Future<void> _persistAccountProfileSettings(
|
||||
SettingsSnapshot settings, {
|
||||
required bool isManualBridge,
|
||||
}) async {
|
||||
@ -121,11 +201,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve the effective config based on the new sources
|
||||
final nextEffective = widget.controller.settingsController.resolveAcpBridgeServerEffectiveConfig(
|
||||
config: nextBridgeConfig,
|
||||
accountSyncState: widget.controller.settingsController.accountSyncState,
|
||||
);
|
||||
final nextEffective = widget.controller.settingsController
|
||||
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
|
||||
|
||||
final nextSettings = settings.copyWith(
|
||||
accountBaseUrl: _accountBaseUrlController.text.trim(),
|
||||
@ -146,14 +223,15 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
_lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
|
||||
_lastSavedAccountIdentifier = nextSettings.accountUsername;
|
||||
_lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
_lastSavedBridgeUrl =
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
}
|
||||
|
||||
Future<void> _loginAccount(SettingsSnapshot settings) async {
|
||||
final baseUrl = _accountBaseUrlController.text.trim();
|
||||
final identifier = _accountIdentifierController.text.trim();
|
||||
try {
|
||||
await _saveAccountProfile(settings, isManualBridge: false);
|
||||
await _persistAccountProfileSettings(settings, isManualBridge: false);
|
||||
await widget.controller.settingsController.loginAccount(
|
||||
baseUrl: baseUrl,
|
||||
identifier: identifier,
|
||||
@ -166,7 +244,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _syncAccount(SettingsSnapshot settings) async {
|
||||
await _saveAccountProfile(settings, isManualBridge: false);
|
||||
await _persistAccountProfileSettings(settings, isManualBridge: false);
|
||||
await widget.controller.settingsController.syncAccountSettings(
|
||||
baseUrl: _accountBaseUrlController.text.trim(),
|
||||
);
|
||||
@ -176,7 +254,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
Future<void> _verifyAccountMfa(SettingsSnapshot settings) async {
|
||||
try {
|
||||
await _saveAccountProfile(settings, isManualBridge: false);
|
||||
await _persistAccountProfileSettings(settings, isManualBridge: false);
|
||||
await widget.controller.settingsController.verifyAccountMfa(
|
||||
baseUrl: _accountBaseUrlController.text.trim(),
|
||||
code: _accountMfaCodeController.text.trim(),
|
||||
@ -251,50 +329,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
|
||||
try {
|
||||
final request = await client
|
||||
.getUrl(Uri.parse('$kManagedBridgeServerUrl/api/ping'))
|
||||
.timeout(const Duration(seconds: 4));
|
||||
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
|
||||
final response = await request.close().timeout(const Duration(seconds: 4));
|
||||
final body = await utf8
|
||||
.decodeStream(response)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return <String, dynamic>{
|
||||
'status': 'error',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.cast<String, dynamic>();
|
||||
}
|
||||
} catch (_) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
return loadBridgeMetadataForSettingsAbout(
|
||||
bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl),
|
||||
authorizationResolver:
|
||||
widget.controller.resolveGatewayAcpAuthorizationHeaderInternal,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -357,12 +396,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
bridgeUrlController: _bridgeUrlController,
|
||||
bridgeTokenController: _bridgeTokenController,
|
||||
onSaveAccountProfile: ({required bool isManualBridge}) =>
|
||||
_saveAccountProfile(
|
||||
_persistAccountProfileSettings(
|
||||
widget.controller.settings,
|
||||
isManualBridge: isManualBridge,
|
||||
),
|
||||
onLogin: () => _loginAccount(widget.controller.settings),
|
||||
onVerifyMfa: () => _verifyAccountMfa(widget.controller.settings),
|
||||
onVerifyMfa: () =>
|
||||
_verifyAccountMfa(widget.controller.settings),
|
||||
onCancelMfa: _cancelAccountMfa,
|
||||
onSync: () => _syncAccount(widget.controller.settings),
|
||||
onLogout: _logoutAccount,
|
||||
|
||||
@ -538,7 +538,7 @@ class SettingsController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Best effort only. Directory watch below remains as a fallback.
|
||||
// Best effort only. If file watching fails, directory watching may still work.
|
||||
}
|
||||
}
|
||||
if (directory != null) {
|
||||
|
||||
@ -51,8 +51,7 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
Future<String> loadEffectiveGatewayToken({int? profileIndex}) async {
|
||||
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
|
||||
.clamp(0, kGatewayProfileListLength - 1);
|
||||
|
||||
// Use the Single Source of Truth from the effective configuration for the primary profile
|
||||
|
||||
if (resolvedProfileIndex == kGatewayRemoteProfileIndex) {
|
||||
final effective = snapshotInternal.acpBridgeServerModeConfig.effective;
|
||||
if (effective.tokenRef.isNotEmpty) {
|
||||
@ -63,7 +62,6 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
// Local Override / Vault / Cloud Sync (Fallback if effective token is missing or for other profiles)
|
||||
return resolveSecretValueInternal(
|
||||
refName: gatewayTokenRefForProfileInternal(resolvedProfileIndex),
|
||||
fallbackRefName: SecretStore.gatewayTokenRefKey(resolvedProfileIndex),
|
||||
@ -77,10 +75,6 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
|
||||
.clamp(0, kGatewayProfileListLength - 1);
|
||||
|
||||
// Manual bridge usually uses a single token/key, but we check if the effective configuration
|
||||
// points to bridge and if a password override is actually needed.
|
||||
// For now, we fall back to the standard resolution logic.
|
||||
|
||||
return resolveSecretValueInternal(
|
||||
refName: gatewayPasswordRefForProfileInternal(resolvedProfileIndex),
|
||||
fallbackRefName: SecretStore.gatewayPasswordRefKey(resolvedProfileIndex),
|
||||
@ -120,12 +114,7 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
|
||||
AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfig({
|
||||
required AcpBridgeServerModeConfig config,
|
||||
AccountSyncState? accountSyncState,
|
||||
}) => resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
this,
|
||||
config: config,
|
||||
accountSyncState: accountSyncState,
|
||||
);
|
||||
}) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config);
|
||||
|
||||
List<SecretReferenceEntry> buildSecretReferences() {
|
||||
final entries = <SecretReferenceEntry>[
|
||||
|
||||
@ -284,18 +284,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: bridgeToken,
|
||||
);
|
||||
final syncedBridgeServerUrl = _resolveBridgeServerUrl(syncPayload);
|
||||
if (!isSupportedExternalAcpEndpoint(syncedBridgeServerUrl)) {
|
||||
return _persistAccountSyncContractFailureInternal(
|
||||
controller,
|
||||
message: 'Bridge endpoint is unavailable',
|
||||
quiet: quiet,
|
||||
);
|
||||
}
|
||||
final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl(
|
||||
controller,
|
||||
bridgeServerUrlOverride: syncedBridgeServerUrl,
|
||||
);
|
||||
final syncedBridgeServerUrl = _extractBridgeServerUrlMetadata(syncPayload);
|
||||
await controller.storeInternal.clearAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
@ -305,12 +294,12 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
|
||||
final nextState = AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: resolvedBridgeServerUrl,
|
||||
bridgeServerUrl: syncedBridgeServerUrl,
|
||||
),
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Bridge access synced',
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncSource: resolvedBridgeServerUrl,
|
||||
lastSyncSource: syncedBridgeServerUrl,
|
||||
lastSyncError: '',
|
||||
profileScope: 'bridge',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
@ -326,7 +315,6 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
controller,
|
||||
config: currentModeConfig,
|
||||
accountSyncState: nextState,
|
||||
);
|
||||
|
||||
final identifier =
|
||||
@ -347,7 +335,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
lastSyncAt: nextState.lastSyncAtMs,
|
||||
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
|
||||
.copyWith(
|
||||
endpoint: nextEffective.endpoint,
|
||||
endpoint: syncedBridgeServerUrl,
|
||||
hasAdvancedOverrides: false,
|
||||
),
|
||||
),
|
||||
@ -578,7 +566,7 @@ Future<AccountSyncResult> _persistAccountSyncContractFailureInternal(
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveBridgeServerUrl(Map<String, dynamic> payload) {
|
||||
String _extractBridgeServerUrlMetadata(Map<String, dynamic> payload) {
|
||||
final explicit = _stringValue(payload['BRIDGE_SERVER_URL']);
|
||||
if (explicit.isNotEmpty) {
|
||||
return explicit;
|
||||
@ -593,10 +581,7 @@ String _resolveBridgeServerUrl(Map<String, dynamic> payload) {
|
||||
AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
SettingsController controller, {
|
||||
required AcpBridgeServerModeConfig config,
|
||||
AccountSyncState? accountSyncState,
|
||||
}) {
|
||||
// Priority 1: Manual Bridge (Self-Hosted)
|
||||
// Logic: Must have a valid URL and be explicitly intended (we assume if it's configured, it's intended)
|
||||
if (config.selfHosted.isConfigured) {
|
||||
return AcpBridgeServerEffectiveConfig(
|
||||
endpoint: config.selfHosted.serverUrl,
|
||||
@ -606,20 +591,6 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
);
|
||||
}
|
||||
|
||||
// Priority 2: Cloud Sync (svc.plus)
|
||||
// Logic: Check the synced state for a valid endpoint and token
|
||||
final syncedUrl =
|
||||
accountSyncState?.syncedDefaults.bridgeServerUrl.trim() ?? '';
|
||||
final hasSyncedToken = accountSyncState?.tokenConfigured.bridge == true;
|
||||
if (isSupportedExternalAcpEndpoint(syncedUrl) && hasSyncedToken) {
|
||||
return AcpBridgeServerEffectiveConfig(
|
||||
endpoint: syncedUrl,
|
||||
tokenRef: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
source: 'cloud',
|
||||
reason: 'Synced cloud configuration from svc.plus is active',
|
||||
);
|
||||
}
|
||||
|
||||
return AcpBridgeServerEffectiveConfig(
|
||||
endpoint: '',
|
||||
tokenRef: '',
|
||||
@ -628,21 +599,6 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveCurrentBridgeServerUrl(
|
||||
SettingsController controller, {
|
||||
String bridgeServerUrlOverride = '',
|
||||
}) {
|
||||
final override = bridgeServerUrlOverride.trim();
|
||||
if (override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
return controller
|
||||
.snapshotInternal
|
||||
.acpBridgeServerModeConfig
|
||||
.effective
|
||||
.endpoint;
|
||||
}
|
||||
|
||||
int _parseExpiresAtMs(Object? value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
|
||||
106
test/features/settings/settings_about_bridge_metadata_test.dart
Normal file
106
test/features/settings/settings_about_bridge_metadata_test.dart
Normal file
@ -0,0 +1,106 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/features/settings/settings_page_core.dart';
|
||||
|
||||
void main() {
|
||||
group('settings about bridge metadata', () {
|
||||
test('loads bridge metadata with bearer authorization', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async {
|
||||
await server.close(force: true);
|
||||
});
|
||||
|
||||
late Map<String, String> requestHeaders;
|
||||
server.listen((request) async {
|
||||
requestHeaders = {
|
||||
'authorization':
|
||||
request.headers.value(HttpHeaders.authorizationHeader) ?? '',
|
||||
'accept': request.headers.value(HttpHeaders.acceptHeader) ?? '',
|
||||
};
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'status': 'ok',
|
||||
'version': '991ecb0',
|
||||
'commit': '991ecb0',
|
||||
'image': 'ghcr.io/x-evor/xworkmate-bridge:991ecb0',
|
||||
'buildDate': '2026-04-21',
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
});
|
||||
|
||||
final metadata = await loadBridgeMetadataForSettingsAbout(
|
||||
bridgeEndpoint: Uri.parse(
|
||||
'http://${server.address.address}:${server.port}',
|
||||
),
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
|
||||
expect(requestHeaders['authorization'], 'Bearer bridge-token');
|
||||
expect(requestHeaders['accept'], 'application/json');
|
||||
expect(metadata['status'], 'ok');
|
||||
expect(metadata['version'], '991ecb0');
|
||||
expect(metadata['commit'], '991ecb0');
|
||||
expect(metadata['image'], 'ghcr.io/x-evor/xworkmate-bridge:991ecb0');
|
||||
expect(metadata['buildDate'], '2026-04-21');
|
||||
});
|
||||
|
||||
test('returns unavailable when bridge authorization is missing', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async {
|
||||
await server.close(force: true);
|
||||
});
|
||||
|
||||
var receivedRequest = false;
|
||||
server.listen((request) async {
|
||||
receivedRequest = true;
|
||||
request.response.statusCode = HttpStatus.ok;
|
||||
await request.response.close();
|
||||
});
|
||||
|
||||
final metadata = await loadBridgeMetadataForSettingsAbout(
|
||||
bridgeEndpoint: Uri.parse(
|
||||
'http://${server.address.address}:${server.port}',
|
||||
),
|
||||
authorizationResolver: (_) async => null,
|
||||
);
|
||||
|
||||
expect(receivedRequest, isFalse);
|
||||
expect(metadata['status'], 'unavailable');
|
||||
expect(metadata['version'], '');
|
||||
expect(metadata['commit'], '');
|
||||
expect(metadata['image'], '');
|
||||
expect(metadata['buildDate'], '');
|
||||
});
|
||||
|
||||
test('returns unavailable when authorized bridge ping fails', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async {
|
||||
await server.close(force: true);
|
||||
});
|
||||
|
||||
server.listen((request) async {
|
||||
request.response.statusCode = HttpStatus.unauthorized;
|
||||
await request.response.close();
|
||||
});
|
||||
|
||||
final metadata = await loadBridgeMetadataForSettingsAbout(
|
||||
bridgeEndpoint: Uri.parse(
|
||||
'http://${server.address.address}:${server.port}',
|
||||
),
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
|
||||
expect(metadata['status'], 'unavailable');
|
||||
expect(metadata['version'], '');
|
||||
expect(metadata['commit'], '');
|
||||
expect(metadata['image'], '');
|
||||
expect(metadata['buildDate'], '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -9,7 +9,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
void main() {
|
||||
group('Bridge runtime cleanup', () {
|
||||
test(
|
||||
'uses synced bridge endpoint only when account sync has a bridge token',
|
||||
'keeps the managed bridge endpoint fixed even when account sync carries a bridge URL',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-runtime-cleanup-',
|
||||
@ -61,7 +61,7 @@ void main() {
|
||||
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal()?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
@ -69,7 +69,7 @@ void main() {
|
||||
AssistantExecutionTarget.gateway,
|
||||
)
|
||||
?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
expect(await store.loadAccountSyncState(), isNotNull);
|
||||
expect(
|
||||
@ -80,7 +80,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'does not fallback to the managed bridge endpoint when signed out',
|
||||
'keeps the managed bridge endpoint fixed when signed out',
|
||||
() {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{
|
||||
@ -89,7 +89,10 @@ void main() {
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
expect(controller.resolveBridgeAcpEndpointInternal(), isNull);
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal()?.toString(),
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -139,10 +142,9 @@ void main() {
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
final bridgeHeader = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'),
|
||||
);
|
||||
final bridgeHeader = await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
);
|
||||
final unrelatedHeader = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://unrelated.example.com/acp/rpc'),
|
||||
@ -153,43 +155,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'ignores legacy INTERNAL_SERVICE_TOKEN for managed bridge auth resolution',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-auth-resolver-legacy-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
environmentOverride: const <String, String>{
|
||||
'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final bridgeHeader = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'),
|
||||
);
|
||||
|
||||
expect(bridgeHeader, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'runtime coordinator only exposes remote and offline gateway modes',
|
||||
() {
|
||||
|
||||
@ -152,7 +152,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'desktop bridge auth resolver sends managed bridge bearer for capabilities HTTP',
|
||||
'desktop bridge auth resolver sends bearer when the caller asks for managed bridge auth',
|
||||
() async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
@ -182,26 +182,14 @@ void main() {
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'bridge-token',
|
||||
);
|
||||
await store.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: capture.baseEndpoint.toString(),
|
||||
),
|
||||
syncState: 'ready',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
apisix: false,
|
||||
),
|
||||
),
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => capture.baseEndpoint,
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
await controller.gatewayAcpClientInternal.loadCapabilities(
|
||||
forceRefresh: true,
|
||||
await client.request(
|
||||
method: 'acp.capabilities',
|
||||
params: const <String, dynamic>{},
|
||||
);
|
||||
|
||||
expect(capture.authorizationHeader, 'Bearer bridge-token');
|
||||
@ -258,80 +246,20 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'desktop bridge auth resolver resolves manual bridge token when configured',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-auth-bridge-manual-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
|
||||
final settings = SettingsSnapshot.defaults().copyWith(
|
||||
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
|
||||
.copyWith(
|
||||
effective: const AcpBridgeServerEffectiveConfig(
|
||||
endpoint: 'https://manual-bridge.example.com',
|
||||
tokenRef: 'acp_bridge_server_password',
|
||||
source: 'bridge',
|
||||
reason: 'Manual test configuration',
|
||||
),
|
||||
selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith(
|
||||
serverUrl: 'https://manual-bridge.example.com',
|
||||
username: 'admin',
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveSettingsSnapshot(settings);
|
||||
await store.saveSecretValueByRef(
|
||||
settings.acpBridgeServerModeConfig.selfHosted.passwordRef,
|
||||
'manual-token',
|
||||
);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
final header = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://manual-bridge.example.com/acp/rpc'),
|
||||
);
|
||||
|
||||
expect(header, 'manual-token');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'desktop task execution routes Hermes through bridge RPC with provider params',
|
||||
() async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
final controller = await _syncedControllerForBridgeEndpoint(
|
||||
capture.baseEndpoint,
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => capture.baseEndpoint,
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final transport = ExternalCodeAgentAcpDesktopTransport(
|
||||
client: controller.gatewayAcpClientInternal,
|
||||
endpointResolver:
|
||||
controller.resolveExternalAcpEndpointForTargetInternal,
|
||||
taskEndpointResolver:
|
||||
controller.resolveExternalAcpEndpointForRequestInternal,
|
||||
client: client,
|
||||
endpointResolver: (_) => capture.baseEndpoint,
|
||||
taskEndpointResolver: (_) => capture.baseEndpoint,
|
||||
);
|
||||
|
||||
await transport.executeTask(
|
||||
@ -359,17 +287,15 @@ void main() {
|
||||
() async {
|
||||
final capture = await _startAcpHttpServer();
|
||||
addTearDown(capture.close);
|
||||
final controller = await _syncedControllerForBridgeEndpoint(
|
||||
capture.baseEndpoint,
|
||||
final client = GatewayAcpClient(
|
||||
endpointResolver: () => capture.baseEndpoint,
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final transport = ExternalCodeAgentAcpDesktopTransport(
|
||||
client: controller.gatewayAcpClientInternal,
|
||||
endpointResolver:
|
||||
controller.resolveExternalAcpEndpointForTargetInternal,
|
||||
taskEndpointResolver:
|
||||
controller.resolveExternalAcpEndpointForRequestInternal,
|
||||
client: client,
|
||||
endpointResolver: (_) => capture.baseEndpoint,
|
||||
taskEndpointResolver: (_) => capture.baseEndpoint,
|
||||
);
|
||||
|
||||
await transport.executeTask(
|
||||
@ -416,48 +342,6 @@ GoTaskServiceRequest _taskRequest({
|
||||
);
|
||||
}
|
||||
|
||||
Future<AppController> _syncedControllerForBridgeEndpoint(Uri endpoint) async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-auth-provider-endpoint-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here.
|
||||
}
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: endpoint.toString(),
|
||||
),
|
||||
syncState: 'ready',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
apisix: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'bridge-token',
|
||||
);
|
||||
final controller = AppController(store: store);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
return controller;
|
||||
}
|
||||
|
||||
Future<_CapturedAcpHttpServer> _startAcpHttpServer() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final capture = _CapturedAcpHttpServer._(
|
||||
|
||||
@ -289,7 +289,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'syncAccountSettings refreshes managed bridge contract from protected account profile',
|
||||
'syncAccountSettings refreshes managed bridge metadata from protected account profile',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-refresh-',
|
||||
@ -365,7 +365,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'synced bridge url becomes runtime endpoint only with a configured bridge token',
|
||||
'managed bridge endpoint stays fixed regardless of synced bridge url metadata',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-runtime-',
|
||||
@ -412,28 +412,28 @@ void main() {
|
||||
|
||||
expect(
|
||||
controller.resolveGatewayAcpEndpointInternal()?.toString(),
|
||||
'https://xworkmate-bridge-alt.svc.plus',
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://xworkmate-bridge-alt.svc.plus/acp/rpc'),
|
||||
),
|
||||
'bridge-token',
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
),
|
||||
isNull,
|
||||
'bridge-token',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'syncAccountSettings blocks and clears stale token when bridge endpoint is unavailable',
|
||||
'syncAccountSettings succeeds when bridge url metadata is missing',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-missing-url-',
|
||||
'xworkmate-account-managed-bridge-missing-metadata-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
@ -483,14 +483,15 @@ void main() {
|
||||
baseUrl: 'https://accounts.svc.plus',
|
||||
);
|
||||
|
||||
expect(result.state, 'blocked');
|
||||
expect(result.message, 'Bridge endpoint is unavailable');
|
||||
expect(result.state, 'ready');
|
||||
expect(result.message, 'Bridge access synced');
|
||||
expect(
|
||||
await store.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
),
|
||||
isNull,
|
||||
'fresh-bridge-token',
|
||||
);
|
||||
expect(controller.accountSyncState!.syncedDefaults.bridgeServerUrl, '');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user