dsh-delete-session 0.1.9 → 0.2.0

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