Files
lobe-sandbox-backend/orchestrator/src/tools.ts

239 lines
8.4 KiB
TypeScript

import { incus, containerName } from './incus.ts';
import { state } from './state.ts';
export interface ToolResult {
success: boolean;
result?: unknown;
error?: { message: string; name?: string };
}
const ok = (result: unknown): ToolResult => ({ success: true, result });
const fail = (message: string, name?: string): ToolResult => ({
success: false,
error: { message, name },
});
// 确保容器跑着 — 调任何工具前必须调
const ensureRunning = async (userId: string): Promise<string> => {
const name = containerName(userId);
const s = await incus.state(name);
if (s === 'MISSING') throw new Error(`Sandbox not provisioned for user ${userId}`);
if (s === 'STOPPED') await incus.start(name);
state.touch(userId);
return name;
};
// 临时路径生成(容器内)
const tmpPath = (ext: string) => `/tmp/sb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
// ==================== executeCode ====================
const LANG_RUNNER: Record<string, { ext: string; cmd: (p: string) => string[] }> = {
python: { ext: '.py', cmd: (p) => ['python3', p] },
javascript: { ext: '.js', cmd: (p) => ['node', p] },
typescript: { ext: '.ts', cmd: (p) => ['bun', 'run', p] },
};
export const executeCode = async (
params: { code: string; language: string; description?: string },
userId: string,
): Promise<ToolResult> => {
const runner = LANG_RUNNER[params.language];
if (!runner) return fail(`unsupported language: ${params.language}`);
const name = await ensureRunning(userId);
const path = tmpPath(runner.ext);
try {
await incus.push(name, path, params.code, { mode: '0644' });
await incus.exec(name, ['chown', 'sandbox:sandbox', path]);
const r = await incus.exec(name, runner.cmd(path), {
user: 1000,
cwd: '/workspace',
timeoutMs: 5 * 60 * 1000,
});
return ok({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode });
} finally {
await incus.exec(name, ['rm', '-f', path]).catch(() => {});
}
};
// ==================== listLocalFiles ====================
export const listLocalFiles = async (
params: { directoryPath: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(
name,
['ls', '-la', '--time-style=long-iso', params.directoryPath],
{ user: 1000 },
);
if (r.exitCode !== 0) return fail(r.stderr || 'ls failed');
return ok({ output: r.stdout });
};
// ==================== readLocalFile ====================
export const readLocalFile = async (
params: { filePath: string; startLine?: number; endLine?: number },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const cmd =
params.startLine || params.endLine
? ['sed', '-n', `${params.startLine ?? 1},${params.endLine ?? '$'}p`, params.filePath]
: ['cat', params.filePath];
const r = await incus.exec(name, cmd, { user: 1000 });
if (r.exitCode !== 0) return fail(r.stderr || 'read failed');
return ok({ content: r.stdout });
};
// ==================== writeLocalFile ====================
export const writeLocalFile = async (
params: { filePath: string; content: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
await incus.push(name, params.filePath, params.content, { mode: '0644' });
await incus.exec(name, ['chown', 'sandbox:sandbox', params.filePath]);
return ok({ filePath: params.filePath, bytes: params.content.length });
};
// ==================== editLocalFile ====================
export const editLocalFile = async (
params: { filePath: string; oldContent: string; newContent: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(name, ['cat', params.filePath], { user: 1000 });
if (r.exitCode !== 0) return fail(`read ${params.filePath}: ${r.stderr}`);
const idx = r.stdout.indexOf(params.oldContent);
if (idx === -1) return fail('oldContent not found');
const nextIdx = r.stdout.indexOf(params.oldContent, idx + params.oldContent.length);
if (nextIdx !== -1) return fail('oldContent is ambiguous (multiple matches)');
const updated =
r.stdout.slice(0, idx) + params.newContent + r.stdout.slice(idx + params.oldContent.length);
await incus.push(name, params.filePath, updated, { mode: '0644' });
await incus.exec(name, ['chown', 'sandbox:sandbox', params.filePath]);
return ok({ filePath: params.filePath, replaced: 1 });
};
// ==================== runCommand (sync, with timeout) ====================
export const runCommand = async (
params: { command: string; cwd?: string; timeoutMs?: number },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(name, ['bash', '-lc', params.command], {
user: 1000,
cwd: params.cwd ?? '/workspace',
timeoutMs: params.timeoutMs ?? 2 * 60 * 1000,
});
return ok({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode });
};
// getCommandOutput + killCommand - MVP synchronous runCommand 已返回完整输出,长驻命令留给 v2
export const getCommandOutput = async (): Promise<ToolResult> =>
fail('long-running commands not yet supported; runCommand is synchronous');
export const killCommand = async (): Promise<ToolResult> =>
fail('long-running commands not yet supported');
// ==================== searchLocalFiles (find by name) ====================
export const searchLocalFiles = async (
params: { directoryPath: string; pattern: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(
name,
['fd', '--no-ignore-vcs', params.pattern, params.directoryPath],
{ user: 1000, timeoutMs: 30_000 },
);
return ok({ matches: r.stdout.split('\n').filter(Boolean) });
};
// ==================== grepContent ====================
export const grepContent = async (
params: { directoryPath: string; pattern: string; caseSensitive?: boolean },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const args = ['rg', '-n', '--no-ignore-vcs'];
if (!params.caseSensitive) args.push('-i');
args.push('--', params.pattern, params.directoryPath);
const r = await incus.exec(name, args, { user: 1000, timeoutMs: 30_000 });
return ok({ matches: r.stdout });
};
// ==================== globLocalFiles ====================
export const globLocalFiles = async (
params: { pattern: string; cwd?: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(
name,
['bash', '-lc', `shopt -s globstar nullglob dotglob; printf '%s\\n' ${JSON.stringify(params.pattern)}`],
{ user: 1000, cwd: params.cwd ?? '/workspace' },
);
return ok({ matches: r.stdout.split('\n').filter(Boolean) });
};
// ==================== moveLocalFiles ====================
export const moveLocalFiles = async (
params: { sources: string[]; destination: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(name, ['mv', '--', ...params.sources, params.destination], { user: 1000 });
if (r.exitCode !== 0) return fail(r.stderr);
return ok({ moved: params.sources.length });
};
// ==================== renameLocalFile ====================
export const renameLocalFile = async (
params: { oldPath: string; newPath: string },
userId: string,
): Promise<ToolResult> => {
const name = await ensureRunning(userId);
const r = await incus.exec(name, ['mv', '--', params.oldPath, params.newPath], { user: 1000 });
if (r.exitCode !== 0) return fail(r.stderr);
return ok({ oldPath: params.oldPath, newPath: params.newPath });
};
// ==================== exportFile ====================
// exportFile 在 routes 里直接处理(要流式 pull → S3 upload)
export const exportFileStub = async (): Promise<ToolResult> =>
fail('exportFile must be routed to /api/v1/export-file, not /api/v1/tools/exportFile');
// ==================== Dispatcher ====================
type ToolHandler = (params: any, userId: string) => Promise<ToolResult>;
export const handlers: Record<string, ToolHandler> = {
executeCode,
listLocalFiles,
readLocalFile,
writeLocalFile,
editLocalFile,
runCommand,
getCommandOutput,
killCommand,
searchLocalFiles,
grepContent,
globLocalFiles,
moveLocalFiles,
renameLocalFile,
exportFile: exportFileStub,
};