Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 2 additions & 48 deletions src/main/presenter/agentPresenter/acp/agentBashHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { spawn, type ChildProcess } from 'child_process'
import fs from 'fs/promises'
import path from 'path'
import os from 'os'
import { z } from 'zod'
Expand All @@ -22,7 +21,6 @@ const COMMAND_KILL_GRACE_MS = 5000
const ExecuteCommandArgsSchema = z.object({
command: z.string().min(1),
timeout: z.number().min(100).optional(),
workdir: z.string().optional(),
description: z.string().min(5).max(100)
})

Expand Down Expand Up @@ -51,7 +49,7 @@ export class AgentBashHandler {
throw new Error(`Invalid arguments: ${parsed.error}`)
}

const { command, timeout, workdir } = parsed.data
const { command, timeout } = parsed.data
if (this.commandPermissionHandler) {
const permissionCheck = this.commandPermissionHandler.checkPermission(
options.conversationId,
Expand All @@ -74,7 +72,7 @@ export class AgentBashHandler {
}
}

const cwd = workdir ? await this.validatePath(workdir) : this.allowedDirectories[0]
const cwd = this.allowedDirectories[0]
const startedAt = Date.now()
const snippetId = options.snippetId ?? nanoid()

Expand Down Expand Up @@ -178,50 +176,6 @@ export class AgentBashHandler {
return filepath
}

private isPathAllowed(candidatePath: string): boolean {
return this.allowedDirectories.some((dir) => {
if (candidatePath === dir) return true
const dirWithSeparator = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`
return candidatePath.startsWith(dirWithSeparator)
})
}

private async validatePath(requestedPath: string): Promise<string> {
const expandedPath = this.expandHome(requestedPath)
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath)
const normalizedRequested = this.normalizePath(absolute)
const isAllowed = this.isPathAllowed(normalizedRequested)
if (!isAllowed) {
throw new Error(
`Access denied - path outside allowed directories: ${absolute} not in ${this.allowedDirectories.join(', ')}`
)
}
try {
const realPath = await fs.realpath(absolute)
const normalizedReal = this.normalizePath(realPath)
const isRealPathAllowed = this.isPathAllowed(normalizedReal)
if (!isRealPathAllowed) {
throw new Error('Access denied - symlink target outside allowed directories')
}
return realPath
} catch {
const parentDir = path.dirname(absolute)
try {
const realParentPath = await fs.realpath(parentDir)
const normalizedParent = this.normalizePath(realParentPath)
const isParentAllowed = this.isPathAllowed(normalizedParent)
if (!isParentAllowed) {
throw new Error('Access denied - parent directory outside allowed directories')
}
return absolute
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`)
}
}
}

