dsh-qiyu-stack 0.15.4 → 0.15.6
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/docs/status.md +44 -1
- package/package.json +1 -1
- package/src/doctor.mjs +54 -10
- package/src/dsh-port.mjs +83 -0
- package/src/qr.mjs +7 -10
package/docs/status.md
CHANGED
|
@@ -6,10 +6,53 @@
|
|
|
6
6
|
|
|
7
7
|
**能用了,也发出去了,但还没被陌生人在干净机器上验证过。**
|
|
8
8
|
|
|
9
|
-
- npm:`dsh-qiyu-stack@0.15.
|
|
9
|
+
- npm:`dsh-qiyu-stack@0.15.6`(`latest`)
|
|
10
10
|
- GitHub:<https://github.com/aesfls/dsh-qiyu-stack>(公开,main)
|
|
11
11
|
- 千语(爸爸本人的实例):正常运行中,桥、NapCat、Docker 都通
|
|
12
12
|
|
|
13
|
+
## 2026-10-23 第五轮:DSH 端口自动探测(v0.15.6)
|
|
14
|
+
|
|
15
|
+
`qiyu.config.json` 里的 `dsh.port` 是某一次写下的快照,而 DSH 的端口会变
|
|
16
|
+
(这台机器就从 64657 变到了 62853)。doctor 只信这个值,就会一直报
|
|
17
|
+
「DSH 端口 64657 没人听」——不是故障,但会让人以为桥坏了。
|
|
18
|
+
|
|
19
|
+
0.15.6 新增 `src/dsh-port.mjs`:
|
|
20
|
+
|
|
21
|
+
1. 先试配置里的端口;不通再扫本机正在监听的端口(`netstat`)
|
|
22
|
+
2. 按特征认,不猜:`/dsh-qq-bridge/state` 返回 `{"ok":true,...}` → DSH + 桥;
|
|
23
|
+
`/` 返回 401 且带 `dsh web authentication required` → DSH 在、桥没装
|
|
24
|
+
3. **不写回配置**——端口下次还会变,写回去只会制造新的过期快照
|
|
25
|
+
|
|
26
|
+
现在 doctor 会自动找到实际端口,输出里注明「是自动探测到的(配置里写的是 64657)」。
|
|
27
|
+
|
|
28
|
+
## 2026-10-23 第四轮:验收命令也补上沙箱降级(v0.15.5)
|
|
29
|
+
|
|
30
|
+
第三轮只修了 `preflight`,**验收命令 `doctor` 漏了**——在 workspace-write 下它照样报
|
|
31
|
+
「装了 Docker,但引擎没起来」,`有失败: true`。而 AGENTS.md 写的验收标准是「不通必须为 0」,
|
|
32
|
+
陌生人跑验收会永远看到 1 个失败。0.15.5 补上:
|
|
33
|
+
|
|
34
|
+
1. `doctor` 接 `沙箱受限过()`:Docker / 容器 / 端口相关的 fail 降级成 `warn`,
|
|
35
|
+
标题加「沙箱限制,这条不可信」,并附一句「去用户终端或提权再验」
|
|
36
|
+
2. `qr.mjs` 的 `docker cp` 改走 `跑()`(原来直连 `execFileSync`,沙箱里会假失败「拿不到二维码」)
|
|
37
|
+
|
|
38
|
+
## 2026-10-23 第三轮:沙箱假阴性(重要)
|
|
39
|
+
|
|
40
|
+
另一个会话在**默认沙箱**(workspace-write)里实测发现:`preflight` 报「这台机器上没有 Docker」,
|
|
41
|
+
可机器上 Docker 装着、容器还在跑。
|
|
42
|
+
|
|
43
|
+
**根因**(平台文档写明):受限沙箱不允许程序开命名管道,所以任何
|
|
44
|
+
`execFile/spawn` 抓输出(`stdio: 'pipe'`)都会 `EPERM`。而 qiyu 的每个 `跑()` 都靠抓输出,
|
|
45
|
+
于是所有判断变成假阴性——这是 agent 路径上最危险的一类 bug,会让 agent 去让用户装一个已经装好的东西。
|
|
46
|
+
|
|
47
|
+
**修法(v0.15.4)**:
|
|
48
|
+
|
|
49
|
+
1. 新增 `src/run.mjs`:先试管道,`EPERM/EACCES/EPIPE` 时**改走「重定向到临时文件再读」**
|
|
50
|
+
(沙箱允许写工作区),并把这次运行标记为「沙箱受限过」
|
|
51
|
+
2. `preflight` 在受限时**把 Docker/端口/Python 这类 fail 降级成 `warn`**,
|
|
52
|
+
标题加「沙箱限制,这条不可信」,并明确告诉 agent:别让用户重装,去他自己的终端或提权再验
|
|
53
|
+
3. `AGENTS.md` 专门加了一节讲这个坑
|
|
54
|
+
4. `AGENTS.md` 已进 npm 包(`files` 字段,0.15.4 起 80 个文件)
|
|
55
|
+
|
|
13
56
|
## 2026-10-23 第二轮补的(agent 入口)
|
|
14
57
|
|
|
15
58
|
一个陌生人会话实测后提的问题,采纳了三条:
|
package/package.json
CHANGED
package/src/doctor.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
|
9
9
|
import { join, dirname, resolve } from 'node:path'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
11
|
import { 读配置 } from './config.mjs'
|
|
12
|
-
import {
|
|
12
|
+
import { 跑, 沙箱受限过 } from './run.mjs'
|
|
13
|
+
import { 探测DSH } from './dsh-port.mjs'
|
|
13
14
|
|
|
14
15
|
const 包根 = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
16
|
|
|
@@ -179,26 +180,42 @@ export async function doctor({ 只输出JSON = false, 跳过WebSocket = false }
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
// ---------- 7. DSH 桥 ----------
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
//
|
|
184
|
+
// DSH 的端口每次启动可能变,不能只信 qiyu.config.json 里的快照。
|
|
185
|
+
// 先试配置端口,再扫本机监听端口,按特征认(见 src/dsh-port.mjs)。
|
|
186
|
+
const 探测 = await 探测DSH({ 已知端口: 配置.dsh.port })
|
|
187
|
+
if (!探测) {
|
|
188
|
+
if (配置.dsh.port) {
|
|
189
|
+
记(
|
|
190
|
+
'warn',
|
|
191
|
+
`DSH 端口 ${配置.dsh.port} 没人听,也没自动找到`,
|
|
192
|
+
'DSH 可能没开,或者端口变了',
|
|
193
|
+
'先把 DSH 开起来再跑一次;开了还找不到,设环境变量 QIYU_DSH_PORT'
|
|
194
|
+
)
|
|
195
|
+
} else {
|
|
196
|
+
记('skip', '跳过 DSH 桥检查(没找到运行中的 DSH)', 'DSH 的端口每台机器都不一样,所以默认不猜', '把 DSH 开起来再跑一次;或者设 QIYU_DSH_PORT')
|
|
197
|
+
}
|
|
198
|
+
} else if (!探测.有桥) {
|
|
199
|
+
记('warn', `DSH 在 ${探测.端口},但没看到 dsh-qq-bridge`, '', '确认 DSH 里装了 dsh-qq-bridge 插件')
|
|
200
|
+
} else {
|
|
186
201
|
try {
|
|
187
|
-
const r = await 请求(`http://127.0.0.1:${
|
|
202
|
+
const r = await 请求(`http://127.0.0.1:${探测.端口}/dsh-qq-bridge/state`)
|
|
188
203
|
if (r.json && r.json.NapCat状态) {
|
|
189
204
|
const 状 = r.json.NapCat状态
|
|
190
|
-
|
|
205
|
+
const 注 = 探测.来源 === '自动探测' ? `DSH 端口 ${探测.端口} 是自动探测到的(配置里写的是 ${配置.dsh.port || '空'})` : ''
|
|
206
|
+
if (状.用户) 记('ok', `DSH 桥认到:${状.昵称}(${状.用户})`, 注)
|
|
191
207
|
else 记('warn', 'DSH 桥连不上 NapCat', JSON.stringify(状).slice(0, 160), '桥的 NapCat 地址/令牌和 qiyu.config.json 要一致')
|
|
192
208
|
} else {
|
|
193
|
-
记('warn', `DSH 在 ${
|
|
209
|
+
记('warn', `DSH 在 ${探测.端口},但没看到 dsh-qq-bridge`, '', '确认 DSH 里装了 dsh-qq-bridge 插件')
|
|
194
210
|
}
|
|
195
211
|
} catch (错) {
|
|
196
212
|
记('warn', 'DSH 桥查不到', String(错.message || 错), '')
|
|
197
213
|
}
|
|
198
|
-
} else {
|
|
199
|
-
记('warn', `DSH 端口 ${桥端口} 没人听`, '', 'DSH 没开?或者端口不是这个:设 QIYU_DSH_PORT')
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
// 给 JSON 输出用:探测到的端口优先,没探测到就退回配置里的值
|
|
217
|
+
const 桥端口 = 探测 ? 探测.端口 : Number(配置.dsh.port) || 0
|
|
218
|
+
|
|
202
219
|
// ---------- 7.5 部署路径体检 ----------
|
|
203
220
|
if (配置.napcat && 配置.napcat.dataDir) {
|
|
204
221
|
const 目录 = String(配置.napcat.dataDir)
|
|
@@ -284,6 +301,33 @@ export async function doctor({ 只输出JSON = false, 跳过WebSocket = false }
|
|
|
284
301
|
记('skip', '跳过模板体检', String(错.message || 错))
|
|
285
302
|
}
|
|
286
303
|
|
|
304
|
+
// ---------- 沙箱兜底:读不到子进程输出时,别把「没装/没起」当成事实 ----------
|
|
305
|
+
//
|
|
306
|
+
// 受限沙箱不允许程序开管道,所有"跑条命令看输出"的判断都可能拿到空结果。
|
|
307
|
+
// 这时把 Docker / 容器 / 端口相关的 fail 降级成 warn——否则 agent 会把
|
|
308
|
+
// 「验收标准:不通必须为 0」理解成"装失败了",去修一台根本没坏的机器。
|
|
309
|
+
if (沙箱受限过()) {
|
|
310
|
+
let 降级了 = 0
|
|
311
|
+
for (const x of 结果) {
|
|
312
|
+
if (x.级别 !== 'fail') continue
|
|
313
|
+
if (!/Docker|引擎|容器|端口/.test(x.标题)) continue
|
|
314
|
+
x.级别 = 'warn'
|
|
315
|
+
x.标题 = '沙箱限制,这条不可信:' + x.标题
|
|
316
|
+
x.怎么修 = '在用户自己的终端里跑 qiyu doctor 再确认一次;或者把这次会话提到 danger-full-access'
|
|
317
|
+
降级了++
|
|
318
|
+
}
|
|
319
|
+
if (降级了) {
|
|
320
|
+
结果.push({
|
|
321
|
+
级别: 'warn',
|
|
322
|
+
标题: '当前沙箱读不到子进程输出',
|
|
323
|
+
依据:
|
|
324
|
+
'受限沙箱不允许程序开管道(stdio: pipe 会 EPERM)。qiyu 已改用"重定向到文件再读"兜底,' +
|
|
325
|
+
'但个别命令仍可能失败,上面 ' + 降级了 + ' 条结论已降级。',
|
|
326
|
+
怎么修: '这类结论不能当真:让用户在自己终端跑一遍,或把会话权限提到 danger-full-access',
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
287
331
|
// ---------- 输出 ----------
|
|
288
332
|
const 有失败 = 结果.some((x) => x.级别 === 'fail')
|
|
289
333
|
|
package/src/dsh-port.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 找 DSH 正在听的端口。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这个:DSH 的端口不是固定的(每次启动可能换一个),
|
|
4
|
+
// 而 qiyu.config.json 里的 dsh.port 是某一次写下来的快照 —— 过一天就旧了。
|
|
5
|
+
// 只信配置会让 doctor 一直报「DSH 端口没人听」,人就会以为桥坏了。
|
|
6
|
+
//
|
|
7
|
+
// 认法(按特征,不猜):
|
|
8
|
+
// · GET /dsh-qq-bridge/state → 200 且 {"ok":true,...} → DSH + 桥都在
|
|
9
|
+
// · GET / → 401 "dsh web authentication required" → DSH 在,但桥没装
|
|
10
|
+
import { 跑 } from './run.mjs'
|
|
11
|
+
|
|
12
|
+
const 探测超时 = 350
|
|
13
|
+
|
|
14
|
+
async function 取(端口, 路径) {
|
|
15
|
+
try {
|
|
16
|
+
const r = await fetch(`http://127.0.0.1:${端口}${路径}`, { signal: AbortSignal.timeout(探测超时) })
|
|
17
|
+
return { 状态: r.status, 文本: await r.text() }
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 这个端口是不是 DSH?返回 { 端口, 有桥 } 或 null */
|
|
24
|
+
async function 认一认(端口) {
|
|
25
|
+
const 桥 = await 取(端口, '/dsh-qq-bridge/state')
|
|
26
|
+
if (桥 && 桥.状态 === 200) {
|
|
27
|
+
try {
|
|
28
|
+
const j = JSON.parse(桥.文本)
|
|
29
|
+
if (j && j.ok === true) return { 端口, 有桥: true }
|
|
30
|
+
} catch {
|
|
31
|
+
// 不是 JSON,继续看根路径
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const 根 = await 取(端口, '/')
|
|
35
|
+
if (根 && 根.状态 === 401 && /dsh web authentication required/i.test(根.文本)) {
|
|
36
|
+
return { 端口, 有桥: false }
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 本机正在听的端口(只听回环/通配地址,跳过系统端口) */
|
|
42
|
+
function 听着的端口() {
|
|
43
|
+
const r = 跑('netstat', ['-ano', '-p', 'TCP'], 8000)
|
|
44
|
+
if (!r.ok) return []
|
|
45
|
+
const 端口们 = new Set()
|
|
46
|
+
for (const 行 of r.出.split(/\r?\n/)) {
|
|
47
|
+
const m = 行.match(/^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING/i)
|
|
48
|
+
if (!m) continue
|
|
49
|
+
const 地址 = m[1]
|
|
50
|
+
const 端口 = Number(m[2])
|
|
51
|
+
if (!(地址 === '0.0.0.0' || 地址 === '127.0.0.1' || 地址 === '[::]' || 地址 === '[::1]')) continue
|
|
52
|
+
if (端口 < 1024) continue
|
|
53
|
+
端口们.add(端口)
|
|
54
|
+
}
|
|
55
|
+
return [...端口们].sort((a, b) => a - b)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 找 DSH。先试已知端口(配置里的),再扫本机监听端口。
|
|
60
|
+
* @param {{已知端口?: number, 并发?: number, 上限?: number}} 选项
|
|
61
|
+
* @returns {Promise<{端口:number, 有桥:boolean, 来源:'配置'|'自动探测'}|null>}
|
|
62
|
+
*/
|
|
63
|
+
export async function 探测DSH({ 已知端口 = 0, 并发 = 16, 上限 = 200 } = {}) {
|
|
64
|
+
const 已知 = Number(已知端口) || 0
|
|
65
|
+
if (已知) {
|
|
66
|
+
const 中 = await 认一认(已知)
|
|
67
|
+
if (中) return { ...中, 来源: '配置' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// NapCat 的三个端口不用试;已知端口试过了也不重复
|
|
71
|
+
const 别试 = new Set([3000, 3001, 6099, 已知].filter(Boolean))
|
|
72
|
+
const 候选 = 听着的端口()
|
|
73
|
+
.filter((p) => !别试.has(p))
|
|
74
|
+
.slice(0, 上限)
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < 候选.length; i += 并发) {
|
|
77
|
+
const 批 = 候选.slice(i, i + 并发)
|
|
78
|
+
const 结果 = await Promise.all(批.map((p) => 认一认(p)))
|
|
79
|
+
const 中 = 结果.find(Boolean)
|
|
80
|
+
if (中) return { ...中, 来源: '自动探测' }
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
package/src/qr.mjs
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 约定:需要用户扫码时,不能只给一个路径(用户还得自己去找文件)。
|
|
4
4
|
// 一律「复制出来 + 用系统默认看图程序打开 + 在终端里说清什么时候过期」。
|
|
5
|
-
import {
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { statSync } from 'node:fs'
|
|
8
8
|
import { 读配置 } from './config.mjs'
|
|
9
|
+
import { 跑 } from './run.mjs'
|
|
9
10
|
|
|
10
11
|
export function 打开文件(路径) {
|
|
11
12
|
try {
|
|
@@ -47,15 +48,11 @@ export async function qr() {
|
|
|
47
48
|
|
|
48
49
|
const 目标 = resolve(process.cwd(), 'napcat-qrcode.png')
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
-
})
|
|
57
|
-
} catch (错) {
|
|
58
|
-
console.error('拿不到二维码:' + String((错 && (错.stderr || 错.message)) || 错).trim().slice(0, 200))
|
|
51
|
+
// 走 run.mjs:受限沙箱里 execFileSync 抓管道会 EPERM,
|
|
52
|
+
// 它会自动改成「重定向到临时文件再读」,不然会误报「拿不到二维码」。
|
|
53
|
+
const 复制 = 跑('docker', ['cp', `${配置.napcat.container}:/app/napcat/cache/qrcode.png`, 目标], 15000)
|
|
54
|
+
if (!复制.ok) {
|
|
55
|
+
console.error('拿不到二维码:' + String(复制.错 || 'docker cp 失败').trim().slice(0, 200))
|
|
59
56
|
console.error('')
|
|
60
57
|
console.error('可能的原因:')
|
|
61
58
|
console.error(' · 容器没起 —— 跑 qiyu doctor 看看')
|