feat(workspace): default storage root setting + desktop local-tools whitelist management (#512)

- Settings → System gains a 'default workspace storage path' item: validated
  on save (absolute, creatable), applied immediately without restart, and
  re-applied from the database on startup. Blank clears the override;
  existing data is never migrated.
- Desktop local file/command tools get a renderer settings page (allowed
  directory list with per-row delete, add via native picker, enable toggle,
  tunnel status); the native dialog additionally gains a 'remove directory'
  flow, fixing the whitelist that could only grow.
- System settings save surfaces backend validation errors as a toast.
This commit is contained in:
matevip 2026-07-14 18:24:58 +08:00
parent cf43294a9e
commit f0dcc44fef
16 changed files with 630 additions and 9 deletions

View File

@ -855,12 +855,17 @@ async function showLocalToolsSettings(): Promise<void> {
].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<void> {
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<void> {
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<void> {
if (!app.isPackaged) {
dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' })

View File

@ -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 */

View File

@ -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.
* <p>

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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();

View File

@ -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<String, String> 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"));
}
}

View File

@ -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.',

View File

@ -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: '全局开关。开启后,下方各类出站连接统一走代理。',

View File

@ -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',

35
mateclaw-ui/src/types/desktop.d.ts vendored Normal file
View File

@ -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<string>
getVersion: () => Promise<string>
openExternal: (url: string) => Promise<void>
getLocalToolsConfig: () => Promise<LocalToolsState>
setLocalToolsConfig: (patch: Partial<LocalToolsConfig>) => Promise<LocalToolsConfig>
// Opens a native folder picker; `added` is null when the user cancels.
addLocalToolsDir: () => Promise<LocalToolsConfig & { added: string | null }>
removeLocalToolsDir: (dir: string) => Promise<LocalToolsConfig>
}
declare global {
interface Window {
mateClawAPI?: MateClawDesktopAPI
}
}
export {}

View File

@ -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'

View File

@ -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: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
},
// 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: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
}]
: []),
// RFC-090 Phase 7: ACP endpoints
{
id: 'acp',

View File

@ -0,0 +1,191 @@
<template>
<div class="settings-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.localToolsTitle') }}</h2>
<p class="section-desc">{{ t('settings.localToolsDesc') }}</p>
</div>
<!-- Shown when the SPA is opened in a plain browser: the bridge only
exists inside the desktop shell, so there is nothing to manage. -->
<div v-if="!isDesktop" class="settings-card">
<div class="empty-note">{{ t('settings.localTools.desktopOnly') }}</div>
</div>
<template v-else>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.localTools.enableLabel') }}</div>
<div class="setting-hint">{{ t('settings.localTools.enableHint') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" :checked="state.enabled" :disabled="busy" @change="onToggleEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.localTools.tunnelLabel') }}</div>
<div class="setting-hint">{{ t('settings.localTools.tunnelHint') }}</div>
</div>
<div class="setting-control">
<span class="status-chip" :class="{ ok: state.connected }">
<span class="dot"></span>
{{ state.connected ? t('settings.localTools.tunnelConnected') : t('settings.localTools.tunnelDisconnected') }}
</span>
</div>
</div>
</div>
<div class="settings-card dirs-card">
<div class="dirs-header">
<div class="setting-info">
<div class="setting-label">{{ t('settings.localTools.dirsLabel') }}</div>
<div class="setting-hint">{{ t('settings.localTools.dirsHint') }}</div>
</div>
<button class="btn-primary" :disabled="busy" @click="onAddDir">
{{ t('settings.localTools.addDir') }}
</button>
</div>
<div v-if="state.allowedDirs.length === 0" class="empty-note">
{{ state.failClosed ? t('settings.localTools.emptyFailClosed') : t('settings.localTools.emptyFailOpen') }}
</div>
<ul v-else class="dir-list">
<li v-for="dir in state.allowedDirs" :key="dir" class="dir-row">
<span class="dir-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</span>
<span class="dir-path">{{ dir }}</span>
<button class="btn-remove" :disabled="busy" @click="onRemoveDir(dir)">
{{ t('common.delete') }}
</button>
</li>
</ul>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcConfirm } from '@/components/common/useConfirm'
import { mcToast } from '@/composables/useMcToast'
import type { LocalToolsState } from '@/types/desktop'
const { t } = useI18n()
const isDesktop = typeof window !== 'undefined' && !!window.mateClawAPI
const busy = ref(false)
const state = reactive<LocalToolsState>({
enabled: false,
allowedDirs: [],
failClosed: true,
connected: false,
})
onMounted(() => {
if (isDesktop) void refresh()
})
async function refresh() {
try {
const cfg = await window.mateClawAPI!.getLocalToolsConfig()
state.enabled = cfg.enabled
state.allowedDirs = cfg.allowedDirs ?? []
state.failClosed = cfg.failClosed
state.connected = cfg.connected
} catch {
mcToast.error(t('settings.localTools.loadFail'))
}
}
async function onToggleEnabled(e: Event) {
const enabled = (e.target as HTMLInputElement).checked
busy.value = true
try {
await window.mateClawAPI!.setLocalToolsConfig({ enabled })
await refresh()
} catch {
mcToast.error(t('settings.localTools.saveFail'))
await refresh()
} finally {
busy.value = false
}
}
async function onAddDir() {
busy.value = true
try {
const result = await window.mateClawAPI!.addLocalToolsDir()
await refresh()
if (result.added) mcToast.success(t('settings.localTools.addSuccess'))
} catch {
mcToast.error(t('settings.localTools.saveFail'))
} finally {
busy.value = false
}
}
async function onRemoveDir(dir: string) {
const ok = await mcConfirm({
title: t('settings.localTools.removeTitle'),
message: t('settings.localTools.removeConfirm', { dir }),
confirmText: t('common.delete'),
tone: 'danger',
})
if (!ok) return
busy.value = true
try {
await window.mateClawAPI!.removeLocalToolsDir(dir)
await refresh()
mcToast.success(t('settings.localTools.removeSuccess'))
} catch {
mcToast.error(t('settings.localTools.saveFail'))
} finally {
busy.value = false
}
}
</script>
<style scoped>
.settings-section { width: 100%; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124,63,30,0.04); width: 100%; }
.settings-card + .settings-card { margin-top: 16px; }
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
.setting-item:last-child { border-bottom: none; }
.setting-info { flex: 1; }
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; }
.setting-control { display: flex; align-items: center; justify-content: flex-end; }
.status-chip { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; padding: 5px 12px; border-radius: 999px; background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
.status-chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.status-chip.ok { background: var(--mc-accent-soft); color: var(--mc-accent); }
.dirs-card .dirs-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; padding-bottom: 14px; }
.empty-note { padding: 14px 4px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; }
.dir-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.dir-row { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border: 1px solid var(--mc-border-light); border-radius: 12px; background: var(--mc-bg-sunken); }
.dir-icon { display: inline-flex; color: var(--mc-accent); flex-shrink: 0; }
.dir-path { flex: 1; font-family: var(--mc-font-mono); font-size: 13px; color: var(--mc-text-primary); word-break: break-all; }
.btn-primary { border: none; border-radius: 10px; padding: 9px 16px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary); color: white; white-space: nowrap; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-remove { border: 1px solid var(--mc-border); border-radius: 8px; padding: 5px 12px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--mc-bg-elevated); color: var(--mc-danger); flex-shrink: 0; }
.btn-remove:hover { border-color: var(--mc-danger); background: var(--mc-danger); color: white; }
.btn-remove:disabled { opacity: 0.5; cursor: not-allowed; }
</style>

View File

@ -44,6 +44,21 @@
</label>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.workspaceStorageRoot') }}</div>
<div class="setting-hint">{{ t('settings.hints.workspaceStorageRoot') }}</div>
</div>
<div class="setting-control-full">
<input
v-model.trim="settings.workspaceStorageRoot"
type="text"
class="form-input"
:placeholder="t('settings.hints.workspaceStorageRootPlaceholder')"
/>
</div>
</div>
</div>
<!-- 搜索服务配置 -->
@ -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<SystemSettings>({
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
}
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()