dsh-bili-scroll 2.0.4 → 2.2.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/index.js CHANGED
@@ -23,19 +23,22 @@ 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'
30
30
  import { McpClient } from './lib/mcp.mjs'
31
31
  import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
32
32
  import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow, dayKey } from './lib/state.mjs'
33
+ import { diagnoseRound } from './lib/diagnose.mjs'
33
34
  import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
34
35
  import { resolveUid } from './lib/uid.mjs'
35
36
  import { loadCred as dmCred, listSessions as dmList, fetchMsgs as dmFetch } from './lib/dm.mjs'
36
37
  import { ensureUv } from './lib/uvfetch.mjs'
37
38
 
38
39
  export const name = 'bili-scroll'
40
+ // attachments / llm 故意**不**列进 inject:它们只在看图时才用 ctx.get 取,
41
+ // 取不到就把图降级成文字说明;列进来会让没有这两个服务的宿主直接装不上。
39
42
  export const inject = ['webServer', 'tools', 'settings']
40
43
 
41
44
  /**
@@ -77,7 +80,8 @@ export const Config = !z ? undefined : z.object({
77
80
  /** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
78
81
  dshBin: z.string().default('').extra('volatile', true),
79
82
  /** headless 那一轮用哪个 profile */
80
- headlessProfile: z.string().default('headless').extra('volatile', true),
83
+ // ⭐ 2.0.5:默认换成"最小权限 profile"(只有 B站 MCP + 窄口 IO,没有文件系统/命令/联网)
84
+ headlessProfile: z.string().default('bili-duty').extra('volatile', true),
81
85
  /** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
82
86
  overlayMcp: z.boolean().default(true).extra('volatile', true),
83
87
  /** 跑 headless 时的工作目录 */
@@ -269,7 +273,15 @@ function reportMeta() {
269
273
  const p = { ...patch }
270
274
  if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
271
275
  if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
272
- if ('personaPrompt' in p) p.personaPrompt = String(p.personaPrompt || '').slice(0, 8000)
276
+ // ⭐ 2.0.5(issue #89):人设上限 8000 → 60000;**截断时要出声**(以前静默砍,用户以为写进去了 ✗)
277
+ if ('personaPrompt' in p) {
278
+ const raw = String(p.personaPrompt || '')
279
+ const LIMIT = 60000
280
+ if (raw.length > LIMIT) {
281
+ log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过保存上限 ' + LIMIT + ' ⇒ 只存前 ' + LIMIT + '(截掉 ' + (raw.length - LIMIT) + ' 字)')
282
+ p.personaPrompt = raw.slice(0, LIMIT)
283
+ } else p.personaPrompt = raw
284
+ }
273
285
  if ('dmWatch' in p) p.dmWatch = Boolean(p.dmWatch)
274
286
  if ('dmWatchMinutes' in p) p.dmWatchMinutes = Math.max(1, Math.min(120, Number(p.dmWatchMinutes) || 1))
275
287
  if ('dmReplyPerDay' in p) p.dmReplyPerDay = Math.max(0, Math.min(20, Number(p.dmReplyPerDay) || 0))
@@ -495,6 +507,12 @@ function reportMeta() {
495
507
  return client.call(toolName, args, opts)
496
508
  }
497
509
 
510
+ /** 同 call,但保留 MCP 返回里的图片块(封面/画面要用)—— 见 lib/mcp.mjs 的 callRaw */
511
+ const callRaw = async (toolName, args, opts) => {
512
+ const client = await ensureMcp()
513
+ return client.callRaw(toolName, args, opts)
514
+ }
515
+
498
516
  const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
499
517
 
500
518
  /* ─────────────── 工具 ─────────────── */
@@ -518,7 +536,7 @@ function reportMeta() {
518
536
 
519
537
  const tool = (spec) => ctx.tools.register(defineTool({
520
538
  ...spec,
521
- output: textOutput,
539
+ output: spec.output || textOutput,
522
540
  async execute(args, exec) { return safe(await spec.execute(args, exec)) },
523
541
  }))
524
542
 
@@ -573,6 +591,109 @@ function reportMeta() {
573
591
  },
574
592
  })
575
593
 
