dsh-ssh-tunnel 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -2
- package/README.md +10 -68
- package/README_en.md +8 -65
- package/lib/client.js +95 -39
- package/package.json +5 -8
- package/scripts/install.ps1 +0 -1
- package/scripts/install.sh +1 -3
- package/scripts/portal-probe.mjs +43 -5
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,22 @@ All notable changes to this project are documented in this file.
|
|
|
10
10
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
11
11
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
12
|
|
|
13
|
+
## [1.1.0] - 2026-10-09
|
|
14
|
+
|
|
15
|
+
### 变更
|
|
16
|
+
|
|
17
|
+
- **客户端面板改挂 DSH 官方右侧栏。** 标签页类型经 `sidebarRightTabs.register` 注册,正文与标题分别占用官方槽位 `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`;引导页入口框由 `guide` 条目渲染(order 44)。注册不再经由第三方侧栏宿主插件:`dsh-better-sidebar` 的 peer、`dsh.client.inject` 条目与 keywords 一并移除。
|
|
18
|
+
- **客户端不再传项目 cwd 提示。** `getProjectContext` 只带 `sessionId`,项目路径由宿主从会话头(`ctx.sessions.get(sessionId).header.cwd`)解析;随之删除宿主注入的 `scope.sessionId` / `scope.cwd` 等字段,会话 id 改取官方席位平铺注入的 `sessionId`。
|
|
19
|
+
- **DSH 兼容下限抬到 0.2.0-rc.2。** `engines.dsh` 与 `@deepseek-ai/dsh-client-locale` peer 收敛为单段 `>=0.2.0-rc.2 <0.3.0-0`,并新增同区间的 `@deepseek-ai/dsh-client-ui-sidebar-right` peer。
|
|
20
|
+
- **官方右侧栏的标签页不自动常驻。** 用户从标签条「+」的引导页入口打开面板;布局按会话持久化,新会话第一次需各开一次。
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **The client panel is mounted on the official DSH right sidebar.** The tab type registers through `sidebarRightTabs.register`, and the body and title take the official seats `sidebar.right.pane.tab` and `sidebar.right.pane.tab.title`; the guide-page entry box renders from the `guide` entry (order 44). Registration no longer goes through a third-party sidebar host plugin: the `dsh-better-sidebar` peer, its `dsh.client.inject` entry and its keywords are removed.
|
|
25
|
+
- **The client no longer sends a project cwd hint.** `getProjectContext` carries only `sessionId`, and the host resolves the project path from the session header (`ctx.sessions.get(sessionId).header.cwd`); the host-injected `scope.sessionId` / `scope.cwd` fields go away, and the session id now comes from the flat `sessionId` prop the official seat injects.
|
|
26
|
+
- **The DSH compatibility floor moves to 0.2.0-rc.2.** `engines.dsh` and the `@deepseek-ai/dsh-client-locale` peer collapse into the single segment `>=0.2.0-rc.2 <0.3.0-0`, and a new `@deepseek-ai/dsh-client-ui-sidebar-right` peer declares the same range.
|
|
27
|
+
- **The official right-sidebar tab is not resident by default.** Users open the panel from the "+" guide page in the tab strip; layout persists per session, so each new session needs one open.
|
|
28
|
+
|
|
13
29
|
## [1.0.2] - 2026-10-08
|
|
14
30
|
|
|
15
31
|
### 新增
|
|
@@ -26,7 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
26
42
|
- **`max_bytes` 按字节钳制。** exec 与 `sftp_read_text` 的输出上限钳制在 1024–1048576 字节(默认 exec 262144、`sftp_read_text` 524288),输出按字节截断并在结果中置 `truncated: true`。
|
|
27
43
|
- **客户端注入清单补齐到达顺序边。** `dsh.client.inject` 增加 `dsh-better-sidebar`;`exports` 放行 `./cordis.patch.yml`;`files` 放行 `scripts/lib/*.cjs`(安装脚本共享逻辑)。
|
|
28
44
|
- **键盘交互式认证不支持。** 该类主机在连接、重连与 `SSHManager` 中被拒绝(`credential=unsupported`),提示改用密码或私钥。
|
|
29
|
-
- **文档更新。**
|
|
45
|
+
- **文档更新。** 安全章节改为客观口径(本机回环、无鉴权 token、本机进程视为已授权用户)、补 seq 游标协议与超时/上限声明、0700/0600 加 POSIX 平台限定。
|
|
30
46
|
|
|
31
47
|
### 安全
|
|
32
48
|
|
|
@@ -46,7 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
46
62
|
- **`max_bytes` is clamped in bytes.** The output cap for exec and `sftp_read_text` is clamped to 1024–1048576 bytes (defaults: 262144 for exec, 524288 for `sftp_read_text`); output is truncated on byte boundaries and the result sets `truncated: true`.
|
|
47
63
|
- **The client inject list completes the arrival-order edge.** `dsh.client.inject` adds `dsh-better-sidebar`; `exports` admits `./cordis.patch.yml`; `files` admits `scripts/lib/*.cjs` (installer shared logic).
|
|
48
64
|
- **keyboard-interactive auth is not supported.** Such hosts are refused on connect, reconnect and in `SSHManager` (`credential=unsupported`) with a message to switch to password or private key.
|
|
49
|
-
- **Documentation updates.** The
|
|
65
|
+
- **Documentation updates.** The security section states the trust boundary objectively (local loopback, no authentication token, local processes treated as authorized users), the seq cursor protocol and timeout/limit declarations are added, and the 0700/0600 statements carry a POSIX platform qualifier.
|
|
50
66
|
|
|
51
67
|
### Security
|
|
52
68
|
|
package/README.md
CHANGED
|
@@ -7,19 +7,19 @@
|
|
|
7
7
|
<p>模型用 <strong>SSHManager</strong> 工具执行命令、传文件;你在侧栏管主机与授权。<strong>密钥不进模型上下文</strong>。</p>
|
|
8
8
|
|
|
9
9
|
<p>
|
|
10
|
-
<a href="https://
|
|
11
|
-
<img src="https://img.shields.io/badge/DSH-%3E%3D0.
|
|
10
|
+
<a href="https://www.npmjs.com/package/dsh-ssh-tunnel"><img src="https://img.shields.io/npm/v/dsh-ssh-tunnel?label=version&color=4f6ef7" alt="Version"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/DSH-%3E%3D0.2.0--rc.2%20%3C0.3.0--0-4f6ef7" alt="DSH">
|
|
12
12
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/OMSociety/dsh-ssh-tunnel?color=4f6ef7" alt="License"></a>
|
|
13
13
|
<a href="https://github.com/OMSociety/dsh-ssh-tunnel/stargazers"><img src="https://img.shields.io/github/stars/OMSociety/dsh-ssh-tunnel?color=4f6ef7" alt="Stars"></a>
|
|
14
14
|
<a href="https://github.com/OMSociety/dsh-ssh-tunnel/issues"><img src="https://img.shields.io/github/issues/OMSociety/dsh-ssh-tunnel?color=4f6ef7" alt="Issues"></a>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
<a href="#这是什么">这是什么</a> • <a href="#核心特性">核心特性</a> • <a href="
|
|
17
|
+
<a href="#这是什么">这是什么</a> • <a href="#核心特性">核心特性</a> • <a href="#安装方式">安装方式</a> • <a href="#侧栏">侧栏</a> • <a href="#模型工具">模型工具</a> • <a href="#安全">安全</a> • <a href="#开发">开发</a> • <a href="#许可证与作者">许可证与作者</a>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
20
|
## 这是什么
|
|
21
21
|
|
|
22
|
-
**DSH SSH Tunnel** 是 DeepSeek Harness
|
|
22
|
+
**DSH SSH Tunnel** 是 DeepSeek Harness 的社区插件,挂在 **DSH 官方右侧栏**的标签页上:把多台 SSH 主机收进一个**主机库**,按项目授权,然后在中央面板里开**交互式终端**(xterm)或**双栏 SFTP**。
|
|
23
23
|
|
|
24
24
|
它**不会**把全局 `fs` / `subprocess` 换成一个远程盘:远端操作只发生在你显式调用的 `SSHManager` 工具与面板里,本地文件与远端文件始终是两侧分明的东西。
|
|
25
25
|
|
|
@@ -29,8 +29,6 @@
|
|
|
29
29
|
|
|
30
30
|
**产品形态与部分 UX 参考开源项目 [LiveAgent](https://github.com/thirsty5034/LiveAgent)**(多机 SSH 主机库、按项目授权、侧栏隧道管理、中央终端 / SFTP 等)。
|
|
31
31
|
|
|
32
|
-
本仓库是 **DSH 原生实现**(Cordis host/client、`dsh-better-sidebar` Tab、`SSHManager` 工具、DSH 本地密钥布局),**不是** LiveAgent 的 git fork,也**不**内嵌 LiveAgent 源码。对照设计时请遵守 LiveAgent 自身许可证。
|
|
33
|
-
|
|
34
32
|
## 核心特性
|
|
35
33
|
|
|
36
34
|
| 特性 | 说明 |
|
|
@@ -44,64 +42,16 @@
|
|
|
44
42
|
| **本地路径守卫** | 上传 / 下载 / 列举 / 删除的本机路径限制在**项目工作区根**内,词法 **与** realpath 双重校验 |
|
|
45
43
|
| **界面双语** | 侧栏与面板随 DSH 界面语言在中文 / 英文间即时切换 |
|
|
46
44
|
|
|
47
|
-
##
|
|
48
|
-
|
|
49
|
-
**桌面版(推荐路径)**
|
|
50
|
-
|
|
51
|
-
DeepSeek Harness 桌面版的插件由 App 统一管理:在 App 的**插件页**安装并升级本插件即可。桌面 profile 目录由 App 独占管理,请不要对它运行下面的 CLI 命令或安装脚本。
|
|
52
|
-
|
|
53
|
-
**CLI 安装(仅限自建 web / headless profile)**
|
|
54
|
-
|
|
55
|
-
以下三种方式只针对自建的 web / headless profile。`<profile>` 处填你自己的 profile 名。
|
|
56
|
-
|
|
57
|
-
方式一:从 npm 安装
|
|
58
|
-
|
|
59
|
-
```sh
|
|
60
|
-
dsh plugin --profile <profile> add "dsh-ssh-tunnel@1.0.2"
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
包已发布到 npm,随包提供预构建产物,本地不需要构建步骤;换版本就把 `@1.0.2` 换成目标版本。
|
|
64
|
-
|
|
65
|
-
方式二:从 GitHub 源安装
|
|
66
|
-
|
|
67
|
-
```sh
|
|
68
|
-
dsh plugin --profile <profile> add "github:OMSociety/dsh-ssh-tunnel"
|
|
69
|
-
```
|
|
45
|
+
## 安装方式
|
|
70
46
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
方式三:一键脚本
|
|
74
|
-
|
|
75
|
-
```sh
|
|
76
|
-
curl -fsSL https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.sh | bash -s -- --profile <profile>
|
|
77
|
-
```
|
|
47
|
+
**从 npm 安装**
|
|
78
48
|
|
|
79
49
|
```powershell
|
|
80
|
-
|
|
50
|
+
# 先停掉正在运行的 DSH(运行中的服务会锁住依赖,装完再起)
|
|
51
|
+
dsh plugin --profile <profile> add "dsh-ssh-tunnel"
|
|
81
52
|
```
|
|
82
53
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
| 说明 | bash | PowerShell |
|
|
86
|
-
|---|---|---|
|
|
87
|
-
| 安装版本(可选,缺省装最新发布版) | `[版本]`(首个位置参数) | `-Version <版本>` |
|
|
88
|
-
| **目标 profile(必填,无默认值)** | `--profile <名称>` | `-Profile <名称>` |
|
|
89
|
-
| 安装源(默认 `github`) | `--from github\|npm` | `-From github\|npm` |
|
|
90
|
-
| 修 profile(可选开关) | `--fix-profile` | `-FixProfile` |
|
|
91
|
-
| 重启 web(可选开关) | `--restart` | `-Restart` |
|
|
92
|
-
| 试运行(可选开关) | `--dry-run` | `-DryRun` |
|
|
93
|
-
|
|
94
|
-
- profile 缺失或不存在时脚本报错并列出实存 profile,退出码 2
|
|
95
|
-
- `--fix-profile` / `-FixProfile`:仅在该开关下,脚本才补写 profile `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude`(幂等)并清理 profile `cordis.patch.yml` 里旧版写入的手动挂载;写入后回读断言,失败回滚并以非零码退出。不带该开关时这两个文件保持原样
|
|
96
|
-
- `--restart` / `-Restart`:重启自托管 web 服务(按 pm2 进程名 `dsh-web`);桌面版请在 App 的插件页操作
|
|
97
|
-
|
|
98
|
-
> **提示**:两个脚本的试运行写法不同——bash 用 `--dry-run`(如 `bash scripts/install.sh --profile <profile> --dry-run`),PowerShell 用 `-DryRun`(如 `pwsh -File scripts/install.ps1 -Profile <profile> -DryRun`)。PowerShell 侧写 `--dry-run` 不生效。
|
|
99
|
-
|
|
100
|
-
> **提示**:环境变量 `PATH` 里没有 `dsh` 命令时,脚本回退用 `npx` 拉起安装器:先打印将要执行的命令并要求确认,确认后才执行;设置环境变量 `DSH_INSTALL_YES=1` 可跳过确认。
|
|
101
|
-
|
|
102
|
-
> **提示**:安装完成后脚本会读取 profile 的 `node_modules/.modules.yaml`,若记录了 `ignoredBuilds`(pnpm 拦截的依赖构建脚本),会打印可复制的豁免指引——把 `ignoredBuilds` 条目所在行原文整行作为 `pnpm-workspace.yaml` 中 `allowBuilds` 的键、值写 `true`;键必须与 lockfile 逐字一致,只写纯包名不生效。
|
|
103
|
-
|
|
104
|
-
> **提示**:装好后**刷新一下浏览器页面**,右侧栏才会出现「SSH 隧道」入口——只重启宿主不够,客户端产物是页面加载时取的。
|
|
54
|
+
> **提示**:装好后**刷新一下浏览器页面**,「SSH 隧道」入口才会出现在右侧栏——只重启宿主不够,客户端产物是页面加载时取的。
|
|
105
55
|
|
|
106
56
|
**装完怎么用**
|
|
107
57
|
|
|
@@ -167,24 +117,17 @@ curl -fsSL https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scrip
|
|
|
167
117
|
| `grants.json` | `projectPathKey → hostIds[]` | `0600` |
|
|
168
118
|
| `known_hosts.json` | 已信任的主机密钥指纹 | `0600` |
|
|
169
119
|
|
|
170
|
-
## 界面国际化
|
|
171
|
-
|
|
172
|
-
- 命名空间:`sshTunnel`;字典 `zh` / `en` 注册到 `ctx.locale`
|
|
173
|
-
- Tab 标题与面板随 DSH 界面语言即时切换;切换语言会使插件面板重新挂载——终端与 SFTP 视图随之重建,远端会话本身不受影响
|
|
174
|
-
- 宿主侧 `SSHManager` 的描述保持英文(面向模型)
|
|
175
|
-
|
|
176
120
|
## 开发
|
|
177
121
|
|
|
178
122
|
```sh
|
|
179
123
|
npm test # 自检脚本全量回归(实际执行 node scripts/smoke-test.mjs)
|
|
180
124
|
npm run check # 语法检查 + 自检
|
|
181
125
|
node scripts/smoke-test.mjs # 直接运行自检(离线设计,不起 SSH、不起 DSH 进程)
|
|
182
|
-
bash scripts/install.sh --profile <profile> --dry-run # 只看安装计划,不动 profile
|
|
183
126
|
bash scripts/sync-to-dsh.sh --dry-run # 预览 link: 接入命令;去掉 --dry-run 才会改写 profile
|
|
184
127
|
node scripts/portal-probe.mjs # 客户端 Tab 渲染探针(能解析到 react 时生效,否则明确跳过)
|
|
185
128
|
```
|
|
186
129
|
|
|
187
|
-
|
|
130
|
+
项目目录:
|
|
188
131
|
|
|
189
132
|
```text
|
|
190
133
|
lib/index.js 宿主入口:工具注册、/dsh-ssh-tunnel/api 路由、授权与路径守卫、xterm 资产下发
|
|
@@ -206,7 +149,6 @@ cordis.patch.yml 包内 bundle patch,CLI 据此写入 dsh.profile.bundl
|
|
|
206
149
|
- 如果这个插件对你有帮助,欢迎点亮 Star;有问题或建议请提 [Issue](https://github.com/OMSociety/dsh-ssh-tunnel/issues) 或 [Pull Request](https://github.com/OMSociety/dsh-ssh-tunnel/pulls)。
|
|
207
150
|
- 变更记录见 [CHANGELOG](CHANGELOG.md)。
|
|
208
151
|
- LiveAgent([thirsty5034/LiveAgent](https://github.com/thirsty5034/LiveAgent)):产品形态与部分 UX 的参考来源(见上文「参考来源」)
|
|
209
|
-
- [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar):右侧栏宿主与 Tab 契约
|
|
210
152
|
- [dsh-git-forge](https://github.com/OMSociety/dsh-git-forge):同门插件,Git 凭据与 push 策略
|
|
211
153
|
- DeepSeek Harness:插件、工具与 agent shell 的宿主
|
|
212
154
|
|
package/README_en.md
CHANGED
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
<p>The model drives the <strong>SSHManager</strong> tool to run commands and move files; you manage hosts and grants in the sidebar. <strong>Secrets never enter model context</strong>.</p>
|
|
8
8
|
|
|
9
9
|
<p>
|
|
10
|
-
<a href="https://
|
|
11
|
-
<img src="https://img.shields.io/badge/DSH-%3E%3D0.
|
|
10
|
+
<a href="https://www.npmjs.com/package/dsh-ssh-tunnel"><img src="https://img.shields.io/npm/v/dsh-ssh-tunnel?label=version&color=4f6ef7" alt="Version"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/DSH-%3E%3D0.2.0--rc.2%20%3C0.3.0--0-4f6ef7" alt="DSH">
|
|
12
12
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/OMSociety/dsh-ssh-tunnel?color=4f6ef7" alt="License"></a>
|
|
13
13
|
<a href="https://github.com/OMSociety/dsh-ssh-tunnel/stargazers"><img src="https://img.shields.io/github/stars/OMSociety/dsh-ssh-tunnel?color=4f6ef7" alt="Stars"></a>
|
|
14
14
|
<a href="https://github.com/OMSociety/dsh-ssh-tunnel/issues"><img src="https://img.shields.io/github/issues/OMSociety/dsh-ssh-tunnel?color=4f6ef7" alt="Issues"></a>
|
|
15
15
|
</p>
|
|
16
|
+
<a href="#what-this-is">What this is</a> • <a href="#features">Features</a> • <a href="#installation">Installation</a> • <a href="#sidebar">Sidebar</a> • <a href="#model-tool">Model tool</a> • <a href="#security">Security</a> • <a href="#development">Development</a> • <a href="#license-and-author">License and author</a>
|
|
16
17
|
</div>
|
|
17
18
|
|
|
18
19
|
## What this is
|
|
19
20
|
|
|
20
|
-
**DSH SSH Tunnel** is a community plugin for DeepSeek Harness, mounted in the
|
|
21
|
+
**DSH SSH Tunnel** is a community plugin for DeepSeek Harness, mounted as a tab in the **official DSH right sidebar**. It collects your SSH hosts into one **host inventory**, authorizes them per project, and then opens an **interactive terminal** (xterm) or **dual-pane SFTP** in the center panel.
|
|
21
22
|
|
|
22
23
|
It does **not** replace the global `fs` / `subprocess` with a remote disk: remote operations happen only in the `SSHManager` tool you call explicitly and inside the panel, and local and remote files remain two clearly separated sides.
|
|
23
24
|
|
|
@@ -27,8 +28,6 @@ Its companion plugin is [dsh-git-forge](https://github.com/OMSociety/dsh-git-for
|
|
|
27
28
|
|
|
28
29
|
**The product shape and several UX patterns are informed by the open-source [LiveAgent](https://github.com/thirsty5034/LiveAgent)** (multi-host SSH inventory, project-scoped access, sidebar tunnel management, center terminal / SFTP surfaces).
|
|
29
30
|
|
|
30
|
-
This package is a **DSH-native implementation** (Cordis host/client plugin, `dsh-better-sidebar` tab, `SSHManager` tool, DSH-local secret layout). It is **not** a git fork of LiveAgent and does **not** vendor LiveAgent sources. Consult LiveAgent under its own license when comparing designs.
|
|
31
|
-
|
|
32
31
|
## Features
|
|
33
32
|
|
|
34
33
|
| Feature | Description |
|
|
@@ -42,63 +41,15 @@ This package is a **DSH-native implementation** (Cordis host/client plugin, `dsh
|
|
|
42
41
|
| **Local path guard** | Local upload / download / list / delete paths are constrained to the **project workspace root**, with both lexical and realpath checks |
|
|
43
42
|
| **Bilingual UI** | Sidebar and panel follow the DSH interface language between Chinese and English live |
|
|
44
43
|
|
|
45
|
-
##
|
|
46
|
-
|
|
47
|
-
**Desktop app (recommended path)**
|
|
48
|
-
|
|
49
|
-
The DeepSeek Harness desktop app manages plugins itself: install and upgrade this plugin from the app's **plugin page**. The desktop profile directory is owned exclusively by the app — do not run the CLI commands or install scripts below against it.
|
|
50
|
-
|
|
51
|
-
**CLI install (self-hosted web / headless profiles only)**
|
|
52
|
-
|
|
53
|
-
The three options below apply only to self-hosted web / headless profiles. Replace `<profile>` with your profile name.
|
|
54
|
-
|
|
55
|
-
Option 1: install from npm
|
|
56
|
-
|
|
57
|
-
```sh
|
|
58
|
-
dsh plugin --profile <profile> add "dsh-ssh-tunnel@1.0.2"
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
The package is published to npm and ships the prebuilt artifacts, so no local build step is involved. Replace `@1.0.2` to install another version.
|
|
62
|
-
|
|
63
|
-
Option 2: install from the GitHub source
|
|
64
|
-
|
|
65
|
-
```sh
|
|
66
|
-
dsh plugin --profile <profile> add "github:OMSociety/dsh-ssh-tunnel"
|
|
67
|
-
```
|
|
44
|
+
## Installation
|
|
68
45
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Option 3: one-line installer
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
curl -fsSL https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.sh | bash -s -- --profile <profile>
|
|
75
|
-
```
|
|
46
|
+
**Install from npm**
|
|
76
47
|
|
|
77
48
|
```powershell
|
|
78
|
-
|
|
49
|
+
# stop the running DSH first (a live service holds the dependency lock; start it again afterwards)
|
|
50
|
+
dsh plugin --profile <profile> add "dsh-ssh-tunnel"
|
|
79
51
|
```
|
|
80
52
|
|
|
81
|
-
Installer parameters (bash and PowerShell map one to one):
|
|
82
|
-
|
|
83
|
-
| Purpose | bash | PowerShell |
|
|
84
|
-
|---|---|---|
|
|
85
|
-
| Version to install (optional; latest release by default) | `[version]` (first positional argument) | `-Version <version>` |
|
|
86
|
-
| **Target profile (required, no default)** | `--profile <name>` | `-Profile <name>` |
|
|
87
|
-
| Install source (default `github`) | `--from github\|npm` | `-From github\|npm` |
|
|
88
|
-
| Profile fix (optional switch) | `--fix-profile` | `-FixProfile` |
|
|
89
|
-
| Restart web (optional switch) | `--restart` | `-Restart` |
|
|
90
|
-
| Dry run (optional switch) | `--dry-run` | `-DryRun` |
|
|
91
|
-
|
|
92
|
-
- A missing or nonexistent profile fails the script with a list of the profiles that do exist, exit code 2
|
|
93
|
-
- `--fix-profile` / `-FixProfile`: only with this switch does the script add the plugin to the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`, idempotent) and remove the hand-written mount older versions put into the profile's `cordis.patch.yml`; writes are read back and asserted, and a failure rolls the change back and exits non-zero. Without the switch both files stay untouched
|
|
94
|
-
- `--restart` / `-Restart`: restarts a self-hosted web service (pm2 process name `dsh-web`); on the desktop app use its plugin page instead
|
|
95
|
-
|
|
96
|
-
> **Note**: The dry-run flag differs between the two scripts — bash takes `--dry-run` (for example `bash scripts/install.sh --profile <profile> --dry-run`), PowerShell takes `-DryRun` (for example `pwsh -File scripts/install.ps1 -Profile <profile> -DryRun`). `--dry-run` has no effect on the PowerShell side.
|
|
97
|
-
|
|
98
|
-
> **Note**: When no `dsh` command is on `PATH`, the script falls back to `npx` to run the installer: it prints the command it is about to execute and asks for confirmation first; set the environment variable `DSH_INSTALL_YES=1` to skip the prompt.
|
|
99
|
-
|
|
100
|
-
> **Note**: After installing, the script reads the profile's `node_modules/.modules.yaml`; if it records `ignoredBuilds` (dependency build scripts blocked by pnpm), it prints a copy-paste exemption recipe — use the whole `ignoredBuilds` entry line verbatim as the key in `allowBuilds` inside `pnpm-workspace.yaml` with the value `true`. The key must match the lockfile literally; a bare package name does not work.
|
|
101
|
-
|
|
102
53
|
> **Note**: After installing, **refresh the browser page** for the "SSH Tunnel" entry to appear in the sidebar — restarting the host alone is not enough, because the client artifact is fetched when the page loads.
|
|
103
54
|
|
|
104
55
|
**First run**
|
|
@@ -165,19 +116,12 @@ Under `$DSH_HOME/ssh-tunnel/` (directory mode `0700`; this is POSIX behavior —
|
|
|
165
116
|
| `grants.json` | `projectPathKey → hostIds[]` | `0600` |
|
|
166
117
|
| `known_hosts.json` | Trusted host key fingerprints | `0600` |
|
|
167
118
|
|
|
168
|
-
## UI internationalization
|
|
169
|
-
|
|
170
|
-
- Namespace: `sshTunnel`; dictionaries `zh` / `en` registered on `ctx.locale`
|
|
171
|
-
- Tab title and panel follow the DSH interface language live; switching the language remounts the plugin panel — the terminal and SFTP views are rebuilt, while the remote session itself is unaffected
|
|
172
|
-
- Host-side `SSHManager` strings stay English (model-facing)
|
|
173
|
-
|
|
174
119
|
## Development
|
|
175
120
|
|
|
176
121
|
```sh
|
|
177
122
|
npm test # full regression of the smoke scripts (runs node scripts/smoke-test.mjs)
|
|
178
123
|
npm run check # syntax check + smoke tests
|
|
179
124
|
node scripts/smoke-test.mjs # run the smoke scripts directly (offline by design: no SSH, no DSH process)
|
|
180
|
-
bash scripts/install.sh --profile <profile> --dry-run # print the install plan without touching the profile
|
|
181
125
|
bash scripts/sync-to-dsh.sh --dry-run # preview the link: registration; drop --dry-run to actually rewrite the profile
|
|
182
126
|
node scripts/portal-probe.mjs # client tab render probe (runs when react resolves, otherwise skips)
|
|
183
127
|
```
|
|
@@ -204,7 +148,6 @@ cordis.patch.yml In-package bundle patch the CLI turns into dsh.profile.b
|
|
|
204
148
|
- If this plugin helps you, a Star is welcome; questions and suggestions go to [Issues](https://github.com/OMSociety/dsh-ssh-tunnel/issues) or [Pull Requests](https://github.com/OMSociety/dsh-ssh-tunnel/pulls).
|
|
205
149
|
- Changes are recorded in the [CHANGELOG](CHANGELOG.md).
|
|
206
150
|
- LiveAgent ([thirsty5034/LiveAgent](https://github.com/thirsty5034/LiveAgent)): prior art for the product shape and several UX patterns (see "Prior art" above)
|
|
207
|
-
- [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar): the sidebar host and tab contract
|
|
208
151
|
- [dsh-git-forge](https://github.com/OMSociety/dsh-git-forge): sibling plugin for Git credentials and push policy
|
|
209
152
|
- DeepSeek Harness: the host for plugins, tools and agent shells
|
|
210
153
|
|
package/lib/client.js
CHANGED
|
@@ -15,10 +15,11 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
return ReactDOMRef;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// --- i18n
|
|
18
|
+
// --- i18n: namespaced dictionaries + live subscription to ctx.locale ---
|
|
19
19
|
const LOCALE_NS = "sshTunnel";
|
|
20
20
|
const zh = {
|
|
21
21
|
tabTitle: "SSH 隧道",
|
|
22
|
+
guideDesc: "打开主机库、项目授权与隧道会话面板",
|
|
22
23
|
projectLabel: "项目: {path}",
|
|
23
24
|
projectUnbound: "(未绑定)",
|
|
24
25
|
refresh: "刷新",
|
|
@@ -108,6 +109,7 @@ window.__ModuleLoader__.load({
|
|
|
108
109
|
};
|
|
109
110
|
const en = {
|
|
110
111
|
tabTitle: "SSH Tunnel",
|
|
112
|
+
guideDesc: "Open the host inventory, project grants and tunnel sessions",
|
|
111
113
|
projectLabel: "Project: {path}",
|
|
112
114
|
projectUnbound: "(not bound)",
|
|
113
115
|
refresh: "Refresh",
|
|
@@ -207,7 +209,7 @@ window.__ModuleLoader__.load({
|
|
|
207
209
|
function isZh() {
|
|
208
210
|
return activeLocale().toLowerCase().startsWith("zh");
|
|
209
211
|
}
|
|
210
|
-
/** Translate a copy key; `{name}` placeholders from params
|
|
212
|
+
/** Translate a copy key; `{name}` placeholders from params. */
|
|
211
213
|
function t(key, params) {
|
|
212
214
|
const dict = isZh() ? zh : en;
|
|
213
215
|
let text = dict[key];
|
|
@@ -221,8 +223,28 @@ window.__ModuleLoader__.load({
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
const TAB_ID = "dsh-ssh-tunnel";
|
|
226
|
+
const TAB_KIND = "ssh-tunnel";
|
|
224
227
|
const API = "/dsh-ssh-tunnel/api";
|
|
225
228
|
|
|
229
|
+
// Read one service out of the object cordis hands to an inject callback.
|
|
230
|
+
// The object exposes `get(name)`; property access is the fallback for
|
|
231
|
+
// shapes without it (and `get` throws for services that were not part of
|
|
232
|
+
// the requested dependency list).
|
|
233
|
+
function pick(injected, key) {
|
|
234
|
+
if (!injected) return undefined;
|
|
235
|
+
try {
|
|
236
|
+
if (typeof injected.get === "function") {
|
|
237
|
+
const value = injected.get(key);
|
|
238
|
+
if (value !== undefined) return value;
|
|
239
|
+
}
|
|
240
|
+
} catch (e) { /* not requested: fall through to property access */ }
|
|
241
|
+
return injected[key];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Official open path captured from the sidebar service (`openTab` by kind);
|
|
245
|
+
// stays null while no sidebar service is present.
|
|
246
|
+
let openOfficial = null;
|
|
247
|
+
|
|
226
248
|
// Errors carry the structured fields the host sends: `code` (API_ERROR_STATUS
|
|
227
249
|
// semantic) and `data` (full parsed body) so callers can react to
|
|
228
250
|
// interactive states (e.g. a host-key prompt arrives as 200 + ok:false +
|
|
@@ -546,12 +568,12 @@ function Btn(props) {
|
|
|
546
568
|
},
|
|
547
569
|
}, React.createElement("div", { className: "ssh-ov-panel", role: "dialog", "aria-modal": "true" }, props.children));
|
|
548
570
|
// Portal to <body>: the overlay relies on position:fixed being
|
|
549
|
-
// viewport-relative.
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
571
|
+
// viewport-relative. Sidebar panel containers may set `contain: layout`
|
|
572
|
+
// (and panels animate with transform), which makes them the containing
|
|
573
|
+
// block for fixed descendants — an inline-rendered overlay would then
|
|
574
|
+
// be trapped inside the narrow sidebar instead of covering the screen.
|
|
575
|
+
// Portaling removes that dependency; fall back to inline rendering if
|
|
576
|
+
// react-dom is absent.
|
|
555
577
|
const rd = getReactDOM();
|
|
556
578
|
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) {
|
|
557
579
|
return rd.createPortal(rootEl, document.body);
|
|
@@ -1149,10 +1171,9 @@ function Btn(props) {
|
|
|
1149
1171
|
|
|
1150
1172
|
function TunnelPanel(props) {
|
|
1151
1173
|
const visible = props.visible;
|
|
1152
|
-
const scope = props.scope || {};
|
|
1153
1174
|
const [view, setView] = React.useState("tunnel");
|
|
1154
1175
|
const [projectPathKey, setProjectPathKey] = React.useState("");
|
|
1155
|
-
const [sessionId, setSessionId] = React.useState(
|
|
1176
|
+
const [sessionId, setSessionId] = React.useState(props.sessionId || "");
|
|
1156
1177
|
const [hosts, setHosts] = React.useState([]);
|
|
1157
1178
|
const [granted, setGranted] = React.useState([]);
|
|
1158
1179
|
const [sessions, setSessions] = React.useState([]);
|
|
@@ -1191,17 +1212,14 @@ function Btn(props) {
|
|
|
1191
1212
|
return !!v;
|
|
1192
1213
|
}
|
|
1193
1214
|
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
const cwdGuess = scope.cwd || scope.repoRoot || "";
|
|
1197
|
-
|
|
1215
|
+
// The host resolves the project cwd from the session header, so the
|
|
1216
|
+
// client only sends the session id.
|
|
1198
1217
|
const refresh = React.useCallback(async () => {
|
|
1199
1218
|
setError("");
|
|
1200
1219
|
const ctx = await api("getProjectContext", {
|
|
1201
|
-
sessionId:
|
|
1202
|
-
cwd: cwdGuess || undefined,
|
|
1220
|
+
sessionId: props.sessionId || sessionId || "",
|
|
1203
1221
|
});
|
|
1204
|
-
const key = ctx.projectPathKey ||
|
|
1222
|
+
const key = ctx.projectPathKey || "";
|
|
1205
1223
|
setProjectPathKey(key);
|
|
1206
1224
|
if (ctx.sessionId) setSessionId(ctx.sessionId);
|
|
1207
1225
|
const [h, g, s, p] = await Promise.all([
|
|
@@ -1214,7 +1232,7 @@ function Btn(props) {
|
|
|
1214
1232
|
setGranted(g.hostIds || []);
|
|
1215
1233
|
setSessions(s.sessions || []);
|
|
1216
1234
|
setPrompts(p.prompts || []);
|
|
1217
|
-
}, [
|
|
1235
|
+
}, [props.sessionId, sessionId]);
|
|
1218
1236
|
|
|
1219
1237
|
React.useEffect(() => {
|
|
1220
1238
|
if (!visible) return;
|
|
@@ -1224,15 +1242,15 @@ function Btn(props) {
|
|
|
1224
1242
|
// Background polls log instead of swallowing: a broken host route
|
|
1225
1243
|
// should be diagnosable from the console (C-23) while the UI
|
|
1226
1244
|
// keeps the last known state.
|
|
1227
|
-
api("listSessions", { projectPathKey
|
|
1245
|
+
api("listSessions", { projectPathKey })
|
|
1228
1246
|
.then(function (s) { setSessions(s.sessions || []); })
|
|
1229
1247
|
.catch(function (e) { console.warn("[dsh-ssh-tunnel] session poll failed:", e && e.message ? e.message : e); });
|
|
1230
|
-
api("listPrompts", { projectPathKey
|
|
1248
|
+
api("listPrompts", { projectPathKey })
|
|
1231
1249
|
.then(function (p) { setPrompts(p.prompts || []); })
|
|
1232
1250
|
.catch(function (e) { console.warn("[dsh-ssh-tunnel] prompt poll failed:", e && e.message ? e.message : e); });
|
|
1233
1251
|
}, 4000);
|
|
1234
1252
|
return function () { clearInterval(t); };
|
|
1235
|
-
}, [visible, refresh, projectPathKey
|
|
1253
|
+
}, [visible, refresh, projectPathKey]);
|
|
1236
1254
|
|
|
1237
1255
|
function patchForm(p) {
|
|
1238
1256
|
setForm(function (prev) { return Object.assign({}, prev, p); });
|
|
@@ -1580,7 +1598,8 @@ function Btn(props) {
|
|
|
1580
1598
|
|
|
1581
1599
|
function TunnelRoot(props) {
|
|
1582
1600
|
const ctx = props.ctx;
|
|
1583
|
-
// Re-render on DSH locale switches
|
|
1601
|
+
// Re-render on DSH locale switches: the dictionaries live in ctx.locale
|
|
1602
|
+
// and the active language is read through a live subscription.
|
|
1584
1603
|
const localeKey = React.useSyncExternalStore(
|
|
1585
1604
|
React.useCallback(function (cb) {
|
|
1586
1605
|
if (!ctx.locale || typeof ctx.locale.subscribe !== "function") return function () {};
|
|
@@ -1598,11 +1617,11 @@ function Btn(props) {
|
|
|
1598
1617
|
return React.createElement(TunnelPanel, {
|
|
1599
1618
|
key: "ssh-tunnel-" + String(localeKey || "en"),
|
|
1600
1619
|
visible: props.visible,
|
|
1601
|
-
|
|
1620
|
+
sessionId: props.sessionId,
|
|
1602
1621
|
});
|
|
1603
1622
|
}
|
|
1604
1623
|
|
|
1605
|
-
const inject = ["
|
|
1624
|
+
const inject = ["locale", "slots"];
|
|
1606
1625
|
function apply(ctx) {
|
|
1607
1626
|
ctx.effect(function () {
|
|
1608
1627
|
return ensureStyles();
|
|
@@ -1618,23 +1637,60 @@ function Btn(props) {
|
|
|
1618
1637
|
};
|
|
1619
1638
|
}, "dsh-ssh-tunnel: dictionaries");
|
|
1620
1639
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1640
|
+
|
|
1641
|
+
// Panel body: the framework injects the tab's own props, including the
|
|
1642
|
+
// owning session id and the tab-info hook.
|
|
1643
|
+
function TunnelBody(props) {
|
|
1644
|
+
const info = props.useTabInfo();
|
|
1645
|
+
const tab = (info && info.tab) || {};
|
|
1646
|
+
return React.createElement(TunnelRoot, {
|
|
1647
|
+
ctx: ctx,
|
|
1648
|
+
visible: tab.visible,
|
|
1649
|
+
sessionId: props.sessionId,
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
// Tab chip content: icon + label. Without this seat the chip is text only.
|
|
1653
|
+
function TunnelTitle(props) {
|
|
1654
|
+
return React.createElement("span", null, icon(14), " " + t("tabTitle"));
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Official right sidebar: the tab type plus the two pane seats. Absent
|
|
1658
|
+
// sidebar services mean the plugin stays silent instead of failing.
|
|
1659
|
+
ctx.inject(["sidebarRightTabs", "sidebarRight"], function (injected) {
|
|
1660
|
+
const tabs = pick(injected, "sidebarRightTabs");
|
|
1661
|
+
if (!tabs || typeof tabs.register !== "function") return;
|
|
1662
|
+
const sidebarRight = pick(injected, "sidebarRight");
|
|
1663
|
+
const disposers = [];
|
|
1664
|
+
disposers.push(tabs.register({
|
|
1624
1665
|
id: TAB_ID,
|
|
1666
|
+
kind: TAB_KIND,
|
|
1667
|
+
priority: "extension",
|
|
1625
1668
|
title: function () { return t("tabTitle"); },
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
return
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1669
|
+
guide: [{
|
|
1670
|
+
id: "main",
|
|
1671
|
+
order: 44,
|
|
1672
|
+
title: function () { return t("tabTitle"); },
|
|
1673
|
+
description: function () { return t("guideDesc"); },
|
|
1674
|
+
icon: function (p) { return icon((p && p.size) || 16); },
|
|
1675
|
+
}],
|
|
1676
|
+
}));
|
|
1677
|
+
const slots = ctx.slots;
|
|
1678
|
+
if (slots && typeof slots.inject === "function") {
|
|
1679
|
+
disposers.push(slots.inject("sidebar.right.pane.tab", function () {
|
|
1680
|
+
return slots.register({ name: "sidebar.right.pane.tab", key: TAB_ID }, TunnelBody);
|
|
1681
|
+
}));
|
|
1682
|
+
disposers.push(slots.inject("sidebar.right.pane.tab.title", function () {
|
|
1683
|
+
return slots.register({ name: "sidebar.right.pane.tab.title", key: TAB_ID }, TunnelTitle);
|
|
1684
|
+
}));
|
|
1685
|
+
}
|
|
1686
|
+
if (sidebarRight && typeof sidebarRight.openTab === "function") {
|
|
1687
|
+
openOfficial = function () { sidebarRight.openTab(TAB_KIND); };
|
|
1688
|
+
}
|
|
1689
|
+
return function () {
|
|
1690
|
+
for (var i = disposers.length - 1; i >= 0; i--) { try { disposers[i](); } catch (e) {} }
|
|
1691
|
+
openOfficial = null;
|
|
1692
|
+
};
|
|
1693
|
+
});
|
|
1638
1694
|
}
|
|
1639
1695
|
exports.apply = apply;
|
|
1640
1696
|
exports.inject = inject;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ssh-tunnel",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Multi-host SSH tunnel and SSHManager tool for DeepSeek Harness (project-scoped grants)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,13 +28,11 @@
|
|
|
28
28
|
"deepseek-harness",
|
|
29
29
|
"ssh",
|
|
30
30
|
"sftp",
|
|
31
|
-
"cordis"
|
|
32
|
-
"better-sidebar",
|
|
33
|
-
"dsh-better-sidebar"
|
|
31
|
+
"cordis"
|
|
34
32
|
],
|
|
35
33
|
"engines": {
|
|
36
34
|
"node": ">=20",
|
|
37
|
-
"dsh": ">=0.
|
|
35
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0-0"
|
|
38
36
|
},
|
|
39
37
|
"main": "lib/index.js",
|
|
40
38
|
"icon": "./icon.svg",
|
|
@@ -56,7 +54,6 @@
|
|
|
56
54
|
},
|
|
57
55
|
"client": {
|
|
58
56
|
"inject": [
|
|
59
|
-
"dsh-better-sidebar",
|
|
60
57
|
"@deepseek-ai/dsh-client-locale"
|
|
61
58
|
],
|
|
62
59
|
"platform": "web"
|
|
@@ -69,8 +66,8 @@
|
|
|
69
66
|
},
|
|
70
67
|
"peerDependencies": {
|
|
71
68
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
72
|
-
"@deepseek-ai/dsh-client-locale": ">=0.
|
|
73
|
-
"dsh-
|
|
69
|
+
"@deepseek-ai/dsh-client-locale": ">=0.2.0-rc.2 <0.3.0-0",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": ">=0.2.0-rc.2 <0.3.0-0"
|
|
74
71
|
},
|
|
75
72
|
"files": [
|
|
76
73
|
"CHANGELOG.md",
|
package/scripts/install.ps1
CHANGED
|
@@ -205,7 +205,6 @@ Say "Running: $CliDisplay plugin --profile $Profile add $AddSpec"
|
|
|
205
205
|
& $CliExe @CliArgs plugin --profile $Profile add $AddSpec
|
|
206
206
|
if ($LASTEXITCODE -ne 0) {
|
|
207
207
|
Warn 'dsh plugin add failed. Check network/registry, or run the command above manually.'
|
|
208
|
-
Warn "Prerequisite suggestion: dsh plugin --profile $Profile add dsh-better-sidebar"
|
|
209
208
|
exit 1
|
|
210
209
|
}
|
|
211
210
|
|
package/scripts/install.sh
CHANGED
|
@@ -51,8 +51,7 @@ dsh-ssh-tunnel 一键安装
|
|
|
51
51
|
环境:DSH_HOME(默认 ~/.dsh)、DSH_CMD(默认 dsh)、REGISTRY、GITHUB_REPO、
|
|
52
52
|
DSH_INSTALL_YES=1(跳过 npx 兜底确认)
|
|
53
53
|
|
|
54
|
-
前置:目标 profile 已初始化(含 package.json 与 pnpm-workspace.yaml
|
|
55
|
-
建议已装 dsh-better-sidebar(侧栏 Tab 依赖)。
|
|
54
|
+
前置:目标 profile 已初始化(含 package.json 与 pnpm-workspace.yaml)。
|
|
56
55
|
EOF
|
|
57
56
|
}
|
|
58
57
|
|
|
@@ -237,7 +236,6 @@ read -r -a CLI_ARR <<< "$DSH_CLI_CMD"
|
|
|
237
236
|
if ! "${CLI_ARR[@]}" plugin --profile "$PROFILE_NAME" add "$ADD_SPEC"; then
|
|
238
237
|
warn "dsh plugin add 失败。可检查网络、registry,或手动执行:"
|
|
239
238
|
warn " $DSH_CLI_CMD plugin --profile $PROFILE_NAME add \"$ADD_SPEC\""
|
|
240
|
-
warn "前置建议:dsh plugin --profile $PROFILE_NAME add dsh-better-sidebar"
|
|
241
239
|
exit 1
|
|
242
240
|
fi
|
|
243
241
|
|
package/scripts/portal-probe.mjs
CHANGED
|
@@ -81,26 +81,64 @@ vm.runInContext(src, sandbox, { filename: "client.js" });
|
|
|
81
81
|
if (!loaded || loaded.id !== "dsh-ssh-tunnel") throw new Error("module did not register");
|
|
82
82
|
const mod = loaded.factory(sandbox.require);
|
|
83
83
|
if (typeof mod.apply !== "function" || !Array.isArray(mod.inject)) throw new Error("bad exports");
|
|
84
|
+
if (!mod.inject.includes("slots")) throw new Error("bundle does not declare the slots service");
|
|
84
85
|
|
|
86
|
+
// Mock cordis ctx. `ctx.inject(deps, cb)` dispatches on the requested dependency
|
|
87
|
+
// names — the bundle asks for ["sidebarRightTabs", "sidebarRight"] and receives an
|
|
88
|
+
// object exposing both `get(name)` and bare properties. `ctx.slots` carries the two
|
|
89
|
+
// pane seats; each register() hands back a disposer, as the real service does.
|
|
85
90
|
const captured = {};
|
|
91
|
+
function sidebarServices() {
|
|
92
|
+
return {
|
|
93
|
+
get(name) { return this[name]; },
|
|
94
|
+
sidebarRightTabs: {
|
|
95
|
+
register(descriptor) { captured.tab = descriptor; return () => {}; },
|
|
96
|
+
},
|
|
97
|
+
sidebarRight: {
|
|
98
|
+
openTab() {},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
86
102
|
const ctx = {
|
|
87
103
|
locale: null,
|
|
88
|
-
|
|
104
|
+
slots: {
|
|
105
|
+
inject(name, cb) {
|
|
106
|
+
const off = cb();
|
|
107
|
+
return typeof off === "function" ? off : () => {};
|
|
108
|
+
},
|
|
109
|
+
register(seat, component) { captured[seat.name + ":" + seat.key] = component; return () => {}; },
|
|
110
|
+
},
|
|
111
|
+
inject(deps, cb) { return cb(sidebarServices()); },
|
|
89
112
|
effect(fn) { return fn(); },
|
|
90
113
|
};
|
|
91
114
|
mod.apply(ctx);
|
|
92
|
-
if (!captured.tab || typeof captured.tab.component !== "function") throw new Error("tab not registered");
|
|
93
115
|
|
|
116
|
+
if (!captured.tab) throw new Error("tab type not registered");
|
|
117
|
+
if (captured.tab.kind !== "ssh-tunnel") throw new Error("unexpected tab kind: " + captured.tab.kind);
|
|
94
118
|
// zh/en dictionaries register only when ctx.locale exists — the title factory must
|
|
95
119
|
// still return a string through the built-in fallback.
|
|
96
120
|
if (!captured.tab.title || typeof captured.tab.title() !== "string") throw new Error("title fn broken");
|
|
121
|
+
const guide = Array.isArray(captured.tab.guide) ? captured.tab.guide[0] : null;
|
|
122
|
+
if (!guide || typeof guide.id !== "string") throw new Error("guide entry has no id");
|
|
123
|
+
if (typeof guide.description !== "function" || typeof guide.description() !== "string")
|
|
124
|
+
throw new Error("guide description fn broken");
|
|
125
|
+
if (typeof guide.icon !== "function") throw new Error("guide icon fn missing");
|
|
126
|
+
|
|
127
|
+
const Body = captured["sidebar.right.pane.tab:" + captured.tab.id];
|
|
128
|
+
if (typeof Body !== "function") throw new Error("pane body seat not registered");
|
|
129
|
+
const Title = captured["sidebar.right.pane.tab.title:" + captured.tab.id];
|
|
130
|
+
if (typeof Title !== "function") throw new Error("pane title seat not registered");
|
|
97
131
|
|
|
98
|
-
|
|
99
|
-
const html = renderToString(
|
|
132
|
+
// The framework injects the pane props: the owning sessionId plus the tab-info hook.
|
|
133
|
+
const html = renderToString(
|
|
134
|
+
Body({ sessionId: "probe", useTabInfo: () => ({ tab: { visible: true } }) }),
|
|
135
|
+
);
|
|
136
|
+
const titleHtml = renderToString(Title({ sessionId: "probe", useTabInfo: () => ({ tab: { visible: true } }) }));
|
|
100
137
|
|
|
101
138
|
// Markers are the class names the tab tree actually renders (lib/client.js).
|
|
102
139
|
for (const marker of ["ssh-t-root", "ssh-t-head"]) {
|
|
103
140
|
if (!html.includes(marker)) throw new Error("missing sidebar markup: " + marker);
|
|
104
141
|
}
|
|
105
|
-
|
|
142
|
+
if (!titleHtml.includes("SSH")) throw new Error("tab title seat rendered no label");
|
|
143
|
+
console.log("probe OK: tab type + both pane seats register, body tree renders, length =", html.length);
|
|
106
144
|
console.log("portal available:", typeof ReactDOM.createPortal === "function");
|