Repository navigation
Expand file tree
/
Copy pathemptyTrash.ts
More file actions
314 lines (281 loc) · 12.4 KB
/
Copy pathemptyTrash.ts
File metadata and controls
314 lines (281 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
/**
* mcp/skills/emptyTrash.ts — empty_trash skill
*
* Empties the system Trash (macOS) or Recycle Bin (Windows) and reports the
* space freed. A dryRun mode lets Claude show the user the impact first.
*
* IMPORTANT: Always confirm with the user before calling without dryRun.
*
* Platform strategy
* -----------------
* darwin Primary path is AppleScript via Finder — Finder holds the TCC
* permission for ~/.Trash natively, so it works without granting
* Full Disk Access to the host app. Falls back to fs.rm if
* AppleScript is unavailable, and surfaces any errors clearly
* (EPERM / TCC failures are no longer silently swallowed).
* win32 Shell.Application COM object for size; Clear-RecycleBin to empty.
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/emptyTrash.ts --dry-run
*/
import * as fs from "fs/promises";
import * as os from "os";
import * as nodePath from "path";
import { execFile } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import { loggedExec } from "./_shared/platform";
import { formatBytes } from "./_shared/formatBytes";
const execFileAsync = promisify(execFile);
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "empty_trash",
description:
"Empties the system Trash (macOS) or Recycle Bin (Windows) and reports " +
"the space freed. Always confirm with the user before calling without dryRun.",
riskLevel: "high",
destructive: true,
requiresConsent: true,
supportsDryRun: true,
affectedScope: ["user"],
auditRequired: true,
tccCategories: ["FullDiskAccess", "Automation"],
footprint: {
kind: "sweep",
darwin: { roots: ["~/.Trash"] },
win32: { roots: ["%SYSTEMDRIVE%/$Recycle.Bin"] },
},
outputKeys: ["dryRun","itemsInTrash","freedBytes","freedHuman","warning","method","itemsRemoved","itemsRemaining","error"],
schema: {
dryRun: z
.boolean()
.nullable().optional()
.describe(
"When true, reports how much would be freed without actually emptying. " +
"Default: false.",
),
},
} as const;
// -- Helpers ------------------------------------------------------------------
// formatBytes is imported from _shared/formatBytes.
async function runPS(script: string, tag: string): Promise<string> {
const encoded = Buffer.from(script, "utf16le").toString("base64");
const { stdout } = await loggedExec(
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
{ tag: `empty_trash:${tag}`, maxBuffer: 4 * 1024 * 1024 },
);
return stdout.trim();
}
/** Run an AppleScript snippet via osascript; returns stdout (trimmed).
*
* Uses execFile (not exec/shell) so the script string is passed as a direct
* argv element to osascript. exec() → JSON.stringify() turns real newlines
* into the two-character sequence \n, which the shell leaves as literal
* backslash-n inside double quotes — osascript then raises syntax error -2741
* because AppleScript requires actual newlines between statements.
*/
async function runOsa(script: string, tag: string): Promise<string> {
const { stdout } = await execFileAsync("osascript", ["-e", script], {
maxBuffer: 4 * 1024 * 1024,
});
return stdout.trim();
}
/** True if an Error looks like a TCC / permission denial from macOS. */
function isTccError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const m = err.message.toLowerCase();
return (
m.includes("operation not permitted") ||
m.includes("not authorized") ||
m.includes("eperm") ||
m.includes("eacces") ||
m.includes("errauthorizationcanceled") ||
m.includes("not allowed assistive access") ||
m.includes("(-1743)") || // automation permission denied
m.includes("(-1728)") // object not found (often a TCC symptom)
);
}
// -- darwin implementation ----------------------------------------------------
interface TrashInfo { bytes: number; itemCount: number; error?: string }
/**
* Measure trash via AppleScript (Finder). Finder has the TCC permission to
* read ~/.Trash even when the host Electron app does not.
*/
async function measureTrashDarwinOsa(): Promise<TrashInfo> {
// Use the POSIX trash path via Finder. `size of` on each item avoids needing
// Full Disk Access on the host process. `every item` excludes .DS_Store by
// default (Finder hides it).
const script = `
tell application "Finder"
set trashItems to items of trash
set itemCount to count of trashItems
set totalBytes to 0
repeat with anItem in trashItems
try
set totalBytes to totalBytes + (size of anItem)
end try
end repeat
end tell
return (itemCount as text) & "," & (totalBytes as text)`.trim();
const out = await runOsa(script, "osa-measure");
const [countStr, bytesStr] = out.split(",");
return {
itemCount: parseInt(countStr ?? "0", 10) || 0,
bytes: parseInt(bytesStr ?? "0", 10) || 0,
};
}
/** Fallback direct-fs measurement (only works with Full Disk Access). */
async function measureTrashDarwinFs(trashPath: string): Promise<TrashInfo> {
let bytes = 0;
let itemCount = 0;
const entries = await fs.readdir(trashPath, { withFileTypes: true });
for (const e of entries) {
if (e.name === ".DS_Store") continue;
itemCount++;
try {
const stat = await fs.stat(nodePath.join(trashPath, e.name));
bytes += stat.size;
} catch { /* skip unreadable entries */ }
}
return { bytes, itemCount };
}
async function measureTrashDarwin(trashPath: string): Promise<TrashInfo> {
// Try AppleScript first (works without Full Disk Access).
try {
return await measureTrashDarwinOsa();
} catch (osaErr) {
// Fall back to direct fs — may work if app has Full Disk Access.
try {
return await measureTrashDarwinFs(trashPath);
} catch (fsErr) {
const tcc = isTccError(osaErr) || isTccError(fsErr);
return {
bytes: 0,
itemCount: 0,
error: tcc
? "Cannot read ~/.Trash — macOS TCC permission denied. To fix: open System Settings → Privacy & Security → Automation → find 'AI Support Agent' (or 'Electron' in dev) → enable the checkbox for Finder. Alternatively, grant Full Disk Access to the app in Privacy & Security → Full Disk Access. After granting, fully quit and relaunch the app."
: `Failed to measure trash: ${(osaErr as Error).message}`,
};
}
}
}
async function emptyTrashDarwin(dryRun: boolean) {
const trashPath = nodePath.join(os.homedir(), ".Trash");
const measured = await measureTrashDarwin(trashPath);
if (dryRun) {
return {
dryRun: true,
itemsInTrash: measured.itemCount,
freedBytes: measured.bytes,
freedHuman: formatBytes(measured.bytes),
...(measured.error ? { warning: measured.error } : {}),
};
}
// Primary path — ask Finder to empty the trash. Finder holds the TCC
// permission natively; this also handles locked items and trash folders
// on mounted external volumes.
let method: "finder" | "fs" = "finder";
let emptyError: string | undefined;
try {
await runOsa(`tell application "Finder" to empty trash`, "osa-empty");
} catch (osaErr) {
// Fall back to direct fs.rm — only works with Full Disk Access.
method = "fs";
try {
const entries = await fs.readdir(trashPath);
const results = await Promise.allSettled(
entries.map((e) =>
fs.rm(nodePath.join(trashPath, e), { recursive: true, force: true }),
),
);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
const firstErr = (failures[0] as PromiseRejectedResult).reason as Error;
emptyError = isTccError(firstErr) || isTccError(osaErr)
? `Could not empty trash — macOS TCC permission denied. To fix: open System Settings → Privacy & Security → Automation → find 'AI Support Agent' (or 'Electron' in dev) → enable the checkbox for Finder. Alternatively, grant Full Disk Access. After granting, fully quit and relaunch the app, then try again. (${failures.length}/${results.length} items failed)`
: `Failed to remove ${failures.length}/${results.length} items: ${firstErr.message}`;
}
} catch (fsErr) {
emptyError = isTccError(osaErr) || isTccError(fsErr)
? "Could not empty trash — macOS TCC permission denied. To fix: open System Settings → Privacy & Security → Automation → find 'AI Support Agent' (or 'Electron' in dev) → enable the checkbox for Finder. Alternatively, grant Full Disk Access. After granting, fully quit and relaunch the app, then try again."
: `Failed to empty trash: ${(fsErr as Error).message}`;
}
}
// Verify by re-measuring. This tells us what *actually* got freed.
const after = await measureTrashDarwin(trashPath);
const freedBytes = Math.max(0, measured.bytes - after.bytes);
const itemsRemoved = Math.max(0, measured.itemCount - after.itemCount);
// ── Silent-failure detection ────────────────────────────────────────────────
// If the empty operation reported success (no thrown error → emptyError
// unset) but the before-measurement showed items and zero were actually
// removed, Finder accepted the AppleScript but did not perform the operation.
// This is the defining symptom of macOS TCC silently denying Apple Events
// to Finder — osascript exits 0, nothing happens. Surface a clear, actionable
// error so the user knows what to fix; without this, the agent reports fake
// success and the trash stays full.
if (
emptyError === undefined &&
measured.itemCount > 0 &&
itemsRemoved === 0
) {
emptyError =
"Trash was not actually emptied (Finder reported success but the items remain). " +
"This usually means AI Support Agent does not have permission to automate Finder. " +
"Open System Settings → Privacy & Security → Automation, find AI Support Agent, " +
"and enable the Finder checkbox. Then quit and relaunch AI Support Agent.";
}
return {
dryRun: false,
method,
itemsRemoved,
itemsRemaining: after.itemCount,
freedBytes,
freedHuman: formatBytes(freedBytes),
...(emptyError ? { error: emptyError } : {}),
};
}
// -- win32 implementation -----------------------------------------------------
async function emptyTrashWin32(dryRun: boolean) {
// Query Recycle Bin size via Shell.Application COM object (works on all Windows versions).
const sizeScript = `
$ErrorActionPreference = 'SilentlyContinue'
$shell = New-Object -ComObject Shell.Application
$bin = $shell.Namespace(0xA)
$items = $bin.Items()
[PSCustomObject]@{
bytes = [long]( ($items | ForEach-Object { $_.Size } | Measure-Object -Sum).Sum -as [long] )
items = [int]$items.Count
} | ConvertTo-Json -Compress`.trim();
let bytes = 0;
let items = 0;
try {
const out = await runPS(sizeScript, "ps-measure");
const parsed = JSON.parse(out) as { bytes: number; items: number };
bytes = parsed.bytes ?? 0;
items = parsed.items ?? 0;
} catch { /* best-effort — proceed even if size query fails */ }
if (dryRun) {
return { dryRun: true, itemsInTrash: items, freedBytes: bytes, freedHuman: formatBytes(bytes) };
}
const clearScript = `Clear-RecycleBin -Force -ErrorAction SilentlyContinue; exit 0`;
await runPS(clearScript, "ps-clear").catch(() => { /* ignore — Clear-RecycleBin is best-effort */ });
return { dryRun: false, itemsRemoved: items, freedBytes: bytes, freedHuman: formatBytes(bytes) };
}
// -- Exported run function ----------------------------------------------------
export async function run({ dryRun = false }: { dryRun?: boolean } = {}) {
const platform = os.platform();
if (platform === "win32") return emptyTrashWin32(dryRun);
if (platform === "darwin") return emptyTrashDarwin(dryRun);
throw new Error(
`[empty_trash] Unsupported platform: ${platform}. ` +
"Supported: darwin, win32.",
);
}
// -- CLI smoke test -----------------------------------------------------------
if (require.main === module) {
const dryRun = process.argv.includes("--dry-run");
console.log(`empty_trash smoke test — dryRun=${dryRun}`);
run({ dryRun })
.then((r) => console.log(JSON.stringify(r, null, 2)))
.catch((err: Error) => { console.error(err.message); process.exit(1); });
}