dsh-molbio-tools 0.21.1 → 0.22.1

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 CHANGED
@@ -10,6 +10,151 @@
10
10
  改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
11
11
  下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
12
12
 
13
+ ## [0.22.1] — 2026-10-25(PubMed key 改走 DSH 凭据库;环境变量降为后备)
14
+
15
+ **工具数不变(60);没有凭据库的组合,行为与 0.22.0 相同。** 0.22.0 只从 `api_key` 参数与
16
+ `process.env` 读 key;这一版把 DSH 的凭据 seam(`ctx.credentials`)插到两者之间,并把环境变量
17
+ 降为最后一级。
18
+
19
+ ### 1. 环境变量为什么不够好
20
+
21
+ 环境变量会被**每个子进程继承**——包括 agent 自己的 shell 工具:一句 `echo $env:NCBI_API_KEY`
22
+ 就能把 key 读进对话;它还会出现在进程环境块、崩溃转储与用户的注册表环境里(Windows 的
23
+ `HKCU\Environment` 是明文)。对任何云端凭证这都是不必要的暴露面;NCBI 的 key 虽然只用于
24
+ 归属与限速,泄漏仍意味着别人可以用你的配额、顶着你的身份发请求。
25
+
26
+ DSH 自带的凭据库(`@deepseek-ai/dsh-credentials-local`)就是为这件事存在的:值存在
27
+ `~/.dsh/.credentials.yaml` 里,**不注入环境**、文件仅属主可读、改动热重载,
28
+ `describe()` 只报告是否已设置与来源、从不回显值,而且**存储值优先于启动环境**。
29
+
30
+ ### 2. 新的解析顺序
31
+
32
+ | 优先级 | 来源 | 说明 |
33
+ | --- | --- | --- |
34
+ | 1 | `api_key` 参数 | 单次调用显式指定;会出现在对话记录与工具卡片里 |
35
+ | 2 | 凭据库 `NCBI_API_KEY` | 推荐;不进入环境,agent 的 shell 子进程读不到 |
36
+ | 3 | 凭据库 `MOLBIO_NCBI_API_KEY` | 同上,插件专用命名 |
37
+ | 4 | 进程环境 `NCBI_API_KEY` / `MOLBIO_NCBI_API_KEY` | 兼容 0.22.0 的用法;暴露面最大 |
38
+
39
+ 实现上调用 `ctx.credentials.resolve(ref)`(存在性检查后才调用),因此凭据库自身的回退层
40
+ (启动环境、项目 `.env`、harness home 的 `.env`)也一并继承;provider 不存在或拒绝该引用时
41
+ **静默回退到 `process.env`**,没有凭据库的组合行为不变。
42
+
43
+ ### 3. key 放进凭据库的三种方式
44
+
45
+ 1. **插件详情页(推荐)**:插件 → 已安装插件 → `dsh-molbio-tools` → **PubMed API key** 一节,
46
+ 粘贴后点 **Save**;见 §4。
47
+ 2. 编辑 `~/.dsh/.credentials.yaml`(产品创建、仅属主可读、热重载):在 `refs:` 下加一行
48
+ `NCBI_API_KEY: 你的key`;下一次工具调用即生效,无需重启。
49
+ 3. 在 DSH 的设置界面保存同名凭据(该界面只显示“是否已设置/来源”,不显示值)。
50
+
51
+ > **这不是对 agent 的硬隔离。** 凭据库自己的文档写得很清楚:agent 的工具进程以同一个 OS 用户
52
+ > 运行,刻意去读那个文件仍然读得到。它解决的是**自动泄漏**:环境变量会被每个 shell 子进程
53
+ > 无条件继承,而凭据库不会。真正要把密钥挡在 agent 之外,需要换提供方(OS 钥匙串/KMS),
54
+ > 当前产品没有随附这类 provider。
55
+
56
+ ### 4. 图形入口:插件详情页里的 PubMed API key
57
+
58
+ 「改 YAML」对没有计算机基础的用户不是可接受的入口。浏览器半因此注册了一个
59
+ `plugins.detail.section` 条目(`id: molbio-ncbi-key`),只在**本包的 bundle 页**与它声明的**行页**
60
+ 上渲染,其它插件返回 `null`:
61
+
62
+ - 输入框是 `type=password`,只写不读;状态行来自 `ctx.remote.credentials.describe(['NCBI_API_KEY'])`,
63
+ 只显示「已配置 / 未配置 / 来源」,**不回显值**。
64
+ - **Save** 走 `ctx.remote.credentials.set('NCBI_API_KEY', value)`,**Remove** 走 `unset`;
65
+ 保存后输入框立即清空,成功/拒绝消息原样显示(`credential/rejected` 的 provider 原文)。
66
+ - 它和 CLI/工具读的是**同一个**凭据引用,所以两边不需要同步;没有凭据 provider 的部署会显示
67
+ Unavailable,而工具仍按 §2 的顺序回退到环境变量。
68
+
69
+ > **测试中发现并修掉的一处真实缺陷(同一版内)**:第一版把 `ctx.remote.credentials` 当成普通
70
+ > 属性直接访问,而它是 **fiber injection**——官方设置页在 `inject` 列表里显式声明
71
+ > `remote.credentials`。没有声明时该命名空间是 `undefined`,点 Save 什么也不会写(状态看起来
72
+ > 只是"没保存")。现在整段的注册被 `ctx.inject(['remote.credentials'], …)` 门控:有命名空间才
73
+ > 出现,没有则整段不注册(面板不受影响);写入之后还会重新 `describe`,若 Host 仍报"未配置"
74
+ > 会把这句话显示出来,而不是假装成功。
75
+
76
+ ### 5. 边界与验证
77
+
78
+ - 默认路径(无 key)仍是 web 检索;有 key 仍是 esearch + esummary + efetch,速率上限不变。
79
+ - 客户端产物因此变了:`lib/client.js` 与 `packages/molbio-panel/lib/client.js` 重新构建,
80
+ 面板包 `0.1.5 → 0.1.6`。
81
+ - smoke 新增三条:**凭据库压过环境变量**、**显式参数压过凭据库**、**provider 抛错时回退到环境**;
82
+ 原有的“无 key 不碰 E-utilities”与“坏 key 报错”保留。`test/client.mjs` 新增详情页座位的
83
+ 等待/注册、本包 subject 渲染、其它 subject 返回 null,以及凭据视图/失败信息三个纯函数。
84
+ - `npm test` 全绿;`client-bundle.mjs --check` 与 `preset-patch.mjs --check` 与源一致。
85
+
86
+ ## [0.22.0] — 2026-10-25(DSH 0.2.1-rc.3 适配;PubMed 官方 API + NCBI API key)
87
+
88
+ **工具数不变(60)。新增一条通路:`molbio_pubmed_search` / `molbio_pubmed_abstract` 可以带着
89
+ NCBI API key 走 PubMed 官方 E-utilities。** 没有 key 时两条工具的行为与 0.21.1 相同——检索仍走
90
+ harness 的 web 检索,摘要仍走 `ctx.web.fetch`。
91
+
92
+ ### 1. 为什么要有官方通路
93
+
94
+ 原来的检索完全依赖 harness 的 web 检索服务:它返回的是**搜索引擎索引到的页面**,不是 PubMed 的
95
+ 题录。三个具体代价:命中的 PMID 要从 URL 里猜、拿不到期刊/年份/作者/DOI 这些结构化字段、也
96
+ 无法用 NCBI 的 API key 提升速率上限(无 key 3 次/秒,有 key 10 次/秒)。官方 API 是两步:
97
+ `esearch`(term → PMID 列表)→ `esummary`(PMID → 题录),摘要另走 `efetch`。
98
+
99
+ ### 2. 通路选择与 key 的来源
100
+
101
+ | 条件 | 检索通路 | 结果字段 |
102
+ | --- | --- | --- |
103
+ | 有 key(参数或环境变量) | 官方 E-utilities:esearch + esummary | PMID、标题、期刊、年份、作者、DOI,另含 `total_matches` |
104
+ | 无 key | 原来的 harness web 检索(行为不变) | 标题、URL、snippet、从 URL 提取的 PMID |
105
+
106
+ key 的优先级是 **`api_key` 参数 > `NCBI_API_KEY` > `MOLBIO_NCBI_API_KEY`**;空白值视为未提供
107
+ (不会发出 `api_key=` 这种被 NCBI 以 400 拒绝的空参数)。一次带 key 的检索是 **2 个请求**,
108
+ `molbio_pubmed_abstract` 是 1 个 efetch 请求。
109
+
110
+ > **内联传 key 会出现在对话记录和工具调用卡片里**,这是参数通路的固有代价;长期使用应设环境
111
+ > 变量。工具的输出、渲染和错误信息都不回显 key。
112
+
113
+ ### 3. 实现
114
+
115
+ - `papers.mjs` 增加纯函数:`pubmedApiKey`(参数/环境变量优先级)、`pubmedSearchUrl` /
116
+ `pubmedSummaryUrl` / `pubmedFetchUrl`(三个 URL 构造器,统一带 `tool=dsh-molbio-tools`)、
117
+ `parseEsearch`(ids + count)、`parseEsummaryRecords`(按 `uids` 顺序归一化题录,
118
+ DOI 从 `articleids` 里取 `idtype: doi`)、`esummarySource`(转成工具返回的 source 形状)。
119
+ - 传输仍走 `ctx.web.fetch`:官方 API 不是一条绕开沙箱/同意机制的新网络通路。没有 `web.fetch`
120
+ 而用户又给了 key 时,工具明确报「需要 ctx.web.fetch」,而不是悄悄退回 web 检索。
121
+ - 错误处理:NCBI 的 `{"error":"API key invalid"}` 会变成 `PubMed E-utilities error: API key
122
+ invalid`,**不会被当成「没有结果」**——「搜不到」和「key 错了」是两件事。空结果返回
123
+ `sources: []` 与 `total_matches: 0`。
124
+ - 输出 schema 扩展:`backend`(`pubmed-eutils` / `web-search`)、`total_matches`,以及 source 上的
125
+ `doi` / `authors` / `journal` / `year`;全部为可选字段,旧调用的输出形状不变。
126
+
127
+ ### 4. rc.3 适配:这次上游什么都没动,但仍要走完流程
128
+
129
+ - `@deepseek-ai/dsh-web-app@0.2.1-rc.3` 的 `standard.patch.yml` 与 rc.2 **逐字节一致**(`git diff
130
+ --no-index` 验证),所以 preset 行清单、`cordis.patch.yml`、`preset-health` 的期望都没有改。
131
+ - 在 rc.3 上全套守卫全绿:`preset-health`(30 行)、`drift-probe`(14 条突变)、`contract`(16 项,
132
+ 含客户端入口与 `presentationMeta` 的行为断言)、`benchmark-profile`(12 行 / 60 工具)。
133
+ - 声明:`engines.dsh` 仍为 `>=0.2.1-rc.2 <0.3.0-0`(rc.2 仍可用);`dshReleases` 把 rc.3 改成
134
+ `compatible`;CI 的 test/release 两条工作流钉到 `0.2.1-rc.3`;面板包 `0.1.4 → 0.1.5`(清单的
135
+ 兼容声明变了,客户端产物字节未变,因此只 bump 面板版本线)。
136
+ - 证据用 rc.3 的 CLI 重跑:`docs/store-evidence.md`(0.22.0 tarball 安装 → 启动 → 卸载)。
137
+
138
+ ### 5. benchmark 的处理(以及为什么没有重跑模型)
139
+
140
+ - 默认通路(无 key)的输出与 0.21.1 相同,`bench --offline` 的 61 题期望值逐条照跑。
141
+ - `literature-search` 仍是 `NETWORK_TASKS` 里唯一的一条:它断言的是模型的最终回答(PMID、标题、
142
+ 摘要措辞),工具结果本来就是**当天的 live PubMed 数据**,没有可以钉住的值。
143
+ - **带 key 的新通路由 `test/smoke.mjs` 覆盖,而不是 benchmark**:benchmark 不能要求真实 key、
144
+ 也不该把 live 记录冻结成期望值。smoke 用 mock 的 esearch/esummary/efetch 断言了四件事——
145
+ key 同时出现在两个请求里、题录字段解析正确、环境变量能回退、无效 key 会报错;另有一条
146
+ 「无 key 时不碰 E-utilities 传输」的对照。
147
+
148
+ ### 6. 版本与验证
149
+
150
+ - 根包 `0.21.1 → 0.22.0`;面板 `0.1.4 → 0.1.5`。
151
+ - `npm test`(DSH 0.2.1-rc.3)全绿:smoke、画面四套、客户端四套、contract 16、drift-probe 14 条
152
+ 突变、preset-health 30 行、release 13 项、benchmark 四道守卫 + `bench --offline` 61 题。
153
+ - `node build/preset-patch.mjs --check` 与 `node build/client-bundle.mjs --check` 均报告与源一致
154
+ (rc.3 适配没有动任何生成物)。
155
+ - 一次性 Profile:tarball **115 个文件 / 约 1.0 MB**;安装 571 ms;启动 `HTTP 200`、38180 字节,
156
+ `__DSH_BOOT__` 含 `dsh-molbio-tools/client.js&rev=a25c3919676b`;卸载 421 ms,`molbio` 引用 0、
157
+ lockfile 与 `node_modules` 无残留。
13
158
  ## [0.21.1] — 2026-10-25(DSH STORE 提交前置:一个 patch、逐版本兼容声明、一个 tarball 一个清单)
14
159
 
15
160
  **工具数不变(60),插件运行时行为零改动。** 这一版不加任何计算能力,只把"这个包以什么形状提交"
package/README.md CHANGED
@@ -55,6 +55,10 @@
55
55
  装完点 **立即启用**(Enable now)——关掉对话框只会装好、不会启用,之后也可以在插件页打开它的开关。
56
56
  3. 重启该 profile,然后在预设选择器里选 "Molecular Biology Lab" 新建会话。
57
57
 
58
+ **(可选)PubMed API key**:重启后打开 **插件 → 已安装插件 → `dsh-molbio-tools` 详情页**,
59
+ 在 **PubMed API key** 一节粘贴 NCBI key 并点 **Save**。它写进 DSH 凭据库(`~/.dsh/.credentials.yaml`),
60
+ 不需要编辑文件、也不进环境变量,下一次检索即生效。没有 key 时检索仍走原来的 web 检索。
61
+
58
62
  ### 次选:命令行安装
59
63
 
60
64
  没有图形界面时(例如服务器上只用 CLI),用这条命令;它和插件页安装等价,都从 npm registry 取包:
@@ -224,8 +228,8 @@ dsh plugin --profile <profile> add dsh-molbio-panel
224
228
 
225
229
  | 工具 | 用途 |
