diff --git a/src/agents/agent.ts b/src/agents/agent.ts index 3b987a7..142968e 100644 --- a/src/agents/agent.ts +++ b/src/agents/agent.ts @@ -21,6 +21,7 @@ import { buildSkillsPrompt } from "../skills/SkillService.js"; import { createDelegateTaskTool, getFileSystemTools, + getRemoteFileSystemTools, loadPrivateTools, scheduleTools, shellTools, @@ -83,6 +84,9 @@ export async function createClawdAgent(options: CreateAgentOptions = {}) { // Get filesystem tools for workspace access const fileSystemTools = await getFileSystemTools(); + // Get remote filesystem tools (SSH tunnel, empty array when disabled) + const remoteFileSystemTools = await getRemoteFileSystemTools(); + // Memory tools shared with subagents (no ForgetMemoryTool for safety) const memoryTools = [new RecallMemoryTool(), new WriteMemoryTool()]; @@ -94,6 +98,7 @@ export async function createClawdAgent(options: CreateAgentOptions = {}) { toolGroups: { shell: shellTools, filesystem: fileSystemTools, + remoteFilesystem: remoteFileSystemTools, memory: memoryTools, }, }); @@ -116,6 +121,7 @@ export async function createClawdAgent(options: CreateAgentOptions = {}) { ...scheduleTools, ...shellTools, ...fileSystemTools, + ...remoteFileSystemTools, ...privateTools, delegateTool, ) diff --git a/src/agents/config/agent.config.ts b/src/agents/config/agent.config.ts index 88fe92f..4954c8c 100644 --- a/src/agents/config/agent.config.ts +++ b/src/agents/config/agent.config.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import matter from "gray-matter"; -import { configExists, getConfig } from "../../config/index.js"; +import { configExists, getConfig, getRawConfig } from "../../config/index.js"; import { HEARTBEAT_OK, SILENT_REPLY_TOKEN } from "../../lib/tokens.js"; import { buildSkillsPrompt } from "../../skills/SkillService.js"; import type { @@ -184,7 +184,31 @@ You have FULL read/write access to the workspace directory. Use these tools to m - Extra flags/arguments ARE allowed: \`git log --author=Marvel --since=yesterday --oneline\` works because \`git log\` is allowlisted. - Avoid shell metacharacters (\`; & | $ !\`). Use simple commands without pipes or chaining. - For dates with spaces, use dotted format: \`--since=2.days.ago\` instead of \`--since="2 days ago"\`. - - \`gh\` (GitHub CLI) is available for GitHub API queries: PRs, issues, commits, etc.`; + - \`gh\` (GitHub CLI) is available for GitHub API queries: PRs, issues, commits, etc. +${buildRemoteFileSystemSection()}`; +} + +/** + * Build the remote filesystem tools section (only when enabled) + */ +function buildRemoteFileSystemSection(): string { + try { + const rawConfig = getRawConfig(); + const remoteFsConfig = rawConfig.tools.remoteFileSystem; + if (!remoteFsConfig?.enabled) return ""; + + const host = remoteFsConfig.sshHost; + const paths = remoteFsConfig.allowedPaths.join(", "); + + return `\n**Remote PC Tools (via SSH to \`${host}\`):** +- These tools access files on the user's *remote machine* over SSH — they are NOT local workspace tools. +- Remote host: \`${host}\` | Accessible paths: \`${paths}\` +- All remote tools are prefixed with \`remote_\`: \`remote_read_file\`, \`remote_write_file\`, \`remote_list_directory\`, \`remote_search_files\`, \`remote_get_file_info\`, \`remote_create_directory\`, \`remote_move_file\` +- When the user asks about files on "my PC", "my computer", "my desktop", or "remote machine", use these \`remote_*\` tools — NOT the local workspace tools. +- The remote tools operate on the remote machine's filesystem at the allowed paths listed above.`; + } catch { + return ""; + } } /** diff --git a/src/config/index.ts b/src/config/index.ts index 3b7838a..f775395 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -74,6 +74,22 @@ const configFileSchema = z.object({ enabled: z.boolean(), allowedCommands: z.array(z.string()), }), + remoteFileSystem: z + .object({ + enabled: z.boolean(), + sshHost: z.string(), + sshKeyPath: z.string(), + sshPort: z.number().default(22), + allowedPaths: z.array(z.string()), + }) + .optional() + .default({ + enabled: false, + sshHost: "", + sshKeyPath: "", + sshPort: 22, + allowedPaths: [], + }), }), skills: z.object({ enabled: z.boolean(), @@ -119,6 +135,9 @@ export interface AppConfig { githubEnabled: boolean; githubToken: string; + // Remote Filesystem + remoteFileSystemEnabled: boolean; + // Features memoryEnabled: boolean; skillsEnabled: boolean; @@ -173,6 +192,9 @@ function toAppConfig(file: ConfigFile): AppConfig { githubEnabled: file.channels.github?.enabled ?? false, githubToken: file.channels.github?.token ?? "", + // Remote Filesystem + remoteFileSystemEnabled: file.tools.remoteFileSystem?.enabled ?? false, + // Features memoryEnabled: file.memory.enabled, skillsEnabled: file.skills.enabled, diff --git a/src/tools/delegateTools.ts b/src/tools/delegateTools.ts index 1f145d2..af2c93e 100644 --- a/src/tools/delegateTools.ts +++ b/src/tools/delegateTools.ts @@ -17,7 +17,12 @@ import { createLogger } from "../lib/logger.js"; const log = createLogger("Delegate"); -const toolGroupNames = ["shell", "filesystem", "memory"] as const; +const toolGroupNames = [ + "shell", + "filesystem", + "remoteFilesystem", + "memory", +] as const; type ToolGroupName = (typeof toolGroupNames)[number]; /** Max LLM round-trips per subagent to keep delegation fast */ @@ -38,7 +43,7 @@ const taskSchema = z.object({ .array(z.enum(toolGroupNames)) .default(["filesystem"]) .describe( - "Tool groups the subagent needs: shell (exec commands), filesystem (read/write files), memory (recall/write memories)", + "Tool groups the subagent needs: shell (exec commands), filesystem (read/write files), remoteFilesystem (read files on remote machine via SSH), memory (recall/write memories)", ), }); diff --git a/src/tools/index.ts b/src/tools/index.ts index 14f1d92..5118538 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,6 @@ export { createDelegateTaskTool } from "./delegateTools.js"; export { getFileSystemTools } from "./fileSystemTools.js"; export { loadPrivateTools } from "./private-loader.js"; +export { getRemoteFileSystemTools } from "./remoteFileSystemTools.js"; export { scheduleTools, setSchedulerDeps } from "./scheduleTools.js"; export { execShellTool, shellTools } from "./shellTools.js"; diff --git a/src/tools/remoteFileSystemTools.ts b/src/tools/remoteFileSystemTools.ts new file mode 100644 index 0000000..e30b73d --- /dev/null +++ b/src/tools/remoteFileSystemTools.ts @@ -0,0 +1,68 @@ +/** + * Remote file system tools for SSH-based access to a remote machine + * Uses the same @modelcontextprotocol/server-filesystem via SSH stdio tunnel + * Tools are prefixed with "remote_" to avoid conflicts with local filesystem tools + */ + +import { type McpConfig, McpToolset } from "@iqai/adk"; +import { getConfig, getRawConfig } from "../config/index.js"; + +/** Prefix applied to all remote filesystem tool names to distinguish from local tools */ +const REMOTE_TOOL_PREFIX = "remote_"; + +/** + * Get remote filesystem MCP tools via SSH tunnel + * Returns empty array when disabled — zero impact on existing functionality + */ +export async function getRemoteFileSystemTools() { + const rawConfig = getRawConfig(); + const remoteFsConfig = rawConfig.tools.remoteFileSystem; + + if (!remoteFsConfig?.enabled) { + return []; + } + + const mcpConfig: McpConfig = { + name: "Remote Filesystem MCP Client", + description: "Access to files on remote machine via SSH", + transport: { + mode: "stdio", + command: "ssh", + args: [ + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "BatchMode=yes", + "-i", + remoteFsConfig.sshKeyPath, + "-p", + String(remoteFsConfig.sshPort), + remoteFsConfig.sshHost, + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + ...remoteFsConfig.allowedPaths, + ], + env: { PATH: process.env.PATH || "" }, + }, + retryOptions: { maxRetries: 2, initialDelay: 500 }, + debug: getConfig().debug, + }; + + if (getConfig().debug) { + console.log( + `[RemoteFileSystemTools] SSH tunnel to ${remoteFsConfig.sshHost}, paths: ${remoteFsConfig.allowedPaths.join(", ")}`, + ); + } + + const mcpToolset = new McpToolset(mcpConfig); + const tools = await mcpToolset.getTools(); + + // Prefix tool names with "remote_" to avoid collisions with local filesystem tools + for (const tool of tools) { + tool.name = `${REMOTE_TOOL_PREFIX}${tool.name}`; + tool.description = `[Remote PC] ${tool.description}`; + } + + return tools; +}