Fix preset publication, volatile settings and client form binding

This commit is contained in:
oliver 2026-10-11 12:54:02 +08:00
parent fe0fe70d33
commit 0b9a3a82bc
66 changed files with 8546 additions and 3690 deletions

View file

@ -28,6 +28,24 @@ Then:
Also need: a reachable **netx API** (no local `pip install netx_mcp`).
## Flexible publication
Settings → Plugins → Netx Ops supports a master publication switch and independent
targets for ops, topology, biz-monitor, and knowledge-base capabilities:
- Enable in the built-in **Netx Ops** preset.
- Select specific other presets from the live Host roster.
- Publish to **all presets** through the Host layer.
Tools and corresponding skills follow the same policy. Changes auto-save and apply
to current and new sessions on their next model request. Turning the master switch
off removes all plugin-managed Netx tools and skills while retaining your selections.
After upgrading the plugin, restart each installed Host once; subsequent publication
changes do not need a restart.
See the [Chinese diagnosis and validation report](docs/PUBLICATION-REVIEW.zh.md).
Development checks: `bun run typecheck`, `bun test`, and `bun run preview:client`.
**Skills:** edit only in the sibling **netx** repo `skills/` tree, then `powershell -File .\scripts\sync-skills-from-netx.ps1` before publish. Linked local installs prefer `../netx/skills` or `NETX_SKILLS_ROOT` at runtime.
## What is coupled

View file

@ -4,7 +4,7 @@
# Host-plane Netx Ops bridge: settings + credentials → connection publish.
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
# Capability groups: ops in preset + public on; topology off.
# Capability groups: ops in preset (not public by default); topology off.
# Rule: one group ↔ one skill (netx-ops / netx-topology).
#
# IM + cron are separate profile bundles (pnpm blockExoticSubdeps forbids
@ -19,6 +19,11 @@
- id: netxops
name: dsh-netxops
config:
toolsEnabled: true
groupOpsPresets: ''
groupTopologyPresets: ''
groupBizMonitorPresets: ''
groupKbPresets: ''
apiUrl: http://127.0.0.1:8890
lang: zh
thinkingLanguage: auto
@ -34,7 +39,7 @@
imTargetId: ''
imTargets: ''
groupOpsInPreset: true
groupOpsPublic: true
groupOpsPublic: false
groupTopologyInPreset: false
groupTopologyPublic: false
groupBizMonitorInPreset: true

View file

@ -1,6 +1,6 @@
# Host-plane Netx Ops bridge: settings + credentials → connection publish.
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
# Capability groups: ops in preset + public on; topology off.
# Capability groups: ops in preset (not public by default); topology off.
# Rule: one group ↔ one skill (netx-ops / netx-topology).
#
# IM + cron are separate profile bundles (pnpm blockExoticSubdeps forbids
@ -15,6 +15,11 @@
- id: netxops
name: dsh-netxops
config:
toolsEnabled: true
groupOpsPresets: ''
groupTopologyPresets: ''
groupBizMonitorPresets: ''
groupKbPresets: ''
apiUrl: http://127.0.0.1:8890
lang: zh
thinkingLanguage: auto
@ -30,7 +35,7 @@
imTargetId: ''
imTargets: ''
groupOpsInPreset: true
groupOpsPublic: true
groupOpsPublic: false
groupTopologyInPreset: false
groupTopologyPublic: false
groupBizMonitorInPreset: true

View file

