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:
Jackie Chui
2026-05-04 14:51:52 -07:00
parent 60203ffd14
commit a718f7ffbd
7 changed files with 698 additions and 4 deletions
+1 -1
View File
@@ -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}`)
}
+5 -2
View File
@@ -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=
+1 -1
View File
@@ -58,7 +58,7 @@
"email": "[email protected]"
},
"engines": {
"node": "^20"
"node": ">=20.12.0 <21.0.0"
},
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {