import { readdir, readFile, writeFile, realpath, lstat } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { SKILLS } from './catalog.js'; import { qvacStatus } from '../daemon/qvac-master.js'; const PERMISSIONS = Object.freeze({ read: 'read', write: 'write', dangerous: 'dangerous', computerUse: 'computer-use' }); export function filesystemRoots(fsAccess, cwd = process.cwd()) { if (fsAccess === 'filesystem') return ['/']; if (fsAccess === 'home') return [os.homedir()]; return [path.resolve(cwd)]; } function isInside(root, candidate) { const relative = path.relative(root, candidate); return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); } // Resolve symlinks as well as lexical paths before accessing workspace files. export async function workspacePath(cwd, file, { write = false, roots } = {}) { const allow = (roots?.length ? roots : [cwd]).map((root) => path.resolve(root)); const target = path.isAbsolute(file) ? path.resolve(file) : path.resolve(cwd, file); let resolved; try { resolved = await realpath(target); } catch (error) { if (!write || error.code !== 'ENOENT') throw error; const entry = await lstat(target).catch((cause) => { if (cause.code !== 'ENOENT') throw cause; return null; }); if (entry?.isSymbolicLink()) throw new Error('path is outside the Jarvis workspace or is a dangling symlink'); resolved = path.join(await realpath(path.dirname(target)), path.basename(target)); } const resolvedRoots = await Promise.all(allow.map(async (root) => { try { return await realpath(root); } catch { return root; } })); if (!resolvedRoots.some((root) => isInside(root, resolved))) throw new Error('path is outside the Jarvis workspace'); return resolved; } async function desktopApps() { const dirs = ['/usr/share/applications', path.join(os.homedir(), '.local/share/applications')]; const apps = []; for (const dir of dirs) { let entries = []; try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.name.endsWith('.desktop')) continue; try { const text = await readFile(path.join(dir, entry.name), 'utf8'); const name = text.match(/^Name=(.*)$/m)?.[1]; const exec = text.match(/^Exec=([^\n ]+)/m)?.[1]; if (name && exec) apps.push({ id: entry.name, name, exec }); } catch {} } } return apps.slice(0, 500); } async function searchFiles(root, query, limit = 50) { const hits = []; async function walk(dir, depth) { if (depth > 8 || hits.length >= limit) return; let entries = []; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (hits.length >= limit) break; if (entry.name === 'node_modules' || entry.name === '.git') continue; const full = path.join(dir, entry.name); if (entry.name.toLowerCase().includes(query.toLowerCase())) hits.push(full); if (entry.isDirectory()) await walk(full, depth + 1); } } await walk(path.resolve(root), 0); return hits; } export function createPhase2Tools({ cwd = process.cwd(), computer, roots } = {}) { const allow = roots?.length ? roots : [cwd]; const bound = (file, options) => workspacePath(cwd, file, { ...options, roots: allow }); return [ { name: 'app_list', permission: PERMISSIONS.read, description: 'List locally installed desktop applications without launching anything.', parameters: { type: 'object', properties: {} }, execute: () => desktopApps(), }, { name: 'fs_search', permission: PERMISSIONS.read, description: 'Search local file and directory names inside the allowed filesystem roots.', parameters: { type: 'object', properties: { query: { type: 'string' }, root: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] }, execute: async ({ query, root = cwd, limit = 50 }) => searchFiles(await bound(root), query, Math.min(100, Number(limit) || 50)), }, { name: 'fs_read', permission: PERMISSIONS.read, description: 'Read a UTF-8 local text file inside the allowed filesystem roots, capped at 400 KiB.', parameters: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] }, execute: async ({ file }) => { const target = await bound(file); return (await readFile(target, 'utf8')).slice(0, 400 * 1024); }, }, { name: 'fs_write', permission: PERMISSIONS.write, description: 'Write a local text file only after an explicit confirmation flag is supplied.', parameters: { type: 'object', properties: { file: { type: 'string' }, contents: { type: 'string' }, confirmed: { type: 'boolean' } }, required: ['file', 'contents', 'confirmed'] }, execute: async ({ file, contents, confirmed }) => { if (confirmed !== true) return { confirmation_required: true, action: 'write', file }; const target = await bound(file, { write: true }); await writeFile(target, String(contents), 'utf8'); return { ok: true, file: target, bytes: Buffer.byteLength(String(contents)) }; }, }, { name: 'memory_recall', permission: PERMISSIONS.read, description: 'List local Jarvis memory notes; contents stay on this machine.', parameters: { type: 'object', properties: {} }, execute: async () => { const dir = path.join(os.homedir(), '.local/share/jarvis/memory'); try { return (await readdir(dir)).slice(0, 200); } catch { return []; } }, }, { name: 'memory_remember', permission: PERMISSIONS.write, description: 'Write a local Jarvis memory note only after explicit confirmation.', parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' }, confirmed: { type: 'boolean' } }, required: ['name', 'text', 'confirmed'] }, execute: async ({ name, text, confirmed }) => { if (confirmed !== true) return { confirmation_required: true, action: 'memory_remember', name }; const dir = path.join(os.homedir(), '.local/share/jarvis/memory'); const safe = String(name).replace(/[^a-zA-Z0-9._-]/g, '_'); await (await import('node:fs/promises')).mkdir(dir, { recursive: true }); await writeFile(path.join(dir, safe), String(text), 'utf8'); return { ok: true, name: safe }; }, }, { name: 'rag_workspaces', permission: PERMISSIONS.read, description: 'List configured local Jarvis RAG workspace directories.', parameters: { type: 'object', properties: {} }, execute: async () => { const dir = path.join(os.homedir(), '.local/share/jarvis/memory'); try { return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name); } catch { return []; } }, }, { name: 'capability_status', permission: PERMISSIONS.read, description: 'Report the truthful local status of every Jarvis capability and the single QVAC master.', parameters: { type: 'object', properties: {} }, execute: () => ({ qvac: qvacStatus(), capabilities: SKILLS.map(([id, label, permission]) => ({ id, label, permission, status: permission === 'computer-use' && !computer?.status?.().active ? 'grant-required' : 'registered' })) }), }, ]; } export { PERMISSIONS };