181 lines
5.6 KiB
JavaScript
181 lines
5.6 KiB
JavaScript
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
// Utility to normalize and validate paths within chroot
|
|
const normalizePath = (inputPath, chrootDir) => {
|
|
// Resolve chroot directory
|
|
const chrootResolved = path.resolve(chrootDir);
|
|
|
|
// Handle root path ("/") as the chroot directory itself
|
|
if (inputPath === "/" || inputPath === "") {
|
|
return chrootResolved;
|
|
}
|
|
|
|
// Resolve input path relative to chroot directory
|
|
const resolved = path.join(chrootResolved, inputPath);
|
|
const normalized = path.normalize(resolved);
|
|
|
|
// Ensure the path is within chrootDir
|
|
if (!normalized.startsWith(chrootResolved)) {
|
|
throw new Error(`Path escapes chroot boundary: ${inputPath}`);
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
// Main recursive directory listing function
|
|
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
|
|
const results = [];
|
|
const seen = new Set(); // Track paths to prevent symlink cycles
|
|
|
|
async function walk(currentDir, currentDepth) {
|
|
if (currentDepth > maxDepth) return;
|
|
|
|
let dirents;
|
|
try {
|
|
dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
|
} catch (err) {
|
|
throw new Error(`Failed to read directory: ${currentDir} (${err?.message || String(err)})`);
|
|
}
|
|
|
|
for (const dirent of dirents) {
|
|
// Skip hidden files/directories unless includeHidden is true
|
|
if (!includeHidden && dirent.name.startsWith(".")) continue;
|
|
|
|
const fullPath = path.join(currentDir, dirent.name);
|
|
// Ensure fullPath is within chroot
|
|
if (!fullPath.startsWith(path.resolve(chrootDir))) {
|
|
continue; // Skip paths outside chroot
|
|
}
|
|
|
|
const relPath = path.relative(startDir, fullPath) || ".";
|
|
|
|
// Prevent symlink cycles
|
|
if (dirent.isSymbolicLink()) {
|
|
const realPath = await fs.realpath(fullPath).catch(() => null);
|
|
// Ensure realPath is within chroot
|
|
if (realPath && !realPath.startsWith(path.resolve(chrootDir))) {
|
|
continue; // Skip symlinks pointing outside chroot
|
|
}
|
|
if (realPath && seen.has(realPath)) continue;
|
|
if (realPath) seen.add(realPath);
|
|
results.push([relPath, "l", null]);
|
|
continue; // Don't recurse into symlinks
|
|
}
|
|
|
|
if (dirent.isDirectory()) {
|
|
seen.add(fullPath);
|
|
results.push([relPath, "d", null]);
|
|
if (currentDepth < maxDepth) {
|
|
await walk(fullPath, currentDepth + 1);
|
|
}
|
|
} else if (dirent.isFile()) {
|
|
let size = null;
|
|
try {
|
|
const stat = await fs.stat(fullPath);
|
|
size = stat.size;
|
|
} catch {
|
|
// Size remains null if stat fails
|
|
}
|
|
results.push([relPath, "f", size]);
|
|
} else {
|
|
results.push([relPath, "o", null]);
|
|
}
|
|
}
|
|
}
|
|
|
|
await walk(startDir, 0);
|
|
return results;
|
|
}
|
|
|
|
export default {
|
|
type: "function",
|
|
name: "list_files",
|
|
description: "List files and directories recursively within a chroot directory with customizable options",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.",
|
|
},
|
|
depth: {
|
|
type: "integer",
|
|
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
|
|
minimum: -1,
|
|
},
|
|
includeHidden: {
|
|
type: "boolean",
|
|
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
|
|
default: false,
|
|
}
|
|
},
|
|
required: ["path", "depth", "includeHidden"],
|
|
additionalProperties: false,
|
|
},
|
|
strict: true,
|
|
};
|
|
|
|
export async function run(args) {
|
|
const inputPath = args?.path || "";
|
|
const depth = Number.isInteger(args?.depth) ? args.depth : 1;
|
|
const includeHidden = args?.includeHidden ?? false;
|
|
const chrootPath = '/workspaces/aiTools';
|
|
|
|
if (!chrootPath) {
|
|
return { err: "Chroot path is required" };
|
|
}
|
|
if (depth < -1) {
|
|
return { err: `Depth must be >= -1, received ${args?.depth}` };
|
|
}
|
|
|
|
let chrootResolved;
|
|
try {
|
|
chrootResolved = path.resolve(chrootPath);
|
|
// Auto-create the chroot base directory if it does not exist
|
|
await fs.mkdir(chrootResolved, { recursive: true });
|
|
} catch (err) {
|
|
return { err: `Failed to prepare chroot path: ${chrootPath} (${err?.message || String(err)})` };
|
|
}
|
|
|
|
let resolvedBase;
|
|
try {
|
|
resolvedBase = normalizePath(inputPath, chrootResolved);
|
|
} catch (err) {
|
|
return { err: err.message };
|
|
}
|
|
|
|
let stat;
|
|
try {
|
|
stat = await fs.lstat(resolvedBase);
|
|
} catch (err) {
|
|
return { err: `Path does not exist: ${resolvedBase} (${err?.message || String(err)})` };
|
|
}
|
|
|
|
const cwd = path.relative(chrootResolved, stat.isFile() ? path.dirname(resolvedBase) : resolvedBase) || ".";
|
|
|
|
// Handle single file case
|
|
if (stat.isFile()) {
|
|
const fileName = path.basename(resolvedBase);
|
|
if (!includeHidden && fileName.startsWith(".")) {
|
|
return { cwd, files: [] };
|
|
}
|
|
return { cwd, files: JSON.stringify([[fileName, 'f', stat.size]]) };
|
|
}
|
|
|
|
// Handle non-directory case
|
|
if (!stat.isDirectory()) {
|
|
return { err: `Not a file or directory: ${resolvedBase}` };
|
|
}
|
|
|
|
// Handle directory case
|
|
try {
|
|
const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden);
|
|
return {
|
|
cwd,
|
|
files: JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0]))), // Sort for consistent output
|
|
};
|
|
} catch (err) {
|
|
return { err: `Failed to list files: ${err?.message || String(err)}` };
|
|
}
|
|
} |