diff --git a/mateclaw-desktop/electron/main/index.ts b/mateclaw-desktop/electron/main/index.ts index 16b46fd2..dc5f4f84 100644 --- a/mateclaw-desktop/electron/main/index.ts +++ b/mateclaw-desktop/electron/main/index.ts @@ -14,6 +14,13 @@ import { recordServer, type ConnectionMode, } from './config' +import { + loadLocalToolsConfig, + saveLocalToolsConfig, + expandPath, + type LocalToolsConfig, +} from './localToolsConfig' +import { LocalBridge } from './localBridge' // ─── Constants ─────────────────────────────────────────────────────────────── @@ -32,6 +39,23 @@ let isQuitting = false let isUpdating = false let backendReady = false +// Local-tool tunnel: lets a remote agent operate this machine's files/shell +// through an authenticated WebSocket back to the backend. The JWT is read from +// the renderer's localStorage (where the admin SPA stores it after login). +async function readRendererToken(): Promise { + if (!mainWindow || mainWindow.isDestroyed()) return null + try { + const token = await mainWindow.webContents.executeJavaScript( + 'window.localStorage && window.localStorage.getItem("token")' + ) + return typeof token === 'string' && token.length > 0 ? token : null + } catch { + return null + } +} + +const localBridge = new LocalBridge(() => BACKEND_URL, readRendererToken) + // Connection state: which backend the shell is talking to. let connectionMode: ConnectionMode | null = null // When true, the splash shows the connection chooser even if a mode was saved @@ -156,6 +180,9 @@ function getAvailablePort(): Promise { } async function startJavaBackend(): Promise { + // A tunnel pinned to a prior backend URL must be dropped before the embedded + // server comes up on a fresh port; it reconnects once the backend is ready. + localBridge.stop() // Remote builds have no bundled JRE/JAR — refuse to start the local backend // and guide the user toward the connection chooser instead of showing a // generic "file not found" error. @@ -284,6 +311,12 @@ function pollBackendReady(): void { console.log(`[MateClaw] Backend ready (${elapsed}ms, status: ${res.statusCode})`) sendToWindow('backend:status', 'ready') + // Bring up the local-tool tunnel. It waits for a renderer JWT (post-login) + // and reconnects on its own, so starting it here is safe even pre-login. + if (loadLocalToolsConfig().enabled) { + localBridge.start() + } + // Do NOT auto-navigate — let the splash screen handle it // after language selection / setup check completes. }) @@ -373,6 +406,9 @@ function startRemoteConnection(url: string): void { } connectionMode = 'remote' backendReady = false + // Drop any tunnel pinned to the previous backend; it is re-established against + // the new URL once the backend reports ready. + localBridge.stop() BACKEND_URL = normalized console.log(`[MateClaw] Remote mode → ${BACKEND_URL}`) pollBackendReady() @@ -413,6 +449,7 @@ function probeServer( function goToConnectionChooser(): void { forceChooser = true backendReady = false + localBridge.stop() loadSplash() } @@ -678,6 +715,36 @@ function registerIpcHandlers(): void { } }) + // ── Local tools IPC ── + + ipcMain.handle('localtools:get-config', () => ({ + ...loadLocalToolsConfig(), + connected: localBridge.isConnected(), + })) + + ipcMain.handle('localtools:set-config', (_event, patch: Partial) => { + const saved = saveLocalToolsConfig(patch) + // Honor an enable/disable toggle immediately. + if (saved.enabled && backendReady) { + localBridge.start() + } else if (!saved.enabled) { + localBridge.stop() + } + return saved + }) + + ipcMain.handle('localtools:add-dir', async () => { + const dir = await pickAllowedDirectory() + return { ...loadLocalToolsConfig(), added: dir } + }) + + ipcMain.handle('localtools:remove-dir', (_event, dir: string) => { + const cfg = loadLocalToolsConfig() + return saveLocalToolsConfig({ + allowedDirs: cfg.allowedDirs.filter((d) => d !== dir), + }) + }) + // ── Auto Updater IPC ── ipcMain.handle('updater:get-state', () => updaterState) @@ -754,6 +821,63 @@ function showAboutDialog(): void { }) } +// Add a directory to the local-tools whitelist via a native folder picker. +// Returns the added path, or null if the user cancelled. +async function pickAllowedDirectory(): Promise { + const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const result = parent + ? await dialog.showOpenDialog(parent, { properties: ['openDirectory', 'createDirectory'] }) + : await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] }) + if (result.canceled || result.filePaths.length === 0) return null + + const dir = result.filePaths[0] + const cfg = loadLocalToolsConfig() + if (!cfg.allowedDirs.includes(dir)) { + saveLocalToolsConfig({ allowedDirs: [...cfg.allowedDirs, dir] }) + } + return dir +} + +// Native overview of the local-tools settings, with quick actions to add a +// directory or toggle the feature. Keeps management self-contained in the +// desktop shell without requiring a renderer settings page. +async function showLocalToolsSettings(): Promise { + const cfg = loadLocalToolsConfig() + const dirs = cfg.allowedDirs.length > 0 + ? cfg.allowedDirs.map((d) => ` • ${d}`).join('\n') + : ` (未配置 — ${cfg.failClosed ? '默认拒绝所有本地访问' : '默认允许全部本地访问'})` + const detail = [ + `状态: ${cfg.enabled ? '已启用' : '已停用'}`, + `隧道: ${localBridge.isConnected() ? '已连接' : '未连接'}`, + '', + '允许访问的目录:', + dirs, + ].join('\n') + + const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const opts = { + type: 'info' as const, + title: '本地工具设置', + message: '本地文件/命令工具', + detail, + buttons: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'], + defaultId: 0, + cancelId: 0, + noLink: true, + } + const res = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + + if (res.response === 1) { + await pickAllowedDirectory() + } else if (res.response === 2) { + const saved = saveLocalToolsConfig({ enabled: !cfg.enabled }) + if (saved.enabled && backendReady) localBridge.start() + else if (!saved.enabled) localBridge.stop() + } +} + async function menuCheckForUpdates(): Promise { if (!app.isPackaged) { dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' }) @@ -795,6 +919,7 @@ function setupApplicationMenu(): void { { label: 'Check for Updates...', click: menuCheckForUpdates }, { type: 'separator' }, { label: 'Switch Server…', click: goToConnectionChooser }, + { label: '本地工具设置…', click: () => { void showLocalToolsSettings() } }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, @@ -811,6 +936,7 @@ function setupApplicationMenu(): void { label: 'File', submenu: [ { label: 'Switch Server…', click: goToConnectionChooser }, + { label: '本地工具设置…', click: () => { void showLocalToolsSettings() } }, { type: 'separator' }, { role: 'quit', label: 'Exit' }, ], @@ -966,6 +1092,7 @@ app.on('before-quit', async (event) => { isQuitting = true event.preventDefault() + localBridge.stop() try { await stopJavaBackend() } catch (err) { diff --git a/mateclaw-desktop/electron/main/localBridge.ts b/mateclaw-desktop/electron/main/localBridge.ts new file mode 100644 index 00000000..61da7461 --- /dev/null +++ b/mateclaw-desktop/electron/main/localBridge.ts @@ -0,0 +1,205 @@ +import WebSocket from 'ws' +import { + readFile, + writeFile, + editFile, + listDir, + statPath, + executeShell, + LocalToolError, +} from './localToolsExecutor' +import { requestApproval, clearApprovalCache } from './localToolsApproval' + +// ─── Desktop → server local-tool tunnel (client side) ──────────────────────── +// Opens a WebSocket to the backend's /api/v1/desktop/ws endpoint, advertises the +// local tool capabilities, and services "call" frames the server forwards when a +// cloud agent invokes a local_* tool. File/shell work runs through the executor +// (whitelist-enforced) and approval (native dialog) modules. Reconnects with +// backoff while the desktop is meant to be online. + +const PROTOCOL_VERSION = 1 +const CAPABILITIES = ['read', 'list', 'stat', 'write', 'edit', 'shell'] +const RECONNECT_MIN_MS = 2000 +const RECONNECT_MAX_MS = 30_000 + +type TokenProvider = () => Promise +type UrlProvider = () => string + +export class LocalBridge { + private ws: WebSocket | null = null + private shouldRun = false + private reconnectDelay = RECONNECT_MIN_MS + private reconnectTimer: NodeJS.Timeout | null = null + + constructor( + private readonly getBackendUrl: UrlProvider, + private readonly getToken: TokenProvider + ) {} + + // Begin maintaining a connection. Safe to call repeatedly. + start(): void { + if (this.shouldRun) return + this.shouldRun = true + void this.connect() + } + + // Tear down the tunnel and stop reconnecting (e.g. on logout or app quit). + stop(): void { + this.shouldRun = false + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + clearApprovalCache() + if (this.ws) { + try { + this.ws.close() + } catch { + /* ignore */ + } + this.ws = null + } + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN + } + + private buildWsUrl(token: string): string | null { + const base = this.getBackendUrl() + if (!base) return null + const wsBase = base.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + return `${wsBase}/api/v1/desktop/ws?token=${encodeURIComponent(token)}` + } + + private async connect(): Promise { + if (!this.shouldRun) return + + const token = await this.getToken() + if (!token) { + // Not logged in yet — retry shortly without escalating backoff. + this.scheduleReconnect(RECONNECT_MIN_MS) + return + } + const url = this.buildWsUrl(token) + if (!url) { + this.scheduleReconnect(RECONNECT_MIN_MS) + return + } + + console.log('[LocalBridge] Connecting tunnel…') + // rejectUnauthorized:false mirrors the app's handling of enterprise + // self-signed certificates for remote servers the user chose to trust. + const ws = new WebSocket(url, { rejectUnauthorized: false }) + this.ws = ws + + ws.on('open', () => { + console.log('[LocalBridge] Tunnel connected') + this.reconnectDelay = RECONNECT_MIN_MS + this.send({ + type: 'hello', + protocolVersion: PROTOCOL_VERSION, + capabilities: CAPABILITIES, + platform: process.platform, + }) + }) + + ws.on('message', (raw: WebSocket.RawData) => { + void this.onMessage(raw.toString()) + }) + + ws.on('close', () => { + console.log('[LocalBridge] Tunnel closed') + this.ws = null + if (this.shouldRun) this.scheduleReconnect(this.reconnectDelay) + }) + + ws.on('error', (err: Error) => { + console.warn('[LocalBridge] Tunnel error:', err.message) + // 'close' fires after 'error'; reconnect is scheduled there. + }) + } + + private scheduleReconnect(delay: number): void { + if (!this.shouldRun || this.reconnectTimer) return + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS) + void this.connect() + }, delay) + } + + private send(obj: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)) + } + } + + private async onMessage(text: string): Promise { + let frame: any + try { + frame = JSON.parse(text) + } catch { + return + } + + if (frame.type === 'hello-ack') { + if (frame.ok === false) console.warn('[LocalBridge] Handshake rejected:', frame.error) + return + } + if (frame.type === 'pong') return + if (frame.type !== 'call') return + + const { id, method, params } = frame + try { + const data = await this.dispatch(method, params || {}) + this.send({ type: 'result', id, ok: true, data }) + } catch (e) { + const code = e instanceof LocalToolError ? e.code : 'ERROR' + const error = e instanceof Error ? e.message : String(e) + this.send({ type: 'result', id, ok: false, code, error }) + } + } + + private async dispatch(method: string, params: any): Promise { + switch (method) { + case 'read_file': + return readFile(params.filePath, params.startLine, params.endLine) + case 'list_dir': + return listDir(params.dirPath) + case 'stat': + return statPath(params.path) + case 'write_file': { + await this.approveOrThrow('write_file', params.filePath, + `文件: ${params.filePath}\n\n内容预览:\n${preview(params.content)}`) + return writeFile(params.filePath, params.content) + } + case 'edit_file': { + await this.approveOrThrow('edit_file', params.filePath, + `文件: ${params.filePath}\n\n替换:\n- ${preview(params.oldText, 200)}\n+ ${preview(params.newText, 200)}`) + return editFile(params.filePath, params.oldText, params.newText, !!params.replaceAll) + } + case 'execute_shell': { + await this.approveOrThrow('execute_shell', params.command, + `命令:\n${params.command}`) + return executeShell(params.command, params.timeoutSeconds || 60) + } + default: + throw new LocalToolError('UNKNOWN_METHOD', `Unknown method: ${method}`) + } + } + + private async approveOrThrow( + kind: 'write_file' | 'edit_file' | 'execute_shell', + subject: string, + detail: string + ): Promise { + const { approved } = await requestApproval({ kind, subject, detail }) + if (!approved) throw new LocalToolError('DENIED', 'User denied') + } +} + +function preview(text: string | undefined, max = 500): string { + const s = text ?? '' + return s.length > max ? `${s.slice(0, max)}\n…(${s.length - max} more chars)` : s +} diff --git a/mateclaw-desktop/electron/main/localToolsApproval.ts b/mateclaw-desktop/electron/main/localToolsApproval.ts new file mode 100644 index 00000000..5f378fe8 --- /dev/null +++ b/mateclaw-desktop/electron/main/localToolsApproval.ts @@ -0,0 +1,82 @@ +import { dialog, BrowserWindow } from 'electron' + +// ─── Local tool approval ───────────────────────────────────────────────────── +// High-risk local operations (file write/edit, shell execution) prompt the user +// with a native dialog showing the full operation context before they run. The +// user may tick "don't ask again this session" to temporarily allow matching +// operations — same path for file ops, same command prefix for shell — until the +// app restarts (the cache is in-memory only). + +// Cache of approvals the user chose to remember this session. +const sessionAllow = new Set() + +export type ApprovalKind = 'write_file' | 'edit_file' | 'execute_shell' + +// The cache key scopes "remember": file ops by exact path, shell by command +// prefix (first word + first 40 chars) so re-running the same kind of command +// doesn't re-prompt, but a different command still does. +function cacheKey(kind: ApprovalKind, subject: string): string { + if (kind === 'execute_shell') { + const head = subject.trim().split(/\s+/)[0] || '' + return `shell:${head}:${subject.trim().slice(0, 40)}` + } + return `${kind}:${subject}` +} + +interface ApprovalRequest { + kind: ApprovalKind + // The path (file ops) or command (shell) this approval is scoped to. + subject: string + // Human-readable detail shown in the dialog body. + detail: string +} + +export interface ApprovalResult { + approved: boolean +} + +function titleFor(kind: ApprovalKind): string { + switch (kind) { + case 'write_file': + return '允许写入本地文件?' + case 'edit_file': + return '允许修改本地文件?' + case 'execute_shell': + return '允许执行本地命令?' + } +} + +export async function requestApproval(req: ApprovalRequest): Promise { + const key = cacheKey(req.kind, req.subject) + if (sessionAllow.has(key)) return { approved: true } + + const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] + const options = { + type: 'warning' as const, + title: titleFor(req.kind), + message: titleFor(req.kind), + detail: `${req.detail}\n\n该操作由远程 Agent 发起,将在你的本机执行。`, + buttons: ['拒绝', '允许'], + defaultId: 0, + cancelId: 0, + checkboxLabel: '本次会话不再询问相同操作', + checkboxChecked: false, + noLink: true, + } + + const result = parent + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) + + const approved = result.response === 1 + if (approved && result.checkboxChecked) { + sessionAllow.add(key) + } + return { approved } +} + +// Drop all remembered approvals — called when the desktop disconnects/logs out so +// a new session starts from a clean slate. +export function clearApprovalCache(): void { + sessionAllow.clear() +} diff --git a/mateclaw-desktop/electron/main/localToolsConfig.ts b/mateclaw-desktop/electron/main/localToolsConfig.ts new file mode 100644 index 00000000..f4d4c274 --- /dev/null +++ b/mateclaw-desktop/electron/main/localToolsConfig.ts @@ -0,0 +1,122 @@ +import { app } from 'electron' +import { join, resolve, relative, isAbsolute } from 'path' +import { homedir } from 'os' +import { existsSync, readFileSync, writeFileSync } from 'fs' + +// ─── Local tools configuration ─────────────────────────────────────────────── +// Governs the desktop's local file/shell tool proxy: whether it is enabled, the +// directory whitelist every local file operation is constrained to, and the +// default policy when no whitelist is configured. Stored as its own JSON file in +// userData so it is independent of the connection config. + +export interface LocalToolsConfig { + // Master switch. When false the desktop advertises no local-tool capabilities + // and rejects any forwarded call. + enabled: boolean + // Absolute (or ~-prefixed) directories the agent may touch. Every local file + // operation must resolve to a path inside one of these. + allowedDirs: string[] + // Policy when allowedDirs is empty: + // true (fail-closed, default) → deny all local file access + // false (fail-open) → allow the entire local filesystem + failClosed: boolean +} + +const DEFAULT_CONFIG: LocalToolsConfig = { + enabled: true, + allowedDirs: [], + failClosed: true, +} + +function getConfigPath(): string { + return join(app.getPath('userData'), 'local-tools.json') +} + +export function loadLocalToolsConfig(): LocalToolsConfig { + try { + const path = getConfigPath() + if (!existsSync(path)) return { ...DEFAULT_CONFIG } + const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial + return { + ...DEFAULT_CONFIG, + ...raw, + allowedDirs: Array.isArray(raw.allowedDirs) ? raw.allowedDirs : [], + } + } catch (err) { + console.error('[MateClaw] Failed to read local-tools config:', err) + return { ...DEFAULT_CONFIG } + } +} + +export function saveLocalToolsConfig(patch: Partial): LocalToolsConfig { + const merged: LocalToolsConfig = { ...loadLocalToolsConfig(), ...patch } + try { + writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8') + } catch (err) { + console.error('[MateClaw] Failed to write local-tools config:', err) + } + return merged +} + +// Expand a leading ~ to the user's home directory and resolve to an absolute, +// normalized path. Returns null for empty input. +export function expandPath(input: string): string | null { + const trimmed = (input || '').trim() + if (!trimmed) return null + const expanded = trimmed === '~' || trimmed.startsWith('~/') + ? join(homedir(), trimmed.slice(1)) + : trimmed + return resolve(expanded) +} + +// Whether `target` is contained by `dir` (or equal to it). Both are resolved +// absolute paths. Uses path.relative so it is symlink-name-agnostic but does not +// follow symlinks — the whitelist is enforced on the lexical path the agent asked +// for, which is the path the user approved. +function isInside(dir: string, target: string): boolean { + const rel = relative(dir, target) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +export interface PathCheck { + allowed: boolean + // Resolved absolute path (when input was parseable), for use by the caller. + resolved: string | null + // Machine-readable reason when not allowed. + reason?: 'disabled' | 'unparseable' | 'whitelist' +} + +// Decide whether a local file operation on `inputPath` is permitted by the +// current configuration. This is the single chokepoint every file tool calls. +export function checkPath(inputPath: string): PathCheck { + const cfg = loadLocalToolsConfig() + if (!cfg.enabled) return { allowed: false, resolved: null, reason: 'disabled' } + + const target = expandPath(inputPath) + if (!target) return { allowed: false, resolved: null, reason: 'unparseable' } + + if (cfg.allowedDirs.length === 0) { + return { allowed: !cfg.failClosed, resolved: target, reason: cfg.failClosed ? 'whitelist' : undefined } + } + + for (const dir of cfg.allowedDirs) { + const base = expandPath(dir) + if (base && isInside(base, target)) { + return { allowed: true, resolved: target } + } + } + return { allowed: false, resolved: target, reason: 'whitelist' } +} + +// The working directory to run a shell command in: the first configured +// whitelist directory, falling back to the user's home. Shell commands are not +// path-checked (they are arbitrary), so they are gated by approval + timeout and +// pinned to a sensible cwd rather than wherever the app launched. +export function shellWorkingDir(): string { + const cfg = loadLocalToolsConfig() + for (const dir of cfg.allowedDirs) { + const base = expandPath(dir) + if (base && existsSync(base)) return base + } + return homedir() +} diff --git a/mateclaw-desktop/electron/main/localToolsExecutor.ts b/mateclaw-desktop/electron/main/localToolsExecutor.ts new file mode 100644 index 00000000..e96dd56a --- /dev/null +++ b/mateclaw-desktop/electron/main/localToolsExecutor.ts @@ -0,0 +1,194 @@ +import { spawn } from 'child_process' +import { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + existsSync, +} from 'fs' +import { dirname } from 'path' +import { checkPath, shellWorkingDir } from './localToolsConfig' + +// ─── Local tool executor ───────────────────────────────────────────────────── +// Runs the actual file/shell operations on the user's machine. Every file +// operation is constrained to the directory whitelist via checkPath(); shell +// commands are gated by approval (handled by the caller) and a hard timeout. +// Output limits mirror the server-side tools: ~30KB for file reads, ~10KB each +// for shell stdout/stderr. + +const MAX_FILE_BYTES = 30 * 1024 +const MAX_SHELL_BYTES = 10_000 +const IS_WINDOWS = process.platform === 'win32' + +export class LocalToolError extends Error { + constructor(public code: string, message: string) { + super(message) + } +} + +function requireAllowed(inputPath: string): string { + const check = checkPath(inputPath) + if (!check.allowed) { + if (check.reason === 'disabled') { + throw new LocalToolError('DISABLED', 'Local tools are disabled in the desktop app') + } + if (check.reason === 'unparseable') { + throw new LocalToolError('BAD_PATH', `Invalid path: ${inputPath}`) + } + throw new LocalToolError( + 'WHITELIST', + `Path is outside the allowed local directories: ${inputPath}` + ) + } + return check.resolved as string +} + +export function readFile(filePath: string, startLine?: number, endLine?: number): unknown { + const path = requireAllowed(filePath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`) + if (statSync(path).isDirectory()) { + throw new LocalToolError('IS_DIR', `Path is a directory: ${filePath}`) + } + + const raw = readFileSync(path, 'utf-8') + const allLines = raw.split('\n') + const totalLines = allLines.length + + const start = startLine && startLine > 0 ? startLine : 1 + const end = endLine && endLine > 0 ? Math.min(endLine, totalLines) : totalLines + if (start > totalLines) { + throw new LocalToolError('RANGE', `startLine ${start} exceeds total lines ${totalLines}`) + } + + let content = '' + let readLines = 0 + let truncated = false + for (let i = start - 1; i < end; i++) { + const line = `${String(i + 1).padStart(6)}\t${allLines[i]}\n` + if (Buffer.byteLength(content + line, 'utf-8') > MAX_FILE_BYTES) { + truncated = true + break + } + content += line + readLines++ + } + + return { filePath: path, totalLines, startLine: start, readLines, content, truncated } +} + +export function writeFile(filePath: string, content: string): unknown { + const path = requireAllowed(filePath) + const existed = existsSync(path) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content ?? '', 'utf-8') + return { + filePath: path, + bytesWritten: Buffer.byteLength(content ?? '', 'utf-8'), + created: !existed, + overwritten: existed, + } +} + +export function editFile( + filePath: string, + oldText: string, + newText: string, + replaceAll: boolean +): unknown { + const path = requireAllowed(filePath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`) + + const original = readFileSync(path, 'utf-8') + if (!original.includes(oldText)) { + throw new LocalToolError('NO_MATCH', 'oldText not found in file') + } + + let replacements = 0 + let updated: string + if (replaceAll) { + updated = original.split(oldText).join(newText) + replacements = original.split(oldText).length - 1 + } else { + updated = original.replace(oldText, newText) + replacements = 1 + } + writeFileSync(path, updated, 'utf-8') + return { filePath: path, replacements, replaceAll: !!replaceAll } +} + +export function listDir(dirPath: string): unknown { + const path = requireAllowed(dirPath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Directory not found: ${dirPath}`) + if (!statSync(path).isDirectory()) { + throw new LocalToolError('NOT_DIR', `Path is not a directory: ${dirPath}`) + } + const entries = readdirSync(path, { withFileTypes: true }).map((e) => ({ + name: e.name, + type: e.isDirectory() ? 'dir' : 'file', + })) + return { dirPath: path, entries } +} + +export function statPath(targetPath: string): unknown { + const path = requireAllowed(targetPath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Path not found: ${targetPath}`) + const st = statSync(path) + return { + path, + size: st.size, + isDirectory: st.isDirectory(), + modifiedTime: st.mtime.toISOString(), + } +} + +function truncateUtf8(buf: Buffer, maxBytes: number): { text: string; truncated: boolean } { + if (buf.length <= maxBytes) return { text: buf.toString('utf-8'), truncated: false } + return { + text: buf.subarray(0, maxBytes).toString('utf-8') + + `\n... [output truncated, exceeds ${maxBytes} byte limit]`, + truncated: true, + } +} + +export function executeShell(command: string, timeoutSeconds: number): Promise { + const cwd = shellWorkingDir() + const timeoutMs = Math.min(Math.max(timeoutSeconds, 1), 300) * 1000 + + // cmd.exe on Windows, /bin/sh on macOS/Linux — mirrors the server tool. + const child = IS_WINDOWS + ? spawn('cmd.exe', ['/D', '/S', '/C', command], { cwd }) + : spawn('/bin/sh', ['-c', command], { cwd }) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + child.stdout.on('data', (d: Buffer) => stdoutChunks.push(d)) + child.stderr.on('data', (d: Buffer) => stderrChunks.push(d)) + + return new Promise((resolvePromise) => { + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, timeoutMs) + + const finish = (exitCode: number) => { + clearTimeout(timer) + const out = truncateUtf8(Buffer.concat(stdoutChunks), MAX_SHELL_BYTES) + const err = truncateUtf8(Buffer.concat(stderrChunks), MAX_SHELL_BYTES) + resolvePromise({ + command, + exitCode, + stdout: out.text, + stderr: err.text, + timedOut, + }) + } + + child.on('error', (e) => { + clearTimeout(timer) + resolvePromise({ command, exitCode: -1, stdout: '', stderr: String(e), timedOut: false }) + }) + child.on('close', (code) => finish(code == null ? -1 : code)) + }) +} diff --git a/mateclaw-desktop/electron/preload/index.ts b/mateclaw-desktop/electron/preload/index.ts index 4d9aa26b..f66b5bc2 100644 --- a/mateclaw-desktop/electron/preload/index.ts +++ b/mateclaw-desktop/electron/preload/index.ts @@ -35,6 +35,12 @@ contextBridge.exposeInMainWorld('mateClawAPI', { return () => ipcRenderer.removeListener('backend:crashed', handler) }, + // Local tools (file/shell proxy) management + getLocalToolsConfig: () => ipcRenderer.invoke('localtools:get-config'), + setLocalToolsConfig: (patch: unknown) => ipcRenderer.invoke('localtools:set-config', patch), + addLocalToolsDir: () => ipcRenderer.invoke('localtools:add-dir'), + removeLocalToolsDir: (dir: string) => ipcRenderer.invoke('localtools:remove-dir', dir), + // Auto-updater getUpdaterState: () => ipcRenderer.invoke('updater:get-state'), checkForUpdates: () => ipcRenderer.invoke('updater:check'), diff --git a/mateclaw-desktop/package.json b/mateclaw-desktop/package.json index 3b3004c0..18890f00 100644 --- a/mateclaw-desktop/package.json +++ b/mateclaw-desktop/package.json @@ -27,9 +27,11 @@ }, "dependencies": { "electron-updater": "^6.3.9", - "vue": "^3.5.13" + "vue": "^3.5.13", + "ws": "^8" }, "devDependencies": { + "@types/ws": "^8.18.1", "@vitejs/plugin-vue": "^5.2.1", "cross-env": "^10.1.0", "electron": "^33.3.1", diff --git a/mateclaw-desktop/pnpm-lock.yaml b/mateclaw-desktop/pnpm-lock.yaml index e80b9b6e..2cb47e2f 100644 --- a/mateclaw-desktop/pnpm-lock.yaml +++ b/mateclaw-desktop/pnpm-lock.yaml @@ -14,7 +14,13 @@ importers: vue: specifier: ^3.5.13 version: 3.5.31(typescript@5.9.3) + ws: + specifier: ^8 + version: 8.21.0 devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.4(vite@6.4.3(@types/node@25.5.0))(vue@3.5.31(typescript@5.9.3)) @@ -472,6 +478,9 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1067,10 +1076,6 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true - glob@7.2.0: - resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -1886,6 +1891,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -2257,6 +2274,10 @@ snapshots: '@types/verror@1.10.11': optional: true + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.0 + '@types/yauzl@2.10.3': dependencies: '@types/node': 20.19.37 @@ -2441,7 +2462,7 @@ snapshots: archiver-utils@2.1.0: dependencies: - glob: 7.2.0 + glob: 7.2.3 graceful-fs: 4.2.11 lazystream: 1.0.1 lodash.defaults: 4.2.0 @@ -3062,15 +3083,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -3896,6 +3908,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + xmlbuilder@15.1.1: {} y18n@5.0.8: {} diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index 9d71da61..a2f04779 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -91,6 +91,10 @@ public class SecurityConfig { "/api/v1/channels/webhook/**", "/api/v1/channels/webchat/**", "/api/v1/talk/ws", + // Desktop local-tool tunnel — the handshake interceptor + // authenticates the ?token= query param itself, so the + // upgrade request is opened to the filter chain like talk/ws. + "/api/v1/desktop/ws", // RFC-045: tool-generated files served via unguessable UUID; entries // expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an // IM-delivered link opened later) is intentional, the UUID is the guard. diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java index 9644f675..cd0bc9c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java @@ -9,6 +9,8 @@ import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import vip.mate.channel.web.TalkModeWebSocketHandler; +import vip.mate.tool.local.DesktopBridgeHandshakeInterceptor; +import vip.mate.tool.local.DesktopBridgeWebSocketHandler; /** * WebSocket 配置 @@ -37,11 +39,19 @@ public class WebSocketConfig implements WebSocketConfigurer { private static final int MAX_TEXT_BUFFER_BYTES = 64 * 1024; private final TalkModeWebSocketHandler talkModeHandler; + private final DesktopBridgeWebSocketHandler desktopBridgeHandler; + private final DesktopBridgeHandshakeInterceptor desktopBridgeHandshakeInterceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(talkModeHandler, "/api/v1/talk/ws") .setAllowedOrigins("*"); + // Desktop local-tool tunnel. The handshake interceptor authenticates the + // ?token= query param and pins the username into the session attributes; + // an unauthenticated socket never reaches the handler. + registry.addHandler(desktopBridgeHandler, "/api/v1/desktop/ws") + .addInterceptors(desktopBridgeHandshakeInterceptor) + .setAllowedOrigins("*"); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java new file mode 100644 index 00000000..e1d7e405 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java @@ -0,0 +1,44 @@ +package vip.mate.tool.local; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * Exposes whether the current user has a live desktop tunnel, so the admin UI + * can show local-tool availability and the connection state. + * + * @author MateClaw Team + */ +@RestController +@RequestMapping("/api/v1/desktop") +@RequiredArgsConstructor +public class DesktopBridgeController { + + private final DesktopBridgeRegistry registry; + + @GetMapping("/status") + public Map status() { + Map body = new HashMap<>(); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = auth != null ? auth.getName() : null; + + DesktopBridgeRegistry.DesktopSession session = + username != null ? registry.getSession(username) : null; + boolean online = session != null && session.session().isOpen(); + + body.put("online", online); + if (online) { + body.put("platform", session.platform()); + body.put("protocolVersion", session.protocolVersion()); + body.put("capabilities", session.capabilities()); + } + return body; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java new file mode 100644 index 00000000..9cc0cf69 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java @@ -0,0 +1,32 @@ +package vip.mate.tool.local; + +/** + * Raised when a {@code local_*} tool cannot reach the user's desktop tunnel. + * The {@link Code} drives the friendly message surfaced back to the agent. + * + * @author MateClaw Team + */ +public class DesktopBridgeException extends RuntimeException { + + public enum Code { + /** No desktop tunnel is connected for the requesting user. */ + OFFLINE, + /** The connected desktop is too old to honor the requested capability. */ + UNSUPPORTED, + /** The desktop did not reply within the call timeout. */ + TIMEOUT, + /** The requesting user could not be resolved from the tool context. */ + NO_USER + } + + private final Code code; + + public DesktopBridgeException(Code code, String message) { + super(message); + this.code = code; + } + + public Code code() { + return code; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java new file mode 100644 index 00000000..c0d6fce0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java @@ -0,0 +1,86 @@ +package vip.mate.tool.local; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.pat.PersonalAccessTokenEntity; +import vip.mate.auth.pat.PersonalAccessTokenService; +import vip.mate.auth.service.AuthService; + +import java.util.Map; +import java.util.Optional; + +/** + * Authenticates the desktop tunnel WebSocket handshake. + *

