dsh-sidecard-ask 2.0.1 → 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.
Files changed (5) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +62 -16
  3. package/client.js +505 -103
  4. package/index.js +457 -68
  5. package/package.json +69 -66
package/CHANGELOG.md CHANGED
@@ -1,5 +1,172 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0
4
+
5
+ **主题:跨端核查后的性能与可观测性收口——流式热路径从「每次 delta 全树重渲染」改成「只重渲染变化的卡」,
6
+ 新增加载预算与活动卡上限,补上一个不免鉴权的探活口,并修掉部署前缀缺少尾斜杠时的基址丢失。**
7
+
8
+ 排查依据见 `CROSS-END-AUDIT-2.0.2.md`(网页端 dsh 0.2.1-rc.2 × 桌面端 dsh-desktop 0.2.0-rc.2 /
9
+ Electron 43.3.0)。本版不含破坏性变更,配置项与接口签名不变。
10
+
11
+ ### 性能
12
+
13
+ - **流式热路径只重渲染变化的卡片**。`useStoreState()` 订阅整个 store,而 `FloatingLayer` 每次渲染都给
14
+ `AnswerCard` 新建 16 个箭头函数,于是任何一次 `store.set` 都会让所有卡片的 props 全部不等——
15
+ `React.memo` 无从下手。现在:
16
+ - `AnswerCard` 经 `React.memo` 包装(对没有 `memo` 的 React 自动退化为原组件);
17
+ - 新增 `useCardHandlers(actions)`:每个卡片 id 只建一次回调包,回调通过 ref 读当时的卡片
18
+ (否则"复制"会复制到两个 delta 之前的文本),因此未变化的卡片 props 引用稳定、整棵子树跳过。
19
+ - **一次 flush 只提交一次状态**。`flushStreamBuffers` 原来对正文和思考各调用一次
20
+ `appendCardText`/`appendCardReasoning`,每次都是一次 `store.set`;3 路并发时理论上 120 次全树渲染/秒。
21
+ 现在两个缓冲合并成一个 `cards` 补丁、一次提交(两个旧 helper 随之删除)。
22
+ - **流式节奏按文本长度自适应**:答案超过 32 K 字符时刷新间隔 50 ms → 100 ms
23
+ (实测 64 K 答案的 `renderRichText` 为 1.2–2.0 ms/帧,翻倍间隔即减半工作量,肉眼无差)。
24
+ - **右栏观察器不再被 class/style 吵醒**。面板测量的 `MutationObserver` 原来把 `class`、`style`
25
+ 放进 `attributeFilter`,聊天区的 hover/滚动/徽标切换每次都会触发一次 rAF 内的
26
+ `getBoundingClientRect`(强制布局);现在只监听 `data-sidebar-right-open` 与
27
+ `data-dockkit-pane-active` 两个真正表示"右栏几何变了"的属性。
28
+ - **活动卡片上限 20**。此前 `store.state.cards` 无上限,长会话会把每条答案的 React 子树一直挂着。
29
+ 超过上限时最旧的**已完成**卡片隐藏为 `open:false`(数据保留、正文卸载、历史可恢复),
30
+ 正在流式的卡片永不隐藏。
31
+ - **冷启动回放预算 200 K 字符**。回放原来把所有历史卡片以 `open:true` 恢复,仅受卡数(默认 10)
32
+ 与单字段 64 K 限制,最坏要挂 64 万字符的 markdown;现在最新一张必定展开,其余按累计字符预算展开,
33
+ 超预算的以折叠态回来。
34
+
35
+ ### 修复
36
+
37
+ - **部署前缀不带尾斜杠时基址丢失**。`new URL('.', 'https://host/dsh')` 会把 `dsh` 当文件名,
38
+ 于是 `API` 退化成 `/sidecard-ask/api`,前缀部署下全部 404。现在首次 404 会回退到根绝对路径重试一次
39
+ (401/403 都**不**改基址:那是鉴权/围栏的答案,不是"路径不对")。正常部署(文档根或带尾斜杠)
40
+ 取值与旧值完全相同,不产生额外请求。
41
+
42
+ ### 可观测性
43
+
44
+ - **新增 `GET /sidecard-ask/api/ping`**:唯一免宿主会话的接口,只回 `{version, at}`。
45
+ 2.0.2 的会话门把脚本/CI 的探活路径全挡住了(连 README 里的 `Invoke-WebRequest` 都变成 401),
46
+ 这条补回"插件是否加载"的判别能力,同时不重新打开"无 cookie 就能 spawn 子代理"的口子。
47
+ 它仍受 loopback/Origin 围栏保护,且**不咨询** `connection` 服务——会话服务坏掉时探活照常工作。
48
+ - 路由分发顺序随之调整:路径解析提前到会话门之前(`ping` 在此之前应答),未知路径仍然对所有动词
49
+ 回同一个 404。
50
+
51
+ ### 文档
52
+
53
+ - README §10.1 的自测配方改为"先 `/ping` 判存活,其余接口需会话"(旧配方在 2.0.2 之后恒 401)。
54
+ - 新增 README §5.4:2026-10-22 桌面端复核(宿主版本落差、Chromium 150、scheme 特权、请求转发、
55
+ 权限、AUMID、加速键、产物缓存)。
56
+ - README §14 补 12–15 四条限制(`/ping` 的边界、活动卡上限、回放预算、前缀重试)。
57
+ - 更正 2.0.2 的一处结论:「桌面端在本机冷启动即崩」**2026-10-22 未能复现**(冷启动成功、宿主在 19387
58
+ 正常服务),改为"与安装包/启动方式相关,需按版本复验"。
59
+
60
+ ### 测试
61
+
62
+ - `verify` 57、`contract` **1408**(原 1377)、`float-placement` **83**(原 82)、`smoke` **271**(原 262),
63
+ 合计 **1819 通过,0 失败**。
64
+ - 新增覆盖:`apiBaseAfter` 的四种状态码(404 改基址、401/403 不改、根绝对不循环)、
65
+ `capLiveCards` 的隐藏规则(只藏最旧、跳过流式卡片、已隐藏不占配额、不改入参)、
66
+ `replayCardOpen` 的预算边界、`React.memo` 与稳定回调包的接线、一次 flush 只有一次 `store.set`、
67
+ `attributeFilter` 不再含 `class`、`/ping` 免鉴权与 405、`/ping` 不咨询 `connection`、
68
+ 其余接口仍 fail-closed、未知路径仍 404。
69
+
70
+ ## 2.0.2
71
+
72
+ **主题:跨端(网页端 / 桌面端)排查后的六个修复 —— 插件 API 补上宿主会话鉴权、诊断上报不再互相覆盖、prompt 预算真正守住 60k、SMTP 密码状态自洽、未知接口状态码对称、用户层配置按 profile 分文件。**
73
+
74
+ 排查方式:网页端在真实宿主上跑运行态探针(curl 打全部 7 个接口 + 真跑一次 SSE 作答);桌面端读
75
+ `DeepSeek Harness.exe` 里 `app.asar` 的 `lib/main.js`(网络转发、权限、下载拦截、窗口参数)做静态取证。
76
+
77
+ ### 安全
78
+
79
+ - **插件 API 之前绕过了宿主的 401 登录门禁**(D1)。`ctx.webServer.register` 注册的 prefix 路由不经过
80
+ `dsh-client-connection` 的 `authorizeIndex`,插件自己又只做「回环 / 可信域 + `Origin` + `Sec-Fetch-Site`」围栏——
81
+ 实测**不带任何 cookie 的 `POST /sidecard-ask/api/ask` 真的会 spawn 一个子代理**,`POST /config` 会写配置。
82
+ 现在在围栏之后追加一次 `ctx.get('connection').requestRejection(req)`:
83
+ - 返回 `401` → 一律 `{code:"unauthorized"}`「需要先通过 DSH 登录(宿主会话未认证)」;
84
+ - 返回 `403` → `{code:"forbidden"}`「请求来源不受信任」;
85
+ - 宿主**没有**挂载 `connection` 服务(旧组合 / 单测)→ 只记一条 info 日志,退回原来的单层围栏;
86
+ - 校验本身抛错 → fail-closed 按 401 拒绝。
87
+ 桌面端不受影响:`forwardWebRequest` 本来就会注入宿主启动时换来的鉴权 cookie(`lib/main.js:8268-8299`)。
88
+
89
+ ### 修复
90
+
91
+ - **`/diagnose` 上报是 last-write-wins**(D2)。`clientReport = report` 无条件覆盖,于是「哪个页面最后上报,
92
+ `/state` 里的客户端诊断就只剩它看到的那点东西」——实测发一个 `{}` 就能把整段诊断清空。改为
93
+ `mergeClientReport(previous, next)`:本次没观测到的字段保留旧值(`slots` / `native` / `better` 浅合并),
94
+ `at` 取新值;完全空的上报直接 `400 bad-request`,不再当成一次有效观测。
95
+ - **prompt 预算算不准**(D3)。`truncateSelection` 把省略标记加在预算之外(给 100 字符的预算返回 119 字符),
96
+ `fixedLength` 又比实际 join 少算一个分隔符,实测总量落到 60018–60023(`MAX_PROMPT_CHARS = 60000`),
97
+ 极端组合(超长 question + 6 轮历史)能把 selection 压到 1 个字符。现在:
98
+ - 省略标记**计入**预算,任何 `maxChars` 下结果都 ≤ cap(`test/contract-test.mjs` 用 9 档 cap × 900 点的
99
+ emoji 样本逐档断言);
100
+ - 固定块先算长度、selection 吃剩余预算,问题行长按 `MAX_QUESTION_CHARS = 20000` 先截断,
101
+ selection 保留 `SELECTION_FLOOR_CHARS = 2000` 下限,预算不够时按轮丢弃更旧的历史而不是把 selection 压没;
102
+ - 最坏组合(19000 字问题 + 6 轮历史 + 20000 字 selection)总量验证 ≤ 60000;
103
+ - `start` 帧新增 `promptTruncated` / `promptDroppedChars` / `promptHistoryDropped`:prompt 侧的截断
104
+ 以前对客户端完全不可见,用户只会看到"答案变短了"。
105
+ - **SMTP 状态自相矛盾**(D4)。`configured` 走 `resolveSmtp()`(环境变量优先),`hasPassword` 却只看设置页里那一格,
106
+ 于是「凭据来自环境变量」时报出 `configured: true` + `hasPassword: false`。现在 `hasPassword` 表示
107
+ **手上真的有一份能用的密码**(任一层),并新增 `source`(`config` / `env`)与 `storedPassword`
108
+ (设置页里是否存了一份)区分两层来源。
109
+ - **未知接口的状态码不对称**(D5)。`GET /nope` 回 405(`method-error`)、`POST /nope` 回 404(`not-found`)——
110
+ 方法名判定排在动词判定之后。现在先判方法名:任何动词打未知接口都是 `404 not-found`,已知接口用错动词才是 405。
111
+ - **网页端与桌面端共用同一份 `config.json`**(D6)。两边是同一台机器上的两个 profile,共用一个 `DSH_HOME`,
112
+ 于是 web 端存的 `sideTools: inherit` 会直接作用于桌面端的运行。用户层文件改为
113
+ `<DSH_HOME>/sidecard-ask/config.<DSH_PROFILE>.json`(profile 名只接受 `[A-Za-z0-9._-]`,否则退回旧名,
114
+ 防止路径穿越);`DSH_PROFILE` 未设置时仍是 `config.json`,**旧文件不迁移、不删除**:新 profile 第一次启动
115
+ 会继承它一次(`/state` 的 `provenance.persistedInheritedFrom` 会说明),下一次保存在自己的文件里落盘。
116
+ - **客户端 `getState()` 丢掉了宿主的错误码**(D1 的配套)。`ok !== true` 时统一抛 `bad-response`,于是 401 会被
117
+ 渲染成"宿主接口不可达"。现在透传 `body.error.code`,并给 `unauthorized` / `forbidden` 各加一条中英文案。
118
+
119
+ ### 复审追加(同版本内)
120
+
121
+ - **一个 `%` 就能打断设置页**。`parseSmtpUrl` 用 `decodeURIComponent` 解账号/密码,密码里带未转义的 `%`
122
+ (`pass%word`)会抛 `URIError`;异常经 `describeFeedback → resolveSmtp → readSmtpFromEnv` 一路逃到 `/state`,
123
+ 而 `/state` 分支当时在 `routeHandler` 的 `try` 之外,于是整页变成「宿主接口不可达」。现在解析失败返回
124
+ `{ok:false, reason}`,且 `/state` 与其余接口共用同一个 try(失败回可读的 500,而不是未处理的拒绝)。
125
+ - **`history` 里的坏条目会把提问打成 TypeError**。`composeSidePrompt` 直接读 `turn.question`,`history:[null]`
126
+ 在 `ask()` 的 try 之前抛出,前端只看到英文 `Cannot read properties of null`。现在非对象条目被丢弃;
127
+ 顺带修掉 `historyTurns: 0` 被 `slice(-0)` 解释成「全部历史」的边界。
128
+ - **历史持久化在配额满时是 O(n²)**。按字段上限单卡最坏 ~0.9 MB、默认 10 卡 ~8.8 MB,超过常见 5 MB 配额后
129
+ 旧实现逐张丢弃并整份重新 `JSON.stringify`。现在先按 2M UTF-16 单元的总预算裁剪,失败时折半丢弃。
130
+ - **答案流式期间重复解析 reasoning**。`renderRichText(card.reasoning)` 未 memo,答案每 50 ms 刷新都重解析
131
+ 整段思考过程;现在与正文一样按文本 memo。
132
+ - **旧版插件探测在启动 6 秒内全文档扫描**。`MutationObserver` 每次 DOM 变更都跑
133
+ `querySelectorAll('.dsa-layer:not([data-dsa-owner])')`,现在合并成 250 ms 一次的尾随扫描,卸载时清定时器。
134
+ - **已存的 SMTP 密码无法清除**(空串=保留)。新增 `feedbackSmtpPassClear: true` 显式清除。
135
+ - `inlineNodes` 的 token 正则从每次调用新建改为模块级常量(用前复位 `lastIndex`)。
136
+ - `openSse.end()` 在流已关闭时提前返回、跳过 `clearInterval`,现在关闭动作统一走 `shutdown()`。
137
+ - README 自测规模更正为实测值,并补上本轮 9 条回归断言(坏 `%` 两处、坏 `history` 条目、`historyTurns: 0`、
138
+ 配额下的裁剪/落盘/删除保持)——现为 verify 57 + contract 1377 + float-placement 82 + smoke 262 = **1778 项**;
139
+ 同时删掉仓库里未声明、无 lock 的 `node_modules`(js-yaml / argparse)残留,`.acl-recovery/` 加入 `.gitignore`。
140
+
141
+ ### 未修:宿主 / 安装包侧(不是本插件能修的)
142
+
143
+ - **桌面端在本机冷启动即崩**:`DeepSeek Harness.exe` 6 秒内退出,唯一确凿栈是
144
+ `Received fatal exception EXCEPTION_BREAKPOINT` → `electron::fuses::IsLoadBrowserProcessSpecificV8SnapshotEnabled`
145
+ → `v8::ScriptCompiler::CreateCodeCacheForFunction`,崩在 V8 snapshot fuse 初始化阶段、早于任何插件加载。
146
+ 安装根还缺 `browser_v8_context_snapshot.bin`。因此桌面端的结论只能来自 `app.asar` 静态取证,没有运行态验证。
147
+ - **桌面壳没有 `app.setAppUserModelId`**(只有 `setAsDefaultProtocolClient("dsh")`,`lib/main.js:13055`):
148
+ Windows 上 toast 通知可能不显示。插件 `notifyOnDone` 走的就是 `new window.Notification`,这一项**待验证**,
149
+ 插件侧无法兜底(`Notification` 是桌面壳的默认会话权限,非麦克风权限一律放行)。
150
+
151
+ ### 排查中推翻的三个旧判断
152
+
153
+ 1. **桌面端下载没有被取消。** `will-download → preventDefault()` 只出现在 `dsh-policy-auth-*` 登录窗
154
+ (`lib/main.js:10113`)和侧边栏 webview 的 `dsh-sidebar-browser-*` 分区(`lib/main.js:10632`,由
155
+ `configureSession` 安装)。主窗口走 `defaultSession`,没有该拦截,`downloadMarkdown()` 在桌面端可用。
156
+ 2. **剪贴板不存在"静默无操作"。** `copyText(text, fallbackElement)` 在 `navigator.clipboard` 缺失或失败时
157
+ 会 `selectNodeContents` 选中卡片正文并返回 `'selected'`,最后才 `'failed'`。
158
+ 3. **`getState()` 没有未处理的 rejection。** 它的两个调用点都收口(`await` + `.catch(() => {})`)。
159
+
160
+ ### 测试
161
+
162
+ - `node test/contract-test.mjs` 1377 通过(2.0.1 是 1334);`smoke-test.mjs` 262(原 226);`verify.mjs` 57;
163
+ `float-placement.mjs` 82。合计 **1778 通过,0 失败**。
164
+ - 新增覆盖:`truncateSelection` 的「结果 ≤ cap」不变式(9 档 cap)、最坏组合的 prompt 预算、`start` 帧的
165
+ prompt 侧截断字段、`/diagnose` 的合并与空上报拒绝、宿主会话门禁四种返回(401 / 403 / undefined / 抛错)、
166
+ 未知接口在 GET/HEAD/POST 下的 404、profile 分文件与一次性继承、`describeSmtpSettings` 的两层来源;
167
+ 复审追加 9 条:坏 `%` 的解析与 `/state` 降级(4)、坏 `history` 条目与 `historyTurns: 0`(2)、
168
+ 配额下的历史裁剪 / 落盘 / 删除保持(3)。
169
+
3
170
  ## 2.0.1