226
230
  | --- | --- |
227
- | `molbio_pubmed_search` | 通过 harness 的 web 检索服务搜文献,自动提取 PMID |
228
- | `molbio_pubmed_abstract` | 按 PMID 拉取摘要(依赖部署提供 web fetch 能力) |
231
+ | `molbio_pubmed_search` | 搜 PubMed 文献。提供 NCBI API key(`api_key` 参数、**DSH 凭据库**的 `NCBI_API_KEY`、或环境变量)时走**官方 E-utilities**(esearch + esummary),返回 PMID/标题/期刊/年份/作者/DOI;无 key 时回退到 harness 的 web 检索 |
232
+ | `molbio_pubmed_abstract` | 按 PMID 拉取摘要(efetch;同样支持 NCBI API key,依赖部署提供 web fetch 能力) |
229
233
  | `molbio_paper_add` / `molbio_paper_list` / `molbio_paper_update` / `molbio_paper_remove` | 阅读库(工作区 `papers.json`):加/列/改/删,按 PMID→URL→标题+年份去重 |
230
234
  | `molbio_paper_export_bibtex` | `papers.json` → BibTeX `.bib`(可按 tag 过滤) |
231
235
  | `molbio_protocol_add` / `molbio_protocol_list` / `molbio_protocol_update` | 协议库(`protocols.json`):步骤列表、自由参数、来源文献 |
@@ -556,6 +560,17 @@ mutations remain deferred")。所以插件无法在工作区里合法地落一
556
560
  ### 文献与记录
557
561
 
558
562
  - 检索与摘要走 harness 的 **web** 服务(部署不提供时工具会明确报错,而不是假装搜到)。
563
+ - 提供 NCBI API key 时,`molbio_pubmed_search` 用官方 E-utilities:esearch 拿 PMID、esummary
564
+ 拿题录(标题/期刊/年份/作者/DOI),`molbio_pubmed_abstract` 用 efetch 拿摘要。key 的解析顺序是
565
+ **`api_key` 参数 → DSH 凭据库(`NCBI_API_KEY`,其次 `MOLBIO_NCBI_API_KEY`)→ 进程环境变量**。
566
+ NCBI 的速率上限是**无 key 3 次/秒、有 key 10 次/秒**,一次带 key 的检索是 2 个请求
567
+ (esearch + esummary)。没有 key 时检索仍走原来的 web 检索通路,行为不变。
568
+ - **优先把 key 放凭据库,而不是环境变量。** 凭据库是 `~/.dsh/.credentials.yaml`(`refs:` 下写
569
+ `NCBI_API_KEY: …`;图形入口是**插件 → 已安装插件 → dsh-molbio-tools → PubMed API key**):
570
+ 不进环境、仅属主可读、改完热重载、下一次调用即生效。环境变量会被**每个子进程继承**——包括 agent 自己的 shell 工具,`echo $env:NCBI_API_KEY`
571
+ 就能读出来,而且明文存在用户环境里;内联 `api_key` 还会进入对话记录与工具卡片。
572
+ > 凭据库不是对 agent 的硬隔离(agent 与你是同一个 OS 用户,刻意去读文件仍可能),但它消除了
573
+ > 「所有 shell 子进程自动继承」这条泄漏路径——这正是环境变量的主要问题。
559
574
  - 阅读库/协议库/实验日志是工作区里的 `papers.json` / `protocols.json` / `experiments.json`,
560
575
  写入遵循会话的**沙箱政策**,与其它文件工具同权;面板(Papers tab)直接读 `papers.json`。
561
576
  - `molbio_paper_export_bibtex` 可选按 tag 过滤导出。
@@ -649,6 +664,14 @@ node <包目录>\preset\install.mjs --profile web --check # 只报告,不组
649
664
  工具会返回 `notes` 说明放宽哪个约束(Tm 窗口、GC 窗口、扩增子长度、探针长度…)。通常是把
650
665
  `amplicon_min/max` 或 `tm_min/max` 放宽一点即可。
