fix: sync bridge artifacts and route openclaw tasks
This commit is contained in:
parent
50c20eec16
commit
16d4d5e221
@ -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) {
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() ?? '',
|
||||
);
|
||||
|
||||
@ -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', () {
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user