4
171
 
5
172
  **主题:清掉 6 个死 i18n 键。无行为变化,无 API / 配置变化,升级无需任何操作。**
package/README.md CHANGED
@@ -153,7 +153,8 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
153
153
 
154
154
  1. **内置默认值**(`index.js` 的 `DEFAULT_CONFIG`)
155
155
  2. **bundle 补丁层**:`cordis.patch.yml` 的 `config:`(改这里需要重启)
156
- 3. **用户层**:设置页保存后写入 `<DSH_HOME>/sidecard-ask/config.json`
156
+ 3. **用户层**:设置页保存后写入 `<DSH_HOME>/sidecard-ask/config.<profile>.json`,其中 `<profile>` 取自
157
+ `DSH_PROFILE`(web 端与桌面端各一份,互不覆盖);`DSH_PROFILE` 未设置时用旧名 `config.json`
157
158
  (`DSH_HOME` 未设置或为空白时回退到 `~/.dsh`;**不会**写进程当前目录)
158
159
 
159
160
  设置页点「重置为 patch 配置」会清空用户层。
@@ -187,7 +188,8 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
187
188
  | `feedbackSmtpPort` | 数字,1–65535 | `465` | SMTP 端口。465 配隐式 TLS,587 配 STARTTLS(服务器没声明 STARTTLS 时不会硬升级) | 立即 |
