/** * file-system.ts — Real local filesystem access via File System Access API. * * Bugs fixed vs original: * #1 fs_read base64 used char-by-char concat + single btoa() → O(n²) + stack * overflow on files >100 KB. Now uses FileReader.readAsDataURL for base64 * (browser-native, no size limit). * #2 fs_write(append:true) read the entire existing file into JS memory, * concatenated, and wrote back → 2× memory + 2× disk write for every * append. Now uses writable.seek(file.size) + writable.write(delta). * #3 No capability pre-flight — agent had to parse an error string. Now * fs_check_support returns {supported, engine, reason, fallback}. * #4 fs_list recursed UNBOUNDED through deep trees before truncating at 500 * in the response. Now bails out of the walk as soon as the cap is hit. * #5 fs_pick_file(multiple:true) returned {aliases:[...]} while all other * tools accept {alias:string} — inconsistent shape. Now also registers * each file under just the base alias at position 0 for simple single-file * access after a multi-pick. * #6 Hash/inode identity — if the user re-picks the SAME folder, we now * detect via isSameEntry() and reuse instead of creating a new handle. * Fixes stale-alias bugs. * #7 Path traversal in "dirAlias:relative/../../etc/passwd" silently worked * at the library level (FSA API doesn't block it). Now rejects '..' in * relative path segments. * #8 No way to verifyPermission() on a stored handle — reloading the page * loses permission. Now fs_request_permission can re-prompt. * #9 fs_write didn't support binary (base64) content. Now accepts * content_base64 or content with explicit encoding. * #10 fs_get_metadata tool — size / type / lastModified / permission * without reading the file body. */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' const DIR_HANDLES = new Map() const FILE_HANDLES = new Map() function isSupported(): boolean { return typeof window !== 'undefined' && typeof (window as any).showDirectoryPicker === 'function' } function isPickFileSupported(): boolean { return typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function' } /* Reject path segments that'd allow escape from the alias root. */ function validateSegments(parts: string[], fullPath: string) { for (const p of parts) { if (p === '..' || p === '.' || p === '') { throw new Error(`Illegal path segment "${p}" in "${fullPath}"`) } if (p.startsWith('/') || /[\\:*?"<>|]/.test(p)) { throw new Error(`Invalid character in path segment "${p}"`) } } } function splitAliasPath(input: string): { alias: string; parts: string[] } { const idx = input.indexOf(':') if (idx === -1) return { alias: input, parts: [] } const alias = input.slice(0, idx) const rel = input.slice(idx + 1) const parts = rel.split('/').filter(Boolean) validateSegments(parts, input) return { alias, parts } } async function resolveFileHandle(pathOrAlias: string, create: boolean = false): Promise { // Direct alias if (FILE_HANDLES.has(pathOrAlias)) return FILE_HANDLES.get(pathOrAlias)! const { alias, parts } = splitAliasPath(pathOrAlias) if (parts.length === 0) throw new Error(`Unknown file alias: ${alias}`) const dir = DIR_HANDLES.get(alias) if (!dir) throw new Error(`Unknown directory alias: ${alias}`) let cur: any = dir for (let i = 0; i < parts.length - 1; i++) { cur = await cur.getDirectoryHandle(parts[i], { create }) } return cur.getFileHandle(parts[parts.length - 1], { create }) } async function resolveDirHandle(aliasOrPath: string, create: boolean = false): Promise { const { alias, parts } = splitAliasPath(aliasOrPath) const base = DIR_HANDLES.get(alias) if (!base) throw new Error(`Unknown directory alias: ${alias}`) let cur: FileSystemDirectoryHandle = base for (const p of parts) { cur = await cur.getDirectoryHandle(p, { create }) } return cur } /* ------------------------------------------------------------------------- */ export const fsCheckSupportTool = tool({ name: 'fs_check_support', description: 'Check if the File System Access API is available in the current browser. ' + 'Returns {supported, directory_pick_supported, file_pick_supported, engine, reason, fallback}. ' + 'Call this FIRST before any fs_pick_* tool to give the user a clear error instead of an opaque failure.', inputSchema: z.object({}), callback: () => { const dir = isSupported() const file = isPickFileSupported() const ua = navigator.userAgent let engine = 'unknown' if (/Firefox\//.test(ua)) engine = 'Firefox' else if (/Edg\//.test(ua)) engine = 'Edge' else if (/Chrome\//.test(ua)) engine = 'Chrome' else if (/Safari\//.test(ua)) engine = 'Safari' const supported = dir && file let reason: string | null = null let fallback: string | null = null if (!supported) { if (engine === 'Firefox') { reason = 'Firefox does not implement the File System Access API (Mozilla refused to ship for fingerprinting/security concerns).' fallback = 'Use the WebContainer tools (webcontainer_write_files / webcontainer_read / webcontainer_ls) for an in-browser Node.js filesystem instead — works in any browser with cross-origin isolation.' } else if (engine === 'Safari') { reason = 'Safari has partial File System Access API support (origin private FS only, no user-picked directories).' fallback = 'Switch to Chrome/Edge for full FS access, or use WebContainer for an in-browser FS.' } else { reason = 'File System Access API not detected in this browser.' fallback = 'Use WebContainer tools, or switch to Chrome/Edge/Opera.' } } return JSON.stringify({ status: 'success', supported, directory_pick_supported: dir, file_pick_supported: file, engine, is_secure_context: typeof window !== 'undefined' ? window.isSecureContext : false, reason, fallback, }) }, }) export const fsPickDirTool = tool({ name: 'fs_pick_dir', description: 'Prompt user to pick a local directory. Returns an alias for later reads/writes. REQUIRES USER GESTURE. Chrome/Edge only — call fs_check_support first.', inputSchema: z.object({ alias: z.string().describe('Name to refer to this directory later, e.g. "project"'), mode: z.enum(['read', 'readwrite']).optional(), }), callback: async (input) => { try { if (!isSupported()) { return JSON.stringify({ status: 'error', error: 'File System Access API not supported', hint: 'Run fs_check_support for the full diagnosis. Use WebContainer tools on Firefox.', }) } const handle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({ mode: input.mode || 'readwrite', }) // Deduplicate: if we already have this exact directory under another // alias, reuse the existing handle so both aliases stay valid. for (const [existingAlias, existing] of DIR_HANDLES.entries()) { if (await existing.isSameEntry?.(handle as any).catch(() => false)) { DIR_HANDLES.set(input.alias, existing) return JSON.stringify({ status: 'success', alias: input.alias, name: existing.name, reused_from: existingAlias, }) } } DIR_HANDLES.set(input.alias, handle) return JSON.stringify({ status: 'success', alias: input.alias, name: handle.name }) } catch (err: unknown) { const msg = (err as Error).message if (msg.includes('aborted') || msg.includes('AbortError')) { return JSON.stringify({ status: 'cancelled', error: 'User cancelled the picker' }) } return JSON.stringify({ status: 'error', error: msg }) } }, }) export const fsPickFileTool = tool({ name: 'fs_pick_file', description: 'Prompt user to pick file(s). Returns an alias (or aliases). REQUIRES USER GESTURE. Chrome/Edge only.', inputSchema: z.object({ alias: z.string().describe('Name to refer to this file later'), multiple: z.boolean().optional(), types: z.array(z.object({ description: z.string().optional(), accept: z.record(z.string(), z.array(z.string())).describe('{"text/*": [".txt",".md"]}'), })).optional().describe('File type filters for the picker'), }), callback: async (input) => { try { if (!isPickFileSupported()) { return JSON.stringify({ status: 'error', error: 'File System Access API not supported (showOpenFilePicker)', hint: 'Run fs_check_support. Use WebContainer tools on Firefox.', }) } const pickerOpts: any = { multiple: !!input.multiple } if (input.types) pickerOpts.types = input.types const handles: FileSystemFileHandle[] = await (window as any).showOpenFilePicker(pickerOpts) if (input.multiple) { const aliases: string[] = [] for (let i = 0; i < handles.length; i++) { const a = `${input.alias}_${i}` FILE_HANDLES.set(a, handles[i]) aliases.push(a) } // Also register the first under the bare alias for convenience. FILE_HANDLES.set(input.alias, handles[0]) return JSON.stringify({ status: 'success', aliases, primary_alias: input.alias, count: handles.length, }) } FILE_HANDLES.set(input.alias, handles[0]) return JSON.stringify({ status: 'success', alias: input.alias, name: handles[0].name }) } catch (err: unknown) { const msg = (err as Error).message if (msg.includes('aborted') || msg.includes('AbortError')) { return JSON.stringify({ status: 'cancelled', error: 'User cancelled the picker' }) } return JSON.stringify({ status: 'error', error: msg }) } }, }) /* Re-verify permissions on stored handles after a page reload or permission loss. */ export const fsRequestPermissionTool = tool({ name: 'fs_request_permission', description: 'Re-prompt the user for permission on a stored handle (useful after a page reload where permission is often lost). ' + 'Requires user gesture.', inputSchema: z.object({ alias: z.string().describe('Directory or file alias'), mode: z.enum(['read', 'readwrite']).optional(), }), callback: async (input) => { try { const handle: any = DIR_HANDLES.get(input.alias) ?? FILE_HANDLES.get(input.alias) if (!handle) return JSON.stringify({ status: 'error', error: `Unknown alias: ${input.alias}` }) const opts = { mode: input.mode ?? 'readwrite' } const query = await handle.queryPermission?.(opts) if (query === 'granted') return JSON.stringify({ status: 'success', permission: 'granted', requested: false }) const result = await handle.requestPermission?.(opts) return JSON.stringify({ status: 'success', permission: result, requested: true }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const fsListTool = tool({ name: 'fs_list', description: 'List entries in a directory alias. Optionally recursive. Path can be "alias" or "alias:subdir". ' + 'Bails out of the walk when max_entries is hit (default 500) so it never hangs on huge trees.', inputSchema: z.object({ path: z.string().describe('Directory alias, or "alias:subdir"'), recursive: z.boolean().optional(), maxDepth: z.number().optional(), max_entries: z.number().optional().describe('Stop after N total entries. Default 500.'), pattern: z.string().optional().describe('Optional filename regex filter'), }), callback: async (input) => { try { const dir = await resolveDirHandle(input.path) const entries: any[] = [] const cap = input.max_entries ?? 500 const maxDepth = input.maxDepth ?? 10 const re = input.pattern ? new RegExp(input.pattern) : null let hitCap = false async function walk(d: FileSystemDirectoryHandle, prefix: string, depth: number) { if (hitCap) return for await (const [name, handle] of (d as any).entries()) { if (hitCap || entries.length >= cap) { hitCap = true; return } const path = prefix ? `${prefix}/${name}` : name if (re && !re.test(name)) continue if (handle.kind === 'file') { const f = await (handle as FileSystemFileHandle).getFile() entries.push({ path, kind: 'file', size: f.size, type: f.type, modified: f.lastModified, }) } else { entries.push({ path, kind: 'dir' }) if (input.recursive && depth < maxDepth) { await walk(handle as FileSystemDirectoryHandle, path, depth + 1) } } } } await walk(dir, '', 0) return JSON.stringify({ status: 'success', path: input.path, count: entries.length, truncated: hitCap, entries, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const fsGetMetadataTool = tool({ name: 'fs_metadata', description: 'Return file metadata (size, type, lastModified, permission) without reading the file body.', inputSchema: z.object({ path: z.string().describe('File alias or "dirAlias:relative/path.ext"'), }), callback: async (input) => { try { const handle = await resolveFileHandle(input.path, false) const f = await handle.getFile() const perm = await (handle as any).queryPermission?.({ mode: 'readwrite' }).catch(() => 'unknown') return JSON.stringify({ status: 'success', path: input.path, name: f.name, size: f.size, type: f.type || 'application/octet-stream', modified: f.lastModified, permission: perm, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const fsReadTool = tool({ name: 'fs_read', description: 'Read a file. Path is either a file alias or "dirAlias:relative/path.ext". ' + 'Returns text by default. as:"base64" returns base64 (chunked, safe for large files). as:"json" parses JSON. ' + 'Respects maxBytes as a hard cap before any read happens.', inputSchema: z.object({ path: z.string(), as: z.enum(['text', 'base64', 'json']).optional(), maxBytes: z.number().optional().describe('Reject files larger than this. Default: no limit.'), encoding: z.string().optional().describe('Text encoding for text/json (default utf-8)'), }), callback: async (input) => { try { const handle = await resolveFileHandle(input.path, false) const file = await handle.getFile() if (input.maxBytes && file.size > input.maxBytes) { return JSON.stringify({ status: 'error', error: `File too large (${file.size} > ${input.maxBytes})`, size: file.size, }) } if (input.as === 'base64') { // Use FileReader.readAsDataURL — browser-native, chunk-safe, no O(n²). const dataUrl = await new Promise((resolve, reject) => { const fr = new FileReader() fr.onload = () => resolve(fr.result as string) fr.onerror = () => reject(fr.error ?? new Error('FileReader error')) fr.readAsDataURL(file) }) // Strip "data:;base64," prefix const commaIdx = dataUrl.indexOf(',') const b64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl return JSON.stringify({ status: 'success', name: file.name, size: file.size, type: file.type, content: b64, }) } const text = await file.text() if (input.as === 'json') { try { return JSON.stringify({ status: 'success', name: file.name, size: file.size, json: JSON.parse(text), }) } catch (e) { return JSON.stringify({ status: 'error', error: 'Not valid JSON', preview: text.slice(0, 200), parse_error: (e as Error).message, }) } } return JSON.stringify({ status: 'success', name: file.name, size: file.size, content: text }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const fsWriteTool = tool({ name: 'fs_write', description: 'Write content to a file alias or "dirAlias:relative/path.ext". Creates intermediate dirs. ' + 'Text mode by default; pass content_base64 instead of content for binary data. ' + 'append:true uses writable.seek() for efficient O(1) append instead of reading the whole file.', inputSchema: z.object({ path: z.string(), content: z.string().optional(), content_base64: z.string().optional().describe('Base64 content — decoded and written as binary.'), append: z.boolean().optional(), }), callback: async (input) => { try { if (!input.content && !input.content_base64) { return JSON.stringify({ status: 'error', error: 'Provide content (text) or content_base64 (binary)' }) } const handle = await resolveFileHandle(input.path, true) // Decide payload + size up front (needed for append seek) let payload: Uint8Array | string let byteLen: number if (input.content_base64) { const bin = atob(input.content_base64) const bytes = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) payload = bytes byteLen = bytes.byteLength } else { payload = input.content! byteLen = new TextEncoder().encode(input.content!).byteLength } const writable: any = await (handle as any).createWritable({ keepExistingData: !!input.append }) if (input.append) { // Efficient append: seek past existing bytes, then write delta. const existing = await handle.getFile() try { if (typeof writable.seek === 'function') { await writable.seek(existing.size) } await writable.write(payload) } catch { // Fallback path (older FSA implementations without seek) const existingText = await existing.text() const combined = typeof payload === 'string' ? existingText + payload : existingText await writable.write(combined) if (payload instanceof Uint8Array) { // Append raw bytes after text (rare path) await writable.write(payload) } } } else { await writable.write(payload) } await writable.close() return JSON.stringify({ status: 'success', path: input.path, bytes: byteLen, mode: input.append ? 'append' : 'overwrite', kind: input.content_base64 ? 'binary' : 'text', }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const fsDeleteTool = tool({ name: 'fs_delete', description: 'Delete a file or subdirectory in a dir alias. Path: "dirAlias:relative/path". Set recursive:true for directories.', inputSchema: z.object({ path: z.string(), recursive: z.boolean().optional(), }), callback: async (input) => { try { const { alias, parts } = splitAliasPath(input.path) const dir = DIR_HANDLES.get(alias) if (!dir) return JSON.stringify({ status: 'error', error: `Unknown alias: ${alias}` }) if (parts.length === 0) { return JSON.stringify({ status: 'error', error: 'Refusing to delete the entire directory root via fs_delete. Use fs_forget to remove the alias.', }) } let cur: any = dir for (let i = 0; i < parts.length - 1; i++) { cur = await cur.getDirectoryHandle(parts[i]) } await cur.removeEntry(parts[parts.length - 1], { recursive: !!input.recursive }) return JSON.stringify({ status: 'success', deleted: input.path }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const fsForgetTool = tool({ name: 'fs_forget', description: 'Drop a directory or file alias from the registry (does NOT delete from disk).', inputSchema: z.object({ alias: z.string(), }), callback: (input) => { const hadDir = DIR_HANDLES.delete(input.alias) const hadFile = FILE_HANDLES.delete(input.alias) return JSON.stringify({ status: 'success', alias: input.alias, removed_dir: hadDir, removed_file: hadFile, }) }, }) export const fsStatusTool = tool({ name: 'fs_status', description: 'List currently opened directory and file aliases, plus API support status.', inputSchema: z.object({}), callback: () => { return JSON.stringify({ status: 'success', supported: isSupported(), directories: Array.from(DIR_HANDLES.entries()).map(([a, h]) => ({ alias: a, name: h.name })), files: Array.from(FILE_HANDLES.entries()).map(([a, h]) => ({ alias: a, name: h.name })), }) }, }) export const FILE_SYSTEM_TOOLS = [ fsCheckSupportTool, // NEW: call first fsPickDirTool, fsPickFileTool, fsRequestPermissionTool, // NEW: reauth after reload fsListTool, fsGetMetadataTool, // NEW: stat without read fsReadTool, fsWriteTool, fsDeleteTool, fsForgetTool, // NEW: unregister alias fsStatusTool, ]