fix: sync bridge artifacts and route openclaw tasks

This commit is contained in:
Haitao Pan 2026-05-03 12:14:35 +08:00
parent 50c20eec16
commit 16d4d5e221
7 changed files with 345 additions and 33 deletions

View File

@ -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<String?> 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) {

View File

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

View File

@ -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 <String, dynamic>{};
}
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,

View File

@ -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() ?? '',
);

View File

@ -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', () {

View File

@ -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 <String, String>{
'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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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 <String, String>{
'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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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',
);
});
}

View File

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