dsh-conversation-recycle-bin 0.3.12 → 0.3.13
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/CHANGELOG.md +454 -437
- package/README.md +8 -8
- package/client.js +2237 -2221
- package/lib/core/cascade.js +682 -658
- package/lib/host/api.js +1508 -1435
- package/lib/host/catalog.js +9 -2
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
package/lib/host/api.js
CHANGED
|
@@ -1,1435 +1,1508 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The plugin's same-origin HTTP API.
|
|
3
|
-
*
|
|
4
|
-
* Transport: the Host Web server route registered by `lib/index.js`. The route
|
|
5
|
-
* is DELIBERATELY not exposed as an agent tool: deleting conversations is a
|
|
6
|
-
* user-only authority (see README "权限声明"). The Client never receives raw
|
|
7
|
-
* filesystem handles — it only sends session ids and receives counts/status.
|
|
8
|
-
*
|
|
9
|
-
* Request contract (all responses JSON, `cache-control: no-store`):
|
|
10
|
-
* GET /state capabilities, adapters, limits, rate budget
|
|
11
|
-
* GET /sessions sessions visible to the batch picker
|
|
12
|
-
* POST /preview counts + policy + a single-use confirm token
|
|
13
|
-
* POST /delete execute (memory → messages → session per id)
|
|
14
|
-
* GET /trash recycle-bin entries + incomplete entries
|
|
15
|
-
* POST /trash/restore restore one entry
|
|
16
|
-
* POST /trash/purge permanently delete one entry
|
|
17
|
-
* POST /trash/discard remove an entry that never committed
|
|
18
|
-
* GET /audit?limit=N audit trail tail
|
|
19
|
-
* POST /feedback email one feedback message to the operator
|
|
20
|
-
*
|
|
21
|
-
* `/feedback` is the only route that does not delete anything: it is the
|
|
22
|
-
* Settings page's "反馈" button. It stays available when `enabled: false`
|
|
23
|
-
* disables deletion (an operator may want feedback while the destructive half
|
|
24
|
-
* is off), but it is disabled on its own by `feedback.transport`/the absence of
|
|
25
|
-
* a configured mail transport.
|
|
26
|
-
*
|
|
27
|
-
* Transport layers, in order: this route's own loopback/origin fence, then DSH's
|
|
28
|
-
* `connection` fence (`requestRejection`) when the service exists, then the
|
|
29
|
-
* `connection/request` waterfall — which is what makes the desktop app-update
|
|
30
|
-
* lock (503 + drain) cover deletions instead of killing them mid-transaction.
|
|
31
|
-
* Mutations additionally take a cross-process lock on `<dataDir>/mutation.lock`
|
|
32
|
-
* so two Hosts sharing one DSH_HOME cannot write the same manifest at once.
|
|
33
|
-
*
|
|
34
|
-
* Confirmation is a two-part server-side protocol, not a Client-side courtesy:
|
|
35
|
-
* `/preview` issues a single-use token bound to (session ids, includeMemory,
|
|
36
|
-
* mode); `/delete` must present that token. An additional typed phrase is
|
|
37
|
-
* required only for a soft batch (double confirmation) and for purging a
|
|
38
|
-
* recycle-bin entry (irreversible); permanent deletion itself is one click.
|
|
39
|
-
* A replayed, edited or expired plan is refused.
|
|
40
|
-
*/
|
|
41
|
-
import { randomBytes } from 'node:crypto'
|
|
42
|
-
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
43
|
-
import { join } from 'node:path'
|
|
44
|
-
import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
|
|
45
|
-
import { redactDetails } from '../core/redact.js'
|
|
46
|
-
import { createAuditLog } from '../core/audit.js'
|
|
47
|
-
import { DEFAULT_MAX_PER_RUN, DAY_MS, daysUntilExpiry, selectExpired } from '../core/retention.js'
|
|
48
|
-
import { createTrashStore, isTrashId } from '../core/trash.js'
|
|
49
|
-
import { createMemoryService } from '../core/memory/registry.js'
|
|
50
|
-
import { performSessionDelete, performPurge, performRestore, performDiscard, entryCapabilities } from '../core/cascade.js'
|
|
51
|
-
import { evaluateSessionPolicy, isPathInside } from '../core/policy.js'
|
|
52
|
-
import { createSlidingWindowLimiter } from '../core/rate-limit.js'
|
|
53
|
-
import { createFileLock } from '../core/file-lock.js'
|
|
54
|
-
import { createCatalogOps, createSessionLogOps } from './catalog.js'
|
|
55
|
-
import { createLifecycleOps } from './lifecycle.js'
|
|
56
|
-
import { createSessionGateway } from './gateway.js'
|
|
57
|
-
import { createMemoryAdapters } from './memory.js'
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
export const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return '
|
|
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
|
-
let
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
return
|
|
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
|
-
let
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
settled
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
req.
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
* @param {
|
|
350
|
-
* @param {
|
|
351
|
-
* @param {
|
|
352
|
-
* @param {
|
|
353
|
-
* @param {
|
|
354
|
-
* @param {
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
config,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
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
|
-
const
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
/** @type {{
|
|
442
|
-
|
|
443
|
-
let
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
function
|
|
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
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
(
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*/
|
|
566
|
-
function
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
expiresAt
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
:
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (typeof
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
})
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
})
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
})
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's same-origin HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Transport: the Host Web server route registered by `lib/index.js`. The route
|
|
5
|
+
* is DELIBERATELY not exposed as an agent tool: deleting conversations is a
|
|
6
|
+
* user-only authority (see README "权限声明"). The Client never receives raw
|
|
7
|
+
* filesystem handles — it only sends session ids and receives counts/status.
|
|
8
|
+
*
|
|
9
|
+
* Request contract (all responses JSON, `cache-control: no-store`):
|
|
10
|
+
* GET /state capabilities, adapters, limits, rate budget
|
|
11
|
+
* GET /sessions sessions visible to the batch picker
|
|
12
|
+
* POST /preview counts + policy + a single-use confirm token
|
|
13
|
+
* POST /delete execute (memory → messages → session per id)
|
|
14
|
+
* GET /trash recycle-bin entries + incomplete entries
|
|
15
|
+
* POST /trash/restore restore one entry
|
|
16
|
+
* POST /trash/purge permanently delete one entry
|
|
17
|
+
* POST /trash/discard remove an entry that never committed
|
|
18
|
+
* GET /audit?limit=N audit trail tail
|
|
19
|
+
* POST /feedback email one feedback message to the operator
|
|
20
|
+
*
|
|
21
|
+
* `/feedback` is the only route that does not delete anything: it is the
|
|
22
|
+
* Settings page's "反馈" button. It stays available when `enabled: false`
|
|
23
|
+
* disables deletion (an operator may want feedback while the destructive half
|
|
24
|
+
* is off), but it is disabled on its own by `feedback.transport`/the absence of
|
|
25
|
+
* a configured mail transport.
|
|
26
|
+
*
|
|
27
|
+
* Transport layers, in order: this route's own loopback/origin fence, then DSH's
|
|
28
|
+
* `connection` fence (`requestRejection`) when the service exists, then the
|
|
29
|
+
* `connection/request` waterfall — which is what makes the desktop app-update
|
|
30
|
+
* lock (503 + drain) cover deletions instead of killing them mid-transaction.
|
|
31
|
+
* Mutations additionally take a cross-process lock on `<dataDir>/mutation.lock`
|
|
32
|
+
* so two Hosts sharing one DSH_HOME cannot write the same manifest at once.
|
|
33
|
+
*
|
|
34
|
+
* Confirmation is a two-part server-side protocol, not a Client-side courtesy:
|
|
35
|
+
* `/preview` issues a single-use token bound to (session ids, includeMemory,
|
|
36
|
+
* mode); `/delete` must present that token. An additional typed phrase is
|
|
37
|
+
* required only for a soft batch (double confirmation) and for purging a
|
|
38
|
+
* recycle-bin entry (irreversible); permanent deletion itself is one click.
|
|
39
|
+
* A replayed, edited or expired plan is refused.
|
|
40
|
+
*/
|
|
41
|
+
import { randomBytes } from 'node:crypto'
|
|
42
|
+
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
|
|
45
|
+
import { redactDetails } from '../core/redact.js'
|
|
46
|
+
import { createAuditLog } from '../core/audit.js'
|
|
47
|
+
import { DEFAULT_MAX_PER_RUN, DAY_MS, daysUntilExpiry, selectExpired } from '../core/retention.js'
|
|
48
|
+
import { createTrashStore, isTrashId } from '../core/trash.js'
|
|
49
|
+
import { createMemoryService } from '../core/memory/registry.js'
|
|
50
|
+
import { performSessionDelete, performPurge, performRestore, performDiscard, entryCapabilities } from '../core/cascade.js'
|
|
51
|
+
import { evaluateSessionPolicy, isPathInside } from '../core/policy.js'
|
|
52
|
+
import { createSlidingWindowLimiter } from '../core/rate-limit.js'
|
|
53
|
+
import { createFileLock } from '../core/file-lock.js'
|
|
54
|
+
import { createCatalogOps, createSessionLogOps } from './catalog.js'
|
|
55
|
+
import { createLifecycleOps } from './lifecycle.js'
|
|
56
|
+
import { createSessionGateway } from './gateway.js'
|
|
57
|
+
import { createMemoryAdapters } from './memory.js'
|
|
58
|
+
import { encodeSessionSegment, projectKey, readSessionLogCwd } from './paths.js'
|
|
59
|
+
import { createFeedbackService } from './feedback.js'
|
|
60
|
+
import { PLUGIN_VERSION } from '../version.js'
|
|
61
|
+
import { SESSION_ID_PATTERN } from '../core/ids.js'
|
|
62
|
+
|
|
63
|
+
export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
|
|
64
|
+
export const REQUIRED_HEADER = 'x-conversation-recycle-bin'
|
|
65
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
66
|
+
/**
|
|
67
|
+
* Lock wait budget for the single-entry trash mutations.
|
|
68
|
+
*
|
|
69
|
+
* `serialize()` chains every mutation in-process and then takes the
|
|
70
|
+
* cross-process file lock, so restore / purge / discard can sit behind a whole
|
|
71
|
+
* batch delete. They used to fall back to the generic 30s lock timeout, which
|
|
72
|
+
* is shorter than one batch's legitimate worst case: the Host answered
|
|
73
|
+
* E_LOCK_BUSY for a request that only needed to wait. The Client's own budget
|
|
74
|
+
* (TRASH_MUTATION_TIMEOUT_MS = 195s) is deliberately slightly larger, so this
|
|
75
|
+
* budget — not the browser's AbortController — decides the outcome.
|
|
76
|
+
*/
|
|
77
|
+
export const TRASH_LOCK_BUDGET_MS = 180_000
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Slack added on top of an operation's lock budget before a queued request is
|
|
81
|
+
* rejected. The Host publishes the resulting number (`queueTimeoutMs`) so the
|
|
82
|
+
* Client waits for the Host's answer instead of inventing a shorter deadline of
|
|
83
|
+
* its own — a client that gave up 60 s early used to report a timeout for an
|
|
84
|
+
* operation the Host then completed.
|
|
85
|
+
*/
|
|
86
|
+
export const QUEUE_GRACE_MS = 60_000
|
|
87
|
+
|
|
88
|
+
/** The wait a caller should allow for `lockBudgetMs` plus queueing behind it. */
|
|
89
|
+
export function queueBudgetFor(lockBudgetMs) {
|
|
90
|
+
const budget = Number(lockBudgetMs)
|
|
91
|
+
return (Number.isFinite(budget) && budget > 0 ? budget : 30_000) + QUEUE_GRACE_MS
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isLoopbackHostname(hostname) {
|
|
95
|
+
const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
96
|
+
return value === 'localhost' || value === '::1' || value === '0:0:0:0:0:0:0:1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hostnameOf(hostHeader) {
|
|
100
|
+
const value = String(hostHeader ?? '').trim().toLowerCase()
|
|
101
|
+
if (value.startsWith('[')) {
|
|
102
|
+
const end = value.indexOf(']')
|
|
103
|
+
return end === -1 ? value : value.slice(1, end)
|
|
104
|
+
}
|
|
105
|
+
const colon = value.lastIndexOf(':')
|
|
106
|
+
return colon > 0 && /^\d+$/.test(value.slice(colon + 1)) ? value.slice(0, colon) : value
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sameHostname(left, right) {
|
|
110
|
+
const normalize = (value) => {
|
|
111
|
+
const trimmed = String(value ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
112
|
+
if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(trimmed)) return trimmed.slice('::ffff:'.length)
|
|
113
|
+
if (trimmed === '0:0:0:0:0:0:0:1') return '::1'
|
|
114
|
+
return trimmed
|
|
115
|
+
}
|
|
116
|
+
return normalize(left) === normalize(right)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Port of a `Host`-style authority, or `''` when none is present. */
|
|
120
|
+
function portOf(hostHeader) {
|
|
121
|
+
const value = String(hostHeader ?? '').trim()
|
|
122
|
+
if (value.startsWith('[')) {
|
|
123
|
+
const end = value.indexOf(']')
|
|
124
|
+
if (end === -1) return ''
|
|
125
|
+
const rest = value.slice(end + 1)
|
|
126
|
+
return rest.startsWith(':') && /^\d+$/.test(rest.slice(1)) ? rest.slice(1) : ''
|
|
127
|
+
}
|
|
128
|
+
const colon = value.lastIndexOf(':')
|
|
129
|
+
return colon > 0 && /^\d+$/.test(value.slice(colon + 1)) ? value.slice(colon + 1) : ''
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a request authority matches the operator's `allowedHosts` list.
|
|
134
|
+
*
|
|
135
|
+
* A bare entry (`app.example`) admits any port on that host; an entry with a
|
|
136
|
+
* port admits only that port. Comparison is hostname-normalized (IPv6 brackets
|
|
137
|
+
* and `::ffff:` mapped addresses included) so `[::1]:8443` and `[::1]:8443`
|
|
138
|
+
* agree regardless of case.
|
|
139
|
+
*/
|
|
140
|
+
function authorityAllowed(hostHeader, allowedHosts) {
|
|
141
|
+
if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) return false
|
|
142
|
+
const hostname = hostnameOf(hostHeader)
|
|
143
|
+
if (hostname === '') return false
|
|
144
|
+
const port = portOf(hostHeader)
|
|
145
|
+
for (const entry of allowedHosts) {
|
|
146
|
+
if (typeof entry !== 'string') continue
|
|
147
|
+
if (!sameHostname(hostnameOf(entry), hostname)) continue
|
|
148
|
+
const entryPort = portOf(entry)
|
|
149
|
+
if (entryPort === '' || entryPort === port) return true
|
|
150
|
+
}
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The port a URL implies when it omits one. */
|
|
155
|
+
function defaultPortFor(protocol) {
|
|
156
|
+
if (protocol === 'https:' || protocol === 'wss:') return '443'
|
|
157
|
+
return '80'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Origin/Host verdict for one request.
|
|
162
|
+
*
|
|
163
|
+
* Hostname alone is not an origin: `http://localhost:9999` served by ANY other
|
|
164
|
+
* loopback process used to pass a Host-name comparison, so a second local web
|
|
165
|
+
* app could drive deletions. The full authority — hostname AND effective port —
|
|
166
|
+
* must match, with a missing port normalized against the Origin's own scheme
|
|
167
|
+
* (the `Host` header carries no scheme, so `https://host` and `Host: host`
|
|
168
|
+
* both mean 443).
|
|
169
|
+
* @returns {{ ok: true } | { ok: false, error: DeleteError }}
|
|
170
|
+
*/
|
|
171
|
+
export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopback, allowedHosts = [] }) {
|
|
172
|
+
const read = (name) => {
|
|
173
|
+
if (headers === null || typeof headers !== 'object') return ''
|
|
174
|
+
const value = headers[name]
|
|
175
|
+
return typeof value === 'string' ? value : ''
|
|
176
|
+
}
|
|
177
|
+
const requestHostname = hostnameOf(read('host'))
|
|
178
|
+
|
|
179
|
+
if (!allowNonLoopback && !isLoopbackHostname(requestHostname) && !authorityAllowed(read('host'), allowedHosts)) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: new DeleteError('E_FORBIDDEN_HOST', 'This route only accepts loopback requests unless the authority is listed in allowedHosts (or allowNonLoopback is enabled).', {
|
|
183
|
+
host: read('host') || null,
|
|
184
|
+
allowedHosts: Array.isArray(allowedHosts) ? allowedHosts : [],
|
|
185
|
+
}),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (read('sec-fetch-site').trim().toLowerCase() === 'cross-site') {
|
|
189
|
+
return { ok: false, error: new DeleteError('E_FORBIDDEN_ORIGIN', 'Cross-site requests are refused.', { reason: 'Sec-Fetch-Site: cross-site' }) }
|
|
190
|
+
}
|
|
191
|
+
if (requireSameOrigin) {
|
|
192
|
+
const origin = read('origin').trim()
|
|
193
|
+
if (origin !== '') {
|
|
194
|
+
let parsed = null
|
|
195
|
+
try {
|
|
196
|
+
parsed = new URL(origin)
|
|
197
|
+
} catch {
|
|
198
|
+
parsed = null
|
|
199
|
+
}
|
|
200
|
+
// The Electron shell serves the app from `dsh-app://app` and the Host's
|
|
201
|
+
// own fences compare against exactly this origin (verified in the shipped
|
|
202
|
+
// app: `out/lib/main.js` refuses `origin !== "dsh-app://app"`). No web
|
|
203
|
+
// origin can mint that scheme, so it is trusted as same-origin. On the
|
|
204
|
+
// real desktop route the shell's forwarder deletes the `Origin` header
|
|
205
|
+
// before the request reaches the Host, so this is defense-in-depth.
|
|
206
|
+
const trustedAppShell = parsed !== null && parsed.protocol === 'dsh-app:' && parsed.hostname === 'app'
|
|
207
|
+
if (trustedAppShell !== true) {
|
|
208
|
+
const requestPort = portOf(read('host'))
|
|
209
|
+
let hostnameMatches = false
|
|
210
|
+
let portMatches = false
|
|
211
|
+
if (parsed !== null) {
|
|
212
|
+
hostnameMatches = parsed.hostname !== '' && sameHostname(parsed.hostname, requestHostname)
|
|
213
|
+
// A missing request port falls back to the Origin's scheme default
|
|
214
|
+
// (80/443) instead of matching every Origin port.
|
|
215
|
+
const effectiveRequestPort = requestPort === '' ? defaultPortFor(parsed.protocol) : requestPort
|
|
216
|
+
const originPort = parsed.port === '' ? defaultPortFor(parsed.protocol) : parsed.port
|
|
217
|
+
portMatches = originPort !== '' && originPort === effectiveRequestPort
|
|
218
|
+
}
|
|
219
|
+
if (!hostnameMatches || !portMatches) {
|
|
220
|
+
return { ok: false, error: new DeleteError('E_FORBIDDEN_ORIGIN', 'The request origin does not match this Host.', { origin }) }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { ok: true }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function expectedPhrase({ count, mode, config }) {
|
|
229
|
+
// Permanent deletion must stay one click: the user asked for no typed
|
|
230
|
+
// confirmation. The server-side token is still required and still binds the
|
|
231
|
+
// exact plan, so a stale or edited plan is refused without ever making the
|
|
232
|
+
// user repeat a phrase.
|
|
233
|
+
if (mode === 'hard') return ''
|
|
234
|
+
if (count > 1 && config.ui.batchDoubleConfirm) return String(count)
|
|
235
|
+
return ''
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeSessionIds(body) {
|
|
239
|
+
const list = Array.isArray(body?.sessionIds)
|
|
240
|
+
? body.sessionIds
|
|
241
|
+
: typeof body?.sessionId === 'string'
|
|
242
|
+
? [body.sessionId]
|
|
243
|
+
: []
|
|
244
|
+
const ids = []
|
|
245
|
+
for (const raw of list) {
|
|
246
|
+
const id = typeof raw === 'string' ? raw.trim() : ''
|
|
247
|
+
if (!SESSION_ID_PATTERN.test(id)) {
|
|
248
|
+
throw new DeleteError('E_BAD_REQUEST', 'A session id is malformed.', { sessionId: String(raw).slice(0, 80) })
|
|
249
|
+
}
|
|
250
|
+
if (!ids.includes(id)) ids.push(id)
|
|
251
|
+
}
|
|
252
|
+
if (ids.length === 0) throw new DeleteError('E_BAD_REQUEST', 'At least one session id is required.', {})
|
|
253
|
+
return ids
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeMode(body, config) {
|
|
257
|
+
const mode = body?.mode === undefined ? config.defaultMode : body.mode
|
|
258
|
+
if (mode !== 'soft' && mode !== 'hard') {
|
|
259
|
+
throw new DeleteError('E_BAD_REQUEST', 'mode must be "soft" or "hard".', { mode: String(mode).slice(0, 20) })
|
|
260
|
+
}
|
|
261
|
+
return mode
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeBoolean(body, key) {
|
|
265
|
+
return body?.[key] === true
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read and parse a JSON body with a hard byte ceiling. */
|
|
269
|
+
function readJsonBody(req) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
let bytes = 0
|
|
272
|
+
let overflow = false
|
|
273
|
+
let settled = false
|
|
274
|
+
const chunks = []
|
|
275
|
+
// The promise MUST settle on every path: an unsettled `/delete` inside
|
|
276
|
+
// `serialize()` parks every later mutation behind it until the Host is
|
|
277
|
+
// restarted. `aborted`/`close` fires when the Client disconnects mid-body
|
|
278
|
+
// (its own timeout, or the tab/app closing) and `end` may never come.
|
|
279
|
+
const finish = (error, value) => {
|
|
280
|
+
if (settled) return
|
|
281
|
+
settled = true
|
|
282
|
+
if (error !== undefined) reject(error)
|
|
283
|
+
else resolve(value)
|
|
284
|
+
}
|
|
285
|
+
req.setEncoding('utf8')
|
|
286
|
+
req.on('data', (chunk) => {
|
|
287
|
+
if (settled) return
|
|
288
|
+
if (overflow) return
|
|
289
|
+
bytes += Buffer.byteLength(chunk, 'utf8')
|
|
290
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
291
|
+
overflow = true
|
|
292
|
+
chunks.length = 0
|
|
293
|
+
// Respond at once instead of draining the whole oversized body; the
|
|
294
|
+
// settled flag keeps the later `end` handler from double-settling.
|
|
295
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body exceeds the size limit.', { limit: MAX_BODY_BYTES }, { httpStatus: 413 }))
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
chunks.push(chunk)
|
|
299
|
+
})
|
|
300
|
+
req.on('error', (error) => {
|
|
301
|
+
finish(new DeleteError('E_BAD_REQUEST', 'The request stream failed.', {}, { cause: error }))
|
|
302
|
+
})
|
|
303
|
+
req.on('aborted', () => {
|
|
304
|
+
finish(new DeleteError('E_BAD_REQUEST', 'The request was aborted before its body arrived.', {}))
|
|
305
|
+
})
|
|
306
|
+
req.on('close', () => {
|
|
307
|
+
// `close` after a complete `end` is the normal path; without it the
|
|
308
|
+
// stream was cut short.
|
|
309
|
+
if (!settled) {
|
|
310
|
+
finish(new DeleteError('E_BAD_REQUEST', 'The request was closed before its body arrived.', {}))
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
req.on('end', () => {
|
|
314
|
+
if (overflow) {
|
|
315
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body exceeds the size limit.', { limit: MAX_BODY_BYTES }, { httpStatus: 413 }))
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const raw = chunks.join('').trim()
|
|
319
|
+
if (raw === '') {
|
|
320
|
+
finish(undefined, {})
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const parsed = JSON.parse(raw)
|
|
325
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
326
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body must be a JSON object.', {}))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
finish(undefined, parsed)
|
|
330
|
+
} catch (error) {
|
|
331
|
+
finish(new DeleteError('E_BAD_REQUEST', 'Request body is not valid JSON.', {}, { cause: error }))
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function sendJson(res, status, payload) {
|
|
338
|
+
if (res.writableEnded === true) return
|
|
339
|
+
const body = JSON.stringify(payload)
|
|
340
|
+
res.writeHead(status, {
|
|
341
|
+
'content-type': 'application/json; charset=utf-8',
|
|
342
|
+
'cache-control': 'no-store',
|
|
343
|
+
'content-length': Buffer.byteLength(body, 'utf8'),
|
|
344
|
+
})
|
|
345
|
+
res.end(body)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {object} input
|
|
350
|
+
* @param {import('@deepseek-ai/cordis').Context} input.ctx
|
|
351
|
+
* @param {ReturnType<import('../core/config.js').normalizeConfig>['value']} input.config
|
|
352
|
+
* @param {object} input.paths resolution from `host/paths.js`
|
|
353
|
+
* @param {() => number} [input.now]
|
|
354
|
+
* @param {Pick<Console, 'error'|'warn'|'info'>} [input.logger]
|
|
355
|
+
* @param {{ acquire: () => Promise<() => Promise<void>>, lockPath?: string }} [input.mutationLock]
|
|
356
|
+
* Cross-process lock serializing mutations. Injectable so a test can drive the
|
|
357
|
+
* E_LOCK_BUSY path with a short wait budget; production uses 30s because a
|
|
358
|
+
* deletion transaction legitimately takes a while and waiting beats failing.
|
|
359
|
+
*/
|
|
360
|
+
export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = console, mutationLock = null }) {
|
|
361
|
+
const gateway = createSessionGateway({ ctx, paths, now })
|
|
362
|
+
const memoryService = createMemoryService({
|
|
363
|
+
adapters: createMemoryAdapters({ config, dshHome: paths.dshHome, now }),
|
|
364
|
+
config,
|
|
365
|
+
now,
|
|
366
|
+
})
|
|
367
|
+
const trash = createTrashStore({ rootDir: paths.trashDir, now })
|
|
368
|
+
const audit = createAuditLog({
|
|
369
|
+
filePath: paths.auditFile,
|
|
370
|
+
maxBytes: config.auditMaxBytes,
|
|
371
|
+
retentionLines: config.auditRetentionLines,
|
|
372
|
+
now,
|
|
373
|
+
})
|
|
374
|
+
const limiter = createSlidingWindowLimiter({ limit: config.maxDeletesPerMinute, windowMs: 60_000, now })
|
|
375
|
+
// Feedback is its own subsystem: it never touches the trash, the audit trail
|
|
376
|
+
// or the mutation lock, so a mail failure cannot leave a deletion half done.
|
|
377
|
+
const feedbackService = createFeedbackService({ config, now, logger })
|
|
378
|
+
// Two Hosts can share one DSH_HOME (the desktop app and `dsh web`), and both
|
|
379
|
+
// write the same trash manifests, bundles and audit trail. The in-process
|
|
380
|
+
// `serialize` chain below cannot see the other one, so every mutation also
|
|
381
|
+
// takes a cross-process advisory lock on `<dataDir>/mutation.lock`. A busy
|
|
382
|
+
// lock surfaces as E_LOCK_BUSY (409) instead of a lost manifest update.
|
|
383
|
+
const mutationLockInstance = mutationLock ?? createFileLock({
|
|
384
|
+
lockPath: join(paths.dataDir, 'mutation.lock'),
|
|
385
|
+
timeoutMs: 30_000,
|
|
386
|
+
staleMs: 120_000,
|
|
387
|
+
// A lock file we cannot delete used to vanish into a silent `.catch`: the
|
|
388
|
+
// next acquire then found it stale, failed to break it, and spun forever.
|
|
389
|
+
warn: (message) => logger.warn?.(`[conversation-recycle-bin] ${message}`),
|
|
390
|
+
})
|
|
391
|
+
// Which UI asked: the desktop Electron shell and `dsh web` share this code,
|
|
392
|
+
// and the audit trail used to record both as `web-ui`.
|
|
393
|
+
const auditSource = process.versions?.electron !== undefined ? 'desktop-ui' : 'web-ui'
|
|
394
|
+
const ops = {
|
|
395
|
+
memory: memoryService,
|
|
396
|
+
sessionLog: createSessionLogOps({ gateway, paths, config, now, logger }),
|
|
397
|
+
catalog: createCatalogOps({ ctx, now }),
|
|
398
|
+
session: gateway,
|
|
399
|
+
lifecycle: createLifecycleOps({ ctx, gateway, config, now }),
|
|
400
|
+
pathExists: async (target) => {
|
|
401
|
+
try {
|
|
402
|
+
await stat(target)
|
|
403
|
+
return true
|
|
404
|
+
} catch {
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
hasFiles: async (target) => {
|
|
409
|
+
try {
|
|
410
|
+
const entries = await readdir(target, { recursive: true })
|
|
411
|
+
return entries.length > 0
|
|
412
|
+
} catch {
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
/**
|
|
417
|
+
* Reconstruct the original session-log location of an entry whose move
|
|
418
|
+
* completed but whose manifest did not record it (a crash between
|
|
419
|
+
* `moveDirectory` and `trash.update`). The log header still carries the
|
|
420
|
+
* session's cwd, which is the only durable input needed to rebuild the
|
|
421
|
+
* Host's project-directory path; a missing/unreadable header returns null
|
|
422
|
+
* and the caller keeps refusing instead of guessing.
|
|
423
|
+
*/
|
|
424
|
+
recoverSessionLog: async (entry) => {
|
|
425
|
+
if (typeof entry?.sessionId !== 'string' || !SESSION_ID_PATTERN.test(entry.sessionId)) return null
|
|
426
|
+
if (typeof entry?.trashId !== 'string' || !isTrashId(entry.trashId)) return null
|
|
427
|
+
const sessionDir = join(trash.entryDir(entry.trashId), 'session')
|
|
428
|
+
if (!(await readdir(sessionDir).catch(() => null))) return null
|
|
429
|
+
const cwd = await readSessionLogCwd(sessionDir)
|
|
430
|
+
if (cwd === null) return null
|
|
431
|
+
const from = join(paths.sessionsRoot, projectKey(cwd), encodeSessionSegment(entry.sessionId))
|
|
432
|
+
if (!isPathInside(paths.sessionsRoot, from) || !isPathInside(paths.trashDir, sessionDir)) return null
|
|
433
|
+
return { sessionId: entry.sessionId, from, to: sessionDir }
|
|
434
|
+
},
|
|
435
|
+
// Manifest paths are data, not truth: a hand-edited manifest must not turn
|
|
436
|
+
// "restore" into a proxy for stat/move on arbitrary directories.
|
|
437
|
+
isInsideSessionsRoot: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.sessionsRoot, target),
|
|
438
|
+
isInsideTrashDir: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.trashDir, target),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** @type {Map<string, { planKey: string, sessionIds: string[], includeMemory: boolean, mode: 'soft'|'hard', phrase: string, issuedAt: number, expiresAt: number, used: boolean }>} */
|
|
442
|
+
const confirmations = new Map()
|
|
443
|
+
let mutationChain = Promise.resolve()
|
|
444
|
+
let requestCounter = 0
|
|
445
|
+
// `/trash` reads and parses every manifest on disk; a short TTL cache keeps
|
|
446
|
+
// the settings page responsive while every mutation invalidates it, so the
|
|
447
|
+
// user never sees a stale list after their own action.
|
|
448
|
+
const TRASH_CACHE_TTL_MS = 1_500
|
|
449
|
+
// Retention is a background chore, not a request: at most one sweep every
|
|
450
|
+
// five minutes (a `/trash` poll must not rescan the whole bin each time), and
|
|
451
|
+
// at most this many entries per sweep so one huge bin cannot hold the
|
|
452
|
+
// mutation lock for minutes.
|
|
453
|
+
const RETENTION_SWEEP_THROTTLE_MS = 300_000
|
|
454
|
+
const RETENTION_MAX_PER_RUN = DEFAULT_MAX_PER_RUN
|
|
455
|
+
// An entry that cannot be purged (busy lock, a half-written bundle) used to be
|
|
456
|
+
// retried every five minutes forever, writing a warn and an audit record each
|
|
457
|
+
// time. Failures now back off exponentially, capped at six hours.
|
|
458
|
+
const RETENTION_RETRY_BASE_MS = 300_000
|
|
459
|
+
const RETENTION_RETRY_MAX_MS = 21_600_000
|
|
460
|
+
let lastRetentionSweepAt = 0
|
|
461
|
+
/** @type {{ at: number, purged: number, failed: number }|null} */
|
|
462
|
+
let lastRetentionResult = null
|
|
463
|
+
let retentionSweepInFlight = null
|
|
464
|
+
/** @type {Map<string, { failures: number, nextAt: number }>} */
|
|
465
|
+
const retentionRetries = new Map()
|
|
466
|
+
let trashCache = null
|
|
467
|
+
let trashCachedAt = 0
|
|
468
|
+
function invalidateTrashCache() {
|
|
469
|
+
trashCache = null
|
|
470
|
+
trashCachedAt = 0
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Serialize one mutation: in-process first (so two HTTP requests never
|
|
475
|
+
* interleave their transactions), then across processes (so a second Host
|
|
476
|
+
* sharing this DSH_HOME cannot write the same manifest concurrently).
|
|
477
|
+
*
|
|
478
|
+
* The in-process queue is bounded too. It used to be an unbounded promise
|
|
479
|
+
* chain: a request whose client had already given up still ran minutes
|
|
480
|
+
* later, and one stuck holder hung every later mutation forever. Now:
|
|
481
|
+
* - `shouldAbort` is checked before the operation runs (twice: when the
|
|
482
|
+
* chain reaches us and again after the cross-process lock), so a client
|
|
483
|
+
* that disconnected changes nothing;
|
|
484
|
+
* - the wait has a budget derived from the operation's own lock budget
|
|
485
|
+
* plus a grace period, so it can never fire before a legitimate holder
|
|
486
|
+
* finishes, and never waits forever either.
|
|
487
|
+
*/
|
|
488
|
+
function serialize(operation, { lockTimeoutMs, shouldAbort } = {}) {
|
|
489
|
+
const queueBudgetMs = queueBudgetFor(lockTimeoutMs === undefined ? 30_000 : lockTimeoutMs)
|
|
490
|
+
let abandoned = false
|
|
491
|
+
// Once the operation itself starts, the queue timer stops: from that moment
|
|
492
|
+
// the outcome belongs to the operation, and answering "not applied" would be
|
|
493
|
+
// a lie (the purge/restore may well have happened). The budget therefore
|
|
494
|
+
// covers the wait, which is the only part this function can honestly abort.
|
|
495
|
+
let started = false
|
|
496
|
+
let timer = null
|
|
497
|
+
const aborted = () => typeof shouldAbort === 'function' && shouldAbort() === true
|
|
498
|
+
const next = mutationChain.then(async () => {
|
|
499
|
+
if (abandoned) {
|
|
500
|
+
throw new DeleteError('E_QUEUE_TIMEOUT', 'The recycle bin was busy; this request was not applied. Retry.', {})
|
|
501
|
+
}
|
|
502
|
+
if (aborted()) {
|
|
503
|
+
throw new DeleteError('E_ABANDONED', 'The client disconnected before this request ran; nothing was changed.', {})
|
|
504
|
+
}
|
|
505
|
+
// The lock lives in the data dir, which a fresh install has not created
|
|
506
|
+
// yet (the trash store and audit log mkdir their own targets lazily).
|
|
507
|
+
await mkdir(paths.dataDir, { recursive: true }).catch(() => {})
|
|
508
|
+
let release
|
|
509
|
+
try {
|
|
510
|
+
release = await mutationLockInstance.acquire(lockTimeoutMs === undefined ? {} : { timeoutMs: lockTimeoutMs })
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error?.code === 'E_LOCK_BUSY') {
|
|
513
|
+
throw new DeleteError('E_LOCK_BUSY', 'Another DSH instance is using this recycle bin; retry in a moment.', {})
|
|
514
|
+
}
|
|
515
|
+
throw error
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
// Queued behind the lock: the caller may have gone in the meantime.
|
|
519
|
+
if (aborted()) {
|
|
520
|
+
throw new DeleteError('E_ABANDONED', 'The client disconnected before this request ran; nothing was changed.', {})
|
|
521
|
+
}
|
|
522
|
+
started = true
|
|
523
|
+
if (timer !== null) clearTimeout(timer)
|
|
524
|
+
return await operation()
|
|
525
|
+
} finally {
|
|
526
|
+
await release()
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
mutationChain = next.then(() => undefined, () => undefined)
|
|
530
|
+
return new Promise((resolve, reject) => {
|
|
531
|
+
timer = setTimeout(() => {
|
|
532
|
+
if (started) return
|
|
533
|
+
abandoned = true
|
|
534
|
+
reject(
|
|
535
|
+
new DeleteError(
|
|
536
|
+
'E_QUEUE_TIMEOUT',
|
|
537
|
+
'The recycle bin stayed busy for too long; this request was not applied. Retry.',
|
|
538
|
+
{ waitedMs: queueBudgetMs },
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
}, queueBudgetMs)
|
|
542
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
543
|
+
next.then(
|
|
544
|
+
(value) => {
|
|
545
|
+
clearTimeout(timer)
|
|
546
|
+
resolve(value)
|
|
547
|
+
},
|
|
548
|
+
(error) => {
|
|
549
|
+
clearTimeout(timer)
|
|
550
|
+
reject(error)
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function requestId() {
|
|
557
|
+
requestCounter += 1
|
|
558
|
+
return `dc-${now().toString(36)}-${requestCounter.toString(36)}-${randomBytes(2).toString('hex')}`
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* True once the HTTP client has gone away. Mutations consult this before they
|
|
563
|
+
* touch disk: a request the user cancelled (or a tab that closed) must not
|
|
564
|
+
* delete a conversation afterwards.
|
|
565
|
+
*/
|
|
566
|
+
function clientGone(req, res) {
|
|
567
|
+
// Only signals that survive a normal, fully-read request count here.
|
|
568
|
+
// `req.destroyed` is NOT one of them: Node's IncomingMessage auto-destroys
|
|
569
|
+
// its readable side once the body is consumed, so it is `true` on every
|
|
570
|
+
// healthy POST after the body has been read (measured on Node 24:
|
|
571
|
+
// `{req.destroyed:true, req.aborted:false, req.complete:true,
|
|
572
|
+
// res.destroyed:false, res.socket.destroyed:false}`). Using it rejected
|
|
573
|
+
// every deletion with E_ABANDONED in 0.3.8.
|
|
574
|
+
if (res?.destroyed === true) return true
|
|
575
|
+
if (res?.writableEnded === true) return true
|
|
576
|
+
const socket = res?.socket ?? req?.socket
|
|
577
|
+
if (socket !== null && socket !== undefined && socket.destroyed === true) return true
|
|
578
|
+
return false
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Effective batch ceiling for this moment: the configured batch size capped
|
|
583
|
+
* by the remaining per-minute budget. Without this, a batch of 21–50 always
|
|
584
|
+
* died with E_RATE_LIMITED even though `maxBatchSessions` said it was legal.
|
|
585
|
+
*/
|
|
586
|
+
function effectiveBatchLimit() {
|
|
587
|
+
const remaining = limiter.status().remaining
|
|
588
|
+
return Math.max(1, Math.min(config.maxBatchSessions, remaining))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function cleanupConfirmations() {
|
|
592
|
+
const current = now()
|
|
593
|
+
for (const [token, record] of confirmations) {
|
|
594
|
+
if (record.expiresAt <= current) confirmations.delete(token)
|
|
595
|
+
}
|
|
596
|
+
if (confirmations.size <= 512) return
|
|
597
|
+
// Evict the oldest plans, not the whole map: a long preview session used to
|
|
598
|
+
// invalidate every open dialog at once.
|
|
599
|
+
const oldest = [...confirmations.entries()].sort((left, right) => left[1].issuedAt - right[1].issuedAt)
|
|
600
|
+
for (const [token] of oldest.slice(0, confirmations.size - 512)) confirmations.delete(token)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function issueConfirmation({ sessionIds, includeMemory, mode }) {
|
|
604
|
+
cleanupConfirmations()
|
|
605
|
+
const token = randomBytes(24).toString('hex')
|
|
606
|
+
const phrase = expectedPhrase({ count: sessionIds.length, mode, config })
|
|
607
|
+
const record = {
|
|
608
|
+
planKey: planKeyOf({ sessionIds, includeMemory, mode }),
|
|
609
|
+
sessionIds: [...sessionIds],
|
|
610
|
+
includeMemory,
|
|
611
|
+
mode,
|
|
612
|
+
phrase,
|
|
613
|
+
issuedAt: now(),
|
|
614
|
+
expiresAt: now() + config.confirmTokenTtlMs,
|
|
615
|
+
used: false,
|
|
616
|
+
}
|
|
617
|
+
confirmations.set(token, record)
|
|
618
|
+
return { token, expiresAt: record.expiresAt, phrase }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function validateConfirmation(token, plan) {
|
|
622
|
+
if (typeof token !== 'string' || token === '') {
|
|
623
|
+
throw new DeleteError('E_CONFIRM_REQUIRED', 'Preview the deletion before confirming it.', {})
|
|
624
|
+
}
|
|
625
|
+
const record = confirmations.get(token)
|
|
626
|
+
if (record === undefined) {
|
|
627
|
+
throw new DeleteError('E_CONFIRM_INVALID', 'The confirmation token is unknown or was already used.', {})
|
|
628
|
+
}
|
|
629
|
+
if (record.expiresAt <= now()) {
|
|
630
|
+
confirmations.delete(token)
|
|
631
|
+
throw new DeleteError('E_CONFIRM_EXPIRED', 'The confirmation expired; preview the deletion again.', { expiredAt: record.expiresAt })
|
|
632
|
+
}
|
|
633
|
+
if (record.used) {
|
|
634
|
+
throw new DeleteError('E_CONFIRM_INVALID', 'The confirmation token was already used.', {})
|
|
635
|
+
}
|
|
636
|
+
if (record.planKey !== planKeyOf(plan)) {
|
|
637
|
+
throw new DeleteError('E_CONFIRM_INVALID', 'The confirmation does not match this deletion plan.', {})
|
|
638
|
+
}
|
|
639
|
+
return { token, record }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function markConfirmationUsed(validated) {
|
|
643
|
+
validated.record.used = true
|
|
644
|
+
confirmations.delete(validated.token)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function planKeyOf({ sessionIds, includeMemory, mode }) {
|
|
648
|
+
return JSON.stringify({ ids: [...sessionIds].sort(), includeMemory: includeMemory === true, mode })
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function preview({ sessionIds, includeMemory, mode }) {
|
|
652
|
+
const rate = limiter.status()
|
|
653
|
+
if (rate.remaining === 0) {
|
|
654
|
+
const retryAfterMs = rate.resetAt === null ? 60_000 : Math.max(1, rate.resetAt - now())
|
|
655
|
+
throw new DeleteError('E_RATE_LIMITED', `Too many deletions; retry in ${Math.ceil(retryAfterMs / 1000)}s.`, {
|
|
656
|
+
retryAfterMs,
|
|
657
|
+
limit: rate.limit,
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
const batchLimit = Math.max(1, Math.min(config.maxBatchSessions, rate.remaining))
|
|
661
|
+
if (sessionIds.length > batchLimit) {
|
|
662
|
+
throw new DeleteError('E_BATCH_TOO_LARGE', `At most ${batchLimit} conversations can be deleted in one batch right now.`, {
|
|
663
|
+
count: sessionIds.length,
|
|
664
|
+
limit: batchLimit,
|
|
665
|
+
maxBatchSessions: config.maxBatchSessions,
|
|
666
|
+
rateRemaining: limiter.status().remaining,
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
const sessions = []
|
|
670
|
+
const blocked = []
|
|
671
|
+
for (const id of sessionIds) {
|
|
672
|
+
const session = await gateway.inspect(id)
|
|
673
|
+
if (session === null) {
|
|
674
|
+
blocked.push({ sessionId: id, policy: { ok: false, code: 'E_NOT_FOUND', message: 'The conversation was not found.' } })
|
|
675
|
+
continue
|
|
676
|
+
}
|
|
677
|
+
const policy = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode, config })
|
|
678
|
+
const item = {
|
|
679
|
+
id: session.id,
|
|
680
|
+
title: session.title,
|
|
681
|
+
cwd: session.cwd,
|
|
682
|
+
events: session.events,
|
|
683
|
+
sizeBytes: session.sizeBytes,
|
|
684
|
+
live: session.live,
|
|
685
|
+
archived: session.archived,
|
|
686
|
+
workspace: session.workspace,
|
|
687
|
+
policy: policy.ok ? { ok: true, warnings: policy.warnings } : { ok: false, code: policy.error.code, message: policy.error.message },
|
|
688
|
+
}
|
|
689
|
+
sessions.push(item)
|
|
690
|
+
if (!policy.ok) blocked.push({ sessionId: id, policy: item.policy })
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// When every session is blocked there is no plan to execute, so the memory
|
|
694
|
+
// count (index reads + parsing) is skipped rather than computed for nothing.
|
|
695
|
+
const emptyMemory = { total: 0, bySession: Object.fromEntries(sessionIds.map((id) => [id, 0])), protected: 0, adapters: [] }
|
|
696
|
+
const memory = !config.memory.enabled
|
|
697
|
+
? { ...emptyMemory, disabled: true }
|
|
698
|
+
: blocked.length === 0
|
|
699
|
+
? await memoryService.countForSessions(sessionIds)
|
|
700
|
+
: { ...emptyMemory, skipped: true }
|
|
701
|
+
const adapterStates = await memoryService.status()
|
|
702
|
+
const confirmation = blocked.length === 0 ? issueConfirmation({ sessionIds, includeMemory, mode }) : null
|
|
703
|
+
|
|
704
|
+
// Worst-case wall time for the matching `/delete`: one lock acquisition,
|
|
705
|
+
// plus per live session a bounded drain, plus per session the memory
|
|
706
|
+
// export/restore under the plugin-private lock. The Client MUST use this
|
|
707
|
+
// instead of its own estimate, otherwise it aborts while the Host is still
|
|
708
|
+
// deleting and retries into an in-flight transaction.
|
|
709
|
+
const liveCount = sessions.filter((item) => item.live === true).length
|
|
710
|
+
const planTimeoutMs = Math.min(900_000, 15_000 + sessionIds.length * 16_000 + liveCount * config.liveDrainMs)
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
sessions,
|
|
714
|
+
blocked,
|
|
715
|
+
memory: {
|
|
716
|
+
enabled: config.memory.enabled,
|
|
717
|
+
deleteMode: config.memory.deleteMode,
|
|
718
|
+
total: memory.total,
|
|
719
|
+
protected: memory.protected,
|
|
720
|
+
bySession: memory.bySession,
|
|
721
|
+
adapters: memory.adapters,
|
|
722
|
+
stores: adapterStates,
|
|
723
|
+
},
|
|
724
|
+
plan: { sessionIds, includeMemory, mode, count: sessionIds.length },
|
|
725
|
+
confirmation,
|
|
726
|
+
limits: {
|
|
727
|
+
maxBatchSessions: config.maxBatchSessions,
|
|
728
|
+
effectiveBatch: batchLimit,
|
|
729
|
+
maxDeletesPerMinute: config.maxDeletesPerMinute,
|
|
730
|
+
rate: limiter.status(),
|
|
731
|
+
trashRetentionDays: config.trashRetentionDays,
|
|
732
|
+
confirmTokenTtlMs: config.confirmTokenTtlMs,
|
|
733
|
+
planTimeoutMs,
|
|
734
|
+
// How long the Client should wait for the answer: the plan's own lock
|
|
735
|
+
// budget plus the queueing grace. The Client used to derive a shorter
|
|
736
|
+
// number and give up 60 s before the Host finished.
|
|
737
|
+
queueTimeoutMs: queueBudgetFor(planTimeoutMs),
|
|
738
|
+
},
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function deletePlan(body) {
|
|
743
|
+
const sessionIds = normalizeSessionIds(body)
|
|
744
|
+
const mode = normalizeMode(body, config)
|
|
745
|
+
const includeMemory = normalizeBoolean(body, 'includeMemory')
|
|
746
|
+
const batchLimit = effectiveBatchLimit()
|
|
747
|
+
if (sessionIds.length > batchLimit) {
|
|
748
|
+
throw new DeleteError('E_BATCH_TOO_LARGE', `At most ${batchLimit} conversations can be deleted in one batch right now.`, {
|
|
749
|
+
count: sessionIds.length,
|
|
750
|
+
limit: batchLimit,
|
|
751
|
+
maxBatchSessions: config.maxBatchSessions,
|
|
752
|
+
rateRemaining: limiter.status().remaining,
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
if (!config.enabled) throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
|
|
756
|
+
|
|
757
|
+
// Confirmation is validated BEFORE any side effect. The token is consumed
|
|
758
|
+
// only once the phrase and the rate budget have passed, so a typo or a
|
|
759
|
+
// temporary rate limit does not force the user to re-preview.
|
|
760
|
+
const validated = validateConfirmation(body?.confirmToken, { sessionIds, includeMemory, mode })
|
|
761
|
+
const phrase = typeof body?.confirmPhrase === 'string' ? body.confirmPhrase.trim() : ''
|
|
762
|
+
if (validated.record.phrase !== '' && phrase !== validated.record.phrase) {
|
|
763
|
+
throw new DeleteError('E_CONFIRM_PHRASE', `Type “${validated.record.phrase}” to confirm this deletion.`, {
|
|
764
|
+
expected: validated.record.phrase,
|
|
765
|
+
received: phrase.slice(0, 40),
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Preflight EVERY session before mutating ANY of them: a batch either
|
|
770
|
+
// starts with a fully authorized plan or is refused with the complete list
|
|
771
|
+
// of reasons, never half-executed because the last id was forbidden.
|
|
772
|
+
const sessions = []
|
|
773
|
+
const refusals = []
|
|
774
|
+
for (const id of sessionIds) {
|
|
775
|
+
const session = await gateway.inspect(id)
|
|
776
|
+
if (session === null) {
|
|
777
|
+
refusals.push({ sessionId: id, code: 'E_NOT_FOUND', message: 'The conversation was not found.' })
|
|
778
|
+
continue
|
|
779
|
+
}
|
|
780
|
+
const policy = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode, config })
|
|
781
|
+
if (!policy.ok) refusals.push({ sessionId: id, code: policy.error.code, message: policy.error.message })
|
|
782
|
+
else sessions.push(session)
|
|
783
|
+
}
|
|
784
|
+
if (refusals.length > 0) {
|
|
785
|
+
await audit
|
|
786
|
+
.append({
|
|
787
|
+
id: `${requestId()}:delete:denied`,
|
|
788
|
+
action: 'delete',
|
|
789
|
+
result: 'denied',
|
|
790
|
+
sessionIds,
|
|
791
|
+
mode,
|
|
792
|
+
includeMemory,
|
|
793
|
+
actor: 'local-user',
|
|
794
|
+
refusals,
|
|
795
|
+
source: auditSource,
|
|
796
|
+
})
|
|
797
|
+
.catch(() => {})
|
|
798
|
+
// Report the ACTUAL first refusal. The wire code is what the Client
|
|
799
|
+
// localizes, so hard-coding E_POLICY_ORPHAN made every refusal (live,
|
|
800
|
+
// protected, outside roots, not found) read as "not owned by a
|
|
801
|
+
// workspace". The HTTP status still comes from that same real code.
|
|
802
|
+
const first = refusals[0]
|
|
803
|
+
throw new DeleteError(first.code ?? 'E_POLICY_ORPHAN', first.message, { refusals }, {
|
|
804
|
+
httpStatus: ERROR_STATUS[first.code] ?? 403,
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Budget is consumed per actually-attempted deletion: a failed item must
|
|
809
|
+
// not silently spend the minute quota it never used. The very first item
|
|
810
|
+
// still refuses outright with 429 (the existing contract); once a plan
|
|
811
|
+
// has started, later exhausted items are recorded as failures.
|
|
812
|
+
//
|
|
813
|
+
// A refusal inside the loop uses `continue`, never `break`: `break` used
|
|
814
|
+
// to drop every session after the exhausted one from both `results` and
|
|
815
|
+
// `failures`, so a batch of 50 with a 20/min budget left items 22-50
|
|
816
|
+
// looking like the plan never mentioned them. Under the per-plan
|
|
817
|
+
// serialize this is still visible via `maxBatchSessions` > budget.
|
|
818
|
+
const request = requestId()
|
|
819
|
+
const results = []
|
|
820
|
+
const failures = []
|
|
821
|
+
for (const session of sessions) {
|
|
822
|
+
const reservation = limiter.take(1)
|
|
823
|
+
if (!reservation.ok) {
|
|
824
|
+
const rateError = new DeleteError('E_RATE_LIMITED', `Too many deletions; retry in ${Math.ceil(reservation.retryAfterMs / 1000)}s.`, {
|
|
825
|
+
retryAfterMs: reservation.retryAfterMs,
|
|
826
|
+
limit: reservation.limit,
|
|
827
|
+
})
|
|
828
|
+
if (results.length === 0 && failures.length === 0) throw rateError
|
|
829
|
+
failures.push({ sessionId: session.id, error: rateError.toJSON() })
|
|
830
|
+
continue
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const outcome = await performSessionDelete({
|
|
834
|
+
session,
|
|
835
|
+
mode,
|
|
836
|
+
includeMemory,
|
|
837
|
+
ops,
|
|
838
|
+
trash,
|
|
839
|
+
audit,
|
|
840
|
+
config,
|
|
841
|
+
now,
|
|
842
|
+
actor: 'local-user',
|
|
843
|
+
source: auditSource,
|
|
844
|
+
requestId: request,
|
|
845
|
+
})
|
|
846
|
+
results.push({
|
|
847
|
+
sessionId: session.id,
|
|
848
|
+
title: session.title,
|
|
849
|
+
trashId: outcome.entry.trashId,
|
|
850
|
+
mode,
|
|
851
|
+
memory: outcome.memory.total,
|
|
852
|
+
durationMs: outcome.durationMs,
|
|
853
|
+
})
|
|
854
|
+
} catch (error) {
|
|
855
|
+
// The deletion never happened: refund the slot so a failed item does
|
|
856
|
+
// not consume the per-minute quota of a successful one.
|
|
857
|
+
reservation.release()
|
|
858
|
+
const normalized = asDeleteError(error)
|
|
859
|
+
failures.push({ sessionId: session.id, error: normalized.toJSON() })
|
|
860
|
+
logger.error?.(`[conversation-recycle-bin] delete failed for ${session.id}: ${normalized.message}`)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
gateway.invalidate()
|
|
864
|
+
invalidateTrashCache()
|
|
865
|
+
|
|
866
|
+
// The token is burned only once the whole plan went through. It used to be
|
|
867
|
+
// marked used before the first item, so a batch that failed halfway left the
|
|
868
|
+
// user holding a dead token: the remaining sessions could not be retried
|
|
869
|
+
// without previewing again and retyping the confirmation phrase. The token
|
|
870
|
+
// is bound to (ids, mode, includeMemory), so a retry can only replay this
|
|
871
|
+
// same plan — already-deleted sessions answer E_NOT_FOUND, never a second
|
|
872
|
+
// deletion.
|
|
873
|
+
if (failures.length === 0) markConfirmationUsed(validated)
|
|
874
|
+
if (results.length === 0 && failures.length > 0) {
|
|
875
|
+
const first = failures[0].error
|
|
876
|
+
throw new DeleteError(first.code, first.message, { ...(first.details ?? {}), failures })
|
|
877
|
+
}
|
|
878
|
+
return { requestId: request, results, failures, mode, includeMemory }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function restoreEntry(trashId) {
|
|
882
|
+
if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
|
|
883
|
+
const entry = await trash.read(trashId)
|
|
884
|
+
if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
|
|
885
|
+
const result = await performRestore({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
|
|
886
|
+
gateway.invalidate()
|
|
887
|
+
invalidateTrashCache()
|
|
888
|
+
return result
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function purgeEntry(trashId, confirmPhrase) {
|
|
892
|
+
if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
|
|
893
|
+
if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
|
|
894
|
+
throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to permanently delete this entry.', { expected: 'DELETE' })
|
|
895
|
+
}
|
|
896
|
+
const entry = await trash.read(trashId)
|
|
897
|
+
if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
|
|
898
|
+
// Purging a soft entry performs the missing hard stages on a still-live
|
|
899
|
+
// session. The cascade's quiesce step stops that session first; if it
|
|
900
|
+
// cannot be stopped within liveDrainMs, the purge fails and rolls back.
|
|
901
|
+
const result = await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
|
|
902
|
+
gateway.invalidate()
|
|
903
|
+
invalidateTrashCache()
|
|
904
|
+
return result
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Remove an entry that never committed (failed / pending / interrupted).
|
|
909
|
+
* These used to be invisible in the UI and rejected by both restore and
|
|
910
|
+
* purge, so they stayed on disk forever.
|
|
911
|
+
*/
|
|
912
|
+
async function discardEntry(trashId, confirmPhrase) {
|
|
913
|
+
if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
|
|
914
|
+
// Validate before anything joins it into a path. `trash.read` answers null
|
|
915
|
+
// for a malformed id, which used to fall through to the "directory may
|
|
916
|
+
// still be on disk" branch below and turn this into an arbitrary-path
|
|
917
|
+
// existence probe.
|
|
918
|
+
if (!isTrashId(trashId)) throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
|
|
919
|
+
if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
|
|
920
|
+
throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to discard this incomplete entry.', { expected: 'DELETE' })
|
|
921
|
+
}
|
|
922
|
+
let entry = await trash.read(trashId)
|
|
923
|
+
if (entry === null) {
|
|
924
|
+
// Missing/corrupt manifest: the directory itself may still be on disk.
|
|
925
|
+
// Verify it exists, then discard against the actual payload; the disk
|
|
926
|
+
// checks inside performDiscard still refuse a folder holding a log or
|
|
927
|
+
// memory files, so a stranded directory can be cleaned without ever
|
|
928
|
+
// trusting the unreadable manifest.
|
|
929
|
+
const dir = join(paths.trashDir, trashId)
|
|
930
|
+
if (!(await ops.pathExists(dir))) {
|
|
931
|
+
throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
|
|
932
|
+
}
|
|
933
|
+
entry = {
|
|
934
|
+
trashId,
|
|
935
|
+
sessionId: null,
|
|
936
|
+
title: null,
|
|
937
|
+
mode: null,
|
|
938
|
+
includeMemory: false,
|
|
939
|
+
deletedAt: 0,
|
|
940
|
+
updatedAt: 0,
|
|
941
|
+
status: 'unreadable',
|
|
942
|
+
memory: { adapters: [], total: 0 },
|
|
943
|
+
sessionLog: null,
|
|
944
|
+
catalog: null,
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
const result = await performDiscard({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
|
|
948
|
+
invalidateTrashCache()
|
|
949
|
+
return result
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function state() {
|
|
953
|
+
// Only wire fields the Client renders; adapter `storePath` is an absolute
|
|
954
|
+
// filesystem handle and must not leave the Host.
|
|
955
|
+
const adapters = (await memoryService.status()).map(({ id, label, available, reason }) => ({ id, label, available, reason }))
|
|
956
|
+
return {
|
|
957
|
+
enabled: config.enabled,
|
|
958
|
+
version: PLUGIN_VERSION,
|
|
959
|
+
// The Client cannot tell a broken deployment from a broken permission
|
|
960
|
+
// otherwise: with `requireConnection` on and no service mounted, every
|
|
961
|
+
// route answers 401 and the panel just looked empty.
|
|
962
|
+
connectionFence: connectionFencePresent(),
|
|
963
|
+
defaultMode: config.defaultMode,
|
|
964
|
+
memory: {
|
|
965
|
+
enabled: config.memory.enabled,
|
|
966
|
+
deleteMode: config.memory.deleteMode,
|
|
967
|
+
adapters,
|
|
968
|
+
},
|
|
969
|
+
limits: {
|
|
970
|
+
maxBatchSessions: config.maxBatchSessions,
|
|
971
|
+
effectiveBatch: effectiveBatchLimit(),
|
|
972
|
+
maxDeletesPerMinute: config.maxDeletesPerMinute,
|
|
973
|
+
rate: limiter.status(),
|
|
974
|
+
trashRetentionDays: config.trashRetentionDays,
|
|
975
|
+
confirmTokenTtlMs: config.confirmTokenTtlMs,
|
|
976
|
+
// Trash mutations take the fixed trash budget; the Client waits for it
|
|
977
|
+
// instead of using its generic request timeout.
|
|
978
|
+
queueTimeoutMs: queueBudgetFor(TRASH_LOCK_BUDGET_MS),
|
|
979
|
+
},
|
|
980
|
+
ui: config.ui,
|
|
981
|
+
feedback: feedbackService.status(),
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function sessionsForUi() {
|
|
986
|
+
const sessions = await gateway.list()
|
|
987
|
+
await gateway.enrichTitles(sessions)
|
|
988
|
+
// Every row carries its own policy verdict: "select all" used to include
|
|
989
|
+
// subagent/orphan/out-of-root conversations that `/preview` then refuses
|
|
990
|
+
// as a whole batch, leaving the user with a disabled button and no hint
|
|
991
|
+
// which row to uncheck. Judge with the configured default mode (the mode
|
|
992
|
+
// the dialog opens in): with `stopLiveSessions: false`, hard is stricter
|
|
993
|
+
// than soft for live sessions, so a fixed soft verdict would mark those
|
|
994
|
+
// rows deletable only for the server to refuse later.
|
|
995
|
+
return sessions.map((session) => {
|
|
996
|
+
const verdict = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode: config.defaultMode, config })
|
|
997
|
+
return {
|
|
998
|
+
id: session.id,
|
|
999
|
+
title: session.title,
|
|
1000
|
+
cwd: session.cwd,
|
|
1001
|
+
updatedAt: session.updatedAt,
|
|
1002
|
+
running: session.running,
|
|
1003
|
+
live: session.live,
|
|
1004
|
+
blank: session.blank,
|
|
1005
|
+
archived: session.archived,
|
|
1006
|
+
pinned: session.pinned,
|
|
1007
|
+
origin: session.origin,
|
|
1008
|
+
workspace: session.workspaceId === null ? null : { id: session.workspaceId },
|
|
1009
|
+
deletable: verdict.ok,
|
|
1010
|
+
policy: verdict.ok
|
|
1011
|
+
? { ok: true, warnings: verdict.warnings }
|
|
1012
|
+
: { ok: false, code: verdict.error.code, message: verdict.error.message },
|
|
1013
|
+
}
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Ordered map with at most `limit` filesystem probes in flight. */
|
|
1018
|
+
async function mapLimit(items, limit, fn) {
|
|
1019
|
+
const out = new Array(items.length)
|
|
1020
|
+
let next = 0
|
|
1021
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
1022
|
+
while (true) {
|
|
1023
|
+
const index = next
|
|
1024
|
+
next += 1
|
|
1025
|
+
if (index >= items.length) return
|
|
1026
|
+
out[index] = await fn(items[index], index)
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
await Promise.all(workers)
|
|
1030
|
+
return out
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Disk payload of one entry, for the capability flags the Client renders. */
|
|
1034
|
+
async function entryDisk(trashId) {
|
|
1035
|
+
if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false }
|
|
1036
|
+
let dir
|
|
1037
|
+
try {
|
|
1038
|
+
dir = trash.entryDir(trashId)
|
|
1039
|
+
} catch {
|
|
1040
|
+
return { hasLogFiles: false, hasMemoryFiles: false }
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
hasLogFiles: await ops.pathExists(join(dir, 'session')),
|
|
1044
|
+
hasMemoryFiles: await ops.hasFiles(join(dir, 'memory')),
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function serializeEntry(entry) {
|
|
1049
|
+
const retentionDays = config.trashRetentionDays
|
|
1050
|
+
// The Client must not recompute the window from `deletedAt`: a browser clock
|
|
1051
|
+
// that is a day off would shift the promise the Host actually keeps. The
|
|
1052
|
+
// countdown is therefore decided here, once, by the same predicate the sweep
|
|
1053
|
+
// uses (`daysUntilExpiry`), and `expiresAt` carries the absolute deadline.
|
|
1054
|
+
const daysLeft = daysUntilExpiry(entry, { retentionDays, now: now() })
|
|
1055
|
+
const disk = await entryDisk(entry.trashId)
|
|
1056
|
+
return {
|
|
1057
|
+
trashId: entry.trashId,
|
|
1058
|
+
sessionId: entry.sessionId,
|
|
1059
|
+
title: entry.title,
|
|
1060
|
+
mode: entry.mode,
|
|
1061
|
+
includeMemory: entry.includeMemory,
|
|
1062
|
+
deletedAt: entry.deletedAt,
|
|
1063
|
+
updatedAt: entry.updatedAt,
|
|
1064
|
+
daysLeft,
|
|
1065
|
+
expiresAt: daysLeft === null ? null : entry.deletedAt + Number(retentionDays) * DAY_MS,
|
|
1066
|
+
memory: entry.memory?.total ?? 0,
|
|
1067
|
+
memoryAdapters: Array.isArray(entry.memory?.adapters) ? entry.memory.adapters : [],
|
|
1068
|
+
hasSessionLog: entry.sessionLog !== null && entry.sessionLog !== undefined,
|
|
1069
|
+
status: entry.status,
|
|
1070
|
+
// The UI renders buttons from the same predicate the routes enforce. Disk
|
|
1071
|
+
// payload is part of that predicate: a manifest can be empty while the
|
|
1072
|
+
// moved log/memory copy is still on disk, and a status-only answer
|
|
1073
|
+
// advertised actions the routes refused.
|
|
1074
|
+
capabilities: entryCapabilities(entry, { ...disk, requirePayload: true }),
|
|
1075
|
+
error: entry.error ?? null,
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** A folder whose manifest cannot be read still has a reliable disk payload. */
|
|
1080
|
+
async function serializeUnreadable(item) {
|
|
1081
|
+
const disk = await entryDisk(item.trashId)
|
|
1082
|
+
return {
|
|
1083
|
+
trashId: item.trashId,
|
|
1084
|
+
sessionId: null,
|
|
1085
|
+
title: null,
|
|
1086
|
+
mode: null,
|
|
1087
|
+
includeMemory: false,
|
|
1088
|
+
deletedAt: 0,
|
|
1089
|
+
updatedAt: 0,
|
|
1090
|
+
memory: 0,
|
|
1091
|
+
memoryAdapters: [],
|
|
1092
|
+
hasSessionLog: false,
|
|
1093
|
+
status: 'unreadable',
|
|
1094
|
+
capabilities: entryCapabilities({ status: 'unreadable' }, disk),
|
|
1095
|
+
error: {
|
|
1096
|
+
code: item.reason,
|
|
1097
|
+
message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
|
|
1098
|
+
},
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function trashList() {
|
|
1103
|
+
// The retention sweep is kicked off, not awaited: it needs the mutation lock
|
|
1104
|
+
// (a purge in flight can hold it for minutes), and a GET must never block on
|
|
1105
|
+
// a write or delete the rows this very response is about to describe. Its
|
|
1106
|
+
// outcome is reported separately as `lastSweep`.
|
|
1107
|
+
void sweepExpiredTrash().catch(() => {})
|
|
1108
|
+
if (trashCache !== null && now() - trashCachedAt < TRASH_CACHE_TTL_MS) return trashCache
|
|
1109
|
+
// Every readable manifest is returned: `trashed` entries are the usable
|
|
1110
|
+
// recycle bin, while failed/pending/restoring/purging entries used to be
|
|
1111
|
+
// invisible and impossible to clean up at all. Directories whose manifest
|
|
1112
|
+
// is missing/corrupt are surfaced as `unreadable` so they are not stranded
|
|
1113
|
+
// on disk; the Client renders both groups separately.
|
|
1114
|
+
const { entries: all, unreadable } = await trash.scanAll()
|
|
1115
|
+
const trashed = all.filter((entry) => entry.status === 'trashed')
|
|
1116
|
+
const incompleteEntries = all.filter((entry) => entry.status !== 'trashed')
|
|
1117
|
+
const [entries, incomplete, unreadableRows] = await Promise.all([
|
|
1118
|
+
mapLimit(trashed, 8, serializeEntry),
|
|
1119
|
+
mapLimit(incompleteEntries, 8, serializeEntry),
|
|
1120
|
+
mapLimit(unreadable, 8, serializeUnreadable),
|
|
1121
|
+
])
|
|
1122
|
+
const result = {
|
|
1123
|
+
entries,
|
|
1124
|
+
incomplete: [...incomplete, ...unreadableRows],
|
|
1125
|
+
// What the background sweep did last time, so entries that vanished can be
|
|
1126
|
+
// explained instead of looking like a bug. `null` until the first sweep.
|
|
1127
|
+
lastSweep: lastRetentionResult,
|
|
1128
|
+
}
|
|
1129
|
+
trashCache = result
|
|
1130
|
+
trashCachedAt = now()
|
|
1131
|
+
return result
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Automatic retention: purge entries that have been in the recycle bin for
|
|
1136
|
+
* more than `trashRetentionDays` days.
|
|
1137
|
+
*
|
|
1138
|
+
* The sweep runs under the same `serialize` lock as a user mutation, so it
|
|
1139
|
+
* can never interleave with a restore/purge of the same entry, and it re-reads
|
|
1140
|
+
* each manifest under that lock (the scan that selected the candidates ran
|
|
1141
|
+
* outside it). `force` exists for tests and for a Host that just started;
|
|
1142
|
+
* production callers use the throttle so a `/trash` poll cannot turn into a
|
|
1143
|
+
* scan-and-purge loop.
|
|
1144
|
+
*
|
|
1145
|
+
* Failure policy: one un-purgeable entry never aborts the sweep. The cascade
|
|
1146
|
+
* already wrote its own `purge:failed` audit record, and the entry stays in
|
|
1147
|
+
* the bin for the next run or for the user.
|
|
1148
|
+
*/
|
|
1149
|
+
async function sweepExpiredTrash({ force = false } = {}) {
|
|
1150
|
+
// `enabled: false` is documented as the master switch, and every route
|
|
1151
|
+
// honours it with 503. A background chore that kept deleting entries while
|
|
1152
|
+
// the UI is unreachable would be the one path that contradicts it: the user
|
|
1153
|
+
// could neither see nor stop the deletion.
|
|
1154
|
+
if (config.enabled !== true) {
|
|
1155
|
+
return { enabled: false, purged: [], failed: [], retentionDays: 0 }
|
|
1156
|
+
}
|
|
1157
|
+
const retentionDays = config.trashRetentionDays
|
|
1158
|
+
if (!(Number(retentionDays) > 0)) return { enabled: false, purged: [], failed: [], retentionDays: 0 }
|
|
1159
|
+
if (retentionSweepInFlight !== null) return retentionSweepInFlight
|
|
1160
|
+
if (force !== true && now() - lastRetentionSweepAt < RETENTION_SWEEP_THROTTLE_MS) {
|
|
1161
|
+
return { skipped: true, purged: [], failed: [], retentionDays }
|
|
1162
|
+
}
|
|
1163
|
+
const run = (async () => {
|
|
1164
|
+
const scannedAt = now()
|
|
1165
|
+
const { entries } = await trash.scanAll()
|
|
1166
|
+
const expired = selectExpired(entries, { retentionDays, now: scannedAt, limit: RETENTION_MAX_PER_RUN })
|
|
1167
|
+
.filter((candidate) => {
|
|
1168
|
+
const retry = retentionRetries.get(candidate.trashId)
|
|
1169
|
+
return retry === undefined || retry.nextAt <= scannedAt
|
|
1170
|
+
})
|
|
1171
|
+
if (expired.length === 0) return { purged: [], failed: [], retentionDays }
|
|
1172
|
+
return serialize(async () => {
|
|
1173
|
+
const purged = []
|
|
1174
|
+
const failed = []
|
|
1175
|
+
for (const candidate of expired) {
|
|
1176
|
+
const entry = await trash.read(candidate.trashId)
|
|
1177
|
+
if (entry === null || !entryCapabilities(entry).canPurge) continue
|
|
1178
|
+
// A soft entry's log stays on disk, so "the session can still be
|
|
1179
|
+
// inspected" is its NORMAL state and must not block retention. Only a
|
|
1180
|
+
// conversation the user actually revived (unarchived) or one that is
|
|
1181
|
+
// running is left alone: purging it unattended would quiesce and
|
|
1182
|
+
// hard-delete the conversation the user is working in. Retention is a
|
|
1183
|
+
// janitor, not a killer.
|
|
1184
|
+
//
|
|
1185
|
+
// `archived` unknown (older Host shape, unreadable workspace state)
|
|
1186
|
+
// stays conservative: skip and let the user decide.
|
|
1187
|
+
if (entry.mode === 'soft' && typeof entry.sessionId === 'string' && entry.sessionId !== '') {
|
|
1188
|
+
let session = null
|
|
1189
|
+
try {
|
|
1190
|
+
session = await gateway.inspect(entry.sessionId)
|
|
1191
|
+
} catch {
|
|
1192
|
+
// Cannot decide whether it is back: refusing to purge is the safe
|
|
1193
|
+
// answer, and the retry policy keeps it out of the hot path.
|
|
1194
|
+
session = { unreadable: true }
|
|
1195
|
+
}
|
|
1196
|
+
if (session !== null && session !== undefined) {
|
|
1197
|
+
const revived = session.archived === false
|
|
1198
|
+
const running = session.running === true || session.live === true || session.unreadable === true
|
|
1199
|
+
if (revived || running || session.archived !== true) {
|
|
1200
|
+
failed.push({ trashId: candidate.trashId, code: 'E_SESSION_ALIVE' })
|
|
1201
|
+
continue
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: 'retention', requestId: requestId() })
|
|
1207
|
+
purged.push(entry.trashId)
|
|
1208
|
+
retentionRetries.delete(entry.trashId)
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
const normalized = asDeleteError(error)
|
|
1211
|
+
failed.push({ trashId: candidate.trashId, code: normalized.code })
|
|
1212
|
+
const failures = (retentionRetries.get(candidate.trashId)?.failures ?? 0) + 1
|
|
1213
|
+
const delay = Math.min(RETENTION_RETRY_BASE_MS * 2 ** (failures - 1), RETENTION_RETRY_MAX_MS)
|
|
1214
|
+
retentionRetries.set(candidate.trashId, { failures, nextAt: now() + delay })
|
|
1215
|
+
logger.warn?.(`[conversation-recycle-bin] retention: ${candidate.trashId} could not be purged (${normalized.code}); next attempt in ${Math.round(delay / 60_000)} min`)
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (purged.length > 0) {
|
|
1219
|
+
gateway.invalidate()
|
|
1220
|
+
invalidateTrashCache()
|
|
1221
|
+
}
|
|
1222
|
+
return { purged, failed, retentionDays }
|
|
1223
|
+
}, { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
|
|
1224
|
+
})()
|
|
1225
|
+
.catch((error) => {
|
|
1226
|
+
// A busy lock or a crashed scan is not an error the user can act on:
|
|
1227
|
+
// record it and let the next sweep try again.
|
|
1228
|
+
const normalized = asDeleteError(error)
|
|
1229
|
+
logger.warn?.(`[conversation-recycle-bin] retention sweep skipped (${normalized.code})`)
|
|
1230
|
+
return { purged: [], failed: [{ trashId: null, code: normalized.code }], retentionDays }
|
|
1231
|
+
})
|
|
1232
|
+
.then((result) => {
|
|
1233
|
+
lastRetentionResult = { at: now(), purged: result.purged?.length ?? 0, failed: result.failed?.length ?? 0 }
|
|
1234
|
+
return result
|
|
1235
|
+
})
|
|
1236
|
+
.finally(() => {
|
|
1237
|
+
retentionSweepInFlight = null
|
|
1238
|
+
lastRetentionSweepAt = now()
|
|
1239
|
+
})
|
|
1240
|
+
retentionSweepInFlight = run
|
|
1241
|
+
return run
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function auditTail(limit) {
|
|
1245
|
+
const parsed = Number.parseInt(String(limit ?? '50'), 10)
|
|
1246
|
+
const safe = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 200) : 50
|
|
1247
|
+
const result = await audit.tail(safe)
|
|
1248
|
+
// The audit file is on disk in full; the wire copy still must not carry
|
|
1249
|
+
// absolute host paths. `redactDetails` shortens path-like keys and strips
|
|
1250
|
+
// absolute paths out of prose, leaving ids/codes/counts intact.
|
|
1251
|
+
return { records: redactDetails(result.records), malformed: result.malformed }
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The plugin's own transport fence: loopback-only unless `allowNonLoopback`,
|
|
1256
|
+
* no cross-site requests, and — when `requireSameOrigin` — an Origin whose
|
|
1257
|
+
* full authority (hostname AND effective port) matches the request Host, or
|
|
1258
|
+
* the desktop `dsh-app://app` shell origin.
|
|
1259
|
+
*
|
|
1260
|
+
* This is the FIRST gate. The `connection` fence in `handler` is
|
|
1261
|
+
* authoritative when present; this one keeps the route safe in a composition
|
|
1262
|
+
* without it and produces the localized error codes the Client knows.
|
|
1263
|
+
*/
|
|
1264
|
+
function transportVerdict(req) {
|
|
1265
|
+
return requestOriginVerdict(req.headers, config)
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** Read the connection service without declaring it a hard dependency. */
|
|
1269
|
+
function connectionService() {
|
|
1270
|
+
try {
|
|
1271
|
+
const value = typeof ctx.get === 'function' ? ctx.get('connection') : ctx.connection
|
|
1272
|
+
return value ?? undefined
|
|
1273
|
+
} catch {
|
|
1274
|
+
return undefined
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* True when a real `connection` service is mounted. Probed rather than
|
|
1280
|
+
* injected: a composition without it must still load.
|
|
1281
|
+
*/
|
|
1282
|
+
function connectionFencePresent() {
|
|
1283
|
+
const connection = connectionService()
|
|
1284
|
+
return connection !== undefined && typeof connection.requestRejection === 'function'
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** `/state` (or the bare prefix) as a GET — the only route readable without a connection fence. */
|
|
1288
|
+
function isStateProbe(req) {
|
|
1289
|
+
if (String(req.method ?? 'GET').toUpperCase() !== 'GET') return false
|
|
1290
|
+
const pathname = new URL(req.url ?? '/', 'http://host.invalid').pathname
|
|
1291
|
+
const path = pathname.startsWith(ROUTE_PREFIX) ? pathname.slice(ROUTE_PREFIX.length) : pathname
|
|
1292
|
+
return path === '' || path === '/state'
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** One request → one response. Route matching stays here, not in `apply`. */
|
|
1296
|
+
const handleRequest = async (req, res) => {
|
|
1297
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
1298
|
+
const url = new URL(req.url ?? '/', 'http://host.invalid')
|
|
1299
|
+
const path = url.pathname.startsWith(ROUTE_PREFIX) ? url.pathname.slice(ROUTE_PREFIX.length) : url.pathname
|
|
1300
|
+
|
|
1301
|
+
const origin = transportVerdict(req)
|
|
1302
|
+
if (!origin.ok) {
|
|
1303
|
+
sendJson(res, origin.error.httpStatus, toWireError(origin.error))
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
if (method === 'GET' && (path === '' || path === '/state')) {
|
|
1309
|
+
sendJson(res, 200, { ok: true, state: await state() })
|
|
1310
|
+
return
|
|
1311
|
+
}
|
|
1312
|
+
if (method === 'GET' && path === '/sessions') {
|
|
1313
|
+
sendJson(res, 200, { ok: true, sessions: await sessionsForUi() })
|
|
1314
|
+
return
|
|
1315
|
+
}
|
|
1316
|
+
if (method === 'GET' && path === '/trash') {
|
|
1317
|
+
sendJson(res, 200, { ok: true, ...(await trashList()) })
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1320
|
+
if (method === 'GET' && path === '/audit') {
|
|
1321
|
+
sendJson(res, 200, { ok: true, ...(await auditTail(url.searchParams.get('limit'))) })
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
// A GET that matched no route is an unknown endpoint (404), not a 405:
|
|
1325
|
+
// 405 is reserved for non-POST verbs on a mounted prefix.
|
|
1326
|
+
if (method === 'GET') {
|
|
1327
|
+
throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
|
|
1328
|
+
}
|
|
1329
|
+
if (method !== 'POST') {
|
|
1330
|
+
throw new DeleteError('E_BAD_REQUEST', 'This endpoint accepts POST requests only.', {}, { httpStatus: 405 })
|
|
1331
|
+
}
|
|
1332
|
+
const header = req.headers?.[REQUIRED_HEADER]
|
|
1333
|
+
if (header !== '1') {
|
|
1334
|
+
throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
|
|
1335
|
+
}
|
|
1336
|
+
if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
|
|
1337
|
+
const body = await readJsonBody(req)
|
|
1338
|
+
|
|
1339
|
+
// Feedback is available even when deletion is switched off: the two
|
|
1340
|
+
// switches answer different questions, and a broken mail transport
|
|
1341
|
+
// must not be reported as "deletion disabled".
|
|
1342
|
+
if (path === '/feedback') {
|
|
1343
|
+
const result = await feedbackService.submit(body)
|
|
1344
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (path === '/preview') {
|
|
1349
|
+
const sessionIds = normalizeSessionIds(body)
|
|
1350
|
+
const mode = normalizeMode(body, config)
|
|
1351
|
+
const includeMemory = normalizeBoolean(body, 'includeMemory')
|
|
1352
|
+
sendJson(res, 200, { ok: true, preview: await preview({ sessionIds, includeMemory, mode }) })
|
|
1353
|
+
return
|
|
1354
|
+
}
|
|
1355
|
+
if (path === '/delete') {
|
|
1356
|
+
// Upper-bound lock wait mirroring preview's planTimeoutMs: a 50-item
|
|
1357
|
+
// batch legitimately outlives the default 30s, so a second Host waiting
|
|
1358
|
+
// on the lock should not be told "busy" while the first is still
|
|
1359
|
+
// working. Per-session export/restore plus a bounded live drain each.
|
|
1360
|
+
const rawIds = Array.isArray(body?.sessionIds)
|
|
1361
|
+
? body.sessionIds
|
|
1362
|
+
: typeof body?.sessionId === 'string'
|
|
1363
|
+
? [body.sessionId]
|
|
1364
|
+
: []
|
|
1365
|
+
const batchCount = Math.max(1, rawIds.length)
|
|
1366
|
+
const lockBudget = Math.min(900_000, 15_000 + batchCount * 16_000 + batchCount * config.liveDrainMs)
|
|
1367
|
+
const result = await serialize(() => deletePlan(body), {
|
|
1368
|
+
lockTimeoutMs: lockBudget,
|
|
1369
|
+
shouldAbort: () => clientGone(req, res),
|
|
1370
|
+
})
|
|
1371
|
+
// `ok` means "the plan was executed", not "every item succeeded".
|
|
1372
|
+
// A partial batch carries `results` + `failures`; marking the whole
|
|
1373
|
+
// response as an error hid those results and made the Client show
|
|
1374
|
+
// "The Host refused the request" while rows had already been deleted.
|
|
1375
|
+
sendJson(res, 200, { ok: true, partial: result.failures.length > 0, ...result })
|
|
1376
|
+
return
|
|
1377
|
+
}
|
|
1378
|
+
if (path === '/trash/restore') {
|
|
1379
|
+
const result = await serialize(() => restoreEntry(body?.trashId), {
|
|
1380
|
+
lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
|
|
1381
|
+
shouldAbort: () => clientGone(req, res),
|
|
1382
|
+
})
|
|
1383
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
1384
|
+
return
|
|
1385
|
+
}
|
|
1386
|
+
if (path === '/trash/purge') {
|
|
1387
|
+
const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), {
|
|
1388
|
+
lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
|
|
1389
|
+
shouldAbort: () => clientGone(req, res),
|
|
1390
|
+
})
|
|
1391
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
if (path === '/trash/discard') {
|
|
1395
|
+
const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), {
|
|
1396
|
+
lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
|
|
1397
|
+
shouldAbort: () => clientGone(req, res),
|
|
1398
|
+
})
|
|
1399
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
const normalized = asDeleteError(error)
|
|
1405
|
+
logger.error?.(`[conversation-recycle-bin] ${method} ${path}: ${normalized.code} ${normalized.message}`)
|
|
1406
|
+
sendJson(res, normalized.httpStatus, toWireError(normalized))
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Route entry point.
|
|
1412
|
+
*
|
|
1413
|
+
* The plugin mounts a `webServer` prefix directly, so it does NOT pass
|
|
1414
|
+
* through `/api` and therefore bypasses DSH's `connection` layer by default.
|
|
1415
|
+
* That layer is not cosmetic: `connection.admit()` applies the Host/Origin
|
|
1416
|
+
* fence and the browser-session admission, and the desktop update task
|
|
1417
|
+
* control listens on `connection/request` to refuse new requests (503) and
|
|
1418
|
+
* drain in-flight ones while an app update installs. Without participating,
|
|
1419
|
+
* an update or a quit could kill a Host in the middle of a deletion
|
|
1420
|
+
* transaction.
|
|
1421
|
+
*
|
|
1422
|
+
* The service is probed, not injected. With `requireConnection` (the default)
|
|
1423
|
+
* a composition that has no `connection` service refuses every mutation
|
|
1424
|
+
* instead of silently degrading to the loopback/origin fence alone — that
|
|
1425
|
+
* fence cannot tell one local process from another, so "no connection layer"
|
|
1426
|
+
* means "any process on this machine can delete". Operators who deliberately
|
|
1427
|
+
* run such a composition set `requireConnection: false`.
|
|
1428
|
+
*/
|
|
1429
|
+
const handler = (req, res) => {
|
|
1430
|
+
void (async () => {
|
|
1431
|
+
const connection = connectionService()
|
|
1432
|
+
const hasConnectionFence = connectionFencePresent()
|
|
1433
|
+
if (!hasConnectionFence && config.requireConnection !== false) {
|
|
1434
|
+
// `/state` stays readable: it carries no conversation data (limits,
|
|
1435
|
+
// adapters, switches) and it is how the panel explains why everything
|
|
1436
|
+
// else is refused. Every other route — including the session list and
|
|
1437
|
+
// the trash — stays 401, because the remaining fence cannot tell one
|
|
1438
|
+
// local process from another.
|
|
1439
|
+
if (isStateProbe(req)) {
|
|
1440
|
+
const origin = transportVerdict(req)
|
|
1441
|
+
if (!origin.ok) {
|
|
1442
|
+
sendJson(res, origin.error.httpStatus, toWireError(origin.error))
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
sendJson(res, 200, { ok: true, state: await state() })
|
|
1446
|
+
return
|
|
1447
|
+
}
|
|
1448
|
+
logger.error?.('[conversation-recycle-bin] no connection service; refusing request because requireConnection is enabled')
|
|
1449
|
+
sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This Host has no connection layer to authenticate the request; set requireConnection: false to allow it.', {})))
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
1452
|
+
if (hasConnectionFence) {
|
|
1453
|
+
let rejection
|
|
1454
|
+
try {
|
|
1455
|
+
rejection = connection.requestRejection(req)
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
// Fail closed. A connection layer that throws cannot be read as
|
|
1458
|
+
// "no opinion": letting the request through here would turn any
|
|
1459
|
+
// auth-layer fault into an unauthenticated delete.
|
|
1460
|
+
logger.error?.('[conversation-recycle-bin] connection check failed:', error)
|
|
1461
|
+
sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', {})))
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
// The documented contract is `401`, `403`, or undefined for "allowed".
|
|
1465
|
+
// Anything else (including a future `503`-style value) is a refusal:
|
|
1466
|
+
// only an explicit absence of a verdict lets the request through.
|
|
1467
|
+
if (rejection !== undefined && rejection !== null) {
|
|
1468
|
+
if (rejection === 401) {
|
|
1469
|
+
sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This request is not authenticated with the Host.', {})))
|
|
1470
|
+
return
|
|
1471
|
+
}
|
|
1472
|
+
if (rejection === 403) {
|
|
1473
|
+
sendJson(res, 403, toWireError(new DeleteError('E_FORBIDDEN_ORIGIN', 'The Host refused this request at the connection layer.', {})))
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', { verdict: rejection })))
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const run = () => handleRequest(req, res)
|
|
1481
|
+
if (typeof ctx.waterfall === 'function') {
|
|
1482
|
+
// Listeners (e.g. the desktop update lock) run outermost-first and may
|
|
1483
|
+
// veto by never calling `next()`; when none is registered the built-in
|
|
1484
|
+
// `run` executes directly.
|
|
1485
|
+
await ctx.waterfall('connection/request', req, res, run)
|
|
1486
|
+
} else {
|
|
1487
|
+
await run()
|
|
1488
|
+
}
|
|
1489
|
+
})().catch((error) => {
|
|
1490
|
+
logger.error?.('[conversation-recycle-bin] route failure:', error)
|
|
1491
|
+
sendJson(res, 500, toWireError(asDeleteError(error)))
|
|
1492
|
+
})
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
return {
|
|
1496
|
+
handler,
|
|
1497
|
+
routePrefix: ROUTE_PREFIX,
|
|
1498
|
+
/**
|
|
1499
|
+
* Run one retention sweep. Called by the Host timer in `index.js` and by
|
|
1500
|
+
* `/trash` (throttled); exposed so the entry point does not need to know
|
|
1501
|
+
* how the sweep selects its candidates.
|
|
1502
|
+
*/
|
|
1503
|
+
sweepExpiredTrash,
|
|
1504
|
+
handler_services: { gateway, memoryService, trash, audit, limiter, feedbackService },
|
|
1505
|
+
// exported for tests
|
|
1506
|
+
__test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, sweepExpiredTrash, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
|
|
1507
|
+
}
|
|
1508
|
+
}
|