Update mcp.test.ts

This commit is contained in:
Tommy D. Rossi
2026-01-02 14:39:01 +01:00
parent e3cb7fd1fd
commit 7630ab7538
3 changed files with 665 additions and 19 deletions
+143 -17
View File
@@ -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)
})
+111 -2
View File
@@ -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<Page, CDPSession> = 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,
}
+411
View File
@@ -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)
}