fix(assistant): require bookmarks for shared skill roots

This commit is contained in:
Haitao Pan 2026-03-25 22:48:57 +08:00
parent e4d10034cc
commit 26b2c63c28
5 changed files with 430 additions and 59 deletions

View File

@ -30,6 +30,7 @@ import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/single_agent_runner.dart';
import '../runtime/skill_directory_access.dart';
@ -266,6 +267,7 @@ class AppController extends ChangeNotifier {
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
bool _disposed = false;
String _resolvedUserHomeDirectory = resolveUserHomeDirectory();
SettingsSnapshot _lastObservedSettingsSnapshot = SettingsSnapshot.defaults();
Future<void> _assistantThreadPersistQueue = Future<void>.value();
Future<void> _settingsObservationQueue = Future<void>.value();
@ -276,6 +278,8 @@ class AppController extends ChangeNotifier {
_singleAgentSharedSkillScanRootFromOverride,
))?.toList(growable: false) ??
_defaultSingleAgentGlobalSkillScanRoots;
final requiresAuthorizedSharedRoots =
_skillDirectoryAccessService.requiresAuthorizedSharedRoots;
final authorizedByPath = <String, AuthorizedSkillDirectory>{
for (final directory in settings.authorizedSkillDirectories)
normalizeAuthorizedSkillDirectoryPath(directory.path): directory,
@ -288,11 +292,16 @@ class AppController extends ChangeNotifier {
continue;
}
final authorizedDirectory = authorizedByPath.remove(resolvedPath);
resolvedRoots.add(
root.copyWith(bookmark: authorizedDirectory?.bookmark ?? ''),
);
final bookmark = authorizedDirectory?.bookmark.trim() ?? '';
if (requiresAuthorizedSharedRoots && bookmark.isEmpty) {
continue;
}
resolvedRoots.add(root.copyWith(bookmark: bookmark));
}
for (final directory in authorizedByPath.values) {
if (requiresAuthorizedSharedRoots && directory.bookmark.trim().isEmpty) {
continue;
}
resolvedRoots.add(
_singleAgentSharedSkillScanRootFromAuthorizedDirectory(directory),
);
@ -368,7 +377,7 @@ class AppController extends ChangeNotifier {
_defaultSingleAgentGlobalSkillScanRoots
.map((item) => item.path)
.toList(growable: false);
String get userHomeDirectory => Platform.environment['HOME']?.trim() ?? '';
String get userHomeDirectory => _resolvedUserHomeDirectory;
String get settingsYamlPath => defaultUserSettingsFilePath() ?? '';
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != settings.toJsonString() ||
@ -2964,6 +2973,8 @@ class AppController extends ChangeNotifier {
Future<void> _initialize() async {
try {
_resolvedUserHomeDirectory = await _skillDirectoryAccessService
.resolveUserHomeDirectory();
await _settingsController.initialize();
_restoreAssistantThreads(await _store.loadAssistantThreadRecords());
await _restoreSharedSingleAgentLocalSkillsCache();
@ -4342,7 +4353,8 @@ class AppController extends ChangeNotifier {
);
}
_SingleAgentSkillScanRoot _singleAgentSharedSkillScanRootFromAuthorizedDirectory(
_SingleAgentSkillScanRoot
_singleAgentSharedSkillScanRootFromAuthorizedDirectory(
AuthorizedSkillDirectory directory,
) {
final normalizedPath = normalizeAuthorizedSkillDirectoryPath(
@ -4369,7 +4381,7 @@ class AppController extends ChangeNotifier {
return trimmed;
}
if (trimmed.startsWith('~/')) {
final home = Platform.environment['HOME']?.trim() ?? '';
final home = _resolvedUserHomeDirectory.trim();
return home.isEmpty ? trimmed : '$home/${trimmed.substring(2)}';
}
final normalizedWorkspace = workspaceRef.trim();

View File

@ -4,10 +4,13 @@ import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'platform_environment.dart';
import 'runtime_models.dart';
abstract class SkillDirectoryAccessService {
bool get isSupported;
bool get requiresAuthorizedSharedRoots;
Future<String> resolveUserHomeDirectory();
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
@ -48,6 +51,14 @@ class UnsupportedSkillDirectoryAccessService
@override
bool get isSupported => false;
@override
bool get requiresAuthorizedSharedRoots => false;
@override
Future<String> resolveUserHomeDirectory() async {
return _fallbackUserHomeDirectory();
}
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
@ -68,6 +79,14 @@ class FileSelectorSkillDirectoryAccessService
@override
bool get isSupported => true;
@override
bool get requiresAuthorizedSharedRoots => false;
@override
Future<String> resolveUserHomeDirectory() async {
return _fallbackUserHomeDirectory();
}
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
@ -104,31 +123,53 @@ class MacOsSkillDirectoryAccessService implements SkillDirectoryAccessService {
static const MethodChannel _channel = MethodChannel(
'plus.svc.xworkmate/skill_directory_access',
);
final FileSelectorSkillDirectoryAccessService _fallbackService =
FileSelectorSkillDirectoryAccessService();
@override
bool get isSupported => true;
@override
bool get requiresAuthorizedSharedRoots => true;
@override
Future<String> resolveUserHomeDirectory() async {
try {
final response = await _channel.invokeMethod<String>(
'resolveUserHomeDirectory',
);
final trimmed = response?.trim() ?? '';
return trimmed.isEmpty ? _fallbackUserHomeDirectory() : trimmed;
} on MissingPluginException {
return _fallbackUserHomeDirectory();
}
}
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
final response = await _channel.invokeMapMethod<String, dynamic>(
'authorizeDirectory',
<String, dynamic>{'suggestedPath': suggestedPath},
);
if (response == null) {
return null;
try {
final response = await _channel.invokeMapMethod<String, dynamic>(
'authorizeDirectory',
<String, dynamic>{'suggestedPath': suggestedPath},
);
if (response == null) {
return null;
}
final normalized = normalizeAuthorizedSkillDirectoryPath(
response['path']?.toString() ?? '',
);
if (normalized.isEmpty) {
return null;
}
return AuthorizedSkillDirectory(
path: normalized,
bookmark: response['bookmark']?.toString().trim() ?? '',
);
} on MissingPluginException {
return _fallbackService.authorizeDirectory(suggestedPath: suggestedPath);
}
final normalized = normalizeAuthorizedSkillDirectoryPath(
response['path']?.toString() ?? '',
);
if (normalized.isEmpty) {
return null;
}
return AuthorizedSkillDirectory(
path: normalized,
bookmark: response['bookmark']?.toString().trim() ?? '',
);
}
@override
@ -149,37 +190,45 @@ class MacOsSkillDirectoryAccessService implements SkillDirectoryAccessService {
onClose: () async {},
);
}
final response = await _channel.invokeMapMethod<String, dynamic>(
'startDirectoryAccess',
<String, dynamic>{'bookmark': bookmark},
);
if (response == null) {
return null;
try {
final response = await _channel.invokeMapMethod<String, dynamic>(
'startDirectoryAccess',
<String, dynamic>{'bookmark': bookmark},
);
if (response == null) {
return null;
}
final accessId = response['accessId']?.toString().trim() ?? '';
final resolvedPath = normalizeAuthorizedSkillDirectoryPath(
response['path']?.toString() ?? normalizedPath,
);
if (accessId.isEmpty || resolvedPath.isEmpty) {
return null;
}
final refreshedBookmark =
response['bookmark']?.toString().trim().isNotEmpty == true
? response['bookmark'].toString().trim()
: directory.bookmark;
return SkillDirectoryAccessHandle(
path: resolvedPath,
refreshedBookmark: refreshedBookmark,
onClose: () async {
await _channel.invokeMethod<void>(
'stopDirectoryAccess',
<String, dynamic>{'accessId': accessId},
);
},
);
} on MissingPluginException {
return _fallbackService.openDirectory(directory);
}
final accessId = response['accessId']?.toString().trim() ?? '';
final resolvedPath = normalizeAuthorizedSkillDirectoryPath(
response['path']?.toString() ?? normalizedPath,
);
if (accessId.isEmpty || resolvedPath.isEmpty) {
return null;
}
final refreshedBookmark =
response['bookmark']?.toString().trim().isNotEmpty == true
? response['bookmark'].toString().trim()
: directory.bookmark;
return SkillDirectoryAccessHandle(
path: resolvedPath,
refreshedBookmark: refreshedBookmark,
onClose: () async {
await _channel.invokeMethod<void>(
'stopDirectoryAccess',
<String, dynamic>{'accessId': accessId},
);
},
);
}
}
String _fallbackUserHomeDirectory() {
return resolveUserHomeDirectory();
}
String _initialDirectoryForSuggestion(String suggestedPath) {
final trimmed = normalizeAuthorizedSkillDirectoryPath(suggestedPath);
if (trimmed.isEmpty) {

View File

@ -1,10 +1,13 @@
import Cocoa
import Darwin
import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
private let skillDirectoryChannelName = "plus.svc.xworkmate/skill_directory_access"
private var directoryAccessSessions: [String: URL] = [:]
private var skillDirectoryChannel: FlutterMethodChannel?
private var skillDirectoryMessengerId: ObjectIdentifier?
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
@ -12,13 +15,7 @@ class AppDelegate: FlutterAppDelegate {
guard let controller = mainFlutterWindow?.contentViewController as? FlutterViewController else {
return
}
let channel = FlutterMethodChannel(
name: skillDirectoryChannelName,
binaryMessenger: controller.engine.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
self?.handleSkillDirectoryCall(call, result: result)
}
registerSkillDirectoryChannel(for: controller)
}
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@ -37,8 +34,27 @@ class AppDelegate: FlutterAppDelegate {
super.applicationWillTerminate(notification)
}
func registerSkillDirectoryChannel(for controller: FlutterViewController) {
let messengerObject = controller.engine.binaryMessenger as AnyObject
let messengerId = ObjectIdentifier(messengerObject)
if skillDirectoryMessengerId == messengerId {
return
}
let channel = FlutterMethodChannel(
name: skillDirectoryChannelName,
binaryMessenger: controller.engine.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
self?.handleSkillDirectoryCall(call, result: result)
}
skillDirectoryChannel = channel
skillDirectoryMessengerId = messengerId
}
private func handleSkillDirectoryCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "resolveUserHomeDirectory":
result(resolveUserHomeDirectoryPath())
case "authorizeDirectory":
authorizeDirectory(call, result: result)
case "startDirectoryAccess":
@ -170,10 +186,10 @@ class AppDelegate: FlutterAppDelegate {
private func initialDirectoryURL(for suggestedPath: String) -> URL? {
let trimmed = suggestedPath.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return FileManager.default.homeDirectoryForCurrentUser
return URL(fileURLWithPath: resolveUserHomeDirectoryPath(), isDirectory: true)
}
var candidate = URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath)
var candidate = URL(fileURLWithPath: expandUserPath(trimmed))
var isDirectory: ObjCBool = false
while true {
if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) {
@ -185,6 +201,21 @@ class AppDelegate: FlutterAppDelegate {
}
candidate = parent
}
return FileManager.default.homeDirectoryForCurrentUser
return URL(fileURLWithPath: resolveUserHomeDirectoryPath(), isDirectory: true)
}
private func expandUserPath(_ path: String) -> String {
guard path.hasPrefix("~/") else {
return path
}
let relative = String(path.dropFirst(2))
return (resolveUserHomeDirectoryPath() as NSString).appendingPathComponent(relative)
}
private func resolveUserHomeDirectoryPath() -> String {
if let directoryPointer = getpwuid(getuid())?.pointee.pw_dir {
return String(cString: directoryPointer)
}
return FileManager.default.homeDirectoryForCurrentUser.path
}
}

View File

@ -9,6 +9,9 @@ class MainFlutterWindow: NSWindow {
self.setFrame(windowFrame, display: true)
RegisterGeneratedPlugins(registry: flutterViewController)
(NSApp.delegate as? AppDelegate)?.registerSkillDirectoryChannel(
for: flutterViewController
)
super.awakeFromNib()

View File

@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/runtime/skill_directory_access.dart';
void main() {
test(
@ -207,6 +208,240 @@ void main() {
},
);
test(
'AppController resolves preset shared roots against the access service home directory',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-home-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final userHome = Directory('${tempDirectory.path}/real-home');
final agentsRoot = Directory('${userHome.path}/.agents/skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
final controller = AppController(
store: await _createStore(tempDirectory.path),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
userHomeDirectory: userHome.path,
),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentSharedSkillScanRootOverrides: const <String>[
'~/.agents/skills',
],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((item) => item.label == 'Browser'),
);
expect(controller.userHomeDirectory, userHome.path);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map((item) => item.label),
contains('Browser'),
);
},
);
test(
'AppController skips preset shared roots without bookmarks when the access service requires authorization',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-macos-preset-unauthorized-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final userHome = Directory('${tempDirectory.path}/real-home');
final agentsRoot = Directory('${userHome.path}/.agents/skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
final controller = AppController(
store: await _createStore(tempDirectory.path),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
userHomeDirectory: userHome.path,
requiresAuthorizedSharedRoots: true,
),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentSharedSkillScanRootOverrides: const <String>[
'~/.agents/skills',
],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.where((item) => item.label == 'Browser'),
isEmpty,
);
},
);
test(
'AppController scans preset shared roots with bookmarks when the access service requires authorization',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-macos-preset-authorized-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final userHome = Directory('${tempDirectory.path}/real-home');
final agentsRoot = Directory('${userHome.path}/.agents/skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
final store = await _createStore(tempDirectory.path);
await store.saveSettingsSnapshot(
_singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith(
authorizedSkillDirectories: <AuthorizedSkillDirectory>[
AuthorizedSkillDirectory(
path: agentsRoot.path,
bookmark: 'bookmark-1',
),
],
),
);
final controller = AppController(
store: store,
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
userHomeDirectory: userHome.path,
requiresAuthorizedSharedRoots: true,
),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentSharedSkillScanRootOverrides: const <String>[
'~/.agents/skills',
],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((item) => item.label == 'Browser'),
);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map((item) => item.label),
contains('Browser'),
);
},
);
test(
'AppController skips custom shared directories without bookmarks when the access service requires authorization',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-macos-custom-unauthorized-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final customRoot = Directory(
'${tempDirectory.path}/custom-shared-skills',
);
await _writeSkill(
customRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
final store = await _createStore(tempDirectory.path);
await store.saveSettingsSnapshot(
_singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith(
authorizedSkillDirectories: <AuthorizedSkillDirectory>[
AuthorizedSkillDirectory(path: customRoot.path),
],
),
);
final controller = AppController(
store: store,
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
userHomeDirectory: tempDirectory.path,
requiresAuthorizedSharedRoots: true,
),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.where((item) => item.label == 'Browser'),
isEmpty,
);
},
);
test(
'AppController keeps thread-bound skills isolated and restores them after restart',
() async {
@ -781,3 +1016,44 @@ SettingsSnapshot _singleAgentTestSettings({required String workspacePath}) {
workspacePath: workspacePath,
);
}
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {
_FakeSkillDirectoryAccessService({
required this.userHomeDirectory,
this.requiresAuthorizedSharedRoots = false,
});
final String userHomeDirectory;
@override
final bool requiresAuthorizedSharedRoots;
@override
bool get isSupported => true;
@override
Future<String> resolveUserHomeDirectory() async {
return userHomeDirectory;
}
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
final normalized = normalizeAuthorizedSkillDirectoryPath(suggestedPath);
if (normalized.isEmpty) {
return null;
}
return AuthorizedSkillDirectory(path: normalized);
}
@override
Future<SkillDirectoryAccessHandle?> openDirectory(
AuthorizedSkillDirectory directory,
) async {
final normalized = normalizeAuthorizedSkillDirectoryPath(directory.path);
if (normalized.isEmpty) {
return null;
}
return SkillDirectoryAccessHandle(path: normalized, onClose: () async {});
}
}