dsh-native-session-delete 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ import { lstat, mkdtemp, realpath, rename, rm } from 'node:fs/promises'
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+
4
+ const failure = (code, message) => ({ ok: false, error: { code, message } })
5
+ const MAX_REQUEST_BYTES = 8 * 1024
6
+
7
+ /**
8
+ * Retain the lifecycle capabilities returned by the public AgentRegistry API.
9
+ * DSH intentionally exposes only a bare Agent from get(); deletion needs the
10
+ * original handle so the host can cancel, drain, unregister, and detach in its
11
+ * own supported order.
12
+ */
13
+ export function installAgentHandleTracker(agents, sessions) {
14
+ const handles = new Map()
15
+ const reservations = new Set()
16
+ const originalCreate = agents.create
17
+ const originalResume = agents.resume
18
+ const originalAgentEnter = agents.enter
19
+ const originalSessionEnter = sessions?.enter
20
+
21
+ const assertAvailable = (sessionId) => {
22
+ if (typeof sessionId === 'string' && reservations.has(sessionId)) {
23
+ throw new Error(`session "${sessionId}" is being permanently deleted`)
24
+ }
25
+ }
26
+
27
+ const track = (handle) => {
28
+ if (handle?.agent?.id !== undefined && typeof handle.dispose === 'function') {
29
+ handles.set(handle.agent.id, handle)
30
+ }
31
+ return handle
32
+ }
33
+ const wrappedCreate = async function (...args) {
34
+ assertAvailable(args[0]?.sessionId)
35
+ return track(await Reflect.apply(originalCreate, this, args))
36
+ }
37
+ const wrappedResume = async function (...args) {
38
+ assertAvailable(args[0]?.resumeSessionId)
39
+ return track(await Reflect.apply(originalResume, this, args))
40
+ }
41
+ const wrappedAgentEnter = typeof originalAgentEnter === 'function'
42
+ ? function (...args) {
43
+ assertAvailable(args[0]?.id)
44
+ return Reflect.apply(originalAgentEnter, this, args)
45
+ }
46
+ : undefined
47
+ const wrappedSessionEnter = typeof originalSessionEnter === 'function'
48
+ ? function (...args) {
49
+ assertAvailable(args[0]?.id)
50
+ return Reflect.apply(originalSessionEnter, this, args)
51
+ }
52
+ : undefined
53
+
54
+ agents.create = wrappedCreate
55
+ agents.resume = wrappedResume
56
+ if (wrappedAgentEnter !== undefined) agents.enter = wrappedAgentEnter
57
+ if (wrappedSessionEnter !== undefined) sessions.enter = wrappedSessionEnter
58
+ let releaseStarted = false
59
+ let released = false
60
+ let resolveRelease
61
+ let releaseTask
62
+
63
+ const finishRelease = () => {
64
+ if (!releaseStarted || released || reservations.size !== 0) return
65
+ released = true
66
+ handles.clear()
67
+ if (agents.create === wrappedCreate) agents.create = originalCreate
68
+ if (agents.resume === wrappedResume) agents.resume = originalResume
69
+ if (wrappedAgentEnter !== undefined && agents.enter === wrappedAgentEnter) agents.enter = originalAgentEnter
70
+ if (wrappedSessionEnter !== undefined && sessions.enter === wrappedSessionEnter) sessions.enter = originalSessionEnter
71
+ resolveRelease?.()
72
+ }
73
+
74
+ return {
75
+ reserve(sessionId) {
76
+ if (releaseStarted || reservations.has(sessionId)) return undefined
77
+ reservations.add(sessionId)
78
+ let active = true
79
+ return () => {
80
+ if (!active) return
81
+ active = false
82
+ reservations.delete(sessionId)
83
+ finishRelease()
84
+ }
85
+ },
86
+ async dispose(sessionId) {
87
+ const handle = handles.get(sessionId)
88
+ if (handle === undefined || agents.get(sessionId) !== handle.agent) {
89
+ handles.delete(sessionId)
90
+ return false
91
+ }
92
+ await handle.dispose()
93
+ handles.delete(sessionId)
94
+ return true
95
+ },
96
+ release() {
97
+ if (released) return releaseTask
98
+ if (!releaseStarted) {
99
+ releaseStarted = true
100
+ releaseTask = new Promise(resolve => { resolveRelease = resolve })
101
+ finishRelease()
102
+ }
103
+ return releaseTask
104
+ },
105
+ }
106
+ }
107
+
108
+ const sendJson = (res, status, body) => {
109
+ res.writeHead(status, {
110
+ 'content-type': 'application/json; charset=utf-8',
111
+ 'cache-control': 'no-store',
112
+ 'x-content-type-options': 'nosniff',
113
+ })
114
+ res.end(JSON.stringify(body))
115
+ }
116
+
117
+ const readJsonBody = async (req) => {
118
+ const chunks = []
119
+ let size = 0
120
+ for await (const chunk of req) {
121
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
122
+ size += buffer.length
123
+ if (size > MAX_REQUEST_BYTES) throw new Error('request-too-large')
124
+ chunks.push(buffer)
125
+ }
126
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
127
+ }
128
+
129
+ const sameHeader = (left, right) => (
130
+ left?.id === right?.id
131
+ && left?.version === right?.version
132
+ && left?.createdAt === right?.createdAt
133
+ && left?.cwd === right?.cwd
134
+ && left?.parentSession === right?.parentSession
135
+ && left?.seedLength === right?.seedLength
136
+ && left?.origin === right?.origin
137
+ && (left?.delegationDepth ?? 0) === (right?.delegationDepth ?? 0)
138
+ && left?.agentPreset === right?.agentPreset
139
+ )
140
+
141
+ const inside = (root, target) => {
142
+ const path = relative(root, target)
143
+ return path !== '' && !path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path)
144
+ }
145
+
146
+ const missing = error => error?.code === 'ENOENT'
147
+
148
+ const sameFile = (left, right) => (
149
+ left.dev === right.dev
150
+ && left.ino === right.ino
151
+ && left.mode === right.mode
152
+ && left.size === right.size
153
+ && left.birthtimeNs === right.birthtimeNs
154
+ )
155
+
156
+ const locationPaths = (sessionRoot, location) => {
157
+ if (location?.kind !== 'jsonl' || typeof location.path !== 'string') return undefined
158
+ const root = resolve(sessionRoot)
159
+ const transcript = resolve(location.path)
160
+ if (!isAbsolute(root) || !isAbsolute(transcript) || !inside(root, transcript)) return undefined
161
+ const parts = relative(root, transcript).split(sep).filter(Boolean)
162
+ if (parts.length !== 3 || !['session.jsonl', 'session.jsonl.zstd'].includes(parts[2])) return undefined
163
+ return {
164
+ root,
165
+ projectDirectory: join(root, parts[0]),
166
+ sessionDirectory: join(root, parts[0], parts[1]),
167
+ transcript,
168
+ }
169
+ }
170
+
171
+ const validateExistingLocation = async (paths) => {
172
+ const [root, project, directory, transcript] = await Promise.all([
173
+ realpath(paths.root),
174
+ lstat(paths.projectDirectory, { bigint: true }),
175
+ lstat(paths.sessionDirectory, { bigint: true }),
176
+ lstat(paths.transcript, { bigint: true }),
177
+ ])
178
+ if (
179
+ !project.isDirectory()
180
+ || project.isSymbolicLink()
181
+ || !directory.isDirectory()
182
+ || directory.isSymbolicLink()
183
+ || !transcript.isFile()
184
+ || transcript.isSymbolicLink()
185
+ ) return undefined
186
+
187
+ const [resolvedProject, resolvedDirectory, resolvedTranscript] = await Promise.all([
188
+ realpath(paths.projectDirectory),
189
+ realpath(paths.sessionDirectory),
190
+ realpath(paths.transcript),
191
+ ])
192
+ if (
193
+ !inside(root, resolvedProject)
194
+ || !inside(root, resolvedDirectory)
195
+ || dirname(resolvedTranscript) !== resolvedDirectory
196
+ ) return undefined
197
+ return { root, resolvedDirectory, directory, transcript }
198
+ }
199
+
200
+ const pathExists = async (path) => {
201
+ try {
202
+ await lstat(path)
203
+ return true
204
+ } catch (error) {
205
+ if (missing(error)) return false
206
+ throw error
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Permanently remove one JSONL session directory. A live Agent is first torn
212
+ * down through its owned host handle; persistence retirement is then awaited
213
+ * before the same path and identity checks used for a cold session.
214
+ */
215
+ export async function deleteSessionSafely(deps, { sessionRoot, sessionId }) {
216
+ if (!deps.sessionPersistence.supportsRawArtifacts) {
217
+ return failure('unsupported-backend', '当前会话存储不是可逐会话删除的 JSONL 后端。')
218
+ }
219
+
220
+ const releaseReservation = deps.agentHandles.reserve?.(sessionId)
221
+ if (typeof deps.agentHandles.reserve === 'function' && releaseReservation === undefined) {
222
+ return failure('deletion-in-progress', '该会话正在删除中,请等待当前操作完成。')
223
+ }
224
+
225
+ try {
226
+ return await deleteReservedSession(deps, { sessionRoot, sessionId })
227
+ } finally {
228
+ releaseReservation?.()
229
+ }
230
+ }
231
+
232
+ async function deleteReservedSession(deps, { sessionRoot, sessionId }) {
233
+ let header = (await deps.sessionPersistence.list()).find(item => item.id === sessionId)
234
+ ?? deps.sessions.get(sessionId)?.header
235
+ if (header === undefined) return failure('session-not-found', '会话不存在或已经删除。')
236
+
237
+ if (deps.agents.get(sessionId) !== undefined) {
238
+ let disposed
239
+ try {
240
+ disposed = await deps.agentHandles.dispose(sessionId)
241
+ } catch {
242
+ return failure('lifecycle-error', 'DSH 未能安全停止并摘载该会话,未删除任何内容。')
243
+ }
244
+ if (!disposed) {
245
+ return failure('lifecycle-unavailable', '无法取得该会话的宿主生命周期句柄,未删除任何内容。请确认插件已更新并在更新后重启 DeepSeek Harness。')
246
+ }
247
+ }
248
+
249
+ if (deps.sessions.get(sessionId) !== undefined || deps.agents.get(sessionId) !== undefined) {
250
+ return failure('lifecycle-unavailable', '宿主未能完整摘载该会话,未删除任何内容。')
251
+ }
252
+
253
+ // inspect() waits for the persistence backend's asynchronous retirement
254
+ // drain. A never-materialized blank session is already fully removed here.
255
+ try {
256
+ const inspected = await deps.sessionPersistence.inspect(sessionId)
257
+ if (!sameHeader(header, inspected.meta)) {
258
+ return failure('unsafe-location', '会话在摘载期间发生了身份变化,未删除任何文件。')
259
+ }
260
+ header = inspected.meta
261
+ } catch (error) {
262
+ const stillStored = (await deps.sessionPersistence.list()).some(item => item.id === sessionId)
263
+ if (!stillStored && deps.sessions.get(sessionId) === undefined && deps.agents.get(sessionId) === undefined) {
264
+ const missingPaths = locationPaths(sessionRoot, deps.sessionPersistence.locate(header))
265
+ if (missingPaths !== undefined && !await pathExists(missingPaths.sessionDirectory)) {
266
+ return { ok: true, value: { deleted: true } }
267
+ }
268
+ return failure('storage-state-unknown', 'DSH 已摘载会话,但无法确认其存储是否已删除。')
269
+ }
270
+ return failure('storage-state-unknown', '无法确认会话存储状态,未继续删除。')
271
+ }
272
+
273
+ const location = deps.sessionPersistence.locate(header)
274
+ const raw = await deps.sessionPersistence.readRaw(sessionId)
275
+ const paths = locationPaths(sessionRoot, location)
276
+ if (paths === undefined || raw === undefined || !sameHeader(header, raw.meta)) {
277
+ return failure('unsafe-location', '无法验证该会话的独立 JSONL 存储位置。')
278
+ }
279
+
280
+ let validated
281
+ try {
282
+ validated = await validateExistingLocation(paths)
283
+ } catch (error) {
284
+ if (error?.code === 'ENOENT') return failure('session-not-found', '会话不存在或已经删除。')
285
+ return failure('storage-error', '读取会话存储失败,未删除任何文件。')
286
+ }
287
+ if (validated === undefined) {
288
+ return failure('unsafe-location', '会话存储位置未通过安全校验,未删除任何文件。')
289
+ }
290
+ if (deps.sessions.get(sessionId) !== undefined || deps.agents.get(sessionId) !== undefined) {
291
+ return failure('session-reopened', '该会话在删除过程中被重新打开,已取消删除。')
292
+ }
293
+
294
+ let quarantineRoot
295
+ let quarantinedDirectory
296
+ let detached = false
297
+ try {
298
+ const moveDirectory = deps.moveDirectory ?? rename
299
+ quarantineRoot = await mkdtemp(join(dirname(validated.root), '.dsh-session-delete-'))
300
+ quarantinedDirectory = join(quarantineRoot, basename(validated.resolvedDirectory))
301
+ await moveDirectory(validated.resolvedDirectory, quarantinedDirectory)
302
+ detached = true
303
+
304
+ const quarantinedTranscript = join(quarantinedDirectory, basename(paths.transcript))
305
+ const [directoryAfter, transcriptAfter] = await Promise.all([
306
+ lstat(quarantinedDirectory, { bigint: true }),
307
+ lstat(quarantinedTranscript, { bigint: true }),
308
+ ])
309
+ if (
310
+ directoryAfter.isSymbolicLink()
311
+ || transcriptAfter.isSymbolicLink()
312
+ || !sameFile(validated.directory, directoryAfter)
313
+ || !sameFile(validated.transcript, transcriptAfter)
314
+ ) {
315
+ await moveDirectory(quarantinedDirectory, validated.resolvedDirectory)
316
+ detached = false
317
+ await rm(quarantineRoot, { recursive: true, force: true })
318
+ return failure('unsafe-location', '会话目录在删除期间发生了身份变化,未删除任何文件。')
319
+ }
320
+
321
+ const removeDirectory = deps.removeDirectory
322
+ ?? (directory => rm(directory, { recursive: true, force: false }))
323
+ await removeDirectory(quarantinedDirectory)
324
+ if (await pathExists(quarantinedDirectory)) {
325
+ return failure('storage-partial', '会话已从 DSH 摘载,但存储清理未完成,不能确认永久删除成功。')
326
+ }
327
+ await rm(quarantineRoot, { recursive: true, force: true })
328
+ return { ok: true, value: { deleted: true } }
329
+ } catch (error) {
330
+ if (detached && quarantinedDirectory !== undefined) {
331
+ let remains = true
332
+ try {
333
+ remains = await pathExists(quarantinedDirectory)
334
+ } catch {
335
+ // Inaccessible storage is not proof of cleanup; retain the quarantine.
336
+ }
337
+ if (remains) {
338
+ return failure('storage-partial', '会话已从 DSH 摘载,但存储清理未完成,不能确认永久删除成功。')
339
+ }
340
+ if (quarantineRoot !== undefined) await rm(quarantineRoot, { recursive: true, force: true }).catch(() => undefined)
341
+ return { ok: true, value: { deleted: true } }
342
+ }
343
+ if (quarantineRoot !== undefined) await rm(quarantineRoot, { recursive: true, force: true }).catch(() => undefined)
344
+ if (missing(error)) return failure('session-not-found', '会话不存在或已经删除。')
345
+ return failure('storage-error', '存储删除失败,未删除任何文件。')
346
+ }
347
+ }
348
+
349
+ // Kept as an import-compatible alias for 0.1.0 consumers.
350
+ export const deleteColdSession = deleteSessionSafely
351
+
352
+ /**
353
+ * HTTP boundary for the destructive operation. The native client must make a
354
+ * same-origin JSON POST and include a confirmation-only header that ordinary
355
+ * links and HTML forms cannot add.
356
+ */
357
+ export function createDeleteRequestHandler({ deleteSession }) {
358
+ return async (req, res) => {
359
+ const host = req.headers.host
360
+ const origin = req.headers.origin
361
+ const contentType = req.headers['content-type']
362
+ const confirmation = req.headers['x-dsh-session-delete-confirmation']
363
+
364
+ if (req.method !== 'POST') {
365
+ sendJson(res, 405, failure('method-not-allowed', '只允许使用 POST 删除会话。'))
366
+ return
367
+ }
368
+ if (
369
+ typeof host !== 'string'
370
+ || origin !== `http://${host}`
371
+ || confirmation !== 'delete-session'
372
+ ) {
373
+ sendJson(res, 403, failure('forbidden', '删除请求未通过同源与确认校验。'))
374
+ return
375
+ }
376
+ if (typeof contentType !== 'string' || !contentType.toLowerCase().startsWith('application/json')) {
377
+ sendJson(res, 415, failure('unsupported-media-type', '删除请求必须使用 JSON。'))
378
+ return
379
+ }
380
+
381
+ let body
382
+ try {
383
+ body = await readJsonBody(req)
384
+ } catch (error) {
385
+ const tooLarge = error instanceof Error && error.message === 'request-too-large'
386
+ sendJson(
387
+ res,
388
+ tooLarge ? 413 : 400,
389
+ failure(tooLarge ? 'request-too-large' : 'invalid-json', tooLarge ? '删除请求过大。' : '删除请求不是有效 JSON。'),
390
+ )
391
+ return
392
+ }
393
+ if (typeof body?.sessionId !== 'string' || body.sessionId.length === 0 || body.sessionId.length > 512) {
394
+ sendJson(res, 400, failure('invalid-session-id', '会话 ID 无效。'))
395
+ return
396
+ }
397
+
398
+ try {
399
+ const result = await deleteSession(body.sessionId)
400
+ sendJson(res, result.ok ? 200 : 409, result)
401
+ } catch {
402
+ sendJson(res, 500, failure('internal', '删除过程中发生未预期错误,未确认删除成功。'))
403
+ }
404
+ }
405
+ }
package/src/index.js ADDED
@@ -0,0 +1,43 @@
1
+ import {
2
+ createDeleteRequestHandler,
3
+ deleteSessionSafely,
4
+ installAgentHandleTracker,
5
+ } from './host/delete-session.mjs'
6
+
7
+ export const name = 'dsh-session-delete'
8
+ export const inject = ['webServer', 'sessionPersistence', 'sessions', 'agents']
9
+
10
+ export function apply(ctx) {
11
+ const sessionRoot = ctx.sessionPersistence?.root
12
+ if (typeof sessionRoot !== 'string' || sessionRoot.length === 0) {
13
+ throw new Error('dsh-session-delete requires the per-session JSONL persistence backend')
14
+ }
15
+
16
+ const agentHandles = installAgentHandleTracker(ctx.agents, ctx.sessions)
17
+ ctx.effect(() => () => agentHandles.release(), 'dsh-session-delete: agent lifecycle tracking')
18
+
19
+ const handler = createDeleteRequestHandler({
20
+ deleteSession: sessionId => deleteSessionSafely({
21
+ sessions: ctx.sessions,
22
+ agents: ctx.agents,
23
+ agentHandles,
24
+ sessionPersistence: ctx.sessionPersistence,
25
+ }, { sessionRoot, sessionId }),
26
+ })
27
+
28
+ ctx.effect(
29
+ () => ctx.webServer.register({
30
+ kind: 'exact',
31
+ path: '/plugins/dsh-session-delete/delete',
32
+ handler,
33
+ }),
34
+ 'dsh-session-delete: confirmed permanent deletion route',
35
+ )
36
+ }
37
+
38
+ export {
39
+ createDeleteRequestHandler,
40
+ deleteColdSession,
41
+ deleteSessionSafely,
42
+ installAgentHandleTracker,
43
+ } from './host/delete-session.mjs'