dsh-my-guardian 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.1] - 2026-08-27
9
+
10
+ ### 变更
11
+
12
+ - **npm 包名改为 `dsh-my-guardian`**:`bsfeng-dsh-guardian` → `dsh-my-guardian`,与 `dsh-my-*` 系列(dsh-my-skill-manager / dsh-my-plugin-manager / dsh-my-memory)统一,目录名 = 包名 = tag 名,避免与 npm 上他人同名包(`dsh-guardian`,lss1213)混淆。安装命令变为 `dsh plugin --profile web add dsh-my-guardian`。API 路径(`/guardian/api/*`)、状态文件路径(`$DSH_HOME/guardian/state.json`)、插件行 id(`guardian`)保持兼容。
13
+
14
+ ## [0.3.0] - 2026-08-26
15
+
16
+ ### 变更
17
+
18
+ - refactor(guardian): 移除 dsh-better-sidebar 第三方依赖(#22)
19
+ - docs+test: 全面审查修复——文档同步补全 + mermaid 测试增强
20
+
21
+ ## [0.2.1] - 2026-08-25
22
+
23
+ ### 变更
24
+
25
+ - **npm 页面元数据优化**:description 改为中英双语(中文在前);README 效果截图引用改为绝对 URL(unpkg),npm 包页面可直接显示图片。
26
+
27
+ ## [0.2.0] - 2026-08-25
28
+
29
+ ### 变更
30
+
31
+ - **npm 包名改为 `bsfeng-dsh-guardian`**:npm 上 `dsh-guardian` 已被他人占用(lss1213 的插件),按用户确认改为 bsfeng 前缀。安装命令变为 `dsh plugin --profile web add link:<仓库路径>/plugins/dsh-guardian`(link 安装 key 同步)。API 路径(`/guardian/api/*`)与状态文件路径(`$DSH_HOME/guardian/state.json`)保持兼容。
32
+ - **Server 端按 P2 模块拆分**:`lib/index.js`(636 行)拆分为 state/fence/events/mount/api 子模块;**Client 端方案 B 拆分**(src 模板 + 5 片段 + build 拼接)。
33
+ - **README 补充真实 DSH 实例效果截图**(assets/panel-main.png + panel-error-detail.png,隔离实例实测)。
34
+ - 行为不变(重构 + 改名)。
35
+
36
+ ## [0.1.0] - 2026-08-23
37
+
38
+ ### Added
39
+
40
+ - 插件治理(dsh-guardian)首个版本:
41
+ - **两段式加载**:新插件写入候选区 `cordis.staged.json`(与 `cordis.patch.yml` 同目录),DSH 启动完成后由守护插件逐个热挂载,不阻塞启动。
42
+ - **失败隔离**:候选插件挂载失败自动记录(尝试次数 + 错误),连续失败 3 次冻结,不再自动重试。
43
+ - **成功转正**:挂载成功的插件自动进入守护插件的持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复。
44
+ - **安全模式**:一键跳过所有候选/已转正插件的加载,快速恢复被插件搞坏的环境。
45
+ - **诊断面板**:dsh-better-sidebar 侧边栏页签(状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关)。
46
+ - **事件监控**:`hmr/config-update-failed`、`loader/entry-init`、`loader/partial-dispose` 诊断事件记录。
47
+ - HTTP API `/guardian/api/*`(loopback 信任围栏)。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bsfeng
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,122 @@
1
+ # dsh-my-guardian — 插件守护
2
+
3
+ > DSH(DeepSeek Harness)插件隔离与失败兜底:**新装/刚更新的插件先进候选区,由守护插件在启动完成后逐个热挂载——成功自动转正,失败自动隔离记录,连续失败冻结,一键安全模式**,配套侧边栏诊断面板。
4
+
5
+ ## 为什么需要
6
+
7
+ DSH 的 Cordis 插件加载是 **all-or-nothing**:启动时任何插件 `import` 失败 / `apply` 抛错 / 依赖缺失,整个 `dsh web` 都会起不来(fail-loud 退出)。装一个新插件把环境搞坏,是插件用户最常踩的坑。
8
+
9
+ 本插件借鉴 VS Code(Extension Host 崩溃自动重启 + 禁用问题扩展)、IntelliJ(Dynamic Plugins 运行时装卸)、systemd(失败计数超限即停)的思路,利用 DSH loader 的**运行时动态挂载 API**(失败可捕获、可回滚),把"新插件"从启动路径挪到启动之后:
10
+
11
+ ```
12
+ cordis.patch.yml(核心区:只放稳定插件,守护插件自身)
13
+ ↓ 新插件写进
14
+ cordis.staged.json(候选区)
15
+ ↓ DSH 启动完成后
16
+ 守护插件逐个热挂载
17
+ ├─ 成功 → 自动转正(进入持久化清单,每次启动自动恢复)
18
+ └─ 失败 → 自动隔离(记录次数+错误,连续 3 次冻结)
19
+ ```
20
+
21
+ ## 功能
22
+
23
+ - **两段式加载**:新装/更新插件先进候选区,启动不阻塞、坏插件不拖垮进程。
24
+ - **失败自动隔离**:挂载失败自动记录(尝试次数 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
25
+ - **成功自动转正**:挂载成功的插件进入持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复挂载。
26
+ - **安全模式**:一键跳过所有候选/已转正插件,快速恢复被插件搞坏的环境。
27
+ - **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关 / 最近事件。
28
+ - **运行中热挂载**:DSH 运行期间往候选区加条目,自动挂载,无需重启。
29
+
30
+ ## 安装
31
+
32
+ > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。
33
+
34
+
35
+ ### 方式一:dsh plugin(推荐)
36
+
37
+ ```sh
38
+ # 1) 克隆本仓库(任意目录)
39
+ git clone https://github.com/baosfeng/my-dsh-plugins.git
40
+ # 2) 以本地 link 方式安装(将 <仓库路径> 替换为上面的克隆目录)
41
+ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
42
+ ```
43
+
44
+ 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)。
45
+
46
+ ### 方式二:手动
47
+
48
+ 1. 克隆本仓库后,在 `~/.dsh/profiles/web/package.json` 的 dependencies 增加 `"dsh-my-guardian": "link:<仓库路径>/plugins/dsh-my-guardian"`
49
+ 2. `cd ~/.dsh/profiles/web && pnpm install`
50
+ 3. 在 `~/.dsh/profiles/web/cordis.patch.yml` 的 insert 列表**第一行**加:
51
+
52
+ ```yaml
53
+ - insert:
54
+ - id: guardian
55
+ name: 'dsh-my-guardian'
56
+ ```
57
+
58
+ > ⚠️ **守护插件自身必须放在正式核心区第一行**(它自己是"看门狗":启动时加载,随后才管理候选插件)。
59
+
60
+ ## 使用
61
+
62
+ ### 候选区文件
63
+
64
+ 新建 `~/.dsh/profiles/web/cordis.staged.json`(与 `cordis.patch.yml` 同目录):
65
+
66
+ ```json
67
+ [
68
+ { "id": "my-plugin", "name": "dsh-my-plugin", "config": { "option": 1 } }
69
+ ]
70
+ ```
71
+
72
+ - `id`:唯一标识(不能与现有插件行 id 冲突)
73
+ - `name`:插件包名(profile node_modules 中可解析)
74
+ - `config`:可选,插件的配置
75
+
76
+ 写入文件后守护插件会自动挂载;挂载成功该条自动从候选文件移除(转正),失败则保留(状态记录在面板中可见)。
77
+
78
+ ### 面板
79
+
80
+ 侧边栏 → "插件守护"页签:
81
+
82
+ | 状态 | 含义 | 操作 |
83
+ |------|------|------|
84
+ | 运行中 | 已挂载 | 移除 |
85
+ | 待加载 | 尚未处理(安全模式等) | — |
86
+ | 失败 ×N | 挂载失败 N 次 | 重试 / 移除 |
87
+ | 冻结 | 连续失败 3 次 | 重试(解除冻结)/ 移除 |
88
+
89
+ ### 效果截图(真实 DSH 实例验证)
90
+
91
+ 侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
92
+
93
+ ![插件守护面板:候选失败隔离 + 转正运行中 + 安全模式开关](https://unpkg.com/dsh-my-guardian/assets/panel-main.png)
94
+
95
+ ![失败自动隔离:错误详情可查](https://unpkg.com/dsh-my-guardian/assets/panel-error-detail.png)
96
+
97
+ > 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3081`,端口 3081)。候选区同时写入 `demo-plugin`(挂载成功 → 自动转正"运行中")与 `dsh-no-such-plugin-xyz`(包不存在 → 挂载失败自动隔离 ×1,错误详情保留可查)。
98
+
99
+ ## 配置
100
+
101
+ **无应用层配置项**(`apply(ctx)` 不接收 config 参数,设置页无可视化配置入口;插件激活即生效)。运行时状态与开关:
102
+
103
+ - **状态文件**:`$DSH_HOME/guardian/state.json`(`~/.dsh/guardian/state.json`)——持久化候选/转正清单、失败次数、安全模式、事件日志。损坏自动降级为空状态,不影响启动。
104
+ - **安全模式**:面板开关(或直接编辑 `state.json` 的 `safeMode: true`)。开启后所有候选/已转正插件不再加载;恢复环境后关闭开关即重新加载。
105
+
106
+ ## 诚实的边界
107
+
108
+ - 本插件提供的是**加载时序与失败处置的兜底隔离**,不是进程级资源隔离(server 端插件仍在同一 Node 进程,client 端仍在同一浏览器页面)。进程级隔离需要 DSH 框架支持 worker/子进程 + RPC(演进建议见 [docs/插件治理/概述.md](../../docs/插件治理/概述.md))。
109
+ - **启动阶段的正式核心区(`cordis.patch.yml`)仍遵循 all-or-nothing**。请把一切新插件先放入候选区验证,稳定后再考虑放核心区。
110
+
111
+ ## 依赖
112
+
113
+ | 依赖 | 用途 | 可选 |
114
+ |---|---|---|
115
+ | `cordis` | 插件运行时 | 是(宿主提供) |
116
+ | `dsh-better-sidebar` | 侧边栏「插件守护」诊断面板(**可选增强,不参与依赖声明**:未安装时自动跳过面板注册,API / 候选区治理不受影响) | 否(可选增强) |
117
+ | `react` | client 端组件 | 是(宿主提供) |
118
+ | `dsh-my-notify` | 失败 / 冻结事件浏览器通知 | 是 |
119
+
120
+ ## 相关文档
121
+
122
+ → [插件治理概述](../../docs/插件治理/概述.md) · [需求清单](../../docs/插件治理/需求清单.md)
@@ -0,0 +1,24 @@
1
+ # dsh-my-guardian bundle patch: inserts the guardian row into the profile
2
+ # roster. This MUST be the FIRST row of the profile's own cordis.patch.yml
3
+ # insert list (the guardian itself is a core row: it loads at boot, before
4
+ # any candidate plugin it will stage later).
5
+ #
6
+ # Install with:
7
+ #
8
+ # git clone https://github.com/baosfeng/my-dsh-plugins.git
9
+ # dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
10
+ #
11
+ # or manually: add the link dependency to the profile package.json, run
12
+ # `pnpm install` in the profile dir, and add this insert row to the FIRST
13
+ # position of the profile's own `cordis.patch.yml`:
14
+ #
15
+ # - insert:
16
+ # - id: guardian
17
+ # name: 'dsh-my-guardian'
18
+ #
19
+ # After the guardian is mounted, new plugins go into the staged file
20
+ # `cordis.staged.json` next to cordis.patch.yml (see README.md) instead of
21
+ # straight into the boot path.
22
+ - insert:
23
+ - id: guardian
24
+ name: 'dsh-my-guardian'
package/lib/api.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * dsh-my-guardian — /guardian/api HTTP surface: deferred registration (the
3
+ * webServer service may mount after the guardian; poll ticks retry until it
4
+ * appears), trust fence, method dispatch and the panel snapshot.
5
+ */
6
+ import { readStagedFile, writeStagedFile } from './state.js'
7
+ import { isTrustedApiRequest } from './fence.js'
8
+
9
+ function writeJson(response, status, value) {
10
+ const payload = JSON.stringify(value)
11
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-cache' })
12
+ response.end(payload)
13
+ }
14
+
15
+ async function readJsonBody(request) {
16
+ let body = ''
17
+ for await (const chunk of request) {
18
+ body += chunk
19
+ if (body.length > 1_000_000) throw new Error('request body too large')
20
+ }
21
+ if (body === '') return {}
22
+ return JSON.parse(body)
23
+ }
24
+
25
+ /** Bind the API entry points to one guardian instance's shared state. */
26
+ export function createApi(ctx, shared) {
27
+ return {
28
+ ensureApi: () => ensureApi(ctx, shared),
29
+ snapshot: () => snapshot(shared),
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Register the /guardian/api routes once the webServer service appears
35
+ * (optional surface; CLI profiles skip it). Retried on every poll tick.
36
+ */
37
+ function ensureApi(ctx, shared) {
38
+ if (shared.apiRegistered) return
39
+ const webServer = ctx.get('webServer')
40
+ if (webServer === undefined) return
41
+ const webRuntime = ctx.get('webRuntime')
42
+ shared.apiRegistered = true
43
+ try {
44
+ ctx.effect(() => webServer.register({
45
+ kind: 'prefix',
46
+ path: '/guardian/api',
47
+ handler: (request, response) => handleApiRequest(ctx, shared, webRuntime, request, response),
48
+ }), 'dsh-my-guardian: /guardian/api routes')
49
+ } catch (error) {
50
+ // registration failed: allow a later poll tick to retry
51
+ shared.apiRegistered = false
52
+ ctx.logger?.warn(`[dsh-my-guardian] api registration failed: ${error instanceof Error ? error.message : String(error)}`)
53
+ }
54
+ }
55
+
56
+ /** Unified route handler: fence → method dispatch → 404/error fallback. */
57
+ async function handleApiRequest(ctx, shared, webRuntime, request, response) {
58
+ if (!isTrustedApiRequest(request, webRuntime?.trustedHosts ?? [])) {
59
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
60
+ return
61
+ }
62
+ const url = new URL(request.url ?? '/', 'http://dsh.internal')
63
+ const method = url.pathname.startsWith('/guardian/api/') ? url.pathname.slice('/guardian/api/'.length) : ''
64
+ try {
65
+ await dispatchApiMethod(ctx, shared, method, request, response)
66
+ } catch (error) {
67
+ writeJson(response, 400, { ok: false, error: { message: error instanceof Error ? error.message : String(error) } })
68
+ }
69
+ }
70
+
71
+ /** Dispatch one API method to its handler; unknown methods get 404. */
72
+ async function dispatchApiMethod(ctx, shared, method, request, response) {
73
+ if (method === 'state' && request.method === 'GET') {
74
+ writeJson(response, 200, { ok: true, value: shared.snapshot() })
75
+ return
76
+ }
77
+ if (request.method !== 'POST') {
78
+ writeJson(response, 404, { ok: false, error: { message: 'unknown guardian API method' } })
79
+ return
80
+ }
81
+ if (method === 'staged') {
82
+ await handleStagedPost(ctx, shared, request, response)
83
+ return
84
+ }
85
+ if (method === 'retry') {
86
+ await handleRetryPost(ctx, shared, request, response)
87
+ return
88
+ }
89
+ if (method === 'remove') {
90
+ await handleRemovePost(ctx, shared, request, response)
91
+ return
92
+ }
93
+ if (method === 'safemode') {
94
+ await handleSafemodePost(ctx, shared, request, response)
95
+ return
96
+ }
97
+ writeJson(response, 404, { ok: false, error: { message: 'unknown guardian API method' } })
98
+ }
99
+
100
+ /** POST /guardian/api/staged — add a candidate entry and mount it. */
101
+ async function handleStagedPost(ctx, shared, request, response) {
102
+ const payload = await readJsonBody(request)
103
+ const id = typeof payload.id === 'string' ? payload.id : ''
104
+ const name = typeof payload.name === 'string' ? payload.name : ''
105
+ if (id === '' || name === '') {
106
+ writeJson(response, 400, { ok: false, error: { message: 'id and name are required' } })
107
+ return
108
+ }
109
+ if (shared.conflictOf(id) !== null) {
110
+ writeJson(response, 409, { ok: false, error: { message: `id "${id}" already in use` } })
111
+ return
112
+ }
113
+ const entries = await readStagedFile(shared.stagedFile)
114
+ if (entries.some((item) => item?.id === id)) {
115
+ writeJson(response, 409, { ok: false, error: { message: `"${id}" already in the staged file` } })
116
+ return
117
+ }
118
+ entries.push({ id, name, ...(payload.config !== undefined ? { config: payload.config } : {}) })
119
+ const writeError = await writeStagedFile(shared.stagedFile, entries)
120
+ if (writeError !== null) throw writeError
121
+ await shared.scanStaged()
122
+ writeJson(response, 200, { ok: true, value: shared.snapshot() })
123
+ }
124
+
125
+ /** POST /guardian/api/retry — manual unfreeze of a staged/promoted entry. */
126
+ async function handleRetryPost(ctx, shared, request, response) {
127
+ const payload = await readJsonBody(request)
128
+ const id = typeof payload.id === 'string' ? payload.id : ''
129
+ const outcome = await shared.retryEntry(id)
130
+ if (outcome === null) {
131
+ writeJson(response, 404, { ok: false, error: { message: `no such entry "${id}"` } })
132
+ } else {
133
+ writeJson(response, 200, { ok: true, value: { outcome } })
134
+ }
135
+ }
136
+
137
+ /** POST /guardian/api/remove — drop an entry everywhere. */
138
+ async function handleRemovePost(ctx, shared, request, response) {
139
+ const payload = await readJsonBody(request)
140
+ const id = typeof payload.id === 'string' ? payload.id : ''
141
+ await shared.removeEntry(id)
142
+ writeJson(response, 200, { ok: true, value: shared.snapshot() })
143
+ }
144
+
145
+ /** POST /guardian/api/safemode — toggle safe mode (R5). */
146
+ async function handleSafemodePost(ctx, shared, request, response) {
147
+ const payload = await readJsonBody(request)
148
+ const enabled = payload.enabled === true
149
+ shared.state.safeMode = enabled
150
+ shared.logEvent('safe-mode', enabled ? 'safe mode enabled' : 'safe mode disabled')
151
+ if (enabled) {
152
+ for (const id of [...shared.mounted]) await shared.unmount(id)
153
+ } else {
154
+ shared.attempted.clear()
155
+ await shared.scanStaged()
156
+ await shared.mountPromoted()
157
+ }
158
+ shared.persistSoon()
159
+ writeJson(response, 200, { ok: true, value: shared.snapshot() })
160
+ }
161
+
162
+ /** Snapshot for the panel (leaf values only). */
163
+ function snapshot(shared) {
164
+ const stagedList = []
165
+ for (const [id, record] of Object.entries(shared.state.staged)) {
166
+ stagedList.push({
167
+ id,
168
+ name: record.name,
169
+ attempts: record.attempts,
170
+ frozen: record.frozen,
171
+ lastError: record.lastError,
172
+ lastFailedAt: record.lastFailedAt,
173
+ status: shared.mounted.has(id) ? 'running' : record.frozen ? 'frozen' : record.attempts > 0 ? 'failed' : 'pending',
174
+ })
175
+ }
176
+ const promotedList = []
177
+ for (const [id, record] of Object.entries(shared.state.promoted)) {
178
+ promotedList.push({
179
+ id,
180
+ name: record.name,
181
+ attempts: record.attempts,
182
+ frozen: record.frozen,
183
+ lastError: record.lastError,
184
+ lastFailedAt: record.lastFailedAt,
185
+ promotedAt: record.promotedAt,
186
+ status: shared.mounted.has(id) ? 'running' : record.frozen ? 'frozen' : record.attempts > 0 ? 'failed' : 'pending',
187
+ })
188
+ }
189
+ return { safeMode: shared.state.safeMode, staged: stagedList, promoted: promotedList, events: shared.state.events.slice(-10) }
190
+ }