Files
dashback/src/tools/rm.js
2026-10-05 11:14:31 +02:00

129 lines
5.5 KiB
JavaScript

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;
},
});
}