@hyzyn/dsh-safe 0.1.0
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 +94 -0
- package/bin/dsh-safe.js +4 -0
- package/lib/cli.js +186 -0
- package/lib/dshpaths.js +78 -0
- package/lib/failures.js +74 -0
- package/lib/knownrows.js +86 -0
- package/lib/patchfile.js +119 -0
- package/lib/quarantine.js +99 -0
- package/lib/wrap.js +109 -0
- package/package.json +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hyzyn
|
|
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,94 @@
|
|
|
1
|
+
# dsh-safe · dsh 启动保险丝
|
|
2
|
+
|
|
3
|
+
中文 | [English](#english)
|
|
4
|
+
|
|
5
|
+
DeepSeek Harness(DSH)的社区插件与 dsh 运行时不兼容时,`dsh web` 会**整体启动失败**——加载器把所有 patch 层拉平成同一棵加载树,任何一个插件 import 失败、`apply` 抛错、或等不到注入的服务,启动审计就会拒绝整棵树,进程退出。此时只能手动编辑 `cordis.patch.yml` 把坏插件禁用。
|
|
6
|
+
|
|
7
|
+
**dsh-safe 把这个手动动作自动化了**:包装运行 `dsh`,启动失败时从报错里识别坏插件,在 profile patch 里把对应行置为 `disabled: true`(记录进隔离台账),然后自动重试。坏插件只影响自己,dsh 照常启动。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @hyzyn/dsh-safe
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
要求 Node >= 20,本机已安装 `dsh` 命令。零运行时依赖。
|
|
16
|
+
|
|
17
|
+
## 快速开始
|
|
18
|
+
|
|
19
|
+
把平时的 `dsh` 换成 `dsh-safe` 即可:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
dsh-safe web # 等价于 dsh web,带自动隔离
|
|
23
|
+
dsh-safe --profile tui --patch ./extra.yml
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
输出示例(坏插件被自动隔离后重试):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broken (@smoke/broken-impl): Cannot find package '@smoke/broken-impl' ...
|
|
30
|
+
[dsh-safe] 已禁用 @smoke/broken-impl (id: smoke-broken) → /Users/me/.dsh/profiles/web/cordis.patch.yml
|
|
31
|
+
原因: Error: failed to import loader entry smoke-broken (@smoke/broken-impl): Cannot find package …
|
|
32
|
+
[dsh-safe] 重试启动…
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 命令
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
dsh-safe <dsh 参数…> 包装运行 dsh
|
|
39
|
+
dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
|
|
40
|
+
dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
|
|
41
|
+
恢复被自动禁用的插件(升级修复后使用)
|
|
42
|
+
dsh-safe help
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
包装模式选项(必须写在 profile / 子命令之前):
|
|
46
|
+
|
|
47
|
+
| 选项 | 说明 |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `--dry-run` | 只解析与报告,不修改任何文件 |
|
|
50
|
+
| `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
|
|
51
|
+
| `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
|
|
52
|
+
|
|
53
|
+
## 工作原理
|
|
54
|
+
|
|
55
|
+
1. **识别失败**:dsh 启动失败时,stderr 里有四类特征(`plugin(s) failed to load: …`、`N entries did not activate` 逐行失败、`failed to apply/import loader entry <id> (<name>)`、外层栈 `…#<entryId>`)。dsh-safe 从中提取坏插件的包名与行 id。
|
|
56
|
+
2. **对照真实行**:扫描 profile patch、`$DSH_HOME/cordis.patch.yml`(home 层)与各 bundle 的 patch,得到「行 id ↔ 插件包名」对照表;只禁用真实存在的行,避免误伤。
|
|
57
|
+
3. **写入托管区块**:在对应 patch 文件末尾追加带标记注释的区块(与 `dsh-mcp-config managed` 同款约定),把命中的行置为 `disabled: true`。用户已有内容与注释原样保留;全新 profile 的 `[]` 模板会被正确替换成块序列。
|
|
58
|
+
4. **台账与恢复**:隔离记录存 `$DSH_HOME/dsh-safe/quarantine.json`。插件升级修复后用 `dsh-safe restore --profile web --all` 摘除区块恢复挂载(`patchReload: live` 的 profile 热生效)。
|
|
59
|
+
|
|
60
|
+
## 安全边界
|
|
61
|
+
|
|
62
|
+
- **第一方保护**:`@deepseek-ai/*` 的行默认跳过(禁用 `dsh-web-app` 这类插件会让 dsh 失去核心能力),需要 `--allow-first-party` 才会动。
|
|
63
|
+
- **只动启动期失败**:模块解析失败 / `apply` 抛错 / 等不到注入服务。运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理,不属于启动隔离范围。
|
|
64
|
+
- **可审计**:每次写入都带原因与时间戳;`--dry-run` 可以先看会禁用谁。
|
|
65
|
+
- **原样透传**:识别不出坏插件、超过重试上限、`dsh plugin`(pnpm 转发)等情况,退出码原样透传,不做任何修改。
|
|
66
|
+
|
|
67
|
+
## 已知限制
|
|
68
|
+
|
|
69
|
+
- patch 文件本身 YAML 解析错误(如手改坏了)时无法识别插件,只会透传。
|
|
70
|
+
- `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
|
|
71
|
+
- 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
|
|
72
|
+
- 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
|
|
73
|
+
|
|
74
|
+
## 开发
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm test # node:test 单元测试 + 假 dsh 集成测试
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
<a name="english"></a>
|
|
81
|
+
## English
|
|
82
|
+
|
|
83
|
+
**dsh-safe** is a fuse for DeepSeek Harness (DSH) startup: when a community plugin is incompatible with the running dsh version, `dsh web` normally fails to boot entirely — one broken plugin rejects the whole loader tree. dsh-safe wraps any `dsh` invocation, parses the startup-failure diagnostics on stderr, marks the offending patch rows as `disabled: true` inside a managed block in your profile patch file (keeping all user content and comments), records the action in a quarantine ledger, and retries. First-party `@deepseek-ai/*` plugins are protected by default; `dsh-safe restore --profile <name> --all` re-enables quarantined rows after you upgrade the plugin.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm install -g @hyzyn/dsh-safe
|
|
87
|
+
dsh-safe web # run dsh web with auto-quarantine
|
|
88
|
+
dsh-safe list # show quarantined plugins
|
|
89
|
+
dsh-safe restore --profile web --all
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
[MIT](./LICENSE)
|
package/bin/dsh-safe.js
ADDED
package/lib/cli.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — 命令行入口。
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* dsh-safe <dsh 参数…> 包装运行 dsh(dsh-safe 的旗标必须写在最前)
|
|
6
|
+
* dsh-safe list [--profile <名>]
|
|
7
|
+
* dsh-safe restore --profile <名> (--id <id>… | --all) [--dry-run]
|
|
8
|
+
* dsh-safe help | --version
|
|
9
|
+
*/
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { loadLedger, restoreQuarantine } from './quarantine.js'
|
|
12
|
+
import { runWrapped } from './wrap.js'
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url)
|
|
15
|
+
const { version } = require('../package.json')
|
|
16
|
+
|
|
17
|
+
const HELP = `dsh-safe ${version} — dsh 启动保险丝
|
|
18
|
+
|
|
19
|
+
社区插件与 dsh 运行时不兼容会让 dsh 整体启动失败。dsh-safe 包装运行 dsh:
|
|
20
|
+
启动失败时从报错里识别坏插件,在 profile patch 里把对应行置为 disabled
|
|
21
|
+
(记录进隔离台账),然后自动重试。
|
|
22
|
+
|
|
23
|
+
用法:
|
|
24
|
+
dsh-safe <dsh 参数…> 包装运行 dsh,例: dsh-safe web
|
|
25
|
+
dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
|
|
26
|
+
dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
|
|
27
|
+
恢复被自动禁用的插件(升级修复后使用)
|
|
28
|
+
dsh-safe help 显示本帮助
|
|
29
|
+
dsh-safe --version 显示版本
|
|
30
|
+
|
|
31
|
+
包装模式选项(必须写在 profile / 子命令之前):
|
|
32
|
+
--dry-run 只解析与报告,不修改任何文件
|
|
33
|
+
--max-retries <n> 自动隔离后最多重试启动的次数(默认 2)
|
|
34
|
+
--allow-first-party 允许自动禁用 @deepseek-ai/* 第一方插件(默认跳过)
|
|
35
|
+
|
|
36
|
+
说明:
|
|
37
|
+
- 行的禁用以 patch 文件末尾的托管区块写入(带标记注释),不改动用户内容;
|
|
38
|
+
恢复用 dsh-safe restore,或手动删除区块。
|
|
39
|
+
- 只隔离"启动期"失败(模块解析失败 / apply 抛错 / 等不到注入服务);
|
|
40
|
+
运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理。
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
function printHelp() {
|
|
44
|
+
process.stdout.write(HELP)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printVersion() {
|
|
48
|
+
process.stdout.write(`${version}\n`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 解析 `--profile <名>` / `--profile=<名>`,返回 [值, 剩余参数]。 */
|
|
52
|
+
function takeProfile(args) {
|
|
53
|
+
let profile
|
|
54
|
+
const rest = []
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
if (args[i] === '--profile') {
|
|
57
|
+
profile = args[++i]
|
|
58
|
+
} else if (args[i].startsWith('--profile=')) {
|
|
59
|
+
profile = args[i].slice('--profile='.length)
|
|
60
|
+
} else {
|
|
61
|
+
rest.push(args[i])
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [profile, rest]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 解析可重复的 `--id <id>` / `--id=<id>`。 */
|
|
68
|
+
function takeIds(args) {
|
|
69
|
+
const ids = []
|
|
70
|
+
const rest = []
|
|
71
|
+
for (let i = 0; i < args.length; i++) {
|
|
72
|
+
if (args[i] === '--id') {
|
|
73
|
+
const v = args[++i]
|
|
74
|
+
if (v !== undefined) ids.push(v)
|
|
75
|
+
} else if (args[i].startsWith('--id=')) {
|
|
76
|
+
ids.push(args[i].slice('--id='.length))
|
|
77
|
+
} else {
|
|
78
|
+
rest.push(args[i])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [ids, rest]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cmdList(args) {
|
|
85
|
+
const [profile] = takeProfile(args)
|
|
86
|
+
const ledger = loadLedger()
|
|
87
|
+
const profiles = profile ? [profile] : Object.keys(ledger.profiles).sort()
|
|
88
|
+
let found = 0
|
|
89
|
+
for (const p of profiles) {
|
|
90
|
+
const entries = ledger.profiles[p] ?? []
|
|
91
|
+
if (!entries.length) continue
|
|
92
|
+
process.stdout.write(`${p}:\n`)
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
found++
|
|
95
|
+
process.stdout.write(` - ${e.name ?? '(未知包名)'} (id: ${e.id})\n 隔离于 ${e.quarantinedAt}\n 原因: ${e.reason}\n 位置: ${e.file}\n`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!found) process.stdout.write(profile ? `${profile}: 没有隔离记录\n` : '没有隔离记录\n')
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cmdRestore(args) {
|
|
103
|
+
const [profile0, rest0] = takeProfile(args)
|
|
104
|
+
const [ids0, rest1] = takeIds(rest0)
|
|
105
|
+
const dryRun = rest1.includes('--dry-run')
|
|
106
|
+
const all = rest1.includes('--all')
|
|
107
|
+
const profile = profile0
|
|
108
|
+
if (!profile) {
|
|
109
|
+
process.stderr.write('[dsh-safe] restore 需要 --profile <名>\n')
|
|
110
|
+
return 2
|
|
111
|
+
}
|
|
112
|
+
if (!all && ids0.length === 0) {
|
|
113
|
+
process.stderr.write('[dsh-safe] restore 需要 --id <id>(可重复)或 --all\n')
|
|
114
|
+
return 2
|
|
115
|
+
}
|
|
116
|
+
const { restored, kept } = restoreQuarantine(profile, all ? 'all' : ids0, dryRun)
|
|
117
|
+
const verb = dryRun ? '(dry-run)将恢复' : '已恢复'
|
|
118
|
+
for (const e of restored) process.stdout.write(`[dsh-safe] ${verb} ${e.name ?? e.id} (id: ${e.id})\n`)
|
|
119
|
+
if (!restored.length) process.stdout.write('[dsh-safe] 没有匹配的隔离记录\n')
|
|
120
|
+
else if (kept.length) process.stdout.write(`[dsh-safe] ${profile} 仍隔离 ${kept.length} 行;重启 dsh 生效。\n`)
|
|
121
|
+
else process.stdout.write('[dsh-safe] 该 profile 的隔离名单已清空;重启 dsh 生效。\n')
|
|
122
|
+
return 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
127
|
+
* @returns {Promise<number>} 退出码
|
|
128
|
+
*/
|
|
129
|
+
export async function main(argv) {
|
|
130
|
+
if (argv.length === 0) {
|
|
131
|
+
printHelp()
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
const [cmd] = argv
|
|
135
|
+
if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
|
136
|
+
printHelp()
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
|
|
140
|
+
printVersion()
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
if (cmd === 'list') return cmdList(argv.slice(1))
|
|
144
|
+
if (cmd === 'restore') return cmdRestore(argv.slice(1))
|
|
145
|
+
|
|
146
|
+
// 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
|
|
147
|
+
// 其余原样转发给 dsh。
|
|
148
|
+
const forwardArgs = []
|
|
149
|
+
let dryRun = false
|
|
150
|
+
let maxRetries = 2
|
|
151
|
+
let allowFirstParty = false
|
|
152
|
+
let i = 0
|
|
153
|
+
for (; i < argv.length; i++) {
|
|
154
|
+
const a = argv[i]
|
|
155
|
+
if (a === '--dry-run') { dryRun = true; continue }
|
|
156
|
+
if (a === '--allow-first-party') { allowFirstParty = true; continue }
|
|
157
|
+
if (a === '--max-retries') {
|
|
158
|
+
const raw = argv[++i]
|
|
159
|
+
const v = raw === undefined || raw === '' ? NaN : Number(raw)
|
|
160
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
161
|
+
process.stderr.write('[dsh-safe] --max-retries 需要一个非负整数\n')
|
|
162
|
+
return 2
|
|
163
|
+
}
|
|
164
|
+
maxRetries = v
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
if (a.startsWith('--max-retries=')) {
|
|
168
|
+
const raw = a.slice('--max-retries='.length)
|
|
169
|
+
const v = raw === '' ? NaN : Number(raw)
|
|
170
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
171
|
+
process.stderr.write('[dsh-safe] --max-retries 需要一个非负整数\n')
|
|
172
|
+
return 2
|
|
173
|
+
}
|
|
174
|
+
maxRetries = v
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
forwardArgs.push(...argv.slice(i))
|
|
180
|
+
if (!forwardArgs.length) {
|
|
181
|
+
printHelp()
|
|
182
|
+
return 0
|
|
183
|
+
}
|
|
184
|
+
if (dryRun) process.stderr.write('[dsh-safe] dry-run:只报告,不修改文件。\n')
|
|
185
|
+
return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty })
|
|
186
|
+
}
|
package/lib/dshpaths.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — 路径与调用解析。
|
|
3
|
+
*
|
|
4
|
+
* 与 dsh 运行时(@deepseek-ai/dsh-home-paths)保持一致的 home 解析规则:
|
|
5
|
+
* `DSH_HOME` 环境变量优先(支持 `~` 前缀展开),默认 `~/.dsh`。
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { resolve, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** dsh home 目录:`DSH_HOME` 优先,默认 `~/.dsh`。 */
|
|
12
|
+
export function dshHome() {
|
|
13
|
+
const raw = process.env.DSH_HOME
|
|
14
|
+
if (!raw || raw.trim() === '') return join(homedir(), '.dsh')
|
|
15
|
+
const expanded = raw === '~' || raw.startsWith('~/')
|
|
16
|
+
? join(homedir(), raw.slice(1).replace(/^[/\\]/, ''))
|
|
17
|
+
: raw
|
|
18
|
+
return resolve(expanded)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const profileDir = (profile) => join(dshHome(), 'profiles', profile)
|
|
22
|
+
export const profilePatchPath = (profile) => join(profileDir(profile), 'cordis.patch.yml')
|
|
23
|
+
export const profileManifestPath = (profile) => join(profileDir(profile), 'package.json')
|
|
24
|
+
|
|
25
|
+
/** dsh home 层的公共 patch(对所有 profile 生效)。 */
|
|
26
|
+
export const homePatchPath = () => join(dshHome(), 'cordis.patch.yml')
|
|
27
|
+
|
|
28
|
+
/** dsh-safe 的隔离台账。 */
|
|
29
|
+
export const ledgerFile = () => join(dshHome(), 'dsh-safe', 'quarantine.json')
|
|
30
|
+
|
|
31
|
+
/** 读文件,不存在或读不了返回 undefined。 */
|
|
32
|
+
export const readIfExists = (path) => {
|
|
33
|
+
try {
|
|
34
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : undefined
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解析转发给 dsh 的启动参数,识别 profile 与模式。
|
|
42
|
+
*
|
|
43
|
+
* 与 dsh 启动器一致:launcher 自己的旗标在最前(`--profile <v>`、`--patch <v>`
|
|
44
|
+
* 各带一个值),第一个无法识别的位置参数起就是内部参数。因此只在"launcher 前缀"
|
|
45
|
+
* 里找 `--profile`;`plugin` 子命令例外——它的 `--profile` 跟在子命令后面。
|
|
46
|
+
*
|
|
47
|
+
* @param {string[]} args 转发参数(已剥掉 dsh-safe 自己的旗标)
|
|
48
|
+
* @returns {{ mode: 'boot'|'plugin', profile: string|null }}
|
|
49
|
+
*/
|
|
50
|
+
export function detectInvocation(args) {
|
|
51
|
+
let firstPositional = -1
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
const a = args[i]
|
|
54
|
+
if (!a.startsWith('-')) { firstPositional = i; break }
|
|
55
|
+
// 带值的 launcher 旗标:跳过它的值
|
|
56
|
+
if (a === '--profile' || a === '--patch') i++
|
|
57
|
+
}
|
|
58
|
+
const first = firstPositional >= 0 ? args[firstPositional] : undefined
|
|
59
|
+
if (first === 'plugin') {
|
|
60
|
+
let profile
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
if (args[i] === '--profile') profile = args[i + 1]
|
|
63
|
+
else if (args[i].startsWith('--profile=')) profile = args[i].slice('--profile='.length)
|
|
64
|
+
}
|
|
65
|
+
return { mode: 'plugin', profile: profile || null }
|
|
66
|
+
}
|
|
67
|
+
let profile
|
|
68
|
+
const launcherEnd = firstPositional < 0 ? args.length : firstPositional
|
|
69
|
+
for (let i = 0; i < launcherEnd; i++) {
|
|
70
|
+
if (args[i] === '--profile') profile = args[i + 1]
|
|
71
|
+
else if (args[i].startsWith('--profile=')) profile = args[i].slice('--profile='.length)
|
|
72
|
+
}
|
|
73
|
+
if (!profile && first) {
|
|
74
|
+
if (first === 'web') profile = 'web'
|
|
75
|
+
else if (existsSync(profileDir(first))) profile = first
|
|
76
|
+
}
|
|
77
|
+
return { mode: 'boot', profile: profile || null }
|
|
78
|
+
}
|
package/lib/failures.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — 从 dsh 启动失败的 stderr 里识别坏插件。
|
|
3
|
+
*
|
|
4
|
+
* 匹配四类启动失败特征(来自 @deepseek-ai/dsh-app-boot 与
|
|
5
|
+
* @deepseek-ai/cordis-plugin-loader 的真实输出):
|
|
6
|
+
*
|
|
7
|
+
* 1. assertEntriesLoaded:
|
|
8
|
+
* `dsh: plugin(s) failed to load: @a/x, @b/y; Cordis startup failed ...`
|
|
9
|
+
* 2. assertEntriesActivated:
|
|
10
|
+
* `dsh: 2 entries did not activate` 之后每行一条 `@a/x: <错误>` / `@a/x: pending (waiting for service(s): xxx)`
|
|
11
|
+
* 3. loader entry 更新失败:
|
|
12
|
+
* `failed to (apply|import|dispose|rollback) loader entry <id> (<name>): <原因>`
|
|
13
|
+
* 4. 外层栈(getOuterStack):
|
|
14
|
+
* ` at file:///…/profiles/web/#<entryId>`
|
|
15
|
+
*
|
|
16
|
+
* 1–3 给出包名(entry.options.name),4 给出行 id;两者都要在调用方与
|
|
17
|
+
* patch 行对照后才会生效,所以这里允许宽收集。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const NAME_CLASS = '[\\w@][\\w@./\\-]*'
|
|
21
|
+
const ID_CLASS = '[\\w:\\-]+'
|
|
22
|
+
|
|
23
|
+
const isPluginName = (s) => NAME_REGEX.test(s)
|
|
24
|
+
const NAME_REGEX = new RegExp(`^${NAME_CLASS}$`)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} text dsh 进程捕获到的 stderr
|
|
28
|
+
* @returns {{ names: Array<[string, string]>, entryIds: Array<[string, string]> }}
|
|
29
|
+
* names: [包名, 出现该命中的原始行];entryIds: [行 id, 原始行]
|
|
30
|
+
*/
|
|
31
|
+
export function parseFailureReport(text) {
|
|
32
|
+
const names = new Map()
|
|
33
|
+
const entryIds = new Map()
|
|
34
|
+
const lines = text.split(/\r?\n/)
|
|
35
|
+
const reEntry = new RegExp(`failed to (?:apply|import|dispose|rollback) loader entry (${ID_CLASS}) \\(([^)]+)\\)`)
|
|
36
|
+
const reStackId = new RegExp(`^\\s*at \\S+#(${ID_CLASS})`)
|
|
37
|
+
const reLoadList = /plugin\(s\) failed to load:\s*([^;\n]+);/
|
|
38
|
+
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const entry = reEntry.exec(line)
|
|
41
|
+
if (entry) {
|
|
42
|
+
entryIds.set(entry[1], line)
|
|
43
|
+
if (isPluginName(entry[2])) names.set(entry[2], line)
|
|
44
|
+
}
|
|
45
|
+
const stackId = reStackId.exec(line)
|
|
46
|
+
if (stackId) entryIds.set(stackId[1], line)
|
|
47
|
+
const list = reLoadList.exec(line)
|
|
48
|
+
if (list) {
|
|
49
|
+
for (const raw of list[1].split(/,\s*/)) {
|
|
50
|
+
const name = raw.trim()
|
|
51
|
+
if (name && isPluginName(name)) names.set(name, line)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// "did not activate" 块:头部之后的每一行 `包名: …` 都是一条失败。
|
|
57
|
+
// 失败行的错误体可能自带多行栈,所以扫到文本末尾,靠调用方的行名对照收窄。
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
if (!lines[i].includes('did not activate')) continue
|
|
60
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
61
|
+
const m = new RegExp(`^\\s*(${NAME_CLASS}):\\s`).exec(lines[j])
|
|
62
|
+
if (m && isPluginName(m[1])) names.set(m[1], lines[j])
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { names: [...names], entryIds: [...entryIds] }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 压缩一行错误为台账里的 reason(去空白、截断)。 */
|
|
70
|
+
export function summarizeLine(line, max = 160) {
|
|
71
|
+
if (!line) return 'startup failure'
|
|
72
|
+
const flat = line.replace(/\s+/g, ' ').trim()
|
|
73
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
74
|
+
}
|
package/lib/knownrows.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — 收集一个 profile 下"行 id ↔ 插件包名"的对照表。
|
|
3
|
+
*
|
|
4
|
+
* 来源(后写的层优先级更高):
|
|
5
|
+
* 1. profile 自己的 cordis.patch.yml(写层)
|
|
6
|
+
* 2. `$DSH_HOME/cordis.patch.yml` home 层(写层:发现的行写回 home patch)
|
|
7
|
+
* 3. profile package.json `dsh.profile.bundles` 里每个 bundle 的 patch
|
|
8
|
+
* (只读对照;禁用行写入 profile 层,它在 bundle 层之后合成,同 id 后写覆盖)
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { scanPatchRows } from './patchfile.js'
|
|
12
|
+
import { homePatchPath, profileDir, profileManifestPath, profilePatchPath, readIfExists } from './dshpaths.js'
|
|
13
|
+
|
|
14
|
+
const readJsonIfExists = (path) => {
|
|
15
|
+
const raw = readIfExists(path)
|
|
16
|
+
if (raw === undefined) return undefined
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(raw)
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} profile
|
|
26
|
+
* @returns {{
|
|
27
|
+
* rows: Array<{ id: string, name: string|null, disabled: boolean, source: string, file: string }>,
|
|
28
|
+
* }}
|
|
29
|
+
*/
|
|
30
|
+
export function collectKnownRows(profile) {
|
|
31
|
+
const rows = []
|
|
32
|
+
const profilePatch = profilePatchPath(profile)
|
|
33
|
+
const homePatch = homePatchPath()
|
|
34
|
+
|
|
35
|
+
const profileText = readIfExists(profilePatch)
|
|
36
|
+
if (profileText !== undefined) {
|
|
37
|
+
for (const row of scanPatchRows(profileText)) rows.push({ ...row, source: 'profile', file: profilePatch })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const homeText = readIfExists(homePatch)
|
|
41
|
+
if (homeText !== undefined) {
|
|
42
|
+
for (const row of scanPatchRows(homeText)) rows.push({ ...row, source: 'home', file: homePatch })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const manifest = readJsonIfExists(profileManifestPath(profile))
|
|
46
|
+
const bundles = manifest?.dsh?.profile?.bundles ?? []
|
|
47
|
+
for (const bundle of bundles) {
|
|
48
|
+
const bundleDir = join(profileDir(profile), 'node_modules', bundle)
|
|
49
|
+
const pkg = readJsonIfExists(join(bundleDir, 'package.json'))
|
|
50
|
+
const patchRel = pkg?.dsh?.bundle?.patch
|
|
51
|
+
if (typeof patchRel !== 'string') continue
|
|
52
|
+
const patchText = readIfExists(join(bundleDir, patchRel))
|
|
53
|
+
if (patchText === undefined) continue
|
|
54
|
+
for (const row of scanPatchRows(patchText)) rows.push({ ...row, source: bundle, file: profilePatch })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { rows, profilePatch, homePatch }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 把失败报告对照到真实存在的 patch 行。
|
|
62
|
+
* 包名命中该包的全部行(一个包可能挂多个 id);行 id 命中对应行。
|
|
63
|
+
* @param {{ names: Array<[string, string]>, entryIds: Array<[string, string]> }} report
|
|
64
|
+
* @param {{ rows: Array<{ id: string, name: string|null, disabled: boolean, file: string }> }} known
|
|
65
|
+
* @returns {Array<{ id: string, name: string|null, disabled: boolean, file: string, line: string }>}
|
|
66
|
+
*/
|
|
67
|
+
export function matchFailures(report, known) {
|
|
68
|
+
const byId = new Map()
|
|
69
|
+
const byName = new Map()
|
|
70
|
+
for (const row of known.rows) {
|
|
71
|
+
if (!byId.has(row.id)) byId.set(row.id, row)
|
|
72
|
+
if (row.name) {
|
|
73
|
+
if (!byName.has(row.name)) byName.set(row.name, [])
|
|
74
|
+
byName.get(row.name).push(row)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const hits = new Map()
|
|
78
|
+
for (const [name, line] of report.names) {
|
|
79
|
+
for (const row of byName.get(name) ?? []) hits.set(row.id, { ...row, line })
|
|
80
|
+
}
|
|
81
|
+
for (const [id, line] of report.entryIds) {
|
|
82
|
+
const row = byId.get(id)
|
|
83
|
+
if (row) hits.set(row.id, { ...row, line })
|
|
84
|
+
}
|
|
85
|
+
return [...hits.values()]
|
|
86
|
+
}
|
package/lib/patchfile.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — cordis.patch.yml 的行扫描与托管区块读写。
|
|
3
|
+
*
|
|
4
|
+
* patch 文件带用户注释与 `!!js` 表达式,不能用 YAML 库无损重写;这里只做
|
|
5
|
+
* 行级扫描(拿 id/name 对照表)与"标记区块"的追加/移除,其余内容原样保留。
|
|
6
|
+
* 托管区块约定与 dsh-mcp-config 的 managed 区块一致(首尾各一行标记注释)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MANAGED_START = '# --- dsh-safe managed (auto-generated; do not edit) ---'
|
|
10
|
+
export const MANAGED_END = '# --- end dsh-safe managed ---'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 扫描 patch 文本中的 loader 行,返回 [{ id, name, disabled }]。
|
|
14
|
+
*
|
|
15
|
+
* 行的形状:`- id: xxx` 后跟同级缩进的 `name:` / `disabled:` 等键。需要避开
|
|
16
|
+
* `config:` 子树里恰好也叫 `id`/`name` 的配置项,因此维护一个"已打开的
|
|
17
|
+
* config: 键缩进"栈:处理每行前先弹出缩进 >= 当前行缩进的 config;只有栈
|
|
18
|
+
* 为空时的 `- id:` 才算 loader 行。
|
|
19
|
+
*
|
|
20
|
+
* @param {string} text patch 文件内容
|
|
21
|
+
* @returns {Array<{ id: string, name: string|null, disabled: boolean }>}
|
|
22
|
+
*/
|
|
23
|
+
export function scanPatchRows(text) {
|
|
24
|
+
const rows = []
|
|
25
|
+
const lines = text.split(/\r?\n/)
|
|
26
|
+
const openConfigs = [] // 已打开 config: 键的缩进
|
|
27
|
+
let current = null // { id, name, disabled, keyIndent }
|
|
28
|
+
const flush = () => {
|
|
29
|
+
if (current) {
|
|
30
|
+
rows.push({ id: current.id, name: current.name, disabled: current.disabled })
|
|
31
|
+
current = null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const indent = /^\s*/.exec(line)[0].length
|
|
36
|
+
while (openConfigs.length && openConfigs[openConfigs.length - 1] >= indent) openConfigs.pop()
|
|
37
|
+
if (openConfigs.length) continue // 在某个 config: 子树里
|
|
38
|
+
const trimmed = line.trim()
|
|
39
|
+
const rowM = /^-\s+id:\s*(\S+)$/.exec(trimmed)
|
|
40
|
+
if (rowM) {
|
|
41
|
+
flush()
|
|
42
|
+
current = { id: rowM[1], name: null, disabled: false, keyIndent: indent + 2 }
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!current || indent !== current.keyIndent) continue
|
|
46
|
+
const nameM = /^name:\s*(.+?)\s*$/.exec(trimmed)
|
|
47
|
+
if (nameM) { current.name = unquote(nameM[1]); continue }
|
|
48
|
+
if (/^disabled:\s*true\s*$/.test(trimmed)) { current.disabled = true; continue }
|
|
49
|
+
if (/^config:(\s|$)/.test(trimmed)) openConfigs.push(indent)
|
|
50
|
+
}
|
|
51
|
+
flush()
|
|
52
|
+
return rows
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function unquote(value) {
|
|
56
|
+
const quoted = /^(['"])(.*?)\1(?:\s|$)/.exec(value)
|
|
57
|
+
if (quoted) return quoted[2]
|
|
58
|
+
return value.split(/\s+#/, 1)[0].trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 生成托管区块文本(不含尾部换行)。
|
|
63
|
+
* @param {Array<{ id: string, name?: string|null, reason?: string, quarantinedAt?: string }>} entries
|
|
64
|
+
*/
|
|
65
|
+
export function buildManagedBlock(entries) {
|
|
66
|
+
const out = [MANAGED_START]
|
|
67
|
+
out.push('# 由 dsh-safe 自动写入:启动失败的插件被置为 disabled,避免拖垮整个启动。')
|
|
68
|
+
out.push('# 恢复:dsh-safe restore --profile <name> [--id <id> ... | --all]')
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
out.push(`# ${e.quarantinedAt ?? ''} · ${e.name ?? e.id} · ${e.reason ?? 'startup failure'}`.trimEnd())
|
|
71
|
+
out.push(`- id: ${e.id}`)
|
|
72
|
+
if (e.name) out.push(` name: '${e.name}'`)
|
|
73
|
+
out.push(' disabled: true')
|
|
74
|
+
}
|
|
75
|
+
out.push(MANAGED_END)
|
|
76
|
+
return out.join('\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 去掉文本中的所有 dsh-safe 托管区块(含标记行),并规范尾部换行。 */
|
|
80
|
+
export function stripManagedBlocks(text) {
|
|
81
|
+
const lines = text.split(/\r?\n/)
|
|
82
|
+
const kept = []
|
|
83
|
+
let skipping = false
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (line.trim() === MANAGED_START) { skipping = true; continue }
|
|
86
|
+
if (skipping) {
|
|
87
|
+
if (line.trim() === MANAGED_END) skipping = false
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
kept.push(line)
|
|
91
|
+
}
|
|
92
|
+
return kept.join('\n').replace(/\n+$/, '')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 在 patch 文本上应用托管区块:先移除旧区块,再追加新区块(block 为 null 只移除)。
|
|
97
|
+
*
|
|
98
|
+
* 追加时若文件还是全新模板(根节点为空的 flow 序列 `[]`),必须去掉 `[]` 行,
|
|
99
|
+
* 否则 `[]` 之后跟块序列是非法 YAML。
|
|
100
|
+
* @returns {{ text: string }}
|
|
101
|
+
*/
|
|
102
|
+
export function applyManagedBlock(text, block) {
|
|
103
|
+
let base = stripManagedBlocks(text)
|
|
104
|
+
if (block) {
|
|
105
|
+
base = base
|
|
106
|
+
.split(/\r?\n/)
|
|
107
|
+
.filter((l) => l.trim() !== '[]')
|
|
108
|
+
.join('\n')
|
|
109
|
+
.replace(/\n+$/, '')
|
|
110
|
+
}
|
|
111
|
+
if (!block) {
|
|
112
|
+
// 摘除后若文档只剩注释/空白,补回 `[]`(dsh 的 parsePatchList 拒绝 null 文档)
|
|
113
|
+
const hasRows = base.split(/\r?\n/).some((l) => l.trim() && !l.trim().startsWith('#'))
|
|
114
|
+
if (!hasRows) return { text: base.length ? `${base}\n[]\n` : '' }
|
|
115
|
+
return { text: base + '\n' }
|
|
116
|
+
}
|
|
117
|
+
const next = base.length ? `${base}\n\n${block}\n` : `${block}\n`
|
|
118
|
+
return { text: next }
|
|
119
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — 隔离台账与 patch 文件写入。
|
|
3
|
+
*
|
|
4
|
+
* 台账:`$DSH_HOME/dsh-safe/quarantine.json`,按 profile 记录被自动禁用的行
|
|
5
|
+
* (id/name/reason/时间/所在文件)。patch 文件的改动只体现为末尾的托管区块
|
|
6
|
+
* (带标记注释,可重复生成、可整体摘除),用户已有内容与注释原样保留。
|
|
7
|
+
*
|
|
8
|
+
* 同一个 patch 文件(如 `$DSH_HOME/cordis.patch.yml`)可能被多个 profile 的
|
|
9
|
+
* 隔离记录共享:托管区块始终按"文件"聚合全部 profile 的台账条目重新生成,
|
|
10
|
+
* 避免一次写入覆盖另一个 profile 的区块。
|
|
11
|
+
*/
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { dirname } from 'node:path'
|
|
14
|
+
import { applyManagedBlock, buildManagedBlock } from './patchfile.js'
|
|
15
|
+
import { ledgerFile, readIfExists } from './dshpaths.js'
|
|
16
|
+
|
|
17
|
+
const EMPTY_LEDGER = () => ({ version: 1, profiles: {} })
|
|
18
|
+
|
|
19
|
+
export function loadLedger() {
|
|
20
|
+
const raw = readIfExists(ledgerFile())
|
|
21
|
+
if (raw === undefined) return EMPTY_LEDGER()
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(raw)
|
|
24
|
+
if (parsed && typeof parsed === 'object' && parsed.profiles && typeof parsed.profiles === 'object') {
|
|
25
|
+
return { version: 1, profiles: parsed.profiles }
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
return EMPTY_LEDGER()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveLedger(ledger) {
|
|
32
|
+
const file = ledgerFile()
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
34
|
+
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 重写指定 patch 文件的托管区块(跨 profile 聚合;extraFiles 兜底清空的文件)。 */
|
|
38
|
+
function rewriteManagedBlocks(ledger, extraFiles = []) {
|
|
39
|
+
const all = Object.values(ledger.profiles).flat()
|
|
40
|
+
const files = new Set([...all.map((e) => e.file), ...extraFiles])
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const entries = all.filter((e) => e.file === file)
|
|
43
|
+
const text = readIfExists(file)
|
|
44
|
+
if (text === undefined) continue
|
|
45
|
+
const { text: next } = applyManagedBlock(text, entries.length ? buildManagedBlock(entries) : null)
|
|
46
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
47
|
+
writeFileSync(file, next)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 把要禁用的行写入对应 patch 文件的托管区块,并更新台账。
|
|
53
|
+
* @param {string} profile
|
|
54
|
+
* @param {Array<{ id: string, name: string|null, reason: string, file: string }>} targets
|
|
55
|
+
* @param {boolean} dryRun
|
|
56
|
+
* @param {string} now ISO 时间戳
|
|
57
|
+
* @returns {{ ledger: object }} 更新后的台账
|
|
58
|
+
*/
|
|
59
|
+
export function writeQuarantine(profile, targets, dryRun, now = new Date().toISOString()) {
|
|
60
|
+
const ledger = loadLedger()
|
|
61
|
+
const list = (ledger.profiles[profile] ??= [])
|
|
62
|
+
for (const t of targets) {
|
|
63
|
+
const existing = list.find((e) => e.id === t.id && e.file === t.file)
|
|
64
|
+
if (existing) {
|
|
65
|
+
existing.reason = t.reason
|
|
66
|
+
existing.quarantinedAt = now
|
|
67
|
+
} else {
|
|
68
|
+
list.push({ id: t.id, name: t.name ?? null, reason: t.reason, quarantinedAt: now, file: t.file })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!dryRun) {
|
|
72
|
+
rewriteManagedBlocks(ledger)
|
|
73
|
+
saveLedger(ledger)
|
|
74
|
+
}
|
|
75
|
+
return { ledger }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 恢复(摘除)被禁用的行:从台账删除并按剩余台账重写托管区块。
|
|
80
|
+
* @param {string} profile
|
|
81
|
+
* @param {string[] | 'all'} ids
|
|
82
|
+
* @param {boolean} dryRun
|
|
83
|
+
* @returns {{ restored: Array<object>, kept: Array<object> }}
|
|
84
|
+
*/
|
|
85
|
+
export function restoreQuarantine(profile, ids, dryRun) {
|
|
86
|
+
const ledger = loadLedger()
|
|
87
|
+
const list = ledger.profiles[profile] ?? []
|
|
88
|
+
if (!list.length) return { restored: [], kept: [] }
|
|
89
|
+
const removeSet = ids === 'all' ? new Set(list.map((e) => e.id)) : new Set(ids)
|
|
90
|
+
const kept = list.filter((e) => !removeSet.has(e.id))
|
|
91
|
+
const restored = list.filter((e) => removeSet.has(e.id))
|
|
92
|
+
if (!dryRun) {
|
|
93
|
+
if (kept.length) ledger.profiles[profile] = kept
|
|
94
|
+
else delete ledger.profiles[profile]
|
|
95
|
+
rewriteManagedBlocks(ledger, restored.map((e) => e.file))
|
|
96
|
+
saveLedger(ledger)
|
|
97
|
+
}
|
|
98
|
+
return { restored, kept }
|
|
99
|
+
}
|
package/lib/wrap.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyzyn/dsh-safe — 包装运行 dsh 的主循环。
|
|
3
|
+
*
|
|
4
|
+
* 运行 dsh(stderr 进管道并同步回显),进程退出后:
|
|
5
|
+
* - 正常退出(0)→ 结束;
|
|
6
|
+
* - 失败退出 → 从 stderr 解析坏插件,对照 patch 行后把对应行置为 disabled
|
|
7
|
+
* (写入托管区块 + 台账),然后重试;识别不出、超过重试上限、或命中
|
|
8
|
+
* 第一方插件(@deepseek-ai/*,默认保护)时原样透传退出码。
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { summarizeLine, parseFailureReport } from './failures.js'
|
|
12
|
+
import { collectKnownRows, matchFailures } from './knownrows.js'
|
|
13
|
+
import { detectInvocation } from './dshpaths.js'
|
|
14
|
+
import { writeQuarantine } from './quarantine.js'
|
|
15
|
+
|
|
16
|
+
const CAPTURE_LIMIT = 512 * 1024
|
|
17
|
+
const FIRST_PARTY_PREFIX = '@deepseek-ai/'
|
|
18
|
+
|
|
19
|
+
const isFirstParty = (name) => typeof name === 'string' && name.startsWith(FIRST_PARTY_PREFIX)
|
|
20
|
+
|
|
21
|
+
/** 运行 dsh:stdin/stdout 直通,stderr 回显并捕获(上限内)。 */
|
|
22
|
+
export function spawnDsh(args, { command = 'dsh' } = {}) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const child = spawn(command, args, {
|
|
25
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
26
|
+
env: process.env,
|
|
27
|
+
})
|
|
28
|
+
let captured = ''
|
|
29
|
+
child.stderr?.on('data', (chunk) => {
|
|
30
|
+
process.stderr.write(chunk)
|
|
31
|
+
if (captured.length < CAPTURE_LIMIT) captured += chunk
|
|
32
|
+
})
|
|
33
|
+
child.on('error', (error) => {
|
|
34
|
+
process.stderr.write(`[dsh-safe] 无法启动 ${command}: ${error.message}\n`)
|
|
35
|
+
resolve({ code: 127, stderr: captured })
|
|
36
|
+
})
|
|
37
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stderr: captured }))
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{
|
|
43
|
+
* forwardArgs: string[],
|
|
44
|
+
* dryRun?: boolean,
|
|
45
|
+
* maxRetries?: number,
|
|
46
|
+
* allowFirstParty?: boolean,
|
|
47
|
+
* log?: (...args: any[]) => void,
|
|
48
|
+
* spawn?: typeof spawnDsh,
|
|
49
|
+
* }} options
|
|
50
|
+
* @returns {Promise<number>} 最终退出码
|
|
51
|
+
*/
|
|
52
|
+
export async function runWrapped(options) {
|
|
53
|
+
const {
|
|
54
|
+
forwardArgs,
|
|
55
|
+
dryRun = false,
|
|
56
|
+
maxRetries = 2,
|
|
57
|
+
allowFirstParty = false,
|
|
58
|
+
log = (line) => process.stderr.write(`${line}\n`),
|
|
59
|
+
spawn: spawnFn = spawnDsh,
|
|
60
|
+
} = options
|
|
61
|
+
const invocation = detectInvocation(forwardArgs)
|
|
62
|
+
for (let attempt = 0; ; attempt++) {
|
|
63
|
+
const { code, stderr } = await spawnFn(forwardArgs)
|
|
64
|
+
if (code === 0) return 0
|
|
65
|
+
if (invocation.mode === 'plugin') {
|
|
66
|
+
log('[dsh-safe] `dsh plugin` 为 pnpm 转发,不做隔离,原样透传。')
|
|
67
|
+
return code
|
|
68
|
+
}
|
|
69
|
+
if (!invocation.profile) {
|
|
70
|
+
log('[dsh-safe] 未能从参数确定 profile(launcher 旗标需在最前,或用 --profile <name>),无法自动隔离,原样透传。')
|
|
71
|
+
return code
|
|
72
|
+
}
|
|
73
|
+
const known = collectKnownRows(invocation.profile)
|
|
74
|
+
const report = parseFailureReport(stderr)
|
|
75
|
+
const hits = matchFailures(report, known)
|
|
76
|
+
const quarantinable = []
|
|
77
|
+
const firstParty = []
|
|
78
|
+
for (const hit of hits) {
|
|
79
|
+
if (hit.disabled) continue // 已经是禁用状态
|
|
80
|
+
if (isFirstParty(hit.name) && !allowFirstParty) firstParty.push(hit)
|
|
81
|
+
else quarantinable.push(hit)
|
|
82
|
+
}
|
|
83
|
+
for (const hit of firstParty) {
|
|
84
|
+
log(`[dsh-safe] 跳过第一方插件 ${hit.name} (id: ${hit.id}) —— 默认保护 @deepseek-ai/*,需要手动处理;确认要禁用可加 --allow-first-party。`)
|
|
85
|
+
}
|
|
86
|
+
if (!quarantinable.length) {
|
|
87
|
+
if (!firstParty.length) log('[dsh-safe] 报错里没有识别出可自动隔离的已挂载插件,原样透传。')
|
|
88
|
+
return code
|
|
89
|
+
}
|
|
90
|
+
if (attempt >= maxRetries) {
|
|
91
|
+
log(`[dsh-safe] 已达最大重试次数(--max-retries ${maxRetries}),不再重试。`)
|
|
92
|
+
return code
|
|
93
|
+
}
|
|
94
|
+
const targets = quarantinable.map((hit) => ({
|
|
95
|
+
id: hit.id,
|
|
96
|
+
name: hit.name,
|
|
97
|
+
reason: summarizeLine(hit.line),
|
|
98
|
+
file: hit.file,
|
|
99
|
+
}))
|
|
100
|
+
writeQuarantine(invocation.profile, targets, dryRun)
|
|
101
|
+
for (const t of targets) {
|
|
102
|
+
const verb = dryRun ? '(dry-run)将禁用' : '已禁用'
|
|
103
|
+
log(`[dsh-safe] ${verb} ${t.name ?? t.id} (id: ${t.id}) → ${t.file}`)
|
|
104
|
+
log(` 原因: ${t.reason}`)
|
|
105
|
+
}
|
|
106
|
+
if (dryRun) return code
|
|
107
|
+
log('[dsh-safe] 重试启动…')
|
|
108
|
+
}
|
|
109
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyzyn/dsh-safe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-safe": "bin/dsh-safe.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/hyzyn/dsh-safe.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"dsh",
|
|
27
|
+
"deepseek-harness",
|
|
28
|
+
"plugin",
|
|
29
|
+
"quarantine",
|
|
30
|
+
"boot"
|
|
31
|
+
]
|
|
32
|
+
}
|