188
189
  | `feedbackSmtpSecure` | 布尔 | `true` | 隐式 TLS(465)。关掉则按 STARTTLS 处理 | 立即 |
189
190
  | `feedbackSmtpUser` | 字符串,≤254 | `''` | SMTP 登录账号。部分中继给的是裸账号名而非邮箱,故不强制邮箱格式 | 立即 |
190
- | `feedbackSmtpPass` | 字符串,≤512 | `''` | **密码 / 授权码**(QQ、163 必须用授权码)。**只写不读**:`/state` 会删掉它、页面永远拿到空串;留空保存=保留原值 | 立即 |
191
+ | `feedbackSmtpPass` | 字符串,≤512 | `''` | **密码 / 授权码**(QQ、163 必须用授权码)。**只写不读**:`/state` 会删掉它、页面永远拿到空串;留空保存=保留原值(要删掉已存的密码用下一行的 `feedbackSmtpPassClear`) | 立即 |
192
+ | `feedbackSmtpPassClear` | 布尔 | `false` | **显式清除已存的 SMTP 密码**(典型场景:改回用环境变量)。只在 `/config` 请求里生效,本身不落盘;与 `feedbackSmtpPass` 同时出现时以清除为准 | 立即 |
191
193
  | `feedbackSmtpFrom` | 字符串(邮箱)\| `''` | `''` | 发信账号显式指定的 `From:`,留空等于 `feedbackSmtpUser`。必须是 SMTP 服务商允许的地址 | 立即 |
