feat(settings): add authorized skills directory authorization

This commit is contained in:
Haitao Pan 2026-03-25 19:22:10 +08:00
parent c2a8cc0b3a
commit ea2edfdc02
15 changed files with 1659 additions and 91 deletions

View File

@ -31,6 +31,7 @@ import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/single_agent_runner.dart';
import '../runtime/skill_directory_access.dart';
enum CodexCooperationState { notStarted, bridgeOnly, registered }
@ -39,11 +40,13 @@ class _SingleAgentSkillScanRoot {
required this.path,
required this.source,
required this.scope,
this.bookmark = '',
});
final String path;
final String source;
final String scope;
final String bookmark;
}
const String _singleAgentLocalSkillsCacheRelativePath =
@ -98,6 +101,7 @@ class AppController extends ChangeNotifier {
RuntimeCoordinator? runtimeCoordinator,
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
SkillDirectoryAccessService? skillDirectoryAccessService,
List<String>? singleAgentLocalSkillScanRoots,
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
ArisBundleRepository? arisBundleRepository,
@ -141,10 +145,10 @@ class AppController extends ChangeNotifier {
_tasksController = DerivedTasksController();
_desktopPlatformService =
desktopPlatformService ?? createDesktopPlatformService();
_singleAgentLocalSkillScanRootOverrides =
(singleAgentLocalSkillScanRoots ??
(_isFlutterTestEnvironment ? const <String>[] : null))
?.toList(growable: false);
_skillDirectoryAccessService =
skillDirectoryAccessService ?? createSkillDirectoryAccessService();
_singleAgentLocalSkillScanRootOverrides = singleAgentLocalSkillScanRoots
?.toList(growable: false);
_gatewayAcpClient = GatewayAcpClient(
endpointResolver: _resolveGatewayAcpEndpoint,
);
@ -187,6 +191,7 @@ class AppController extends ChangeNotifier {
late final DevicesController _devicesController;
late final DerivedTasksController _tasksController;
late final DesktopPlatformService _desktopPlatformService;
late final SkillDirectoryAccessService _skillDirectoryAccessService;
late final List<String>? _singleAgentLocalSkillScanRootOverrides;
late final GatewayAcpClient _gatewayAcpClient;
late final DirectSingleAgentAppServerClient _singleAgentAppServerClient;
@ -247,15 +252,17 @@ class AppController extends ChangeNotifier {
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
bool _disposed = false;
static bool get _isFlutterTestEnvironment =>
Platform.environment.containsKey('FLUTTER_TEST');
SettingsSnapshot _lastObservedSettingsSnapshot = SettingsSnapshot.defaults();
Future<void> _assistantThreadPersistQueue = Future<void>.value();
Future<void> _settingsObservationQueue = Future<void>.value();
List<_SingleAgentSkillScanRoot> get _singleAgentGlobalSkillScanRoots =>
(_singleAgentLocalSkillScanRootOverrides?.map(
_singleAgentGlobalSkillScanRootFromOverride,
))?.toList(growable: false) ??
_defaultSingleAgentGlobalSkillScanRoots;
settings.authorizedSkillDirectories
.map(_singleAgentGlobalSkillScanRootFromAuthorizedDirectory)
.toList(growable: false);
WorkspaceDestination get destination => _destination;
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
@ -317,6 +324,16 @@ class AppController extends ChangeNotifier {
SettingsSnapshot get settings => _settingsController.snapshot;
SettingsSnapshot get settingsDraft =>
_settingsDraftInitialized ? _settingsDraft : settings;
bool get supportsSkillDirectoryAuthorization =>
_skillDirectoryAccessService.isSupported;
List<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
settings.authorizedSkillDirectories;
List<String> get recommendedAuthorizedSkillDirectoryPaths =>
_defaultSingleAgentGlobalSkillScanRoots
.map((item) => item.path)
.toList(growable: false);
String get userHomeDirectory => Platform.environment['HOME']?.trim() ?? '';
String get settingsYamlPath => defaultUserSettingsFilePath() ?? '';
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != settings.toJsonString() ||
_draftSecretValues.isNotEmpty;
@ -2605,6 +2622,62 @@ class AppController extends ChangeNotifier {
);
}
Future<AuthorizedSkillDirectory?> authorizeSkillDirectory({
String suggestedPath = '',
}) {
return _skillDirectoryAccessService.authorizeDirectory(
suggestedPath: suggestedPath,
);
}
Future<void> saveAuthorizedSkillDirectories(
List<AuthorizedSkillDirectory> directories,
) async {
if (_disposed) {
return;
}
final previous = settings;
final previousDraft = _settingsDraft;
final hadDraftChanges = hasSettingsDraftChanges;
final draftInitialized = _settingsDraftInitialized;
final pendingSettingsApply = _pendingSettingsApply;
final pendingGatewayApply = _pendingGatewayApply;
final pendingAiGatewayApply = _pendingAiGatewayApply;
await _persistSettingsSnapshot(
previous.copyWith(
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(
directories: directories,
),
),
);
if (_disposed) {
return;
}
await _applyPersistedSettingsSideEffects(
previous: previous,
current: settings,
refreshAfterSave: false,
);
_lastAppliedSettings = settings;
if (draftInitialized && hadDraftChanges) {
_settingsDraft = previousDraft.copyWith(
authorizedSkillDirectories: settings.authorizedSkillDirectories,
);
_settingsDraftInitialized = true;
_pendingSettingsApply = pendingSettingsApply;
_pendingGatewayApply = pendingGatewayApply;
_pendingAiGatewayApply = pendingAiGatewayApply;
} else {
_settingsDraft = settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_pendingGatewayApply = false;
_pendingAiGatewayApply = false;
_settingsDraftStatusMessage = '';
}
notifyListeners();
}
Future<void> toggleAssistantNavigationDestination(
WorkspaceDestination destination,
) async {
@ -2892,6 +2965,7 @@ class AppController extends ChangeNotifier {
return;
}
}
_lastObservedSettingsSnapshot = settings;
_modelsController.restoreFromSettings(settings.aiGateway);
_multiAgentOrchestrator.updateConfig(settings.multiAgent);
setActiveAppLanguage(settings.appLanguage);
@ -2947,6 +3021,7 @@ class AppController extends ChangeNotifier {
}
_settingsDraft = settings;
_lastAppliedSettings = settings;
_lastObservedSettingsSnapshot = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = '';
} catch (error) {
@ -3069,12 +3144,29 @@ class AppController extends ChangeNotifier {
static bool _isGatewayDraftKey(String key) =>
key.startsWith('gateway_token_') || key.startsWith('gateway_password_');
bool _authorizedSkillDirectoriesChanged(
SettingsSnapshot previous,
SettingsSnapshot current,
) {
return jsonEncode(
previous.authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
) !=
jsonEncode(
current.authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
);
}
Future<void> _persistSettingsSnapshot(SettingsSnapshot snapshot) async {
final sanitized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
),
);
_lastObservedSettingsSnapshot = sanitized;
await _settingsController.saveSnapshot(sanitized);
_settingsDraft = sanitized;
_settingsDraftInitialized = true;
@ -3114,6 +3206,16 @@ class AppController extends ChangeNotifier {
return;
}
}
if (_authorizedSkillDirectoriesChanged(previous, current)) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
if (refreshAfterSave) {
_recomputeTasks();
}
@ -4122,34 +4224,53 @@ class AppController extends ChangeNotifier {
}) async {
final dedupedByName = <String, AssistantThreadSkillEntry>{};
for (final rootSpec in roots) {
final resolvedRootPath = _resolveSingleAgentSkillRootPath(
var resolvedRootPath = _resolveSingleAgentSkillRootPath(
rootSpec.path,
workspaceRef: workspaceRef,
);
if (resolvedRootPath.isEmpty) {
continue;
}
final root = Directory(resolvedRootPath);
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') {
SkillDirectoryAccessHandle? accessHandle;
try {
if (rootSpec.bookmark.trim().isNotEmpty) {
accessHandle = await _skillDirectoryAccessService.openDirectory(
AuthorizedSkillDirectory(
path: resolvedRootPath,
bookmark: rootSpec.bookmark,
),
);
if (accessHandle == null) {
continue;
}
resolvedRootPath = normalizeAuthorizedSkillDirectoryPath(
accessHandle.path,
);
}
final root = Directory(resolvedRootPath);
if (!await root.exists()) {
continue;
}
final entry = await _skillEntryFromFile(
entity,
rootSpec,
resolvedRootPath,
);
final normalizedName = entry.label.trim().toLowerCase();
if (normalizedName.isEmpty) {
continue;
await for (final entity in root.list(
recursive: true,
followLinks: false,
)) {
if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') {
continue;
}
final entry = await _skillEntryFromFile(
entity,
rootSpec,
resolvedRootPath,
);
final normalizedName = entry.label.trim().toLowerCase();
if (normalizedName.isEmpty) {
continue;
}
dedupedByName[normalizedName] = entry;
}
dedupedByName[normalizedName] = entry;
} finally {
await accessHandle?.close();
}
}
final entries = dedupedByName.values.toList(growable: false);
@ -4198,6 +4319,22 @@ class AppController extends ChangeNotifier {
);
}
_SingleAgentSkillScanRoot
_singleAgentGlobalSkillScanRootFromAuthorizedDirectory(
AuthorizedSkillDirectory directory,
) {
final normalizedPath = normalizeAuthorizedSkillDirectoryPath(
directory.path,
);
final lowered = normalizedPath.toLowerCase();
return _SingleAgentSkillScanRoot(
path: normalizedPath,
source: _sourceForSkillRootPath(lowered),
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
bookmark: directory.bookmark,
);
}
String _resolveSingleAgentSkillRootPath(
String rawPath, {
String workspaceRef = '',
@ -5223,7 +5360,7 @@ class AppController extends ChangeNotifier {
void _attachChildListeners() {
_runtimeCoordinator.addListener(_relayChildChange);
_settingsController.addListener(_relayChildChange);
_settingsController.addListener(_handleSettingsControllerChange);
_agentsController.addListener(_relayChildChange);
_sessionsController.addListener(_relayChildChange);
_chatController.addListener(_relayChildChange);
@ -5239,7 +5376,7 @@ class AppController extends ChangeNotifier {
void _detachChildListeners() {
_runtimeCoordinator.removeListener(_relayChildChange);
_settingsController.removeListener(_relayChildChange);
_settingsController.removeListener(_handleSettingsControllerChange);
_agentsController.removeListener(_relayChildChange);
_sessionsController.removeListener(_relayChildChange);
_chatController.removeListener(_relayChildChange);
@ -5253,6 +5390,66 @@ class AppController extends ChangeNotifier {
_multiAgentOrchestrator.removeListener(_relayChildChange);
}
void _handleSettingsControllerChange() {
final previous = _lastObservedSettingsSnapshot;
final current = settings;
final previousJson = previous.toJsonString();
final currentJson = current.toJsonString();
if (currentJson == previousJson) {
_notifyIfActive();
return;
}
final hadDraftChanges =
_settingsDraftInitialized &&
(_settingsDraft.toJsonString() != previousJson ||
_draftSecretValues.isNotEmpty);
if (!_settingsDraftInitialized || !hadDraftChanges) {
_settingsDraft = current;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = '';
}
_lastObservedSettingsSnapshot = current;
_settingsObservationQueue = _settingsObservationQueue
.then((_) async {
await _handleObservedSettingsChange(
previous: previous,
current: current,
);
})
.catchError((_) {});
_notifyIfActive();
}
Future<void> _handleObservedSettingsChange({
required SettingsSnapshot previous,
required SettingsSnapshot current,
}) async {
if (_disposed) {
return;
}
setActiveAppLanguage(current.appLanguage);
_multiAgentOrchestrator.updateConfig(current.multiAgent);
if (previous.codexCliPath != current.codexCliPath ||
previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) {
await _refreshResolvedCodexCliPath();
_registerCodexExternalProvider();
if (_disposed) {
return;
}
}
if (_authorizedSkillDirectoriesChanged(previous, current)) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
_notifyIfActive();
}
void _relayChildChange() {
_notifyIfActive();
}

View File

@ -106,6 +106,17 @@ class AppController extends ChangeNotifier {
SettingsSnapshot get settings => _settings;
SettingsSnapshot get settingsDraft =>
_settingsDraftInitialized ? _settingsDraft : _settings;
bool get supportsSkillDirectoryAuthorization => false;
List<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
_settings.authorizedSkillDirectories;
List<String> get recommendedAuthorizedSkillDirectoryPaths => const <String>[
'/etc/skills',
'~/.agents/skills',
'~/.codex/skills',
'~/.workbuddy/skills',
];
String get userHomeDirectory => '';
String get settingsYamlPath => '';
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != _settings.toJsonString() ||
_draftSecretValues.isNotEmpty;
@ -926,6 +937,29 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
Future<AuthorizedSkillDirectory?> authorizeSkillDirectory({
String suggestedPath = '',
}) async {
return null;
}
Future<void> saveAuthorizedSkillDirectories(
List<AuthorizedSkillDirectory> directories,
) async {
_settings = _settings.copyWith(
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(
directories: directories,
),
);
if (_settingsDraftInitialized) {
_settingsDraft = _settingsDraft.copyWith(
authorizedSkillDirectories: _settings.authorizedSkillDirectories,
);
}
await _persistSettings();
notifyListeners();
}
void saveAiGatewayApiKeyDraft(String value) {
_saveSecretDraft(_draftAiGatewayApiKeyKey, value);
}

View File

@ -13,6 +13,7 @@ import '../../runtime/gateway_runtime.dart';
import '../../runtime/runtime_controllers.dart';
import '../../runtime/runtime_models.dart';
import 'codex_integration_card.dart';
import 'skill_directory_authorization_card.dart';
import '../../widgets/section_tabs.dart';
import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart';
@ -825,6 +826,10 @@ class _SettingsPageState extends State<SettingsPage> {
_GatewayIntegrationSubTab.gateway => 'OpenClaw Gateway',
_GatewayIntegrationSubTab.llm => appText('LLM 接入点', 'LLM Endpoints'),
_GatewayIntegrationSubTab.acp => appText('ACP 外部接入', 'External ACP'),
_GatewayIntegrationSubTab.skills => appText(
'SKILLS 目录授权',
'SKILLS Directory Authorization',
),
};
return [
SectionTabs(
@ -832,6 +837,7 @@ class _SettingsPageState extends State<SettingsPage> {
'OpenClaw Gateway',
appText('LLM 接入点', 'LLM Endpoints'),
appText('ACP 外部接入', 'External ACP'),
appText('SKILLS 目录授权', 'SKILLS Directory Authorization'),
],
value: tabLabel,
onChanged: (value) => setState(() {
@ -839,7 +845,9 @@ class _SettingsPageState extends State<SettingsPage> {
'OpenClaw Gateway' => _GatewayIntegrationSubTab.gateway,
_ when value == appText('LLM 接入点', 'LLM Endpoints') =>
_GatewayIntegrationSubTab.llm,
_ => _GatewayIntegrationSubTab.acp,
_ when value == appText('ACP 外部接入', 'External ACP') =>
_GatewayIntegrationSubTab.acp,
_ => _GatewayIntegrationSubTab.skills,
};
}),
),
@ -882,6 +890,9 @@ class _SettingsPageState extends State<SettingsPage> {
_GatewayIntegrationSubTab.acp => <Widget>[
_buildExternalAcpEndpointManager(context, controller, settings),
],
_GatewayIntegrationSubTab.skills => <Widget>[
SkillDirectoryAuthorizationCard(controller: controller),
],
},
];
}
@ -4762,7 +4773,7 @@ class _WorkflowStep extends StatelessWidget {
}
}
enum _GatewayIntegrationSubTab { gateway, llm, acp }
enum _GatewayIntegrationSubTab { gateway, llm, acp, skills }
enum _LlmEndpointSlot { aiGateway, ollamaLocal, ollamaCloud }

View File

@ -0,0 +1,445 @@
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../widgets/surface_card.dart';
class SkillDirectoryAuthorizationCard extends StatefulWidget {
const SkillDirectoryAuthorizationCard({super.key, required this.controller});
final AppController controller;
@override
State<SkillDirectoryAuthorizationCard> createState() =>
_SkillDirectoryAuthorizationCardState();
}
class _SkillDirectoryAuthorizationCardState
extends State<SkillDirectoryAuthorizationCard> {
bool _busy = false;
String? _statusMessage;
String? _errorMessage;
@override
Widget build(BuildContext context) {
final controller = widget.controller;
final theme = Theme.of(context);
final palette = context.palette;
final homeDirectory = controller.userHomeDirectory;
final authorizedDirectories = controller.authorizedSkillDirectories;
final presetPaths = controller.recommendedAuthorizedSkillDirectoryPaths;
final customDirectories = authorizedDirectories
.where(
(directory) => !presetPaths.any(
(preset) => _matchesResolvedPath(
preset,
directory.path,
homeDirectory: homeDirectory,
),
),
)
.toList(growable: false);
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('SKILLS 目录授权', 'SKILLS Directory Authorization'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'只有在这里显式授权的目录才会被扫描为单机智能体 skills。设置中心修改会写入 settings.yaml外部直接改 settings.yaml 也会热加载回 UI 与技能缓存。',
'Only directories explicitly granted here are scanned as single-agent skills. Settings Center changes write back to settings.yaml, and external settings.yaml edits hot-reload into the UI and skill cache.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
_InfoRow(
label: appText('同步文件', 'Synced File'),
value: controller.settingsYamlPath,
),
_InfoRow(
label: appText('已授权目录', 'Granted Directories'),
value: '${authorizedDirectories.length}',
),
const SizedBox(height: 16),
if (!controller.supportsSkillDirectoryAuthorization)
_InlineBanner(
color: Colors.orange,
icon: Icons.info_outline_rounded,
message: appText(
'当前平台不支持目录授权文件选择器。',
'The current platform does not support the directory authorization picker.',
),
)
else ...[
for (final presetPath in presetPaths) ...[
_buildDirectoryRow(
context,
title: presetPath,
subtitle: _resolvePathForDisplay(
presetPath,
homeDirectory: homeDirectory,
),
directory: _findAuthorizedDirectory(
authorizedDirectories,
presetPath,
homeDirectory: homeDirectory,
),
onAuthorize: () => _authorizeDirectory(
suggestedPath: _resolvePathForDisplay(
presetPath,
homeDirectory: homeDirectory,
),
),
),
const SizedBox(height: 10),
],
if (customDirectories.isNotEmpty) ...[
Text(
appText('自定义目录', 'Custom Directories'),
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 10),
for (final directory in customDirectories) ...[
_buildDirectoryRow(
context,
title: _displayNameForPath(directory.path),
subtitle: directory.path,
directory: directory,
onAuthorize: () =>
_authorizeDirectory(suggestedPath: directory.path),
),
const SizedBox(height: 10),
],
],
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: _busy ? null : () => _authorizeDirectory(),
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.create_new_folder_outlined),
label: Text(appText('添加自定义目录', 'Add Custom Directory')),
),
),
],
if ((_statusMessage ?? _errorMessage) != null) ...[
const SizedBox(height: 16),
_InlineBanner(
color: _errorMessage == null ? Colors.green : Colors.red,
icon: _errorMessage == null
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
message: _errorMessage ?? _statusMessage!,
),
],
const SizedBox(height: 12),
Text(
appText(
'macOS 会通过目录选择器显式授予只读访问,并持久化授权 bookmark移除目录会立即停止扫描该目录。',
'On macOS the directory picker grants explicit read-only access and persists the authorization bookmark. Removing a directory stops scanning it immediately.',
),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
],
),
);
}
Widget _buildDirectoryRow(
BuildContext context, {
required String title,
required String subtitle,
required AuthorizedSkillDirectory? directory,
required Future<void> Function() onAuthorize,
}) {
final theme = Theme.of(context);
final palette = context.palette;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(title, style: theme.textTheme.titleSmall)),
_StatusChip(authorized: directory != null),
],
),
const SizedBox(height: 6),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _busy ? null : onAuthorize,
icon: Icon(
directory == null
? Icons.folder_open_rounded
: Icons.refresh_rounded,
),
label: Text(
directory == null
? appText('授权目录', 'Authorize')
: appText('重新授权', 'Re-authorize'),
),
),
if (directory != null)
OutlinedButton.icon(
onPressed: _busy ? null : () => _removeDirectory(directory),
icon: const Icon(Icons.delete_outline_rounded),
label: Text(appText('移除', 'Remove')),
),
],
),
],
),
);
}
AuthorizedSkillDirectory? _findAuthorizedDirectory(
List<AuthorizedSkillDirectory> directories,
String candidatePath, {
required String homeDirectory,
}) {
for (final directory in directories) {
if (_matchesResolvedPath(
directory.path,
candidatePath,
homeDirectory: homeDirectory,
)) {
return directory;
}
}
return null;
}
Future<void> _authorizeDirectory({String suggestedPath = ''}) async {
setState(() {
_busy = true;
_statusMessage = null;
_errorMessage = null;
});
try {
final granted = await widget.controller.authorizeSkillDirectory(
suggestedPath: suggestedPath,
);
if (granted == null) {
if (!mounted) {
return;
}
setState(() {
_busy = false;
_statusMessage = appText(
'已取消目录授权。',
'Directory authorization canceled.',
);
});
return;
}
final next = normalizeAuthorizedSkillDirectories(
directories: <AuthorizedSkillDirectory>[
...widget.controller.authorizedSkillDirectories.where(
(item) => !_matchesResolvedPath(
item.path,
granted.path,
homeDirectory: widget.controller.userHomeDirectory,
),
),
granted,
],
);
await widget.controller.saveAuthorizedSkillDirectories(next);
if (!mounted) {
return;
}
setState(() {
_busy = false;
_statusMessage = appText(
'目录已授权并同步到 settings.yaml。',
'Directory authorized and synced to settings.yaml.',
);
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_busy = false;
_errorMessage = error.toString();
});
}
}
Future<void> _removeDirectory(AuthorizedSkillDirectory directory) async {
setState(() {
_busy = true;
_statusMessage = null;
_errorMessage = null;
});
try {
final next = widget.controller.authorizedSkillDirectories
.where(
(item) => !_matchesResolvedPath(
item.path,
directory.path,
homeDirectory: widget.controller.userHomeDirectory,
),
)
.toList(growable: false);
await widget.controller.saveAuthorizedSkillDirectories(next);
if (!mounted) {
return;
}
setState(() {
_busy = false;
_statusMessage = appText(
'目录已移除并停止扫描。',
'Directory removed and no longer scanned.',
);
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_busy = false;
_errorMessage = error.toString();
});
}
}
String _resolvePathForDisplay(String path, {required String homeDirectory}) {
final normalized = normalizeAuthorizedSkillDirectoryPath(path);
if (normalized.startsWith('~/') && homeDirectory.trim().isNotEmpty) {
return '$homeDirectory/${normalized.substring(2)}';
}
return normalized;
}
bool _matchesResolvedPath(
String left,
String right, {
required String homeDirectory,
}) {
return _resolvePathForDisplay(left, homeDirectory: homeDirectory) ==
_resolvePathForDisplay(right, homeDirectory: homeDirectory);
}
String _displayNameForPath(String path) {
final normalized = normalizeAuthorizedSkillDirectoryPath(path);
final segments = normalized.split(RegExp(r'[\\/]'));
return segments.isEmpty ? normalized : segments.last;
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.authorized});
final bool authorized;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final color = authorized ? Colors.green : palette.textMuted;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
authorized ? appText('已授权', 'Granted') : appText('未授权', 'Not granted'),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _InlineBanner extends StatelessWidget {
const _InlineBanner({
required this.color,
required this.icon,
required this.message,
});
final Color color;
final IconData icon;
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(message)),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
),
Expanded(child: SelectableText(value)),
],
),
);
}
}