651
666
 
667
+ **PubMed 的 API key 怎么配?**
668
+ 推荐用**插件详情页**:插件 → 已安装插件 → `dsh-molbio-tools` → **PubMed API key**,粘贴后点 Save
669
+ (写入 DSH 凭据库的 `NCBI_API_KEY`,不进环境变量,下一次检索生效)。也可以手动在
670
+ `~/.dsh/.credentials.yaml` 的 `refs:` 下加一行 `NCBI_API_KEY: 你的key`。
671
+ 也可以用环境变量 `NCBI_API_KEY`,或在单次调用里传 `api_key`,但两者都更容易泄漏:**环境变量会被
672
+ agent 的 shell 子进程继承**,内联参数会出现在对话记录与工具卡片里。有 key 时走官方 E-utilities
673
+ (PubMed 自己的题录,含 DOI),没有 key 时仍走 harness 的 web 检索。
674
+
652
675
  **"帮我把这段序列分析一下" 它却问我要参数?**
653
676
  把目标说具体即可:要引物还是探针、产物多长、Tm 多少、哪个区域。工具默认值都是常规实验条件
654
677
  (例如引物 18–28 nt / Tm 55–65 °C / GC 40–60%),不特别说明时按默认来。
@@ -13,7 +13,11 @@
13
13
  "whatever PubMed answers today and there is no recorded value to pin. Its",
14
14
  "assertions are therefore all `where: \"answer\"` and structural — a PMID",
15
15
  "appears, a title appears — and `benchmark/verifications.mjs` deliberately",
16
- "declares no offline invocation for it. See NETWORK_TASKS there."
16
+ "declares no offline invocation for it. See NETWORK_TASKS there.",
17
+ "",
18
+ "The 0.22.0 NCBI-API-key path is NOT exercised here: a benchmark cannot",
19
+ "require a real key or pin live PubMed records. test/smoke.mjs covers it with",
20
+ "mocked esearch/esummary/efetch responses (including the invalid-key error)."
17
21
  ],
18
22
  "tasks": [
19
23
  {
@@ -402,5 +402,10 @@ export function invocationsFor(task) {
402
402
  * answer (structural facts: a PMID appears, a title appears), which is why this
403
403
  * module returns no invocations for them and why the offline gate tolerates that
404
404
  * specific case while failing every other task that forgets to declare one.
405
+ *
406
+ * The NCBI-API-key path added in 0.22.0 is covered by `test/smoke.mjs` instead:
407
+ * a keyed call is two E-utilities requests (esearch + esummary) whose responses
408
+ * the mock returns, including the invalid-key error. A benchmark must not need a
409
+ * real NCBI key or pin live PubMed data, so this task stays network-only.
405
410
  */
406
411
  export const NETWORK_TASKS = new Set(['literature-search']);
@@ -32,8 +32,12 @@
32
32
  import { createElement as h, useEffect, useMemo, useRef, useState } from 'react';
33
33
  import {
34
34
  LIBRARY_FILE,
35
+ NCBI_KEY_REF,
35
36
  childPath,
36
37
  classifyEntry,
38
+ credentialInfo,
39
+ isMolbioDetailSubject,
40
+ remoteFailure,
37
41
  decodeWorkspaceBytes,
38
42
  filterPapers,
39
43
  isOpenable,
@@ -489,6 +493,142 @@ function PapersPanel({ sessionId, remote, useSessions }) {
489
493
  h('div', { style: styles.main }, ...detail));
490
494
  }
491
495
 
496
+ /** The state of the PubMed key, read from the host's credential store. */
497
+ async function readNcbiKey(remote) {
498
+ if (remote?.credentials === undefined || typeof remote.credentials.describe !== 'function') {
499
+ return { phase: 'error', configured: false, writable: false, message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' };
500
+ }
501
+ let response;
502
+ try {
503
+ response = await remote.credentials.describe([NCBI_KEY_REF]);
504
+ } catch (error) {
505
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
506
+ }
507
+ const failure = remoteFailure(response);
508
+ if (failure !== '') return { phase: 'error', configured: false, writable: false, message: failure };
509
+ return { phase: 'ready', ...credentialInfo(response, NCBI_KEY_REF), message: '' };
510
+ }
511
+
512
+ const keyStyles = {
513
+ section: { borderTop: '1px solid rgba(127, 127, 127, 0.28)', marginTop: 16, paddingTop: 12, display: 'grid', gap: 8 },
514
+ title: { margin: 0, fontSize: 14 },
515
+ note: { margin: 0, fontSize: 12, opacity: 0.75, lineHeight: 1.5 },
516
+ status: { margin: 0, fontSize: 12 },
517
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
518
+ input: { flex: '1 1 240px', minWidth: 200, padding: '4px 8px' },
519
+ message: { margin: 0, fontSize: 12 },
520
+ };
521
+
522
+ /**
523
+ * The Plugins → installed plugin → detail section that stores the NCBI key.
524
+ *
525
+ * It renders only for this package's own subject (bundle page, or one of the
526
+ * row pages the bundle declares) and returns null everywhere else, which is the
527
+ * contract `plugins.detail.section` asks a contribution to honour. The value is
528
+ * write-only from here: `credentials.describe` answers whether a key is
529
+ * configured and where it came from, never the key itself.
530
+ */
531
+ function NcbiKeySection({ subject, remote }) {
532
+ const [draft, setDraft] = useState('');
533
+ const [state, setState] = useState({ phase: 'loading', configured: false, writable: true, message: '' });
534
+ const subjectToken = isMolbioDetailSubject(subject)
535
+ ? `${subject.kind}:${subject.pkg.name}:${subject.row?.rowId ?? ''}`
536
+ : '';
537
+ useEffect(() => {
538
+ if (subjectToken === '') return undefined;
539
+ let live = true;
540
+ const load = () => readNcbiKey(remote).then((next) => { if (live) setState(next); });
541
+ load();
542
+ // Another surface may rotate or clear the same reference (Settings, the
543
+ // file watcher); follow the host event instead of showing a stale badge.
544
+ const off = typeof remote?.$on === 'function'
545
+ ? remote.$on('credentials/reference-updated', (ref) => { if (ref === NCBI_KEY_REF) load(); })
546
+ : undefined;
547
+ return () => {
548
+ live = false;
549
+ if (typeof off === 'function') off();
550
+ };
551
+ }, [remote, subjectToken]);
552
+ if (!isMolbioDetailSubject(subject)) return null;
553
+ const busy = state.phase === 'saving';
554
+ const save = async () => {
555
+ const value = draft.trim();
556
+ if (value === '') return;
557
+ if (remote?.credentials === undefined || typeof remote.credentials.set !== 'function') {
558
+ setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
559
+ return;
560
+ }
561
+ setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
562
+ let response;
563
+ try {
564
+ response = await remote.credentials.set(NCBI_KEY_REF, value);
565
+ } catch (error) {
566
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
567
+ }
568
+ const failure = remoteFailure(response);
569
+ setDraft('');
570
+ const next = await readNcbiKey(remote);
571
+ setState(failure !== ''
572
+ ? { ...next, phase: 'error', message: failure }
573
+ : next.phase === 'error'
574
+ ? next
575
+ : next.configured === true
576
+ ? { ...next, message: 'Saved. The next PubMed call uses this key.' }
577
+ : { ...next, phase: 'error', message: 'The host accepted the write but still reports no key configured. Restart the profile and try again.' });
578
+ };
579
+ const remove = async () => {
580
+ if (remote?.credentials === undefined || typeof remote.credentials.unset !== 'function') {
581
+ setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
582
+ return;
583
+ }
584
+ setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
585
+ let response;
586
+ try {
587
+ response = await remote.credentials.unset(NCBI_KEY_REF);
588
+ } catch (error) {
589
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
590
+ }
591
+ const failure = remoteFailure(response);
592
+ const next = await readNcbiKey(remote);
593
+ setState(failure !== ''
594
+ ? { ...next, phase: 'error', message: failure }
595
+ : next.phase === 'error'
596
+ ? next
597
+ : next.configured === false
598
+ ? { ...next, message: 'Removed. PubMed search falls back to web search.' }
599
+ : { ...next, phase: 'error', message: 'The host still reports a key configured after Remove.' });
600
+ };
601
+ const status = state.phase === 'loading'
602
+ ? 'Checking the credential store…'
603
+ : state.phase === 'error'
604
+ ? 'Unavailable'
605
+ : state.configured
606
+ ? `Configured${state.source === undefined ? '' : ` (source: ${state.source})`}`
607
+ : 'Not configured';
608
+ return h('section', { 'data-molbio-ncbi-key': '', style: keyStyles.section },
609
+ h('h4', { style: keyStyles.title }, 'PubMed API key'),
610
+ h('p', { style: keyStyles.note },
611
+ 'Stored in the harness credential store. molbio_pubmed_search and molbio_pubmed_abstract use it for the official PubMed E-utilities API; without it they fall back to web search.'),
612
+ h('p', { 'data-molbio-key-status': state.phase, style: keyStyles.status }, status),
613
+ h('div', { style: keyStyles.row },
614
+ h('input', {
615
+ type: 'password',
616
+ value: draft,
617
+ placeholder: 'NCBI API key (36 characters)',
618
+ autoComplete: 'off',
619
+ spellCheck: false,
620
+ 'aria-label': 'NCBI API key',
621
+ style: keyStyles.input,
622
+ onChange: (event) => setDraft(event.target.value),
623
+ }),
624
+ h('button', { type: 'button', disabled: busy || state.writable === false || draft.trim() === '', onClick: save },
625
+ busy ? 'Saving…' : 'Save'),
626
+ h('button', { type: 'button', disabled: busy || state.writable === false || state.configured !== true, onClick: remove }, 'Remove')),
627
+ state.message === '' ? null : h('p', { 'data-molbio-key-message': '', role: 'status', style: keyStyles.message }, state.message),
628
+ h('p', { style: keyStyles.note },
629
+ 'Create a key under NCBI → Account Settings → API Key Management. Prefer this over an environment variable: the store is not inherited by shell commands.'));
630
+ }
631
+
492
632
  /** Required browser services. */
493
633
  const inject = ['slots', 'sidebarRightTabs', 'remote'];
494
634
 
@@ -572,6 +712,25 @@ function apply(ctx) {
572
712
  key: toolName,
573
713
  }, MolbioMapCard)), `molbio panel: ${toolName} card`);
574
714
  }
