dsh-delete-session 0.1.9 → 0.2.0
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.
- package/LICENSE +21 -21
- package/README.md +201 -96
- package/cordis.patch.yml +7 -7
- package/package.json +3 -2
- package/src/client.js +1450 -415
- package/src/index.js +2007 -517
- package/test/artifacts.host.test.js +953 -0
- package/test/checkbox.dom.test.js +1844 -0
package/src/index.js
CHANGED
|
@@ -1,517 +1,2007 @@
|
|
|
1
|
-
// dsh-delete-session - host half.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// POST /dsh-delete-session/delete { sessionId }
|
|
6
|
-
//
|
|
7
|
-
// Deletion pipeline, kept consistent with the live DSH services so in-memory
|
|
8
|
-
// state and on-disk units cannot disagree after the request returns:
|
|
9
|
-
//
|
|
10
|
-
// 1. stop a running agent (cancel + time-boxed quiescence) when one exists;
|
|
11
|
-
// 2. flush and detach the live session so its async dispose cannot rewrite
|
|
12
|
-
// the log directory after we remove it, and wait for the persistence
|
|
13
|
-
// retirement that session/disposed starts;
|
|
14
|
-
// 3. take the persistence write lease briefly: a busy lease means the
|
|
15
|
-
// session is still open in DSH, so the request is refused with 409
|
|
16
|
-
// instead of racing the active writer;
|
|
17
|
-
// 4. remove every on-disk session directory (raw id and session- prefix);
|
|
18
|
-
// 5. verify through the persistence service and a directory re-scan;
|
|
19
|
-
// 6. detach the session from workspace accounting so the sidebar drops the
|
|
20
|
-
// row instead of re-grouping an orphan under "Ungrouped".
|
|
21
|
-
//
|
|
22
|
-
// The plugin imports nothing from the DSH SDK: every service is resolved
|
|
23
|
-
// through the cordis context at call time, so the module loads on any profile
|
|
24
|
-
// and degrades to the filesystem path when a service is absent.
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
import
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
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
|
-
return
|
|
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
|
-
return
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1
|
+
// dsh-delete-session - host half.
|
|
2
|
+
//
|
|
3
|
+
// Loopback-only HTTP routes serve the client half:
|
|
4
|
+
//
|
|
5
|
+
// POST /dsh-delete-session/delete { sessionId }
|
|
6
|
+
//
|
|
7
|
+
// Deletion pipeline, kept consistent with the live DSH services so in-memory
|
|
8
|
+
// state and on-disk units cannot disagree after the request returns:
|
|
9
|
+
//
|
|
10
|
+
// 1. stop a running agent (cancel + time-boxed quiescence) when one exists;
|
|
11
|
+
// 2. flush and detach the live session so its async dispose cannot rewrite
|
|
12
|
+
// the log directory after we remove it, and wait for the persistence
|
|
13
|
+
// retirement that session/disposed starts;
|
|
14
|
+
// 3. take the persistence write lease briefly: a busy lease means the
|
|
15
|
+
// session is still open in DSH, so the request is refused with 409
|
|
16
|
+
// instead of racing the active writer;
|
|
17
|
+
// 4. remove every on-disk session directory (raw id and session- prefix);
|
|
18
|
+
// 5. verify through the persistence service and a directory re-scan;
|
|
19
|
+
// 6. detach the session from workspace accounting so the sidebar drops the
|
|
20
|
+
// row instead of re-grouping an orphan under "Ungrouped".
|
|
21
|
+
//
|
|
22
|
+
// The plugin imports nothing from the DSH SDK: every service is resolved
|
|
23
|
+
// through the cordis context at call time, so the module loads on any profile
|
|
24
|
+
// and degrades to the filesystem path when a service is absent.
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
import fs from 'node:fs'
|
|
27
|
+
import os from 'node:os'
|
|
28
|
+
import path from 'node:path'
|
|
29
|
+
import { promisify } from 'node:util'
|
|
30
|
+
|
|
31
|
+
/** The asynchronous face of `fs`, used wherever a blocking call could stall the server. */
|
|
32
|
+
const fsp = fs.promises
|
|
33
|
+
/** The same underlying call as `fs.realpathSync.native`, just not blocking. */
|
|
34
|
+
const realpathNative = promisify(fs.realpath.native)
|
|
35
|
+
|
|
36
|
+
export const name = 'dsh-delete-session'
|
|
37
|
+
|
|
38
|
+
const ROUTE_PREFIX = '/dsh-delete-session'
|
|
39
|
+
const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
40
|
+
const MAX_BATCH = 100
|
|
41
|
+
|
|
42
|
+
class HttpError extends Error {
|
|
43
|
+
constructor(status, code, message) {
|
|
44
|
+
super(message)
|
|
45
|
+
this.status = status
|
|
46
|
+
this.code = code
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- paths -------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function dshHome() {
|
|
53
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sessionsRoot() {
|
|
57
|
+
return path.join(dshHome(), 'sessions')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A session id travels in two spellings: the raw uuid and `session-<uuid>`.
|
|
61
|
+
// The on-disk JSONL backend uses the prefixed form for the directory name,
|
|
62
|
+
// while workspace and projection rows may carry either. Clean both.
|
|
63
|
+
function idVariants(sessionId) {
|
|
64
|
+
const out = new Set([sessionId])
|
|
65
|
+
if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
|
|
66
|
+
else out.add(`session-${sessionId}`)
|
|
67
|
+
return [...out]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Scan every workspace directory under $DSH_HOME/sessions for the session's
|
|
71
|
+
// directories, so the workspace-path encoding never has to be re-derived here.
|
|
72
|
+
function findSessionDirs(sessionId) {
|
|
73
|
+
const root = sessionsRoot()
|
|
74
|
+
let entries
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
const variants = idVariants(sessionId)
|
|
81
|
+
const found = []
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.isDirectory()) continue
|
|
84
|
+
for (const variant of variants) {
|
|
85
|
+
const candidate = path.join(root, entry.name, variant)
|
|
86
|
+
try {
|
|
87
|
+
if (fs.statSync(candidate).isDirectory() && !found.includes(candidate)) found.push(candidate)
|
|
88
|
+
} catch {
|
|
89
|
+
// keep scanning
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return found
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The C# behind the cached recycle helper. One process handles every path of a
|
|
98
|
+
* delete (the session directory and its cache rows), because each PowerShell
|
|
99
|
+
* start costs the better part of a second and a delete used to pay it twice.
|
|
100
|
+
*/
|
|
101
|
+
const RECYCLE_CSHARP = `using System;
|
|
102
|
+
using System.IO;
|
|
103
|
+
using Microsoft.VisualBasic.FileIO;
|
|
104
|
+
|
|
105
|
+
public static class Program {
|
|
106
|
+
public static int Main(string[] args) {
|
|
107
|
+
int done = 0;
|
|
108
|
+
foreach (string target in args) {
|
|
109
|
+
try {
|
|
110
|
+
if (Directory.Exists(target)) {
|
|
111
|
+
FileSystem.DeleteDirectory(target, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
|
|
112
|
+
} else if (File.Exists(target)) {
|
|
113
|
+
FileSystem.DeleteFile(target, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
|
|
114
|
+
}
|
|
115
|
+
done++;
|
|
116
|
+
} catch {
|
|
117
|
+
// leave it: the caller verifies the path is gone
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return done == args.Length ? 0 : 1;
|
|
121
|
+
}
|
|
122
|
+
}`
|
|
123
|
+
|
|
124
|
+
/** Bump when `RECYCLE_CSHARP` changes, so a cached build is not reused. */
|
|
125
|
+
const RECYCLE_EXE_VERSION = 1
|
|
126
|
+
const RECYCLE_TIMEOUT_MS = 60 * 1000
|
|
127
|
+
|
|
128
|
+
/** Compile the recycle helper once into the DSH cache; null when it cannot. */
|
|
129
|
+
async function ensureRecycleExe() {
|
|
130
|
+
if (process.platform !== 'win32') return null
|
|
131
|
+
const dir = path.join(dshHome(), 'cache', 'dsh-delete-session', `v${RECYCLE_EXE_VERSION}`)
|
|
132
|
+
const exe = path.join(dir, 'dsh-recycle.exe')
|
|
133
|
+
if (await pathExists(exe)) return exe
|
|
134
|
+
const csc = findCsc()
|
|
135
|
+
if (csc === null) return null
|
|
136
|
+
try {
|
|
137
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
138
|
+
const source = path.join(dir, 'recycle.cs')
|
|
139
|
+
await fsp.writeFile(source, RECYCLE_CSHARP, 'utf8')
|
|
140
|
+
const built = await runExecutable(csc, ['-nologo', '-target:exe', `-out:${exe}`, '-r:System.dll', '-r:Microsoft.VisualBasic.dll', source], PICK_COMPILE_TIMEOUT_MS)
|
|
141
|
+
try { await fsp.rm(source, { force: true }) } catch { /* best effort */ }
|
|
142
|
+
if (built.code === 0) {
|
|
143
|
+
if (await pathExists(exe)) return exe
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// fall through to the PowerShell path
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The command that hands paths to the platform trash.
|
|
153
|
+
*
|
|
154
|
+
* Windows is done in-process (see `sendToRecycleBin`); macOS has no trash command
|
|
155
|
+
* of its own, so Finder performs the delete, and Linux delegates to `gio trash`,
|
|
156
|
+
* which ships with GLib on every mainstream desktop. Kept separate from running
|
|
157
|
+
* it so the shape can be tested on a machine that has neither.
|
|
158
|
+
*/
|
|
159
|
+
/**
|
|
160
|
+
* Compile the helper executables while nothing is waiting for them. The first
|
|
161
|
+
* pick or delete otherwise pays for `csc` (0.5-1.7 s); by the time the user
|
|
162
|
+
* clicks, the cache is already there.
|
|
163
|
+
*
|
|
164
|
+
* Deliberately fire-and-forget and never fatal: a machine without `csc` keeps
|
|
165
|
+
* the PowerShell fallbacks, and a failure here must not stop the plugin loading.
|
|
166
|
+
*/
|
|
167
|
+
function warmCompiledHelpers() {
|
|
168
|
+
if (process.platform !== 'win32') return
|
|
169
|
+
const warm = async () => {
|
|
170
|
+
try { await ensurePickerExe() } catch { /* the fallback covers it */ }
|
|
171
|
+
try { await ensureRecycleExe() } catch { /* the fallback covers it */ }
|
|
172
|
+
}
|
|
173
|
+
void warm()
|
|
174
|
+
}
|
|
175
|
+
function trashCommandFor(platform, targets) {
|
|
176
|
+
if (platform === 'darwin') {
|
|
177
|
+
// One -e per path: AppleScript has no list form for this, and a failure on one
|
|
178
|
+
// path must not stop the rest.
|
|
179
|
+
return {
|
|
180
|
+
program: 'osascript',
|
|
181
|
+
args: targets.flatMap((target) => [
|
|
182
|
+
'-e',
|
|
183
|
+
`tell application "Finder" to delete (POSIX file "${target.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`,
|
|
184
|
+
]),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { program: 'gio', args: ['trash', ...targets] }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run the platform trash command. Returns false when the command is missing
|
|
192
|
+
* (`gio` on a bare container, a denied Finder automation prompt) so the caller
|
|
193
|
+
* keeps the staged copy instead of pretending the delete happened.
|
|
194
|
+
*/
|
|
195
|
+
async function trashWithSystem(targets, platform) {
|
|
196
|
+
const command = trashCommandFor(platform, targets)
|
|
197
|
+
try {
|
|
198
|
+
const run = await runExecutable(command.program, command.args, RECYCLE_TIMEOUT_MS)
|
|
199
|
+
return run.code === 0
|
|
200
|
+
} catch {
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Send paths to the Windows Recycle Bin.
|
|
207
|
+
*
|
|
208
|
+
* A delete has to be undoable, and the Recycle Bin is the undo the user already
|
|
209
|
+
* knows. Node has no API for it, and a Node process cannot synthesize the
|
|
210
|
+
* `$I`/`$R` pair the Shell owns, so one PowerShell does the work through
|
|
211
|
+
* `Microsoft.VisualBasic.FileIO.FileSystem` — the supported managed wrapper of
|
|
212
|
+
* "delete with undo". The script prints one verdict per path, so a partial
|
|
213
|
+
* failure is reported instead of being assumed away.
|
|
214
|
+
*
|
|
215
|
+
* @returns `true` only when every path actually left its original location.
|
|
216
|
+
*/
|
|
217
|
+
async function sendToRecycleBin(targets) {
|
|
218
|
+
if (targets.length === 0) return true
|
|
219
|
+
// macOS and Linux have their own trash; Windows is handled below in-process.
|
|
220
|
+
if (process.platform !== 'win32') return trashWithSystem(targets, process.platform)
|
|
221
|
+
|
|
222
|
+
// The compiled helper answers in ~100 ms where one PowerShell start costs the
|
|
223
|
+
// better part of a second, and it takes every path of a delete at once.
|
|
224
|
+
const exe = await ensureRecycleExe()
|
|
225
|
+
if (exe !== null) {
|
|
226
|
+
try {
|
|
227
|
+
const run = await runExecutable(exe, targets, RECYCLE_TIMEOUT_MS)
|
|
228
|
+
return run.code === 0
|
|
229
|
+
} catch {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const list = targets.map((target) => `'${target.replace(/'/g, "''")}'`).join(', ')
|
|
235
|
+
const script = [
|
|
236
|
+
'Add-Type -AssemblyName Microsoft.VisualBasic',
|
|
237
|
+
`$targets = @(${list})`,
|
|
238
|
+
'foreach ($target in $targets) {',
|
|
239
|
+
' try {',
|
|
240
|
+
' if (Test-Path -LiteralPath $target -PathType Container) {',
|
|
241
|
+
' [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($target,',
|
|
242
|
+
' [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,',
|
|
243
|
+
' [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)',
|
|
244
|
+
' } elseif (Test-Path -LiteralPath $target) {',
|
|
245
|
+
' [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($target,',
|
|
246
|
+
' [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,',
|
|
247
|
+
' [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)',
|
|
248
|
+
' }',
|
|
249
|
+
' [Console]::Out.WriteLine(\'OK\')',
|
|
250
|
+
' } catch {',
|
|
251
|
+
' [Console]::Out.WriteLine(\'FAIL\')',
|
|
252
|
+
' }',
|
|
253
|
+
'}',
|
|
254
|
+
].join('\r\n')
|
|
255
|
+
|
|
256
|
+
const scriptPath = path.join(os.tmpdir(), `dsh-delete-session-bin-${process.pid}-${Date.now()}.ps1`)
|
|
257
|
+
const bytes = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(script, 'utf8')])
|
|
258
|
+
let output
|
|
259
|
+
try {
|
|
260
|
+
await fsp.writeFile(scriptPath, bytes)
|
|
261
|
+
output = await runPowershell(
|
|
262
|
+
['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
263
|
+
PICK_TIMEOUT_MS,
|
|
264
|
+
)
|
|
265
|
+
} catch {
|
|
266
|
+
return false
|
|
267
|
+
} finally {
|
|
268
|
+
try { await fsp.rm(scriptPath, { force: true }) } catch { /* best effort */ }
|
|
269
|
+
}
|
|
270
|
+
const ok = output.split(/\r?\n/).filter((line) => line.trim() === 'OK').length
|
|
271
|
+
return ok === targets.length
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Move paths into a fresh `$DSH_HOME/session-trash-<stamp>` folder.
|
|
276
|
+
*
|
|
277
|
+
* This is the fast half of a retire: a same-volume rename costs microseconds,
|
|
278
|
+
* while the shell's recycle operation costs ~600 ms. The caller reports the
|
|
279
|
+
* delete as done after this, so the wait never reaches the user.
|
|
280
|
+
*
|
|
281
|
+
* @returns `{ moved, root }` — the source paths and the staging folder, or a null
|
|
282
|
+
* root when the folder could not be created.
|
|
283
|
+
*/
|
|
284
|
+
async function stageToTrash(targets) {
|
|
285
|
+
if (targets.length === 0) return { moved: [], root: null }
|
|
286
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 17)
|
|
287
|
+
const root = path.join(dshHome(), `session-trash-${stamp}-${process.pid}-${retireCounter++}`)
|
|
288
|
+
try {
|
|
289
|
+
await fsp.mkdir(root, { recursive: true })
|
|
290
|
+
} catch {
|
|
291
|
+
return { moved: [], root: null }
|
|
292
|
+
}
|
|
293
|
+
const moved = []
|
|
294
|
+
for (const target of targets) {
|
|
295
|
+
const destination = await freeDestination(root, path.basename(target))
|
|
296
|
+
if (destination === undefined) continue
|
|
297
|
+
try {
|
|
298
|
+
await fsp.rename(target, destination)
|
|
299
|
+
moved.push(target)
|
|
300
|
+
continue
|
|
301
|
+
} catch {
|
|
302
|
+
// A cross-volume rename fails; copy then drop the original.
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await fsp.cp(target, destination, { recursive: true, force: false, errorOnExist: true })
|
|
306
|
+
await fsp.rm(target, { recursive: true, force: true })
|
|
307
|
+
moved.push(target)
|
|
308
|
+
} catch {
|
|
309
|
+
// Leave it: a path that cannot be retired is reported by the caller.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { moved, root: moved.length > 0 ? root : null }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Keeps two retires in the same millisecond out of one staging folder. */
|
|
316
|
+
let retireCounter = 0
|
|
317
|
+
|
|
318
|
+
/** The staging move alone, for callers that just want the paths out of the way. */
|
|
319
|
+
async function moveToSessionTrash(targets) {
|
|
320
|
+
return (await stageToTrash(targets)).moved
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Retire paths for good. The caller gets control back as soon as the paths are
|
|
325
|
+
* out of their original location — a same-volume rename — and the Recycle Bin
|
|
326
|
+
* move finishes in the background.
|
|
327
|
+
*
|
|
328
|
+
* That order is deliberate: the shell's recycle operation costs ~600 ms, which
|
|
329
|
+
* is exactly the wait a delete should not have. If the background call fails, the
|
|
330
|
+
* paths are still in `session-trash-<stamp>` and recoverable by hand; nothing is
|
|
331
|
+
* ever unlinked.
|
|
332
|
+
*/
|
|
333
|
+
async function retirePaths(targets) {
|
|
334
|
+
if (targets.length === 0) return []
|
|
335
|
+
const staged = await stageToTrash(targets)
|
|
336
|
+
if (staged.root !== null) {
|
|
337
|
+
// Fire and forget: never awaited, never allowed to reject into the caller.
|
|
338
|
+
try {
|
|
339
|
+
sendToRecycleBin([staged.root]).catch(() => {})
|
|
340
|
+
} catch {
|
|
341
|
+
// the staged copy stays where it is
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return staged.moved
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Remove paths outright. This is the branch behind the unchecked Recycle Bin
|
|
349
|
+
* switch: the dialog says the delete is permanent in that state, so the files
|
|
350
|
+
* must not linger anywhere. Every other path through this plugin retires to the
|
|
351
|
+
* bin, and that difference is the whole point of the switch.
|
|
352
|
+
*
|
|
353
|
+
* @returns the paths that are gone afterwards.
|
|
354
|
+
*/
|
|
355
|
+
function removeForever(targets) {
|
|
356
|
+
const gone = []
|
|
357
|
+
for (const target of targets) {
|
|
358
|
+
if (!fs.existsSync(target)) continue
|
|
359
|
+
try {
|
|
360
|
+
fs.rmSync(target, { recursive: true, force: true })
|
|
361
|
+
if (!fs.existsSync(target)) gone.push(target)
|
|
362
|
+
} catch {
|
|
363
|
+
// reported by the caller's own existence checks
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return gone
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Retire a list according to the switch: into the system trash (staged first,
|
|
370
|
+
* then the shell takes it) or off the disk. Kept separate from the delete so the
|
|
371
|
+
* choice itself can be tested without standing up a whole session.
|
|
372
|
+
*/
|
|
373
|
+
async function retireTargets(targets, recycle) {
|
|
374
|
+
return recycle ? retirePaths(targets) : removeForever(targets)
|
|
375
|
+
}
|
|
376
|
+
async function retireSessionDirs(sessionId) {
|
|
377
|
+
return retirePaths(findSessionDirs(sessionId))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Deleting the on-disk directory is not enough: projection caches keep a
|
|
381
|
+
// per-session JSON row (`$DSH_HOME/storages/<store>/sessions/<id>.json`) that
|
|
382
|
+
// would linger as a ghost — the sidebar list is directory-backed, so the row
|
|
383
|
+
// can never be shown or deleted again (issue: "can't delete anymore"). Retire
|
|
384
|
+
// the session's cache units together with its directories.
|
|
385
|
+
function findSessionCaches(sessionId) {
|
|
386
|
+
const storesRoot = path.join(dshHome(), 'storages')
|
|
387
|
+
const found = []
|
|
388
|
+
let stores
|
|
389
|
+
try {
|
|
390
|
+
stores = fs.readdirSync(storesRoot, { withFileTypes: true })
|
|
391
|
+
} catch {
|
|
392
|
+
return found
|
|
393
|
+
}
|
|
394
|
+
const names = idVariants(sessionId).map((variant) => `${variant}.json`)
|
|
395
|
+
for (const store of stores) {
|
|
396
|
+
if (!store.isDirectory()) continue
|
|
397
|
+
const dir = path.join(storesRoot, store.name, 'sessions')
|
|
398
|
+
let entries
|
|
399
|
+
try {
|
|
400
|
+
entries = fs.readdirSync(dir)
|
|
401
|
+
} catch {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
if (!names.includes(entry)) continue
|
|
406
|
+
found.push(path.join(dir, entry))
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return found
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function retireSessionCaches(sessionId) {
|
|
413
|
+
return retirePaths(findSessionCaches(sessionId))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- live state --------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
// Stop a running agent before its session disappears: cancel the active turn
|
|
419
|
+
// and wait, time-boxed, so a stuck driver never blocks the deletion.
|
|
420
|
+
async function quiesceAgent(ctx, sessionId) {
|
|
421
|
+
const agents = ctx.get('agents')
|
|
422
|
+
if (!agents || typeof agents.get !== 'function') return false
|
|
423
|
+
const agent = agents.get(sessionId)
|
|
424
|
+
if (!agent) return false
|
|
425
|
+
try {
|
|
426
|
+
if (typeof agent.cancel === 'function') agent.cancel({ kind: 'user' })
|
|
427
|
+
} catch {
|
|
428
|
+
// agent may already be settling
|
|
429
|
+
}
|
|
430
|
+
if (typeof agent.whenIdle === 'function') {
|
|
431
|
+
try {
|
|
432
|
+
await Promise.race([agent.whenIdle(), new Promise((resolve) => setTimeout(resolve, 10000))])
|
|
433
|
+
} catch {
|
|
434
|
+
// proceed with deletion anyway
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return true
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Flush a live session, then detach its entered record so DSH emits
|
|
441
|
+
// host/session-removed and the client list drops the row. The persistence
|
|
442
|
+
// retirement started by session/disposed is awaited before the caller unlinks
|
|
443
|
+
// anything, because its final drain could otherwise recreate the directory.
|
|
444
|
+
async function flushAndDetach(ctx, sessionId) {
|
|
445
|
+
const sessions = ctx.get('sessions')
|
|
446
|
+
let flushed = false
|
|
447
|
+
let detached = false
|
|
448
|
+
if (sessions) {
|
|
449
|
+
for (const variant of idVariants(sessionId)) {
|
|
450
|
+
const live = typeof sessions.get === 'function' ? sessions.get(variant) : undefined
|
|
451
|
+
if (!live) continue
|
|
452
|
+
if (typeof sessions.flush === 'function') {
|
|
453
|
+
try {
|
|
454
|
+
await sessions.flush(live)
|
|
455
|
+
flushed = true
|
|
456
|
+
} catch {
|
|
457
|
+
// the log is removed anyway
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const entered = sessions.store && typeof sessions.store.get === 'function' ? sessions.store.get(variant) : undefined
|
|
462
|
+
if (entered && typeof entered.detach === 'function') {
|
|
463
|
+
entered.detach()
|
|
464
|
+
detached = true
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
// best effort: the directory removal below is the authority
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const sp = ctx.get('sessionPersistence')
|
|
472
|
+
try {
|
|
473
|
+
const retirement = sp && sp.retirements && typeof sp.retirements.get === 'function' ? sp.retirements.get(sessionId) : undefined
|
|
474
|
+
if (retirement && typeof retirement.then === 'function') await retirement
|
|
475
|
+
} catch {
|
|
476
|
+
// no retirement registry: nothing to wait for
|
|
477
|
+
}
|
|
478
|
+
return { flushed, detached }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Probe the persistence write lease. Holding it proves no active writer is
|
|
482
|
+
// around; it is released immediately. Detaching a live session releases its
|
|
483
|
+
// lease asynchronously (session/disposed starts a persistence retirement), so
|
|
484
|
+
// the probe is retried with a short backoff before the request is refused -
|
|
485
|
+
// in practice the first click then succeeds without forcing anything.
|
|
486
|
+
// The backoff is short (200ms x 12 = ~2.2s worst case) so a batch containing
|
|
487
|
+
// live sessions stays responsive while the lease retirement lands.
|
|
488
|
+
const BUSY_RETRY_ATTEMPTS = 12
|
|
489
|
+
const BUSY_RETRY_DELAY_MS = 200
|
|
490
|
+
|
|
491
|
+
function sleep(ms) {
|
|
492
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function assertNoActiveWriter(ctx, sessionId) {
|
|
496
|
+
const sp = ctx.get('sessionPersistence')
|
|
497
|
+
if (!sp || typeof sp.open !== 'function') return false
|
|
498
|
+
for (let attempt = 1; attempt <= BUSY_RETRY_ATTEMPTS; attempt += 1) {
|
|
499
|
+
let handle = null
|
|
500
|
+
let busy = false
|
|
501
|
+
try {
|
|
502
|
+
handle = await sp.open(sessionId, 'write')
|
|
503
|
+
} catch (error) {
|
|
504
|
+
const message = String((error && error.message) || error)
|
|
505
|
+
if (/own|busy|lock|hold|writer/i.test(message)) {
|
|
506
|
+
busy = true
|
|
507
|
+
} else {
|
|
508
|
+
return false
|
|
509
|
+
}
|
|
510
|
+
} finally {
|
|
511
|
+
try {
|
|
512
|
+
if (handle && typeof handle.close === 'function') await handle.close()
|
|
513
|
+
} catch {
|
|
514
|
+
// lease release failure is harmless: the directory is removed next
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (!busy) return true
|
|
518
|
+
if (attempt < BUSY_RETRY_ATTEMPTS) await sleep(BUSY_RETRY_DELAY_MS)
|
|
519
|
+
}
|
|
520
|
+
throw new HttpError(409, 'busy', 'session is currently open in DSH')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Bounded concurrency for batch deletes. Different sessions are independent
|
|
524
|
+
// (agent quiesce, lease probe and directory sweep all key off the session id),
|
|
525
|
+
// so the pipeline runs in parallel through a small pool. The one shared
|
|
526
|
+
// mutation - workspace accounting - is serialized through withWorkspaceLock so
|
|
527
|
+
// two deletions can never interleave on the same store.
|
|
528
|
+
//
|
|
529
|
+
// 6 is measured, not guessed: on 64 sessions of ~2MB the wall time was
|
|
530
|
+
// 281ms at 4, 189ms at 6 and 142ms at 8 (the I/O parallelism saturates around
|
|
531
|
+
// 8), so 6 keeps a ~33% gain over 4 while staying below the saturation knee
|
|
532
|
+
// and limiting the number of simultaneous lease-probe loops.
|
|
533
|
+
const BATCH_CONCURRENCY = 6
|
|
534
|
+
|
|
535
|
+
let workspaceLock = Promise.resolve()
|
|
536
|
+
function withWorkspaceLock(fn) {
|
|
537
|
+
const run = workspaceLock.then(fn, fn)
|
|
538
|
+
workspaceLock = run.then(() => undefined, () => undefined)
|
|
539
|
+
return run
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function runWithConcurrency(items, limit, worker) {
|
|
543
|
+
const results = new Array(items.length)
|
|
544
|
+
let cursor = 0
|
|
545
|
+
const runners = []
|
|
546
|
+
for (let i = 0; i < Math.min(limit, items.length); i += 1) {
|
|
547
|
+
runners.push((async () => {
|
|
548
|
+
while (true) {
|
|
549
|
+
const index = cursor
|
|
550
|
+
cursor += 1
|
|
551
|
+
if (index >= items.length) return
|
|
552
|
+
results[index] = await worker(items[index], index)
|
|
553
|
+
}
|
|
554
|
+
})())
|
|
555
|
+
}
|
|
556
|
+
await Promise.all(runners)
|
|
557
|
+
return results
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function detachFromWorkspace(ctx, sessionId) {
|
|
561
|
+
const workspace = ctx.get('workspaceRegistry')
|
|
562
|
+
if (!workspace) return false
|
|
563
|
+
const variants = idVariants(sessionId)
|
|
564
|
+
let touched = false
|
|
565
|
+
try {
|
|
566
|
+
if (typeof workspace.list === 'function') {
|
|
567
|
+
for (const entry of workspace.list()) {
|
|
568
|
+
if (!entry || !Array.isArray(entry.sessionIds)) continue
|
|
569
|
+
if (!variants.some((variant) => entry.sessionIds.includes(variant))) continue
|
|
570
|
+
try {
|
|
571
|
+
await entry.detachSession(sessionId)
|
|
572
|
+
touched = true
|
|
573
|
+
} catch {
|
|
574
|
+
// keep going: the header index cleanup below still applies
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch {
|
|
579
|
+
// registry unavailable
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
if (workspace.sessionPaths && typeof workspace.sessionPaths.delete === 'function') {
|
|
583
|
+
for (const variant of variants) workspace.sessionPaths.delete(variant)
|
|
584
|
+
}
|
|
585
|
+
} catch {
|
|
586
|
+
// private map absent on this version
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
if (workspace.headers && typeof workspace.headers.delete === 'function') {
|
|
590
|
+
for (const variant of variants) workspace.headers.delete(variant)
|
|
591
|
+
}
|
|
592
|
+
} catch {
|
|
593
|
+
// private map absent on this version
|
|
594
|
+
}
|
|
595
|
+
return touched
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// --- core delete -------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
async function deleteSession(ctx, sessionId, options) {
|
|
601
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
602
|
+
throw new HttpError(400, 'invalid', 'invalid session id')
|
|
603
|
+
}
|
|
604
|
+
// Undoable by default: only an explicit `recycle: false` sends the files to a
|
|
605
|
+
// plain removal instead of the system trash.
|
|
606
|
+
const recycle = options === undefined || options === null || options.recycle !== false
|
|
607
|
+
const stopped = await quiesceAgent(ctx, sessionId)
|
|
608
|
+
const { flushed, detached } = await flushAndDetach(ctx, sessionId)
|
|
609
|
+
await assertNoActiveWriter(ctx, sessionId)
|
|
610
|
+
|
|
611
|
+
// Retire the on-disk directories first, then the cache rows. With the Recycle
|
|
612
|
+
// Bin switch on (the default) they are staged and the shell moves them to the
|
|
613
|
+
// bin in the background; switched off, they are removed outright — that is what
|
|
614
|
+
// the dialog promises in that state.
|
|
615
|
+
//
|
|
616
|
+
// The second directory pass costs nothing when the drain recreated nothing,
|
|
617
|
+
// which is the normal case.
|
|
618
|
+
const cacheFiles = findSessionCaches(sessionId)
|
|
619
|
+
const targets = [...findSessionDirs(sessionId), ...cacheFiles]
|
|
620
|
+
let removed = await retireTargets(targets, recycle)
|
|
621
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
622
|
+
removed = removed.concat(await retireTargets(findSessionDirs(sessionId), recycle))
|
|
623
|
+
|
|
624
|
+
const sp = ctx.get('sessionPersistence')
|
|
625
|
+
if (sp && typeof sp.stat === 'function') {
|
|
626
|
+
const after = await sp.stat(sessionId).catch(() => undefined)
|
|
627
|
+
if (after) {
|
|
628
|
+
throw new HttpError(500, 'verify-failed', 'session is still visible to the persistence service after deletion')
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const leftover = findSessionDirs(sessionId)
|
|
632
|
+
if (leftover.length > 0) {
|
|
633
|
+
throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Cache rows are projection state, not the source of truth: retire them even
|
|
637
|
+
// when the directories were already gone, so a deleted session cannot leave
|
|
638
|
+
// a ghost behind.
|
|
639
|
+
// Anything of the cache set that survived the shared call is reported, so a
|
|
640
|
+
// locked row is still visible in the result.
|
|
641
|
+
const cachesRemoved = cacheFiles.filter((file) => !fs.existsSync(file))
|
|
642
|
+
|
|
643
|
+
const workspaceDetached = await withWorkspaceLock(() => detachFromWorkspace(ctx, sessionId))
|
|
644
|
+
if (removed.length === 0 && !workspaceDetached && !flushed && !detached && cachesRemoved.length === 0) {
|
|
645
|
+
throw new HttpError(404, 'not-found', 'session not found')
|
|
646
|
+
}
|
|
647
|
+
return { removed: removed.length, caches: cachesRemoved.length, stopped, flushed, detached, workspaceDetached }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// --- artifacts: what a session touched, and quarantining it -----------------
|
|
651
|
+
//
|
|
652
|
+
// "Produced files" is derived from the durable session log alone: the log is the
|
|
653
|
+
// only record that outlives a closed Session (the host's own observation state
|
|
654
|
+
// lives in a plugin-local WeakMap and dies with the process, and the
|
|
655
|
+
// `workspace/changes` summaries live in a temporary directory that disposal
|
|
656
|
+
// removes). Classification rests on one invariant of the default
|
|
657
|
+
// fs-observation-policy: `write` may not overwrite a file that was not read
|
|
658
|
+
// first, and `edit` requires a prior read. So a write whose path has no earlier
|
|
659
|
+
// successful read CREATED the file; anything else already existed.
|
|
660
|
+
//
|
|
661
|
+
// Nothing here deletes. Files are moved into a timestamped quarantine directory
|
|
662
|
+
// under $DSH_HOME, keeping their path relative to the session's working
|
|
663
|
+
// directory, so a wrong call is recoverable.
|
|
664
|
+
|
|
665
|
+
const QUARANTINE_PREFIX = 'purge-quarantine'
|
|
666
|
+
const MAX_LISTED = 500
|
|
667
|
+
/** How many path resolutions run at once: enough to hide the threadpool round trip, few enough to stay polite. */
|
|
668
|
+
const RESOLVE_CONCURRENCY = 16
|
|
669
|
+
/** How many directories the folder picker lists before saying there are more. */
|
|
670
|
+
const MAX_BROWSE_ENTRIES = 500
|
|
671
|
+
/** How long a native folder dialog may stay open before the request gives up. */
|
|
672
|
+
const PICK_TIMEOUT_MS = 5 * 60 * 1000
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The folder picker.
|
|
676
|
+
*
|
|
677
|
+
* Windows offers its modern chooser through COM (`IFileOpenDialog` +
|
|
678
|
+
* `FOS_PICKFOLDERS`), and the interesting part is not the dialog but the wait
|
|
679
|
+
* before it. Measured on the target machine:
|
|
680
|
+
*
|
|
681
|
+
* PowerShell startup ......... ~520 ms
|
|
682
|
+
* + Windows.Forms assembly .... ~950 ms (left over from the old
|
|
683
|
+
* `FolderBrowserDialog` version)
|
|
684
|
+
* compiled .exe, no console ... ~120 ms
|
|
685
|
+
*
|
|
686
|
+
* So the picker is a tiny executable: compiled once into the DSH home, spawned
|
|
687
|
+
* directly afterwards. The PowerShell script below stays as the fallback for a
|
|
688
|
+
* machine without `csc.exe`.
|
|
689
|
+
*
|
|
690
|
+
* The executable runs with `windowsHide`, which hides the first window it shows,
|
|
691
|
+
* so a background thread calls `ShowWindow(SW_SHOW)` and raises it. That thread
|
|
692
|
+
* also centres the dialog and retries, because the window reports no final size
|
|
693
|
+
* on the first tick.
|
|
694
|
+
*/
|
|
695
|
+
const PICK_CSHARP = `
|
|
696
|
+
using System;
|
|
697
|
+
using System.Text;
|
|
698
|
+
using System.Threading;
|
|
699
|
+
using System.Runtime.InteropServices;
|
|
700
|
+
|
|
701
|
+
public static class DshFolderPick {
|
|
702
|
+
[DllImport("user32.dll")] static extern bool ShowWindow(IntPtr h, int cmd);
|
|
703
|
+
[DllImport("user32.dll")] static extern IntPtr SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
|
704
|
+
[DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr h);
|
|
705
|
+
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l);
|
|
706
|
+
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
707
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
|
|
708
|
+
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
709
|
+
[DllImport("user32.dll")] static extern bool SystemParametersInfoW(uint action, uint param, out RECT rect, uint winIni);
|
|
710
|
+
[DllImport("kernel32.dll")] static extern uint GetCurrentProcessId();
|
|
711
|
+
delegate bool EnumProc(IntPtr h, IntPtr l);
|
|
712
|
+
|
|
713
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
714
|
+
struct RECT { public int Left, Top, Right, Bottom; }
|
|
715
|
+
|
|
716
|
+
static IntPtr FindDialog() {
|
|
717
|
+
uint me = GetCurrentProcessId();
|
|
718
|
+
IntPtr found = IntPtr.Zero;
|
|
719
|
+
EnumWindows((h, l) => {
|
|
720
|
+
uint pid;
|
|
721
|
+
GetWindowThreadProcessId(h, out pid);
|
|
722
|
+
if (pid == me) {
|
|
723
|
+
var cls = new StringBuilder(128, 128);
|
|
724
|
+
GetClassNameW(h, cls, 128);
|
|
725
|
+
if (cls.ToString().StartsWith("#32770") && found == IntPtr.Zero) found = h;
|
|
726
|
+
}
|
|
727
|
+
return true;
|
|
728
|
+
}, IntPtr.Zero);
|
|
729
|
+
return found;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
static void Raise() {
|
|
733
|
+
bool centered = false;
|
|
734
|
+
for (int i = 0; i < 80; i++) {
|
|
735
|
+
Thread.Sleep(150);
|
|
736
|
+
IntPtr h = FindDialog();
|
|
737
|
+
if (h == IntPtr.Zero) continue;
|
|
738
|
+
ShowWindow(h, 5);
|
|
739
|
+
if (!centered) {
|
|
740
|
+
RECT work, box;
|
|
741
|
+
if (SystemParametersInfoW(0x0030, 0, out work, 0) && GetWindowRect(h, out box)) {
|
|
742
|
+
int w = box.Right - box.Left;
|
|
743
|
+
int ht = box.Bottom - box.Top;
|
|
744
|
+
int availW = work.Right - work.Left;
|
|
745
|
+
int availH = work.Bottom - work.Top;
|
|
746
|
+
// A half-built rect would park the window off screen, so only a
|
|
747
|
+
// believable size that actually fits is allowed to move it.
|
|
748
|
+
if (w > 600 && ht > 300 && w < availW && ht < availH) {
|
|
749
|
+
int x = work.Left + (availW - w) / 2;
|
|
750
|
+
int y = work.Top + (availH - ht) / 2;
|
|
751
|
+
SetWindowPos(h, (IntPtr)(-1), x, y, 0, 0, 0x0001);
|
|
752
|
+
centered = true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
SetWindowPos(h, (IntPtr)(-1), 0, 0, 0, 0, 0x0003);
|
|
757
|
+
BringWindowToTop(h);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
[ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
762
|
+
interface IFileOpenDialog {
|
|
763
|
+
[PreserveSig] int Show(IntPtr parent);
|
|
764
|
+
void SetFileTypes(uint c, IntPtr f);
|
|
765
|
+
void SetFileTypeIndex(uint i);
|
|
766
|
+
void GetFileTypeIndex(out uint i);
|
|
767
|
+
void Advise(IntPtr p, out uint c);
|
|
768
|
+
void Unadvise(uint c);
|
|
769
|
+
void SetOptions(uint fos);
|
|
770
|
+
void GetOptions(out uint fos);
|
|
771
|
+
void SetDefaultFolder(IShellItem i);
|
|
772
|
+
void SetFolder(IShellItem i);
|
|
773
|
+
void GetFolder(out IShellItem i);
|
|
774
|
+
void GetCurrentSelection(out IShellItem i);
|
|
775
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
776
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
|
|
777
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
778
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
779
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
780
|
+
void GetResult(out IShellItem i);
|
|
781
|
+
void AddPlace(IShellItem i, uint f);
|
|
782
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string e);
|
|
783
|
+
void Close(int hr);
|
|
784
|
+
void SetClientGuid(ref Guid g);
|
|
785
|
+
void ClearClientData();
|
|
786
|
+
void SetFilter(IntPtr f);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
790
|
+
interface IShellItem {
|
|
791
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
792
|
+
void GetParent(out IShellItem ppsi);
|
|
793
|
+
void GetDisplayName(uint sigdn, out IntPtr name);
|
|
794
|
+
void GetAttributes(uint mask, out uint attrs);
|
|
795
|
+
void Compare(IShellItem psi, uint hint, out int order);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
|
|
799
|
+
class FileOpenDialog { }
|
|
800
|
+
|
|
801
|
+
public static string Show(string title, string okLabel) {
|
|
802
|
+
var raiser = new Thread(Raise);
|
|
803
|
+
raiser.IsBackground = true;
|
|
804
|
+
raiser.Start();
|
|
805
|
+
|
|
806
|
+
var dialog = (IFileOpenDialog)new FileOpenDialog();
|
|
807
|
+
uint options;
|
|
808
|
+
dialog.GetOptions(out options);
|
|
809
|
+
dialog.SetOptions(options | 0x20u | 0x40u | 0x800u);
|
|
810
|
+
if (title != null) dialog.SetTitle(title);
|
|
811
|
+
if (okLabel != null) dialog.SetOkButtonLabel(okLabel);
|
|
812
|
+
|
|
813
|
+
int hr = dialog.Show(IntPtr.Zero);
|
|
814
|
+
if (hr != 0) return null;
|
|
815
|
+
|
|
816
|
+
IShellItem item;
|
|
817
|
+
dialog.GetResult(out item);
|
|
818
|
+
IntPtr name;
|
|
819
|
+
item.GetDisplayName(0x80058000u, out name);
|
|
820
|
+
string path = Marshal.PtrToStringUni(name);
|
|
821
|
+
Marshal.FreeCoTaskMem(name);
|
|
822
|
+
return path;
|
|
823
|
+
}
|
|
824
|
+
}`
|
|
825
|
+
|
|
826
|
+
/** The compiled form: the class above plus an entry point that prints one path. */
|
|
827
|
+
const PICK_EXE_SOURCE = PICK_CSHARP + `
|
|
828
|
+
|
|
829
|
+
public static class Program {
|
|
830
|
+
public static int Main(string[] args) {
|
|
831
|
+
string picked = DshFolderPick.Show(args.Length > 0 ? args[0] : null, args.Length > 1 ? args[1] : null);
|
|
832
|
+
if (picked == null) return 1;
|
|
833
|
+
// Written as UTF-8 bytes rather than through Console.Out: its encoding
|
|
834
|
+
// depends on a console this process does not own, and paths are not ASCII.
|
|
835
|
+
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(picked);
|
|
836
|
+
using (System.IO.Stream stdout = Console.OpenStandardOutput()) {
|
|
837
|
+
stdout.Write(bytes, 0, bytes.Length);
|
|
838
|
+
stdout.Flush();
|
|
839
|
+
}
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
}`
|
|
843
|
+
|
|
844
|
+
/** The fallback for a host without `csc.exe`: the same class through PowerShell. */
|
|
845
|
+
const PICK_SCRIPT = `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
846
|
+
$source = @'
|
|
847
|
+
${PICK_CSHARP}
|
|
848
|
+
'@
|
|
849
|
+
Add-Type -TypeDefinition $source
|
|
850
|
+
$picked = [DshFolderPick]::Show('选择隔离夹保存位置', '选择此文件夹')
|
|
851
|
+
if ($picked) { [Console]::Out.Write($picked) }
|
|
852
|
+
`
|
|
853
|
+
|
|
854
|
+
/** Bump when `PICK_EXE_SOURCE` changes, so a cached build is not reused. */
|
|
855
|
+
const PICK_EXE_VERSION = 1
|
|
856
|
+
/** Compiling once is slow; it happens on the first ever pick only. */
|
|
857
|
+
const PICK_COMPILE_TIMEOUT_MS = 60 * 1000
|
|
858
|
+
|
|
859
|
+
/** The C# compiler that ships with .NET Framework, if this machine has it. */
|
|
860
|
+
function findCsc() {
|
|
861
|
+
const windir = process.env.WINDIR || 'C:\\Windows'
|
|
862
|
+
for (const flavor of ['Framework64', 'Framework']) {
|
|
863
|
+
const candidate = path.join(windir, 'Microsoft.NET', flavor, 'v4.0.30319', 'csc.exe')
|
|
864
|
+
try {
|
|
865
|
+
if (fs.statSync(candidate).isFile()) return candidate
|
|
866
|
+
} catch {
|
|
867
|
+
// try the next flavour
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return null
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Compile the picker once into `$DSH_HOME/cache/dsh-delete-session`, then reuse
|
|
875
|
+
* it. Returns null when this machine cannot compile, so the caller falls back to
|
|
876
|
+
* the PowerShell path rather than failing the pick.
|
|
877
|
+
*/
|
|
878
|
+
async function ensurePickerExe() {
|
|
879
|
+
if (process.platform !== 'win32') return null
|
|
880
|
+
const dir = path.join(dshHome(), 'cache', 'dsh-delete-session', `v${PICK_EXE_VERSION}`)
|
|
881
|
+
const exe = path.join(dir, 'dsh-folder-pick.exe')
|
|
882
|
+
if (await pathExists(exe)) return exe
|
|
883
|
+
const csc = findCsc()
|
|
884
|
+
if (csc === null) return null
|
|
885
|
+
try {
|
|
886
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
887
|
+
const source = path.join(dir, 'picker.cs')
|
|
888
|
+
await fsp.writeFile(source, PICK_EXE_SOURCE, 'utf8')
|
|
889
|
+
const built = await runExecutable(csc, ['-nologo', '-target:exe', `-out:${exe}`, '-r:System.dll', source], PICK_COMPILE_TIMEOUT_MS)
|
|
890
|
+
try { await fsp.rm(source, { force: true }) } catch { /* best effort */ }
|
|
891
|
+
if (built.code === 0) {
|
|
892
|
+
if (await pathExists(exe)) return exe
|
|
893
|
+
}
|
|
894
|
+
} catch {
|
|
895
|
+
// fall through to the PowerShell path
|
|
896
|
+
}
|
|
897
|
+
return null
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Run a program and collect its output, leaving the meaning of the exit to the caller. */
|
|
901
|
+
function runExecutable(program, args, timeoutMs) {
|
|
902
|
+
return new Promise((resolve, reject) => {
|
|
903
|
+
const child = spawn(program, args, { windowsHide: true })
|
|
904
|
+
let out = ''
|
|
905
|
+
let err = ''
|
|
906
|
+
let settled = false
|
|
907
|
+
const finish = (fn, value) => {
|
|
908
|
+
if (settled) return
|
|
909
|
+
settled = true
|
|
910
|
+
clearTimeout(timer)
|
|
911
|
+
fn(value)
|
|
912
|
+
}
|
|
913
|
+
const timer = setTimeout(() => {
|
|
914
|
+
try { child.kill() } catch { /* already gone */ }
|
|
915
|
+
finish(reject, new HttpError(504, 'timeout', '等待系统对话框超时'))
|
|
916
|
+
}, timeoutMs)
|
|
917
|
+
|
|
918
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString('utf8') })
|
|
919
|
+
child.stderr.on('data', (chunk) => { err += chunk.toString('utf8') })
|
|
920
|
+
child.on('error', (error) => {
|
|
921
|
+
finish(reject, new HttpError(500, 'spawn', `无法启动 ${path.basename(program)}:${String((error && error.message) || error)}`))
|
|
922
|
+
})
|
|
923
|
+
child.on('close', (code) => finish(resolve, { code, stdout: out, stderr: err }))
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Argument keys carrying the target path, per writing tool. */
|
|
928
|
+
const WRITE_TOOL_PATHS = {
|
|
929
|
+
write: ['file_path', 'path'],
|
|
930
|
+
edit: ['file_path', 'path'],
|
|
931
|
+
str_replace_editor: ['path', 'file_path'],
|
|
932
|
+
create_file: ['file_path', 'path'],
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Argument keys carrying the target path, per reading tool. */
|
|
936
|
+
const READ_TOOL_PATHS = {
|
|
937
|
+
read: ['file_path', 'path'],
|
|
938
|
+
read_file: ['file_path', 'path'],
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Resolve the target path one tool call names.
|
|
943
|
+
* @param name - tool name.
|
|
944
|
+
* @param argumentsJson - the model's raw argument JSON.
|
|
945
|
+
* @param cwd - the session's recorded working directory, for relative paths.
|
|
946
|
+
* @returns the absolute path and whether the tool writes, or undefined.
|
|
947
|
+
*/
|
|
948
|
+
function toolTarget(name, argumentsJson, cwd) {
|
|
949
|
+
const writes = Object.prototype.hasOwnProperty.call(WRITE_TOOL_PATHS, name)
|
|
950
|
+
const keys = writes ? WRITE_TOOL_PATHS[name] : READ_TOOL_PATHS[name]
|
|
951
|
+
if (keys === undefined) return undefined
|
|
952
|
+
let args
|
|
953
|
+
try {
|
|
954
|
+
args = JSON.parse(typeof argumentsJson === 'string' ? argumentsJson : '')
|
|
955
|
+
} catch {
|
|
956
|
+
return undefined
|
|
957
|
+
}
|
|
958
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) return undefined
|
|
959
|
+
const raw = keys.map((key) => args[key]).find((value) => typeof value === 'string' && value.trim() !== '')
|
|
960
|
+
if (raw === undefined) return undefined
|
|
961
|
+
let absolute
|
|
962
|
+
if (path.isAbsolute(raw)) absolute = raw
|
|
963
|
+
else if (cwd === undefined) return undefined
|
|
964
|
+
else absolute = path.resolve(cwd, raw)
|
|
965
|
+
return { path: absolute, writes }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Read a stored session's events through the persistence seam. */
|
|
969
|
+
async function readSessionEvents(ctx, sessionId) {
|
|
970
|
+
const sp = ctx.get('sessionPersistence')
|
|
971
|
+
if (!sp || typeof sp.open !== 'function') {
|
|
972
|
+
return { events: [], warning: '持久化服务不可用,无法读取会话日志' }
|
|
973
|
+
}
|
|
974
|
+
let handle = null
|
|
975
|
+
try {
|
|
976
|
+
handle = await sp.open(sessionId, 'read')
|
|
977
|
+
const result = await handle.read()
|
|
978
|
+
const events = Array.isArray(result && result.events) ? result.events : []
|
|
979
|
+
return { events, warning: undefined }
|
|
980
|
+
} catch (error) {
|
|
981
|
+
return { events: [], warning: `读取会话日志失败:${String((error && error.message) || error)}` }
|
|
982
|
+
} finally {
|
|
983
|
+
try {
|
|
984
|
+
if (handle && typeof handle.close === 'function') await handle.close()
|
|
985
|
+
} catch {
|
|
986
|
+
// a close failure must not change the plan
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Identity of one path for "have we seen this file before?" purposes. The real
|
|
993
|
+
* path when it resolves, so a symlink alias and its target count as the same
|
|
994
|
+
* file; otherwise a case-folded resolved path on Windows, whose filesystem does
|
|
995
|
+
* not distinguish `Foo.md` from `foo.md`. Comparing spellings instead would let
|
|
996
|
+
* a write through an alias look like a creation.
|
|
997
|
+
* @param absolute - the path a tool call named.
|
|
998
|
+
* @param cache - optional per-scan memo; without it the resolution cost scales
|
|
999
|
+
* with log length rather than with the number of distinct files.
|
|
1000
|
+
* @returns a stable key for the file, not for the spelling.
|
|
1001
|
+
*/
|
|
1002
|
+
async function identityKey(absolute, cache) {
|
|
1003
|
+
if (cache !== undefined) {
|
|
1004
|
+
const hit = cache.get(absolute)
|
|
1005
|
+
if (hit !== undefined) return hit
|
|
1006
|
+
}
|
|
1007
|
+
let key
|
|
1008
|
+
try {
|
|
1009
|
+
key = await realpathNative(absolute)
|
|
1010
|
+
} catch {
|
|
1011
|
+
const resolved = path.resolve(absolute)
|
|
1012
|
+
key = process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
1013
|
+
}
|
|
1014
|
+
if (cache !== undefined) cache.set(absolute, key)
|
|
1015
|
+
return key
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Every path this session's writing tools touched, split by whether the session
|
|
1020
|
+
* created it or found it already there.
|
|
1021
|
+
* @param events - stored session events, in order.
|
|
1022
|
+
* @param cwd - the session's recorded working directory.
|
|
1023
|
+
* @param cache - optional per-scan memo for path resolution, shared across calls.
|
|
1024
|
+
* @returns `{ created, modified }`, keyed by file identity, each row `{ path, identity, ops }`.
|
|
1025
|
+
*/
|
|
1026
|
+
async function collectSessionPaths(events, cwd, cache) {
|
|
1027
|
+
const pending = new Map()
|
|
1028
|
+
const read = new Set()
|
|
1029
|
+
const created = new Map()
|
|
1030
|
+
const modified = new Map()
|
|
1031
|
+
const memo = cache !== undefined ? cache : new Map()
|
|
1032
|
+
|
|
1033
|
+
// Resolve every distinct path the log names first, together. A long session
|
|
1034
|
+
// names the same handful of files thousands of times, so awaiting per call
|
|
1035
|
+
// would buy one threadpool round trip per mention; this buys one per file.
|
|
1036
|
+
const named = new Set()
|
|
1037
|
+
for (const event of events) {
|
|
1038
|
+
const data = event && event.data
|
|
1039
|
+
if (!data || typeof data !== 'object' || event.type !== 'tool/call') continue
|
|
1040
|
+
const target = toolTarget(typeof data.name === 'string' ? data.name : '', data.arguments, cwd)
|
|
1041
|
+
if (target !== undefined) named.add(target.path)
|
|
1042
|
+
}
|
|
1043
|
+
await runWithConcurrency([...named], RESOLVE_CONCURRENCY, async (absolute) => {
|
|
1044
|
+
await identityKey(absolute, memo)
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
const commit = (call) => {
|
|
1048
|
+
// A file the session created and later edited stays "created": one identity
|
|
1049
|
+
// must never sit in both buckets, or a scope=all run would target it twice
|
|
1050
|
+
// and report a spurious failure for the second attempt.
|
|
1051
|
+
const alreadyCreated = created.get(call.identity)
|
|
1052
|
+
if (alreadyCreated !== undefined) {
|
|
1053
|
+
alreadyCreated.ops += 1
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
const bucket = call.existedBefore ? modified : created
|
|
1057
|
+
const prior = bucket.get(call.identity)
|
|
1058
|
+
if (prior !== undefined) prior.ops += 1
|
|
1059
|
+
else bucket.set(call.identity, { path: call.path, identity: call.identity, ops: 1 })
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
for (const event of events) {
|
|
1063
|
+
const data = event && event.data
|
|
1064
|
+
if (!data || typeof data !== 'object') continue
|
|
1065
|
+
|
|
1066
|
+
if (event.type === 'tool/call') {
|
|
1067
|
+
const name = typeof data.name === 'string' ? data.name : ''
|
|
1068
|
+
const target = toolTarget(name, data.arguments, cwd)
|
|
1069
|
+
// Existence is decided at call time, against the reads that already
|
|
1070
|
+
// succeeded - the policy's own ordering - and on identity, so an alias of
|
|
1071
|
+
// a file the session read is still an edit.
|
|
1072
|
+
if (target !== undefined) {
|
|
1073
|
+
const identity = await identityKey(target.path, memo)
|
|
1074
|
+
pending.set(data.callId, {
|
|
1075
|
+
path: target.path,
|
|
1076
|
+
identity,
|
|
1077
|
+
writes: target.writes,
|
|
1078
|
+
existedBefore: read.has(identity),
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
continue
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (event.type !== 'tool/result') continue
|
|
1085
|
+
const message = data.message
|
|
1086
|
+
const callId = message && typeof message.toolCallId === 'string' ? message.toolCallId : undefined
|
|
1087
|
+
if (callId === undefined) continue
|
|
1088
|
+
const call = pending.get(callId)
|
|
1089
|
+
if (call === undefined) continue
|
|
1090
|
+
pending.delete(callId)
|
|
1091
|
+
if (data.error !== undefined || (message && message.isError === true)) continue
|
|
1092
|
+
if (call.writes) commit(call)
|
|
1093
|
+
else read.add(call.identity)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return { created, modified }
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Whether `child` is strictly inside `parent`, comparing real paths. Folded on
|
|
1101
|
+
* Windows, where the filesystem ignores case: a realpath does not normalise the
|
|
1102
|
+
* case of a prefix that still matches a differently-spelled spelling.
|
|
1103
|
+
*/
|
|
1104
|
+
function insidePath(parent, child) {
|
|
1105
|
+
if (parent === child) return false
|
|
1106
|
+
const fold = (value) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
|
1107
|
+
const base = fold(parent)
|
|
1108
|
+
const prefix = base.endsWith(path.sep) ? base : base + path.sep
|
|
1109
|
+
return fold(child).startsWith(prefix)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Is this directory inside a git working tree? A `.git` entry marks the root —
|
|
1114
|
+
* a directory in a normal clone, a file in a worktree or a submodule.
|
|
1115
|
+
*
|
|
1116
|
+
* The walk memoises per directory: one plan can span hundreds of files that share
|
|
1117
|
+
* a handful of folders, and each miss would otherwise stat every level again.
|
|
1118
|
+
*/
|
|
1119
|
+
async function isInsideGitRepo(directory, cache) {
|
|
1120
|
+
let current = path.resolve(directory)
|
|
1121
|
+
const walked = []
|
|
1122
|
+
for (;;) {
|
|
1123
|
+
if (cache.has(current)) {
|
|
1124
|
+
const verdict = cache.get(current)
|
|
1125
|
+
for (const dir of walked) cache.set(dir, verdict)
|
|
1126
|
+
return verdict
|
|
1127
|
+
}
|
|
1128
|
+
walked.push(current)
|
|
1129
|
+
try {
|
|
1130
|
+
const marker = await fsp.stat(path.join(current, '.git'))
|
|
1131
|
+
if (marker.isDirectory() || marker.isFile()) {
|
|
1132
|
+
for (const dir of walked) cache.set(dir, true)
|
|
1133
|
+
return true
|
|
1134
|
+
}
|
|
1135
|
+
} catch {
|
|
1136
|
+
// no .git here; keep walking up
|
|
1137
|
+
}
|
|
1138
|
+
const parent = path.dirname(current)
|
|
1139
|
+
if (parent === current) {
|
|
1140
|
+
for (const dir of walked) cache.set(dir, false)
|
|
1141
|
+
return false
|
|
1142
|
+
}
|
|
1143
|
+
current = parent
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Keep only the paths that may actually be moved: an existing regular file whose
|
|
1149
|
+
* real path is strictly inside the session's working directory and outside the
|
|
1150
|
+
* DSH home. Every rejection carries its reason.
|
|
1151
|
+
* @param rows - candidate rows `{ path, ops }`.
|
|
1152
|
+
* @param cwd - the session's recorded working directory.
|
|
1153
|
+
* @param home - resolved DSH home.
|
|
1154
|
+
* @param options - `{ skipGit }`: also refuse files inside a git working tree.
|
|
1155
|
+
* @returns `{ allowed, refused }`.
|
|
1156
|
+
*/
|
|
1157
|
+
async function classifyPaths(rows, cwd, home, options) {
|
|
1158
|
+
const allowed = []
|
|
1159
|
+
const refused = []
|
|
1160
|
+
const skipGit = options !== undefined && options !== null && options.skipGit === true
|
|
1161
|
+
const gitCache = new Map()
|
|
1162
|
+
const list = [...rows.values()]
|
|
1163
|
+
if (cwd === undefined) {
|
|
1164
|
+
for (const row of list) refused.push({ path: row.path, reason: '会话未记录工作目录,无法安全定位' })
|
|
1165
|
+
return { allowed, refused }
|
|
1166
|
+
}
|
|
1167
|
+
let realCwd
|
|
1168
|
+
try {
|
|
1169
|
+
realCwd = await fsp.realpath(cwd)
|
|
1170
|
+
} catch {
|
|
1171
|
+
for (const row of list) refused.push({ path: row.path, reason: '会话工作目录已不存在,无法校验归属' })
|
|
1172
|
+
return { allowed, refused }
|
|
1173
|
+
}
|
|
1174
|
+
let realHome = home
|
|
1175
|
+
try {
|
|
1176
|
+
realHome = await fsp.realpath(home)
|
|
1177
|
+
} catch {
|
|
1178
|
+
// an absent home cannot contain anything that exists
|
|
1179
|
+
}
|
|
1180
|
+
// Resolve each row's stat and real path together rather than one after another:
|
|
1181
|
+
// a long session can list hundreds of files, and awaiting them serially turns
|
|
1182
|
+
// one threadpool round trip into hundreds. `runWithConcurrency` keeps the order.
|
|
1183
|
+
const verdicts = await runWithConcurrency(list, RESOLVE_CONCURRENCY, async (row) => {
|
|
1184
|
+
let info
|
|
1185
|
+
try {
|
|
1186
|
+
info = await fsp.stat(row.path)
|
|
1187
|
+
} catch {
|
|
1188
|
+
return { refuse: { path: row.path, reason: '文件已不存在' } }
|
|
1189
|
+
}
|
|
1190
|
+
if (!info.isFile()) return { refuse: { path: row.path, reason: '不是普通文件,已跳过' } }
|
|
1191
|
+
let real
|
|
1192
|
+
try {
|
|
1193
|
+
real = await fsp.realpath(row.path)
|
|
1194
|
+
} catch {
|
|
1195
|
+
return { refuse: { path: row.path, reason: '无法解析真实路径,已跳过' } }
|
|
1196
|
+
}
|
|
1197
|
+
if (!insidePath(realCwd, real)) {
|
|
1198
|
+
return { refuse: { path: row.path, reason: '不在该对话的工作目录内,已跳过' } }
|
|
1199
|
+
}
|
|
1200
|
+
if (insidePath(realHome, real)) {
|
|
1201
|
+
return { refuse: { path: row.path, reason: '位于 DSH 数据目录内,已跳过' } }
|
|
1202
|
+
}
|
|
1203
|
+
if (skipGit && await isInsideGitRepo(path.dirname(real), gitCache)) {
|
|
1204
|
+
return { refuse: { path: row.path, reason: '位于 git 仓库内,已跳过' } }
|
|
1205
|
+
}
|
|
1206
|
+
return { allow: { path: real, displayPath: row.path, ops: row.ops, bytes: info.size, mtime: info.mtimeMs } }
|
|
1207
|
+
})
|
|
1208
|
+
for (const verdict of verdicts) {
|
|
1209
|
+
if (verdict.allow !== undefined) allowed.push(verdict.allow)
|
|
1210
|
+
else refused.push(verdict.refuse)
|
|
1211
|
+
}
|
|
1212
|
+
return { allowed, refused }
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Build the artifact plan for one session.
|
|
1217
|
+
* @param ctx - host plugin context.
|
|
1218
|
+
* @param sessionId - validated session id.
|
|
1219
|
+
* @returns `{ cwd, created, modified, refused, truncated, warnings }`.
|
|
1220
|
+
*/
|
|
1221
|
+
async function planArtifacts(ctx, sessionId, cache, options) {
|
|
1222
|
+
const home = dshHome()
|
|
1223
|
+
const warnings = []
|
|
1224
|
+
const sp = ctx.get('sessionPersistence')
|
|
1225
|
+
let cwd
|
|
1226
|
+
if (sp && typeof sp.stat === 'function') {
|
|
1227
|
+
try {
|
|
1228
|
+
const snapshot = await sp.stat(sessionId)
|
|
1229
|
+
let header = null
|
|
1230
|
+
if (snapshot !== null && typeof snapshot === 'object') {
|
|
1231
|
+
header = snapshot.header !== null && typeof snapshot.header === 'object' ? snapshot.header : snapshot
|
|
1232
|
+
}
|
|
1233
|
+
if (header && typeof header.cwd === 'string' && header.cwd !== '') cwd = header.cwd
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
warnings.push(`未找到该会话的存储记录:${String((error && error.message) || error)}`)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
const { events, warning } = await readSessionEvents(ctx, sessionId)
|
|
1239
|
+
if (warning !== undefined) warnings.push(warning)
|
|
1240
|
+
const { created, modified } = await collectSessionPaths(events, cwd, cache)
|
|
1241
|
+
const createdPlan = await classifyPaths(created, cwd, home, options)
|
|
1242
|
+
const modifiedPlan = await classifyPaths(modified, cwd, home, options)
|
|
1243
|
+
const refused = [...createdPlan.refused, ...modifiedPlan.refused]
|
|
1244
|
+
return {
|
|
1245
|
+
// Stamped so a caller handing this plan back cannot smuggle in another
|
|
1246
|
+
// session's list: runQuarantine refuses a plan that does not belong here.
|
|
1247
|
+
sessionId,
|
|
1248
|
+
cwd,
|
|
1249
|
+
created: createdPlan.allowed.slice(0, MAX_LISTED),
|
|
1250
|
+
modified: modifiedPlan.allowed.slice(0, MAX_LISTED),
|
|
1251
|
+
refused: refused.slice(0, MAX_LISTED),
|
|
1252
|
+
truncated: createdPlan.allowed.length > MAX_LISTED
|
|
1253
|
+
|| modifiedPlan.allowed.length > MAX_LISTED
|
|
1254
|
+
|| refused.length > MAX_LISTED,
|
|
1255
|
+
warnings,
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Claim a quarantine directory under the DSH home. The name carries second
|
|
1261
|
+
* resolution only, so two deletes can land on the same name; an exclusive mkdir
|
|
1262
|
+
* (never `recursive: true`, which would happily reuse an existing tree) decides
|
|
1263
|
+
* the winner and the loser retries with a suffix. Two runs therefore can never
|
|
1264
|
+
* share a tree, which is what stops one run's file from overwriting another's.
|
|
1265
|
+
* @param home - resolved DSH home.
|
|
1266
|
+
* @returns the claimed directory, or undefined when none could be claimed.
|
|
1267
|
+
*/
|
|
1268
|
+
async function claimQuarantineDirectory(home) {
|
|
1269
|
+
// A fresh home has no quarantine root yet; creating it is the only write this
|
|
1270
|
+
// side does outside the quarantine tree, and it keeps a missing home from
|
|
1271
|
+
// turning into a silent "cannot claim" on the very first delete.
|
|
1272
|
+
try {
|
|
1273
|
+
await fsp.mkdir(home, { recursive: true })
|
|
1274
|
+
} catch {
|
|
1275
|
+
return undefined
|
|
1276
|
+
}
|
|
1277
|
+
const base = `${QUARANTINE_PREFIX}-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`
|
|
1278
|
+
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
|
1279
|
+
const candidate = path.join(home, attempt === 0 ? base : `${base}-${attempt}`)
|
|
1280
|
+
try {
|
|
1281
|
+
await fsp.mkdir(candidate, { recursive: false })
|
|
1282
|
+
return candidate
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
if (error && error.code === 'EEXIST') continue
|
|
1285
|
+
return undefined
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return undefined
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** Whether a path exists, without throwing. */
|
|
1292
|
+
async function pathExists(target) {
|
|
1293
|
+
try {
|
|
1294
|
+
await fsp.access(target)
|
|
1295
|
+
return true
|
|
1296
|
+
} catch {
|
|
1297
|
+
return false
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* A destination inside the quarantine tree that does not already exist. The
|
|
1303
|
+
* move itself must never overwrite: a quarantine that silently replaced an
|
|
1304
|
+
* earlier copy would destroy the only surviving copy of the file it replaced.
|
|
1305
|
+
* @param directory - claimed quarantine directory.
|
|
1306
|
+
* @param relative - path relative to the session's working directory.
|
|
1307
|
+
* @returns a free absolute path, or undefined when every name was taken.
|
|
1308
|
+
*/
|
|
1309
|
+
async function freeDestination(directory, relative) {
|
|
1310
|
+
const first = path.join(directory, relative)
|
|
1311
|
+
if (!(await pathExists(first))) return first
|
|
1312
|
+
const dir = path.dirname(first)
|
|
1313
|
+
const ext = path.extname(first)
|
|
1314
|
+
const stem = path.basename(first, ext)
|
|
1315
|
+
for (let n = 1; n < 10000; n += 1) {
|
|
1316
|
+
const candidate = path.join(dir, `${stem}-${n}${ext}`)
|
|
1317
|
+
if (!(await pathExists(candidate))) return candidate
|
|
1318
|
+
}
|
|
1319
|
+
return undefined
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Move the chosen files into a timestamped quarantine directory under the DSH
|
|
1324
|
+
* home, preserving each path relative to the session's working directory. Files
|
|
1325
|
+
* are moved, never removed, and an existing quarantine entry is never replaced,
|
|
1326
|
+
* so a wrong call is always recoverable by hand.
|
|
1327
|
+
*
|
|
1328
|
+
* Every step is asynchronous on purpose: a cross-device move copies, and a
|
|
1329
|
+
* synchronous copy inside the request handler would freeze the event loop, which
|
|
1330
|
+
* means every other session's traffic stalls behind one large quarantine.
|
|
1331
|
+
*
|
|
1332
|
+
* @param targets - absolute file paths to move.
|
|
1333
|
+
* @param cwd - the session's recorded working directory.
|
|
1334
|
+
* @param home - resolved DSH home.
|
|
1335
|
+
* @returns `{ directory, moved, failed }`.
|
|
1336
|
+
*/
|
|
1337
|
+
async function quarantineFiles(targets, cwd, home) {
|
|
1338
|
+
const moved = []
|
|
1339
|
+
const failed = []
|
|
1340
|
+
if (targets.length === 0) return { directory: null, moved, failed }
|
|
1341
|
+
|
|
1342
|
+
const directory = await claimQuarantineDirectory(home)
|
|
1343
|
+
if (directory === undefined) {
|
|
1344
|
+
for (const target of targets) failed.push({ path: target, error: '无法创建隔离目录' })
|
|
1345
|
+
return { directory: null, moved, failed }
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
let realCwd = cwd
|
|
1349
|
+
if (typeof cwd === 'string') {
|
|
1350
|
+
try {
|
|
1351
|
+
realCwd = await fsp.realpath(cwd)
|
|
1352
|
+
} catch {
|
|
1353
|
+
// keep the recorded spelling; classifyPaths already proved containment
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
for (const target of targets) {
|
|
1357
|
+
let relative = path.basename(target)
|
|
1358
|
+
if (typeof realCwd === 'string') {
|
|
1359
|
+
const candidate = path.relative(realCwd, target)
|
|
1360
|
+
if (candidate !== '' && !candidate.startsWith('..') && !path.isAbsolute(candidate)) relative = candidate
|
|
1361
|
+
}
|
|
1362
|
+
try {
|
|
1363
|
+
await fsp.mkdir(path.dirname(path.join(directory, relative)), { recursive: true })
|
|
1364
|
+
const destination = await freeDestination(directory, relative)
|
|
1365
|
+
if (destination === undefined) {
|
|
1366
|
+
failed.push({ path: target, error: '隔离目录内没有可用的目标文件名' })
|
|
1367
|
+
continue
|
|
1368
|
+
}
|
|
1369
|
+
try {
|
|
1370
|
+
await fsp.rename(target, destination)
|
|
1371
|
+
} catch {
|
|
1372
|
+
// A cross-device rename fails; fall back to copy-then-remove. `COPYFILE_EXCL`
|
|
1373
|
+
// keeps the copy from replacing anything that appeared in between.
|
|
1374
|
+
await fsp.copyFile(target, destination, fs.constants.COPYFILE_EXCL)
|
|
1375
|
+
await fsp.rm(target, { force: true })
|
|
1376
|
+
}
|
|
1377
|
+
moved.push({ from: target, to: destination })
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
failed.push({ path: target, error: String((error && error.message) || error) })
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return { directory, moved, failed }
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Quarantine the files one request selected. The candidate set is recomputed
|
|
1387
|
+
* here from the session log and the request's paths are intersected with it, so
|
|
1388
|
+
* a tampered request cannot move anything the user was not shown.
|
|
1389
|
+
* @param ctx - host plugin context.
|
|
1390
|
+
* @param sessionId - validated session id.
|
|
1391
|
+
* @param request - `{ scope, paths }` from the client; undefined asks for none.
|
|
1392
|
+
* @returns the outcome, or null when the request asked for no quarantine.
|
|
1393
|
+
*/
|
|
1394
|
+
async function runQuarantine(ctx, sessionId, request, knownPlan) {
|
|
1395
|
+
if (request === undefined || request === null || typeof request !== 'object') return null
|
|
1396
|
+
// Validated here as well as at the route, because `internals` exposes this
|
|
1397
|
+
// function and a direct caller must not be able to skip the check.
|
|
1398
|
+
if (!SESSION_ID_RE.test(sessionId)) throw new HttpError(400, 'invalid', 'invalid session id')
|
|
1399
|
+
const scope = request.scope === 'all' ? 'all' : 'created'
|
|
1400
|
+
// The same flag the dialog was built with, so a derived set cannot contain a
|
|
1401
|
+
// file the listing refused to show.
|
|
1402
|
+
const skipGit = request.skipGit === true
|
|
1403
|
+
// Re-deriving the plan is the expensive half of a delete; a caller that already
|
|
1404
|
+
// holds it for this session hands it over instead of paying for it twice.
|
|
1405
|
+
const plan = knownPlan === undefined || knownPlan === null
|
|
1406
|
+
? await planArtifacts(ctx, sessionId, undefined, { skipGit })
|
|
1407
|
+
: knownPlan
|
|
1408
|
+
// A handed-in plan must be this session's plan. The route always derives it
|
|
1409
|
+
// from the same id, but `internals` exposes this function, and a future caller
|
|
1410
|
+
// passing its neighbour's plan would move files the user never saw.
|
|
1411
|
+
if (plan.sessionId !== sessionId) throw new HttpError(400, 'invalid', 'plan does not belong to this session')
|
|
1412
|
+
const candidates = scope === 'all' ? [...plan.created, ...plan.modified] : [...plan.created]
|
|
1413
|
+
const allowed = new Set(candidates.map((row) => row.path))
|
|
1414
|
+
const requested = Array.isArray(request.paths) ? request.paths : null
|
|
1415
|
+
const requestedStrings = requested === null ? null : new Set(requested.filter((value) => typeof value === 'string'))
|
|
1416
|
+
const targets = requested === null
|
|
1417
|
+
? [...allowed]
|
|
1418
|
+
: [...requestedStrings].filter((value) => allowed.has(value))
|
|
1419
|
+
// Resolve the configured save path before moving a single file: a bad path must
|
|
1420
|
+
// stop the request, not send the files somewhere unintended.
|
|
1421
|
+
const rootInfo = await resolveQuarantineRoot(request.root, plan.cwd)
|
|
1422
|
+
if (rootInfo.error !== undefined) throw new HttpError(400, 'invalid', rootInfo.error)
|
|
1423
|
+
const root = rootInfo.root
|
|
1424
|
+
const outcome = await quarantineFiles(targets, plan.cwd, root)
|
|
1425
|
+
return {
|
|
1426
|
+
scope,
|
|
1427
|
+
directory: outcome.moved.length > 0 ? outcome.directory : null,
|
|
1428
|
+
root,
|
|
1429
|
+
moved: outcome.moved,
|
|
1430
|
+
failed: outcome.failed,
|
|
1431
|
+
candidates: allowed.size,
|
|
1432
|
+
selected: targets.length,
|
|
1433
|
+
dropped: requestedStrings === null ? 0 : requestedStrings.size - targets.length,
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Decide which directory the timestamped quarantine folder is created under. An
|
|
1439
|
+
* empty request means the DSH home, which is what every earlier version did.
|
|
1440
|
+
* @param requested - the directory the client asked for, if any.
|
|
1441
|
+
* @param cwd - the session's working directory, used to refuse a "quarantine"
|
|
1442
|
+
* that would only shuffle the files around inside the tree they came from.
|
|
1443
|
+
* @returns `{ root }` or `{ error }`.
|
|
1444
|
+
*/
|
|
1445
|
+
async function resolveQuarantineRoot(requested, cwd) {
|
|
1446
|
+
const home = dshHome()
|
|
1447
|
+
if (requested === undefined || requested === null || requested === '') return { root: home }
|
|
1448
|
+
if (typeof requested !== 'string') return { error: '保存路径必须是字符串' }
|
|
1449
|
+
if (requested.length > 4096) return { error: '保存路径过长' }
|
|
1450
|
+
if (!path.isAbsolute(requested)) return { error: '保存路径必须是绝对路径' }
|
|
1451
|
+
|
|
1452
|
+
let real
|
|
1453
|
+
try {
|
|
1454
|
+
real = await fsp.realpath(requested)
|
|
1455
|
+
} catch {
|
|
1456
|
+
return { error: `保存路径不存在:${requested}` }
|
|
1457
|
+
}
|
|
1458
|
+
let info
|
|
1459
|
+
try {
|
|
1460
|
+
info = await fsp.stat(real)
|
|
1461
|
+
} catch {
|
|
1462
|
+
return { error: `无法读取保存路径:${requested}` }
|
|
1463
|
+
}
|
|
1464
|
+
if (!info.isDirectory()) return { error: '保存路径不是目录' }
|
|
1465
|
+
|
|
1466
|
+
// The sessions store belongs to DSH; filling it with quarantined files would
|
|
1467
|
+
// drop them back into the one tree the delete pipeline sweeps.
|
|
1468
|
+
const sessionsRoot = path.join(home, 'sessions')
|
|
1469
|
+
if (real === sessionsRoot || insidePath(sessionsRoot, real)) {
|
|
1470
|
+
return { error: '不能把隔离夹设在会话存储目录内' }
|
|
1471
|
+
}
|
|
1472
|
+
if (typeof cwd === 'string') {
|
|
1473
|
+
let realCwd
|
|
1474
|
+
try {
|
|
1475
|
+
realCwd = await fsp.realpath(cwd)
|
|
1476
|
+
} catch {
|
|
1477
|
+
realCwd = undefined
|
|
1478
|
+
}
|
|
1479
|
+
if (realCwd !== undefined && (real === realCwd || insidePath(realCwd, real))) {
|
|
1480
|
+
return { error: '不能把隔离夹设在会话工作目录内' }
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return { root: real }
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** The drive letters Windows exposes, skipping the ones that cannot be read. */
|
|
1487
|
+
async function listDriveRoots() {
|
|
1488
|
+
if (process.platform !== 'win32') return []
|
|
1489
|
+
const roots = []
|
|
1490
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
1491
|
+
const drive = `${String.fromCharCode(code)}:\\`
|
|
1492
|
+
try {
|
|
1493
|
+
await fsp.access(drive)
|
|
1494
|
+
roots.push({ name: drive, path: drive })
|
|
1495
|
+
} catch {
|
|
1496
|
+
// absent or unreadable: not offered
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return roots
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* One directory listing for the folder picker. Directories only: the picker
|
|
1504
|
+
* chooses where a folder is created, so files would be noise.
|
|
1505
|
+
* @param target - absolute directory to list; the DSH home when omitted.
|
|
1506
|
+
*/
|
|
1507
|
+
async function listDirectories(target) {
|
|
1508
|
+
const home = dshHome()
|
|
1509
|
+
const start = target === undefined || target === null || target === '' ? home : target
|
|
1510
|
+
if (typeof start !== 'string') throw new HttpError(400, 'invalid', 'path must be a string')
|
|
1511
|
+
if (!path.isAbsolute(start)) throw new HttpError(400, 'invalid', 'path must be absolute')
|
|
1512
|
+
|
|
1513
|
+
let real
|
|
1514
|
+
try {
|
|
1515
|
+
real = await fsp.realpath(start)
|
|
1516
|
+
} catch {
|
|
1517
|
+
throw new HttpError(400, 'invalid', `目录不存在:${start}`)
|
|
1518
|
+
}
|
|
1519
|
+
let entries
|
|
1520
|
+
try {
|
|
1521
|
+
entries = await fsp.readdir(real, { withFileTypes: true })
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
throw new HttpError(400, 'invalid', `无法读取目录:${String((error && error.message) || error)}`)
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const directories = []
|
|
1527
|
+
for (const entry of entries) {
|
|
1528
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
1529
|
+
const full = path.join(real, entry.name)
|
|
1530
|
+
try {
|
|
1531
|
+
// `stat` follows a link, so a shortcut to a folder is still offered.
|
|
1532
|
+
const info = await fsp.stat(full)
|
|
1533
|
+
if (!info.isDirectory()) continue
|
|
1534
|
+
} catch {
|
|
1535
|
+
continue
|
|
1536
|
+
}
|
|
1537
|
+
directories.push({ name: entry.name, path: full })
|
|
1538
|
+
}
|
|
1539
|
+
directories.sort((a, b) => a.name.localeCompare(b.name))
|
|
1540
|
+
|
|
1541
|
+
const parent = real === path.parse(real).root ? null : path.dirname(real)
|
|
1542
|
+
return {
|
|
1543
|
+
path: real,
|
|
1544
|
+
parent,
|
|
1545
|
+
entries: directories.slice(0, MAX_BROWSE_ENTRIES),
|
|
1546
|
+
truncated: directories.length > MAX_BROWSE_ENTRIES,
|
|
1547
|
+
// At a drive root, offer the other drives so Windows users are not stranded.
|
|
1548
|
+
roots: parent === null ? await listDriveRoots() : [],
|
|
1549
|
+
defaultRoot: home,
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Run one PowerShell invocation and resolve its trimmed stdout.
|
|
1555
|
+
*
|
|
1556
|
+
* The script is passed as a file, never on stdin: `-Command -` reads stdin with
|
|
1557
|
+
* the console code page, and a non-ASCII byte in the script (the Chinese dialog
|
|
1558
|
+
* description, for one) silently killed the whole run instead of erroring.
|
|
1559
|
+
*/
|
|
1560
|
+
function runPowershell(args, timeoutMs) {
|
|
1561
|
+
return new Promise((resolve, reject) => {
|
|
1562
|
+
const child = spawn('powershell.exe', args, {
|
|
1563
|
+
windowsHide: true,
|
|
1564
|
+
})
|
|
1565
|
+
let out = ''
|
|
1566
|
+
let err = ''
|
|
1567
|
+
let settled = false
|
|
1568
|
+
const finish = (fn, value) => {
|
|
1569
|
+
if (settled) return
|
|
1570
|
+
settled = true
|
|
1571
|
+
clearTimeout(timer)
|
|
1572
|
+
fn(value)
|
|
1573
|
+
}
|
|
1574
|
+
const timer = setTimeout(() => {
|
|
1575
|
+
try { child.kill() } catch { /* already gone */ }
|
|
1576
|
+
finish(reject, new HttpError(504, 'timeout', '等待系统对话框超时'))
|
|
1577
|
+
}, timeoutMs)
|
|
1578
|
+
|
|
1579
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString('utf8') })
|
|
1580
|
+
child.stderr.on('data', (chunk) => { err += chunk.toString('utf8') })
|
|
1581
|
+
child.on('error', (error) => {
|
|
1582
|
+
finish(reject, new HttpError(500, 'spawn', `无法启动系统对话框:${String((error && error.message) || error)}`))
|
|
1583
|
+
})
|
|
1584
|
+
child.on('close', (code) => {
|
|
1585
|
+
if (code !== 0) {
|
|
1586
|
+
finish(reject, new HttpError(500, 'shell', `系统对话框异常退出(${code}):${err.trim().slice(0, 300)}`))
|
|
1587
|
+
return
|
|
1588
|
+
}
|
|
1589
|
+
finish(resolve, out.trim())
|
|
1590
|
+
})
|
|
1591
|
+
})
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Open a directory in the operating system's file manager. The child is detached
|
|
1596
|
+
* and unreferenced so the HTTP response returns at once and the window lives on
|
|
1597
|
+
* its own; `explorer.exe` reports a non-zero exit even when it succeeds, so its
|
|
1598
|
+
* status is deliberately ignored.
|
|
1599
|
+
*/
|
|
1600
|
+
function revealDirectory(directory) {
|
|
1601
|
+
const opener = process.platform === 'win32'
|
|
1602
|
+
? { program: 'explorer.exe', args: [directory] }
|
|
1603
|
+
: process.platform === 'darwin'
|
|
1604
|
+
? { program: 'open', args: [directory] }
|
|
1605
|
+
: { program: 'xdg-open', args: [directory] }
|
|
1606
|
+
const child = spawn(opener.program, opener.args, { detached: true, stdio: 'ignore', windowsHide: false })
|
|
1607
|
+
child.unref()
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* The slow path: the same dialog through PowerShell. Windows PowerShell 5.1
|
|
1612
|
+
* reads a script file as UTF-8 only when it carries a BOM, and without one the
|
|
1613
|
+
* Chinese labels corrupt the run into a silent exit.
|
|
1614
|
+
*/
|
|
1615
|
+
async function pickFolderWithPowershell() {
|
|
1616
|
+
const scriptPath = path.join(os.tmpdir(), `dsh-delete-session-pick-${process.pid}-${Date.now()}.ps1`)
|
|
1617
|
+
const bytes = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(PICK_SCRIPT, 'utf8')])
|
|
1618
|
+
await fsp.writeFile(scriptPath, bytes)
|
|
1619
|
+
try {
|
|
1620
|
+
return await runPowershell(
|
|
1621
|
+
['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
1622
|
+
PICK_TIMEOUT_MS,
|
|
1623
|
+
)
|
|
1624
|
+
} finally {
|
|
1625
|
+
try { await fsp.rm(scriptPath, { force: true }) } catch { /* best effort */ }
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Raise the real Windows folder picker and return what the user chose. The
|
|
1631
|
+
* browser cannot hand a page a filesystem path, but this process can ask the OS.
|
|
1632
|
+
* @returns `{ cancelled, path }`.
|
|
1633
|
+
*/
|
|
1634
|
+
async function pickFolderNative() {
|
|
1635
|
+
if (process.platform !== 'win32') {
|
|
1636
|
+
throw new HttpError(501, 'unsupported', '系统文件夹对话框只支持 Windows,请改用浏览…')
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// The compiled picker answers in ~120 ms; the PowerShell fallback costs a
|
|
1640
|
+
// second before the dialog even appears, so it is only the second choice.
|
|
1641
|
+
let picked = ''
|
|
1642
|
+
const exe = await ensurePickerExe()
|
|
1643
|
+
if (exe !== null) {
|
|
1644
|
+
const run = await runExecutable(exe, ['选择隔离夹保存位置', '选择此文件夹'], PICK_TIMEOUT_MS)
|
|
1645
|
+
// Exit 1 is a cancelled dialog; anything else means it never opened.
|
|
1646
|
+
if (run.code !== 0 && run.code !== 1) {
|
|
1647
|
+
throw new HttpError(500, 'shell', `系统对话框异常退出(${run.code})`)
|
|
1648
|
+
}
|
|
1649
|
+
picked = run.code === 0 ? run.stdout : ''
|
|
1650
|
+
} else {
|
|
1651
|
+
picked = await pickFolderWithPowershell()
|
|
1652
|
+
}
|
|
1653
|
+
if (picked === '') return { cancelled: true, path: null }
|
|
1654
|
+
|
|
1655
|
+
const resolved = path.resolve(picked)
|
|
1656
|
+
let info
|
|
1657
|
+
try {
|
|
1658
|
+
info = await fsp.stat(resolved)
|
|
1659
|
+
} catch {
|
|
1660
|
+
throw new HttpError(400, 'invalid', '选中的目录已不存在')
|
|
1661
|
+
}
|
|
1662
|
+
if (!info.isDirectory()) throw new HttpError(400, 'invalid', '选中的不是目录')
|
|
1663
|
+
return { cancelled: false, path: resolved }
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// --- http --------------------------------------------------------------------
|
|
1667
|
+
|
|
1668
|
+
function isLoopbackAddress(address) {
|
|
1669
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
1670
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function isLocalHostHeader(host) {
|
|
1674
|
+
if (typeof host !== 'string' || host.length === 0) return false
|
|
1675
|
+
const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
|
|
1676
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1'
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function sendJson(res, status, body) {
|
|
1680
|
+
const payload = JSON.stringify(body)
|
|
1681
|
+
res.writeHead(status, {
|
|
1682
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1683
|
+
'content-length': Buffer.byteLength(payload),
|
|
1684
|
+
})
|
|
1685
|
+
res.end(payload)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function readBody(req) {
|
|
1689
|
+
return new Promise((resolve, reject) => {
|
|
1690
|
+
let data = ''
|
|
1691
|
+
req.on('data', (chunk) => {
|
|
1692
|
+
data += chunk
|
|
1693
|
+
if (data.length > 1e6) req.destroy()
|
|
1694
|
+
})
|
|
1695
|
+
req.on('end', () => resolve(data))
|
|
1696
|
+
req.on('error', reject)
|
|
1697
|
+
req.on('aborted', () => reject(new Error('aborted')))
|
|
1698
|
+
})
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Destructive local endpoint: loopback socket, loopback Host header, and a
|
|
1702
|
+
// same-origin check when the browser sends Origin. Tunneled or LAN clients
|
|
1703
|
+
// get 403 before any session data is touched.
|
|
1704
|
+
function guard(req, res) {
|
|
1705
|
+
if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
|
|
1706
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
|
|
1707
|
+
return false
|
|
1708
|
+
}
|
|
1709
|
+
const host = req.headers.host
|
|
1710
|
+
if (!isLocalHostHeader(host)) {
|
|
1711
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
|
|
1712
|
+
return false
|
|
1713
|
+
}
|
|
1714
|
+
const origin = req.headers.origin
|
|
1715
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
1716
|
+
let originHost = null
|
|
1717
|
+
try {
|
|
1718
|
+
originHost = new URL(origin).host
|
|
1719
|
+
} catch {
|
|
1720
|
+
originHost = null
|
|
1721
|
+
}
|
|
1722
|
+
if (originHost !== host) {
|
|
1723
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
|
|
1724
|
+
return false
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return true
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// --- plugin ------------------------------------------------------------------
|
|
1731
|
+
|
|
1732
|
+
export function apply(ctx) {
|
|
1733
|
+
warmCompiledHelpers()
|
|
1734
|
+
|
|
1735
|
+
const registerRoutes = (webServer, fiber) => {
|
|
1736
|
+
// Read-only scan: the dialog shows what a delete would move, before it does.
|
|
1737
|
+
fiber.effect(() => webServer.register({
|
|
1738
|
+
kind: 'exact',
|
|
1739
|
+
path: `${ROUTE_PREFIX}/artifacts`,
|
|
1740
|
+
handler: async (req, res) => {
|
|
1741
|
+
if (!guard(req, res)) return
|
|
1742
|
+
if (req.method !== 'POST') {
|
|
1743
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1744
|
+
return
|
|
1745
|
+
}
|
|
1746
|
+
let body = {}
|
|
1747
|
+
try {
|
|
1748
|
+
const raw = await readBody(req)
|
|
1749
|
+
if (raw) body = JSON.parse(raw)
|
|
1750
|
+
} catch {
|
|
1751
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
1755
|
+
if (!sessionId) {
|
|
1756
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
|
|
1757
|
+
return
|
|
1758
|
+
}
|
|
1759
|
+
try {
|
|
1760
|
+
if (!SESSION_ID_RE.test(sessionId)) throw new HttpError(400, 'invalid', 'invalid session id')
|
|
1761
|
+
const plan = await planArtifacts(ctx, sessionId, undefined, { skipGit: body.skipGit === true })
|
|
1762
|
+
// A bad save path is reported alongside the plan rather than replacing
|
|
1763
|
+
// it: the dialog can still show the files while saying where they cannot go.
|
|
1764
|
+
const rootInfo = await resolveQuarantineRoot(body.root, plan.cwd)
|
|
1765
|
+
sendJson(res, 200, {
|
|
1766
|
+
ok: true,
|
|
1767
|
+
sessionId,
|
|
1768
|
+
...plan,
|
|
1769
|
+
defaultRoot: dshHome(),
|
|
1770
|
+
quarantineRoot: rootInfo.error === undefined ? rootInfo.root : null,
|
|
1771
|
+
rootError: rootInfo.error === undefined ? null : rootInfo.error,
|
|
1772
|
+
})
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
1775
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
1776
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
1777
|
+
}
|
|
1778
|
+
},
|
|
1779
|
+
}))
|
|
1780
|
+
|
|
1781
|
+
// The real Windows folder dialog, raised on the user's behalf. Browsers cannot
|
|
1782
|
+
// hand a page a filesystem path, but this process is local, so it can.
|
|
1783
|
+
fiber.effect(() => webServer.register({
|
|
1784
|
+
kind: 'exact',
|
|
1785
|
+
path: `${ROUTE_PREFIX}/pick`,
|
|
1786
|
+
handler: async (req, res) => {
|
|
1787
|
+
if (!guard(req, res)) return
|
|
1788
|
+
if (req.method !== 'POST') {
|
|
1789
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1790
|
+
return
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
const picked = await pickFolderNative()
|
|
1794
|
+
sendJson(res, 200, { ok: true, ...picked })
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
1797
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
1798
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
}))
|
|
1802
|
+
|
|
1803
|
+
// Open a folder in the OS file manager. The path is validated as an existing
|
|
1804
|
+
// directory first: this hands a string to Explorer, so it must never be able
|
|
1805
|
+
// to carry anything but a directory.
|
|
1806
|
+
fiber.effect(() => webServer.register({
|
|
1807
|
+
kind: 'exact',
|
|
1808
|
+
path: `${ROUTE_PREFIX}/reveal`,
|
|
1809
|
+
handler: async (req, res) => {
|
|
1810
|
+
if (!guard(req, res)) return
|
|
1811
|
+
if (req.method !== 'POST') {
|
|
1812
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1813
|
+
return
|
|
1814
|
+
}
|
|
1815
|
+
let body = {}
|
|
1816
|
+
try {
|
|
1817
|
+
const raw = await readBody(req)
|
|
1818
|
+
if (raw) body = JSON.parse(raw)
|
|
1819
|
+
} catch {
|
|
1820
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
1821
|
+
return
|
|
1822
|
+
}
|
|
1823
|
+
try {
|
|
1824
|
+
const target = typeof body.path === 'string' ? body.path.trim() : ''
|
|
1825
|
+
if (target === '') throw new HttpError(400, 'invalid', 'path required')
|
|
1826
|
+
const resolved = path.resolve(target)
|
|
1827
|
+
let info
|
|
1828
|
+
try {
|
|
1829
|
+
info = await fsp.stat(resolved)
|
|
1830
|
+
} catch {
|
|
1831
|
+
throw new HttpError(400, 'invalid', '目录不存在')
|
|
1832
|
+
}
|
|
1833
|
+
if (!info.isDirectory()) throw new HttpError(400, 'invalid', '不是目录')
|
|
1834
|
+
revealDirectory(resolved)
|
|
1835
|
+
sendJson(res, 200, { ok: true, path: resolved })
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
1838
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
1839
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
1840
|
+
}
|
|
1841
|
+
},
|
|
1842
|
+
}))
|
|
1843
|
+
|
|
1844
|
+
// Folder picker for the quarantine save path: directories only, never file
|
|
1845
|
+
// contents, and the same loopback guard as every other route here.
|
|
1846
|
+
fiber.effect(() => webServer.register({
|
|
1847
|
+
kind: 'exact',
|
|
1848
|
+
path: `${ROUTE_PREFIX}/browse`,
|
|
1849
|
+
handler: async (req, res) => {
|
|
1850
|
+
if (!guard(req, res)) return
|
|
1851
|
+
if (req.method !== 'POST') {
|
|
1852
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1853
|
+
return
|
|
1854
|
+
}
|
|
1855
|
+
let body = {}
|
|
1856
|
+
try {
|
|
1857
|
+
const raw = await readBody(req)
|
|
1858
|
+
if (raw) body = JSON.parse(raw)
|
|
1859
|
+
} catch {
|
|
1860
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
1861
|
+
return
|
|
1862
|
+
}
|
|
1863
|
+
try {
|
|
1864
|
+
const listing = await listDirectories(body.path)
|
|
1865
|
+
sendJson(res, 200, { ok: true, ...listing })
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
1868
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
1869
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
}))
|
|
1873
|
+
|
|
1874
|
+
fiber.effect(() => webServer.register({
|
|
1875
|
+
kind: 'exact',
|
|
1876
|
+
path: `${ROUTE_PREFIX}/delete`,
|
|
1877
|
+
handler: async (req, res) => {
|
|
1878
|
+
if (!guard(req, res)) return
|
|
1879
|
+
if (req.method !== 'POST') {
|
|
1880
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
let body = {}
|
|
1884
|
+
try {
|
|
1885
|
+
const raw = await readBody(req)
|
|
1886
|
+
if (raw) body = JSON.parse(raw)
|
|
1887
|
+
} catch {
|
|
1888
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
1889
|
+
return
|
|
1890
|
+
}
|
|
1891
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
1892
|
+
if (!sessionId) {
|
|
1893
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
|
|
1894
|
+
return
|
|
1895
|
+
}
|
|
1896
|
+
try {
|
|
1897
|
+
// Validate before touching anything: the quarantine runs first, so an
|
|
1898
|
+
// unchecked id would reach the filesystem ahead of deleteSession's own
|
|
1899
|
+
// guard. `/artifacts` already validates at the same point.
|
|
1900
|
+
if (!SESSION_ID_RE.test(sessionId)) throw new HttpError(400, 'invalid', 'invalid session id')
|
|
1901
|
+
// Quarantine first: the session log is still there to derive the plan
|
|
1902
|
+
// from, and a partial move leaves the conversation intact to retry.
|
|
1903
|
+
const wantsQuarantine = typeof body.quarantine === 'object' && body.quarantine !== null
|
|
1904
|
+
// The plan handed to the move must honour the same `skipGit` the dialog
|
|
1905
|
+
// listed, or a file the listing refused to show would move anyway.
|
|
1906
|
+
const plan = wantsQuarantine
|
|
1907
|
+
? await planArtifacts(ctx, sessionId, undefined, { skipGit: body.quarantine.skipGit === true })
|
|
1908
|
+
: null
|
|
1909
|
+
const quarantine = await runQuarantine(ctx, sessionId, body.quarantine, plan)
|
|
1910
|
+
const result = await deleteSession(ctx, sessionId, { recycle: body.recycle !== false })
|
|
1911
|
+
sendJson(res, 200, quarantine === null ? { ok: true, ...result } : { ok: true, ...result, quarantine })
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
1914
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
1915
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
}))
|
|
1919
|
+
|
|
1920
|
+
fiber.effect(() => webServer.register({
|
|
1921
|
+
kind: 'exact',
|
|
1922
|
+
path: `${ROUTE_PREFIX}/delete-many`,
|
|
1923
|
+
handler: async (req, res) => {
|
|
1924
|
+
if (!guard(req, res)) return
|
|
1925
|
+
if (req.method !== 'POST') {
|
|
1926
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1927
|
+
return
|
|
1928
|
+
}
|
|
1929
|
+
let body = {}
|
|
1930
|
+
try {
|
|
1931
|
+
const raw = await readBody(req)
|
|
1932
|
+
if (raw) body = JSON.parse(raw)
|
|
1933
|
+
} catch {
|
|
1934
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
1935
|
+
return
|
|
1936
|
+
}
|
|
1937
|
+
const sessionIds = Array.isArray(body.sessionIds)
|
|
1938
|
+
? [...new Set(body.sessionIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
|
|
1939
|
+
: []
|
|
1940
|
+
if (sessionIds.length === 0) {
|
|
1941
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionIds required' })
|
|
1942
|
+
return
|
|
1943
|
+
}
|
|
1944
|
+
if (sessionIds.length > MAX_BATCH) {
|
|
1945
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: `too many sessions (max ${MAX_BATCH})` })
|
|
1946
|
+
return
|
|
1947
|
+
}
|
|
1948
|
+
// Bounded-concurrency batch: every item runs the same guarded pipeline
|
|
1949
|
+
// (lease retry, directory sweep, workspace accounting); independent
|
|
1950
|
+
// sessions run in parallel and the workspace accounting mutation is
|
|
1951
|
+
// serialized inside deleteSession, so a batch can never interleave two
|
|
1952
|
+
// deletions of the same storage.
|
|
1953
|
+
const results = await runWithConcurrency(sessionIds, BATCH_CONCURRENCY, async (sessionId) => {
|
|
1954
|
+
try {
|
|
1955
|
+
const result = await deleteSession(ctx, sessionId, { recycle: body.recycle !== false })
|
|
1956
|
+
return { sessionId, ok: true, removed: result.removed }
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
return {
|
|
1959
|
+
sessionId,
|
|
1960
|
+
ok: false,
|
|
1961
|
+
code: error instanceof HttpError ? error.code : 'internal',
|
|
1962
|
+
error: String((error && error.message) || error),
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
})
|
|
1966
|
+
const deleted = results.filter((item) => item.ok).length
|
|
1967
|
+
sendJson(res, 200, { ok: true, results, deleted, failed: results.length - deleted })
|
|
1968
|
+
},
|
|
1969
|
+
}))
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const webServer = ctx.get('webServer')
|
|
1973
|
+
if (webServer) {
|
|
1974
|
+
registerRoutes(webServer, ctx)
|
|
1975
|
+
} else {
|
|
1976
|
+
// A terminal-only profile never provides a web surface; register once one
|
|
1977
|
+
// appears instead of waiting on a service that will never arrive.
|
|
1978
|
+
ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// Re-exported for the offline test suite, which drives the artifact plan and the
|
|
1983
|
+
// quarantine against a scratch tree and a stubbed persistence service. Nothing
|
|
1984
|
+
// else in the host reads this face.
|
|
1985
|
+
export const internals = {
|
|
1986
|
+
ensurePickerExe,
|
|
1987
|
+
claimQuarantineDirectory,
|
|
1988
|
+
classifyPaths,
|
|
1989
|
+
collectSessionPaths,
|
|
1990
|
+
findSessionCaches,
|
|
1991
|
+
freeDestination,
|
|
1992
|
+
identityKey,
|
|
1993
|
+
insidePath,
|
|
1994
|
+
listDirectories,
|
|
1995
|
+
moveToSessionTrash,
|
|
1996
|
+
planArtifacts,
|
|
1997
|
+
quarantineFiles,
|
|
1998
|
+
resolveQuarantineRoot,
|
|
1999
|
+
removeForever,
|
|
2000
|
+
retirePaths,
|
|
2001
|
+
retireTargets,
|
|
2002
|
+
runQuarantine,
|
|
2003
|
+
sendToRecycleBin,
|
|
2004
|
+
toolTarget,
|
|
2005
|
+
trashCommandFor,
|
|
2006
|
+
warmCompiledHelpers,
|
|
2007
|
+
}
|