private async runShellProcess(
command: string,
cwd: string,
Expand Down
53 changes: 28 additions & 25 deletions src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,11 @@ export class AgentFileSystemHandler {
return filepath
}

private async validatePath(requestedPath: string): Promise<string> {
private async validatePath(requestedPath: string, baseDirectory?: string): Promise<string> {
const expandedPath = this.expandHome(requestedPath)
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath)
: path.resolve(baseDirectory ?? this.allowedDirectories[0], expandedPath)
const normalizedRequested = this.normalizePath(absolute)
const isAllowed = this.isPathAllowed(normalizedRequested)
if (!isAllowed) {
Expand Down Expand Up @@ -631,15 +631,15 @@ export class AgentFileSystemHandler {
}
}

async readFile(args: unknown): Promise<string> {
async readFile(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = ReadFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}
const results = await Promise.all(
parsed.data.paths.map(async (filePath: string) => {
try {
const validPath = await this.validatePath(filePath)
const validPath = await this.validatePath(filePath, baseDirectory)
const content = await fs.readFile(validPath, 'utf-8')
return `${filePath}:\n${content}\n`
} catch (error) {
Expand All @@ -651,22 +651,22 @@ export class AgentFileSystemHandler {
return results.join('\n---\n')
}

async writeFile(args: unknown): Promise<string> {
async writeFile(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = WriteFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}
const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
await fs.writeFile(validPath, parsed.data.content, 'utf-8')
return `Successfully wrote to ${parsed.data.path}`
}

async listDirectory(args: unknown): Promise<string> {
async listDirectory(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = ListDirectoryArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}
const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
const entries = await fs.readdir(validPath, { withFileTypes: true })
const formatted = entries
.map((entry) => {
Expand All @@ -677,26 +677,27 @@ export class AgentFileSystemHandler {
return `Directory listing for ${parsed.data.path}:\n\n${formatted}`
}

async createDirectory(args: unknown): Promise<string> {
async createDirectory(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = CreateDirectoryArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}
const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
await fs.mkdir(validPath, { recursive: true })
return `Successfully created directory ${parsed.data.path}`
}

async moveFiles(args: unknown): Promise<string> {
async moveFiles(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = MoveFilesArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}
const results = await Promise.all(
parsed.data.sources.map(async (source) => {
const validSourcePath = await this.validatePath(source)
const validSourcePath = await this.validatePath(source, baseDirectory)
const validDestPath = await this.validatePath(
path.join(parsed.data.destination, path.basename(source))
path.join(parsed.data.destination, path.basename(source)),
baseDirectory
)
try {
await fs.rename(validSourcePath, validDestPath)
Expand All @@ -709,12 +710,12 @@ export class AgentFileSystemHandler {
return results.join('\n')
}

async editText(args: unknown): Promise<string> {
async editText(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = EditTextArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}
const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
const content = await fs.readFile(validPath, 'utf-8')
let modifiedContent = content

Expand Down Expand Up @@ -747,13 +748,13 @@ export class AgentFileSystemHandler {
return diff
}

async grepSearch(args: unknown): Promise<string> {
async grepSearch(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = GrepSearchArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}

const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
const result = await this.runGrepSearch(validPath, parsed.data.pattern, {
filePattern: parsed.data.filePattern,
recursive: parsed.data.recursive,
Expand Down Expand Up @@ -791,13 +792,13 @@ export class AgentFileSystemHandler {
return `Found ${result.totalMatches} matches in ${result.files.length} files:\n\n${formattedResults}`
}

async textReplace(args: unknown): Promise<string> {
async textReplace(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = TextReplaceArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}

const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
const result = await this.replaceTextInFile(
validPath,
parsed.data.pattern,
Expand All @@ -812,14 +813,14 @@ export class AgentFileSystemHandler {
return result.success ? result.diff || '' : result.error || 'Text replacement failed'
}

async directoryTree(args: unknown): Promise<string> {
async directoryTree(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = DirectoryTreeArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}

const buildTree = async (currentPath: string): Promise<TreeEntry[]> => {
const validPath = await this.validatePath(currentPath)
const validPath = await this.validatePath(currentPath, baseDirectory)
const entries = await fs.readdir(validPath, { withFileTypes: true })
const result: TreeEntry[] = []

Expand All @@ -844,20 +845,20 @@ export class AgentFileSystemHandler {
return JSON.stringify(treeData, null, 2)
}

async getFileInfo(args: unknown): Promise<string> {
async getFileInfo(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = GetFileInfoArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}

const validPath = await this.validatePath(parsed.data.path)
const validPath = await this.validatePath(parsed.data.path, baseDirectory)
const info = await this.getFileStats(validPath)
return Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
}

async globSearch(args: unknown): Promise<string> {
async globSearch(args: unknown, baseDirectory?: string): Promise<string> {
const parsed = GlobSearchArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
Expand All @@ -867,7 +868,9 @@ export class AgentFileSystemHandler {
validateGlobPattern(pattern)

// Determine root directory
const searchRoot = root ? await this.validatePath(root) : this.allowedDirectories[0]
const searchRoot = root
? await this.validatePath(root, baseDirectory)
: await this.validatePath(baseDirectory ?? this.allowedDirectories[0])

// Default exclusions
const defaultExclusions = [
Expand Down
72 changes: 56 additions & 16 deletions src/main/presenter/agentPresenter/acp/agentToolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import logger from '@shared/logger'
import { presenter } from '@/presenter'
import { AgentFileSystemHandler } from './agentFileSystemHandler'
import { AgentBashHandler } from './agentBashHandler'

Expand Down Expand Up @@ -155,11 +156,6 @@ export class AgentToolManager {
.max(600000)
.optional()
.describe('Optional timeout in milliseconds'),
workdir: z
.string()
.min(1)
.optional()
.describe('Working directory (defaults to workspace root); prefer this over using cd'),
description: z.string().min(5).max(100).describe('Brief description of what the command does')
})
}
Expand Down Expand Up @@ -255,6 +251,35 @@ export class AgentToolManager {
throw new Error(`Unknown Agent tool: ${toolName}`)
}

private async getWorkdirForConversation(conversationId: string): Promise<string | null> {
try {
const session = await presenter?.sessionManager?.getSession(conversationId)
if (!session?.resolved) {
return null
}

const resolved = session.resolved

if (resolved.chatMode === 'acp agent') {
const modelId = resolved.modelId
const map = resolved.acpWorkdirMap
return modelId && map ? (map[modelId] ?? null) : null
}

if (resolved.chatMode === 'agent') {
return resolved.agentWorkspacePath ?? null
}

return null
} catch (error) {
logger.warn('[AgentToolManager] Failed to get workdir for conversation:', {
conversationId,
error
})
return null
}
}

private getFileSystemToolDefinitions(): MCPToolDefinition[] {
const schemas = this.fileSystemSchemas
return [
Expand Down Expand Up @@ -508,31 +533,46 @@ export class AgentToolManager {
}

const parsedArgs = validationResult.data
let dynamicWorkdir: string | null = null
if (conversationId) {
try {
dynamicWorkdir = await this.getWorkdirForConversation(conversationId)
} catch (error) {
logger.warn('[AgentToolManager] Failed to get workdir for conversation:', {
conversationId,
error
})
}
}

const baseDirectory = dynamicWorkdir ?? undefined

try {
switch (toolName) {
case 'read_file':
return { content: await this.fileSystemHandler.readFile(parsedArgs) }
return { content: await this.fileSystemHandler.readFile(parsedArgs, baseDirectory) }
case 'write_file':
return { content: await this.fileSystemHandler.writeFile(parsedArgs) }
return { content: await this.fileSystemHandler.writeFile(parsedArgs, baseDirectory) }
case 'list_directory':
return { content: await this.fileSystemHandler.listDirectory(parsedArgs) }
return { content: await this.fileSystemHandler.listDirectory(parsedArgs, baseDirectory) }
case 'create_directory':
return { content: await this.fileSystemHandler.createDirectory(parsedArgs) }
return {
content: await this.fileSystemHandler.createDirectory(parsedArgs, baseDirectory)
}
case 'move_files':
return { content: await this.fileSystemHandler.moveFiles(parsedArgs) }
return { content: await this.fileSystemHandler.moveFiles(parsedArgs, baseDirectory) }
case 'edit_text':
return { content: await this.fileSystemHandler.editText(parsedArgs) }
return { content: await this.fileSystemHandler.editText(parsedArgs, baseDirectory) }
case 'glob_search':
return { content: await this.fileSystemHandler.globSearch(parsedArgs) }
return { content: await this.fileSystemHandler.globSearch(parsedArgs, baseDirectory) }
case 'directory_tree':
return { content: await this.fileSystemHandler.directoryTree(parsedArgs) }
return { content: await this.fileSystemHandler.directoryTree(parsedArgs, baseDirectory) }
case 'get_file_info':
return { content: await this.fileSystemHandler.getFileInfo(parsedArgs) }
return { content: await this.fileSystemHandler.getFileInfo(parsedArgs, baseDirectory) }
case 'grep_search':
return { content: await this.fileSystemHandler.grepSearch(parsedArgs) }
return { content: await this.fileSystemHandler.grepSearch(parsedArgs, baseDirectory) }
case 'text_replace':
return { content: await this.fileSystemHandler.textReplace(parsedArgs) }
return { content: await this.fileSystemHandler.textReplace(parsedArgs, baseDirectory) }
case 'execute_command':
if (!this.bashHandler) {
throw new Error('Bash handler not initialized for execute_command tool')
Expand Down
Loading