Files

208 lines
7.2 KiB
TypeScript

import * as plugins from './plugins.js';
export interface IToolPermissionRequest {
type: string;
title: string;
metadata?: Record<string, unknown>;
}
export interface IToolRunOptions {
cwd?: string;
timeoutMs?: number;
abortSignal?: AbortSignal;
}
export interface IToolShellResult {
exitCode: number;
stdout: string;
stderr?: string;
signal?: string;
}
export interface IToolShellContext {
run(command: string, options?: IToolRunOptions): Promise<IToolShellResult | string>;
}
export interface IToolFilesystemReadOptions {
startLine?: number;
endLine?: number;
}
export interface IToolFilesystemListOptions {
recursive?: boolean;
}
export interface IToolFilesystemContext {
readFile(filePath: string, options?: IToolFilesystemReadOptions): Promise<string>;
writeFile(filePath: string, content: string): Promise<string | void>;
listDirectory(directoryPath: string, options?: IToolFilesystemListOptions): Promise<string[] | string>;
deletePath?(targetPath: string): Promise<string | void>;
}
export type TBrowserToolAction = 'navigate' | 'snapshot' | 'screenshot' | 'click' | 'fill' | 'press' | 'evaluate' | 'close';
export interface IBrowserToolInput {
action?: TBrowserToolAction | string;
url?: string;
selector?: string;
text?: string;
script?: string;
timeoutMs?: number;
}
export interface IToolBrowserContext {
execute(input: IBrowserToolInput, options?: { timeoutMs?: number; abortSignal?: AbortSignal }): Promise<unknown>;
}
export interface IToolExecutionContext {
cwd?: string;
rootDir?: string;
abortSignal?: AbortSignal;
shell?: IToolShellContext;
fs?: IToolFilesystemContext;
browser?: IToolBrowserContext;
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
}
export interface ILocalToolExecutionContextOptions {
cwd?: string;
rootDir?: string;
abortSignal?: AbortSignal;
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
}
export const createLocalToolExecutionContext = (options: ILocalToolExecutionContextOptions = {}): IToolExecutionContext => {
const cwd = options.cwd ?? process.cwd();
const rootDir = options.rootDir;
return {
cwd,
rootDir,
abortSignal: options.abortSignal,
requestPermission: options.requestPermission,
shell: {
run: (command, runOptions) => runLocalShellCommand(command, {
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
timeoutMs: runOptions?.timeoutMs,
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
}),
},
fs: {
readFile: async (filePath, readOptions) => {
const resolved = resolveLocalPath(filePath, rootDir, cwd);
const content = await plugins.fs.promises.readFile(resolved, 'utf8');
if (readOptions?.startLine !== undefined || readOptions?.endLine !== undefined) {
const lines = content.split('\n');
const start = Math.max((readOptions.startLine ?? 1) - 1, 0);
const end = Math.max(readOptions.endLine ?? lines.length, start);
return lines.slice(start, end).join('\n');
}
return content;
},
writeFile: async (filePath, content) => {
const resolved = resolveLocalPath(filePath, rootDir, cwd);
await plugins.fs.promises.mkdir(plugins.path.dirname(resolved), { recursive: true });
await plugins.fs.promises.writeFile(resolved, content, 'utf8');
return `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`;
},
listDirectory: async (directoryPath, listOptions) => {
const resolved = resolveLocalPath(directoryPath, rootDir, cwd);
return listLocalDirectory(resolved, !!listOptions?.recursive);
},
deletePath: async (targetPath) => {
const resolved = resolveLocalPath(targetPath, rootDir, cwd);
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
return `Deleted ${targetPath}`;
},
},
};
};
export const formatShellResult = (result: IToolShellResult | string): string => {
if (typeof result === 'string') return result;
if (result.exitCode === 0) return result.stdout;
return [
`Exit code: ${result.exitCode}`,
result.signal ? `Signal: ${result.signal}` : '',
`stdout:\n${result.stdout}`,
`stderr:\n${result.stderr ?? ''}`,
].filter(Boolean).join('\n');
};
export const formatToolOutput = (output: unknown): string => {
if (typeof output === 'string') return output;
try {
return JSON.stringify(output, undefined, 2);
} catch {
return String(output);
}
};
const resolveLocalPath = (targetPath: string, rootDir?: string, baseDir?: string): string => {
const base = rootDir ?? baseDir ?? process.cwd();
const resolved = plugins.path.isAbsolute(targetPath)
? plugins.path.resolve(targetPath)
: plugins.path.resolve(base, targetPath);
if (rootDir) {
const resolvedRoot = plugins.path.resolve(rootDir);
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + plugins.path.sep)) {
throw new Error(`Access denied: "${targetPath}" is outside allowed root "${rootDir}"`);
}
}
return resolved;
};
const listLocalDirectory = async (directoryPath: string, recursive: boolean): Promise<string[]> => {
const entries = await plugins.fs.promises.readdir(directoryPath, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const relativePath = entry.name + (entry.isDirectory() ? '/' : '');
result.push(relativePath);
if (recursive && entry.isDirectory()) {
const childEntries = await listLocalDirectory(plugins.path.join(directoryPath, entry.name), true);
result.push(...childEntries.map((childEntry) => `${entry.name}/${childEntry}`));
}
}
return result;
};
const runLocalShellCommand = async (command: string, options: IToolRunOptions): Promise<IToolShellResult> => {
return new Promise<IToolShellResult>((resolve) => {
const child = plugins.childProcess.spawn('bash', ['-lc', command], {
cwd: options.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timeout = options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeoutMs)
: undefined;
const abort = () => child.kill('SIGTERM');
options.abortSignal?.addEventListener('abort', abort, { once: true });
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
options.abortSignal?.removeEventListener('abort', abort);
resolve({ exitCode: 1, stdout, stderr: `${stderr}${error.message}` });
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
options.abortSignal?.removeEventListener('abort', abort);
resolve({
exitCode: code ?? (timedOut ? 124 : 1),
stdout,
stderr: timedOut ? `${stderr}\nCommand timed out after ${options.timeoutMs}ms.`.trim() : stderr,
signal: signal ?? undefined,
});
});
});
};