diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 37fbf4b2..a68514bb 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -655,9 +655,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (!sameBridgeHost) { return null; } - final authorization = await resolveGatewayAcpAuthorizationHeaderInternal( - uri, - ); + final authorization = + await resolveBridgeArtifactAuthorizationHeaderInternal(uri); if (authorization == null || authorization.trim().isEmpty) { return null; } @@ -724,7 +723,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (bridgeEndpoint == null) { return null; } - if (request.target.isGateway) { + if (_usesOpenClawTaskSubmitEndpointInternal(request)) { return bridgeEndpoint.replace(path: '/gateway/openclaw'); } return bridgeEndpoint; @@ -769,6 +768,30 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return null; } + Future resolveBridgeArtifactAuthorizationHeaderInternal( + Uri endpoint, + ) async { + final normalizedHost = endpoint.host.trim().toLowerCase(); + final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); + final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; + if (bridgeHost.isEmpty || normalizedHost != bridgeHost) { + return null; + } + + final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); + if (envToken != null && envToken.isNotEmpty) { + return _normalizeAuthorizationHeaderInternal(envToken); + } + + final bridgeToken = (await storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ))?.trim(); + if (bridgeToken?.isNotEmpty == true) { + return _normalizeAuthorizationHeaderInternal(bridgeToken!); + } + return null; + } + int? gatewayProfileIndexMatchingEndpointInternal(Uri endpoint) { final normalizedHost = endpoint.host.trim().toLowerCase(); final normalizedScheme = endpoint.scheme.trim().toLowerCase(); @@ -803,6 +826,34 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ) => kGatewayRemoteProfileIndex; } +bool _usesOpenClawTaskSubmitEndpointInternal(GoTaskServiceRequest request) { + if (request.isMultiAgentRequest || !request.target.isGateway) { + return false; + } + return normalizeSingleAgentProviderId(request.provider.providerId) == + kCanonicalGatewayProviderId; +} + +String _normalizeAuthorizationHeaderInternal(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return ''; + } + if (_looksLikeAuthorizationHeaderInternal(trimmed)) { + return trimmed; + } + return 'Bearer $trimmed'; +} + +bool _looksLikeAuthorizationHeaderInternal(String raw) { + final separatorIndex = raw.indexOf(RegExp(r'\s')); + if (separatorIndex <= 0 || separatorIndex >= raw.length - 1) { + return false; + } + final scheme = raw.substring(0, separatorIndex); + return RegExp(r"^[A-Za-z][A-Za-z0-9!#$%&'*+.^_`|~-]*$").hasMatch(scheme); +} + String _sanitizeArtifactRelativePathInternal(String raw) { final trimmed = raw.trim().replaceAll('\\', '/'); if (trimmed.isEmpty) { diff --git a/lib/runtime/acp_endpoint_paths.dart b/lib/runtime/acp_endpoint_paths.dart index f2333dd8..b147db91 100644 --- a/lib/runtime/acp_endpoint_paths.dart +++ b/lib/runtime/acp_endpoint_paths.dart @@ -94,17 +94,6 @@ Uri? resolveAcpHttpRpcEndpoint(Uri? endpoint) { if (endpoint == null || endpoint.host.trim().isEmpty) { return null; } - if (_isGatewayOpenClawPath(endpoint.path)) { - final scheme = endpoint.scheme.trim().toLowerCase(); - if (scheme != 'http' && scheme != 'https') { - return null; - } - return endpoint.replace( - path: '/gateway/openclaw', - query: null, - fragment: null, - ); - } if (AcpEndpointPaths.isProviderMappingPath(endpoint.path)) { return null; } @@ -115,12 +104,3 @@ Uri? resolveAcpHttpRpcEndpoint(Uri? endpoint) { final paths = AcpEndpointPaths.fromBaseEndpoint(endpoint); return endpoint.replace(path: paths.httpRpcPath, query: null, fragment: null); } - -bool _isGatewayOpenClawPath(String rawPath) { - var path = rawPath.trim(); - if (!path.startsWith('/')) { - path = '/$path'; - } - path = path.replaceFirst(RegExp(r'/+$'), ''); - return path == '/gateway/openclaw'; -} diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 9373cf7d..efd6c8a0 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -546,7 +546,7 @@ class GatewayAcpClient { Uri? endpointOverride, String authorizationOverride = '', }) async { - final endpoint = _resolveHttpRpcEndpoint(endpointOverride); + final endpoint = _resolveHttpRpcEndpoint(endpointOverride, request.method); if (endpoint == null) { throw const GatewayAcpException( 'Missing ACP HTTP endpoint', @@ -1046,8 +1046,17 @@ class GatewayAcpClient { return const {}; } - Uri? _resolveHttpRpcEndpoint([Uri? endpointOverride]) { - return resolveAcpHttpRpcEndpoint(endpointOverride ?? endpointResolver()); + Uri? _resolveHttpRpcEndpoint([Uri? endpointOverride, String method = '']) { + final endpoint = endpointOverride ?? endpointResolver(); + if (_isOpenClawTaskSubmitEndpoint(endpoint) && + _isOpenClawTaskSubmitMethod(method)) { + return endpoint?.replace( + path: '/gateway/openclaw', + query: null, + fragment: null, + ); + } + return resolveAcpHttpRpcEndpoint(endpoint); } String _nextRequestId(String method) { @@ -1105,6 +1114,20 @@ class GatewayAcpClient { } } +bool _isOpenClawTaskSubmitEndpoint(Uri? endpoint) { + var path = endpoint?.path.trim() ?? ''; + if (!path.startsWith('/')) { + path = '/$path'; + } + path = path.replaceFirst(RegExp(r'/+$'), ''); + return path == '/gateway/openclaw'; +} + +bool _isOpenClawTaskSubmitMethod(String method) { + final normalized = method.trim(); + return normalized == 'session.start' || normalized == 'session.message'; +} + class _GatewayAcpRpcRequest { const _GatewayAcpRpcRequest({ required this.id, diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index ffd7c918..7a8203e1 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -425,7 +425,11 @@ class GoTaskServiceArtifact { contentType: json['contentType']?.toString().trim() ?? '', encoding: json['encoding']?.toString().trim() ?? '', content: json['content']?.toString() ?? '', - downloadUrl: json['downloadUrl']?.toString().trim() ?? '', + downloadUrl: + json['downloadUrl']?.toString().trim() ?? + json['downloadURL']?.toString().trim() ?? + json['download_url']?.toString().trim() ?? + '', sizeBytes: parseSize(json['sizeBytes'] ?? json['size']), sha256: json['sha256']?.toString().trim() ?? '', ); diff --git a/test/runtime/acp_endpoint_paths_test.dart b/test/runtime/acp_endpoint_paths_test.dart index 9cf7a14a..7670a228 100644 --- a/test/runtime/acp_endpoint_paths_test.dart +++ b/test/runtime/acp_endpoint_paths_test.dart @@ -11,15 +11,12 @@ void main() { expect(endpoint.toString(), 'https://xworkmate-bridge.svc.plus/acp/rpc'); }); - test('keeps OpenClaw gateway submit path as HTTP endpoint', () { + test('rejects OpenClaw gateway submit path as a global ACP base', () { final endpoint = resolveAcpHttpRpcEndpoint( Uri.parse('https://xworkmate-bridge.svc.plus/gateway/openclaw'), ); - expect( - endpoint.toString(), - 'https://xworkmate-bridge.svc.plus/gateway/openclaw', - ); + expect(endpoint, isNull); }); test('rejects provider mapping paths as app RPC bases', () { diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index dc9e8183..59394494 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -114,6 +114,23 @@ void main() { final artifact = File('${localWorkspace.path}/notes/hello.txt'); expect(await artifact.readAsString(), 'artifact body'); + await controller.persistGoTaskArtifactsForSessionInternal( + 'session-1', + result, + ); + final versionedArtifact = File('${localWorkspace.path}/notes/hello.v2.txt'); + expect(await versionedArtifact.readAsString(), 'artifact body'); + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'session-1', + ); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + contains('notes/hello.txt'), + ); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + contains('notes/hello.v2.txt'), + ); expect( controller .requireTaskThreadForSessionInternal('session-1') @@ -121,4 +138,157 @@ void main() { 'synced', ); }); + + test( + 'downloads bridge URL artifacts into the local thread workspace', + () async { + String observedAuthorization = ''; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + observedAuthorization = + request.headers.value(HttpHeaders.authorizationHeader) ?? ''; + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.text + ..write('downloaded artifact body'); + await request.response.close(); + }); + + final controller = AppController( + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + }, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-download-artifact-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + + controller.upsertTaskThreadInternal( + 'session-1', + workspaceBinding: WorkspaceBinding( + workspaceId: 'session-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + final result = GoTaskServiceResult( + success: true, + message: 'hello', + turnId: 'turn-1', + raw: { + 'artifacts': >[ + { + 'relativePath': 'reports/download.txt', + 'downloadUrl': + 'http://xworkmate-bridge.svc.plus:${server.port}/artifact/download.txt', + 'contentType': 'text/plain', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + final proxyClient = HttpClient() + ..findProxy = (_) => 'PROXY 127.0.0.1:${server.port}'; + await HttpOverrides.runZoned(() async { + await controller.persistGoTaskArtifactsForSessionInternal( + 'session-1', + result, + ); + }, createHttpClient: (_) => proxyClient); + + final artifact = File('${localWorkspace.path}/reports/download.txt'); + expect(await artifact.readAsString(), 'downloaded artifact body'); + expect(observedAuthorization, 'Bearer bridge-token'); + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'session-1', + ); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + contains('reports/download.txt'), + ); + expect( + controller + .requireTaskThreadForSessionInternal('session-1') + .lastArtifactSyncStatus, + 'synced', + ); + }, + ); + + test('skips download URL artifacts outside the bridge host', () async { + final controller = AppController( + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + }, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-skipped-download-artifact-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + + controller.upsertTaskThreadInternal( + 'session-1', + workspaceBinding: WorkspaceBinding( + workspaceId: 'session-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + final result = GoTaskServiceResult( + success: true, + message: 'hello', + turnId: 'turn-1', + raw: { + 'artifacts': >[ + { + 'relativePath': 'reports/download.txt', + 'downloadUrl': 'https://example.invalid/artifact/download.txt', + 'contentType': 'text/plain', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'session-1', + result, + ); + + expect( + await File('${localWorkspace.path}/reports/download.txt').exists(), + isFalse, + ); + expect( + controller + .requireTaskThreadForSessionInternal('session-1') + .lastArtifactSyncStatus, + 'no-inline-content', + ); + }); } diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index cf93032f..108b831c 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -625,6 +625,91 @@ void main() { }, ); + test( + 'desktop OpenClaw follow-up routes through dedicated bridge gateway path', + () async { + final capture = await _startAcpHttpServer(); + addTearDown(capture.close); + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + authorizationResolver: (_) async => 'bridge-token', + ); + + final transport = ExternalCodeAgentAcpDesktopTransport( + client: client, + endpointResolver: (_) => capture.baseEndpoint, + taskEndpointResolver: (_) => + capture.baseEndpoint.replace(path: '/gateway/openclaw'), + ); + + await transport.executeTask( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + resumeSession: true, + ), + onUpdate: (_) {}, + ); + + expect(capture.requestPath, '/gateway/openclaw'); + expect(capture.requestBody, contains('"method":"session.message"')); + }, + ); + + test( + 'desktop controller only uses gateway path for OpenClaw task submit', + () { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final openClawStart = controller + .resolveExternalAcpEndpointForRequestInternal( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + ), + ); + final openClawFollowUp = controller + .resolveExternalAcpEndpointForRequestInternal( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + resumeSession: true, + ), + ); + final unspecifiedGateway = controller + .resolveExternalAcpEndpointForRequestInternal( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.unspecified, + ), + ); + final multiAgentGateway = controller + .resolveExternalAcpEndpointForRequestInternal( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + multiAgent: true, + ), + ); + final agentTask = controller + .resolveExternalAcpEndpointForRequestInternal( + _taskRequest( + target: AssistantExecutionTarget.agent, + provider: SingleAgentProvider.codex, + ), + ); + + expect(openClawStart?.path, '/gateway/openclaw'); + expect(openClawFollowUp?.path, '/gateway/openclaw'); + expect(unspecifiedGateway?.path, ''); + expect(multiAgentGateway?.path, ''); + expect(agentTask?.path, ''); + }, + ); + test( 'desktop task execution uses session.start for new sessions', () async { @@ -857,6 +942,7 @@ GoTaskServiceRequest _taskRequest({ required AssistantExecutionTarget target, required SingleAgentProvider provider, bool resumeSession = false, + bool multiAgent = false, String remoteWorkingDirectoryHint = '', }) { return GoTaskServiceRequest( @@ -875,6 +961,7 @@ GoTaskServiceRequest _taskRequest({ provider: provider, remoteWorkingDirectoryHint: remoteWorkingDirectoryHint, resumeSession: resumeSession, + multiAgent: multiAgent, ); }