208 lines
7.2 KiB
TypeScript
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,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
};
|