Fix ACP bearer auth normalization

This commit is contained in:
Haitao Pan 2026-04-13 12:44:27 +08:00
parent 5b347ea239
commit b5abc8bf09
4 changed files with 273 additions and 59 deletions

View File

@ -684,7 +684,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
))?.trim();
final normalizedToken = bridgeToken?.trim() ?? '';
if (normalizedToken.isNotEmpty) {
return 'Bearer $normalizedToken';
return normalizedToken;
}
}
return null;

View File

@ -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<Map<String, dynamic>> _consumeSseRpcResponse({

View File

@ -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 <String, String>{
'BRIDGE_SERVER_URL': 'https://stale.example.invalid',
},
);
addTearDown(controller.dispose);
await controller.settingsControllerInternal.initialize();
final controller = AppController(
store: store,
environmentOverride: const <String, String>{
'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 <String, String>{
'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 <String, String>{
'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',

View File

@ -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 <String, dynamic>{},
);
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 <String, dynamic>{},
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 <String, dynamic>{},
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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{'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<void> close() => _server.close(force: true);
}