dsh-conversation-recycle-bin 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +454 -412
- package/README.md +8 -8
- package/client.js +75 -38
- package/lib/core/cascade.js +56 -23
- package/lib/core/file-lock.js +43 -8
- package/lib/core/policy.js +126 -119
- package/lib/core/trash.js +245 -227
- package/lib/host/api.js +182 -37
- package/lib/host/catalog.js +9 -2
- package/lib/host/gateway.js +368 -351
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
package/lib/core/policy.js
CHANGED
|
@@ -1,119 +1,126 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Authorization and data-ownership policy.
|
|
3
|
-
*
|
|
4
|
-
* The Host is a local single-user application, so "who is the operator" is
|
|
5
|
-
* answered by the transport (same-origin loopback route, user-only UI) and not
|
|
6
|
-
* by a user database. What remains is *data ownership*: a session must exist,
|
|
7
|
-
* must belong to a registered workspace (unless the operator opted into orphan
|
|
8
|
-
* deletion), must not be a delegated subagent's private session, and must not
|
|
9
|
-
* be listed as protected. Hard deletion of a live session is allowed by
|
|
10
|
-
* default because the cascade quiesces it first (cancel the turn, archive with
|
|
11
|
-
* `stopActivity`, then drain); `stopLiveSessions: false` restores a strict
|
|
12
|
-
* refusal for operators who do not want the plugin to touch running work.
|
|
13
|
-
*
|
|
14
|
-
* Every check returns a `DeleteError` so route and tests share one wording.
|
|
15
|
-
*/
|
|
16
|
-
import { DeleteError } from './errors.js'
|
|
17
|
-
|
|
18
|
-
/** Normalize a Windows/Unix path for containment checks (no filesystem access). */
|
|
19
|
-
export function normalizePathForCompare(input) {
|
|
20
|
-
if (typeof input !== 'string' || input === '') return ''
|
|
21
|
-
let value = input.replace(/\\/g, '/').replace(/\/+$/g, '')
|
|
22
|
-
// Windows paths compare case-insensitively: drive-lettered paths and UNC
|
|
23
|
-
// roots (`\\server\share`, `\\?\C:\...`). POSIX paths stay case-sensitive so
|
|
24
|
-
// a root check cannot become broader than the filesystem's own identity rules.
|
|
25
|
-
if (/^[A-Za-z]:\//.test(value) || value.startsWith('//')) value = value.toLowerCase()
|
|
26
|
-
return value
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** True when `candidate` equals `root` or lies below it. */
|
|
30
|
-
export function isPathInside(root, candidate) {
|
|
31
|
-
const normalizedRoot = normalizePathForCompare(root)
|
|
32
|
-
const normalizedCandidate = normalizePathForCompare(candidate)
|
|
33
|
-
if (normalizedRoot === '' || normalizedCandidate === '') return false
|
|
34
|
-
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Evaluate the policy for ONE session.
|
|
39
|
-
*
|
|
40
|
-
* @param {object} input
|
|
41
|
-
* @param {{ id: string, title?: string, cwd?: string, origin?: string, blank?: boolean }} input.session
|
|
42
|
-
* @param {{ id: string, path: string, title?: string } | null} input.workspace owning workspace, if any
|
|
43
|
-
* @param {boolean} input.live whether
|
|
44
|
-
*
|
|
45
|
-
* @param {
|
|
46
|
-
* @
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
warnings.push('The conversation is
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Authorization and data-ownership policy.
|
|
3
|
+
*
|
|
4
|
+
* The Host is a local single-user application, so "who is the operator" is
|
|
5
|
+
* answered by the transport (same-origin loopback route, user-only UI) and not
|
|
6
|
+
* by a user database. What remains is *data ownership*: a session must exist,
|
|
7
|
+
* must belong to a registered workspace (unless the operator opted into orphan
|
|
8
|
+
* deletion), must not be a delegated subagent's private session, and must not
|
|
9
|
+
* be listed as protected. Hard deletion of a live session is allowed by
|
|
10
|
+
* default because the cascade quiesces it first (cancel the turn, archive with
|
|
11
|
+
* `stopActivity`, then drain); `stopLiveSessions: false` restores a strict
|
|
12
|
+
* refusal for operators who do not want the plugin to touch running work.
|
|
13
|
+
*
|
|
14
|
+
* Every check returns a `DeleteError` so route and tests share one wording.
|
|
15
|
+
*/
|
|
16
|
+
import { DeleteError } from './errors.js'
|
|
17
|
+
|
|
18
|
+
/** Normalize a Windows/Unix path for containment checks (no filesystem access). */
|
|
19
|
+
export function normalizePathForCompare(input) {
|
|
20
|
+
if (typeof input !== 'string' || input === '') return ''
|
|
21
|
+
let value = input.replace(/\\/g, '/').replace(/\/+$/g, '')
|
|
22
|
+
// Windows paths compare case-insensitively: drive-lettered paths and UNC
|
|
23
|
+
// roots (`\\server\share`, `\\?\C:\...`). POSIX paths stay case-sensitive so
|
|
24
|
+
// a root check cannot become broader than the filesystem's own identity rules.
|
|
25
|
+
if (/^[A-Za-z]:\//.test(value) || value.startsWith('//')) value = value.toLowerCase()
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when `candidate` equals `root` or lies below it. */
|
|
30
|
+
export function isPathInside(root, candidate) {
|
|
31
|
+
const normalizedRoot = normalizePathForCompare(root)
|
|
32
|
+
const normalizedCandidate = normalizePathForCompare(candidate)
|
|
33
|
+
if (normalizedRoot === '' || normalizedCandidate === '') return false
|
|
34
|
+
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Evaluate the policy for ONE session.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} input
|
|
41
|
+
* @param {{ id: string, title?: string, cwd?: string, origin?: string, blank?: boolean }} input.session
|
|
42
|
+
* @param {{ id: string, path: string, title?: string } | null} input.workspace owning workspace, if any
|
|
43
|
+
* @param {boolean} input.live whether the Host still knows the session at all; used
|
|
44
|
+
* only as a fallback when `session.running` is unknown
|
|
45
|
+
* @param {'soft'|'hard'} input.mode requested deletion mode
|
|
46
|
+
* @param {ReturnType<import('./config.js').normalizeConfig>['value']} input.config
|
|
47
|
+
* @returns {{ ok: true, warnings: string[] } | { ok: false, error: DeleteError }}
|
|
48
|
+
*/
|
|
49
|
+
export function evaluateSessionPolicy({ session, workspace, live, mode, config }) {
|
|
50
|
+
const warnings = []
|
|
51
|
+
const id = String(session?.id ?? '')
|
|
52
|
+
// "Exists" is not "busy". `live` says the Host still knows the session, which
|
|
53
|
+
// is true for every idle conversation too; gating a hard delete on it made
|
|
54
|
+
// `stopLiveSessions: false` refuse to delete conversations nobody was using.
|
|
55
|
+
// When the Host does not report a status at all we keep the old, stricter
|
|
56
|
+
// reading: unknown counts as running.
|
|
57
|
+
const running = session?.running === true || (session?.running === undefined && live === true)
|
|
58
|
+
|
|
59
|
+
if (config.protectedSessionIds.includes(id)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: new DeleteError('E_POLICY_PROTECTED', 'This conversation is protected by configuration and cannot be deleted.', {
|
|
63
|
+
sessionId: id,
|
|
64
|
+
}),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (session?.origin === 'subagent' && !config.allowSubagentSessions) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: new DeleteError('E_POLICY_SUBAGENT', 'Subagent sessions cannot be deleted unless explicitly allowed.', {
|
|
72
|
+
sessionId: id,
|
|
73
|
+
}),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (workspace === null || workspace === undefined) {
|
|
78
|
+
if (!config.allowOrphanSessions) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: new DeleteError('E_POLICY_ORPHAN', 'This conversation is not owned by a registered workspace.', {
|
|
82
|
+
sessionId: id,
|
|
83
|
+
cwd: session?.cwd ?? null,
|
|
84
|
+
}),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
warnings.push('The conversation has no owning workspace; orphan deletion is enabled.')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.allowedWorkspaceRoots.length > 0) {
|
|
91
|
+
const cwd = session?.cwd ?? workspace?.path
|
|
92
|
+
const allowed = config.allowedWorkspaceRoots.some((root) => isPathInside(root, cwd ?? ''))
|
|
93
|
+
if (!allowed) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: new DeleteError('E_POLICY_ROOT', 'This conversation is outside the allowed workspace roots.', {
|
|
97
|
+
sessionId: id,
|
|
98
|
+
cwd: cwd ?? null,
|
|
99
|
+
}),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (mode === 'hard' && running) {
|
|
105
|
+
if (!config.stopLiveSessions) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: new DeleteError('E_POLICY_LIVE', 'This conversation is still open in a live agent, and stopLiveSessions is disabled.', {
|
|
109
|
+
sessionId: id,
|
|
110
|
+
}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
warnings.push('The conversation is open in a live agent; it will be stopped before its log is removed.')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A soft delete also quiesces the session (`archiveSession(id, { stopActivity:
|
|
117
|
+
// true })`), so it interrupts a running turn exactly like a hard delete does.
|
|
118
|
+
// Say so before the click instead of after the round died.
|
|
119
|
+
if (mode === 'soft' && running) {
|
|
120
|
+
warnings.push('The conversation is running right now; a soft delete stops the current turn before moving it to the recycle bin.')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (session?.blank === true) warnings.push('This is an empty conversation.')
|
|
124
|
+
|
|
125
|
+
return { ok: true, warnings }
|
|
126
|
+
}
|