diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 41c58bb3..a037f64e 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -291,7 +291,11 @@ forms of `additionalProperties`, union `type`, `anyOf`, `oneOf`, `allOf`, `not`, or arbitrary draft-specific keywords. See [`examples/native-tool-plugin.mjs`](../examples/native-tool-plugin.mjs) for -a complete no-dependency Node example. +a minimal no-dependency Node example. The repository also ships +[`plugins/safe-delete`](../plugins/safe-delete/README.md), an optional +project-root-fenced Plugin that moves one file or directory to the operating +system Trash/Recycle Bin without adding permanent deletion to WebCodex's built-in +tool surface. ## Calling and failure semantics diff --git a/docs/PLUGINS.zh-CN.md b/docs/PLUGINS.zh-CN.md index bf2d4ef9..57e21fc9 100644 --- a/docs/PLUGINS.zh-CN.md +++ b/docs/PLUGINS.zh-CN.md @@ -256,8 +256,11 @@ keyword 会在 provider admission 时明确拒绝,不会 silently ignore。v1 `minimum` / `maximum`、schema 形式的 `additionalProperties`、union `type`、`anyOf`、 `oneOf`、`allOf`、`not` 或任意 draft-specific keyword。 -完整无依赖 Node 示例见 -[`examples/native-tool-plugin.mjs`](../examples/native-tool-plugin.mjs)。 +最小无依赖 Node 示例见 +[`examples/native-tool-plugin.mjs`](../examples/native-tool-plugin.mjs)。仓库还提供可选的 +[`plugins/safe-delete`](../plugins/safe-delete/README.zh-CN.md):它把删除权限限制在配置的 +项目根内,只把单个文件或目录移入系统 Trash / Recycle Bin,不会把永久删除能力加入 +WebCodex 内建工具面。 ## 调用与失败语义 diff --git a/plugins/safe-delete/README.md b/plugins/safe-delete/README.md new file mode 100644 index 00000000..6fb2974b --- /dev/null +++ b/plugins/safe-delete/README.md @@ -0,0 +1,94 @@ +# Safe Delete Native Plugin + +`safe_delete` is a dependency-free WebCodex Native Tool Plugin that moves one +ordinary file or directory to the operating system Trash/Recycle Bin instead of +permanently deleting it. + +It is intentionally a Plugin rather than a built-in WebCodex filesystem tool. +Install it only on Runners where you want the model to have this additional +local capability. + +## Safety contract + +- The Plugin provider `cwd` is the deletion authority root. `path` is always + relative to that directory. +- Absolute paths, `..` traversal, the authority root itself, paths that resolve + outside the root, symlinks/junctions, and non-file/non-directory entries fail + closed. +- One call handles exactly one path. There is no batch mode and no `force` + option. +- An already-absent path is a successful no-op, but the tool is deliberately + **not** declared idempotent: after an uncertain result, a new object may have + been created at the same relative path. Never retry an unknown result blindly. +- Backend timeout, a backend failure after the original path disappears, or an + unverifiable postcondition returns `outcome=unknown`; inspect the path and + Trash before retrying. +- There is **no permanent-delete fallback for the requested path**. The Plugin never + uses `rm`, `unlink`, `Remove-Item`, or an equivalent operation to dispose of + the requested file or directory. + +This protects against ordinary model/operator path mistakes. It is not a +sandbox against a hostile process racing filesystem names while the Trash API +is running. + +## Platform backends + +| Platform | Backend | +| --- | --- | +| Linux | freedesktop.org Home Trash (`$XDG_DATA_HOME/Trash`) using same-filesystem atomic rename; then `gio trash`; then `trash-put` only when `gio` is not installed | +| macOS | Foundation `NSFileManager` Trash API through built-in JXA (`/usr/bin/osascript -l JavaScript`), with the path passed as argv | +| Windows | PowerShell + `Microsoft.VisualBasic.FileIO` with `SendToRecycleBin` | + +If no supported backend is available, the operation fails without deleting the +path. The built-in Linux backend deliberately refuses cross-filesystem copy + +unlink; it falls through to another Trash backend instead. + +## Runner configuration + +Set `cwd` to the exact project/root you want this Plugin to be allowed to trash +from. Use an absolute path to the Plugin script when the Plugin code lives +outside that root: + +```toml +[plugins] +request_timeout_secs = 30 + +[[plugins.providers]] +id = "safe-delete" +name = "Safe Delete" +command = "node" +args = ["/absolute/path/to/webcodex/plugins/safe-delete/plugin.mjs"] +cwd = "/absolute/path/to/project" +timeout_secs = 30 +``` + +During development, use the normal Plugin loop: + +```text +plugin_tool check +-> plugin_tool reload +-> plugin_tool list +-> plugin_tool describe +-> plugin_tool call +``` + +Restart the Runner when you want the newly admitted `safe_delete` tool to become +eligible for first-class startup exposure. + +## Tool + +```text +safe_delete({"path":"build/old-output.bin"}) +``` + +Possible structured outcomes are `trashed`, `already_absent`, `rejected`, +`failed`, and `unknown`. + +## Development + +No npm install is required: + +```bash +node --check plugins/safe-delete/plugin.mjs +node --test plugins/safe-delete/plugin.test.mjs +``` diff --git a/plugins/safe-delete/README.zh-CN.md b/plugins/safe-delete/README.zh-CN.md new file mode 100644 index 00000000..db8dfa4d --- /dev/null +++ b/plugins/safe-delete/README.zh-CN.md @@ -0,0 +1,81 @@ +# Safe Delete Native Plugin + +`safe_delete` 是一个零第三方依赖的 WebCodex Native Tool Plugin。它不会永久删除 +文件,而是把一个普通文件或目录移动到操作系统的 Trash / Recycle Bin。 + +它有意作为 Plugin 提供,而不是加入 WebCodex 内建文件系统工具。只有在你确实希望 +模型获得这项额外本机能力的 Runner 上才安装它。 + +## 安全契约 + +- Plugin provider 的 `cwd` 就是删除权限根;`path` 永远相对于这个目录解析。 +- 绝对路径、`..` traversal、权限根本身、解析后逃出权限根的路径、symlink/junction, + 以及普通文件/目录以外的对象全部 fail closed。 +- 每次调用只处理一个路径;没有 batch,也没有 `force`。 +- 目标已经不存在时返回成功 no-op,但整个工具会明确标记为**非幂等**:未知结果之后, + 同一个相对路径可能已经出现了新的对象,因此不能盲目重试。 +- backend timeout、backend 报错但原路径已经消失,或无法验证最终状态时返回 + `outcome=unknown`;再次调用前应先检查路径和回收站。 +- **绝不对请求目标 fallback 到永久删除**。Plugin 不会用 `rm`、`unlink`、 + `Remove-Item` 或等价操作处置用户要求删除的文件或目录。 + +这个边界用于降低模型或操作者拼错路径造成的事故风险;它不是对抗恶意进程并发修改 +文件系统名称的 sandbox。 + +## 平台 backend + +| 平台 | Backend | +| --- | --- | +| Linux | 先按 freedesktop.org Home Trash(`$XDG_DATA_HOME/Trash`)做同文件系统 atomic rename;再尝试 `gio trash`;只有系统未安装 `gio` 时才尝试 `trash-put` | +| macOS | 通过系统内置 JXA(`/usr/bin/osascript -l JavaScript`)直接调用 Foundation `NSFileManager` Trash API,目标路径作为独立 argv 传入 | +| Windows | PowerShell + `Microsoft.VisualBasic.FileIO` 的 `SendToRecycleBin` | + +没有可用的安全回收站 backend 时,操作会失败,不会改成永久删除。内建 Linux backend +明确拒绝用跨文件系统 copy + unlink 模拟回收站,而是继续尝试其他 Trash backend。 + +## Runner 配置 + +把 `cwd` 设置为你希望这个 Plugin **唯一有权移动到回收站**的项目/目录。如果 Plugin +脚本不在这个权限根下,使用脚本的绝对路径: + +```toml +[plugins] +request_timeout_secs = 30 + +[[plugins.providers]] +id = "safe-delete" +name = "Safe Delete" +command = "node" +args = ["/absolute/path/to/webcodex/plugins/safe-delete/plugin.mjs"] +cwd = "/absolute/path/to/project" +timeout_secs = 30 +``` + +开发阶段使用标准 Plugin 闭环: + +```text +plugin_tool check +-> plugin_tool reload +-> plugin_tool list +-> plugin_tool describe +-> plugin_tool call +``` + +需要让新的 `safe_delete` 进入 startup first-class 候选时,再重启 Runner。 + +## Tool + +```text +safe_delete({"path":"build/old-output.bin"}) +``` + +结构化结果可能是 `trashed`、`already_absent`、`rejected`、`failed` 或 `unknown`。 + +## 开发 + +不需要执行 npm install: + +```bash +node --check plugins/safe-delete/plugin.mjs +node --test plugins/safe-delete/plugin.test.mjs +``` diff --git a/plugins/safe-delete/plugin.mjs b/plugins/safe-delete/plugin.mjs new file mode 100644 index 00000000..a8ee1f60 --- /dev/null +++ b/plugins/safe-delete/plugin.mjs @@ -0,0 +1,634 @@ +// WebCodex Safe Delete Native Tool Plugin. +// +// Moves one file or directory under the configured Plugin cwd to the operating +// system Trash/Recycle Bin. It never permanently deletes the requested path as a fallback. + +import { spawnSync as nodeSpawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { pathToFileURL } from "node:url"; + +export const PROTOCOL_VERSION = "webcodex-plugin-v1"; +const MAX_PATH_CHARS = 4096; +const BACKEND_TIMEOUT_MS = 10_000; +const BACKEND_MAX_BUFFER = 64 * 1024; + +export const SAFE_DELETE_TOOL = { + name: "safe_delete", + title: "Safe delete", + description: + "Move exactly one ordinary file or directory under this Plugin provider's configured cwd to the operating system Trash/Recycle Bin. Use this instead of permanent deletion when recovery may be needed. The path must be relative to the provider cwd. The provider cwd itself, paths that escape it, symlinks/junctions, and unsupported file types are rejected. This tool never permanently deletes the requested path through rm, unlink, Remove-Item, or another fallback.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + minLength: 1, + maxLength: MAX_PATH_CHARS, + description: + "One file or directory path relative to the Plugin provider cwd. Absolute paths and parent traversal are rejected.", + }, + }, + required: ["path"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + outcome: { + type: "string", + enum: ["trashed", "already_absent", "rejected", "failed", "unknown"], + }, + path: { type: "string", maxLength: MAX_PATH_CHARS }, + backend: { + type: "string", + enum: ["none", "freedesktop", "gio", "trash-put", "foundation", "powershell"], + }, + errorCode: { type: "string", maxLength: 128 }, + }, + required: ["outcome", "path", "backend", "errorCode"], + additionalProperties: false, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, +}; + +function result(id, value) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result: value })}\n`); +} + +function rpcError(id, code, message) { + process.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`, + ); +} + +function toolResult(text, structuredContent, isError = false) { + return { + content: [{ type: "text", text }], + structuredContent, + isError, + }; +} + +function safeRelativeDisplay(value) { + if (typeof value !== "string") return "[invalid path]"; + const withoutControls = value.replace(/[\u0000-\u001f\u007f]/gu, "?"); + return withoutControls.slice(0, MAX_PATH_CHARS); +} + +function structured(outcome, relativePath, backend = "none", errorCode = "") { + return { + outcome, + path: safeRelativeDisplay(relativePath), + backend, + errorCode, + }; +} + +function rejected(relativePath, code, message) { + return toolResult(message, structured("rejected", relativePath, "none", code), true); +} + +function failed(relativePath, backend, code, message, outcome = "failed") { + return toolResult(message, structured(outcome, relativePath, backend, code), true); +} + +function isWithinOrEqual(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) + ); +} + +function isStrictlyWithin(root, candidate) { + return candidate !== root && isWithinOrEqual(root, candidate); +} + +function hasParentTraversal(value) { + return value.split(/[\\/]+/u).some((component) => component === ".."); +} + +function realpathNative(value) { + return fs.realpathSync.native ? fs.realpathSync.native(value) : fs.realpathSync(value); +} + +function nearestExistingAncestor(value) { + let current = path.dirname(value); + for (;;) { + try { + return realpathNative(current); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const parent = path.dirname(current); + if (parent === current) throw new Error("no existing ancestor"); + current = parent; + } +} + +export function resolveAuthorizedTarget(relativePath, root = process.cwd()) { + if (typeof relativePath !== "string") { + return { ok: false, code: "invalid_path", message: "safe_delete requires a string path" }; + } + if ( + relativePath.length === 0 || + relativePath.length > MAX_PATH_CHARS || + /[\u0000-\u001f\u007f]/u.test(relativePath) + ) { + return { + ok: false, + code: "invalid_path", + message: "safe_delete path is empty, too long, or contains control characters", + }; + } + if (path.isAbsolute(relativePath)) { + return { + ok: false, + code: "absolute_path_forbidden", + message: "safe_delete accepts only paths relative to the Plugin provider cwd", + }; + } + if (hasParentTraversal(relativePath)) { + return { + ok: false, + code: "parent_traversal_forbidden", + message: "safe_delete rejects parent traversal components", + }; + } + + let canonicalRoot; + try { + canonicalRoot = realpathNative(root); + } catch { + return { + ok: false, + code: "root_unavailable", + message: "safe_delete could not resolve the Plugin provider cwd", + }; + } + + const lexicalTarget = path.resolve(canonicalRoot, relativePath); + if (lexicalTarget === canonicalRoot) { + return { + ok: false, + code: "root_delete_forbidden", + message: "safe_delete refuses to move the Plugin provider cwd itself to Trash", + }; + } + if (!isStrictlyWithin(canonicalRoot, lexicalTarget)) { + return { + ok: false, + code: "path_outside_root", + message: "safe_delete path is outside the Plugin provider cwd", + }; + } + + let metadata; + try { + metadata = fs.lstatSync(lexicalTarget); + } catch (error) { + if (error?.code !== "ENOENT") { + return { + ok: false, + code: "path_inspection_failed", + message: "safe_delete could not inspect the requested path", + }; + } + try { + const ancestor = nearestExistingAncestor(lexicalTarget); + if (!isWithinOrEqual(canonicalRoot, ancestor)) { + return { + ok: false, + code: "path_outside_root", + message: "safe_delete path resolves outside the Plugin provider cwd", + }; + } + } catch { + return { + ok: false, + code: "path_inspection_failed", + message: "safe_delete could not verify the requested path boundary", + }; + } + return { + ok: true, + absent: true, + root: canonicalRoot, + target: lexicalTarget, + displayPath: relativePath, + }; + } + + if (metadata.isSymbolicLink()) { + return { + ok: false, + code: "symlink_unsupported", + message: + "safe_delete refuses symlinks and junctions because cross-platform Trash backends differ in link-following behavior", + }; + } + if (!metadata.isFile() && !metadata.isDirectory()) { + return { + ok: false, + code: "unsupported_file_type", + message: "safe_delete supports only ordinary files and directories", + }; + } + + let canonicalTarget; + try { + canonicalTarget = realpathNative(lexicalTarget); + } catch { + return { + ok: false, + code: "path_inspection_failed", + message: "safe_delete could not resolve the requested path", + }; + } + if (!isStrictlyWithin(canonicalRoot, canonicalTarget)) { + return { + ok: false, + code: "path_outside_root", + message: "safe_delete path resolves outside the Plugin provider cwd", + }; + } + + return { + ok: true, + absent: false, + root: canonicalRoot, + target: canonicalTarget, + displayPath: relativePath, + kind: metadata.isDirectory() ? "directory" : "file", + }; +} + +function normalizeSpawnResult(value) { + if (value?.error?.code === "ENOENT") return { state: "missing" }; + if (value?.error?.code === "ETIMEDOUT") return { state: "timeout" }; + if (value?.error) return { state: "failed" }; + if (value?.status === 0) return { state: "success" }; + return { state: "failed" }; +} + +function spawnBackend(spawnSync, command, args, options = {}) { + return normalizeSpawnResult( + spawnSync(command, args, { + shell: false, + windowsHide: true, + timeout: BACKEND_TIMEOUT_MS, + maxBuffer: BACKEND_MAX_BUFFER, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }), + ); +} + +const MACOS_TRASH_JXA = String.raw` +ObjC.import('Foundation'); +function run(argv) { + const url = $.NSURL.fileURLWithPath(argv[0]); + const resultURL = Ref(); + const error = Ref(); + const ok = $.NSFileManager.defaultManager.trashItemAtURLResultingItemURLError( + url, + resultURL, + error, + ); + if (!ok) throw new Error('Trash operation failed'); +} +`.trim(); + +const WINDOWS_TRASH_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName Microsoft.VisualBasic +$target = $env:WEBCODEX_SAFE_DELETE_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { throw 'missing target' } +$item = Get-Item -LiteralPath $target -Force +$ui = [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs +$recycle = [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin +$cancel = [Microsoft.VisualBasic.FileIO.UICancelOption]::ThrowException +if ($item.PSIsContainer) { + [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($target, $ui, $recycle, $cancel) +} else { + [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($target, $ui, $recycle, $cancel) +} +`.trim(); + +function ensureRealDirectory(directory) { + try { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const metadata = fs.lstatSync(directory); + return metadata.isDirectory() && !metadata.isSymbolicLink(); + } catch { + return false; + } +} + +function trashDeletionDate(now = new Date()) { + const pad = (value) => String(value).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; +} + +function trashInfoPathValue(target) { + return target.split("/").map((component) => encodeURIComponent(component)).join("/"); +} + +export function moveToFreedesktopTrash( + target, + { env = process.env, home = os.homedir(), now = new Date() } = {}, +) { + const dataHome = env.XDG_DATA_HOME?.trim() || (home ? path.join(home, ".local", "share") : ""); + if (!dataHome || !path.isAbsolute(dataHome)) return { state: "unavailable" }; + + const trashRoot = path.join(dataHome, "Trash"); + const filesDir = path.join(trashRoot, "files"); + const infoDir = path.join(trashRoot, "info"); + if (!ensureRealDirectory(filesDir) || !ensureRealDirectory(infoDir)) { + return { state: "unavailable" }; + } + + const base = path.basename(target) || "item"; + let destination; + let infoPath; + let tempInfo; + for (let attempt = 0; attempt < 8; attempt += 1) { + const suffix = randomBytes(8).toString("hex"); + const name = `${base}.${suffix}`; + const candidateDestination = path.join(filesDir, name); + const candidateInfo = path.join(infoDir, `${name}.trashinfo`); + const candidateTemp = path.join(infoDir, `.${name}.trashinfo.webcodex-tmp`); + if ( + !fs.existsSync(candidateDestination) && + !fs.existsSync(candidateInfo) && + !fs.existsSync(candidateTemp) + ) { + destination = candidateDestination; + infoPath = candidateInfo; + tempInfo = candidateTemp; + break; + } + } + if (!destination || !infoPath || !tempInfo) return { state: "unavailable" }; + + const info = `[Trash Info]\nPath=${trashInfoPathValue(target)}\nDeletionDate=${trashDeletionDate(now)}\n`; + try { + fs.writeFileSync(tempInfo, info, { encoding: "utf8", flag: "wx", mode: 0o600 }); + } catch { + return { state: "unavailable" }; + } + + try { + // rename is intentionally required: EXDEV fails closed rather than copying + // and permanently unlinking the requested path across filesystems. + fs.renameSync(target, destination); + } catch { + try { + fs.rmSync(tempInfo, { force: true }); + } catch { + // Cleanup concerns only our own unpublished metadata temp file. + } + return { state: "unavailable" }; + } + + try { + fs.renameSync(tempInfo, infoPath); + } catch { + // The requested object is already inside Trash/files. Do not attempt to + // move it back or report a definite failure: recovery metadata publication + // is incomplete, so the effect is explicitly unknown. + return { state: "unknown" }; + } + return { state: "success" }; +} + +export function runTrashBackend( + target, + { platform = process.platform, spawnSync = nodeSpawnSync, env = process.env } = {}, +) { + if (platform === "linux") { + const freedesktop = moveToFreedesktopTrash(target, { env }); + if (freedesktop.state === "success" || freedesktop.state === "unknown") { + return { backend: "freedesktop", ...freedesktop }; + } + const gio = spawnBackend(spawnSync, "gio", ["trash", target]); + if (gio.state !== "missing") return { backend: "gio", ...gio }; + const trashPut = spawnBackend(spawnSync, "trash-put", [target]); + if (trashPut.state !== "missing") return { backend: "trash-put", ...trashPut }; + return { backend: "none", state: "missing" }; + } + + if (platform === "darwin") { + const foundation = spawnBackend(spawnSync, "/usr/bin/osascript", [ + "-l", + "JavaScript", + "-e", + MACOS_TRASH_JXA, + target, + ]); + return { + backend: foundation.state === "missing" ? "none" : "foundation", + ...foundation, + }; + } + + if (platform === "win32") { + const childEnv = { ...env, WEBCODEX_SAFE_DELETE_TARGET: target }; + const args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_TRASH_SCRIPT]; + const powershell = spawnBackend(spawnSync, "powershell.exe", args, { env: childEnv }); + if (powershell.state !== "missing") return { backend: "powershell", ...powershell }; + const pwsh = spawnBackend(spawnSync, "pwsh.exe", args, { env: childEnv }); + if (pwsh.state !== "missing") return { backend: "powershell", ...pwsh }; + return { backend: "none", state: "missing" }; + } + + return { backend: "none", state: "missing" }; +} + +function targetStillExists(target) { + try { + fs.lstatSync(target); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +export function safeDelete( + argumentsValue, + { + root = process.cwd(), + platform = process.platform, + spawnSync = nodeSpawnSync, + env = process.env, + } = {}, +) { + const requestedPath = argumentsValue?.path; + const displayPath = safeRelativeDisplay(requestedPath); + const target = resolveAuthorizedTarget(requestedPath, root); + if (!target.ok) return rejected(displayPath, target.code, target.message); + if (target.absent) { + return toolResult( + `Nothing was deleted: ${displayPath} is already absent.`, + structured("already_absent", displayPath), + ); + } + + const backendResult = runTrashBackend(target.target, { platform, spawnSync, env }); + if (backendResult.state === "missing") { + return failed( + displayPath, + "none", + "trash_backend_unavailable", + "No supported system Trash/Recycle Bin backend is available; safe_delete did not fall back to permanent deletion.", + ); + } + if (backendResult.state === "timeout") { + return failed( + displayPath, + backendResult.backend, + "trash_operation_timeout", + "The system Trash operation timed out. Its effect is unknown; inspect the path or Trash before retrying.", + "unknown", + ); + } + if (backendResult.state === "unknown") { + return failed( + displayPath, + backendResult.backend, + "trash_operation_unknown", + "The item may already be inside the system Trash, but recovery metadata could not be confirmed. Inspect the path and Trash before retrying.", + "unknown", + ); + } + if (backendResult.state !== "success") { + try { + if (!targetStillExists(target.target)) { + return failed( + displayPath, + backendResult.backend, + "trash_operation_unknown", + "The Trash backend reported a failure but the original path is now absent. Its effect is unknown; inspect the path and Trash before retrying.", + "unknown", + ); + } + } catch { + return failed( + displayPath, + backendResult.backend, + "trash_postcondition_unknown", + "The Trash backend reported a failure and safe_delete could not verify whether the original path still exists. Inspect the path and Trash before retrying.", + "unknown", + ); + } + return failed( + displayPath, + backendResult.backend, + "trash_operation_failed", + "The system Trash/Recycle Bin backend reported a failure and the original path is still present; safe_delete did not use a permanent-delete fallback.", + ); + } + + try { + if (targetStillExists(target.target)) { + return failed( + displayPath, + backendResult.backend, + "trash_postcondition_unknown", + "The Trash backend returned success but the path still exists. The effect is unknown; inspect the path and Trash before retrying.", + "unknown", + ); + } + } catch { + return failed( + displayPath, + backendResult.backend, + "trash_postcondition_unknown", + "The Trash backend returned success but safe_delete could not verify the postcondition. Inspect the path and Trash before retrying.", + "unknown", + ); + } + + return toolResult( + `Moved ${displayPath} to the system Trash/Recycle Bin.`, + structured("trashed", displayPath, backendResult.backend), + ); +} + +export function handleRequest(request, options = {}) { + const { id, method, params = {} } = request ?? {}; + if (request?.jsonrpc !== "2.0" || id === undefined) { + return { kind: "rpc_error", id: id ?? null, code: -32600, message: "invalid request" }; + } + if (method === "initialize") { + if (params.protocolVersion !== PROTOCOL_VERSION) { + return { + kind: "rpc_error", + id, + code: -32602, + message: "unsupported protocol version", + }; + } + return { kind: "result", id, value: { protocolVersion: PROTOCOL_VERSION } }; + } + if (method === "tools/list") { + return { kind: "result", id, value: { tools: [SAFE_DELETE_TOOL] } }; + } + if (method === "tools/call") { + if (params.name !== SAFE_DELETE_TOOL.name) { + return { + kind: "result", + id, + value: toolResult( + "unknown tool", + structured("rejected", "[unknown]", "none", "unknown_tool"), + true, + ), + }; + } + return { kind: "result", id, value: safeDelete(params.arguments ?? {}, options) }; + } + return { kind: "rpc_error", id, code: -32601, message: "method not found" }; +} + +function sendHandled(handled) { + if (handled.kind === "rpc_error") { + rpcError(handled.id, handled.code, handled.message); + } else { + result(handled.id, handled.value); + } +} + +export function runProtocolServer(options = {}) { + const input = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, + }); + input.on("line", (line) => { + let request; + try { + request = JSON.parse(line); + } catch { + // stdout is protocol-only. Local diagnostics belong on stderr. + console.error("safe-delete plugin received malformed JSON"); + return; + } + sendHandled(handleRequest(request, options)); + }); +} + +const invokedAsMain = + process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (invokedAsMain) runProtocolServer(); diff --git a/plugins/safe-delete/plugin.test.mjs b/plugins/safe-delete/plugin.test.mjs new file mode 100644 index 00000000..6dd837ea --- /dev/null +++ b/plugins/safe-delete/plugin.test.mjs @@ -0,0 +1,387 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + PROTOCOL_VERSION, + SAFE_DELETE_TOOL, + moveToFreedesktopTrash, + resolveAuthorizedTarget, + runTrashBackend, + safeDelete, +} from "./plugin.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pluginPath = path.join(here, "plugin.mjs"); + +function tempRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), "webcodex-safe-delete-")); +} + +test("tool definition marks the operation destructive and single-path", () => { + assert.equal(SAFE_DELETE_TOOL.name, "safe_delete"); + assert.equal(SAFE_DELETE_TOOL.annotations.destructiveHint, true); + assert.equal(SAFE_DELETE_TOOL.annotations.readOnlyHint, false); + assert.equal(SAFE_DELETE_TOOL.annotations.idempotentHint, false); + assert.deepEqual(SAFE_DELETE_TOOL.inputSchema.required, ["path"]); + assert.equal(SAFE_DELETE_TOOL.inputSchema.additionalProperties, false); +}); + +test("absolute paths, root deletion, and parent traversal are rejected", () => { + const root = tempRoot(); + try { + assert.equal(resolveAuthorizedTarget(path.resolve(root, "victim"), root).code, "absolute_path_forbidden"); + assert.equal(resolveAuthorizedTarget(".", root).code, "root_delete_forbidden"); + assert.equal(resolveAuthorizedTarget("../victim", root).code, "parent_traversal_forbidden"); + assert.equal(resolveAuthorizedTarget(`nested${path.sep}..${path.sep}victim`, root).code, "parent_traversal_forbidden"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("missing target is a safe no-op and never invokes a backend", () => { + const root = tempRoot(); + let calls = 0; + try { + const value = safeDelete( + { path: "missing.txt" }, + { + root, + platform: "linux", + spawnSync: () => { + calls += 1; + return { status: 0 }; + }, + }, + ); + assert.equal(value.isError, false); + assert.equal(value.structuredContent.outcome, "already_absent"); + assert.equal(calls, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("symlink target is rejected without invoking Trash", { skip: process.platform === "win32" }, () => { + const root = tempRoot(); + const outside = tempRoot(); + let calls = 0; + try { + fs.writeFileSync(path.join(outside, "target.txt"), "outside"); + fs.symlinkSync(path.join(outside, "target.txt"), path.join(root, "link.txt")); + const value = safeDelete( + { path: "link.txt" }, + { + root, + platform: "linux", + spawnSync: () => { + calls += 1; + return { status: 0 }; + }, + }, + ); + assert.equal(value.isError, true); + assert.equal(value.structuredContent.errorCode, "symlink_unsupported"); + assert.equal(calls, 0); + assert.equal(fs.readFileSync(path.join(outside, "target.txt"), "utf8"), "outside"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test("symlinked parent escape is rejected", { skip: process.platform === "win32" }, () => { + const root = tempRoot(); + const outside = tempRoot(); + try { + fs.writeFileSync(path.join(outside, "victim.txt"), "outside"); + fs.symlinkSync(outside, path.join(root, "escape")); + const resolved = resolveAuthorizedTarget("escape/victim.txt", root); + assert.equal(resolved.ok, false); + assert.equal(resolved.code, "path_outside_root"); + assert.equal(fs.readFileSync(path.join(outside, "victim.txt"), "utf8"), "outside"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test("freedesktop backend creates Trash metadata and moves the requested file", () => { + const root = tempRoot(); + const dataHome = tempRoot(); + const target = path.join(root, "victim #1.txt"); + fs.writeFileSync(target, "recoverable"); + try { + const value = moveToFreedesktopTrash(target, { + env: { XDG_DATA_HOME: dataHome }, + home: root, + now: new Date(2026, 8, 5, 12, 34, 56), + }); + assert.equal(value.state, "success"); + assert.equal(fs.existsSync(target), false); + const files = fs.readdirSync(path.join(dataHome, "Trash", "files")); + const infos = fs.readdirSync(path.join(dataHome, "Trash", "info")); + assert.equal(files.length, 1); + assert.equal(infos.length, 1); + assert.equal( + fs.readFileSync(path.join(dataHome, "Trash", "files", files[0]), "utf8"), + "recoverable", + ); + const info = fs.readFileSync(path.join(dataHome, "Trash", "info", infos[0]), "utf8"); + assert.match(info, /^\[Trash Info\]/m); + assert.match(info, /victim%20%231\.txt/); + assert.match(info, /DeletionDate=2026-09-05T12:34:56/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(dataHome, { recursive: true, force: true }); + } +}); + +test("Linux falls back to gio when freedesktop Trash cannot start", () => { + const calls = []; + const root = tempRoot(); + const target = path.join(root, "victim"); + fs.writeFileSync(target, "x"); + try { + const value = runTrashBackend(target, { + platform: "linux", + spawnSync: (command, args) => { + calls.push([command, args]); + return { status: 0 }; + }, + env: { XDG_DATA_HOME: "relative-not-usable" }, + }); + assert.equal(value.backend, "gio"); + assert.equal(value.state, "success"); + assert.deepEqual(calls, [["gio", ["trash", target]]]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Linux does not try another backend after a real gio failure", () => { + const calls = []; + const root = tempRoot(); + const target = path.join(root, "victim"); + fs.writeFileSync(target, "x"); + try { + const value = runTrashBackend(target, { + platform: "linux", + env: { XDG_DATA_HOME: "relative-not-usable" }, + spawnSync: (command, args) => { + calls.push([command, args]); + return { status: 7 }; + }, + }); + assert.equal(value.backend, "gio"); + assert.equal(value.state, "failed"); + assert.equal(calls.length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Linux fails closed when no Trash backend is installed", () => { + const missing = () => ({ error: { code: "ENOENT" } }); + const root = tempRoot(); + const target = path.join(root, "victim"); + fs.writeFileSync(target, "x"); + try { + assert.deepEqual( + runTrashBackend(target, { + platform: "linux", + spawnSync: missing, + env: { XDG_DATA_HOME: "relative-not-usable" }, + }), + { backend: "none", state: "missing" }, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("macOS uses Foundation Trash and passes the target as argv instead of interpolating it", () => { + const calls = []; + const target = `/tmp/quote-'-$HOME-victim`; + runTrashBackend(target, { + platform: "darwin", + spawnSync: (command, args) => { + calls.push({ command, args }); + return { status: 0 }; + }, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "/usr/bin/osascript"); + assert.equal(calls[0].args.at(-1), target); + assert.equal(calls[0].args.at(-2).includes(target), false); + assert.deepEqual(calls[0].args.slice(0, 3), ["-l", "JavaScript", "-e"]); + assert.match(calls[0].args.at(-2), /NSFileManager/); + assert.equal(calls[0].args.at(-2).includes("Finder"), false); +}); + +test("Windows passes the target through environment and only falls back when powershell is missing", () => { + const calls = []; + const target = String.raw`C:\repo\odd ' victim.txt`; + const value = runTrashBackend(target, { + platform: "win32", + env: { PATH: "test" }, + spawnSync: (command, args, options) => { + calls.push({ command, args, options }); + if (command === "powershell.exe") return { error: { code: "ENOENT" } }; + return { status: 0 }; + }, + }); + assert.equal(value.backend, "powershell"); + assert.equal(value.state, "success"); + assert.deepEqual(calls.map((call) => call.command), ["powershell.exe", "pwsh.exe"]); + for (const call of calls) { + assert.equal(call.options.env.WEBCODEX_SAFE_DELETE_TARGET, target); + assert.equal(call.args.join(" ").includes(target), false); + assert.equal(call.args.join(" ").includes("Remove-Item"), false); + } +}); + +test("backend failure never removes the target or exposes raw backend stderr", () => { + const root = tempRoot(); + const target = path.join(root, "victim.txt"); + fs.writeFileSync(target, "keep"); + try { + const value = safeDelete( + { path: "victim.txt" }, + { + root, + platform: "linux", + env: { XDG_DATA_HOME: "relative-not-usable" }, + spawnSync: () => ({ status: 9, stderr: `secret:${root}` }), + }, + ); + assert.equal(value.isError, true); + assert.equal(value.structuredContent.outcome, "failed"); + assert.equal(value.structuredContent.errorCode, "trash_operation_failed"); + assert.equal(fs.readFileSync(target, "utf8"), "keep"); + assert.equal(JSON.stringify(value).includes(root), false); + assert.equal(JSON.stringify(value).includes("secret:"), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("backend failure after the target disappears is outcome unknown", () => { + const root = tempRoot(); + const target = path.join(root, "victim.txt"); + fs.writeFileSync(target, "maybe trashed"); + try { + const value = safeDelete( + { path: "victim.txt" }, + { + root, + platform: "linux", + env: { XDG_DATA_HOME: "relative-not-usable" }, + spawnSync: () => { + fs.unlinkSync(target); + return { status: 9 }; + }, + }, + ); + assert.equal(value.isError, true); + assert.equal(value.structuredContent.outcome, "unknown"); + assert.equal(value.structuredContent.errorCode, "trash_operation_unknown"); + assert.match(value.content[0].text, /inspect/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("timeout is reported as unknown and does not invite automatic retry", () => { + const root = tempRoot(); + const target = path.join(root, "victim.txt"); + fs.writeFileSync(target, "keep"); + try { + const value = safeDelete( + { path: "victim.txt" }, + { + root, + platform: "linux", + env: { XDG_DATA_HOME: "relative-not-usable" }, + spawnSync: () => ({ error: { code: "ETIMEDOUT" } }), + }, + ); + assert.equal(value.isError, true); + assert.equal(value.structuredContent.outcome, "unknown"); + assert.equal(value.structuredContent.errorCode, "trash_operation_timeout"); + assert.match(value.content[0].text, /inspect/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("successful backend must make the target absent before reporting trashed", () => { + const root = tempRoot(); + const target = path.join(root, "victim.txt"); + fs.writeFileSync(target, "trash me"); + try { + const value = safeDelete( + { path: "victim.txt" }, + { + root, + platform: "linux", + env: { XDG_DATA_HOME: "relative-not-usable" }, + spawnSync: () => { + fs.unlinkSync(target); + return { status: 0 }; + }, + }, + ); + assert.equal(value.isError, false); + assert.equal(value.structuredContent.outcome, "trashed"); + assert.equal(value.structuredContent.backend, "gio"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("JSON-RPC initialize/list/call works without touching Trash for an absent path", async () => { + const root = tempRoot(); + const child = spawn(process.execPath, [pluginPath], { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); + const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + const responses = []; + lines.on("line", (line) => responses.push(JSON.parse(line))); + + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: PROTOCOL_VERSION } })}\n`, + ); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "safe_delete", arguments: { path: "already-gone.txt" } } })}\n`, + ); + child.stdin.end(); + + const exitCode = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill(); + reject(new Error("plugin protocol smoke timed out")); + }, 5000); + child.once("exit", (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + try { + assert.equal(exitCode, 0); + assert.equal(responses.length, 3); + assert.equal(responses[0].result.protocolVersion, PROTOCOL_VERSION); + assert.equal(responses[1].result.tools[0].name, "safe_delete"); + assert.equal(responses[2].result.structuredContent.outcome, "already_absent"); + } finally { + lines.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +});