dsh-conversation-recycle-bin 0.3.15 → 0.3.16
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 +27 -0
- package/README.md +1 -1
- package/client.js +28 -8
- package/cordis.patch.yml +6 -0
- package/lib/core/memory/json-index-adapter.js +32 -2
- package/lib/core/policy.js +126 -126
- package/lib/core/trash.js +245 -245
- package/lib/host/gateway.js +368 -368
- package/lib/host/paths.js +27 -7
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.3.16] — 2026-11-03
|
|
7
|
+
|
|
8
|
+
一次针对源码本身的复查(逐模块读 Host 半边 + 记忆适配器、`client.js` 相关路径)。两条真缺陷、三处健壮性改进、两处文档不一致。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **记忆节点的 `nodeId` 能决定文件落点(路径穿越,已复现)**:`nodeId` 是外部记忆库 JSON 的 key(md_cg 的 `_index.json`,或 `memory.generic.indexFile` 指向的任何 JSON),插件此前直接拼成 `${nodeId}.content` 当作回收站里的文件名。一个 `../../../../escaped-node` 的 key 会把内容文件移出 `trash/<id>/memory/<adapter>/files/`,落到磁盘任意可写目录(文件名后缀固定 `.content`)。现在普通 id 保持历史命名(旧 bundle 仍可恢复),任何不是「纯文件名字符集」的 id 改用 `sha256(id)` 前 32 位命名;恢复时 `entry.storedFile` 还必须满足 `basename(x) === x`,否则抛 `E_TRASH_CONFLICT`。
|
|
13
|
+
- **无 overlay 座位时「反馈」仍是死按钮**:0.3.15 的内联确认框把 `kind: 'feedback'` 排除了,而反馈横幅就在同一个设置面板里,于是在 dsh-go 原生界面上点「反馈」什么都不发生。现在内联渲染按 `kind` 分派 `FeedbackDialog` / `DeleteDialog`,两类对话框共用同一条降级路径。
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **晚挂载的 overlay 不再导致两个对话框并存**:`useOverlaySeatPresent()` 从「500ms 后查一次」改成「250ms 轮询、最长 10s」,overlay 一旦挂载就让位给真正的宿主。
|
|
18
|
+
- **跨盘移动不再可能留下半个目标**:`moveDirectory()` 的 EXDEV 退路改为「先 `cp` 到目标旁的 `.partial-<pid>-<ts>`,再 `rename` 就位,最后删源」,崩溃时不会出现一个半写目录被后续恢复当成完整日志。
|
|
19
|
+
- **两处读取路径的头部预算统一**:纯文本日志首行从 64 KB 提到与 zstd 路径一致的 256 KB,且「填满预算还没换行」直接判为不可读,不再把一个截断行交给 `JSON.parse`。
|
|
20
|
+
- `writeJsonAtomic()` 改为模块顶部引入 `writeFile`(原来每次调用都 `await import`),rename 失败时清理临时文件。
|
|
21
|
+
|
|
22
|
+
### Docs
|
|
23
|
+
|
|
24
|
+
- `README.md` 的状态行从 `0.3.14` 更正到 `0.3.16`(0.3.15 发布时漏改,且已随 npm 包外发)。
|
|
25
|
+
- `cordis.patch.yml` 的默认层补上 `requireConnection: true`:该键此前只存在于 `config.schema.json` 与 `lib/core/config.js` 的 `DEFAULTS`,使「默认层 21 项 / schema 22 项」对不上。
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- `test/memory.test.mjs`(13 → 15):恶意 `nodeId` 不能把内容文件搬出 bundle、且事后该文件确实在 `files/` 内;旧式 `<nodeId>.content` bundle 仍可恢复,而 `../../etc/passwd` 形式的 `storedFile` 被拒。
|
|
30
|
+
- `test/client-module.test.mjs`(28 → 29):无 overlay 座位时反馈对话框走内联路径(挂的是 `FeedbackDialog`,不是 `DeleteDialog`)。
|
|
31
|
+
- 合计 192 → **195**。
|
|
32
|
+
|
|
6
33
|
## [0.3.15] — 2026-10-31
|
|
7
34
|
|
|
8
35
|
dsh-go(Android)适配性审计后的修复。审计结论:Host 半边开箱可用(手机里跑的是真 Node 24 + 原版 DSH 引擎),问题全在**前端座位**与**移动端输入**上。
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.3.
|
|
5
|
+
> 状态:`0.3.16`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
|
|
6
6
|
> 运行要求:**Node.js ≥ 22.15**(Host 侧用到 `readdir(..., { recursive: true })`、`AbortSignal` 等 22.x 行为),DSH ≥ `0.2.1-rc.2`(`package.json` 的 `engines` 已如实声明,DSH 安装校验会据此拒绝更低版本)。
|
|
7
7
|
> 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
|
|
8
8
|
> 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
|
package/client.js
CHANGED
|
@@ -705,11 +705,27 @@ window.__ModuleLoader__.load({
|
|
|
705
705
|
function useOverlaySeatPresent() {
|
|
706
706
|
const [present, setPresent] = useState(overlaySeatMounted === true)
|
|
707
707
|
useEffect(() => {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
708
|
+
if (overlaySeatMounted === true) {
|
|
709
|
+
setPresent(true)
|
|
710
|
+
return undefined
|
|
711
|
+
}
|
|
712
|
+
// The overlay host mounts with the shell, but nothing guarantees it is
|
|
713
|
+
// already there when this panel first renders: poll briefly so a late
|
|
714
|
+
// mount still wins over the inline copy instead of rendering both.
|
|
715
|
+
let timer = null
|
|
716
|
+
const tick = () => {
|
|
717
|
+
if (overlaySeatMounted !== true) return
|
|
718
|
+
if (timer !== null) clearInterval(timer)
|
|
719
|
+
setPresent(true)
|
|
720
|
+
}
|
|
721
|
+
timer = setInterval(tick, 250)
|
|
722
|
+
const stop = setTimeout(() => {
|
|
723
|
+
if (timer !== null) clearInterval(timer)
|
|
724
|
+
}, 10_000)
|
|
725
|
+
return () => {
|
|
726
|
+
if (timer !== null) clearInterval(timer)
|
|
727
|
+
clearTimeout(stop)
|
|
728
|
+
}
|
|
713
729
|
}, [])
|
|
714
730
|
return present
|
|
715
731
|
}
|
|
@@ -718,8 +734,10 @@ window.__ModuleLoader__.load({
|
|
|
718
734
|
const payload = useDialogState()
|
|
719
735
|
const overlayPresent = useOverlaySeatPresent()
|
|
720
736
|
if (overlayPresent || payload === null) return null
|
|
721
|
-
//
|
|
722
|
-
|
|
737
|
+
// Every dialog this plugin opens has to travel through here, not only the
|
|
738
|
+
// delete one: the feedback form is opened from this very panel, so
|
|
739
|
+
// excluding it left a second dead button on hosts without an overlay seat.
|
|
740
|
+
return payload
|
|
723
741
|
}
|
|
724
742
|
|
|
725
743
|
/**
|
|
@@ -2140,7 +2158,9 @@ window.__ModuleLoader__.load({
|
|
|
2140
2158
|
// The panel keeps its own seat, so it renders the same dialog inline.
|
|
2141
2159
|
inlineDialog === null ? null : h('div', { className: 'dc-inline-dialog' },
|
|
2142
2160
|
h('p', { className: 'dc-hint' }, t('dialog.inlineHint')),
|
|
2143
|
-
|
|
2161
|
+
inlineDialog.kind === 'feedback'
|
|
2162
|
+
? h(FeedbackDialog, { payload: inlineDialog, key: `inline:${inlineDialog.key ?? 'feedback'}` })
|
|
2163
|
+
: h(DeleteDialog, { payload: inlineDialog, key: `inline:${inlineDialog.key ?? ''}` })))
|
|
2144
2164
|
}
|
|
2145
2165
|
|
|
2146
2166
|
/* ------------------------------------------------------------------ *
|
package/cordis.patch.yml
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
# Refuse every request when the Web server is bound beyond loopback,
|
|
22
22
|
# unless the operator opts in explicitly below.
|
|
23
23
|
requireSameOrigin: true
|
|
24
|
+
# Refuse every request when the Host exposes no `connection` service:
|
|
25
|
+
# the loopback/origin fence cannot tell two local processes apart, so
|
|
26
|
+
# without it any local process could delete a conversation. Listed here
|
|
27
|
+
# (and not only in config.schema.json) so the shipped default layer shows
|
|
28
|
+
# every key the contract has.
|
|
29
|
+
requireConnection: true
|
|
24
30
|
allowNonLoopback: false
|
|
25
31
|
# Reverse-proxy deployments: list the exact authorities the proxy puts
|
|
26
32
|
# in `Host` (host, host:port). A bare host admits any port. Listing them
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import * as defaultFs from 'node:fs/promises'
|
|
42
42
|
import { createHash, randomBytes } from 'node:crypto'
|
|
43
|
-
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
43
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
44
44
|
import { resolveJsonPointer } from '../config.js'
|
|
45
45
|
import { DeleteError } from '../errors.js'
|
|
46
46
|
import { createFileLock } from '../file-lock.js'
|
|
@@ -78,6 +78,30 @@ function validateRelativeContentPath(rootDir, relative) {
|
|
|
78
78
|
|
|
79
79
|
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* File name a node's content gets inside the restore bundle.
|
|
83
|
+
*
|
|
84
|
+
* The node id is a KEY FROM AN EXTERNAL JSON FILE (md_cg's index, or whatever
|
|
85
|
+
* `memory.generic.indexFile` points at), so it is data, not a path: an id like
|
|
86
|
+
* `../../..` used to move the content file out of the bundle and into any
|
|
87
|
+
* directory the Host can write. Ordinary ids keep their historical name, so
|
|
88
|
+
* bundles written before this change still restore; anything that is not a
|
|
89
|
+
* plain name is replaced by a hash (`bundle.entries[].nodeId` still carries the
|
|
90
|
+
* real id, which is all a restore needs).
|
|
91
|
+
*/
|
|
92
|
+
function contentFileName(nodeId) {
|
|
93
|
+
const raw = String(nodeId)
|
|
94
|
+
if (raw !== '.' && raw !== '..' && /^[A-Za-z0-9._-]{1,120}$/.test(raw)) return `${raw}.content`
|
|
95
|
+
return `${createHash('sha256').update(raw).digest('hex').slice(0, 32)}.content`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A stored file name must stay a name: no separators, no traversal. */
|
|
99
|
+
function isPlainFileName(value) {
|
|
100
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..' && basename(value) === value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
81
105
|
/**
|
|
82
106
|
* @param {object} options
|
|
83
107
|
* @param {string} options.id stable adapter id
|
|
@@ -362,7 +386,7 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
362
386
|
if (rootDir !== null && typeof relative === 'string') {
|
|
363
387
|
const source = validateRelativeContentPath(rootDir, relative)
|
|
364
388
|
if (source !== null) {
|
|
365
|
-
const storedName =
|
|
389
|
+
const storedName = contentFileName(nodeId)
|
|
366
390
|
try {
|
|
367
391
|
await fsApi.rename(source, join(filesDir, storedName))
|
|
368
392
|
movedFiles.push({ from: source, to: join(filesDir, storedName) })
|
|
@@ -488,6 +512,12 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
488
512
|
if (entry.storedFile !== null && entry.originalPath !== null && rootDir !== null) {
|
|
489
513
|
const destination = validateRelativeContentPath(rootDir, entry.originalPath)
|
|
490
514
|
if (destination === null) throw new DeleteError('E_TRASH_CONFLICT', 'Memory content path is invalid; restore aborted.', { nodeId: entry.nodeId })
|
|
515
|
+
if (!isPlainFileName(entry.storedFile)) {
|
|
516
|
+
throw new DeleteError('E_TRASH_CONFLICT', 'The restore bundle names a content file outside its own directory; restore aborted.', {
|
|
517
|
+
adapter: id,
|
|
518
|
+
nodeId: entry.nodeId,
|
|
519
|
+
})
|
|
520
|
+
}
|
|
491
521
|
const stored = join(bundle.bundlePath ? dirname(bundle.bundlePath) : '', 'files', entry.storedFile)
|
|
492
522
|
await fsApi.mkdir(dirname(destination), { recursive: true })
|
|
493
523
|
await fsApi.rename(stored, destination)
|
package/lib/core/policy.js
CHANGED
|
@@ -1,126 +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 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
|
-
}
|
|
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
|
+
}
|