diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 7f583056..51cad4cd 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -758,15 +758,20 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ); return; } + final root = Directory(existingThread.workspaceBinding.workspacePath); + final artifactSyncPolicy = await _loadArtifactSyncPolicyInternal( + root, + existingThread.selectedSkillKeys, + ); final artifacts = result.artifacts; if (artifacts.isEmpty) { - final root = Directory(existingThread.workspaceBinding.workspacePath); final currentTaskArtifactRelativePaths = isOpenClawNoExportedArtifactsGuardResultInternal(result) ? const [] : await _workspaceArtifactPathsModifiedSinceInternal( root, existingThread.lifecycleState.lastRunAtMs, + artifactSyncPolicy, ); if (currentTaskArtifactRelativePaths.isNotEmpty) { upsertTaskThreadInternal( @@ -789,18 +794,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ); return; } - final root = Directory(existingThread.workspaceBinding.workspacePath); await root.create(recursive: true); var wroteArtifact = false; var failedArtifact = false; var skippedArtifact = false; + var rejectedArtifact = false; final currentTaskArtifactPaths = {}; for (final artifact in artifacts) { final relativePath = _sanitizeArtifactRelativePathInternal( artifact.relativePath, ); - if (relativePath.isEmpty) { + if (relativePath.isEmpty || artifactSyncPolicy.ignores(relativePath)) { skippedArtifact = true; continue; } @@ -811,7 +816,11 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final bytes = bytesResult.bytes; if (bytes == null) { final existingArtifactPaths = - await _existingWorkspaceArtifactPathsInternal(root, relativePath); + await _existingWorkspaceArtifactPathsInternal( + root, + relativePath, + artifactSyncPolicy, + ); if (existingArtifactPaths.isEmpty) { skippedArtifact = true; continue; @@ -820,6 +829,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { wroteArtifact = true; continue; } + if (artifactSyncPolicy.rejects(artifact, relativePath, bytes)) { + rejectedArtifact = true; + continue; + } final target = await _nextArtifactTargetFileInternal(root, relativePath); await target.parent.create(recursive: true); final verified = await _writeVerifiedArtifactBytesInternal( @@ -848,6 +861,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ? (failedArtifact || skippedArtifact ? 'partial' : 'synced') : failedArtifact ? 'download-failed' + : rejectedArtifact + ? 'no-exported-artifacts' : 'no-artifacts'; final currentTaskArtifactRelativePaths = wroteArtifact ? (currentTaskArtifactPaths.toList(growable: false)..sort()) @@ -1231,6 +1246,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Future> _existingWorkspaceArtifactPathsInternal( Directory root, String relativePath, + _ArtifactSyncPolicy policy, ) async { final targetPath = DesktopThreadArtifactService.resolveAbsolutePathInternal( root.path, @@ -1246,7 +1262,9 @@ Future> _existingWorkspaceArtifactPathsInternal( root.path, targetPath, ); - return resolvedRelativePath == null || resolvedRelativePath.isEmpty + return resolvedRelativePath == null || + resolvedRelativePath.isEmpty || + policy.ignores(resolvedRelativePath) ? const [] : [resolvedRelativePath]; } @@ -1260,7 +1278,9 @@ Future> _existingWorkspaceArtifactPathsInternal( for (final file in files) { final resolvedRelativePath = DesktopThreadArtifactService.relativePathInternal(root.path, file.path); - if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) { + if (resolvedRelativePath != null && + resolvedRelativePath.isNotEmpty && + !policy.ignores(resolvedRelativePath)) { paths.add(resolvedRelativePath); } } @@ -1271,6 +1291,7 @@ Future> _existingWorkspaceArtifactPathsInternal( Future> _workspaceArtifactPathsModifiedSinceInternal( Directory root, double? sinceMs, + _ArtifactSyncPolicy policy, ) async { final thresholdMs = sinceMs ?? 0; if (thresholdMs <= 0 || !await root.exists()) { @@ -1281,7 +1302,7 @@ Future> _workspaceArtifactPathsModifiedSinceInternal( for (final file in files) { try { final stat = await file.stat(); - if (stat.modified.millisecondsSinceEpoch.toDouble() < thresholdMs) { + if (stat.modified.millisecondsSinceEpoch.toDouble() <= thresholdMs) { continue; } final resolvedRelativePath = @@ -1295,6 +1316,9 @@ Future> _workspaceArtifactPathsModifiedSinceInternal( if (_isWorkspaceArtifactNoisePathInternal(resolvedRelativePath)) { continue; } + if (policy.ignores(resolvedRelativePath)) { + continue; + } paths.add(resolvedRelativePath); } on FileSystemException { continue; @@ -1309,6 +1333,54 @@ bool _isWorkspaceArtifactNoisePathInternal(String relativePath) { '.DS_Store'; } +Future<_ArtifactSyncPolicy> _loadArtifactSyncPolicyInternal( + Directory root, + List selectedSkillKeys, +) async { + final files = [ + File( + DesktopThreadArtifactService.resolveAbsolutePathInternal( + root.path, + 'artifact-ignore.md', + ), + ), + ]; + for (final skillKey in selectedSkillKeys) { + final normalizedSkillKey = _sanitizeArtifactRelativePathInternal(skillKey); + if (normalizedSkillKey.isEmpty) { + continue; + } + files.add( + File( + DesktopThreadArtifactService.resolveAbsolutePathInternal( + root.path, + 'skills/$normalizedSkillKey/artifact-ignore.md', + ), + ), + ); + } + final policyFiles = []; + for (final file in files) { + final resolvedRelativePath = + DesktopThreadArtifactService.relativePathInternal(root.path, file.path); + if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) { + policyFiles.add(resolvedRelativePath); + } + } + final policies = <_ArtifactSyncPolicy>[]; + try { + for (final file in files) { + if (!await file.exists()) { + continue; + } + policies.add(_ArtifactSyncPolicy.parse(await file.readAsString())); + } + } on FileSystemException { + return const _ArtifactSyncPolicy(); + } + return _ArtifactSyncPolicy.merge(policies, policyFiles: policyFiles); +} + String _normalizeAuthorizationHeaderInternal(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { @@ -1356,6 +1428,238 @@ class _ArtifactBytesResult { final bool failed; } +class _ArtifactSyncPolicy { + const _ArtifactSyncPolicy({ + this.ignoreRules = const <_ArtifactIgnoreRule>[], + this.rejectRules = const <_ArtifactRejectRule>[], + this.policyFiles = const [], + }); + + factory _ArtifactSyncPolicy.merge( + List<_ArtifactSyncPolicy> policies, { + required List policyFiles, + }) { + return _ArtifactSyncPolicy( + ignoreRules: policies + .expand((policy) => policy.ignoreRules) + .toList(growable: false), + rejectRules: policies + .expand((policy) => policy.rejectRules) + .toList(growable: false), + policyFiles: policyFiles, + ); + } + + factory _ArtifactSyncPolicy.parse(String markdown) { + final ignoreRules = <_ArtifactIgnoreRule>[]; + final rejectRules = <_ArtifactRejectRule>[]; + var inIgnoreBlock = false; + var inRejectBlock = false; + var fields = >{}; + for (final rawLine in markdown.split(RegExp(r'\r?\n'))) { + final line = rawLine.trim(); + if (line.startsWith('```')) { + final fenceName = line + .replaceFirst(RegExp(r'^`+'), '') + .trim() + .toLowerCase(); + if (!inIgnoreBlock && + !inRejectBlock && + fenceName == 'artifact-ignore') { + inIgnoreBlock = true; + continue; + } + if (!inRejectBlock && fenceName == 'artifact-reject') { + inRejectBlock = true; + fields = >{}; + continue; + } + if (inIgnoreBlock) { + inIgnoreBlock = false; + } + if (inRejectBlock) { + final rule = _ArtifactRejectRule.tryParse(fields); + if (rule != null) { + rejectRules.add(rule); + } + inRejectBlock = false; + fields = >{}; + } + continue; + } + if (inIgnoreBlock) { + if (line.isEmpty || line.startsWith('#')) { + continue; + } + final rule = _ArtifactIgnoreRule.tryParse(line); + if (rule != null) { + ignoreRules.add(rule); + } + continue; + } + if (!inRejectBlock || line.isEmpty || line.startsWith('#')) { + continue; + } + final separator = line.indexOf('='); + if (separator <= 0) { + continue; + } + final key = line.substring(0, separator).trim().toLowerCase(); + final value = line.substring(separator + 1).trim(); + if (key.isEmpty || value.isEmpty) { + continue; + } + fields.putIfAbsent(key, () => []).add(value); + } + return _ArtifactSyncPolicy( + ignoreRules: ignoreRules, + rejectRules: rejectRules, + ); + } + + final List<_ArtifactIgnoreRule> ignoreRules; + final List<_ArtifactRejectRule> rejectRules; + final List policyFiles; + + bool ignores(String relativePath) { + if (_isWorkspaceArtifactNoisePathInternal(relativePath)) { + return true; + } + final normalizedPath = _sanitizeArtifactRelativePathInternal(relativePath); + if (DesktopThreadArtifactService.baseNameInternal(normalizedPath) == + 'artifact-ignore.md' || + policyFiles.contains(normalizedPath)) { + return true; + } + for (final rule in ignoreRules) { + if (rule.matches(normalizedPath)) { + return true; + } + } + return false; + } + + bool rejects( + GoTaskServiceArtifact artifact, + String relativePath, + List bytes, + ) { + for (final rule in rejectRules) { + if (rule.matches(artifact, relativePath, bytes)) { + return true; + } + } + return false; + } +} + +class _ArtifactIgnoreRule { + const _ArtifactIgnoreRule(this.pattern); + + static _ArtifactIgnoreRule? tryParse(String raw) { + final pattern = _sanitizeArtifactRelativePathInternal(raw); + if (pattern.isEmpty) { + return null; + } + return _ArtifactIgnoreRule(raw.trim()); + } + + final String pattern; + + bool matches(String relativePath) { + final normalizedPath = _sanitizeArtifactRelativePathInternal( + relativePath, + ).toLowerCase(); + final trimmedPattern = pattern.trim(); + if (trimmedPattern.endsWith('/')) { + final directoryPattern = _sanitizeArtifactRelativePathInternal( + trimmedPattern.substring(0, trimmedPattern.length - 1), + ).toLowerCase(); + return normalizedPath == directoryPattern || + normalizedPath.startsWith('$directoryPattern/'); + } + return _matchesArtifactPathPatternInternal(normalizedPath, trimmedPattern); + } +} + +class _ArtifactRejectRule { + const _ArtifactRejectRule({ + required this.path, + required this.contentType, + required this.contains, + }); + + static _ArtifactRejectRule? tryParse(Map> fields) { + final contains = fields['contains'] ?? const []; + if (contains.isEmpty) { + return null; + } + return _ArtifactRejectRule( + path: _firstValue(fields['path']), + contentType: _firstValue(fields['contenttype']), + contains: contains, + ); + } + + final String? path; + final String? contentType; + final List contains; + + static String? _firstValue(List? values) { + if (values == null || values.isEmpty) { + return null; + } + return values.first; + } + + bool matches( + GoTaskServiceArtifact artifact, + String relativePath, + List bytes, + ) { + if (path != null && + !_matchesArtifactPathPatternInternal(relativePath, path!)) { + return false; + } + if (contentType != null && + !artifact.contentType.trim().toLowerCase().contains( + contentType!.trim().toLowerCase(), + )) { + return false; + } + final text = utf8.decode(bytes, allowMalformed: true); + for (final needle in contains) { + if (!text.contains(needle)) { + return false; + } + } + return true; + } +} + +bool _matchesArtifactPathPatternInternal(String relativePath, String pattern) { + final normalizedPath = _sanitizeArtifactRelativePathInternal( + relativePath, + ).toLowerCase(); + final normalizedPattern = _sanitizeArtifactRelativePathInternal( + pattern, + ).toLowerCase(); + if (normalizedPath.isEmpty || normalizedPattern.isEmpty) { + return false; + } + if (normalizedPattern == normalizedPath) { + return true; + } + if (normalizedPattern.startsWith('*.')) { + return !normalizedPath.contains('/') && + normalizedPath.endsWith(normalizedPattern.substring(1)); + } + if (normalizedPattern.startsWith('**/*.')) { + return normalizedPath.endsWith(normalizedPattern.substring(4)); + } + return false; +} + class _ArtifactDownloadAttemptResult { const _ArtifactDownloadAttemptResult({ required this.bytes, diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 0fa77d74..d926f810 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart' as crypto; @@ -1354,6 +1355,195 @@ void main() { }, ); + test('rejects artifacts matched by artifact-ignore policy', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-openclaw-placeholder-pdf-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + controller.upsertTaskThreadInternal( + 'unit-fixture-task-a', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-a', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + await File('${localWorkspace.path}/artifact-ignore.md').writeAsString( + '```artifact-reject\n' + 'path=exports/final.pdf\n' + 'contentType=application/pdf\n' + 'contains=XWorkmate Task Artifact\n' + 'contains=Required extensions: pdf\n' + 'contains=TaskThread workspace context:\n' + 'contains=Workspace isolation rules:\n' + '```\n', + ); + + final placeholderBytes = utf8.encode( + '%PDF-1.3\n' + 'BT /F1 14 Tf (XWorkmate Task Artifact) Tj ' + '(Required extensions: pdf) Tj ' + '(TaskThread workspace context:) Tj ' + '(Workspace isolation rules:) Tj ET', + ); + final result = GoTaskServiceResult( + success: true, + message: + 'OpenClaw final artifacts were written to the current task artifact scope: pdf.', + turnId: 'turn-1', + raw: { + 'artifactWarnings': ['agent.wait request timeout'], + 'artifacts': >[ + { + 'relativePath': 'exports/final.pdf', + 'contentType': 'application/pdf', + 'encoding': 'base64', + 'content': base64Encode(placeholderBytes), + 'sizeBytes': placeholderBytes.length, + 'sha256': crypto.sha256.convert(placeholderBytes).toString(), + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-a', + result, + ); + + expect( + await File('${localWorkspace.path}/exports/final.pdf').exists(), + isFalse, + ); + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', + ); + expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); + expect(thread.lastTaskArtifactRelativePaths, isEmpty); + }); + + test('loads global and selected skill artifact-ignore policies', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-skill-artifact-policy-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + await Directory( + '${localWorkspace.path}/skills/video-production/it-infra-evolution-video-v2', + ).create(recursive: true); + await File('${localWorkspace.path}/artifact-ignore.md').writeAsString( + '```artifact-ignore\n' + 'tmp/\n' + '```\n', + ); + await File( + '${localWorkspace.path}/skills/video-production/it-infra-evolution-video-v2/artifact-ignore.md', + ).writeAsString( + '```artifact-ignore\n' + 'renders/tmp/\n' + '```\n', + ); + final startedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + await Future.delayed(const Duration(milliseconds: 20)); + await Directory('${localWorkspace.path}/tmp').create(); + await Directory( + '${localWorkspace.path}/renders/tmp', + ).create(recursive: true); + await Directory('${localWorkspace.path}/renders').create(); + await File('${localWorkspace.path}/tmp/build.log').writeAsString('log'); + await File( + '${localWorkspace.path}/renders/tmp/scratch.png', + ).writeAsBytes([1, 2, 3]); + await File( + '${localWorkspace.path}/renders/final.mp4', + ).writeAsBytes([4, 5, 6]); + + controller.upsertTaskThreadInternal( + 'unit-fixture-task-a', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-a', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + selectedSkillKeys: const [ + 'video-production/it-infra-evolution-video-v2', + ], + lifecycleStatus: 'running', + lastRunAtMs: startedAtMs, + lastResultCode: 'running', + ); + + const result = GoTaskServiceResult( + success: true, + message: 'done', + turnId: 'turn-1', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-a', + result, + ); + + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', + ); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastTaskArtifactRelativePaths, ['renders/final.mp4']); + + controller.upsertTaskThreadInternal( + 'unit-fixture-task-b', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-b', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + selectedSkillKeys: const [], + lifecycleStatus: 'running', + lastRunAtMs: startedAtMs, + lastResultCode: 'running', + ); + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-b', + result, + ); + final unselectedSkillThread = controller + .requireTaskThreadForSessionInternal('unit-fixture-task-b'); + expect(unselectedSkillThread.lastTaskArtifactRelativePaths, [ + 'renders/final.mp4', + 'renders/tmp/scratch.png', + ]); + }); + test('records ordinary empty artifact results as no artifacts', () async { final controller = AppController( environmentOverride: const {},