dsh-sidecard-ask 2.1.3 → 2.1.4
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/CHANGELOG.md +54 -0
- package/client.js +88 -16
- package/index.js +25 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.4
|
|
4
|
+
|
|
5
|
+
**主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
|
|
6
|
+
排查依据:Software Architect / Application Security Engineer / Reality Checker 三方评审 + 源码复核 + 实测复现。
|
|
7
|
+
|
|
8
|
+
### 修复
|
|
9
|
+
|
|
10
|
+
- **B1(阻断,静默数据丢失)关闭的卡片在下次答题完成时被抹掉**。`syncPersisted()` 只从
|
|
11
|
+
「活跃卡片」重建整个持久化集合再整键覆盖,而 `close()` 只把卡片移出活跃区、durable 快照
|
|
12
|
+
仍留在 `store.state.history` 里,于是关掉的那张被静默删除(`contract-test` 原先把破坏后的
|
|
13
|
+
结果当成了期望值)。现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目,按 id 去重(活跃方
|
|
14
|
+
优先)后按 newest-first 截到 cap。删除路径本来就先改 `store.history` 再写盘,并集不会把
|
|
15
|
+
它们删掉的条目复活。
|
|
16
|
+
- **B2(高,虚假安全保证)`agentPreset` 在 0.2.1-alpha.2+ 的 activation 面没被转发**。
|
|
17
|
+
`startSubagent` 只在旧 `start(provider, request)` 面转发 `agentPreset`,新面的 `request`
|
|
18
|
+
漏了它 → 子代理拿默认全套工具,而 start 帧仍按配置上报 `toolFilter: 'preset'`,卡片于是
|
|
19
|
+
对用户谎报「工具集由 preset 决定」。现在两个面都转发。
|
|
20
|
+
- **B3(中高,降级链断裂)Rung 1 `sessions.using()` 没有 try/catch**。它一抛(会话归档、
|
|
21
|
+
形状漂移,或它内部的 `no-session-face`),整条「改发到主对话」就断在第一档,后面
|
|
22
|
+
`retain` / `inputActions` / `draft` 三档全部不可达。现在与 Rung 2 对称降级,
|
|
23
|
+
`prompt-rejected` 仍照旧抛给调用方(那是用户可见的拒绝,不是通道缺失)。
|
|
24
|
+
- **B4(中,原生栏串卡)第二张卡开进原生右栏时第一张没有迁回浮层**。它只是被盖住——卡片
|
|
25
|
+
仍标 `native-rightbar`、store 里被过滤掉,流式输出继续往看不见的卡片里写,且 rail 上那张
|
|
26
|
+
永远关不掉。现在 `open()` 先把栏里其它卡片迁回 `flow`;`close()` 增加归属校验(栏被另一
|
|
27
|
+
张卡占用时不替它关)与 `contentId` 行匹配(多行时关「属于这张卡」的那行);`openTab`
|
|
28
|
+
抛错时回滚 `cardId`,避免 CardHost 去渲染一张根本没有 tab 的卡。
|
|
29
|
+
- **B5(中低)`/cancel` 把跨会话的全局 run id 列表回给页面**。`runs` 是跨会话的全局表,
|
|
30
|
+
回 `active` 等于泄露别的会话正在追问的卡片 id(`cancel` 本身也不校验归属),而 Client
|
|
31
|
+
从不消费它。现在只回 `{ cancelled }`。
|
|
32
|
+
- **B6(中低,迁移被永久挡住)空的 v3 信封会阻断 v1/v2 迁移**。`raw !== null` 就直接
|
|
33
|
+
return,损坏的 JSON 或旧版本留下的空信封同样挡住。现在写盘时带 `writtenAt` 标记,只有
|
|
34
|
+
「本版本自己写的空信封」才算权威(用户确实清空了历史,绝不从 v2/v1 复活),没有该标记的
|
|
35
|
+
空信封与损坏 JSON 继续向下探测。
|
|
36
|
+
- **B7(低,卸载泄漏)`dispose()` 只清了 `streamListeners`,漏了 `chunkListeners`**。第二个
|
|
37
|
+
流来源的订阅者会留下仍被 `session/event` 桥持有的闭包(它捕获整个 engine 状态)。
|
|
38
|
+
- **B9(低,死重量)持久化写死 `slice(-12)`,而 `followUp` 只发 6 轮**。改成
|
|
39
|
+
`HISTORY_TURNS`,与上下文预算同一个上限。
|
|
40
|
+
- **B10(低,死代码)`let finished = false` 只赋值不读取**,删除。
|
|
41
|
+
- **B11(低,可诊断性)信任围栏的 403 只说 `forbidden`**。现在说明「Host 不在信任范围 +
|
|
42
|
+
怎么放行(127.0.0.1 或 `trustedHosts`)」。有意**不**自动信任绑定地址:插件读不到宿主的
|
|
43
|
+
bind 配置,猜一个就等于自己拆掉 DNS-rebinding 防线。
|
|
44
|
+
- **B8(条件性,平台限制)配置里的 SMTP 密码以明文落在 `~/.dsh/` 下的用户层 JSON**。
|
|
45
|
+
写盘已带 `mode: 0o600`(POSIX 生效,Windows 忽略),代码注释与 README 均已注明;
|
|
46
|
+
无进程内可做的进一步缓解,**保留为已文档化的限制**,建议优先用环境变量。
|
|
47
|
+
|
|
48
|
+
### 验证
|
|
49
|
+
|
|
50
|
+
`node test/verify.mjs` / `contract-test.mjs` / `float-placement.mjs` / `smoke-test.mjs`:
|
|
51
|
+
**57 / 1446 / 100 / 277 全绿(1880 条,0 失败)**。相对修复前 1859 条新增 21 条回归断言,
|
|
52
|
+
逐条对应上面的缺陷:关卡片后历史仍在(contract)、activation 面转发 preset 且无能力时
|
|
53
|
+
不带该字段(smoke)、`using()` 抛错降级而 `prompt-rejected` 仍抛(contract)、空/损坏 v3
|
|
54
|
+
信封不挡迁移且自有空信封不被复活(contract)、原生栏单地址与关 tab 归属(contract)、
|
|
55
|
+
`/cancel` 不泄露 `active`(smoke)。
|
|
56
|
+
|
|
3
57
|
## 2.1.3
|
|
4
58
|
|
|
5
59
|
**主题:折叠态胶囊不再压在聊天输入框上(2026-10-25 实拍报告)。**
|
package/client.js
CHANGED
|
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
|
|
|
117
117
|
* the Host and the module's `api.version` both read it, so a report can
|
|
118
118
|
* never claim a generation the browser is not actually running.
|
|
119
119
|
*/
|
|
120
|
-
const CLIENT_VERSION = '2.1.
|
|
120
|
+
const CLIENT_VERSION = '2.1.4'
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -2178,16 +2178,26 @@ window.__ModuleLoader__.load({
|
|
|
2178
2178
|
}
|
|
2179
2179
|
|
|
2180
2180
|
// Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
|
|
2181
|
+
// 与 Rung 2 必须对称地包 try/catch:`using()` 本身(或它内部的
|
|
2182
|
+
// `no-session-face` 抛出)一失败就该降级到 retain → inputActions →
|
|
2183
|
+
// draft,而不是让整个"改发到主对话"断在这里 —— 后两档存在的理由
|
|
2184
|
+
// 正是这种情况。`prompt-rejected` 仍然照旧抛给调用方(那是用户可见的
|
|
2185
|
+
// 拒绝,不是通道缺失)。
|
|
2181
2186
|
if (sessionId !== null && typeof sessions?.using === 'function') {
|
|
2182
|
-
|
|
2183
|
-
const
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2187
|
+
try {
|
|
2188
|
+
const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
|
|
2189
|
+
const face = reference?.binding?.session
|
|
2190
|
+
if (face === undefined || typeof face.prompt !== 'function') {
|
|
2191
|
+
throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
|
|
2192
|
+
}
|
|
2193
|
+
return face.prompt(parts, 'queue')
|
|
2194
|
+
})
|
|
2195
|
+
accepted(result)
|
|
2196
|
+
return { mode: 'session', via: 'sessions.using' }
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (error?.code === 'prompt-rejected') throw error
|
|
2199
|
+
console.warn(`[${PLUGIN_ID}] sessions.using 提交失败,尝试下一级:`, error)
|
|
2200
|
+
}
|
|
2191
2201
|
}
|
|
2192
2202
|
|
|
2193
2203
|
// Rung 2 — `retain()` + explicit release: the same operation spelled out,
|
|
@@ -2307,8 +2317,22 @@ window.__ModuleLoader__.load({
|
|
|
2307
2317
|
cardId: null,
|
|
2308
2318
|
open(card) {
|
|
2309
2319
|
if (!this.available() || typeof this.controller.openTab !== 'function') return false
|
|
2320
|
+
// 同一个 kind 在原生栏里只有一个地址(见 cardId 的注释):先把栏里
|
|
2321
|
+
// 其它卡片迁回浮层,否则它们既不在栏里也不在浮层上,流式输出还会
|
|
2322
|
+
// 继续往一张看不见的卡片里写。
|
|
2323
|
+
for (const other of store.state.cards) {
|
|
2324
|
+
if (other.id !== card.id && other.surface === 'native-rightbar') {
|
|
2325
|
+
patchCard(other.id, { surface: 'flow' })
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2310
2328
|
this.cardId = card.id
|
|
2311
|
-
|
|
2329
|
+
try {
|
|
2330
|
+
this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
|
|
2331
|
+
} catch (error) {
|
|
2332
|
+
// 开栏失败就把地址回滚,免得 CardHost 去渲染一张根本没有 tab 的卡。
|
|
2333
|
+
this.cardId = null
|
|
2334
|
+
throw error
|
|
2335
|
+
}
|
|
2312
2336
|
return true
|
|
2313
2337
|
},
|
|
2314
2338
|
close(card) {
|
|
@@ -2319,6 +2343,10 @@ window.__ModuleLoader__.load({
|
|
|
2319
2343
|
// and lets CardHost show its closed-card face.
|
|
2320
2344
|
const controller = this.controller
|
|
2321
2345
|
if (controller === null || controller === undefined) return false
|
|
2346
|
+
// 归属校验:栏里那条同 kind 的 tab 若正被另一张卡片占用,就不能替它
|
|
2347
|
+
// 关掉(否则关 A 会把 B 的 tab 一起关掉)。cardId === null 是"清理
|
|
2348
|
+
// 残留 tab"的路径,此时按 kind 关是对的。
|
|
2349
|
+
if (this.cardId !== null && this.cardId !== card.id) return false
|
|
2322
2350
|
let rows = []
|
|
2323
2351
|
try {
|
|
2324
2352
|
rows = typeof controller.openTabs?.getSnapshot === 'function'
|
|
@@ -2328,6 +2356,12 @@ window.__ModuleLoader__.load({
|
|
|
2328
2356
|
const row = Array.isArray(rows)
|
|
2329
2357
|
? rows.find(entry => entry?.kind === CARD_KIND
|
|
2330
2358
|
&& typeof entry?.tabId === 'string' && entry.tabId !== ''
|
|
2359
|
+
// 单地址模型下这里只该有一行;万一宿主保留了多行,必须关"属于这张
|
|
2360
|
+
// 卡片"的那一行(openTab 把 cardId 写进 params → contentId),
|
|
2361
|
+
// 否则会替另一张卡片关掉它的 tab。
|
|
2362
|
+
&& (typeof entry?.contentId !== 'string'
|
|
2363
|
+
|| entry.contentId === ''
|
|
2364
|
+
|| entry.contentId === card.id)
|
|
2331
2365
|
&& (typeof entry?.sessionId !== 'string'
|
|
2332
2366
|
|| store.state.sessionId === null
|
|
2333
2367
|
|| entry.sessionId === store.state.sessionId))
|
|
@@ -5841,13 +5875,32 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5841
5875
|
} catch { return [] }
|
|
5842
5876
|
}
|
|
5843
5877
|
|
|
5878
|
+
/** 是否是「本版本自己写下的空信封」(带 writtenAt 标记)。 */
|
|
5879
|
+
function isOwnedEmptyEnvelope(raw) {
|
|
5880
|
+
try {
|
|
5881
|
+
const value = JSON.parse(raw)
|
|
5882
|
+
return value !== null
|
|
5883
|
+
&& typeof value === 'object'
|
|
5884
|
+
&& value.version === 3
|
|
5885
|
+
&& Array.isArray(value.cards)
|
|
5886
|
+
&& Number.isFinite(value.writtenAt)
|
|
5887
|
+
} catch {
|
|
5888
|
+
return false
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
|
|
5844
5892
|
function readPersisted() {
|
|
5845
5893
|
let raw = null
|
|
5846
5894
|
try {
|
|
5847
5895
|
raw = window.localStorage?.getItem(PERSIST_KEY)
|
|
5848
5896
|
} catch { return [] }
|
|
5849
5897
|
const v3 = parseEnvelope(raw, 3).slice(0, 50)
|
|
5850
|
-
if (v3.length > 0
|
|
5898
|
+
if (v3.length > 0) return v3
|
|
5899
|
+
// 空的 v3 信封只有在「本版本自己写的」时候才算权威:它带 writtenAt,
|
|
5900
|
+
// 说明用户确实把历史清空了,此时绝不能回落到 v2/v1 把旧记录复活。
|
|
5901
|
+
// 而没有 writtenAt 的空信封(旧版本写的 / 迁移中途留下的)或损坏的
|
|
5902
|
+
// JSON 必须继续向下探测 —— 否则 v1、v2 的数据会被永久挡住。
|
|
5903
|
+
if (raw !== null && raw !== undefined && isOwnedEmptyEnvelope(raw)) return v3
|
|
5851
5904
|
// v2 → v3: v2 entries have no `versions`, so the field is seeded empty.
|
|
5852
5905
|
let v2raw = null
|
|
5853
5906
|
try {
|
|
@@ -5881,7 +5934,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5881
5934
|
let lastError = null
|
|
5882
5935
|
for (;;) {
|
|
5883
5936
|
try {
|
|
5884
|
-
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
|
|
5937
|
+
window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining, writtenAt: Date.now() }))
|
|
5885
5938
|
return remaining
|
|
5886
5939
|
} catch (error) {
|
|
5887
5940
|
lastError = error
|
|
@@ -5907,7 +5960,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5907
5960
|
.filter(card => card.carrier === 'side'
|
|
5908
5961
|
&& (card.status === 'done' || card.status === 'stopped')
|
|
5909
5962
|
&& card.text !== '')
|
|
5910
|
-
.slice(0, cap)
|
|
5911
5963
|
.map(card => ({
|
|
5912
5964
|
id: card.id,
|
|
5913
5965
|
question: card.question,
|
|
@@ -5923,7 +5975,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5923
5975
|
createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
|
|
5924
5976
|
provider: typeof card.provider === 'string' ? card.provider : '',
|
|
5925
5977
|
elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
|
|
5926
|
-
|
|
5978
|
+
// 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
|
|
5979
|
+
// 这里曾经写死 `slice(-12)`,而 followUp 只发 6 轮,多出来的
|
|
5980
|
+
// 6 轮永远不会被读,是纯死重量。
|
|
5981
|
+
history: Array.isArray(card.history) ? card.history.slice(-HISTORY_TURNS) : [],
|
|
5927
5982
|
// P2-2: kept so a replayed rewrite card still offers "insert".
|
|
5928
5983
|
actionId: typeof card.actionId === 'string' ? card.actionId.slice(0, 60) : null,
|
|
5929
5984
|
// P2-5b: bounded, text-capped older answers. `question` is short
|
|
@@ -5947,7 +6002,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5947
6002
|
store.set({ history: [] })
|
|
5948
6003
|
return
|
|
5949
6004
|
}
|
|
5950
|
-
|
|
6005
|
+
// #6(2026-10-26 修复):关闭一张卡片只把它从「活跃区」移除,
|
|
6006
|
+
// durable 快照仍然留在 store.history 里。这里过去只从活跃卡片
|
|
6007
|
+
// 重建整个集合再整键覆盖,于是「关掉的那张」会在下一次答题完成
|
|
6008
|
+
// 时被静默抹掉(contract-test 把破坏后的结果当成了期望)。
|
|
6009
|
+
// 现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目(按 id 去重,
|
|
6010
|
+
// 活跃方优先),再按 newest-first 截到 cap。
|
|
6011
|
+
// 删除路径(deleteCard / deleteHistoryCard / 改投主对话 /
|
|
6012
|
+
// clearHistory)都是先改 store.history 再写盘,所以并集不会把它们
|
|
6013
|
+
// 删掉的条目复活。
|
|
6014
|
+
const liveIds = new Set(finished.map(entry => entry.id))
|
|
6015
|
+
const kept = store.state.history.filter(entry => entry !== null
|
|
6016
|
+
&& typeof entry === 'object'
|
|
6017
|
+
&& typeof entry.id === 'string'
|
|
6018
|
+
&& !liveIds.has(entry.id))
|
|
6019
|
+
const merged = [...finished, ...kept]
|
|
6020
|
+
.sort((left, right) => (Number(right.createdAt) || 0) - (Number(left.createdAt) || 0))
|
|
6021
|
+
.slice(0, cap)
|
|
6022
|
+
const persisted = writePersisted(merged)
|
|
5951
6023
|
store.set({ history: persisted })
|
|
5952
6024
|
}
|
|
5953
6025
|
|
package/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
|
|
|
45
45
|
export const inject = ['webServer']
|
|
46
46
|
|
|
47
47
|
/** Version of this plugin (kept in step with package.json by test/verify.mjs). */
|
|
48
|
-
export const PLUGIN_VERSION = '2.1.
|
|
48
|
+
export const PLUGIN_VERSION = '2.1.4'
|
|
49
49
|
|
|
50
50
|
/** Route prefix of the plugin's own API. */
|
|
51
51
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -1541,7 +1541,7 @@ function openSse(res) {
|
|
|
1541
1541
|
* Build the engine that owns every side-card run.
|
|
1542
1542
|
* @param {import('@deepseek-ai/cordis').Context} ctx - host plugin context.
|
|
1543
1543
|
* @param {() => Record<string, unknown>} configOf - live config reader.
|
|
1544
|
-
* @returns {{ask: Function, cancel: Function,
|
|
1544
|
+
* @returns {{ask: Function, cancel: Function, dispose: Function, capabilities: Function}}
|
|
1545
1545
|
*/
|
|
1546
1546
|
function createSideEngine(ctx, configOf) {
|
|
1547
1547
|
/** id → run record. */
|
|
@@ -1630,6 +1630,11 @@ function createSideEngine(ctx, configOf) {
|
|
|
1630
1630
|
parent: fields.parent,
|
|
1631
1631
|
...(fields.persona === undefined ? {} : { persona: fields.persona }),
|
|
1632
1632
|
...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
|
|
1633
|
+
// 0.2.1-alpha.2+ 的 `request` 与旧 `start(provider, request)` 是同一个
|
|
1634
|
+
// SubagentStartRequest 形状,`agentPreset` 必须一起转发。漏掉这一项
|
|
1635
|
+
// 会让子代理拿到默认全套工具,而 start 帧仍按配置上报
|
|
1636
|
+
// `toolFilter: 'preset'`,卡片于是对用户谎报"工具集由 preset 决定"。
|
|
1637
|
+
...(fields.agentPreset === undefined ? {} : { agentPreset: fields.agentPreset }),
|
|
1633
1638
|
},
|
|
1634
1639
|
})
|
|
1635
1640
|
return {
|
|
@@ -1893,7 +1898,6 @@ function createSideEngine(ctx, configOf) {
|
|
|
1893
1898
|
/** Which source produced deltas: process-local frames or durable chunks. */
|
|
1894
1899
|
let framesSeen = false
|
|
1895
1900
|
let chunksSeen = false
|
|
1896
|
-
let finished = false
|
|
1897
1901
|
|
|
1898
1902
|
/** Detach this run's stream listener (frames are per attempt). */
|
|
1899
1903
|
let detach = () => {}
|
|
@@ -2078,7 +2082,6 @@ function createSideEngine(ctx, configOf) {
|
|
|
2078
2082
|
})
|
|
2079
2083
|
return
|
|
2080
2084
|
}
|
|
2081
|
-
finished = true
|
|
2082
2085
|
sink.send('done', {
|
|
2083
2086
|
id,
|
|
2084
2087
|
text,
|
|
@@ -2140,9 +2143,12 @@ function createSideEngine(ctx, configOf) {
|
|
|
2140
2143
|
}
|
|
2141
2144
|
runs.clear()
|
|
2142
2145
|
streamListeners.clear()
|
|
2146
|
+
// 第二个流来源的订阅者也要清:只清 streamListeners 会在插件卸载后
|
|
2147
|
+
// 留下仍被 `session/event` 桥持有的闭包(它捕获了整个 engine 状态)。
|
|
2148
|
+
chunkListeners.clear()
|
|
2143
2149
|
}
|
|
2144
2150
|
|
|
2145
|
-
return { ask, cancel, dispose, capabilities
|
|
2151
|
+
return { ask, cancel, dispose, capabilities }
|
|
2146
2152
|
}
|
|
2147
2153
|
|
|
2148
2154
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -2594,7 +2600,10 @@ export function apply(ctx, patchConfig) {
|
|
|
2594
2600
|
writeError(res, 400, 'bad-request', '缺少 id')
|
|
2595
2601
|
return
|
|
2596
2602
|
}
|
|
2597
|
-
|
|
2603
|
+
// 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
|
|
2604
|
+
// 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
|
|
2605
|
+
// 校验归属),Client 侧也没有任何地方消费这个字段。
|
|
2606
|
+
writeOk(res, { cancelled: engine.cancel(id) })
|
|
2598
2607
|
}
|
|
2599
2608
|
|
|
2600
2609
|
/** `/config` — merge a partial config into the user layer and persist it. */
|
|
@@ -2707,7 +2716,16 @@ export function apply(ctx, patchConfig) {
|
|
|
2707
2716
|
/** One request → one response; the dispatcher owns path parsing and the fence. */
|
|
2708
2717
|
const routeHandler = async (req, res) => {
|
|
2709
2718
|
if (!isTrustedRequest(req, trustedHostsOf())) {
|
|
2710
|
-
|
|
2719
|
+
// 说明"为什么被拒 + 怎么放行":这条 403 只会出现在 DSH 被绑到非回环
|
|
2720
|
+
// 地址、或经反向代理访问时(Host 既不是 loopback,也不在 trustedHosts
|
|
2721
|
+
// 里)。有意不自动信任绑定地址:插件读不到宿主的 bind 配置,猜一个就等于
|
|
2722
|
+
// 自己把 DNS-rebinding 防线拆掉。
|
|
2723
|
+
writeError(
|
|
2724
|
+
res,
|
|
2725
|
+
403,
|
|
2726
|
+
'forbidden',
|
|
2727
|
+
'Host 不在信任范围:请用 http://127.0.0.1:<端口> 访问,或把该地址写进 trustedHosts 配置',
|
|
2728
|
+
)
|
|
2711
2729
|
return
|
|
2712
2730
|
}
|
|
2713
2731
|
// Path parsing runs BEFORE the session gate now, because `ping` must be
|