129 lines
5.5 KiB
JavaScript
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;
|
|
},
|
|
});
|
|
} |