UNPKG

@mastra/core

Version:
1,505 lines 342 kB
import { a as RegisteredLogger } from "./logger-B_aQzjbm.js"; import { t as MastraBase } from "./base-BeUQ6mLP.js"; import "./tracing-Bm0k4FBA.js"; import { a as RequestContext } from "./request-context-p_Tq-4EM.js"; import { r as createTool } from "./tool-qGw4ZhYO.js"; import { _ as WorkspaceNotAvailableError, a as FileReadRequiredError, c as FilesystemNotReadyError, d as PermissionError, f as SandboxFeatureNotSupportedError, g as WorkspaceError, h as StaleFileError, i as FileNotFoundError, l as IsDirectoryError, m as SearchNotAvailableError, n as DirectoryNotFoundError, p as SandboxNotAvailableError, r as FileExistsError, s as FilesystemNotAvailableError, t as DirectoryNotEmptyError, u as NotDirectoryError, y as WorkspaceReadOnlyError } from "./errors-B0YGz4tO.js"; import { a as extractGlobBase, c as resolvePathPattern, d as fsStat, f as isEexistError, h as resolveToBasePath, i as createGlobMatcher, l as expandTilde, m as isTextFile, n as LocalSkillSource, o as isGlobPattern, p as isEnoentError, t as WorkspaceSkillsImpl, u as fsExists } from "./workspace-skills-BcLsoENh.js"; import { createRequire } from "module"; import { Readable, Writable } from "stream"; import * as crypto from "crypto"; import { createHash } from "crypto"; import { z } from "zod/v4"; import * as os$1 from "os"; import os from "os"; import { constants, existsSync, realpathSync } from "fs"; import * as nodePath from "path"; import path, { dirname, join, parse } from "path"; import pMap, { pMapSkip } from "p-map"; import posixPath from "path/posix"; import * as fs$2 from "fs/promises"; import fs$1 from "fs/promises"; import { fileURLToPath, pathToFileURL } from "url"; import { execFileSync } from "child_process"; import { StringDecoder } from "string_decoder"; import matter from "gray-matter"; import { estimateTokenCount, sliceByTokens } from "tokenx"; import ignore from "ignore"; //#region src/workspace/lifecycle.ts /** * Call a lifecycle method on a provider, preferring the `_`-prefixed wrapper * (which adds status tracking & race-condition safety) when available, * falling back to the plain method for interface-only implementations. * * @example * ```typescript * await callLifecycle(sandbox, 'start'); // calls sandbox._start() ?? sandbox.start() * await callLifecycle(filesystem, 'init'); // calls filesystem._init() ?? filesystem.init() * ``` */ async function callLifecycle(provider, method) { const wrappedFn = provider[`_${method}`]; if (typeof wrappedFn === "function") await wrappedFn.call(provider); else { const plainFn = provider[method]; if (typeof plainFn === "function") await plainFn.call(provider); } } //#endregion //#region src/workspace/filesystem/composite-filesystem.ts /** * CompositeFilesystem - Routes operations to mounted filesystems based on path. * * Creates a unified filesystem view by combining multiple filesystems at different * mount points. Useful for composing local storage, S3, and other backends. * * @example * ```typescript * const cfs = new CompositeFilesystem({ * mounts: { * '/local': new LocalFilesystem({ basePath: './data' }), * '/s3': new S3Filesystem({ bucket: 'my-bucket', ... }), * } * }); * * // readdir('/') returns ['local', 's3'] * // readFile('/local/file.txt') reads from LocalFilesystem * // readFile('/s3/data.json') reads from S3Filesystem * ``` */ /** * CompositeFilesystem implementation. * * Routes file operations to the appropriate underlying filesystem based on path. * Supports cross-mount operations (copy/move between different filesystems). * * The generic parameter preserves the concrete types of mounted filesystems, * enabling typed access via `mounts.get()`. * * @example * ```typescript * const cfs = new CompositeFilesystem({ * mounts: { * '/local': new LocalFilesystem({ basePath: './data' }), * '/s3': new S3Filesystem({ bucket: 'my-bucket' }), * }, * }); * * cfs.mounts.get('/local') // LocalFilesystem * cfs.mounts.get('/s3') // S3Filesystem * ``` */ 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 [path, fs] of Object.entries(config.mounts)) { const normalized = this.normalizePath(path); this._mounts.set(normalized, fs); } if (this._mounts.size === 0) throw new Error("CompositeFilesystem requires at least one mount"); this.readOnly = [...this._mounts.values()].every((fs) => fs.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, fs] of this._mounts) mounts[mountPath] = await fs.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(path) { return this.resolveMount(path)?.fs; } /** * Get the mount path for a given path. * Returns undefined if the path doesn't resolve to any mount. */ getMountPathForPath(path) { return this.resolveMount(path)?.mountPath; } /** * Resolve a workspace-relative path to an absolute disk path. * Strips the mount prefix and delegates to the underlying filesystem. */ resolveAbsolutePath(path) { const r = this.resolveMount(path); if (!r) return void 0; return r.fs.resolveAbsolutePath?.(r.fsPath); } normalizePath(path) { if (!path || path === "/" || path === ".") return "/"; let n = posixPath.normalize(path); if (n === ".") return "/"; if (!n.startsWith("/")) n = `/${n}`; if (n.length > 1 && n.endsWith("/")) n = n.slice(0, -1); return n; } resolveMount(path) { const normalized = this.normalizePath(path); let best = null; for (const [mountPath, fs] of this._mounts) if (normalized === mountPath || normalized.startsWith(mountPath + "/")) { if (!best || mountPath.length > best.mountPath.length) best = { mountPath, fs }; } 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(path) { const normalized = this.normalizePath(path); if (this.resolveMount(normalized)) return null; const entriesMap = /* @__PURE__ */ new Map(); for (const [mountPath, fs] of this._mounts.entries()) if (normalized === "/" ? mountPath.startsWith("/") : mountPath.startsWith(normalized + "/")) { 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: fs.provider, icon: fs.icon, displayName: fs.displayName, description: fs.description, status: fs.status, error: fs.error }; entriesMap.set(next, entry); } } return entriesMap.size > 0 ? Array.from(entriesMap.values()) : null; } isVirtualPath(path) { const normalized = this.normalizePath(path); 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(fs, path, operation) { if (fs.readOnly) throw new PermissionError(path, `${operation} (filesystem is read-only)`); } async init() { this.status = "initializing"; for (const [mountPath, fs] of this._mounts.entries()) try { await callLifecycle(fs, "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 fs of this._mounts.values()) try { await callLifecycle(fs, "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(path, options) { const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); return r.fs.readFile(r.fsPath, options); } async writeFile(path, content, options) { const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); this.assertWritable(r.fs, path, "writeFile"); return r.fs.writeFile(r.fsPath, content, options); } async appendFile(path, content) { const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); this.assertWritable(r.fs, path, "appendFile"); return r.fs.appendFile(r.fsPath, content); } async deleteFile(path, options) { const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); this.assertWritable(r.fs, path, "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(path, options) { const virtual = this.getVirtualEntries(path); if (virtual) return virtual; const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); return r.fs.readdir(r.fsPath, options); } async mkdir(path, options) { const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); this.assertWritable(r.fs, path, "mkdir"); return r.fs.mkdir(r.fsPath, options); } async rmdir(path, options) { const r = this.resolveMount(path); if (!r) throw new Error(`No mount for path: ${path}`); this.assertWritable(r.fs, path, "rmdir"); return r.fs.rmdir(r.fsPath, options); } async exists(path) { if (this.isVirtualPath(path)) return true; const r = this.resolveMount(path); if (!r) return false; if (r.fsPath === "") return true; return r.fs.exists(r.fsPath); } async stat(path) { const normalized = this.normalizePath(path); if (this.isVirtualPath(path)) { 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(path); if (!r) throw new Error(`No mount for path: ${path}`); 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(path) { if (this.isVirtualPath(path)) return false; const r = this.resolveMount(path); if (!r) return false; try { return (await r.fs.stat(r.fsPath)).type === "file"; } catch { return false; } } async isDirectory(path) { if (this.isVirtualPath(path)) return true; const r = this.resolveMount(path); if (!r) return false; if (r.fsPath === "") return true; try { return (await r.fs.stat(r.fsPath)).type === "directory"; } catch { return false; } } /** * Get instructions describing the mounted filesystems. * Used by agents to understand available storage locations. */ getInstructions(_opts) { return `Filesystem mount points:\n${Array.from(this._mounts.entries()).map(([mountPath, fs]) => { return `- ${mountPath}: ${fs.displayName || fs.provider} ${fs.readOnly ? "(read-only)" : "(read-write)"}`; }).join("\n")}`; } }; //#endregion //#region src/workspace/filesystem/mastra-filesystem.ts /** * MastraFilesystem Base Class * * Abstract base class for filesystem providers that want automatic logger integration * and lifecycle management. * * Extends MastraBase to receive the Mastra logger when registered with a Mastra instance. * * ## Lifecycle Management * * The base class provides race-condition-safe lifecycle wrappers: * - `_init()` - Handles concurrent calls, status management * - `_destroy()` - Handles concurrent calls and status management * * Subclasses override the plain `init()` and `destroy()` methods to provide * their implementation. Callers use the `_`-prefixed wrappers (or `callLifecycle()`) * which add status tracking and race-condition safety. * * External providers can extend this class to get logger support, or implement * the WorkspaceFilesystem interface directly if they don't need logging. */ /** * Abstract base class for filesystem providers with logger support and lifecycle management. * * Providers that extend this class automatically receive the Mastra logger * when the filesystem is used with a Mastra instance. * * @example * ```typescript * class MyCustomFilesystem extends MastraFilesystem { * readonly id = 'my-fs'; * readonly name = 'MyCustomFilesystem'; * readonly provider = 'custom'; * status: ProviderStatus = 'pending'; * * constructor() { * super({ name: 'MyCustomFilesystem' }); * } * * // Override init() to provide initialization logic * async init(): Promise<void> { * // Your initialization logic here * } * * async readFile(path: string): Promise<string | Buffer> { * await this.ensureReady(); * this.logger.debug('Reading file', { path }); * // Implementation... * } * // ... implement other WorkspaceFilesystem methods * } * ``` */ var MastraFilesystem = class extends MastraBase { /** Error message when status is 'error' */ error; /** 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; } /** * 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() {} }; //#endregion //#region src/workspace/utils.ts /** * Resolve an instructions override against default instructions. * * - `undefined` → return default * - `string` → return the string as-is * - `function` → call with { defaultInstructions, requestContext } */ function resolveInstructions(override, getDefault, requestContext) { if (typeof override === "string") return override; const defaultInstructions = getDefault(); if (override === void 0) return defaultInstructions; return override({ defaultInstructions, requestContext }); } //#endregion //#region src/workspace/filesystem/local-filesystem.ts /** * Local Filesystem Provider * * A filesystem implementation backed by a folder on the local disk. * This is the default filesystem for development and local agents. */ /** * Local filesystem implementation. * * Stores files in a folder on the user's machine. * This is the recommended filesystem for development and persistent local storage. * * @example * ```typescript * import { Workspace, LocalFilesystem } from '@mastra/core'; * * const workspace = new Workspace({ * filesystem: new LocalFilesystem({ basePath: './my-workspace' }), * }); * * await workspace.init(); * await workspace.writeFile('hello.txt', 'Hello World!'); * ``` */ 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 relative = nodePath.relative(root, absolutePath); return !relative.startsWith("..") && !nodePath.isAbsolute(relative); } _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; 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; } } 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 fs$2.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 fs$2.realpath(root)); } catch (error) { if (isEnoentError(error)) continue; throw error; } if (!rootReals.some((rootReal) => targetReal === rootReal || targetReal.startsWith(rootReal + nodePath.sep))) 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 { if ((await fs$2.stat(absolutePath)).isDirectory()) throw new IsDirectoryError(inputPath); if (options?.encoding) return await fs$2.readFile(absolutePath, { encoding: options.encoding }); return await fs$2.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 { if (!(await fs$2.stat(dir)).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 fs$2.mkdir(dir, { recursive: true }); } if (options?.expectedMtime) try { const currentStat = await fs$2.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 fs$2.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 fs$2.mkdir(dir, { recursive: true }); await fs$2.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 { if ((await fs$2.stat(absolutePath)).isDirectory()) throw new IsDirectoryError(inputPath); await fs$2.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 { if ((await fs$2.stat(srcPath)).isDirectory()) { if (!options?.recursive) throw new IsDirectoryError(src); await this.copyDirectory(srcPath, destPath, options); } else { await fs$2.mkdir(nodePath.dirname(destPath), { recursive: true }); const copyFlags = options?.overwrite === false ? constants.COPYFILE_EXCL : 0; try { await fs$2.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 fs$2.mkdir(dest, { recursive: true }); const entries = await fs$2.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 fs$2.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 fs$2.mkdir(nodePath.dirname(destPath), { recursive: true }); if (options?.overwrite === false) { await this.copyFile(src, dest, { ...options, overwrite: false }); await fs$2.rm(srcPath, { recursive: true, force: true }); return; } try { await fs$2.rename(srcPath, destPath); } catch (error) { if (error.code !== "EXDEV") throw error; await this.copyFile(src, dest, options); await fs$2.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 fs$2.mkdir(absolutePath, { recursive: options?.recursive ?? true }); } catch (error) { if (isEexistError(error)) { if (!(await fs$2.stat(absolutePath)).isDirectory()) throw new FileExistsError(inputPath); } else if (isEnoentError(error)) throw new DirectoryNotFoundError(nodePath.dirname(inputPath)); 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 { if (!(await fs$2.stat(absolutePath)).isDirectory()) throw new NotDirectoryError(inputPath); if (options?.recursive) await fs$2.rm(absolutePath, { recursive: true, force: options?.force ?? false }); else { if ((await fs$2.readdir(absolutePath)).length > 0) throw new DirectoryNotEmptyError(inputPath); await fs$2.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 { if (!(await fs$2.stat(absolutePath)).isDirectory()) throw new NotDirectoryError(inputPath); const entries = await fs$2.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 fs$2.readlink(entryPath); resolvedType = (await fs$2.stat(entryPath)).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 { fileEntry.size = (await fs$2.stat(entryPath)).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); return { ...await fsStat(absolutePath, inputPath), path: this.toRelativePath(absolutePath) }; } async realpath(inputPath) { await this.ensureReady(); const absolutePath = this.resolvePath(inputPath); await this.assertPathContained(absolutePath); const canonicalPath = await fs$2.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 fs$2.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(" "); } }; //#endregion //#region src/workspace/filesystem/file-read-tracker.ts /** * In-memory implementation of FileReadTracker. */ var InMemoryFileReadTracker = class { records = /* @__PURE__ */ new Map(); recordRead(path, modifiedAt) { const normalizedPath = this.normalizePath(path); this.records.set(normalizedPath, { path: normalizedPath, readAt: /* @__PURE__ */ new Date(), modifiedAtRead: modifiedAt }); } getReadRecord(path) { return this.records.get(this.normalizePath(path)); } needsReRead(path, currentModifiedAt) { const record = this.getReadRecord(path); if (!record) return { needsReRead: true, reason: `File "${path}" has not been read. You must read a file before writing to it.` }; if (currentModifiedAt.getTime() > record.modifiedAtRead.getTime()) return { needsReRead: true, reason: `File "${path}" 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(path) { this.records.delete(this.normalizePath(path)); } clear() { this.records.clear(); } normalizePath(pathStr) { return nodePath.posix.normalize(pathStr.replace(/\\/g, "/")).replace(/\/$/, "") || "/"; } }; //#endregion //#region src/workspace/filesystem/file-write-lock.ts /** * In-memory implementation of FileWriteLock using per-path promise queues. * * Adapted from mastracode's `withWriteLock` pattern. */ 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 resolve; let reject; const resultPromise = new Promise((res, rej) => { resolve = res; reject = rej; }); const queuePromise = currentQueue.catch(() => {}).then(async () => { let timeoutId; try { const result = await Promise.race([fn(), new Promise((_, rej) => { timeoutId = setTimeout(() => rej(/* @__PURE__ */ new Error(`write-lock timeout on "${key}" after ${this.timeoutMs}ms`)), this.timeoutMs); })]); clearTimeout(timeoutId); resolve(result); } catch (error) { clearTimeout(timeoutId); reject(error); } }); this.queues.set(key, queuePromise); queuePromise.finally(() => { if (this.queues.get(key) === queuePromise) this.queues.delete(key); }); return resultPromise; } normalizePath(pathStr) { return nodePath.posix.normalize(pathStr.replace(/\\/g, "/").replace(/^\/\/+/, "/")).replace(/\/+$/, "") || "/"; } }; //#endregion //#region src/workspace/lsp/language.ts /** * Language Detection * * Maps file extensions to LSP language identifiers. * Browser-safe — no Node.js dependencies. */ /** * Maps file extensions (including the dot) to LSP language identifiers. */ const LANGUAGE_EXTENSIONS = { ".ts": "typescript", ".tsx": "typescriptreact", ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript", ".cjs": "javascript", ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust", ".c": "c", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".h": "c", ".hpp": "cpp", ".java": "java", ".json": "json", ".jsonc": "jsonc", ".yaml": "yaml", ".yml": "yaml", ".md": "markdown", ".html": "html", ".css": "css", ".scss": "scss", ".sass": "sass", ".less": "less" }; /** * Get the LSP language ID for a file path based on its extension. * Returns undefined if the extension is not recognized. * * When `customExtensions` is provided, it is checked first, allowing * custom servers to register new file extensions or override built-in mappings. */ function getLanguageId(filePath, customExtensions) { const dotIndex = filePath.lastIndexOf("."); if (dotIndex === -1) return void 0; const ext = filePath.substring(dotIndex); return customExtensions?.[ext] ?? LANGUAGE_EXTENSIONS[ext]; } //#endregion //#region src/workspace/lsp/client.ts /** * LSP Client * * JSON-RPC client wrapper for communicating with language servers. * Uses dynamic imports for vscode-jsonrpc and vscode-languageserver-protocol * to keep them as optional dependencies. * * Spawns LSP servers via a SandboxProcessManager, so it works with any * sandbox backend (local, E2B, etc.) that has a process manager. */ /** Cached module references — undefined means not yet checked, null means unavailable */ let jsonrpcModule; let lspProtocolModule; /** * Check if vscode-jsonrpc is available without importing it. * Synchronous check — safe to call at registration time. */ function isLSPAvailable() { if (jsonrpcModule !== void 0) return jsonrpcModule !== null; try { const req = createRequire(import.meta.url); req.resolve("vscode-jsonrpc/node"); req.resolve("vscode-languageserver-protocol"); return true; } catch { return false; } } /** * Load vscode-jsonrpc and vscode-languageserver-protocol. * Returns null if not available. Caches result after first call. */ async function loadLSPDeps() { if (jsonrpcModule !== void 0 && lspProtocolModule !== void 0) { if (jsonrpcModule === null || lspProtocolModule === null) return null; return { ...jsonrpcModule, ...lspProtocolModule }; } try { const req = createRequire(import.meta.url); const jsonrpc = req("vscode-jsonrpc/node"); const protocol = req("vscode-languageserver-protocol"); jsonrpcModule = { StreamMessageReader: jsonrpc.StreamMessageReader, StreamMessageWriter: jsonrpc.StreamMessageWriter, createMessageConnection: jsonrpc.createMessageConnection }; lspProtocolModule = { TextDocumentIdentifier: protocol.TextDocumentIdentifier, Position: protocol.Position }; return { ...jsonrpcModule, ...lspProtocolModule }; } catch { jsonrpcModule = null; lspProtocolModule = null; return null; } } /** Convert a filesystem path to a properly encoded file:// URI. */ function toFileUri(fsPath) { return pathToFileURL(fsPath).toString(); } /** * Normalize a file:// URI to a canonical fs-path-based key for diagnostics * map storage/lookup. On Windows, different LSP servers emit different * canonical forms for the same path (e.g. `file:///C:/...` vs * `file:///c%3A/...`), so we convert back to an OS path and compare those * instead of comparing URI strings directly. */ function diagnosticsKey(uriOrPath) { let fsPath; try { fsPath = uriOrPath.startsWith("file:") ? fileURLToPath(uriOrPath) : uriOrPath; } catch { return uriOrPath; } const driveMatch = fsPath.match(/^[\\/]?([a-zA-Z]):([\\/].*)$/); if (driveMatch) return `${driveMatch[1].toLowerCase()}:${driveMatch[2]}`; return fsPath; } async function withTimeout(promise, ms, errorMessage) { let timer; return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(errorMessage)), ms); })]).finally(() => clearTimeout(timer)); } /** * Wraps a JSON-RPC connection to a single LSP server process. * Uses a SandboxProcessManager to spawn the server process. */ var LSPClient = class { connection = null; handle = null; serverDef; workspaceRoot; processManager; diagnostics = /* @__PURE__ */ new Map(); initializationOptions = null; supportsPullDiagnostics = false; constructor(serverDef, workspaceRoot, processManager) { this.serverDef = serverDef; this.workspaceRoot = workspaceRoot; this.processManager = processManager; } /** Whether the underlying server process is still running. */ get isAlive() { return this.handle !== null && this.handle.exitCode === void 0; } /** Name of the LSP server. */ get serverName() { return this.serverDef.name; } /** * Initialize the LSP connection — spawns the server and performs the handshake. */ async initialize(initTimeout = 1e4) { const deps = await loadLSPDeps(); if (!deps) throw new Error("LSP dependencies (vscode-jsonrpc) are not available"); const { StreamMessageReader, StreamMessageWriter, createMessageConnection } = deps; const command = this.serverDef.command(this.workspaceRoot); if (!command) throw new Error("Failed to resolve LSP server command"); this.handle = await this.processManager.spawn(command, { cwd: this.workspaceRoot }); const initializationOptions = this.serverDef.initialization?.(this.workspaceRoot); const reader = new StreamMessageReader(this.handle.reader); const writer = new StreamMessageWriter(this.handle.writer); const originalWrite = writer.write.bind(writer); writer.write = (msg) => originalWrite(msg).catch(() => {}); this.connection = createMessageConnection(reader, writer); this.connection.onError(() => {}); this.connection.onNotification("textDocument/publishDiagnostics", (params) => { this.diagnostics.set(diagnosticsKey(params.uri), params.diagnostics); }); this.connection.listen(); const initParams = { processId: process.pid, rootUri: toFileUri(this.workspaceRoot), workspaceFolders: [{ name: "workspace", uri: toFileUri(this.workspaceRoot) }], capabilities: { window: { workDoneProgress: true }, workspace: { configuration: true }, textDocument: { publishDiagnostics: { relatedInformation: true, tagSupport: { valueSet: [1, 2] }, versionSupport: false }, synchronization: { didOpen: true, didChange: true, dynamicRegistration: false, willSave: false, willSaveWaitUntil: false, didSave: false }, completion: { dynamicRegistration: false, completionItem: { snippetSupport: false, commitCharactersSupport: false, documentationFormat: ["markdown", "plaintext"], deprecatedSupport: false, preselectSupport: false } }, definition: { dynamicRegistration: false, linkSupport: true }, typeDefinition: { dynamicRegistration: false, linkSupport: true }, implementation: { dynamicRegistration: false, linkSupport: true }, references: { dynamicRegistration: false }, documentHighlight: { dynamicRegistration: false }, documentSymbol: { dynamicRegist