192
194
 
193
195
  非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
@@ -283,14 +285,14 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
283
285
 
284
286
  **收件邮箱默认可用,也可被环境变量接管。** 打包默认值是维护者邮箱(`b64:` 混淆存储,见上表);
285
287
  环境变量 `DSH_SIDECARD_FEEDBACK_TO` 或 `DSH_CRB_FEEDBACK_TO`(与 dsh-conversation-recycle-bin 共用)
286
- **优先**于它。用户层配置落在 `$DSH_HOME/sidecard-ask/config.json`
287
- (Windows 默认 `C:\Users\<你>\.dsh\sidecard-ask\config.json`)——**不在插件目录里,因此不会被提交、
288
- 不会进 npm 包**。
288
+ **优先**于它。用户层配置落在 `$DSH_HOME/sidecard-ask/config.<profile>.json`
289
+ (`<profile>` = `DSH_PROFILE`;Windows 默认 `C:\Users\<你>\.dsh\sidecard-ask\config.web.json`
290
+ / `config.desktop.json`,两个壳各一份)——**不在插件目录里,因此不会被提交、不会进 npm 包**。
289
291
 
290
292
  **SMTP 凭证有两种给法**,都落在部署方/维护者这一侧(1.9.1 起设置页不再提供填写框):
291
293
 
