@tencent-ai/codebuddy-code 2.106.0 → 2.106.1-dev.4c12da5.202606131025

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,589 @@
1
+ /**
2
+ * genie-safe-delete — Node.js 运行时安全删除 shim
3
+ *
4
+ * 通过 NODE_OPTIONS="--require=genie-safe-delete.cjs" 注入,
5
+ * 拦截 fs 删除 API,把文件移入操作系统的回收站而不是真删。
6
+ *
7
+ * - macOS:python3 ctypes 调 CoreServices.FSMoveObjectToTrashSync
8
+ * - Windows:powershell 调 Microsoft.VisualBasic.FileIO.FileSystem
9
+ * - Linux:实现 freedesktop Trash spec(~/.local/share/Trash/)
10
+ *
11
+ * 设计要点见 apps/workbuddy-desktop/docs/safe-delete-runtime-trash.md
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const url = require('url');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // 触发条件:会话 ID 缺失则零开销退出
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const SESSION_ID = process.env.CODEBUDDY_SESSION_ID
27
+ || process.env.CLAUDE_SESSION_ID;
28
+
29
+ if (!SESSION_ID) {
30
+ return;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // 全局状态
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const PLATFORM = process.platform; // 'darwin' | 'win32' | 'linux' | ...
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // 平台分发
41
+ // ---------------------------------------------------------------------------
42
+
43
+ // ---- 解析 genie-trash binary 路径 ----
44
+ // 优先使用 GENIE_TRASH_DIR 环境变量(由 buildSafeDeleteEnv() 注入),
45
+ // 解决打包后 extraResources(<Resources>/vendor/genie-trash/)与
46
+ // shim(app.asar.unpacked/cli/vendor/shim/)不共父目录的路径错位问题。
47
+ // 开发环境 fallback: ../genie-trash/ 相对 __dirname
48
+ function resolveTrashBin() {
49
+ const ext = PLATFORM === 'win32' ? '.exe' : '';
50
+ const fileName = `${PLATFORM}-${process.arch}${ext}`;
51
+ if (process.env.GENIE_TRASH_DIR) {
52
+ return path.join(process.env.GENIE_TRASH_DIR, fileName);
53
+ }
54
+ return path.resolve(__dirname, '..', 'genie-trash', fileName);
55
+ }
56
+
57
+ // ---- 尝试 native binary,不可用则降级 ----
58
+ let _trashBinAvailable = null; // null=未探测, true=可用, false=不可用
59
+
60
+ function trashViaBinary(absPath) {
61
+ if (_trashBinAvailable === false) return false; // 已知不可用,快速短路
62
+ try {
63
+ execFileSync(resolveTrashBin(), [absPath], {
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ timeout: 5_000,
66
+ });
67
+ _trashBinAvailable = true;
68
+ return true;
69
+ } catch (e) {
70
+ // ENOENT:binary 不存在,进程生命周期内不会凭空出现,缓存禁用
71
+ if (e.code === 'ENOENT') {
72
+ _trashBinAvailable = false;
73
+ return false;
74
+ }
75
+ // EACCES:权限问题可能是临时状态(如 chmod +x 后恢复),不缓存
76
+ if (e.code === 'EACCES') {
77
+ return false;
78
+ }
79
+ // binary 已启动但对该路径操作失败(exit code 1)或超时——向上抛,不真删
80
+ const detail = e.stderr ? e.stderr.toString().trim() : '';
81
+ throw new Error(`[safe-delete] 操作失败: ${detail || e.message}`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 把文件/目录移入系统回收站。
87
+ * 优先 native binary(性能最优),不可用时自动降级到平台实现。
88
+ * 同步调用,失败抛错(调用方捕获后 fallback 到原生删除)。
89
+ *
90
+ * @param {string} absPath 绝对路径
91
+ */
92
+ function trashItem(absPath) {
93
+ // 优先 native binary
94
+ if (trashViaBinary(absPath)) return;
95
+
96
+ // ---- 降级:当前平台实现(全部保留) ----
97
+ if (PLATFORM === 'darwin') {
98
+ return trashOnMac(absPath);
99
+ }
100
+ if (PLATFORM === 'win32') {
101
+ return trashOnWindows(absPath);
102
+ }
103
+ // linux / freebsd / 其它 POSIX
104
+ return trashOnFreedesktop(absPath);
105
+ }
106
+
107
+ // ----- macOS -----
108
+
109
+ const DARWIN_TRASH_PYTHON_SCRIPT = `
110
+ import os
111
+ import sys
112
+ from ctypes import Structure, byref, c_char, c_char_p, cdll
113
+ from ctypes.util import find_library
114
+
115
+ path = sys.argv[1]
116
+ foundation_path = find_library("Foundation")
117
+ coreservices_path = find_library("CoreServices")
118
+ if not foundation_path or not coreservices_path:
119
+ print("Foundation/CoreServices unavailable", file=sys.stderr)
120
+ sys.exit(1)
121
+
122
+ Foundation = cdll.LoadLibrary(foundation_path)
123
+ CoreServices = cdll.LoadLibrary(coreservices_path)
124
+ GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString
125
+ GetMacOSStatusCommentString.restype = c_char_p
126
+ FSPathMakeRefWithOptions = CoreServices.FSPathMakeRefWithOptions
127
+ FSMoveObjectToTrashSync = CoreServices.FSMoveObjectToTrashSync
128
+
129
+ class FSRef(Structure):
130
+ _fields_ = [("hidden", c_char * 80)]
131
+
132
+ def status_to_message(code: int) -> str:
133
+ raw = GetMacOSStatusCommentString(code)
134
+ if not raw:
135
+ return f"OSStatus {code}"
136
+ return raw.decode("utf-8", errors="replace")
137
+
138
+ path_b = os.fsencode(path)
139
+ fp = FSRef()
140
+ rc = FSPathMakeRefWithOptions(path_b, 0x01, byref(fp), None)
141
+ if rc:
142
+ print(status_to_message(rc), file=sys.stderr)
143
+ sys.exit(1)
144
+
145
+ rc = FSMoveObjectToTrashSync(byref(fp), None, 0)
146
+ if rc:
147
+ print(status_to_message(rc), file=sys.stderr)
148
+ sys.exit(1)
149
+ `.trim();
150
+
151
+ function pathStillExists(absPath) {
152
+ if (fs.existsSync(absPath)) {
153
+ return true;
154
+ }
155
+ try {
156
+ // existsSync 对 dangling symlink 返回 false,lstat 可识别该场景。
157
+ fs.lstatSync(absPath);
158
+ return true;
159
+ } catch (_) {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ function trashOnMac(absPath) {
165
+ execFileSync('python3', ['-', absPath], {
166
+ input: DARWIN_TRASH_PYTHON_SCRIPT,
167
+ stdio: ['pipe', 'ignore', 'pipe'],
168
+ timeout: 10_000,
169
+ });
170
+ // API 调用成功后追加存在性校验:路径仍在原处视为失败(fail-closed)。
171
+ if (pathStillExists(absPath)) {
172
+ throw new Error('File still exists after python ctypes trash command');
173
+ }
174
+ }
175
+
176
+ // ----- Windows -----
177
+
178
+ function trashOnWindows(absPath) {
179
+ // 用 Microsoft.VisualBasic.FileIO.FileSystem 的 SendToRecycleBin 选项
180
+ // 它对文件和目录有不同的 API:DeleteFile / DeleteDirectory
181
+ const stat = fs.lstatSync(absPath);
182
+ const method = stat.isDirectory() ? 'DeleteDirectory' : 'DeleteFile';
183
+ // 路径中的单引号需双写(PowerShell 单引号字符串)
184
+ const escaped = absPath.replace(/'/g, "''");
185
+ const script = [
186
+ '$ErrorActionPreference = "Stop"',
187
+ 'Add-Type -AssemblyName Microsoft.VisualBasic',
188
+ `[Microsoft.VisualBasic.FileIO.FileSystem]::${method}('${escaped}',`
189
+ + ' [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,'
190
+ + ' [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)',
191
+ ].join('; ');
192
+ execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
193
+ stdio: ['ignore', 'ignore', 'pipe'],
194
+ timeout: 15_000,
195
+ windowsHide: true,
196
+ });
197
+ }
198
+
199
+ // ----- Linux freedesktop -----
200
+
201
+ const HOME = os.homedir();
202
+ const XDG_DATA_HOME = process.env.XDG_DATA_HOME || path.join(HOME, '.local', 'share');
203
+ const TRASH_FILES = path.join(XDG_DATA_HOME, 'Trash', 'files');
204
+ const TRASH_INFO = path.join(XDG_DATA_HOME, 'Trash', 'info');
205
+
206
+ let _dirsEnsured = false;
207
+
208
+ function trashOnFreedesktop(absPath) {
209
+ // freedesktop spec 要求 trash 目录权限 0700
210
+ if (!_dirsEnsured) {
211
+ fs.mkdirSync(TRASH_FILES, { recursive: true, mode: 0o700 });
212
+ fs.mkdirSync(TRASH_INFO, { recursive: true, mode: 0o700 });
213
+ _dirsEnsured = true;
214
+ }
215
+
216
+ const baseName = path.basename(absPath);
217
+ // writeTrashInfo 内部使用原子 wx 写入 + EEXIST 重试,消除 TOCTOU 窗口
218
+ const { destFiles, destInfo } = writeTrashInfo(absPath, baseName);
219
+
220
+ try {
221
+ fs.renameSync(absPath, destFiles);
222
+ } catch (e) {
223
+ if (e.code === 'EXDEV') {
224
+ // 跨设备:copy + delete 回退(参照 Python send2trash 的 shutil.move 回退)
225
+ try {
226
+ const st = fs.lstatSync(absPath);
227
+ if (st.isDirectory()) {
228
+ copyRecursiveSync(absPath, destFiles);
229
+ origRmSync
230
+ ? origRmSync(absPath, { recursive: true, force: true })
231
+ : removeRecursiveSync(absPath);
232
+ } else {
233
+ fs.copyFileSync(absPath, destFiles);
234
+ origUnlinkSync(absPath);
235
+ }
236
+ return;
237
+ } catch (copyErr) {
238
+ // copy 也失败 / 源删除失败 → 清理 trashinfo 和已复制到 trash 的文件
239
+ try { origUnlinkSync(destInfo); } catch (_) { /* ignore */ }
240
+ try {
241
+ if (fs.existsSync(destFiles)) {
242
+ origRmSync
243
+ ? origRmSync(destFiles, { recursive: true, force: true })
244
+ : removeRecursiveSync(destFiles);
245
+ }
246
+ } catch (_) { /* ignore */ }
247
+ throw copyErr;
248
+ }
249
+ }
250
+ // 其他错误(权限不足等)→ 清理 trashinfo,向上抛
251
+ try { origUnlinkSync(destInfo); } catch (_) { /* ignore */ }
252
+ throw e;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 递归删除目录(origRmSync 不可用时的回退)。
258
+ */
259
+ function removeRecursiveSync(dirPath) {
260
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
261
+ for (const entry of entries) {
262
+ const full = path.join(dirPath, entry.name);
263
+ if (entry.isDirectory()) {
264
+ removeRecursiveSync(full);
265
+ } else {
266
+ origUnlinkSync(full);
267
+ }
268
+ }
269
+ origRmdirSync(dirPath);
270
+ }
271
+
272
+ /**
273
+ * 递归复制目录(fs.cpSync 不可用时的回退,Node < 16.7)。
274
+ */
275
+ function copyRecursiveSync(src, dest) {
276
+ if (fs.cpSync) {
277
+ fs.cpSync(src, dest, { recursive: true });
278
+ return;
279
+ }
280
+ fs.mkdirSync(dest, { recursive: true });
281
+ const entries = fs.readdirSync(src, { withFileTypes: true });
282
+ for (const entry of entries) {
283
+ const srcFull = path.join(src, entry.name);
284
+ const destFull = path.join(dest, entry.name);
285
+ if (entry.isDirectory()) {
286
+ copyRecursiveSync(srcFull, destFull);
287
+ } else {
288
+ fs.copyFileSync(srcFull, destFull);
289
+ }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * 写入 .trashinfo 文件并返回 dest 路径。
295
+ *
296
+ * 使用原子 writeFileSync('wx') + EEXIST 重试,缩小 existsSync→write 之间的
297
+ * TOCTOU 窗口(另一进程在检查与写入之间创建同名文件时可重试)。
298
+ *
299
+ * 命名策略: baseName, baseName.2, baseName.3, ...(freedesktop spec 兼容)
300
+ *
301
+ * @param {string} absPath 原始文件绝对路径(用于写入 trashinfo 的 Path 字段)
302
+ * @param {string} baseName 文件名(不含目录),用于构造 trash 中的目标名
303
+ * @returns {{ destFiles: string, destInfo: string, trashedName: string }}
304
+ */
305
+ function writeTrashInfo(absPath, baseName) {
306
+ const deletionDate = new Date().toISOString().replace(/\.\d{3}Z$/, '');
307
+ const escapedPath = absPath.split('/').map(s => {
308
+ return s.replace(/[^\x20-\x7E]/g, c => encodeURIComponent(c));
309
+ }).join('/');
310
+ const info = `[Trash Info]\nPath=${escapedPath}\nDeletionDate=${deletionDate}\n`;
311
+
312
+ let name = baseName;
313
+ let i = 2;
314
+ const nextName = () => {
315
+ name = `${baseName}.${i++}`;
316
+ if (i > 1000) {
317
+ throw new Error('Too many trash name conflicts');
318
+ }
319
+ };
320
+ while (true) {
321
+ const destFiles = path.join(TRASH_FILES, name);
322
+ const destInfo = path.join(TRASH_INFO, name + '.trashinfo');
323
+ // 半残留场景:files 存在但 info 缺失,必须避开,避免后续 rename 覆盖旧文件
324
+ if (fs.existsSync(destFiles)) {
325
+ nextName();
326
+ continue;
327
+ }
328
+ try {
329
+ // wx:原子创建,若文件已存在抛 EEXIST → 自增重试
330
+ fs.writeFileSync(destInfo, info, { flag: 'wx', mode: 0o600 });
331
+ // 竞争窗口:若另一进程在本次写入后创建了同名 files,删除刚写入的 info 并重试
332
+ if (fs.existsSync(destFiles)) {
333
+ try { origUnlinkSync(destInfo); } catch (_) { /* ignore */ }
334
+ nextName();
335
+ continue;
336
+ }
337
+ return { destFiles, destInfo, trashedName: name };
338
+ } catch (e) {
339
+ if (e.code === 'EEXIST') {
340
+ nextName();
341
+ continue;
342
+ }
343
+ throw e;
344
+ }
345
+ }
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // 核心:尝试移入回收站。失败/超限时返回 false,调用方走原生删除。
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /**
353
+ * 把文件/目录移入系统回收站。失败时抛错(fail-closed),不降级真删。
354
+ * @param {string} absPath 绝对路径
355
+ */
356
+ function tryTrash(absPath) {
357
+ trashItem(absPath); // 成功静默;失败抛错,调用方不应继续真删
358
+ return true;
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // fs 入口包装
363
+ // ---------------------------------------------------------------------------
364
+
365
+ // 保存原始引用(必须在所有 patch 之前)
366
+ const origUnlinkSync = fs.unlinkSync.bind(fs);
367
+ const origRmdirSync = fs.rmdirSync.bind(fs);
368
+ const origRmSync = fs.rmSync ? fs.rmSync.bind(fs) : null;
369
+
370
+ const origUnlink = fs.unlink.bind(fs);
371
+ const origRmdir = fs.rmdir.bind(fs);
372
+ const origRm = fs.rm ? fs.rm.bind(fs) : null;
373
+
374
+ const origPromisesUnlink = fs.promises ? fs.promises.unlink.bind(fs.promises) : null;
375
+ const origPromisesRmdir = fs.promises ? fs.promises.rmdir.bind(fs.promises) : null;
376
+ const origPromisesRm = fs.promises && fs.promises.rm ? fs.promises.rm.bind(fs.promises) : null;
377
+
378
+ // ----- 简单入口(unlink / rmdir)-----
379
+
380
+ /**
381
+ * 将 fs API 的 filePath 参数转为绝对路径。
382
+ * 处理 URL 对象(如 new URL('file:///tmp/x')),避免 String(URL) 产生无效路径绕过 shim。
383
+ * @param {string|Buffer|URL} filePath
384
+ * @returns {string} 绝对路径
385
+ */
386
+ function toAbsPath(filePath) {
387
+ if (filePath instanceof URL) {
388
+ return url.fileURLToPath(filePath);
389
+ }
390
+ // 防御跨 VM context 的 URL 对象(instanceof 跨 realm 返回 false)
391
+ if (filePath && typeof filePath === 'object' && filePath.href && typeof filePath.protocol === 'string') {
392
+ return url.fileURLToPath(filePath);
393
+ }
394
+ return path.resolve(String(filePath));
395
+ }
396
+
397
+ function makeSyncWrapper(orig, requireDir) {
398
+ return function (filePath, ...rest) {
399
+ const absPath = toAbsPath(filePath);
400
+ if (requireDir) {
401
+ const st = safeLstat(absPath);
402
+ if (st === null) return orig(filePath, ...rest); // ENOENT → 原生抛
403
+ if (!st) { /* EACCES/ELOOP:无法 stat,跳过类型检查直接尝试 trash */ }
404
+ else {
405
+ if (!st.isDirectory()) return orig(filePath, ...rest); // ENOTDIR
406
+ // rmdirSync 不带 {recursive: true} 时,非空目录交还原生抛 ENOTEMPTY
407
+ // 带 {recursive: true} 时跳过 isDirEmpty 检查,让 tryTrash 处理整个目录树
408
+ const opts = rest[0];
409
+ if (!(opts && opts.recursive) && !isDirEmpty(absPath)) return orig(filePath, ...rest);
410
+ }
411
+ } else {
412
+ const st = safeLstat(absPath);
413
+ if (st === null) return orig(filePath, ...rest); // ENOENT
414
+ if (st && st.isDirectory()) return orig(filePath, ...rest); // EISDIR(stat 成功 + 是目录)
415
+ }
416
+
417
+ tryTrash(absPath); // 失败时抛错,不调 orig(fail-closed)
418
+ return undefined;
419
+ };
420
+ }
421
+
422
+ /**
423
+ * 安全的 lstat:区分 ENOENT(文件不存在)和其他错误(EACCES/ELOOP 等)。
424
+ * @returns {object|null|undefined}
425
+ * Stats 对象 — 成功
426
+ * null — 文件不存在 (ENOENT)
427
+ * undefined — 无法获取状态(EACCES/ELOOP 等),调用方应回退原生
428
+ */
429
+ function safeLstat(absPath) {
430
+ try { return fs.lstatSync(absPath); } catch (e) {
431
+ if (e.code === 'ENOENT') return null;
432
+ return undefined;
433
+ }
434
+ }
435
+
436
+ function isDirEmpty(absPath) {
437
+ try { return fs.readdirSync(absPath).length === 0; } catch (_) { return false; }
438
+ }
439
+
440
+ function makeCallbackWrapper(orig, requireDir) {
441
+ return function (filePath, ...rest) {
442
+ const cb = rest[rest.length - 1];
443
+ // 保持 Node callback API 参数校验语义:
444
+ // callback 缺失/非函数时交还原生抛 ERR_INVALID_ARG_TYPE,
445
+ // 不应在参数非法时触发 safe-delete。
446
+ if (typeof cb !== 'function') {
447
+ return orig(filePath, ...rest);
448
+ }
449
+ const absPath = toAbsPath(filePath);
450
+
451
+ const st = safeLstat(absPath);
452
+ if (st === null) return orig(filePath, ...rest); // ENOENT → 原生抛
453
+ if (st) {
454
+ if (requireDir && !st.isDirectory()) return orig(filePath, ...rest);
455
+ if (!requireDir && st.isDirectory()) return orig(filePath, ...rest);
456
+ }
457
+ // rmdir 不带 {recursive: true} 时,非空目录交还原生抛 ENOTEMPTY
458
+ // rest 可能是 [callback] 或 [options, callback]
459
+ const opts = rest[0] && typeof rest[0] === 'object' ? rest[0] : null;
460
+ if (requireDir && st && !(opts && opts.recursive) && !isDirEmpty(absPath)) return orig(filePath, ...rest);
461
+
462
+ try {
463
+ tryTrash(absPath);
464
+ } catch (e) {
465
+ if (typeof cb === 'function') {
466
+ process.nextTick(() => cb(e));
467
+ return;
468
+ }
469
+ throw e;
470
+ }
471
+ if (typeof cb === 'function') process.nextTick(() => cb(null));
472
+ };
473
+ }
474
+
475
+ function makePromiseWrapper(orig, requireDir) {
476
+ return async function (filePath, ...rest) {
477
+ const absPath = toAbsPath(filePath);
478
+ const st = safeLstat(absPath);
479
+ if (st === null) return orig(filePath, ...rest); // ENOENT → 原生抛
480
+ if (st) {
481
+ if (requireDir && !st.isDirectory()) return orig(filePath, ...rest);
482
+ if (!requireDir && st.isDirectory()) return orig(filePath, ...rest);
483
+ }
484
+ // rmdir 不带 {recursive: true} 时,非空目录交还原生抛 ENOTEMPTY
485
+ const opts = rest[0] || null;
486
+ if (requireDir && st && !(opts && opts.recursive) && !isDirEmpty(absPath)) return orig(filePath, ...rest);
487
+
488
+ tryTrash(absPath); // 失败抛错 → async 函数自动转 rejected Promise(fail-closed)
489
+ return undefined;
490
+ };
491
+ }
492
+
493
+ // ----- rm / rmSync / promises.rm(支持 recursive / force)-----
494
+
495
+ /**
496
+ * @returns {{ done: boolean }}
497
+ * done=true → 已移入回收站,调用方返回成功
498
+ * done=false → 前置条件不满足(ENOENT / stat 错误 / 非 recursive 删目录),调用方走原生
499
+ * throws → 回收站操作失败(fail-closed),调用方不得真删
500
+ */
501
+ function tryRm(absPath, opts) {
502
+ const recursive = !!(opts && opts.recursive);
503
+ const force = !!(opts && opts.force);
504
+
505
+ const st = safeLstat(absPath);
506
+ // st === null:文件不存在 (ENOENT)
507
+ if (st === null) {
508
+ if (force) return { done: true }; // force + 不存在 = 静默成功
509
+ return { done: false }; // 让原生抛 ENOENT
510
+ }
511
+ // st === undefined:EACCES/ELOOP 等 stat 错误 → 仍尝试移入回收站
512
+ // 不降级真删:文件可能可通过 trash 路径处理(如权限允许 rename 但不允许 lstat),
513
+ // 若 trash 也失败则抛错(fail-closed),与 makeSyncWrapper / makeCallbackWrapper 一致
514
+ if (!st) {
515
+ tryTrash(absPath);
516
+ return { done: true };
517
+ }
518
+
519
+ // 非 recursive 删目录 → 让原生抛 EISDIR / ENOTEMPTY
520
+ if (st.isDirectory() && !recursive) {
521
+ return { done: false };
522
+ }
523
+
524
+ tryTrash(absPath); // 失败时抛错(fail-closed)
525
+ return { done: true };
526
+ }
527
+
528
+ function wrappedRmSync(filePath, options) {
529
+ const absPath = toAbsPath(filePath);
530
+ const r = tryRm(absPath, options || {});
531
+ if (r.done) return undefined;
532
+ return origRmSync(filePath, options);
533
+ }
534
+
535
+ function wrappedRm(filePath, options, callback) {
536
+ let opts, cb;
537
+ if (typeof options === 'function') { opts = {}; cb = options; }
538
+ else { opts = options || {}; cb = callback; }
539
+
540
+ // 保持 Node callback API 参数校验语义:
541
+ // fs.rm 的 callback 必填,缺失/非函数时应先报参数错,
542
+ // 且不触发任何删除动作(包括 safe-delete / 真删)。
543
+ if (typeof cb !== 'function') {
544
+ const err = new TypeError('The "cb" argument must be of type function.');
545
+ err.code = 'ERR_INVALID_ARG_TYPE';
546
+ throw err;
547
+ }
548
+
549
+ const absPath = toAbsPath(filePath);
550
+ let r;
551
+ try {
552
+ r = tryRm(absPath, opts);
553
+ } catch (e) {
554
+ // trash 失败(fail-closed):传给 callback 或同步抛出
555
+ if (typeof cb === 'function') return process.nextTick(() => cb(e));
556
+ throw e;
557
+ }
558
+
559
+ if (r.done) {
560
+ if (typeof cb === 'function') return process.nextTick(() => cb(null));
561
+ return undefined;
562
+ }
563
+ return origRm(filePath, options, callback);
564
+ }
565
+
566
+ async function wrappedPromisesRm(filePath, options) {
567
+ const absPath = toAbsPath(filePath);
568
+ const r = tryRm(absPath, options || {});
569
+ if (r.done) return undefined;
570
+ return origPromisesRm(filePath, options);
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // 安装 patch
575
+ // ---------------------------------------------------------------------------
576
+
577
+ fs.unlinkSync = makeSyncWrapper(origUnlinkSync, false);
578
+ fs.rmdirSync = makeSyncWrapper(origRmdirSync, true);
579
+ if (origRmSync) fs.rmSync = wrappedRmSync;
580
+
581
+ fs.unlink = makeCallbackWrapper(origUnlink, false);
582
+ fs.rmdir = makeCallbackWrapper(origRmdir, true);
583
+ if (origRm) fs.rm = wrappedRm;
584
+
585
+ if (fs.promises) {
586
+ fs.promises.unlink = makePromiseWrapper(origPromisesUnlink, false);
587
+ fs.promises.rmdir = makePromiseWrapper(origPromisesRmdir, true);
588
+ if (origPromisesRm) fs.promises.rm = wrappedPromisesRm;
589
+ }
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # safe-delete rm entrypoint.
3
+ # Shared implementation lives in safe-delete-common.sh.
4
+
5
+ set -o pipefail
6
+
7
+ REAL_RM="${SAFE_DELETE_REAL_RM:-/bin/rm}"
8
+ [ -x "$REAL_RM" ] || REAL_RM="/usr/bin/rm"
9
+
10
+ # Zero-overhead: no sandbox session → pass through immediately
11
+ [ -z "$CODEBUDDY_SESSION_ID" ] && [ -z "$CLAUDE_SESSION_ID" ] && exec "$REAL_RM" "$@"
12
+
13
+ SELF_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
14
+ # shellcheck source=/dev/null
15
+ . "${SELF_DIR}/safe-delete-common.sh"
16
+
17
+ safe_delete_main rm "$@"
18
+ exit $?
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # safe-delete rmdir entrypoint.
3
+ # Shared implementation lives in safe-delete-common.sh.
4
+
5
+ set -o pipefail
6
+
7
+ REAL_RMDIR="${SAFE_DELETE_REAL_RMDIR:-/bin/rmdir}"
8
+ [ -x "$REAL_RMDIR" ] || REAL_RMDIR="/usr/bin/rmdir"
9
+
10
+ # Zero-overhead: no sandbox session → pass through immediately
11
+ [ -z "$CODEBUDDY_SESSION_ID" ] && [ -z "$CLAUDE_SESSION_ID" ] && exec "$REAL_RMDIR" "$@"
12
+
13
+ SELF_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
14
+ # shellcheck source=/dev/null
15
+ . "${SELF_DIR}/safe-delete-common.sh"
16
+
17
+ safe_delete_main rmdir "$@"
18
+ exit $?
@@ -0,0 +1,8 @@
1
+ # genie-safe-delete bash environment — sourced via BASH_ENV for every bash subprocess.
2
+ # Defines rm/unlink/rmdir wrappers to intercept shell delete commands and move to OS trash.
3
+ if [ -n "${CODEBUDDY_SAFE_DELETE_BIN_DIR:-}" ]; then
4
+ rm() { "${CODEBUDDY_SAFE_DELETE_BIN_DIR}/rm" "$@"; }
5
+ unlink() { "${CODEBUDDY_SAFE_DELETE_BIN_DIR}/unlink" "$@"; }
6
+ rmdir() { "${CODEBUDDY_SAFE_DELETE_BIN_DIR}/rmdir" "$@"; }
7
+ export -f rm unlink rmdir
8
+ fi