dsh-runtime-reconcile 0.0.0-stage → 0.2.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 +29 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/README.zh.md +212 -0
- package/cordis.patch.yml +10 -0
- package/core.js +413 -0
- package/icon.svg +5 -0
- package/index.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -4
- package/test/selftest.mjs +195 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
Discoverability and metadata only — no behavior changes, no new permissions, still zero I/O.
|
|
6
|
+
|
|
7
|
+
- Package metadata: `displayName`, `icon`, expanded `keywords` (npm search), `homepage`.
|
|
8
|
+
- README (EN/ZH): npm/version/download badges, a 30-second quick-start, and a one-command install up top.
|
|
9
|
+
- GitHub repository: shorter searchable `description`, npm `homepage`, and expanded `topics` for ecosystem discovery.
|
|
10
|
+
- Runtime-reported version in `core.js` synced to 0.2.1.
|
|
11
|
+
|
|
12
|
+
## 0.2.0
|
|
13
|
+
|
|
14
|
+
Diagnosis quality and engineering hardening. No new permissions, no new dependencies, still zero I/O.
|
|
15
|
+
|
|
16
|
+
- **Root-cause correlation**: a missing web provider is now linked to the installed-but-disabled bundle that likely provides it. That bundle's finding is promoted from `info` to `warning` and marked `linkedToProviderError`.
|
|
17
|
+
- **Actionable remedies**: error and warning findings carry a `remedy` string (e.g. `Enable bundle "@liustack/modsearch" (or set web.searchProvider to …)`), rendered in the text report.
|
|
18
|
+
- **Three-state provider selection**: besides "not registered", the check now detects a configured provider that is registered but `available()` returns false (`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`), and reports which registered providers are unavailable.
|
|
19
|
+
- **Enabled-but-not-active rows**: `rows` now flags enabled Loader rows whose fiber never reached `active` — the class of silent failure static scanners cannot see — instead of only counting disabled rows.
|
|
20
|
+
- **Removed a no-op check**: `tools.definitions()` returns the already-resolved visible set (a Map keyed by name), so duplicate visible names are impossible. The old duplicate-name detection could never fire. Replaced with real signals: visible count, tools declaring no `output` renderer, and listing cap.
|
|
21
|
+
- **Honest `ok`**: a check group that could not run (missing service, throwing enumeration) now sets `complete: false` and forces `ok: false`. Previously an unreadable registry could still produce a clean bill of health.
|
|
22
|
+
- **Input validation**: an invalid `focus` is rejected instead of silently producing an empty "OK" report; `maxTools` is clamped 0–200.
|
|
23
|
+
- **Report metadata**: adds `complete`, `completeness.checked/unavailable`, `version`, and healthy-path findings (`*_PROVIDER_OK`, `BUNDLES_ALL_ENABLED`, `ROWS_ALL_ACTIVE`).
|
|
24
|
+
- **Structure**: pure inspection logic moved to `core.js` (exported as `./core`, unit-testable). `index.js` now only wires the tool. Added `test/selftest.mjs` (14 checks) and `npm test`.
|
|
25
|
+
- Corrected a latent syntax error in the 0.1.0 source path (`ROWS_ALL_ACTIVE` template literal) that the new module split surfaced; 0.1.0's shipped `index.js` was valid.
|
|
26
|
+
|
|
27
|
+
## 0.1.0
|
|
28
|
+
|
|
29
|
+
Initial release: read-only `reconcile_runtime` tool with five live check groups (providers / bundles / rows / tools / services).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shuai0503
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,214 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-runtime-reconcile
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> A read-only agent tool that reconciles **what your DeepSeek Harness profile declares** against **what is actually registered in the live runtime**.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-runtime-reconcile)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-runtime-reconcile)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://github.com/topics/dsh-plugin)
|
|
9
|
+
|
|
10
|
+
English · [中文](README.zh.md)
|
|
11
|
+
|
|
12
|
+
**30-second start:** `dsh plugin --profile web add dsh-runtime-reconcile` → start a new session → ask your agent `reconcile_runtime`. It will tell you *why* a tool is failing and what to enable.
|
|
13
|
+
|
|
14
|
+
Static source scanners see a valid config. This tool sees the running process. It answers questions those scanners cannot:
|
|
15
|
+
|
|
16
|
+
- Why does `web_search` say *`configured web provider "modsearch" is not registered`* when the config looks fine?
|
|
17
|
+
- Which installed bundle is disabled but still referenced by an active config row?
|
|
18
|
+
- Which tool names are shadowed in the live registry right now?
|
|
19
|
+
- Which core services did this profile fail to mount?
|
|
20
|
+
|
|
21
|
+
It is **read-only**: no file writes, no network calls, no config changes, no runtime imports.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## The problem, concretely
|
|
26
|
+
|
|
27
|
+
DeepSeek Harness is "everything is a plugin". A profile composes dozens of bundles over ordered patch layers. A plugin row can be inserted, disabled, or overridden by a higher layer; a provider can be named in config while the bundle that registers it sits disabled. The composed config remains syntactically valid, so nothing complains at boot — the failure only appears when a tool call reaches provider selection:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
configured web provider "modsearch" is not registered
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`ctx.web` holds `config.searchProvider = 'modsearch'`, but its live `searchProviders` map contains only the providers whose bundles actually mounted. Source-level checks (`dsh-trust-check`, `dsh-plugin-preflight`, `dsh-conflict-doctor`, `dsh-doctor`, `dsh-plugin-ops`) inspect packages, manifests and patch files **before** or **outside** the running composition. They cannot see this drift, because the drift only exists after the Loader has applied every layer.
|
|
34
|
+
|
|
35
|
+
`dsh-runtime-reconcile` runs *inside* that composed process and reads the live services through `ctx.get()`.
|
|
36
|
+
|
|
37
|
+
### How it differs from adjacent tools
|
|
38
|
+
|
|
39
|
+
| Tool | Inspects | Sees live provider maps / Loader rows? |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `dsh-trust-check`, `dsh-plugin-preflight` | package source / manifest | no (pre-install, static) |
|
|
42
|
+
| `dsh-conflict-doctor`, `dsh-plugin-conflict-guard` | declared capabilities | no (static / at install) |
|
|
43
|
+
| `dsh-doctor`, `dsh-plugin-ops` | profile files, boot logs | no (pre-boot / post-mortem) |
|
|
44
|
+
| `dsh-harbor` | declared vs detected ledger | partly (static reconciliation) |
|
|
45
|
+
| `dsh-vitals`, `dsh-system-vitals` | CPU / RAM / GPU | no (hardware, not the plugin graph) |
|
|
46
|
+
| **dsh-runtime-reconcile** | **live `ctx.web`, `ctx.tools`, `ctx.pluginManager`** | **yes** |
|
|
47
|
+
|
|
48
|
+
Ecosystem scale of the underlying problem is documented in [deepseek-harness discussion #4253](https://github.com/deepseek-ai/deepseek-harness/discussions/4253): across 9,873 real plugins, 553 tool-name collisions, 452 entry-id collisions and 89 orphan patches fail silently. This tool does not fix those — the fixes that matter belong upstream — but it makes the *current* profile's state observable to both the user and the agent.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What it checks
|
|
53
|
+
|
|
54
|
+
| Group | Live source | Detects |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `providers` | `ctx.web.config`, `searchProviders` / `fetchProviders` maps, `DSH_WEB_SEARCH_PROVIDER` / `DSH_WEB_FETCH_PROVIDER` | configured-but-unregistered, registered-but-unavailable, no provider, ambiguous providers |
|
|
57
|
+
| `bundles` | `pluginManager.listBundles()` | installed-but-disabled bundles, **correlated as the likely root cause** of a provider error |
|
|
58
|
+
| `rows` | `pluginManager.listPlugins()` | **enabled-but-not-active** Loader rows, disabled rows, active / total counts |
|
|
59
|
+
| `tools` | `tools.definitions()` | visible tool count, tools without an `output` renderer, listing cap |
|
|
60
|
+
| `services` | `ctx.get()` on core seams | missing optional services |
|
|
61
|
+
|
|
62
|
+
Findings carry `severity`, `code`, `title`, `detail`, structured `evidence` and an actionable **`remedy`**. They are **observations, not a security verdict**.
|
|
63
|
+
|
|
64
|
+
A check group that cannot run (missing service, throwing enumeration) is recorded in `completeness.unavailable` and forces `ok: false` / `complete: false` — an unreadable registry is never reported as a clean bill of health. An invalid `focus` is rejected rather than silently yielding an empty OK report.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
One command, no build step, zero dependencies, no network access at runtime:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
dsh plugin --profile web add dsh-runtime-reconcile
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
GitHub source (pin a commit or tag):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
dsh plugin --profile web add github:shuai0503/dsh-runtime-reconcile#v0.2.1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
From npm (no build step, no dependencies):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
dsh plugin --profile web add dsh-runtime-reconcile
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Local development:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
dsh plugin --profile web add file:/absolute/path/to/dsh-runtime-reconcile
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or via the Harness plugin manager / Web GUI: install the bundle directory. GitHub-sourced installs may require allowing a build — **this package has no build scripts and no dependencies**, so there is nothing to allow.
|
|
95
|
+
|
|
96
|
+
Then **start a new session** so the newly registered tool enters the model's tool set.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
Ask your agent, or call directly:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
reconcile_runtime {}
|
|
106
|
+
reconcile_runtime { "focus": "providers" }
|
|
107
|
+
reconcile_runtime { "focus": "all", "maxTools": 200 }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`focus` narrows the output without skipping any safety-relevant inspection; `maxTools` caps the listed tool names (default 40, max 200), not the checks themselves.
|
|
111
|
+
|
|
112
|
+
### Example output
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Runtime reconcile — 1 error(s), 1 warning(s), 4 info
|
|
116
|
+
focus=all complete=true generatedAt=2026-11-14T09:35:06.996Z
|
|
117
|
+
|
|
118
|
+
web.search: configured=modsearch registered=[deepseek-official, web-search-free]
|
|
119
|
+
web.fetch : configured=fs-quality-fetch registered=[fs-quality-fetch, http]
|
|
120
|
+
bundles: 3/4 enabled; disabled=[@liustack/modsearch]
|
|
121
|
+
loader rows: 200/202 active; 2 disabled; 0 enabled-but-not-active
|
|
122
|
+
tools: 5 visible
|
|
123
|
+
|
|
124
|
+
[ERROR] WEB_SEARCH_PROVIDER_MISSING — Configured search provider "modsearch" is not registered
|
|
125
|
+
ctx.web.searchProvider points at "modsearch", but the live searchProviders
|
|
126
|
+
registry contains [deepseek-official, web-search-free]. Every search call fails
|
|
127
|
+
at provider selection. The bundle "@liustack/modsearch" is installed but
|
|
128
|
+
disabled — that is the likely cause.
|
|
129
|
+
evidence: {"configuredFrom":"web.searchProvider","configured":"modsearch",
|
|
130
|
+
"registered":["deepseek-official","web-search-free"],
|
|
131
|
+
"disabledBundle":"@liustack/modsearch"}
|
|
132
|
+
remedy: Enable bundle "@liustack/modsearch" (or set web.searchProvider to one
|
|
133
|
+
of: deepseek-official, web-search-free).
|
|
134
|
+
[WARNING] BUNDLE_DISABLED — Bundle "@liustack/modsearch" is installed but disabled
|
|
135
|
+
This disabled bundle is the likely cause of a provider error above.
|
|
136
|
+
evidence: {"bundle":"@liustack/modsearch","linkedToProviderError":true}
|
|
137
|
+
[INFO] WEB_FETCH_PROVIDER_OK — fetch provider "fs-quality-fetch" is configured and live
|
|
138
|
+
[INFO] ROWS_DISABLED — 2 of 202 Loader rows are disabled
|
|
139
|
+
[INFO] TOOL_REGISTRY_OK — 5 tools are visible in the global scope
|
|
140
|
+
[INFO] SERVICES_OK — 7 core services are live
|
|
141
|
+
|
|
142
|
+
Read-only live inspection. No files, no network, no config changes.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The error/bundle pair is the whole diagnosis, and the `remedy` line is the next action. This tool deliberately does **not** edit your `cordis.patch.yml`.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Design constraints (why it looks unusual)
|
|
150
|
+
|
|
151
|
+
- **Zero runtime imports.** This package imports no `@deepseek-ai/*` module. Profiles use a hoisted pnpm layout: importing a runtime package from a third-party bundle can hoist a second copy to the profile root and shadow the host's copy. `Symbol` identity is per-module-instance, so the host then reads `undefined` and every tool call fails — see [discussion #1697](https://github.com/deepseek-ai/deepseek-harness/discussions/1697). Everything is reached through `ctx.get()` / `ctx.inject()` instead.
|
|
152
|
+
- **No row-level `inject`.** A declared injection that is unavailable would keep the row from activating entirely — the plugin would be silent exactly when it is most useful. The plugin always mounts, and registers its single tool only when the `tools` seam is present.
|
|
153
|
+
- **Namespaced row id.** `dsh-runtime-reconcile` avoids the generic ids (`storage`, `web`, …) that collide across hundreds of community bundles.
|
|
154
|
+
- **Host-only, no Client half.** The report renders in the conversation, so there is no slot registration, no theme dependency and no Web rebuild.
|
|
155
|
+
- **Plain `ToolDefinition` object.** No `defineTool()` import; the tool is registered with `ctx.tools.register()` using a plain JSON schema.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Compatibility
|
|
160
|
+
|
|
161
|
+
| | |
|
|
162
|
+
|---|---|
|
|
163
|
+
| DeepSeek Harness | `>=0.2.1-rc.2 <0.4.0-0` (declared via `engines.dsh`) |
|
|
164
|
+
| Verified on | 0.2.2-rc.1 / 0.2.2-rc.2 (Web profile, Windows) |
|
|
165
|
+
| Node | no direct Node API used in shipped code |
|
|
166
|
+
| Profiles | web / desktop / any profile mounting the `tools` seam |
|
|
167
|
+
| Dependencies | **none** |
|
|
168
|
+
| Build scripts | **none** |
|
|
169
|
+
| Network calls | **none** |
|
|
170
|
+
| Writes | **none** |
|
|
171
|
+
|
|
172
|
+
If the `tools` seam is absent the plugin still mounts and the `services` check reports it; the tool simply is not registered.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
npm test # 14 pure-core checks, no Harness runtime needed
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`core.js` holds all inspection logic and depends on nothing — `collect()` takes a context-like object, so `test/selftest.mjs` drives it with a faithful mock of the live services. `index.js` only wires the tool. This split keeps the diagnosis testable without booting a profile.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Privacy and trust
|
|
187
|
+
|
|
188
|
+
The tool reads service objects already present in the Harness process. It does not read your files, inspect personal browser profiles, read credentials, contact any host, or transmit anything. Its entire output is a JSON report rendered into the current conversation. The one environment value it consults is `DSH_WEB_SEARCH_PROVIDER` / `DSH_WEB_FETCH_PROVIDER` (a provider id, not a secret) because the web seam itself honours those overrides.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Limitations
|
|
193
|
+
|
|
194
|
+
- It **diagnoses, does not repair**. Fixing a misconfigured provider means editing the profile patch or enabling the bundle, which is a user decision.
|
|
195
|
+
- Checks are best-effort and defensive: a changed internal service shape degrades into a warning rather than a crash. If a future DSH renames `searchProviders`, the provider check reports the seam as unavailable instead of guessing.
|
|
196
|
+
- Duplicate visible tool names are **not** detected, by design: `tools.definitions()` is the already-resolved visible set (a Map keyed by name), so duplicates cannot appear. Scoped shadowing is resolved by the registry itself. Declaring otherwise would be a check that can never fire.
|
|
197
|
+
- The disabled-bundle correlation is heuristic string matching (package name ↔ provider id). It is a strong hint, not proof of ownership.
|
|
198
|
+
- Findings describe state at call time. Re-run after enabling/disabling bundles or changing config.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Repository
|
|
203
|
+
|
|
204
|
+
Source: [github.com/shuai0503/dsh-runtime-reconcile](https://github.com/shuai0503/dsh-runtime-reconcile). Issues and pull requests are welcome.
|
|
205
|
+
|
|
206
|
+
The `v0.2.1` tag makes the GitHub install command above resolve. The package is also published to npm, so `dsh plugin --profile web add dsh-runtime-reconcile` works without a build step. See [CHANGELOG.md](CHANGELOG.md) for the 0.1.0 → 0.2.1 changes.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT — see [LICENSE](LICENSE).
|
|
213
|
+
|
|
214
|
+
This is an independent community plugin, not affiliated with, endorsed by, or published by DeepSeek.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# dsh-runtime-reconcile
|
|
2
|
+
|
|
3
|
+
> 一个**只读**的 Agent 工具:把「DeepSeek Harness profile 声明了什么」与「活体运行时里真正注册了什么」做对账。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-runtime-reconcile)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-runtime-reconcile)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://github.com/topics/dsh-plugin)
|
|
9
|
+
|
|
10
|
+
[English](README.md) · 中文
|
|
11
|
+
|
|
12
|
+
**30 秒上手**:`dsh plugin --profile web add dsh-runtime-reconcile` → 新开一个会话 → 对 Agent 说 `reconcile_runtime`。它会告诉你某次工具调用**为什么**失败、该启用哪个 bundle。
|
|
13
|
+
|
|
14
|
+
静态扫描器看到的是一份语法合法的配置。这个工具看到的是**正在运行的进程**。它能回答那些扫描器回答不了的问题:
|
|
15
|
+
|
|
16
|
+
- 为什么 `web_search` 报 *`configured web provider "modsearch" is not registered`*,可配置明明看着没问题?
|
|
17
|
+
- 哪个已安装的 bundle 被禁用了,却仍被某条生效的配置行引用?
|
|
18
|
+
- 此刻活体工具注册表里有哪些工具名被遮蔽、哪些工具缺 output renderer?
|
|
19
|
+
- 这个 profile 有哪些核心服务没挂载上?
|
|
20
|
+
|
|
21
|
+
**只读**:不写文件、不联网、不改配置、不导入任何运行时包。
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 问题,具体地说
|
|
26
|
+
|
|
27
|
+
DeepSeek Harness 是「一切皆插件」。一个 profile 由数十个 bundle 按顺序叠加的补丁层组合而成。某行可以被插入、禁用、或被更高层覆盖;某个 provider 可以在配置里被点名,而负责注册它的 bundle 却处于 disabled。组合出来的配置**语法依然完全合法**,启动时不会有任何抱怨——直到某次工具调用走到 provider 选择才炸:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
configured web provider "modsearch" is not registered
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`ctx.web` 里 `config.searchProvider = 'modsearch'`,但它的活体 `searchProviders` Map 里只有那些 bundle 真正挂载后才注册的 provider。源码级检查(`dsh-trust-check`、`dsh-plugin-preflight`、`dsh-conflict-doctor`、`dsh-doctor`、`dsh-plugin-ops`)看的是**安装前/进程外**的包、清单和补丁文件,看不到这种漂移——因为漂移只在 Loader 把所有层应用完之后才存在。
|
|
34
|
+
|
|
35
|
+
`dsh-runtime-reconcile` 就跑在那个组合完成的进程内部,通过 `ctx.get()` 读活体服务。
|
|
36
|
+
|
|
37
|
+
### 与同类工具的差别
|
|
38
|
+
|
|
39
|
+
| 工具 | 检查对象 | 能看到活体 provider 表 / Loader 行吗 |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `dsh-trust-check`、`dsh-plugin-preflight` | 包源码 / manifest | 否(装前静态) |
|
|
42
|
+
| `dsh-conflict-doctor`、`dsh-plugin-conflict-guard` | 声明的能力 | 否(静态 / 安装时) |
|
|
43
|
+
| `dsh-doctor`、`dsh-plugin-ops` | profile 文件、启动日志 | 否(启动前 / 事后) |
|
|
44
|
+
| `dsh-harbor` | 声明 vs 检出的 ledger | 部分(静态对账) |
|
|
45
|
+
| `dsh-vitals`、`dsh-system-vitals` | CPU / 内存 / GPU | 否(硬件,不是插件图) |
|
|
46
|
+
| **dsh-runtime-reconcile** | **活体 `ctx.web` / `ctx.tools` / `ctx.pluginManager`** | **是** |
|
|
47
|
+
|
|
48
|
+
问题规模有量化依据:[deepseek-harness 讨论 #4253](https://github.com/deepseek-ai/deepseek-harness/discussions/4253) 扫描 9,873 个真实插件,发现 553 起工具名冲突、452 起 entry id 撞车、89 个孤儿补丁静默失效。本工具**不修**这些(真正该修的在核心里),但它让**当前 profile 的状态**对用户和 Agent 都可见。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 检查项
|
|
53
|
+
|
|
54
|
+
| 组 | 活体数据源 | 检出 |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `providers` | `ctx.web.config`、`searchProviders`/`fetchProviders` Map、`DSH_WEB_SEARCH_PROVIDER`/`DSH_WEB_FETCH_PROVIDER` | 配置指向但未注册、已注册但不可用、无 provider、多 provider 歧义 |
|
|
57
|
+
| `bundles` | `pluginManager.listBundles()` | 已装但禁用的 bundle,**自动关联为 provider 错误的可能根因** |
|
|
58
|
+
| `rows` | `pluginManager.listPlugins()` | **enabled 但未进入 active 阶段**的 Loader 行、禁用行、active/total |
|
|
59
|
+
| `tools` | `tools.definitions()` | 可见工具数、缺 `output` renderer 的工具、列表上限 |
|
|
60
|
+
| `services` | `ctx.get()` 核心 seam | 缺失的可选服务 |
|
|
61
|
+
|
|
62
|
+
每条 finding 带 `severity`、`code`、`title`、`detail`、结构化 `evidence`,以及可执行的 **`remedy`**。它们是**观察结论,不是安全背书**。
|
|
63
|
+
|
|
64
|
+
某个检查组跑不起来(服务缺失、枚举抛错)会记进 `completeness.unavailable`,并强制 `ok: false` / `complete: false`——读不到注册表绝不会被报成「一切正常」。非法的 `focus` 会被拒绝,而不是静默产出一份空的 OK 报告。
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 安装
|
|
69
|
+
|
|
70
|
+
一条命令,无需构建、零依赖、运行时不联网:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
dsh plugin --profile web add dsh-runtime-reconcile
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
GitHub 源(推荐,固定到 tag 或 commit):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
dsh plugin --profile web add github:shuai0503/dsh-runtime-reconcile#v0.2.1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
npm(同上,免构建):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
dsh plugin --profile web add dsh-runtime-reconcile
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
本地开发:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
dsh plugin --profile web add file:/绝对路径/dsh-runtime-reconcile
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
也可以在 Harness 插件管理器 / Web GUI 里安装 bundle 目录。GitHub 源安装有时会要求允许构建脚本——**本包无构建脚本、无依赖**,没什么需要允许的。
|
|
95
|
+
|
|
96
|
+
然后**新开一个会话**,让新注册的工具进入模型的工具集。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 用法
|
|
101
|
+
|
|
102
|
+
直接让 Agent 调,或显式调用:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
reconcile_runtime {}
|
|
106
|
+
reconcile_runtime { "focus": "providers" }
|
|
107
|
+
reconcile_runtime { "focus": "all", "maxTools": 200 }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`focus` 只收窄输出,不跳过任何与安全性相关的检查;`maxTools` 限制列出的工具名数量(默认 40,上限 200),不限制检查本身。
|
|
111
|
+
|
|
112
|
+
### 输出示例
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Runtime reconcile — 1 error(s), 1 warning(s), 4 info
|
|
116
|
+
focus=all complete=true generatedAt=2026-11-14T09:35:06.996Z
|
|
117
|
+
|
|
118
|
+
web.search: configured=modsearch registered=[deepseek-official, web-search-free]
|
|
119
|
+
web.fetch : configured=fs-quality-fetch registered=[fs-quality-fetch, http]
|
|
120
|
+
bundles: 3/4 enabled; disabled=[@liustack/modsearch]
|
|
121
|
+
loader rows: 200/202 active; 2 disabled; 0 enabled-but-not-active
|
|
122
|
+
tools: 5 visible
|
|
123
|
+
|
|
124
|
+
[ERROR] WEB_SEARCH_PROVIDER_MISSING — Configured search provider "modsearch" is not registered
|
|
125
|
+
ctx.web.searchProvider 指向 "modsearch",但活体 searchProviders 注册表里是
|
|
126
|
+
[deepseek-official, web-search-free]。每次搜索调用都会在 provider 选择处失败。
|
|
127
|
+
bundle "@liustack/modsearch" 已安装但被禁用——这很可能就是原因。
|
|
128
|
+
evidence: {"configuredFrom":"web.searchProvider","configured":"modsearch",
|
|
129
|
+
"registered":["deepseek-official","web-search-free"],
|
|
130
|
+
"disabledBundle":"@liustack/modsearch"}
|
|
131
|
+
remedy: 启用 bundle "@liustack/modsearch"(或把 web.searchProvider 改成
|
|
132
|
+
deepseek-official / web-search-free 之一)。
|
|
133
|
+
[WARNING] BUNDLE_DISABLED — Bundle "@liustack/modsearch" 已安装但被禁用
|
|
134
|
+
这个被禁用的 bundle 很可能是上面 provider 错误的原因。
|
|
135
|
+
evidence: {"bundle":"@liustack/modsearch","linkedToProviderError":true}
|
|
136
|
+
[INFO] WEB_FETCH_PROVIDER_OK — fetch provider "fs-quality-fetch" 已配置且存活
|
|
137
|
+
[INFO] ROWS_DISABLED — 202 行里有 2 行禁用
|
|
138
|
+
[INFO] TOOL_REGISTRY_OK — 全局作用域可见 5 个工具
|
|
139
|
+
[INFO] SERVICES_OK — 7 个核心服务均存活
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
错误 + bundle 这两条就是完整诊断,`remedy` 那行就是下一步动作。工具**故意不**去改你的 `cordis.patch.yml`。
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 设计约束(为什么长这样)
|
|
147
|
+
|
|
148
|
+
- **零运行时导入。** 不 import 任何 `@deepseek-ai/*` 模块。profile 用 hoisted pnpm 布局:第三方 bundle 引入运行时包可能把第二份副本提升到 profile 顶层、遮蔽宿主的副本。`Symbol` 身份是 per-module-instance 的,宿主随后读到 `undefined`,整个 profile 的每次工具调用都会失败——见[讨论 #1697](https://github.com/deepseek-ai/deepseek-harness/discussions/1697)。全部改用 `ctx.get()` / `ctx.inject()`。
|
|
149
|
+
- **row 层不写 `inject`。** 声明式注入一旦不可用会让整行不激活——插件会在最该说话的时候失声。插件始终挂载,只在 `tools` seam 存在时注册工具。
|
|
150
|
+
- **命名空间化的 row id / 工具名。** `dsh-runtime-reconcile` / `reconcile_runtime` 避开 #4253 测到的 452 起 entry id 撞车与 553 起工具名撞车。
|
|
151
|
+
- **Host-only,无 Client 半。** 报告在会话里渲染,无需 slot 注册、主题 token,也无需重建 Web 产物。
|
|
152
|
+
- **纯对象 `ToolDefinition`。** 不用 `defineTool()`,用 `ctx.tools.register()` 配普通 JSON Schema。
|
|
153
|
+
- **`core.js` 是纯逻辑。** 所有检查逻辑不依赖运行时,`collect()` 接受一个 context-like 对象,因此可用 mock 做单元测试。
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 兼容性
|
|
158
|
+
|
|
159
|
+
| | |
|
|
160
|
+
|---|---|
|
|
161
|
+
| DeepSeek Harness | `>=0.2.1-rc.2 <0.4.0-0`(通过 `engines.dsh` 声明) |
|
|
162
|
+
| 实测环境 | 0.2.2-rc.1 / 0.2.2-rc.2(Web profile,Windows) |
|
|
163
|
+
| Node | 发布代码不直接用 Node API |
|
|
164
|
+
| Profile | web / desktop / 任何挂载了 `tools` seam 的 profile |
|
|
165
|
+
| 依赖 | **无** |
|
|
166
|
+
| 构建脚本 | **无** |
|
|
167
|
+
| 网络请求 | **无** |
|
|
168
|
+
| 写操作 | **无** |
|
|
169
|
+
|
|
170
|
+
若 `tools` seam 缺失,插件仍会挂载,`services` 检查会把它报出来,只是工具不会注册。
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## 开发
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
npm test # 14 项纯 core 检查,不需要 Harness 运行时
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
(本机 node 不在 PATH 时,先把 node 所在目录加进 PATH——这是本机环境事实,不是项目要求。)
|
|
181
|
+
|
|
182
|
+
`core.js` 承载全部检查逻辑、零依赖;`index.js` 只负责接线。这样诊断逻辑不用启动 profile 就能测试。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 隐私与信任
|
|
187
|
+
|
|
188
|
+
工具只读取 Harness 进程里本就存在的服务对象。不读你的文件、不翻个人浏览器 profile、不读凭据、不联系任何主机、不往外发任何东西。全部输出就是一份渲染进当前会话的 JSON 报告。唯一会查的环境变量是 `DSH_WEB_SEARCH_PROVIDER` / `DSH_WEB_FETCH_PROVIDER`(一个 provider id,不是密钥),因为 web seam 本身认这两个覆盖。
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 局限
|
|
193
|
+
|
|
194
|
+
- **只诊断,不修复。** 改配置或启用 bundle 是用户决定。
|
|
195
|
+
- 检查是尽力而为、防御式的:内部服务结构变了会降级成 warning 而不是崩溃。若未来 DSH 改名 `searchProviders`,provider 检查会报「seam 不可用」而不是瞎猜。
|
|
196
|
+
- **故意不检测重复的可见工具名**:`tools.definitions()` 是已解析的可见集合(按名字索引的 Map),重复不可能出现;scope 遮蔽由注册表自己解决。硬说能检测,就是一个永远不会触发的检查。
|
|
197
|
+
- 禁用 bundle 的关联是启发式字符串匹配(包名 ↔ provider id),是强提示,不是所有权证明。
|
|
198
|
+
- finding 描述的是调用时刻的状态。启用/禁用 bundle 或改配置后请重跑。
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 仓库
|
|
203
|
+
|
|
204
|
+
源码:[github.com/shuai0503/dsh-runtime-reconcile](https://github.com/shuai0503/dsh-runtime-reconcile),欢迎 issue 与 PR。`v0.2.1` tag 让上面的 GitHub 安装命令可解析。本包同时也发布在 npm 上,所以 `dsh plugin --profile web add dsh-runtime-reconcile` 无需任何构建步骤即可使用。0.1.0 → 0.2.1 的变化见 [CHANGELOG.md](CHANGELOG.md)。
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 许可
|
|
209
|
+
|
|
210
|
+
MIT — 见 [LICENSE](LICENSE)。
|
|
211
|
+
|
|
212
|
+
独立社区插件,与 DeepSeek 无隶属、无背书、非官方发布。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# One Host-only row. No row-level inject: the plugin must always mount so it can
|
|
2
|
+
# *report* a missing service rather than silently not activating. index.js uses
|
|
3
|
+
# ctx.inject(['tools'], ...) for the one capability its tool registration truly
|
|
4
|
+
# needs, and ctx.get() defensively for everything else.
|
|
5
|
+
#
|
|
6
|
+
# The row id is namespaced to the package to avoid the entry-id collisions
|
|
7
|
+
# measured in deepseek-harness discussion #4253 (452 colliding entry ids).
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-runtime-reconcile
|
|
10
|
+
name: dsh-runtime-reconcile
|
package/core.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-runtime-reconcile — pure inspection core.
|
|
3
|
+
*
|
|
4
|
+
* No runtime imports, no Node APIs beyond process.env, no I/O. `collect()` takes
|
|
5
|
+
* a context-like object and returns a plain JSON report, so it can be unit
|
|
6
|
+
* tested with a mock (see test/selftest.mjs) and reused outside a Host plugin.
|
|
7
|
+
*
|
|
8
|
+
* Design note: importing a @deepseek-ai/* runtime package from a third-party
|
|
9
|
+
* bundle can hoist a second copy and shadow the host's copy (Symbol identity is
|
|
10
|
+
* per-module-instance), breaking every tool call — see deepseek-harness
|
|
11
|
+
* discussion #1697. Everything here is reached through ctx.get().
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const GROUPS = Object.freeze([
|
|
15
|
+
'providers', 'bundles', 'rows', 'tools', 'services',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const FOCI = Object.freeze(['all', ...GROUPS]);
|
|
19
|
+
|
|
20
|
+
/** Core seams we expect in a healthy web profile. */
|
|
21
|
+
const EXPECTED_SERVICES = Object.freeze([
|
|
22
|
+
'tools', 'web', 'pluginManager', 'configEditor', 'commands',
|
|
23
|
+
'systemPrompt', 'sessionProjections',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Read a service defensively; a missing service is reported, never thrown. */
|
|
27
|
+
function service(ctx, key) {
|
|
28
|
+
try {
|
|
29
|
+
return ctx?.get?.(key);
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Pull a configured id out of a z.string().volatile() field or a plain value. */
|
|
36
|
+
function configuredId(value) {
|
|
37
|
+
if (value === undefined || value === null) return undefined;
|
|
38
|
+
if (typeof value === 'string') return value || undefined;
|
|
39
|
+
if (typeof value === 'object' && typeof value.get === 'function') {
|
|
40
|
+
try {
|
|
41
|
+
return configuredId(value.get());
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Provider registry ids plus each provider's live availability/readiness. */
|
|
50
|
+
function inspectProviderMap(map) {
|
|
51
|
+
const entries = [];
|
|
52
|
+
if (!map || typeof map.keys !== 'function') return { entries, available: false, ids: [] };
|
|
53
|
+
for (const [id, provider] of map.entries()) {
|
|
54
|
+
const name = String(id);
|
|
55
|
+
let present = true;
|
|
56
|
+
let unavailable = false;
|
|
57
|
+
try {
|
|
58
|
+
// The web seam calls provider.available() before selecting it.
|
|
59
|
+
if (provider && typeof provider.available === 'function' && !provider.available()) unavailable = true;
|
|
60
|
+
} catch {
|
|
61
|
+
// A throwing available() is itself a fault at selection time.
|
|
62
|
+
unavailable = true;
|
|
63
|
+
}
|
|
64
|
+
entries.push({ id: name, present, unavailable });
|
|
65
|
+
}
|
|
66
|
+
entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
67
|
+
return {
|
|
68
|
+
entries,
|
|
69
|
+
available: entries.length > 0,
|
|
70
|
+
ids: entries.map((e) => e.id),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeFinding(severity, code, title, detail, evidence, remedy) {
|
|
75
|
+
return {
|
|
76
|
+
severity, code, title, detail,
|
|
77
|
+
...(evidence !== undefined ? { evidence } : {}),
|
|
78
|
+
...(remedy !== undefined ? { remedy } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Does the named provider belong to a disabled bundle? Pure string matching. */
|
|
83
|
+
function bundleForProvider(providerId, disabledBundles) {
|
|
84
|
+
if (!providerId) return undefined;
|
|
85
|
+
const needle = providerId.toLowerCase();
|
|
86
|
+
// Longest match first: "@liustack/modsearch" should beat a short "mod" hit.
|
|
87
|
+
const sorted = [...disabledBundles].sort((a, b) => b.length - a.length);
|
|
88
|
+
return sorted.find((name) => {
|
|
89
|
+
const n = name.toLowerCase();
|
|
90
|
+
return n === needle || n.endsWith('/' + needle) || needle === n.split('/').pop() || n.includes(needle);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Live report.
|
|
96
|
+
*
|
|
97
|
+
* `focus`: 'all' | one of GROUPS. Invalid values are rejected (see
|
|
98
|
+
* normalizeOptions) rather than silently producing an empty "OK" report.
|
|
99
|
+
* `maxTools`: cap on listed tool names, clamped 0..200.
|
|
100
|
+
*/
|
|
101
|
+
export async function collect(ctx, options = {}) {
|
|
102
|
+
const { focus = 'all', maxTools = 40 } = options;
|
|
103
|
+
const findings = [];
|
|
104
|
+
const summary = { errors: 0, warnings: 0, info: 0 };
|
|
105
|
+
const completeness = { checked: [], unavailable: [] };
|
|
106
|
+
let incomplete = false;
|
|
107
|
+
|
|
108
|
+
const add = (f) => {
|
|
109
|
+
if (!f) return;
|
|
110
|
+
findings.push(f);
|
|
111
|
+
if (f.severity === 'error') summary.errors += 1;
|
|
112
|
+
else if (f.severity === 'warning') summary.warnings += 1;
|
|
113
|
+
else summary.info += 1;
|
|
114
|
+
};
|
|
115
|
+
const group = (name, present) => {
|
|
116
|
+
const wanted = focus === 'all' || focus === name;
|
|
117
|
+
if (!wanted) return false;
|
|
118
|
+
completeness.checked.push(name);
|
|
119
|
+
if (!present) {
|
|
120
|
+
completeness.unavailable.push(name);
|
|
121
|
+
incomplete = true;
|
|
122
|
+
}
|
|
123
|
+
return present;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Pre-fetch bundles once: the provider check correlates against them below.
|
|
127
|
+
// This keeps the diagnosis in one report without a second plugin-manager call.
|
|
128
|
+
const pm = service(ctx, 'pluginManager');
|
|
129
|
+
let bundlesRaw = null;
|
|
130
|
+
let bundlesError;
|
|
131
|
+
if (pm && typeof pm.listBundles === 'function') {
|
|
132
|
+
try {
|
|
133
|
+
const value = await pm.listBundles();
|
|
134
|
+
if (Array.isArray(value)) bundlesRaw = value;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
bundlesError = String(error?.message ?? error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const disabledBundles = [];
|
|
140
|
+
const bundleSnapshot = { total: 0, enabled: 0, disabled: [], unavailable: false };
|
|
141
|
+
if (bundlesRaw) {
|
|
142
|
+
bundleSnapshot.total = bundlesRaw.length;
|
|
143
|
+
for (const b of bundlesRaw) {
|
|
144
|
+
const name = String(b?.name ?? b?.id ?? '?');
|
|
145
|
+
if (b?.enabled === true) bundleSnapshot.enabled += 1;
|
|
146
|
+
else if (b?.enabled === false) {
|
|
147
|
+
bundleSnapshot.disabled.push(name);
|
|
148
|
+
disabledBundles.push(name);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
bundleSnapshot.unavailable = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- 1. Web providers: three-state selection, correlated with bundles ---
|
|
156
|
+
const providerSnapshot = { search: null, fetch: null };
|
|
157
|
+
const web = service(ctx, 'web');
|
|
158
|
+
if (group('providers', !!web)) {
|
|
159
|
+
for (const [kind, configKey, mapKey, envKey] of [
|
|
160
|
+
['search', 'searchProvider', 'searchProviders', 'DSH_WEB_SEARCH_PROVIDER'],
|
|
161
|
+
['fetch', 'fetchProvider', 'fetchProviders', 'DSH_WEB_FETCH_PROVIDER'],
|
|
162
|
+
]) {
|
|
163
|
+
const envValue = process.env?.[envKey];
|
|
164
|
+
const configured = configuredId(web.config?.[configKey]) ?? envValue;
|
|
165
|
+
const inspected = inspectProviderMap(web[mapKey]);
|
|
166
|
+
providerSnapshot[kind] = {
|
|
167
|
+
configured: configured ?? null,
|
|
168
|
+
configuredFrom: envValue ? envKey : (configured ? `web.${configKey}` : null),
|
|
169
|
+
registered: inspected.ids,
|
|
170
|
+
unavailable: inspected.entries.filter((e) => e.unavailable).map((e) => e.id),
|
|
171
|
+
};
|
|
172
|
+
if (!inspected.available) {
|
|
173
|
+
add(makeFinding('error', `WEB_${kind.toUpperCase()}_NO_PROVIDER`,
|
|
174
|
+
`No ${kind} provider is registered`,
|
|
175
|
+
`The live ${mapKey} map is empty, so ${kind}() cannot resolve any provider.`,
|
|
176
|
+
{ map: mapKey },
|
|
177
|
+
'Enable the bundle that provides a web search/fetch provider, or set web.' + configKey + ' to one that is mounted.'));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (configured && !inspected.ids.includes(configured)) {
|
|
181
|
+
const culprit = bundleForProvider(configured, disabledBundles);
|
|
182
|
+
const evidence = {
|
|
183
|
+
configuredFrom: providerSnapshot[kind].configuredFrom,
|
|
184
|
+
configured,
|
|
185
|
+
registered: inspected.ids,
|
|
186
|
+
...(culprit ? { disabledBundle: culprit } : {}),
|
|
187
|
+
};
|
|
188
|
+
add(makeFinding('error', `WEB_${kind.toUpperCase()}_PROVIDER_MISSING`,
|
|
189
|
+
`Configured ${kind} provider "${configured}" is not registered`,
|
|
190
|
+
`ctx.web.${configKey} points at "${configured}", but the live ${mapKey} registry contains [${inspected.ids.join(', ')}]. Every ${kind} call fails at provider selection.`
|
|
191
|
+
+ (culprit ? ` The bundle "${culprit}" is installed but disabled — that is the likely cause.` : ''),
|
|
192
|
+
evidence,
|
|
193
|
+
culprit
|
|
194
|
+
? `Enable bundle "${culprit}" (or set web.${configKey} to one of: ${inspected.ids.join(', ')}).`
|
|
195
|
+
: `Set web.${configKey} to one of the registered providers: ${inspected.ids.join(', ')}, or install the bundle that provides "${configured}".`));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const chosen = configured
|
|
199
|
+
? inspected.entries.find((e) => e.id === configured)
|
|
200
|
+
: undefined;
|
|
201
|
+
if (chosen?.unavailable) {
|
|
202
|
+
add(makeFinding('error', `WEB_${kind.toUpperCase()}_PROVIDER_UNAVAILABLE`,
|
|
203
|
+
`Configured ${kind} provider "${configured}" is registered but unavailable`,
|
|
204
|
+
'The web seam calls provider.available() before selecting it; this provider reports false (or threw), so resolveProvider() rejects with WEB_PROVIDER_CONFIGURED_UNAVAILABLE.',
|
|
205
|
+
{ configured, registered: inspected.ids },
|
|
206
|
+
`Check the provider's credentials/readiness, or point web.${configKey} at another registered provider.`));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!configured && inspected.ids.length > 1) {
|
|
210
|
+
add(makeFinding('warning', `WEB_${kind.toUpperCase()}_AMBIGUOUS`,
|
|
211
|
+
`${inspected.ids.length} ${kind} providers registered with no selection`,
|
|
212
|
+
'Without an explicit config, resolveProvider() rejects with WEB_PROVIDER_AMBIGUOUS.',
|
|
213
|
+
{ registered: inspected.ids },
|
|
214
|
+
`Set web.${configKey} to one of: ${inspected.ids.join(', ')}.`));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (configured && inspected.ids.includes(configured)) {
|
|
218
|
+
add(makeFinding('info', `WEB_${kind.toUpperCase()}_PROVIDER_OK`,
|
|
219
|
+
`${kind} provider "${configured}" is configured and live`,
|
|
220
|
+
'The configured id resolves to a registered, available provider.',
|
|
221
|
+
{ configured, registered: inspected.ids }));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- 2. Disabled installed bundles ---
|
|
227
|
+
if (group('bundles', bundlesRaw !== null)) {
|
|
228
|
+
if (bundlesError) {
|
|
229
|
+
add(makeFinding('warning', 'BUNDLE_LIST_FAILED', 'Could not enumerate installed bundles',
|
|
230
|
+
bundlesError, undefined, 'Retry after the profile settles; a failing plugin manager may indicate a broken composition.'));
|
|
231
|
+
} else {
|
|
232
|
+
for (const name of bundleSnapshot.disabled) {
|
|
233
|
+
const linked = findings.some((f) => f.code.startsWith('WEB_') && f.evidence?.disabledBundle === name);
|
|
234
|
+
add(makeFinding(linked ? 'warning' : 'info',
|
|
235
|
+
'BUNDLE_DISABLED', `Bundle "${name}" is installed but disabled`,
|
|
236
|
+
linked
|
|
237
|
+
? 'This disabled bundle is the likely cause of a provider error above.'
|
|
238
|
+
: 'Its rows are inserted into the composition but never mounted. It matters only when a config points at a capability it provides.',
|
|
239
|
+
{ bundle: name, linkedToProviderError: linked }));
|
|
240
|
+
}
|
|
241
|
+
if (bundleSnapshot.disabled.length === 0) {
|
|
242
|
+
add(makeFinding('info', 'BUNDLES_ALL_ENABLED', `${bundleSnapshot.total} installed bundles are enabled`,
|
|
243
|
+
'No installed bundle is disabled.'));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- 3. Loader rows: enabled-but-not-active is the real silent failure ---
|
|
249
|
+
const rowsSnapshot = { total: 0, active: 0, disabled: [], notActive: [] };
|
|
250
|
+
let rowsRaw = null;
|
|
251
|
+
let rowsError;
|
|
252
|
+
if (pm && typeof pm.listPlugins === 'function') {
|
|
253
|
+
try {
|
|
254
|
+
const value = await pm.listPlugins();
|
|
255
|
+
if (Array.isArray(value)) rowsRaw = value;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
rowsError = String(error?.message ?? error);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (group('rows', rowsRaw !== null)) {
|
|
261
|
+
if (rowsError) {
|
|
262
|
+
add(makeFinding('warning', 'PLUGIN_LIST_FAILED', 'Could not enumerate Loader rows', rowsError));
|
|
263
|
+
} else {
|
|
264
|
+
rowsSnapshot.total = rowsRaw.length;
|
|
265
|
+
for (const row of rowsRaw) {
|
|
266
|
+
const label = String(row?.moduleName ?? row?.entryId ?? '?');
|
|
267
|
+
if (row?.enabled === false) rowsSnapshot.disabled.push(label);
|
|
268
|
+
else if (row?.enabled === true && row?.fiberPhase !== 'active') rowsSnapshot.notActive.push(label);
|
|
269
|
+
else if (row?.enabled === true) rowsSnapshot.active += 1;
|
|
270
|
+
}
|
|
271
|
+
if (rowsSnapshot.notActive.length > 0) {
|
|
272
|
+
add(makeFinding('warning', 'ROWS_NOT_ACTIVE',
|
|
273
|
+
`${rowsSnapshot.notActive.length} enabled Loader row(s) are not in the active phase`,
|
|
274
|
+
'These rows are enabled but their fiber never reached "active" — the capability they were meant to provide is not live. This is the class of failure static scanners cannot see.',
|
|
275
|
+
{ rows: rowsSnapshot.notActive.slice(0, 20) },
|
|
276
|
+
'Check the row\'s injection targets and load errors; a restart or fixing the missing dependency usually resolves it.'));
|
|
277
|
+
}
|
|
278
|
+
if (rowsSnapshot.disabled.length > 0) {
|
|
279
|
+
add(makeFinding('info', 'ROWS_DISABLED',
|
|
280
|
+
`${rowsSnapshot.disabled.length} of ${rowsSnapshot.total} Loader rows are disabled`,
|
|
281
|
+
'Disabled rows are expected for opt-in features. They matter only when a configured provider, command or service points at one of them.',
|
|
282
|
+
{ count: rowsSnapshot.disabled.length, examples: rowsSnapshot.disabled.slice(0, 12) }));
|
|
283
|
+
}
|
|
284
|
+
if (rowsSnapshot.notActive.length === 0 && rowsSnapshot.disabled.length === 0) {
|
|
285
|
+
add(makeFinding('info', 'ROWS_ALL_ACTIVE', `${rowsSnapshot.active}/${rowsSnapshot.total} Loader rows are active`,
|
|
286
|
+
'Every enabled row reached the active fiber phase.'));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- 4. Tool registry health ---
|
|
292
|
+
// NOTE: tools.definitions() returns the *resolved* visible set (a Map keyed by
|
|
293
|
+
// name), so duplicate names cannot appear there — counting them was a no-op.
|
|
294
|
+
// Instead report real signals: visibility, reserved-name safety, schema caps.
|
|
295
|
+
const tools = service(ctx, 'tools');
|
|
296
|
+
const toolSnapshot = { count: 0, names: [], truncated: false };
|
|
297
|
+
let defs = null;
|
|
298
|
+
let toolsError;
|
|
299
|
+
if (tools && typeof tools.definitions === 'function') {
|
|
300
|
+
try {
|
|
301
|
+
const value = tools.definitions();
|
|
302
|
+
if (Array.isArray(value)) defs = value;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
toolsError = String(error?.message ?? error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (group('tools', defs !== null)) {
|
|
308
|
+
if (toolsError) {
|
|
309
|
+
add(makeFinding('warning', 'TOOL_SCAN_FAILED', 'Could not enumerate live tools', toolsError));
|
|
310
|
+
} else {
|
|
311
|
+
const names = defs.map((d) => String(d?.name ?? '')).filter(Boolean).sort();
|
|
312
|
+
const cap = Math.max(0, Math.min(200, maxTools));
|
|
313
|
+
toolSnapshot.count = names.length;
|
|
314
|
+
toolSnapshot.names = names.slice(0, cap);
|
|
315
|
+
toolSnapshot.truncated = names.length > cap;
|
|
316
|
+
const withOutput = defs.filter((d) => d?.output && typeof d.output.render === 'function').length;
|
|
317
|
+
const withoutOutput = names.length - withOutput;
|
|
318
|
+
if (withoutOutput > 0) {
|
|
319
|
+
add(makeFinding('warning', 'TOOLS_WITHOUT_OUTPUT',
|
|
320
|
+
`${withoutOutput} registered tool(s) declare no output renderer`,
|
|
321
|
+
'ToolsService.register() requires output { schema, render }. Definitions without one may be scoped-only views or an incomplete registration.',
|
|
322
|
+
{ count: withoutOutput }));
|
|
323
|
+
}
|
|
324
|
+
add(makeFinding('info', 'TOOL_REGISTRY_OK',
|
|
325
|
+
`${names.length} tools are visible in the global scope`,
|
|
326
|
+
`The live resolved registry was read successfully${toolSnapshot.truncated ? ` (listing capped at ${cap})` : ''}. Scoped shadowing is resolved by the registry itself, so duplicate visible names are not possible.`,
|
|
327
|
+
{ count: names.length, withOutput, listed: toolSnapshot.names.length }));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- 5. Declared-vs-live services ---
|
|
332
|
+
if (group('services', true)) {
|
|
333
|
+
const missing = EXPECTED_SERVICES.filter((key) => service(ctx, key) === undefined);
|
|
334
|
+
if (missing.length > 0) {
|
|
335
|
+
add(makeFinding('warning', 'SERVICES_MISSING', `${missing.length} expected services are not mounted`,
|
|
336
|
+
'These are optional seams; their absence is only a fault if another enabled row injects them.',
|
|
337
|
+
{ missing }));
|
|
338
|
+
} else {
|
|
339
|
+
add(makeFinding('info', 'SERVICES_OK', `${EXPECTED_SERVICES.length} core services are live`,
|
|
340
|
+
EXPECTED_SERVICES.join(' / ') + ' all resolve through ctx.get().'));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// `ok` must not be true when checks could not run: an unreadable registry is
|
|
345
|
+
// not a clean bill of health.
|
|
346
|
+
const ok = summary.errors === 0 && !incomplete;
|
|
347
|
+
return {
|
|
348
|
+
ok,
|
|
349
|
+
complete: !incomplete,
|
|
350
|
+
focus,
|
|
351
|
+
generatedAt: new Date().toISOString(),
|
|
352
|
+
version: '0.2.1',
|
|
353
|
+
summary,
|
|
354
|
+
completeness,
|
|
355
|
+
findings,
|
|
356
|
+
snapshot: {
|
|
357
|
+
providers: providerSnapshot,
|
|
358
|
+
bundles: bundleSnapshot,
|
|
359
|
+
rows: rowsSnapshot,
|
|
360
|
+
tools: toolSnapshot,
|
|
361
|
+
},
|
|
362
|
+
note: 'Read-only live inspection. No files, no network, no config changes. Findings are observations, not a security verdict.',
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Validate and normalize tool arguments. Throws on an invalid focus. */
|
|
367
|
+
export function normalizeOptions(args = {}) {
|
|
368
|
+
const focus = typeof args.focus === 'string' ? args.focus : 'all';
|
|
369
|
+
if (!FOCI.includes(focus)) {
|
|
370
|
+
throw new Error(`invalid focus "${focus}"; expected one of: ${FOCI.join(', ')}`);
|
|
371
|
+
}
|
|
372
|
+
const rawMax = Number(args.maxTools);
|
|
373
|
+
const maxTools = Number.isFinite(rawMax) && args.maxTools !== undefined
|
|
374
|
+
? Math.max(0, Math.min(200, Math.floor(rawMax)))
|
|
375
|
+
: 40;
|
|
376
|
+
return { focus, maxTools };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function renderText(report) {
|
|
380
|
+
const lines = [];
|
|
381
|
+
const status = report.ok ? 'OK' : `${report.summary.errors} error(s)`;
|
|
382
|
+
lines.push(`Runtime reconcile — ${status}, ${report.summary.warnings} warning(s), ${report.summary.info} info`);
|
|
383
|
+
lines.push(`focus=${report.focus} complete=${report.complete} generatedAt=${report.generatedAt}`);
|
|
384
|
+
lines.push('');
|
|
385
|
+
const s = report.snapshot;
|
|
386
|
+
for (const kind of ['search', 'fetch']) {
|
|
387
|
+
const p = s.providers[kind];
|
|
388
|
+
if (!p) continue;
|
|
389
|
+
const label = kind === 'search' ? 'web.search' : 'web.fetch ';
|
|
390
|
+
lines.push(`${label}: configured=${p.configured ?? '(none)'} registered=[${p.registered.join(', ') || 'none'}]`
|
|
391
|
+
+ (p.unavailable?.length ? ` unavailable=[${p.unavailable.join(', ')}]` : ''));
|
|
392
|
+
}
|
|
393
|
+
lines.push(`bundles: ${s.bundles.enabled}/${s.bundles.total} enabled; disabled=[${s.bundles.disabled.join(', ') || 'none'}]`);
|
|
394
|
+
lines.push(`loader rows: ${s.rows.active}/${s.rows.total} active; ${s.rows.disabled.length} disabled; ${s.rows.notActive.length} enabled-but-not-active`);
|
|
395
|
+
lines.push(`tools: ${s.tools.count} visible${s.tools.truncated ? ' (listing capped)' : ''}`);
|
|
396
|
+
if (report.completeness.unavailable.length > 0) {
|
|
397
|
+
lines.push(`unavailable checks: ${report.completeness.unavailable.join(', ')} — report is incomplete, ok is false`);
|
|
398
|
+
}
|
|
399
|
+
lines.push('');
|
|
400
|
+
if (report.findings.length === 0) {
|
|
401
|
+
lines.push('No findings.');
|
|
402
|
+
} else {
|
|
403
|
+
for (const f of report.findings) {
|
|
404
|
+
lines.push(`[${f.severity.toUpperCase()}] ${f.code} — ${f.title}`);
|
|
405
|
+
lines.push(` ${f.detail}`);
|
|
406
|
+
if (f.evidence !== undefined) lines.push(` evidence: ${JSON.stringify(f.evidence)}`);
|
|
407
|
+
if (f.remedy) lines.push(` remedy: ${f.remedy}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
lines.push('');
|
|
411
|
+
lines.push(report.note);
|
|
412
|
+
return lines.join('\n');
|
|
413
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#12324a"/>
|
|
3
|
+
<path d="M10 33h11l5-11 7 22 6-15 4 4h11" fill="none" stroke="#57d6a8" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
<circle cx="49" cy="15" r="4" fill="#f2b857"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-runtime-reconcile — Host half. Read-only, zero-dependency.
|
|
3
|
+
*
|
|
4
|
+
* All inspection logic lives in core.js (pure, unit-tested). This module only
|
|
5
|
+
* wires one model-facing tool to it. No @deepseek-ai/* runtime imports: see
|
|
6
|
+
* deepseek-harness discussion #1697 for the hoisted-shadowing hazard.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { collect, normalizeOptions, renderText } from './core.js';
|
|
10
|
+
|
|
11
|
+
export { collect, normalizeOptions, renderText } from './core.js';
|
|
12
|
+
|
|
13
|
+
function reconcileTool(ctx) {
|
|
14
|
+
return {
|
|
15
|
+
// Namespaced on purpose: generic names collide across the ecosystem
|
|
16
|
+
// (553 tool-name collisions measured in discussion #4253).
|
|
17
|
+
name: 'reconcile_runtime',
|
|
18
|
+
description:
|
|
19
|
+
'Read-only live inspection of the running DeepSeek Harness profile. Reconciles what is configured against what is actually registered: configured-but-missing or unavailable web providers (the cause of "configured web provider is not registered"), disabled bundles correlated as the likely root cause, enabled-but-not-active Loader rows, tool registry health, and missing core services. Reports actionable remedies. Makes no network calls and changes nothing. Use this when a tool fails mysteriously, after installing/enabling plugins, or to check profile health.',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
focus: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
enum: ['all', 'providers', 'bundles', 'rows', 'tools', 'services'],
|
|
26
|
+
description: 'Which check group to run. Default "all". Use "providers" for the fast web-provider diagnosis.',
|
|
27
|
+
},
|
|
28
|
+
maxTools: {
|
|
29
|
+
type: 'number',
|
|
30
|
+
description: 'Cap on tool names listed in the snapshot (default 40, max 200). Does not limit the checks.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
output: {
|
|
36
|
+
schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: {
|
|
40
|
+
ok: { type: 'boolean' },
|
|
41
|
+
complete: { type: 'boolean' },
|
|
42
|
+
focus: { type: 'string' },
|
|
43
|
+
generatedAt: { type: 'string' },
|
|
44
|
+
version: { type: 'string' },
|
|
45
|
+
summary: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
properties: {
|
|
49
|
+
errors: { type: 'number' },
|
|
50
|
+
warnings: { type: 'number' },
|
|
51
|
+
info: { type: 'number' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
completeness: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
checked: { type: 'array', items: { type: 'string' } },
|
|
59
|
+
unavailable: { type: 'array', items: { type: 'string' } },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
findings: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
additionalProperties: true,
|
|
67
|
+
properties: {
|
|
68
|
+
severity: { type: 'string' },
|
|
69
|
+
code: { type: 'string' },
|
|
70
|
+
title: { type: 'string' },
|
|
71
|
+
detail: { type: 'string' },
|
|
72
|
+
remedy: { type: 'string' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
snapshot: { type: 'object', additionalProperties: true },
|
|
77
|
+
note: { type: 'string' },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
render(_args, value) {
|
|
81
|
+
return [{ type: 'text', text: renderText(value) }];
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
async execute(args = {}) {
|
|
85
|
+
const options = normalizeOptions(args);
|
|
86
|
+
return collect(ctx, options);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function apply(ctx) {
|
|
92
|
+
// No row-level inject: the plugin must always mount so it can *report* a
|
|
93
|
+
// missing service rather than silently not activating. Register the tool only
|
|
94
|
+
// when the tools seam is present.
|
|
95
|
+
ctx.inject(['tools'], (scoped) => {
|
|
96
|
+
let tools;
|
|
97
|
+
try {
|
|
98
|
+
tools = scoped.get('tools') ?? ctx.get('tools');
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!tools || typeof tools.register !== 'function') return;
|
|
103
|
+
const dispose = tools.register(reconcileTool(ctx));
|
|
104
|
+
scoped.effect(() => dispose);
|
|
105
|
+
});
|
|
106
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Runtime reconcile",
|
|
4
|
+
"description": "A read-only agent tool that reconciles what the profile declares against what is actually registered in the live runtime, with root-cause correlation and actionable remedies. No files, no network, no config changes."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-runtime-reconcile",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "A read-only agent tool that reconciles what a DeepSeek Harness profile declares against what is actually registered in the live runtime.",
|
|
5
|
+
"displayName": "Runtime Reconcile",
|
|
6
|
+
"icon": "icon.svg",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"dsh",
|
|
12
|
+
"dsh-plugin",
|
|
13
|
+
"cordis",
|
|
14
|
+
"diagnostics",
|
|
15
|
+
"runtime",
|
|
16
|
+
"runtime-reconciliation",
|
|
17
|
+
"plugin-conflict",
|
|
18
|
+
"plugin-diagnostics",
|
|
19
|
+
"observability",
|
|
20
|
+
"profile-health",
|
|
21
|
+
"ssrf-prevention",
|
|
22
|
+
"tool-registry",
|
|
23
|
+
"agent-tools"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/shuai0503/dsh-runtime-reconcile.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/shuai0503/dsh-runtime-reconcile/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/shuai0503/dsh-runtime-reconcile#readme",
|
|
33
|
+
"author": "shuai0503",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./index.js",
|
|
36
|
+
"./core": "./core.js",
|
|
37
|
+
"./locale/*.json": "./locale/*.json",
|
|
38
|
+
"./icon": "./icon.svg"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"index.js",
|
|
42
|
+
"core.js",
|
|
43
|
+
"test/selftest.mjs",
|
|
44
|
+
"cordis.patch.yml",
|
|
45
|
+
"README.md",
|
|
46
|
+
"README.zh.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"locale/*.json",
|
|
50
|
+
"icon.svg"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "node test/selftest.mjs"
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"bundle": { "patch": "./cordis.patch.yml" }
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"dsh": ">=0.2.1-rc.2 <0.4.0-0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeatable self-test for the pure core. No Harness runtime needed.
|
|
3
|
+
* node test/selftest.mjs
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { collect, normalizeOptions, renderText, FOCI } from '../core.js';
|
|
7
|
+
|
|
8
|
+
const str = (v) => ({ get: () => v });
|
|
9
|
+
let passed = 0;
|
|
10
|
+
function test(name, fn) {
|
|
11
|
+
try {
|
|
12
|
+
fn();
|
|
13
|
+
passed += 1;
|
|
14
|
+
console.log('PASS ' + name);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.log('FAIL ' + name + ' — ' + error.message);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function atest(name, fn) {
|
|
21
|
+
try {
|
|
22
|
+
await fn();
|
|
23
|
+
passed += 1;
|
|
24
|
+
console.log('PASS ' + name);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.log('FAIL ' + name + ' — ' + error.message);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build a faithful mock of the live profile state observed on this machine. */
|
|
32
|
+
function mockContext(overrides = {}) {
|
|
33
|
+
const defs = overrides.defs ?? [
|
|
34
|
+
{ name: 'web_search', output: { render() {} } },
|
|
35
|
+
{ name: 'web_fetch', output: { render() {} } },
|
|
36
|
+
{ name: 'reconcile_runtime', output: { render() {} } },
|
|
37
|
+
];
|
|
38
|
+
const bundles = overrides.bundles ?? [
|
|
39
|
+
{ name: '@deepseek-ai/dsh-base', enabled: true },
|
|
40
|
+
{ name: '@liustack/modsearch', enabled: false },
|
|
41
|
+
{ name: 'dsh-runtime-reconcile', enabled: true },
|
|
42
|
+
];
|
|
43
|
+
const rows = overrides.rows ?? [
|
|
44
|
+
{ entryId: 'a', moduleName: 'x', enabled: true, fiberPhase: 'active' },
|
|
45
|
+
{ entryId: 'include:modsearch', moduleName: '@liustack/modsearch', enabled: false, fiberPhase: null },
|
|
46
|
+
];
|
|
47
|
+
const services = {
|
|
48
|
+
web: overrides.web ?? {
|
|
49
|
+
config: { searchProvider: str('modsearch'), fetchProvider: str('http') },
|
|
50
|
+
searchProviders: new Map([['deepseek-official', {}], ['web-search-free', {}]]),
|
|
51
|
+
fetchProviders: new Map([['http', {}]]),
|
|
52
|
+
},
|
|
53
|
+
pluginManager: {
|
|
54
|
+
async listBundles() { return bundles; },
|
|
55
|
+
async listPlugins() { return rows; },
|
|
56
|
+
},
|
|
57
|
+
tools: { definitions() { return defs; }, register() { return () => {}; } },
|
|
58
|
+
configEditor: {}, commands: {}, systemPrompt: {}, sessionProjections: {},
|
|
59
|
+
};
|
|
60
|
+
if (overrides.missingServices) for (const k of overrides.missingServices) delete services[k];
|
|
61
|
+
return { get: (k) => services[k] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const codes = (r) => r.findings.map((f) => f.code);
|
|
65
|
+
|
|
66
|
+
await atest('core: catches configured-but-unregistered search provider', async () => {
|
|
67
|
+
const r = await collect(mockContext(), { focus: 'all' });
|
|
68
|
+
assert.equal(r.ok, false);
|
|
69
|
+
assert.ok(codes(r).includes('WEB_SEARCH_PROVIDER_MISSING'));
|
|
70
|
+
assert.ok(!codes(r).includes('WEB_FETCH_PROVIDER_MISSING'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await atest('core: correlates the error with the disabled bundle', async () => {
|
|
74
|
+
const r = await collect(mockContext(), { focus: 'all' });
|
|
75
|
+
const err = r.findings.find((f) => f.code === 'WEB_SEARCH_PROVIDER_MISSING');
|
|
76
|
+
assert.equal(err.evidence.disabledBundle, '@liustack/modsearch');
|
|
77
|
+
assert.match(err.remedy, /Enable bundle "@liustack\/modsearch"/);
|
|
78
|
+
const b = r.findings.find((f) => f.code === 'BUNDLE_DISABLED' && f.evidence.bundle === '@liustack/modsearch');
|
|
79
|
+
assert.equal(b.severity, 'warning');
|
|
80
|
+
assert.equal(b.evidence.linkedToProviderError, true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await atest('core: healthy profile reports ok with provider OK findings', async () => {
|
|
84
|
+
const r = await collect(mockContext({
|
|
85
|
+
web: {
|
|
86
|
+
config: { searchProvider: str('deepseek-official'), fetchProvider: str('http') },
|
|
87
|
+
searchProviders: new Map([['deepseek-official', {}]]),
|
|
88
|
+
fetchProviders: new Map([['http', {}]]),
|
|
89
|
+
},
|
|
90
|
+
bundles: [{ name: 'dsh-runtime-reconcile', enabled: true }],
|
|
91
|
+
rows: [{ entryId: 'a', moduleName: 'x', enabled: true, fiberPhase: 'active' }],
|
|
92
|
+
}), { focus: 'all' });
|
|
93
|
+
assert.equal(r.ok, true);
|
|
94
|
+
assert.equal(r.complete, true);
|
|
95
|
+
assert.ok(codes(r).includes('WEB_SEARCH_PROVIDER_OK'));
|
|
96
|
+
assert.ok(codes(r).includes('BUNDLES_ALL_ENABLED'));
|
|
97
|
+
assert.ok(codes(r).includes('ROWS_ALL_ACTIVE'));
|
|
98
|
+
assert.ok(codes(r).includes('SERVICES_OK'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await atest('core: registered-but-unavailable provider is an error', async () => {
|
|
102
|
+
const r = await collect(mockContext({
|
|
103
|
+
web: {
|
|
104
|
+
config: { searchProvider: str('slow'), fetchProvider: str('http') },
|
|
105
|
+
searchProviders: new Map([['slow', { available: () => false }]]),
|
|
106
|
+
fetchProviders: new Map([['http', {}]]),
|
|
107
|
+
},
|
|
108
|
+
}), { focus: 'providers' });
|
|
109
|
+
assert.ok(codes(r).includes('WEB_SEARCH_PROVIDER_UNAVAILABLE'));
|
|
110
|
+
assert.equal(r.snapshot.providers.search.unavailable[0], 'slow');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await atest('core: multiple providers with no config is ambiguous with remedy', async () => {
|
|
114
|
+
const r = await collect(mockContext({
|
|
115
|
+
web: {
|
|
116
|
+
config: { searchProvider: str(''), fetchProvider: str('') },
|
|
117
|
+
searchProviders: new Map([['a', {}], ['b', {}]]),
|
|
118
|
+
fetchProviders: new Map([['http', {}]]),
|
|
119
|
+
},
|
|
120
|
+
}), { focus: 'providers' });
|
|
121
|
+
assert.ok(codes(r).includes('WEB_SEARCH_AMBIGUOUS'));
|
|
122
|
+
const f = r.findings.find((x) => x.code === 'WEB_SEARCH_AMBIGUOUS');
|
|
123
|
+
assert.match(f.remedy, /Set web.searchProvider/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await atest('core: enabled-but-not-active Loader row is a warning', async () => {
|
|
127
|
+
const r = await collect(mockContext({
|
|
128
|
+
rows: [
|
|
129
|
+
{ entryId: 'a', moduleName: 'good', enabled: true, fiberPhase: 'active' },
|
|
130
|
+
{ entryId: 'b', moduleName: 'stuck', enabled: true, fiberPhase: null },
|
|
131
|
+
],
|
|
132
|
+
}), { focus: 'rows' });
|
|
133
|
+
assert.ok(codes(r).includes('ROWS_NOT_ACTIVE'));
|
|
134
|
+
assert.deepEqual(r.snapshot.rows.notActive, ['stuck']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await atest('core: missing services reported, not silently OK', async () => {
|
|
138
|
+
const r = await collect(mockContext({ missingServices: ['commands', 'configEditor'] }), { focus: 'services' });
|
|
139
|
+
assert.ok(codes(r).includes('SERVICES_MISSING'));
|
|
140
|
+
assert.deepEqual(r.findings.find((f) => f.code === 'SERVICES_MISSING').evidence.missing, ['configEditor', 'commands']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await atest('core: unavailable check sets complete=false and ok=false', async () => {
|
|
144
|
+
// Only `tools` resolves; web / pluginManager are absent so their groups
|
|
145
|
+
// cannot run and must be marked unavailable rather than silently OK.
|
|
146
|
+
const ctx = { get: (k) => (k === 'tools' ? { definitions: () => [] } : undefined) };
|
|
147
|
+
const r = await collect(ctx, { focus: 'all' });
|
|
148
|
+
assert.equal(r.ok, false);
|
|
149
|
+
assert.equal(r.complete, false);
|
|
150
|
+
assert.ok(r.completeness.unavailable.includes('providers'));
|
|
151
|
+
assert.ok(r.completeness.unavailable.includes('bundles'));
|
|
152
|
+
assert.match(renderText(r), /report is incomplete/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await atest('core: focus narrows checked groups', async () => {
|
|
156
|
+
const r = await collect(mockContext(), { focus: 'providers' });
|
|
157
|
+
assert.deepEqual(r.completeness.checked, ['providers']);
|
|
158
|
+
assert.ok(!codes(r).includes('BUNDLE_DISABLED'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await atest('core: tools without output renderer warned (real signal, not fake duplicates)', async () => {
|
|
162
|
+
const r = await collect(mockContext({ defs: [{ name: 'bare' }, { name: 'ok', output: { render() {} } }] }), { focus: 'tools' });
|
|
163
|
+
assert.ok(codes(r).includes('TOOLS_WITHOUT_OUTPUT'));
|
|
164
|
+
assert.ok(codes(r).includes('TOOL_REGISTRY_OK'));
|
|
165
|
+
assert.ok(!codes(r).includes('TOOL_NAME_SHADOWED'));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('normalizeOptions: defaults', () => {
|
|
169
|
+
assert.deepEqual(normalizeOptions(), { focus: 'all', maxTools: 40 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('normalizeOptions: clamps maxTools to 0..200', () => {
|
|
173
|
+
assert.equal(normalizeOptions({ maxTools: 9999 }).maxTools, 200);
|
|
174
|
+
assert.equal(normalizeOptions({ maxTools: -5 }).maxTools, 0);
|
|
175
|
+
assert.equal(normalizeOptions({ maxTools: 12.9 }).maxTools, 12);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('normalizeOptions: rejects invalid focus', () => {
|
|
179
|
+
assert.throws(() => normalizeOptions({ focus: 'nonsense' }), /invalid focus/);
|
|
180
|
+
assert.ok(FOCI.includes('all') && FOCI.includes('providers'));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('renderText: includes remedy lines', () => {
|
|
184
|
+
const text = renderText({
|
|
185
|
+
ok: false, complete: true, focus: 'all', generatedAt: 'x',
|
|
186
|
+
summary: { errors: 1, warnings: 0, info: 0 },
|
|
187
|
+
completeness: { checked: [], unavailable: [] },
|
|
188
|
+
findings: [{ severity: 'error', code: 'X', title: 't', detail: 'd', remedy: 'do this' }],
|
|
189
|
+
snapshot: { providers: { search: null, fetch: null }, bundles: { enabled: 0, total: 0, disabled: [] }, rows: { active: 0, total: 0, disabled: [], notActive: [] }, tools: { count: 0 } },
|
|
190
|
+
note: 'n',
|
|
191
|
+
});
|
|
192
|
+
assert.match(text, /remedy: do this/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log(`\n${passed} checks passed`);
|