594
+ /* ─────────────── 看画面:把图交给模型 ───────────────
595
+ * 图片在工具结果里是附件引用 { type:'image', attachment },不是 image_url;
596
+ * 字节先存进 ctx.get('attachments'),由 render 拼进返回内容。
597
+ * 模型不支持图片输入时退化成文字(查 llm.resolveModelInfo 的 inputModalities)。
598
+ */
599
+ const imageOutput = {
600
+ schema: { type: 'object', properties: {}, additionalProperties: true },
601
+ render: (_args, value) => {
602
+ const src = value && typeof value === 'object' ? value : { value }
603
+ const { image, images, ...rest } = src
604
+ const parts = [{ type: 'text', text: JSON.stringify(rest, null, 2) }]
605
+ const refs = [...(image ? [image] : []), ...(Array.isArray(images) ? images : [])]
606
+ for (const ref of refs) parts.push({ type: 'image', attachment: ref })
607
+ return parts
608
+ },
609
+ }
610
+
611
+ /** 把 MCP 回来的图存成附件引用,返回 { ok, refs } 或 { ok:false, reason, hint } */
612
+ const attachImages = async (exec, images) => {
613
+ const attachments = ctx.get('attachments')
614
+ if (!attachments?.saveImages) {
615
+ return { ok: false, reason: 'attachments', hint: '宿主的图片服务不可用(ctx.get("attachments") 是空的)' }
616
+ }
617
+
618
+ const llm = ctx.get('llm')
619
+ const routed = exec?.agent?.session?.requestHeader?.()?.config
620
+ const provider = routed?.provider ?? exec?.agent?.options?.provider
621
+ const model = routed?.model ?? exec?.agent?.options?.model
622
+ if (!llm?.resolveModelInfo) {
623
+ return { ok: false, reason: 'model_probe', hint: `查不到模型能力(provider=${provider} model=${model})` }
624
+ }
625
+ let modalities
626
+ try {
627
+ modalities = (await llm.resolveModelInfo(provider, model, exec?.signal))?.inputModalities
628
+ } catch (e) {
629
+ return { ok: false, reason: 'model_probe', hint: `查模型能力失败(${provider}/${model}):${String(e?.message ?? e)}` }
630
+ }
631
+ if (!Array.isArray(modalities)) {
632
+ return { ok: false, reason: 'model_probe', hint: `模型 ${provider}/${model} 没声明输入模态` }
633
+ }
634
+ if (!modalities.includes('image')) {
635
+ return { ok: false, reason: 'model_no_image', hint: `模型 ${provider}/${model} 不支持图片输入(${modalities.join(',') || '无'})` }
636
+ }
637
+
638
+ try {
639
+ const refs = await attachments.saveImages(
640
+ images.map((i) => ({ data: Buffer.from(i.data, 'base64'), mediaType: i.mimeType })),
641
+ )
642
+ return { ok: true, refs }
643
+ } catch (e) {
644
+ return { ok: false, reason: 'save_failed', hint: '存图片失败:' + String(e?.message ?? e) }
645
+ }
646
+ }
647
+
648
+ tool({
649
+ name: 'bili_cover',
650
+ description: '看一个视频的封面(等比缩到 512px 宽)。最便宜的一档"看一眼":'
651
+ + '能看出风格、标题美术、大致题材,但看不出视频里具体发生了什么。',
652
+ parameters: { bvid: { type: 'string', description: 'BV号' } },
653
+ output: imageOutput,
654
+ async execute(args, exec) {
655
+ const { json, images } = await callRaw('bili_cover', { bvid: String(args.bvid ?? '') }, { signal: exec?.signal })
656
+ const meta = json && typeof json === 'object' ? json : { raw: json }
657
+ if (!meta.ok) return meta
658
+ const img = images[0]
659
+ if (!img) return { ...meta, ok: false, error: 'MCP 没返回图片' }
660
+
661
+ const a = await attachImages(exec, [img])
662
+ return a.ok
663
+ ? { ...meta, image_attached: true, image: a.refs[0] }
664
+ : { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
665
+ },
666
+ })
667
+
668
+ tool({
669
+ name: 'bili_frames',
670
+ description: '看视频里的画面(从 B 站自带的故事板裁 1-6 帧,每帧 480×270)。\n'
671
+ + '想知道"这条视频到底拍了什么"就用它 —— 字幕告诉你说什么,画面告诉你拍的是什么。\n'
672
+ + '默认按时长均匀取 3 帧;想盯某个时刻(比如字幕里提到的那段)就传 at。',
673
+ parameters: {
674
+ bvid: { type: 'string', description: 'BV号' },
675
+ num: { type: 'number', description: '取几帧,1-6,默认 3' },
676
+ at: { type: 'array', items: { type: 'number' }, description: '指定时间点(秒)列表;传了就以它为准,忽略 num' },
677
+ },
678
+ output: imageOutput,
679
+ async execute(args, exec) {
680
+ const at = Array.isArray(args.at) ? args.at.map(Number).filter(Number.isFinite) : undefined
681
+ const { json, images } = await callRaw('bili_frames', {
682
+ bvid: String(args.bvid ?? ''),
683
+ num: Math.max(1, Math.min(Number(args.num) || 3, 6)),
684
+ ...(at && at.length ? { at } : {}),
685
+ }, { signal: exec?.signal })
686
+ const meta = json && typeof json === 'object' ? json : { raw: json }
687
+ if (!meta.ok) return meta
688
+ if (!images.length) return { ...meta, ok: false, error: 'MCP 没返回图片' }
689
+
690
+ const a = await attachImages(exec, images)
691
+ return a.ok
692
+ ? { ...meta, image_attached: true, images: a.refs }
693
+ : { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
694
+ },
695
+ })
696
+
576
697
  tool({
577
698
  name: 'bili_reply',
578
699
  description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
@@ -630,9 +751,6 @@ function reportMeta() {
630
751
  dmMode: cfg.dmMode,
631
752
  dmUid: Number(cfg.dmUid) || 0,
632
753
  transcribe: cfg.transcribe,
633
- // ⭐ 2.0.4:认人改成"只认显式 UID / 登录账号自己"之后,**用小号登录、大号聊天**的人会静默失去私信回复 ✗
634
- // ⇒ 这里只给一条**提示**(不自动改!),界面上让他一键确认 —— 自动把"私信对象"当主人等于把安全修复撤销 ✗
635
- ownerInfo: ownerInfo(),
636
754
  loggedIn: hasCredential(),
637
755
  home: stateDir,
638
756
  report: join(outDir, `${dayKey(now)}.md`),
@@ -756,6 +874,20 @@ function reportMeta() {
756
874
  return st.day === dayKey(now) ? Number(st.count || 0) : 0
757
875
  }
758
876
 
877
+ // ⭐⭐ 2.0.5(2026-10-24 用户实证,群友「羽涅」):一键升级以前把 profile 写死成 web ✗
878
+ // 桌面版用户的 profile 是 desktop ⇒ 升级失败,反而把 0.1.7 装进 web 里,越修越乱 ✓
879
+ // 现在从"自己这个模块的真实路径"里取 profile 名:挂载点是 ~/.dsh/profiles/<名字>/node_modules/<包>/
880
+ const ownProfileName = (() => {
881
+ try {
882
+ const u = String(import.meta.url || '').replace(/\\/g, '/')
883
+ const m = u.match(/\/profiles\/([^/]+)\/node_modules\//)
884
+ return m ? m[1] : ''
885
+ } catch { return '' }
886
+ })()
887
+ const profileArg = ownProfileName || 'web'
888
+ // ⭐ 顺带:把"我是从哪个 profile 加载的"报出来,排障时一眼看得出
889
+ try { log('插件加载自 profile:' + profileArg + (ownProfileName ? '' : '(没识别到,按默认 web 处理)')) } catch { /* 忽略 */ }
890
+
759
891
  // ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
760
892
  // ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
761
893
  let upgrading = false
@@ -794,17 +926,17 @@ function reportMeta() {
794
926
  // ⚠️ 2026-10-19:找不到 dsh 时不再硬跑,直接把要做的事说清楚
795
927
  if (dsh.missing) {
796
928
  appendInstall('=== 没法自动升级:找不到 dsh。' + dsh.hint + ' ===\n')
797
- appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
929
+ appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
798
930
  upgrading = false
799
931
  return
800
932
  }
801
933
  let cmd = dsh.command || 'dsh'
802
- let args = [...(dsh.args || []), 'plugin', '--profile', 'web', 'add', spec]
934
+ let args = [...(dsh.args || []), 'plugin', '--profile', profileArg, 'add', spec]
803
935
  if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
804
936
  appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
805
937
  const code = await spawnTo(cmd, args, { sink: appendInstall })
806
938
  if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
807
- else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile web add dsh-bili-scroll ===\n')
939
+ else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
808
940
  } catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
809
941
  })()
810
942
  return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
@@ -993,16 +1125,28 @@ function reportMeta() {
993
1125
  }
994
1126
  }
995
1127
  if (persona) {
1128
+ // ⭐ 2.0.5(issue #89):6000 → 40000;超了要写一行日志,别让用户以为"规则生效了" ✗
1129
+ const PERSONA_LIMIT = 40000
1130
+ const personaText = (() => {
1131
+ const raw = String(persona)
1132
+ if (raw.length > PERSONA_LIMIT) {
1133
+ log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过上限 ' + PERSONA_LIMIT + ' ⇒ 只送前 ' + PERSONA_LIMIT + '(截掉 ' + (raw.length - PERSONA_LIMIT) + ' 字)')
1134
+ return raw.slice(0, PERSONA_LIMIT)
1135
+ }
1136
+ return raw
1137
+ })()
996
1138
  ownerDoc = '\n\n===== 使用者的人设' + (personaFromFile ? '(读自 ' + personaFromFile + ')' : '提示词') + '(**按这个来**)=====\n'
997
- + persona.slice(0, 6000)
1139
+ + personaText
998
1140
  + '\n===== 人设结束 =====\n'
999
1141
  }
1000
1142
  const wsDir = String(cfg.workspace || '').trim()
1001
1143
  const agents = (!persona && wsDir) ? join(wsDir, 'AGENTS.md') : ''
1002
1144
  if (agents && existsSync(agents)) {
1003
1145
  const text = readFileSync(agents, 'utf8')
1146
+ const AG_LIMIT = 40000
1147
+ if (text.length > AG_LIMIT) log('⚠️ 工作区的 AGENTS.md ' + text.length + ' 字符,超过上限 ' + AG_LIMIT + ' ⇒ 只送前 ' + AG_LIMIT + '(截掉 ' + (text.length - AG_LIMIT) + ' 字)')
1004
1148
  ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
1005
- + text.slice(0, 6000)
1149
+ + text.slice(0, AG_LIMIT)
1006
1150
  + '\n===== 设定结束 =====\n'
1007
1151
  }
1008
1152
  } catch { /* 读不到就算了,别因此跑不成 */ }
@@ -1038,7 +1182,11 @@ function reportMeta() {
1038
1182
  + (already > 0
1039
1183
  ? `今天已经挑过 ${already} 条了 ⇒ **这一轮最多再挑 ${left} 条**(「每天最多 ${quota} 条」是全天总量,不是每轮);`
1040
1184
  : `今天最多挑 ${quota} 条;`)
1041
- + `关键词${kws.length ? `用这些:${kws.join('、')}` : '用章程里的默认那几组'}。`
1185
+ // ⭐ 2026-10-24:留空不再用内置关键词(那会让所有用户刷同一批视频、评论区扎堆)
1186
+ // ⇒ 留空 = 用 `bili_recommend` 刷**这个账号的首页推荐流**,按各自画像天然散开 ✓
1187
+ + (kws.length
1188
+ ? `关键词用这些:${kws.join('、')}(用 bili_search 搜)。`
1189
+ : '没填关键词 ⇒ **这一轮用 bili_recommend 刷首页推荐流**(不要用 bili_search 搜固定词)。')
1042
1190
  + `本地转写:${cfg.transcribe
1043
1191
  ? (asrReadyNow()
1044
1192
  ? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
@@ -1050,6 +1198,18 @@ function reportMeta() {
1050
1198
  + `⭐ 另外把这一轮**看过的每一条视频(挑中的和跳过的都算)**追加进归档:${join(digestDir, `${dayKey().slice(0, 7)}.md`)}`
1051
1199
  + ` —— 一行一条:日期 · BV 号 · 标题 · UP · 一句话结论 · 链接(跳过的那条在后面注明理由);`
1052
1200
  + `这是**给以后检索用**的(报告是给人看的,归档是给"回头翻"用的),别省略、也别只写挑中的。`
1201
+ // ⭐ 2026-11 语感:把"它自己以前发过的评论"喂进去 —— 没有这段,它每轮都从零开始,
1202
+ // 自然每条都往"平均腔调"上靠(那就是鱼八股)。有这段它才会长出自己的口癖、并避开重复开头。
1203
+ + (() => {
1204
+ try {
1205
+ const led = join(stateDir, 'comments.md')
1206
+ if (!existsSync(led)) return ''
1207
+ const rows = readFileSync(led, 'utf8').split('\n').filter((l) => l.trim().startsWith('- '))
1208
+ const bodies = rows.slice(-5).map((l) => (l.includes('·') ? l.split('·').slice(1).join('·') : l.slice(2)).trim()).filter(Boolean)
1209
+ if (!bodies.length) return ''
1210
+ return `⭐ **你以前发过的评论**(写新的之前先看一遍:**别用相同的开头、别套同一个句式**,它应该越来越像"这一个它"而不是模板):` + bodies.map((b) => '「' + b.slice(0, 60) + '」').join(' ') + '。'
1211
+ } catch { return '' }
1212
+ })()
1053
1213
  + `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${commentOn
1054
1214
  ? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
1055
1215
  : (dmModeEff === 'only'
@@ -1099,7 +1259,12 @@ function reportMeta() {
1099
1259
  * 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
1100
1260
  */
1101
1261
  const mcpWhitelist = () => {
1102
- const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info']
1262
+ const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info',
1263
+ 'bili_cover', 'bili_frames', 'bili_danmaku', 'bili_recommend'] // 看封面 / 画面帧 / 弹幕:只读,不需要额外的闸
1264
+ // ⭐⭐ 2.0.5(issue #96 安全加固):值班轮改成最小权限 profile(没有 fs / shell / web)⇒
1265
+ // 写日报 / 判重表 / 归档、以及无字幕转写,全走这几只**固定路径的窄口工具** ✓
1266
+ list.push('duty_paths', 'duty_report_append', 'duty_seen_append', 'duty_digest_append', 'duty_seen_recent')
1267
+ if (cfg.asrInstalled !== false) list.push('bili_transcribe')
1103
1268
  // ⭐⭐ 2.0.4(第三方体检第 3 条):**"只发私信"时评论通道必须硬关** —— 以前只看 autoComment,
1104
1269
  // 用户以前开过自动评论、后来切到"只私信",那个 true 还在 config 里 ⇒ 评论工具照样下发 ✗
1105
1270
  const dmOnly = String(cfg.dmMode || '').trim() === 'only'
@@ -1111,6 +1276,34 @@ function reportMeta() {
1111
1276
  return list
1112
1277
  }
1113
1278
 
1279
+
1280
+ // ⭐⭐ 2.0.5(issue #96 / PR #97 思路):把「值班轮专用 profile」铺到 <DSH_HOME>/profiles/bili-duty/
1281
+ // · 组成 = dsh-base + dsh-headless + 我们那层 cordis.patch.yml(关掉 fs / shell / web / subagent / skill…)
1282
+ // · 铺不上就明确退回 headless,并在日志里说清"这一轮不是最小权限"(失败要出声)
1283
+ let dutyProfileState = 'unknown' // ok / missing / failed
1284
+ const dutyProfileDir = join(dirname(stateDir), 'profiles', 'bili-duty')
1285
+ const ensureDutyProfile = () => {
1286
+ try {
1287
+ const src = join(pluginDir, 'profile-template', 'bili-duty')
1288
+ if (!existsSync(src)) { dutyProfileState = 'missing'; return false }
1289
+ mkdirSync(dirname(dutyProfileDir), { recursive: true })
1290
+ // 每次启动都覆盖(模板很小;这是安全边界,用户改它也会被覆盖)
1291
+ cpSync(src, dutyProfileDir, { recursive: true, force: true })
1292
+ dutyProfileState = existsSync(join(dutyProfileDir, 'cordis.patch.yml')) ? 'ok' : 'failed'
1293
+ return dutyProfileState === 'ok'
1294
+ } catch (e) {
1295
+ dutyProfileState = 'failed'
1296
+ log('⚠️ 最小权限 profile 没铺成功(' + String(e?.message ?? e) + ')⇒ 这一轮会退回 headless')
1297
+ return false
1298
+ }
1299
+ }
1300
+ // 真正用的 profile:用户手填了别的就尊重;还是老默认 has headless ⇒ 用最小权限那个
1301
+ const effectiveProfile = () => {
1302
+ const want = String(cfg.headlessProfile || '').trim()
1303
+ if (want && want !== 'headless' && want !== 'bili-duty') return want
1304
+ return dutyProfileState === 'ok' ? 'bili-duty' : (want || 'headless')
1305
+ }
1306
+
1114
1307
  const ensureOverlay = () => {
1115
1308
  const file = join(stateDir, 'headless-overlay.yml')
1116
1309
  const script = join(stateDir, 'mcp_server.py')
@@ -1198,9 +1391,13 @@ function reportMeta() {
1198
1391
 
1199
1392
  /**
1200
1393
  * ⭐ 保底:那一轮**没写出报告**时,宿主自己补一份"占位报告"。
1201
- * 起因(2026-10-16 用户实测反馈):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
1202
- * 最后 `out/` 是空的、判重表也没建 ⇒ **使用者完全看不到发生了什么**,也不知道白烧了钱 ✗。
1203
- * ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补 ✓),不覆盖已有的报告 ✓。
1394
+ * 起因①(2026-10-16 用户实测):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
1395
+ * 最后 `out/` 空、判重表也没建 ⇒ 使用者完全看不到发生了什么 ✗。
1396
+ * 起因②(2026-10-24 用户实测):`NO_ADAPTER: no adapter registered for provider "…"`
1397
+ * ⇒ 那一轮 1 秒 exit 1,报告里只有 code=1 ⇒ 用户看不懂 ✗。
1398
+ * 现在:先从日志尾部认已知失败形态(`lib/diagnose.mjs`,纯函数),能认出来就把"是什么+怎么办"
1399
+ * 写进报告顶部;认不出来就走原来的兜底文案(行为跟以前一样)。
1400
+ * ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补),不覆盖已有报告。
1204
1401
  */
1205
1402
  const stubReportIfMissing = (code) => {
1206
1403
  try {
@@ -1208,16 +1405,36 @@ function reportMeta() {
1208
1405
  const f = join(outDir, `${dayKey()}.md`)
1209
1406
  if (existsSync(f)) return
1210
1407
  let tail = ''
1211
- try { tail = readFileSync(logFile, 'utf8').slice(-1500) } catch { /* 读不到就算了 */ }
1408
+ try { tail = readFileSync(logFile, 'utf8').slice(-6000) } catch { /* 读不到就算了 */ }
1409
+ // ⚠️ diagnoseRound 是纯函数、自己不会抛;这里再包一层是"绝不因为诊断把人搞挂"
1410
+ let why = null
1411
+ try { why = diagnoseRound(tail) } catch (e) { log(`诊断失败(已兜住):${e?.message ?? e}`) }
1412
+ if (why) log(`这一轮没跑起来:${why.title}`)
1413
+ const head = why
1414
+ ? [
1415
+ `# 刷B站 · ${dayKey()} —— 这一轮没跑起来`,
1416
+ '',
1417
+ `> ⚠️ **${why.title}**(退出码 ${code})`,
1418
+ '',
1419
+ why.body,
1420
+ '',
1421
+ '---',
1422
+ '',
1423
+ '## 日志尾部(完整版在 `duty.log`)',
1424
+ '',
1425
+ ]
1426
+ : [
1427
+ `# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
1428
+ '',
1429
+ `> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
1430
+ '> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
1431
+ '> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
1432
+ '',
1433
+ ]
1212
1434
  writeFileSync(f, [
1213
- `# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
1214
- '',
1215
- `> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
1216
- '> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
1217
- '> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
1218
- '',
1435
+ ...head,
1219
1436
  '```',
1220
- String(tail).trim(),
1437
+ String(tail).trim().slice(-4000),
1221
1438
  '```',
1222
1439
  '',
1223
1440
  ].join('\n'), 'utf8')
@@ -1227,6 +1444,47 @@ function reportMeta() {
1227
1444
 
1228
1445
  // ⭐⭐ 2.0.4(第三方体检第 2 条):**同一时间只允许一轮在跑**,并且**登记这个子进程**,
1229
1446
  // 关页面/卸载插件时能把它一起收掉(以前它没有锁、也不归 ctx.effect 管 ✗)。
1447
+ // ⭐⭐ 2026-10-25:**跨宿主的运行锁**(issue 提到"没加原子锁"—— 那一半是真的)。
1448
+ // 同一台机器可能同时开着网页版和桌面版两个 DSH,两边都装了插件、都有自己的定时器:
1449
+ // 同一分钟两个都到点 ⇒ 都开跑 ⇒ **同一条视频被推两遍**,`seen.md` 也会多出重复行 ✗
1450
+ // (私信监听早就有 `dm-lock.json` 了,跑一轮这块一直只有"进程内"的标志位 ✗)
1451
+ // 设计:一张带 pid + 时间的纸条,谁先写成谁跑,另一个让开;10 分钟自动过期(防进程崩了锁死)。
1452
+ // ⚠️ **写不了锁就照常跑** —— 宁可偶尔重复一次,也不能因为锁坏了就不干活。
1453
+ const runLockFile = join(stateDir, 'run-lock.json')
1454
+ const RUN_LOCK_TTL = 10 * 60_000
1455
+ const pidAlive = (pid) => {
1456
+ if (!pid) return false
1457
+ try { process.kill(pid, 0); return true } catch { return false }
1458
+ }
1459
+ /** 试着占锁:拿到 ⇒ true;有人正占着(新鲜的、进程还活着、不是我)⇒ false */
1460
+ const acquireRunLock = (why) => {
1461
+ try {
1462
+ let lock = null
1463
+ try { lock = JSON.parse(readFileSync(runLockFile, 'utf8')) } catch { lock = null }
1464
+ if (lock && Number(lock.pid) !== process.pid) {
1465
+ const fresh = Number(lock.ts) > Date.now() - RUN_LOCK_TTL
1466
+ if (fresh && pidAlive(Number(lock.pid))) {
1467
+ log(`这一轮没跑(${why}):另一个宿主正在跑(pid=${lock.pid})⇒ 让给它`)
1468
+ return false
1469
+ }
1470
+ if (!fresh) log('运行锁已过期(' + Math.round((Date.now() - Number(lock.ts)) / 60000) + ' 分钟前那个),这一轮照跑')
1471
+ }
1472
+ writeFileSync(runLockFile, JSON.stringify({ pid: process.pid, ts: Date.now(), why }), 'utf8')
1473
+ return true
1474
+ } catch (e) {
1475
+ // 锁文件坏了/写不进去 ⇒ 不拦路(fail-open,但留痕)
1476
+ log('运行锁读写失败(这一轮照跑):' + String(e?.message ?? e))
1477
+ return true
1478
+ }
1479
+ }
1480
+ /** 放锁:只放自己的那张(别把别人的删了) */
1481
+ const releaseRunLock = () => {
1482
+ try {
1483
+ const lock = JSON.parse(readFileSync(runLockFile, 'utf8'))
1484
+ if (Number(lock?.pid) === process.pid) writeFileSync(runLockFile, '{"pid":0,"ts":0}', 'utf8')
1485
+ } catch { /* 没有锁 / 读不出来 / 写不进去都无所谓,靠 10 分钟过期兜底 */ }
1486
+ }
1487
+
1230
1488
  let dutyChild = null
1231
1489
  let dutyStartedAt = 0
1232
1490
  const runDuty = (why = 'window') => {
@@ -1248,8 +1506,10 @@ function reportMeta() {
1248
1506
  try { stubReportIfMissing(1) } catch { /* 补不上也不影响这句话已经进日志 */ }
1249
1507
  return { started: false, why, error: msg }
1250
1508
  }
1509
+ // ⭐ 跨宿主运行锁:拿到才跑(写不了锁时照常跑,函数内部已经 fail-open)
1510
+ if (!acquireRunLock(why)) return { started: false, why, busy: true, error: '另一个宿主正在跑这一轮' }
1251
1511
  const overlay = ensureOverlay()
1252
- const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
1512
+ const args = [...dsh.args, '--profile', effectiveProfile()]
1253
1513
  if (overlay) args.push('--patch', overlay)
1254
1514
  args.push(promptFor())
1255
1515
  // ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
@@ -1291,6 +1551,7 @@ function reportMeta() {
1291
1551
  child.stdout?.on('data', pick)
1292
1552
  child.stderr?.on('data', pick)
1293
1553
  child.on('exit', (code) => {
1554
+ releaseRunLock() // ⭐ 这一轮结束了,把锁放开(删不掉也没事,10 分钟自动过期)
1294
1555
  log(`刷B站结束(${why},code=${code})`)
1295
1556
  stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
1296
1557
  // ⭐ 2026-10-20:这一轮跑成了、并且使用者选了收藏夹 ⇒ 把挑中的存进去(后台做,别拖住收尾)
@@ -1314,7 +1575,7 @@ function reportMeta() {
1314
1575
  child.on('close', () => { if (dutyChild === child) dutyChild = null })
1315
1576
  dutyChild = child
1316
1577
  dutyStartedAt = Date.now()
1317
- child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
1578
+ child.on('error', (e) => { releaseRunLock(); log(`刷B站起不来:${e?.message ?? e}`) })
1318
1579
  // ⭐ 起不来/被杀掉也要把锁放开,否则之后每一轮都会被判成'上一轮还没结束' ✗
1319
1580
  child.on('close', () => { if (dutyChild === child) dutyChild = null })
1320
1581
  return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
@@ -1404,6 +1665,46 @@ function reportMeta() {
1404
1665
  } catch { return null }
1405
1666
  }
1406
1667
 
1668
+ // ⭐⭐ 2.0.5(有人反馈"私信没有上下文" —— 属实):以前每条私信都是一次全新会话 ⇒ 它永远"初次见面" ✗
1669
+ // 现在固定**续同一个会话**:headless 支持 `--session-id <id>`,历史由 DSH 自己带着、超上限它自己压缩 ✓
1670
+ // 第一次跑完把开出来的会话 id 记到 dm-session.json;之后每次都带上;会话失效就丢掉重开(不会卡死)✓
1671
+ const dmSessionFile = join(stateDir, 'dm-session.json')
1672
+ const readDmSession = () => {
1673
+ try {
1674
+ const j = JSON.parse(readFileSync(dmSessionFile, 'utf8'))
1675
+ return j && typeof j.id === 'string' && j.id ? j.id : ''
1676
+ } catch { return '' }
1677
+ }
1678
+ const writeDmSession = (id) => { try { writeFileSync(dmSessionFile, JSON.stringify({ id, at: Date.now() }, null, 2), 'utf8') } catch { /* 写不了下次再认 */ } }
1679
+ // 跑完之后,从 $DSH_HOME/sessions/*/<id>/ 里找"这次新出现的那个"(mtime 晚于启动时刻)
1680
+ const discoverDmSession = (sinceTs) => {
1681
+ try {
1682
+ const root = join(process.env.DSH_HOME || join(process.env.USERPROFILE || '', '.dsh'), 'sessions')
1683
+ if (!existsSync(root)) return ''
1684
+ // ⚠️ 只认"这一轮工作目录 = 插件家"那个 workspace(否则会误认到自己聊天那条会话 ✗)
1685
+ // 命名规则:--<盘符去掉冒号>-<路径用横线拼>--(如 --C-Users-99496-.dsh-bili-duty--)
1686
+ const wantWs = '--' + stateDir.replace(/:/g, '').replace(/[\\/]/g, '-') + '--'
1687
+ let best = ''
1688
+ let bestAt = 0
1689
+ const scanWs = [wantWs].filter((w) => existsSync(join(root, w)))
1690
+ const list = scanWs.length ? scanWs : readdirSync(root)
1691
+ for (const ws of list) {
1692
+ const wsDir = join(root, ws)
1693
+ let dirs = []
1694
+ try { dirs = readdirSync(wsDir) } catch { continue }
1695
+ for (const d of dirs) {
1696
+ const full = join(wsDir, d)
1697
+ try {
1698
+ const st = statSync(full)
1699
+ if (!st.isDirectory()) continue
1700
+ if (st.mtimeMs >= sinceTs && st.mtimeMs > bestAt) { bestAt = st.mtimeMs; best = d }
1701
+ } catch { /* 跳过 */ }
1702
+ }
1703
+ }
1704
+ return best
1705
+ } catch { return '' }
1706
+ }
1707
+
1407
1708
  function dmPromptFor(fromUid, text) {
1408
1709
  const raw = String(text || '').slice(0, 600)
1409
1710
  const bv = (raw.match(/BV[0-9A-Za-z]{10}/) || [])[0] || ''
@@ -1431,10 +1732,14 @@ function reportMeta() {
1431
1732
  const dsh = resolveDsh()
1432
1733
  if (dsh.missing) { log('私信回复没跑:找不到 dsh(' + dsh.hint + ')'); return { ok: false } }
1433
1734
  const overlay = ensureOverlay()
1434
- const args = [...dsh.args, '--profile', cfg.headlessProfile || 'headless']
1435
- if (overlay) args.push('--patch', overlay)
1436
- args.push(dmPromptFor(fromUid, text))
1437
- log(`私信回复开跑:来自 uid=${fromUid}`)
1735
+ const base = [...dsh.args, '--profile', effectiveProfile()]
1736
+ if (overlay) base.push('--patch', overlay)
1737
+ if (dmReplyRunning) { log('私信回复:上一条还在跑 ⇒ 这条等下一跳'); return { ok: false, busy: true } }
1738
+ dmReplyRunning = true
1739
+ const savedSid = readDmSession()
1740
+ const args = savedSid ? [...base, '--session-id', savedSid, dmPromptFor(fromUid, text)] : [...base, dmPromptFor(fromUid, text)]
1741
+ log(`私信回复开跑:来自 uid=${fromUid}` + (savedSid ? '(续会话 ' + savedSid.slice(0, 20) + '…)' : '(开新会话)'))
1742
+ const startedAt = Date.now()
1438
1743
  const child = spawn(dsh.command, args, {
1439
1744
  cwd: stateDir, shell: dsh.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
1440
1745
  env: uvEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
@@ -1443,13 +1748,31 @@ function reportMeta() {
1443
1748
  const pick = (buf) => { try { appendFileSync(logFile, toText(buf), 'utf8') } catch { /* 丢日志也不能抛 */ } }
1444
1749
  child.stdout?.on('data', pick)
1445
1750
  child.stderr?.on('data', pick)
1446
- child.on('exit', (code) => { log(`私信回复结束(code=${code})`); rotateLogIfBig() })
1751
+ child.on('exit', (code) => {
1752
+ dmReplyRunning = false
1753
+ log(`私信回复结束(code=${code})`)
1754
+ rotateLogIfBig()
1755
+ // ① 本来没有会话 id:跑完把它开出来的那个认领下来,下一条就能接着聊
1756
+ if (!savedSid && code === 0) {
1757
+ const id = discoverDmSession(startedAt)
1758
+ if (id) { writeDmSession(id); log('私信会话已记录:' + id.slice(0, 24) + '…(以后私信都接着这个会话)') }
1759
+ else log('⚠️ 没找到这次的会话 id ⇒ 下一条私信还是新会话(不影响回复,只是没上下文)')
1760
+ }
1761
+ // ② 带了 id 却失败(会话被删/迁移过)⇒ 丢掉它,下一条重开,别卡死在坏 id 上
1762
+ if (savedSid && code !== 0) {
1763
+ writeDmSession('')
1764
+ log('⚠️ 续会话失败(' + savedSid.slice(0, 16) + '…)⇒ 清掉 id,立刻用新会话重试一次(这条不能没回)')
1765
+ setTimeout(() => { try { runDmReply(fromUid, text) } catch { /* 兜住 */ } }, 800)
1766
+ }
1767
+ })
1447
1768
  return { ok: true }
1448
1769
  } catch (e) { log('私信回复出错:' + String(e?.message ?? e)); return { ok: false } }
1449
1770
  }
1450
1771
 
1451
1772
  let dmTimer = null
1452
1773
  let dmBusy = false
1774
+ // ⭐ 2.0.5:一条私信回复没跑完时,不要让下一条进来(不然两个进程抢同一个会话,DSH 会报 "already owned by an active write handle")
1775
+ let dmReplyRunning = false
1453
1776
  async function dmTick() {
1454
1777
  if (dmBusy) return
1455
1778
  dmBusy = true
@@ -2087,6 +2410,8 @@ function reportMeta() {
2087
2410
  pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
2088
2411
  lastRunAt: st.lastRunAt || null,
2089
2412
  lastWhy: st.lastWhy || null,
2413
+ // ⭐ 2.0.4:界面上"它会认谁当主人"那行用这个(用小号登录、大号聊天的会看错)
2414
+ ownerInfo: ownerInfo(),
2090
2415
  loggedIn: hasCredential(),
2091
2416
  venvReady: venvReadyNow(),
2092
2417
  version: myVersion(),
@@ -2403,7 +2728,7 @@ function reportMeta() {
2403
2728
  '版本:' + myVersion(),
2404
2729
  '平台:' + process.platform + ' ' + process.arch + ' · node ' + process.versions.node + (process.versions.electron ? ' · electron ' + process.versions.electron : ''),
2405
2730
  'dsh:' + (() => { try { const d = resolveDsh(); return d.missing ? '没找到(' + d.hint + ')' : d.command + ' ' + d.args.join(' ') } catch (e) { return '探测失败:' + e?.message } })(),
2406
- 'headless profile:' + (cfg.headlessProfile || 'headless'),
2731
+ 'headless profile:' + effectiveProfile() + '(最小权限:' + dutyProfileState + ')',
2407
2732
  'pythonPath:' + cfg.pythonPath,
2408
2733
  'venv 就绪:' + (venvReadyNow() ? '是' : '否') + (missingVenvDeps().length ? '(缺:' + missingVenvDeps().join(' / ') + ')' : ''),
2409
2734
  '转写环境:' + (existsSync(join(stateDir, '.venv-asr')) ? '在' : '没装'),
@@ -2516,37 +2841,101 @@ function reportMeta() {
2516
2841
  return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
2517
2842
  }
2518
2843
 
2519
- try {
2520
2844
  const sameOriginOk = (req) => {
2521
2845
  try {
2522
2846
  const host = String(req.headers.host || '')
2523
2847
  if (!host) return true // 没有 Host 头 = 不是浏览器/不是外来的 HTTP 请求
2524
2848
  if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)) return false
2525
2849
  const pick = String(req.headers.origin || req.headers.referer || '')
2526
- if (!pick) return true // 不是浏览器发的(本机脚本/宿主内部)—— 本机进程本来就有权限
2850
+ if (!pick) return true // 不是浏览器发的(本机脚本/宿主内部)
2527
2851
  try {
2528
2852
  const u = new URL(pick)
2529
- return /^(127\.0\.0\.1|localhost|\[::1\])$/i.test(u.hostname)
2853
+ // ⭐ 2.0.5(采纳 PR #94):**端口也要比** —— 只比 hostname 的话,
2854
+ // 本机上另一个端口的页面(dev server / Jupyter / 有 XSS 的本地页)也能驱动这些写接口 ✗
2855
+ const originAuthority = String(u.host || '').toLowerCase()
2856
+ const hostAuthority = String(host).toLowerCase()
2857
+ if (originAuthority && originAuthority === hostAuthority) return true
2858
+ const defaultPort = u.protocol === 'https:' ? '443' : '80'
2859
+ const withPort = (u.hostname + ':' + (u.port || defaultPort)).toLowerCase()
2860
+ return withPort === hostAuthority
2530
2861
  } catch { return false }
2531
2862
  } catch { return false }
2532
2863
  }
2864
+
2865
+ // ⭐⭐ 2.0.5(issue #88):**走 DSH 自己的信任栅栏** —— 隧道把 Host 改写成回环时,
2866
+ // 我们那层"本机同源"会被绕过;connection.requestRejection 才是真正的鉴权(认 cookie/token)✓
2867
+ // 取法照 DSH 内部写法:Reflect.get(ctx, "connection") ⇒ requestRejection(req)
2868
+ // 返回 undefined = 放行;返回数字 = 回这个状态码(通常 401)
2869
+ // ⚠️ 栅栏不可用时不静默放行:仍要求过"本机同源",两道都不成 ⇒ 拒 ✓
2870
+ let fenceWarned = false
2871
+ // ⭐ 取 connection 的三种写法都试(实测:Reflect.get 在插件 ctx 上拿不到 ⇒ 2026-10-23 真机验证后发现)
2872
+ let nodeConnCached = null
2873
+ let nodeConnTried = false
2874
+ const getConnection = () => {
2875
+ if (nodeConnTried) return nodeConnCached
2876
+ nodeConnTried = true
2877
+ const tries = [
2878
+ ['ctx.get("connection")', () => (typeof ctx.get === 'function' ? ctx.get('connection') : null)],
2879
+ ['Reflect.get(ctx, "connection")', () => Reflect.get(ctx, 'connection')],
2880
+ ['ctx.root.connection', () => (ctx.root && typeof ctx.root === 'object' ? ctx.root.connection : null)],
2881
+ ]
2882
+ for (const [how, fn] of tries) {
2883
+ try {
2884
+ const c = fn()
2885
+ if (c && typeof c.requestRejection === 'function') {
2886
+ nodeConnCached = c
2887
+ log('信任栅栏:用 ' + how + ' 拿到了 connection 服务 ✓')
2888
+ return c
2889
+ }
2890
+ } catch { /* 换下一个 */ }
2891
+ }
2892
+ log('⚠️ 三种写法都拿不到 connection.requestRejection ⇒ 退回"本机同源"这一道')
2893
+ return null
2894
+ }
2895
+ const fenceReject = (req, res) => {
2896
+ const conn = getConnection()
2897
+ if (!conn) {
2898
+ if (!fenceWarned) { fenceWarned = true; log("⚠️ 拿不到 DSH 信任栅栏(connection.requestRejection)⇒ 退回本机同源这一道") }
2899
+ return false
2900
+ }
2901
+ try {
2902
+ // ⭐ 2.0.5(采纳 PR #94):契约入参是 `{ headers }`(ConnectionTrustRequest),不是原始 IncomingMessage;
2903
+ // 两种形状都给一遍,谁的实现都能接住(实测传原始 req 也能过,但按契约来更稳)
2904
+ const rejection = conn.requestRejection({ headers: req.headers })
2905
+ if (rejection === undefined) return false
2906
+ res.statusCode = typeof rejection === "number" ? rejection : 403
2907
+ try { res.end() } catch { /* 忽略 */ }
2908
+ return true
2909
+ } catch (err) {
2910
+ log("信任栅栏调用出错,按不通过处理:" + String(err?.message ?? err))
2911
+ res.statusCode = 403
2912
+ try { res.end() } catch { /* 忽略 */ }
2913
+ return true
2914
+ }
2915
+ }
2916
+
2917
+ try {
2533
2918
  ctx.effect(() => ctx.webServer.register({
2534
2919
  kind: 'prefix',
2535
2920
  path: '/api/bili-duty',
2536
- // ⭐ 2026-10-23 安全加固(审计点名"未鉴权的控制接口"):**只接受本机同源请求** ——
2537
- // 别的网页在你浏览器里也能打到 127.0.0.1,所以要比对 Host 与本机、Origin/Referer 与同源 ✓
2538
2921
  handler: async (req, res) => {
2539
- if (!sameOriginOk(req)) {
2540
- try { sendJson(res, 403, { ok: false, error: '只接受本机同源请求' }) } catch { /* 忽略 */ }
2541
- return
2542
- }
2543
- try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
2544
- sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
2922
+ // ① 先过 DSH 的信任栅栏(真鉴权)
2923
+ if (fenceReject(req, res)) return
2924
+ // ② 再过本机同源(栅栏拿不到时的兜底,也挡浏览器里别的网页)
2925
+ if (!sameOriginOk(req)) {
2926
+ try { sendJson(res, 403, { ok: false, error: '只接受本机同源请求' }) } catch { /* 忽略 */ }
2927
+ return
2928
+ }
2929
+ try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (err) {
2930
+ sendJson(res, 500, { ok: false, error: err?.message ?? String(err) })
2545
2931
  }
2546
2932
  },
2547
2933
  }), 'bili-duty: /api/bili-duty 路由')
2548
2934
  } catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
2549
2935
 
2550
2936
  ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
2937
+ try { ensureDutyProfile() } catch { /* 上面已记日志 */ }
2938
+ if (dutyProfileState === 'ok') log('值班轮用最小权限 profile:bili-duty(无文件系统 / 无命令 / 无联网)')
2939
+ else log('⚠️ 最小权限 profile 不可用(' + dutyProfileState + ')⇒ 值班轮仍用 headless(这一轮能力偏大)')
2551
2940
  log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
2552
2941
  }