292
- 1. **用户层配置**(`$DSH_HOME/sidecard-ask/config.json`,Windows 默认
293
- `C:\Users\<你>\.dsh\sidecard-ask\config.json`,或 profile 的 `cordis.patch.yml` 里给该行写 `config`):
294
+ 1. **用户层配置**(`$DSH_HOME/sidecard-ask/config.<profile>.json`,Windows 默认
295
+ `C:\Users\<你>\.dsh\sidecard-ask\config.web.json`,或 profile 的 `cordis.patch.yml` 里给该行写 `config`):
294
296
  `feedbackSmtpHost` / `feedbackSmtpPort` / `feedbackSmtpSecure` / `feedbackSmtpUser` / `feedbackSmtpPass` /
295
297
  `feedbackSmtpFrom`。**密码只写不读**:`/state` 删除该键、页面永远拿到空串,留空保存表示保留原值。
296
298
  QQ 邮箱请填 `smtp.qq.com` / 465 / `secure: true` / 你的 QQ 邮箱 / **授权码**
@@ -484,6 +486,25 @@ dsh-better-sidebar 为此专门带了一条防御(`html[data-platform] body>[d
484
486
  `Alt+Q` 是否会撞上桌面 App 的菜单加速键(Electron 的菜单加速键由菜单先处理,页面的 keydown 可能收不到;
485
487
  若冲突,在设置页改成 `Ctrl+Alt+Q` 之类即可);Electron 下 `navigator.clipboard` 的写入权限(已有"选中文本"降级兜底)。
486
488
 
489
+ ### 5.4 2026-10-22 复核(v2.1.0 / 桌面 0.2.0-rc.2)
490
+
491
+ 上一轮之后桌面端与网页端的形态都变过,本轮把能静态取证的部分重新核了一遍,**完整报告见
492
+ [`CROSS-END-AUDIT-2.0.2.md`](CROSS-END-AUDIT-2.0.2.md)**(含实测数字与未验证清单)。要点:
493
+
494
+ | 维度 | 事实 | 对插件意味着什么 |
495
+ |---|---|---|
496
+ | 宿主版本落差 | 桌面 `dsh-desktop 0.2.0-rc.2`(包内 `dsh` 同版本);网页 **`0.2.1-rc.2`** | 适配矩阵要按两端分别列,不能写成"DSH 0.2.x" |
497
+ | 渲染内核 | Electron **43.3.0 = Chromium 150.0.7871.212**(`~/.dsh/electron/version` 亦为 43.3.0);网页端取决于访问者浏览器(本机对照 Edge/Chromium 155) | `renderRichText` 在 150 与 155 上实测同量级(8 k 0.2–0.3 ms,64 k 1.2–2.0 ms) |
498
+ | 自定义 scheme | `dsh-app://app/` 特权:`standard/secure/supportFetchAPI/corsEnabled/stream/codeCache` | `fetch` + 流式读取可用,不需要 EventSource |
499
+ | 请求转发 | `forwardWebRequest`:只放行 `origin=dsh-app://app`,删 `host/origin/cookie/sec-fetch-site` 后注入宿主 cookie,`duplex:"half"` | 2.0.2 起的会话门在桌面端靠这条链路通过;插件侧 `origin` 缺失时被判为可信(`isTrustedRequest`) |
500
+ | 权限 | 主窗口 `setPermissionRequestHandler`:非 `media` 一律放行;登录窗/侧栏 webview 一律拒绝并拦下载 | 剪贴板与通知**有**权限;导出下载走主窗口,不受拦截 |
501
+ | 通知 | 全包 **无 `setAppUserModelId`** | Windows toast 可能不弹;`notifyOnDone` 默认关,打开后仍以卡片状态为兜底 |
502
+ | 菜单加速键 | 全包 `Alt+Q` **0 命中** | 外壳不抢默认快捷键(输入法/其它程序仍可能抢) |
503
+ | 插件产物缓存 | `/plugins/*` 被壳改写为 `cache-control: no-store` | 每次加载重新拉 349 KB(localhost,传输≈0,代价在解析) |
504
+
505
+ > 另:2.0.2 CHANGELOG 里"桌面端在本机冷启动即崩"的结论,2026-10-22 **未能复现**——本次冷启动成功、
506
+ > 宿主在 19387 正常服务。该条已按"与安装包/启动方式相关,需按版本复验"处理,不再当作既定事实。
507
+
487
508
  ---
488
509
 
489
510
  ## 六、未知 DSH API:占位接口与替换方式
@@ -772,9 +793,9 @@ node test/float-placement.mjs # 浮层定位矩阵
772
793
  node test/smoke-test.mjs # 冒烟:装载、槽位、渲染、错误路径
773
794
  ```
774
795
 
775
- 当前规模(本机实测,四项均 exit 0):verify **57** 项 + contract **1239** 项 + float-placement **78** 项 +
776
- smoke **226** 项 = **1600 项全部通过**(较 1.8.4 的 1359 多 241 项;contract 从 989 涨到 1239,新增大多来自
777
- 反馈功能:
796
+ 当前规模(本机实测,四项均 exit 0):verify **57** 项 + contract **1377** 项 + float-placement **82** 项 +
797
+ smoke **262** 项 = **1778 项全部通过**(较 1.8.4 的 1359 多 419 项;contract 从 989 涨到 1377,新增大多来自
798
+ 反馈功能与 2.0.x 的跨端修复:
778
799
  纯函数边界、`parseSmtpUrl` / `readSmtpFromEnv` 两种写法(含复用 `DSH_CRB_SMTP_URL`)、`b64:` 收件人解码、
779
800
  「环境变量优先于用户层配置」的优先级回归、`Reply-To:` 组头、限速闸门,以及一条**真 socket** 的 `/feedback`
780
801
  端到端——本地 SMTP 假中继逐条断言 `AUTH PLAIN`、信封、`DATA`、编码后的主题与正文、429 与 `retry-after`,
@@ -814,11 +835,21 @@ node tools/compat-probe.mjs 0.1.7-rc.2 # 也可只探某个版本
814
835
 
815
836
  ### 10.1 对已安装实例做真实联调(本机实测通过)
816
837
 
838
+ > **2.1.0 起除 `/ping` 外的每个接口都走宿主会话门。** 不带浏览器登录 cookie 的脚本调用会拿到
839
+ > `401 {"error":{"code":"unauthorized"}}`,这表示"没登录",**不表示插件没起来**——先用第 0 步区分。
840
+ > 页面内自检(设置 → 侧边卡片追问 → 运行自检)读的是同一份 `/state`,不需要任何脚本。
841
+
817
842
  ```powershell
818
- # 1) 宿主接口与能力(archived 父会话检测也在这里)
819
- (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state -UseBasicParsing).Content
843
+ # 0) 探活:唯一免鉴权的接口,只回插件版本与宿主时钟
844
+ (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ping -UseBasicParsing).Content
845
+ # → {"ok":true,"value":{"version":"2.1.0","at":1792...}} 200 = 插件宿主半已加载
846
+
847
+ # 1) 宿主接口与能力(archived 父会话检测也在这里)——需要会话
848
+ # 在本机浏览器已登录的前提下复用会话:先 -WebSession 登录,或把浏览器的 Cookie 头带进来
849
+ (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
850
+ -Headers @{ Cookie = $cookie } -UseBasicParsing).Content
820
851
 
821
- # 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID)
852
+ # 2) 端到端流式作答:把 sessionId 换成当前会话 id($env:DSH_SESSION_ID);同样需要会话
822
853
  # 期望事件序列:start → reasoning*/delta* → status → done(done.text 是真实答案)
823
854
  $body = @{ id='probe'; question='用一句话说明这句话在说什么。';
824
855
  selection='槽位出口会带上 data-slot 标记。'; zone='chat';
@@ -826,11 +857,13 @@ $body = @{ id='probe'; question='用一句话说明这句话在说什么。';
826
857
  $tmp = Join-Path $env:TEMP 'probe.json'
827
858
  [System.IO.File]::WriteAllText($tmp, $body, (New-Object System.Text.UTF8Encoding($false)))
828
859
  (Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/ask -Method POST `
860
+ -Headers @{ Cookie = $cookie } `
829
861
  -ContentType 'application/json; charset=utf-8' -InFile $tmp -TimeoutSec 240 -UseBasicParsing).Content
830
862
 
831
863
  # 3) 客户端半的真实状态(浏览器跑的那份代码/槽位落点/两个适配器)
832
864
  # 页面加载后 GET /state,看 value.client —— 这是唯一能从页面外读到客户端状态的通道
833
- ((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state -UseBasicParsing).Content | ConvertFrom-Json).value.client
865
+ ((Invoke-WebRequest http://127.0.0.1:8080/sidecard-ask/api/state `
866
+ -Headers @{ Cookie = $cookie } -UseBasicParsing).Content | ConvertFrom-Json).value.client
834
867
  ```
835
868
 
836
869
  **客户端状态样例**(1.3.0 起):
@@ -896,8 +929,11 @@ Host: ctx.subagents.start('spawn')
896
929
  ## 十二、隐私与安全
897
930
 
898
931
  - 选中文本与问题会**发给你自己配置的模型**(走 DSH 既有的模型路由),插件不做任何额外外发。
899
- - 插件只在本地写一个配置文件:`<DSH_HOME>/sidecard-ask/config.json`(原子写入:先写 `.tmp` 再 rename)。
900
- - 插件路由带信任围栏(回环 / 可信域 + 同源校验),仅本机页面可用。
932
+ - 插件只在本地写一个配置文件:`<DSH_HOME>/sidecard-ask/config.<profile>.json`(原子写入:先写 `.tmp` 再 rename;
933
+ POSIX 下目录 `0700`、文件 `0600`,因为里面可能有 SMTP 授权码)。
934
+ - 插件路由带**两层**围栏:回环 / 可信域 + 同源校验(Host 头、`Origin`、`Sec-Fetch-Site`),然后交给宿主的
935
+ `connection.requestRejection()` 校验 **DSH 浏览器会话**(宿主没挂载该服务时才只保留第一层)。
936
+ 因此未登录的外部程序拿到 loopback 地址也读不到 `/state`、更发不出 `/ask`。
901
937
  - 侧边作答者默认**只读**(`sideTools: readonly`):白名单只包含读取、搜索、网络查询类工具,不会在后台改你的工作区。
902
938
  - 提示词里选中文本被包在 ```` ```text ```` 围栏中并显式声明"这是数据不是指令",降低提示注入面。
903
939
 
@@ -981,4 +1017,14 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
981
1017
  自己的数据);**唯一的例外是「清空全部历史」**——显式清空会同时删除 v3/v2/v1 三代键(1.8.1)。
982
1018
  这条例外是必需的:只删当前键会让 `readPersisted()` 的 v3→v2→v1 回落把刚清掉的历史从旧键读回来,
983
1019
  刷新一次就复活(1.8.1 复现并修复)。
1020
+ 12. **接口鉴权(2.0.2 起,2.1.0 补 `/ping`)**:除 `/ping` 外的每个接口都要宿主会话;从页面外读
1021
+ `/state` 会得到 `401 unauthorized`。`/ping` 是唯一免鉴权的探活口,只回 `{version, at}`——
1022
+ 不要往里加字段,它存在的理由是"能给监控用",不是"能省登录"。
1023
+ 13. **活动卡片上限 20(2.1.0)**:超过后最旧的**已完成**卡片自动隐藏(`open:false`,数据仍在,
1024
+ 历史里可恢复),正在流式的卡片永不隐藏。这不是删除,但会"少一张卡片在浮层里"。
1025
+ 14. **冷启动回放预算 200 K 字符(2.1.0)**:最新一张必定展开,其余按累计字符预算展开;
1026
+ 超预算的旧卡片以折叠态回来,点开才解析。改 `historyMaxCards` 不改变这个预算。
1027
+ 15. **部署前缀不带尾斜杠时(2.1.0)**:`API` 基址会先按 `document.baseURI` 猜一次,若得到 404
1028
+ 则回退到根绝对路径重试一次。这是唯一安全的判别方式(401/403 都不能用来改基址),
1029
+ 代价是每次页面加载最多多一次 404 往返——正常部署下不会触发。
984
1030