dsh-delete-session 0.1.1
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/LICENSE +21 -0
- package/README.md +64 -0
- package/README.zh.md +52 -0
- package/cordis.patch.yml +7 -0
- package/package.json +42 -0
- package/src/client.js +347 -0
- package/src/index.js +369 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DDDMUC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# dsh-delete-session
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Delete sessions right from the DSH Web sidebar. The session row's `...` menu
|
|
6
|
+
(rename / fork / archive) gets one more action: **Delete session**. A
|
|
7
|
+
risk-consent dialog shows the session name and id, and confirming removes the
|
|
8
|
+
session log, its workspace accounting and its live session state.
|
|
9
|
+
|
|
10
|
+
Built for DeepSeek Harness (DSH) Web, tested on `0.1.5-rc.1`.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Delete session** in the sidebar session `...` menu, without switching to
|
|
15
|
+
the conversation (the session id is read from the menu's React fiber, so the
|
|
16
|
+
action always targets the row you clicked).
|
|
17
|
+
- **Risk-consent dialog**: shows the session title and id; the confirm button
|
|
18
|
+
stays disabled until the acknowledgement checkbox is ticked.
|
|
19
|
+
- **Real deletion**: stops a running agent (cancel + quiescence), flushes and
|
|
20
|
+
detaches the live session, removes every on-disk session directory (both the
|
|
21
|
+
raw and `session-` prefixed id), verifies through the persistence service,
|
|
22
|
+
then detaches the session from workspace accounting so the sidebar drops the
|
|
23
|
+
row instead of showing an orphan under "Ungrouped".
|
|
24
|
+
- **Safe by default**: the host route is loopback-only (loopback socket,
|
|
25
|
+
loopback Host header, same-origin check); a session still held open by DSH
|
|
26
|
+
is refused with a clear message instead of racing the active writer.
|
|
27
|
+
- **Bilingual**: the menu item and dialog follow the DSH interface language
|
|
28
|
+
(zh / en) when the locale service is present, with a browser-language
|
|
29
|
+
fallback.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
dsh plugin --profile web add github:DDDMUC/dsh-delete-session
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then restart `dsh web`. The item appears in the sidebar session `...` menu.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
- `src/index.js` (host) registers one loopback-only route:
|
|
42
|
+
`POST /dsh-delete-session/delete` `{ sessionId }`.
|
|
43
|
+
- `src/client.js` (browser) is a classic client bundle: it watches for the
|
|
44
|
+
portalled session menu (`body > [role="menu"]`), walks the React fiber chain
|
|
45
|
+
to the session node (`props.node.id`), clones a native menu row for styling,
|
|
46
|
+
and opens the confirmation dialog.
|
|
47
|
+
- No DSH SDK imports: every service (`sessions`, `sessionPersistence`,
|
|
48
|
+
`agents`, `workspaceRegistry`, `webServer`) is resolved through the cordis
|
|
49
|
+
context at call time, and the plugin degrades to filesystem cleanup when a
|
|
50
|
+
service is absent.
|
|
51
|
+
|
|
52
|
+
## Known limitations
|
|
53
|
+
|
|
54
|
+
- A session that DSH currently holds open cannot be deleted: the runtime
|
|
55
|
+
exposes no public way to release the write ownership, so the request is
|
|
56
|
+
refused with a message asking to restart DSH and retry.
|
|
57
|
+
- The sidebar menu injection is a DOM shim. Core UI changes to the menu markup
|
|
58
|
+
or the row fiber shape may require a plugin update; the menu item is simply
|
|
59
|
+
not injected when the contract changes, and nothing else breaks.
|
|
60
|
+
- Deletion is permanent. There is no trash bin and no undo.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# dsh-delete-session
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
在 DSH Web 侧边栏直接删除会话:会话行的 `...` 菜单(重命名 / 分叉 / 归档)里会多出一项
|
|
6
|
+
**删除会话**。点击后弹出风险确认对话框(显示会话名称与 ID),确认后删除会话日志、
|
|
7
|
+
工作区记账与宿主内存中的会话状态。
|
|
8
|
+
|
|
9
|
+
为 DeepSeek Harness(DSH)Web 构建,已在 `0.1.5-rc.1` 上实测。
|
|
10
|
+
|
|
11
|
+
## 功能
|
|
12
|
+
|
|
13
|
+
- **侧边栏会话菜单里的「删除会话」**:无需切换会话(会话 ID 从菜单的 React fiber 读取,
|
|
14
|
+
永远作用于你点击的那一行)。
|
|
15
|
+
- **风险确认对话框**:显示会话标题与 ID;勾选「我已了解,永久删除」前确认按钮不可用。
|
|
16
|
+
- **真正的删除**:停止运行中的 agent(cancel + 等待空闲)、flush 并 detach 活跃会话、
|
|
17
|
+
删除磁盘上的全部会话目录(原始 ID 与 `session-` 前缀两种写法)、经持久化服务复核,
|
|
18
|
+
最后从工作区记账中移除,避免侧栏残留「未分组」孤儿。
|
|
19
|
+
- **默认安全**:宿主路由仅限本机回环(回环 socket + 回环 Host 头 + 同源校验);
|
|
20
|
+
被 DSH 打开占用中的会话会被明确拒绝,而不是与活跃写者竞争。
|
|
21
|
+
- **中英双语**:菜单项与对话框跟随 DSH 界面语言(存在 locale 服务时),否则按浏览器语言回退。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
dsh plugin --profile web add github:DDDMUC/dsh-delete-session
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
重启 `dsh web` 后,在侧边栏会话 `...` 菜单中即可看到「删除会话」。
|
|
30
|
+
|
|
31
|
+
## 工作原理
|
|
32
|
+
|
|
33
|
+
- `src/index.js`(宿主):注册一条仅限回环的路由
|
|
34
|
+
`POST /dsh-delete-session/delete` `{ sessionId }`。
|
|
35
|
+
- `src/client.js`(浏览器):经典客户端 bundle——监听 portal 到 body 的会话菜单
|
|
36
|
+
(`body > [role="menu"]`),沿 React fiber 链读取会话节点(`props.node.id`),
|
|
37
|
+
克隆原生菜单行保持样式一致,然后打开确认对话框。
|
|
38
|
+
- 不引入任何 DSH SDK:所有服务(`sessions`、`sessionPersistence`、`agents`、
|
|
39
|
+
`workspaceRegistry`、`webServer`)都在调用时通过 cordis 上下文获取;
|
|
40
|
+
服务缺失时退化为纯文件系统清理。
|
|
41
|
+
|
|
42
|
+
## 已知限制
|
|
43
|
+
|
|
44
|
+
- 被 DSH 当前打开占用的会话无法删除:运行时没有公开的写所有权释放入口,
|
|
45
|
+
因此会明确拒绝并提示「重启 DSH 后再试」。
|
|
46
|
+
- 侧边栏菜单注入是 DOM shim。核心 UI 若调整菜单结构或行 fiber 形状,可能需要插件跟进更新;
|
|
47
|
+
契约变化时插件只是不再注入菜单项,不会影响其他功能。
|
|
48
|
+
- 删除不可恢复:没有回收站,也没有撤销。
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-delete-session bundle patch: inserts the plugin row into the profile
|
|
2
|
+
# roster. The node half serves the /dsh-delete-session/* JSON routes; the
|
|
3
|
+
# browser half (exports "./client") injects the sidebar session-row menu item
|
|
4
|
+
# and the confirmation dialog.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: dsh-delete-session
|
|
7
|
+
name: 'dsh-delete-session'
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-delete-session",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, then removes the session log, workspace accounting and live session state.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./src/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"README.zh.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"deepseek-harness",
|
|
21
|
+
"dsh",
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"session",
|
|
24
|
+
"delete"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/DDDMUC/dsh-delete-session.git"
|
|
30
|
+
},
|
|
31
|
+
"dsh": {
|
|
32
|
+
"bundle": {
|
|
33
|
+
"patch": "./cordis.patch.yml"
|
|
34
|
+
},
|
|
35
|
+
"client": {
|
|
36
|
+
"inject": [
|
|
37
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
38
|
+
],
|
|
39
|
+
"platform": "web"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// dsh-delete-session - client half.
|
|
2
|
+
//
|
|
3
|
+
// DSH's sidebar session-row "..." menu (rename / fork / archive) is rendered
|
|
4
|
+
// by the core UI and offers no plugin slot for extra rows, so the delete item
|
|
5
|
+
// is injected at the DOM level:
|
|
6
|
+
//
|
|
7
|
+
// * watch for the portalled session menu (body > [role="menu"]);
|
|
8
|
+
// * walk the React fiber chain from that menu to the session node and read
|
|
9
|
+
// its real id (props.node.id) - no title matching, no conversation jump;
|
|
10
|
+
// * clone a native menu row so the new item keeps the core styling;
|
|
11
|
+
// * open a risk-consent dialog and POST /dsh-delete-session/delete.
|
|
12
|
+
//
|
|
13
|
+
// The module is a classic client bundle (client-modules protocol): it
|
|
14
|
+
// registers a factory with window.__ModuleLoader__ and returns apply().
|
|
15
|
+
// No React, no SDK imports - theme tokens and DOM only, so it survives core
|
|
16
|
+
// UI revisions that keep the menu contract.
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: 'dsh-delete-session',
|
|
19
|
+
factory: () => {
|
|
20
|
+
const NS = 'dsh-delete-session'
|
|
21
|
+
|
|
22
|
+
const zh = {
|
|
23
|
+
menu: '删除会话',
|
|
24
|
+
title: '删除会话',
|
|
25
|
+
desc: '将永久删除该会话及其全部对话记录(会话日志、工作区记账与统计),此操作不可恢复。',
|
|
26
|
+
cancel: '取消',
|
|
27
|
+
confirm: '删除',
|
|
28
|
+
deleting: '删除中…',
|
|
29
|
+
ack: '我已了解,永久删除',
|
|
30
|
+
done: '已删除会话',
|
|
31
|
+
failed: '删除失败:',
|
|
32
|
+
busy: '该会话正被 DSH 打开,暂时无法删除;请重启 DSH 后再试。',
|
|
33
|
+
notFound: '未找到该会话(可能已被删除)。',
|
|
34
|
+
untitled: '未命名会话',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const en = {
|
|
38
|
+
menu: 'Delete session',
|
|
39
|
+
title: 'Delete session',
|
|
40
|
+
desc: 'This permanently deletes the session and all of its conversation records (log, workspace accounting and statistics). This action cannot be undone.',
|
|
41
|
+
cancel: 'Cancel',
|
|
42
|
+
confirm: 'Delete',
|
|
43
|
+
deleting: 'Deleting...',
|
|
44
|
+
ack: 'I understand - delete permanently',
|
|
45
|
+
done: 'Session deleted',
|
|
46
|
+
failed: 'Delete failed: ',
|
|
47
|
+
busy: 'This session is currently open in DSH and cannot be deleted yet. Restart DSH and try again.',
|
|
48
|
+
notFound: 'Session not found (it may already be deleted).',
|
|
49
|
+
untitled: 'Untitled session',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let localeSvc = null
|
|
53
|
+
let sessionsSvc = null
|
|
54
|
+
|
|
55
|
+
function browserLang() {
|
|
56
|
+
if (typeof navigator === 'undefined') return 'zh'
|
|
57
|
+
for (const tag of (navigator.languages || []).concat([navigator.language])) {
|
|
58
|
+
const primary = String(tag || '').toLowerCase().split('-')[0]
|
|
59
|
+
if (primary === 'zh' || primary === 'en') return primary
|
|
60
|
+
}
|
|
61
|
+
return 'zh'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function t(key) {
|
|
65
|
+
if (localeSvc && typeof localeSvc.translate === 'function') {
|
|
66
|
+
try {
|
|
67
|
+
const text = localeSvc.translate(NS, key)
|
|
68
|
+
if (typeof text === 'string' && text !== key) return text
|
|
69
|
+
} catch {
|
|
70
|
+
// fall back to the built-in dictionaries
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return (browserLang() === 'en' ? en : zh)[key] || key
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- fiber access ----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function findFiber(el) {
|
|
79
|
+
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
|
|
80
|
+
return key ? el[key] : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Walk up from the portalled menu element to the session row component
|
|
84
|
+
// that carries `node.id`. A menu without such a node is not a session
|
|
85
|
+
// menu (settings, header dropdowns) and is left alone.
|
|
86
|
+
function sessionInfoFromMenu(menuEl) {
|
|
87
|
+
let fiber = findFiber(menuEl)
|
|
88
|
+
let guard = 0
|
|
89
|
+
while (fiber && guard++ < 300) {
|
|
90
|
+
const props = fiber.memoizedProps
|
|
91
|
+
if (props && props.node && typeof props.node.id === 'string' && props.node.id) {
|
|
92
|
+
const title = props.node.title || props.node.name || null
|
|
93
|
+
return { id: props.node.id, title: typeof title === 'string' ? title : null }
|
|
94
|
+
}
|
|
95
|
+
fiber = fiber.return
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- styles ----------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
const CSS = [
|
|
103
|
+
'.dsdel-backdrop{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center;padding:16px}',
|
|
104
|
+
'.dsdel-dialog{width:min(420px,92vw);border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.3));border-radius:14px;padding:18px;box-shadow:0 16px 48px rgba(0,0,0,.32);display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary,inherit);background:linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),var(--dsw-alias-bg-base,#fff);-webkit-backdrop-filter:blur(18px) saturate(140%);backdrop-filter:blur(18px) saturate(140%)}',
|
|
105
|
+
'.dsdel-title{font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary,inherit);margin:0}',
|
|
106
|
+
'.dsdel-text{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);margin:0;word-break:break-all}',
|
|
107
|
+
'.dsdel-meta{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary,#888);margin:0;word-break:break-all}',
|
|
108
|
+
'.dsdel-ack{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,inherit);cursor:pointer}',
|
|
109
|
+
'.dsdel-ack input{width:15px;height:15px;accent-color:var(--dsw-alias-state-error-primary,#e5484d);cursor:pointer;flex:none}',
|
|
110
|
+
'.dsdel-error{font-size:12px;line-height:16px;color:var(--dsw-alias-state-error-primary,#e5484d);margin:0}',
|
|
111
|
+
'.dsdel-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}',
|
|
112
|
+
'.dsdel-btn{appearance:none;min-height:32px;padding:0 14px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-fill-elevated,rgba(128,128,128,.08));color:var(--dsw-alias-label-primary,inherit);border-radius:8px;font-size:13px;cursor:pointer}',
|
|
113
|
+
'.dsdel-btn:disabled{opacity:.5;cursor:default}',
|
|
114
|
+
'.dsdel-danger{border-color:var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
|
|
115
|
+
'.dsdel-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:2147483601;background:Canvas;color:CanvasText;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgba(0,0,0,.25);max-width:min(92vw,460px);cursor:pointer}',
|
|
116
|
+
].join('')
|
|
117
|
+
|
|
118
|
+
function ensureStyle() {
|
|
119
|
+
if (document.querySelector('style[data-dsh-delete-session]')) return
|
|
120
|
+
const style = document.createElement('style')
|
|
121
|
+
style.dataset.dshSessionDelete = '1'
|
|
122
|
+
style.textContent = CSS
|
|
123
|
+
document.head.appendChild(style)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- feedback --------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function closeMenu() {
|
|
129
|
+
try { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) } catch { /* ignore */ }
|
|
130
|
+
try { document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) } catch { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toast(text, isError) {
|
|
134
|
+
const el = document.createElement('div')
|
|
135
|
+
el.className = 'dsdel-toast'
|
|
136
|
+
el.setAttribute('role', 'status')
|
|
137
|
+
el.textContent = text
|
|
138
|
+
if (isError) {
|
|
139
|
+
el.style.background = '#4A1D1D'
|
|
140
|
+
el.style.color = '#FFD9D9'
|
|
141
|
+
}
|
|
142
|
+
document.body.appendChild(el)
|
|
143
|
+
const kill = () => el.remove()
|
|
144
|
+
el.addEventListener('click', kill)
|
|
145
|
+
setTimeout(kill, 3200)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function refreshList() {
|
|
149
|
+
const svc = sessionsSvc
|
|
150
|
+
if (svc) {
|
|
151
|
+
try {
|
|
152
|
+
if (typeof svc.refresh === 'function') { svc.refresh(); return }
|
|
153
|
+
if (typeof svc.refreshList === 'function') { svc.refreshList(); return }
|
|
154
|
+
} catch {
|
|
155
|
+
// fall through to reload
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
try { window.location.reload() } catch { /* ignore */ }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- dialog ----------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function openDialog(info) {
|
|
164
|
+
ensureStyle()
|
|
165
|
+
const backdrop = document.createElement('div')
|
|
166
|
+
backdrop.className = 'dsdel-backdrop'
|
|
167
|
+
const dialog = document.createElement('div')
|
|
168
|
+
dialog.className = 'dsdel-dialog'
|
|
169
|
+
|
|
170
|
+
const title = document.createElement('h3')
|
|
171
|
+
title.className = 'dsdel-title'
|
|
172
|
+
title.textContent = t('title')
|
|
173
|
+
|
|
174
|
+
const meta = document.createElement('p')
|
|
175
|
+
meta.className = 'dsdel-meta'
|
|
176
|
+
meta.textContent = `${info.title || t('untitled')} · ${info.id}`
|
|
177
|
+
|
|
178
|
+
const text = document.createElement('p')
|
|
179
|
+
text.className = 'dsdel-text'
|
|
180
|
+
text.textContent = t('desc')
|
|
181
|
+
|
|
182
|
+
const ack = document.createElement('label')
|
|
183
|
+
ack.className = 'dsdel-ack'
|
|
184
|
+
const checkbox = document.createElement('input')
|
|
185
|
+
checkbox.type = 'checkbox'
|
|
186
|
+
const ackText = document.createElement('span')
|
|
187
|
+
ackText.textContent = t('ack')
|
|
188
|
+
ack.appendChild(checkbox)
|
|
189
|
+
ack.appendChild(ackText)
|
|
190
|
+
|
|
191
|
+
const error = document.createElement('p')
|
|
192
|
+
error.className = 'dsdel-error'
|
|
193
|
+
error.style.display = 'none'
|
|
194
|
+
|
|
195
|
+
const actions = document.createElement('div')
|
|
196
|
+
actions.className = 'dsdel-actions'
|
|
197
|
+
const cancel = document.createElement('button')
|
|
198
|
+
cancel.type = 'button'
|
|
199
|
+
cancel.className = 'dsdel-btn'
|
|
200
|
+
cancel.textContent = t('cancel')
|
|
201
|
+
const confirm = document.createElement('button')
|
|
202
|
+
confirm.type = 'button'
|
|
203
|
+
confirm.className = 'dsdel-btn dsdel-danger'
|
|
204
|
+
confirm.textContent = t('confirm')
|
|
205
|
+
confirm.disabled = true
|
|
206
|
+
actions.appendChild(cancel)
|
|
207
|
+
actions.appendChild(confirm)
|
|
208
|
+
|
|
209
|
+
dialog.appendChild(title)
|
|
210
|
+
dialog.appendChild(meta)
|
|
211
|
+
dialog.appendChild(text)
|
|
212
|
+
dialog.appendChild(ack)
|
|
213
|
+
dialog.appendChild(error)
|
|
214
|
+
dialog.appendChild(actions)
|
|
215
|
+
backdrop.appendChild(dialog)
|
|
216
|
+
document.body.appendChild(backdrop)
|
|
217
|
+
|
|
218
|
+
let busy = false
|
|
219
|
+
const close = () => {
|
|
220
|
+
if (busy) return
|
|
221
|
+
backdrop.remove()
|
|
222
|
+
}
|
|
223
|
+
checkbox.addEventListener('change', () => { confirm.disabled = !checkbox.checked })
|
|
224
|
+
cancel.addEventListener('click', close)
|
|
225
|
+
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() })
|
|
226
|
+
backdrop.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
227
|
+
|
|
228
|
+
confirm.addEventListener('click', () => {
|
|
229
|
+
if (busy || !checkbox.checked) return
|
|
230
|
+
busy = true
|
|
231
|
+
confirm.disabled = true
|
|
232
|
+
cancel.disabled = true
|
|
233
|
+
confirm.textContent = t('deleting')
|
|
234
|
+
error.style.display = 'none'
|
|
235
|
+
fetch('/dsh-delete-session/delete', {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: { 'content-type': 'application/json' },
|
|
238
|
+
body: JSON.stringify({ sessionId: info.id }),
|
|
239
|
+
})
|
|
240
|
+
.then(async (res) => {
|
|
241
|
+
let data = {}
|
|
242
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
243
|
+
if (!res.ok || !data.ok) {
|
|
244
|
+
const code = data && data.code
|
|
245
|
+
const message = code === 'busy'
|
|
246
|
+
? t('busy')
|
|
247
|
+
: code === 'not-found'
|
|
248
|
+
? t('notFound')
|
|
249
|
+
: (data && data.error) || `HTTP ${res.status}`
|
|
250
|
+
throw new Error(message)
|
|
251
|
+
}
|
|
252
|
+
backdrop.remove()
|
|
253
|
+
toast(t('done'))
|
|
254
|
+
refreshList()
|
|
255
|
+
})
|
|
256
|
+
.catch((reason) => {
|
|
257
|
+
busy = false
|
|
258
|
+
confirm.disabled = !checkbox.checked
|
|
259
|
+
cancel.disabled = false
|
|
260
|
+
confirm.textContent = t('confirm')
|
|
261
|
+
error.textContent = t('failed') + String((reason && reason.message) || reason)
|
|
262
|
+
error.style.display = 'block'
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- sidebar menu injection ------------------------------------------------
|
|
268
|
+
|
|
269
|
+
const TRASH_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M9 7V4h6v3"/></svg>'
|
|
270
|
+
|
|
271
|
+
function augmentMenu(menuEl, info) {
|
|
272
|
+
if (menuEl.querySelector('[data-dsh-delete-session]')) return
|
|
273
|
+
const viewport = menuEl.querySelector('[role="presentation"]') || menuEl.firstElementChild
|
|
274
|
+
if (!viewport) return
|
|
275
|
+
const proto = menuEl.querySelector('[role="menuitem"]')
|
|
276
|
+
if (!proto) return
|
|
277
|
+
const protoWrap = proto.parentElement
|
|
278
|
+
const wrap = protoWrap ? protoWrap.cloneNode(false) : document.createElement('div')
|
|
279
|
+
if (protoWrap && protoWrap.className) wrap.className = protoWrap.className
|
|
280
|
+
|
|
281
|
+
const button = document.createElement('button')
|
|
282
|
+
button.type = 'button'
|
|
283
|
+
button.setAttribute('role', 'menuitem')
|
|
284
|
+
button.setAttribute('data-dsh-delete-session', '1')
|
|
285
|
+
if (proto.className) button.className = proto.className
|
|
286
|
+
button.style.color = 'var(--dsw-alias-state-error-primary,#e5484d)'
|
|
287
|
+
|
|
288
|
+
const icon = document.createElement('span')
|
|
289
|
+
icon.style.cssText = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center;justify-content:center'
|
|
290
|
+
icon.innerHTML = TRASH_ICON
|
|
291
|
+
const label = document.createElement('span')
|
|
292
|
+
label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
293
|
+
label.textContent = t('menu')
|
|
294
|
+
|
|
295
|
+
button.appendChild(icon)
|
|
296
|
+
button.appendChild(label)
|
|
297
|
+
button.addEventListener('click', (e) => {
|
|
298
|
+
e.stopPropagation()
|
|
299
|
+
e.preventDefault()
|
|
300
|
+
closeMenu()
|
|
301
|
+
openDialog(info)
|
|
302
|
+
})
|
|
303
|
+
wrap.appendChild(button)
|
|
304
|
+
viewport.appendChild(wrap)
|
|
305
|
+
|
|
306
|
+
// The portalled menu measured its height before this row existed; nudge
|
|
307
|
+
// the core placement so the grown card is re-clamped on screen.
|
|
308
|
+
window.dispatchEvent(new Event('resize'))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function install() {
|
|
312
|
+
if (typeof document === 'undefined' || !document.body) return null
|
|
313
|
+
ensureStyle()
|
|
314
|
+
const seen = new WeakSet()
|
|
315
|
+
const scan = () => {
|
|
316
|
+
const menus = document.querySelectorAll('body > [role="menu"]')
|
|
317
|
+
menus.forEach((menuEl) => {
|
|
318
|
+
if (seen.has(menuEl)) return
|
|
319
|
+
let info = null
|
|
320
|
+
try { info = sessionInfoFromMenu(menuEl) } catch { /* ignore */ }
|
|
321
|
+
if (!info) return
|
|
322
|
+
seen.add(menuEl)
|
|
323
|
+
try { augmentMenu(menuEl, info) } catch { /* best-effort DOM shim */ }
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
scan()
|
|
327
|
+
const observer = new MutationObserver(scan)
|
|
328
|
+
observer.observe(document.body, { childList: true, subtree: false })
|
|
329
|
+
return () => observer.disconnect()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function apply(ctx) {
|
|
333
|
+
sessionsSvc = ctx.get('sessions') || null
|
|
334
|
+
if (!sessionsSvc) {
|
|
335
|
+
try { ctx.inject(['sessions'], (sub) => { sessionsSvc = sub.sessions }) } catch { /* optional */ }
|
|
336
|
+
}
|
|
337
|
+
localeSvc = ctx.get('locale') || null
|
|
338
|
+
if (!localeSvc) {
|
|
339
|
+
try { ctx.inject(['locale'], (sub) => { localeSvc = sub.locale }) } catch { /* optional */ }
|
|
340
|
+
}
|
|
341
|
+
const dispose = install()
|
|
342
|
+
if (dispose) ctx.effect(() => dispose)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { apply }
|
|
346
|
+
},
|
|
347
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
|
|
34
|
+
class HttpError extends Error {
|
|
35
|
+
constructor(status, code, message) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.status = status
|
|
38
|
+
this.code = code
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- paths -------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function dshHome() {
|
|
45
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sessionsRoot() {
|
|
49
|
+
return path.join(dshHome(), 'sessions')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A session id travels in two spellings: the raw uuid and `session-<uuid>`.
|
|
53
|
+
// The on-disk JSONL backend uses the prefixed form for the directory name,
|
|
54
|
+
// while workspace and projection rows may carry either. Clean both.
|
|
55
|
+
function idVariants(sessionId) {
|
|
56
|
+
const out = new Set([sessionId])
|
|
57
|
+
if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
|
|
58
|
+
else out.add(`session-${sessionId}`)
|
|
59
|
+
return [...out]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Scan every workspace directory under $DSH_HOME/sessions for the session's
|
|
63
|
+
// directories, so the workspace-path encoding never has to be re-derived here.
|
|
64
|
+
function findSessionDirs(sessionId) {
|
|
65
|
+
const root = sessionsRoot()
|
|
66
|
+
let entries
|
|
67
|
+
try {
|
|
68
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
69
|
+
} catch {
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
const variants = idVariants(sessionId)
|
|
73
|
+
const found = []
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory()) continue
|
|
76
|
+
for (const variant of variants) {
|
|
77
|
+
const candidate = path.join(root, entry.name, variant)
|
|
78
|
+
try {
|
|
79
|
+
if (fs.statSync(candidate).isDirectory() && !found.includes(candidate)) found.push(candidate)
|
|
80
|
+
} catch {
|
|
81
|
+
// keep scanning
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return found
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function removeSessionDirs(sessionId) {
|
|
89
|
+
const dirs = findSessionDirs(sessionId)
|
|
90
|
+
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true })
|
|
91
|
+
return dirs
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- live state --------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
// Stop a running agent before its session disappears: cancel the active turn
|
|
97
|
+
// and wait, time-boxed, so a stuck driver never blocks the deletion.
|
|
98
|
+
async function quiesceAgent(ctx, sessionId) {
|
|
99
|
+
const agents = ctx.get('agents')
|
|
100
|
+
if (!agents || typeof agents.get !== 'function') return false
|
|
101
|
+
const agent = agents.get(sessionId)
|
|
102
|
+
if (!agent) return false
|
|
103
|
+
try {
|
|
104
|
+
if (typeof agent.cancel === 'function') agent.cancel({ kind: 'user' })
|
|
105
|
+
} catch {
|
|
106
|
+
// agent may already be settling
|
|
107
|
+
}
|
|
108
|
+
if (typeof agent.whenIdle === 'function') {
|
|
109
|
+
try {
|
|
110
|
+
await Promise.race([agent.whenIdle(), new Promise((resolve) => setTimeout(resolve, 10000))])
|
|
111
|
+
} catch {
|
|
112
|
+
// proceed with deletion anyway
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Flush a live session, then detach its entered record so DSH emits
|
|
119
|
+
// host/session-removed and the client list drops the row. The persistence
|
|
120
|
+
// retirement started by session/disposed is awaited before the caller unlinks
|
|
121
|
+
// anything, because its final drain could otherwise recreate the directory.
|
|
122
|
+
async function flushAndDetach(ctx, sessionId) {
|
|
123
|
+
const sessions = ctx.get('sessions')
|
|
124
|
+
let flushed = false
|
|
125
|
+
let detached = false
|
|
126
|
+
if (sessions) {
|
|
127
|
+
for (const variant of idVariants(sessionId)) {
|
|
128
|
+
const live = typeof sessions.get === 'function' ? sessions.get(variant) : undefined
|
|
129
|
+
if (!live) continue
|
|
130
|
+
if (typeof sessions.flush === 'function') {
|
|
131
|
+
try {
|
|
132
|
+
await sessions.flush(live)
|
|
133
|
+
flushed = true
|
|
134
|
+
} catch {
|
|
135
|
+
// the log is removed anyway
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const entered = sessions.store && typeof sessions.store.get === 'function' ? sessions.store.get(variant) : undefined
|
|
140
|
+
if (entered && typeof entered.detach === 'function') {
|
|
141
|
+
entered.detach()
|
|
142
|
+
detached = true
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// best effort: the directory removal below is the authority
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const sp = ctx.get('sessionPersistence')
|
|
150
|
+
try {
|
|
151
|
+
const retirement = sp && sp.retirements && typeof sp.retirements.get === 'function' ? sp.retirements.get(sessionId) : undefined
|
|
152
|
+
if (retirement && typeof retirement.then === 'function') await retirement
|
|
153
|
+
} catch {
|
|
154
|
+
// no retirement registry: nothing to wait for
|
|
155
|
+
}
|
|
156
|
+
return { flushed, detached }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Probe the persistence write lease. Holding it proves no active writer is
|
|
160
|
+
// around; it is released immediately. A busy lease means DSH still has the
|
|
161
|
+
// session open (the runtime offers no public way to release it), so the
|
|
162
|
+
// request is refused and the caller can retry after restarting DSH.
|
|
163
|
+
async function assertNoActiveWriter(ctx, sessionId) {
|
|
164
|
+
const sp = ctx.get('sessionPersistence')
|
|
165
|
+
if (!sp || typeof sp.open !== 'function') return false
|
|
166
|
+
let handle = null
|
|
167
|
+
try {
|
|
168
|
+
handle = await sp.open(sessionId, 'write')
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const message = String((error && error.message) || error)
|
|
171
|
+
if (/own|busy|lock|hold|writer/i.test(message)) {
|
|
172
|
+
throw new HttpError(409, 'busy', 'session is currently open in DSH')
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
} finally {
|
|
176
|
+
try {
|
|
177
|
+
if (handle && typeof handle.close === 'function') await handle.close()
|
|
178
|
+
} catch {
|
|
179
|
+
// lease release failure is harmless: the directory is removed next
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function detachFromWorkspace(ctx, sessionId) {
|
|
186
|
+
const workspace = ctx.get('workspaceRegistry')
|
|
187
|
+
if (!workspace) return false
|
|
188
|
+
const variants = idVariants(sessionId)
|
|
189
|
+
let touched = false
|
|
190
|
+
try {
|
|
191
|
+
if (typeof workspace.list === 'function') {
|
|
192
|
+
for (const entry of workspace.list()) {
|
|
193
|
+
if (!entry || !Array.isArray(entry.sessionIds)) continue
|
|
194
|
+
if (!variants.some((variant) => entry.sessionIds.includes(variant))) continue
|
|
195
|
+
try {
|
|
196
|
+
await entry.detachSession(sessionId)
|
|
197
|
+
touched = true
|
|
198
|
+
} catch {
|
|
199
|
+
// keep going: the header index cleanup below still applies
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// registry unavailable
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
if (workspace.sessionPaths && typeof workspace.sessionPaths.delete === 'function') {
|
|
208
|
+
for (const variant of variants) workspace.sessionPaths.delete(variant)
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// private map absent on this version
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
if (workspace.headers && typeof workspace.headers.delete === 'function') {
|
|
215
|
+
for (const variant of variants) workspace.headers.delete(variant)
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// private map absent on this version
|
|
219
|
+
}
|
|
220
|
+
return touched
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- core delete -------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async function deleteSession(ctx, sessionId) {
|
|
226
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
227
|
+
throw new HttpError(400, 'invalid', 'invalid session id')
|
|
228
|
+
}
|
|
229
|
+
const stopped = await quiesceAgent(ctx, sessionId)
|
|
230
|
+
const { flushed, detached } = await flushAndDetach(ctx, sessionId)
|
|
231
|
+
await assertNoActiveWriter(ctx, sessionId)
|
|
232
|
+
|
|
233
|
+
// Remove the on-disk directories first. If the filesystem refuses, fail
|
|
234
|
+
// before touching workspace accounting so a half-deleted session cannot
|
|
235
|
+
// fall out of its group into "Ungrouped".
|
|
236
|
+
let removed = removeSessionDirs(sessionId)
|
|
237
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
238
|
+
removed = removed.concat(removeSessionDirs(sessionId))
|
|
239
|
+
|
|
240
|
+
const sp = ctx.get('sessionPersistence')
|
|
241
|
+
if (sp && typeof sp.stat === 'function') {
|
|
242
|
+
const after = await sp.stat(sessionId).catch(() => undefined)
|
|
243
|
+
if (after) {
|
|
244
|
+
throw new HttpError(500, 'verify-failed', 'session is still visible to the persistence service after deletion')
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const leftover = findSessionDirs(sessionId)
|
|
248
|
+
if (leftover.length > 0) {
|
|
249
|
+
throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const workspaceDetached = await detachFromWorkspace(ctx, sessionId)
|
|
253
|
+
if (removed.length === 0 && !workspaceDetached && !flushed && !detached) {
|
|
254
|
+
throw new HttpError(404, 'not-found', 'session not found')
|
|
255
|
+
}
|
|
256
|
+
return { removed: removed.length, stopped, flushed, detached, workspaceDetached }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- http --------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
function isLoopbackAddress(address) {
|
|
262
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
263
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isLocalHostHeader(host) {
|
|
267
|
+
if (typeof host !== 'string' || host.length === 0) return false
|
|
268
|
+
const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
|
|
269
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1'
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sendJson(res, status, body) {
|
|
273
|
+
const payload = JSON.stringify(body)
|
|
274
|
+
res.writeHead(status, {
|
|
275
|
+
'content-type': 'application/json; charset=utf-8',
|
|
276
|
+
'content-length': Buffer.byteLength(payload),
|
|
277
|
+
})
|
|
278
|
+
res.end(payload)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readBody(req) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
let data = ''
|
|
284
|
+
req.on('data', (chunk) => {
|
|
285
|
+
data += chunk
|
|
286
|
+
if (data.length > 1e6) req.destroy()
|
|
287
|
+
})
|
|
288
|
+
req.on('end', () => resolve(data))
|
|
289
|
+
req.on('error', reject)
|
|
290
|
+
req.on('aborted', () => reject(new Error('aborted')))
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Destructive local endpoint: loopback socket, loopback Host header, and a
|
|
295
|
+
// same-origin check when the browser sends Origin. Tunneled or LAN clients
|
|
296
|
+
// get 403 before any session data is touched.
|
|
297
|
+
function guard(req, res) {
|
|
298
|
+
if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
|
|
299
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
const host = req.headers.host
|
|
303
|
+
if (!isLocalHostHeader(host)) {
|
|
304
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
const origin = req.headers.origin
|
|
308
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
309
|
+
let originHost = null
|
|
310
|
+
try {
|
|
311
|
+
originHost = new URL(origin).host
|
|
312
|
+
} catch {
|
|
313
|
+
originHost = null
|
|
314
|
+
}
|
|
315
|
+
if (originHost !== host) {
|
|
316
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- plugin ------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
export function apply(ctx) {
|
|
326
|
+
const registerRoutes = (webServer, fiber) => {
|
|
327
|
+
fiber.effect(() => webServer.register({
|
|
328
|
+
kind: 'exact',
|
|
329
|
+
path: `${ROUTE_PREFIX}/delete`,
|
|
330
|
+
handler: async (req, res) => {
|
|
331
|
+
if (!guard(req, res)) return
|
|
332
|
+
if (req.method !== 'POST') {
|
|
333
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
let body = {}
|
|
337
|
+
try {
|
|
338
|
+
const raw = await readBody(req)
|
|
339
|
+
if (raw) body = JSON.parse(raw)
|
|
340
|
+
} catch {
|
|
341
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
345
|
+
if (!sessionId) {
|
|
346
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const result = await deleteSession(ctx, sessionId)
|
|
351
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
354
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
355
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
}))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const webServer = ctx.get('webServer')
|
|
362
|
+
if (webServer) {
|
|
363
|
+
registerRoutes(webServer, ctx)
|
|
364
|
+
} else {
|
|
365
|
+
// A terminal-only profile never provides a web surface; register once one
|
|
366
|
+
// appears instead of waiting on a service that will never arrive.
|
|
367
|
+
ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
|
|
368
|
+
}
|
|
369
|
+
}
|