diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index dbc5ac3b..9afbc0e2 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -684,7 +684,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ))?.trim(); final normalizedToken = bridgeToken?.trim() ?? ''; if (normalizedToken.isNotEmpty) { - return 'Bearer $normalizedToken'; + return normalizedToken; } } return null; diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 8ffa713f..1c36f4d8 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -637,11 +637,33 @@ class GatewayAcpClient { Uri endpoint, { String authorizationOverride = '', }) async { - final override = authorizationOverride.trim(); + final override = _normalizeAuthorizationHeader(authorizationOverride); if (override.isNotEmpty) { return override; } - return (await authorizationResolver?.call(endpoint))?.trim() ?? ''; + return _normalizeAuthorizationHeader( + (await authorizationResolver?.call(endpoint))?.trim() ?? '', + ); + } + + String _normalizeAuthorizationHeader(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return ''; + } + if (_looksLikeAuthorizationHeader(trimmed)) { + return trimmed; + } + return 'Bearer $trimmed'; + } + + bool _looksLikeAuthorizationHeader(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); } Future> _consumeSseRpcResponse({ diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index 13ebfc7a..dc50c551 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -8,68 +8,115 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('Bridge runtime cleanup', () { - test('resolves the current synced bridge endpoint before env leftovers', () async { - final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-bridge-runtime-cleanup-', - ); - addTearDown(() async { - if (await storeRoot.exists()) { - await storeRoot.delete(recursive: true); - } - }); + test( + 'resolves the current synced bridge endpoint before env leftovers', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-runtime-cleanup-', + ); + 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(); - await store.saveAccountSyncState( - AccountSyncState.defaults().copyWith( - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - bridgeServerUrl: 'https://xworkmate-bridge-alt.svc.plus', + 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: 'https://xworkmate-bridge-alt.svc.plus', + ), + syncState: 'ready', ), - syncState: 'ready', - ), - ); + ); - final controller = AppController( - store: store, - environmentOverride: const { - 'BRIDGE_SERVER_URL': 'https://stale.example.invalid', - }, - ); - addTearDown(controller.dispose); - await controller.settingsControllerInternal.initialize(); + final controller = AppController( + store: store, + environmentOverride: const { + 'BRIDGE_SERVER_URL': 'https://stale.example.invalid', + }, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); - expect( - controller.resolveBridgeAcpEndpointInternal()?.toString(), - 'https://xworkmate-bridge-alt.svc.plus', - ); - expect( - controller - .resolveExternalAcpEndpointForTargetInternal( - AssistantExecutionTarget.gateway, - ) - ?.toString(), - 'https://xworkmate-bridge-alt.svc.plus', - ); - }); + expect( + controller.resolveBridgeAcpEndpointInternal()?.toString(), + 'https://xworkmate-bridge-alt.svc.plus', + ); + expect( + controller + .resolveExternalAcpEndpointForTargetInternal( + AssistantExecutionTarget.gateway, + ) + ?.toString(), + 'https://xworkmate-bridge-alt.svc.plus', + ); + }, + ); - test('falls back to the managed bridge endpoint without BRIDGE_SERVER_URL', () { - final controller = AppController( - environmentOverride: const { - 'BRIDGE_SERVER_URL': 'https://stale.example.invalid', - }, - ); - addTearDown(controller.dispose); + test( + 'falls back to the managed bridge endpoint without BRIDGE_SERVER_URL', + () { + final controller = AppController( + environmentOverride: const { + 'BRIDGE_SERVER_URL': 'https://stale.example.invalid', + }, + ); + addTearDown(controller.dispose); - expect( - controller.resolveBridgeAcpEndpointInternal()?.toString(), - kManagedBridgeServerUrl, - ); - }); + expect( + controller.resolveBridgeAcpEndpointInternal()?.toString(), + kManagedBridgeServerUrl, + ); + }, + ); + + test( + 'resolves raw bridge token only for the current managed bridge endpoint', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-auth-resolver-', + ); + 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(); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + + final bridgeHeader = await controller + .resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'), + ); + final unrelatedHeader = await controller + .resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://unrelated.example.com/acp/rpc'), + ); + + expect(bridgeHeader, 'bridge-token'); + expect(unrelatedHeader, isNull); + }, + ); test( 'runtime coordinator only exposes remote and offline gateway modes', diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart new file mode 100644 index 00000000..3f21c88e --- /dev/null +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/gateway_acp_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + group('GatewayAcpClient authorization', () { + test('normalizes raw resolver token into bearer header for HTTP', () async { + final capture = await _startAcpHttpServer(); + addTearDown(capture.close); + + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + authorizationResolver: (_) async => 'bridge-token', + ); + + final response = await client.request( + method: 'acp.capabilities', + params: const {}, + ); + + expect(capture.authorizationHeader, 'Bearer bridge-token'); + expect(capture.requestPath, '/acp/rpc'); + expect((response['result'] as Map)['ok'], true); + }); + + test( + 'normalizes raw authorization override into bearer header for HTTP', + () async { + final capture = await _startAcpHttpServer(); + addTearDown(capture.close); + + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + ); + + await client.request( + method: 'acp.capabilities', + params: const {}, + authorizationOverride: 'override-token', + ); + + expect(capture.authorizationHeader, 'Bearer override-token'); + }, + ); + + test('preserves prebuilt bearer authorization header', () async { + final capture = await _startAcpHttpServer(); + addTearDown(capture.close); + + final client = GatewayAcpClient( + endpointResolver: () => capture.baseEndpoint, + ); + + await client.request( + method: 'acp.capabilities', + params: const {}, + authorizationOverride: 'Bearer ready-token', + ); + + expect(capture.authorizationHeader, 'Bearer ready-token'); + }); + + test('desktop bridge auth resolver skips unrelated endpoints', () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-acp-auth-unrelated-', + ); + 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(); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + + final header = await controller + .resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://unrelated.example.com/acp/rpc'), + ); + + expect(header, isNull); + }); + }); +} + +Future<_CapturedAcpHttpServer> _startAcpHttpServer() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final capture = _CapturedAcpHttpServer._( + server, + Uri.parse('http://127.0.0.1:${server.port}'), + ); + server.listen((request) async { + capture.authorizationHeader = + request.headers.value(HttpHeaders.authorizationHeader) ?? ''; + capture.requestPath = request.uri.path; + final body = await utf8.decoder.bind(request).join(); + final id = _decodeRequestId(body); + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': {'ok': true}, + }), + ); + await request.response.close(); + }); + return capture; +} + +String _decodeRequestId(String body) { + final decoded = jsonDecode(body); + if (decoded is Map && decoded['id'] != null) { + return decoded['id'].toString(); + } + return 'request-id'; +} + +class _CapturedAcpHttpServer { + _CapturedAcpHttpServer._(this._server, this.baseEndpoint); + + final HttpServer _server; + final Uri baseEndpoint; + String authorizationHeader = ''; + String requestPath = ''; + + Future close() => _server.close(force: true); +}