715
+ // Plugins -> the installed plugin's detail page: the one place a user can
716
+ // store the NCBI key without editing a YAML file or exporting a variable.
717
+ //
718
+ // `remote.credentials` is a FIBER INJECTION, not merely a property on the
719
+ // Remote carrier: the shipped settings pages declare it in `inject` for the
720
+ // same reason. Without the declaration `ctx.remote.credentials` is undefined,
721
+ // which is exactly how the first build of this section made Save a no-op.
722
+ // The gate keeps the panel working in deployments that expose no credentials
723
+ // namespace: the section simply never registers there.
724
+ ctx.effect(() => {
725
+ const gate = ctx.inject(['remote.credentials'], (keyCtx) => {
726
+ keyCtx.effect(() => keyCtx.slots.inject('plugins.detail.section', () => keyCtx.slots.register({
727
+ name: 'plugins.detail.section',
728
+ id: 'molbio-ncbi-key',
729
+ order: 100,
730
+ }, (props) => h(NcbiKeySection, { ...props, remote: keyCtx.remote }))), 'molbio panel: NCBI key section');
731
+ });
732
+ return () => { gate.dispose(); };
733
+ }, 'molbio panel: NCBI key section gate');
575
734
  }
576
735
 
577
736
  export { apply, inject };
@@ -390,3 +390,41 @@ export function paperLink(paper) {
390
390
  if (typeof paper.pmid === 'string' && paper.pmid !== '') return `https://pubmed.ncbi.nlm.nih.gov/${paper.pmid}/`;
391
391
  return undefined;
392
392
  }
