Unify bridge sync gating for ACP sessions
This commit is contained in:
parent
e7eeef193c
commit
47e2909cd7
@ -226,6 +226,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}) {
|
||||
final raw = error.toString().trim();
|
||||
final lowered = raw.toLowerCase();
|
||||
if ((lowered.contains('acp_endpoint_missing') ||
|
||||
lowered.contains('missing acp endpoint')) &&
|
||||
target == AssistantExecutionTarget.singleAgent) {
|
||||
return appText(
|
||||
'当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试。',
|
||||
'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.',
|
||||
);
|
||||
}
|
||||
if (lowered.contains('gateway not connected') ||
|
||||
lowered.contains('code: offline') ||
|
||||
lowered.contains('offlin') && lowered.contains('gateway')) {
|
||||
@ -724,10 +732,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}
|
||||
|
||||
Uri? resolveGatewayAcpEndpointInternal() {
|
||||
return resolveBridgeAcpEndpointInternal() ??
|
||||
_nonLoopbackGatewayProfileBaseUriInternal(
|
||||
settings.primaryGatewayProfile,
|
||||
);
|
||||
return resolveBridgeAcpEndpointInternal();
|
||||
}
|
||||
|
||||
Uri? resolveBridgeAcpEndpointInternal() {
|
||||
@ -748,19 +753,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
|
||||
Uri? resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget target,
|
||||
) {
|
||||
final bridgeEndpoint = resolveBridgeAcpEndpointInternal();
|
||||
if (bridgeEndpoint != null) {
|
||||
return bridgeEndpoint;
|
||||
}
|
||||
if (target == AssistantExecutionTarget.gateway) {
|
||||
return _nonLoopbackGatewayProfileBaseUriInternal(
|
||||
settings.primaryGatewayProfile,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
Uri? resolveExternalAcpEndpointForTargetInternal(AssistantExecutionTarget _) {
|
||||
return resolveBridgeAcpEndpointInternal();
|
||||
}
|
||||
|
||||
Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) {
|
||||
@ -775,30 +769,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
);
|
||||
}
|
||||
|
||||
Uri? _nonLoopbackGatewayProfileBaseUriInternal(
|
||||
GatewayConnectionProfile profile,
|
||||
) {
|
||||
if (isLoopbackHostInternal(profile.host)) {
|
||||
return null;
|
||||
}
|
||||
return gatewayProfileBaseUriInternal(profile);
|
||||
}
|
||||
|
||||
Future<String?> resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri endpoint,
|
||||
) async {
|
||||
final normalizedHost = endpoint.host.trim().toLowerCase();
|
||||
final bridgeHost =
|
||||
Uri.tryParse(
|
||||
settings
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.remoteServerSummary
|
||||
.endpoint
|
||||
.trim(),
|
||||
)?.host.trim().toLowerCase() ??
|
||||
'';
|
||||
if (bridgeHost.isNotEmpty && normalizedHost == bridgeHost) {
|
||||
final bridgeEndpoint = resolveBridgeAcpEndpointInternal();
|
||||
final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? '';
|
||||
final bridgePort = bridgeEndpoint?.port ?? 0;
|
||||
final matchesBridgeEndpoint =
|
||||
bridgeHost.isNotEmpty &&
|
||||
normalizedHost == bridgeHost &&
|
||||
(bridgePort <= 0 || endpoint.port == bridgePort);
|
||||
if (matchesBridgeEndpoint) {
|
||||
final bridgeToken =
|
||||
(await storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
|
||||
@ -57,6 +57,9 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
sessionKey,
|
||||
);
|
||||
final selection = controller.singleAgentProviderForSession(sessionKey);
|
||||
final effectiveProvider =
|
||||
controller.resolvedSingleAgentProviderInternal(selection) ??
|
||||
selection;
|
||||
final preflightWorkingDirectory = controller
|
||||
.resolveSingleAgentWorkingDirectoryForSessionInternal(sessionKey);
|
||||
if (preflightWorkingDirectory == null ||
|
||||
@ -76,37 +79,32 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
final aiGatewayApiKey = await controller.loadAiGatewayApiKey();
|
||||
final routingResolution = await controller.goTaskServiceClientInternal
|
||||
.resolveExternalAcpRouting(
|
||||
taskPrompt: message,
|
||||
workingDirectory: preflightWorkingDirectory,
|
||||
routing: routing,
|
||||
aiGatewayBaseUrl: controller.aiGatewayUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
final effectiveProvider =
|
||||
routingResolution.resolvedProviderId.trim().isEmpty
|
||||
? null
|
||||
: SingleAgentProviderCopy.fromJsonValue(
|
||||
routingResolution.resolvedProviderId,
|
||||
);
|
||||
final unavailableReason =
|
||||
routingResolution.unavailable ||
|
||||
(routingResolution.resolvedExecutionTarget == 'single-agent' &&
|
||||
effectiveProvider == null)
|
||||
? (routingResolution.unavailableMessage.isNotEmpty
|
||||
? routingResolution.unavailableMessage
|
||||
: selection.isUnspecified
|
||||
? appText(
|
||||
'当前没有可用的 GoTaskService Provider。',
|
||||
'No GoTaskService provider is currently available.',
|
||||
)
|
||||
: appText(
|
||||
'当前 GoTaskService 不支持 ${selection.label}。',
|
||||
'GoTaskService does not currently support ${selection.label}.',
|
||||
))
|
||||
controller.singleAgentShouldSuggestAcpSwitchForSession(sessionKey)
|
||||
? singleAgentUnavailableLabelDesktopInternal(
|
||||
controller,
|
||||
sessionKey,
|
||||
null,
|
||||
)
|
||||
: controller.singleAgentNeedsAiGatewayConfigurationForSession(
|
||||
sessionKey,
|
||||
)
|
||||
? singleAgentUnavailableLabelDesktopInternal(
|
||||
controller,
|
||||
sessionKey,
|
||||
appText(
|
||||
'Bridge 当前没有同步到可用 Provider。',
|
||||
'The bridge does not currently have any synced providers.',
|
||||
),
|
||||
)
|
||||
: controller.resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
) ==
|
||||
null
|
||||
? appText(
|
||||
'当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试。',
|
||||
'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.',
|
||||
)
|
||||
: null;
|
||||
if (unavailableReason != null) {
|
||||
controller.upsertTaskThreadInternal(
|
||||
@ -120,17 +118,14 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
sessionKey,
|
||||
assistantErrorMessageSingleAgentDesktopInternal(
|
||||
controller,
|
||||
singleAgentUnavailableLabelDesktopInternal(
|
||||
controller,
|
||||
sessionKey,
|
||||
unavailableReason,
|
||||
),
|
||||
unavailableReason,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveProvider != null) {
|
||||
final aiGatewayApiKey = await controller.loadAiGatewayApiKey();
|
||||
if (!effectiveProvider.isUnspecified) {
|
||||
appendSingleAgentRuntimeStatusDesktopInternal(
|
||||
controller,
|
||||
sessionKey,
|
||||
@ -140,7 +135,9 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
final workingDirectory = controller
|
||||
.resolveSingleAgentWorkingDirectoryForSessionInternal(
|
||||
sessionKey,
|
||||
provider: effectiveProvider,
|
||||
provider: effectiveProvider.isUnspecified
|
||||
? null
|
||||
: effectiveProvider,
|
||||
);
|
||||
final resolvedWorkingDirectory =
|
||||
workingDirectory == null || workingDirectory.trim().isEmpty
|
||||
@ -159,25 +156,18 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
prompt: message,
|
||||
workingDirectory: resolvedWorkingDirectory,
|
||||
model: routingResolution.resolvedModel.trim().isNotEmpty
|
||||
? routingResolution.resolvedModel
|
||||
: controller.assistantModelForSession(sessionKey),
|
||||
model: controller.assistantModelForSession(sessionKey),
|
||||
thinking: thinking,
|
||||
selectedSkills: routingResolution.resolvedSkills.isNotEmpty
|
||||
? routingResolution.resolvedSkills
|
||||
: selectedSkills,
|
||||
selectedSkills: selectedSkills,
|
||||
inlineAttachments: attachments,
|
||||
localAttachments: localAttachments,
|
||||
aiGatewayBaseUrl: controller.aiGatewayUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
agentId: '',
|
||||
metadata: const <String, dynamic>{},
|
||||
routing: _resolvedRoutingConfigDesktopInternal(
|
||||
routing,
|
||||
routingResolution,
|
||||
),
|
||||
routing: routing,
|
||||
routingHint: 'single-agent',
|
||||
provider: effectiveProvider ?? SingleAgentProvider.unspecified,
|
||||
provider: effectiveProvider,
|
||||
remoteWorkingDirectoryHint:
|
||||
controller
|
||||
.requireTaskThreadForSessionInternal(sessionKey)
|
||||
@ -231,31 +221,6 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
});
|
||||
}
|
||||
|
||||
ExternalCodeAgentAcpRoutingConfig _resolvedRoutingConfigDesktopInternal(
|
||||
ExternalCodeAgentAcpRoutingConfig original,
|
||||
ExternalCodeAgentAcpRoutingResolution resolution,
|
||||
) {
|
||||
final explicitExecutionTarget = switch (resolution.resolvedExecutionTarget
|
||||
.trim()
|
||||
.toLowerCase()) {
|
||||
'single-agent' => 'single-agent',
|
||||
'multi-agent' => 'multi-agent',
|
||||
'gateway' => 'gateway',
|
||||
_ => original.explicitExecutionTarget,
|
||||
};
|
||||
return ExternalCodeAgentAcpRoutingConfig(
|
||||
mode: ExternalCodeAgentAcpRoutingMode.explicit,
|
||||
preferredGatewayTarget: original.preferredGatewayTarget,
|
||||
explicitExecutionTarget: explicitExecutionTarget,
|
||||
explicitProviderId: resolution.resolvedProviderId,
|
||||
explicitModel: resolution.resolvedModel,
|
||||
explicitSkills: resolution.resolvedSkills,
|
||||
allowSkillInstall: original.allowSkillInstall,
|
||||
availableSkills: original.availableSkills,
|
||||
installApproval: original.installApproval,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _applySingleAgentGoTaskResultDesktopInternal(
|
||||
AppController controller, {
|
||||
required String sessionKey,
|
||||
|
||||
@ -2,8 +2,6 @@ import 'account_runtime_client.dart';
|
||||
import 'runtime_controllers_settings.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
const _kProductionBridgeEndpoint = 'https://xworkmate-bridge.svc.plus';
|
||||
|
||||
Future<void> loginAccountSettingsInternal(
|
||||
SettingsController controller, {
|
||||
required String baseUrl,
|
||||
@ -260,6 +258,15 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
state: 'blocked',
|
||||
message: 'Bridge authorization is unavailable',
|
||||
);
|
||||
await controller.storeInternal.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncState: result.state,
|
||||
syncMessage: result.message,
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: result.message,
|
||||
profileScope: 'bridge',
|
||||
),
|
||||
);
|
||||
controller.accountStatusInternal = result.message;
|
||||
if (!quiet) {
|
||||
controller.accountBusyInternal = false;
|
||||
@ -268,6 +275,11 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
return result;
|
||||
}
|
||||
|
||||
await controller.storeInternal.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: bridgeToken,
|
||||
);
|
||||
|
||||
final bridgeServerUrl = bridgeServerUrlOverride.trim().isNotEmpty
|
||||
? bridgeServerUrlOverride.trim()
|
||||
: controller.accountSyncStateInternal?.syncedDefaults.bridgeServerUrl
|
||||
@ -291,12 +303,36 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
.remoteServerSummary
|
||||
.endpoint
|
||||
.trim()
|
||||
: _kProductionBridgeEndpoint;
|
||||
|
||||
await controller.storeInternal.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: bridgeToken,
|
||||
);
|
||||
: '';
|
||||
if (bridgeServerUrl.isEmpty ||
|
||||
!isSupportedExternalAcpEndpoint(bridgeServerUrl)) {
|
||||
const result = AccountSyncResult(
|
||||
state: 'blocked',
|
||||
message: 'Bridge server is unavailable',
|
||||
);
|
||||
await controller.storeInternal.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults(),
|
||||
syncState: result.state,
|
||||
syncMessage: result.message,
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: result.message,
|
||||
profileScope: 'bridge',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
apisix: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await controller.reloadDerivedStateInternal();
|
||||
controller.accountStatusInternal = result.message;
|
||||
if (!quiet) {
|
||||
controller.accountBusyInternal = false;
|
||||
controller.notifyListeners();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
await controller.storeInternal.clearAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
@ -475,6 +511,10 @@ String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
|
||||
if (explicit.isNotEmpty) {
|
||||
return explicit;
|
||||
}
|
||||
final internalServiceToken = _stringValue(payload['internalServiceToken']);
|
||||
if (internalServiceToken.isNotEmpty) {
|
||||
return internalServiceToken;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@ -333,6 +333,56 @@ void main() {
|
||||
});
|
||||
|
||||
group('resolveGatewayAcpAuthorizationHeaderInternal', () {
|
||||
test('requires synced bridge endpoint before ACP endpoint can resolve', () {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
expect(controller.resolveBridgeAcpEndpointInternal(), isNull);
|
||||
expect(
|
||||
controller.resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
|
||||
controller.settingsController.snapshotInternal = controller.settings
|
||||
.copyWith(
|
||||
acpBridgeServerModeConfig: controller
|
||||
.settings
|
||||
.acpBridgeServerModeConfig
|
||||
.copyWith(
|
||||
cloudSynced: controller
|
||||
.settings
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.copyWith(
|
||||
remoteServerSummary:
|
||||
const AcpBridgeServerRemoteServerSummary(
|
||||
endpoint: 'https://bridge.customer.example/acp',
|
||||
hasAdvancedOverrides: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.resolveBridgeAcpEndpointInternal(),
|
||||
Uri.parse('https://bridge.customer.example/acp'),
|
||||
);
|
||||
expect(
|
||||
controller.resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
Uri.parse('https://bridge.customer.example/acp'),
|
||||
);
|
||||
expect(
|
||||
controller.resolveExternalAcpEndpointForTargetInternal(
|
||||
AssistantExecutionTarget.gateway,
|
||||
),
|
||||
Uri.parse('https://bridge.customer.example/acp'),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'prefers the synced bridge bearer token over the account session token',
|
||||
() async {
|
||||
@ -480,6 +530,7 @@ class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient {
|
||||
return <String, dynamic>{
|
||||
'token': 'session-token',
|
||||
'internalServiceToken': 'bridge-token',
|
||||
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus',
|
||||
'expiresAt': '2026-04-12T00:00:00Z',
|
||||
'user': <String, dynamic>{
|
||||
'id': 'u-1',
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller_desktop_core.dart';
|
||||
import 'package:xworkmate/app/app_controller_desktop_runtime_helpers.dart';
|
||||
@ -6,6 +8,7 @@ import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
|
||||
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
|
||||
import 'package:xworkmate/runtime/go_task_service_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@ -14,14 +17,49 @@ void main() {
|
||||
test(
|
||||
'single-agent requests reuse the unique thread workspace workingDirectory',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-working-directory-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
|
||||
secretRootPathResolver: () async => root.path,
|
||||
supportRootPathResolver: () async => root.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
acpBridgeServerModeConfig: SettingsSnapshot.defaults()
|
||||
.acpBridgeServerModeConfig
|
||||
.copyWith(
|
||||
cloudSynced: SettingsSnapshot.defaults()
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.copyWith(
|
||||
remoteServerSummary:
|
||||
const AcpBridgeServerRemoteServerSummary(
|
||||
endpoint: 'https://bridge.customer.example',
|
||||
hasAdvancedOverrides: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final client = _CapturingGoTaskServiceClient();
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
goTaskServiceClient: client,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(() async {
|
||||
controller.dispose();
|
||||
store.dispose();
|
||||
if (await root.exists()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
const sessionKey = 'draft:single-agent-working-directory';
|
||||
controller.initializeAssistantThreadContext(
|
||||
@ -53,6 +91,42 @@ void main() {
|
||||
expectedThreadWorkingDirectory,
|
||||
],
|
||||
);
|
||||
expect(
|
||||
client.resolveExternalAcpRoutingCallCount,
|
||||
0,
|
||||
reason:
|
||||
'single-agent turns should go straight to session.start/session.message without app-side routing preflight',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'single-agent turns stay blocked until bridge server has been synced',
|
||||
() async {
|
||||
final client = _CapturingGoTaskServiceClient();
|
||||
final controller = AppController(
|
||||
goTaskServiceClient: client,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
const sessionKey = 'draft:single-agent-missing-bridge-server';
|
||||
controller.initializeAssistantThreadContext(
|
||||
sessionKey,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.switchSession(sessionKey);
|
||||
|
||||
await controller.sendChatMessage('first turn');
|
||||
|
||||
expect(client.requests, isEmpty);
|
||||
final messages = controller
|
||||
.requireTaskThreadForSessionInternal(sessionKey)
|
||||
.messages;
|
||||
expect(messages, isNotEmpty);
|
||||
expect(messages.last.text, contains('Bridge Server'));
|
||||
},
|
||||
);
|
||||
|
||||
@ -98,6 +172,7 @@ void main() {
|
||||
|
||||
class _CapturingGoTaskServiceClient implements GoTaskServiceClient {
|
||||
final List<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];
|
||||
int resolveExternalAcpRoutingCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> cancelTask({
|
||||
@ -168,6 +243,7 @@ class _CapturingGoTaskServiceClient implements GoTaskServiceClient {
|
||||
String aiGatewayBaseUrl = '',
|
||||
String aiGatewayApiKey = '',
|
||||
}) async {
|
||||
resolveExternalAcpRoutingCallCount += 1;
|
||||
return const ExternalCodeAgentAcpRoutingResolution(
|
||||
raw: <String, dynamic>{
|
||||
'resolvedExecutionTarget': 'single-agent',
|
||||
|
||||
@ -60,7 +60,7 @@ void main() {
|
||||
lastSyncSource: 'https://xworkmate-bridge.svc.plus',
|
||||
profileScope: 'bridge',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
openclaw: true,
|
||||
bridge: true,
|
||||
vault: false,
|
||||
apisix: false,
|
||||
),
|
||||
|
||||
@ -133,6 +133,74 @@ void main() {
|
||||
expect(controller.accountSession?.email, 'review@svc.plus');
|
||||
expect(controller.accountSyncState?.syncState, 'ready');
|
||||
});
|
||||
|
||||
test(
|
||||
'login stays blocked when bridge server is not included in sync data',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-auth-missing-bridge-server-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
appDataRootPathResolver: () async => root.path,
|
||||
secretRootPathResolver: () async => root.path,
|
||||
supportRootPathResolver: () async => root.path,
|
||||
);
|
||||
final controller = SettingsController(
|
||||
store,
|
||||
accountClientFactory: (_) =>
|
||||
_MissingBridgeServerAccountRuntimeClient(),
|
||||
);
|
||||
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.customer.example',
|
||||
accountUsername: 'review@customer.example',
|
||||
),
|
||||
);
|
||||
|
||||
await controller.loginAccount(
|
||||
baseUrl: 'https://accounts.customer.example',
|
||||
identifier: 'review@customer.example',
|
||||
password: '***REMOVED-CREDENTIAL***',
|
||||
);
|
||||
|
||||
expect(controller.accountSignedIn, isTrue);
|
||||
expect(
|
||||
controller.accountStatus,
|
||||
'Signed in as review@customer.example',
|
||||
);
|
||||
expect(controller.accountSyncState?.syncState, 'blocked');
|
||||
expect(
|
||||
controller.accountSyncState?.syncMessage,
|
||||
'Bridge server is unavailable',
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.cloudSynced
|
||||
.remoteServerSummary
|
||||
.endpoint,
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
await store.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
),
|
||||
'bridge-token',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -230,3 +298,37 @@ class _MfaAccountRuntimeClient extends AccountRuntimeClient {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MissingBridgeServerAccountRuntimeClient extends AccountRuntimeClient {
|
||||
_MissingBridgeServerAccountRuntimeClient()
|
||||
: super(baseUrl: 'https://accounts.customer.example');
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> login({
|
||||
required String identifier,
|
||||
required String password,
|
||||
}) async {
|
||||
return <String, dynamic>{
|
||||
'token': 'session-token',
|
||||
'BRIDGE_AUTH_TOKEN': 'bridge-token',
|
||||
'expiresAt': '2026-04-12T00:00:00Z',
|
||||
'user': <String, dynamic>{
|
||||
'id': 'u-2',
|
||||
'email': 'review@customer.example',
|
||||
'name': 'Customer Review',
|
||||
'role': 'readonly',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AccountSessionSummary> loadSession({required String token}) async {
|
||||
return const AccountSessionSummary(
|
||||
userId: 'u-2',
|
||||
email: 'review@customer.example',
|
||||
name: 'Customer Review',
|
||||
role: 'readonly',
|
||||
mfaEnabled: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user