diff --git a/apps/server/package.json b/apps/server/package.json index aa9081e3..24fc0929 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -55,8 +55,6 @@ "@hono/mcp": "^0.2.3", "@hono/node-server": "^1.19.6", "@hono/zod-validator": "^0.4.3", - "@mariozechner/pi-agent-core": "^0.54.2", - "@mariozechner/pi-coding-agent": "^0.54.2", "@modelcontextprotocol/sdk": "^1.25.2", "@openrouter/ai-sdk-provider": "^2.2.3", "@sentry/bun": "^10.31.0", diff --git a/apps/server/src/agent/tool-loop/ai-sdk-agent.ts b/apps/server/src/agent/tool-loop/ai-sdk-agent.ts index 86aed516..e4f78d4a 100644 --- a/apps/server/src/agent/tool-loop/ai-sdk-agent.ts +++ b/apps/server/src/agent/tool-loop/ai-sdk-agent.ts @@ -4,11 +4,11 @@ import { stepCountIs, ToolLoopAgent, type UIMessage } from 'ai' import type { Browser } from '../../browser/browser' import type { KlavisClient } from '../../lib/clients/klavis/klavis-client' import { logger } from '../../lib/logger' +import { buildFilesystemToolSet } from '../../tools/filesystem/native-tool-adapter' import type { ToolRegistry } from '../../tools/tool-registry' import { buildSystemPrompt } from '../prompt' import type { ResolvedAgentConfig } from '../types' import { createCompactionPrepareStep } from './compaction' -import { buildFilesystemToolSet } from './filesystem-tools/pi-tool-adapter' import { buildMcpServerSpecs, createMcpClients } from './mcp-builder' import { createLanguageModel } from './provider-factory' import { buildBrowserToolSet } from './tool-adapter' diff --git a/apps/server/src/agent/tool-loop/filesystem-tools/pi-tool-adapter.ts b/apps/server/src/agent/tool-loop/filesystem-tools/pi-tool-adapter.ts deleted file mode 100644 index 092e8afe..00000000 --- a/apps/server/src/agent/tool-loop/filesystem-tools/pi-tool-adapter.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider' -import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core' -import { - createBashTool, - createEditTool, - createFindTool, - createGrepTool, - createLsTool, - createReadTool, - createWriteTool, -} from '@mariozechner/pi-coding-agent' -import { jsonSchema, type ToolSet, tool } from 'ai' -import { logger } from '../../../lib/logger' -import { metrics } from '../../../lib/metrics' - -type PiContent = AgentToolResult['content'] - -function piContentToModelOutput( - content: PiContent, -): LanguageModelV2ToolResultOutput { - const hasImages = content.some((c) => c.type === 'image') - - if (!hasImages) { - const text = content - .filter( - (c): c is PiContent[number] & { type: 'text' } => c.type === 'text', - ) - .map((c) => c.text) - .join('\n') - return { type: 'text', value: text || 'Success' } - } - - return { - type: 'content', - value: content.map((c) => { - if (c.type === 'text') { - return { type: 'text' as const, text: c.text } - } - return { - type: 'media' as const, - data: c.data, - mediaType: c.mimeType, - } - }), - } -} - -// biome-ignore lint/suspicious/noExplicitAny: AgentTool is contravariant on TParameters — each createXxxTool returns a specific generic that can't assign to AgentTool without widening -function createAllTools(cwd: string): Record> { - return { - read: createReadTool(cwd), - bash: createBashTool(cwd), - edit: createEditTool(cwd), - write: createWriteTool(cwd), - grep: createGrepTool(cwd), - find: createFindTool(cwd), - ls: createLsTool(cwd), - } -} - -export function buildFilesystemToolSet(cwd: string): ToolSet { - const piTools = createAllTools(cwd) - const toolSet: ToolSet = {} - - for (const [name, piTool] of Object.entries(piTools)) { - const prefixedName = `filesystem_${name}` - - toolSet[prefixedName] = tool({ - description: piTool.description, - inputSchema: jsonSchema( - JSON.parse(JSON.stringify(piTool.parameters)) as Parameters< - typeof jsonSchema - >[0], - ), - execute: async (params) => { - const startTime = performance.now() - try { - const result = await piTool.execute(crypto.randomUUID(), params) - - metrics.log('tool_executed', { - tool_name: prefixedName, - duration_ms: Math.round(performance.now() - startTime), - success: true, - }) - - return { content: result.content, isError: false } - } catch (error) { - const errorText = - error instanceof Error ? error.message : String(error) - - logger.error('Filesystem tool execution failed', { - tool: prefixedName, - error: errorText, - }) - metrics.log('tool_executed', { - tool_name: prefixedName, - duration_ms: Math.round(performance.now() - startTime), - success: false, - error_message: - error instanceof Error ? error.message : 'Unknown error', - }) - - return { - content: [{ type: 'text' as const, text: errorText }], - isError: true, - } - } - }, - toModelOutput: ({ output }) => { - const result = output as { - content: PiContent - isError: boolean - } - if (result.isError) { - const text = result.content - .filter( - (c): c is PiContent[number] & { type: 'text' } => - c.type === 'text', - ) - .map((c) => c.text) - .join('\n') - return { type: 'error-text', value: text } - } - if (!result.content?.length) { - return { type: 'text', value: 'Success' } - } - return piContentToModelOutput(result.content) - }, - }) - } - - return toolSet -} diff --git a/apps/server/src/tools/filesystem/bash.ts b/apps/server/src/tools/filesystem/bash.ts new file mode 100644 index 00000000..28c3e80a --- /dev/null +++ b/apps/server/src/tools/filesystem/bash.ts @@ -0,0 +1,103 @@ +import { spawn } from 'node:child_process' +import { z } from 'zod' +import { truncateTail } from './truncate' +import type { FilesystemTool } from './types' + +const bashInputSchema = z.object({ + command: z.string().describe('Shell command to execute'), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds (optional)'), +}) + +type BashInput = z.infer + +function getShellCommand(): { shell: string; args: string[] } { + if (process.platform === 'win32') { + return { shell: 'cmd.exe', args: ['/d', '/s', '/c'] } + } + return { shell: process.env.SHELL || '/bin/bash', args: ['-lc'] } +} + +async function runCommand( + command: string, + cwd: string, + timeoutSeconds?: number, +): Promise<{ exitCode: number | null; output: string; timedOut: boolean }> { + const { shell, args } = getShellCommand() + + return new Promise((resolve, reject) => { + const child = spawn(shell, [...args, command], { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const outputParts: string[] = [] + let timedOut = false + + const timeoutId = timeoutSeconds + ? setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + }, timeoutSeconds * 1_000) + : undefined + + child.stdout.on('data', (chunk: Buffer) => { + outputParts.push(chunk.toString('utf-8')) + }) + + child.stderr.on('data', (chunk: Buffer) => { + outputParts.push(chunk.toString('utf-8')) + }) + + child.on('error', (error) => { + if (timeoutId) clearTimeout(timeoutId) + reject(error) + }) + + child.on('close', (code) => { + if (timeoutId) clearTimeout(timeoutId) + resolve({ + exitCode: code, + output: outputParts.join(''), + timedOut, + }) + }) + }) +} + +export const bashTool: FilesystemTool = { + name: 'bash', + description: + 'Execute a shell command in the session directory. Returns combined stdout and stderr.', + inputSchema: bashInputSchema, + execute: async ({ command, timeout }, cwd) => { + const { exitCode, output, timedOut } = await runCommand( + command, + cwd, + timeout, + ) + const truncation = truncateTail(output) + + let text = truncation.content || '(no output)' + if (truncation.truncated) { + const startLine = truncation.totalLines - truncation.outputLines + 1 + text += `\n\n[Showing lines ${startLine}-${truncation.totalLines} of ${truncation.totalLines}. Output truncated.]` + } + + if (timedOut) { + throw new Error(`${text}\n\nCommand timed out after ${timeout} seconds`) + } + + if (exitCode !== 0) { + throw new Error(`${text}\n\nCommand exited with code ${exitCode}`) + } + + return { + content: [{ type: 'text', text }], + } + }, +} diff --git a/apps/server/src/tools/filesystem/edit.ts b/apps/server/src/tools/filesystem/edit.ts new file mode 100644 index 00000000..03026c4a --- /dev/null +++ b/apps/server/src/tools/filesystem/edit.ts @@ -0,0 +1,94 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { z } from 'zod' +import { assertPathWithinCwd, resolvePathInCwd } from './path-utils' +import type { FilesystemTool } from './types' + +const editInputSchema = z.object({ + path: z.string().describe('Path to the file to edit (relative or absolute)'), + oldText: z + .string() + .describe('Exact text to find and replace (must be unique in the file)'), + newText: z.string().describe('New text to replace the old text with'), +}) + +type EditInput = z.infer + +function stripBom(content: string): { bom: string; text: string } { + return content.startsWith('\uFEFF') + ? { bom: '\uFEFF', text: content.slice(1) } + : { bom: '', text: content } +} + +function detectLineEnding(content: string): '\r\n' | '\n' { + const crlfIndex = content.indexOf('\r\n') + const lfIndex = content.indexOf('\n') + if (lfIndex === -1 || crlfIndex === -1) return '\n' + return crlfIndex < lfIndex ? '\r\n' : '\n' +} + +function normalizeToLf(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n') +} + +function restoreLineEndings( + content: string, + lineEnding: '\r\n' | '\n', +): string { + return lineEnding === '\r\n' ? content.replace(/\n/g, '\r\n') : content +} + +export const editTool: FilesystemTool = { + name: 'edit', + description: + 'Edit a file by replacing exact text. The oldText must match exactly and be unique.', + inputSchema: editInputSchema, + execute: async ({ path: rawPath, oldText, newText }, cwd) => { + const absolutePath = resolvePathInCwd(rawPath, cwd) + assertPathWithinCwd(absolutePath, cwd) + + const rawContent = await readFile(absolutePath, 'utf-8') + const { bom, text: withoutBom } = stripBom(rawContent) + + const lineEnding = detectLineEnding(withoutBom) + const normalizedContent = normalizeToLf(withoutBom) + const normalizedOldText = normalizeToLf(oldText) + const normalizedNewText = normalizeToLf(newText) + + if (!normalizedContent.includes(normalizedOldText)) { + throw new Error( + `Could not find the exact text in ${rawPath}. The oldText value must match exactly.`, + ) + } + + const occurrences = normalizedContent.split(normalizedOldText).length - 1 + if (occurrences > 1) { + throw new Error( + `Found ${occurrences} occurrences in ${rawPath}. oldText must be unique.`, + ) + } + + const replacedContent = normalizedContent.replace( + normalizedOldText, + normalizedNewText, + ) + + if (replacedContent === normalizedContent) { + throw new Error(`No changes made to ${rawPath}.`) + } + + await writeFile( + absolutePath, + `${bom}${restoreLineEndings(replacedContent, lineEnding)}`, + 'utf-8', + ) + + return { + content: [ + { + type: 'text', + text: `Successfully replaced text in ${rawPath}.`, + }, + ], + } + }, +} diff --git a/apps/server/src/tools/filesystem/find.ts b/apps/server/src/tools/filesystem/find.ts new file mode 100644 index 00000000..024af74c --- /dev/null +++ b/apps/server/src/tools/filesystem/find.ts @@ -0,0 +1,103 @@ +import { stat } from 'node:fs/promises' +import { z } from 'zod' +import { + assertPathWithinCwd, + matchesGlob, + resolvePathInCwd, + safeRelativePath, + toPosixPath, +} from './path-utils' +import { walkEntries } from './scan' +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from './truncate' +import type { FilesystemTool } from './types' + +const findInputSchema = z.object({ + pattern: z + .string() + .describe( + 'Glob pattern to match files, e.g. *.ts, **/*.json, src/**/*.spec.ts', + ), + path: z + .string() + .optional() + .describe('Directory to search in (default: current directory)'), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of results to return (default: 1000)'), +}) + +type FindInput = z.infer + +const DEFAULT_LIMIT = 1_000 + +function formatEntry(relativePath: string, isDirectory: boolean): string { + return isDirectory ? `${relativePath}/` : relativePath +} + +export const findTool: FilesystemTool = { + name: 'find', + description: + 'Search for files by glob pattern. Returns matching paths relative to the search directory.', + inputSchema: findInputSchema, + execute: async ({ pattern, path: rawPath, limit }, cwd) => { + const searchPath = resolvePathInCwd(rawPath || '.', cwd) + assertPathWithinCwd(searchPath, cwd) + + const searchStats = await stat(searchPath) + if (!searchStats.isDirectory()) { + throw new Error(`Not a directory: ${rawPath || '.'}`) + } + + const effectiveLimit = limit ?? DEFAULT_LIMIT + const entries = await walkEntries(searchPath) + + const results: string[] = [] + for (const entry of entries) { + if (results.length >= effectiveLimit) break + assertPathWithinCwd(entry.absolutePath, cwd) + + const relativePath = safeRelativePath(entry.absolutePath, searchPath) + if (relativePath.length === 0) continue + if (!matchesGlob(relativePath, pattern)) continue + + results.push(formatEntry(relativePath, entry.isDirectory)) + } + + results.sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }), + ) + + if (results.length === 0) { + return { + content: [{ type: 'text', text: 'No files found matching pattern' }], + } + } + + const baseMessage = results.join('\n') + const truncation = truncateHead(baseMessage, { + maxLines: Number.MAX_SAFE_INTEGER, + maxBytes: DEFAULT_MAX_BYTES, + }) + + const notices: string[] = [] + if (results.length >= effectiveLimit) { + notices.push(`${effectiveLimit} results limit reached`) + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} output limit reached`) + } + + const scope = toPosixPath(rawPath || '.') + const header = `Found ${results.length} path(s) in ${scope}:` + const suffix = notices.length > 0 ? `\n\n[${notices.join('. ')}]` : '' + + return { + content: [ + { type: 'text', text: `${header}\n${truncation.content}${suffix}` }, + ], + } + }, +} diff --git a/apps/server/src/tools/filesystem/grep.ts b/apps/server/src/tools/filesystem/grep.ts new file mode 100644 index 00000000..3a76d67c --- /dev/null +++ b/apps/server/src/tools/filesystem/grep.ts @@ -0,0 +1,272 @@ +import { readFile, stat } from 'node:fs/promises' +import { z } from 'zod' +import { + assertPathWithinCwd, + escapeRegExp, + matchesGlob, + resolvePathInCwd, + safeRelativePath, + toPosixPath, +} from './path-utils' +import { walkEntries } from './scan' +import { + DEFAULT_MAX_BYTES, + formatSize, + GREP_MAX_LINE_LENGTH, + truncateHead, + truncateLine, +} from './truncate' +import type { FilesystemTool } from './types' + +const grepInputSchema = z.object({ + pattern: z.string().describe('Search pattern (regex or literal string)'), + path: z + .string() + .optional() + .describe('Directory or file to search (default: current directory)'), + glob: z + .string() + .optional() + .describe('Optional glob file filter, e.g. *.ts or **/*.spec.ts'), + ignoreCase: z + .boolean() + .optional() + .describe('Case-insensitive search (default: false)'), + literal: z + .boolean() + .optional() + .describe('Treat pattern as literal string (default: false)'), + context: z + .number() + .int() + .nonnegative() + .optional() + .describe('Number of lines before and after each match'), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of matches to return (default: 100)'), +}) + +type GrepInput = z.infer + +const DEFAULT_LIMIT = 100 + +function isBinaryContent(content: Buffer): boolean { + return content.subarray(0, 1_024).includes(0) +} + +function buildPatternRegex(params: { + pattern: string + ignoreCase?: boolean + literal?: boolean +}): RegExp { + const source = params.literal ? escapeRegExp(params.pattern) : params.pattern + return new RegExp(source, params.ignoreCase ? 'i' : '') +} + +function formatMatchLine( + filePath: string, + lineNumber: number, + line: string, +): string { + const truncated = truncateLine(line.replace(/\r/g, ''), GREP_MAX_LINE_LENGTH) + return `${filePath}:${lineNumber}: ${truncated.text}` +} + +function formatContextLine( + filePath: string, + lineNumber: number, + line: string, +): string { + const truncated = truncateLine(line.replace(/\r/g, ''), GREP_MAX_LINE_LENGTH) + return `${filePath}-${lineNumber}- ${truncated.text}` +} + +async function collectSearchFiles( + searchPath: string, + cwd: string, +): Promise { + const searchStats = await stat(searchPath) + if (!searchStats.isDirectory()) { + return [searchPath] + } + + const entries = await walkEntries(searchPath) + const files: string[] = [] + + for (const entry of entries) { + if (entry.isDirectory) continue + assertPathWithinCwd(entry.absolutePath, cwd) + files.push(entry.absolutePath) + } + + return files +} + +function collectContextLines(params: { + relativePath: string + lineIndex: number + lines: string[] + contextLines: number +}): string[] { + const { relativePath, lineIndex, lines, contextLines } = params + + if (contextLines === 0) { + return [ + formatMatchLine(relativePath, lineIndex + 1, lines[lineIndex] ?? ''), + ] + } + + const start = Math.max(0, lineIndex - contextLines) + const end = Math.min(lines.length - 1, lineIndex + contextLines) + const output: string[] = [] + + for (let i = start; i <= end; i++) { + const line = lines[i] ?? '' + output.push( + i === lineIndex + ? formatMatchLine(relativePath, i + 1, line) + : formatContextLine(relativePath, i + 1, line), + ) + } + + return output +} + +async function searchFile(params: { + filePath: string + searchRoot: string + regex: RegExp + contextLines: number + glob?: string + remainingLimit: number +}): Promise<{ matches: string[]; matchCount: number }> { + const { filePath, searchRoot, regex, contextLines, glob, remainingLimit } = + params + const relativePath = safeRelativePath(filePath, searchRoot) + + if (!matchesGlob(relativePath, glob)) { + return { matches: [], matchCount: 0 } + } + + const fileBuffer = await readFile(filePath) + if (isBinaryContent(fileBuffer)) { + return { matches: [], matchCount: 0 } + } + + const lines = fileBuffer.toString('utf-8').split('\n') + const matches: string[] = [] + let matchCount = 0 + + for (let index = 0; index < lines.length; index++) { + if (matchCount >= remainingLimit) break + + const line = lines[index] ?? '' + if (!regex.test(line)) continue + + matches.push( + ...collectContextLines({ + relativePath, + lineIndex: index, + lines, + contextLines, + }), + ) + + matchCount++ + } + + return { matches, matchCount } +} + +function buildResultText(params: { + pattern: string + rawPath?: string + matchCount: number + matches: string[] + effectiveLimit: number +}): string { + const { pattern, rawPath, matchCount, matches, effectiveLimit } = params + const header = `Found ${matchCount} match(es) for pattern "${pattern}" in ${toPosixPath(rawPath || '.')}:` + const truncation = truncateHead(`${header}\n${matches.join('\n')}`, { + maxLines: Number.MAX_SAFE_INTEGER, + maxBytes: DEFAULT_MAX_BYTES, + }) + + const notices: string[] = [] + if (matchCount >= effectiveLimit) { + notices.push(`${effectiveLimit} results limit reached`) + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} output limit reached`) + } + + return notices.length > 0 + ? `${truncation.content}\n\n[${notices.join('. ')}]` + : truncation.content +} + +export const grepTool: FilesystemTool = { + name: 'grep', + description: + 'Search file contents for a pattern. Returns matching lines with file paths and line numbers.', + inputSchema: grepInputSchema, + execute: async ( + { pattern, path: rawPath, glob, ignoreCase, literal, context, limit }, + cwd, + ) => { + const searchPath = resolvePathInCwd(rawPath || '.', cwd) + assertPathWithinCwd(searchPath, cwd) + + const searchStats = await stat(searchPath) + const searchRoot = searchStats.isDirectory() ? searchPath : cwd + const files = await collectSearchFiles(searchPath, cwd) + + const regex = buildPatternRegex({ pattern, ignoreCase, literal }) + const contextLines = context ?? 0 + const effectiveLimit = limit ?? DEFAULT_LIMIT + + const formattedMatches: string[] = [] + let totalMatches = 0 + + for (const filePath of files) { + if (totalMatches >= effectiveLimit) break + + const result = await searchFile({ + filePath, + searchRoot, + regex, + contextLines, + glob, + remainingLimit: effectiveLimit - totalMatches, + }) + + formattedMatches.push(...result.matches) + totalMatches += result.matchCount + } + + if (formattedMatches.length === 0) { + return { + content: [{ type: 'text', text: 'No matches found for pattern' }], + } + } + + return { + content: [ + { + type: 'text', + text: buildResultText({ + pattern, + rawPath, + matchCount: totalMatches, + matches: formattedMatches, + effectiveLimit, + }), + }, + ], + } + }, +} diff --git a/apps/server/src/tools/filesystem/ls.ts b/apps/server/src/tools/filesystem/ls.ts new file mode 100644 index 00000000..39cfd2f5 --- /dev/null +++ b/apps/server/src/tools/filesystem/ls.ts @@ -0,0 +1,77 @@ +import { readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import { z } from 'zod' +import { assertPathWithinCwd, resolvePathInCwd } from './path-utils' +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from './truncate' +import type { FilesystemTool } from './types' + +const lsInputSchema = z.object({ + path: z + .string() + .optional() + .describe('Directory to list (default: current directory)'), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of entries to return (default: 500)'), +}) + +type LsInput = z.infer + +const DEFAULT_LIMIT = 500 + +export const lsTool: FilesystemTool = { + name: 'ls', + description: + 'List directory contents. Returns entries sorted alphabetically, with / suffix for directories.', + inputSchema: lsInputSchema, + execute: async ({ path: rawPath, limit }, cwd) => { + const directoryPath = resolvePathInCwd(rawPath || '.', cwd) + assertPathWithinCwd(directoryPath, cwd) + + const stats = await stat(directoryPath) + if (!stats.isDirectory()) { + throw new Error(`Not a directory: ${rawPath || '.'}`) + } + + const entries = await readdir(directoryPath, { withFileTypes: true }) + entries.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ) + + const effectiveLimit = limit ?? DEFAULT_LIMIT + const formatted: string[] = [] + + for (const entry of entries) { + if (formatted.length >= effectiveLimit) break + const fullPath = path.join(directoryPath, entry.name) + assertPathWithinCwd(fullPath, cwd) + formatted.push(entry.isDirectory() ? `${entry.name}/` : entry.name) + } + + if (formatted.length === 0) { + return { content: [{ type: 'text', text: '(empty directory)' }] } + } + + const truncation = truncateHead(formatted.join('\n'), { + maxLines: Number.MAX_SAFE_INTEGER, + maxBytes: DEFAULT_MAX_BYTES, + }) + + const notices: string[] = [] + if (entries.length > effectiveLimit) { + notices.push(`${effectiveLimit} entries limit reached`) + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} output limit reached`) + } + + const suffix = notices.length > 0 ? `\n\n[${notices.join('. ')}]` : '' + + return { + content: [{ type: 'text', text: `${truncation.content}${suffix}` }], + } + }, +} diff --git a/apps/server/src/tools/filesystem/native-tool-adapter.ts b/apps/server/src/tools/filesystem/native-tool-adapter.ts new file mode 100644 index 00000000..05519024 --- /dev/null +++ b/apps/server/src/tools/filesystem/native-tool-adapter.ts @@ -0,0 +1,143 @@ +import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider' +import { type ToolSet, tool } from 'ai' +import type { ZodTypeAny } from 'zod' +import { logger } from '../../lib/logger' +import { metrics } from '../../lib/metrics' +import { bashTool } from './bash' +import { editTool } from './edit' +import { findTool } from './find' +import { grepTool } from './grep' +import { lsTool } from './ls' +import { readTool } from './read' +import type { FilesystemContentItem, FilesystemToolResult } from './types' +import { writeTool } from './write' + +function contentToModelOutput( + content: FilesystemContentItem[], +): LanguageModelV2ToolResultOutput { + const hasImages = content.some((item) => item.type === 'image') + + if (!hasImages) { + const text = content + .filter( + (item): item is Extract => + item.type === 'text', + ) + .map((item) => item.text) + .join('\n') + + return { type: 'text', value: text || 'Success' } + } + + return { + type: 'content', + value: content.map((item) => { + if (item.type === 'text') { + return { type: 'text' as const, text: item.text } + } + + return { + type: 'media' as const, + data: item.data, + mediaType: item.mimeType, + } + }), + } +} + +const nativeTools = [ + readTool, + bashTool, + editTool, + writeTool, + grepTool, + findTool, + lsTool, +] as const + +type NativeToolDefinition = { + name: string + description: string + inputSchema: ZodTypeAny + // biome-ignore lint/suspicious/noExplicitAny: tool schemas differ per tool and are normalized through runtime parse() + execute: (input: any, cwd: string) => Promise +} + +const nativeToolDefinitions = nativeTools as unknown as NativeToolDefinition[] + +export function buildFilesystemToolSet(cwd: string): ToolSet { + const toolSet: ToolSet = {} + + for (const filesystemTool of nativeToolDefinitions) { + const prefixedName = `filesystem_${filesystemTool.name}` + + toolSet[prefixedName] = tool({ + description: filesystemTool.description, + inputSchema: filesystemTool.inputSchema, + execute: async (params: unknown) => { + const startTime = performance.now() + + try { + const parsedParams = filesystemTool.inputSchema.parse(params) + const result = await filesystemTool.execute(parsedParams, cwd) + + metrics.log('tool_executed', { + tool_name: prefixedName, + duration_ms: Math.round(performance.now() - startTime), + success: !(result.isError ?? false), + }) + + return { + content: result.content, + isError: result.isError ?? false, + } + } catch (error) { + const errorText = + error instanceof Error ? error.message : String(error) + + logger.error('Filesystem tool execution failed', { + tool: prefixedName, + error: errorText, + }) + + metrics.log('tool_executed', { + tool_name: prefixedName, + duration_ms: Math.round(performance.now() - startTime), + success: false, + error_message: errorText, + }) + + return { + content: [{ type: 'text' as const, text: errorText }], + isError: true, + } + } + }, + toModelOutput: ({ output }) => { + const result = output as FilesystemToolResult + + if (result.isError) { + const text = result.content + .filter( + ( + item, + ): item is Extract => + item.type === 'text', + ) + .map((item) => item.text) + .join('\n') + + return { type: 'error-text', value: text } + } + + if (!result.content || result.content.length === 0) { + return { type: 'text', value: 'Success' } + } + + return contentToModelOutput(result.content) + }, + }) + } + + return toolSet +} diff --git a/apps/server/src/tools/filesystem/path-utils.ts b/apps/server/src/tools/filesystem/path-utils.ts new file mode 100644 index 00000000..02acd6ca --- /dev/null +++ b/apps/server/src/tools/filesystem/path-utils.ts @@ -0,0 +1,117 @@ +import { existsSync, realpathSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g + +function normalizeUnicodeSpaces(value: string): string { + return value.replace(UNICODE_SPACES, ' ') +} + +function normalizeLeadingAt(value: string): string { + return value.startsWith('@') ? value.slice(1) : value +} + +export function expandPath(rawPath: string): string { + const normalized = normalizeUnicodeSpaces(normalizeLeadingAt(rawPath)) + + if (normalized === '~') return os.homedir() + if (normalized.startsWith('~/')) + return path.join(os.homedir(), normalized.slice(2)) + + return normalized +} + +export function resolvePathInCwd(rawPath: string, cwd: string): string { + const expanded = expandPath(rawPath) + if (path.isAbsolute(expanded)) return path.resolve(expanded) + return path.resolve(cwd, expanded) +} + +function resolveRealPathOrFallback(resolvedPath: string): string { + try { + return realpathSync.native(resolvedPath) + } catch { + return resolvedPath + } +} + +function resolveBoundaryCheckedPath(resolvedPath: string): string { + const absolutePath = path.resolve(resolvedPath) + let probe = absolutePath + + while (!existsSync(probe)) { + const parent = path.dirname(probe) + if (parent === probe) return absolutePath + probe = parent + } + + const realProbe = resolveRealPathOrFallback(probe) + const remainder = path.relative(probe, absolutePath) + return path.resolve(realProbe, remainder) +} + +export function isPathWithinCwd(resolvedPath: string, cwd: string): boolean { + const root = resolveBoundaryCheckedPath(path.resolve(cwd)) + const target = resolveBoundaryCheckedPath(path.resolve(resolvedPath)) + const relative = path.relative(root, target) + + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ) +} + +export function assertPathWithinCwd(resolvedPath: string, cwd: string): void { + if (!isPathWithinCwd(resolvedPath, cwd)) { + throw new Error(`Path is outside the session directory: ${resolvedPath}`) + } +} + +export function toPosixPath(filePath: string): string { + return filePath.split(path.sep).join('/') +} + +export function safeRelativePath(absolutePath: string, cwd: string): string { + return toPosixPath(path.relative(cwd, absolutePath)) +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function globToRegExp(pattern: string): RegExp { + const normalized = toPosixPath(pattern) + let regex = '' + + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i] + const next = normalized[i + 1] + + if (char === '*' && next === '*') { + regex += '.*' + i++ + continue + } + + if (char === '*') { + regex += '[^/]*' + continue + } + + if (char === '?') { + regex += '[^/]' + continue + } + + regex += escapeRegExp(char) + } + + return new RegExp(`^${regex}$`) +} + +export function matchesGlob(pathValue: string, pattern?: string): boolean { + if (!pattern) return true + const regex = globToRegExp(pattern) + return regex.test(toPosixPath(pathValue)) +} diff --git a/apps/server/src/tools/filesystem/read.ts b/apps/server/src/tools/filesystem/read.ts new file mode 100644 index 00000000..eba8cdbf --- /dev/null +++ b/apps/server/src/tools/filesystem/read.ts @@ -0,0 +1,148 @@ +import { readFile, stat } from 'node:fs/promises' +import { z } from 'zod' +import { assertPathWithinCwd, resolvePathInCwd } from './path-utils' +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateHead, +} from './truncate' +import type { FilesystemTool } from './types' + +const IMAGE_MIME_TYPES: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', +} + +const readInputSchema = z.object({ + path: z.string().describe('Path to the file to read (relative or absolute)'), + offset: z + .number() + .int() + .positive() + .optional() + .describe('Line number to start reading from (1-indexed)'), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of lines to read'), +}) + +type ReadInput = z.infer + +function detectImageMimeType(filePath: string): string | null { + const lastDotIndex = filePath.lastIndexOf('.') + if (lastDotIndex < 0) return null + const extension = filePath.slice(lastDotIndex).toLowerCase() + return IMAGE_MIME_TYPES[extension] ?? null +} + +function assertNotDirectory( + stats: { isDirectory(): boolean }, + rawPath: string, +): void { + if (stats.isDirectory()) { + throw new Error(`Path is a directory, expected a file: ${rawPath}`) + } +} + +function buildReadTextOutput(params: { + rawPath: string + selectedContent: string + startLine: number + totalLines: number + userLimit?: number +}): string { + const { rawPath, selectedContent, startLine, totalLines, userLimit } = params + const truncation = truncateHead(selectedContent, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }) + + if (truncation.firstLineExceedsLimit) { + const firstLine = selectedContent.split('\n')[0] ?? '' + const lineSize = formatSize(Buffer.byteLength(firstLine, 'utf-8')) + return `[Line ${startLine} is ${lineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLine}p' ${rawPath} | head -c ${DEFAULT_MAX_BYTES}]` + } + + if (truncation.truncated) { + const endLine = startLine + truncation.outputLines - 1 + const nextOffset = endLine + 1 + const byLines = truncation.truncatedBy === 'lines' + const suffix = byLines + ? `[Showing lines ${startLine}-${endLine} of ${totalLines}. Use offset=${nextOffset} to continue.]` + : `[Showing lines ${startLine}-${endLine} of ${totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]` + return `${truncation.content}\n\n${suffix}` + } + + if (userLimit !== undefined && startLine + userLimit - 1 < totalLines) { + const nextOffset = startLine + userLimit + const remaining = totalLines - (startLine + userLimit - 1) + return `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]` + } + + return truncation.content +} + +export const readTool: FilesystemTool = { + name: 'read', + description: + 'Read the contents of a file. Supports text files and images (jpg, png, gif, webp). For text files, output is truncated to 2000 lines or 50KB.', + inputSchema: readInputSchema, + execute: async ({ path: rawPath, offset, limit }, cwd) => { + const absolutePath = resolvePathInCwd(rawPath, cwd) + assertPathWithinCwd(absolutePath, cwd) + + const stats = await stat(absolutePath) + assertNotDirectory(stats, rawPath) + + const mimeType = detectImageMimeType(absolutePath) + if (mimeType) { + const buffer = await readFile(absolutePath) + return { + content: [ + { type: 'text', text: `Read image file [${mimeType}]` }, + { type: 'image', data: buffer.toString('base64'), mimeType }, + ], + } + } + + const fileContent = await readFile(absolutePath, 'utf-8') + const allLines = fileContent.split('\n') + const totalLines = allLines.length + const startLine = offset ?? 1 + const startIndex = startLine - 1 + + if (startLine < 1) { + throw new Error('Offset must be greater than or equal to 1') + } + + if (startIndex >= totalLines) { + throw new Error( + `Offset ${startLine} is beyond end of file (${totalLines} lines total)`, + ) + } + + const limitedLines = limit + ? allLines.slice(startIndex, startIndex + limit) + : allLines.slice(startIndex) + + const selectedContent = limitedLines.join('\n') + const text = buildReadTextOutput({ + rawPath, + selectedContent, + startLine, + totalLines, + userLimit: limit, + }) + + return { + content: [{ type: 'text', text }], + } + }, +} diff --git a/apps/server/src/tools/filesystem/scan.ts b/apps/server/src/tools/filesystem/scan.ts new file mode 100644 index 00000000..168b76b9 --- /dev/null +++ b/apps/server/src/tools/filesystem/scan.ts @@ -0,0 +1,54 @@ +import { readdir } from 'node:fs/promises' +import path from 'node:path' + +const IGNORED_DIRECTORIES = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', +]) + +export interface WalkEntry { + absolutePath: string + isDirectory: boolean +} + +function assertNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('Operation aborted') + } +} + +export async function walkEntries( + rootPath: string, + signal?: AbortSignal, +): Promise { + const queue: string[] = [rootPath] + const entries: WalkEntry[] = [] + + while (queue.length > 0) { + assertNotAborted(signal) + + const currentDir = queue.shift() + if (!currentDir) continue + + const directoryEntries = await readdir(currentDir, { withFileTypes: true }) + + for (const entry of directoryEntries) { + assertNotAborted(signal) + + const absolutePath = path.join(currentDir, entry.name) + const isDirectory = entry.isDirectory() + + entries.push({ absolutePath, isDirectory }) + + if (isDirectory && !IGNORED_DIRECTORIES.has(entry.name)) { + queue.push(absolutePath) + } + } + } + + return entries +} diff --git a/apps/server/src/tools/filesystem/truncate.ts b/apps/server/src/tools/filesystem/truncate.ts new file mode 100644 index 00000000..aaca0974 --- /dev/null +++ b/apps/server/src/tools/filesystem/truncate.ts @@ -0,0 +1,204 @@ +export const DEFAULT_MAX_LINES = 2_000 +export const DEFAULT_MAX_BYTES = 50 * 1_024 +export const GREP_MAX_LINE_LENGTH = 500 + +export interface TruncationResult { + content: string + truncated: boolean + truncatedBy: 'lines' | 'bytes' | null + totalLines: number + totalBytes: number + outputLines: number + outputBytes: number + lastLinePartial: boolean + firstLineExceedsLimit: boolean + maxLines: number + maxBytes: number +} + +export interface TruncationOptions { + maxLines?: number + maxBytes?: number +} + +export function formatSize(bytes: number): string { + if (bytes < 1_024) return `${bytes}B` + if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(1)}KB` + return `${(bytes / (1_024 * 1_024)).toFixed(1)}MB` +} + +export function truncateHead( + content: string, + options: TruncationOptions = {}, +): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + + const totalBytes = Buffer.byteLength(content, 'utf-8') + const lines = content.split('\n') + const totalLines = lines.length + + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } + } + + const firstLineBytes = Buffer.byteLength(lines[0] ?? '', 'utf-8') + if (firstLineBytes > maxBytes) { + return { + content: '', + truncated: true, + truncatedBy: 'bytes', + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + } + } + + const outputLines: string[] = [] + let outputBytes = 0 + let truncatedBy: 'lines' | 'bytes' = 'lines' + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i] ?? '' + const lineBytes = Buffer.byteLength(line, 'utf-8') + (i > 0 ? 1 : 0) + if (outputBytes + lineBytes > maxBytes) { + truncatedBy = 'bytes' + break + } + outputLines.push(line) + outputBytes += lineBytes + } + + if (outputLines.length >= maxLines && outputBytes <= maxBytes) { + truncatedBy = 'lines' + } + + const outputContent = outputLines.join('\n') + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLines.length, + outputBytes: Buffer.byteLength(outputContent, 'utf-8'), + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } +} + +export function truncateTail( + content: string, + options: TruncationOptions = {}, +): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + + const totalBytes = Buffer.byteLength(content, 'utf-8') + const lines = content.split('\n') + const totalLines = lines.length + + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } + } + + const outputLines: string[] = [] + let outputBytes = 0 + let truncatedBy: 'lines' | 'bytes' = 'lines' + let lastLinePartial = false + + for (let i = lines.length - 1; i >= 0 && outputLines.length < maxLines; i--) { + const line = lines[i] ?? '' + const lineBytes = + Buffer.byteLength(line, 'utf-8') + (outputLines.length > 0 ? 1 : 0) + + if (outputBytes + lineBytes > maxBytes) { + truncatedBy = 'bytes' + if (outputLines.length === 0) { + outputLines.unshift(truncateStringToBytesFromEnd(line, maxBytes)) + outputBytes = Buffer.byteLength(outputLines[0] ?? '', 'utf-8') + lastLinePartial = true + } + break + } + + outputLines.unshift(line) + outputBytes += lineBytes + } + + if (outputLines.length >= maxLines && outputBytes <= maxBytes) { + truncatedBy = 'lines' + } + + const outputContent = outputLines.join('\n') + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLines.length, + outputBytes: Buffer.byteLength(outputContent, 'utf-8'), + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } +} + +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + const buffer = Buffer.from(str, 'utf-8') + if (buffer.length <= maxBytes) return str + + let start = buffer.length - maxBytes + while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) { + start++ + } + return buffer.slice(start).toString('utf-8') +} + +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false } + } + return { + text: `${line.slice(0, maxChars)}... [truncated]`, + wasTruncated: true, + } +} diff --git a/apps/server/src/tools/filesystem/types.ts b/apps/server/src/tools/filesystem/types.ts new file mode 100644 index 00000000..07953685 --- /dev/null +++ b/apps/server/src/tools/filesystem/types.ts @@ -0,0 +1,17 @@ +import type { z } from 'zod' + +export type FilesystemContentItem = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + +export interface FilesystemToolResult { + content: FilesystemContentItem[] + isError?: boolean +} + +export interface FilesystemTool { + name: string + description: string + inputSchema: z.ZodType + execute: (input: TInput, cwd: string) => Promise +} diff --git a/apps/server/src/tools/filesystem/write.ts b/apps/server/src/tools/filesystem/write.ts new file mode 100644 index 00000000..dd6cd3a2 --- /dev/null +++ b/apps/server/src/tools/filesystem/write.ts @@ -0,0 +1,36 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { z } from 'zod' +import { assertPathWithinCwd, resolvePathInCwd } from './path-utils' +import type { FilesystemTool } from './types' + +const writeInputSchema = z.object({ + path: z.string().describe('Path to the file to write (relative or absolute)'), + content: z.string().describe('Content to write to the file'), +}) + +type WriteInput = z.infer + +export const writeTool: FilesystemTool = { + name: 'write', + description: + 'Write content to a file. Creates the file if it does not exist and overwrites it if it does.', + inputSchema: writeInputSchema, + execute: async ({ path: rawPath, content }, cwd) => { + const absolutePath = resolvePathInCwd(rawPath, cwd) + assertPathWithinCwd(absolutePath, cwd) + + const directory = path.dirname(absolutePath) + await mkdir(directory, { recursive: true }) + await writeFile(absolutePath, content, 'utf-8') + + return { + content: [ + { + type: 'text', + text: `Successfully wrote ${Buffer.byteLength(content, 'utf-8')} bytes to ${rawPath}`, + }, + ], + } + }, +} diff --git a/apps/server/tests/agent/filesystem-tools/bash.test.ts b/apps/server/tests/agent/filesystem-tools/bash.test.ts new file mode 100644 index 00000000..acba3147 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/bash.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { bashTool } from '../../../src/tools/filesystem/bash' + +describe('filesystem bash tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-bash-tool-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('executes command and returns output', async () => { + const result = await bashTool.execute( + { command: "printf 'hello'" }, + tempDir, + ) + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + assert.strictEqual(text, 'hello') + }) + + it('throws when command exits with non-zero status', async () => { + await assert.rejects( + () => bashTool.execute({ command: 'exit 7' }, tempDir), + /Command exited with code 7/, + ) + }) + + it('times out long-running command', async () => { + if (process.platform === 'win32') { + return + } + + await assert.rejects( + () => + bashTool.execute( + { + command: 'sleep 2', + timeout: 0.1, + }, + tempDir, + ), + /timed out/, + ) + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/edit.test.ts b/apps/server/tests/agent/filesystem-tools/edit.test.ts new file mode 100644 index 00000000..e0aba046 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/edit.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { editTool } from '../../../src/tools/filesystem/edit' + +describe('filesystem edit tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-edit-tool-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('replaces unique text in a file', async () => { + const filePath = path.join(tempDir, 'sample.txt') + fs.writeFileSync(filePath, 'hello old world\n') + + const result = await editTool.execute( + { + path: 'sample.txt', + oldText: 'old', + newText: 'new', + }, + tempDir, + ) + + const updated = fs.readFileSync(filePath, 'utf-8') + assert.strictEqual(updated, 'hello new world\n') + assert.match(result.content[0]?.text || '', /Successfully replaced text/) + }) + + it('fails when oldText is not unique', async () => { + const filePath = path.join(tempDir, 'sample.txt') + fs.writeFileSync(filePath, 'old\nold\n') + + await assert.rejects( + () => + editTool.execute( + { + path: 'sample.txt', + oldText: 'old', + newText: 'new', + }, + tempDir, + ), + /must be unique/, + ) + }) + + it('rejects paths outside session directory', async () => { + const outsidePath = path.join(tempDir, '..', 'outside.txt') + fs.writeFileSync(outsidePath, 'value') + + await assert.rejects( + () => + editTool.execute( + { + path: '../outside.txt', + oldText: 'value', + newText: 'changed', + }, + tempDir, + ), + /outside the session directory/, + ) + }) + + it('rejects symlink targets outside session directory', async () => { + if (process.platform === 'win32') { + return + } + + const outsideDir = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-edit-tool-outside-'), + ) + + try { + const outsideFile = path.join(outsideDir, 'secret.txt') + fs.writeFileSync(outsideFile, 'value') + fs.symlinkSync(outsideFile, path.join(tempDir, 'escape.txt')) + + await assert.rejects( + () => + editTool.execute( + { + path: 'escape.txt', + oldText: 'value', + newText: 'changed', + }, + tempDir, + ), + /outside the session directory/, + ) + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/find.test.ts b/apps/server/tests/agent/filesystem-tools/find.test.ts new file mode 100644 index 00000000..6de4f960 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/find.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { findTool } from '../../../src/tools/filesystem/find' + +describe('filesystem find tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-find-tool-')) + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }) + fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }) + fs.writeFileSync(path.join(tempDir, 'src/index.ts'), 'export {}\n') + fs.writeFileSync(path.join(tempDir, 'src/utils/helper.ts'), 'export {}\n') + fs.writeFileSync(path.join(tempDir, 'README.md'), '# readme\n') + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('finds files by glob pattern', async () => { + const result = await findTool.execute( + { + path: '.', + pattern: '**/*.ts', + }, + tempDir, + ) + + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + assert.match(text, /src\/index\.ts/) + assert.match(text, /src\/utils\/helper\.ts/) + assert.doesNotMatch(text, /README\.md/) + }) + + it('applies limit notice', async () => { + const result = await findTool.execute( + { + path: '.', + pattern: '**/*', + limit: 1, + }, + tempDir, + ) + + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + assert.match(text, /1 results limit reached/) + }) + + it('returns no-files message when nothing matches', async () => { + const result = await findTool.execute( + { + path: '.', + pattern: '**/*.go', + }, + tempDir, + ) + + assert.strictEqual(result.content[0]?.type, 'text') + assert.strictEqual( + result.content[0]?.text, + 'No files found matching pattern', + ) + }) + + it('rejects symlink targets outside session directory', async () => { + if (process.platform === 'win32') { + return + } + + const outsideDir = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-find-tool-outside-'), + ) + + try { + const outsideFile = path.join(outsideDir, 'secret.txt') + fs.writeFileSync(outsideFile, 'secret') + fs.symlinkSync(outsideFile, path.join(tempDir, 'escape.txt')) + + await assert.rejects( + () => + findTool.execute( + { + path: '.', + pattern: '**/*', + }, + tempDir, + ), + /outside the session directory/, + ) + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/grep.test.ts b/apps/server/tests/agent/filesystem-tools/grep.test.ts new file mode 100644 index 00000000..5012ab79 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/grep.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { grepTool } from '../../../src/tools/filesystem/grep' + +describe('filesystem grep tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-grep-tool-')) + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'const x = 1 // TODO\n') + fs.writeFileSync(path.join(tempDir, 'b.ts'), 'TODO: second\nline2\n') + fs.writeFileSync(path.join(tempDir, 'note.txt'), 'TODO in txt\n') + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('finds matches and respects glob filtering', async () => { + const result = await grepTool.execute( + { + path: '.', + pattern: 'TODO', + literal: true, + glob: '*.ts', + }, + tempDir, + ) + + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + assert.match(text, /a\.ts:1:/) + assert.match(text, /b\.ts:1:/) + assert.doesNotMatch(text, /note\.txt/) + }) + + it('applies result limit notice', async () => { + const result = await grepTool.execute( + { + path: '.', + pattern: 'TODO', + literal: true, + limit: 1, + }, + tempDir, + ) + + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + assert.match(text, /1 results limit reached/) + }) + + it('returns no-match message when nothing matches', async () => { + const result = await grepTool.execute( + { + path: '.', + pattern: 'SHOULD_NOT_MATCH', + }, + tempDir, + ) + + assert.strictEqual(result.content[0]?.type, 'text') + assert.strictEqual(result.content[0]?.text, 'No matches found for pattern') + }) + + it('rejects symlink targets outside session directory', async () => { + if (process.platform === 'win32') { + return + } + + const outsideDir = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-grep-tool-outside-'), + ) + + try { + const outsideFile = path.join(outsideDir, 'secret.txt') + fs.writeFileSync(outsideFile, 'TODO from outside\n') + fs.symlinkSync(outsideFile, path.join(tempDir, 'escape.txt')) + + await assert.rejects( + () => + grepTool.execute( + { + path: '.', + pattern: 'TODO', + literal: true, + }, + tempDir, + ), + /outside the session directory/, + ) + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/ls.test.ts b/apps/server/tests/agent/filesystem-tools/ls.test.ts new file mode 100644 index 00000000..7280a812 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/ls.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { lsTool } from '../../../src/tools/filesystem/ls' + +describe('filesystem ls tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-ls-tool-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('lists directory entries', async () => { + fs.mkdirSync(path.join(tempDir, 'alpha')) + fs.writeFileSync(path.join(tempDir, 'beta.txt'), 'x') + + const result = await lsTool.execute({ path: '.' }, tempDir) + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + + assert.match(text, /alpha\//) + assert.match(text, /beta\.txt/) + }) + + it('returns empty-directory message', async () => { + const result = await lsTool.execute({ path: '.' }, tempDir) + assert.strictEqual(result.content[0]?.type, 'text') + assert.strictEqual(result.content[0]?.text, '(empty directory)') + }) + + it('applies entry limit notice', async () => { + fs.writeFileSync(path.join(tempDir, 'a.txt'), 'a') + fs.writeFileSync(path.join(tempDir, 'b.txt'), 'b') + + const result = await lsTool.execute({ path: '.', limit: 1 }, tempDir) + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + + assert.match(text, /1 entries limit reached/) + }) + + it('rejects symlink targets outside session directory', async () => { + if (process.platform === 'win32') { + return + } + + const outsideDir = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-ls-tool-outside-'), + ) + + try { + const outsideFile = path.join(outsideDir, 'secret.txt') + fs.writeFileSync(outsideFile, 'secret') + fs.symlinkSync(outsideFile, path.join(tempDir, 'escape.txt')) + + await assert.rejects( + () => lsTool.execute({ path: '.' }, tempDir), + /outside the session directory/, + ) + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/path-utils.test.ts b/apps/server/tests/agent/filesystem-tools/path-utils.test.ts new file mode 100644 index 00000000..099554a5 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/path-utils.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + assertPathWithinCwd, + matchesGlob, + resolvePathInCwd, + safeRelativePath, +} from '../../../src/tools/filesystem/path-utils' + +describe('filesystem path utils', () => { + it('resolves relative paths inside cwd', () => { + const cwd = '/tmp/workspace' + const resolved = resolvePathInCwd('src/index.ts', cwd) + + assert.strictEqual(resolved, path.resolve('/tmp/workspace/src/index.ts')) + assert.doesNotThrow(() => assertPathWithinCwd(resolved, cwd)) + }) + + it('rejects traversal outside cwd', () => { + const cwd = '/tmp/workspace' + const outside = resolvePathInCwd('../etc/passwd', cwd) + + assert.throws(() => assertPathWithinCwd(outside, cwd), { + message: /outside the session directory/, + }) + }) + + it('matches basic glob patterns', () => { + assert.strictEqual(matchesGlob('src/app.ts', 'src/*.ts'), true) + assert.strictEqual(matchesGlob('src/utils/app.ts', 'src/*.ts'), false) + assert.strictEqual(matchesGlob('src/utils/app.ts', 'src/**/*.ts'), true) + }) + + it('builds safe relative posix paths', () => { + const relative = safeRelativePath( + '/tmp/workspace/src/a.ts', + '/tmp/workspace', + ) + assert.strictEqual(relative, 'src/a.ts') + }) + + it('rejects symlink targets outside cwd', () => { + if (process.platform === 'win32') { + return + } + + const cwd = fs.mkdtempSync(path.join(tmpdir(), 'browseros-path-cwd-')) + const outside = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-path-outside-'), + ) + + try { + const outsideFile = path.join(outside, 'secret.txt') + fs.writeFileSync(outsideFile, 'secret') + const symlink = path.join(cwd, 'escape.txt') + fs.symlinkSync(outsideFile, symlink) + + assert.throws(() => assertPathWithinCwd(symlink, cwd), { + message: /outside the session directory/, + }) + } finally { + fs.rmSync(cwd, { recursive: true, force: true }) + fs.rmSync(outside, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/read.test.ts b/apps/server/tests/agent/filesystem-tools/read.test.ts new file mode 100644 index 00000000..aab14937 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/read.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { readTool } from '../../../src/tools/filesystem/read' + +describe('filesystem read tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-read-tool-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('reads text file with offset and limit', async () => { + const filePath = path.join(tempDir, 'sample.txt') + fs.writeFileSync(filePath, 'line1\nline2\nline3\nline4\n') + + const result = await readTool.execute( + { + path: 'sample.txt', + offset: 2, + limit: 2, + }, + tempDir, + ) + + const text = + result.content[0]?.type === 'text' ? result.content[0].text : '' + assert.match(text, /line2\nline3/) + assert.match(text, /more lines in file|Use offset=/) + }) + + it('reads image file as media output', async () => { + const imagePath = path.join(tempDir, 'image.png') + fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + + const result = await readTool.execute({ path: 'image.png' }, tempDir) + assert.strictEqual(result.content[0]?.type, 'text') + assert.strictEqual(result.content[1]?.type, 'image') + if (result.content[1]?.type === 'image') { + assert.strictEqual(result.content[1].mimeType, 'image/png') + assert.ok(result.content[1].data.length > 0) + } + }) + + it('rejects directory reads', async () => { + const dirPath = path.join(tempDir, 'folder') + fs.mkdirSync(dirPath) + + await assert.rejects( + () => readTool.execute({ path: 'folder' }, tempDir), + /Path is a directory/, + ) + }) + + it('rejects paths outside session directory', async () => { + const outside = path.join(tempDir, '..', 'outside-read.txt') + fs.writeFileSync(outside, 'hello') + + await assert.rejects( + () => readTool.execute({ path: '../outside-read.txt' }, tempDir), + /outside the session directory/, + ) + }) + + it('rejects symlink targets outside session directory', async () => { + if (process.platform === 'win32') { + return + } + + const outsideDir = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-read-tool-outside-'), + ) + + try { + const outsideFile = path.join(outsideDir, 'secret.txt') + fs.writeFileSync(outsideFile, 'secret') + fs.symlinkSync(outsideFile, path.join(tempDir, 'escape.txt')) + + await assert.rejects( + () => readTool.execute({ path: 'escape.txt' }, tempDir), + /outside the session directory/, + ) + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/truncate.test.ts b/apps/server/tests/agent/filesystem-tools/truncate.test.ts new file mode 100644 index 00000000..47dbaf1f --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/truncate.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from 'bun:test' +import assert from 'node:assert' +import { + truncateHead, + truncateLine, + truncateTail, +} from '../../../src/tools/filesystem/truncate' + +describe('filesystem truncate helpers', () => { + it('truncateHead truncates by line count', () => { + const input = 'a\nb\nc\nd' + const result = truncateHead(input, { maxLines: 2, maxBytes: 1024 }) + + assert.strictEqual(result.truncated, true) + assert.strictEqual(result.truncatedBy, 'lines') + assert.strictEqual(result.content, 'a\nb') + }) + + it('truncateHead truncates by byte count', () => { + const input = '12345\n67890\nabcde' + const result = truncateHead(input, { maxLines: 10, maxBytes: 8 }) + + assert.strictEqual(result.truncated, true) + assert.strictEqual(result.truncatedBy, 'bytes') + assert.strictEqual(result.content, '12345') + }) + + it('truncateTail keeps latest lines', () => { + const input = 'line1\nline2\nline3\nline4' + const result = truncateTail(input, { maxLines: 2, maxBytes: 1024 }) + + assert.strictEqual(result.truncated, true) + assert.strictEqual(result.content, 'line3\nline4') + }) + + it('truncateLine shortens very long lines', () => { + const result = truncateLine('abcdefghijklmnopqrstuvwxyz', 10) + + assert.strictEqual(result.wasTruncated, true) + assert.match(result.text, /truncated/) + }) +}) diff --git a/apps/server/tests/agent/filesystem-tools/write.test.ts b/apps/server/tests/agent/filesystem-tools/write.test.ts new file mode 100644 index 00000000..9439e1b6 --- /dev/null +++ b/apps/server/tests/agent/filesystem-tools/write.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { writeTool } from '../../../src/tools/filesystem/write' + +describe('filesystem write tool', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'browseros-write-tool-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('writes content and creates parent directories', async () => { + const result = await writeTool.execute( + { + path: 'nested/deep/file.txt', + content: 'hello world', + }, + tempDir, + ) + + const content = fs.readFileSync( + path.join(tempDir, 'nested/deep/file.txt'), + 'utf-8', + ) + + assert.strictEqual(content, 'hello world') + assert.match( + result.content[0]?.type === 'text' ? result.content[0].text : '', + /Successfully wrote/, + ) + }) + + it('rejects paths outside session directory', async () => { + await assert.rejects( + () => + writeTool.execute( + { + path: '../outside-write.txt', + content: 'bad', + }, + tempDir, + ), + /outside the session directory/, + ) + }) + + it('rejects symlink targets outside session directory', async () => { + if (process.platform === 'win32') { + return + } + + const outsideDir = fs.mkdtempSync( + path.join(tmpdir(), 'browseros-write-tool-outside-'), + ) + + try { + const outsideFile = path.join(outsideDir, 'secret.txt') + fs.writeFileSync(outsideFile, 'secret') + fs.symlinkSync(outsideFile, path.join(tempDir, 'escape.txt')) + + await assert.rejects( + () => + writeTool.execute( + { + path: 'escape.txt', + content: 'overwrite', + }, + tempDir, + ), + /outside the session directory/, + ) + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }) + } + }) +})