diff --git a/mateclaw-desktop/electron/main/index.ts b/mateclaw-desktop/electron/main/index.ts index dc5f4f84..5e288468 100644 --- a/mateclaw-desktop/electron/main/index.ts +++ b/mateclaw-desktop/electron/main/index.ts @@ -855,12 +855,17 @@ async function showLocalToolsSettings(): Promise { ].join('\n') const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const hasDirs = cfg.allowedDirs.length > 0 + const buttons = hasDirs + ? ['关闭', '添加目录…', '移除目录…', cfg.enabled ? '停用' : '启用'] + : ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'] + const toggleId = buttons.length - 1 const opts = { type: 'info' as const, title: '本地工具设置', message: '本地文件/命令工具', detail, - buttons: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'], + buttons, defaultId: 0, cancelId: 0, noLink: true, @@ -871,13 +876,39 @@ async function showLocalToolsSettings(): Promise { if (res.response === 1) { await pickAllowedDirectory() - } else if (res.response === 2) { + } else if (hasDirs && res.response === 2) { + await pickDirectoryToRemove(cfg.allowedDirs) + } else if (res.response === toggleId) { const saved = saveLocalToolsConfig({ enabled: !cfg.enabled }) if (saved.enabled && backendReady) localBridge.start() else if (!saved.enabled) localBridge.stop() } } +// Second-level picker for removing a whitelisted directory: native dialogs +// cannot render per-item delete controls, so each directory becomes a button. +async function pickDirectoryToRemove(dirs: string[]): Promise { + const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const opts = { + type: 'question' as const, + title: '移除目录', + message: '选择要从白名单移除的目录', + detail: '移除后,本地文件/命令工具将无法再访问该目录。', + buttons: ['取消', ...dirs], + defaultId: 0, + cancelId: 0, + noLink: true, + } + const res = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + if (res.response === 0) return + + const dir = dirs[res.response - 1] + const cfg = loadLocalToolsConfig() + saveLocalToolsConfig({ allowedDirs: cfg.allowedDirs.filter((d) => d !== dir) }) +} + async function menuCheckForUpdates(): Promise { if (!app.isPackaged) { dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' }) diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 2687d01f..109b9de1 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -10,6 +10,13 @@ public class SystemSettingsDTO { private Boolean debugMode; private Boolean stateGraphEnabled; + /** + * Default workspace storage root (global fallback sandbox root). Empty + * string = not overridden, fall back to the yml/env configuration. Null on + * save = field not submitted (partial payloads keep the stored value). + */ + private String workspaceStorageRoot; + // ===== 搜索服务配置 ===== private Boolean searchEnabled; /** serper / tavily */ diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index ce85061c..16f58427 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -1,20 +1,31 @@ package vip.mate.system.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import vip.mate.exception.MateClawException; import vip.mate.plugin.PluginManager; import vip.mate.system.model.SearchProviderCatalogEntry; import vip.mate.system.model.SearchProviderCatalogResponse; import vip.mate.system.model.SystemSettingEntity; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.guard.WorkspacePathGuard; import vip.mate.tool.search.SearchProvider; import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Set; +@Slf4j @Service public class SystemSettingService { @@ -78,6 +89,14 @@ public class SystemSettingService { private static final String DEFAULT_VISION_MODEL_KEY = "default.vision_model"; private static final String DEFAULT_VIDEO_MODEL_KEY = "default.video_model"; + /** + * Default workspace storage root override. When set, it replaces the + * yml/env-configured {@code mateclaw.workspace.sandbox.root} as the global + * fallback sandbox root for conversations without a per-workspace base + * path. Empty string means "not overridden" (fall back to yml/env). + */ + private static final String WORKSPACE_STORAGE_ROOT_KEY = "workspace.storage_root"; + private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey"; private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl"; private static final String FAL_API_KEY_KEY = "falApiKey"; @@ -100,6 +119,7 @@ public class SystemSettingService { private final SystemSettingMapper systemSettingMapper; private final SearchProviderRegistry searchProviderRegistry; private final SettingCrypto settingCrypto; + private final WorkspaceSandboxProperties workspaceSandboxProperties; /** * {@code PluginManager} is injected lazily because the bean graph is @@ -118,10 +138,12 @@ public class SystemSettingService { public SystemSettingService(SystemSettingMapper systemSettingMapper, SearchProviderRegistry searchProviderRegistry, SettingCrypto settingCrypto, + WorkspaceSandboxProperties workspaceSandboxProperties, @Lazy PluginManager pluginManager) { this.systemSettingMapper = systemSettingMapper; this.searchProviderRegistry = searchProviderRegistry; this.settingCrypto = settingCrypto; + this.workspaceSandboxProperties = workspaceSandboxProperties; this.pluginManager = pluginManager; } @@ -210,6 +232,9 @@ public class SystemSettingService { // Multimodal sidecar routing — empty string means "not configured" dto.setDefaultVisionModelId(parseIdOrNull(getValue(DEFAULT_VISION_MODEL_KEY, ""))); dto.setDefaultVideoModelId(parseIdOrNull(getValue(DEFAULT_VIDEO_MODEL_KEY, ""))); + + // Default workspace storage root — empty string means "not overridden" + dto.setWorkspaceStorageRoot(getValue(WORKSPACE_STORAGE_ROOT_KEY, "")); return dto; } @@ -461,9 +486,90 @@ public class SystemSettingService { String.valueOf(dto.getDefaultVideoModelId()), "Default video-capable model id (mate_model_config.id) for sidecar routing"); } + + // Default workspace storage root. null = field not submitted (partial + // save from an unrelated settings page); blank = explicit clear, fall + // back to the yml/env-configured sandbox root. Applied immediately — + // no restart required. Only affects newly created files; existing data + // is never migrated. + if (dto.getWorkspaceStorageRoot() != null) { + String root = dto.getWorkspaceStorageRoot().trim(); + if (!root.isEmpty()) { + validateWorkspaceStorageRoot(root); + } + saveValue(WORKSPACE_STORAGE_ROOT_KEY, root, "默认工作空间存储路径(全局兜底沙箱根,空=使用配置文件默认值)"); + applyWorkspaceStorageRoot(root); + } return getSettings(); } + /** + * Reject a storage root that could never work: relative paths (the guard + * needs a stable absolute boundary) and paths that cannot be created. + */ + private void validateWorkspaceStorageRoot(String root) { + Path path; + try { + path = Paths.get(root); + } catch (InvalidPathException e) { + throw new MateClawException("err.settings.storage_root_invalid", 400, + "Invalid storage path: " + e.getMessage()); + } + if (!path.isAbsolute()) { + throw new MateClawException("err.settings.storage_root_not_absolute", 400, + "Storage path must be absolute: " + root); + } + try { + Files.createDirectories(path); + } catch (Exception e) { + throw new MateClawException("err.settings.storage_root_create_failed", 400, + "Cannot create storage directory " + root + ": " + e.getMessage()); + } + } + + /** + * Register the effective global fallback sandbox root with + * {@link WorkspacePathGuard}. Priority: DB override > yml/env > built-in + * default. A blank override restores the yml/env-configured behaviour, + * including the {@code enabled=false} escape hatch. + */ + private void applyWorkspaceStorageRoot(String override) { + if (override == null || override.isBlank()) { + if (workspaceSandboxProperties.isEnabled()) { + Path root = Paths.get(workspaceSandboxProperties.getRoot()).toAbsolutePath().normalize(); + WorkspacePathGuard.setDefaultRoot(root.toString()); + } else { + WorkspacePathGuard.setDefaultRoot(null); + } + return; + } + Path root = Paths.get(override).toAbsolutePath().normalize(); + WorkspacePathGuard.setDefaultRoot(root.toString()); + } + + /** + * Apply a persisted storage-root override once the database is ready. + * Startup registration order: WorkspaceSandboxAutoConfiguration registers + * the yml/env root at context construction, then this listener overrides + * it with the DB value when one is set. + */ + @EventListener(ApplicationReadyEvent.class) + public void applyPersistedWorkspaceStorageRoot() { + String root = getValue(WORKSPACE_STORAGE_ROOT_KEY, ""); + if (root == null || root.isBlank()) { + return; + } + try { + Files.createDirectories(Paths.get(root)); + } catch (Exception e) { + // Registering the root still tightens the boundary even if the + // directory can't be pre-created; log and continue. + log.warn("[SystemSetting] Failed to create workspace storage root {}: {}", root, e.getMessage()); + } + applyWorkspaceStorageRoot(root); + log.info("[SystemSetting] Workspace storage root override applied: {}", root); + } + /** * Dedicated update path for the multimodal sidecar configuration. *

diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 76147844..f5853324 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -330,3 +330,8 @@ chat.stopMarker.userAborted=[\u5df2\u88ab\u7528\u6237\u4e2d\u6b62] tool.send_file.error.too_large=\u6587\u4ef6\u8fc7\u5927\uff1a{0}MB \u8d85\u51fa\u9650\u5236 {1}MB tool.send_file.error.failed=\u53d1\u9001\u6587\u4ef6\u5931\u8d25\uff1a{0} tool.send_file.success={0} \u5df2\u53d1\u9001\uff1a[{0}]({1})\uff08\u94fe\u63a5 10 \u5206\u949f\u5185\u6709\u6548\uff09\u3002\n\u91cd\u8981\uff1a\u56de\u7b54\u7528\u6237\u65f6**\u5fc5\u987b**\u4f7f\u7528\u4e0a\u8ff0\u76f8\u5bf9\u8def\u5f84 `{1}`\uff0c**\u4e0d\u8981**\u6dfb\u52a0\u4efb\u4f55 https://\u3001http:// \u57df\u540d\u524d\u7f00\uff0c\u524d\u7aef\u4f1a\u81ea\u52a8\u62fc\u63a5\u5f53\u524d\u4e3b\u673a\u3002 + +# --- Settings: workspace storage root --- +err.settings.storage_root_invalid=\u5b58\u50a8\u8def\u5f84\u65e0\u6548 +err.settings.storage_root_not_absolute=\u5b58\u50a8\u8def\u5f84\u5fc5\u987b\u4e3a\u7edd\u5bf9\u8def\u5f84 +err.settings.storage_root_create_failed=\u65e0\u6cd5\u521b\u5efa\u5b58\u50a8\u76ee\u5f55\uff0c\u8bf7\u68c0\u67e5\u8def\u5f84\u4e0e\u5199\u5165\u6743\u9650 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index ee08f9de..35769b96 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -337,3 +337,8 @@ chat.stopMarker.userAborted=[Stopped by user] tool.send_file.error.too_large=File too large: {0}MB exceeds limit of {1}MB tool.send_file.error.failed=Failed to send file: {0} tool.send_file.success={0} sent: [{0}]({1}) (link valid for 10 minutes).\nIMPORTANT: when replying to the user you **must** use the relative path `{1}` exactly; do **not** prepend any https:// or http:// host. The frontend will prepend the current host automatically. + +# --- Settings: workspace storage root --- +err.settings.storage_root_invalid=Invalid storage path +err.settings.storage_root_not_absolute=Storage path must be an absolute path +err.settings.storage_root_create_failed=Cannot create the storage directory; check the path and write permission diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java index 0f3a78f7..f79f34d3 100644 --- a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java @@ -14,6 +14,7 @@ import vip.mate.plugin.PluginManager; import vip.mate.system.model.SystemSettingEntity; import vip.mate.system.repository.SystemSettingMapper; import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; import java.util.List; @@ -47,7 +48,7 @@ class SystemSettingBoolApiTest { @BeforeEach void setUp() { service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()), - new SettingCrypto("test-key"), mock(PluginManager.class)); + new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), mock(PluginManager.class)); } private SystemSettingEntity row(String value) { diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java index 18b004a0..25079662 100644 --- a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java @@ -17,6 +17,7 @@ import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.repository.SystemSettingMapper; import vip.mate.tool.search.SearchProvider; import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; import vip.mate.tool.search.SearchResult; import java.util.List; @@ -68,7 +69,7 @@ class SystemSettingServiceCatalogTest { @DisplayName("marks builtin providers as builtin=true with no pluginName") void builtinEntry() { SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false))); - service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager); + service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager); SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); @@ -86,7 +87,7 @@ class SystemSettingServiceCatalogTest { SearchProviderRegistry registry = new SearchProviderRegistry(List.of()); registry.registerPluginProvider(stub("my-search", 500, true, true)); when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin"); - service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager); + service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager); SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); @@ -105,7 +106,7 @@ class SystemSettingServiceCatalogTest { stub("duckduckgo", 100, false, true))); registry.registerPluginProvider(stub("my-search", 200, true, true)); when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin"); - service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager); + service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager); SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); @@ -128,7 +129,7 @@ class SystemSettingServiceCatalogTest { @DisplayName("surfaces the resolved provider id and source alongside the catalog") void resolvedSurfaced() { SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true))); - service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager); + service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager); SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); @@ -140,7 +141,7 @@ class SystemSettingServiceCatalogTest { @DisplayName("resolvedId/resolvedSource are null when no provider is available at all") void resolvedNullWhenNothingAvailable() { SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false))); - service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager); + service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager); SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingWorkspaceStorageRootTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingWorkspaceStorageRootTest.java new file mode 100644 index 00000000..cc789a6d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingWorkspaceStorageRootTest.java @@ -0,0 +1,140 @@ +package vip.mate.system.service; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.exception.MateClawException; +import vip.mate.plugin.PluginManager; +import vip.mate.system.model.SystemSettingEntity; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.guard.WorkspacePathGuard; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +/** + * Covers the default workspace storage root setting: validation, immediate + * registration with {@link WorkspacePathGuard}, clearing back to the yml/env + * default, and the partial-payload (null) no-op contract. + */ +@ExtendWith(MockitoExtension.class) +class SystemSettingWorkspaceStorageRootTest { + + @Mock + private SystemSettingMapper mapper; + + @TempDir + Path tempDir; + + private WorkspaceSandboxProperties sandboxProperties; + private SystemSettingService service; + private final Map store = new HashMap<>(); + private Path originalDefaultRoot; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SystemSettingEntity.class); + } + + @BeforeEach + void setUp() { + originalDefaultRoot = WorkspacePathGuard.getDefaultRoot(); + sandboxProperties = new WorkspaceSandboxProperties(); + sandboxProperties.setRoot(tempDir.resolve("yml-default").toString()); + service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()), + new SettingCrypto("test-key"), sandboxProperties, mock(PluginManager.class)); + + // Back the mapper with an in-memory map so saveValue/getValue round-trip. + store.clear(); + lenient().when(mapper.selectOne(any())).thenAnswer(inv -> null); + lenient().when(mapper.selectList(any())).thenAnswer(inv -> List.of()); + lenient().when(mapper.insert(any(SystemSettingEntity.class))).thenAnswer(inv -> { + SystemSettingEntity e = inv.getArgument(0); + store.put(e.getSettingKey(), e.getSettingValue()); + return 1; + }); + } + + @AfterEach + void restoreGuard() { + WorkspacePathGuard.setDefaultRoot(originalDefaultRoot == null ? null : originalDefaultRoot.toString()); + } + + private SystemSettingsDTO dtoWithRoot(String root) { + SystemSettingsDTO dto = new SystemSettingsDTO(); + dto.setWorkspaceStorageRoot(root); + return dto; + } + + @Test + @DisplayName("relative path is rejected") + void relativePathRejected() { + MateClawException ex = assertThrows(MateClawException.class, + () -> service.saveSettings(dtoWithRoot("data/workspace"))); + assertEquals("err.settings.storage_root_not_absolute", ex.getMsgKey()); + assertNull(store.get("workspace.storage_root"), + "the storage root must not be persisted on validation failure"); + } + + @Test + @DisplayName("absolute path is persisted and registered immediately") + void absolutePathApplied() { + Path newRoot = tempDir.resolve("custom-root"); + service.saveSettings(dtoWithRoot(newRoot.toString())); + + assertEquals(newRoot.toString(), store.get("workspace.storage_root")); + assertEquals(newRoot.toAbsolutePath().normalize(), WorkspacePathGuard.getDefaultRoot()); + assertTrue(newRoot.toFile().isDirectory(), "directory should be created on save"); + } + + @Test + @DisplayName("blank value clears the override and falls back to the yml root") + void blankClearsOverride() { + service.saveSettings(dtoWithRoot(tempDir.resolve("custom-root").toString())); + service.saveSettings(dtoWithRoot("")); + + assertEquals("", store.get("workspace.storage_root")); + assertEquals(Paths.get(sandboxProperties.getRoot()).toAbsolutePath().normalize(), + WorkspacePathGuard.getDefaultRoot()); + } + + @Test + @DisplayName("blank value with sandbox disabled clears the guard entirely") + void blankWithSandboxDisabled() { + sandboxProperties.setEnabled(false); + service.saveSettings(dtoWithRoot("")); + assertNull(WorkspacePathGuard.getDefaultRoot()); + } + + @Test + @DisplayName("null (field not submitted) leaves the stored value untouched") + void nullFieldIsNoOp() { + service.saveSettings(new SystemSettingsDTO()); + assertNull(store.get("workspace.storage_root")); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 7632ed91..924f363f 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -751,6 +751,7 @@ export default { advanced: 'Advanced', skillCurator: 'Skill Curator', proxy: 'Network Proxy', + localTools: 'Local Tools', }, models: { sidecar: { @@ -1045,6 +1046,7 @@ export default { language: 'Language', streamEnabled: 'Stream Response', debugMode: 'Debug Mode', + workspaceStorageRoot: 'Default Workspace Storage Path', searchEnabled: 'Enable Search', searchProvider: 'Search Provider', searchFallbackEnabled: 'Fallback on Failure', @@ -1096,6 +1098,8 @@ export default { language: 'Interface language preference stored in backend settings.', streamEnabled: 'Controls whether chat prefers streaming output in UI settings.', debugMode: 'Reserved for showing more execution details later.', + workspaceStorageRoot: 'Files of new conversations and workspaces are stored under this path (the global fallback directory). Takes effect immediately and never migrates existing data; leave blank to use the server default. Must be an absolute path.', + workspaceStorageRootPlaceholder: 'Leave blank for the server default, e.g. /data/mateclaw/workspace', searchEnabled: 'When disabled, the search tool will be unavailable to agents.', searchProvider: 'Primary search provider used when the search tool is invoked.', searchFallbackEnabled: 'Automatically try the other provider when the primary one fails.', @@ -1175,6 +1179,28 @@ export default { searchGoToPlugins: 'Go to Plugins →', proxyTitle: 'Network Proxy', proxyDesc: 'Configure a global HTTP / SOCKS proxy for all outbound requests (LLM APIs, web search, channel bridging, etc.). For environments that cannot reach overseas APIs directly or require a unified egress.', + localToolsTitle: 'Local Tools', + localToolsDesc: 'Manage the desktop local file/command tools: master switch, tunnel status, and the allowed-directory whitelist.', + localTools: { + desktopOnly: 'Local tools are only available inside the desktop client. Open this page in the MateClaw desktop app to manage them.', + enableLabel: 'Enable local tools', + enableHint: 'When on, agents can reach local file and command tools through the tunnel, constrained to the directory whitelist below.', + tunnelLabel: 'Tunnel status', + tunnelHint: 'Connection state of the local-tools channel between the desktop and the server.', + tunnelConnected: 'Connected', + tunnelDisconnected: 'Disconnected', + dirsLabel: 'Allowed directories', + dirsHint: 'Local file operations are confined to the directories below. Removal takes effect immediately.', + addDir: 'Add directory', + emptyFailClosed: 'No directories configured — all local file access is currently denied by default.', + emptyFailOpen: 'No directories configured — the entire local filesystem is currently accessible by default.', + removeTitle: 'Remove directory', + removeConfirm: 'Remove {dir} from the whitelist? Agents will no longer be able to access it.', + removeSuccess: 'Directory removed', + addSuccess: 'Directory added', + loadFail: 'Failed to load local tools configuration', + saveFail: 'Operation failed, please retry', + }, proxy: { enableLabel: 'Enable proxy', enableHint: 'Global switch. When on, the outbound connections below all route through the proxy.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 3f2ac345..b8786f47 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -613,6 +613,7 @@ export default { advanced: '高级', skillCurator: '技能管家', proxy: '网络代理', + localTools: '本地工具', }, models: { sidecar: { @@ -907,6 +908,7 @@ export default { language: '界面语言', streamEnabled: '流式响应', debugMode: '调试模式', + workspaceStorageRoot: '默认工作空间存储路径', searchEnabled: '启用搜索', searchProvider: '搜索提供商', searchFallbackEnabled: '失败回退', @@ -964,6 +966,8 @@ export default { language: '界面语言会持久化到后端设置中。', streamEnabled: '用于控制前端默认流式响应偏好。', debugMode: '预留给后续执行明细展示。', + workspaceStorageRoot: '新建会话、工作空间的文件将存放在该路径下(作为全局兜底目录)。修改后立即生效,不影响已有数据;留空则使用服务端默认位置。必须为绝对路径。', + workspaceStorageRootPlaceholder: '留空使用服务端默认位置,例如 /data/mateclaw/workspace', searchEnabled: '关闭后搜索工具将不可用,Agent 无法联网搜索。', searchProvider: '选择主搜索提供商,调用搜索工具时优先使用。', searchFallbackEnabled: '主提供商调用失败时,自动回退到另一个提供商。', @@ -1049,6 +1053,28 @@ export default { searchGoToPlugins: '前往插件页 →', proxyTitle: '网络代理', proxyDesc: '为所有出站请求(LLM API、网页搜索、频道桥接等)配置全局 HTTP / SOCKS 代理。适用于无法直连海外 API、或要求统一出口的网络环境。', + localToolsTitle: '本地工具', + localToolsDesc: '管理桌面端本地文件/命令工具:启停开关、隧道状态与允许访问的目录白名单。', + localTools: { + desktopOnly: '本地工具仅在桌面客户端内可用。请在 MateClaw 桌面应用中打开此页面进行管理。', + enableLabel: '启用本地工具', + enableHint: '开启后,智能体可通过隧道访问本机的文件与命令工具(受下方目录白名单约束)。', + tunnelLabel: '隧道状态', + tunnelHint: '桌面端与服务器之间的本地工具通道连接状态。', + tunnelConnected: '已连接', + tunnelDisconnected: '未连接', + dirsLabel: '允许访问的目录', + dirsHint: '本机文件操作只能落在以下目录内。删除立即生效。', + addDir: '添加目录', + emptyFailClosed: '未配置任何目录 — 当前默认拒绝所有本地文件访问。', + emptyFailOpen: '未配置任何目录 — 当前默认允许访问整个本地文件系统。', + removeTitle: '移除目录', + removeConfirm: '确定将 {dir} 从白名单中移除?移除后智能体将无法再访问该目录。', + removeSuccess: '目录已移除', + addSuccess: '目录已添加', + loadFail: '读取本地工具配置失败', + saveFail: '操作失败,请重试', + }, proxy: { enableLabel: '启用代理', enableHint: '全局开关。开启后,下方各类出站连接统一走代理。', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index a811cf7d..051514c7 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -245,6 +245,15 @@ const router = createRouter({ component: () => import('@/views/Settings/Proxy/index.vue'), meta: { title: 'Settings - Proxy', requiredCapability: 'manage:settings' }, }, + { + // Desktop-only: local file/shell tool whitelist management. The + // page itself degrades to a notice when the desktop bridge is + // absent (plain browser access). + path: 'local-tools', + name: 'SettingsLocalTools', + component: () => import('@/views/Settings/LocalTools/index.vue'), + meta: { title: 'Settings - Local Tools', requiredCapability: 'manage:settings' }, + }, // RFC-090 Phase 7: ACP endpoints (External coding agents) { path: 'acp', diff --git a/mateclaw-ui/src/types/desktop.d.ts b/mateclaw-ui/src/types/desktop.d.ts new file mode 100644 index 00000000..7477bf4d --- /dev/null +++ b/mateclaw-ui/src/types/desktop.d.ts @@ -0,0 +1,35 @@ +// Bridge API exposed by the desktop shell's preload script. Present only when +// the SPA runs inside the desktop app; always access via optional chaining. +export interface LocalToolsConfig { + // Master switch for the local file/shell tool proxy. + enabled: boolean + // Directories the agent may touch through the local tools tunnel. + allowedDirs: string[] + // Policy when allowedDirs is empty: true = deny all, false = allow all. + failClosed: boolean +} + +export interface LocalToolsState extends LocalToolsConfig { + // Whether the tunnel to the backend is currently connected. + connected: boolean +} + +export interface MateClawDesktopAPI { + getPlatform: () => Promise + getVersion: () => Promise + openExternal: (url: string) => Promise + + getLocalToolsConfig: () => Promise + setLocalToolsConfig: (patch: Partial) => Promise + // Opens a native folder picker; `added` is null when the user cancels. + addLocalToolsDir: () => Promise + removeLocalToolsDir: (dir: string) => Promise +} + +declare global { + interface Window { + mateClawAPI?: MateClawDesktopAPI + } +} + +export {} diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 71318791..11a6485a 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -808,6 +808,8 @@ export interface SystemSettings { language: 'zh-CN' | 'en-US' streamEnabled: boolean debugMode: boolean + // Default workspace storage root; '' = use the server-side default + workspaceStorageRoot?: string // 搜索服务配置 searchEnabled: boolean searchProvider: 'serper' | 'tavily' diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index 62709975..c0870df8 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -55,6 +55,9 @@ import { useI18n } from 'vue-i18n' const route = useRoute() const { t } = useI18n() +// The desktop preload bridge marks that we run inside the desktop shell. +const isDesktop = typeof window !== 'undefined' && !!window.mateClawAPI + // Routes that benefit from extra editor width — the sub-nav auto-collapses // to a 56px rail unless the user has explicitly toggled it open. const COMPACT_ROUTES = ['/settings/workflows'] @@ -195,6 +198,16 @@ const sections = computed(() => [ label: t('settings.sections.proxy', '网络代理'), icon: '', }, + // Desktop-only entry: the local tools bridge exists only inside the + // desktop shell, so hide the nav item in plain browsers. + ...(isDesktop + ? [{ + id: 'local-tools', + path: '/settings/local-tools', + label: t('settings.sections.localTools', '本地工具'), + icon: '', + }] + : []), // RFC-090 Phase 7: ACP endpoints { id: 'acp', diff --git a/mateclaw-ui/src/views/Settings/LocalTools/index.vue b/mateclaw-ui/src/views/Settings/LocalTools/index.vue new file mode 100644 index 00000000..bfd24f2e --- /dev/null +++ b/mateclaw-ui/src/views/Settings/LocalTools/index.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/System/index.vue b/mateclaw-ui/src/views/Settings/System/index.vue index a8872ead..2f58a009 100644 --- a/mateclaw-ui/src/views/Settings/System/index.vue +++ b/mateclaw-ui/src/views/Settings/System/index.vue @@ -44,6 +44,21 @@ + +