393
+
394
+ /** The credential reference the PubMed tools read first. */
395
+ export const NCBI_KEY_REF = 'NCBI_API_KEY';
396
+
397
+ /**
398
+ * Whether a Plugins-page detail subject is this package's own entry.
399
+ *
400
+ * Bundles and rows both expose `pkg.name`; the key section is offered on the
401
+ * bundle page and on every row page the bundle declares, and on nothing else.
402
+ */
403
+ export function isMolbioDetailSubject(subject) {
404
+ if (subject === null || typeof subject !== 'object') return false;
405
+ if (subject.kind !== 'bundle' && subject.kind !== 'row') return false;
406
+ const pkg = subject.pkg;
407
+ return pkg !== null && typeof pkg === 'object' && pkg.name === 'dsh-molbio-tools';
408
+ }
409
+
410
+ /**
411
+ * The host failure message carried by a Remote result, or '' on success.
412
+ * Remote calls answer `{ ok: true, value }` or `{ ok: false, error }`.
413
+ */
414
+ export function remoteFailure(response) {
415
+ if (response === null || typeof response !== 'object' || response.ok !== false) return '';
416
+ const message = response.error?.message;
417
+ return typeof message === 'string' && message !== '' ? message : 'the host refused the request';
418
+ }
419
+
420
+ /** One reference's view from a `credentials.describe` response (never the value). */
421
+ export function credentialInfo(response, ref) {
422
+ const view = response !== null && typeof response === 'object' && response.ok !== false
423
+ ? response.value?.[ref]
424
+ : undefined;
425
+ return {
426
+ configured: view?.configured === true,
427
+ writable: view?.writable !== false,
428
+ ...typeof view?.source === 'string' && view.source !== '' ? { source: view.source } : {},
429
+ };
430
+ }
@@ -201,7 +201,7 @@ export function tagVersion(tag) {
201
201
  * `rc.10 < rc.2`, so a release of `rc.2` after `rc.10` would look like a
202
202
  * DOWNGRADE and the gate would refuse a legitimate publish (or, in the other
203
203
  * direction, wave through a stale one). This repository does cut prereleases
204
- * (the harness baseline itself is `0.2.1-rc.2`), so the rule is implemented
204
+ * (the harness baseline itself is `0.2.1-rc.3`), so the rule is implemented
205
205
  * rather than approximated.
206
206
  *
207
207
  * Semver's rule: split on dots; numeric identifiers compare numerically and rank
@@ -13,6 +13,7 @@
13
13
  | **Molbio**(右栏 tab) | 列出会话工作区里的序列文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图 | `workspaceFiles.readAll`(二进制,含 SnapGene) |
14
14
  | **Papers**(右栏 tab) | 把 `molbio_paper_*` 工具维护的 `papers.json` 渲染成可搜索的阅读列表 + 详情面板(标题/作者/期刊/年份/PMID/URL/标签/笔记,标题链接到 PubMed 或原 URL) | `workspaceFiles.read`(文本) |
15
15
  | **图谱调用卡**(`tool.call.toolview`) | `molbio_plasmid_map` / `molbio_plasmid_map_file` 的调用卡:在对话里直接画出这次调用产出的图谱(摘要行 + 写入路径),失败时显示错误文本与 Inspect 入口 | 工具自己声明的 `output.presentationMeta`(见下) |
16
+ | **PubMed API key**(`plugins.detail.section`) | 插件 → 已安装插件 → `dsh-molbio-tools` 详情页里的一段表单:password 输入 + Save/Remove,状态只报「已配置 / 来源 / 未配置」,永不回显值 | `ctx.remote.credentials.describe` / `set` / `unset`(host 的凭据 seam) |
16
17
 
17
18
  **解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`),
18
19
  不经过任何工具调用、不落盘 SVG、不弹系统查看器。
@@ -46,7 +47,7 @@ SVG 随 meta 传输有上限(256 KB):真实载体的图谱 20-60 KB,但
46
47
  | `build/client-bundle-core.mjs` | 打包器的**生成逻辑**(可 import):`createGenerator({entry, baseDir})` → 模块图 + `renderBundle(packageName)`;`test/contract.mjs` 在进程内用它重算产物做新鲜度检查(不 spawn,受限沙箱下也能验证) |
47
48
  | `build/browser-api.mjs` | 浏览器安全面:从**包根的 `.mjs` 源文件**再导出面板可用的一切(单一事实来源) |
48
49
  | `build/panel-core.mjs` | 面板的数据通路(分类/解码/解析/渲染/文献库投影),不含 React,可在 Node 里单测 |
49
- | `build/client-entry.mjs` | 浏览器半本体:两个 tab 类型的注册、正文、标题 chip,以及两个 map 工具的调用卡 |
50
+ | `build/client-entry.mjs` | 浏览器半本体:两个 tab 类型的注册、正文、标题 chip,两个 map 工具的调用卡,以及插件详情页的 key 配置段 |
50
51
  | `lib/client.js` | **构建产物**(`exports["./client"]` 指向它;由 `npm run build:client` 生成) |
51
52
  | `packages/molbio-panel/` | 面板专用包(宿主半边空实现),面板与 57 个工具解耦的交付通道 |
52
53
  | `test/client.mjs` | 按加载器的方式执行产物 + 驱动两条数据通路(含真实 pUC118 夹具) |
@@ -131,6 +132,14 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
131
132
  - **图片式交互**:SVG 里注入的是真实 DOM(可缩放、可选中文本),但没有点特征跳转之类的联动。
132
133
  - **CSS 内联**:产物刻意不注入样式表——`factory` 里的样式副作用要自己管拆除;
133
134
  内联样式让产物保持单文件、零生命周期负担。
135
+ - **key 配置段只在「本包」的详情页出现**:`plugins.detail.section` 是 list 座位,条目自己判断
136
+ `subject`;本包只在 `kind: bundle|row` 且 `pkg.name === 'dsh-molbio-tools'` 时渲染,其它插件页
137
+ 返回 `null`。它依赖宿主挂载了凭据 provider(默认组合都有):没有时整段通过
138
+ `ctx.inject(['remote.credentials'], …)` 门控而不注册,工具仍会按「参数 → 凭据库 → 环境变量」
139
+ 的顺序回退,不会因此不可用。**`remote.credentials` 是 fiber injection,不是普通属性**:
140
+ 官方设置页同样在 `inject` 里声明它;第一版漏了这一步,`ctx.remote.credentials` 是 `undefined`,
141
+ 点 Save 什么也没写(0.22.1 测试中发现并修掉)。**输入框只写不读**:`describe` 的返回里没有值,
142
+ 保存成功后输入框立即清空;写后仍报未配置、或 Remove 后仍报已配置,都会把不一致显示出来。
134
143
  - **每次改源都要重新构建**:`lib/client.js` 是**产物**,改了根 `.mjs` 或 `build/*.mjs` 后
135
144
  必须重跑 `npm run build:client`,否则面板还跑旧逻辑(这与 preset 的"版本目录"规则是
136
145
  两码事:客户端产物靠 `rev` 哈希缓存失效,不靠目录名)。
@@ -146,9 +155,11 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
146
155
  `guide`、无 `patterns`、guide `order` 40/41)、四个 keyed 座位的注册、两个正文的 `inject`
147
156
  工厂都只交出 `remote`。座位那一层从**一个座位都没声明**的最坏顺序启动(`test/slots-stub.mjs`
148
157
  复刻 shell 的 SlotCore 语义):`apply()` 必须不抛;右栏座位声明后四条注册落地;调用卡座位在
149
- ui-tool 声明之前一直**处于等待**、声明之后才落地;重声明不产生重复注册;并当场复刻
150
- "未声明座位 `register()` 必抛"的那条 SlotCore 异常——0.7.1 把整个 GUI 卡在
151
- "Failed to load plugins" 的就是它。
158
+ ui-tool 声明之前一直**处于等待**、声明之后才落地;插件详情页座位同样先等待、后注册;重声明
159
+ 不产生重复注册;并当场复刻 "未声明座位 `register()` 必抛"的那条 SlotCore 异常——0.7.1 把整个
160
+ GUI 卡在 "Failed to load plugins" 的就是它。key 段还断言了:本包 subject 渲染出
161
+ `section` + password 输入 + Save,其它包的 subject 返回 `null`,以及凭据视图/失败信息
162
+ 三个纯函数(`isMolbioDetailSubject` / `credentialInfo` / `remoteFailure`)。
152
163
  3. **组件行为**:`test/panel-render.mjs` 把**真实的面板组件**放进 Node 跑——没有 React
153
164
  (harness 里有,浏览器里的 React 由 shell 播种)也没有 DOM,所以这个文件自带一套最小
154
165
  钩子宿主(`createElement`/`useState`/`useEffect`/`useMemo`/`useRef`,语义与 React 一致:
@@ -159,7 +170,9 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
159
170
  列目录失败的错误态、文献库的列表/搜索/标签过滤/详情/PubMed 链接回退、空库提示、坏库
160
171
  报错(**不显示成"空库"**)、以及卸载时中止在途读取。
161
172
  这一层抓到过三个真 bug:列表把不可打开的文件也列出来、`sortEntries` 的 `other` 档从不
162
- 生效、以及坏库时列表仍宣称"还没有文献"。
173
+ 生效、以及坏库时列表仍宣称"还没有文献"。插件详情页的 key 段也被真实驱动:
174
+ `describe` → 输入 → Save(trim 后写入 `NCBI_API_KEY`)→ 状态刷新 + 清空输入 → Remove →
175
+ provider 拒绝时消息原样显示。
163
176
  4. **数据通路(质粒)**:真实 pUC118 `.dna` → 记录(名称/长度/拓扑/特征,AmpR 2102-2962
164
177
  反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;FASTA → 比对 → logo;
165
178
  扩展名分类、排序、base64 解码、非法输入的错误路径。
@@ -234,6 +247,9 @@ shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
234
247
 
235
248
  ## 7. 下一步
236
249
 
250
+ - **插件详情页的 key 配置**:✅ 已落地(0.22.1,见第 1 与第 4 节)。后续可把同一个
251
+ `plugins.detail.section` 模式用于其它凭据(例如文献库的机构代理),前提是它们也有一个
252
+ 稳定的环境变量名。
237
253
  - **`tool.call.toolview`**:✅ 已落地(见第 1 与第 3 节),覆盖两个 map 工具。可继续做的
238
254
  是给 `molbio_sequence_logo` / `molbio_grna_design` 之类的工具也加上卡片(同一套
239
255
  `presentationMeta` + 卡片模式)。**注意上线前先跑 `test/client.mjs` 的抢座位顺序那一层**:
package/docs/history.md CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 0.22.1 | — | **PubMed key 改走 DSH 凭据库**:解析顺序 `api_key` → 凭据库 → 环境变量;无凭据库时行为同 0.22.0 | 60 |
20
+ | 0.22.0 | — | **DSH 0.2.1-rc.3 适配 + PubMed 官方 API**:`molbio_pubmed_search`/`_abstract` 支持 NCBI API key(E-utilities),无 key 行为不变 | 60 |
19
21
  | 0.21.1 | — | **DSH STORE 提交前置**:单文件 bundle patch、逐版本兼容声明;根 tarball 只带一个插件清单(工具行为零变化) | 60 |
20
22
  | 0.21.0 | — | **DSH 0.2.1-rc.2 适配**:preset 行清单逐行重抄;contract 两条断言重锚到行为;两条 suspended 记录用真实模型重录 | 60 |
21
23
  | 0.20.1 | — | 插件专属图标(透明底双螺旋 SVG),运行时零改动 | 60 |
@@ -44,6 +46,49 @@
44
46
 
45
47
  ---
46
48
 
49
+ ## v0.22.1(2026-10-25,包 0.22.1)
50
+
51
+ **环境变量不是唯一选择。** 0.22.0 只从 `api_key` 参数与 `process.env` 读 key;这一版把 DSH 的
52
+ `ctx.credentials` 插到中间,并把环境变量降为后备。
53
+
54
+ - 新顺序:`api_key` 参数 → 凭据库(`NCBI_API_KEY` / `MOLBIO_NCBI_API_KEY`)→ 进程环境。
55
+ - 为什么:环境变量会被**每个子进程**继承——包括 agent 自己的 shell 工具。凭据库不进入环境、
56
+ 仅属主可读、热重载,`describe()` 不回显值。但 DSH 凭据库的文档也写明:agent 与你是同一个 OS
57
+ 用户,它不是硬隔离,只是消除了「自动继承」这条路径。
58
+ - 没有凭据库的组合(以及 provider 抛错的组合)静默回退到环境,行为与 0.22.0 一致;显式参数仍然
59
+ 最高优先。
60
+ - smoke 新增三条:凭据库压过环境、参数压过凭据库、provider 抛错回退。
61
+ - 0.22.1 同时补了**图形入口**:插件 → 已安装插件 → 本包详情页里的 `plugins.detail.section`
62
+ 表单(password 输入 + Save/Remove,值只写不读)。改 YAML 不该是唯一入口。
63
+ - **测试里抓到的缺陷**:`ctx.remote.credentials` 是 fiber injection,不是 Remote 载体的普通属性。
64
+ 第一版没在 `inject` 里声明它,命名空间就是 `undefined`,Save 静默地什么也没写;官方设置页
65
+ 一直把 `remote.credentials` 写在 `inject` 列表里,那就是这行声明的用途。现在用
66
+ `ctx.inject(['remote.credentials'], …)` 门控整段注册,并在写后重新 `describe` 核对结果。
67
+
68
+ ## v0.22.0(2026-10-25,包 0.22.0)
69
+
70
+ 一半是适配(rc.3),一半是新能力(PubMed 官方 API + key)。三条教训。
71
+
72
+ **(1) 「上游没变」不是可以跳过流程的理由,而是一个需要被证明的结论。**
73
+ rc.3 的 `standard.patch.yml` 与 rc.2 逐字节相同,所以 preset 行、生成物、`preset-health` 的期望
74
+ 一行都没动——但这是先 diff 过、再把 `preset-health` / `drift-probe` / `contract` /
75
+ `benchmark-profile` 全套在 rc.3 上跑绿之后才敢下的结论。上一次(0.21.0)同一个文件在补丁级版本里
76
+ 改了 12 处结构;把「这次应该没事」当选版本的理由,就是在赌下一次。
77
+
78
+ **(2) 官方 API 不是「另一条网络通路」,它仍然是一次 `ctx.web.fetch`。**
79
+ PubMed E-utilities 的加入没有引入 `fetch` 依赖,也没有绕开沙箱/同意机制:工具只是把 URL 构造成
80
+ esearch/esummary/efetch,再交给同一个 `ctx.web` 缝。两个细节必须做对:**空结果与鉴权失败是两件
81
+ 事**(`{"error":"API key invalid"}` 不能渲染成「没有搜到」);**key 的优先级要可预测**
82
+ (参数 > `NCBI_API_KEY` > `MOLBIO_NCBI_API_KEY`,空白值视为缺席)。另外,内联传 key 会让它出现在
83
+ 对话记录与工具卡片里——这是参数通路的固有代价,所以文档把「长期使用请设环境变量」写在工具描述里,
84
+ 而不是只写在 README。
85
+
86
+ **(3) 需要真实凭证/实时数据的路径,归 smoke,不归 benchmark。**
87
+ benchmark 的 `literature-search` 一直是 `NETWORK_TASKS`:它只断言最终回答的结构,因为工具返回的
88
+ 是当天的 PubMed 数据,没有可冻结的值。带 key 的新通路同样不能被 benchmark 钉住(既不能要求一个
89
+ 真 key,也不该把 live 记录写进 fixture),于是用 mock 的 esearch/esummary/efetch 在 `test/smoke.mjs`
90
+ 里断言「key 真的进了两个请求」「字段解析正确」「环境变量能回退」「坏 key 会报错」,并保留一条
91
+ 「无 key 时不碰 E-utilities」的对照——benchmark 负责模型会不会用,smoke 负责这条分支本身对不对。
47
92
  ## v0.21.1(2026-10-25,包 0.21.1)
48
93
 
49
94
  上架前置版:一个工具都没改,只改「提交的形状」。三条教训都关于**契约不止一份**。
@@ -32,7 +32,7 @@
32
32
 
33
33
  - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**60 个 `molbio_*` 工具**(其中
34
34
  `molbio_plot` 一个工具承载 8 种图表)。
35
- - **基线**:DSH **0.2.1-rc.2**;`package.json` 版本 0.21.1。
35
+ - **基线**:DSH **0.2.1-rc.3**;`package.json` 版本 0.22.1。
36
36
  - **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
37
37
  **两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
38
38
  - **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
package/docs/roadmap.md CHANGED
@@ -10,12 +10,12 @@ L = 需要新机制或外部资源)。
10
10
 
11
11
  ---
12
12
 
13
- ## 1. 当前状态(0.21.1)
13
+ ## 1. 当前状态(0.22.1)
14
14
 
15
15
  - 60 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
16
16
  - 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
17
17
  (见 [benchmark/README.md](../benchmark/README.md))。
18
- - 基线:DSH **0.2.1-rc.2**。
18
+ - 基线:DSH **0.2.1-rc.3**。
19
19
  - **CI 已在位**(`.github/workflows/test.yml`,0.18.2 起):push / PR / 手动都跑全套;harness 装
20
20
  **钉死版本**,并用 `npm prefix -g` 显式导出 `DSH_HARNESS_ROOT`。0.18.4 起**同时跑
21
21
  `ubuntu-latest` 与 `windows-latest`**——3.6 已修,套件不再依赖宿主分隔符。
@@ -37,6 +37,14 @@ L = 需要新机制或外部资源)。
37
37
  `dshReleases`、根 tarball 去掉 `packages/`(一个包只带一个插件清单)。剩下的两条预检规则
38
38
  不是改包能解决的,证据与请求见 [store-preset-exemption.md](store-preset-exemption.md)。
39
39
  下一版若要动工具,按本文件第 2 节挑候选;若只等商城结论,则不必发版。
40
+ - **0.22.0 是 rc.3 适配 + 文献检索升级**:DSH 基线 0.2.1-rc.2 → 0.2.1-rc.3(上游
41
+ `standard` 行清单**逐字节未变**,`preset-health` / `drift-probe` / `contract` 在 rc.3 上
42
+ 全绿,因此这一半只改声明与证据);`molbio_pubmed_search` / `molbio_pubmed_abstract` 增加
43
+ NCBI API key 通路,走官方 E-utilities(esearch + esummary),无 key 时保持原有 web 检索。
44
+ - **0.22.1 是 key 存放方式的安全加固 + 图形入口**:`molbio_pubmed_search` / `molbio_pubmed_abstract`
45
+ 的 key 解析顺序变为 **`api_key` 参数 → DSH 凭据库 → 环境变量**(环境变量会被 agent 自己的 shell
46
+ 子进程继承,凭据库不会);同时插件详情页出现 **PubMed API key** 表单,用户不必改 YAML。
47
+ 没有凭据库的组合行为与 0.22.0 相同。
40
48
 
41
49
  ---
42
50