UNPKG

@mastra/core

Version:

Mastra is a framework for building AI-powered applications and agents with a modern TypeScript stack.

1,534 lines (1,528 loc) 364 kB
import { createTool } from './chunk-6FFXBNBE.js'; import { MastraBase } from './chunk-WENZPAHS.js'; import { RegisteredLogger } from './chunk-DBBWTK24.js'; import { RequestContext } from './chunk-BBVL3KAA.js'; import * as nodePath from 'path'; import nodePath__default, { parse, join, dirname } from 'path'; import pMap, { pMapSkip } from 'p-map'; import posixPath from 'path/posix'; import { realpathSync, constants, existsSync } from 'fs'; import * as fs2 from 'fs/promises'; import fs2__default from 'fs/promises'; import * as os3 from 'os'; import os3__default from 'os'; import picomatch from 'picomatch'; import { createRequire } from 'module'; import { fileURLToPath, pathToFileURL } from 'url'; import { execFileSync } from 'child_process'; import * as crypto from 'crypto'; import { createHash } from 'crypto'; import { StringDecoder } from 'string_decoder'; import { Readable, Writable } from 'stream'; import matter from 'gray-matter'; import { z } from 'zod/v4'; import { estimateTokenCount, sliceByTokens } from 'tokenx'; import ignore from 'ignore'; // src/workspace/errors.ts var WorkspaceError = class extends Error { constructor(message, code, workspaceId) { super(message); this.code = code; this.workspaceId = workspaceId; this.name = "WorkspaceError"; } code; workspaceId; }; var WorkspaceNotAvailableError = class extends WorkspaceError { constructor() { super("Workspace not available. Ensure the agent has a workspace configured.", "NO_WORKSPACE"); this.name = "WorkspaceNotAvailableError"; } }; var FilesystemNotAvailableError = class extends WorkspaceError { constructor() { super("Workspace does not have a filesystem configured", "NO_FILESYSTEM"); this.name = "FilesystemNotAvailableError"; } }; var SandboxNotAvailableError = class extends WorkspaceError { constructor(message) { super(message ?? "Workspace does not have a sandbox configured", "NO_SANDBOX"); this.name = "SandboxNotAvailableError"; } }; var SandboxFeatureNotSupportedError = class extends WorkspaceError { constructor(feature) { super(`Sandbox does not support ${feature}`, "FEATURE_NOT_SUPPORTED"); this.name = "SandboxFeatureNotSupportedError"; } }; var SearchNotAvailableError = class extends WorkspaceError { constructor() { super("Workspace does not have search configured (enable bm25 or provide vectorStore + embedder)", "NO_SEARCH"); this.name = "SearchNotAvailableError"; } }; var WorkspaceNotReadyError = class extends WorkspaceError { constructor(workspaceId, status) { super(`Workspace is not ready (status: ${status})`, "NOT_READY", workspaceId); this.name = "WorkspaceNotReadyError"; } }; var WorkspaceReadOnlyError = class extends WorkspaceError { constructor(operation) { super(`Workspace is in read-only mode. Cannot perform: ${operation}`, "READ_ONLY"); this.name = "WorkspaceReadOnlyError"; } }; var FilesystemError = class extends Error { constructor(message, code, path9) { super(message); this.code = code; this.path = path9; this.name = "FilesystemError"; } code; path; }; var FileNotFoundError = class extends FilesystemError { constructor(path9) { super(`File not found: ${path9}`, "ENOENT", path9); this.name = "FileNotFoundError"; } }; var DirectoryNotFoundError = class extends FilesystemError { constructor(path9) { super(`Directory not found: ${path9}`, "ENOENT", path9); this.name = "DirectoryNotFoundError"; } }; var FileExistsError = class extends FilesystemError { constructor(path9) { super(`File already exists: ${path9}`, "EEXIST", path9); this.name = "FileExistsError"; } }; var IsDirectoryError = class extends FilesystemError { constructor(path9) { super(`Path is a directory: ${path9}`, "EISDIR", path9); this.name = "IsDirectoryError"; } }; var NotDirectoryError = class extends FilesystemError { constructor(path9) { super(`Path is not a directory: ${path9}`, "ENOTDIR", path9); this.name = "NotDirectoryError"; } }; var DirectoryNotEmptyError = class extends FilesystemError { constructor(path9) { super(`Directory not empty: ${path9}`, "ENOTEMPTY", path9); this.name = "DirectoryNotEmptyError"; } }; var PermissionError = class extends FilesystemError { constructor(path9, operation) { super(`Permission denied: ${operation} on ${path9}`, "EACCES", path9); this.operation = operation; this.name = "PermissionError"; } operation; }; var FileReadRequiredError = class extends FilesystemError { constructor(path9, reason) { super(reason, "EREAD_REQUIRED", path9); this.name = "FileReadRequiredError"; } }; var StaleFileError = class extends FilesystemError { constructor(path9, expectedMtime, actualMtime) { super( `File was modified externally: ${path9} (expected mtime ${expectedMtime.toISOString()}, actual ${actualMtime.toISOString()})`, "ESTALE", path9 ); this.expectedMtime = expectedMtime; this.actualMtime = actualMtime; this.name = "StaleFileError"; } expectedMtime; actualMtime; }; var FilesystemNotReadyError = class extends FilesystemError { constructor(id) { super(`Filesystem "${id}" is not ready. Call init() first or use ensureReady().`, "ENOTREADY", id); this.name = "FilesystemNotReadyError"; } }; // src/workspace/lifecycle.ts async function callLifecycle(provider, method) { const wrapped = `_${method}`; const wrappedFn = provider[wrapped]; if (typeof wrappedFn === "function") { await wrappedFn.call(provider); } else { const plainFn = provider[method]; if (typeof plainFn === "function") { await plainFn.call(provider); } } } // src/workspace/filesystem/composite-filesystem.ts var CompositeFilesystem = class { id; name = "CompositeFilesystem"; provider = "composite"; readOnly; status = "ready"; _mounts; constructor(config) { this.id = `cfs-${Date.now().toString(36)}`; this._mounts = /* @__PURE__ */ new Map(); for (const [path9, fs6] of Object.entries(config.mounts)) { const normalized = this.normalizePath(path9); this._mounts.set(normalized, fs6); } if (this._mounts.size === 0) { throw new Error("CompositeFilesystem requires at least one mount"); } this.readOnly = [...this._mounts.values()].every((fs6) => fs6.readOnly) || void 0; const mountPaths = [...this._mounts.keys()]; for (const a of mountPaths) { for (const b of mountPaths) { if (a !== b && b.startsWith(a + "/")) { throw new Error(`Nested mount paths are not supported: "${b}" is nested under "${a}"`); } } } } /** * Get all mount paths. */ get mountPaths() { return Array.from(this._mounts.keys()); } /** * Get the mounts map. * Returns a typed map where `get()` preserves the concrete filesystem type per mount path. */ get mounts() { return this._mounts; } /** * Get status and metadata for this composite filesystem. * Includes info from each mounted filesystem in `metadata.mounts`. */ async getInfo() { const mounts = {}; for (const [mountPath, fs6] of this._mounts) { mounts[mountPath] = await fs6.getInfo?.() ?? null; } return { id: this.id, name: this.name, provider: this.provider, status: this.status, readOnly: this.readOnly, metadata: { mounts } }; } /** * Get the underlying filesystem for a given path. * Returns undefined if the path doesn't resolve to any mount. */ getFilesystemForPath(path9) { const resolved = this.resolveMount(path9); return resolved?.fs; } /** * Get the mount path for a given path. * Returns undefined if the path doesn't resolve to any mount. */ getMountPathForPath(path9) { const resolved = this.resolveMount(path9); return resolved?.mountPath; } /** * Resolve a workspace-relative path to an absolute disk path. * Strips the mount prefix and delegates to the underlying filesystem. */ resolveAbsolutePath(path9) { const r = this.resolveMount(path9); if (!r) return void 0; return r.fs.resolveAbsolutePath?.(r.fsPath); } normalizePath(path9) { if (!path9 || path9 === "/" || path9 === ".") return "/"; let n = posixPath.normalize(path9); if (n === ".") return "/"; if (!n.startsWith("/")) n = `/${n}`; if (n.length > 1 && n.endsWith("/")) n = n.slice(0, -1); return n; } resolveMount(path9) { const normalized = this.normalizePath(path9); let best = null; for (const [mountPath, fs6] of this._mounts) { if (normalized === mountPath || normalized.startsWith(mountPath + "/")) { if (!best || mountPath.length > best.mountPath.length) { best = { mountPath, fs: fs6 }; } } } if (!best) return null; let fsPath = normalized.slice(best.mountPath.length); if (fsPath === "/") fsPath = ""; else if (fsPath.startsWith("/")) fsPath = fsPath.slice(1); return { fs: best.fs, fsPath, mountPath: best.mountPath }; } getVirtualEntries(path9) { const normalized = this.normalizePath(path9); if (this.resolveMount(normalized)) return null; const entriesMap = /* @__PURE__ */ new Map(); for (const [mountPath, fs6] of this._mounts.entries()) { const isUnder = normalized === "/" ? mountPath.startsWith("/") : mountPath.startsWith(normalized + "/"); if (isUnder) { const remaining = normalized === "/" ? mountPath.slice(1) : mountPath.slice(normalized.length + 1); const next = remaining.split("/")[0]; if (next && !entriesMap.has(next)) { const isDirectMount = remaining === next; const entry = { name: next, type: "directory" }; if (isDirectMount) { entry.mount = { provider: fs6.provider, icon: fs6.icon, displayName: fs6.displayName, description: fs6.description, status: fs6.status, error: fs6.error }; } entriesMap.set(next, entry); } } } return entriesMap.size > 0 ? Array.from(entriesMap.values()) : null; } isVirtualPath(path9) { const normalized = this.normalizePath(path9); if (normalized === "/" && !this._mounts.has("/")) return true; for (const mountPath of this._mounts.keys()) { if (mountPath.startsWith(normalized + "/")) return true; } return false; } /** * Assert that a filesystem is writable (not read-only). * @throws {PermissionError} if the filesystem is read-only */ assertWritable(fs6, path9, operation) { if (fs6.readOnly) { throw new PermissionError(path9, `${operation} (filesystem is read-only)`); } } // =========================================================================== // WorkspaceFilesystem Implementation // =========================================================================== async init() { this.status = "initializing"; for (const [mountPath, fs6] of this._mounts.entries()) { try { await callLifecycle(fs6, "init"); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.warn(`[CompositeFilesystem] Mount "${mountPath}" failed to initialize: ${message}`); } } this.status = "ready"; } async destroy() { this.status = "destroying"; const errors = []; for (const fs6 of this._mounts.values()) { try { await callLifecycle(fs6, "destroy"); } catch (e) { errors.push(e instanceof Error ? e : new Error(String(e))); } } if (errors.length > 0) { this.status = "error"; throw new AggregateError(errors, "Some filesystems failed to destroy"); } this.status = "destroyed"; } async readFile(path9, options) { const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); return r.fs.readFile(r.fsPath, options); } async writeFile(path9, content, options) { const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); this.assertWritable(r.fs, path9, "writeFile"); return r.fs.writeFile(r.fsPath, content, options); } async appendFile(path9, content) { const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); this.assertWritable(r.fs, path9, "appendFile"); return r.fs.appendFile(r.fsPath, content); } async deleteFile(path9, options) { const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); this.assertWritable(r.fs, path9, "deleteFile"); return r.fs.deleteFile(r.fsPath, options); } async copyFile(src, dest, options) { const srcR = this.resolveMount(src); const destR = this.resolveMount(dest); if (!srcR) throw new Error(`No mount for source: ${src}`); if (!destR) throw new Error(`No mount for dest: ${dest}`); this.assertWritable(destR.fs, dest, "copyFile"); if (srcR.mountPath === destR.mountPath) { return srcR.fs.copyFile(srcR.fsPath, destR.fsPath, options); } const content = await srcR.fs.readFile(srcR.fsPath); await destR.fs.writeFile(destR.fsPath, content, { overwrite: options?.overwrite }); } async moveFile(src, dest, options) { const srcR = this.resolveMount(src); const destR = this.resolveMount(dest); if (!srcR) throw new Error(`No mount for source: ${src}`); if (!destR) throw new Error(`No mount for dest: ${dest}`); this.assertWritable(destR.fs, dest, "moveFile"); this.assertWritable(srcR.fs, src, "moveFile"); if (srcR.mountPath === destR.mountPath) { return srcR.fs.moveFile(srcR.fsPath, destR.fsPath, options); } await this.copyFile(src, dest, options); await srcR.fs.deleteFile(srcR.fsPath); } async readdir(path9, options) { const virtual = this.getVirtualEntries(path9); if (virtual) return virtual; const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); return r.fs.readdir(r.fsPath, options); } async mkdir(path9, options) { const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); this.assertWritable(r.fs, path9, "mkdir"); return r.fs.mkdir(r.fsPath, options); } async rmdir(path9, options) { const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); this.assertWritable(r.fs, path9, "rmdir"); return r.fs.rmdir(r.fsPath, options); } async exists(path9) { if (this.isVirtualPath(path9)) return true; const r = this.resolveMount(path9); if (!r) return false; if (r.fsPath === "") return true; return r.fs.exists(r.fsPath); } async stat(path9) { const normalized = this.normalizePath(path9); if (this.isVirtualPath(path9)) { const parts = normalized.split("/").filter(Boolean); const now = /* @__PURE__ */ new Date(); return { name: parts[parts.length - 1] || "", path: normalized, type: "directory", size: 0, createdAt: now, modifiedAt: now }; } const r = this.resolveMount(path9); if (!r) throw new Error(`No mount for path: ${path9}`); if (r.fsPath === "") { const parts = normalized.split("/").filter(Boolean); const now = /* @__PURE__ */ new Date(); return { name: parts[parts.length - 1] || "", path: normalized, type: "directory", size: 0, createdAt: now, modifiedAt: now }; } return r.fs.stat(r.fsPath); } async isFile(path9) { if (this.isVirtualPath(path9)) return false; const r = this.resolveMount(path9); if (!r) return false; try { const stat4 = await r.fs.stat(r.fsPath); return stat4.type === "file"; } catch { return false; } } async isDirectory(path9) { if (this.isVirtualPath(path9)) return true; const r = this.resolveMount(path9); if (!r) return false; if (r.fsPath === "") return true; try { const stat4 = await r.fs.stat(r.fsPath); return stat4.type === "directory"; } catch { return false; } } /** * Get instructions describing the mounted filesystems. * Used by agents to understand available storage locations. */ getInstructions(_opts) { const mountDescriptions = Array.from(this._mounts.entries()).map(([mountPath, fs6]) => { const name = fs6.displayName || fs6.provider; const access3 = fs6.readOnly ? "(read-only)" : "(read-write)"; return `- ${mountPath}: ${name} ${access3}`; }).join("\n"); return `Filesystem mount points: ${mountDescriptions}`; } }; // src/workspace/filesystem/mastra-filesystem.ts var MastraFilesystem = class extends MastraBase { /** Error message when status is 'error' */ error; // --------------------------------------------------------------------------- // Lifecycle Promise Tracking (prevents race conditions) // --------------------------------------------------------------------------- /** Promise for _init() to prevent race conditions from concurrent calls */ _initPromise; /** Promise for _destroy() to prevent race conditions from concurrent calls */ _destroyPromise; /** Lifecycle callbacks */ _onInit; _onDestroy; constructor(options) { super({ name: options.name, component: RegisteredLogger.WORKSPACE }); this._onInit = options.onInit; this._onDestroy = options.onDestroy; } // --------------------------------------------------------------------------- // Lifecycle Wrappers (race-condition-safe) // --------------------------------------------------------------------------- /** * Initialize the filesystem (wrapper with status management and race-condition safety). * * This method is race-condition-safe - concurrent calls will return the same promise. * Handles status management automatically. * * Subclasses override `init()` to provide their initialization logic. */ async _init() { if (this.status === "ready") { return; } if (this._destroyPromise) { try { await this._destroyPromise; } catch { } } if (this._initPromise) { return this._initPromise; } this._initPromise = this._executeInit(); try { await this._initPromise; } finally { this._initPromise = void 0; } } /** * Internal init execution - handles status. */ async _executeInit() { this.status = "initializing"; this.error = void 0; try { await this.init(); this.status = "ready"; try { await this._onInit?.({ filesystem: this }); } catch (error) { this.logger.warn("onInit callback failed", { error }); } } catch (error) { this.status = "error"; this.error = error instanceof Error ? error.message : String(error); this.logger.error("Failed to initialize filesystem", { error, id: this.id }); throw error; } } /** * Override this method to implement filesystem initialization logic. * * Called by `_init()` after status is set to 'initializing'. * Status will be set to 'ready' on success, 'error' on failure. * * @example * ```typescript * async init(): Promise<void> { * this._client = new StorageClient({ ... }); * await this._client.connect(); * } * ``` */ async init() { } /** * Ensure the filesystem is ready. * * Calls `_init()` if status is not 'ready'. Useful for lazy initialization * where operations should automatically initialize the filesystem if needed. * * @throws {FilesystemNotReadyError} if the filesystem fails to reach 'ready' status * * @example * ```typescript * async readFile(path: string): Promise<string | Buffer> { * await this.ensureReady(); * // Now safe to use the filesystem * } * ``` */ async ensureReady() { if (this.status !== "ready") { await this._init(); } if (this.status !== "ready") { throw new FilesystemNotReadyError(this.id); } } /** * Destroy the filesystem and clean up all resources (wrapper with status management). * * This method is race-condition-safe - concurrent calls will return the same promise. * Handles status management. * * Subclasses override `destroy()` to provide their destroy logic. */ async _destroy() { if (this.status === "destroyed") { return; } if (this.status === "pending") { this.status = "destroyed"; return; } if (this._destroyPromise) { return this._destroyPromise; } this._destroyPromise = this._executeDestroy(); try { await this._destroyPromise; } finally { this._destroyPromise = void 0; } } /** * Internal destroy execution - handles status. */ async _executeDestroy() { if (this._initPromise) { try { await this._initPromise; } catch { } } this.status = "destroying"; try { await this._onDestroy?.({ filesystem: this }); await this.destroy(); this.status = "destroyed"; } catch (error) { this.status = "error"; this.logger.error("Failed to destroy filesystem", { error, id: this.id }); throw error; } } /** * Override this method to implement filesystem destroy logic. * * Called by `_destroy()` after status is set to 'destroying'. * Status will be set to 'destroyed' on success, 'error' on failure. */ async destroy() { } }; // src/workspace/utils.ts function resolveInstructions(override, getDefault, requestContext) { if (typeof override === "string") return override; const defaultInstructions = getDefault(); if (override === void 0) return defaultInstructions; return override({ defaultInstructions, requestContext }); } function expandTilde(p) { if (p === "~") return os3.homedir(); if (p.startsWith("~/") || p.startsWith("~\\")) { return nodePath.join(os3.homedir(), p.slice(2)); } return p; } function isEnoentError(error) { return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT"; } function isEexistError(error) { return error !== null && typeof error === "object" && "code" in error && error.code === "EEXIST"; } var MIME_TYPES = { // Text txt: "text/plain", html: "text/html", htm: "text/html", css: "text/css", csv: "text/csv", md: "text/markdown", // Code js: "application/javascript", mjs: "application/javascript", ts: "application/typescript", tsx: "application/typescript", jsx: "application/javascript", json: "application/json", xml: "application/xml", yaml: "text/yaml", yml: "text/yaml", // Programming languages py: "text/x-python", rb: "text/x-ruby", go: "text/x-go", rs: "text/x-rust", java: "text/x-java", c: "text/x-c", cpp: "text/x-c++", h: "text/x-c", hpp: "text/x-c++", sh: "text/x-sh", bash: "text/x-sh", zsh: "text/x-sh", // Config toml: "text/toml", ini: "text/plain", env: "text/plain", // Database/Query sql: "text/x-sql", graphql: "application/graphql", gql: "application/graphql", // Frameworks vue: "text/x-vue", // Images png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", webp: "image/webp", ico: "image/x-icon", bmp: "image/bmp", tiff: "image/tiff", tif: "image/tiff", heic: "image/heic", heif: "image/heif", avif: "image/avif", // Documents pdf: "application/pdf", // Audio mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", aac: "audio/aac", // Video mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", avi: "video/x-msvideo", mkv: "video/x-matroska", // Archives zip: "application/zip", tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip", bz2: "application/x-bzip2", "7z": "application/x-7z-compressed", rar: "application/vnd.rar", // Executables / binaries exe: "application/vnd.microsoft.portable-executable", dll: "application/vnd.microsoft.portable-executable", so: "application/x-sharedlib", dylib: "application/x-sharedlib", bin: "application/x-binary", dat: "application/x-binary", // Disk images / packages dmg: "application/x-apple-diskimage", iso: "application/x-iso9660-image", deb: "application/vnd.debian.binary-package", rpm: "application/x-rpm", // Office documents doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", // Fonts ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2", // Compiled code wasm: "application/wasm", class: "application/java-vm", pyc: "application/x-python-code" }; function getMimeType(filename) { const ext = nodePath.extname(filename).slice(1).toLowerCase(); return MIME_TYPES[ext] ?? "application/octet-stream"; } var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([ ".md", ".txt", ".json", ".yaml", ".yml", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp", ".sh", ".bash", ".zsh", ".html", ".htm", ".css", ".xml", ".toml", ".ini", ".env", ".csv", ".sql", ".graphql", ".gql", ".vue", ".svg" ]); function isTextFile(filename) { const ext = nodePath.extname(filename).toLowerCase(); return TEXT_EXTENSIONS.has(ext); } function resolveToBasePath(basePath, filePath) { const expanded = expandTilde(filePath); if (nodePath.isAbsolute(expanded)) { return nodePath.normalize(expanded); } return nodePath.resolve(basePath, expanded); } async function fsExists(absolutePath) { try { await fs2.access(absolutePath); return true; } catch { return false; } } async function fsStat(absolutePath, userPath) { try { const stats = await fs2.stat(absolutePath); return { name: nodePath.basename(absolutePath), type: stats.isDirectory() ? "directory" : "file", size: stats.size, createdAt: stats.birthtime, modifiedAt: stats.mtime, mimeType: stats.isFile() ? getMimeType(absolutePath) : void 0 }; } catch (error) { if (isEnoentError(error)) { throw new FileNotFoundError(userPath); } throw error; } } // src/workspace/filesystem/local-filesystem.ts var LocalFilesystem = class extends MastraFilesystem { id; name = "LocalFilesystem"; provider = "local"; readOnly; status = "pending"; _basePath; _contained; _allowedPaths; _instructionsOverride; /** * The absolute base path on disk where files are stored. * Useful for understanding how workspace paths map to disk paths. */ get basePath() { return this._basePath; } /** * Whether file operations are restricted to stay within basePath. * * When `true` (default), relative paths resolve against basePath and * absolute paths are kept as-is. Any resolved path that falls outside * basePath (and allowedPaths) throws a PermissionError. When `false`, * no containment check is applied. * * **Note:** When used as a CompositeFilesystem mount with `contained: false`, * the agent can access any path on the host filesystem through this mount. */ get contained() { return this._contained; } /** * Current set of resolved allowed paths. * These paths are permitted beyond basePath when containment is enabled. */ get allowedPaths() { return this._allowedPaths; } /** * Update allowed paths. Accepts a direct array or an updater callback * receiving the current paths (React setState pattern). * * @example * ```typescript * // Set directly * fs.setAllowedPaths(['../shared-data']); * * // Update with callback * fs.setAllowedPaths(prev => [...prev, '~/.claude/skills']); * ``` */ setAllowedPaths(pathsOrUpdater) { const newPaths = typeof pathsOrUpdater === "function" ? pathsOrUpdater(this._allowedPaths) : pathsOrUpdater; this._allowedPaths = newPaths.map((p) => resolveToBasePath(this._basePath, p)); } constructor(options) { super({ ...options, name: "LocalFilesystem" }); this.id = options.id ?? this.generateId(); this._basePath = nodePath.resolve(expandTilde(options.basePath)); this._contained = options.contained ?? true; this.readOnly = options.readOnly; this._allowedPaths = (options.allowedPaths ?? []).map((p) => resolveToBasePath(this._basePath, p)); this._instructionsOverride = options.instructions; } /** * Return mount config for sandbox integration. * LocalSandbox uses this to create a symlink from the mount path to basePath. */ getMountConfig() { return { type: "local", basePath: this._basePath }; } generateId() { return `local-fs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } /** * Check if an absolute path falls within basePath or any allowed path. */ _isWithinRoot(absolutePath, root) { const relative2 = nodePath.relative(root, absolutePath); return !relative2.startsWith("..") && !nodePath.isAbsolute(relative2); } _resolvePathForContainment(absolutePath) { let currentPath = absolutePath; while (true) { try { const realPath = realpathSync(currentPath); if (currentPath === absolutePath) { return realPath; } const remainder = nodePath.relative(currentPath, absolutePath); return nodePath.join(realPath, remainder); } catch (error) { if (!isEnoentError(error)) return void 0; } const parentPath = nodePath.dirname(currentPath); if (parentPath === currentPath) { return void 0; } currentPath = parentPath; } } _isWithinAnyRoot(absolutePath) { const roots = [this._basePath, ...this._allowedPaths]; if (roots.some((root) => this._isWithinRoot(absolutePath, root))) { return true; } const resolvedPath = this._resolvePathForContainment(absolutePath); if (!resolvedPath) { return false; } return roots.some((root) => { const resolvedRoot = this._resolvePathForContainment(root); return resolvedRoot ? this._isWithinRoot(resolvedPath, resolvedRoot) : false; }); } toBuffer(content) { if (Buffer.isBuffer(content)) return content; if (content instanceof Uint8Array) return Buffer.from(content); return Buffer.from(content, "utf-8"); } resolvePath(inputPath) { const absolutePath = resolveToBasePath(this._basePath, inputPath); if (this._contained) { if (!this._isWithinAnyRoot(absolutePath)) { throw new PermissionError(inputPath, this._accessOperationHint(inputPath)); } } return absolutePath; } /** * Build the operation string for a containment-violation `PermissionError`. * * When the caller passed an absolute path, suggest a concrete relative form * only when that suffix names an existing entry under the workspace (e.g. * `/src/app.ts` → `src/app.ts` if `<basePath>/src` exists). Otherwise emit a * soft hint that doesn't lie about specific paths — agents that mistake `/` * for the workspace root learn the workspace is sandboxed without us * inventing a fictitious in-workspace location for `/etc/passwd`. */ _accessOperationHint(inputPath) { if (!nodePath.isAbsolute(inputPath)) return "access"; const stripped = inputPath.replace(/^[/\\]+/, ""); if (!stripped) return "access"; const firstSegment = stripped.split(/[/\\]/, 1)[0]; if (firstSegment && firstSegment !== "." && firstSegment !== "..") { try { if (realpathSync(nodePath.join(this._basePath, firstSegment))) { return `access (path is outside the workspace; use a relative path like "${stripped}")`; } } catch { } } return 'access (path is outside the workspace; use a path relative to the workspace root, without a leading "/")'; } /** * Resolve a workspace-relative path to an absolute disk path. * Uses the same resolution logic as internal file operations. * Returns `undefined` if the path violates containment. */ resolveAbsolutePath(inputPath) { try { return this.resolvePath(inputPath); } catch { return void 0; } } toRelativePath(absolutePath) { return nodePath.relative(this._basePath, absolutePath).replace(/\\/g, "/"); } assertWritable(operation) { if (this.readOnly) { throw new WorkspaceReadOnlyError(operation); } } /** * Verify that the resolved path doesn't escape basePath via symlinks. * Uses realpath to resolve symlinks and check the actual target. */ async assertPathContained(absolutePath) { if (!this._contained) return; if (this._allowedPaths.some((root) => this._isWithinRoot(absolutePath, root))) { return; } let targetReal; try { targetReal = await fs2.realpath(absolutePath); } catch (error) { if (isEnoentError(error)) return; throw error; } const roots = [this._basePath, ...this._allowedPaths]; const rootReals = []; for (const root of roots) { try { rootReals.push(await fs2.realpath(root)); } catch (error) { if (isEnoentError(error)) continue; throw error; } } const isWithinRoot = rootReals.some( (rootReal) => targetReal === rootReal || targetReal.startsWith(rootReal + nodePath.sep) ); if (!isWithinRoot) { throw new PermissionError(absolutePath, "access"); } } async readFile(inputPath, options) { this.logger.debug("Reading file", { path: inputPath, encoding: options?.encoding }); await this.ensureReady(); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); try { const stats = await fs2.stat(absolutePath); if (stats.isDirectory()) { throw new IsDirectoryError(inputPath); } if (options?.encoding) { return await fs2.readFile(absolutePath, { encoding: options.encoding }); } return await fs2.readFile(absolutePath); } catch (error) { if (error instanceof IsDirectoryError) throw error; if (isEnoentError(error)) { throw new FileNotFoundError(inputPath); } throw error; } } async writeFile(inputPath, content, options) { const contentSize = Buffer.isBuffer(content) ? content.length : content.length; this.logger.debug("Writing file", { path: inputPath, size: contentSize, recursive: options?.recursive }); await this.ensureReady(); this.assertWritable("writeFile"); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); if (options?.recursive === false) { const dir = nodePath.dirname(absolutePath); const parentPath = nodePath.dirname(inputPath); try { const stat4 = await fs2.stat(dir); if (!stat4.isDirectory()) { throw new NotDirectoryError(parentPath); } } catch (error) { if (error instanceof NotDirectoryError) throw error; if (isEnoentError(error)) { throw new DirectoryNotFoundError(parentPath); } throw error; } } if (options?.recursive !== false) { const dir = nodePath.dirname(absolutePath); await fs2.mkdir(dir, { recursive: true }); } if (options?.expectedMtime) { try { const currentStat = await fs2.stat(absolutePath); if (currentStat.mtime.getTime() !== options.expectedMtime.getTime()) { throw new StaleFileError(inputPath, options.expectedMtime, currentStat.mtime); } } catch (error) { if (error instanceof StaleFileError) throw error; if (!isEnoentError(error)) throw error; } } const writeFlag = options?.overwrite === false ? "wx" : "w"; try { await fs2.writeFile(absolutePath, this.toBuffer(content), { flag: writeFlag }); } catch (error) { if (options?.overwrite === false && isEexistError(error)) { throw new FileExistsError(inputPath); } throw error; } } async appendFile(inputPath, content) { const contentSize = Buffer.isBuffer(content) ? content.length : content.length; this.logger.debug("Appending to file", { path: inputPath, size: contentSize }); await this.ensureReady(); this.assertWritable("appendFile"); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); const dir = nodePath.dirname(absolutePath); await fs2.mkdir(dir, { recursive: true }); await fs2.appendFile(absolutePath, this.toBuffer(content)); } async deleteFile(inputPath, options) { this.logger.debug("Deleting file", { path: inputPath, force: options?.force }); await this.ensureReady(); this.assertWritable("deleteFile"); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); try { const stats = await fs2.stat(absolutePath); if (stats.isDirectory()) { throw new IsDirectoryError(inputPath); } await fs2.unlink(absolutePath); } catch (error) { if (error instanceof IsDirectoryError) throw error; if (isEnoentError(error)) { if (!options?.force) { throw new FileNotFoundError(inputPath); } } else { throw error; } } } async copyFile(src, dest, options) { this.logger.debug("Copying file", { src, dest, recursive: options?.recursive }); await this.ensureReady(); this.assertWritable("copyFile"); const srcPath = this.resolvePath(src); const destPath = this.resolvePath(dest); await this.assertPathContained(srcPath); await this.assertPathContained(destPath); try { const stats = await fs2.stat(srcPath); if (stats.isDirectory()) { if (!options?.recursive) { throw new IsDirectoryError(src); } await this.copyDirectory(srcPath, destPath, options); } else { await fs2.mkdir(nodePath.dirname(destPath), { recursive: true }); const copyFlags = options?.overwrite === false ? constants.COPYFILE_EXCL : 0; try { await fs2.copyFile(srcPath, destPath, copyFlags); } catch (error) { if (options?.overwrite === false && isEexistError(error)) { throw new FileExistsError(dest); } throw error; } } } catch (error) { if (error instanceof IsDirectoryError || error instanceof FileExistsError) throw error; if (isEnoentError(error)) { throw new FileNotFoundError(src); } throw error; } } async copyDirectory(src, dest, options) { await this.ensureReady(); await fs2.mkdir(dest, { recursive: true }); const entries = await fs2.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcEntry = nodePath.join(src, entry.name); const destEntry = nodePath.join(dest, entry.name); await this.assertPathContained(srcEntry); await this.assertPathContained(destEntry); if (entry.isDirectory()) { await this.copyDirectory(srcEntry, destEntry, options); } else { const copyFlags = options?.overwrite === false ? constants.COPYFILE_EXCL : 0; try { await fs2.copyFile(srcEntry, destEntry, copyFlags); } catch (error) { if (options?.overwrite === false && isEexistError(error)) { continue; } throw error; } } } } async moveFile(src, dest, options) { this.logger.debug("Moving file", { src, dest, overwrite: options?.overwrite }); await this.ensureReady(); this.assertWritable("moveFile"); const srcPath = this.resolvePath(src); const destPath = this.resolvePath(dest); await this.assertPathContained(srcPath); await this.assertPathContained(destPath); try { await fs2.mkdir(nodePath.dirname(destPath), { recursive: true }); if (options?.overwrite === false) { await this.copyFile(src, dest, { ...options, overwrite: false }); await fs2.rm(srcPath, { recursive: true, force: true }); return; } try { await fs2.rename(srcPath, destPath); } catch (error) { const code = error.code; if (code !== "EXDEV") { throw error; } await this.copyFile(src, dest, options); await fs2.rm(srcPath, { recursive: true, force: true }); } } catch (error) { if (error instanceof FileExistsError) throw error; if (isEnoentError(error)) { throw new FileNotFoundError(src); } throw error; } } async mkdir(inputPath, options) { this.logger.debug("Creating directory", { path: inputPath, recursive: options?.recursive }); await this.ensureReady(); this.assertWritable("mkdir"); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); try { await fs2.mkdir(absolutePath, { recursive: options?.recursive ?? true }); } catch (error) { if (isEexistError(error)) { const stats = await fs2.stat(absolutePath); if (!stats.isDirectory()) { throw new FileExistsError(inputPath); } } else if (isEnoentError(error)) { const parentPath = nodePath.dirname(inputPath); throw new DirectoryNotFoundError(parentPath); } else { throw error; } } } async rmdir(inputPath, options) { this.logger.debug("Removing directory", { path: inputPath, recursive: options?.recursive, force: options?.force }); await this.ensureReady(); this.assertWritable("rmdir"); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); try { const stats = await fs2.stat(absolutePath); if (!stats.isDirectory()) { throw new NotDirectoryError(inputPath); } if (options?.recursive) { await fs2.rm(absolutePath, { recursive: true, force: options?.force ?? false }); } else { const entries = await fs2.readdir(absolutePath); if (entries.length > 0) { throw new DirectoryNotEmptyError(inputPath); } await fs2.rmdir(absolutePath); } } catch (error) { if (error instanceof NotDirectoryError || error instanceof DirectoryNotEmptyError) { throw error; } if (isEnoentError(error)) { if (!options?.force) { throw new DirectoryNotFoundError(inputPath); } } else { throw error; } } } async readdir(inputPath, options) { this.logger.debug("Reading directory", { path: inputPath, recursive: options?.recursive }); await this.ensureReady(); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); try { const stats = await fs2.stat(absolutePath); if (!stats.isDirectory()) { throw new NotDirectoryError(inputPath); } const entries = await fs2.readdir(absolutePath, { withFileTypes: true }); const result = []; for (const entry of entries) { const entryPath = nodePath.join(absolutePath, entry.name); if (options?.extension) { const extensions = Array.isArray(options.extension) ? options.extension : [options.extension]; if (entry.isFile()) { const ext = nodePath.extname(entry.name); if (!extensions.some((e) => e === ext || e === ext.slice(1))) { continue; } } } const isSymlink = entry.isSymbolicLink(); let symlinkTarget; let resolvedType = "file"; if (isSymlink) { try { symlinkTarget = await fs2.readlink(entryPath); const targetStat = await fs2.stat(entryPath); resolvedType = targetStat.isDirectory() ? "directory" : "file"; } catch { resolvedType = "file"; } } else { resolvedType = entry.isDirectory() ? "directory" : "file"; } const fileEntry = { name: entry.name, type: resolvedType, isSymlink: isSymlink || void 0, symlinkTarget }; if (resolvedType === "file" && !isSymlink) { try { const stat4 = await fs2.stat(entryPath); fileEntry.size = stat4.size; } catch { } } result.push(fileEntry); if (options?.recursive && resolvedType === "directory") { const depth = options.maxDepth ?? 100; if (depth > 0) { const subEntries = await this.readdir(this.toRelativePath(entryPath), { ...options, maxDepth: depth - 1 }); result.push( ...subEntries.map((e) => ({ ...e, name: `${entry.name}/${e.name}` })) ); } } } return result; } catch (error) { if (error instanceof NotDirectoryError) throw error; if (isEnoentError(error)) { throw new DirectoryNotFoundError(inputPath); } throw error; } } async exists(inputPath) { await this.ensureReady(); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); return fsExists(absolutePath); } async stat(inputPath) { await this.ensureReady(); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); const result = await fsStat(absolutePath, inputPath); return { ...result, path: this.toRelativePath(absolutePath) }; } async realpath(inputPath) { await this.ensureReady(); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); const canonicalPath = await fs2.realpath(absolutePath); return this.toRelativePath(canonicalPath); } /** * Initialize the local filesystem by creating the base directory. * Status management is handled by the base class. */ async init() { this.logger.debug("Initializing filesystem", { basePath: this._basePath }); await fs2.mkdir(this._basePath, { recursive: true }); this.logger.debug("Filesystem initialized", { basePath: this._basePath }); } /** * Clean up the local filesystem. * LocalFilesystem doesn't delete files on destroy by default. * Status management is handled by the base class. */ async destroy() { } getInfo() { return { id: this.id, name: this.name, provider: this.provider, readOnly: this.readOnly, status: this.status, error: this.error, metadata: { basePath: this.basePath, contained: this._contained, ...this._allowedPaths.length > 0 && { allowedPaths: [...this._allowedPaths] } } }; } getInstructions(opts) { return resolveInstructions(this._instructionsOverride, () => this._getDefaultInstructions(), opts?.requestContext); } _getDefaultInstructions() { const parts = [`Local filesystem at "${this.basePath}". Relative paths resolve from this directory.`]; if (this._contained) { if (this._allowedPaths.length > 0) { parts.push( `File access is restricted to this directory and the following allowed paths: ${this._allowedPaths.join(", ")}.` ); } else { parts.push("File access is restricted to this directory."); } } else { parts.push("Containment is disabled, so any path on the host filesystem is accessible."); } return parts.join(" "); } }; var InMemoryFileReadTracker = class { records = /* @__PURE__ */ new Map(); recordRead(path9, modifiedAt) { const normalizedPath = this.normalizePath(path9); this.records.set(normalizedPath, { path: normalizedPath, readAt: /* @__PURE__ */ new Date(), modifiedAtRead: modifiedAt }); } getReadRecord(path9) { return this.records.get(this.normalizePath(path9)); } needsReRead(path9, currentModifiedAt) { const record = this.getReadRecord(path9); if (!record) { return { needsReRead: true, reason: `File "${path9}" has not been read. You must read a file before writing to it.` }; } if (currentModifiedAt.getTime() > record.modifiedAtRead.getTime()) { return { needsReRead: true, reason: `File "${path9}" was modified since last read (read at: ${record.modifiedAtRead.toISOString()}, current: ${currentModifiedAt.toISOString()}). Please re-read the file to get the latest contents.` }; } return { needsReRead: false }; } clearReadRecord(path9) { this.records.delete(this.normalizePath(path9)); } clear() { this.records.clear(); } normalizePath(pathStr) { const normalized = nodePath.posix.normalize(pathStr.replace(/\\/g, "/")); return normalized.replace(/\/$/, "") || "/"; } }; var InMemoryFileWriteLock = class { queues = /* @__PURE__ */ new Map(); timeoutMs; constructor(opts) { this.timeoutMs = opts?.timeoutMs ?? 3e4; } get size() { return this.queues.size; } withLock(filePath, fn) { const key = this.normalizePath(filePath); const currentQueue = this.queues.get(key) ?? Promise.resolve(); let resolve6; let reject; const resultPromise