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