dsh-molbio-tools 0.22.0 → 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,79 @@
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
+
13
86
  ## [0.22.0] — 2026-10-25(DSH 0.2.1-rc.3 适配;PubMed 官方 API + NCBI API key)
14
87
 
15
88
  **工具数不变(60)。新增一条通路:`molbio_pubmed_search` / `molbio_pubmed_abstract` 可以带着
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,7 +228,7 @@ dsh plugin --profile <profile> add dsh-molbio-panel
224
228
 
225
229
  | 工具 | 用途 |
226
230
  | --- | --- |
227
- | `molbio_pubmed_search` | 搜 PubMed 文献。提供 NCBI API key(`api_key` 参数,或环境变量 `NCBI_API_KEY` / `MOLBIO_NCBI_API_KEY`)时走**官方 E-utilities**(esearch + esummary),返回 PMID/标题/期刊/年份/作者/DOI;无 key 时回退到 harness 的 web 检索 |
231
+ | `molbio_pubmed_search` | 搜 PubMed 文献。提供 NCBI API key(`api_key` 参数、**DSH 凭据库**的 `NCBI_API_KEY`、或环境变量)时走**官方 E-utilities**(esearch + esummary),返回 PMID/标题/期刊/年份/作者/DOI;无 key 时回退到 harness 的 web 检索 |
228
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 过滤) |
@@ -557,10 +561,16 @@ mutations remain deferred")。所以插件无法在工作区里合法地落一
557
561
 
558
562
  - 检索与摘要走 harness 的 **web** 服务(部署不提供时工具会明确报错,而不是假装搜到)。
559
563
  - 提供 NCBI API key 时,`molbio_pubmed_search` 用官方 E-utilities:esearch 拿 PMID、esummary
560
- 拿题录(标题/期刊/年份/作者/DOI),`molbio_pubmed_abstract` 用 efetch 拿摘要。key 的来源优先级是
561
- `api_key` 参数 > `NCBI_API_KEY` > `MOLBIO_NCBI_API_KEY`;**长期使用请用环境变量**,内联传参会让 key
562
- 出现在对话记录与工具卡片里。NCBI 的速率上限是**无 key 3 次/秒、有 key 10 次/秒**,一次带 key 的
563
- 检索是 2 个请求(esearch + esummary)。没有 key 时检索仍走原来的 web 检索通路,行为不变。
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 子进程自动继承」这条泄漏路径——这正是环境变量的主要问题。
564
574
  - 阅读库/协议库/实验日志是工作区里的 `papers.json` / `protocols.json` / `experiments.json`,
565
575
  写入遵循会话的**沙箱政策**,与其它文件工具同权;面板(Papers tab)直接读 `papers.json`。
566
576
  - `molbio_paper_export_bibtex` 可选按 tag 过滤导出。
@@ -655,10 +665,12 @@ node <包目录>\preset\install.mjs --profile web --check # 只报告,不组
655
665
  `amplicon_min/max` 或 `tm_min/max` 放宽一点即可。
656
666
 
657
667
  **PubMed 的 API key 怎么配?**
658
- 在启动 DSH 的环境里设 `NCBI_API_KEY`(或 `MOLBIO_NCBI_API_KEY`),插件会自动把它带进 E-utilities 请求;
659
- 也可以在单次调用里传 `api_key`。有 key 时 `molbio_pubmed_search` 走官方 API(PubMed 自己的题录,
660
- 含 DOI),没有 key 时仍走 harness 的 web 检索。注意:**内联传的 key 会出现在对话记录和工具调用卡片里**,
661
- 长期用请设环境变量。
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 检索。
662
674
 
663
675
  **"帮我把这段序列分析一下" 它却问我要参数?**
664
676
  把目标说具体即可:要引物还是探针、产物多长、Tm 多少、哪个区域。工具默认值都是常规实验条件
@@ -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
+ }
@@ -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,7 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 0.22.1 | — | **PubMed key 改走 DSH 凭据库**:解析顺序 `api_key` → 凭据库 → 环境变量;无凭据库时行为同 0.22.0 | 60 |
19
20
  | 0.22.0 | — | **DSH 0.2.1-rc.3 适配 + PubMed 官方 API**:`molbio_pubmed_search`/`_abstract` 支持 NCBI API key(E-utilities),无 key 行为不变 | 60 |
20
21
  | 0.21.1 | — | **DSH STORE 提交前置**:单文件 bundle patch、逐版本兼容声明;根 tarball 只带一个插件清单(工具行为零变化) | 60 |
21
22
  | 0.21.0 | — | **DSH 0.2.1-rc.2 适配**:preset 行清单逐行重抄;contract 两条断言重锚到行为;两条 suspended 记录用真实模型重录 | 60 |
@@ -45,6 +46,25 @@
45
46
 
46
47
  ---
47
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
+
48
68
  ## v0.22.0(2026-10-25,包 0.22.0)
49
69
 
50
70
  一半是适配(rc.3),一半是新能力(PubMed 官方 API + key)。三条教训。
@@ -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.3**;`package.json` 版本 0.22.0。
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,7 +10,7 @@ L = 需要新机制或外部资源)。
10
10
 
11
11
  ---
12
12
 
13
- ## 1. 当前状态(0.22.0)
13
+ ## 1. 当前状态(0.22.1)
14
14
 
15
15
  - 60 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
16
16
  - 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
@@ -41,6 +41,10 @@ L = 需要新机制或外部资源)。
41
41
  `standard` 行清单**逐字节未变**,`preset-health` / `drift-probe` / `contract` 在 rc.3 上
42
42
  全绿,因此这一半只改声明与证据);`molbio_pubmed_search` / `molbio_pubmed_abstract` 增加
43
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 相同。
44
48
 
45
49
  ---
46
50
 
package/index.mjs CHANGED
@@ -6914,15 +6914,46 @@ async function eutilsRequest(web, url, signal, label) {
6914
6914
  }
6915
6915
  }
