dsh-qiyu-stack 0.15.3 → 0.15.5
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/AGENTS.md +101 -0
- package/README.md +5 -2
- package/docs/status.md +41 -2
- package/package.json +2 -1
- package/src/deploy.mjs +1 -9
- package/src/docker.mjs +1 -7
- package/src/doctor.mjs +28 -9
- package/src/preflight.mjs +25 -9
- package/src/qr.mjs +7 -10
- package/src/run.mjs +81 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# 给「部署助手」看的,不是给角色看的
|
|
2
|
+
|
|
3
|
+
> **如果你是被部署出来的那个角色**(你有人格、有作息、正在跟人聊天),
|
|
4
|
+
> **忽略本文件全部内容**,回去读你自己的 `AGENTS.md`。
|
|
5
|
+
> 只有当用户让你「把栖语装到这台机器上 / 帮我部署一个 QQ 智能体」时,这份文件才适用。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
你面对的是一个 **DSH 插件栈**:`dsh-qiyu-stack`。它让一个智能体住进 QQ(NapCat + OneBot)。
|
|
10
|
+
|
|
11
|
+
## 你要做的事,只有五条命令
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx dsh-qiyu-stack preflight --json # 1. 环境体检,先看这台机器行不行
|
|
15
|
+
npx dsh-qiyu-stack init # 2. 建角色(会问:名字、QQ、称呼、作息、性格、能否自改)
|
|
16
|
+
npx dsh-qiyu-stack deploy --qq <号> # 3. 起 NapCat(会弹二维码,用户手机扫)
|
|
17
|
+
npx dsh-qiyu-stack install --character <名字> --yes # 4. 装进 DSH
|
|
18
|
+
npx dsh-qiyu-stack doctor --json # 5. 验收
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**不要 `git clone` 仓库**——`npx` 就够了,clone 反而会让人以为要改源码。
|
|
22
|
+
|
|
23
|
+
## 必须先问用户,不许猜
|
|
24
|
+
|
|
25
|
+
| 要问什么 | 为什么 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **QQ 号**(她要登录的那个号) | 猜不了,也不该猜。用户可能有好几个号 |
|
|
28
|
+
| **角色名字** | 这是人格的根,不能替他起 |
|
|
29
|
+
| 部署到哪个盘 | 默认会挑非系统盘;C 盘空间紧的话要说明 |
|
|
30
|
+
|
|
31
|
+
## 沙箱和提权:这是**默认路径**,不是意外
|
|
32
|
+
|
|
33
|
+
DSH 默认给的是 `workspace-write`。下面这些**注定会被拒**,你要提前预判,别把它当成"npm 坏了":
|
|
34
|
+
|
|
35
|
+
| 会失败的动作 | 正确做法 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `npx` 写 `%LOCALAPPDATA%\npm-cache` | 加 `--cache ./.npm-cache`(指到工作区),或申请提升权限 |
|
|
38
|
+
| `install` 写 `~/.dsh/profiles/...` | **申请提权**(这是正常操作,不是异常);被拒就请用户自己在终端跑这一条 |
|
|
39
|
+
| 写 `~/.dsh/qiyu/`、`~/.dsh/*.json` | 同上 |
|
|
40
|
+
|
|
41
|
+
**申请提权时说清楚要写哪里、为什么**,别默默重试。
|
|
42
|
+
|
|
43
|
+
### ⚠️ 一个会骗你的坑:沙箱里读不到子进程输出
|
|
44
|
+
|
|
45
|
+
受限沙箱不允许程序开命名管道,所以任何 `execFile/spawn` **抓输出**(`stdio: 'pipe'`)都会 `EPERM`。
|
|
46
|
+
表现是:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
preflight --json → { "ok": false, "不通": 1, "标题": "这台机器上没有 Docker" }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
而机器上 Docker 明明装着、容器还在跑。
|
|
53
|
+
|
|
54
|
+
v0.15.4 起 `qiyu` 会自动兜底(把输出重定向到临时文件再读),仍然读不到的,会**把结论降级成 `warn`
|
|
55
|
+
并在标题前面加「沙箱限制,这条不可信」**。看到这几个字,别让用户去装东西,改成:
|
|
56
|
+
|
|
57
|
+
1. 让用户**在他自己的终端**里跑一遍同样的命令;或
|
|
58
|
+
2. 把这次会话提到 `danger-full-access` 再验。
|
|
59
|
+
|
|
60
|
+
**永远不要**在拿到「沙箱限制」标记时,让用户重装 Docker / 改配置。
|
|
61
|
+
|
|
62
|
+
## 必须交还给人做的四件事
|
|
63
|
+
|
|
64
|
+
这四件 agent **做不了,也不该做**,直接告诉用户:
|
|
65
|
+
|
|
66
|
+
1. **装 Docker Desktop** —— 要管理员权限 + 重启电脑。给他链接和步骤,等他做完再回来
|
|
67
|
+
2. **重启 DSH** —— 装完之后必须重启。**重启会结束当前会话**,所以先把状态写下来(见下)
|
|
68
|
+
3. **手机扫码** —— `deploy` 会自动打开二维码图片,但"用哪个号扫"只有人知道
|
|
69
|
+
4. **在手机确认页勾「下次登录无需确认」** —— 不勾的话每次重启容器都要重新验证
|
|
70
|
+
|
|
71
|
+
## 千万别碰的东西
|
|
72
|
+
|
|
73
|
+
- `characters/<名字>/AGENTS.md`、`character.json`、`schedule.json` —— **用户的角色,只能创建,不能替换已有内容**
|
|
74
|
+
- `~/.dsh/AGENTS.md` —— 全局用户配置,改了会影响这台机器上**所有**会话(包括别人家已经养着的角色)
|
|
75
|
+
- 任何模板里的"默认值" —— 那些刻意留空是有原因的(见 `docs/conventions.md`)
|
|
76
|
+
|
|
77
|
+
## 重启前要做的事
|
|
78
|
+
|
|
79
|
+
`install` 跑完会打印交接语。你要做的是:
|
|
80
|
+
|
|
81
|
+
1. 把 `qiyu doctor --json` 的**预期结果**告诉用户("重启后应该看到 0 不通")
|
|
82
|
+
2. 提醒他:**重启后回到这个会话**(如果 DSH 能恢复),或者开新会话后先跑一次 `npx dsh-qiyu-stack doctor --json`,把结果贴回来
|
|
83
|
+
3. **不要说"重新开始"** —— 东西已经装好了,只是要验证
|
|
84
|
+
|
|
85
|
+
## 验收标准
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx dsh-qiyu-stack doctor --json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `不通`(fail)必须为 **0**
|
|
92
|
+
- `待看`(warn)可以有——通常是"Windows 上别装 Windows 版 NapCat"这类提示
|
|
93
|
+
- 模板体检必须是 `ok`(说明没有别人的私人值混进来)
|
|
94
|
+
|
|
95
|
+
过了这关,才算装完。
|
|
96
|
+
|
|
97
|
+
## 出问题去哪查
|
|
98
|
+
|
|
99
|
+
- 部署细节和踩过的坑:`docs/deployment.md`
|
|
100
|
+
- 默认值红线:`docs/conventions.md`
|
|
101
|
+
- 当前状态与已知未做:`docs/status.md`
|
package/README.md
CHANGED
|
@@ -133,13 +133,16 @@ characters/小满/ 你的角色包(不进版本库)
|
|
|
133
133
|
**已发布**:[npm](https://www.npmjs.com/package/dsh-qiyu-stack) · [GitHub](https://github.com/aesfls/dsh-qiyu-stack)
|
|
134
134
|
|
|
135
135
|
```bash
|
|
136
|
-
npx dsh-qiyu-stack preflight #
|
|
136
|
+
npx dsh-qiyu-stack preflight # 环境体检(加 --json 给 agent 用)
|
|
137
137
|
npx dsh-qiyu-stack init # 建角色(向导)
|
|
138
138
|
npx dsh-qiyu-stack deploy --qq <号> # 起 NapCat + 出码
|
|
139
139
|
npx dsh-qiyu-stack install --character <名字> --yes # 装进 DSH
|
|
140
|
-
npx dsh-qiyu-stack doctor #
|
|
140
|
+
npx dsh-qiyu-stack doctor # 自检(加 --json 给 agent 用)
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
> 让 AI 帮你装?把这个仓库地址丢给它就行 —— 根目录的 [AGENTS.md](AGENTS.md) 是给 agent 看的说明书。
|
|
144
|
+
> 它知道自己能做什么、哪些必须交还给人(装 Docker、重启 DSH、手机扫码)。
|
|
145
|
+
|
|
143
146
|
已经能用:
|
|
144
147
|
|
|
145
148
|
- [x] **`qiyu init` 向导**:名字 / QQ / 称呼 / 作息 / 性格 /「能不能自己改性格」;
|
package/docs/status.md
CHANGED
|
@@ -4,12 +4,51 @@
|
|
|
4
4
|
|
|
5
5
|
## 一句话状态
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**能用了,也发出去了,但还没被陌生人在干净机器上验证过。**
|
|
8
8
|
|
|
9
|
-
- npm:`dsh-qiyu-stack@0.15.
|
|
9
|
+
- npm:`dsh-qiyu-stack@0.15.5`(`latest`)
|
|
10
10
|
- GitHub:<https://github.com/aesfls/dsh-qiyu-stack>(公开,main)
|
|
11
11
|
- 千语(爸爸本人的实例):正常运行中,桥、NapCat、Docker 都通
|
|
12
12
|
|
|
13
|
+
## 2026-10-23 第四轮:验收命令也补上沙箱降级(v0.15.5)
|
|
14
|
+
|
|
15
|
+
第三轮只修了 `preflight`,**验收命令 `doctor` 漏了**——在 workspace-write 下它照样报
|
|
16
|
+
「装了 Docker,但引擎没起来」,`有失败: true`。而 AGENTS.md 写的验收标准是「不通必须为 0」,
|
|
17
|
+
陌生人跑验收会永远看到 1 个失败。0.15.5 补上:
|
|
18
|
+
|
|
19
|
+
1. `doctor` 接 `沙箱受限过()`:Docker / 容器 / 端口相关的 fail 降级成 `warn`,
|
|
20
|
+
标题加「沙箱限制,这条不可信」,并附一句「去用户终端或提权再验」
|
|
21
|
+
2. `qr.mjs` 的 `docker cp` 改走 `跑()`(原来直连 `execFileSync`,沙箱里会假失败「拿不到二维码」)
|
|
22
|
+
|
|
23
|
+
## 2026-10-23 第三轮:沙箱假阴性(重要)
|
|
24
|
+
|
|
25
|
+
另一个会话在**默认沙箱**(workspace-write)里实测发现:`preflight` 报「这台机器上没有 Docker」,
|
|
26
|
+
可机器上 Docker 装着、容器还在跑。
|
|
27
|
+
|
|
28
|
+
**根因**(平台文档写明):受限沙箱不允许程序开命名管道,所以任何
|
|
29
|
+
`execFile/spawn` 抓输出(`stdio: 'pipe'`)都会 `EPERM`。而 qiyu 的每个 `跑()` 都靠抓输出,
|
|
30
|
+
于是所有判断变成假阴性——这是 agent 路径上最危险的一类 bug,会让 agent 去让用户装一个已经装好的东西。
|
|
31
|
+
|
|
32
|
+
**修法(v0.15.4)**:
|
|
33
|
+
|
|
34
|
+
1. 新增 `src/run.mjs`:先试管道,`EPERM/EACCES/EPIPE` 时**改走「重定向到临时文件再读」**
|
|
35
|
+
(沙箱允许写工作区),并把这次运行标记为「沙箱受限过」
|
|
36
|
+
2. `preflight` 在受限时**把 Docker/端口/Python 这类 fail 降级成 `warn`**,
|
|
37
|
+
标题加「沙箱限制,这条不可信」,并明确告诉 agent:别让用户重装,去他自己的终端或提权再验
|
|
38
|
+
3. `AGENTS.md` 专门加了一节讲这个坑
|
|
39
|
+
4. `AGENTS.md` 已进 npm 包(`files` 字段,0.15.4 起 80 个文件)
|
|
40
|
+
|
|
41
|
+
## 2026-10-23 第二轮补的(agent 入口)
|
|
42
|
+
|
|
43
|
+
一个陌生人会话实测后提的问题,采纳了三条:
|
|
44
|
+
|
|
45
|
+
1. **仓库根 `AGENTS.md`**:给部署助手看的说明书——五条命令、必须先问的(QQ 号/角色名)、
|
|
46
|
+
沙箱和提权是**默认路径**不是意外、必须交还给人做的四件事(装 Docker / 重启 DSH / 扫码 / 勾确认框)、
|
|
47
|
+
千万别碰的东西(角色包、`~/.dsh/AGENTS.md`)、验收标准
|
|
48
|
+
2. **`preflight --json` / `doctor --json`**:让 agent 机器判断卡在哪,别去正则扒中文
|
|
49
|
+
3. **`install` 交接语**:改成「重启后回这个会话,或开新会话跑 doctor --json 贴回来」,
|
|
50
|
+
并写明**「不是重新开始」**
|
|
51
|
+
|
|
13
52
|
## 今天做了什么
|
|
14
53
|
|
|
15
54
|
1. **NapCat 迁移落地**:Windows 版 NapCat 被 QQ 的文件校验堵死,改走 Docker + Linux QQ,跑通
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qiyu-stack",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.5",
|
|
4
4
|
"description": "栖语栈:让一个智能体住进 QQ —— QQ 双向桥、长期记忆、心情、语音、作息,以及把 NapCat 部署这件事一次做对",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"files": [
|
|
18
|
+
"AGENTS.md",
|
|
18
19
|
"bin",
|
|
19
20
|
"src",
|
|
20
21
|
"templates",
|
package/src/deploy.mjs
CHANGED
|
@@ -13,21 +13,13 @@ import { createInterface } from 'node:readline'
|
|
|
13
13
|
import { 读配置, 配置文件路径 } from './config.mjs'
|
|
14
14
|
import { 打开文件 } from './qr.mjs'
|
|
15
15
|
import { 确保引擎, 引擎版本 } from './docker.mjs'
|
|
16
|
+
import { 跑 } from './run.mjs'
|
|
16
17
|
|
|
17
18
|
const 绿 = (s) => `\x1b[32m${s}\x1b[0m`
|
|
18
19
|
const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
19
20
|
const 红 = (s) => `\x1b[31m${s}\x1b[0m`
|
|
20
21
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
21
22
|
|
|
22
|
-
function 跑(命令, 参数, 超时 = 60000) {
|
|
23
|
-
try {
|
|
24
|
-
const 出 = execFileSync(命令, 参数, { timeout: 超时, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
25
|
-
return { ok: true, 出: String(出).trim() }
|
|
26
|
-
} catch (错) {
|
|
27
|
-
return { ok: false, 错: String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
23
|
async function 端口通(端口, 超时 = 1200) {
|
|
32
24
|
const net = await import('node:net')
|
|
33
25
|
return new Promise((解决) => {
|
package/src/docker.mjs
CHANGED
|
@@ -8,18 +8,12 @@
|
|
|
8
8
|
import { execFile, spawn } from 'node:child_process'
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
+
import { 跑 } from './run.mjs'
|
|
11
12
|
|
|
12
13
|
const 绿 = (s) => `\x1b[32m${s}\x1b[0m`
|
|
13
14
|
const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
14
15
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
15
16
|
|
|
16
|
-
export function 跑(命令, 参数, 超时 = 15000) {
|
|
17
|
-
return new Promise((解决) => {
|
|
18
|
-
execFile(命令, 参数, { timeout: 超时, windowsHide: true, encoding: 'utf8' }, (错, 出, 错出) => {
|
|
19
|
-
解决({ ok: !错, 出: String(出 || '').trim(), 错: String(错出 || (错 && 错.message) || '').trim() })
|
|
20
|
-
})
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
17
|
|
|
24
18
|
export async function 引擎版本() {
|
|
25
19
|
const r = await 跑('docker', ['version', '--format', '{{.Server.Version}}'], 10000)
|
package/src/doctor.mjs
CHANGED
|
@@ -9,6 +9,7 @@ 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 { 跑, 沙箱受限过 } from './run.mjs'
|
|
12
13
|
|
|
13
14
|
const 包根 = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
14
15
|
|
|
@@ -17,15 +18,6 @@ const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
|
17
18
|
const 红 = (s) => `\x1b[31m${s}\x1b[0m`
|
|
18
19
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
19
20
|
|
|
20
|
-
function 跑(命令, 参数, 超时 = 8000) {
|
|
21
|
-
try {
|
|
22
|
-
const 出 = execFileSync(命令, 参数, { timeout: 超时, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
23
|
-
return { ok: true, 出: String(出).trim() }
|
|
24
|
-
} catch (错) {
|
|
25
|
-
return { ok: false, 错: String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
function 端口通(主机, 端口, 超时 = 1500) {
|
|
30
22
|
return new Promise((解决) => {
|
|
31
23
|
const s = net.connect({ host: 主机, port: 端口 })
|
|
@@ -292,6 +284,33 @@ export async function doctor({ 只输出JSON = false, 跳过WebSocket = false }
|
|
|
292
284
|
记('skip', '跳过模板体检', String(错.message || 错))
|
|
293
285
|
}
|
|
294
286
|
|
|
287
|
+
// ---------- 沙箱兜底:读不到子进程输出时,别把「没装/没起」当成事实 ----------
|
|
288
|
+
//
|
|
289
|
+
// 受限沙箱不允许程序开管道,所有"跑条命令看输出"的判断都可能拿到空结果。
|
|
290
|
+
// 这时把 Docker / 容器 / 端口相关的 fail 降级成 warn——否则 agent 会把
|
|
291
|
+
// 「验收标准:不通必须为 0」理解成"装失败了",去修一台根本没坏的机器。
|
|
292
|
+
if (沙箱受限过()) {
|
|
293
|
+
let 降级了 = 0
|
|
294
|
+
for (const x of 结果) {
|
|
295
|
+
if (x.级别 !== 'fail') continue
|
|
296
|
+
if (!/Docker|引擎|容器|端口/.test(x.标题)) continue
|
|
297
|
+
x.级别 = 'warn'
|
|
298
|
+
x.标题 = '沙箱限制,这条不可信:' + x.标题
|
|
299
|
+
x.怎么修 = '在用户自己的终端里跑 qiyu doctor 再确认一次;或者把这次会话提到 danger-full-access'
|
|
300
|
+
降级了++
|
|
301
|
+
}
|
|
302
|
+
if (降级了) {
|
|
303
|
+
结果.push({
|
|
304
|
+
级别: 'warn',
|
|
305
|
+
标题: '当前沙箱读不到子进程输出',
|
|
306
|
+
依据:
|
|
307
|
+
'受限沙箱不允许程序开管道(stdio: pipe 会 EPERM)。qiyu 已改用"重定向到文件再读"兜底,' +
|
|
308
|
+
'但个别命令仍可能失败,上面 ' + 降级了 + ' 条结论已降级。',
|
|
309
|
+
怎么修: '这类结论不能当真:让用户在自己终端跑一遍,或把会话权限提到 danger-full-access',
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
295
314
|
// ---------- 输出 ----------
|
|
296
315
|
const 有失败 = 结果.some((x) => x.级别 === 'fail')
|
|
297
316
|
|
package/src/preflight.mjs
CHANGED
|
@@ -11,21 +11,13 @@ import { existsSync, statfsSync, readFileSync } from 'node:fs'
|
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import { homedir, release } from 'node:os'
|
|
13
13
|
import { createInterface } from 'node:readline'
|
|
14
|
+
import { 跑, 沙箱受限过 } from './run.mjs'
|
|
14
15
|
|
|
15
16
|
const 绿 = (s) => `\x1b[32m${s}\x1b[0m`
|
|
16
17
|
const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
17
18
|
const 红 = (s) => `\x1b[31m${s}\x1b[0m`
|
|
18
19
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
19
20
|
|
|
20
|
-
function 跑(命令, 参数, 超时 = 8000) {
|
|
21
|
-
try {
|
|
22
|
-
const 出 = execFileSync(命令, 参数, { timeout: 超时, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
23
|
-
return { ok: true, 出: String(出).trim() }
|
|
24
|
-
} catch (错) {
|
|
25
|
-
return { ok: false, 错: String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
function 端口通(端口, 超时 = 1500) {
|
|
30
22
|
return new Promise((解决) => {
|
|
31
23
|
const s = net.connect({ host: '127.0.0.1', port: 端口 })
|
|
@@ -357,6 +349,30 @@ export async function preflight(选项 = {}) {
|
|
|
357
349
|
}
|
|
358
350
|
}
|
|
359
351
|
|
|
352
|
+
// ---------- 沙箱兜底:读不到命令输出时,别把「没装」当成事实 ----------
|
|
353
|
+
//
|
|
354
|
+
// 受限沙箱不允许程序开管道,所有"跑条命令看输出"的判断都可能拿到空结果。
|
|
355
|
+
// 这时把相关 fail 降级成 warn 并说清楚——否则 agent 会去让用户装一个已经装好的 Docker。
|
|
356
|
+
if (沙箱受限过()) {
|
|
357
|
+
let 降级了 = 0
|
|
358
|
+
for (const x of 结果) {
|
|
359
|
+
if (x.级别 !== 'fail') continue
|
|
360
|
+
if (!/Docker|容器|端口|Python|WSL|虚拟化/.test(x.标题)) continue
|
|
361
|
+
x.级别 = 'warn'
|
|
362
|
+
x.标题 = '沙箱限制,这条不可信:' + x.标题
|
|
363
|
+
x.怎么修 = '在用户自己的终端里跑 qiyu preflight 再确认一次;或者把这次会话提到 danger-full-access'
|
|
364
|
+
降级了++
|
|
365
|
+
}
|
|
366
|
+
结果.push({
|
|
367
|
+
级别: 'warn',
|
|
368
|
+
标题: '当前沙箱读不到子进程输出',
|
|
369
|
+
依据:
|
|
370
|
+
'受限沙箱不允许程序开管道(stdio: pipe 会 EPERM)。qiyu 已改用"重定向到文件再读"兜底,' +
|
|
371
|
+
'但个别命令仍可能失败,' + (降级了 ? `上面 ${降级了} 条结论已降级` : '') + '。',
|
|
372
|
+
怎么修: '这类结论不能当真:让用户在自己终端跑一遍,或把会话权限提到 danger-full-access',
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
360
376
|
// ---------- 输出 ----------
|
|
361
377
|
const 坏 = 结果.filter((x) => x.级别 === 'fail').length
|
|
362
378
|
const 疑 = 结果.filter((x) => x.级别 === 'warn').length
|
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 看看')
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// 跑外部命令,并且**在受限沙箱里也能活**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这个文件(2026-10-23 实测):
|
|
4
|
+
// DSH 默认沙箱(workspace-write / read-only)不允许程序开命名管道,
|
|
5
|
+
// 所以 `execFileSync(cmd, args, { stdio: ['ignore','pipe','pipe'] })` 会直接 EPERM。
|
|
6
|
+
// 而 qiyu 里几乎所有判断都靠"跑条命令看输出"——一旦 EPERM,
|
|
7
|
+
// 就会变成「没装 Docker」「端口查不出来」「容器不存在」这类**假阴性**,
|
|
8
|
+
// 把用户和 agent 一起带沟里。
|
|
9
|
+
//
|
|
10
|
+
// 兜底办法:抓不到管道,就把输出重定向到临时文件再读(沙箱允许写工作区)。
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
|
|
16
|
+
let 遇到过沙箱限制 = false
|
|
17
|
+
|
|
18
|
+
/** 这次运行里,有没有因为沙箱读不到命令输出(结果可能不准) */
|
|
19
|
+
export function 沙箱受限过() {
|
|
20
|
+
return 遇到过沙箱限制
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function 引号(s) {
|
|
24
|
+
const t = String(s)
|
|
25
|
+
return /[\s"&|<>^]/.test(t) ? '"' + t.replace(/"/g, '\\"') + '"' : t
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 把命令的输出重定向到文件,再读回来(不依赖管道)。 */
|
|
29
|
+
function 落盘跑(命令, 参数, 超时) {
|
|
30
|
+
let 目录 = ''
|
|
31
|
+
try {
|
|
32
|
+
目录 = mkdtempSync(join(tmpdir(), 'qiyu-cmd-'))
|
|
33
|
+
const 输出文件 = join(目录, 'out.txt')
|
|
34
|
+
const 整行 = [命令, ...参数].map(引号).join(' ') + ' > ' + 引号(输出文件) + ' 2>&1'
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
execFileSync('cmd.exe', ['/c', 整行], { timeout: 超时, windowsHide: true, stdio: ['ignore', 'ignore', 'ignore'] })
|
|
37
|
+
} else {
|
|
38
|
+
execFileSync('sh', ['-c', 整行], { timeout: 超时, stdio: ['ignore', 'ignore', 'ignore'] })
|
|
39
|
+
}
|
|
40
|
+
const 出 = existsSync(输出文件) ? readFileSync(输出文件, 'utf8').trim() : ''
|
|
41
|
+
return { ok: true, 出, 错: '' }
|
|
42
|
+
} catch (错) {
|
|
43
|
+
// 命令本身失败(退出码非 0)时,输出仍可能在文件里——尽量把它带回去
|
|
44
|
+
let 出 = ''
|
|
45
|
+
try {
|
|
46
|
+
const 文件 = join(目录, 'out.txt')
|
|
47
|
+
if (目录 && existsSync(文件)) 出 = readFileSync(文件, 'utf8').trim()
|
|
48
|
+
} catch {}
|
|
49
|
+
return { ok: false, 出, 错: 出 || String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
50
|
+
} finally {
|
|
51
|
+
try {
|
|
52
|
+
if (目录) rmSync(目录, { recursive: true, force: true })
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 跑一条命令。
|
|
59
|
+
* @returns {{ok:boolean, 出:string, 错:string, 沙箱?:boolean}}
|
|
60
|
+
*/
|
|
61
|
+
export function 跑(命令, 参数 = [], 超时 = 8000) {
|
|
62
|
+
try {
|
|
63
|
+
const 出 = execFileSync(命令, 参数, {
|
|
64
|
+
timeout: 超时,
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
})
|
|
69
|
+
return { ok: true, 出: String(出).trim(), 错: '' }
|
|
70
|
+
} catch (错) {
|
|
71
|
+
const 码 = String((错 && 错.code) || '')
|
|
72
|
+
const 文 = String((错 && (错.stderr || 错.message)) || 错)
|
|
73
|
+
// 被沙箱拦了:换一条不靠管道的路
|
|
74
|
+
if (码 === 'EPERM' || 码 === 'EACCES' || 码 === 'EPIPE' || /EPERM|EACCES|spawn .*pipe/i.test(文)) {
|
|
75
|
+
遇到过沙箱限制 = true
|
|
76
|
+
const 结果 = 落盘跑(命令, 参数, 超时)
|
|
77
|
+
return { ...结果, 沙箱: true }
|
|
78
|
+
}
|
|
79
|
+
return { ok: false, 出: String((错 && 错.stdout) || '').trim(), 错: 文.trim() }
|
|
80
|
+
}
|
|
81
|
+
}
|