Update mcp.test.ts
This commit is contained in:
+143
-17
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user