import fs from 'fs'; import path from 'path'; import { defineTool, ToolError } from 'libllmorbiter'; import { getStore } from '../db/store.js'; import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js'; import { isSkillPath } from './skill.js'; /** * rm — deletes a single file from the virtual filesystem (VFS). * * This is the tool that makes a SKILL file removable. `reset_vfs` deliberately spares skills * so a routine cleanup cannot destroy authored knowledge; `rm` is the explicit, single-file * escape hatch for when a skill really should go (it is outdated, wrong, or superseded). * * Ported from fidoca's `rm`, with three differences forced by this environment: * * - **No undo stack.** fidoca has `pushUndo`; dashback has no undo facility, so a delete here * is immediate and final. The tool says so plainly so the model does not describe it as * reversible. * - **Deletes in both places.** fidoca only unlinks from disk. Here the VFS has two halves, * so the database row must go too — otherwise the file reappears on the next * `prepareVfs` hydration, which would make a "successful" delete silently undone. * - **Guards the read-only image space**, matching `patch`, so a delete cannot be used to * remove database images by writing to their path. * * Directories are refused: deleting a tree recursively is a bulk operation, and * `reset_vfs` already covers that case (while still sparing skills). */ /** * Validates a VFS-relative path and returns what is on disk at it. * * Performs no deletion itself — the caller sequences the database and disk writes, because * the order matters and only the tool wrapper knows about both halves. * * @param {string} vfsRoot * @param {string} relPath VFS-relative path, forward slashes * @returns {{ abs: string, size: number }} absolute path and size of the file * @throws {Error} on traversal, a missing path, or a directory */ export function deleteVfsFile(vfsRoot, relPath) { const abs = path.resolve(vfsRoot, relPath); const rel = path.relative(vfsRoot, abs); // Defence in depth: callers already check, but this helper is exported, so re-check here // rather than trusting the caller with a path-traversal primitive. if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error(`Access denied: path "${relPath}" escapes virtual filesystem boundary.`); } if (!fs.existsSync(abs)) { throw new Error(`File not found: "${relPath}"`); } if (!fs.statSync(abs).isFile()) { throw new Error(`"${relPath}" is not a file. Use reset_vfs to clear directories (it keeps SKILL files).`); } return { abs, size: fs.statSync(abs).size }; } /** * Creates the rm tool bound to a company's virtual filesystem. * @param {{ googleAuthId: number }} ctx */ export function createRmTool({ googleAuthId }) { return defineTool({ name: 'rm', description: 'Delete a single file from the virtual filesystem (VFS). ' + 'Use this to remove a SKILL file: reset_vfs intentionally preserves skills, so rm is how a skill is ' + 'deleted on purpose. Also use it to drop one unwanted artifact. ' + 'Deletion is immediate and cannot be undone. Use reset_vfs to clear many files or a directory at once.', strict: false, parameters: { type: 'object', additionalProperties: false, required: ['filename'], properties: { filename: { type: 'string', description: 'File to delete, relative to the VFS root using forward slashes ' + '(e.g. "image-pipeline.SKILL", "notes.md", "labels/sheet1.png").', }, }, }, invoke: async ({ filename } = {}) => { if (typeof filename !== 'string' || !filename.trim()) { throw new ToolError('rm requires a non-empty "filename".'); } const clean = filename.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); if (!clean) { throw new ToolError('rm requires a non-empty "filename".'); } if (isReadOnlyDatabasePath(clean)) { throw new ToolError( `Access denied: "${clean}" matches the read-only database image pattern and cannot be deleted.` ); } const vfsRoot = await prepareVfs({ googleAuthId }); const { abs, size } = deleteVfsFile(vfsRoot, clean); // Database first. If this throws, the file is still on disk — recoverable, and the row // still exists to describe it. Doing it the other way round would leave a blob on // disk that the next prepareVfs hydration would restore, silently undoing the delete. const store = await getStore(); const res = await store.modelvfs.delete(googleAuthId, clean); fs.unlinkSync(abs); const wasSkill = isSkillPath(clean); const result = { success: true, deleted: res?.deleted !== false, filename: clean, size, message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`, }; if (wasSkill) { result.type = 'skill'; result.note = 'SKILL file deleted. It will not be restored by reset_vfs.'; } return result; }, }); }