Unify bridge sync gating for ACP sessions

This commit is contained in:
Haitao Pan 2026-04-11 22:46:06 +08:00
parent e7eeef193c
commit 47e2909cd7
7 changed files with 336 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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