From 7630ab7538daaf0756e8430b1ad1501757291a39 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 2 Jan 2026 14:39:01 +0100 Subject: [PATCH] Update mcp.test.ts --- playwriter/src/mcp.test.ts | 160 ++++++++++++-- playwriter/src/mcp.ts | 113 +++++++++- playwriter/src/scoped-fs.ts | 411 ++++++++++++++++++++++++++++++++++++ 3 files changed, 665 insertions(+), 19 deletions(-) create mode 100644 playwriter/src/scoped-fs.ts diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 40bc6c9..bee29d6 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -824,6 +824,83 @@ describe('MCP Server Tests', () => { await page.goto('about:blank') }) + it('should auto-reconnect MCP after extension WebSocket reconnects', async () => { + // This test verifies that the MCP automatically reconnects when the browser + // disconnects (e.g., when the extension WebSocket reconnects and the relay + // server closes all playwright clients). The fix adds browser.on('disconnected') + // handler that clears state.isConnected, so ensureConnection() creates a new connection. + + const serviceWorker = await getExtensionServiceWorker(testCtx!.browserContext) + + // 1. Create a test page and enable extension + const page = await testCtx!.browserContext.newPage() + await page.goto('https://example.com/auto-reconnect-test') + await page.waitForLoadState('domcontentloaded') + await page.bringToFront() + + const initialEnable = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + expect(initialEnable.isConnected).toBe(true) + await new Promise(resolve => setTimeout(resolve, 100)) + + // 2. Verify MCP can execute commands + const beforeResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` + const pages = context.pages(); + const testPage = pages.find(p => p.url().includes('auto-reconnect-test')); + return { pagesCount: pages.length, foundTestPage: !!testPage }; + `, + }, + }) + const beforeOutput = (beforeResult as any).content[0].text + expect(beforeOutput).toContain('foundTestPage') + expect(beforeOutput).toContain('true') + + // 3. Simulate extension WebSocket reconnection + // This causes relay server to close all playwright client WebSockets + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise(resolve => setTimeout(resolve, 100)) + + // Re-enable extension (simulates extension reconnecting) + await page.bringToFront() + const reconnectResult = await serviceWorker.evaluate(async () => { + return await globalThis.toggleExtensionForActiveTab() + }) + expect(reconnectResult.isConnected).toBe(true) + await new Promise(resolve => setTimeout(resolve, 100)) + + // 4. Execute command WITHOUT calling resetPlaywright() + // The browser.on('disconnected') handler should have cleared state.isConnected, + // causing ensureConnection() to automatically create a new connection + const afterResult = await client.callTool({ + name: 'execute', + arguments: { + code: js` + const pages = context.pages(); + const testPage = pages.find(p => p.url().includes('auto-reconnect-test')); + return { pagesCount: pages.length, foundTestPage: !!testPage, url: testPage?.url() }; + `, + }, + }) + + const afterOutput = (afterResult as any).content[0].text + // The command should succeed and find our test page + expect(afterOutput).toContain('foundTestPage') + expect(afterOutput).toContain('true') + expect(afterOutput).toContain('auto-reconnect-test') + // Should NOT contain error about extension not connected + expect(afterOutput).not.toContain('Extension not connected') + expect((afterResult as any).isError).not.toBe(true) + + // Clean up + await page.goto('about:blank') + }) + it('should capture browser console logs with getLatestLogs', async () => { // Ensure clean state and clear any existing logs const resetResult = await client.callTool({ @@ -1260,10 +1337,14 @@ describe('MCP Server Tests', () => { await page.close() }, 30000) - it('should be usable after toggle with waitForEvent', async () => { - // This test demonstrates the proper way to wait for a page after toggle. - // Instead of arbitrary sleep(), use context.waitForEvent('page'). - // See page-ready-investigation.md for details on why this is needed. + it('should be usable after toggle with valid URL', async () => { + // This test validates the extension properly waits for valid URLs before + // sending Target.attachedToTarget. Uses Discord - a heavy React SPA. + // + // We use waitForEvent('page') to wait for Playwright to process the event. + // The KEY assertion is that when the event fires, the URL is VALID (not empty). + // Before the fix: event fired with empty URL -> page broken forever + // After the fix: event fires with valid URL -> page works immediately const _browserContext = getBrowserContext() const serviceWorker = await getExtensionServiceWorker(_browserContext) @@ -1271,33 +1352,76 @@ describe('MCP Server Tests', () => { const context = browser.contexts()[0] const page = await _browserContext.newPage() - await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }) + await page.goto('https://discord.com/login') await page.bringToFront() - // Set up page listener BEFORE toggle - this is the key pattern - const pagePromise = context.waitForEvent('page', { - predicate: p => p.url().includes('example.com'), - timeout: 5000 - }) + // Set up listener BEFORE toggle + const pagePromise = context.waitForEvent('page', { timeout: 10000 }) - // Toggle extension - no sleep needed after! + // Toggle extension - extension waits for valid URL before sending event await serviceWorker.evaluate(async () => { await globalThis.toggleExtensionForActiveTab() }) - // Wait for page event - this resolves when Playwright has fully processed the page + // Wait for page event const targetPage = await pagePromise + console.log('Page URL when event fired:', targetPage.url()) - // Page is now guaranteed to be ready - expect(targetPage.url()).toContain('example.com') + // KEY ASSERTION: URL must NOT be empty - this is what the extension fix guarantees + expect(targetPage.url()).not.toBe('') + expect(targetPage.url()).not.toBe(':') + expect(targetPage.url()).toContain('discord.com') - // evaluate() works immediately + // evaluate() works immediately - no waiting needed const result = await targetPage.evaluate(() => window.location.href) - expect(result).toContain('example.com') + expect(result).toContain('discord.com') await browser.close() await page.close() - }, 30000) + }, 60000) + + it('should have non-empty URLs when connecting to already-loaded pages', async () => { + // This test validates that when we connect to a browser with already-loaded pages, + // all pages have non-empty URLs. Empty URLs break Playwright permanently. + + const _browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(_browserContext) + + // Create and fully load a heavy page BEFORE connecting + const page = await _browserContext.newPage() + await page.goto('https://discord.com/login', { waitUntil: 'load' }) + await page.bringToFront() + + // Toggle extension to attach to the loaded page + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + // NOW connect via CDP - page should already be attached + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const context = browser.contexts()[0] + + // Get all pages and verify NONE have empty URLs + const pages = context.pages() + console.log('All page URLs:', pages.map(p => p.url())) + + expect(pages.length).toBeGreaterThan(0) + for (const p of pages) { + expect(p.url()).not.toBe('') + expect(p.url()).not.toBe(':') + expect(p.url()).not.toBeUndefined() + } + + // Find Discord page and verify it works + const discordPage = pages.find(p => p.url().includes('discord.com')) + expect(discordPage).toBeDefined() + + const result = await discordPage!.evaluate(() => window.location.href) + expect(result).toContain('discord.com') + + await browser.close() + await page.close() + }, 60000) it('should maintain correct page.url() with iframe-heavy pages', async () => { const browserContext = getBrowserContext() @@ -1993,6 +2117,8 @@ describe('MCP Server Tests', () => { await page.close() }, 60000) + + }) diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 3ff8e00..74c0ca7 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -20,6 +20,7 @@ import { Debugger } from './debugger.js' import { Editor } from './editor.js' import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js' import { getReactSource, type ReactSourceLocation } from './react-source.js' +import { ScopedFS } from './scoped-fs.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -114,6 +115,102 @@ const cdpSessionCache: WeakMap = new WeakMap() const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988 const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe` +// Create a scoped fs instance that allows access to cwd, /tmp, and os.tmpdir() +const scopedFs = new ScopedFS() + +/** + * Allowlist of Node.js built-in modules that are safe to use in the sandbox. + * Dangerous modules like child_process, cluster, worker_threads, vm, net are blocked. + */ +const ALLOWED_MODULES = new Set([ + // Safe utility modules + 'path', + 'node:path', + 'url', + 'node:url', + 'querystring', + 'node:querystring', + 'punycode', + 'node:punycode', + + // Crypto and encoding + 'crypto', + 'node:crypto', + 'buffer', + 'node:buffer', + 'string_decoder', + 'node:string_decoder', + + // Utilities + 'util', + 'node:util', + 'assert', + 'node:assert', + 'events', + 'node:events', + 'timers', + 'node:timers', + + // Streams and compression + 'stream', + 'node:stream', + 'zlib', + 'node:zlib', + + // HTTP (fetch is already available, these are consistent) + 'http', + 'node:http', + 'https', + 'node:https', + 'http2', + 'node:http2', + + // System info (read-only, useful for debugging) + 'os', + 'node:os', + + // fs is allowed but returns sandboxed version + 'fs', + 'node:fs', +]) + +/** + * Create a sandboxed require function that: + * 1. Returns scoped fs for 'fs' and 'node:fs' + * 2. Only allows modules in the ALLOWED_MODULES allowlist + * 3. Blocks all other modules (child_process, net, vm, third-party packages, etc.) + */ +function createSandboxedRequire(originalRequire: NodeRequire): NodeRequire { + const sandboxedRequire = ((id: string) => { + // Check allowlist first + if (!ALLOWED_MODULES.has(id)) { + const error = new Error( + `Module "${id}" is not allowed in the sandbox. ` + + `Only safe Node.js built-ins are permitted: ${[...ALLOWED_MODULES].filter((m) => !m.startsWith('node:')).join(', ')}`, + ) + error.name = 'ModuleNotAllowedError' + throw error + } + + // Return sandboxed fs + if (id === 'fs' || id === 'node:fs') { + return scopedFs + } + + return originalRequire(id) + }) as NodeRequire + + // Copy over require properties + sandboxedRequire.resolve = originalRequire.resolve + sandboxedRequire.cache = originalRequire.cache + sandboxedRequire.extensions = originalRequire.extensions + sandboxedRequire.main = originalRequire.main + + return sandboxedRequire +} + +const sandboxedRequire = createSandboxedRequire(require) + interface RemoteConfig { host: string port: number @@ -304,6 +401,12 @@ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> { const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT }) const browser = await chromium.connectOverCDP(cdpEndpoint) + // Clear connection state when browser disconnects (e.g., extension reconnects, relay server restarts) + browser.on('disconnected', () => { + mcpLog('Browser disconnected, clearing connection state') + clearConnectionState() + }) + const contexts = browser.contexts() const context = contexts.length > 0 ? contexts[0] : await browser.newContext() @@ -437,6 +540,12 @@ async function resetConnection(): Promise<{ browser: Browser; page: Page; contex const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT }) const browser = await chromium.connectOverCDP(cdpEndpoint) + // Clear connection state when browser disconnects (e.g., extension reconnects, relay server restarts) + browser.on('disconnected', () => { + mcpLog('Browser disconnected, clearing connection state') + clearConnectionState() + }) + const contexts = browser.contexts() const context = contexts.length > 0 ? contexts[0] : await browser.newContext() @@ -788,7 +897,7 @@ server.tool( formatStylesAsText, getReactSource: getReactSourceFn, resetPlaywright: vmContextObj.resetPlaywright, - require, + require: sandboxedRequire, // TODO --experimental-vm-modules is needed to make import work in vm import: vmContextObj.import, ...usefulGlobals, @@ -797,7 +906,7 @@ server.tool( Object.assign(vmContextObj, resetObj) return { page: newPage, context: newContext } }, - require, + require: sandboxedRequire, import: (specifier: string) => import(specifier), ...usefulGlobals, } diff --git a/playwriter/src/scoped-fs.ts b/playwriter/src/scoped-fs.ts new file mode 100644 index 0000000..bdaa718 --- /dev/null +++ b/playwriter/src/scoped-fs.ts @@ -0,0 +1,411 @@ +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' + +/** + * A sandboxed fs wrapper that restricts all file operations to allowed directories. + * Any attempt to access files outside the allowed directories will throw an EPERM error. + * + * By default, allows access to: + * - Current working directory (process.cwd()) + * - /tmp + * - os.tmpdir() + * + * This is used in the MCP VM context to prevent agents from accessing sensitive system files. + */ +export class ScopedFS { + private allowedDirs: string[] + + constructor(allowedDirs?: string[]) { + // Default allowed directories: cwd, /tmp, os.tmpdir() + const defaultDirs = [process.cwd(), '/tmp', os.tmpdir()] + + // Use provided dirs or defaults, resolve all to absolute paths + const dirs = allowedDirs ?? defaultDirs + this.allowedDirs = [...new Set(dirs.map((d) => path.resolve(d)))] + } + + /** + * Check if a resolved path is within any of the allowed directories. + */ + private isPathAllowed(resolved: string): boolean { + return this.allowedDirs.some((dir) => { + return resolved === dir || resolved.startsWith(dir + path.sep) + }) + } + + /** + * Resolve a path and ensure it stays within allowed directories. + * Throws EPERM if the resolved path escapes the sandbox. + */ + private resolvePath(filePath: string): string { + // If it's an absolute path, use it directly + // If it's relative, resolve from cwd + const resolved = path.resolve(filePath) + + if (!this.isPathAllowed(resolved)) { + const error = new Error( + `EPERM: operation not permitted, access outside allowed directories: ${filePath}`, + ) as NodeJS.ErrnoException + error.code = 'EPERM' + error.errno = -1 + error.syscall = 'access' + error.path = filePath + throw error + } + return resolved + } + + // Sync methods + + readFileSync = (filePath: fs.PathOrFileDescriptor, options?: any): any => { + const resolved = this.resolvePath(filePath.toString()) + return fs.readFileSync(resolved, options) + } + + writeFileSync = (filePath: fs.PathOrFileDescriptor, data: any, options?: any): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.writeFileSync(resolved, data, options) + } + + appendFileSync = (filePath: fs.PathOrFileDescriptor, data: any, options?: any): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.appendFileSync(resolved, data, options) + } + + readdirSync = (dirPath: fs.PathLike, options?: any): any => { + const resolved = this.resolvePath(dirPath.toString()) + return fs.readdirSync(resolved, options) + } + + mkdirSync = (dirPath: fs.PathLike, options?: any): any => { + const resolved = this.resolvePath(dirPath.toString()) + return fs.mkdirSync(resolved, options) + } + + rmdirSync = (dirPath: fs.PathLike, options?: any): void => { + const resolved = this.resolvePath(dirPath.toString()) + fs.rmdirSync(resolved, options) + } + + unlinkSync = (filePath: fs.PathLike): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.unlinkSync(resolved) + } + + statSync = (filePath: fs.PathLike, options?: any): any => { + const resolved = this.resolvePath(filePath.toString()) + return fs.statSync(resolved, options) + } + + lstatSync = (filePath: fs.PathLike, options?: any): any => { + const resolved = this.resolvePath(filePath.toString()) + return fs.lstatSync(resolved, options) + } + + existsSync = (filePath: fs.PathLike): boolean => { + try { + const resolved = this.resolvePath(filePath.toString()) + return fs.existsSync(resolved) + } catch { + return false + } + } + + accessSync = (filePath: fs.PathLike, mode?: number): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.accessSync(resolved, mode) + } + + copyFileSync = (src: fs.PathLike, dest: fs.PathLike, mode?: number): void => { + const resolvedSrc = this.resolvePath(src.toString()) + const resolvedDest = this.resolvePath(dest.toString()) + fs.copyFileSync(resolvedSrc, resolvedDest, mode) + } + + renameSync = (oldPath: fs.PathLike, newPath: fs.PathLike): void => { + const resolvedOld = this.resolvePath(oldPath.toString()) + const resolvedNew = this.resolvePath(newPath.toString()) + fs.renameSync(resolvedOld, resolvedNew) + } + + chmodSync = (filePath: fs.PathLike, mode: fs.Mode): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.chmodSync(resolved, mode) + } + + chownSync = (filePath: fs.PathLike, uid: number, gid: number): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.chownSync(resolved, uid, gid) + } + + utimesSync = (filePath: fs.PathLike, atime: fs.TimeLike, mtime: fs.TimeLike): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.utimesSync(resolved, atime, mtime) + } + + realpathSync = (filePath: fs.PathLike, options?: any): any => { + const resolved = this.resolvePath(filePath.toString()) + const real = fs.realpathSync(resolved, options) + // Verify the real path is also within allowed directories (handles symlinks) + const realStr = real.toString() + if (!this.isPathAllowed(realStr)) { + const error = new Error(`EPERM: operation not permitted, realpath escapes allowed directories`) as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + return real + } + + readlinkSync = (filePath: fs.PathLike, options?: any): any => { + const resolved = this.resolvePath(filePath.toString()) + return fs.readlinkSync(resolved, options) + } + + symlinkSync = (target: fs.PathLike, linkPath: fs.PathLike, type?: fs.symlink.Type | null): void => { + const resolvedLink = this.resolvePath(linkPath.toString()) + // Target is relative to link location, resolve it to check bounds + const linkDir = path.dirname(resolvedLink) + const resolvedTarget = path.resolve(linkDir, target.toString()) + if (!this.isPathAllowed(resolvedTarget)) { + const error = new Error(`EPERM: operation not permitted, symlink target outside allowed directories`) as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + fs.symlinkSync(target, resolvedLink, type) + } + + rmSync = (filePath: fs.PathLike, options?: fs.RmOptions): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.rmSync(resolved, options) + } + + // Async callback methods + + readFile = (filePath: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.readFile as any)(resolved, ...args) + } + + writeFile = (filePath: any, data: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.writeFile as any)(resolved, data, ...args) + } + + appendFile = (filePath: any, data: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.appendFile as any)(resolved, data, ...args) + } + + readdir = (dirPath: any, ...args: any[]): void => { + const resolved = this.resolvePath(dirPath.toString()) + ;(fs.readdir as any)(resolved, ...args) + } + + mkdir = (dirPath: any, ...args: any[]): void => { + const resolved = this.resolvePath(dirPath.toString()) + ;(fs.mkdir as any)(resolved, ...args) + } + + rmdir = (dirPath: any, ...args: any[]): void => { + const resolved = this.resolvePath(dirPath.toString()) + ;(fs.rmdir as any)(resolved, ...args) + } + + unlink = (filePath: any, callback: any): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.unlink(resolved, callback) + } + + stat = (filePath: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.stat as any)(resolved, ...args) + } + + lstat = (filePath: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.lstat as any)(resolved, ...args) + } + + access = (filePath: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.access as any)(resolved, ...args) + } + + copyFile = (src: any, dest: any, ...args: any[]): void => { + const resolvedSrc = this.resolvePath(src.toString()) + const resolvedDest = this.resolvePath(dest.toString()) + ;(fs.copyFile as any)(resolvedSrc, resolvedDest, ...args) + } + + rename = (oldPath: any, newPath: any, callback: any): void => { + const resolvedOld = this.resolvePath(oldPath.toString()) + const resolvedNew = this.resolvePath(newPath.toString()) + fs.rename(resolvedOld, resolvedNew, callback) + } + + chmod = (filePath: any, mode: any, callback: any): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.chmod(resolved, mode, callback) + } + + chown = (filePath: any, uid: any, gid: any, callback: any): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.chown(resolved, uid, gid, callback) + } + + rm = (filePath: any, ...args: any[]): void => { + const resolved = this.resolvePath(filePath.toString()) + ;(fs.rm as any)(resolved, ...args) + } + + exists = (filePath: any, callback: any): void => { + try { + const resolved = this.resolvePath(filePath.toString()) + fs.exists(resolved, callback) + } catch { + callback(false) + } + } + + // Stream methods + + createReadStream = (filePath: fs.PathLike, options?: any): fs.ReadStream => { + const resolved = this.resolvePath(filePath.toString()) + return fs.createReadStream(resolved, options) + } + + createWriteStream = (filePath: fs.PathLike, options?: any): fs.WriteStream => { + const resolved = this.resolvePath(filePath.toString()) + return fs.createWriteStream(resolved, options) + } + + // Watch methods + + watch = (filePath: any, ...args: any[]): fs.FSWatcher => { + const resolved = this.resolvePath(filePath.toString()) + return (fs.watch as any)(resolved, ...args) + } + + watchFile = (filePath: any, ...args: any[]): fs.StatWatcher => { + const resolved = this.resolvePath(filePath.toString()) + return (fs.watchFile as any)(resolved, ...args) + } + + unwatchFile = (filePath: any, listener?: any): void => { + const resolved = this.resolvePath(filePath.toString()) + fs.unwatchFile(resolved, listener) + } + + // Promise-based API (fs.promises equivalent) + get promises() { + const self = this + return { + readFile: async (filePath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.readFile(resolved, options) + }, + writeFile: async (filePath: fs.PathLike, data: any, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.writeFile(resolved, data, options) + }, + appendFile: async (filePath: fs.PathLike, data: any, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.appendFile(resolved, data, options) + }, + readdir: async (dirPath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(dirPath.toString()) + return fs.promises.readdir(resolved, options) + }, + mkdir: async (dirPath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(dirPath.toString()) + return fs.promises.mkdir(resolved, options) + }, + rmdir: async (dirPath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(dirPath.toString()) + return fs.promises.rmdir(resolved, options) + }, + unlink: async (filePath: fs.PathLike) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.unlink(resolved) + }, + stat: async (filePath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.stat(resolved, options) + }, + lstat: async (filePath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.lstat(resolved, options) + }, + access: async (filePath: fs.PathLike, mode?: number) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.access(resolved, mode) + }, + copyFile: async (src: fs.PathLike, dest: fs.PathLike, mode?: number) => { + const resolved = self.resolvePath(src.toString()) + const resolvedDest = self.resolvePath(dest.toString()) + return fs.promises.copyFile(resolved, resolvedDest, mode) + }, + rename: async (oldPath: fs.PathLike, newPath: fs.PathLike) => { + const resolvedOld = self.resolvePath(oldPath.toString()) + const resolvedNew = self.resolvePath(newPath.toString()) + return fs.promises.rename(resolvedOld, resolvedNew) + }, + chmod: async (filePath: fs.PathLike, mode: fs.Mode) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.chmod(resolved, mode) + }, + chown: async (filePath: fs.PathLike, uid: number, gid: number) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.chown(resolved, uid, gid) + }, + rm: async (filePath: fs.PathLike, options?: fs.RmOptions) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.rm(resolved, options) + }, + realpath: async (filePath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + const real = await fs.promises.realpath(resolved, options) + const realStr = real.toString() + if (!self.isPathAllowed(realStr)) { + const error = new Error(`EPERM: operation not permitted, realpath escapes allowed directories`) as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + return real + }, + readlink: async (filePath: fs.PathLike, options?: any) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.readlink(resolved, options) + }, + symlink: async (target: fs.PathLike, linkPath: fs.PathLike, type?: string) => { + const resolvedLink = self.resolvePath(linkPath.toString()) + const linkDir = path.dirname(resolvedLink) + const resolvedTarget = path.resolve(linkDir, target.toString()) + if (!self.isPathAllowed(resolvedTarget)) { + const error = new Error( + `EPERM: operation not permitted, symlink target outside allowed directories`, + ) as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + return fs.promises.symlink(target, resolvedLink, type as any) + }, + utimes: async (filePath: fs.PathLike, atime: fs.TimeLike, mtime: fs.TimeLike) => { + const resolved = self.resolvePath(filePath.toString()) + return fs.promises.utimes(resolved, atime, mtime) + }, + } + } + + // Constants passthrough + constants = fs.constants +} + +/** + * Create a scoped fs instance with allowed directories. + * Defaults to cwd, /tmp, and os.tmpdir() if no directories specified. + */ +export function createScopedFS(allowedDirs?: string[]): ScopedFS { + return new ScopedFS(allowedDirs) +}