Merge branch 'codex/skills-symlink-scan'

This commit is contained in:
Haitao Pan 2026-03-26 12:03:27 +08:00
commit 80486d98eb
2 changed files with 108 additions and 7 deletions

View File

@ -4274,13 +4274,8 @@ class AppController extends ChangeNotifier {
if (!await root.exists()) {
continue;
}
await for (final entity in root.list(
recursive: true,
followLinks: false,
)) {
if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') {
continue;
}
final skillFiles = await _collectSkillFilesFromDirectory(root);
for (final entity in skillFiles) {
final entry = await _skillEntryFromFile(
entity,
rootSpec,
@ -4303,6 +4298,57 @@ class AppController extends ChangeNotifier {
return entries;
}
Future<List<File>> _collectSkillFilesFromDirectory(Directory root) async {
final skillFiles = <File>[];
final visitedDirectories = <String>{};
Future<void> visitDirectory(Directory directory) async {
final directoryKey = await _directoryScanKey(directory);
if (!visitedDirectories.add(directoryKey)) {
return;
}
await for (final entity in directory.list(followLinks: false)) {
if (entity is File) {
if (entity.uri.pathSegments.last == 'SKILL.md') {
skillFiles.add(entity);
}
continue;
}
if (entity is Directory) {
await visitDirectory(entity);
continue;
}
if (entity is! Link) {
continue;
}
final resolvedType = await FileSystemEntity.type(
entity.path,
followLinks: true,
);
if (resolvedType == FileSystemEntityType.file) {
if (entity.uri.pathSegments.last == 'SKILL.md') {
skillFiles.add(File(entity.path));
}
continue;
}
if (resolvedType == FileSystemEntityType.directory) {
await visitDirectory(Directory(entity.path));
}
}
}
await visitDirectory(root);
return skillFiles;
}
Future<String> _directoryScanKey(Directory directory) async {
try {
return await directory.resolveSymbolicLinks();
} catch (_) {
return directory.absolute.path;
}
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentSharedSkillEntries() {
return _scanSingleAgentSkillEntries(_singleAgentSharedSkillScanRoots);
}

View File

@ -210,6 +210,61 @@ void main() {
},
);
test(
'AppController scans skills inside symlinked directories under shared roots',
() async {
if (Platform.isWindows) {
return;
}
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-symlink-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final sharedRoot = Directory('${tempDirectory.path}/shared-root');
final actualSkillRoot = Directory('${tempDirectory.path}/actual-skills');
await sharedRoot.create(recursive: true);
await _writeSkill(
actualSkillRoot,
'linked-browser',
skillName: 'Linked Browser',
description: 'Loaded through a symlinked directory',
);
await Link('${sharedRoot.path}/linked-pack').create(actualSkillRoot.path);
final controller = AppController(
store: await _createStore(tempDirectory.path),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentSharedSkillScanRootOverrides: <String>[sharedRoot.path],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((skill) => skill.label == 'Linked Browser'),
);
final linkedSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'Linked Browser');
expect(linkedSkill.description, 'Loaded through a symlinked directory');
expect(linkedSkill.source, 'custom');
expect(linkedSkill.sourceLabel, contains('linked-pack/linked-browser'));
},
);
test(
'AppController resolves preset shared roots against the access service home directory',
() async {