dsh-bili-scroll 2.0.4 → 2.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/AGENTS.md +3 -1
- package/CONTRIBUTORS.md +5 -1
- package/README.md +12 -2
- package/client.js +5 -0
- package/index.js +335 -31
- package/lib/mcp.mjs +34 -9
- package/lib/state.mjs +11 -12
- package/lib/uvfetch.mjs +217 -161
- package/package.json +2 -1
- package/profile-template/bili-duty/cordis.patch.yml +116 -0
- package/profile-template/bili-duty/cordis.yml +4 -0
- package/profile-template/bili-duty/package.json +13 -0
- package/prompts/duty.md +109 -11
- package/vendor/bilibili-mcp/__pycache__/mcp_server.cpython-314.pyc +0 -0
- package/vendor/bilibili-mcp/mcp_server.py +521 -0
- package/vendor/bilibili-mcp/requirements.txt +2 -0
package/AGENTS.md
CHANGED
|
@@ -42,12 +42,14 @@
|
|
|
42
42
|
|
|
43
43
|
## 仓库结构(改代码时看这里)
|
|
44
44
|
|
|
45
|
-
- `index.js` —— host 半端:
|
|
45
|
+
- `index.js` —— host 半端:13 个工具 + 谷时定时器 + HTTP 接口 + 配置(`config.json` 与 DSH 原生设置页**逐字段对账**:见 `loadCfg` / `saveCfgHost`)+ 值班轮的工具白名单(`mcpWhitelist` → `BILI_MCP_TOOLS`)
|
|
46
46
|
- `client.js` —— 浏览器半端:设置页(注册在 `settings.section`)
|
|
47
47
|
- `lib/` —— 状态目录与家目录 / 极小的 MCP stdio 客户端 / 扫码登录 / **`hostdeps.mjs`(从宿主树取 `@deepseek-ai/*`,拿不到就退到自己依赖 / 本地等价实现)** / **`verify.mjs`(评论发出后复核)**
|
|
48
48
|
- `prompts/duty.md` —— 一轮要做的事(判定口径 + 红线,**用户可以改**);升级规矩见 `lib/state.mjs` 的 `ensureCharter`(用户改过 ⇒ 保留他那份、新版落 `duty.md.new`)
|
|
49
49
|
- `vendor/bilibili-mcp/` —— 内置的 Python MCP(MIT,AGI_Ananas)
|
|
50
50
|
- `selfcheck.mjs` + `tools/` —— 本地预检:`node selfcheck.mjs`(**现在不用解析钩子了**;`tools/register.mjs` 那条还留着当备份)
|
|
51
|
+
· `tools/check-vision.py` —— 看图纯函数的回归检查(不联网)。动了 `_https` / `_cover_url` / `_snapshot_entries` / `_crop_tile` 就跑一次:
|
|
52
|
+
`<家目录>/.venv/Scripts/python.exe tools/check-vision.py`(用插件自己的 venv,里面才有 Pillow)
|
|
51
53
|
|
|
52
54
|
## 约定
|
|
53
55
|
|
package/CONTRIBUTORS.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 外部贡献与致谢
|
|
2
2
|
|
|
3
3
|
这个插件的一些修复,最初的判断或实现来自社区。**这里只列代码/修法被采纳、并写进提交 `Co-authored-by` 的人**
|
|
4
|
-
|
|
4
|
+
(整包合入的 PR 作者由 git 历史自动计入);其余只提了问题或线索的朋友,我在各条 PR / issue 下另行道谢,不列在这里。
|
|
5
5
|
|
|
6
6
|
| 人 | 采纳了什么 |
|
|
7
7
|
|---|---|
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
| [@Rookiexg](https://github.com/Rookiexg) | headless overlay 要补 `agent-default-model`,否则账号登录的机器会 `MISSING_CREDENTIAL`(PR #50) |
|
|
10
10
|
| [@383891598](https://github.com/383891598) | yt-dlp 的进度条会污染 stdout,导致"输出非空 = 成功"恒真(PR #23,部分采纳) |
|
|
11
11
|
|
|
12
|
+
| [@Annoias](https://github.com/Annoias) | **看画面**:bili_cover / bili_frames 两个工具(用 B站 故事板裁帧、不下视频)+ 章程「什么时候该看画面」+ 看图纯函数自检(**PR #92,已整包合入**) |
|
|
13
|
+
| [@Anguis-L](https://github.com/Anguis-L) | **同源检查要比端口**(只比 hostname 会让本机别的端口的页面驱动写接口)+ 栅栏入参按契约传 headers(PR #94 的思路,已在自己的实现里采纳) |
|
|
14
|
+
| [@LUOXI23333](https://github.com/LUOXI23333) | **值班轮最小权限 profile + 窄口 IO 工具**的整体思路(issue #96 / PR #97)——这版按这个思路独立实现,取舍写在 #96 的回复里 |
|
|
15
|
+
|
|
12
16
|
## 关于"为什么不整包合 PR"
|
|
13
17
|
|
|
14
18
|
这个仓库的做法是**一条一条读、把修法自己在 main 上实现**,而不是整包合并 —— 这样发布节奏在自己手上、出问题好定位回滚。
|
package/README.md
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
## 它做什么
|
|
22
22
|
|
|
23
23
|
- 按你填的关键词(留空则用内置那几组)搜近几天的视频
|
|
24
|
-
-
|
|
24
|
+
- **逐条读字幕原文再判断**(不看摘要);没字幕的**演示类**视频会先从 B站 自带的故事板**抽几帧看画面**;
|
|
25
|
+
实在读不到内容才跳过 —— 或者你开了「本地转写」
|
|
25
26
|
- 写一份 Markdown 报告到 `out/<日期>.md`:每条含「为什么值得」+「它自己的一句话」;每条的段首固定写成 `### BV号` —— 插件数今天挑了几条,数的就是这些标题行
|
|
26
27
|
- 开着「自动评论」时,每轮发一条评论、**@ 你**一次(默认每轮 1 条,设置页可调到每轮最多 20 条)
|
|
27
28
|
- 可以在**峰谷时段自动跑**(DeepSeek 的优惠时段),省 token
|
|
@@ -115,9 +116,18 @@ dsh plugin --profile web add github:c1overzz/dsh-bili-scroll
|
|
|
115
116
|
- 那一轮的**工作目录 = 插件自己的家目录**(它被沙箱关着,只写得了自己那一亩三分地);
|
|
116
117
|
⭐ 你填的**工作区里的 `AGENTS.md` 由插件读进来、内联进提示词** ⇒ **它按你的设定说话**,
|
|
117
118
|
而不是用一套通用人格
|
|
118
|
-
- ⭐
|
|
119
|
+
- ⭐ **那一轮拿不到整台工具**:只给它**六个只读的**(搜视频 / 读字幕 / 读评论 / 看视频信息 / 看封面 / 看画面帧),
|
|
119
120
|
开了「自动评论」或「回复评论」才多给一个**发评论** —— 上传视频、发动态、私信、抓全站那些**根本不注册**,
|
|
120
121
|
模型连它们的名字都看不到
|
|
122
|
+
- 🔒 **那一轮没有文件系统、没有命令、没有联网**(2026-10-24 起):它跑在一个**专用 profile** `bili-duty` 上 ——
|
|
123
|
+
工具只留 B站 MCP + 几只"窄口"的(写日报 / 记判重表 / 查判重表 / 写归档 / 转写),
|
|
124
|
+
**路径全部由插件定死,不接受路径参数**。这样即便哪天视频标题、评论、弹幕里塞了奇怪的东西,
|
|
125
|
+
它也**读不到**你这台电脑上的别的东西 —— 以前那个 profile 是带文件系统**读**权限的
|
|
126
|
+
(DSH 的沙箱只限制写、不限制读,这是真事)。
|
|
127
|
+
万一专用 profile 铺不上,会**明确退回普通 headless 并在日志里说一句**,不会静默降级。
|
|
128
|
+
- **看封面 / 看画面帧需要宿主支持图片**:宿主得提供 `attachments`(存图)和 `llm`(查模型能不能看图)。
|
|
129
|
+
缺任何一个就**退化成文字说明**(返回里 `image_attached: false` + `image_skip_reason`),
|
|
130
|
+
其余功能照常 —— 所以这两个服务不在插件的 `inject` 里。
|
|
121
131
|
- ⭐ **值班章程(`prompts/duty.md`)升级的规矩**:插件记着「上次铺的是哪一版」(`charter.sha256`)——
|
|
122
132
|
你**没改过**就跟着插件自动升级;**改过**就保留你那份、把新版另存成 `duty.md.new`(日志里会说一句),自己合一下。
|
|
123
133
|
⚠️ **从 0.1.5 及以前升上来的第一次没有账本可比** ⇒ 保守地当成「你改过」:新版落成 `duty.md.new`;
|
package/client.js
CHANGED
|
@@ -687,6 +687,11 @@ function WatchTab() {
|
|
|
687
687
|
placeholder: '想让它怎么说话就写在这儿 —— 比如「叫我某某,说话简短点,别用 emoji」。也可以直接填一个文件路径(如 D:\\deepseek\\AGENTS.md,或它所在的那个目录),每次运行都会重新读那个文件。留空 = 按中性说话(不扮演任何人)。',
|
|
688
688
|
value: persona ?? '', onChange: (e) => setPersona(e.target.value),
|
|
689
689
|
}),
|
|
690
|
+
// ⭐ 2.0.5(issue #89):把人设字数亮出来 —— 超 40000 只送前 40000、超 60000 保存就截,得让用户自己看得见
|
|
691
|
+
h('div', { 'data-bd-note': '' },
|
|
692
|
+
'字数:' + String(persona ?? '').length
|
|
693
|
+
+ (String(persona ?? '').length > 40000 ? '(⚠️ 超过 40000,超出的部分不会生效)' : '')
|
|
694
|
+
+ (String(persona ?? '').length > 60000 ? '(⚠️ 超过 60000,保存时会被截断)' : '')),
|
|
690
695
|
h('div', { 'data-bd-note': '' },
|
|
691
696
|
(st.personaFromWs && String(persona || '').trim() === '')
|
|
692
697
|
? '你现在还是用「工作区」里那份 AGENTS.md 说话的(照旧生效)。想把它搬到这儿改,就把上面的路径填成本机那个目录。'
|
package/index.js
CHANGED
|
@@ -23,7 +23,7 @@ const hostDeps = await loadHostDeps()
|
|
|
23
23
|
const z = hostDeps.z
|
|
24
24
|
const defineTool = hostDeps.defineTool
|
|
25
25
|
import { spawn } from 'node:child_process'
|
|
26
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
27
27
|
import { join, dirname } from 'node:path'
|
|
28
28
|
import { createRequire } from 'node:module'
|
|
29
29
|
import { fileURLToPath } from 'node:url'
|
|
@@ -36,6 +36,8 @@ import { loadCred as dmCred, listSessions as dmList, fetchMsgs as dmFetch } from
|
|
|
36
36
|
import { ensureUv } from './lib/uvfetch.mjs'
|
|
37
37
|
|
|
38
38
|
export const name = 'bili-scroll'
|
|
39
|
+
// attachments / llm 故意**不**列进 inject:它们只在看图时才用 ctx.get 取,
|
|
40
|
+
// 取不到就把图降级成文字说明;列进来会让没有这两个服务的宿主直接装不上。
|
|
39
41
|
export const inject = ['webServer', 'tools', 'settings']
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -77,7 +79,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
77
79
|
/** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
|
|
78
80
|
dshBin: z.string().default('').extra('volatile', true),
|
|
79
81
|
/** headless 那一轮用哪个 profile */
|
|
80
|
-
|
|
82
|
+
// ⭐ 2.0.5:默认换成"最小权限 profile"(只有 B站 MCP + 窄口 IO,没有文件系统/命令/联网)
|
|
83
|
+
headlessProfile: z.string().default('bili-duty').extra('volatile', true),
|
|
81
84
|
/** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
|
|
82
85
|
overlayMcp: z.boolean().default(true).extra('volatile', true),
|
|
83
86
|
/** 跑 headless 时的工作目录 */
|
|
@@ -269,7 +272,15 @@ function reportMeta() {
|
|
|
269
272
|
const p = { ...patch }
|
|
270
273
|
if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
|
|
271
274
|
if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
|
|
272
|
-
|
|
275
|
+
// ⭐ 2.0.5(issue #89):人设上限 8000 → 60000;**截断时要出声**(以前静默砍,用户以为写进去了 ✗)
|
|
276
|
+
if ('personaPrompt' in p) {
|
|
277
|
+
const raw = String(p.personaPrompt || '')
|
|
278
|
+
const LIMIT = 60000
|
|
279
|
+
if (raw.length > LIMIT) {
|
|
280
|
+
log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过保存上限 ' + LIMIT + ' ⇒ 只存前 ' + LIMIT + '(截掉 ' + (raw.length - LIMIT) + ' 字)')
|
|
281
|
+
p.personaPrompt = raw.slice(0, LIMIT)
|
|
282
|
+
} else p.personaPrompt = raw
|
|
283
|
+
}
|
|
273
284
|
if ('dmWatch' in p) p.dmWatch = Boolean(p.dmWatch)
|
|
274
285
|
if ('dmWatchMinutes' in p) p.dmWatchMinutes = Math.max(1, Math.min(120, Number(p.dmWatchMinutes) || 1))
|
|
275
286
|
if ('dmReplyPerDay' in p) p.dmReplyPerDay = Math.max(0, Math.min(20, Number(p.dmReplyPerDay) || 0))
|
|
@@ -495,6 +506,12 @@ function reportMeta() {
|
|
|
495
506
|
return client.call(toolName, args, opts)
|
|
496
507
|
}
|
|
497
508
|
|
|
509
|
+
/** 同 call,但保留 MCP 返回里的图片块(封面/画面要用)—— 见 lib/mcp.mjs 的 callRaw */
|
|
510
|
+
const callRaw = async (toolName, args, opts) => {
|
|
511
|
+
const client = await ensureMcp()
|
|
512
|
+
return client.callRaw(toolName, args, opts)
|
|
513
|
+
}
|
|
514
|
+
|
|
498
515
|
const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
|
|
499
516
|
|
|
500
517
|
/* ─────────────── 工具 ─────────────── */
|
|
@@ -518,7 +535,7 @@ function reportMeta() {
|
|
|
518
535
|
|
|
519
536
|
const tool = (spec) => ctx.tools.register(defineTool({
|
|
520
537
|
...spec,
|
|
521
|
-
output: textOutput,
|
|
538
|
+
output: spec.output || textOutput,
|
|
522
539
|
async execute(args, exec) { return safe(await spec.execute(args, exec)) },
|
|
523
540
|
}))
|
|
524
541
|
|
|
@@ -573,6 +590,109 @@ function reportMeta() {
|
|
|
573
590
|
},
|
|
574
591
|
})
|
|
575
592
|
|
|
593
|
+
/* ─────────────── 看画面:把图交给模型 ───────────────
|
|
594
|
+
* 图片在工具结果里是附件引用 { type:'image', attachment },不是 image_url;
|
|
595
|
+
* 字节先存进 ctx.get('attachments'),由 render 拼进返回内容。
|
|
596
|
+
* 模型不支持图片输入时退化成文字(查 llm.resolveModelInfo 的 inputModalities)。
|
|
597
|
+
*/
|
|
598
|
+
const imageOutput = {
|
|
599
|
+
schema: { type: 'object', properties: {}, additionalProperties: true },
|
|
600
|
+
render: (_args, value) => {
|
|
601
|
+
const src = value && typeof value === 'object' ? value : { value }
|
|
602
|
+
const { image, images, ...rest } = src
|
|
603
|
+
const parts = [{ type: 'text', text: JSON.stringify(rest, null, 2) }]
|
|
604
|
+
const refs = [...(image ? [image] : []), ...(Array.isArray(images) ? images : [])]
|
|
605
|
+
for (const ref of refs) parts.push({ type: 'image', attachment: ref })
|
|
606
|
+
return parts
|
|
607
|
+
},
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 把 MCP 回来的图存成附件引用,返回 { ok, refs } 或 { ok:false, reason, hint } */
|
|
611
|
+
const attachImages = async (exec, images) => {
|
|
612
|
+
const attachments = ctx.get('attachments')
|
|
613
|
+
if (!attachments?.saveImages) {
|
|
614
|
+
return { ok: false, reason: 'attachments', hint: '宿主的图片服务不可用(ctx.get("attachments") 是空的)' }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const llm = ctx.get('llm')
|
|
618
|
+
const routed = exec?.agent?.session?.requestHeader?.()?.config
|
|
619
|
+
const provider = routed?.provider ?? exec?.agent?.options?.provider
|
|
620
|
+
const model = routed?.model ?? exec?.agent?.options?.model
|
|
621
|
+
if (!llm?.resolveModelInfo) {
|
|
622
|
+
return { ok: false, reason: 'model_probe', hint: `查不到模型能力(provider=${provider} model=${model})` }
|
|
623
|
+
}
|
|
624
|
+
let modalities
|
|
625
|
+
try {
|
|
626
|
+
modalities = (await llm.resolveModelInfo(provider, model, exec?.signal))?.inputModalities
|
|
627
|
+
} catch (e) {
|
|
628
|
+
return { ok: false, reason: 'model_probe', hint: `查模型能力失败(${provider}/${model}):${String(e?.message ?? e)}` }
|
|
629
|
+
}
|
|
630
|
+
if (!Array.isArray(modalities)) {
|
|
631
|
+
return { ok: false, reason: 'model_probe', hint: `模型 ${provider}/${model} 没声明输入模态` }
|
|
632
|
+
}
|
|
633
|
+
if (!modalities.includes('image')) {
|
|
634
|
+
return { ok: false, reason: 'model_no_image', hint: `模型 ${provider}/${model} 不支持图片输入(${modalities.join(',') || '无'})` }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const refs = await attachments.saveImages(
|
|
639
|
+
images.map((i) => ({ data: Buffer.from(i.data, 'base64'), mediaType: i.mimeType })),
|
|
640
|
+
)
|
|
641
|
+
return { ok: true, refs }
|
|
642
|
+
} catch (e) {
|
|
643
|
+
return { ok: false, reason: 'save_failed', hint: '存图片失败:' + String(e?.message ?? e) }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
tool({
|
|
648
|
+
name: 'bili_cover',
|
|
649
|
+
description: '看一个视频的封面(等比缩到 512px 宽)。最便宜的一档"看一眼":'
|
|
650
|
+
+ '能看出风格、标题美术、大致题材,但看不出视频里具体发生了什么。',
|
|
651
|
+
parameters: { bvid: { type: 'string', description: 'BV号' } },
|
|
652
|
+
output: imageOutput,
|
|
653
|
+
async execute(args, exec) {
|
|
654
|
+
const { json, images } = await callRaw('bili_cover', { bvid: String(args.bvid ?? '') }, { signal: exec?.signal })
|
|
655
|
+
const meta = json && typeof json === 'object' ? json : { raw: json }
|
|
656
|
+
if (!meta.ok) return meta
|
|
657
|
+
const img = images[0]
|
|
658
|
+
if (!img) return { ...meta, ok: false, error: 'MCP 没返回图片' }
|
|
659
|
+
|
|
660
|
+
const a = await attachImages(exec, [img])
|
|
661
|
+
return a.ok
|
|
662
|
+
? { ...meta, image_attached: true, image: a.refs[0] }
|
|
663
|
+
: { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
|
|
664
|
+
},
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
tool({
|
|
668
|
+
name: 'bili_frames',
|
|
669
|
+
description: '看视频里的画面(从 B 站自带的故事板裁 1-6 帧,每帧 480×270)。\n'
|
|
670
|
+
+ '想知道"这条视频到底拍了什么"就用它 —— 字幕告诉你说什么,画面告诉你拍的是什么。\n'
|
|
671
|
+
+ '默认按时长均匀取 3 帧;想盯某个时刻(比如字幕里提到的那段)就传 at。',
|
|
672
|
+
parameters: {
|
|
673
|
+
bvid: { type: 'string', description: 'BV号' },
|
|
674
|
+
num: { type: 'number', description: '取几帧,1-6,默认 3' },
|
|
675
|
+
at: { type: 'array', items: { type: 'number' }, description: '指定时间点(秒)列表;传了就以它为准,忽略 num' },
|
|
676
|
+
},
|
|
677
|
+
output: imageOutput,
|
|
678
|
+
async execute(args, exec) {
|
|
679
|
+
const at = Array.isArray(args.at) ? args.at.map(Number).filter(Number.isFinite) : undefined
|
|
680
|
+
const { json, images } = await callRaw('bili_frames', {
|
|
681
|
+
bvid: String(args.bvid ?? ''),
|
|
682
|
+
num: Math.max(1, Math.min(Number(args.num) || 3, 6)),
|
|
683
|
+
...(at && at.length ? { at } : {}),
|
|
684
|
+
}, { signal: exec?.signal })
|
|
685
|
+
const meta = json && typeof json === 'object' ? json : { raw: json }
|
|
686
|
+
if (!meta.ok) return meta
|
|
687
|
+
if (!images.length) return { ...meta, ok: false, error: 'MCP 没返回图片' }
|
|
688
|
+
|
|
689
|
+
const a = await attachImages(exec, images)
|
|
690
|
+
return a.ok
|
|
691
|
+
? { ...meta, image_attached: true, images: a.refs }
|
|
692
|
+
: { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
|
|
693
|
+
},
|
|
694
|
+
})
|
|
695
|
+
|
|
576
696
|
tool({
|
|
577
697
|
name: 'bili_reply',
|
|
578
698
|
description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
|
|
@@ -630,9 +750,6 @@ function reportMeta() {
|
|
|
630
750
|
dmMode: cfg.dmMode,
|
|
631
751
|
dmUid: Number(cfg.dmUid) || 0,
|
|
632
752
|
transcribe: cfg.transcribe,
|
|
633
|
-
// ⭐ 2.0.4:认人改成"只认显式 UID / 登录账号自己"之后,**用小号登录、大号聊天**的人会静默失去私信回复 ✗
|
|
634
|
-
// ⇒ 这里只给一条**提示**(不自动改!),界面上让他一键确认 —— 自动把"私信对象"当主人等于把安全修复撤销 ✗
|
|
635
|
-
ownerInfo: ownerInfo(),
|
|
636
753
|
loggedIn: hasCredential(),
|
|
637
754
|
home: stateDir,
|
|
638
755
|
report: join(outDir, `${dayKey(now)}.md`),
|
|
@@ -756,6 +873,20 @@ function reportMeta() {
|
|
|
756
873
|
return st.day === dayKey(now) ? Number(st.count || 0) : 0
|
|
757
874
|
}
|
|
758
875
|
|
|
876
|
+
// ⭐⭐ 2.0.5(2026-10-24 用户实证,群友「羽涅」):一键升级以前把 profile 写死成 web ✗
|
|
877
|
+
// 桌面版用户的 profile 是 desktop ⇒ 升级失败,反而把 0.1.7 装进 web 里,越修越乱 ✓
|
|
878
|
+
// 现在从"自己这个模块的真实路径"里取 profile 名:挂载点是 ~/.dsh/profiles/<名字>/node_modules/<包>/
|
|
879
|
+
const ownProfileName = (() => {
|
|
880
|
+
try {
|
|
881
|
+
const u = String(import.meta.url || '').replace(/\\/g, '/')
|
|
882
|
+
const m = u.match(/\/profiles\/([^/]+)\/node_modules\//)
|
|
883
|
+
return m ? m[1] : ''
|
|
884
|
+
} catch { return '' }
|
|
885
|
+
})()
|
|
886
|
+
const profileArg = ownProfileName || 'web'
|
|
887
|
+
// ⭐ 顺带:把"我是从哪个 profile 加载的"报出来,排障时一眼看得出
|
|
888
|
+
try { log('插件加载自 profile:' + profileArg + (ownProfileName ? '' : '(没识别到,按默认 web 处理)')) } catch { /* 忽略 */ }
|
|
889
|
+
|
|
759
890
|
// ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
|
|
760
891
|
// ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
|
|
761
892
|
let upgrading = false
|
|
@@ -794,17 +925,17 @@ function reportMeta() {
|
|
|
794
925
|
// ⚠️ 2026-10-19:找不到 dsh 时不再硬跑,直接把要做的事说清楚
|
|
795
926
|
if (dsh.missing) {
|
|
796
927
|
appendInstall('=== 没法自动升级:找不到 dsh。' + dsh.hint + ' ===\n')
|
|
797
|
-
appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile
|
|
928
|
+
appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
|
|
798
929
|
upgrading = false
|
|
799
930
|
return
|
|
800
931
|
}
|
|
801
932
|
let cmd = dsh.command || 'dsh'
|
|
802
|
-
|
|
933
|
+
let args = [...(dsh.args || []), 'plugin', '--profile', profileArg, 'add', spec]
|
|
803
934
|
if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
|
|
804
935
|
appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
|
|
805
936
|
const code = await spawnTo(cmd, args, { sink: appendInstall })
|
|
806
937
|
if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
|
|
807
|
-
else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile
|
|
938
|
+
else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
|
|
808
939
|
} catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
|
|
809
940
|
})()
|
|
810
941
|
return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
|
|
@@ -993,16 +1124,28 @@ function reportMeta() {
|
|
|
993
1124
|
}
|
|
994
1125
|
}
|
|
995
1126
|
if (persona) {
|
|
1127
|
+
// ⭐ 2.0.5(issue #89):6000 → 40000;超了要写一行日志,别让用户以为"规则生效了" ✗
|
|
1128
|
+
const PERSONA_LIMIT = 40000
|
|
1129
|
+
const personaText = (() => {
|
|
1130
|
+
const raw = String(persona)
|
|
1131
|
+
if (raw.length > PERSONA_LIMIT) {
|
|
1132
|
+
log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过上限 ' + PERSONA_LIMIT + ' ⇒ 只送前 ' + PERSONA_LIMIT + '(截掉 ' + (raw.length - PERSONA_LIMIT) + ' 字)')
|
|
1133
|
+
return raw.slice(0, PERSONA_LIMIT)
|
|
1134
|
+
}
|
|
1135
|
+
return raw
|
|
1136
|
+
})()
|
|
996
1137
|
ownerDoc = '\n\n===== 使用者的人设' + (personaFromFile ? '(读自 ' + personaFromFile + ')' : '提示词') + '(**按这个来**)=====\n'
|
|
997
|
-
+
|
|
1138
|
+
+ personaText
|
|
998
1139
|
+ '\n===== 人设结束 =====\n'
|
|
999
1140
|
}
|
|
1000
1141
|
const wsDir = String(cfg.workspace || '').trim()
|
|
1001
1142
|
const agents = (!persona && wsDir) ? join(wsDir, 'AGENTS.md') : ''
|
|
1002
1143
|
if (agents && existsSync(agents)) {
|
|
1003
1144
|
const text = readFileSync(agents, 'utf8')
|
|
1145
|
+
const AG_LIMIT = 40000
|
|
1146
|
+
if (text.length > AG_LIMIT) log('⚠️ 工作区的 AGENTS.md ' + text.length + ' 字符,超过上限 ' + AG_LIMIT + ' ⇒ 只送前 ' + AG_LIMIT + '(截掉 ' + (text.length - AG_LIMIT) + ' 字)')
|
|
1004
1147
|
ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
|
|
1005
|
-
+ text.slice(0,
|
|
1148
|
+
+ text.slice(0, AG_LIMIT)
|
|
1006
1149
|
+ '\n===== 设定结束 =====\n'
|
|
1007
1150
|
}
|
|
1008
1151
|
} catch { /* 读不到就算了,别因此跑不成 */ }
|
|
@@ -1099,7 +1242,12 @@ function reportMeta() {
|
|
|
1099
1242
|
* 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
|
|
1100
1243
|
*/
|
|
1101
1244
|
const mcpWhitelist = () => {
|
|
1102
|
-
const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info'
|
|
1245
|
+
const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info',
|
|
1246
|
+
'bili_cover', 'bili_frames', 'bili_danmaku'] // 看封面 / 画面帧 / 弹幕:只读,不需要额外的闸
|
|
1247
|
+
// ⭐⭐ 2.0.5(issue #96 安全加固):值班轮改成最小权限 profile(没有 fs / shell / web)⇒
|
|
1248
|
+
// 写日报 / 判重表 / 归档、以及无字幕转写,全走这几只**固定路径的窄口工具** ✓
|
|
1249
|
+
list.push('duty_paths', 'duty_report_append', 'duty_seen_append', 'duty_digest_append', 'duty_seen_recent')
|
|
1250
|
+
if (cfg.asrInstalled !== false) list.push('bili_transcribe')
|
|
1103
1251
|
// ⭐⭐ 2.0.4(第三方体检第 3 条):**"只发私信"时评论通道必须硬关** —— 以前只看 autoComment,
|
|
1104
1252
|
// 用户以前开过自动评论、后来切到"只私信",那个 true 还在 config 里 ⇒ 评论工具照样下发 ✗
|
|
1105
1253
|
const dmOnly = String(cfg.dmMode || '').trim() === 'only'
|
|
@@ -1111,6 +1259,34 @@ function reportMeta() {
|
|
|
1111
1259
|
return list
|
|
1112
1260
|
}
|
|
1113
1261
|
|
|
1262
|
+
|
|
1263
|
+
// ⭐⭐ 2.0.5(issue #96 / PR #97 思路):把「值班轮专用 profile」铺到 <DSH_HOME>/profiles/bili-duty/
|
|
1264
|
+
// · 组成 = dsh-base + dsh-headless + 我们那层 cordis.patch.yml(关掉 fs / shell / web / subagent / skill…)
|
|
1265
|
+
// · 铺不上就明确退回 headless,并在日志里说清"这一轮不是最小权限"(失败要出声)
|
|
1266
|
+
let dutyProfileState = 'unknown' // ok / missing / failed
|
|
1267
|
+
const dutyProfileDir = join(dirname(stateDir), 'profiles', 'bili-duty')
|
|
1268
|
+
const ensureDutyProfile = () => {
|
|
1269
|
+
try {
|
|
1270
|
+
const src = join(pluginDir, 'profile-template', 'bili-duty')
|
|
1271
|
+
if (!existsSync(src)) { dutyProfileState = 'missing'; return false }
|
|
1272
|
+
mkdirSync(dirname(dutyProfileDir), { recursive: true })
|
|
1273
|
+
// 每次启动都覆盖(模板很小;这是安全边界,用户改它也会被覆盖)
|
|
1274
|
+
cpSync(src, dutyProfileDir, { recursive: true, force: true })
|
|
1275
|
+
dutyProfileState = existsSync(join(dutyProfileDir, 'cordis.patch.yml')) ? 'ok' : 'failed'
|
|
1276
|
+
return dutyProfileState === 'ok'
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
dutyProfileState = 'failed'
|
|
1279
|
+
log('⚠️ 最小权限 profile 没铺成功(' + String(e?.message ?? e) + ')⇒ 这一轮会退回 headless')
|
|
1280
|
+
return false
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// 真正用的 profile:用户手填了别的就尊重;还是老默认 has headless ⇒ 用最小权限那个
|
|
1284
|
+
const effectiveProfile = () => {
|
|
1285
|
+
const want = String(cfg.headlessProfile || '').trim()
|
|
1286
|
+
if (want && want !== 'headless' && want !== 'bili-duty') return want
|
|
1287
|
+
return dutyProfileState === 'ok' ? 'bili-duty' : (want || 'headless')
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1114
1290
|
const ensureOverlay = () => {
|
|
1115
1291
|
const file = join(stateDir, 'headless-overlay.yml')
|
|
1116
1292
|
const script = join(stateDir, 'mcp_server.py')
|
|
@@ -1249,7 +1425,7 @@ function reportMeta() {
|
|
|
1249
1425
|
return { started: false, why, error: msg }
|
|
1250
1426
|
}
|
|
1251
1427
|
const overlay = ensureOverlay()
|
|
1252
|
-
|
|
1428
|
+
const args = [...dsh.args, '--profile', effectiveProfile()]
|
|
1253
1429
|
if (overlay) args.push('--patch', overlay)
|
|
1254
1430
|
args.push(promptFor())
|
|
1255
1431
|
// ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
|
|
@@ -1404,6 +1580,46 @@ function reportMeta() {
|
|
|
1404
1580
|
} catch { return null }
|
|
1405
1581
|
}
|
|
1406
1582
|
|
|
1583
|
+
// ⭐⭐ 2.0.5(有人反馈"私信没有上下文" —— 属实):以前每条私信都是一次全新会话 ⇒ 它永远"初次见面" ✗
|
|
1584
|
+
// 现在固定**续同一个会话**:headless 支持 `--session-id <id>`,历史由 DSH 自己带着、超上限它自己压缩 ✓
|
|
1585
|
+
// 第一次跑完把开出来的会话 id 记到 dm-session.json;之后每次都带上;会话失效就丢掉重开(不会卡死)✓
|
|
1586
|
+
const dmSessionFile = join(stateDir, 'dm-session.json')
|
|
1587
|
+
const readDmSession = () => {
|
|
1588
|
+
try {
|
|
1589
|
+
const j = JSON.parse(readFileSync(dmSessionFile, 'utf8'))
|
|
1590
|
+
return j && typeof j.id === 'string' && j.id ? j.id : ''
|
|
1591
|
+
} catch { return '' }
|
|
1592
|
+
}
|
|
1593
|
+
const writeDmSession = (id) => { try { writeFileSync(dmSessionFile, JSON.stringify({ id, at: Date.now() }, null, 2), 'utf8') } catch { /* 写不了下次再认 */ } }
|
|
1594
|
+
// 跑完之后,从 $DSH_HOME/sessions/*/<id>/ 里找"这次新出现的那个"(mtime 晚于启动时刻)
|
|
1595
|
+
const discoverDmSession = (sinceTs) => {
|
|
1596
|
+
try {
|
|
1597
|
+
const root = join(process.env.DSH_HOME || join(process.env.USERPROFILE || '', '.dsh'), 'sessions')
|
|
1598
|
+
if (!existsSync(root)) return ''
|
|
1599
|
+
// ⚠️ 只认"这一轮工作目录 = 插件家"那个 workspace(否则会误认到自己聊天那条会话 ✗)
|
|
1600
|
+
// 命名规则:--<盘符去掉冒号>-<路径用横线拼>--(如 --C-Users-99496-.dsh-bili-duty--)
|
|
1601
|
+
const wantWs = '--' + stateDir.replace(/:/g, '').replace(/[\\/]/g, '-') + '--'
|
|
1602
|
+
let best = ''
|
|
1603
|
+
let bestAt = 0
|
|
1604
|
+
const scanWs = [wantWs].filter((w) => existsSync(join(root, w)))
|
|
1605
|
+
const list = scanWs.length ? scanWs : readdirSync(root)
|
|
1606
|
+
for (const ws of list) {
|
|
1607
|
+
const wsDir = join(root, ws)
|
|
1608
|
+
let dirs = []
|
|
1609
|
+
try { dirs = readdirSync(wsDir) } catch { continue }
|
|
1610
|
+
for (const d of dirs) {
|
|
1611
|
+
const full = join(wsDir, d)
|
|
1612
|
+
try {
|
|
1613
|
+
const st = statSync(full)
|
|
1614
|
+
if (!st.isDirectory()) continue
|
|
1615
|
+
if (st.mtimeMs >= sinceTs && st.mtimeMs > bestAt) { bestAt = st.mtimeMs; best = d }
|
|
1616
|
+
} catch { /* 跳过 */ }
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
return best
|
|
1620
|
+
} catch { return '' }
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1407
1623
|
function dmPromptFor(fromUid, text) {
|
|
1408
1624
|
const raw = String(text || '').slice(0, 600)
|
|
1409
1625
|
const bv = (raw.match(/BV[0-9A-Za-z]{10}/) || [])[0] || ''
|
|
@@ -1431,10 +1647,14 @@ function reportMeta() {
|
|
|
1431
1647
|
const dsh = resolveDsh()
|
|
1432
1648
|
if (dsh.missing) { log('私信回复没跑:找不到 dsh(' + dsh.hint + ')'); return { ok: false } }
|
|
1433
1649
|
const overlay = ensureOverlay()
|
|
1434
|
-
const
|
|
1435
|
-
if (overlay)
|
|
1436
|
-
|
|
1437
|
-
|
|
1650
|
+
const base = [...dsh.args, '--profile', effectiveProfile()]
|
|
1651
|
+
if (overlay) base.push('--patch', overlay)
|
|
1652
|
+
if (dmReplyRunning) { log('私信回复:上一条还在跑 ⇒ 这条等下一跳'); return { ok: false, busy: true } }
|
|
1653
|
+
dmReplyRunning = true
|
|
1654
|
+
const savedSid = readDmSession()
|
|
1655
|
+
const args = savedSid ? [...base, '--session-id', savedSid, dmPromptFor(fromUid, text)] : [...base, dmPromptFor(fromUid, text)]
|
|
1656
|
+
log(`私信回复开跑:来自 uid=${fromUid}` + (savedSid ? '(续会话 ' + savedSid.slice(0, 20) + '…)' : '(开新会话)'))
|
|
1657
|
+
const startedAt = Date.now()
|
|
1438
1658
|
const child = spawn(dsh.command, args, {
|
|
1439
1659
|
cwd: stateDir, shell: dsh.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
1440
1660
|
env: uvEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
|
|
@@ -1443,13 +1663,31 @@ function reportMeta() {
|
|
|
1443
1663
|
const pick = (buf) => { try { appendFileSync(logFile, toText(buf), 'utf8') } catch { /* 丢日志也不能抛 */ } }
|
|
1444
1664
|
child.stdout?.on('data', pick)
|
|
1445
1665
|
child.stderr?.on('data', pick)
|
|
1446
|
-
child.on('exit', (code) => {
|
|
1666
|
+
child.on('exit', (code) => {
|
|
1667
|
+
dmReplyRunning = false
|
|
1668
|
+
log(`私信回复结束(code=${code})`)
|
|
1669
|
+
rotateLogIfBig()
|
|
1670
|
+
// ① 本来没有会话 id:跑完把它开出来的那个认领下来,下一条就能接着聊
|
|
1671
|
+
if (!savedSid && code === 0) {
|
|
1672
|
+
const id = discoverDmSession(startedAt)
|
|
1673
|
+
if (id) { writeDmSession(id); log('私信会话已记录:' + id.slice(0, 24) + '…(以后私信都接着这个会话)') }
|
|
1674
|
+
else log('⚠️ 没找到这次的会话 id ⇒ 下一条私信还是新会话(不影响回复,只是没上下文)')
|
|
1675
|
+
}
|
|
1676
|
+
// ② 带了 id 却失败(会话被删/迁移过)⇒ 丢掉它,下一条重开,别卡死在坏 id 上
|
|
1677
|
+
if (savedSid && code !== 0) {
|
|
1678
|
+
writeDmSession('')
|
|
1679
|
+
log('⚠️ 续会话失败(' + savedSid.slice(0, 16) + '…)⇒ 清掉 id,立刻用新会话重试一次(这条不能没回)')
|
|
1680
|
+
setTimeout(() => { try { runDmReply(fromUid, text) } catch { /* 兜住 */ } }, 800)
|
|
1681
|
+
}
|
|
1682
|
+
})
|
|
1447
1683
|
return { ok: true }
|
|
1448
1684
|
} catch (e) { log('私信回复出错:' + String(e?.message ?? e)); return { ok: false } }
|
|
1449
1685
|
}
|
|
1450
1686
|
|
|
1451
1687
|
let dmTimer = null
|
|
1452
1688
|
let dmBusy = false
|
|
1689
|
+
// ⭐ 2.0.5:一条私信回复没跑完时,不要让下一条进来(不然两个进程抢同一个会话,DSH 会报 "already owned by an active write handle")
|
|
1690
|
+
let dmReplyRunning = false
|
|
1453
1691
|
async function dmTick() {
|
|
1454
1692
|
if (dmBusy) return
|
|
1455
1693
|
dmBusy = true
|
|
@@ -2087,6 +2325,8 @@ function reportMeta() {
|
|
|
2087
2325
|
pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
|
|
2088
2326
|
lastRunAt: st.lastRunAt || null,
|
|
2089
2327
|
lastWhy: st.lastWhy || null,
|
|
2328
|
+
// ⭐ 2.0.4:界面上"它会认谁当主人"那行用这个(用小号登录、大号聊天的会看错)
|
|
2329
|
+
ownerInfo: ownerInfo(),
|
|
2090
2330
|
loggedIn: hasCredential(),
|
|
2091
2331
|
venvReady: venvReadyNow(),
|
|
2092
2332
|
version: myVersion(),
|
|
@@ -2403,7 +2643,7 @@ function reportMeta() {
|
|
|
2403
2643
|
'版本:' + myVersion(),
|
|
2404
2644
|
'平台:' + process.platform + ' ' + process.arch + ' · node ' + process.versions.node + (process.versions.electron ? ' · electron ' + process.versions.electron : ''),
|
|
2405
2645
|
'dsh:' + (() => { try { const d = resolveDsh(); return d.missing ? '没找到(' + d.hint + ')' : d.command + ' ' + d.args.join(' ') } catch (e) { return '探测失败:' + e?.message } })(),
|
|
2406
|
-
'headless profile:' + (
|
|
2646
|
+
'headless profile:' + effectiveProfile() + '(最小权限:' + dutyProfileState + ')',
|
|
2407
2647
|
'pythonPath:' + cfg.pythonPath,
|
|
2408
2648
|
'venv 就绪:' + (venvReadyNow() ? '是' : '否') + (missingVenvDeps().length ? '(缺:' + missingVenvDeps().join(' / ') + ')' : ''),
|
|
2409
2649
|
'转写环境:' + (existsSync(join(stateDir, '.venv-asr')) ? '在' : '没装'),
|
|
@@ -2516,37 +2756,101 @@ function reportMeta() {
|
|
|
2516
2756
|
return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
|
|
2517
2757
|
}
|
|
2518
2758
|
|
|
2519
|
-
try {
|
|
2520
2759
|
const sameOriginOk = (req) => {
|
|
2521
2760
|
try {
|
|
2522
2761
|
const host = String(req.headers.host || '')
|
|
2523
2762
|
if (!host) return true // 没有 Host 头 = 不是浏览器/不是外来的 HTTP 请求
|
|
2524
2763
|
if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)) return false
|
|
2525
2764
|
const pick = String(req.headers.origin || req.headers.referer || '')
|
|
2526
|
-
if (!pick) return true //
|
|
2765
|
+
if (!pick) return true // 不是浏览器发的(本机脚本/宿主内部)
|
|
2527
2766
|
try {
|
|
2528
2767
|
const u = new URL(pick)
|
|
2529
|
-
|
|
2768
|
+
// ⭐ 2.0.5(采纳 PR #94):**端口也要比** —— 只比 hostname 的话,
|
|
2769
|
+
// 本机上另一个端口的页面(dev server / Jupyter / 有 XSS 的本地页)也能驱动这些写接口 ✗
|
|
2770
|
+
const originAuthority = String(u.host || '').toLowerCase()
|
|
2771
|
+
const hostAuthority = String(host).toLowerCase()
|
|
2772
|
+
if (originAuthority && originAuthority === hostAuthority) return true
|
|
2773
|
+
const defaultPort = u.protocol === 'https:' ? '443' : '80'
|
|
2774
|
+
const withPort = (u.hostname + ':' + (u.port || defaultPort)).toLowerCase()
|
|
2775
|
+
return withPort === hostAuthority
|
|
2530
2776
|
} catch { return false }
|
|
2531
2777
|
} catch { return false }
|
|
2532
2778
|
}
|
|
2779
|
+
|
|
2780
|
+
// ⭐⭐ 2.0.5(issue #88):**走 DSH 自己的信任栅栏** —— 隧道把 Host 改写成回环时,
|
|
2781
|
+
// 我们那层"本机同源"会被绕过;connection.requestRejection 才是真正的鉴权(认 cookie/token)✓
|
|
2782
|
+
// 取法照 DSH 内部写法:Reflect.get(ctx, "connection") ⇒ requestRejection(req)
|
|
2783
|
+
// 返回 undefined = 放行;返回数字 = 回这个状态码(通常 401)
|
|
2784
|
+
// ⚠️ 栅栏不可用时不静默放行:仍要求过"本机同源",两道都不成 ⇒ 拒 ✓
|
|
2785
|
+
let fenceWarned = false
|
|
2786
|
+
// ⭐ 取 connection 的三种写法都试(实测:Reflect.get 在插件 ctx 上拿不到 ⇒ 2026-10-23 真机验证后发现)
|
|
2787
|
+
let nodeConnCached = null
|
|
2788
|
+
let nodeConnTried = false
|
|
2789
|
+
const getConnection = () => {
|
|
2790
|
+
if (nodeConnTried) return nodeConnCached
|
|
2791
|
+
nodeConnTried = true
|
|
2792
|
+
const tries = [
|
|
2793
|
+
['ctx.get("connection")', () => (typeof ctx.get === 'function' ? ctx.get('connection') : null)],
|
|
2794
|
+
['Reflect.get(ctx, "connection")', () => Reflect.get(ctx, 'connection')],
|
|
2795
|
+
['ctx.root.connection', () => (ctx.root && typeof ctx.root === 'object' ? ctx.root.connection : null)],
|
|
2796
|
+
]
|
|
2797
|
+
for (const [how, fn] of tries) {
|
|
2798
|
+
try {
|
|
2799
|
+
const c = fn()
|
|
2800
|
+
if (c && typeof c.requestRejection === 'function') {
|
|
2801
|
+
nodeConnCached = c
|
|
2802
|
+
log('信任栅栏:用 ' + how + ' 拿到了 connection 服务 ✓')
|
|
2803
|
+
return c
|
|
2804
|
+
}
|
|
2805
|
+
} catch { /* 换下一个 */ }
|
|
2806
|
+
}
|
|
2807
|
+
log('⚠️ 三种写法都拿不到 connection.requestRejection ⇒ 退回"本机同源"这一道')
|
|
2808
|
+
return null
|
|
2809
|
+
}
|
|
2810
|
+
const fenceReject = (req, res) => {
|
|
2811
|
+
const conn = getConnection()
|
|
2812
|
+
if (!conn) {
|
|
2813
|
+
if (!fenceWarned) { fenceWarned = true; log("⚠️ 拿不到 DSH 信任栅栏(connection.requestRejection)⇒ 退回本机同源这一道") }
|
|
2814
|
+
return false
|
|
2815
|
+
}
|
|
2816
|
+
try {
|
|
2817
|
+
// ⭐ 2.0.5(采纳 PR #94):契约入参是 `{ headers }`(ConnectionTrustRequest),不是原始 IncomingMessage;
|
|
2818
|
+
// 两种形状都给一遍,谁的实现都能接住(实测传原始 req 也能过,但按契约来更稳)
|
|
2819
|
+
const rejection = conn.requestRejection({ headers: req.headers })
|
|
2820
|
+
if (rejection === undefined) return false
|
|
2821
|
+
res.statusCode = typeof rejection === "number" ? rejection : 403
|
|
2822
|
+
try { res.end() } catch { /* 忽略 */ }
|
|
2823
|
+
return true
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
log("信任栅栏调用出错,按不通过处理:" + String(err?.message ?? err))
|
|
2826
|
+
res.statusCode = 403
|
|
2827
|
+
try { res.end() } catch { /* 忽略 */ }
|
|
2828
|
+
return true
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
try {
|
|
2533
2833
|
ctx.effect(() => ctx.webServer.register({
|
|
2534
2834
|
kind: 'prefix',
|
|
2535
2835
|
path: '/api/bili-duty',
|
|
2536
|
-
// ⭐ 2026-10-23 安全加固(审计点名"未鉴权的控制接口"):**只接受本机同源请求** ——
|
|
2537
|
-
// 别的网页在你浏览器里也能打到 127.0.0.1,所以要比对 Host 与本机、Origin/Referer 与同源 ✓
|
|
2538
2836
|
handler: async (req, res) => {
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2837
|
+
// ① 先过 DSH 的信任栅栏(真鉴权)
|
|
2838
|
+
if (fenceReject(req, res)) return
|
|
2839
|
+
// ② 再过本机同源(栅栏拿不到时的兜底,也挡浏览器里别的网页)
|
|
2840
|
+
if (!sameOriginOk(req)) {
|
|
2841
|
+
try { sendJson(res, 403, { ok: false, error: '只接受本机同源请求' }) } catch { /* 忽略 */ }
|
|
2842
|
+
return
|
|
2843
|
+
}
|
|
2844
|
+
try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (err) {
|
|
2845
|
+
sendJson(res, 500, { ok: false, error: err?.message ?? String(err) })
|
|
2545
2846
|
}
|
|
2546
2847
|
},
|
|
2547
2848
|
}), 'bili-duty: /api/bili-duty 路由')
|
|
2548
2849
|
} catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
|
|
2549
2850
|
|
|
2550
2851
|
ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
|
|
2852
|
+
try { ensureDutyProfile() } catch { /* 上面已记日志 */ }
|
|
2853
|
+
if (dutyProfileState === 'ok') log('值班轮用最小权限 profile:bili-duty(无文件系统 / 无命令 / 无联网)')
|
|
2854
|
+
else log('⚠️ 最小权限 profile 不可用(' + dutyProfileState + ')⇒ 值班轮仍用 headless(这一轮能力偏大)')
|
|
2551
2855
|
log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
|
|
2552
2856
|
}
|