mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
added LocalBackend support for disk sandbox.
- Added SANDBOX_LOCAL_PATH incase users want to edit the path - adds MAX_LIST_OBJECTS for glob and grep - pinned 20.12.0 < 21.0.0 to support file system calls
This commit is contained in:
+1
-1
@@ -93,7 +93,7 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20",
|
||||
"node": ">=20.12.0 <21.0.0",
|
||||
"pnpm": "^10.26.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -48,3 +48,6 @@ export interface BackendProtocol {
|
||||
// paginate large text files so a single read can't blow the model's context window
|
||||
// (~500 lines ≈ a few thousand tokens). Matches Claude Code's Read tool default.
|
||||
export const DEFAULT_READ_LIMIT = 500
|
||||
|
||||
// Cap on results returned by listing operations (glob, grep) across all backends.
|
||||
export const MAX_LIST_OBJECTS = 1_000
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { LocalBackend } from './LocalBackend'
|
||||
|
||||
const tmpRoots: string[] = []
|
||||
|
||||
function makeBackend(): LocalBackend {
|
||||
const root = mkdtempSync(join(tmpdir(), 'flowise-local-'))
|
||||
tmpRoots.push(root)
|
||||
return new LocalBackend(root)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of tmpRoots) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
tmpRoots.length = 0
|
||||
})
|
||||
|
||||
describe('LocalBackend — constructor', () => {
|
||||
it('creates the rootPath directory if it does not exist', () => {
|
||||
const root = join(tmpdir(), `flowise-local-create-${Date.now()}`)
|
||||
tmpRoots.push(root)
|
||||
expect(existsSync(root)).toBe(false)
|
||||
// eslint-disable-next-line no-new
|
||||
new LocalBackend(root)
|
||||
expect(existsSync(root)).toBe(true)
|
||||
})
|
||||
|
||||
it('empty-string rootPath does NOT resolve to CWD (security: prevents sandbox escape)', () => {
|
||||
const backend = new LocalBackend('')
|
||||
const root = (backend as unknown as { root: string }).root
|
||||
expect(root).not.toBe(process.cwd())
|
||||
expect(root).toBe(resolve(homedir(), '.flowise', 'sandbox'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — write', () => {
|
||||
it('write of new file returns { path, filesUpdate: null }', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.write('/workspace/notes.md', '# Notes')
|
||||
expect('path' in result).toBe(true)
|
||||
if ('path' in result) {
|
||||
expect(result.path).toBe('/workspace/notes.md')
|
||||
expect(result.filesUpdate).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('duplicate write returns an error mentioning the path', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/workspace/file.txt', 'original')
|
||||
const result = await backend.write('/workspace/file.txt', 'duplicate')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) {
|
||||
expect(result.error).toContain('/workspace/file.txt')
|
||||
expect(result.error).toContain('already exists')
|
||||
}
|
||||
})
|
||||
|
||||
it('writes Uint8Array content to disk for binary mime types', async () => {
|
||||
const backend = makeBackend()
|
||||
const png = new Uint8Array([137, 80, 78, 71])
|
||||
const result = await backend.write('/img.png', png)
|
||||
expect('path' in result).toBe(true)
|
||||
})
|
||||
|
||||
it('creates intermediate directories', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.write('/a/b/c/deep.txt', 'hi')
|
||||
expect('path' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — read', () => {
|
||||
it('write + read round-trip returns the written content', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/workspace/hello.txt', 'hi from disk')
|
||||
const result = await backend.read('/workspace/hello.txt')
|
||||
expect('content' in result).toBe(true)
|
||||
if ('content' in result) {
|
||||
expect(result.content).toBe('hi from disk')
|
||||
expect(result.mimeType).toBe('text/plain')
|
||||
}
|
||||
})
|
||||
|
||||
it('read of missing file returns an error', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.read('/workspace/missing.txt')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) {
|
||||
expect(result.error).toContain('/workspace/missing.txt')
|
||||
}
|
||||
})
|
||||
|
||||
it('read returns binary Uint8Array for binary mime types', async () => {
|
||||
const backend = makeBackend()
|
||||
const png = new Uint8Array([137, 80, 78, 71])
|
||||
await backend.write('/img.png', png)
|
||||
const result = await backend.read('/img.png')
|
||||
expect('content' in result).toBe(true)
|
||||
if ('content' in result) {
|
||||
expect(result.content).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(result.content as Uint8Array)).toEqual([137, 80, 78, 71])
|
||||
expect(result.mimeType).toBe('image/png')
|
||||
}
|
||||
})
|
||||
|
||||
it('read with offset and limit paginates lines', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/paginated.txt', 'line1\nline2\nline3\nline4\nline5')
|
||||
const result = await backend.read('/paginated.txt', 1, 2)
|
||||
expect('content' in result).toBe(true)
|
||||
if ('content' in result) {
|
||||
const lines = (result.content as string).split('\n').filter(Boolean)
|
||||
expect(lines).toEqual(['line2', 'line3'])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — readRaw', () => {
|
||||
it('returns the underlying FileData', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/raw.txt', 'raw bytes')
|
||||
const result = await backend.readRaw('/raw.txt')
|
||||
expect('data' in result).toBe(true)
|
||||
if ('data' in result) {
|
||||
expect(result.data.content).toBe('raw bytes')
|
||||
expect(result.data.mimeType).toBe('text/plain')
|
||||
expect(typeof result.data.created_at).toBe('number')
|
||||
expect(typeof result.data.modified_at).toBe('number')
|
||||
}
|
||||
})
|
||||
|
||||
it('readRaw of missing file returns an error', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.readRaw('/nope.txt')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — edit', () => {
|
||||
it('replaces a single match and returns { path, occurrences, filesUpdate: null }', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/edit.txt', 'hello world')
|
||||
const result = await backend.edit('/edit.txt', 'world', 'sandbox')
|
||||
expect('error' in result).toBe(false)
|
||||
if (!('error' in result)) {
|
||||
expect(result.path).toBe('/edit.txt')
|
||||
expect(result.occurrences).toBe(1)
|
||||
expect(result.filesUpdate).toBeNull()
|
||||
}
|
||||
const after = await backend.read('/edit.txt')
|
||||
if ('content' in after) expect(after.content).toBe('hello sandbox')
|
||||
})
|
||||
|
||||
it('replaceAll replaces every occurrence', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/all.txt', 'foo bar foo')
|
||||
const result = await backend.edit('/all.txt', 'foo', 'baz', true)
|
||||
if (!('error' in result)) expect(result.occurrences).toBe(2)
|
||||
const after = await backend.read('/all.txt')
|
||||
if ('content' in after) expect(after.content).toBe('baz bar baz')
|
||||
})
|
||||
|
||||
it('zero matches returns an error mentioning the path', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/empty.txt', 'hello')
|
||||
const result = await backend.edit('/empty.txt', 'missing', 'x')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toContain('/empty.txt')
|
||||
})
|
||||
|
||||
it('multiple matches without replaceAll errors and reports the count', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/multi.txt', 'foo foo foo')
|
||||
const result = await backend.edit('/multi.txt', 'foo', 'bar')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toContain('3')
|
||||
})
|
||||
|
||||
it('errors when target file is binary', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/img.png', new Uint8Array([137, 80, 78, 71]))
|
||||
const result = await backend.edit('/img.png', 'a', 'b')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toMatch(/binary/i)
|
||||
})
|
||||
|
||||
it('errors when file is missing', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.edit('/none.txt', 'a', 'b')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — ls', () => {
|
||||
async function seed(): Promise<LocalBackend> {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/a.txt', 'A')
|
||||
await backend.write('/dir/b.txt', 'B')
|
||||
await backend.write('/dir/sub/c.txt', 'C')
|
||||
return backend
|
||||
}
|
||||
|
||||
it('ls("/") returns top-level file and directory entries', async () => {
|
||||
const backend = await seed()
|
||||
const result = await backend.ls('/')
|
||||
expect('files' in result).toBe(true)
|
||||
if ('files' in result) {
|
||||
const names = result.files.map((f) => f.name)
|
||||
expect(names).toContain('a.txt')
|
||||
expect(names).toContain('dir')
|
||||
const dir = result.files.find((f) => f.name === 'dir')!
|
||||
expect(dir.isDirectory).toBe(true)
|
||||
const file = result.files.find((f) => f.name === 'a.txt')!
|
||||
expect(file.isDirectory).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('ls("/dir") lists nested files and the deeper subdirectory', async () => {
|
||||
const backend = await seed()
|
||||
const result = await backend.ls('/dir')
|
||||
expect('files' in result).toBe(true)
|
||||
if ('files' in result) {
|
||||
const names = result.files.map((f) => f.name)
|
||||
expect(names).toContain('b.txt')
|
||||
expect(names).toContain('sub')
|
||||
const sub = result.files.find((f) => f.name === 'sub')!
|
||||
expect(sub.isDirectory).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns entries sorted alphabetically', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/zeta.txt', '')
|
||||
await backend.write('/alpha.txt', '')
|
||||
await backend.write('/mu.txt', '')
|
||||
const result = await backend.ls('/')
|
||||
if ('files' in result) {
|
||||
const names = result.files.map((f) => f.name)
|
||||
expect(names).toEqual(['alpha.txt', 'mu.txt', 'zeta.txt'])
|
||||
}
|
||||
})
|
||||
|
||||
it('returns an empty list for a missing directory', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.ls('/does/not/exist')
|
||||
if ('files' in result) expect(result.files).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — glob', () => {
|
||||
it('matches files by pattern across directories', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/src/foo.ts', '')
|
||||
await backend.write('/src/bar.js', '')
|
||||
await backend.write('/lib/baz.ts', '')
|
||||
const result = await backend.glob('**/*.ts', '/')
|
||||
expect('files' in result).toBe(true)
|
||||
if ('files' in result) {
|
||||
const paths = result.files.map((f) => f.path).sort()
|
||||
expect(paths).toEqual(['/lib/baz.ts', '/src/foo.ts'])
|
||||
expect(result.truncated).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches single-segment pattern within a directory', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/src/foo.ts', '')
|
||||
await backend.write('/src/deep/bar.ts', '')
|
||||
const result = await backend.glob('*.ts', '/src')
|
||||
if ('files' in result) {
|
||||
const paths = result.files.map((f) => f.path)
|
||||
expect(paths).toEqual(['/src/foo.ts'])
|
||||
}
|
||||
})
|
||||
|
||||
it('caps results at MAX_LIST_OBJECTS and reports truncated:true', async () => {
|
||||
const backend = makeBackend()
|
||||
// Seed 1001 files — one over the cap
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
await backend.write(`/many/file${i}.txt`, '')
|
||||
}
|
||||
const result = await backend.glob('**/*.txt', '/')
|
||||
expect('files' in result).toBe(true)
|
||||
if ('files' in result) {
|
||||
expect(result.files.length).toBe(1000)
|
||||
expect(result.truncated).toBe(true)
|
||||
}
|
||||
}, 20000)
|
||||
})
|
||||
|
||||
describe('LocalBackend — grep', () => {
|
||||
it('finds matching lines and reports 1-indexed line numbers', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/notes.txt', 'line one\nfind me\nline three')
|
||||
const result = await backend.grep('find me', '/')
|
||||
expect('matches' in result).toBe(true)
|
||||
if ('matches' in result) {
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches[0].line).toBe(2)
|
||||
expect(result.matches[0].path).toBe('/notes.txt')
|
||||
expect(result.matches[0].content).toBe('find me')
|
||||
expect(result.truncated).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('respects glob filename filter', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/a.ts', 'target')
|
||||
await backend.write('/a.js', 'target')
|
||||
const result = await backend.grep('target', '/', '*.ts')
|
||||
if ('matches' in result) {
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches[0].path).toBe('/a.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('null dirPath searches from root', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/deep/nested/file.txt', 'find me')
|
||||
const result = await backend.grep('find me', null)
|
||||
if ('matches' in result) {
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches[0].path).toBe('/deep/nested/file.txt')
|
||||
}
|
||||
})
|
||||
|
||||
it('skips binary files', async () => {
|
||||
const backend = makeBackend()
|
||||
await backend.write('/img.png', new Uint8Array([137, 80, 78, 71]))
|
||||
await backend.write('/text.txt', 'hello')
|
||||
const result = await backend.grep('hello', '/')
|
||||
if ('matches' in result) {
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches[0].path).toBe('/text.txt')
|
||||
}
|
||||
})
|
||||
|
||||
it('invalid regex returns an error', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.grep('([', '/')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toMatch(/[Ii]nvalid/)
|
||||
})
|
||||
|
||||
it('caps scanned files at MAX_LIST_OBJECTS and reports truncated:true', async () => {
|
||||
const backend = makeBackend()
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
await backend.write(`/many/file${i}.txt`, 'needle')
|
||||
}
|
||||
const result = await backend.grep('needle', '/')
|
||||
expect('matches' in result).toBe(true)
|
||||
if ('matches' in result) {
|
||||
expect(result.truncated).toBe(true)
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
describe('LocalBackend — path traversal', () => {
|
||||
it('write rejects ../ escape', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.write('/../escape.txt', 'pwn')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toMatch(/[Pp]ath traversal/)
|
||||
})
|
||||
|
||||
it('read rejects ../ escape', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.read('/../../etc/passwd')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toMatch(/[Pp]ath traversal/)
|
||||
})
|
||||
|
||||
it('readRaw rejects ../ escape', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.readRaw('/../etc/passwd')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
|
||||
it('edit rejects ../ escape', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.edit('/../escape.txt', 'a', 'b')
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) expect(result.error).toMatch(/[Pp]ath traversal/)
|
||||
})
|
||||
|
||||
it('ls rejects ../ escape', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.ls('/../')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
|
||||
it('glob rejects ../ escape on basePath', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.glob('*.txt', '/../')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
|
||||
it('grep rejects ../ escape on dirPath', async () => {
|
||||
const backend = makeBackend()
|
||||
const result = await backend.grep('hi', '/../')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
|
||||
it('legitimate paths without ../ succeed', async () => {
|
||||
const backend = makeBackend()
|
||||
const w = await backend.write('/legit.txt', 'ok')
|
||||
expect('path' in w).toBe(true)
|
||||
const r = await backend.read('/legit.txt')
|
||||
if ('content' in r) expect(r.content).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — isolation', () => {
|
||||
it('two instances on different roots do not share state', async () => {
|
||||
const a = makeBackend()
|
||||
const b = makeBackend()
|
||||
await a.write('/shared.txt', 'only in A')
|
||||
const fromB = await b.read('/shared.txt')
|
||||
expect('error' in fromB).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBackend — binary round-trip', () => {
|
||||
it('binary write/read preserves bytes and mime', async () => {
|
||||
const backend = makeBackend()
|
||||
const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
await backend.write('/bin.png', png)
|
||||
const result = await backend.read('/bin.png')
|
||||
expect('content' in result).toBe(true)
|
||||
if ('content' in result) {
|
||||
expect(result.content).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(result.content as Uint8Array)).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
expect(result.mimeType).toBe('image/png')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { mkdirSync, type Dirent, type Stats } from 'node:fs'
|
||||
import fs from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
BackendProtocol,
|
||||
DEFAULT_READ_LIMIT,
|
||||
EditResult,
|
||||
FileInfo,
|
||||
GlobResult,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
LsResult,
|
||||
MAX_LIST_OBJECTS,
|
||||
ReadRawResult,
|
||||
ReadResult,
|
||||
WriteResult
|
||||
} from '../BackendProtocol'
|
||||
import { escapeRegex, getMimeType, globToRegex, isTextMimeType, paginateLines } from '../utils'
|
||||
|
||||
/**
|
||||
* Disk-backed `BackendProtocol` implementation for development.
|
||||
*
|
||||
* Files live on the host filesystem under a configurable `rootPath`. Path
|
||||
* traversal (`../`) is rejected; deliberately-crafted symlinks pointing
|
||||
* outside `rootPath` are NOT detected (uses `fs.stat`). For strict
|
||||
* containment, wrap with `fs.realpath` checks — left out here for simplicity.
|
||||
*
|
||||
* @remarks
|
||||
* **Dev-only.** No isolation from the host filesystem beyond `rootPath`.
|
||||
* Do not enable in production. The host process can read/write anything
|
||||
* under `rootPath` as the OS user running the app.
|
||||
*/
|
||||
export class LocalBackend implements BackendProtocol {
|
||||
protected root: string
|
||||
|
||||
constructor(rootPath?: string) {
|
||||
this.root = path.resolve(rootPath || path.join(homedir(), '.flowise', 'sandbox'))
|
||||
mkdirSync(this.root, { recursive: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a virtual `/`-rooted path under `this.root`. Returns `null` if
|
||||
* the resolved absolute path escapes the root (e.g. via `../`).
|
||||
*/
|
||||
protected resolveSafe(filePath: string): string | null {
|
||||
const relative = filePath.replace(/^\/+/, '')
|
||||
const abs = path.resolve(this.root, relative)
|
||||
if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
/** Convert an absolute on-disk path back to a virtual `/`-rooted POSIX path. */
|
||||
protected toVirtual(abs: string): string {
|
||||
const rel = path.relative(this.root, abs).split(path.sep).join('/')
|
||||
return rel ? '/' + rel : '/'
|
||||
}
|
||||
|
||||
async write(filePath: string, content: string | Uint8Array): Promise<WriteResult> {
|
||||
const abs = this.resolveSafe(filePath)
|
||||
if (!abs) return { error: `Path traversal rejected: ${filePath}` }
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
try {
|
||||
await fs.writeFile(abs, content, { flag: 'wx' })
|
||||
} catch (e) {
|
||||
if (e?.code === 'EEXIST' || e?.code === 'EISDIR') {
|
||||
return { error: `Cannot write to ${filePath} because it already exists.` }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return { path: filePath, filesUpdate: null }
|
||||
}
|
||||
|
||||
async read(filePath: string, offset = 0, limit = DEFAULT_READ_LIMIT): Promise<ReadResult> {
|
||||
const abs = this.resolveSafe(filePath)
|
||||
if (!abs) return { error: `Path traversal rejected: ${filePath}` }
|
||||
let buf: Buffer
|
||||
try {
|
||||
buf = await fs.readFile(abs)
|
||||
} catch {
|
||||
return { error: `File not found: ${filePath}` }
|
||||
}
|
||||
const mimeType = getMimeType(filePath)
|
||||
if (!isTextMimeType(mimeType)) {
|
||||
return { content: new Uint8Array(buf), mimeType }
|
||||
}
|
||||
const { content, truncated } = paginateLines(buf.toString('utf8'), offset, limit)
|
||||
return { content, mimeType, truncated }
|
||||
}
|
||||
|
||||
async readRaw(filePath: string): Promise<ReadRawResult> {
|
||||
const abs = this.resolveSafe(filePath)
|
||||
if (!abs) return { error: `Path traversal rejected: ${filePath}` }
|
||||
let buf: Buffer
|
||||
let stat: Stats
|
||||
try {
|
||||
buf = await fs.readFile(abs)
|
||||
stat = await fs.stat(abs)
|
||||
} catch {
|
||||
return { error: `File not found: ${filePath}` }
|
||||
}
|
||||
const mimeType = getMimeType(filePath)
|
||||
return {
|
||||
data: {
|
||||
content: isTextMimeType(mimeType) ? buf.toString('utf8') : new Uint8Array(buf),
|
||||
mimeType,
|
||||
created_at: stat.birthtimeMs || stat.mtimeMs,
|
||||
modified_at: stat.mtimeMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async edit(filePath: string, oldStr: string, newStr: string, replaceAll = false): Promise<EditResult> {
|
||||
const abs = this.resolveSafe(filePath)
|
||||
if (!abs) return { error: `Path traversal rejected: ${filePath}` }
|
||||
const mimeType = getMimeType(filePath)
|
||||
if (!isTextMimeType(mimeType)) {
|
||||
return { error: `Cannot edit binary file: ${filePath}` }
|
||||
}
|
||||
let content: string
|
||||
try {
|
||||
content = await fs.readFile(abs, 'utf8')
|
||||
} catch {
|
||||
return { error: `File not found: ${filePath}` }
|
||||
}
|
||||
const matches = content.match(new RegExp(escapeRegex(oldStr), 'g'))
|
||||
const occurrences = matches ? matches.length : 0
|
||||
if (occurrences === 0) {
|
||||
return { error: `String not found in ${filePath}` }
|
||||
}
|
||||
if (occurrences > 1 && !replaceAll) {
|
||||
return {
|
||||
error: `Found ${occurrences} occurrences of the string in ${filePath}. Pass replaceAll=true to replace all, or provide a more specific oldStr.`
|
||||
}
|
||||
}
|
||||
const updated = replaceAll ? content.replaceAll(oldStr, newStr) : content.replace(oldStr, newStr)
|
||||
await fs.writeFile(abs, updated, 'utf8')
|
||||
return { path: filePath, occurrences, filesUpdate: null }
|
||||
}
|
||||
|
||||
async ls(dirPath: string): Promise<LsResult> {
|
||||
const abs = this.resolveSafe(dirPath)
|
||||
if (!abs) return { error: `Path traversal rejected: ${dirPath}` }
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return { files: [] }
|
||||
}
|
||||
const normalized = dirPath === '/' ? '/' : dirPath.endsWith('/') ? dirPath : dirPath + '/'
|
||||
const files: FileInfo[] = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryAbs = path.join(abs, entry.name)
|
||||
const stat = await fs.stat(entryAbs).catch(() => null)
|
||||
const isDirectory = stat?.isDirectory() ?? entry.isDirectory()
|
||||
return {
|
||||
name: entry.name,
|
||||
path: normalized + entry.name,
|
||||
size: isDirectory ? 0 : stat?.size ?? 0,
|
||||
isDirectory,
|
||||
mimeType: isDirectory ? undefined : getMimeType(entry.name)
|
||||
}
|
||||
})
|
||||
)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return { files }
|
||||
}
|
||||
|
||||
async glob(pattern: string, basePath = '/'): Promise<GlobResult> {
|
||||
const absBase = this.resolveSafe(basePath)
|
||||
if (!absBase) return { error: `Path traversal rejected: ${basePath}` }
|
||||
const regex = globToRegex(pattern)
|
||||
const normalized = basePath === '/' ? '/' : basePath.endsWith('/') ? basePath : basePath + '/'
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(absBase, { withFileTypes: true, recursive: true })
|
||||
} catch {
|
||||
return { files: [], truncated: false }
|
||||
}
|
||||
const files: FileInfo[] = []
|
||||
let truncated = false
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) continue
|
||||
const entryAbs = path.join((entry as Dirent & { parentPath: string }).parentPath, entry.name)
|
||||
const relative = path.relative(absBase, entryAbs).split(path.sep).join('/')
|
||||
if (!regex.test(relative)) continue
|
||||
if (files.length >= MAX_LIST_OBJECTS) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const stat = await fs.stat(entryAbs).catch(() => null)
|
||||
files.push({
|
||||
name: entry.name,
|
||||
path: normalized === '/' ? '/' + relative : normalized + relative,
|
||||
size: stat?.size ?? 0,
|
||||
isDirectory: false,
|
||||
mimeType: getMimeType(entry.name)
|
||||
})
|
||||
}
|
||||
return { files, truncated }
|
||||
}
|
||||
|
||||
async grep(pattern: string, dirPath: string | null = '/', glob: string | null = null): Promise<GrepResult> {
|
||||
const basePath = dirPath ?? '/'
|
||||
const absBase = this.resolveSafe(basePath)
|
||||
if (!absBase) return { error: `Path traversal rejected: ${basePath}` }
|
||||
let regex: RegExp
|
||||
try {
|
||||
regex = new RegExp(pattern)
|
||||
} catch {
|
||||
return { error: `Invalid regex pattern: ${pattern}` }
|
||||
}
|
||||
const globRx = glob ? globToRegex(glob) : null
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(absBase, { withFileTypes: true, recursive: true })
|
||||
} catch {
|
||||
return { matches: [], truncated: false }
|
||||
}
|
||||
const matches: GrepMatch[] = []
|
||||
let scanned = 0
|
||||
let truncated = false
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) continue
|
||||
if (scanned >= MAX_LIST_OBJECTS) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const entryAbs = path.join((entry as Dirent & { parentPath: string }).parentPath, entry.name)
|
||||
const mimeType = getMimeType(entry.name)
|
||||
if (!isTextMimeType(mimeType)) continue
|
||||
if (globRx && !globRx.test(entry.name)) continue
|
||||
scanned++
|
||||
const content = await fs.readFile(entryAbs, 'utf8').catch(() => null)
|
||||
if (content === null) continue
|
||||
const relative = path.relative(this.root, entryAbs).split(path.sep).join('/')
|
||||
const lines = content.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
matches.push({ path: '/' + relative, line: i + 1, content: lines[i] })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { matches, truncated }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BackendProtocol, FileData } from './BackendProtocol'
|
||||
import { LocalBackend } from './backends/LocalBackend'
|
||||
import { StateBackend } from './backends/StateBackend'
|
||||
|
||||
type FileStore = Record<string, FileData>
|
||||
@@ -12,6 +13,8 @@ export async function createBackend(initialFiles?: FileStore): Promise<BackendPr
|
||||
switch (type) {
|
||||
case 'state':
|
||||
return new StateBackend(initialFiles)
|
||||
case 'local':
|
||||
return new LocalBackend(process.env.SANDBOX_LOCAL_PATH)
|
||||
default:
|
||||
throw new Error(`Unknown SANDBOX_TYPE: ${type}`)
|
||||
}
|
||||
|
||||
@@ -212,6 +212,9 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
|
||||
############################################################################################################
|
||||
|
||||
# SmartAgent sandbox backend. Uncomment to enable the VFS demo (turn-scoped, in-memory).
|
||||
# Options for the demo slice: state (in-memory, turn-scoped)
|
||||
# Future options (not yet implemented): local, local-shell, agentcore, e2b, daytona, modal
|
||||
# Options: state (in-memory, turn-scoped), local (host disk, dev-only)
|
||||
# Future options (not yet implemented): local-shell, agentcore, e2b, daytona, modal
|
||||
# SANDBOX_TYPE=state
|
||||
|
||||
# Root directory for SANDBOX_TYPE=local (default: ~/.flowise/sandbox)
|
||||
# SANDBOX_LOCAL_PATH=
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"email": "[email protected]"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20"
|
||||
"node": ">=20.12.0 <21.0.0"
|
||||
},
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user