6916
6916
 
6917
+ /** Credential-store references tried, in order, before the process environment. */
6918
+ const PUBMED_KEY_REFS = ['NCBI_API_KEY', 'MOLBIO_NCBI_API_KEY'];
6919
+
6920
+ /**
6921
+ * Resolve the NCBI API key from (1) the call argument, (2) the harness
6922
+ * credential store, (3) the process environment.
6923
+ *
6924
+ * The store is the recommended channel: a stored value is not injected into the
6925
+ * environment, so the agent's own shell children cannot read it by accident, and
6926
+ * `ctx.credentials.resolve` itself falls back through the launch environment and
6927
+ * the .env files. A provider that refuses this ref (a composition without a
6928
+ * credential store, a name outside its grammar) must degrade to the environment
6929
+ * rather than break the tool, hence the per-ref catch.
6930
+ */
6931
+ async function pubmedKey(args, ctx) {
6932
+ const explicit = typeof args.api_key === 'string' ? args.api_key.trim() : '';
6933
+ if (explicit !== '') return explicit;
6934
+ const credentials = ctx.get('credentials');
6935
+ if (credentials !== undefined && typeof credentials.resolve === 'function') {
6936
+ for (const ref of PUBMED_KEY_REFS) {
6937
+ try {
6938
+ const hit = await credentials.resolve(ref);
6939
+ if (typeof hit?.value === 'string' && hit.value.trim() !== '') return hit.value.trim();
6940
+ } catch {
6941
+ /* fall through to the environment */
6942
+ }
6943
+ }
6944
+ }
6945
+ return pubmedApiKey(args);
6946
+ }
6947
+
6917
6948
  const pubmedAbstractTool = (ctx) => define({
6918
6949
  name: 'molbio_pubmed_abstract',
6919
- description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Pass api_key (or set NCBI_API_KEY / MOLBIO_NCBI_API_KEY) to attribute the request to your NCBI key and raise the rate limit. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
6950
+ description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Provide an NCBI API key through the harness credential store (NCBI_API_KEY), the api_key argument, or the environment to attribute the request to your NCBI key and raise the rate limit. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
6920
6951
  parameters: {
6921
6952
  type: 'object',
6922
6953
  required: ['pmids'],
6923
6954
  properties: {
6924
6955
  pmids: { type: 'array', items: { type: 'string' }, description: 'PubMed IDs, e.g. ["37607951"].' },
6925
- api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities. Prefer setting NCBI_API_KEY in the environment: an inline key appears in the conversation transcript.' },
6956
+ api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities. Resolution order: this argument, the harness credential store (NCBI_API_KEY / MOLBIO_NCBI_API_KEY), then the environment. Prefer the credential store: an inline key appears in the conversation transcript.' },
6926
6957
  },
6927
6958
  },
6928
6959
  outputSchema: {
@@ -6966,7 +6997,7 @@ const pubmedAbstractTool = (ctx) => define({
6966
6997
  if (web === undefined || typeof web.fetch !== 'function') {
6967
6998
  throw new MolbioInputError('this deployment provides no web fetch capability, so PubMed abstracts cannot be retrieved; molbio_pubmed_search still works for titles and snippets');
6968
6999
  }
6969
- const apiKey = pubmedApiKey(args);
7000
+ const apiKey = await pubmedKey(args, ctx);
6970
7001
  const url = pubmedFetchUrl(pmids, apiKey);
6971
7002
  const result = await web.fetch({ url }, exec?.signal);
6972
7003
  const body = result?.body?.content;
@@ -7318,14 +7349,14 @@ function paperServiceDeps(ctx, exec, args) {
7318
7349
 
7319
7350
  const pubmedSearchTool = (ctx) => define({
7320
7351
  name: 'molbio_pubmed_search',
7321
- description: 'Search PubMed for literature. With an NCBI API key (the api_key argument, or NCBI_API_KEY / MOLBIO_NCBI_API_KEY in the environment) it queries the official E-utilities API and returns PubMed records with PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web-search service. Add the papers you keep to the reading library with molbio_paper_add.',
7352
+ description: 'Search PubMed for literature. With an NCBI API key (the api_key argument, the harness credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it queries the official E-utilities API and returns PubMed records with PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web-search service. Add the papers you keep to the reading library with molbio_paper_add.',
7322
7353
  parameters: {
7323
7354
  type: 'object',
7324
7355
  required: ['query'],
7325
7356
  properties: {
7326
7357
  query: requiredString('Search query, e.g. "CRISPR base editing review 2024" or "site:pubmed.ncbi.nlm.nih.gov KRAS G12D".'),
7327
7358
  max_results: { type: 'integer', description: 'Maximum results (1-20, default 8).' },
7328
- api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API. Prefer setting NCBI_API_KEY in the environment: an inline key appears in the conversation transcript.' },
7359
+ api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API. Resolution order: this argument, the harness credential store (NCBI_API_KEY / MOLBIO_NCBI_API_KEY), then the environment. Prefer the credential store: an inline key appears in the conversation transcript.' },
7329
7360
  },
7330
7361
  },
7331
7362
  outputSchema: {
@@ -7378,7 +7409,7 @@ const pubmedSearchTool = (ctx) => define({
7378
7409
  throw new MolbioInputError('max_results must be an integer between 1 and 20');
7379
7410
  }
7380
7411
  const web = ctx.get('web');
7381
- const apiKey = pubmedApiKey(args);
7412
+ const apiKey = await pubmedKey(args, ctx);
7382
7413
  if (apiKey !== undefined) {
7383
7414
  if (web === undefined || typeof web.fetch !== 'function') {
7384
7415
  throw new MolbioInputError('an NCBI API key was provided, but this deployment provides no web fetch capability; the official PubMed API needs ctx.web.fetch');
@@ -7574,9 +7605,9 @@ const PROMPT_SECTION = `Molecular-biology tools (dsh-molbio-tools) are available
7574
7605
  - Sequences & files: molbio_align (local alignment with a readable match line), molbio_msa_align (progressive multiple sequence alignment — pass an array of sequences or a workspace FASTA), molbio_conservation (consensus + per-column identity/conservation + variable positions; accepts aligned rows or raw sequences it aligns first), molbio_sequence_logo (draws the same alignment as a sequence logo SVG — pass the molbio_msa_align output rows as the alignment argument; letters are sized by per-position information content), molbio_fasta_fastq (FASTA/FASTQ stats/extract/convert/QC on workspace files), molbio_batch_analyze (run one summary/ORF/enzyme analysis over every matching FASTA/FASTQ/GenBank/.dna file in a directory and write one CSV/TSV table), molbio_extract_region (pull a CDS/promoter/region from a plasmid file by feature label or coordinates).
7575
7606
  - CRISPR: molbio_grna_design (family-aware guide RNAs over a sequence or plasmid file: cas_family spcas9/sacas9/cas12a/cas13a/cas13b/cas13d selects the PAM side, crRNA length, seed and target type — Cas12a uses a 5' TTTV PAM and 23 nt crRNA; Cas13 targets RNA and reports a PFS preference rather than a PAM. Both strands are scanned for DNA families, the sense strand by default for Cas13; guides are filtered on GC/poly-T/self-complementarity and ranked, with a mismatch-tolerant off-target search over the same sequence — reaching a whole genome simply means passing it as the target). Pass the top candidates on for ordering with save_path (CSV) or map_path (SVG map); the ranking is a heuristic and says so, so present it as candidate ranking, never as a validated efficiency prediction.
7576
7607
  - Map extras: pass gc_skew: true for the GC skew ring, show_unique_cutters: true to mark single-cutting enzymes on the map.
7577
- - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; pass api_key or set NCBI_API_KEY to use your NCBI key), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7608
+ - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; an NCBI key comes from the api_key argument, the credential store's NCBI_API_KEY, or the environment), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7578
7609
  - Bench analysis (v19): molbio_fastq_qc (read-level QC for a FASTQ file: per-base quality with quartiles, per-read quality, base content, GC distribution, length, exact duplication, over-represented sequences, adapter screen, plus a report SVG — use it for "is this run good"), molbio_codon_usage (CAI/RSCU/Nc/GC3, rare-codon list and CDS CpG against E. coli/yeast/human — use it for "will this gene express"; it explains, molbio_codon_optimize rewrites), molbio_phylogenetic_tree (distance method: p-distance/JC/K2P/Tamura-Nei, UPGMA or neighbour-joining, bootstrap support, consensus, Newick file and an SVG tree — it is NOT a maximum-likelihood or Bayesian analysis, and bootstrap percentages are not p-values), molbio_pcr_simulate (where a primer pair amplifies: product sizes and coordinates on both strands, mismatch detail, the 3'-end exact-match requirement, circular templates, screening other templates, and an expected gel — use it for "what band will I see"), molbio_gc_composition (sliding-window GC, CpG islands under the Gardiner-Garden or Takai criteria, GC/AT skew with the cumulative-skew origin/terminus indication, dinucleotide obs/exp, entropy, complexity, N50/L50, plus a figure — use it for "is this a promoter or an origin").
7579
- - Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, or NCBI_API_KEY / MOLBIO_NCBI_API_KEY in the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7610
+ - Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, the credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7580
7611
 
7581
7612
  Cloning workflow rules (follow them to avoid slow re-derivation):
7582
7613
  - To clone a sequence into a plasmid, call molbio_clone_simulate FIRST — pass the BARE insert with add_flanks: true and the tool adds the enzyme recognition sites itself. The tool also validates single cutting, insert orientation, and internal sites itself: never pre-verify enzymes, cutting counts, or flank construction by thinking. Its output already contains final_sequence AND the remapped feature coordinates (features field). NEVER recompute feature coordinates or insert positions by reasoning — always take them from the tool output.
package/lib/client.js CHANGED
@@ -8517,6 +8517,44 @@ window.__ModuleLoader__.load({
8517
8517
  return undefined;
8518
8518
  }
8519
8519
 
8520
+ /** The credential reference the PubMed tools read first. */
8521
+ const NCBI_KEY_REF = 'NCBI_API_KEY';
8522
+
8523
+ /**
8524
+ * Whether a Plugins-page detail subject is this package's own entry.
8525
+ *
8526
+ * Bundles and rows both expose `pkg.name`; the key section is offered on the
8527
+ * bundle page and on every row page the bundle declares, and on nothing else.
8528
+ */
8529
+ function isMolbioDetailSubject(subject) {
8530
+ if (subject === null || typeof subject !== 'object') return false;
8531
+ if (subject.kind !== 'bundle' && subject.kind !== 'row') return false;
8532
+ const pkg = subject.pkg;
8533
+ return pkg !== null && typeof pkg === 'object' && pkg.name === 'dsh-molbio-tools';
8534
+ }
8535
+
8536
+ /**
8537
+ * The host failure message carried by a Remote result, or '' on success.
8538
+ * Remote calls answer `{ ok: true, value }` or `{ ok: false, error }`.
8539
+ */
8540
+ function remoteFailure(response) {
8541
+ if (response === null || typeof response !== 'object' || response.ok !== false) return '';
8542
+ const message = response.error?.message;
8543
+ return typeof message === 'string' && message !== '' ? message : 'the host refused the request';
8544
+ }
8545
+
8546
+ /** One reference's view from a `credentials.describe` response (never the value). */
8547
+ function credentialInfo(response, ref) {
8548
+ const view = response !== null && typeof response === 'object' && response.ok !== false
8549
+ ? response.value?.[ref]
8550
+ : undefined;
8551
+ return {
8552
+ configured: view?.configured === true,
8553
+ writable: view?.writable !== false,
8554
+ ...typeof view?.source === 'string' && view.source !== '' ? { source: view.source } : {},
8555
+ };
8556
+ }
8557
+
8520
8558
  exports.PLASMID_EXTENSIONS = PLASMID_EXTENSIONS;
8521
8559
  exports.ALIGNMENT_EXTENSIONS = ALIGNMENT_EXTENSIONS;
8522
8560
  exports.extensionOf = extensionOf;
@@ -8541,6 +8579,10 @@ window.__ModuleLoader__.load({
8541
8579
  exports.paperFields = paperFields;
8542
8580
  exports.paperSummary = paperSummary;
8543
8581
  exports.paperLink = paperLink;
8582
+ exports.NCBI_KEY_REF = NCBI_KEY_REF;
8583
+ exports.isMolbioDetailSubject = isMolbioDetailSubject;
8584
+ exports.remoteFailure = remoteFailure;
8585
+ exports.credentialInfo = credentialInfo;
8544
8586
  return exports;
8545
8587
  };
8546
8588
  __molbio_modules["build/client-entry.mjs"] = () => {
@@ -8577,7 +8619,7 @@ window.__ModuleLoader__.load({
8577
8619
  * inside the factory would also have to be torn down by hand on unload.
8578
8620
  */
8579
8621
  const { createElement: h, useEffect, useMemo, useRef, useState } = __ext0;
8580
- const { LIBRARY_FILE, childPath, classifyEntry, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
8622
+ const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
8581
8623
  /** This implementation's identity, and the key its body/title register under. */
8582
8624
  const PANEL_ID = 'dsh-molbio-tools';
8583
8625
  /** Type discriminator the tab is opened by. */
@@ -9015,6 +9057,142 @@ window.__ModuleLoader__.load({
9015
9057
  h('div', { style: styles.main }, ...detail));
9016
9058
  }
9017
9059
 
9060
+ /** The state of the PubMed key, read from the host's credential store. */
9061
+ async function readNcbiKey(remote) {
9062
+ if (remote?.credentials === undefined || typeof remote.credentials.describe !== 'function') {
9063
+ return { phase: 'error', configured: false, writable: false, message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' };
9064
+ }
9065
+ let response;
9066
+ try {
9067
+ response = await remote.credentials.describe([NCBI_KEY_REF]);
9068
+ } catch (error) {
9069
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
9070
+ }
9071
+ const failure = remoteFailure(response);
9072
+ if (failure !== '') return { phase: 'error', configured: false, writable: false, message: failure };
9073
+ return { phase: 'ready', ...credentialInfo(response, NCBI_KEY_REF), message: '' };
9074
+ }
9075
+
9076
+ const keyStyles = {
9077
+ section: { borderTop: '1px solid rgba(127, 127, 127, 0.28)', marginTop: 16, paddingTop: 12, display: 'grid', gap: 8 },
9078
+ title: { margin: 0, fontSize: 14 },
9079
+ note: { margin: 0, fontSize: 12, opacity: 0.75, lineHeight: 1.5 },
9080
+ status: { margin: 0, fontSize: 12 },
9081
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
9082
+ input: { flex: '1 1 240px', minWidth: 200, padding: '4px 8px' },
9083
+ message: { margin: 0, fontSize: 12 },
9084
+ };
9085
+
9086
+ /**
9087
+ * The Plugins → installed plugin → detail section that stores the NCBI key.
9088
+ *
9089
+ * It renders only for this package's own subject (bundle page, or one of the
9090
+ * row pages the bundle declares) and returns null everywhere else, which is the
9091
+ * contract `plugins.detail.section` asks a contribution to honour. The value is
9092
+ * write-only from here: `credentials.describe` answers whether a key is
9093
+ * configured and where it came from, never the key itself.
9094
+ */
9095
+ function NcbiKeySection({ subject, remote }) {
9096
+ const [draft, setDraft] = useState('');
9097
+ const [state, setState] = useState({ phase: 'loading', configured: false, writable: true, message: '' });
9098
+ const subjectToken = isMolbioDetailSubject(subject)
9099
+ ? `${subject.kind}:${subject.pkg.name}:${subject.row?.rowId ?? ''}`
9100
+ : '';
9101
+ useEffect(() => {
9102
+ if (subjectToken === '') return undefined;
9103
+ let live = true;
9104
+ const load = () => readNcbiKey(remote).then((next) => { if (live) setState(next); });
9105
+ load();
9106
+ // Another surface may rotate or clear the same reference (Settings, the
9107
+ // file watcher); follow the host event instead of showing a stale badge.
9108
+ const off = typeof remote?.$on === 'function'
9109
+ ? remote.$on('credentials/reference-updated', (ref) => { if (ref === NCBI_KEY_REF) load(); })
9110
+ : undefined;
9111
+ return () => {
9112
+ live = false;
9113
+ if (typeof off === 'function') off();
9114
+ };
9115
+ }, [remote, subjectToken]);
9116
+ if (!isMolbioDetailSubject(subject)) return null;
9117
+ const busy = state.phase === 'saving';
9118
+ const save = async () => {
9119
+ const value = draft.trim();
9120
+ if (value === '') return;
9121
+ if (remote?.credentials === undefined || typeof remote.credentials.set !== 'function') {
9122
+ setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
9123
+ return;
9124
+ }
9125
+ setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
9126
+ let response;
9127
+ try {
9128
+ response = await remote.credentials.set(NCBI_KEY_REF, value);
9129
+ } catch (error) {
9130
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
9131
+ }
9132
+ const failure = remoteFailure(response);
9133
+ setDraft('');
9134
+ const next = await readNcbiKey(remote);
9135
+ setState(failure !== ''
9136
+ ? { ...next, phase: 'error', message: failure }
9137
+ : next.phase === 'error'
9138
+ ? next
9139
+ : next.configured === true
9140
+ ? { ...next, message: 'Saved. The next PubMed call uses this key.' }
9141
+ : { ...next, phase: 'error', message: 'The host accepted the write but still reports no key configured. Restart the profile and try again.' });
9142
+ };
9143
+ const remove = async () => {
9144
+ if (remote?.credentials === undefined || typeof remote.credentials.unset !== 'function') {
9145
+ setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
9146
+ return;
9147
+ }
9148
+ setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
9149
+ let response;
9150
+ try {
9151
+ response = await remote.credentials.unset(NCBI_KEY_REF);
9152
+ } catch (error) {
9153
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
9154
+ }
9155
+ const failure = remoteFailure(response);
9156
+ const next = await readNcbiKey(remote);
9157
+ setState(failure !== ''
9158
+ ? { ...next, phase: 'error', message: failure }
9159
+ : next.phase === 'error'
9160
+ ? next
9161
+ : next.configured === false
9162
+ ? { ...next, message: 'Removed. PubMed search falls back to web search.' }
9163
+ : { ...next, phase: 'error', message: 'The host still reports a key configured after Remove.' });
9164
+ };
9165
+ const status = state.phase === 'loading'
9166
+ ? 'Checking the credential store…'
9167
+ : state.phase === 'error'
9168
+ ? 'Unavailable'
9169
+ : state.configured
9170
+ ? `Configured${state.source === undefined ? '' : ` (source: ${state.source})`}`
9171
+ : 'Not configured';
9172
+ return h('section', { 'data-molbio-ncbi-key': '', style: keyStyles.section },
9173
+ h('h4', { style: keyStyles.title }, 'PubMed API key'),
9174
+ h('p', { style: keyStyles.note },
9175
+ '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.'),
9176
+ h('p', { 'data-molbio-key-status': state.phase, style: keyStyles.status }, status),
9177
+ h('div', { style: keyStyles.row },
9178
+ h('input', {
9179
+ type: 'password',
9180
+ value: draft,
9181
+ placeholder: 'NCBI API key (36 characters)',
9182
+ autoComplete: 'off',
9183
+ spellCheck: false,
9184
+ 'aria-label': 'NCBI API key',
9185
+ style: keyStyles.input,
9186
+ onChange: (event) => setDraft(event.target.value),
9187
+ }),
9188
+ h('button', { type: 'button', disabled: busy || state.writable === false || draft.trim() === '', onClick: save },
9189
+ busy ? 'Saving…' : 'Save'),
9190
+ h('button', { type: 'button', disabled: busy || state.writable === false || state.configured !== true, onClick: remove }, 'Remove')),
9191
+ state.message === '' ? null : h('p', { 'data-molbio-key-message': '', role: 'status', style: keyStyles.message }, state.message),
9192
+ h('p', { style: keyStyles.note },
9193
+ 'Create a key under NCBI → Account Settings → API Key Management. Prefer this over an environment variable: the store is not inherited by shell commands.'));
9194
+ }
9195
+
9018
9196
  /** Required browser services. */
9019
9197
  const inject = ['slots', 'sidebarRightTabs', 'remote'];
9020
9198
 
@@ -9098,6 +9276,25 @@ window.__ModuleLoader__.load({
9098
9276
  key: toolName,
9099
9277
  }, MolbioMapCard)), `molbio panel: ${toolName} card`);
9100
9278
  }
9279
+ // Plugins -> the installed plugin's detail page: the one place a user can
9280
+ // store the NCBI key without editing a YAML file or exporting a variable.
9281
+ //
9282
+ // `remote.credentials` is a FIBER INJECTION, not merely a property on the
9283
+ // Remote carrier: the shipped settings pages declare it in `inject` for the
9284
+ // same reason. Without the declaration `ctx.remote.credentials` is undefined,
9285
+ // which is exactly how the first build of this section made Save a no-op.
9286
+ // The gate keeps the panel working in deployments that expose no credentials
9287
+ // namespace: the section simply never registers there.
9288
+ ctx.effect(() => {
9289
+ const gate = ctx.inject(['remote.credentials'], (keyCtx) => {
9290
+ keyCtx.effect(() => keyCtx.slots.inject('plugins.detail.section', () => keyCtx.slots.register({
9291
+ name: 'plugins.detail.section',
9292
+ id: 'molbio-ncbi-key',
9293
+ order: 100,
9294
+ }, (props) => h(NcbiKeySection, { ...props, remote: keyCtx.remote }))), 'molbio panel: NCBI key section');
9295
+ });
9296
+ return () => { gate.dispose(); };
9297
+ }, 'molbio panel: NCBI key section gate');
9101
9298
  }
9102
9299
 
9103
9300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-molbio-tools",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel and hydropathy plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, read-level FASTQ QC, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
package/test/client.mjs CHANGED
@@ -101,6 +101,7 @@ assert.deepEqual([...module.inject], ['slots', 'sidebarRightTabs', 'remote'], 't
101
101
 
102
102
  const effects = [];
103
103
  const tabTypes = [];
104
+ const injectedDeps = [];
104
105
  const slots = createSlotsStub();
105
106
  const ctx = {
106
107
  remote: {
@@ -122,9 +123,16 @@ const ctx = {
122
123
  effects.push(label);
123
124
  return factory();
124
125
  },
126
+ inject(deps, callback) {
127
+ injectedDeps.push([...deps]);
128
+ const child = Object.create(ctx);
129
+ child.remote = { ...ctx.remote, credentials: {} };
130
+ return { dispose() {}, value: callback(child) };
131
+ },
125
132
  sidebarRightTabs: { register(definition) { tabTypes.push(definition); return () => undefined; } },
126
133
  slots,
127
134
  };
135
+ assert.deepEqual(injectedDeps, [], 'no dependency gate has run yet');
128
136
  assert.doesNotThrow(() => module.apply(ctx), 'apply() must not require any seat to be declared yet');
129
137
 
130
138
  assert.deepEqual(effects, [
@@ -136,7 +144,10 @@ assert.deepEqual(effects, [
136
144
  'molbio panel: papers tab title',
137
145
  'molbio panel: molbio_plasmid_map card',
138
146
  'molbio panel: molbio_plasmid_map_file card',
147
+ 'molbio panel: NCBI key section gate',
148
+ 'molbio panel: NCBI key section',
139
149
  ]);
150
+ assert.deepEqual(injectedDeps, [['remote.credentials']], 'the key section waits for the credentials Remote namespace');
140
151
  assert.equal(tabTypes.length, 2, 'the package contributes two page tab types');
141
152
  assert.deepEqual(
142
153
  tabTypes.map((type) => [type.id, type.kind, type.title()]),
@@ -153,7 +164,7 @@ assert.deepEqual(tabTypes.map((type) => type.guide[0].order), [40, 41], 'the gui
153
164
  assert.equal(slots.registrations.length, 0, 'nothing registers before its seat exists');
154
165
  assert.deepEqual(
155
166
  slots.injectedSeats,
156
- ['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview'],
167
+ ['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview', 'plugins.detail.section'],
157
168
  'each seat is claimed by waiting for its declaration, not by registering blindly',
158
169
  );
159
170
  // The shell's guard, reproduced on the stub: this is the exact exception a bare
@@ -169,8 +180,8 @@ slots.declare('sidebar.right.pane.tab');
169
180
  slots.declare('sidebar.right.pane.tab.title');
170
181
  assert.deepEqual(
171
182
  slots.pendingSeats,
172
- ['tool.call.toolview'],
173
- 'the transcript card is still waiting for the seat ui-tool declares',
183
+ ['tool.call.toolview', 'plugins.detail.section'],
184
+ 'the transcript card and the plugin-detail section wait for the seats their owners declare',
174
185
  );
175
186
  assert.equal(slots.registrations.length, 4, 'both tab bodies and titles land as soon as their seat exists');
176
187
  slots.declare('tool.call.toolview');
@@ -211,6 +222,39 @@ assert.equal(claim('tool.call.toolview', 'molbio_plasmid_map').registration.inje
211
222
  slots.declare('tool.call.toolview');
212
223
  assert.equal(slots.registrations.length, 6, 're-declaring the seat re-installs both cards instead of duplicating them');
213
224
 
225
+ // The Plugins page's detail seat belongs to the plugin manager. The section
226
+ // lands when that seat is declared, and the component itself decides whether
227
+ // the open subject is this package.
228
+ slots.declare('plugins.detail.section');
229
+ assert.equal(slots.registrations.length, 7, 'the key section lands when the plugin manager declares its seat');
230
+ const keyClaim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
231
+ assert.ok(keyClaim, 'the key section is claimed in plugins.detail.section');
232
+ assert.equal(keyClaim.registration.id, 'molbio-ncbi-key', 'the list seat is addressed by an id of our own');
233
+ assert.equal(typeof keyClaim.component, 'function', 'the section is a component');
234
+ // The registered component binds `remote`; invoking its element type is how a
235
+ // test renders it without React (the hooks are stubbed by this file's reactStub).
236
+ const ours = keyClaim.component({ subject: { kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } } });
237
+ assert.equal(typeof ours.type, 'function', 'the wrapper renders this package\'s section');
238
+ const sectionTree = ours.type(ours.props);
239
+ assert.equal(sectionTree.type, 'section');
240
+ assert.equal(sectionTree.props['data-molbio-ncbi-key'], '');
241
+ const flat = [];
242
+ const walk = (node) => {
243
+ if (node === null || typeof node !== 'object') return;
244
+ flat.push(node);
245
+ for (const child of Array.isArray(node.children) ? node.children : []) walk(child);
246
+ };
247
+ walk(sectionTree);
248
+ assert.ok(flat.some((node) => node.type === 'h4' && node.children.join('') === 'PubMed API key'), 'the section is titled');
249
+ assert.ok(flat.some((node) => node.type === 'input' && node.props.type === 'password'), 'the section offers a password input');
250
+ assert.ok(flat.some((node) => node.type === 'button' && node.children.join('') === 'Save'), 'the section offers a save control');
251
+ const renderFor = (subject) => {
252
+ const wrapper = keyClaim.component({ subject });
253
+ return wrapper.type(wrapper.props);
254
+ };
255
+ assert.equal(renderFor({ kind: 'bundle', pkg: { name: 'another-plugin' } }), null, 'another package gets no section');
256
+ assert.equal(renderFor({ kind: 'item', id: 'web-search' }), null, 'an official-plugin page gets no section');
257
+
214
258
  // ── 3. the panel's data path, against the real fixture ──────────────────────
215
259
 
216
260
  assert.equal(panelCore.extensionOf('pUC118.dna'), 'dna');
@@ -231,6 +275,21 @@ const sorted = panelCore.sortEntries([
231
275
  ]);
232
276
  assert.deepEqual(sorted.map((entry) => entry.name), ['a.dna', 'z.dna', 'a.fasta', 'notes.md']);
233
277
 
278
+ // The plugin-detail key section's pure helpers: which subject it belongs to,
279
+ // how a credential view is read, and how a refused Remote call is surfaced.
280
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } }), true);
281
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'row', pkg: { name: 'dsh-molbio-tools' }, row: { rowId: 'molbio-client' } }), true);
282
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'bundle', pkg: { name: 'another-plugin' } }), false);
283
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'item', id: 'dsh-molbio-tools' }), false, 'official-plugin pages are not ours');
284
+ assert.equal(panelCore.isMolbioDetailSubject(null), false);
285
+ assert.deepEqual(
286
+ panelCore.credentialInfo({ ok: true, value: { NCBI_API_KEY: { configured: true, source: 'file', writable: true } } }, 'NCBI_API_KEY'),
287
+ { configured: true, writable: true, source: 'file' },
288
+ );
289
+ assert.deepEqual(panelCore.credentialInfo({ ok: true, value: {} }, 'NCBI_API_KEY'), { configured: false, writable: true });
290
+ assert.equal(panelCore.remoteFailure({ ok: false, error: { message: 'credential/rejected: the store is unreadable' } }), 'credential/rejected: the store is unreadable');
291
+ assert.equal(panelCore.remoteFailure({ ok: true, value: {} }), '', 'a successful result carries no failure');
292
+
234
293
  assert.equal(panelCore.childPath('D:\\work', 'a.dna'), 'D:\\work/a.dna');
235
294
  assert.equal(panelCore.childPath('', 'a.dna'), 'a.dna');
236
295
 
package/test/contract.mjs CHANGED
@@ -277,6 +277,24 @@ check('the built bundle registers the seats and services the framework expects',
277
277
  // The inject factory must hand over exactly the services the framework does
278
278
  // NOT provide, so it can never shadow sessionId or a synthesised hook.
279
279
  assert.ok(/inject: \(\) => \(\{ remote \}\)/.test(bundle), 'the body injects only the Remote face');
280
+
281
+ // The plugin-detail key section depends on TWO upstream facts: the plugin
282
+ // manager still declares the seat, and the settings controller still exposes
283
+ // the credentials Remote namespace with the argument order we call. Both are
284
+ // silent if they move (the section simply never renders), so pin them here.
285
+ assert.ok(bundle.includes('plugins.detail.section'), 'the bundle waits for the plugin-manager detail seat');
286
+ for (const call of ['credentials.describe(', 'credentials.set(', 'credentials.unset(']) {
287
+ assert.ok(bundle.includes(call), `the key section calls ${call}…)`);
288
+ }
289
+ assert.ok(bundle.includes("ctx.inject(['remote.credentials']"), 'the key section declares the credentials FIBER INJECTION (a plain property is undefined)');
290
+ const manager = clientBundle('@deepseek-ai/dsh-client-ui-plugin-manager');
291
+ assert.ok(manager !== undefined && manager.text.includes('plugins.detail.section'), 'the plugin manager still declares plugins.detail.section');
292
+ const settings = hostHalf('@deepseek-ai/dsh-api-settings-controller');
293
+ assert.ok(settings !== undefined, 'the settings controller is installed');
294
+ assert.ok(settings.text.includes('namespace: "credentials"'), 'the credentials Remote namespace still exists');
295
+ assert.match(settings.text, /async describe\(refs\)/, 'describe takes the reference list first');
296
+ assert.match(settings.text, /async set\(ref, value\)/, 'set takes (ref, value)');
297
+ assert.match(settings.text, /async unset\(ref\)/, 'unset takes the reference');
280
298
  });
281
299
 
282
300
  check('the package manifest keeps the client declaration the scan keys on', () => {
@@ -460,10 +478,12 @@ check('every seat claim waits for the declaration (slots.inject, not a bare regi
460
478
 
461
479
  // Our artifact: every `register` must sit inside a wait for that same seat.
462
480
  const bundle = readFileSync(join(packageRoot, 'lib', 'client.js'), 'utf8');
463
- const waited = [...bundle.matchAll(/slots\.inject\('([^']+)', \(\) => (?:ctx\.)?slots\.register\(\{\s*name: '([^']+)'/g)];
481
+ // The receiver may be a fiber context (`keyCtx.slots`), not only `ctx.slots`:
482
+ // the key section registers inside a ctx.inject gate.
483
+ const waited = [...bundle.matchAll(/slots\.inject\('([^']+)', \(\) => (?:[A-Za-z_$][\w$]*\.)?slots\.register\(\{\s*name: '([^']+)'/g)];
464
484
  assert.ok(waited.length > 0, 'the bundle claims its seats through slots.inject');
465
485
  for (const [, seat, name] of waited) assert.equal(seat, name, `the claim of ${name} waits for the seat it registers into`);
466
- const registers = (bundle.match(/(?:ctx\.)?slots\.register\(/g) ?? []).length;
486
+ const registers = (bundle.match(/(?:[A-Za-z_$][\w$]*\.)?slots\.register\(/g) ?? []).length;
467
487
  assert.equal(registers, waited.length, 'and no registration exists outside such a wait');
468
488
  });
469
489
 
package/test/map-card.mjs CHANGED
@@ -253,12 +253,14 @@ function mount(Component) {
253
253
  const registrations = [];
254
254
  const module = await import(entryPath.href);
255
255
  const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview']);
256
- module.apply({
256
+ const cardCtx = {
257
257
  remote: {},
258
258
  effect: (factory) => factory(),
259
+ inject: (_deps, callback) => ({ dispose() {}, value: callback(cardCtx) }),
259
260
  sidebarRightTabs: { register: (definition) => registrations.push(definition) },
260
261
  slots,
261
- });
262
+ };
263
+ module.apply(cardCtx);
262
264
  const MapCard = slots.registrations.find(
263
265
  (entry) => entry.registration.name === 'tool.call.toolview' && entry.registration.key === 'molbio_plasmid_map',
264
266
  ).component;
@@ -308,12 +308,17 @@ const module = await (async () => {
308
308
  }
309
309
  })();
310
310
 
311
- const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview']);
311
+ const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview', 'plugins.detail.section']);
312
312
  const ctx = {
313
313
  remote: remote({}),
314
314
  effect(factory) {
315
315
  return factory();
316
316
  },
317
+ // `apply` gates the key section on the credentials Remote namespace; this
318
+ // file supplies every dependency it asks for, like the default composition.
319
+ inject(_deps, callback) {
320
+ return { dispose() {}, value: callback(ctx) };
321
+ },
317
322
  sidebarRightTabs: { register: (definition) => registrations.push(definition) },
318
323
  slots,
319
324
  };
@@ -594,6 +599,77 @@ const library = {
594
599
  harness.unmount();
595
600
  }
596
601
 
602
+ // ── the plugin-detail key section (write-only credential form) ─────────────
603
+
604
+ {
605
+ const claim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
606
+ assert.ok(claim, 'the key section registered once its seat was declared');
607
+ const store = { value: undefined, describe: [], set: [], unset: [], refuse: '' };
608
+ ctx.remote.credentials = {
609
+ async describe(refs) {
610
+ store.describe.push(refs);
611
+ return {
612
+ ok: true,
613
+ value: {
614
+ NCBI_API_KEY: {
615
+ configured: store.value !== undefined,
616
+ writable: true,
617
+ ...store.value === undefined ? {} : { source: 'file' },
618
+ },
619
+ },
620
+ };
621
+ },
622
+ async set(ref, value) {
623
+ store.set.push([ref, value]);
624
+ if (store.refuse !== '') return { ok: false, error: { message: store.refuse } };
625
+ store.value = value;
626
+ return { ok: true, value: null };
627
+ },
628
+ async unset(ref) {
629
+ store.unset.push(ref);
630
+ store.value = undefined;
631
+ return { ok: true, value: null };
632
+ },
633
+ };
634
+ const subject = { kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } };
635
+ const wrapper = claim.component({ subject });
636
+ const harness = mount(wrapper.type);
637
+ globalThis.__molbioTestHooks = harness;
638
+ harness.setProps({ subject, remote: ctx.remote });
639
+ await harness.render();
640
+ assert.deepEqual(store.describe[0], ['NCBI_API_KEY'], 'the section asks only about its own reference');
641
+ assert.match(markup(harness.tree), /Not configured/, 'an empty store reads as not configured');
642
+ const input = elements(harness.tree, 'input')[0];
643
+ assert.equal(input.props.type, 'password', 'the key is typed into a password field');
644
+ input.props.onChange({ target: { value: ' key-123 ' } });
645
+ await harness.render();
646
+ const save = elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Save');
647
+ await save.props.onClick();
648
+ await flush();
649
+ await harness.render();
650
+ assert.deepEqual(store.set, [['NCBI_API_KEY', 'key-123']], 'Save trims and stores under NCBI_API_KEY');
651
+ assert.ok(store.describe.length >= 2, 'the status is re-read after the write');
652
+ assert.match(markup(harness.tree), /Configured \(source: file\)/, 'the status names the source, never the value');
653
+ assert.equal(elements(harness.tree, 'input')[0].props.value, '', 'the draft is cleared after saving');
654
+
655
+ const remove = elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Remove');
656
+ await remove.props.onClick();
657
+ await flush();
658
+ await harness.render();
659
+ assert.deepEqual(store.unset, ['NCBI_API_KEY'], 'Remove deletes the reference');
660
+ assert.match(markup(harness.tree), /Not configured/, 'the status follows the removal');
661
+
662
+ // A provider refusal is shown verbatim instead of being swallowed.
663
+ store.refuse = 'credential/rejected: the credentials file is unreadable';
664
+ elements(harness.tree, 'input')[0].props.onChange({ target: { value: 'x' } });
665
+ await harness.render();
666
+ await elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Save').props.onClick();
667
+ await flush();
668
+ await harness.render();
669
+ assert.ok(markup(harness.tree).includes('credential/rejected: the credentials file is unreadable'), 'a refused write keeps the host message');
670
+ harness.unmount();
671
+ }
672
+
597
673
  // ── effects are disposed on unmount ─────────────────────────────────────────
598
674
 
599
675
  {
package/test/smoke.mjs CHANGED
@@ -1989,6 +1989,55 @@ delete process.env.MOLBIO_NCBI_API_KEY;
1989
1989
  }
1990
1990
  }
1991
1991
 
1992
+ {
1993
+ // the harness credential store outranks the environment without ever
1994
+ // materializing the key in process.env; an explicit argument still wins.
1995
+ const saved = process.env.NCBI_API_KEY;
1996
+ process.env.NCBI_API_KEY = 'env-loses';
1997
+ extraServices.set('credentials', {
1998
+ async resolve(ref) {
1999
+ return ref === 'NCBI_API_KEY' ? { value: 'store-key', source: 'store' } : undefined;
2000
+ },
2001
+ });
2002
+ try {
2003
+ mockWeb.calls.length = 0;
2004
+ const stored = await run('molbio_pubmed_search', { query: 'stored key' });
2005
+ assert.equal(stored.backend, 'pubmed-eutils');
2006
+ assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=store-key')));
2007
+
2008
+ mockWeb.calls.length = 0;
2009
+ const explicit = await run('molbio_pubmed_search', { query: 'explicit key', api_key: 'arg-key' });
2010
+ assert.equal(explicit.backend, 'pubmed-eutils');
2011
+ assert.ok(mockWeb.calls.every((url) => url.includes('api_key=arg-key')));
2012
+ } finally {
2013
+ extraServices.delete('credentials');
2014
+ delete process.env.NCBI_API_KEY;
2015
+ if (saved !== undefined) process.env.NCBI_API_KEY = saved;
2016
+ }
2017
+ }
2018
+
2019
+ {
2020
+ // a credential provider that refuses the ref must degrade to the environment,
2021
+ // not break the tool (a composition may have no store at all).
2022
+ const saved = process.env.NCBI_API_KEY;
2023
+ process.env.NCBI_API_KEY = 'env-fallback';
2024
+ extraServices.set('credentials', {
2025
+ async resolve() {
2026
+ throw new Error('no credential store in this composition');
2027
+ },
2028
+ });
2029
+ try {
2030
+ mockWeb.calls.length = 0;
2031
+ const out = await run('molbio_pubmed_search', { query: 'fallback' });
2032
+ assert.equal(out.backend, 'pubmed-eutils');
2033
+ assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=env-fallback')));
2034
+ } finally {
2035
+ extraServices.delete('credentials');
2036
+ delete process.env.NCBI_API_KEY;
2037
+ if (saved !== undefined) process.env.NCBI_API_KEY = saved;
2038
+ }
2039
+ }
2040
+
1992
2041
  {
1993
2042
  // an invalid key is reported as such, not presented as "no results"
1994
2043
  await assert.rejects(() => run('molbio_pubmed_search', { query: 'x', api_key: 'bad-key' }), /API key invalid/);