@ -6,7 +6,7 @@ One install wires **all of**:
| Piece | How you use it |
|-------|----------------|
| Host tools | Capability groups **ops / topology** (one group ↔ one skill). Default ops in Ops preset; topology and all public off. Other agents may mount `dsh-netxops/tools-ops|topology` |
| Host tools | Capability groups **ops / topology / biz-monitor**. Each group targets Netx Ops, selected other presets, or all presets; tools and skills share the policy. A master switch revokes all Netx publication. Defaults: ops+bizMonitor in Netx Ops, all public off. |
| Companion plugins | **dsh-im-ops** + **dsh-ops-cron** — add as **direct** profile deps in the same command (DSH/pnpm blocks `github:` as transitive deps) |
| Plugins card | Settings → Plugins → **Netx Ops** (URL / API lang / thinking+reply language / token / capability groups) |
| Agent preset + skills | Settings → Agent presets → **Netx Ops**. Host composes from **this host's** shipped **standard** + Netx Ops overlays (no preset inheritance). DSH ≥0.2 (Desktop **and** web) uses declarative `@deepseek-ai/dsh-agent-preset` rows synced into that profile's `cordis.patch.yml` (from `@deepseek-ai/dsh-web-app/presets/standard.patch.yml`). A directory under `~/.dsh/.agent-presets/netxops` is still written for older hosts, but 0.2 no longer lists it. Restart Desktop / `dsh web` once after upgrade. Playbooks follow the same group toggles |
@ -35,21 +35,25 @@ bun run link:peers
# 或 PowerShell: .\scripts\link-dsh-peers.ps1
```
脚本优先用 `~/.dsh/profiles/node_modules` 里那一套 peers(schemastery 3.18.2 + dsh-*),**不要**单独链 Desktop profile 里的 schemastery 3.18.4——混链会导致 `Config({})` 变成空代理,Desktop 直接 **web boot: dsh-netxops failed**。
脚本优先从 `~/.dsh/profiles/node_modules` 选择一整套 peers;该目录不完整时才考虑 profile 的完整依赖集。不要单独替换某个内部包版本,混链可能使 Config 或服务上下文不兼容。
然后**重启 Desktop**(或在插件页关掉再启用 dsh-netxops)。症状对照:
| 现象 | 原因 |
|------|------|
| `Cannot find package '@deepseek-ai/schemastery'` / **netxops 未运行** | 未跑 `link:peers` |
| **web boot: dsh-netxops failed** | peers 版本错乱(例如链了 Desktop 的 schemastery 3.18.4)→ 重跑 `bun run link:peers` 再重启 |
| `pending (waiting for service: settingsScope)` → 会话无 `netx__*` / 无 `netx-ops` | 旧客户端仍硬/软等待已删除的 `settingsScope`(DSH 0.2);用 **≥0.1.50** 的 tgz 重装并完全重启 Desktop,勿用脏的 `link:` / 混锁 `.modules.yaml` |
| 日志 `no connection yet` / 会话无 `netx__*`,且 `profiles/*/cordis.yml` 只有 `preset-netxops`、**没有** `name: dsh-netxops` | 脏重装后 bundle 宿主行丢失、预设通道自愈不同步。**≥0.1.53** 会在预设 tools 启动时把宿主块写回 `cordis.patch.yml`;看日志 `host bridge … MISSING … wrote it back` 后**完全退出再开** Desktop/`dsh web`。仍不行则 `dsh plugin remove dsh-netxops` 再 `add` |
| **web boot: dsh-netxops failed** | 检查具体加载错误;若 peers 混链,重跑 `bun run link:peers` 后重启 |
| `pending (waiting for service: settingsScope)` → 会话无 `netx__*` / 无 `netx-ops` | 旧客户端仍硬/软等待已删除的 `settingsScope`(DSH 0.2);用 **≥0.1.55** 的 tgz 重装并完全重启 Desktop,勿用脏的 `link:` / 混锁 `.modules.yaml` |
| Desktop 卡在 **Loading plugins…** / 设置页无 Netx Ops | 旧客户端 `immediately: true` 或同步 `inject(['configForms'])`;用 **≥0.1.55**(`immediately: false` + deferred poll) |
| 无 live connection,预设内无 `netx__*` | 检查 Host 插件是否启用及其加载错误。缺少 Host 时现在停止发布,不再通过预设入口自动补回被关闭的插件。 |
改 Host/Client 后照常 `bun run bundle`,一般不用重装;换机器或清过本仓库 `node_modules` 才需再 `link:peers`。
备选:`npm pack` 后在 Desktop「添加插件」填 **`.tgz` 绝对路径**(复制进 profile,不走 `link:`,也就不用 link peers)。
**≥0.1.54**:运行时依赖已打进 `lib/`(含 `fflate`),装 tgz **不必访问 npm registry**;若仍去拉 `registry.npmjs.org/fflate` 说明装的是旧包。现场不要在 `profiles/desktop` 里手搓 `pnpm add`。
备选:`npm pack` 后在 Desktop「添加插件」填 **`.tgz` 绝对路径**(复制进 profile,不走 `link:`,也就不用 link peers)。
当前实现:第三方运行时依赖(含 `fflate`)已打进 `lib/`;六个 agent-tools 入口为自包含包。Host 启动即发布实际策略,凭据软注入并异步补齐。缺少 Host 连接桥接时,预设入口保持关闭。
插件卡使用 `settings.section` 和官方 `settings.plugin.item`,配合 deferred `configForms`。能力组支持指定其他预设、全预设公开及总开关。详情见 [工具发布与 UI 修复说明](PUBLICATION-REVIEW.zh.md)。
## Install
@ -69,7 +73,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
Optional: **导出全部会话** downloads `dsh-sessions-<host>-<utc>.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts).
Capability groups: **ops** defaults on (in Netx Ops + published to other presets). Enable **topology** if needed; uncheck **对其他预设公开** to keep tools Netx-Ops-only. Capability toggles **auto-save and hot-reload** (Host remounts `netx__*` / skills immediately — no Desktop restart).
Capability groups: **ops + biz-monitor** default on in Netx Ops only. Use **指定其他预设** for selective publication or **对所有预设公开** for Host-wide publication. The master switch revokes all Netx tools and skills while keeping selections. These toggles auto-save and affect the next model request without a restart.
Optional: **知识库** — set package root (MANIFEST v1.0); default mounts `_skills/kb-*` into Netx Ops; optional public. See [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md).
2. Restart after install/upgrade so the bundle patch layers apply. Open Settings → **Agent presets** → **Netx Ops** should appear.
The Netx Ops preset is **standard + netxops tools/persona** (shell, search, plan, todo, web, workflows, … plus `netx__*`). Re-activate (or restart the Host) so `$DSH_HOME/.agent-presets/netxops` is recomposed from the current Harness `standard` preset.
@ -94,7 +98,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
1. Plugins card **Netx Ops** visible.
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
3. Agent presets → Custom → **Netx Ops**.
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Cutover analysis: `netx__getBizMonitorContext` … (**bizMonitor** → `netx-biz-monitor`). Canvas / dual_unit need **topology** (`netx-topology`).
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Cutover analysis: `netx__getBizMonitorContext` … (**bizMonitor** → `netx-biz-monitor`). Native canvas tools need **topology** (`netx-topology`); advanced dual-unit/orbit/polish requires the separate topology MCP.
5. Export section shows a session count; **下载 ZIP** starts a browser download.
6. Optional knowledge-base badge shows operator/country/version when `kbRoot` + MANIFEST are valid.

View file

@ -0,0 +1,99 @@
# Netx Ops 工具发布与 UI 修复说明
分析日期:2026-10-11。本次修改在原工作区已有修改的基础上完成,未发布 npm 版本,包版本仍为 0.1.69。
## 结论
问题横跨设置持久化、工具入口、Host 与预设作用域、异步 skill 生命周期和安装模板。单独调整 UI 复选框无法解决。现在四类能力可以独立选择 Netx Ops、指定其他预设或全部预设,且受统一发布总开关控制。
这里的“发布”是向模型注册本插件提供的工具和 skill,不是上传 npm 包,也不是更改 Netx 服务端 API 权限。关闭后,后续模型请求不再获得这些能力;已经发送的模型请求、正在执行的网络操作,以及模型已读入的 skill 内容不会被追溯撤回。
## 原因及处理
1. **分组入口绕过发布设置。** `tools-ops`、`tools-topology` 等旧入口通过 forced 模式强制挂载。现保留兼容导出,但它们只过滤能力组,仍遵守发布策略和总开关。缺少 Host 连接桥接时默认停止发布,移除“自动补回 Host”行为。
2. **关闭值被默认值覆盖。** 布尔值 false 原先可能被解释为 unset;清空目标预设也可能恢复配置默认值。现在保存明确的 false 与空字符串,当前字段优先于旧别名。只有点击“重置”才回到配置基线。
3. **其他预设只有全局公开,缺少定向发布。** 新增按组的预设 ID 列表,并读取当前 Host 的真实预设目录。定向能力注册到每个活跃会话的精确 scope;创建会话、切换预设、策略改变和卸载均同步处理。
4. **Host 与预设重复挂载。** 公开组由 Host 注册一次,再由各预设继承;局部挂载不再重复注册这些组。取消公开后,根据原先保留的 Netx Ops/指定预设选择恢复局部发布。
5. **异步加载在关闭后继续注册 skill。** 分组与知识包加载加入 AbortSignal、代次检查和清理。关闭、重载和卸载会撤销待完成的加载;KB context 也随发布策略重挂载。
6. **部分注册失败留下残余工具。** 工具和 skill 均逐项记录 disposer;发生中途失败时回滚此前注册。连接/知识库订阅者相互隔离,一个订阅者出错不会阻止其他挂载处理。
7. **凭据解析竞态覆盖最新设置。** 策略先同步生效,凭据异步解析按请求代次提交,过期结果丢弃。更换 URL 或凭据引用时不复用旧 token;live snapshot 的空 token 不会在工具调用时回退成旧凭据。
8. **诊断只反映最后一个模块。** 独立构建的 Host 和 tools 入口现在通过进程共享 inventory 记录各挂载的来源、作用域、组、工具名和错误。UI 展示当前 Host 的挂载明细,工具总数按名称去重。这个数字表示注册结果,并不证明 Netx API 鉴权成功。
9. **UI 注册接口不匹配本机 Harness。** 本机实际接口是 `settings.plugin.item`,按设置命名空间 key 匹配卡片;已替换无效的 `plugins.item`。两端严格类型检查以本机官方声明为依据。
10. **自动保存整张表单并丢失快速编辑。** 保存队列现在按字段提交,处理保存期间的新编辑,失败后释放 saving 状态并保留草稿。切换能力组不会保存其他连接草稿;浏览知识库目录仅自动保存 kbRoot。
11. **双安装环境混用模板与配置。** 优先延后读取运行中 Host 的真实 standard 声明,再叠加 Netx Ops;不阻塞 Host 初始发布。仅在旧 Host 不支持读取时按当前 profile 查找模板和依赖,只同步当前 profile 的 patch。预设目录采用受管文件哈希更新,保留用户编辑与附加文件;不再删除整个目录、不写包内生成文件。managed 标记缺失 END 时拒绝截断配置。
12. **拓扑工具目录包含不能执行的能力。** 原生 REST 无法完成 `sinkTopologyDualUnits`,已从原生发布目录移除。拓扑 skill 加入能力说明:高级 dual-unit/orbit/polish 需要对应 MCP;原生布局和分析按实际 REST 实现使用。CLI 执行及拓扑修改工具不再声明并发安全。
13. **表单绑定到空命名空间,或者仅保存到客户端内存。** Harness 的 `configForms.get(id)` 对未知 ID 也返回 placeholder;原代码取到第一个对象即绑定,可能永远等不到真正的 netxops 表单。现在仅选择 ready 的真实 namespace,并在连接前显示等待状态、禁用保存;提交层也拒绝向未连接或只读表单写入。
14. **新版 Host 的 volatile 配置被当成普通标量。** 当前 Harness 的 `.volatile()` 返回稳定引用,须通过 `.get()` 读取。旧 profile 缺少新预设列表字段时,默认空字符串也会被包装,直接调用 `.split()` 就导致插件激活失败;布尔引用还可能使 `false` 被误读。现在保留原始引用,每次操作读取普通快照,统一覆盖连接、语言、凭据引用、超时、安装、告警、分组及 KB 字段。兼容旧 Host 的普通标量;预设解析对非法类型返回空列表。监听当前 Loader 的实例事件 `loader/volatile-update`,在引用提交后立即更新发布策略。
15. **卸载广播触发失效上下文中的重新挂载。** 真实 Cordis 测试发现,Host 卸载时发布关闭状态,部分订阅尚未清理,会再次调用失效上下文的 `effect()`。现在卸载标志同时阻止工具、skills 和 KB 的挂载回调,清理阶段不会重新注册能力。
16. **工具已恢复,但客户端永久等待 Host 设置。** 客户端未声明 `configForms` 注入,却在轮询时直接读取 `ctx.configForms`;Cordis 会拒绝这种访问,延后读取也不会解除限制。候选命名空间还读取了不存在的 `ctx.name`,同样抛出异常,二者都被轮询的 catch 掩盖。现在用 `ctx.get('configForms')` 查询当前可用的可选服务,移除无效命名空间来源,目录选择器也使用可选服务查询。新增已构建客户端在真实 Cordis 中的表单绑定与写入测试,兼容当前 configForms 和旧 settingsScope。
## 现在如何配置
打开设置 → 插件 → Netx Ops,启用“启用 Netx 能力发布”。每组分别选择:
- “在 Netx Ops 预设中启用”:控制内置 netxops。
- “指定其他预设”:勾选任意当前可用预设。清空即撤销全部定向发布;已失效的预设 ID 仍可取消。
- “对所有预设公开”:通过 Host 向所有预设发布,包括 netxops。取消公开不会清空前两种选择。
例如只向标准助手开放运维能力:取消 ops 的 Netx Ops 开关与公开开关,在指定预设中选择 standard。拓扑、割接业务与 KB 均可使用不同目标。
总开关关闭会撤销插件管理的所有 Netx 工具和 skill,保留各组选择。它不控制独立的告警投递开关、会话导出或其他插件/MCP 注册的同名能力。能力选择自动保存;API 地址和手工输入的 KB 路径等连接草稿仍使用保存/丢弃。
组目录当前包含 ops 15 个、topology 15 个、biz-monitor 8 个 REST 工具。知识库另有本地工具及 MANIFEST 声明的 skill 包;实际数量依配置及加载结果变化。
## 实现入口
- `src/netx/capability-groups.ts`:统一发布策略、兼容旧字段和预设目标解析。
- `src/netx/config-values.ts`:按官方跨 ESM/CJS 协议读取 volatile 引用,兼容普通标量。
- `src/netx/preset-publication.ts`:活跃会话的定向作用域挂载与清理。
- `src/netx/group-tools-plugin.ts`:局部工具、skill、KB 的动态生命周期。
- `src/index.ts`:Host 设置桥接、同步策略、凭据解析、RPC 和全局挂载。
- `src/client/card-form.ts`、`controller.ts`、`NetxopsCard.tsx`:提交队列、预设目录和 UI。
- `src/agent-preset-install.ts`:当前 profile 的模板选择与保守安装更新。
## 本机与升级
已核对桌面与 Web 的 `node_modules/dsh-netxops` 均链接到当前 `D:\project\chatgpt\netxops`。本次构建会更新它们共同引用的 `lib/`。需要分别重启桌面 Host 和 Web Host 一次,以重新加载模块与客户端包;日常开关热更新无需重启。
未在运行中的真实 Host 中改变预设选择、重启应用或调用 Netx API。表单写入使用隔离的测试服务,真实凭据与网络设备未参与测试。用户反馈 Netx Ops 预设可以调用工具;随后通过 computer-use 核对正在运行的桌面设置页,客户端热更新后等待提示消失,显示已有连接配置和 23 个已挂载 Netx 工具。
安装器保留用户管理的同名预设,以及无法确认未被编辑的旧 managed 文件,并记录警告。旧版 Harness 的 `.agent-presets` 目录是共享目录;两套 Host 若版本不同,应优先使用各自 profile 的声明式预设。`$DSH_HOME/netxops-mount-status.json` 为旧版诊断快照,两个进程共享 home 时可能互相覆盖;设置页的 RPC inventory 来自各自进程,应以它为准。
本机离线 Web 模板探测发现源码 standard 与已安装依赖的查找结果不一致,缺少 workflow-ptc、tool-present、plugin-manager/tools 等引用。运行时优先从 Host registry 读取 standard 可避免使用另一份源码模板;若旧 Host 只能走文件回退且依赖不完整,安装器会记录错误并保留已有预设。
## 验证与维护
验证命令:
```powershell
bun run bundle
bun run typecheck
bun test
```
回归覆盖目标预设隔离、Host 继承去重、总开关、旧分组入口、注册回滚、异步取消、卸载、快速切换、保存失败重试、空目标、双 profile 与安装文件保留。定向发布集成测试使用本机真实 Cordis 和 dsh-scope 服务上下文。
最终检查:Host/Client 构建成功;严格类型检查通过;133 项测试通过;打包清单检查通过。发布文件清单排除了本机 `.npmrc` 和私有调试控制脚本。
新增启动回归使用本机官方 Schemastery/Cosmokit 的当前源码作为 schema peer,加载已构建的 Host JS;本机安装的旧 `lib` 不支持 volatile 引用,因此单独测试 `Config({})` 无法覆盖新版桌面行为。测试覆盖旧 profile 的缺省字段、新版和旧版配置、开关为 false、保留引用的更新、即时撤销,以及真实 Cordis 中的激活、所属实例事件和无错误卸载。该集成测试使用隔离目录与工具/skill 服务,不连接 Netx 生产服务。
浏览器验证包括选择其他预设、总开关关闭后禁用组控件且保留选择、重新启用,以及自动保存目标时保留未保存的 API 地址草稿。预览使用实际 Card/Controller 和内存设置适配器;运行 `bun run preview:client` 可复查。
![新版发布 UI,模拟 Host 数据](assets/publication-preview.jpg)
实际桌面设置卡片已核对;Web 界面、当前与新会话的定向能力目录,以及配置好的 Netx API token 权限仍需在相应环境验证。高级拓扑 MCP、IM 投递和生产 Netx 服务连通性不包含在本次离线验证中。

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View file

@ -0,0 +1,51 @@
/** Exercise the real card and controller with an in-memory Host for visual QA. */
import { useSyncExternalStore } from 'react'
import { createRoot } from 'react-dom/client'
import { NetxopsCard } from '../../src/client/NetxopsCard.tsx'
import { NetxopsCardController } from '../../src/client/controller.ts'
import { zh, en } from '../../src/client/locales.ts'
import { unconfiguredKbSnapshot } from '../../src/netx/kb-manifest.ts'
const base = {
apiUrl: 'http://127.0.0.1:8890', lang: 'zh', thinkingLanguage: 'auto', replyLanguage: 'follow-user',
nmsProvider: 'zte-ume', tokenCredentialRef: 'NETX_API_TOKEN', toolsEnabled: true,
groupOpsInPreset: true, groupOpsPublic: false, groupOpsPresets: '',
groupTopologyInPreset: false, groupTopologyPublic: false, groupTopologyPresets: '',
groupBizMonitorInPreset: true, groupBizMonitorPublic: false, groupBizMonitorPresets: '',
kbRoot: '', groupKbInPreset: true, groupKbPublic: false, groupKbPresets: '',
alarmPushEnabled: false, alarmDeliverDsh: true, alarmDeliverIm: false,
imTargets: '', imBotId: '', imTargetId: '',
}
const user = {}, listeners = new Set<() => void>()
const scope = {
getSnapshot: () => ({ status: 'ready', writable: true, value: { ...base, ...user }, base, user }),
subscribe(fn: () => void) { listeners.add(fn); return () => listeners.delete(fn) },
async set(field: string, value: unknown) { user[field] = value; for (const fn of listeners) fn() },
async unset(field: string) { delete user[field]; for (const fn of listeners) fn() },
}
const controller = new NetxopsCardController(scope, { get: () => undefined } as never)
controller.setAlarmPushRpc(async (_channel, endpoint) => {
const value = scope.getSnapshot().value
if (endpoint === 'presets.catalog') return { ok: true, value: { presets: [
{ id: 'netxops', name: 'Netx Ops' }, { id: 'standard', name: '标准助手' },
{ id: 'review', name: '代码审查' }, { id: 'network-analyst', name: '网络分析' },
] } }
if (endpoint === 'tools.status') return { ok: true, value: {
mounts: [{ source: 'Netx Ops', plane: 'preset', version: 'dev', groups: ['ops', 'bizMonitor'],
names: value.toolsEnabled ? Array.from({ length: 23 }, (_, i) => `netx__preview${i}`) : [],
count: value.toolsEnabled ? 23 : 0 }],
connection: { toolsEnabled: value.toolsEnabled, apiUrl: value.apiUrl, tokenConfigured: false },
} }
if (endpoint === 'kb.status' || endpoint === 'kb.reload') return { ok: true, value: unconfiguredKbSnapshot() }
if (endpoint === 'alarm-push.status') return { ok: true, value: { phase: 'disabled', enabled: false, wsUrl: '' } }
if (endpoint === 'im-delivery.catalog') return { ok: true, value: { available: false, options: [] } }
return { ok: true, value: { available: false, reasonCode: 'no_persistence', sessionCount: 0 } }
})
const face = controller.inject(), store = face.hooks.netxopsCard
const dictionary = new URLSearchParams(location.search).get('lang') === 'en' ? en : zh
function Preview() {
const state = useSyncExternalStore(store.subscribe, store.getSnapshot)
return <NetxopsCard {...face} t={key => dictionary[key]}
useNetxopsCard={selector => selector(state)} {...({} as never)} />
}
createRoot(document.getElementById('root')!).render(<Preview />)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,14 @@
{
"name": "dsh-netxops",
"version": "0.1.54",
"version": "0.1.69",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT",
"type": "module",
"private": false,
"main": "./lib/index.js",
"engines": {
"node": ">=20"
},
"exports": {
".": "./lib/index.js",
"./tools": "./lib/agent-tools.js",
@ -15,6 +18,7 @@
"./tools-topology": "./lib/agent-tools-topology.js",
"./tools-biz-monitor": "./lib/agent-tools-biz-monitor.js",
"./client": "./lib/client.js",
"./cordis.patch.yml": "./cordis.bundle.patch.yml",
"./package.json": "./package.json"
},
"files": [
@ -27,15 +31,28 @@
"lib/agent-tools-topology.js",
"lib/agent-tools-biz-monitor.js",
"lib/client.js",
"tests/",
"test/",
"presets/",
"cordis.patch.yml",
"cordis.bundle.patch.yml",
"docs/",
"examples/",
"scripts/",
"scripts/build-host.mjs",
"scripts/build-client.mjs",
"scripts/gen-preset-patch.mjs",
"scripts/typecheck.mjs",
"scripts/preview-client.mjs",
"scripts/link-dsh-peers.mjs",
"scripts/link-dsh-peers.ps1",
"scripts/link-dsh-peers.sh",
"scripts/link-preset.ps1",
"scripts/link-preset.sh",
"scripts/set-netx-token.ps1",
"scripts/set-netx-token.sh",
"scripts/sync-skills-from-netx.ps1",
"README.md",
"LICENSE",
".npmrc"
"LICENSE"
],
"keywords": [
"dsh-plugin",
@ -58,8 +75,11 @@
"bundle:host": "bun run scripts/build-host.mjs",
"bundle:client": "bun run scripts/build-client.mjs",
"gen:preset-patch": "bun run scripts/gen-preset-patch.mjs",
"typecheck": "node scripts/typecheck.mjs",
"preview:client": "bun scripts/preview-client.mjs",
"link:peers": "node scripts/link-dsh-peers.mjs --profile desktop",
"link:peers:ps1": "powershell -NoProfile -File ./scripts/link-dsh-peers.ps1"
"link:peers:ps1": "powershell -NoProfile -File ./scripts/link-dsh-peers.ps1",
"test": "bun test test tests"
},
"dsh": {
"bundle": {
@ -67,7 +87,7 @@
},
"client": {
"platform": "web",
"immediately": true,
"immediately": false,
"inject": [
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-locale"
@ -75,9 +95,33 @@
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-credentials": "*",
"@deepseek-ai/dsh-settings": "*",
"@deepseek-ai/dsh-tools": "*",
"@deepseek-ai/dsh-scope": "*",
"@deepseek-ai/schemastery": "*",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": {
"optional": true
},
"@deepseek-ai/dsh-credentials": {
"optional": true
},
"@deepseek-ai/dsh-settings": {
"optional": true
},
"@deepseek-ai/dsh-tools": {
"optional": true
},
"@deepseek-ai/dsh-scope": {
"optional": true
},
"@deepseek-ai/schemastery": {
"optional": true
},
"react": {
"optional": true
}

View file

@ -8,7 +8,7 @@ description: netx 运维:告警、网元、只读 CLI。
处理 netx **告警 / 网元清单 / 只读 CLI** 时遵循本手册。
画布布图不在本 skill(见 **netx-topology**)。本手册里的「路径」仅指 `findTopologyPaths` 查两端关联。
**工具名**:DSH 为 `netx__` + stem(如 `netx__queryNmsAlarms`);MCP 多为同名裸 stem。下文一律写 stem。
**工具名**:DSH 为 `netx__` + stem(如 `netx__queryNmsAlarms`)。下文写 stem 时,调用一律加 `netx__` 前缀。
**两套「一批」(勿混):**

View file

@ -1,10 +1,14 @@
/**
* Build lib/index.js (host) and lib/agent-tools.js (Ops preset tool mount).
* Build lib/index.js (host) and lib/agent-tools*.js (preset tool mounts).
* Node refuses to strip TypeScript inside node_modules, so GitHub/npm installs
* must ship prebuilt JS. Usage: bun run scripts/build-host.mjs
*
* Agent-tools entries are built as self-contained bundles (no shared chunk).
* Cordis/Desktop load each export path independently; a split chunk once missed
* a connection-publish race and is harder to reason about under custom loaders.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
@ -16,6 +20,7 @@ const external = [
'@deepseek-ai/dsh-credentials',
'@deepseek-ai/dsh-settings',
'@deepseek-ai/dsh-tools',
'@deepseek-ai/dsh-scope',
'@deepseek-ai/dsh-llm',
'@deepseek-ai/dsh-brand',
'@deepseek-ai/dsh-agent',
@ -39,6 +44,21 @@ const entries = [
mkdirSync(outDir, { recursive: true })
/** Drop split-build leftovers from 0.1.56–0.1.57. */
for (const name of readdirSync(outDir)) {
if (name === 'agent-tools-shared.js' || /^chunk-.*\.js$/.test(name)) {
rmSync(join(outDir, name), { force: true })
}
}
for (const name of ['_split-test', '_split-test2']) {
const target = resolve(outDir, name)
const within = relative(resolve(root), target)
if (within.startsWith('..') || isAbsolute(within) || dirname(target) !== resolve(outDir)) {
throw new Error(`Refusing cleanup outside the build directory: ${target}`)
}
rmSync(target, { recursive: true, force: true })
}
for (const { entry, out } of entries) {
const result = await Bun.build({
entrypoints: [entry],

View file

@ -17,7 +17,9 @@ import { composeNetxopsPresetArtifacts } from '../src/agent-preset-install.ts'
import { readFileSync } from 'node:fs'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const artifacts = composeNetxopsPresetArtifacts()
// This distributable declaration targets Desktop; runtime installation composes
// separately against the running Host instead of reusing this static template.
const artifacts = composeNetxopsPresetArtifacts('desktop')
const declarativePath = join(root, 'presets', 'netxops.preset.patch.yml')
writeFileSync(declarativePath, artifacts.declarativePatch)
console.log(

View file

@ -14,7 +14,7 @@
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
@ -27,6 +27,7 @@ const peers = [
'dsh-credentials',
'dsh-settings',
'dsh-tools',
'dsh-scope',
'dsh-llm',
]
@ -38,7 +39,7 @@ function profileArg() {
/**
* Prefer the shared healed fallback `profiles/node_modules` (one coherent peer
* set: schemastery 3.18.2 + dsh-* 0.1.5-rc.2). Do NOT prefer the Desktop
* set). Do NOT prefer the Desktop
* profile's schemastery first — Desktop 0.2 ships 3.18.4 which, when linked
* alone into this package, makes `Config({})` return cosmokit volatile proxies
* instead of defaults and Host apply fails ("web boot: dsh-netxops failed").
@ -64,9 +65,13 @@ function linkOne(shortName, target) {
const dest = join(root, 'node_modules', '@deepseek-ai', shortName)
mkdirSync(dirname(dest), { recursive: true })
if (existsSync(dest)) {
const within = relative(resolve(root, 'node_modules', '@deepseek-ai'), resolve(dest))
if (!within || within.startsWith('..') || isAbsolute(within) || within.includes('/') || within.includes('\\')) {
throw new Error(`refusing to replace path outside the peer directory: ${dest}`)
}
const st = lstatSync(dest)
if (st.isSymbolicLink() || st.isDirectory()) {
rmSync(dest, { recursive: true, force: true })
if (st.isSymbolicLink()) {
rmSync(dest, { force: true })
} else {
throw new Error(`refusing to replace non-link path ${dest}`)
}
@ -81,9 +86,11 @@ const dirs = candidateDirs(profile)
console.log(`DSH_HOME=${dshHome} profile=${profile}`)
console.log(`search:\n ${dirs.join('\n ')}`)
const coherent = dirs.find(dir => peers.every(name => existsSync(join(dir, name, 'package.json'))))
if (!coherent) throw new Error('No complete Harness peer set found; refusing to mix package generations.')
let missing = 0
for (const name of peers) {
const target = resolvePeer(name, dirs)
const target = resolvePeer(name, [coherent])
if (!target) {
console.error(`missing peer @deepseek-ai/${name}`)
missing++

View file

@ -18,8 +18,7 @@ param(
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
$DstRoot = Join-Path $Root 'node_modules\@deepseek-ai'
# Shared heal first — profile-local schemastery (Desktop 0.2 = 3.18.4) breaks
# Config({}) when mixed with shared dsh-* 0.1.5-rc.2 (volatile proxies, Host fail).
# Select one complete peer set, avoiding packages from different Host generations.
$SearchRoots = @(
(Join-Path $DshHome 'profiles\node_modules\@deepseek-ai'),
(Join-Path $DshHome "profiles\$Profile\node_modules\@deepseek-ai"),
@ -33,15 +32,25 @@ $Peers = @(
'dsh-credentials',
'dsh-settings',
'dsh-tools',
'dsh-scope',
'dsh-llm'
)
New-Item -ItemType Directory -Force -Path $DstRoot | Out-Null
Write-Host "DSH_HOME=$DshHome profile=$Profile"
$CoherentRoot = $null
foreach ($searchRoot in $SearchRoots) {
$complete = $true
foreach ($peer in $Peers) {
if (-not (Test-Path -LiteralPath (Join-Path $searchRoot "$peer\package.json"))) { $complete = $false; break }
}
if ($complete) { $CoherentRoot = $searchRoot; break }
}
if (-not $CoherentRoot) { throw 'No complete Harness peer set found; refusing to mix package generations.' }
foreach ($name in $Peers) {
$src = $null
foreach ($root in $SearchRoots) {
foreach ($root in @($CoherentRoot)) {
$candidate = Join-Path $root $name
if (Test-Path (Join-Path $candidate 'package.json')) {
$src = $candidate
@ -52,12 +61,17 @@ foreach ($name in $Peers) {
throw "Peer @deepseek-ai/$name not found under:`n $($SearchRoots -join "`n ")"
}
$dst = Join-Path $DstRoot $name
$absoluteDst = [IO.Path]::GetFullPath($dst)
$absolutePeerRoot = [IO.Path]::GetFullPath($DstRoot).TrimEnd('\')
if ([IO.Path]::GetDirectoryName($absoluteDst) -ne $absolutePeerRoot) {
throw "Refusing to replace a path outside the peer directory: $absoluteDst"
}
if (Test-Path $dst) {
$item = Get-Item $dst -Force
if ($item.LinkType -eq 'Junction' -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
cmd /c "rmdir `"$dst`""
Remove-Item -LiteralPath $absoluteDst -Force
} else {
Remove-Item -Recurse -Force $dst
throw "Refusing to replace a non-link peer directory: $absoluteDst"
}
}
New-Item -ItemType Junction -Path $dst -Target $src | Out-Null

View file

@ -0,0 +1,33 @@
/** Local UI QA using the real controller; no requests to Netx or the installed Host. */
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
const anchors = ['desktop', 'web', ''].map(profile =>
join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-client-ui-renderer', 'package.json'))
let require
for (const anchor of anchors.filter(existsSync)) {
const candidate = createRequire(anchor)
try { candidate.resolve('react-dom/client'); require = candidate; break } catch { /* Try another Host. */ }
}
if (!require) throw new Error('UI preview requires the installed Harness React runtime.')
const result = await Bun.build({ entrypoints: [join(root, 'examples/local-debug/ui-preview.tsx')],
target: 'browser', format: 'esm', define: { 'process.env.NODE_ENV': JSON.stringify('development') },
plugins: [{ name: 'host-react', setup(build) {
build.onResolve({ filter: /^(react(?:\/.*)?|react-dom\/client)$/ }, args => ({ path: require.resolve(args.path) }))
} }],
})
if (!result.success) throw new Error(result.logs.map(String).join('\n'))
const bundle = await result.outputs[0].text()
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
if (new URL(request.url).pathname === '/app.js') return new Response(bundle, { headers: { 'content-type': 'text/javascript' } })
return new Response(`<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Netx Ops · UI QA</title><style>body{margin:0;background:#f5f6f8;font-family:system-ui,sans-serif}main{max-width:820px;margin:24px auto;padding:0 20px}.preview-note{font-size:12px;color:#646a73;margin-bottom:16px}</style>
<main><p class="preview-note">本地 UI 验证 · 模拟预设与状态,仅在内存中保存。Local UI preview; simulated Host data.</p><div id="root"></div></main><script type="module" src="/app.js"></script></html>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } })
} })
console.log(`Netx Ops UI preview: ${server.url}`)

54
scripts/typecheck.mjs Normal file
View file

@ -0,0 +1,54 @@
/** Check both plugin faces against the locally installed Harness declarations. */
import { createRequire } from 'node:module'
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
const anchors = [join(root, 'package.json'), ...['desktop', 'web', ''].map(profile =>
join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-client-ui-renderer', 'package.json'))]
const requires = anchors.filter(existsSync).flatMap(path => [createRequire(path), createRequire(realpathSync(path))])
function findPackage(name) {
for (const req of requires) {
try { return req.resolve(`${name}/package.json`) } catch { /* Try another installed profile. */ }
}
throw new Error(`Missing typecheck peer ${name}; install the Harness and run link:peers.`)
}
const paths = {}
for (const spec of [
'@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-locale/client',
'@deepseek-ai/dsh-client-ui-renderer/client', '@deepseek-ai/dsh-api-remotes/client',
'@deepseek-ai/dsh-client-ui-settings/client',
'@deepseek-ai/dsh-client-ui-settings-plugins/client',
]) {
const parts = spec.split('/'), pkgName = parts.slice(0, 2).join('/'), subpath = parts.slice(2).join('/')
const pkgPath = findPackage(pkgName), pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const entry = pkg.exports?.[subpath ? `./${subpath}` : '.']
const types = typeof entry === 'object' ? entry.types : pkg.types
if (!types) throw new Error(`No declarations for ${spec}`)
paths[spec] = [join(dirname(pkgPath), types)]
}
const nodeTypes = dirname(findPackage('@types/node'))
const folder = mkdtempSync(join(tmpdir(), 'netxops-typecheck-'))
try {
const configPath = join(folder, 'tsconfig.json')
writeFileSync(configPath, JSON.stringify({ compilerOptions: {
target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx',
noEmit: true, strict: true, skipLibCheck: true, allowImportingTsExtensions: true,
types: ['node', 'react'], typeRoots: [dirname(nodeTypes), join(root, 'node_modules', '@types')], paths,
}, include: [join(root, 'src', '**', '*.ts'), join(root, 'src', '**', '*.tsx')].map(path => path.replaceAll('\\', '/')) }))
const result = spawnSync(process.execPath, [join(root, 'node_modules', 'typescript', 'bin', 'tsc'), '-p', configPath],
{ cwd: root, stdio: 'inherit' })
process.exitCode = result.status ?? 1
} finally {
// The only cleanup target is the exact unique temporary directory returned above.
const target = realpathSync(folder)
const within = relative(realpathSync(tmpdir()), target)
if (!within || within.startsWith('..') || isAbsolute(within) || !target.startsWith(resolve(folder))) {
throw new Error(`Refusing cleanup outside the temporary workspace: ${target}`)
}
rmSync(target, { recursive: true, force: true })
}

View file

@ -2,7 +2,7 @@
* Install Netx Ops agent preset for both DSH host styles:
*
* - DSH ≥0.2 (`@deepseek-ai/dsh-agent-preset` + registry): declarative insert
* row synced into **desktop and web** profile `cordis.patch.yml` on apply.
* row synced into the **running** profile's `cordis.patch.yml` on apply.
* Directory `$DSH_HOME/.agent-presets/netxops` is still written for older
* hosts that scan `@deepseek-ai/dsh-agent-presets`, but 0.2 web/desktop no
* longer load that roster — without the patch row, Settings → Agent presets
@ -19,15 +19,14 @@ import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { ensureNetxopsHostInProfilePatches } from './netx/heal-profile-entry.ts'
export const NETXOPS_PRESET_ID = 'netxops'
@ -47,6 +46,17 @@ function resolveDshHome(): string {
return join(homedir(), '.dsh')
}
/** Current Host profile; installing one Host never edits a second Host's patch. */
export function resolveActiveProfile(baseUrl = ''): string {
const declared = process.env.DSH_PROFILE?.trim()
if (declared && /^[\w-]+$/.test(declared)) return declared
const fromBase = /[\\/]profiles[\\/]([\w-]+)[\\/]/.exec(baseUrl)?.[1]
if (fromBase) return fromBase
const flag = process.argv.indexOf('--profile')
if (flag >= 0 && /^[\w-]+$/.test(process.argv[flag + 1] ?? '')) return process.argv[flag + 1]
return process.versions.electron ? 'desktop' : 'web'
}
function electronResourcesRoots(): string[] {
const roots: string[] = []
const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
@ -70,13 +80,13 @@ function readableFile(path: string): boolean {
* Prefer Electron `app.asar` (what the running Desktop actually loads) over
* `$DSH_HOME/profiles/node_modules` (often the older CLI heal tree).
*/
export function resolveDesktopStandardPatchPath(): string | null {
export function resolveDesktopStandardPatchPath(profile = resolveActiveProfile()): string | null {
const dshHome = resolveDshHome()
const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim()
const candidates: string[] = []
if (envOverride) candidates.push(envOverride)
for (const resources of electronResourcesRoots()) {
for (const resources of profile === 'desktop' ? electronResourcesRoots() : []) {
candidates.push(
join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'),
join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'),
@ -84,11 +94,10 @@ export function resolveDesktopStandardPatchPath(): string | null {
}
const requireAnchors = [
join(dshHome, 'profiles', 'desktop', 'package.json'),
join(dshHome, 'profiles', 'desktop', 'node_modules', 'dsh-netxops', 'package.json'),
join(dshHome, 'profiles', profile, 'package.json'),
join(dshHome, 'profiles', profile, 'node_modules', 'dsh-netxops', 'package.json'),
join(packageRoot(), 'package.json'),
join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'package.json'),
join(dshHome, 'profiles', 'web', 'package.json'),
]
for (const anchor of requireAnchors) {
if (!existsSync(anchor)) continue
@ -117,11 +126,10 @@ export function resolveDesktopStandardPatchPath(): string | null {
* Profile-local copies win over the shared `$DSH_HOME/profiles/node_modules`
* heal tree, which is frequently an older CLI generation than Desktop.
*/
export function resolveStandardPresetDir(): string | null {
export function resolveStandardPresetDir(profile = resolveActiveProfile()): string | null {
const dshHome = resolveDshHome()
const candidates = [
join(dshHome, 'profiles', 'desktop', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
join(dshHome, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
// Source checkout used by `dsh` CLI / local Harness.
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'DeepSeekHarness', 'packages', 'preset', 'agent-presets', 'presets', 'standard'),
// Shared heal tree last — often lags Desktop 0.2.
@ -131,8 +139,7 @@ export function resolveStandardPresetDir(): string | null {
if (existsSync(join(dir, 'agent.cordis.yml'))) return dir
}
for (const nm of [
join(dshHome, 'profiles', 'desktop', 'node_modules'),
join(dshHome, 'profiles', 'web', 'node_modules'),
join(dshHome, 'profiles', profile, 'node_modules'),
join(dshHome, 'profiles', 'node_modules'),
]) {
const pkgJson = join(nm, '@deepseek-ai', 'dsh-agent-presets', 'package.json')
@ -173,23 +180,21 @@ export function desktopRuntimeLikelyPresent(): boolean {
}
/** Host roots used for bare package health checks (same walk shape as DSH discovery). */
export function hostResolveBases(): string[] {
export function hostResolveBases(profile = resolveActiveProfile()): string[] {
const dshHome = resolveDshHome()
const bases: string[] = []
for (const resources of electronResourcesRoots()) {
for (const resources of profile === 'desktop' ? electronResourcesRoots() : []) {
bases.push(join(resources, 'app.asar', 'dsh', 'package.json'))
bases.push(join(resources, 'app.asar', 'dsh', 'node_modules'))
}
const localAppData = process.env.LOCALAPPDATA?.trim()
if (localAppData) {
if (localAppData && profile === 'desktop') {
const asarDsh = join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar', 'dsh')
bases.push(join(asarDsh, 'package.json'), join(asarDsh, 'node_modules'))
}
bases.push(
join(dshHome, 'profiles', 'desktop', 'package.json'),
join(dshHome, 'profiles', 'desktop', 'node_modules'),
join(dshHome, 'profiles', 'web', 'package.json'),
join(dshHome, 'profiles', 'web', 'node_modules'),
join(dshHome, 'profiles', profile, 'package.json'),
join(dshHome, 'profiles', profile, 'node_modules'),
join(dshHome, 'profiles', 'node_modules'),
join(packageRoot(), 'package.json'),
)
@ -389,7 +394,7 @@ export interface NetxopsPresetArtifacts {
/** Human-readable source path (directory or patch file). */
standardSource: string
/** Kind of host standard used for composition. */
standardKind: 'desktop-patch' | 'directory'
standardKind: 'desktop-patch' | 'directory' | 'host-registry'
/** Directory to seed `.agent-presets/netxops` from; null when patch-only. */
standardDir: string | null
name: string
@ -410,12 +415,8 @@ function readPresetMeta(metaPath: string): { name: string; description: string;
}
}
function desktopProfilePresent(): boolean {
return existsSync(join(resolveDshHome(), 'profiles', 'desktop'))
}
/** Compose standard + Netx Ops overlays into directory + declarative artifacts. */
export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
export function composeNetxopsPresetArtifacts(profile = resolveActiveProfile(), standardDocument?: string): NetxopsPresetArtifacts {
const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID)
const personaPath = join(overlayRoot, 'PERSONA.md')
const metaPath = join(overlayRoot, 'preset.yml')
@ -423,29 +424,35 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
throw new Error(`bundled preset overlay missing under ${overlayRoot}`)
}
const meta = readPresetMeta(metaPath)
const bases = hostResolveBases()
const patchPath = resolveDesktopStandardPatchPath()
const bases = hostResolveBases(profile)
const patchPath = standardDocument === undefined ? resolveDesktopStandardPatchPath(profile) : null
const usePatch = patchPath !== null && (
process.env.NETXOPS_PREFER_DESKTOP_PATCH !== '0'
) && (
typeof process.versions.electron === 'string'
|| process.env.NETXOPS_PREFER_DESKTOP_PATCH === '1'
|| desktopProfilePresent()
|| desktopRuntimeLikelyPresent()
|| profile === 'desktop'
|| resolveStandardPresetDir(profile) === null
)
let standardSource: string
let standardKind: 'desktop-patch' | 'directory'
let standardKind: NetxopsPresetArtifacts['standardKind']
let standardDir: string | null
let agentCordis: string
if (usePatch && patchPath !== null) {
if (standardDocument !== undefined) {
if (!standardDocument.trim()) throw new Error('Host standard preset composition is empty')
standardSource = `Host ${profile}: standard preset`
standardKind = 'host-registry'
standardDir = null
agentCordis = standardDocument
} else if (usePatch && patchPath !== null) {
standardSource = patchPath
standardKind = 'desktop-patch'
standardDir = null
agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync(patchPath, 'utf8')))
} else {
const dir = resolveStandardPresetDir()
const dir = resolveStandardPresetDir(profile)
if (dir === null) {
throw new Error(
'cannot find host standard preset (Desktop standard.patch.yml or @deepseek-ai/dsh-agent-presets)',
@ -463,14 +470,18 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
personaEntryFromMarkdown(readFileSync(personaPath, 'utf8')),
)
agentCordis = ensureNetxopsToolsRow(agentCordis)
agentCordis = alignWorkflowEngineForHost(agentCordis, bases)
if (standardDocument === undefined) {
agentCordis = alignWorkflowEngineForHost(agentCordis, bases, profile === 'desktop' ? 'desktop' : 'web')
}
if (!agentCordis.endsWith('\n')) agentCordis += '\n'
const missing = unresolvableCompositionPackages(agentCordis, bases)
// A declaration read from the running Host preserves its Loader dialect and
// package resolution. Filesystem anchors may point at a different checkout.
const missing = (standardDocument === undefined ? unresolvableCompositionPackages(agentCordis, bases) : [])
.filter((name) => !name.startsWith('dsh-netxops'))
// Packaged Desktop keeps most `@deepseek-ai/*` only inside app.asar; plain
// Node cannot probe them, but the Host can. Do not block the write for those.
const desktopAsar = desktopRuntimeLikelyPresent()
const desktopAsar = profile === 'desktop' && desktopRuntimeLikelyPresent()
const blocking = missing.filter((name) => !(desktopAsar && name.startsWith('@deepseek-ai/')))
if (blocking.length > 0) {
throw new Error(
@ -498,31 +509,47 @@ ${pluginsBody}
return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch }
}
function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void {
export function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void {
const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID)
const destParent = join(resolveDshHome(), '.agent-presets')
const dest = join(destParent, NETXOPS_PRESET_ID)
mkdirSync(destParent, { recursive: true })
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
const marker = join(dest, '.dsh-netxops-managed')
if (existsSync(dest) && !existsSync(marker)) {
logger.warn('netxops: preserving user-managed preset at %s', dest)
return
}
const existed = existsSync(dest)
mkdirSync(dest, { recursive: true })
if (artifacts.standardDir !== null && existsSync(artifacts.standardDir)) {
if (!existed && artifacts.standardDir !== null && existsSync(artifacts.standardDir)) {
cpSync(artifacts.standardDir, dest, { recursive: true })
}
writeFileSync(join(dest, 'preset.yml'), readFileSync(join(overlayRoot, 'preset.yml'), 'utf8'))
writeFileSync(join(dest, 'PERSONA.md'), readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8'))
writeFileSync(join(dest, 'agent.cordis.yml'), artifacts.agentCordis)
const skillsSrc = join(overlayRoot, 'skills')
if (existsSync(skillsSrc)) cpSync(skillsSrc, join(dest, 'skills'), { recursive: true })
writeFileSync(
join(dest, '.dsh-netxops-managed'),
[
`composed-from: ${artifacts.standardKind}`,
`standard-source: ${artifacts.standardSource}`,
`at: ${new Date().toISOString()}`,
'',
].join('\n'),
'utf8',
)
let hashes: Record<string, string> = {}
if (existsSync(marker)) {
try { hashes = JSON.parse(readFileSync(marker, 'utf8')).files ?? {} }
catch { /* Legacy marker: preserve differing files on the first upgrade. */ }
}
const hash = (text: string): string => createHash('sha256').update(text).digest('hex')
const files = {
'preset.yml': readFileSync(join(overlayRoot, 'preset.yml'), 'utf8'),
'PERSONA.md': readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8'),
'agent.cordis.yml': artifacts.agentCordis,
}
for (const [name, content] of Object.entries(files)) {
const path = join(dest, name)
if (existed && existsSync(path)) {
const current = readFileSync(path, 'utf8')
if (current === content) { hashes[name] = hash(content); continue }
if (hash(current) !== hashes[name]) {
logger.warn('netxops: preserving edited preset file %s', path)
continue
}
}
writeFileSync(path, content, 'utf8')
hashes[name] = hash(content)
}
// Skills are published exclusively by the runtime policy, rather than copied into auto-scanned roots.
writeFileSync(marker, `${JSON.stringify({ standardSource: artifacts.standardSource, files: hashes }, null, 2)}\n`, 'utf8')
logger.info(
'netxops: directory preset composed from %s → %s',
artifacts.standardSource,
@ -536,7 +563,7 @@ function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Conte
* Both `desktop` and `web` need the `@deepseek-ai/dsh-agent-preset` insert;
* directory `.agent-presets` alone is invisible on 0.2.
*/
function syncDeclarativeIntoProfilePatch(
export function syncDeclarativeIntoProfilePatch(
profileName: string,
declarativePatch: string,
logger: Context['logger'],
@ -546,16 +573,26 @@ function syncDeclarativeIntoProfilePatch(
const patchPath = join(profileDir, 'cordis.patch.yml')
if (!existsSync(profileDir)) return
let patch = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '[]\n'
const original = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '[]\n'
let patch = original
// Drop previous managed blocks (literal markers — do not RegExp `(managed)`).
patch = stripManagedPresetBlocks(patch)
// Drop a bare enable stub left by older installs.
patch = patch.replace(/^- id: preset-netxops\n(?: .*\n)*/m, '')
if (/^\s*- id: preset-netxops\s*$/m.test(patch)) {
logger.warn('netxops: preserving user-declared preset-netxops in %s', patchPath)
return
}
if (patch.trim() === '' || patch.trim() === '[]') patch = ''
if (!patch.endsWith('\n') && patch.length > 0) patch += '\n'
const managed = `${DESKTOP_PATCH_BEGIN}\n${declarativePatch.trimEnd()}\n${DESKTOP_PATCH_END}\n`
writeFileSync(patchPath, `${patch}${managed}`, 'utf8')
const next = `${patch}${managed}`
if (next === original) return
if (existsSync(patchPath) && readFileSync(patchPath, 'utf8') !== original) {
logger.warn('netxops: profile patch changed during preset installation; preserving %s', patchPath)
return
}
writeFileSync(patchPath, next, 'utf8')
logger.info(
'netxops: declarative preset synced into profiles/%s/cordis.patch.yml (restart %s to load)',
profileName,
@ -571,8 +608,7 @@ function stripManagedPresetBlocks(patch: string): string {
if (start < 0) break
const end = out.indexOf(DESKTOP_PATCH_END, start)
if (end < 0) {
out = out.slice(0, start)
break
throw new Error('unterminated managed preset block; preserving profile patch')
}
let cut = end + DESKTOP_PATCH_END.length
if (out[cut] === '\r') cut += 1
@ -585,10 +621,10 @@ function stripManagedPresetBlocks(patch: string): string {
/**
* Build directory preset (web) + sync declarative patch into Desktop profiles.
*/
export function ensureAgentPresetInstalled(logger: Context['logger']): void {
export function ensureAgentPresetInstalled(logger: Context['logger'], profile = resolveActiveProfile(), standardDocument?: string): void {
let artifacts: NetxopsPresetArtifacts
try {
artifacts = composeNetxopsPresetArtifacts()
artifacts = composeNetxopsPresetArtifacts(profile, standardDocument)
} catch (error) {
logger.warn('netxops: skip preset install: %s', error)
return
@ -599,24 +635,8 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
logger.error('netxops: failed to install directory preset: %s', error)
}
try {
// Refresh shipped declarative file inside the package for next bundle consumers.
writeFileSync(join(packageRoot(), 'presets', 'netxops.preset.patch.yml'), artifacts.declarativePatch)
} catch {
// package may be read-only in some installs
}
try {
for (const profile of ['desktop', 'web']) {
syncDeclarativeIntoProfilePatch(profile, artifacts.declarativePatch, logger)
}
syncDeclarativeIntoProfilePatch(profile, artifacts.declarativePatch, logger)
} catch (error) {
logger.error('netxops: failed to sync declarative preset into profile patch: %s', error)
}
try {
// (1) Sparse `- id: netxops` from Plugin Manager → expand missing config keys.
// (2) Missing host row after dirty reinstall (preset channel self-healed,
// bundle host insert lost) → insert full `name: dsh-netxops` block.
ensureNetxopsHostInProfilePatches(logger)
} catch (error) {
logger.error('netxops: failed to heal host profile entry: %s', error)
}
}

View file

@ -20,9 +20,12 @@ import {
} from '../netx/im-targets.ts'
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
import { kbStatusTone, type KbSnapshot } from './kb-status-view.ts'
import { toolsMountTone, type ToolsMountView } from './tools-status-view.ts'
import { ensureStyles } from './styles.ts'
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
import { parsePresetIds, TOOLS_BY_GROUP } from '../netx/capability-groups.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey {
switch (phase) {
@ -78,6 +81,24 @@ function fillTemplate(template: string, vars: Record<string, string>): string {
))
}
function toolsBadgeLabel(
t: (key: NetxopsLocaleKey) => string,
view: ToolsMountView | null,
): string {
if (!view) return t('toolsStatusUnknown')
if (!view.toolsEnabled) return t('toolsDisabled')
if (view.error) {
return fillTemplate(t('toolsStatusError'), { detail: view.error })
}
if (view.count > 0) {
return fillTemplate(t('toolsStatusMounted'), {
count: String(view.count),
version: view.version || '?',
})
}
return t('toolsStatusEmpty')
}
function exportStatusMessage(
t: (key: NetxopsLocaleKey) => string,
status: NonNullable<NetxopsCardState['sessionsExportStatus']>,
@ -108,7 +129,7 @@ function exportErrorMessage(
}
export type NetxopsCardProps =
PropsRuntime<'settings.section'>
(PropsRuntime<'settings.section'> | PropsRuntime<'settings.plugin.item'>)
& PropsLocale<'settings.netxops'>
& InjectFace<NetxopsCardFace>
@ -309,6 +330,35 @@ function ImDeliveryPicker(props: {
)
}
function PresetTargets(props: {
field: CardFieldState
state: NetxopsCardState
disabled: boolean
onEdit: (value: string) => void
t: (key: NetxopsLocaleKey) => string
}) {
const selected = parsePresetIds(props.field.text)
const rows = props.state.presetCatalog.filter(row => row.id !== 'netxops')
const unknown = selected.filter(id => !rows.some(row => row.id === id))
return (
<details className="dsh-nx-targets">
<summary>{props.t('selectedPresets')} · {selected.length}</summary>
<p className="dsh-nx-hint">{props.t('publicationHint')}</p>
{props.state.presetCatalogError ? <p className="dsh-nx-invalid">{props.t('presetCatalogUnavailable')}</p> : null}
{[...rows, ...unknown.map(id => ({ id, name: undefined, broken: undefined }))].map(row => (
<label key={row.id} className="dsh-nx-checkRow">
<input type="checkbox" checked={selected.includes(row.id)} disabled={props.disabled}
onChange={event => { props.onEdit((event.target.checked
? [...selected, row.id] : selected.filter(id => id !== row.id)).join(',')) }} />
<span>{row.name || row.id}{row.name && row.name !== row.id ? ` (${row.id})` : ''}</span>
{row.broken ? <span className="dsh-nx-invalid" title={row.broken}>{props.t('presetUnavailable')}</span> : null}
</label>
))}
{rows.length === 0 && unknown.length === 0 ? <p className="dsh-nx-hint">{props.t('noOtherPresets')}</p> : null}
</details>
)
}
function CapabilityGroupBlock(props: {
title: string
inPresetLabel: string
@ -318,6 +368,7 @@ function CapabilityGroupBlock(props: {
disabled: boolean
onEditInPreset: (checked: boolean) => void
onEditPublic: (checked: boolean) => void
targets?: ReactNode
}) {
return (
<div className="dsh-nx-groupBlock">
@ -342,6 +393,7 @@ function CapabilityGroupBlock(props: {
<span>{props.publicLabel}</span>
</label>
</div>
{props.targets}
</div>
)
}
@ -419,7 +471,8 @@ export function NetxopsCard(props: NetxopsCardProps) {
if (!state.available) {
return (
<section className="dsh-nx-settings" aria-label={t('title')}>
<div className="dsh-nx-settings-empty">{t('sessionsExportUnavailable')}</div>
<h2>{t('title')}</h2>
<div className="dsh-nx-settings-empty" role="status">{t('settingsUnavailable')}</div>
</section>
)
}
@ -427,6 +480,11 @@ export function NetxopsCard(props: NetxopsCardProps) {
const disabled = !state.writable
const blocked = !state.dirty || state.invalid || state.saving
const pushStatus = state.alarmPushStatus
const publicationDisabled = disabled || state.toolsEnabled.text !== 'true'
const targets = (field: string, value: CardFieldState) => (
<PresetTargets field={value} state={state} disabled={publicationDisabled} t={t}
onEdit={text => { props.edit(field, text) }} />
)
return (
<section className="dsh-nx-settings" aria-label={t('title')}>
@ -441,6 +499,25 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card">
<h3>{t('sectionConnection')}</h3>
<div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head">
<span>{t('toolsStatus')}</span>
<StatusBadge
tone={toolsMountTone(state.toolsStatus)}
label={toolsBadgeLabel(t, state.toolsStatus)}
/>
</div>
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
<details><summary>{t('mountDetails')}</summary>
{state.toolsStatus.mounts.map((mount, index) => (
<p key={`${mount.source}:${index}`} className={mount.error ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
{mount.source} · {mount.count} · {mount.groups.join(', ') || '—'}{mount.error ? ` · ${mount.error}` : ''}
</p>
))}
</details>
) : null}
</div>
<div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head">
<label htmlFor="netxops-api-token">{t('apiToken')}</label>
@ -469,7 +546,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
field={state.apiUrl}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
invalidLabel={t('apiUrlInvalid')}
disabled={disabled}
onEdit={(text) => { props.edit('apiUrl', text) }}
onReset={() => { props.resetField('apiUrl') }}
@ -536,13 +613,20 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card">
<h3>{t('sectionCapabilities')}</h3>
<ToggleField id="netxops-tools-enabled" label={t('toolsEnabled')}
checked={state.toolsEnabled.text === 'true'} disabled={disabled}
overridden={state.toolsEnabled.overridden} overriddenLabel={t('overridden')} resetLabel={t('reset')}
onToggle={checked => { props.edit('toolsEnabled', String(checked)) }}
onReset={() => { props.resetField('toolsEnabled') }}
hint={<p className="dsh-nx-hint">{t('toolsEnabledHint')}</p>} />
<CapabilityGroupBlock
title={t('groupOps')}
title={`${t('groupOps')} · ${TOOLS_BY_GROUP.ops.length}`}
targets={targets('groupOpsPresets', state.groupOpsPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupOpsInPreset}
published={state.groupOpsPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
}}
@ -551,12 +635,13 @@ export function NetxopsCard(props: NetxopsCardProps) {
}}
/>
<CapabilityGroupBlock
title={t('groupTopology')}
title={`${t('groupTopology')} · ${TOOLS_BY_GROUP.topology.length}`}
targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupTopologyInPreset}
published={state.groupTopologyPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
}}
@ -565,12 +650,13 @@ export function NetxopsCard(props: NetxopsCardProps) {
}}
/>
<CapabilityGroupBlock
title={t('groupBizMonitor')}
title={`${t('groupBizMonitor')} · ${TOOLS_BY_GROUP.bizMonitor.length}`}
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupBizMonitorInPreset}
published={state.groupBizMonitorPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
}}
@ -650,11 +736,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
</div>
<CapabilityGroupBlock
title={t('groupKb')}
targets={targets('groupKbPresets', state.groupKbPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupKbInPreset}
published={state.groupKbPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupKbInPreset', checked ? 'true' : 'false')
}}

View file

@ -3,6 +3,8 @@
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
*/
import { normalizeApiUrl } from '../netx/http.ts'
import { parsePresetIds } from '../netx/capability-groups.ts'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'
@ -62,6 +64,33 @@ export function textField(field: string): CardFieldSpec {
}
}
/** Empty selection must override a profile's target list rather than restore it. */
export function presetIdsField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: text => ({ kind: 'set', value: parsePresetIds(text).join(',') }),
}
}
/**
* http(s) API root — rejects credentials-in-URL and non-http schemes.
* Empty clears to schema default; save stores the normalized URL.
*/
export function apiUrlField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: (text) => {
const trimmed = text.trim()
if (trimmed === '') return { kind: 'clear' }
const normalized = normalizeApiUrl(trimmed)
if (!normalized.ok) return undefined
return { kind: 'set', value: normalized.url }
},
}
}
/** Boolean settings field rendered as a checkbox (stores true / clears when false). */
export function booleanField(field: string): CardFieldSpec {
return {
@ -108,6 +137,7 @@ export class CardForm<T> {
private readonly listeners = new Set<() => void>()
private saving = false
private failed = false
private saveQueue: Promise<void> = Promise.resolve()
constructor(
private readonly scope: SettingsFormScope<T>,
@ -128,11 +158,18 @@ export class CardForm<T> {
shell(): CardShell {
const snapshot = this.scope.getSnapshot()
const plan = this.plan()
let committedInvalid = false
for (const field of this.specs.keys()) {
if (this.field(field).invalid) {
committedInvalid = true
break
}
}
return {
available: snapshot.status === 'ready',
writable: snapshot.writable,
dirty: plan.length > 0,
invalid: plan.some(item => item.run === undefined),
invalid: committedInvalid || plan.some(item => item.run === undefined),
saving: this.saving,
failed: this.failed,
}
@ -145,7 +182,15 @@ export class CardForm<T> {
}
const spec = this.spec(field)
if (staged === undefined) {
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
const text = spec.format(this.sectionValue(field))
// Surface committed bad values (e.g. file:// apiUrl) so Save stays blocked
// until the operator fixes them — not only while the field is staged.
const write = text.trim() === '' ? { kind: 'clear' as const } : spec.parse(text)
return {
text,
overridden: this.stored(field),
invalid: text.trim() !== '' && write === undefined,
}
}
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
return {
@ -171,21 +216,43 @@ export class CardForm<T> {
}
}
async save(): Promise<void> {
const plan = this.plan()
/** Serialize writes and save only requested fields for immediate publication toggles. */
save(fields?: readonly string[]): Promise<void> {
const task = this.saveQueue.then(() => this.saveNow(fields))
this.saveQueue = task.catch(() => {})
return task
}
private async saveNow(fields?: readonly string[]): Promise<void> {
const snapshot = this.snapshotOf()
if (snapshot.status !== 'ready' || !snapshot.writable) {
this.failed = true
this.publish()
return
}
const staged = new Map([...this.staged].filter(([field]) => !fields || fields.includes(field)))
const plan = this.plan().filter(item => !fields || fields.includes(item.field))
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
if (writes.length !== plan.length) return
this.saving = true
this.failed = false
this.publish()
let landed = true
for (const write of writes) {
landed = await write() && landed
try {
for (const write of writes) landed = await write() && landed
} catch {
landed = false
} finally {
// A newer edit made during a write remains staged for the next queued save.
if (landed) {
for (const [field, edit] of staged) {
if (this.staged.get(field) === edit) this.staged.delete(field)
}
}
this.saving = false
this.failed = !landed
this.publish()
}
if (landed) this.staged.clear()
this.saving = false
this.failed = !landed
this.publish()
}
private plan(): PlannedWrite[] {

View file

@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import type { SnapshotStore } from './snapshot-store.ts'
import {
CardForm, textField, booleanField, booleanFieldPersistFalse,
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
import {
@ -15,6 +15,10 @@ import {
type AlarmPushRpcCall,
type AlarmPushStatus,
} from './alarm-push-status-view.ts'
import {
fetchToolsStatus,
type ToolsMountView,
} from './tools-status-view.ts'
import {
fetchKbStatus,
reloadKbStatus,
@ -35,12 +39,23 @@ import {
} from './sessions-export-view.ts'
import type { SessionsExportReasonCode } from '../session-export-shared.ts'
/** Host Config / configForms namespace (must match cordis entry id). */
export const NETXOPS_NS = 'netxops'
/**
* Settings sidebar slot id — must stay distinct from NETXOPS_NS.
* Colliding with the Config namespace silently drops nav registration on Desktop 0.2.
*/
export const NETXOPS_SECTION_ID = 'dsh-netxops'
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
const API_TOKEN_FIELD = 'apiToken'
const STATUS_POLL_MS = 2_000
export interface NetxopsSettings {
toolsEnabled?: boolean
groupOpsPresets?: string
groupTopologyPresets?: string
groupBizMonitorPresets?: string
groupKbPresets?: string
apiUrl?: string
lang?: string
thinkingLanguage?: string
@ -76,6 +91,13 @@ interface CredentialState {
}
export interface NetxopsCardState extends CardShell {
toolsEnabled: CardFieldState
groupOpsPresets: CardFieldState
groupTopologyPresets: CardFieldState
groupBizMonitorPresets: CardFieldState
groupKbPresets: CardFieldState
presetCatalog: Array<{ id: string; name?: string; broken?: string }>
presetCatalogError: boolean
apiUrl: CardFieldState
lang: CardFieldState
thinkingLanguage: CardFieldState
@ -102,6 +124,8 @@ export interface NetxopsCardState extends CardShell {
apiTokenRemoteReady: boolean
/** Host WSS status when Connection RPC is available; otherwise null. */
alarmPushStatus: AlarmPushStatus | null
/** Host `netx__*` tool mount snapshot; null when RPC unavailable. */
toolsStatus: ToolsMountView | null
/** Latest KB snapshot (saved root, or live preview while editing). */
kbStatus: KbSnapshot | null
/** Soft-injected `remote.directoryPicker` is available. */
@ -136,7 +160,7 @@ export interface NetxopsCardFace extends CardActions {
}
type DirectoryPickerRemote = {
pick: (signal?: AbortSignal) => Promise<string | null>
pick: (signal?: AbortSignal) => Promise<unknown>
}
type CredentialsRemote = {
@ -155,6 +179,11 @@ export class NetxopsCardController {
}
private rpcCall: AlarmPushRpcCall | undefined
private alarmPushStatus: AlarmPushStatus | null = null
private toolsStatus: ToolsMountView | null = null
private toolsStatusInFlight = false
private presetCatalog: NetxopsCardState['presetCatalog'] = []
private presetCatalogError = false
private presetCatalogInFlight = false
private kbStatus: KbSnapshot | null = null
private kbPreview: KbSnapshot | null = null
private kbDirectoryPicker: DirectoryPickerRemote | undefined
@ -181,24 +210,31 @@ export class NetxopsCardController {
this.form = new CardForm(
scope,
[
textField('apiUrl'),
booleanFieldPersistFalse('toolsEnabled'),
presetIdsField('groupOpsPresets'),
presetIdsField('groupTopologyPresets'),
presetIdsField('groupBizMonitorPresets'),
presetIdsField('groupKbPresets'),
apiUrlField('apiUrl'),
textField('lang'),
textField('thinkingLanguage'),
textField('replyLanguage'),
textField('nmsProvider'),
textField('kbRoot'),
// Persist false explicitly: `booleanField` clears on off, which lets a
// profile patch (`groupOpsPublic: true`) win again — uncheck looked
// instant in the UI then tools stayed published.
booleanFieldPersistFalse('groupOpsInPreset'),
// Default is true — must persist false explicitly or "off" snaps back.
booleanFieldPersistFalse('groupOpsPublic'),
booleanField('groupTopologyInPreset'),
booleanField('groupTopologyPublic'),
booleanFieldPersistFalse('groupTopologyInPreset'),
booleanFieldPersistFalse('groupTopologyPublic'),
booleanFieldPersistFalse('groupBizMonitorInPreset'),
booleanField('groupBizMonitorPublic'),
booleanFieldPersistFalse('groupBizMonitorPublic'),
booleanFieldPersistFalse('groupKbInPreset'),
booleanField('groupKbPublic'),
booleanField('alarmPushEnabled'),
booleanFieldPersistFalse('groupKbPublic'),
booleanFieldPersistFalse('alarmPushEnabled'),
booleanFieldPersistFalse('alarmDeliverDsh'),
booleanField('alarmDeliverIm'),
booleanFieldPersistFalse('alarmDeliverIm'),
textField('imTargets'),
textField('imBotId'),
textField('imTargetId'),
@ -239,6 +275,13 @@ export class NetxopsCardController {
this.alarmPushStatus = null
changed = true
}
if (this.toolsStatus !== null) {
this.toolsStatus = null
changed = true
}
this.presetCatalog = []
this.presetCatalogError = true
changed = true
if (this.kbStatus !== null || this.kbPreview !== null) {
this.kbStatus = null
this.kbPreview = null
@ -257,6 +300,8 @@ export class NetxopsCardController {
}
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.refreshToolsStatus()
void this.refreshPresetCatalog()
void this.reloadKbFromHost()
void this.refreshKbPreview()
void this.refreshImDeliveryCatalog()
@ -279,6 +324,8 @@ export class NetxopsCardController {
if (this.pollTimer !== undefined) return
this.pollTimer = setInterval(() => {
void this.refreshAlarmPushStatus()
void this.refreshToolsStatus()
void this.refreshPresetCatalog()
void this.refreshKbStatus()
void this.refreshImDeliveryCatalog()
void this.refreshSessionsExportStatus()
@ -316,6 +363,51 @@ export class NetxopsCardController {
}
}
private async refreshToolsStatus(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.toolsStatusInFlight) return
this.toolsStatusInFlight = true
try {
const next = await fetchToolsStatus(call)
const prev = this.toolsStatus
if (
prev
&& prev.count === next.count
&& prev.version === next.version
&& prev.plane === next.plane
&& prev.error === next.error
&& prev.tokenConfigured === next.tokenConfigured
&& prev.apiUrl === next.apiUrl
&& JSON.stringify(prev) === JSON.stringify(next)
) return
this.toolsStatus = next
this.store.set(this.projection())
} catch {
// Keep last good snapshot; next poll retries.
} finally {
this.toolsStatusInFlight = false
}
}
private async refreshPresetCatalog(): Promise<void> {
const call = this.rpcCall
if (!call || this.presetCatalogInFlight) return
this.presetCatalogInFlight = true
try {
const result = await call('/netxops', 'presets.catalog', {}) as {
ok?: boolean; value?: { presets?: NetxopsCardState['presetCatalog'] }
}
if (call !== this.rpcCall) return
this.presetCatalogError = result?.ok !== true || !Array.isArray(result.value?.presets)
if (!this.presetCatalogError) this.presetCatalog = result.value!.presets!
this.store.set(this.projection())
} catch {
if (call !== this.rpcCall) return
this.presetCatalogError = true
this.store.set(this.projection())
} finally { this.presetCatalogInFlight = false }
}
private async refreshImDeliveryCatalog(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.catalogInFlight) return
@ -467,7 +559,7 @@ export class NetxopsCardController {
const trimmed = path.trim()
this.kbUiError = null
this.store.set(this.projection())
await this.form.save()
await this.form.save(['kbRoot'])
const shell = this.form.shell()
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
if (shell.failed || saved !== trimmed) {
@ -595,6 +687,13 @@ export class NetxopsCardController {
private projection(): NetxopsCardState {
return {
toolsEnabled: this.form.field('toolsEnabled'),
groupOpsPresets: this.form.field('groupOpsPresets'),
groupTopologyPresets: this.form.field('groupTopologyPresets'),
groupBizMonitorPresets: this.form.field('groupBizMonitorPresets'),
groupKbPresets: this.form.field('groupKbPresets'),
presetCatalog: this.presetCatalog,
presetCatalogError: this.presetCatalogError,
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
@ -621,6 +720,7 @@ export class NetxopsCardController {
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
toolsStatus: this.toolsStatus,
kbStatus: this.kbPreview ?? this.kbStatus,
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
kbUiError: this.kbUiError,
@ -681,6 +781,11 @@ export class NetxopsCardController {
inject(): NetxopsCardFace {
const actions = this.form.actions()
const capabilityFields = new Set([
'toolsEnabled',
'groupOpsPresets',
'groupTopologyPresets',
'groupBizMonitorPresets',
'groupKbPresets',
'groupOpsInPreset',
'groupOpsPublic',
'groupTopologyInPreset',
@ -698,7 +803,7 @@ export class NetxopsCardController {
if (field === 'kbRoot') void this.refreshKbPreview()
// Capability toggles must hit the host Config immediately — staged-only
// edits never publish public tools/skills to other presets.
if (capabilityFields.has(field)) void this.form.save()
if (capabilityFields.has(field)) void this.form.save([field])
},
discard: () => {
actions.discard()
@ -707,7 +812,7 @@ export class NetxopsCardController {
resetField: (field) => {
actions.resetField(field)
if (field === 'kbRoot') void this.refreshKbPreview()
if (capabilityFields.has(field)) void this.form.save()
if (capabilityFields.has(field)) void this.form.save([field])
},
exportAllSessions: () => { this.exportAllSessions() },
browseKbRoot: () => { this.browseKbRoot() },

View file

@ -6,23 +6,32 @@
* inject surfaces as `waiting for service: settingsScope`, the host never
* publishes a connection, and Netx Ops sessions get zero `netx__*` tools.
*
* Surfaces (dual-stack), matching working plugins like dsh-im-ops:
* Desktop 0.2 boot contract (aligned with dsh-search-mcp / dsh-trilium):
* - `dsh.client.immediately: false` — stage-one prefetch must not race Host.
* - Never `ctx.inject(['configForms'])` during apply; poll after apply returns
* (sync inject / accessor wait freezes "Loading plugins…").
* - `settings.section` id ≠ Config namespace (`dsh-netxops` vs `netxops`).
*
* Surfaces (dual-stack):
* - Always register `settings.section` in `apply` (Settings sidebar).
* - Form transport: soft `configForms` (0.2); one-shot `ctx.get('settingsScope')`
* for ≤0.1.5 only — never `ctx.inject(['settingsScope'])`.
* - Form transport: deferred `configForms` poll (0.2); one-shot
* `ctx.get('settingsScope')` for ≤0.1.5 only — never inject settingsScope.
* - Soft-attach real form scopes into a deferred memory scope so Save
* still writes the host entry when the transport is ready.
* - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed`.
* - Also register `settings.plugin.item` via `configForms.whileServed`.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
import { NetxopsCard } from './NetxopsCard.tsx'
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
import { NETXOPS_NS, NETXOPS_SECTION_ID, NetxopsCardController } from './controller.ts'
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
import type { SettingsFormScope } from './settings-form.ts'
import { createWaitingSettingsScope, findReadySettingsForm } from './settings-form.ts'
import type { NetxopsSettings } from './controller.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@ -55,7 +64,6 @@ function namespaceCandidates(ctx: ClientContext): string[] {
fiber?.options?.id,
fiber?.id,
fiber?.options?.name,
(ctx as { name?: string }).name,
NETXOPS_NS,
'dsh-netxops',
]
@ -88,42 +96,9 @@ type DeferredSettingsScope = SettingsFormScope<NetxopsSettings> & {
attach: (real: SettingsFormScope<NetxopsSettings>) => void
}
/** In-memory form so Settings nav can mount before configForms attaches. */
/** Waiting form so the sidebar can mount without pretending settings were saved. */
function createMemoryScope(initial: NetxopsSettings): SettingsFormScope<NetxopsSettings> {
let value: NetxopsSettings = { ...initial }
let revision = 0
const listeners = new Set<() => void>()
const notify = (): void => {
for (const listener of listeners) listener()
}
return {
getSnapshot: () => ({
status: 'ready',
value,
writable: true,
revision,
}),
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
set: async (field, next) => {
value = { ...value, [field]: next as never }
revision += 1
notify()
return true
},
unset: async (field) => {
const next = { ...value }
delete next[field as keyof NetxopsSettings]
value = next
revision += 1
notify()
return true
},
}
return createWaitingSettingsScope({ ...initial })
}
/** Swap the live transport without recreating the card controller. */
@ -161,6 +136,11 @@ function createDeferredScope(
}
const MEMORY_DEFAULTS: NetxopsSettings = {
toolsEnabled: true,
groupOpsPresets: '',
groupTopologyPresets: '',
groupBizMonitorPresets: '',
groupKbPresets: '',
apiUrl: 'http://127.0.0.1:8890',
lang: 'zh',
thinkingLanguage: 'auto',
@ -174,10 +154,10 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
imTargetId: '',
imTargets: '',
groupOpsInPreset: true,
groupOpsPublic: true,
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
kbRoot: '',
groupKbInPreset: true,
@ -216,11 +196,11 @@ export function apply(ctx: ClientContext): void {
}
}
/** Settings sidebar — same unconditional surface im-ops uses. */
/** Settings sidebar — id must not equal Config ns `netxops`. */
try {
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: NETXOPS_NS,
id: NETXOPS_SECTION_ID,
order: 24,
label: () => t('title'),
locale: LOCALE_NS,
@ -237,11 +217,9 @@ export function apply(ctx: ClientContext): void {
const registerPluginsItem = (id: string): (() => void) => {
if (pluginsItemId === id) return () => {}
try {
const off = ctx.slots.inject('plugins.item', () => ctx.slots.register({
name: 'plugins.item',
id,
order: 24,
label: () => t('title'),
const off = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: id,
locale: LOCALE_NS,
inject: () => {
ensureExtras()
@ -263,16 +241,7 @@ export function apply(ctx: ClientContext): void {
forms: ConfigFormsLike,
candidates: readonly string[],
): { ns: string, scope: SettingsFormScope<NetxopsSettings> } | undefined => {
if (typeof forms.get !== 'function') return undefined
for (const ns of candidates) {
try {
const scope = forms.get(ns)
if (scope && typeof scope.getSnapshot === 'function') return { ns, scope }
} catch {
// wrong ns — try next
}
}
return undefined
return findReadySettingsForm(forms, candidates)
}
const attachForm = (
@ -285,67 +254,86 @@ export function apply(ctx: ClientContext): void {
return registerPluginsItem(hit.ns)
}
// DSH ≥0.1.7 / 0.2.0 — configForms (persist + plugins.item).
ctx.inject(['configForms'], (formsCtx) => {
const forms = (formsCtx as { configForms?: ConfigFormsLike }).configForms
if (!forms) {
formsCtx.logger?.warn?.('netxops: configForms inject fired but service missing')
return
}
const candidates = namespaceCandidates(formsCtx)
formsCtx.logger?.info?.('netxops: configForms candidates=%s', candidates.join(','))
if (typeof forms.whileServed === 'function') {
formsCtx.effect(() => forms.whileServed!(candidates, (served) => {
const ns = candidates.find((id) => served.has(id))
if (!ns) {
formsCtx.logger?.warn?.(
'netxops: whileServed fired but none of %s are served (have=%s)',
candidates.join(','),
[...served].join(',') || '(empty)',
)
return () => {}
}
const hit = tryGetForm(forms, [ns, ...candidates])
if (!hit) {
formsCtx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
return () => {}
}
return attachForm(hit, 'whileServed')
}), 'netxops: configForms whileServed')
}
const immediate = tryGetForm(forms, candidates)
if (immediate) {
formsCtx.effect(
() => attachForm(immediate, 'eager'),
'netxops: configForms eager attach',
// DSH ≥0.1.7 / 0.2.0 — attach configForms by deferred poll.
// Never ctx.inject(['configForms']) or read ctx.configForms during apply:
// on Desktop 0.2 that waits and freezes "Loading plugins…".
let formsAttached = false
const tryAttachForms = (): boolean => {
if (formsAttached) return true
try {
// Direct ctx.configForms requires an inject edge even after apply. get()
// is Cordis' non-blocking lookup for an optional, currently active service.
const forms = ctx.get('configForms') as ConfigFormsLike | undefined
if (!forms || typeof forms.get !== 'function') return false
const candidates = namespaceCandidates(ctx)
const hit = tryGetForm(forms, candidates)
if (!hit) return false
ctx.logger?.info?.('netxops: configForms candidates=%s', candidates.join(','))
ctx.effect(
() => attachForm(hit, 'poll'),
'netxops: configForms poll attach',
)
} else if (typeof forms.whileServed !== 'function') {
formsCtx.logger?.warn?.(
'netxops: configForms present but no get/whileServed match for %s',
candidates.join(','),
)
}
})
// DSH ≤0.1.5 — one-shot settingsScope bind. Do NOT ctx.inject(['settingsScope']):
// on Desktop 0.2 that service is gone and a waiting inject shows up as
// `dsh-netxops: pending (waiting for service: settingsScope)`, which
// prevents the host bridge from publishing a connection → no netx__* tools.
try {
const getter = (ctx as { get?: (name: string) => unknown }).get
if (typeof getter === 'function' && getter.call(ctx, 'configForms') === undefined) {
const binder = getter.call(ctx, 'settingsScope') as SettingsScopeLike | undefined
if (binder && typeof binder.bind === 'function') {
ctx.logger?.info?.('netxops: attach form via settingsScope (legacy one-shot)')
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
ensureExtras()
if (typeof forms.whileServed === 'function') {
ctx.effect(() => forms.whileServed!(candidates, (served) => {
const ns = candidates.find((id) => served.has(id))
if (!ns) {
ctx.logger?.warn?.(
'netxops: whileServed fired but none of %s are served (have=%s)',
candidates.join(','),
[...served].join(',') || '(empty)',
)
return () => {}
}
const next = tryGetForm(forms, [ns, ...candidates])
if (!next) {
ctx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
return () => {}
}
return attachForm(next, 'whileServed')
}), 'netxops: configForms whileServed')
}
formsAttached = true
return true
} catch {
return false
}
} catch (error) {
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
}
/** DSH ≤0.1.5 only — one-shot bind when configForms never appears. */
const tryLegacySettingsScope = (): boolean => {
if (formsAttached) return true
try {
const getter = (ctx as { get?: (name: string) => unknown }).get
if (typeof getter !== 'function') return false
const binder = getter.call(ctx, 'settingsScope') as SettingsScopeLike | undefined
if (!binder || typeof binder.bind !== 'function') return false
ctx.logger?.info?.('netxops: attach form via settingsScope (legacy one-shot)')
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
ensureExtras()
formsAttached = true
return true
} catch (error) {
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
return false
}
}
// Defer all form-transport probes until after apply returns.
const startFormsPoll = (): void => {
if (tryAttachForms() || tryLegacySettingsScope()) return
let ticks = 0
const timer = setInterval(() => {
ticks += 1
if (tryAttachForms() || (ticks >= 3 && tryLegacySettingsScope())) {
clearInterval(timer)
}
}, 1_000)
try {
ctx.effect(() => () => clearInterval(timer), 'netxops: configForms poll')
} catch {
clearInterval(timer)
}
}
setTimeout(startFormsPoll, 0)
}
/** Soft-wire credentials / connection / directoryPicker when present. */
@ -378,7 +366,8 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
}, 'remote.credentials')
soft(['connection'], (connCtx) => {
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
const connection = connCtx.get('connection') as { rpc?: { call?: AlarmPushRpcCall } } | undefined
const call = connection?.rpc?.call?.bind(connection.rpc)
if (typeof call !== 'function') {
connCtx.logger?.warn?.('netxops: connection.rpc.call unavailable — alarm status UI disabled')
return
@ -389,21 +378,19 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
}, 'netxops: clear alarm-push rpc')
}, 'connection')
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => {
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<unknown> } | undefined): void => {
if (!picker || typeof picker.pick !== 'function') return
card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise<string | null> })
card.setDirectoryPicker({ pick: picker.pick.bind(picker) })
}
bindDirectoryPicker(
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker,
)
bindDirectoryPicker(ctx.get('remote.directoryPicker') as
{ pick?: (signal?: AbortSignal) => Promise<unknown> } | undefined)
soft(['remote.directoryPicker'], (dpCtx) => {
const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
| { pick?: (signal?: AbortSignal) => Promise<string | null> }
| { pick?: (signal?: AbortSignal) => Promise<unknown> }
| undefined
: undefined
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<unknown> } } })
.remote?.directoryPicker
bindDirectoryPicker(viaGet ?? viaNested)
dpCtx.effect(() => () => {

View file

@ -1,6 +1,17 @@
/** Locale keys for the Netx Ops settings section. */
export type NetxopsLocaleKey =
| 'settingsUnavailable'
| 'toolsEnabled'
| 'toolsEnabledHint'
| 'toolsDisabled'
| 'selectedPresets'
| 'publicationHint'
| 'presetCatalogUnavailable'
| 'presetUnavailable'
| 'noOtherPresets'
| 'mountStatusHint'
| 'mountDetails'
| 'title'
| 'description'
| 'sectionConnection'
@ -10,6 +21,7 @@ export type NetxopsLocaleKey =
| 'sectionExport'
| 'apiUrl'
| 'apiUrlHint'
| 'apiUrlInvalid'
| 'lang'
| 'langHint'
| 'thinkingLanguage'
@ -54,6 +66,11 @@ export type NetxopsLocaleKey =
| 'apiTokenUnavailable'
| 'apiTokenSet'
| 'apiTokenUnset'
| 'toolsStatus'
| 'toolsStatusMounted'
| 'toolsStatusEmpty'
| 'toolsStatusError'
| 'toolsStatusUnknown'
| 'sessionsExport'
| 'sessionsExportCount'
| 'sessionsExportUnavailable'
@ -79,6 +96,17 @@ export type NetxopsLocaleKey =
| 'configSaved'
export const en: Record<NetxopsLocaleKey, string> = {
settingsUnavailable: 'Waiting for Host settings. Saving is disabled until connected. If this persists, check that the Host plugin is enabled.',
toolsEnabled: 'Publish Netx capabilities',
toolsEnabledHint: 'Auto-saves. Turn off to withdraw all Netx tools and skills; your group selections are retained.',
toolsDisabled: 'All Netx tools and skills are off',
selectedPresets: 'Selected other presets',
publicationHint: 'Tools and their skills are published together. Changes auto-save and apply to the next model request in current and new sessions. Public capabilities are inherited by every preset, including Netx Ops.',
presetCatalogUnavailable: 'Preset roster unavailable. Existing selections are retained and can be removed.',
presetUnavailable: 'Preset unavailable',
noOtherPresets: 'No other presets available on this Host.',
mountStatusHint: 'Actual mounts on this Host. Selected other presets mount when a session is active. This is registration status; API access is checked during tool calls.',
mountDetails: 'Mount details',
title: 'Netx Ops',
description: 'API, token, capabilities, knowledge base, and alarm delivery.',
sectionConnection: 'Connection',
@ -88,6 +116,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
sectionExport: 'Session export',
apiUrl: 'API URL',
apiUrlHint: 'e.g. http://127.0.0.1:8890',
apiUrlInvalid: 'Must be http(s) without username/password (no file:/javascript:)',
lang: 'API / alarm language',
langHint: 'zh / en — netx API query + alarm prompt copy only (not model reply)',
thinkingLanguage: 'Thinking language',
@ -97,12 +126,12 @@ export const en: Record<NetxopsLocaleKey, string> = {
capabilityGroups: 'Capability groups',
nmsProvider: 'NMS provider',
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupBizMonitor: 'bizMonitor',
groupOps: 'Alarms, inventory and CLI',
groupTopology: 'Topology and layout',
groupBizMonitor: 'Cutover monitoring',
groupKb: 'Knowledge skills',
groupInPreset: 'In Netx Ops preset',
groupPublic: 'Publish to other presets (auto-saves; hot-reloads)',
groupInPreset: 'In Netx Ops preset (auto-saves; mount/unmount immediately)',
groupPublic: 'Publish to every preset (auto-saves)',
kbRoot: 'Package root',
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.',
kbBrowse: 'Browse…',
@ -132,6 +161,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
apiTokenUnavailable: 'remote.credentials unavailable — set NETX_API_TOKEN via script.',
apiTokenSet: 'Configured',
apiTokenUnset: 'Not set',
toolsStatus: 'Agent tools',
toolsStatusMounted: '{count} netx__* tools (v{version})',
toolsStatusEmpty: 'No active Netx tool mounts',
toolsStatusError: 'Tool mount failed: {detail}',
toolsStatusUnknown: 'Host status unavailable',
sessionsExport: 'Export all sessions',
sessionsExportCount: '{count} sessions',
sessionsExportUnavailable: 'Export unavailable',
@ -158,6 +192,17 @@ export const en: Record<NetxopsLocaleKey, string> = {
}
export const zh: Record<NetxopsLocaleKey, string> = {
settingsUnavailable: '正在等待 Host 设置,连接就绪前不能保存。若持续等待,请检查 Host 插件是否启用。',
toolsEnabled: '启用 Netx 能力发布',
toolsEnabledHint: '自动保存。关闭会撤销全部 Netx 工具和 skill,保留各组选择,便于随时恢复。',
toolsDisabled: '全部 Netx 工具和 skill 已关闭',
selectedPresets: '指定其他预设',
publicationHint: '工具与对应 skill 一起发布。选择自动保存,对当前及新会话的下一次模型请求生效。公开能力由所有预设继承,也包括 Netx Ops。',
presetCatalogUnavailable: '暂时无法读取预设列表,已有选择会保留,也可以取消。',
presetUnavailable: '预设不可用',
noOtherPresets: '此 Host 暂无其他预设。',
mountStatusHint: '显示此 Host 的实际挂载。指定其他预设在会话活跃时挂载;此状态表示注册结果,API 权限在调用工具时验证。',
mountDetails: '查看实际挂载明细',
title: 'Netx Ops',
description: 'API、Token、能力组、知识库与告警投递。',
sectionConnection: '连接',
@ -167,6 +212,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
sectionExport: '会话导出',
apiUrl: 'API 地址',
apiUrlHint: '例如 http://127.0.0.1:8890',
apiUrlInvalid: '须为无账号密码的 http(s) 地址(不支持 file:/javascript:)',
lang: 'API / 告警语言',
langHint: 'zh / en — 仅影响 netx API 与告警文案,不控制模型回复',
thinkingLanguage: '思考语言',
@ -176,12 +222,12 @@ export const zh: Record<NetxopsLocaleKey, string> = {
capabilityGroups: '能力组',
nmsProvider: 'NMS 提供方',
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupBizMonitor: 'bizMonitor',
groupOps: '告警、资产与 CLI 运维',
groupTopology: '拓扑与布局',
groupBizMonitor: '割接业务监控',
groupKb: '知识库技能',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开(自动保存,即时挂载/卸载)',
groupInPreset: '在 Netx Ops 预设中启用(自动保存,即时挂载/卸载)',
groupPublic: '对所有预设公开(自动保存)',
kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。',
kbBrowse: '浏览…',
@ -211,6 +257,11 @@ export const zh: Record<NetxopsLocaleKey, string> = {
apiTokenUnavailable: '未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。',
apiTokenSet: '已配置',
apiTokenUnset: '未设置',
toolsStatus: 'Agent 工具',
toolsStatusMounted: '已挂载 {count} 个 netx__*(v{version})',
toolsStatusEmpty: '当前没有活跃的 Netx 工具挂载',
toolsStatusError: '工具挂载失败:{detail}',
toolsStatusUnknown: '无法读取 Host 状态',
sessionsExport: '导出全部会话',
sessionsExportCount: '{count} 个会话',
sessionsExportUnavailable: '无法导出',

View file

@ -22,3 +22,29 @@ export interface SettingsFormScope<T> {
set(field: string, value: unknown): Promise<boolean | void>
unset(field: string): Promise<boolean | void>
}
/** Read-only placeholder until an actual Host settings namespace is served. */
export function createWaitingSettingsScope<T>(initial: T): SettingsFormScope<T> {
const snapshot: SettingsFormSnapshot<T> = { status: 'loading', value: initial, writable: false }
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
set: async () => { throw new Error('settings_transport_unavailable') },
unset: async () => { throw new Error('settings_transport_unavailable') },
}
}
/** get() may return a placeholder for any id; only a served namespace is usable. */
export function findReadySettingsForm<T>(
forms: { get?: (id: string) => SettingsFormScope<T> },
namespaces: readonly string[],
): { ns: string; scope: SettingsFormScope<T> } | undefined {
if (!forms.get) return undefined
for (const ns of namespaces) {
try {
const scope = forms.get(ns)
if (scope?.getSnapshot().status === 'ready') return { ns, scope }
} catch { /* Wrong namespace or service not ready; keep trying. */ }
}
return undefined
}

View file

@ -43,6 +43,11 @@ const CSS = `
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px}
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px}
.dsh-nx-targets{margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5)}
.dsh-nx-settings summary{cursor:pointer;font-size:12px;font-weight:500;line-height:1.6}
.dsh-nx-targets[open] .dsh-nx-checkRow{padding:5px 0;flex-wrap:wrap}
.dsh-nx-settings input:focus-visible,.dsh-nx-settings button:focus-visible,.dsh-nx-settings summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:2px}
.dsh-nx-settings>.dsh-nx-settings-actions{position:sticky;bottom:0;padding:12px;background:var(--dsw-alias-bg-base,#fff);border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5);flex-wrap:wrap}
.dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
.dsh-nx-pathRow>input{flex:1;min-width:0}

View file

@ -0,0 +1,108 @@
/**
* Browser-side Host tool-mount status (RPC `tools.status`).
*/
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
/** Same channel as host `NETXOPS_RPC_CHANNEL`. */
export const NETXOPS_RPC_CHANNEL = '/netxops'
export const TOOLS_STATUS_ENDPOINT = 'tools.status'
export interface ToolsMountView {
toolsEnabled: boolean
mounts: Array<{ source: string; count: number; groups: string[]; plane: string; error?: string }>
count: number
version: string
groups: string[]
plane: string
error?: string
tokenConfigured: boolean | null
apiUrl: string
}
const EMPTY: ToolsMountView = {
toolsEnabled: true,
mounts: [],
count: 0,
version: '',
groups: [],
plane: '',
tokenConfigured: null,
apiUrl: '',
}
/** Normalize host `tools.status` RPC value. */
export function asToolsMountView(value: unknown): ToolsMountView {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...EMPTY }
const row = value as Record<string, unknown>
const mount = row.mount
const connection = row.connection
let count = 0
let version = ''
let groups: string[] = []
let plane = ''
let error: string | undefined
if (mount !== null && typeof mount === 'object' && !Array.isArray(mount)) {
const m = mount as Record<string, unknown>
count = typeof m.count === 'number' ? m.count : 0
version = typeof m.version === 'string' ? m.version : ''
plane = typeof m.plane === 'string' ? m.plane : ''
error = typeof m.error === 'string' ? m.error : undefined
if (Array.isArray(m.groups)) {
groups = m.groups.filter((g): g is string => typeof g === 'string')
}
}
let tokenConfigured: boolean | null = null
let apiUrl = ''
if (connection !== null && typeof connection === 'object' && !Array.isArray(connection)) {
const c = connection as Record<string, unknown>
tokenConfigured = c.tokenConfigured === true
apiUrl = typeof c.apiUrl === 'string' ? c.apiUrl : ''
}
const mounts: ToolsMountView['mounts'] = []
const names = new Set<string>()
if (Array.isArray(row.mounts)) {
for (const raw of row.mounts) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
const m = raw as Record<string, unknown>
const entry = {
source: typeof m.source === 'string' ? m.source : String(m.plane ?? ''),
count: typeof m.count === 'number' ? m.count : 0,
groups: Array.isArray(m.groups) ? m.groups.filter((g): g is string => typeof g === 'string') : [],
plane: typeof m.plane === 'string' ? m.plane : '',
error: typeof m.error === 'string' ? m.error : undefined,
}
mounts.push(entry)
if (Array.isArray(m.names)) for (const name of m.names) if (typeof name === 'string') names.add(name)
if (entry.error) error = entry.error
if (typeof m.version === 'string' && m.version) version = m.version
}
count = names.size
groups = [...new Set(mounts.flatMap(m => m.groups))]
}
const toolsEnabled = !(connection && typeof connection === 'object'
&& (connection as Record<string, unknown>).toolsEnabled === false)
return { count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled }
}
/** Fetch Host tool mount snapshot. */
export async function fetchToolsStatus(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<ToolsMountView> {
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_STATUS_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asToolsMountView((result as { value?: unknown }).value)
}
return { ...EMPTY }
}
/** Badge tone for the tools mount line. */
export function toolsMountTone(view: ToolsMountView | null): 'ok' | 'warn' | 'err' | 'neutral' {
if (!view) return 'neutral'
if (view.error) return 'err'
if (!view.toolsEnabled) return 'neutral'
if (view.count > 0) return 'ok'
return 'warn'
}

View file

@ -19,6 +19,9 @@
* @module dsh-netxops
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
@ -29,7 +32,13 @@ export {
ensureAgentPresetInstalled,
NETXOPS_PRESET_ID,
} from './agent-preset-install.ts'
import { ensureAgentPresetInstalled } from './agent-preset-install.ts'
export {
mergeHostConfigForHeal,
NETXOPS_HOST_CONFIG_DEFAULTS,
writePatchIfUnchanged,
} from './netx/heal-profile-entry.ts'
export { normalizeApiUrl } from './netx/http.ts'
import { ensureAgentPresetInstalled, resolveActiveProfile } from './agent-preset-install.ts'
import { startAlarmPushClient } from './netx/alarm-push.ts'
import {
getAlarmPushStatus,
@ -41,6 +50,7 @@ import { resetAlarmSession } from './netx/alarm-session.ts'
import {
capabilityGroupsFromSettings,
groupsForPlane,
kbEnabledForPlane,
type NetxCapabilityGroupSettingsFields,
} from './netx/capability-groups.ts'
import { registerGroupSkills } from './netx/group-skills.ts'
@ -66,6 +76,7 @@ import {
thinkingInstruction,
thinkingReminder,
} from './netx/model-language.ts'
import { normalizeApiUrl, type NormalizeApiUrlResult } from './netx/http.ts'
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
import {
getSessionsExportStatus,
@ -74,13 +85,33 @@ import {
sessionsExportResponse,
} from './netx/session-export.ts'
import { mountNetxopsWebRoute } from './netx/netxops-web-rpc.ts'
import {
getToolMountStatus,
getToolMountStatuses,
publishToolMountStatus,
resetToolMountStatus,
} from './netx/tool-mount-status.ts'
import { registerNetxTools } from './netx/tools.ts'
import { installPresetPublication } from './netx/preset-publication.ts'
import { readConfigSnapshot, readConfigValue, type ConfigInput } from './netx/config-values.ts'
// The event is absent from older peer declarations, where it is never emitted.
declare module '@deepseek-ai/cordis' {
interface Events {
'loader/volatile-update'(paths: readonly (readonly string[])[]): void
}
}
/** Cordis plugin name. */
export const name = 'netxops'
/** Wait for the credentials store before publishing a Bearer snapshot. */
export const inject = ['credentials']
/**
* Hard-inject `tools` (trilium / official cookbook pattern). Soft `credentials`
* previously left connection unpublished when pending; tools soft-inject could
* also never fire — Host then registered zero `netx__*` globally. Credentials
* stay soft below; we provisional-publish immediately.
*/
export const inject = ['tools']
/** Connection RPC channel for browser status reads. */
export const NETXOPS_RPC_CHANNEL = '/netxops'
@ -91,8 +122,23 @@ export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
/** Default credential reference for the netx API bearer token. */
export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
/** Default tool-call timeout when settings omit / null the volatile field. */
export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 120_000
/** Default REST root when settings projection emits null/empty for apiUrl. */
export const DEFAULT_API_URL = 'http://127.0.0.1:8890'
/** Default API lang query when settings projection emits null/empty for lang. */
export const DEFAULT_LANG = 'zh'
/** Plugin / settings section shape. */
export interface Config {
/** Master switch for every Netx tool and skill publication. */
toolsEnabled: boolean
groupOpsPresets: string
groupTopologyPresets: string
groupBizMonitorPresets: string
groupKbPresets: string
/** netx REST root (no trailing slash required). */
apiUrl: string
/** Passed as `lang` query when starting with `en` (API + alarm copy only). */
@ -145,7 +191,7 @@ export interface Config {
nmsProvider: string
/** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */
groupOpsInPreset: boolean
/** Publish ops tools/skills to other presets (default on). */
/** Publish ops tools/skills to other presets (default off — Netx Ops only). */
groupOpsPublic: boolean
/** topology canvas / layout tools in the Netx Ops preset (default off). */
groupTopologyInPreset: boolean
@ -177,7 +223,12 @@ function vol<S>(schema: S): S {
return typeof candidate.volatile === 'function' ? candidate.volatile() : schema
}
export const Config: z<Config> = z.object({
export const Config: z<Config, ConfigInput<Config>> = z.object({
toolsEnabled: vol(z.boolean().default(true)),
groupOpsPresets: vol(z.string().default('')),
groupTopologyPresets: vol(z.string().default('')),
groupBizMonitorPresets: vol(z.string().default('')),
groupKbPresets: vol(z.string().default('')),
apiUrl: vol(z.string().default('http://127.0.0.1:8890')),
lang: vol(z.string().default('zh')),
thinkingLanguage: vol(z.string().default('auto')),
@ -193,7 +244,7 @@ export const Config: z<Config> = z.object({
imTargetId: vol(z.string().default('')),
nmsProvider: vol(z.string().default('zte-ume')),
groupOpsInPreset: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(false)),
groupTopologyInPreset: vol(z.boolean().default(false)),
groupTopologyPublic: vol(z.boolean().default(false)),
groupBizMonitorInPreset: vol(z.boolean().default(true)),
@ -203,13 +254,117 @@ export const Config: z<Config> = z.object({
groupKbPublic: vol(z.boolean().default(false)),
})
/** Merge settings projection without letting null/undefined wipe profile defaults. */
export function mergeConfigSkippingNullish(
base: ConfigInput<Config>,
patch: Record<string, unknown>,
): Config {
const live = Object.fromEntries(
Object.entries(readConfigSnapshot(patch)).filter(([, v]) => v !== null && v !== undefined),
)
return { ...readConfigSnapshot(base), ...live }
}
/** Positive finite timeout for `defineTool`; fall back when projection emits null. */
export function resolveToolCallTimeoutMs(
value: unknown,
fallback = DEFAULT_TOOL_CALL_TIMEOUT_MS,
): number {
value = readConfigValue(value)
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
type ApiUrlNormalizeError = Extract<NormalizeApiUrlResult, { ok: false }>['error']
/** Heal null/empty lang from settings projection. */
export function resolveLang(value: unknown, fallback = DEFAULT_LANG): string {
value = readConfigValue(value)
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback
}
/**
* Resolve bearer token from the credentials seam (or empty when unset).
* Requires `inject: ['credentials']` so the store is live before apply.
* Resolve a publishable apiUrl.
* null/undefined/"" → default localhost; invalid non-empty → "" (keep warn path).
*/
async function resolveToken(ctx: Context, refName: string): Promise<string> {
const hit = await ctx.credentials.resolve(credentialRef(refName))
return hit?.value ?? ''
export function resolveApiUrlForConnection(value: unknown): {
url: string
usedDefault: boolean
error?: ApiUrlNormalizeError
} {
value = readConfigValue(value)
const raw = typeof value === 'string' ? value.trim() : ''
if (!raw) {
const normalized = normalizeApiUrl(DEFAULT_API_URL)
return {
url: normalized.ok ? normalized.url : DEFAULT_API_URL,
usedDefault: true,
}
}
const normalized = normalizeApiUrl(raw)
if (normalized.ok) return { url: normalized.url, usedDefault: false }
return { url: '', usedDefault: false, error: normalized.error }
}
function packageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
return typeof pkg.version === 'string' ? pkg.version : '0'
} catch {
return '0'
}
}
/** Heal null/empty/invalid tokenCredentialRef from settings projection. */
export function resolveTokenCredentialRef(value: unknown): string {
value = readConfigValue(value)
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.trim())
? value.trim()
: DEFAULT_TOKEN_REF
}
type CredentialsResolve = {
resolve?: (ref: unknown) => Promise<{ value?: string } | undefined>
}
function credentialsFrom(ctx: Context): CredentialsResolve | undefined {
const direct = (ctx as { credentials?: CredentialsResolve }).credentials
if (direct && typeof direct.resolve === 'function') return direct
if (typeof (ctx as { get?: (name: string) => unknown }).get === 'function') {
const viaGet = (ctx as { get: (name: string) => unknown }).get('credentials') as CredentialsResolve | undefined
if (viaGet && typeof viaGet.resolve === 'function') return viaGet
}
return undefined
}
/**
* Resolve bearer token from the credentials seam (or env fallback).
* Never throws — invalid/null ref names fall back to {@link DEFAULT_TOKEN_REF}.
*/
async function resolveToken(
ctx: Context,
refName: unknown,
log?: { warn: (...args: unknown[]) => void },
): Promise<string> {
const ref = resolveTokenCredentialRef(refName)
const fromEnv = (): string => process.env[ref]?.trim() ?? ''
try {
const credentials = credentialsFrom(ctx)
if (!credentials?.resolve) {
const env = fromEnv()
if (!env) log?.warn('netxops: credentials service unavailable; %s empty', ref)
return env
}
const hit = await credentials.resolve(credentialRef(ref))
const value = typeof hit?.value === 'string' ? hit.value.trim() : ''
if (value) return value
const env = fromEnv()
if (!env) log?.warn('netxops: credential %s resolve returned empty', ref)
return env
} catch (error) {
log?.warn('netxops: credential %s resolve failed: %s', ref, error)
return fromEnv()
}
}
/** Normalize settings.describe() across DSH generations (array or { namespaces }). */
@ -236,9 +391,9 @@ function describeRows(describe: unknown): Array<{ ns?: string, value?: unknown }
*/
function installNetxopsSettings(
ctx: Context,
entry: Config,
entry: ConfigInput<Config>,
hooks: {
setSource: (current: () => Config) => void
setSource: (current: () => ConfigInput<Config>) => void
onChange: () => void
},
): void {
@ -246,13 +401,13 @@ function installNetxopsSettings(
installSettingsSection?: (
context: Context,
ns: string,
schema: z<Config>,
schema: z<Config, ConfigInput<Config>>,
base: Config,
sectionHooks: typeof hooks,
) => void
}).installSettingsSection
if (typeof legacy === 'function') {
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, readConfigSnapshot(entry), hooks)
return
}
@ -261,7 +416,7 @@ function installNetxopsSettings(
installSection?: (
context: Context,
ns: string,
schema: z<Config>,
schema: z<Config, ConfigInput<Config>>,
base: Config,
sectionHooks: typeof hooks,
) => void
@ -269,7 +424,7 @@ function installNetxopsSettings(
}
if (typeof settings.installSection === 'function') {
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, readConfigSnapshot(entry), hooks)
return
}
@ -279,9 +434,11 @@ function installNetxopsSettings(
const readLive = (): Config => {
const row = describeRows(settings.describe).find(item => item.ns === NETXOPS_SETTINGS_NAMESPACE)
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
return { ...entry, ...(row.value as Partial<Config>) }
// Settings projection may emit null for volatile scalars; never let null/undefined
// wipe profile defaults (e.g. toolCallTimeoutMs → defineTool throw → zero tools).
return mergeConfigSkippingNullish(entry, row.value as Record<string, unknown>)
}
return entry
return readConfigSnapshot(entry)
}
hooks.setSource(readLive)
hooks.onChange()
@ -317,14 +474,178 @@ function installNetxopsSettings(
/**
* Apply the Netx Ops host bridge (settings + connection publish + optional alarm push).
*/
export function apply(ctx: Context, config: Config = Config({})): void {
let source: () => Config = () => config
let publishing: Promise<void> = Promise.resolve()
export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as Config)): void {
let source: () => Config = () => readConfigSnapshot(config)
const initial = source()
let generation = 0
let disposed = false
let resolvedTokenRef = resolveTokenCredentialRef(initial.tokenCredentialRef)
let stopAlarmPush: (() => void) | undefined
/** Prefer the soft-injected credentials fiber — parent ctx may lack the service. */
let credentialsCtx: Context = ctx
if (config.installAgentPreset) {
ensureAgentPresetInstalled(ctx.logger)
if (initial.installAgentPreset) {
const profile = resolveActiveProfile(ctx.baseUrl)
let scheduled = false
const fallback = setTimeout(() => {
if (!scheduled && !disposed && source().installAgentPreset) {
ensureAgentPresetInstalled(ctx.logger, profile)
}
}, 1000)
ctx.inject(['agentPresets'], presetCtx => {
scheduled = true
clearTimeout(fallback)
let active = true
const timer = setTimeout(() => {
void (async () => {
const registry = presetCtx.get('agentPresets') as {
readDocument?: (id: string) => Promise<{ content: string }>
}
let document: string | undefined
try { document = (await registry.readDocument?.('standard'))?.content }
catch (error) { presetCtx.logger.warn('netxops: cannot read Host standard preset: %s', error) }
if (active && !disposed && source().installAgentPreset) {
ensureAgentPresetInstalled(presetCtx.logger, profile, document)
}
})().catch(error => { presetCtx.logger.warn('netxops: preset installation failed: %s', error) })
}, 0)
presetCtx.effect(() => () => { active = false; clearTimeout(timer) }, 'netxops: Host standard composition')
})
ctx.effect(() => () => { clearTimeout(fallback) }, 'netxops: preset installation fallback')
}
const connectionSnapshot = (current: Config, token: string) => {
const resolved = resolveApiUrlForConnection(current.apiUrl)
if (resolved.error) {
ctx.logger.warn(
'netxops: invalid apiUrl (%s) — using empty url until fixed; use http(s) without embedded credentials',
resolved.error,
)
} else if (resolved.usedDefault) {
ctx.logger.warn(
'netxops: apiUrl missing/null in settings — falling back to %s',
resolved.url,
)
}
return {
toolsEnabled: current.toolsEnabled !== false,
groupKbPresets: current.groupKbPresets ?? '',
apiUrl: resolved.url,
token,
lang: resolveLang(current.lang),
toolCallTimeoutMs: resolveToolCallTimeoutMs(current.toolCallTimeoutMs),
groups: capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields),
groupKbInPreset: typeof current.groupKbInPreset === 'boolean'
? current.groupKbInPreset
: true,
groupKbPublic: current.groupKbPublic === true,
}
}
// Sync provisional publish BEFORE any async work so preset `dsh-netxops/tools`
// can remount in the same tick (credentials resolve may take a while).
const provisional = connectionSnapshot(initial, '')
publishNetxConnection(provisional)
installPresetPublication(ctx)
ctx.logger.info(
'netxops: provisional connection published → %s (token pending credentials)',
provisional.apiUrl || '(invalid apiUrl)',
)
// Host-global tools FIRST (trilium pattern). Hard-inject guarantees tools;
// smoke/unit may call apply without it — resolve via get() so a bare
// `ctx.tools` access cannot throw "without inject". Register before settings
// / credentials so a later soft-inject stall cannot leave the catalog empty.
const toolsApi = (() => {
try {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('tools')
: undefined
if (fromGet && typeof (fromGet as { register?: unknown }).register === 'function') {
return fromGet as { register: (entry: unknown) => () => void }
}
} catch {
// ignore
}
const direct = (ctx as { tools?: { register: (entry: unknown) => () => void } }).tools
return direct && typeof direct.register === 'function' ? direct : undefined
})()
if (!toolsApi) {
ctx.logger.warn(
'netxops: tools missing on apply — host tool mount skipped (loader should inject tools)',
)
publishToolMountStatus({
version: 'unknown',
plane: 'host',
groups: [],
names: [],
count: 0,
at: new Date().toISOString(),
error: 'tools service missing on host apply',
source: 'host',
})
} else {
let disposeToolsMount: (() => void) | undefined
let disposeKbLocal: (() => void) | undefined
const remountHostTools = (): void => {
if (disposed) return
disposeToolsMount?.()
disposeToolsMount = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
// trilium-style: register inside ctx.effect so fiber teardown owns the mount
disposeToolsMount = ctx.effect(() => {
try {
const unregister = registerNetxTools(ctx, connection, { plane: 'host' })
ctx.logger.info(
'netxops: host tools groups=[%s] count=%s',
enabled.join(',') || '(none)',
getToolMountStatus()?.count ?? 0,
)
return () => { unregister() }
} catch (error) {
publishToolMountStatus({
version: packageVersion(),
plane: 'host',
groups: [...enabled],
names: [],
count: 0,
at: new Date().toISOString(),
error: String(error),
})
ctx.logger.error('netxops: host tools register failed: %s', error)
return () => {}
}
}, 'netxops: host tools mount')
}
const remountKbLocal = (): void => {
if (disposed) return
disposeKbLocal?.()
disposeKbLocal = undefined
const connection = getNetxConnection()
if (!connection) return
// Host plane = public only (same rule as capability groups).
if (!kbEnabledForPlane(connection, 'host')) return
disposeKbLocal = ctx.effect(() => {
const unregister = registerKbLocalTools(ctx, getKbContext(), { plane: 'host', source: 'netxops-host:kb-local' })
return () => { unregister?.() }
}, 'netxops: host kb-local tools')
}
const stopWatch = watchNetxConnection(() => {
remountHostTools()
remountKbLocal()
})
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
remountHostTools()
remountKbLocal()
ctx.effect(() => () => {
stopWatch()
stopKbWatch()
disposeToolsMount?.()
disposeKbLocal?.()
resetToolMountStatus()
}, 'netxops: dispose host tools')
}
const restartAlarmPush = (current: Config, apiUrl: string, token: string): void => {
@ -346,7 +667,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
return
}
const lang = current.lang
const lang = resolveLang(current.lang)
const deliverDsh = current.alarmDeliverDsh !== false
const imTargets = resolveImTargets({
imTargets: current.imTargets ?? '',
@ -372,30 +693,40 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
const publish = (): void => {
publishing = publishing.then(async () => {
const gen = ++generation
const current = source()
const token = await resolveToken(ctx, current.tokenCredentialRef)
if (gen !== generation) return
if (disposed) return
const gen = ++generation
// Sync publish first (reuse last token) so capability toggles remount in the
// same tick — do not wait on credentials.resolve for group on/off.
const currentSync = source()
const previous = getNetxConnection()
const sameCredential = resolveTokenCredentialRef(currentSync.tokenCredentialRef) === resolvedTokenRef
const sameHost = resolveApiUrlForConnection(currentSync.apiUrl).url === previous?.apiUrl
const prevToken = sameCredential && sameHost ? previous?.token ?? '' : ''
publishNetxConnection(connectionSnapshot(currentSync, prevToken))
const kb = resolveKbRoot(currentSync.kbRoot ?? '')
publishKbContext(kb)
applyKbEnv(kb)
if (!currentSync.alarmPushEnabled) {
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmPushStatus()
}
void (async () => {
const current = source()
const tokenRef = resolveTokenCredentialRef(current.tokenCredentialRef)
const token = await resolveToken(credentialsCtx, tokenRef, ctx.logger)
if (disposed || gen !== generation) return
resolvedTokenRef = tokenRef
const apiUrl = current.apiUrl.replace(/\/$/, '')
const tokenConfigured = token.trim().length > 0
const groups = capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields)
if (current.nmsProvider && current.nmsProvider !== 'zte-ume') {
ctx.logger.warn(
'netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter',
current.nmsProvider,
)
}
publishNetxConnection({
apiUrl,
token,
lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs,
groups,
groupKbInPreset: current.groupKbInPreset !== false,
groupKbPublic: current.groupKbPublic === true,
})
const snapshot = connectionSnapshot(current, token)
publishNetxConnection(snapshot)
const kb = resolveKbRoot(current.kbRoot ?? '')
publishKbContext(kb)
applyKbEnv(kb)
@ -410,12 +741,13 @@ export function apply(ctx: Context, config: Config = Config({})): void {
} else if (kb.status === 'error') {
ctx.logger.warn('netxops: knowledge base error — %s', kb.errorMessage)
}
restartAlarmPush(current, apiUrl, token)
restartAlarmPush(current, snapshot.apiUrl, token)
const tokenConfigured = token.trim().length > 0
if (!tokenConfigured) {
ctx.logger.warn(
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
apiUrl,
current.tokenCredentialRef,
snapshot.apiUrl,
tokenRef,
)
} else {
const imSinkCount = resolveImTargets({
@ -424,32 +756,41 @@ export function apply(ctx: Context, config: Config = Config({})): void {
imTargetId: current.imTargetId ?? '',
}).length
ctx.logger.info(
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
apiUrl,
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s host=[%s]',
snapshot.apiUrl,
current.alarmPushEnabled === true,
current.alarmDeliverDsh !== false,
imSinkCount,
groupsForPlane(groups, 'public').join(',') || '(none)',
groupsForPlane(snapshot.groups, 'host').join(',') || '(none)',
)
}
}).catch((error) => {
})().catch((error) => {
ctx.logger.error('netxops: connection publish error: %s', error)
})
}
publish()
// Current Loader commits volatile references in place and notifies this
// owning fiber. Read them again and revoke/remount immediately on UI saves.
ctx.on('loader/volatile-update', () => { publish() })
installNetxopsSettings(ctx, config, {
setSource: (current) => {
source = current
source = () => readConfigSnapshot(current())
},
onChange: () => {
publish()
},
})
ctx.on('credentials/reference-updated', (ref) => {
if (String(ref) === source().tokenCredentialRef) publish()
// Soft credentials — never block Host apply on this service.
ctx.inject(['credentials'], (credCtx) => {
credentialsCtx = credCtx
publish()
credCtx.on('credentials/reference-updated', (ref) => {
if (String(ref) === resolveTokenCredentialRef(source().tokenCredentialRef)) publish()
})
})
// Host-global model language: every preset on this Host inherits these
@ -475,7 +816,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})()
: undefined
if (section !== null && typeof section === 'object' && !Array.isArray(section)) {
return { ...source(), ...(section as Partial<Config>) }
return mergeConfigSkippingNullish(source(), section as Record<string, unknown>)
}
return source()
}
@ -525,94 +866,64 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}, 'netxops: model language prompts')
})
// Optional host-layer publish: groups with `public=true` become visible to other presets.
ctx.inject(['tools'], (toolsCtx) => {
let unregisterTools: (() => void) | undefined
let unregisterKbLocal: (() => void) | undefined
const remountPublicTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
try {
unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' })
toolsCtx.logger.info(
'netxops: host public tools groups=[%s]',
enabled.join(',') || '(none)',
)
} catch (error) {
toolsCtx.logger.error('netxops: host public tools register failed: %s', error)
}
}
const remountKbLocal = (): void => {
unregisterKbLocal?.()
unregisterKbLocal = undefined
const connection = getNetxConnection()
if (!connection || connection.groupKbPublic !== true) return
unregisterKbLocal = registerKbLocalTools(toolsCtx, getKbContext())
}
remountPublicTools()
remountKbLocal()
const stopWatch = watchNetxConnection(() => {
remountPublicTools()
remountKbLocal()
})
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
toolsCtx.effect(() => () => {
stopWatch()
stopKbWatch()
unregisterTools?.()
unregisterKbLocal?.()
}, 'netxops: dispose public tools')
})
ctx.inject(['skills'], (skillsCtx) => {
let skillAbort = new AbortController()
let packAbort = new AbortController()
let unregisterSkills: (() => void) | undefined
let unregisterKbSkill: (() => void) | undefined
let unregisterKbPack: (() => void) | undefined
let generation = 0
let packGeneration = 0
const remountPublicSkills = (): void => {
const remountHostSkills = (): void => {
if (disposed) return
const gen = ++generation
skillAbort.abort()
skillAbort = new AbortController()
unregisterSkills?.()
unregisterSkills = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
void registerGroupSkills(skillsCtx, enabled, 'netxops-public').then((dispose) => {
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', skillAbort.signal).then((dispose) => {
if (gen !== generation) {
dispose()
return
}
unregisterSkills = dispose
skillsCtx.logger.info(
'netxops: host public skills groups=[%s]',
'netxops: host skills groups=[%s]',
enabled.join(',') || '(none)',
)
}).catch((error) => {
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
skillsCtx.logger.warn('netxops: host skill register failed: %s', error)
})
}
const remountKbSkill = (): void => {
if (disposed) return
unregisterKbSkill?.()
unregisterKbSkill = undefined
const connection = getNetxConnection()
if (!kbEnabledForPlane(connection, 'host')) return
const snapshot = getKbContext()
unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot)
skillsCtx.logger.info(
'netxops: kb-context skill status=%s',
'netxops: kb-context skill status=%s (host/public)',
snapshot.status,
)
}
const remountKbPack = (): void => {
if (disposed) return
const gen = ++packGeneration
packAbort.abort()
packAbort = new AbortController()
unregisterKbPack?.()
unregisterKbPack = undefined
const connection = getNetxConnection()
const enabled = connection?.groupKbPublic === true
const enabled = kbEnabledForPlane(connection, 'host') && Boolean(connection)
void registerKbPackSkills(skillsCtx, getKbContext(), {
enabled,
providerLabel: 'netxops-kb-pack-public',
providerLabel: 'netxops-kb-pack-host',
signal: packAbort.signal,
}).then((dispose) => {
if (gen !== packGeneration) {
dispose()
@ -620,32 +931,64 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
unregisterKbPack = dispose
}).catch((error) => {
skillsCtx.logger.warn('netxops: public kb pack skill register failed: %s', error)
skillsCtx.logger.warn('netxops: host kb pack skill register failed: %s', error)
})
}
remountPublicSkills()
remountKbSkill()
remountKbPack()
const stopWatch = watchNetxConnection(() => {
remountPublicSkills()
remountHostSkills()
remountKbSkill()
remountKbPack()
})
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
})
remountHostSkills()
remountKbSkill()
remountKbPack()
skillsCtx.effect(() => () => {
generation += 1
packGeneration += 1
skillAbort.abort()
packAbort.abort()
stopWatch()
stopKbWatch()
unregisterSkills?.()
unregisterKbSkill?.()
unregisterKbPack?.()
}, 'netxops: dispose public skills')
}, 'netxops: dispose host skills')
})
const handleNetxopsRpc = async (endpoint: string, payload?: unknown): Promise<unknown> => {
if (endpoint === 'presets.catalog') {
const registry = ctx.get('agentPresets') as {
list?: () => Promise<Array<{ id: string; name?: string; broken?: string }>>
} | undefined
if (!registry?.list) return { ok: false, error: 'preset_registry_unavailable' }
const presets = await registry.list()
return { ok: true, value: { presets: presets.map(({ id, name, broken }) => ({ id, name, broken })) } }
}
if (endpoint === 'tools.status') {
const mount = getToolMountStatus()
const connection = getNetxConnection()
return {
ok: true,
value: {
mount: mount ?? null,
mounts: getToolMountStatuses(),
connection: connection
? {
apiUrl: connection.apiUrl,
toolsEnabled: connection.toolsEnabled !== false,
tokenConfigured: connection.token.trim().length > 0,
hostGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host'),
presetGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'preset'),
publicGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'public'),
}
: null,
},
}
}
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
@ -736,14 +1079,14 @@ export function apply(ctx: Context, config: Config = Config({})): void {
channel: string,
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
) => (() => void) | Promise<void>
const rpc = (mountCtx.connection as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
const rpc = (mountCtx.get('connection') as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
if (rpc && typeof rpc.handle === 'function') {
mountCtx.logger.info('netxops: /netxops rpc mounted via connection.rpc.handle (legacy)')
const legacy = rpc.handle(
NETXOPS_RPC_CHANNEL,
(endpoint, payload) => handleNetxopsRpc(endpoint, payload),
)
return () => { void legacy() }
return () => { if (typeof legacy === 'function') legacy() }
}
mountCtx.logger.warn('netxops: /netxops rpc unavailable — settings card status disabled')
return () => {}
@ -752,7 +1095,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
// Browser card: sessions export download via shared /api Fetch route.
ctx.inject(['connection'], (connCtx) => {
const connection = connCtx.connection as {
const connection = connCtx.get('connection') as {
rpc?: { handle?: (
channel: string,
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
@ -770,7 +1113,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled')
} else {
connCtx.effect(() => {
const dispose = fetchApi.register({
const dispose = fetchApi.register!({
path: NETXOPS_SESSIONS_EXPORT_PATH,
methods: ['GET', 'HEAD'],
fetch: async (request) => {
@ -786,7 +1129,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
ctx.effect(() => () => {
disposed = true
generation += 1
publishNetxConnection({ ...provisional, token: '', toolsEnabled: false })
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmSession()

View file

@ -18,10 +18,15 @@ export function alarmSubscribeUrl(apiUrl: string): string {
} catch {
return ''
}
// Only promote http(s) → ws(s); never rewrite file:/data:/javascript: etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
if (url.username || url.password) return ''
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = '/v1/integrations/dsh-alarm/ws'
url.search = ''
url.hash = ''
url.username = ''
url.password = ''
return url.toString()
}

View file

@ -8,6 +8,8 @@
* - `bizMonitor` → skill `netx-biz-monitor` — cutover / biz_state read + overnight analysis
*/
import { readConfigSnapshot, readConfigValue } from './config-values.ts'
/** Stable group ids used in settings, skill dirs, and registration filters. */
export type NetxCapabilityGroupId = 'ops' | 'topology' | 'bizMonitor'
@ -17,6 +19,8 @@ export interface NetxGroupExposure {
inPreset: boolean
/** Mount on the host tool/skill layer so other presets can see the group. */
public: boolean
/** Additional preset ids selected by the operator. */
presets?: readonly string[]
}
/** Full capability-group policy published with the connection snapshot. */
@ -24,6 +28,10 @@ export type NetxCapabilityGroups = Record<NetxCapabilityGroupId, NetxGroupExposu
/** Flat settings fields (Plugins card + schemastery Config). */
export interface NetxCapabilityGroupSettingsFields {
toolsEnabled?: boolean
groupOpsPresets?: string
groupTopologyPresets?: string
groupBizMonitorPresets?: string
groupOpsInPreset: boolean
groupOpsPublic: boolean
groupTopologyInPreset: boolean
@ -33,10 +41,10 @@ export interface NetxCapabilityGroupSettingsFields {
}
/**
* Default: ops in Ops preset; topology / bizMonitor and all public off.
* Default: ops + bizMonitor in Ops preset; topology and all public off.
*/
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
ops: Object.freeze({ inPreset: true, public: true }),
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }),
})
@ -79,7 +87,6 @@ export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly str
'netx__layoutTopologyView',
'netx__suggestSinkHubs',
'netx__analyzeTopologyViewLayout',
'netx__sinkTopologyDualUnits',
]),
bizMonitor: Object.freeze([
'netx__listBizMonitors',
@ -112,7 +119,7 @@ export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.fre
* Legacy aliases (all map into `ops`):
* - `groupNms*` / `groupCommon*` / `groupManagedNe*`
*
* Legacy `groupTopologyLayout*` ORs into `topology`.
* Legacy `groupTopologyLayout*` applies only when the current flag is absent.
*/
export function capabilityGroupsFromSettings(
fields: Partial<NetxCapabilityGroupSettingsFields & {
@ -126,17 +133,17 @@ export function capabilityGroupsFromSettings(
groupTopologyLayoutPublic?: boolean
}> | null | undefined,
): NetxCapabilityGroups {
const src = fields ?? {}
const src = readConfigSnapshot(fields ?? {})
let opsInPreset = true
if (src.groupOpsInPreset !== undefined) {
opsInPreset = src.groupOpsInPreset !== false
if (typeof src.groupOpsInPreset === 'boolean') {
opsInPreset = src.groupOpsInPreset
} else {
const legacy = [
src.groupNmsInPreset,
src.groupCommonInPreset,
src.groupManagedNeInPreset,
].filter((v): v is boolean => v !== undefined)
].filter((v): v is boolean => typeof v === 'boolean')
if (legacy.length > 0) {
// Any explicit true → on; all explicit false → off.
opsInPreset = legacy.some((v) => v === true)
@ -144,57 +151,88 @@ export function capabilityGroupsFromSettings(
}
let bizMonitorInPreset = true
if (src.groupBizMonitorInPreset !== undefined) {
bizMonitorInPreset = src.groupBizMonitorInPreset !== false
if (typeof src.groupBizMonitorInPreset === 'boolean') {
bizMonitorInPreset = src.groupBizMonitorInPreset
}
return {
ops: {
inPreset: opsInPreset,
public: (() => {
if (src.groupOpsPublic !== undefined) return src.groupOpsPublic !== false
if (typeof src.groupOpsPublic === 'boolean') return src.groupOpsPublic
const legacy = [
src.groupNmsPublic,
src.groupCommonPublic,
src.groupManagedNePublic,
].filter((v): v is boolean => v !== undefined)
// No explicit flag → default on (other presets see ops after install).
if (legacy.length === 0) return true
].filter((v): v is boolean => typeof v === 'boolean')
// No explicit flag → default off (Netx Ops preset only).
if (legacy.length === 0) return false
return legacy.some((v) => v === true)
})(),
...(src.groupOpsPresets ? { presets: parsePresetIds(src.groupOpsPresets) } : {}),
},
topology: {
inPreset: src.groupTopologyInPreset === true
|| src.groupTopologyLayoutInPreset === true,
public: src.groupTopologyPublic === true
|| src.groupTopologyLayoutPublic === true,
inPreset: typeof src.groupTopologyInPreset === 'boolean'
? src.groupTopologyInPreset : src.groupTopologyLayoutInPreset === true,
public: typeof src.groupTopologyPublic === 'boolean'
? src.groupTopologyPublic : src.groupTopologyLayoutPublic === true,
...(src.groupTopologyPresets ? { presets: parsePresetIds(src.groupTopologyPresets) } : {}),
},
bizMonitor: {
inPreset: bizMonitorInPreset,
public: src.groupBizMonitorPublic === true,
...(src.groupBizMonitorPresets ? { presets: parsePresetIds(src.groupBizMonitorPresets) } : {}),
},
}
}
/**
* KB exposure mirrors capability groups: preset plane ↔ inPreset, host/public ↔ public.
* Defaults: inPreset on, public off (same strategy as ops/bizMonitor).
*/
export function kbEnabledForPlane(
flags: { toolsEnabled?: boolean, groupKbInPreset?: boolean, groupKbPublic?: boolean, groupKbPresets?: string } | undefined,
plane: 'preset' | 'public' | 'host',
presetId = 'netxops',
): boolean {
if (flags) flags = readConfigSnapshot(flags)
if (flags?.toolsEnabled === false) return false
if (!flags) return plane === 'preset'
if (plane === 'preset') {
return !flags.groupKbPublic && (presetId === 'netxops'
? flags.groupKbInPreset !== false
: parsePresetIds(flags.groupKbPresets ?? '').includes(presetId))
}
return flags.groupKbPublic === true
}
/**
* Groups enabled for one registration plane.
* - `preset` — `inPreset` (Netx Ops standing mount via `dsh-netxops/tools`)
* - `public` — `public` only
* - `host` — same as `public` (Host-global registry for other presets)
*
* Host must NOT union `inPreset`: otherwise unchecking「公开」leaves tools on the
* global registry whenever「预设内」is still on, so revoke never takes effect.
*/
export function groupsForPlane(
groups: NetxCapabilityGroups | undefined,
plane: 'preset' | 'public',
plane: 'preset' | 'public' | 'host',
only?: readonly NetxCapabilityGroupId[],
): NetxCapabilityGroupId[] {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS
const enabled = CAPABILITY_GROUP_IDS.filter((id) => (
plane === 'preset' ? policy[id].inPreset : policy[id].public
))
const enabled = CAPABILITY_GROUP_IDS.filter((id) => {
if (plane === 'preset') return policy[id].inPreset
// host === public: only explicitly published groups leave the Ops preset.
return policy[id].public
})
if (!only || only.length === 0) return enabled
const allow = new Set(only)
return enabled.filter((id) => allow.has(id))
}
/**
* Force-enable the listed groups (for `dsh-netxops/tools-<group>` mounts).
* Normalize a group allow-list; publication policy still controls registration.
* Legacy `nms` / `common` aliases resolve to `ops`.
*/
export function groupsForced(
@ -216,3 +254,23 @@ export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]):
}
return out
}
/** Parse the persisted preset-id list. Empty is an explicit empty selection. */
export function parsePresetIds(value: unknown): string[] {
const current = readConfigValue(value)
if (typeof current !== 'string') return []
return [...new Set(current.split(/[\s,,]+/).filter(Boolean))]
}
/** Groups mounted locally in a preset; public groups are inherited from Host. */
export function groupsForPreset(
connection: { toolsEnabled?: boolean, groups?: NetxCapabilityGroups },
presetId: string,
only?: readonly NetxCapabilityGroupId[],
): NetxCapabilityGroupId[] {
if (connection.toolsEnabled === false) return []
const policy = connection.groups ?? DEFAULT_CAPABILITY_GROUPS
return CAPABILITY_GROUP_IDS.filter(id => (!only?.length || only.includes(id))
&& !policy[id].public
&& (presetId === 'netxops' ? policy[id].inPreset : policy[id].presets?.includes(presetId)))
}

28
src/netx/config-values.ts Normal file
View file

@ -0,0 +1,28 @@
/** The current Harness keeps volatile scalars in stable references. Older Hosts
* supply ordinary values. Keep references at the boundary and read a snapshot
* for each operation so Loader updates are visible without a plugin restart.
*/
export interface ConfigReference<T> {
get(): T
}
export type ConfigInput<T extends object> = {
[K in keyof T]: T[K] | ConfigReference<T[K]>
}
// Official cosmokit protocol, shared across its ESM/CJS copies. Do not import
// isVolatile from an external peer: older Harness builds do not export it.
const volatileWrite = Symbol.for('cosmokit.volatile.write')
export function readConfigValue(value: unknown): unknown {
if (value !== null && typeof value === 'object' && volatileWrite in value
&& 'get' in value && typeof value.get === 'function') {
return value.get()
}
return value
}
/** Our Config fields are scalars; this never descends into arbitrary objects. */
export function readConfigSnapshot<T extends object>(input: ConfigInput<T>): T {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, readConfigValue(value)])) as T
}

View file

@ -199,6 +199,7 @@ export async function registerGroupSkills(
ctx: Context,
groupIds: readonly NetxCapabilityGroupId[],
providerLabel: string,
signal?: AbortSignal,
): Promise<() => void> {
const skillsApi = (ctx as { skills?: { register: (skill: {
name: string
@ -217,31 +218,42 @@ export async function registerGroupSkills(
const disposers: Array<() => void> = []
const enabled = new Set(groupIds)
const log = (ctx as { logger?: { warn: (msg: string, ...args: unknown[]) => void } }).logger
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId)) continue
const { skills, skipped } = await loadGroupSkills(root, groupId)
for (const miss of skipped) {
log?.warn?.('%s: skip skill dir %s (%s)', providerLabel, miss.directory, miss.reason)
try {
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId)) continue
const { skills, skipped } = await loadGroupSkills(root, groupId)
if (signal?.aborted) break
for (const miss of skipped) {
log?.warn?.('%s: skip skill dir %s (%s)', providerLabel, miss.directory, miss.reason)
}
if (skills.length === 0) {
log?.warn?.(
'%s: no skills loaded for group=%s from %s',
providerLabel,
groupId,
join(root, SKILL_DIR_BY_GROUP[groupId]),
)
}
for (const skill of skills) {
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: groupId === 'topology'
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Native layout supports catalog and move_nodes/sink_nodes. Dual-unit/orbit/polish recipes require a separately installed netx-topology MCP. Native analyzeTopologyViewLayout reports basic graph statistics; suggestSinkHubs uses degree ranking. Native sinkTopologyDualUnits is unavailable.\n`
: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider: providerLabel,
source: 'custom',
}))
}
}
if (skills.length === 0) {
log?.warn?.(
'%s: no skills loaded for group=%s from %s',
providerLabel,
groupId,
join(root, SKILL_DIR_BY_GROUP[groupId]),
)
}
for (const skill of skills) {
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider: providerLabel,
source: 'custom',
}))
if (signal?.aborted) {
for (const dispose of disposers.splice(0)) dispose()
}
} catch (error) {
for (const dispose of disposers.splice(0)) dispose()
throw error
}
return () => {

View file

@ -2,11 +2,14 @@
* Shared agent-plane mount: tools + skills for one or more capability groups.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'
import {
groupsForced,
groupsForPlane,
groupsForPreset,
kbEnabledForPlane,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
import { registerGroupSkills } from './group-skills.ts'
@ -14,20 +17,36 @@ import { registerKbContextSkill } from './kb-context-skill.ts'
import { registerKbLocalTools } from './kb-local-tools.ts'
import { registerKbPackSkills } from './kb-pack-skills.ts'
import { getKbContext, watchKbContext } from './kb-runtime.ts'
import { tryRecoverMissingHostBridge } from './heal-profile-entry.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
import {
getNetxConnection,
requireNetxConnection,
watchNetxConnection,
} from './runtime.ts'
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
import { registerNetxTools } from './tools.ts'
function packageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
return typeof pkg.version === 'string' ? pkg.version : '0'
} catch {
return '0'
}
}
export interface GroupToolsPluginOptions {
/** Cordis plugin name. */
name: string
/**
* `settings` — honor inPreset/public from connection (Ops aggregate mount).
* `forced` — always register `only` groups when connection exists (per-group export).
* `forced` — legacy export label; only filters groups and obeys the publication policy.
*/
mode: 'settings' | 'forced'
/** Group allow-list; omit in settings mode to use all groups. */
only?: readonly NetxCapabilityGroupId[]
/** Explicit target for live session mounts; otherwise resolve the calling preset. */
presetId?: string
}
/**
@ -38,44 +57,50 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
let unregisterKbLocal: (() => void) | undefined
let unregisterSkills: (() => void) | undefined
let skillGeneration = 0
const presetId = (): string => {
if (options.presetId) return options.presetId
const registry = ctx.get('agentPresets') as { composedPreset?: (ctx: Context) => string | undefined } | undefined
return registry?.composedPreset?.(ctx) ?? 'netxops'
}
const mountId = getToolMountId(ctx, options.name)
const resolveGroups = (): NetxCapabilityGroupId[] => {
const connection = getNetxConnection()
if (options.mode === 'forced') {
return groupsForced(options.only ?? [])
}
return groupsForPlane(connection?.groups, 'preset', options.only)
const connection = requireNetxConnection()
return groupsForPreset(connection, presetId(), options.only)
}
const remountTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
const connection = getNetxConnection()
if (connection === undefined) {
// Preset channel can self-heal while the Host bundle row is gone — restore
// `dsh-netxops` into cordis.patch.yml once, then require a full restart.
tryRecoverMissingHostBridge(ctx.logger, options.name)
return
}
const connection = requireNetxConnection()
const enabled = resolveGroups()
if (enabled.length === 0) {
ctx.logger.info('%s: no groups enabled', options.name)
return
}
try {
// Always call register (incl. empty) so revoke publishes count=0 and matches host.
unregisterTools = registerNetxTools(ctx, connection, {
plane: 'preset',
only: options.mode === 'forced' ? enabled : options.only,
forceGroups: options.mode === 'forced' ? enabled : undefined,
forceGroups: enabled,
source: options.name,
})
ctx.logger.info(
'%s: groups=[%s] → %s tokenConfigured=%s',
'%s: groups=[%s] → %s tokenConfigured=%s live=%s',
options.name,
enabled.join(',') || '(none)',
connection.apiUrl,
connection.token.trim().length > 0,
getNetxConnection() !== undefined,
)
} catch (error) {
publishToolMountStatus({
id: mountId,
version: packageVersion(),
plane: 'preset',
groups: [...enabled],
names: [],
count: 0,
at: new Date().toISOString(),
error: String(error),
source: options.name,
})
ctx.logger.error('%s: registerNetxTools failed: %s', options.name, error)
}
}
@ -83,31 +108,48 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
const remountKbLocal = (): void => {
unregisterKbLocal?.()
unregisterKbLocal = undefined
const connection = getNetxConnection()
if (connection === undefined) return
if (connection.groupKbInPreset === false) return
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext())
const connection = requireNetxConnection()
if (options.only || !kbEnabledForPlane(connection, 'preset', presetId())) return
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext(), { plane: 'preset', source: `${options.name}:kb-local` })
}
remountTools()
remountKbLocal()
// Subscribe BEFORE the first remount. Host publish is async (credentials
// resolve); if it lands between remount and watch, tools stay empty forever.
const stopToolWatch = watchNetxConnection(() => {
remountTools()
remountKbLocal()
})
const stopKbToolWatch = watchKbContext(() => { remountKbLocal() })
remountTools()
remountKbLocal()
// Backup poll: covers publish that raced before watch, and slow credentials.
let pollTicks = 0
const pollTimer = setInterval(() => {
pollTicks += 1
if (getNetxConnection() !== undefined) {
remountTools()
remountKbLocal()
clearInterval(pollTimer)
return
}
if (pollTicks >= 40) clearInterval(pollTimer) // ~20s
}, 500)
ctx.inject(['skills'], (skillsCtx) => {
let skillAbort = new AbortController()
let packAbort = new AbortController()
let unregisterKbSkill: (() => void) | undefined
let unregisterKbPack: (() => void) | undefined
let packGeneration = 0
const remountSkills = (): void => {
const gen = ++skillGeneration
skillAbort.abort()
skillAbort = new AbortController()
unregisterSkills?.()
unregisterSkills = undefined
if (getNetxConnection() === undefined) return
const enabled = resolveGroups()
void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
void registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => {
if (gen !== skillGeneration) {
dispose()
return
@ -120,17 +162,22 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
const remountKbSkill = (): void => {
unregisterKbSkill?.()
unregisterKbSkill = undefined
const connection = requireNetxConnection()
if (options.only || !kbEnabledForPlane(connection, 'preset', presetId())) return
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext())
}
const remountKbPack = (): void => {
const gen = ++packGeneration
packAbort.abort()
packAbort = new AbortController()
unregisterKbPack?.()
unregisterKbPack = undefined
const connection = getNetxConnection()
const enabled = connection?.groupKbInPreset !== false
const connection = requireNetxConnection()
const enabled = !options.only && kbEnabledForPlane(connection, 'preset', presetId())
void registerKbPackSkills(skillsCtx, getKbContext(), {
enabled,
providerLabel: `${options.name}-kb-pack`,
signal: packAbort.signal,
}).then((dispose) => {
if (gen !== packGeneration) {
dispose()
@ -142,20 +189,23 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
})
}
remountSkills()
remountKbSkill()
remountKbPack()
const stopSkillWatch = watchNetxConnection(() => {
remountSkills()
remountKbSkill()
remountKbPack()
})
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
})
remountSkills()
remountKbSkill()
remountKbPack()
skillsCtx.effect(() => () => {
skillGeneration += 1
packGeneration += 1
skillAbort.abort()
packAbort.abort()
stopSkillWatch()
stopKbWatch()
unregisterSkills?.()
@ -168,11 +218,13 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
})
ctx.effect(() => () => {
clearInterval(pollTimer)
stopToolWatch()
stopKbToolWatch()
unregisterTools?.()
unregisterTools = undefined
unregisterKbLocal?.()
unregisterKbLocal = undefined
removeToolMountStatus(mountId)
}, `${options.name}: dispose tools`)
}

View file

@ -13,7 +13,7 @@
* recovery when only the preset side is still alive).
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
@ -25,6 +25,11 @@ const HOST_HEAL_ONCE = Symbol.for('dsh-netxops.host-bridge-heal-attempted')
/** Defaults merged into missing keys only — never overwrite user values. */
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
toolsEnabled: true,
groupOpsPresets: '',
groupTopologyPresets: '',
groupBizMonitorPresets: '',
groupKbPresets: '',
apiUrl: 'http://127.0.0.1:8890',
lang: 'zh',
thinkingLanguage: 'auto',
@ -40,8 +45,8 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | bool
imTargetId: '',
imTargets: '',
groupOpsInPreset: true,
/** Default on: other presets inherit ops tools/skills after install. */
groupOpsPublic: true,
/** Default off: ops stays Netx-Ops-only unless the operator publishes. */
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: true,
@ -171,8 +176,22 @@ function needsHeal(entry: HostEntry): boolean {
return false
}
/**
* Fill missing default keys only — never overwrite existing user values,
* and keep unknown user keys for round-trip safety.
*/
export function mergeHostConfigForHeal(
existing: Readonly<Record<string, string | boolean | number>>,
): Record<string, string | boolean | number> {
const out: Record<string, string | boolean | number> = { ...existing }
for (const [key, value] of Object.entries(NETXOPS_HOST_CONFIG_DEFAULTS)) {
if (!Object.hasOwn(out, key)) out[key] = value
}
return out
}
function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>): string[] {
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }
const config = mergeHostConfigForHeal(entry.config)
const out: string[] = [
'- id: netxops',
' name: dsh-netxops',
@ -180,12 +199,35 @@ function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>)
if (entry.disabled === true) out.push(' disabled: true')
else out.push(' disabled: false')
out.push(' config:')
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
out.push(` ${key}: ${formatScalar(config[key]!)}`)
}
for (const [key, value] of Object.entries(config)) {
if (Object.hasOwn(NETXOPS_HOST_CONFIG_DEFAULTS, key)) continue
out.push(` ${key}: ${formatScalar(value)}`)
}
return out
}
/**
* Write only when the on-disk contents still match `expectedRaw`.
* Aborts (returns false) if Plugin Manager / another heal raced us.
*/
export function writePatchIfUnchanged(
patchPath: string,
expectedRaw: string,
nextRaw: string,
): boolean {
if (nextRaw === expectedRaw) return false
const existed = existsSync(patchPath)
const mtimeMs = existed ? statSync(patchPath).mtimeMs : 0
const current = existed ? readFileSync(patchPath, 'utf8') : ''
if (current !== expectedRaw) return false
if (existed && statSync(patchPath).mtimeMs !== mtimeMs) return false
writeFileSync(patchPath, nextRaw, 'utf8')
return true
}
/**
* Insert or expand the Host `dsh-netxops` row in desktop/web `cordis.patch.yml`.
* @returns counts of profiles rewritten (sparse) vs freshly inserted (missing).
@ -232,8 +274,16 @@ export function ensureNetxopsHostInProfilePatches(logger: Context['logger']): Ho
if (nextLines === null || kind === null) continue
const normalized = nextLines.join(nl)
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`
if (withNl === raw) continue
writeFileSync(patchPath, withNl, 'utf8')
if (!writePatchIfUnchanged(patchPath, raw, withNl)) {
if (withNl !== raw) {
logger.warn(
'netxops: skipped heal write for profiles/%s/cordis.patch.yml '
+ '(file changed concurrently — Plugin Manager/HMR race)',
profileName,
)
}
continue
}
if (kind === 'insert') {
inserted += 1
logger.error(

View file

@ -8,10 +8,40 @@ export interface NetxConnection {
timeoutMs: number
/** Read the latest bearer on each request (do not freeze at client creation). */
getToken: () => string
/** Read the latest apiUrl on each request (settings may heal after first mount). */
getApiUrl?: () => string
}
export type NetxJson = Record<string, unknown>
export type NormalizeApiUrlResult =
| { ok: true, url: string }
| { ok: false, error: 'empty' | 'invalid_url' | 'unsupported_scheme' | 'credentials_in_url' }
/**
* Accept only bare http(s) API roots — no credentials-in-URL, no file/data/etc.
* Trailing slash is stripped; path prefixes (e.g. `/netx`) are kept.
*/
export function normalizeApiUrl(raw: string): NormalizeApiUrlResult {
const trimmed = typeof raw === 'string' ? raw.trim() : ''
if (!trimmed) return { ok: false, error: 'empty' }
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return { ok: false, error: 'invalid_url' }
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: 'unsupported_scheme' }
}
if (parsed.username || parsed.password) {
return { ok: false, error: 'credentials_in_url' }
}
const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, '')
const search = parsed.search && parsed.search !== '?' ? parsed.search : ''
return { ok: true, url: `${parsed.origin}${path}${search}` }
}
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
其他: 'Other',
时钟: 'Clock',
@ -19,9 +49,13 @@ const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
电源: 'Power',
}
function asLang(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
function localizePayload(lang: string, data: NetxJson): NetxJson {
if (!lang.trim().toLowerCase().startsWith('en')) return data
if (!asLang(lang).trim().toLowerCase().startsWith('en')) return data
const proto = data.protocol_summary
if (!Array.isArray(proto)) return data
for (const row of proto) {
@ -44,15 +78,13 @@ function encodeQuery(params: Record<string, string | number | boolean>): string
}
/**
* Build a client bound to apiUrl / lang / timeout; Bearer is resolved per request.
* @param connection - apiUrl / lang / timeout / getToken.
* Build a client bound to apiUrl / lang / timeout; Bearer + apiUrl are resolved per request.
* @param connection - apiUrl / lang / timeout / getToken / optional getApiUrl.
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
*/
export function createNetxClient(connection: NetxConnection) {
const base = connection.apiUrl.replace(/\/$/, '')
const langParams = (): Record<string, string> => {
const lang = connection.lang.trim().toLowerCase()
const lang = asLang(connection.lang).trim().toLowerCase()
if (lang.startsWith('en')) return { lang: 'en' }
return {}
}
@ -67,7 +99,20 @@ export function createNetxClient(connection: NetxConnection) {
signal?: AbortSignal
} = {},
): Promise<NetxJson> {
const token = connection.getToken().trim()
const rawUrl = typeof connection.getApiUrl === 'function'
? connection.getApiUrl()
: connection.apiUrl
const normalized = normalizeApiUrl(typeof rawUrl === 'string' ? rawUrl : '')
const base = normalized.ok ? normalized.url : ''
if (!normalized.ok || !base) {
return {
ok: false,
error: 'netx_api_url_invalid',
detail: `apiUrl must be http(s) without embedded credentials (${normalized.ok ? 'empty' : normalized.error}).`,
}
}
const rawToken = connection.getToken()
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
if (token.length === 0) {
return {
ok: false,

View file

@ -5,6 +5,7 @@
*/
import type { NetxJson } from './http.ts'
export type LosslessJsonValue = string | number | boolean | null | LosslessJsonValue[] | { [key: string]: LosslessJsonValue }
/**
* Detach a plain JSON-compatible value for DSH tool results.
@ -12,15 +13,15 @@ import type { NetxJson } from './http.ts'
* @param value - handler result (may contain undefined holes).
* @returns parsed plain JSON object, or a safe error envelope.
*/
export function toLosslessJson(value: unknown): NetxJson {
export function toLosslessJson(value: unknown): Record<string, LosslessJsonValue> {
try {
const text = JSON.stringify(value)
if (text === undefined) {
return { ok: false, error: 'tool_result_not_json', detail: 'undefined_root' }
}
const parsed: unknown = JSON.parse(text)
const parsed = JSON.parse(text) as LosslessJsonValue
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as NetxJson
return parsed
}
return { ok: true, data: { value: parsed } }
} catch (error) {

View file

@ -18,6 +18,7 @@ import {
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
import { getKbContext } from './kb-runtime.ts'
import { toLosslessJson } from './json-safe.ts'
import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
@ -33,7 +34,7 @@ function renderJson(_args: unknown, value: unknown) {
}
const jsonOut = {
schema: { type: 'json' as const },
schema: { type: 'object' as const, additionalProperties: true as const },
render: renderJson,
}
@ -72,6 +73,7 @@ function tool(
export function registerKbLocalTools(
ctx: Context,
snapshot: KbSnapshot = getKbContext(),
options: { plane?: 'host' | 'preset'; source?: string } = {},
): () => void {
if (!kbLocalToolsEnabled(snapshot)) return () => {}
@ -220,7 +222,19 @@ export function registerKbLocalTools(
),
]
const disposers = entries.map((entry) => toolsApi.register(entry))
const disposers: Array<() => void> = []
const source = options.source ?? 'netxops-kb-local'
const id = getToolMountId(ctx, source)
const status = { id, source, version: getToolMountStatus()?.version ?? '', plane: options.plane ?? 'preset',
groups: ['kb'], at: new Date().toISOString() }
try {
for (const entry of entries) disposers.push(toolsApi.register(entry))
publishToolMountStatus({ ...status, names: entries.map(entry => entry.name), count: entries.length })
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) })
throw error
}
ctx.logger.info(
'netxops: kb local tools registered count=%s localRoot=%s',
disposers.length,
@ -228,5 +242,6 @@ export function registerKbLocalTools(
)
return () => {
for (const dispose of disposers) dispose()
removeToolMountStatus(id)
}
}

View file

@ -200,7 +200,7 @@ function parseContent(raw: unknown): KbContentFlags {
const mapped = LEGACY_CONTENT_KEY[key]
if (mapped !== undefined) {
// Short keys only fill has* when the contract key is absent (no dual truth).
if (on && !present.has(mapped)) out[mapped] = true
if (on && !present.has(String(mapped))) out[mapped] = true
continue
}
out[key] = on

View file

@ -49,7 +49,7 @@ async function registerSkillsUnderRoot(
skillsApi: { register: SkillsRegister },
root: string,
provider: string,
options: { logMissing: boolean; label: string },
options: { logMissing: boolean; label: string; signal?: AbortSignal },
): Promise<Array<() => void>> {
let entries: string[]
try {
@ -63,40 +63,47 @@ async function registerSkillsUnderRoot(
if (entries.length === 0) return []
const disposers: Array<() => void> = []
for (const entry of entries) {
const full = join(root, entry)
let isDir = false
try {
isDir = (await stat(full)).isDirectory()
} catch {
continue
try {
for (const entry of entries) {
if (options.signal?.aborted) break
const full = join(root, entry)
let isDir = false
try {
isDir = (await stat(full)).isDirectory()
} catch {
continue
}
if (!isDir) continue
let skill
try {
skill = await loadSkillBundle(full)
} catch (error) {
ctx.logger.warn(
'netxops: kb %s skill load failed at %s: %s',
options.label,
full,
error instanceof Error ? error.message : String(error),
)
continue
}
if (!skill) {
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
continue
}
if (options.signal?.aborted) break
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider,
source: 'custom',
}))
}
if (!isDir) continue
let skill
try {
skill = await loadSkillBundle(full)
} catch (error) {
ctx.logger.warn(
'netxops: kb %s skill load failed at %s: %s',
options.label,
full,
error instanceof Error ? error.message : String(error),
)
continue
}
if (!skill) {
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
continue
}
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider,
source: 'custom',
}))
} catch (error) {
for (const dispose of disposers.splice(0)) dispose()
throw error
}
return disposers
}
@ -113,6 +120,7 @@ export async function registerKbPackSkills(
options: {
enabled: boolean
providerLabel?: string
signal?: AbortSignal
},
): Promise<() => void> {
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
@ -128,17 +136,27 @@ export async function registerKbPackSkills(
const disposers: Array<() => void> = []
const skillsRoot = resolveKbSkillsRoot(snapshot)
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
logMissing: true,
label: 'pack',
}))
const localRoot = resolveKbLocalSkillsRoot(snapshot)
if (localRoot) {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
logMissing: false,
label: 'local',
try {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
logMissing: true,
label: 'pack',
signal: options.signal,
}))
if (localRoot) {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
logMissing: false,
label: 'local',
signal: options.signal,
}))
}
} catch (error) {
for (const dispose of disposers.splice(0)) dispose()
throw error
}
if (options.signal?.aborted) {
for (const dispose of disposers.splice(0)) dispose()
}
if (disposers.length > 0) {

View file

@ -55,12 +55,15 @@ export function getKbContext(): KbSnapshot {
*/
export function publishKbContext(next: KbSnapshot): void {
const state = store()
if (JSON.stringify(state.snapshot) === JSON.stringify(next)) return
state.snapshot = {
...next,
content: { ...next.content },
paths: { ...next.paths },
}
for (const listener of state.listeners) listener()
for (const listener of [...state.listeners]) {
try { listener() } catch (error) { console.error('netxops: knowledge subscriber failed', error) }
}
}
/**

View file

@ -0,0 +1,65 @@
/** Publish selected capabilities into live session scopes without rewriting presets. */
import type { Context } from '@deepseek-ai/cordis'
import { createScope, scopeOf, type Scope } from '@deepseek-ai/dsh-scope'
import { groupsForPreset, kbEnabledForPlane } from './capability-groups.ts'
import { applyGroupToolsPlugin } from './group-tools-plugin.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
'agent-preset/selected': (sessionId: string, presetId: string) => void
}
}
interface LiveAgent { id: string; ctx: Context }
interface PresetRoster {
composedPreset(ctx: Context): string | undefined
list(): Promise<Array<{ id: string; name?: string; broken?: string }>>
}
/** Attach selected groups to existing and newly created sessions on either Host. */
export function installPresetPublication(ctx: Context): void {
ctx.inject(['agents', 'agentPresets', 'tools'], child => {
const agents = child.get('agents') as { list(): LiveAgent[] }
const presets = child.get('agentPresets') as PresetRoster
const mounts = new Map<string, { presetId: string; scope: Scope }>()
let active = true
const release = (id: string): void => {
const mount = mounts.get(id)
if (!mount) return
mounts.delete(id)
mount.scope.rawDispose()
}
const sync = (agent: LiveAgent): void => {
if (!active) return
const presetId = presets.composedPreset(agent.ctx)
const connection = getNetxConnection()
// Netx Ops owns its standing aggregate mount. Other targets use live scopes.
const enabled = connection && presetId && presetId !== 'netxops'
&& (groupsForPreset(connection, presetId).length > 0
|| kbEnabledForPlane(connection, 'preset', presetId))
const previous = mounts.get(agent.id)
if (!enabled || previous?.presetId !== presetId) release(agent.id)
if (!enabled || mounts.has(agent.id)) return
const key = scopeOf(agent.ctx)
if (!key) {
child.logger.warn('netxops: selected preset %s has no session scope', presetId)
return
}
// Register on the exact live scope; a sibling scope would be invisible to the agent.
const scope = createScope(child, key)
mounts.set(agent.id, { presetId, scope })
applyGroupToolsPlugin(scope.ctx, { name: `netxops-selected:${presetId}:${agent.id}`, mode: 'settings', presetId })
}
const syncAll = (): void => { for (const agent of agents.list()) sync(agent) }
child.on('agent/created', ({ agent }: { agent: LiveAgent }) => { sync(agent) })
child.on('agent/disposed', ({ agent }: { agent: LiveAgent }) => { release(agent.id) })
child.on('agent-preset/selected', () => { syncAll() })
const stop = watchNetxConnection(syncAll)
syncAll()
child.effect(() => () => {
active = false
stop()
for (const id of [...mounts.keys()]) release(id)
}, 'netxops: selected preset publication')
})
}

View file

@ -6,6 +6,8 @@
* even when Bun emits them as separate ESM files.
*/
import { DEFAULT_CAPABILITY_GROUPS } from './capability-groups.ts'
import { normalizeApiUrl } from './http.ts'
import type { NetxToolConnection } from './tools.ts'
type Listener = () => void
@ -33,8 +35,11 @@ function store(): Store {
*/
export function publishNetxConnection(next: NetxToolConnection): void {
const state = store()
if (JSON.stringify(state.connection) === JSON.stringify(next)) return
state.connection = next
for (const listener of state.listeners) listener()
for (const listener of [...state.listeners]) {
try { listener() } catch (error) { console.error('netxops: connection subscriber failed', error) }
}
}
/** @returns the last published connection, if any. */
@ -42,6 +47,33 @@ export function getNetxConnection(): NetxToolConnection | undefined {
return store().connection
}
/**
* Fallback used when the Host bridge has not published yet (credentials still
* pending, or host apply delayed). Publication stays disabled until the Host
* publishes its actual policy, so a detached preset cannot bypass revocation.
*/
export function provisionalNetxConnection(): NetxToolConnection {
const envUrl = process.env.NETX_API_URL?.trim() ?? ''
const normalized = envUrl ? normalizeApiUrl(envUrl) : normalizeApiUrl('http://127.0.0.1:8890')
return {
toolsEnabled: false,
apiUrl: normalized.ok ? normalized.url : 'http://127.0.0.1:8890',
token: process.env.NETX_API_TOKEN?.trim() ?? '',
lang: (typeof process.env.NETX_LANG === 'string' && process.env.NETX_LANG.trim()
? process.env.NETX_LANG.trim()
: 'zh'),
toolCallTimeoutMs: 120_000,
groups: DEFAULT_CAPABILITY_GROUPS,
groupKbInPreset: true,
groupKbPublic: false,
}
}
/** Live snapshot, or a disabled provisional connection until the Host is ready. */
export function requireNetxConnection(): NetxToolConnection {
return getNetxConnection() ?? provisionalNetxConnection()
}
/**
* Subscribe to connection publishes (settings / credential remounts).
* @param listener - called synchronously after each publish.

View file

@ -108,7 +108,7 @@ function resolveSessionStore(ctx: Context): SessionStoreFlushApi | undefined {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('sessions')
: undefined
const sessions = (fromGet ?? (ctx as { sessions?: SessionStoreFlushApi }).sessions) as
const sessions = (fromGet ?? (ctx as { sessions?: object }).sessions) as
| SessionStoreFlushApi
| undefined
if (!sessions || typeof sessions.get !== 'function' || typeof sessions.flush !== 'function') {

View file

@ -0,0 +1,63 @@
/** Process-wide mount inventory shared by independently bundled plugin entries. */
import { mkdirSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export interface NetxToolMountStatus {
id?: string
version: string
plane: string
groups: string[]
names: string[]
count: number
at: string
error?: string
source?: string
}
interface Store {
mounts: Map<string, NetxToolMountStatus>
contexts: WeakMap<object, number>
sequence: number
}
const KEY = Symbol.for('dsh-netxops.mount-inventory')
function store(): Store {
const root = globalThis as typeof globalThis & { [KEY]?: Store }
return root[KEY] ??= { mounts: new Map(), contexts: new WeakMap(), sequence: 0 }
}
/** Stable registration-owner id, shared across independently built exports. */
export function getToolMountId(ctx: object, source: string): string {
const state = store()
let id = state.contexts.get(ctx)
if (id === undefined) { id = ++state.sequence; state.contexts.set(ctx, id) }
return `${source}:${id}`
}
/** Copies of every mount; remounting one preset never replaces another. */
export function getToolMountStatuses(): NetxToolMountStatus[] {
return [...store().mounts.values()].map(row => ({ ...row, groups: [...row.groups], names: [...row.names] }))
}
/** Legacy singular view always prefers the Host mount. */
export function getToolMountStatus(): NetxToolMountStatus | undefined {
const rows = getToolMountStatuses()
return rows.find(row => row.plane === 'host') ?? rows.at(-1)
}
function persist(): void {
try {
const path = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'netxops-mount-status.json')
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)}\n`, 'utf8')
} catch {
// Diagnostics are best-effort on read-only installations.
}
}
export function publishToolMountStatus(next: NetxToolMountStatus): void {
store().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] })
persist()
}
export function removeToolMountStatus(id: string): void {
store().mounts.delete(id)
persist()
}
export function resetToolMountStatus(): void {
store().mounts.clear()
persist()
}

View file

@ -2,6 +2,9 @@
* Register native DSH tools (`netx__*`) that call netx REST.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import {
@ -13,10 +16,23 @@ import {
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { toLosslessJson } from './json-safe.ts'
import { getNetxConnection } from './runtime.ts'
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
function packageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
return typeof pkg.version === 'string' ? pkg.version : '0'
} catch {
return '0'
}
}
export interface NetxToolConnection {
toolsEnabled?: boolean
groupKbPresets?: string
apiUrl: string
token: string
lang: string
@ -33,6 +49,11 @@ export interface NetxToolConnection {
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
const MUTATING_TOOLS = new Set([
'netx__execManagedNe', 'netx__createTopologyFolder', 'netx__addTopologyViewNodes',
'netx__removeTopologyViewNodes', 'netx__copyTopologyViewNodes', 'netx__updateTopologyViewPositions',
'netx__projectTopologyNeighbors', 'netx__classifyTopologyFabricNodes', 'netx__layoutTopologyView',
])
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
@ -53,8 +74,15 @@ function renderJson(_args: unknown, value: unknown) {
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
}
/**
* Wire-compatible free-form object output.
* Prefer this over author-only `{ type: 'json' }`: Desktop Host ToolRuntime
* often comes from app.asar while `defineTool` resolves from the linked
* checkout — mismatched copies can reject `json` at register time, which
* leaves skills mounted and `netx__*` absent.
*/
const jsonOut = {
schema: { type: 'json' as const },
schema: { type: 'object' as const, additionalProperties: true as const },
render: renderJson,
}
@ -72,7 +100,7 @@ function tool(
parameters: parameters as never,
output: jsonOut,
timeoutMs,
isConcurrencySafe: () => true,
isConcurrencySafe: () => !MUTATING_TOOLS.has(name),
async execute(args, exec) {
const result = await handler(getClient(), args as NetxJson, exec.signal)
// DSH rejects tool results that contain `undefined` property holes
@ -89,13 +117,15 @@ function tool(
export interface RegisterNetxToolsOptions {
/**
* `preset` — groups with `inPreset` (Netx Ops preset scope).
* `public` — groups with `public` (host layer for other presets).
* `public` — groups with `public` only.
* `host` — same as `public` (Host-global registry for other presets).
*/
plane: 'preset' | 'public'
plane: 'preset' | 'public' | 'host'
/** Optional intersect with settings-enabled groups. */
only?: readonly NetxCapabilityGroupId[]
/** When set, register exactly these groups (ignore plane flags). */
forceGroups?: readonly NetxCapabilityGroupId[]
source?: string
}
/**
@ -111,19 +141,43 @@ export function registerNetxTools(
connection: NetxToolConnection,
options: RegisterNetxToolsOptions,
): () => void {
const enabledGroups = options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only)
const enabledGroups = connection.toolsEnabled === false ? [] : (options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only))
const id = getToolMountId(ctx, options.source ?? options.plane)
const allow = toolNamesForGroups(enabledGroups)
if (allow.size === 0) return () => {}
if (allow.size === 0) {
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names: [],
count: 0,
at: new Date().toISOString(),
source: options.source ?? options.plane,
})
return () => { removeToolMountStatus(id) }
}
// Guard: DSH defineTool throws if timeoutMs is null/NaN/≤0 (settings projection can emit null).
const t = typeof connection.toolCallTimeoutMs === 'number'
&& Number.isFinite(connection.toolCallTimeoutMs)
&& connection.toolCallTimeoutMs > 0
? connection.toolCallTimeoutMs
: 120_000
const client = createNetxClient({
apiUrl: connection.apiUrl,
lang: connection.lang,
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
getToken: () => getNetxConnection()?.token ?? '',
timeoutMs: Math.min(t, 45_000),
getToken: () => {
const live = getNetxConnection()
// A resolved empty token means revoked; never resurrect an ambient token.
return live ? live.token.trim() : connection.token.trim()
},
// Live apiUrl: settings may heal null→default after the first mount.
getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl,
})
const getClient = () => client
const t = connection.toolCallTimeoutMs
const catalog = [
tool(
@ -666,11 +720,39 @@ export function registerNetxTools(
),
]
const disposers = catalog
.filter((entry) => allow.has(entry.name))
.map((entry) => ctx.tools.register(entry))
const selected = catalog.filter((entry) => allow.has(entry.name))
const names = selected.map((entry) => entry.name)
const disposers: Array<() => void> = []
try {
for (const entry of selected) disposers.push(ctx.tools.register(entry))
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names,
count: names.length,
at: new Date().toISOString(),
source: options.source ?? options.plane,
})
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names: [],
count: 0,
at: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
source: options.source ?? options.plane,
})
throw error
}
return () => {
for (const dispose of disposers) dispose()
removeToolMountStatus(id)
}
}

View file

@ -10,6 +10,7 @@ import {
DEFAULT_CAPABILITY_GROUPS,
groupsForPlane,
groupsForced,
kbEnabledForPlane,
SKILL_DIR_BY_GROUP,
toolNamesForGroups,
TOOLS_BY_GROUP,
@ -23,6 +24,75 @@ test('defaults: ops + bizMonitor in preset; topology and public off', () => {
})
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(undefined, 'public'), [])
assert.deepEqual(groupsForPlane(undefined, 'host'), [])
})
test('host plane equals public only (not inPreset union)', () => {
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: false,
groupOpsPublic: true,
groupBizMonitorInPreset: false,
groupTopologyInPreset: true,
})
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
assert.deepEqual(groupsForPlane(groups, 'public'), ['ops'])
assert.deepEqual(groupsForPlane(groups, 'host'), ['ops'])
})
test('unchecking public revokes host even when inPreset stays on', () => {
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: true,
groupOpsPublic: false,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
})
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(groups, 'host'), [])
assert.deepEqual(groupsForPlane(groups, 'public'), [])
})
test('preset and host planes are symmetric; only defaults differ', () => {
const both = capabilityGroupsFromSettings({
groupOpsInPreset: true,
groupOpsPublic: true,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorPublic: true,
})
assert.deepEqual(groupsForPlane(both, 'preset'), ['ops'])
assert.deepEqual(groupsForPlane(both, 'host'), ['ops', 'bizMonitor'])
const neither = capabilityGroupsFromSettings({
groupOpsInPreset: false,
groupOpsPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorPublic: false,
})
assert.deepEqual(groupsForPlane(neither, 'preset'), [])
assert.deepEqual(groupsForPlane(neither, 'host'), [])
})
test('null boolean flags do not resurrect defaults incorrectly', () => {
// Settings projection may emit null for volatile booleans.
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: /** @type {any} */ (null),
groupOpsPublic: /** @type {any} */ (null),
groupBizMonitorInPreset: /** @type {any} */ (null),
groupBizMonitorPublic: /** @type {any} */ (null),
})
// null is not a boolean → fall back to defaults (ops+bizMonitor inPreset, public off)
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(groups, 'host'), [])
})
test('kbEnabledForPlane mirrors inPreset/public symmetry', () => {
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: false }, 'preset'), true)
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: false }, 'host'), false)
assert.equal(kbEnabledForPlane({ groupKbInPreset: false, groupKbPublic: true }, 'preset'), false)
assert.equal(kbEnabledForPlane({ groupKbInPreset: false, groupKbPublic: true }, 'host'), true)
assert.equal(kbEnabledForPlane({}, 'preset'), true)
assert.equal(kbEnabledForPlane({}, 'host'), false)
})
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
@ -38,7 +108,7 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas;
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'))
assert.equal(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'), false)
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
@ -55,12 +125,12 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'))
assert.equal(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
})
test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => {
test('legacy groupNms/common/managedNe map to ops; absent topology flags use legacy layout', () => {
const groups = capabilityGroupsFromSettings({
groupNmsInPreset: false,
groupCommonInPreset: false,
@ -81,7 +151,7 @@ test('legacy any-true turns ops inPreset on', () => {
assert.equal(groups.ops.inPreset, true)
})
test('groupsForced ignores settings for per-export mounts', () => {
test('groupsForced normalizes group allow-lists independently from publication', () => {
assert.deepEqual(groupsForced(['ops']), ['ops'])
assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology'])
assert.deepEqual(groupsForced(['bizMonitor']), ['bizMonitor'])

View file

@ -0,0 +1,107 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { runInNewContext } from 'node:vm'
import { Context, Service } from '@deepseek-ai/cordis'
const require = createRequire(import.meta.url)
// The shell seeds React from its own dependency tree, not the plugin's local
// development junction. Use that same installed peer tree for this built smoke.
const profileHome = process.env.DSH_HOME || join(homedir(), '.dsh')
const renderer = ['', 'desktop', 'web'].map(profile => join(profileHome, 'profiles', profile, 'node_modules',
'@deepseek-ai', 'dsh-client-ui-renderer', 'package.json')).find(existsSync)
const sharedRequire = renderer ? createRequire(renderer) : require
function loadClient() {
let client
runInNewContext(readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8'), {
window: { __ModuleLoader__: { load({ factory }) { client = factory(spec =>
spec === 'react' || spec.startsWith('react/') ? sharedRequire(spec) : require(spec)) } } },
setTimeout, clearTimeout, setInterval, clearInterval, console, AbortController,
})
return client
}
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
function hostForm() {
let snapshot = { status: 'ready', writable: true, value: { toolsEnabled: true, groupOpsPresets: 'standard', apiUrl: 'http://127.0.0.1:8890' },
user: {}, base: {}, revision: 1 }
const listeners = new Set(), writes = []
return {
writes, getSnapshot: () => snapshot,
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
async set(field, value) {
writes.push([field, value])
snapshot = { ...snapshot, value: { ...snapshot.value, [field]: value }, user: { ...snapshot.user, [field]: value }, revision: snapshot.revision + 1 }
for (const fn of listeners) fn()
return true
},
async unset(field) {
const user = { ...snapshot.user }; delete user[field]
snapshot = { ...snapshot, user, revision: snapshot.revision + 1 }
for (const fn of listeners) fn()
return true
},
}
}
async function fixture(t, legacy = false) {
const root = new Context(), owners = [], slots = new Map(), actual = hostForm()
class Slots extends Service {
constructor(ctx) { super(ctx, 'slots') }
inject(name, register) { return register() }
register(spec) {
const key = `${spec.name}:${spec.id ?? spec.key}`
slots.set(key, spec)
return () => slots.delete(key)
}
}
class Locale extends Service {
constructor(ctx) { super(ctx, 'locale') }
register() { return () => {} }
bind() { return key => key }
}
class Forms extends Service {
constructor(ctx) { super(ctx, legacy ? 'settingsScope' : 'configForms') }
get(id) { return id === 'netxops' ? actual : { getSnapshot: () => ({ status: 'unavailable', writable: false }) } }
bind({ namespace }) { return this.get(namespace) }
whileServed(ids, register) { return register(new Set(['netxops'])) }
}
t.after(async () => { for (const owner of owners.reverse()) await owner.dispose() })
for (const Provider of [Slots, Locale, Forms]) owners.push(await root.plugin(Provider))
const client = loadClient()
const owner = await root.plugin({ ...client, name: 'netxops-client-binding' })
owners.push(owner)
return { root, owner, slots, actual }
}
test('built settings card uses optional lookup in real Cordis and saves through the Host form', async t => {
const { owner, slots, actual } = await fixture(t)
assert.equal(owner.state, 2 /* ACTIVE */)
assert.throws(() => owner.ctx.configForms, /without inject/)
const section = slots.get('settings.section:dsh-netxops')
assert.ok(section)
const face = section.inject()
assert.equal(face.hooks.netxopsCard.getSnapshot().available, false)
await pause(80)
assert.equal(face.hooks.netxopsCard.getSnapshot().available, true)
assert.equal(face.hooks.netxopsCard.getSnapshot().writable, true)
assert.equal(face.hooks.netxopsCard.getSnapshot().groupOpsPresets.text, 'standard')
face.edit('toolsEnabled', 'false')
await pause(20)
assert.deepEqual(actual.writes, [['toolsEnabled', false]])
assert.equal(face.hooks.netxopsCard.getSnapshot().toolsEnabled.text, 'false')
assert.ok(slots.has('settings.plugin.item:netxops'))
})
test('built settings card still follows the legacy Host settings scope', async t => {
const { slots, actual } = await fixture(t, true)
const face = slots.get('settings.section:dsh-netxops').inject()
await pause(80)
assert.equal(face.hooks.netxopsCard.getSnapshot().available, true)
face.edit('groupOpsPresets', '')
await pause(20)
assert.deepEqual(actual.writes, [['groupOpsPresets', '']])
})

View file

@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createWaitingSettingsScope, findReadySettingsForm } from '../src/client/settings-form.ts'
import { CardForm, booleanFieldPersistFalse } from '../src/client/card-form.ts'
test('unconnected settings cannot report a successful local-only publication save', async () => {
const scope = createWaitingSettingsScope({ toolsEnabled: true })
const form = new CardForm(scope, [booleanFieldPersistFalse('toolsEnabled')])
assert.equal(form.shell().available, false)
assert.equal(form.shell().writable, false)
form.actions().edit('toolsEnabled', 'false')
await form.save(['toolsEnabled'])
assert.equal(scope.getSnapshot().value.toolsEnabled, true)
assert.equal(form.shell().failed, true)
assert.equal(form.shell().dirty, true)
await assert.rejects(scope.set('toolsEnabled', false), /settings_transport_unavailable/)
})
test('form selection ignores unknown namespace placeholders and waits for the real namespace', () => {
let ready = false
const actual = { getSnapshot: () => ({ status: ready ? 'ready' : 'loading', writable: ready }) }
const placeholder = { getSnapshot: () => ({ status: 'unavailable', writable: false }) }
const forms = { get(id) { return id === 'netxops' ? actual : placeholder } }
assert.equal(findReadySettingsForm(forms, ['client-entry-id', 'netxops', 'dsh-netxops']), undefined)
ready = true
assert.deepEqual(findReadySettingsForm(forms, ['client-entry-id', 'netxops', 'dsh-netxops']), { ns: 'netxops', scope: actual })
})

View file

@ -0,0 +1,103 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { composeNetxopsPresetArtifacts, hostResolveBases, installDirectoryPreset, resolveActiveProfile, resolveStandardPresetDir, syncDeclarativeIntoProfilePatch } from '../src/agent-preset-install.ts'
const log = { info() {}, warn() {}, error() {} }
const patch = '- insert:\n - id: preset-netxops\n name: preset-plugin\n'
function home(t) {
const previous = process.env.DSH_HOME
const root = mkdtempSync(join(tmpdir(), 'netxops-install-'))
process.env.DSH_HOME = root
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
return root
}
const artifacts = { standardSource: 'fixture', standardKind: 'desktop-patch', standardDir: null,
agentCordis: '- id: netxops-tools\n name: dsh-netxops/tools\n', name: 'Netx Ops', description: '', order: '50', declarativePatch: patch }
test('updating Desktop patch leaves Web patch intact', t => {
const root = home(t)
for (const profile of ['desktop', 'web']) {
mkdirSync(join(root, 'profiles', profile), { recursive: true })
writeFileSync(join(root, 'profiles', profile, 'cordis.patch.yml'), '# user configuration\n[]\n')
}
const web = join(root, 'profiles', 'web', 'cordis.patch.yml'), original = readFileSync(web, 'utf8')
syncDeclarativeIntoProfilePatch('desktop', patch, log)
assert.equal(readFileSync(web, 'utf8'), original)
assert.match(readFileSync(join(root, 'profiles', 'desktop', 'cordis.patch.yml'), 'utf8'), /preset-netxops/)
})
test('unterminated managed block does not truncate user configuration', t => {
const root = home(t), folder = join(root, 'profiles', 'web')
mkdirSync(folder, { recursive: true })
const path = join(folder, 'cordis.patch.yml')
const original = '# BEGIN dsh-netxops-preset (managed)\n- id: preserve-me\n'
writeFileSync(path, original)
assert.throws(() => syncDeclarativeIntoProfilePatch('web', patch, log), /unterminated/)
assert.equal(readFileSync(path, 'utf8'), original)
})
test('unmanaged preset declaration remains authoritative', t => {
const root = home(t), folder = join(root, 'profiles', 'web')
mkdirSync(folder, { recursive: true })
const path = join(folder, 'cordis.patch.yml'), original = '- insert:\n - id: preset-netxops\n name: custom-preset\n'
writeFileSync(path, original)
syncDeclarativeIntoProfilePatch('web', patch, log)
assert.equal(readFileSync(path, 'utf8'), original)
})
test('managed directory update preserves edits and extra files', t => {
const root = home(t), folder = join(root, '.agent-presets', 'netxops')
installDirectoryPreset(artifacts, log)
writeFileSync(join(folder, 'notes.md'), 'user notes')
writeFileSync(join(folder, 'agent.cordis.yml'), '- id: user-edited\n')
installDirectoryPreset({ ...artifacts, agentCordis: '- id: updated\n' }, log)
assert.equal(readFileSync(join(folder, 'notes.md'), 'utf8'), 'user notes')
assert.equal(readFileSync(join(folder, 'agent.cordis.yml'), 'utf8'), '- id: user-edited\n')
assert.equal(existsSync(join(folder, 'skills')), false, 'runtime-controlled skills are not copied into auto-scanned roots')
})
test('profile identity follows the running profile path', () => {
const previous = process.env.DSH_PROFILE
delete process.env.DSH_PROFILE
try {
assert.equal(resolveActiveProfile('file:///C:/dsh/profiles/desktop/cordis.yml'), 'desktop')
assert.equal(resolveActiveProfile('C:\\dsh\\profiles\\web\\cordis.yml'), 'web')
} finally { if (previous !== undefined) process.env.DSH_PROFILE = previous }
})
test('fresh install overlays the seeded standard composition', t => {
const root = home(t), standard = join(root, 'standard')
mkdirSync(standard)
writeFileSync(join(standard, 'agent.cordis.yml'), '- id: standard-only\n')
installDirectoryPreset({ ...artifacts, standardDir: standard }, log)
assert.equal(readFileSync(join(root, '.agent-presets', 'netxops', 'agent.cordis.yml'), 'utf8'), artifacts.agentCordis)
})
test('each Host resolves its own standard before shared dependencies', t => {
const root = home(t)
for (const profile of ['desktop', 'web']) {
const folder = join(root, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard')
mkdirSync(folder, { recursive: true })
writeFileSync(join(folder, 'agent.cordis.yml'), `- id: ${profile}\n`)
writeFileSync(join(root, 'profiles', profile, 'package.json'), '{}')
assert.equal(resolveStandardPresetDir(profile), folder)
const other = profile === 'desktop' ? 'web' : 'desktop'
assert.equal(hostResolveBases(profile).some(path => path.startsWith(join(root, 'profiles', other))), false)
}
})
test('runtime composition preserves the Host standard dialect and workflow engine', t => {
home(t)
const original = "- id: tool-fs\n name: '@host-only/fs'\n disabled: !!js process.platform === 'win32'\n- id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n"
const composed = composeNetxopsPresetArtifacts('web', original)
assert.equal(composed.standardKind, 'host-registry')
assert.match(composed.agentCordis, /@host-only\/fs/)
assert.match(composed.agentCordis, /disabled: !!js process.platform === 'win32'/)
assert.match(composed.agentCordis, /workflow-worker-thread/)
assert.doesNotMatch(composed.agentCordis, /workflow-ptc/)
assert.match(composed.agentCordis, /name: dsh-netxops\/tools/)
assert.match(composed.agentCordis, /- id: persona/)
})

View file

@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { CardForm, booleanFieldPersistFalse, textField, presetIdsField } from '../src/client/card-form.ts'
function formScope(options = {}) {
const value = { groupOpsPublic: false, groupOpsPresets: 'standard', apiUrl: 'http://saved' }
const listeners = new Set(), writes = []
const scope = { getSnapshot: () => ({ status: 'ready', writable: true, value, base: { ...value }, user: { ...value } }),
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
async set(field, next) {
writes.push([field, next]); await options.beforeWrite?.()
if (options.fail) throw new Error('save failed')
value[field] = next; for (const fn of listeners) fn()
}, async unset(field) { delete value[field] } }
return { scope, value, writes }
}
const specs = [booleanFieldPersistFalse('groupOpsPublic'), textField('apiUrl'), presetIdsField('groupOpsPresets')]
test('publication autosave does not commit unrelated drafts', async () => {
const { scope, value, writes } = formScope()
const form = new CardForm(scope, specs), actions = form.actions()
actions.edit('apiUrl', 'http://draft')
actions.edit('groupOpsPublic', 'true')
await form.save(['groupOpsPublic'])
assert.equal(value.apiUrl, 'http://saved')
assert.equal(form.field('apiUrl').text, 'http://draft')
assert.deepEqual(writes, [['groupOpsPublic', true]])
assert.equal(form.shell().dirty, true)
})
test('rapid on then off saves the latest edit without losing it', async () => {
let resolveWrite, started
const ready = new Promise(resolve => { started = resolve })
const gate = new Promise(resolve => { resolveWrite = resolve })
let first = true
const { scope, value, writes } = formScope({ beforeWrite: async () => { if (first) { first = false; started(); await gate } } })
const form = new CardForm(scope, specs), actions = form.actions()
actions.edit('groupOpsPublic', 'true')
const one = form.save(['groupOpsPublic'])
await ready
actions.edit('groupOpsPublic', 'false')
const two = form.save(['groupOpsPublic'])
resolveWrite()
await Promise.all([one, two])
assert.deepEqual(writes, [['groupOpsPublic', true], ['groupOpsPublic', false]])
assert.equal(value.groupOpsPublic, false)
assert.equal(form.shell().dirty, false)
})
test('write failure releases saving state and retains draft for retry', async () => {
const options = { fail: true }, { scope, value } = formScope(options)
const form = new CardForm(scope, specs)
form.actions().edit('groupOpsPublic', 'true')
await form.save(['groupOpsPublic'])
assert.equal(form.shell().saving, false)
assert.equal(form.shell().failed, true)
assert.equal(form.shell().dirty, true)
options.fail = false
await form.save(['groupOpsPublic'])
assert.equal(value.groupOpsPublic, true)
assert.equal(form.shell().failed, false)
})
test('empty target selection persists an explicit override', async () => {
const { scope, value, writes } = formScope()
const form = new CardForm(scope, specs)
form.actions().edit('groupOpsPresets', '')
await form.save(['groupOpsPresets'])
assert.equal(value.groupOpsPresets, '')
assert.deepEqual(writes, [['groupOpsPresets', '']])
})

View file

@ -0,0 +1,157 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { capabilityGroupsFromSettings, groupsForPreset, kbEnabledForPlane } from '../src/netx/capability-groups.ts'
import { applyGroupToolsPlugin } from '../src/netx/group-tools-plugin.ts'
import { registerNetxTools } from '../src/netx/tools.ts'
import { registerKbLocalTools } from '../src/netx/kb-local-tools.ts'
import { registerGroupSkills } from '../src/netx/group-skills.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { getToolMountStatuses, publishToolMountStatus, resetToolMountStatus } from '../src/netx/tool-mount-status.ts'
import { asToolsMountView } from '../src/client/tools-status-view.ts'
const log = { info() {}, warn() {}, error() {} }
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
function connection(fields = {}) {
return { apiUrl: 'http://127.0.0.1:8890', token: 'test-token', lang: 'zh', toolCallTimeoutMs: 1000,
toolsEnabled: fields.toolsEnabled ?? true, groups: capabilityGroupsFromSettings(fields),
groupKbInPreset: fields.groupKbInPreset ?? true, groupKbPublic: fields.groupKbPublic ?? false,
groupKbPresets: fields.groupKbPresets ?? '' }
}
function fakeContext(failAt = -1) {
const tools = new Map(), skills = new Map(), cleanups = []
let registrations = 0
const register = map => entry => {
if (map === tools && ++registrations === failAt) throw new Error('registration refused')
if (map.has(entry.name)) throw new Error(`duplicate: ${entry.name}`)
map.set(entry.name, entry)
return () => { if (map.get(entry.name) === entry) map.delete(entry.name) }
}
const ctx = { logger: log, tools: { register: register(tools) }, skills: { register: register(skills) },
get(name) { return this[name] }, inject(deps, fn) { fn(this) },
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup); return cleanup } }
return { ctx, tools, skills, dispose() { for (const fn of cleanups.reverse()) fn() } }
}
function diagnosticHome(t) {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-publication-'))
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
}
test('selected presets are isolated and global publication is inherited once', () => {
const selected = connection({ groupOpsInPreset: false, groupOpsPresets: 'standard,review standard', groupBizMonitorInPreset: false })
assert.deepEqual(groupsForPreset(selected, 'standard'), ['ops'])
assert.deepEqual(groupsForPreset(selected, 'review'), ['ops'])
assert.deepEqual(groupsForPreset(selected, 'netxops'), [])
assert.deepEqual(groupsForPreset(selected, 'other'), [])
selected.groups.ops.public = true
assert.deepEqual(groupsForPreset(selected, 'standard'), [], 'public tools come from Host, with no duplicate local registration')
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: true }, 'preset'), false)
})
test('master switch blocks selected, public, preset and explicit group mounts', async t => {
diagnosticHome(t)
const disabled = connection({ toolsEnabled: false, groupOpsPublic: true, groupOpsPresets: 'standard', groupKbPublic: true })
assert.deepEqual(groupsForPreset(disabled, 'standard'), [])
assert.equal(kbEnabledForPlane(disabled, 'host'), false)
const { ctx, tools } = fakeContext()
const dispose = registerNetxTools(ctx, disabled, { plane: 'host', forceGroups: ['ops'] })
assert.equal(tools.size, 0)
dispose()
})
test('failed tool registration rolls back every earlier tool', t => {
diagnosticHome(t)
const { ctx, tools } = fakeContext(3)
assert.throws(() => registerNetxTools(ctx, connection(), { plane: 'preset' }), /registration refused/)
assert.equal(tools.size, 0)
assert.ok(getToolMountStatuses().some(row => row.error === 'registration refused'))
resetToolMountStatus()
})
test('disabled group exports cannot force publication back on', async t => {
diagnosticHome(t)
publishNetxConnection(connection({ groupOpsInPreset: false, groupBizMonitorInPreset: false, groupKbInPreset: false }))
const mount = fakeContext()
applyGroupToolsPlugin(mount.ctx, { name: 'explicit-ops', mode: 'forced', only: ['ops'] })
await pause()
assert.equal(mount.tools.size, 0)
assert.equal(mount.skills.size, 0)
mount.dispose()
})
test('turning off a mounted group revokes tools, KB context and pending skill loads', async t => {
diagnosticHome(t)
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
const mount = fakeContext()
applyGroupToolsPlugin(mount.ctx, { name: 'lifecycle', mode: 'settings' })
assert.equal(mount.tools.size, 15)
assert.equal(mount.skills.has('kb-context'), true)
publishNetxConnection(connection({ toolsEnabled: false }))
await pause()
assert.equal(mount.tools.size, 0)
assert.equal(mount.skills.size, 0)
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
await pause()
assert.equal(mount.tools.size, 15)
assert.equal(mount.skills.has('netx-ops'), true)
mount.dispose()
await pause()
assert.equal(mount.tools.size, 0)
assert.equal(mount.skills.size, 0)
})
test('aborted skill load contributes nothing after asynchronous reads', async () => {
const mount = fakeContext()
const abort = new AbortController()
const pending = registerGroupSkills(mount.ctx, ['ops', 'topology'], 'aborted', abort.signal)
abort.abort()
const dispose = await pending
assert.equal(mount.skills.size, 0)
dispose()
})
test('mount inventory survives cross-bundle publishes and reports all scopes', async t => {
diagnosticHome(t)
resetToolMountStatus()
publishToolMountStatus({ id: 'host-test', version: 'test', plane: 'host', source: 'host', groups: [], names: [], count: 0, at: '' })
const entry = await import('../lib/agent-tools.js')
publishNetxConnection(connection({ groupBizMonitorInPreset: false, groupKbInPreset: false }))
const mount = fakeContext()
entry.apply(mount.ctx)
const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.id === 'host-test'))
assert.ok(statuses.some(row => row.count === 15))
const view = asToolsMountView({ mounts: statuses, connection: { toolsEnabled: true } })
assert.equal(view.count, 15)
mount.dispose()
assert.equal(getToolMountStatuses().some(row => row.count > 0), false)
resetToolMountStatus()
})
test('write tools serialize while read tools allow concurrency', t => {
diagnosticHome(t)
const mount = fakeContext()
const dispose = registerNetxTools(mount.ctx, connection(), { plane: 'preset', forceGroups: ['ops', 'topology'] })
assert.equal(mount.tools.get('netx__queryNmsAlarms').isConcurrencySafe({}), true)
assert.equal(mount.tools.get('netx__execManagedNe').isConcurrencySafe({}), false)
assert.equal(mount.tools.get('netx__updateTopologyViewPositions').isConcurrencySafe({}), false)
dispose()
})
test('knowledge-base tools contribute to the mount inventory and disappear on disposal', t => {
diagnosticHome(t)
resetToolMountStatus()
const mount = fakeContext()
const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } }
const dispose = registerKbLocalTools(mount.ctx, snapshot, { plane: 'preset', source: 'kb-test' })
assert.equal(mount.tools.size, 7)
const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 7 && row.groups.includes('kb')))
assert.equal(asToolsMountView({ mounts: statuses }).count, 7)
dispose()
assert.equal(mount.tools.size, 0)
assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false)
})

View file

@ -0,0 +1,103 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { Context, Service } from '@deepseek-ai/cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installPresetPublication } from '../src/netx/preset-publication.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { capabilityGroupsFromSettings } from '../src/netx/capability-groups.ts'
const layers = new Map()
const skillLayers = new Map()
let live = []
class Tools extends Service {
constructor(ctx) { super(ctx, 'tools') }
register(entry) {
const key = scopeOf(this.ctx)
let layer = layers.get(key)
if (!layer) layers.set(key, layer = new Map())
if (layer.has(entry.name)) throw new Error(`duplicate tool ${entry.name}`)
layer.set(entry.name, entry)
return () => layer.delete(entry.name)
}
}
class Skills extends Service {
constructor(ctx) { super(ctx, 'skills') }
register(entry) {
const key = scopeOf(this.ctx)
let layer = skillLayers.get(key)
if (!layer) skillLayers.set(key, layer = new Map())
if (layer.has(entry.name)) throw new Error(`duplicate skill ${entry.name}`)
layer.set(entry.name, entry)
return () => layer.delete(entry.name)
}
}
class Agents extends Service {
constructor(ctx) { super(ctx, 'agents') }
list() { return live }
}
class Presets extends Service {
constructor(ctx) { super(ctx, 'agentPresets') }
composedPreset(ctx) { return ctx.presetId }
}
const wait = () => new Promise(resolve => setTimeout(resolve, 50))
test('selected publication uses each exact session scope and cleans up on off, switch, and unload', async t => {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-selected-'))
const ctx = new Context()
const owners = []
t.after(async () => {
for (const owner of owners.reverse()) await owner.dispose()
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
layers.clear(); skillLayers.clear(); live = []
})
owners.push(await ctx.plugin(Tools))
owners.push(await ctx.plugin(Skills))
owners.push(await ctx.plugin(Agents))
owners.push(await ctx.plugin(Presets))
const scopes = []
owners.push(await ctx.plugin(Object.assign(inner => {
for (const id of ['standard', 'review']) {
const key = {}
const scope = createScope(inner, key)
const agent = { id, ctx: scope.ctx.extend({ presetId: id }) }
scopes.push({ key, scope, agent })
}
}, { inject: ['tools', 'skills'] })))
live = scopes.map(row => row.agent)
const publish = (overrides = {}) => publishNetxConnection({
apiUrl: 'http://127.0.0.1:8890', token: '', lang: 'zh', toolCallTimeoutMs: 1000,
toolsEnabled: true,
groups: capabilityGroupsFromSettings({ groupOpsPresets: 'standard', groupBizMonitorInPreset: false }),
groupKbInPreset: false, groupKbPresets: '', ...overrides,
})
publish()
const owner = await ctx.plugin(installPresetPublication)
owners.push(owner)
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
assert.equal(skillLayers.get(scopes[0].key)?.has('netx-ops'), true)
assert.equal(layers.get(scopes[1].key)?.size ?? 0, 0)
publish({ toolsEnabled: false })
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 0)
assert.equal(skillLayers.get(scopes[0].key)?.size, 0)
publish()
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
scopes[0].agent.ctx = scopes[0].agent.ctx.extend({ presetId: 'review' })
await ctx.serial('agent-preset/selected', 'standard', 'review')
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 0)
scopes[1].agent.ctx = scopes[1].agent.ctx.extend({ presetId: 'standard' })
await ctx.serial('agent/created', { agent: scopes[1].agent })
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 15)
await owner.dispose()
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 0)
assert.equal(skillLayers.get(scopes[1].key)?.size, 0)
})

View file

@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { apiUrlField } from '../src/client/card-form.ts'
test('apiUrlField accepts http(s) and normalizes trailing slash', () => {
const field = apiUrlField('apiUrl')
assert.deepEqual(field.parse('http://127.0.0.1:8890/'), {
kind: 'set',
value: 'http://127.0.0.1:8890',
})
assert.deepEqual(field.parse(''), { kind: 'clear' })
})
test('apiUrlField rejects unsafe URLs', () => {
const field = apiUrlField('apiUrl')
assert.equal(field.parse('file:///tmp'), undefined)
assert.equal(field.parse('http://user:pass@h/'), undefined)
assert.equal(field.parse('not-a-url'), undefined)
})

View file

@ -0,0 +1,112 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile, stat } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (path) => readFile(resolve(root, path), 'utf8')
test('package declares Desktop 0.2 client contract', async () => {
const pkg = JSON.parse(await read('package.json'))
assert.equal(pkg.version, '0.1.69')
assert.equal(pkg.engines.node, '>=20')
assert.equal(pkg.dsh.client.immediately, false)
assert.deepEqual(pkg.dsh.client.inject, [
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-locale',
])
assert.ok(!pkg.dsh.client.inject.includes('@deepseek-ai/dsh-client-runtime'))
assert.equal(pkg.exports['./cordis.patch.yml'], './cordis.bundle.patch.yml')
assert.ok(!pkg.files.includes('lib/agent-tools-shared.js'))
for (const name of [
'@deepseek-ai/cordis',
'@deepseek-ai/dsh-credentials',
'@deepseek-ai/dsh-settings',
'@deepseek-ai/dsh-tools',
'@deepseek-ai/schemastery',
]) {
assert.equal(pkg.peerDependencies[name], '*', `${name} should be open peer *`)
}
})
test('agent-tools entries are self-contained (no shared chunk)', async () => {
for (const name of [
'agent-tools.js',
'agent-tools-ops.js',
'agent-tools-nms.js',
'agent-tools-common.js',
'agent-tools-topology.js',
'agent-tools-biz-monitor.js',
]) {
const entry = await read(`lib/${name}`)
assert.doesNotMatch(entry, /agent-tools-shared/)
assert.match(entry, /applyGroupToolsPlugin|function apply/)
const info = await stat(resolve(root, `lib/${name}`))
assert.ok(info.size > 20_000, `${name} should be a full bundle, got ${info.size}`)
}
})
test('browser half uses deferred configForms and distinct section id', async () => {
const client = await read('lib/client.js')
assert.match(client, /name:\s*["']settings\.section["']/)
assert.match(client, /NETXOPS_SECTION_ID\s*=\s*["']dsh-netxops["']/)
assert.match(client, /NETXOPS_NS\s*=\s*["']netxops["']/)
assert.match(client, /id:\s*NETXOPS_SECTION_ID/)
assert.match(client, /configForms poll/)
assert.match(client, /createDeferredScope/)
assert.match(client, /tools\.status|TOOLS_STATUS_ENDPOINT|toolsStatus/)
assert.doesNotMatch(client, /inject\(\[["']configForms["']\]/)
assert.doesNotMatch(client, /inject\(\[["']settingsScope["']\]/)
assert.match(client, /name:\s*["']settings\.plugin\.item["']/)
assert.match(client, /key:\s*id/)
assert.doesNotMatch(client, /name:\s*["']plugins\.item["']/)
assert.doesNotMatch(client, /dsh-client-runtime/)
assert.doesNotMatch(client, /mcpServers|python -m netx_mcp/)
})
test('host Config marks scalar fields volatile for 0.2 projection', async () => {
const host = await read('lib/index.js')
assert.match(host, /function vol\(/)
assert.match(host, /\.volatile\s*\(/)
assert.match(host, /NETXOPS_SETTINGS_NAMESPACE\s*=\s*["']netxops["']/)
assert.match(host, /document-updated/)
assert.match(host, /normalizeApiUrl/)
assert.match(host, /mergeHostConfigForHeal/)
assert.match(host, /writePatchIfUnchanged/)
assert.match(host, /groupOpsPublic:\s*vol\(z\.boolean\(\)\.default\(false\)\)/)
assert.doesNotMatch(host, /inject\(\[["']settingsScope["']\]/)
})
test('client validates apiUrl and defaults ops public off', async () => {
const client = await read('lib/client.js')
assert.match(client, /apiUrlField\s*\(/)
assert.match(client, /apiUrlInvalid/)
assert.match(client, /groupOpsPublic:\s*false/)
assert.match(client, /booleanFieldPersistFalse\s*\(\s*["']groupOpsPublic["']\s*\)/)
})
test('tools plugin subscribes before first remount (connection race)', async () => {
const tools = await read('lib/agent-tools.js')
const watchIdx = tools.indexOf('watchNetxConnection')
const remountIdx = tools.indexOf('remountTools()')
assert.ok(watchIdx >= 0 && remountIdx >= 0)
assert.ok(
watchIdx < remountIdx,
'watchNetxConnection must be registered before the first remountTools()',
)
assert.match(tools, /setInterval/)
assert.match(tools, /requireNetxConnection|provisionalNetxConnection/)
})
test('host hard-injects tools; soft credentials + provisional publish', async () => {
const host = await read('lib/index.js')
assert.match(host, /inject\s*=\s*\[\s*["']tools["']\s*\]/)
assert.match(host, /provisional connection published/)
assert.match(host, /plane:\s*["']host["']/)
assert.match(host, /tools\.status/)
assert.match(host, /netxops-mount-status\.json/)
assert.match(host, /additionalProperties:\s*!0|additionalProperties:\s*true/)
assert.match(host, /inject\(\[["']credentials["']\]/)
assert.doesNotMatch(host, /export const inject = \[["']credentials["']\]/)
})

View file

@ -0,0 +1,61 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
getNetxConnection,
publishNetxConnection,
watchNetxConnection,
} from '../src/netx/runtime.ts'
import { DEFAULT_CAPABILITY_GROUPS } from '../src/netx/capability-groups.ts'
function clearConnection() {
publishNetxConnection({
apiUrl: '',
token: '',
lang: 'zh',
toolCallTimeoutMs: 1000,
groups: DEFAULT_CAPABILITY_GROUPS,
groupKbInPreset: false,
groupKbPublic: false,
})
// runtime has no reset — overwrite with a sentinel then ignore
}
test('watch-then-remount does not miss an in-flight publish', () => {
let mounts = 0
const remount = () => {
if (getNetxConnection()?.apiUrl) mounts += 1
}
// Bad order (pre-0.1.58): remount → publish → watch → miss
mounts = 0
remount()
publishNetxConnection({
apiUrl: 'http://127.0.0.1:8890',
token: 't',
lang: 'zh',
toolCallTimeoutMs: 1000,
groups: DEFAULT_CAPABILITY_GROUPS,
groupKbInPreset: true,
groupKbPublic: false,
})
const stopBad = watchNetxConnection(remount)
assert.equal(mounts, 0, 'bad order misses the publish')
stopBad()
// Good order: watch → publish → remount (and watch also fires)
mounts = 0
clearConnection()
const stopGood = watchNetxConnection(remount)
publishNetxConnection({
apiUrl: 'http://127.0.0.1:8890',
token: 't',
lang: 'zh',
toolCallTimeoutMs: 1000,
groups: DEFAULT_CAPABILITY_GROUPS,
groupKbInPreset: true,
groupKbPublic: false,
})
remount()
assert.ok(mounts >= 1, 'good order sees connection')
stopGood()
})

56
tests/heal-merge.test.mjs Normal file
View file

@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
mergeHostConfigForHeal,
NETXOPS_HOST_CONFIG_DEFAULTS,
writePatchIfUnchanged,
} from '../src/netx/heal-profile-entry.ts'
test('mergeHostConfigForHeal fills missing defaults only', () => {
const merged = mergeHostConfigForHeal({
apiUrl: 'http://custom:8890',
groupOpsPublic: false,
})
assert.equal(merged.apiUrl, 'http://custom:8890')
assert.equal(merged.groupOpsPublic, false)
assert.equal(merged.lang, NETXOPS_HOST_CONFIG_DEFAULTS.lang)
assert.equal(merged.tokenCredentialRef, NETXOPS_HOST_CONFIG_DEFAULTS.tokenCredentialRef)
assert.equal(merged.groupBizMonitorInPreset, NETXOPS_HOST_CONFIG_DEFAULTS.groupBizMonitorInPreset)
})
test('mergeHostConfigForHeal preserves unknown user keys', () => {
const merged = mergeHostConfigForHeal({
apiUrl: 'http://127.0.0.1:8890',
customSiteTag: 'hq-edge-1',
})
assert.equal(merged.customSiteTag, 'hq-edge-1')
assert.equal(merged.apiUrl, 'http://127.0.0.1:8890')
})
test('mergeHostConfigForHeal never overwrites empty-string user values with defaults', () => {
const merged = mergeHostConfigForHeal({
kbRoot: '',
imBotId: '',
})
assert.equal(merged.kbRoot, '')
assert.equal(merged.imBotId, '')
})
test('NETXOPS_HOST_CONFIG_DEFAULTS keeps ops public off', () => {
assert.equal(NETXOPS_HOST_CONFIG_DEFAULTS.groupOpsPublic, false)
})
test('writePatchIfUnchanged aborts when file raced', () => {
const dir = mkdtempSync(join(tmpdir(), 'netxops-heal-'))
const path = join(dir, 'cordis.patch.yml')
writeFileSync(path, 'v1\n', 'utf8')
assert.equal(writePatchIfUnchanged(path, 'v1\n', 'v2\n'), true)
assert.equal(readFileSync(path, 'utf8'), 'v2\n')
// Caller still holds stale expectedRaw after another writer changed the file.
writeFileSync(path, 'other\n', 'utf8')
assert.equal(writePatchIfUnchanged(path, 'v2\n', 'v3\n'), false)
assert.equal(readFileSync(path, 'utf8'), 'other\n')
})

41
tests/http-url.test.mjs Normal file
View file

@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { normalizeApiUrl } from '../src/netx/http.ts'
import { alarmSubscribeUrl } from '../src/netx/alarm-push.ts'
test('normalizeApiUrl accepts http(s) roots and strips trailing slash', () => {
assert.deepEqual(normalizeApiUrl('http://127.0.0.1:8890/'), {
ok: true,
url: 'http://127.0.0.1:8890',
})
assert.deepEqual(normalizeApiUrl('https://netx.example.com/api/'), {
ok: true,
url: 'https://netx.example.com/api',
})
assert.deepEqual(normalizeApiUrl(' http://192.168.1.10:8890 '), {
ok: true,
url: 'http://192.168.1.10:8890',
})
})
test('normalizeApiUrl rejects empty, bad schemes, and credentials-in-URL', () => {
assert.equal(normalizeApiUrl('').error, 'empty')
assert.equal(normalizeApiUrl('not a url').error, 'invalid_url')
assert.equal(normalizeApiUrl('file:///etc/passwd').error, 'unsupported_scheme')
assert.equal(normalizeApiUrl('javascript:alert(1)').error, 'unsupported_scheme')
assert.equal(normalizeApiUrl('http://user:pass@127.0.0.1:8890').error, 'credentials_in_url')
})
test('alarmSubscribeUrl only promotes http(s) to ws(s)', () => {
assert.equal(
alarmSubscribeUrl('http://127.0.0.1:8890'),
'ws://127.0.0.1:8890/v1/integrations/dsh-alarm/ws',
)
assert.equal(
alarmSubscribeUrl('https://netx.example.com/'),
'wss://netx.example.com/v1/integrations/dsh-alarm/ws',
)
assert.equal(alarmSubscribeUrl('file:///tmp'), '')
assert.equal(alarmSubscribeUrl('http://user:secret@127.0.0.1:8890'), '')
assert.equal(alarmSubscribeUrl(''), '')
})

View file

@ -0,0 +1,347 @@
/**
* Smoke: prove dsh-netxops/tools + host can register netx__* against a fake registry.
* Run: node tests/smoke-register-tools.mjs
*/
import assert from 'node:assert/strict'
import { pathToFileURL } from 'node:url'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const STORE_KEY = Symbol.for('dsh-netxops.connection-store')
function connectionStore() {
return globalThis[STORE_KEY]
}
function resetConnectionStore() {
const state = connectionStore()
if (state) {
state.connection = undefined
state.listeners.clear()
}
}
function createLogger(tag) {
const lines = []
const log = (level, ...args) => {
const msg = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')
lines.push(`[${level}] ${msg}`)
console.log(`[${tag}/${level}]`, ...args)
}
return {
lines,
info: (...a) => log('info', ...a),
warn: (...a) => log('warn', ...a),
error: (...a) => log('error', ...a),
debug: (...a) => log('debug', ...a),
}
}
/**
* Minimal cordis-like Context: tools.register records names;
* inject runs when all named services exist on ctx; effect runs immediately.
*/
function createFakeCtx(services = {}) {
const registered = []
const effects = []
const logger = createLogger(services._tag ?? 'ctx')
const ctx = {
logger,
tools: {
register(entry) {
const name = entry?.name ?? String(entry)
registered.push(name)
return () => {
const i = registered.indexOf(name)
if (i >= 0) registered.splice(i, 1)
}
},
},
skills: {
register() {
return () => {}
},
},
credentials: {
async resolve() {
return { value: 'smoke-token' }
},
},
settings: {
installSection() {},
describe() {
return []
},
get() {
return undefined
},
},
systemPrompt: {
section() {
return () => {}
},
context() {
return () => {}
},
},
get(name) {
return ctx[name]
},
inject(deps, callback) {
const list = Array.isArray(deps) ? deps : [deps]
const missing = list.filter((d) => ctx[d] === undefined && services[d] === undefined)
if (missing.length > 0) {
// Soft-inject semantics: wait forever (never call) when deps absent.
return
}
// Merge optional service overrides, then invoke.
for (const d of list) {
if (services[d] !== undefined) ctx[d] = services[d]
}
try {
callback(ctx)
} catch (error) {
logger.error('inject callback threw: %s', error)
throw error
}
},
effect(factory, label) {
try {
const dispose = factory()
effects.push({ label, dispose })
return typeof dispose === 'function' ? dispose : () => {}
} catch (error) {
logger.error('effect %s threw: %s', label, error)
throw error
}
},
on() {
return () => {}
},
...services,
}
// Ensure tools always present unless explicitly removed
if (services.tools === undefined && ctx.tools) {
// keep default
}
return { ctx, registered, effects, logger }
}
function disposeAll(effects) {
for (const { dispose } of effects.splice(0)) {
try {
if (typeof dispose === 'function') dispose()
} catch {
// ignore
}
}
}
async function smokeAgentTools() {
console.log('\n=== A) agent-tools apply (provisional connection) ===')
resetConnectionStore()
const mod = await import(pathToFileURL(join(root, 'lib/agent-tools.js')).href)
assert.equal(mod.name, 'netxops-tools')
assert.deepEqual(mod.inject, ['tools'])
assert.equal(typeof mod.apply, 'function')
const { ctx, registered, effects, logger } = createFakeCtx({ _tag: 'agent-tools' })
// Only tools — skills inject should soft-skip (not block tool registration)
delete ctx.skills
let thrown = null
try {
mod.apply(ctx)
} catch (error) {
thrown = error
console.error('agent-tools apply threw:', error)
}
const netx = registered.filter((n) => String(n).startsWith('netx__'))
const hasQuery = netx.includes('netx__queryNmsAlarms')
const sample = netx.slice(0, 12)
console.log('registered count:', registered.length)
console.log('netx__ count:', netx.length)
console.log('sample:', sample)
console.log('has netx__queryNmsAlarms:', hasQuery)
console.log('logger errors:', logger.lines.filter((l) => l.startsWith('[error]')))
disposeAll(effects)
return {
ok: thrown === null && hasQuery && netx.length >= 10,
thrown,
count: netx.length,
names: netx,
sample,
hasQuery,
}
}
async function smokeHostPublish() {
console.log('\n=== B) host apply (provisional publish + public tools) ===')
resetConnectionStore()
const mod = await import(pathToFileURL(join(root, 'lib/index.js')).href)
assert.equal(mod.name, 'netxops')
assert.equal(typeof mod.apply, 'function')
assert.equal(typeof mod.Config, 'function')
const publicRegistered = []
const { ctx, effects, logger } = createFakeCtx({
_tag: 'host',
tools: {
register(entry) {
const name = entry?.name ?? String(entry)
publicRegistered.push(name)
return () => {
const i = publicRegistered.indexOf(name)
if (i >= 0) publicRegistered.splice(i, 1)
}
},
},
})
const config = mod.Config({
apiUrl: 'http://127.0.0.1:8890',
installAgentPreset: false,
alarmPushEnabled: false,
groupOpsInPreset: true,
groupOpsPublic: true,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupKbInPreset: false,
groupKbPublic: false,
})
let thrown = null
try {
mod.apply(ctx, config)
} catch (error) {
thrown = error
console.error('host apply threw:', error)
}
// Allow async publish() (credentials resolve) one tick
await new Promise((r) => setTimeout(r, 50))
const store = connectionStore()
const conn = store?.connection
const provisionalOk = Boolean(conn?.apiUrl)
const hostNetx = publicRegistered.filter((n) => String(n).startsWith('netx__'))
const hasQueryHost = hostNetx.includes('netx__queryNmsAlarms')
console.log('connection published:', provisionalOk, conn?.apiUrl, 'tokenConfigured=', Boolean(conn?.token?.trim()))
console.log('host netx__ count:', hostNetx.length)
console.log('host sample:', hostNetx.slice(0, 8))
console.log('has host netx__queryNmsAlarms:', hasQueryHost)
console.log('logger errors:', logger.lines.filter((l) => l.startsWith('[error]')))
disposeAll(effects)
return {
ok: thrown === null && provisionalOk && hasQueryHost && hostNetx.length >= 5,
thrown,
provisionalOk,
apiUrl: conn?.apiUrl,
tokenConfigured: Boolean(conn?.token?.trim()),
publicCount: hostNetx.length,
publicSample: hostNetx.slice(0, 8),
hasQueryPublic: hasQueryHost,
}
}
async function smokeHostThenAgent() {
console.log('\n=== C) host publish then agent-tools remount ===')
resetConnectionStore()
const host = await import(pathToFileURL(join(root, 'lib/index.js')).href)
const tools = await import(pathToFileURL(join(root, 'lib/agent-tools.js')).href)
const hostBag = createFakeCtx({ _tag: 'host-c' })
// Host without tools — only publish path
delete hostBag.ctx.tools
delete hostBag.ctx.skills
const agentBag = createFakeCtx({ _tag: 'agent-c' })
delete agentBag.ctx.skills
host.apply(
hostBag.ctx,
host.Config({
apiUrl: 'http://10.0.0.1:8890',
installAgentPreset: false,
alarmPushEnabled: false,
groupOpsInPreset: true,
groupOpsPublic: false,
groupKbInPreset: false,
}),
)
await new Promise((r) => setTimeout(r, 50))
tools.apply(agentBag.ctx)
await new Promise((r) => setTimeout(r, 20))
const netx = agentBag.registered.filter((n) => String(n).startsWith('netx__'))
const store = connectionStore()?.connection
console.log('live apiUrl:', store?.apiUrl)
console.log('agent netx__ count:', netx.length)
console.log('has query:', netx.includes('netx__queryNmsAlarms'))
disposeAll(hostBag.effects)
disposeAll(agentBag.effects)
return {
ok: netx.includes('netx__queryNmsAlarms') && store?.apiUrl?.includes('10.0.0.1'),
count: netx.length,
apiUrl: store?.apiUrl,
}
}
const results = {
agentTools: await smokeAgentTools(),
host: await smokeHostPublish(),
combined: await smokeHostThenAgent(),
}
console.log('\n=== SUMMARY ===')
console.log(JSON.stringify({
agentTools: {
pass: results.agentTools.ok,
count: results.agentTools.count,
hasQuery: results.agentTools.hasQuery,
sample: results.agentTools.sample,
error: results.agentTools.thrown ? String(results.agentTools.thrown) : null,
},
host: {
pass: results.host.ok,
provisionalOk: results.host.provisionalOk,
apiUrl: results.host.apiUrl,
tokenConfigured: results.host.tokenConfigured,
publicCount: results.host.publicCount,
publicSample: results.host.publicSample,
error: results.host.thrown ? String(results.host.thrown) : null,
},
combined: {
pass: results.combined.ok,
count: results.combined.count,
apiUrl: results.combined.apiUrl,
},
}, null, 2))
const pass = results.agentTools.ok && results.host.ok && results.combined.ok
if (!pass) {
console.error('\nSMOKE FAIL')
process.exitCode = 1
} else {
console.log('\nSMOKE PASS')
}

View file

@ -0,0 +1,93 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_API_URL,
DEFAULT_LANG,
DEFAULT_TOKEN_REF,
DEFAULT_TOOL_CALL_TIMEOUT_MS,
mergeConfigSkippingNullish,
resolveApiUrlForConnection,
resolveLang,
resolveTokenCredentialRef,
resolveToolCallTimeoutMs,
} from '../src/index.ts'
const base = {
apiUrl: 'http://127.0.0.1:8890',
lang: 'zh',
thinkingLanguage: 'auto',
replyLanguage: 'follow-user',
tokenCredentialRef: 'NETX_API_TOKEN',
toolCallTimeoutMs: 120_000,
installAgentPreset: true,
alarmPushEnabled: false,
alarmDeliverDsh: true,
alarmDeliverIm: false,
imTargets: '',
imBotId: '',
imTargetId: '',
nmsProvider: 'zte-ume',
groupOpsInPreset: true,
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
kbRoot: '',
groupKbInPreset: true,
groupKbPublic: false,
}
test('mergeConfigSkippingNullish ignores null/undefined volatile fields', () => {
const merged = mergeConfigSkippingNullish(base, {
apiUrl: null,
toolCallTimeoutMs: null,
lang: 'en',
groupOpsPublic: undefined,
})
assert.equal(merged.apiUrl, 'http://127.0.0.1:8890')
assert.equal(merged.toolCallTimeoutMs, 120_000)
assert.equal(merged.lang, 'en')
assert.equal(merged.groupOpsPublic, false)
})
test('resolveToolCallTimeoutMs rejects null / NaN / non-positive', () => {
assert.equal(resolveToolCallTimeoutMs(null), DEFAULT_TOOL_CALL_TIMEOUT_MS)
assert.equal(resolveToolCallTimeoutMs(undefined), DEFAULT_TOOL_CALL_TIMEOUT_MS)
assert.equal(resolveToolCallTimeoutMs(Number.NaN), DEFAULT_TOOL_CALL_TIMEOUT_MS)
assert.equal(resolveToolCallTimeoutMs(0), DEFAULT_TOOL_CALL_TIMEOUT_MS)
assert.equal(resolveToolCallTimeoutMs(-1), DEFAULT_TOOL_CALL_TIMEOUT_MS)
assert.equal(resolveToolCallTimeoutMs(90_000), 90_000)
})
test('resolveApiUrlForConnection falls back on null/empty; rejects bad schemes', () => {
assert.deepEqual(resolveApiUrlForConnection(null), {
url: DEFAULT_API_URL,
usedDefault: true,
})
assert.deepEqual(resolveApiUrlForConnection(''), {
url: DEFAULT_API_URL,
usedDefault: true,
})
assert.deepEqual(resolveApiUrlForConnection('http://10.0.0.1:8890/'), {
url: 'http://10.0.0.1:8890',
usedDefault: false,
})
assert.equal(resolveApiUrlForConnection('file:///tmp').url, '')
assert.equal(resolveApiUrlForConnection('file:///tmp').error, 'unsupported_scheme')
})
test('resolveTokenCredentialRef heals null/invalid to NETX_API_TOKEN', () => {
assert.equal(resolveTokenCredentialRef(null), DEFAULT_TOKEN_REF)
assert.equal(resolveTokenCredentialRef(''), DEFAULT_TOKEN_REF)
assert.equal(resolveTokenCredentialRef('bad-name'), DEFAULT_TOKEN_REF)
assert.equal(resolveTokenCredentialRef('NETX_API_TOKEN'), 'NETX_API_TOKEN')
assert.equal(resolveTokenCredentialRef('CUSTOM_TOKEN'), 'CUSTOM_TOKEN')
})
test('resolveLang heals null/empty to zh', () => {
assert.equal(resolveLang(null), DEFAULT_LANG)
assert.equal(resolveLang(undefined), DEFAULT_LANG)
assert.equal(resolveLang(''), DEFAULT_LANG)
assert.equal(resolveLang('en'), 'en')
})

View file

@ -0,0 +1,248 @@
import test, { after } from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { mkdtempSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as host from '../lib/index.js'
import { Context, Service } from '@deepseek-ai/cordis'
import { parsePresetIds, capabilityGroupsFromSettings, kbEnabledForPlane } from '../src/netx/capability-groups.ts'
// Exercise the current official Schema implementation, not merely the installed
// lib: this local Harness has newer source alongside an older scalar-only lib.
const peer = createRequire(import.meta.resolve('@deepseek-ai/schemastery'))
const cosmokitSource = peer.resolve('@deepseek-ai/cosmokit/src/index.ts')
const schemaSource = peer.resolve('@deepseek-ai/schemastery/src/index.ts')
const built = await Bun.build({ entrypoints: [schemaSource], target: 'bun', format: 'esm', plugins: [{
name: 'official-volatile-source',
setup(build) {
build.onResolve({ filter: /^@deepseek-ai\/cosmokit$/ }, () => ({ path: cosmokitSource }))
},
}] })
assert.equal(built.success, true, String(built.logs))
const schemaUrl = `data:text/javascript;base64,${Buffer.from(await built.outputs[0].text()).toString('base64')}`
// Load the shipped JS with the current official schema peer. Keep it beside
// lib/index.js so skill and package discovery use the same paths as production.
const hostFile = fileURLToPath(new URL('../lib/index.js', import.meta.url))
const currentHostFile = fileURLToPath(new URL(`../lib/.volatile-startup-${randomUUID()}.mjs`, import.meta.url))
const hostCode = readFileSync(hostFile, 'utf8')
const withCurrentSchema = hostCode.replace(/from ["']@deepseek-ai\/schemastery["']/g, `from '${schemaUrl}'`)
assert.notEqual(withCurrentSchema, hostCode, 'must replace the external schema peer')
writeFileSync(currentHostFile, withCurrentSchema)
after(() => unlinkSync(currentHostFile))
const currentHost = await import(pathToFileURL(currentHostFile).href)
const currentConfig = currentHost.Config
const { createVolatile, updateVolatile, isVolatile } = await import(pathToFileURL(peer.resolve('@deepseek-ai/cosmokit/src/volatile.ts')).href)
const store = () => globalThis[Symbol.for('dsh-netxops.connection-store')].connection
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
function context(t, settings = { describe: () => [] }) {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-volatile-'))
const tools = new Map(), skills = new Map(), listeners = new Map(), prompts = new Map(), effects = [], injected = [], errors = []
const register = map => entry => {
assert.equal(map.has(entry.name), false, `duplicate ${entry.name}`)
map.set(entry.name, entry)
return () => map.delete(entry.name)
}
const ctx = {
logger: { info() {}, warn() {}, error(...args) { errors.push(args) } },
tools: { register: register(tools) }, skills: { register: register(skills) }, settings,
credentials: { async resolve() { return { value: '' } } },
systemPrompt: {
section(spec) { prompts.set(spec.name, spec); return () => prompts.delete(spec.name) },
context(spec) { prompts.set(spec.name, spec); return () => prompts.delete(spec.name) },
},
get(name) { return this[name] },
inject(deps, callback) {
injected.push([...deps])
if (deps.every(name => this[name])) callback(this)
},
effect(fn) { const dispose = fn(); if (typeof dispose === 'function') effects.push(dispose); return dispose },
on(event, fn) {
let list = listeners.get(event)
if (!list) listeners.set(event, list = new Set())
list.add(fn)
return () => list.delete(fn)
},
emit(event, ...args) { for (const fn of listeners.get(event) ?? []) fn(...args) },
}
t.after(async () => {
for (const dispose of effects.reverse()) dispose()
listeners.clear()
await pause()
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
})
return { ctx, tools, skills, prompts, injected, errors }
}
test('current Schema wraps defaults and partial profile starts the shipped Host bundle', async t => {
// Existing profiles lack the new target-list fields. They arrive as references
// containing empty defaults, which used to throw value.split on activation.
const config = currentConfig({
installAgentPreset: false, alarmPushEnabled: false, apiUrl: 'http://10.0.0.1:8890/',
lang: 'en', toolCallTimeoutMs: 45000, tokenCredentialRef: 'CUSTOM_TOKEN',
groupOpsInPreset: false, groupOpsPublic: true, groupKbInPreset: false,
thinkingLanguage: 'en', replyLanguage: 'en',
})
assert.ok(isVolatile(config.groupOpsPresets))
assert.equal(config.groupOpsPresets.get(), '')
assert.ok(isVolatile(config.installAgentPreset))
const mounted = context(t)
assert.doesNotThrow(() => currentHost.apply(mounted.ctx, config))
assert.equal(store().apiUrl, 'http://10.0.0.1:8890')
assert.equal(store().lang, 'en')
assert.equal(store().toolCallTimeoutMs, 45000)
assert.equal(store().groups.ops.inPreset, false)
assert.equal(store().groups.ops.public, true)
assert.equal(store().groupKbInPreset, false)
assert.equal(mounted.injected.some(deps => deps.length === 1 && deps[0] === 'agentPresets'), false,
'false reference must not run the installer')
assert.equal(mounted.tools.size, 15)
await pause()
assert.ok(mounted.skills.has('netx-ops'))
assert.equal(globalThis[Symbol.for('dsh-netxops.alarm-push-status')].status.enabled, false)
assert.equal(globalThis[Symbol.for('dsh-netxops.alarm-push-status')].status.phase, 'disabled')
assert.equal(mounted.errors.length, 0)
})
test('Loader updates read retained references and immediately revoke and republish tools and skills', async t => {
const config = currentConfig({ installAgentPreset: false, alarmPushEnabled: false,
groupOpsPublic: true, groupOpsPresets: 'standard,review', groupKbPresets: 'review' })
const mounted = context(t)
currentHost.apply(mounted.ctx, config)
await pause()
assert.equal(mounted.tools.size, 15)
assert.ok(mounted.skills.has('netx-ops'))
assert.deepEqual(store().groups.ops.presets, ['standard', 'review'])
assert.equal(store().groupKbPresets, 'review')
const original = config.toolsEnabled
updateVolatile(original, createVolatile(false))
updateVolatile(config.groupOpsPresets, createVolatile(''))
mounted.ctx.emit('loader/volatile-update', [['toolsEnabled'], ['groupOpsPresets']])
assert.equal(config.toolsEnabled, original, 'Loader retains identity')
assert.equal(store().toolsEnabled, false)
assert.equal(mounted.tools.size, 0, 'revocation is synchronous')
await pause()
assert.equal(mounted.skills.size, 0)
assert.deepEqual(store().groups.ops.presets ?? [], [])
updateVolatile(original, createVolatile(true))
updateVolatile(config.groupOpsPublic, createVolatile(false))
updateVolatile(config.groupOpsPresets, createVolatile('standard'))
updateVolatile(config.apiUrl, createVolatile('http://10.0.0.2:8890'))
mounted.ctx.emit('loader/volatile-update', [['toolsEnabled'], ['groupOpsPublic'], ['groupOpsPresets'], ['apiUrl']])
assert.equal(store().toolsEnabled, true)
assert.equal(store().apiUrl, 'http://10.0.0.2:8890')
assert.equal(store().groups.ops.public, false)
assert.deepEqual(store().groups.ops.presets, ['standard'])
await pause()
assert.equal(mounted.tools.size, 0)
assert.equal(mounted.skills.size, 0)
assert.equal(mounted.errors.length, 0)
})
test('legacy scalar settings remain valid and a reference-returning settings scope is read on every change', async t => {
let current = host.Config({ installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true })
let changed
const mounted = context(t, {
installSection(ctx, ns, schema, base, hooks) {
assert.equal(typeof base.installAgentPreset, 'boolean')
hooks.setSource(() => current)
changed = hooks.onChange
changed()
},
})
host.apply(mounted.ctx, current)
assert.equal(mounted.tools.size, 15)
current = currentConfig({ installAgentPreset: false, alarmPushEnabled: false, toolsEnabled: false, groupOpsPublic: true })
changed()
assert.equal(store().toolsEnabled, false)
assert.equal(mounted.tools.size, 0)
await pause()
assert.equal(mounted.skills.size, 0)
assert.equal(mounted.errors.length, 0)
})
test('policy helpers read official references and malformed preset lists cannot crash or select a target', () => {
const config = currentConfig({ groupOpsInPreset: false, groupOpsPublic: true,
groupOpsPresets: 'standard,review standard', groupKbPublic: false, groupKbInPreset: false })
const groups = capabilityGroupsFromSettings(config)
assert.equal(groups.ops.inPreset, false)
assert.equal(groups.ops.public, true)
assert.deepEqual(groups.ops.presets, ['standard', 'review'])
assert.equal(kbEnabledForPlane(config, 'preset'), false)
assert.equal(kbEnabledForPlane(config, 'host'), false)
assert.equal(host.resolveTokenCredentialRef(createVolatile('CUSTOM_TOKEN')), 'CUSTOM_TOKEN')
const merged = host.mergeConfigSkippingNullish(config, {
groupOpsPublic: createVolatile(false), apiUrl: createVolatile(null), groupOpsPresets: createVolatile(''),
})
assert.equal(merged.groupOpsPublic, false)
assert.equal(merged.groupOpsPresets, '')
assert.equal(merged.apiUrl, 'http://127.0.0.1:8890')
for (const value of [null, undefined, 0, true, {}, [], { get: () => 'standard' }]) {
assert.deepEqual(parsePresetIds(value), [])
}
assert.deepEqual(parsePresetIds(createVolatile('standard,review standard')), ['standard', 'review'])
})
test('real Cordis activates the current-schema bundle and delivers the owning fiber update', async t => {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-volatile-cordis-'))
const root = new Context(), owners = [], tools = new Map(), skills = new Map()
class Tools extends Service {
constructor(ctx) { super(ctx, 'tools') }
register(entry) {
assert.equal(tools.has(entry.name), false)
tools.set(entry.name, entry)
return () => tools.delete(entry.name)
}
}
class Skills extends Service {
constructor(ctx) { super(ctx, 'skills') }
register(entry) {
assert.equal(skills.has(entry.name), false)
skills.set(entry.name, entry)
return () => skills.delete(entry.name)
}
}
t.after(async () => {
for (const owner of owners.reverse()) await owner.dispose()
await pause()
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
})
owners.push(await root.plugin(Tools))
owners.push(await root.plugin(Skills))
const owner = await root.plugin({ ...currentHost, name: 'volatile-startup-integration' }, {
installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true,
})
owners.push(owner)
// FiberState is a const enum and has no JS export in this installed Cordis.
assert.equal(owner.state, 2 /* ACTIVE */)
assert.ok(isVolatile(owner.config.groupOpsPresets))
assert.equal(tools.size, 15)
await pause()
assert.ok(skills.has('netx-ops'))
updateVolatile(owner.config.toolsEnabled, createVolatile(false))
const self = Object.create(owner.ctx)
self[Context.filter] = ctx => ctx.fiber === owner
owner.ctx.emit(self, 'loader/volatile-update', [['toolsEnabled']])
assert.equal(owner.state, 2 /* ACTIVE */, 'volatile edit keeps the active fiber')
assert.equal(store().toolsEnabled, false)
assert.equal(tools.size, 0)
await pause()
assert.equal(skills.size, 0)
const unloadErrors = [], previousError = console.error
try {
console.error = (...args) => { unloadErrors.push(args) }
await owner.dispose()
owners.pop()
await pause()
} finally { console.error = previousError }
assert.equal(tools.size, 0)
assert.equal(skills.size, 0)
assert.deepEqual(unloadErrors, [], 'unload must not remount on an inactive context')
})