+
+
{{ t('settings.fields.workspaceStorageRoot') }}
+
{{ t('settings.hints.workspaceStorageRoot') }}
+
+
+ +
+
@@ -279,6 +294,7 @@ import { onMounted, reactive, ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { settingsApi } from '@/api' import { applyLocale } from '@/i18n' +import { mcToast } from '@/composables/useMcToast' import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import { buildProviderOptions, builtinFallbackCatalog, resolveDefaultExpandedId, resolveSourceLabelKey } from '@/composables/useSearchProviderCatalog' import type { SystemSettings, SearchProviderCatalog } from '@/types' @@ -329,6 +345,7 @@ const settings = reactive({ language: 'zh-CN', streamEnabled: true, debugMode: false, + workspaceStorageRoot: '', searchEnabled: true, searchProvider: 'serper', searchFallbackEnabled: false, @@ -366,7 +383,13 @@ async function onSaveSettings() { if (weixinoaAppSecretInput.value) { payload.weixinoaAppSecret = weixinoaAppSecretInput.value } - await settingsApi.update(payload) + try { + await settingsApi.update(payload) + } catch (e: any) { + // Surface backend validation failures (e.g. invalid storage path). + mcToast.error(e?.response?.data?.msg || e?.message || t('settings.messages.saveFailed')) + return + } await applyLocale(settings.language) // 重新加载以获取最新脱敏值 await loadSettings()