+ * The desktop cannot send custom headers on a browser-style WebSocket open, so + * the token is passed as a {@code ?token=} query parameter (same convention the + * SSE endpoints use). Both JWT and Personal Access Token forms are accepted. + * On success the resolved username is stashed in the session attributes under + * {@link #USERNAME_ATTR} for the handler to read; on failure the handshake is + * rejected so an unauthenticated socket never reaches the tool bridge. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DesktopBridgeHandshakeInterceptor implements HandshakeInterceptor { + + public static final String USERNAME_ATTR = "mateclaw.desktopUser"; + + private final AuthService authService; + private final PersonalAccessTokenService patService; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + String token = UriComponentsBuilder.fromUri(request.getURI()) + .build().getQueryParams().getFirst("token"); + if (token == null || token.isBlank()) { + log.warn("[DesktopBridge] Handshake rejected: missing token"); + return false; + } + + String username = resolveUsername(token); + if (username == null) { + log.warn("[DesktopBridge] Handshake rejected: invalid token"); + return false; + } + + attributes.put(USERNAME_ATTR, username); + log.info("[DesktopBridge] Handshake accepted for user={}", username); + return true; + } + + private String resolveUsername(String token) { + try { + if (token.startsWith(PersonalAccessTokenService.PAT_PREFIX)) { + Optional maybe = patService.findActiveByPlaintext(token); + if (maybe.isEmpty()) return null; + UserEntity user = authService.findById(maybe.get().getUserId()); + return (user != null && Boolean.TRUE.equals(user.getEnabled())) ? user.getUsername() : null; + } + Claims claims = authService.parseClaims(token); + if (claims == null) return null; + String username = claims.getSubject(); + UserEntity user = authService.findByUsername(username); + return (user != null && Boolean.TRUE.equals(user.getEnabled())) ? username : null; + } catch (Exception e) { + return null; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // no-op + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java new file mode 100644 index 00000000..bb3183d4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java @@ -0,0 +1,181 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registry of connected desktop tunnels, keyed by the authenticated username. + *

+ * A desktop client opens a WebSocket to the server and registers itself here. + * When a cloud agent invokes a {@code local_*} tool, the tool resolves the + * requesting user, looks up that user's live desktop session, and forwards an + * RPC call. The desktop executes the file/shell operation locally and replies, + * which completes the pending future the caller is blocked on. + *

+ * Concurrency: a single {@link WebSocketSession} is not safe for concurrent + * sends, so every frame written to a session is guarded by a monitor on that + * session. Pending RPC futures live in a flat map keyed by request id; the + * handler completes them when the matching {@code result} frame arrives. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DesktopBridgeRegistry { + + private final ObjectMapper objectMapper; + + /** + * A live desktop tunnel. {@code protocolVersion} and {@code capabilities} + * are negotiated in the {@code hello} handshake so {@code local_*} tools + * can degrade gracefully against older clients (older desktops advertise a + * smaller capability set — e.g. read/list only — and never receive + * write/edit/shell calls). + */ + public record DesktopSession( + WebSocketSession session, + String username, + int protocolVersion, + Set capabilities, + String platform) { + + public boolean supports(String capability) { + return capabilities != null && capabilities.contains(capability); + } + } + + /** username -> live desktop session (latest connection wins). */ + private final ConcurrentHashMap sessionsByUser = new ConcurrentHashMap<>(); + + /** wsSessionId -> username, for cleanup on disconnect. */ + private final ConcurrentHashMap userByWsSession = new ConcurrentHashMap<>(); + + /** requestId -> caller awaiting the desktop's reply. */ + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + + /** requestId -> id of the ws session it was routed to, so a disconnect only fails its own calls. */ + private final ConcurrentHashMap pendingOwner = new ConcurrentHashMap<>(); + + /** Register a freshly handshaken desktop session, replacing any prior one for the user. */ + public void register(DesktopSession desktop) { + DesktopSession prior = sessionsByUser.put(desktop.username(), desktop); + userByWsSession.put(desktop.session().getId(), desktop.username()); + if (prior != null && !prior.session().getId().equals(desktop.session().getId())) { + // Same user reconnected from another window — drop the stale one. + userByWsSession.remove(prior.session().getId()); + closeQuietly(prior.session()); + } + log.info("[DesktopBridge] Registered desktop for user={}, protocol={}, caps={}, platform={}", + desktop.username(), desktop.protocolVersion(), desktop.capabilities(), desktop.platform()); + } + + /** Remove a session on disconnect/error and fail any of its in-flight calls. */ + public void unregister(WebSocketSession session) { + String wsId = session.getId(); + String username = userByWsSession.remove(wsId); + if (username != null) { + DesktopSession current = sessionsByUser.get(username); + if (current != null && current.session().getId().equals(wsId)) { + sessionsByUser.remove(username); + } + log.info("[DesktopBridge] Unregistered desktop for user={}", username); + } + // Fail only the pending calls that were routed to this socket — other + // users' desktops keep their in-flight calls. + pendingOwner.forEach((id, ownerWsId) -> { + if (ownerWsId.equals(wsId)) { + CompletableFuture future = pending.remove(id); + pendingOwner.remove(id); + if (future != null && !future.isDone()) { + future.completeExceptionally(new DesktopBridgeException( + DesktopBridgeException.Code.OFFLINE, "Desktop disconnected before replying")); + } + } + }); + } + + public boolean isOnline(String username) { + if (username == null) return false; + DesktopSession s = sessionsByUser.get(username); + return s != null && s.session().isOpen(); + } + + public DesktopSession getSession(String username) { + return username == null ? null : sessionsByUser.get(username); + } + + /** Complete a pending call with the desktop's {@code result} payload. */ + public void complete(String requestId, JsonNode resultEnvelope) { + CompletableFuture future = pending.remove(requestId); + pendingOwner.remove(requestId); + if (future != null) { + future.complete(resultEnvelope); + } else { + log.debug("[DesktopBridge] No pending call for id={} (timed out or duplicate)", requestId); + } + } + + /** + * Send a {@code call} frame to the user's desktop and return a future that + * completes when the matching {@code result} frame arrives. Throws + * {@link DesktopBridgeException} with {@code OFFLINE} when the user has no + * live tunnel, or {@code UNSUPPORTED} when the desktop is too old to honor + * the requested capability. + */ + public CompletableFuture call(String username, String method, String capability, ObjectNode params) { + DesktopSession desktop = sessionsByUser.get(username); + if (desktop == null || !desktop.session().isOpen()) { + throw new DesktopBridgeException(DesktopBridgeException.Code.OFFLINE, + "No desktop is connected for this user"); + } + if (capability != null && !desktop.supports(capability)) { + throw new DesktopBridgeException(DesktopBridgeException.Code.UNSUPPORTED, + "The connected desktop does not support '" + capability + + "' (upgrade the MateClaw desktop app)"); + } + + String requestId = UUID.randomUUID().toString(); + ObjectNode frame = objectMapper.createObjectNode(); + frame.put("type", "call"); + frame.put("id", requestId); + frame.put("method", method); + frame.set("params", params != null ? params : objectMapper.createObjectNode()); + + CompletableFuture future = new CompletableFuture<>(); + pending.put(requestId, future); + pendingOwner.put(requestId, desktop.session().getId()); + try { + WebSocketSession session = desktop.session(); + synchronized (session) { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(frame))); + } + } catch (IOException e) { + pending.remove(requestId); + pendingOwner.remove(requestId); + throw new DesktopBridgeException(DesktopBridgeException.Code.OFFLINE, + "Failed to reach desktop: " + e.getMessage()); + } + return future; + } + + private void closeQuietly(WebSocketSession session) { + try { + if (session.isOpen()) session.close(); + } catch (IOException ignored) { + // best-effort + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java new file mode 100644 index 00000000..b99467ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java @@ -0,0 +1,117 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.AbstractWebSocketHandler; + +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Server endpoint for the desktop local-tool tunnel ({@code /api/v1/desktop/ws}). + *

+ * Protocol (JSON text frames): + *

    + *
  • desktop → server {@code {"type":"hello","protocolVersion":1,"capabilities":[...],"platform":"darwin"}}
  • + *
  • server → desktop {@code {"type":"hello-ack","minProtocol":1}}
  • + *
  • server → desktop {@code {"type":"call","id":"","method":"read_file","params":{...}}}
  • + *
  • desktop → server {@code {"type":"result","id":"","ok":true,"data":{...}}} + * or {@code {"type":"result","id":"","ok":false,"error":"...","code":"DENIED"}}
  • + *
  • desktop → server {@code {"type":"ping"}} → server replies {@code {"type":"pong"}}
  • + *
+ * The authenticated username is injected into the session attributes by + * {@link DesktopBridgeHandshakeInterceptor}; an unauthenticated socket never + * reaches this handler. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DesktopBridgeWebSocketHandler extends AbstractWebSocketHandler { + + /** Lowest desktop protocol version the server still accepts. */ + private static final int MIN_PROTOCOL = 1; + + private final DesktopBridgeRegistry registry; + private final ObjectMapper objectMapper; + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + log.info("[DesktopBridge] WebSocket connected: {} (user={})", + session.getId(), session.getAttributes().get(DesktopBridgeHandshakeInterceptor.USERNAME_ATTR)); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + JsonNode data; + try { + data = objectMapper.readTree(message.getPayload()); + } catch (Exception e) { + log.warn("[DesktopBridge] Invalid JSON frame: {}", e.getMessage()); + return; + } + + String type = data.path("type").asText(""); + switch (type) { + case "hello" -> handleHello(session, data); + case "result" -> registry.complete(data.path("id").asText(), data); + case "ping" -> send(session, "{\"type\":\"pong\"}"); + default -> log.debug("[DesktopBridge] Ignoring frame type='{}'", type); + } + } + + private void handleHello(WebSocketSession session, JsonNode data) throws IOException { + String username = (String) session.getAttributes().get(DesktopBridgeHandshakeInterceptor.USERNAME_ATTR); + if (username == null) { + // Defense in depth — interceptor should have rejected already. + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + + int protocolVersion = data.path("protocolVersion").asInt(1); + if (protocolVersion < MIN_PROTOCOL) { + send(session, "{\"type\":\"hello-ack\",\"ok\":false,\"error\":\"protocol too old\"}"); + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + + Set capabilities = new LinkedHashSet<>(); + JsonNode caps = data.path("capabilities"); + if (caps.isArray()) { + caps.forEach(c -> capabilities.add(c.asText())); + } + String platform = data.path("platform").asText("unknown"); + + registry.register(new DesktopBridgeRegistry.DesktopSession( + session, username, protocolVersion, Set.copyOf(capabilities), platform)); + send(session, "{\"type\":\"hello-ack\",\"ok\":true,\"minProtocol\":" + MIN_PROTOCOL + "}"); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + registry.unregister(session); + log.info("[DesktopBridge] WebSocket disconnected: {} (status={})", session.getId(), status); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + registry.unregister(session); + log.warn("[DesktopBridge] Transport error: {} - {}", session.getId(), exception.getMessage()); + } + + private void send(WebSocketSession session, String json) throws IOException { + if (session.isOpen()) { + synchronized (session) { + session.sendMessage(new TextMessage(json)); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java new file mode 100644 index 00000000..3ebca584 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java @@ -0,0 +1,132 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.ConcurrencyUnsafe; + +/** + * Tools that operate on files on the user's local desktop machine (not + * the server). Each call is forwarded over the desktop WebSocket tunnel to the + * MateClaw desktop app, which enforces a directory whitelist, prompts the user + * for approval on writes/edits, and executes the operation locally. + *

+ * These tools require a connected desktop tunnel for the requesting user; when + * none is connected they return a friendly {@code OFFLINE} error so the agent + * can fall back to server-side tools or tell the user to open the desktop app. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalFileTools { + + /** Read calls are cheap; allow the desktop a short window to reply. */ + private static final int READ_TIMEOUT_SECONDS = 30; + /** Writes/edits may trigger an approval dialog the user must read first. */ + private static final int APPROVAL_TIMEOUT_SECONDS = 180; + + private final LocalToolBridgeService bridge; + private final ObjectMapper objectMapper; + + @Tool(description = """ + LOCAL: Read a file on the USER'S LOCAL DESKTOP machine (not the server). \ + Supports an optional 1-based line range. Output is truncated to ~30KB. \ + Requires the MateClaw desktop app to be connected and the path to be \ + inside the user's configured local directory whitelist. Use the plain \ + read_file tool for server-side files.""") + public String local_read_file( + @ToolParam(description = "Absolute path on the user's local machine") String filePath, + @ToolParam(description = "Start line number (1-based, inclusive). Omit to start at line 1", required = false) Integer startLine, + @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("filePath", filePath); + if (startLine != null) params.put("startLine", startLine); + if (endLine != null) params.put("endLine", endLine); + return call(ctx, "local_read_file", "read_file", + LocalToolBridgeService.CAP_READ, params, READ_TIMEOUT_SECONDS, false); + } + + @ConcurrencyUnsafe("local file write — must serialize with reads/writes on overlapping paths") + @Tool(description = """ + LOCAL: Write content to a file on the USER'S LOCAL DESKTOP machine (not \ + the server). Overwrites if it exists, creates parent directories as \ + needed. ALWAYS prompts the user for native approval on the desktop \ + before writing. Requires the path to be inside the local directory \ + whitelist.""") + public String local_write_file( + @ToolParam(description = "Absolute path on the user's local machine") String filePath, + @ToolParam(description = "Full content to write to the file") String content, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("filePath", filePath); + params.put("content", content); + return call(ctx, "local_write_file", "write_file", + LocalToolBridgeService.CAP_WRITE, params, APPROVAL_TIMEOUT_SECONDS, true); + } + + @ConcurrencyUnsafe("local in-place edit — must not race with reads/writes on the same path") + @Tool(description = """ + LOCAL: Edit a file on the USER'S LOCAL DESKTOP machine via find-and-replace. \ + Replaces the first exact match of oldText with newText (set replaceAll=true \ + for all). ALWAYS prompts the user for native approval on the desktop before \ + editing. Requires the path to be inside the local directory whitelist.""") + public String local_edit_file( + @ToolParam(description = "Absolute path on the user's local machine") String filePath, + @ToolParam(description = "Original text to find (exact match)") String oldText, + @ToolParam(description = "Replacement text") String newText, + @ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("filePath", filePath); + params.put("oldText", oldText); + params.put("newText", newText); + params.put("replaceAll", Boolean.TRUE.equals(replaceAll)); + return call(ctx, "local_edit_file", "edit_file", + LocalToolBridgeService.CAP_EDIT, params, APPROVAL_TIMEOUT_SECONDS, true); + } + + @Tool(description = """ + LOCAL: List entries in a directory on the USER'S LOCAL DESKTOP machine \ + (not the server). Returns each entry with a name and a type marker \ + (file/dir). Requires the directory to be inside the local directory \ + whitelist.""") + public String local_list_dir( + @ToolParam(description = "Absolute directory path on the user's local machine") String dirPath, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("dirPath", dirPath); + return call(ctx, "local_list_dir", "list_dir", + LocalToolBridgeService.CAP_LIST, params, READ_TIMEOUT_SECONDS, false); + } + + @Tool(description = """ + LOCAL: Get metadata for a path on the USER'S LOCAL DESKTOP machine: size \ + in bytes, last-modified time, and whether it is a directory. Requires the \ + path to be inside the local directory whitelist.""") + public String local_stat( + @ToolParam(description = "Absolute path on the user's local machine") String path, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("path", path); + return call(ctx, "local_stat", "stat", + LocalToolBridgeService.CAP_STAT, params, READ_TIMEOUT_SECONDS, false); + } + + private String call(@Nullable ToolContext ctx, String toolName, String method, String capability, + ObjectNode params, int timeoutSeconds, boolean mutating) { + ChatOrigin origin = ChatOrigin.from(ctx); + LocalToolBridgeService.BridgeResult result = + bridge.invoke(origin, toolName, method, capability, params, timeoutSeconds, mutating); + return LocalToolFormat.render(result, objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java new file mode 100644 index 00000000..58c55bdc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java @@ -0,0 +1,63 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.ConcurrencyUnsafe; + +/** + * Executes a shell command on the user's local desktop machine (not the + * server) over the desktop tunnel. The desktop app prompts the user for native + * approval before running, uses {@code cmd.exe} on Windows and {@code /bin/sh} + * on macOS/Linux, and truncates stdout/stderr to ~10KB each — mirroring the + * server-side shell tool's limits. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalShellTool { + + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final int MAX_TIMEOUT_SECONDS = 300; + /** Extra slack on top of the command timeout so the user can read the approval dialog. */ + private static final int APPROVAL_SLACK_SECONDS = 180; + + private final LocalToolBridgeService bridge; + private final ObjectMapper objectMapper; + + @ConcurrencyUnsafe("local shell command execution can mutate global state on the user's machine") + @Tool(description = """ + LOCAL: Execute a shell command on the USER'S LOCAL DESKTOP machine (not \ + the server). Uses cmd.exe on Windows, /bin/sh on macOS/Linux. ALWAYS \ + prompts the user for native approval on the desktop before running. \ + Returns structured JSON with exitCode, stdout, stderr, timedOut \ + (stdout/stderr truncated to ~10KB each). Use execute_shell_command for \ + server-side execution.""") + public String local_execute_shell( + @ToolParam(description = "Shell command to execute on the user's local machine") String command, + @ToolParam(description = "Timeout in seconds, default 60, hard cap 300", required = false) Integer timeoutSeconds, + @Nullable ToolContext ctx) { + int cmdTimeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS; + cmdTimeout = Math.min(cmdTimeout, MAX_TIMEOUT_SECONDS); + + ObjectNode params = objectMapper.createObjectNode(); + params.put("command", command); + params.put("timeoutSeconds", cmdTimeout); + + ChatOrigin origin = ChatOrigin.from(ctx); + LocalToolBridgeService.BridgeResult result = bridge.invoke( + origin, "local_execute_shell", "execute_shell", + LocalToolBridgeService.CAP_SHELL, params, + cmdTimeout + APPROVAL_SLACK_SECONDS, true); + return LocalToolFormat.render(result, objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java new file mode 100644 index 00000000..87cc46db --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java @@ -0,0 +1,145 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.guard.model.ToolGuardAuditLogEntity; +import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * High-level entry point the {@code local_*} tools use to execute an operation + * on the requesting user's desktop. + *

+ * It resolves the requester from the {@link ChatOrigin} carried in the tool + * context, forwards the call over {@link DesktopBridgeRegistry}, blocks for the + * desktop's reply (bounded by a timeout), and writes an audit record to + * {@code mate_tool_guard_audit_log} regardless of outcome. Approval itself is + * performed natively on the desktop (where the user can see the full path / + * command / content), so this layer only records the decision the desktop made. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LocalToolBridgeService { + + /** Capability tokens advertised by the desktop in its handshake. */ + public static final String CAP_READ = "read"; + public static final String CAP_LIST = "list"; + public static final String CAP_STAT = "stat"; + public static final String CAP_WRITE = "write"; + public static final String CAP_EDIT = "edit"; + public static final String CAP_SHELL = "shell"; + + private final DesktopBridgeRegistry registry; + private final ObjectMapper objectMapper; + private final ToolGuardAuditLogMapper auditLogMapper; + + /** Whether a user has a live desktop tunnel right now. */ + public boolean isOnline(@Nullable ChatOrigin origin) { + return origin != null && registry.isOnline(origin.requesterId()); + } + + /** + * Result envelope returned to the tools. {@code data} is the desktop's + * payload on success; on failure {@code error}/{@code code} describe why. + */ + public record BridgeResult(boolean ok, @Nullable JsonNode data, + @Nullable String error, @Nullable String code) { + + public static BridgeResult success(JsonNode data) { + return new BridgeResult(true, data, null, null); + } + + public static BridgeResult failure(String code, String error) { + return new BridgeResult(false, null, error, code); + } + } + + /** + * Forward a tool call to the user's desktop and wait for the reply. + * + * @param origin chat origin carrying the requester identity + * @param toolName the {@code local_*} tool name (for audit) + * @param method the desktop RPC method (e.g. {@code read_file}) + * @param capability capability the desktop must advertise, or null + * @param params the call parameters + * @param timeoutSeconds how long to wait for the desktop reply + * @param mutating true for write/edit/shell (drives the audit decision label) + */ + public BridgeResult invoke(@Nullable ChatOrigin origin, String toolName, String method, + @Nullable String capability, ObjectNode params, + int timeoutSeconds, boolean mutating) { + String username = origin != null ? origin.requesterId() : null; + if (username == null || username.isBlank()) { + audit(origin, toolName, params, "ERROR"); + return BridgeResult.failure("NO_USER", + "Cannot determine which desktop to reach for this request"); + } + + try { + CompletableFuture future = registry.call(username, method, capability, params); + JsonNode envelope = future.get(timeoutSeconds, TimeUnit.SECONDS); + boolean ok = envelope.path("ok").asBoolean(false); + if (ok) { + audit(origin, toolName, params, mutating ? "APPROVED" : "ALLOW"); + return BridgeResult.success(envelope.path("data")); + } + String code = envelope.path("code").asText("ERROR"); + String error = envelope.path("error").asText("Operation failed on desktop"); + audit(origin, toolName, params, "DENIED".equalsIgnoreCase(code) ? "DENIED" : "BLOCK"); + return BridgeResult.failure(code, error); + + } catch (DesktopBridgeException e) { + audit(origin, toolName, params, "OFFLINE"); + return BridgeResult.failure(e.code().name(), e.getMessage()); + } catch (TimeoutException e) { + audit(origin, toolName, params, "TIMEOUT"); + return BridgeResult.failure("TIMEOUT", + "Desktop did not respond within " + timeoutSeconds + "s"); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + audit(origin, toolName, params, "ERROR"); + return BridgeResult.failure("ERROR", cause.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + audit(origin, toolName, params, "ERROR"); + return BridgeResult.failure("ERROR", "Interrupted while waiting for desktop"); + } + } + + /** Write an audit row reusing the tool-guard audit table. Best-effort. */ + private void audit(@Nullable ChatOrigin origin, String toolName, ObjectNode params, String decision) { + try { + ToolGuardAuditLogEntity entity = new ToolGuardAuditLogEntity(); + if (origin != null) { + entity.setConversationId(origin.conversationId()); + entity.setAgentId(origin.agentId() != null ? String.valueOf(origin.agentId()) : null); + entity.setUserId(origin.requesterId()); + entity.setChannelType(origin.channelType() != null ? origin.channelType() : "desktop"); + } + entity.setToolName(toolName); + entity.setToolParamsJson(truncate(params != null ? params.toString() : null)); + entity.setDecision(decision); + auditLogMapper.insert(entity); + } catch (Exception e) { + log.warn("[LocalToolBridge] Failed to record audit: {}", e.getMessage()); + } + } + + private static String truncate(String s) { + if (s == null) return null; + return s.length() > 2000 ? s.substring(0, 2000) : s; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java new file mode 100644 index 00000000..dec2390a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java @@ -0,0 +1,40 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Renders a {@link LocalToolBridgeService.BridgeResult} into the JSON string a + * {@code local_*} tool returns to the agent. Successful calls pass the desktop + * payload through verbatim; failures become a uniform error object the LLM can + * reason about ({@code error}, {@code code}, {@code message}). + * + * @author MateClaw Team + */ +final class LocalToolFormat { + + private LocalToolFormat() { + } + + static String render(LocalToolBridgeService.BridgeResult result, ObjectMapper om) { + try { + if (result.ok()) { + JsonNode data = result.data(); + if (data == null || data.isNull() || data.isMissingNode()) { + ObjectNode ok = om.createObjectNode(); + ok.put("ok", true); + return om.writerWithDefaultPrettyPrinter().writeValueAsString(ok); + } + return om.writerWithDefaultPrettyPrinter().writeValueAsString(data); + } + ObjectNode err = om.createObjectNode(); + err.put("error", true); + err.put("code", result.code()); + err.put("message", result.error()); + return om.writerWithDefaultPrettyPrinter().writeValueAsString(err); + } catch (Exception e) { + return "{\"error\":true,\"message\":\"Failed to render local tool result\"}"; + } + } +} diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 7bd78328..101deda3 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -444,6 +444,16 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0); +-- Built-in tool: Local File Access (operates on the user's local desktop via the desktop tunnel) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0); + +-- Built-in tool: Local Shell (operates on the user's local desktop via the desktop tunnel) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); + -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql index aeab6c12..c69b5685 100644 --- a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql +++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql @@ -488,6 +488,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; +-- Built-in tool: Local File Access (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Local Shell (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql index 57f56646..440db0cb 100644 --- a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql @@ -483,6 +483,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; +-- 内置工具:本地文件访问(通过桌面隧道操作用户本机文件) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', '本地文件访问', '通过桌面隧道读取/写入/编辑/列目录/获取元数据,操作的是用户本机文件(非服务器)。受目录白名单约束;写入与编辑需用户在桌面端原生审批。', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:本地命令执行(通过桌面隧道在用户本机执行) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index cdbb2845..dca49bad 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -497,6 +497,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); +-- Built-in tool: Local File Access (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + +-- Built-in tool: Local Shell (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 262e7141..a72b31ab 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -492,6 +492,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); +-- 内置工具:本地文件访问(通过桌面隧道操作用户本机文件) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', '本地文件访问', '通过桌面隧道读取/写入/编辑/列目录/获取元数据,操作的是用户本机文件(非服务器)。受目录白名单约束;写入与编辑需用户在桌面端原生审批。', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + +-- 内置工具:本地命令执行(通过桌面隧道在用户本机执行) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 773480a4..eb337aaf 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -445,6 +445,16 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0); +-- 内置工具:本地文件访问(通过桌面隧道操作用户本机文件) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000026, 'LocalFileTools', '本地文件访问', '通过桌面隧道读取/写入/编辑/列目录/获取元数据,操作的是用户本机文件(非服务器)。受目录白名单约束;写入与编辑需用户在桌面端原生审批。', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0); + +-- 内置工具:本地命令执行(通过桌面隧道在用户本机执行) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); + -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql new file mode 100644 index 00000000..5c83a166 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql @@ -0,0 +1,17 @@ +-- V160: Register the desktop local-tool proxies as built-in tools so they show +-- up in the tool picker and can be bound per-agent. The agent runtime already +-- discovers these @Tool beans live (core-tier, auto-available even without a +-- row), but the picker / per-agent binding validation reads mate_tool — without +-- these rows operators cannot grant local file/shell access to agents that use +-- an explicit tool allowlist. One row per bean: the alias index resolves the +-- class simple name to every @Tool method the bean exposes, so binding +-- 'LocalFileTools' grants all five local file operations as one capability. +-- Idempotent: MERGE INTO updates the row when the id already matches. + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0); + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql new file mode 100644 index 00000000..1b8d7b0a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql @@ -0,0 +1,17 @@ +-- V160: Register the desktop local-tool proxies as built-in tools so they show +-- up in the tool picker and can be bound per-agent. The agent runtime already +-- discovers these @Tool beans live (core-tier, auto-available even without a +-- row), but the picker / per-agent binding validation reads mate_tool — without +-- these rows operators cannot grant local file/shell access to agents that use +-- an explicit tool allowlist. One row per bean: the alias index resolves the +-- class simple name to every @Tool method the bean exposes, so binding +-- 'LocalFileTools' grants all five local file operations as one capability. +-- Idempotent: ON CONFLICT keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql new file mode 100644 index 00000000..10cdc3ce --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql @@ -0,0 +1,17 @@ +-- V160: Register the desktop local-tool proxies as built-in tools so they show +-- up in the tool picker and can be bound per-agent. The agent runtime already +-- discovers these @Tool beans live (core-tier, auto-available even without a +-- row), but the picker / per-agent binding validation reads mate_tool — without +-- these rows operators cannot grant local file/shell access to agents that use +-- an explicit tool allowlist. One row per bean: the alias index resolves the +-- class simple name to every @Tool method the bean exposes, so binding +-- 'LocalFileTools' grants all five local file operations as one capability. +-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);