View File

@ -13,6 +13,49 @@ void debugOverridePersistentSupportRoot(String? path) {
: normalizeStoreDirectoryPath(trimmed);
}
String? defaultUserSettingsRootPath({
Map<String, String>? environment,
String? operatingSystem,
}) {
final env = environment ?? Platform.environment;
final os = operatingSystem ?? Platform.operatingSystem;
final home = env['HOME']?.trim() ?? '';
if (home.isEmpty) {
return null;
}
if (os == 'macos') {
return '$home/Library/Application Support/xworkmate';
}
if (os == 'linux') {
final xdgConfigHome = env['XDG_CONFIG_HOME']?.trim() ?? '';
if (xdgConfigHome.isNotEmpty) {
return '$xdgConfigHome/xworkmate';
}
return '$home/.config/xworkmate';
}
if (os == 'windows') {
final appData = env['APPDATA']?.trim() ?? '';
if (appData.isNotEmpty) {
return '$appData\\xworkmate';
}
}
return '$home/.xworkmate';
}
String? defaultUserSettingsFilePath({
Map<String, String>? environment,
String? operatingSystem,
}) {
final root = defaultUserSettingsRootPath(
environment: environment,
operatingSystem: operatingSystem,
);
if ((root ?? '').isEmpty) {
return null;
}
return '$root/config/settings.yaml';
}
enum PersistentStoreScope { settings, tasks, secrets, audit }
class PersistentWriteFailure {
@ -133,32 +176,17 @@ class StoreLayoutResolver {
if (override != null && override.isNotEmpty) {
return override;
}
if (Platform.isMacOS) {
final macUserRoot = defaultUserSettingsRootPath();
if ((macUserRoot ?? '').isNotEmpty) {
return macUserRoot;
}
}
try {
final supportDirectory = await getApplicationSupportDirectory();
return '${supportDirectory.path}/xworkmate';
} catch (_) {
final home = Platform.environment['HOME']?.trim() ?? '';
if (home.isEmpty) {
return null;
}
if (Platform.isMacOS) {
return '$home/Library/Application Support/xworkmate';
}
if (Platform.isLinux) {
final xdgConfigHome =
Platform.environment['XDG_CONFIG_HOME']?.trim() ?? '';
if (xdgConfigHome.isNotEmpty) {
return '$xdgConfigHome/xworkmate';
}
return '$home/.config/xworkmate';
}
if (Platform.isWindows) {
final appData = Platform.environment['APPDATA']?.trim() ?? '';
if (appData.isNotEmpty) {
return '$appData\\xworkmate';
}
}
return '$home/.xworkmate';
return defaultUserSettingsRootPath();
}
}

View File

@ -13,8 +13,14 @@ class SettingsController extends ChangeNotifier {
final SecureConfigStore _store;
bool _disposed = false;
final List<StreamSubscription<FileSystemEvent>> _settingsWatchSubscriptions =
<StreamSubscription<FileSystemEvent>>[];
Timer? _settingsReloadDebounce;
Timer? _settingsPollTimer;
SettingsSnapshot _snapshot = SettingsSnapshot.defaults();
String _lastSnapshotJson = SettingsSnapshot.defaults().toJsonString();
String _lastSettingsFileStamp = '';
Map<String, String> _secureRefs = const <String, String>{};
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
String _ollamaStatus = 'Idle';
@ -39,12 +45,22 @@ class SettingsController extends ChangeNotifier {
@override
void dispose() {
_disposed = true;
_settingsReloadDebounce?.cancel();
_settingsPollTimer?.cancel();
for (final subscription in _settingsWatchSubscriptions) {
unawaited(subscription.cancel());
}
_settingsWatchSubscriptions.clear();
super.dispose();
}
Future<void> initialize() async {
_snapshot = await _store.loadSettingsSnapshot();
_lastSnapshotJson = _snapshot.toJsonString();
await _reloadDerivedState();
await _startSettingsWatcher();
await _refreshSettingsFileStamp();
_startSettingsPolling();
notifyListeners();
}
@ -55,13 +71,17 @@ class SettingsController extends ChangeNotifier {
Future<void> saveSnapshot(SettingsSnapshot snapshot) async {
_snapshot = snapshot;
_lastSnapshotJson = _snapshot.toJsonString();
await _store.saveSettingsSnapshot(snapshot);
await _refreshSettingsFileStamp();
await _reloadDerivedState();
notifyListeners();
}
Future<void> resetSnapshot(SettingsSnapshot snapshot) async {
_snapshot = snapshot;
_lastSnapshotJson = _snapshot.toJsonString();
await _refreshSettingsFileStamp();
await _reloadDerivedState();
notifyListeners();
}
@ -790,6 +810,130 @@ class SettingsController extends ChangeNotifier {
}
return '$base.$profileIndex';
}
Future<void> _startSettingsWatcher() async {
for (final subscription in _settingsWatchSubscriptions) {
await subscription.cancel();
}
_settingsWatchSubscriptions.clear();
final files = await _store.resolvedSettingsFiles();
final directories = await _store.resolvedSettingsWatchDirectories();
void scheduleReload() {
_settingsReloadDebounce?.cancel();
_settingsReloadDebounce = Timer(
const Duration(milliseconds: 160),
() => unawaited(_reloadSettingsFromDiskIfChanged()),
);
}
for (final file in files) {
try {
if (await file.exists()) {
_settingsWatchSubscriptions.add(
file.watch().listen((_) {
scheduleReload();
}),
);
}
} catch (_) {
// Best effort only. Directory watch below remains as a fallback.
}
}
for (final directory in directories) {
try {
if (!await directory.exists()) {
await directory.create(recursive: true);
}
_settingsWatchSubscriptions.add(
directory.watch().listen((_) {
scheduleReload();
}),
);
} catch (_) {
// Best effort only. Missing watch support should not block runtime.
}
}
}
Future<void> _reloadSettingsFromDiskIfChanged() async {
if (_disposed) {
return;
}
final nextStamp = await _resolveStableSettingsFileStamp();
if (nextStamp == _lastSettingsFileStamp) {
return;
}
final reload = await _store.reloadSettingsSnapshotResult();
if (!reload.applied) {
return;
}
_lastSettingsFileStamp = nextStamp;
final next = reload.snapshot;
final nextJson = next.toJsonString();
if (nextJson == _lastSnapshotJson) {
return;
}
_snapshot = next;
_lastSnapshotJson = nextJson;
await _reloadDerivedState();
notifyListeners();
}
void _startSettingsPolling() {
_settingsPollTimer?.cancel();
_settingsPollTimer = Timer.periodic(const Duration(seconds: 1), (_) {
unawaited(_pollSettingsFileChanges());
});
}
Future<void> _pollSettingsFileChanges() async {
if (_disposed) {
return;
}
final previousStamp = _lastSettingsFileStamp;
final nextStamp = await _computeSettingsFileStamp();
if (nextStamp == previousStamp) {
return;
}
await _reloadSettingsFromDiskIfChanged();
}
Future<void> _refreshSettingsFileStamp() async {
_lastSettingsFileStamp = await _computeSettingsFileStamp();
}
Future<String> _resolveStableSettingsFileStamp() async {
var current = await _computeSettingsFileStamp();
for (var attempt = 0; attempt < 4; attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 120));
final next = await _computeSettingsFileStamp();
if (next == current) {
return next;
}
current = next;
}
return current;
}
Future<String> _computeSettingsFileStamp() async {
final files = await _store.resolvedSettingsFiles();
final buffer = StringBuffer();
for (final file in files) {
buffer.write(file.path);
if (await file.exists()) {
final stat = await file.stat();
buffer
..write(':')
..write(stat.modified.millisecondsSinceEpoch)
..write(':')
..write(stat.size);
} else {
buffer.write(':missing');
}
buffer.write('|');
}
return buffer.toString();
}
}
class _AiGatewayResponseException implements Exception {

View File

@ -236,6 +236,65 @@ List<ExternalAcpEndpointProfile> replaceExternalAcpEndpointForProvider(
return normalizeExternalAcpEndpoints(profiles: next);
}
String normalizeAuthorizedSkillDirectoryPath(String path) {
final trimmed = path.trim();
if (trimmed.length <= 1) {
return trimmed;
}
return trimmed.replaceFirst(RegExp(r'[\\/]+$'), '');
}
class AuthorizedSkillDirectory {
const AuthorizedSkillDirectory({required this.path, this.bookmark = ''});
final String path;
final String bookmark;
AuthorizedSkillDirectory copyWith({String? path, String? bookmark}) {
return AuthorizedSkillDirectory(
path: normalizeAuthorizedSkillDirectoryPath(path ?? this.path),
bookmark: bookmark ?? this.bookmark,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'path': path,
if (bookmark.trim().isNotEmpty) 'bookmark': bookmark,
};
}
factory AuthorizedSkillDirectory.fromJson(Map<String, dynamic> json) {
return AuthorizedSkillDirectory(
path: normalizeAuthorizedSkillDirectoryPath(
json['path']?.toString() ?? '',
),
bookmark: json['bookmark']?.toString().trim() ?? '',
);
}
}
List<AuthorizedSkillDirectory> normalizeAuthorizedSkillDirectories({
Iterable<AuthorizedSkillDirectory>? directories,
}) {
final incoming =
directories?.toList(growable: false) ??
const <AuthorizedSkillDirectory>[];
final normalized = <AuthorizedSkillDirectory>[];
final seen = <String>{};
for (final item in incoming) {
final path = normalizeAuthorizedSkillDirectoryPath(item.path);
if (path.isEmpty || !seen.add(path)) {
continue;
}
normalized.add(
AuthorizedSkillDirectory(path: path, bookmark: item.bookmark.trim()),
);
}
normalized.sort((left, right) => left.path.compareTo(right.path));
return List<AuthorizedSkillDirectory>.unmodifiable(normalized);
}
class AssistantThreadConnectionState {
const AssistantThreadConnectionState({
required this.executionTarget,
@ -1306,6 +1365,7 @@ class SettingsSnapshot {
required this.defaultProvider,
required this.gatewayProfiles,
required this.externalAcpEndpoints,
required this.authorizedSkillDirectories,
required this.ollamaLocal,
required this.ollamaCloud,
required this.vault,
@ -1341,6 +1401,7 @@ class SettingsSnapshot {
final String defaultProvider;
final List<GatewayConnectionProfile> gatewayProfiles;
final List<ExternalAcpEndpointProfile> externalAcpEndpoints;
final List<AuthorizedSkillDirectory> authorizedSkillDirectories;
final OllamaLocalConfig ollamaLocal;
final OllamaCloudConfig ollamaCloud;
final VaultConfig vault;
@ -1377,6 +1438,7 @@ class SettingsSnapshot {
defaultProvider: 'gateway',
gatewayProfiles: normalizeGatewayProfiles(),
externalAcpEndpoints: normalizeExternalAcpEndpoints(),
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(),
ollamaLocal: OllamaLocalConfig.defaults(),
ollamaCloud: OllamaCloudConfig.defaults(),
vault: VaultConfig.defaults(),
@ -1414,6 +1476,7 @@ class SettingsSnapshot {
String? defaultProvider,
List<GatewayConnectionProfile>? gatewayProfiles,
List<ExternalAcpEndpointProfile>? externalAcpEndpoints,
List<AuthorizedSkillDirectory>? authorizedSkillDirectories,
OllamaLocalConfig? ollamaLocal,
OllamaCloudConfig? ollamaCloud,
VaultConfig? vault,
@ -1441,6 +1504,12 @@ class SettingsSnapshot {
final resolvedExternalAcpEndpoints = externalAcpEndpoints != null
? normalizeExternalAcpEndpoints(profiles: externalAcpEndpoints)
: this.externalAcpEndpoints;
final resolvedAuthorizedSkillDirectories =
authorizedSkillDirectories != null
? normalizeAuthorizedSkillDirectories(
directories: authorizedSkillDirectories,
)
: this.authorizedSkillDirectories;
return SettingsSnapshot(
appLanguage: appLanguage ?? this.appLanguage,
appActive: appActive ?? this.appActive,
@ -1455,6 +1524,7 @@ class SettingsSnapshot {
defaultProvider: defaultProvider ?? this.defaultProvider,
gatewayProfiles: resolvedGatewayProfiles,
externalAcpEndpoints: resolvedExternalAcpEndpoints,
authorizedSkillDirectories: resolvedAuthorizedSkillDirectories,
ollamaLocal: ollamaLocal ?? this.ollamaLocal,
ollamaCloud: ollamaCloud ?? this.ollamaCloud,
vault: vault ?? this.vault,
@ -1505,6 +1575,9 @@ class SettingsSnapshot {
'externalAcpEndpoints': externalAcpEndpoints
.map((item) => item.toJson())
.toList(growable: false),
'authorizedSkillDirectories': authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
'ollamaLocal': ollamaLocal.toJson(),
'ollamaCloud': ollamaCloud.toJson(),
'vault': vault.toJson(),
@ -1593,6 +1666,16 @@ class SettingsSnapshot {
),
),
);
final authorizedSkillDirectories = normalizeAuthorizedSkillDirectories(
directories:
((json['authorizedSkillDirectories'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => AuthorizedSkillDirectory.fromJson(
item.cast<String, dynamic>(),
),
),
);
return SettingsSnapshot(
appLanguage: AppLanguageCopy.fromJsonValue(
json['appLanguage'] as String?,
@ -1622,6 +1705,7 @@ class SettingsSnapshot {
SettingsSnapshot.defaults().defaultProvider,
gatewayProfiles: gatewayProfiles,
externalAcpEndpoints: externalAcpEndpoints,
authorizedSkillDirectories: authorizedSkillDirectories,
ollamaLocal: OllamaLocalConfig.fromJson(
(json['ollamaLocal'] as Map?)?.cast<String, dynamic>() ?? const {},
),

View File

@ -55,10 +55,26 @@ class SecureConfigStore {
return _settingsStore.loadSettingsSnapshot();
}
Future<SettingsSnapshot> reloadSettingsSnapshot() {
return _settingsStore.reloadSettingsSnapshot();
}
Future<SettingsSnapshotReloadResult> reloadSettingsSnapshotResult() {
return _settingsStore.reloadSettingsSnapshotResult();
}
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) {
return _settingsStore.saveSettingsSnapshot(snapshot);
}
Future<List<File>> resolvedSettingsFiles() {
return _settingsStore.resolvedSettingsFiles();
}
Future<List<Directory>> resolvedSettingsWatchDirectories() {
return _settingsStore.resolvedSettingsWatchDirectories();
}
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() {
return _settingsStore.loadAssistantThreadRecords();
}

View File

@ -8,6 +8,20 @@ import 'runtime_models.dart';
typedef SecureConfigDatabaseOpener =
FutureOr<Object?> Function(String resolvedPath);
enum SettingsSnapshotReloadStatus { applied, invalid }
class SettingsSnapshotReloadResult {
const SettingsSnapshotReloadResult({
required this.snapshot,
required this.status,
});
final SettingsSnapshot snapshot;
final SettingsSnapshotReloadStatus status;
bool get applied => status == SettingsSnapshotReloadStatus.applied;
}
class SettingsStore {
SettingsStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
@ -20,7 +34,10 @@ class SettingsStore {
StoreLayoutResolver(
localRootPathResolver: databasePathResolver,
supportRootPathResolver: defaultSupportDirectoryPathResolver,
);
),
_enableUserSettingsMirror =
databasePathResolver == null &&
defaultSupportDirectoryPathResolver == null;
static const String settingsKey = 'xworkmate.settings.snapshot';
static const String auditKey = 'xworkmate.secrets.audit';
@ -29,8 +46,11 @@ class SettingsStore {
static const String databaseTableName = 'config_entries';
final StoreLayoutResolver _layoutResolver;
final bool _enableUserSettingsMirror;
bool _initialized = false;
StoreLayout? _layout;
List<File> _settingsFiles = const <File>[];
List<Directory> _settingsWatchDirectories = const <Directory>[];
SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults();
List<AssistantThreadRecord> _threadRecords = const <AssistantThreadRecord>[];
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
@ -49,8 +69,14 @@ class SettingsStore {
_initialized = true;
try {
_layout = await _layoutResolver.resolve();
_settingsFiles = _resolveSettingsFiles(_layout!);
_settingsWatchDirectories = _resolveSettingsWatchDirectories(
_settingsFiles,
);
} catch (_) {
_layout = null;
_settingsFiles = const <File>[];
_settingsWatchDirectories = const <Directory>[];
return;
}
_settingsSnapshot = await _readSettingsSnapshot();
@ -63,6 +89,37 @@ class SettingsStore {
return _settingsSnapshot;
}
Future<SettingsSnapshot> reloadSettingsSnapshot() async {
final result = await reloadSettingsSnapshotResult();
return result.snapshot;
}
Future<SettingsSnapshotReloadResult> reloadSettingsSnapshotResult() async {
await initialize();
final result = await _readSettingsSnapshotResult();
if (result.status == SettingsSnapshotReloadStatus.invalid) {
return SettingsSnapshotReloadResult(
snapshot: _settingsSnapshot,
status: SettingsSnapshotReloadStatus.invalid,
);
}
_settingsSnapshot = result.snapshot;
return SettingsSnapshotReloadResult(
snapshot: _settingsSnapshot,
status: SettingsSnapshotReloadStatus.applied,
);
}
Future<List<File>> resolvedSettingsFiles() async {
await initialize();
return List<File>.from(_settingsFiles);
}
Future<List<Directory>> resolvedSettingsWatchDirectories() async {
await initialize();
return List<Directory>.from(_settingsWatchDirectories);
}
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
await initialize();
_settingsSnapshot = snapshot;
@ -76,10 +133,13 @@ class SettingsStore {
return;
}
try {
await atomicWriteString(
layout.settingsFile,
encodeYamlDocument(snapshot.toJson()),
);
final contents = encodeYamlDocument(snapshot.toJson());
for (final file
in _settingsFiles.isEmpty
? <File>[layout.settingsFile]
: _settingsFiles) {
await atomicWriteString(file, contents);
}
_settingsWriteFailure = null;
} catch (error) {
_settingsWriteFailure = _buildWriteFailure(
@ -173,7 +233,12 @@ class SettingsStore {
return;
}
try {
await deleteIfExists(layout.settingsFile);
final settingsFiles = _settingsFiles.isEmpty
? <File>[layout.settingsFile]
: _settingsFiles;
for (final file in settingsFiles) {
await deleteIfExists(file);
}
_settingsWriteFailure = null;
} catch (error) {
_settingsWriteFailure = _buildWriteFailure(
@ -238,18 +303,83 @@ class SettingsStore {
void dispose() {}
Future<SettingsSnapshot> _readSettingsSnapshot() async {
final layout = _layout;
if (layout == null || !await layout.settingsFile.exists()) {
return SettingsSnapshot.defaults();
final result = await _readSettingsSnapshotResult();
return result.status == SettingsSnapshotReloadStatus.invalid
? SettingsSnapshot.defaults()
: result.snapshot;
}
Future<SettingsSnapshotReloadResult> _readSettingsSnapshotResult() async {
if (_settingsFiles.isEmpty) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.defaults(),
status: SettingsSnapshotReloadStatus.applied,
);
}
try {
final raw = await layout.settingsFile.readAsString();
final decoded = decodeYamlDocument(raw);
if (decoded is Map) {
return SettingsSnapshot.fromJson(decoded.cast<String, dynamic>());
var sawExistingFile = false;
var sawInvalidFile = false;
for (final file in _settingsFiles) {
if (!await file.exists()) {
continue;
}
} catch (_) {}
return SettingsSnapshot.defaults();
sawExistingFile = true;
try {
final raw = await file.readAsString();
final decoded = decodeYamlDocument(raw);
if (decoded is Map) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.fromJson(
decoded.cast<String, dynamic>(),
),
status: SettingsSnapshotReloadStatus.applied,
);
}
sawInvalidFile = true;
} catch (_) {
sawInvalidFile = true;
}
}
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.defaults(),
status: sawExistingFile && sawInvalidFile
? SettingsSnapshotReloadStatus.invalid
: SettingsSnapshotReloadStatus.applied,
);
}
List<File> _resolveSettingsFiles(StoreLayout layout) {
final resolved = <File>[];
final seen = <String>{};
void addPath(String path) {
final normalized = path.trim();
if (normalized.isEmpty || !seen.add(normalized)) {
return;
}
resolved.add(File(normalized));
}
final userPath = _enableUserSettingsMirror
? defaultUserSettingsFilePath()
: null;
if ((userPath ?? '').isNotEmpty) {
addPath(userPath!);
}
addPath(layout.settingsFile.path);
return List<File>.unmodifiable(resolved);
}
List<Directory> _resolveSettingsWatchDirectories(List<File> files) {
final directories = <Directory>[];
final seen = <String>{};
for (final file in files) {
final path = file.parent.path.trim();
if (path.isEmpty || !seen.add(path)) {
continue;
}
directories.add(Directory(path));
}
return List<Directory>.unmodifiable(directories);
}
Future<List<AssistantThreadRecord>> _readAssistantThreadRecords() async {

View File

@ -0,0 +1,193 @@
import 'dart:async';
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'runtime_models.dart';
abstract class SkillDirectoryAccessService {
bool get isSupported;
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
});
Future<SkillDirectoryAccessHandle?> openDirectory(
AuthorizedSkillDirectory directory,
);
}
class SkillDirectoryAccessHandle {
SkillDirectoryAccessHandle({
required this.path,
required Future<void> Function() onClose,
this.refreshedBookmark = '',
}) : _onClose = onClose;
final String path;
final String refreshedBookmark;
final Future<void> Function() _onClose;
Future<void> close() => _onClose();
}
SkillDirectoryAccessService createSkillDirectoryAccessService() {
final isFlutterTest = Platform.environment.containsKey('FLUTTER_TEST');
if (Platform.isMacOS && !isFlutterTest) {
return MacOsSkillDirectoryAccessService();
}
if (Platform.isLinux || Platform.isWindows || isFlutterTest) {
return FileSelectorSkillDirectoryAccessService();
}
return UnsupportedSkillDirectoryAccessService();
}
class UnsupportedSkillDirectoryAccessService
implements SkillDirectoryAccessService {
@override
bool get isSupported => false;
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
return null;
}
@override
Future<SkillDirectoryAccessHandle?> openDirectory(
AuthorizedSkillDirectory directory,
) async {
return null;
}
}
class FileSelectorSkillDirectoryAccessService
implements SkillDirectoryAccessService {
@override
bool get isSupported => true;
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
final directoryPath = await getDirectoryPath(
initialDirectory: _initialDirectoryForSuggestion(suggestedPath),
);
final normalized = normalizeAuthorizedSkillDirectoryPath(
directoryPath ?? '',
);
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,
refreshedBookmark: directory.bookmark,
onClose: () async {},
);
}
}
class MacOsSkillDirectoryAccessService implements SkillDirectoryAccessService {
static const MethodChannel _channel = MethodChannel(
'plus.svc.xworkmate/skill_directory_access',
);
@override
bool get isSupported => true;
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
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() ?? '',
);
}
@override
Future<SkillDirectoryAccessHandle?> openDirectory(
AuthorizedSkillDirectory directory,
) async {
final bookmark = directory.bookmark.trim();
final normalizedPath = normalizeAuthorizedSkillDirectoryPath(
directory.path,
);
if (bookmark.isEmpty) {
if (normalizedPath.isEmpty) {
return null;
}
return SkillDirectoryAccessHandle(
path: normalizedPath,
refreshedBookmark: directory.bookmark,
onClose: () async {},
);
}
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},
);
},
);
}
}
String _initialDirectoryForSuggestion(String suggestedPath) {
final trimmed = normalizeAuthorizedSkillDirectoryPath(suggestedPath);
if (trimmed.isEmpty) {
return '';
}
final directory = Directory(trimmed);
if (directory.existsSync()) {
return directory.parent.path;
}
return directory.parent.path;
}

View File

@ -3,6 +3,24 @@ import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
private let skillDirectoryChannelName = "plus.svc.xworkmate/skill_directory_access"
private var directoryAccessSessions: [String: URL] = [:]
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
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)
}
}
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
@ -10,4 +28,163 @@ class AppDelegate: FlutterAppDelegate {
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
override func applicationWillTerminate(_ notification: Notification) {
for (_, url) in directoryAccessSessions {
url.stopAccessingSecurityScopedResource()
}
directoryAccessSessions.removeAll()
super.applicationWillTerminate(notification)
}
private func handleSkillDirectoryCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "authorizeDirectory":
authorizeDirectory(call, result: result)
case "startDirectoryAccess":
startDirectoryAccess(call, result: result)
case "stopDirectoryAccess":
stopDirectoryAccess(call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func authorizeDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let arguments = call.arguments as? [String: Any]
let suggestedPath = (arguments?["suggestedPath"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let panel = NSOpenPanel()
panel.title = "授权技能目录"
panel.message = "请选择要授予 XWorkmate 只读访问权限的技能目录。"
panel.prompt = "授权"
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.canCreateDirectories = false
panel.resolvesAliases = true
panel.showsHiddenFiles = true
if let initialURL = initialDirectoryURL(for: suggestedPath) {
panel.directoryURL = initialURL
}
guard panel.runModal() == .OK, let selectedURL = panel.url else {
result(nil)
return
}
do {
let resolvedURL = selectedURL.standardizedFileURL
let bookmarkData = try resolvedURL.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
result([
"path": resolvedURL.path,
"bookmark": bookmarkData.base64EncodedString(),
])
} catch {
result(
FlutterError(
code: "bookmark_create_failed",
message: error.localizedDescription,
details: nil
)
)
}
}
private func startDirectoryAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let arguments = call.arguments as? [String: Any]
let bookmark = (arguments?["bookmark"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !bookmark.isEmpty, let bookmarkData = Data(base64Encoded: bookmark) else {
result(
FlutterError(
code: "invalid_bookmark",
message: "Missing directory bookmark.",
details: nil
)
)
return
}
do {
var isStale = false
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
guard url.startAccessingSecurityScopedResource() else {
result(
FlutterError(
code: "directory_access_denied",
message: "Failed to start security-scoped access.",
details: nil
)
)
return
}
let accessId = UUID().uuidString
directoryAccessSessions[accessId] = url
var payload: [String: Any] = [
"accessId": accessId,
"path": url.standardizedFileURL.path,
]
if isStale,
let refreshedBookmark = try? url.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil
) {
payload["bookmark"] = refreshedBookmark.base64EncodedString()
}
result(payload)
} catch {
result(
FlutterError(
code: "directory_access_failed",
message: error.localizedDescription,
details: nil
)
)
}
}
private func stopDirectoryAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let arguments = call.arguments as? [String: Any]
let accessId = (arguments?["accessId"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !accessId.isEmpty else {
result(nil)
return
}
if let url = directoryAccessSessions.removeValue(forKey: accessId) {
url.stopAccessingSecurityScopedResource()
}
result(nil)
}
private func initialDirectoryURL(for suggestedPath: String) -> URL? {
let trimmed = suggestedPath.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return FileManager.default.homeDirectoryForCurrentUser
}
var candidate = URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath)
var isDirectory: ObjCBool = false
while true {
if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) {
return isDirectory.boolValue ? candidate.deletingLastPathComponent() : candidate.deletingLastPathComponent()
}
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path || parent.path.isEmpty {
break
}
candidate = parent
}
return FileManager.default.homeDirectoryForCurrentUser
}
}

View File

@ -6,6 +6,8 @@
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>

View File

@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>

View File

@ -136,6 +136,76 @@ void main() {
},
);
test(
'AppController hot reloads authorized skill directories from settings.yaml',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-hot-reload-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
final store = await _createStore(tempDirectory.path);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.where((skill) => skill.label == 'Browser'),
isEmpty,
);
final updatedSnapshot =
_singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith(
authorizedSkillDirectories: <AuthorizedSkillDirectory>[
AuthorizedSkillDirectory(path: agentsRoot.path),
],
);
final settingsFile = File('${tempDirectory.path}/config/settings.yaml');
await settingsFile.writeAsString(
encodeYamlDocument(updatedSnapshot.toJson()),
flush: true,
);
await _waitFor(
() => controller.authorizedSkillDirectories
.map((item) => item.path)
.contains(agentsRoot.path),
);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((skill) => skill.label == 'Browser'),
);
expect(
controller.authorizedSkillDirectories.map((item) => item.path),
<String>[agentsRoot.path],
);
},
);
test(
'AppController keeps thread-bound skills isolated and restores them after restart',
() async {
@ -266,15 +336,21 @@ void main() {
);
expect(
controller.assistantSelectedSkillsForSession(taskA).map((skill) => skill.label),
controller
.assistantSelectedSkillsForSession(taskA)
.map((skill) => skill.label),
const <String>['PPT'],
);
expect(
controller.assistantSelectedSkillsForSession(taskB).map((skill) => skill.label),
controller
.assistantSelectedSkillsForSession(taskB)
.map((skill) => skill.label),
const <String>['WordX'],
);
expect(
controller.assistantSelectedSkillsForSession(taskC).map((skill) => skill.label),
controller
.assistantSelectedSkillsForSession(taskC)
.map((skill) => skill.label),
const <String>['Browser'],
);
@ -286,7 +362,9 @@ void main() {
await restoredController.switchSession(taskA);
await _waitFor(
() =>
restoredController.assistantImportedSkillsForSession(taskA).length ==
restoredController
.assistantImportedSkillsForSession(taskA)
.length ==
4,
);
expect(
@ -298,7 +376,9 @@ void main() {
await restoredController.switchSession(taskB);
await _waitFor(
() =>
restoredController.assistantImportedSkillsForSession(taskB).length ==
restoredController
.assistantImportedSkillsForSession(taskB)
.length ==
4,
);
expect(
@ -310,7 +390,9 @@ void main() {
await restoredController.switchSession(taskC);
await _waitFor(
() =>
restoredController.assistantImportedSkillsForSession(taskC).length ==
restoredController
.assistantImportedSkillsForSession(taskC)
.length ==
4,
);
expect(
@ -457,7 +539,8 @@ void main() {
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
@ -491,10 +574,9 @@ void main() {
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((item) => item.label == 'Workspace Only Skill'),
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((item) => item.label == 'Workspace Only Skill'),
);
expect(
@ -549,7 +631,8 @@ void main() {
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
@ -635,7 +718,8 @@ void main() {
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
@ -667,10 +751,9 @@ void main() {
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.isNotEmpty,
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.isNotEmpty,
);
final sharedSkill = controller
@ -698,7 +781,8 @@ void main() {
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
@ -732,14 +816,15 @@ void main() {
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() =>
controller.assistantImportedSkillsForSession(
controller.currentSessionKey,
).isEmpty,
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.isEmpty,
);
expect(
controller.assistantImportedSkillsForSession(controller.currentSessionKey),
controller.assistantImportedSkillsForSession(
controller.currentSessionKey,
),
isEmpty,
);
},

View File

@ -827,6 +827,26 @@ void main() {
expect(decoded.assistantLastSessionKey, 'draft:session-1');
});
test('SettingsSnapshot encodes and decodes authorizedSkillDirectories', () {
final snapshot = SettingsSnapshot.defaults().copyWith(
authorizedSkillDirectories: const <AuthorizedSkillDirectory>[
AuthorizedSkillDirectory(path: '/etc/skills'),
AuthorizedSkillDirectory(
path: '/Users/test/.codex/skills',
bookmark: 'bookmark-data',
),
],
);
final decoded = SettingsSnapshot.fromJsonString(snapshot.toJsonString());
expect(
decoded.authorizedSkillDirectories.map((item) => item.path),
const <String>['/Users/test/.codex/skills', '/etc/skills'],
);
expect(decoded.authorizedSkillDirectories.first.bookmark, 'bookmark-data');
});
test(
'AssistantThreadRecord keeps compatibility with legacy json payloads',
() {