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 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
- - **文档更新。** README 安装章节重构(桌面版走 App 插件页、CLI 形态限自建 web/headless profile、`--profile` 占位符、allowBuilds 处置)、安全章节改为客观口径(本机回环、无鉴权 token、本机进程视为已授权用户)、补 seq 游标协议与超时/上限声明、0700/0600 加 POSIX 平台限定。
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 README install section is restructured (desktop installs go through the app plugin page, CLI forms target self-hosted web/headless profiles, `<profile>` placeholders, allowBuilds handling), 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.
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://github.com/OMSociety/dsh-ssh-tunnel/releases"><img src="https://img.shields.io/github/v/tag/OMSociety/dsh-ssh-tunnel?label=version&color=4f6ef7" alt="Version"></a>
11
- <img src="https://img.shields.io/badge/DSH-%3E%3D0.1.7--rc.2%20%3C0.2.0--0%20%7C%7C%20%3E%3D0.2.0--rc.1%20%3C0.3.0--0-4f6ef7" alt="DSH">
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="#快速开始">快速开始</a> • <a href="#侧栏">侧栏</a> • <a href="#模型工具">模型工具</a> • <a href="#安全">安全</a> • <a href="#开发">开发</a> • <a href="#许可证与作者">许可证与作者</a>
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 的社区插件,挂在右侧栏宿主 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 上:把多台 SSH 主机收进一个**主机库**,按项目授权,然后在中央面板里开**交互式终端**(xterm)或**双栏 SFTP**。
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
- 想复现某次安装就钉住 ref:在仓库地址后加 `#<tag 或提交 sha>`。
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
- & ([scriptblock]::Create((irm https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.ps1))) -Profile <profile>
50
+ # 先停掉正在运行的 DSH(运行中的服务会锁住依赖,装完再起)
51
+ dsh plugin --profile <profile> add "dsh-ssh-tunnel"
81
52
  ```
82
53
 
83
- 脚本参数(bash 与 PowerShell 一一对应):
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://github.com/OMSociety/dsh-ssh-tunnel/releases"><img src="https://img.shields.io/github/v/tag/OMSociety/dsh-ssh-tunnel?label=version&color=4f6ef7" alt="Version"></a>
11
- <img src="https://img.shields.io/badge/DSH-%3E%3D0.1.7--rc.2%20%3C0.2.0--0%20%7C%7C%20%3E%3D0.2.0--rc.1%20%3C0.3.0--0-4f6ef7" alt="DSH">
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 sidebar host [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-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
+ **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
- ## Quick start
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
- To reproduce a specific install, pin a ref by appending `#<tag or commit sha>` to the repository URL.
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
- & ([scriptblock]::Create((irm https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.ps1))) -Profile <profile>
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 (same pattern as dsh-better-sidebar) ---
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 (better-sidebar style). */
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. better-sidebar >=0.16 gives its panel
550
- // containers `contain: layout` (and panels animate with transform),
551
- // which makes them the containing block for fixed descendants — an
552
- // inline-rendered overlay would then be trapped inside the narrow
553
- // sidebar instead of covering the screen. Portaling removes that
554
- // dependency; fall back to inline rendering if react-dom is absent.
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(scope.sessionId || "");
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
- // scope.cwd is the real scope key better-sidebar passes; repoRoot is
1195
- // the accepted alias. workspacePath was never provided by any host.
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: scope.sessionId || sessionId || "",
1202
- cwd: cwdGuess || undefined,
1220
+ sessionId: props.sessionId || sessionId || "",
1203
1221
  });
1204
- const key = ctx.projectPathKey || cwdGuess || "";
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
- }, [scope.sessionId, cwdGuess, sessionId]);
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: projectPathKey || cwdGuess })
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: projectPathKey || cwdGuess })
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, cwdGuess]);
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 (better-sidebar pattern).
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
- scope: props.scope,
1620
+ sessionId: props.sessionId,
1602
1621
  });
1603
1622
  }
1604
1623
 
1605
- const inject = ["betterSidebar", "locale"];
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
- if (!ctx.betterSidebar) return;
1622
- ctx.effect(function () {
1623
- return ctx.betterSidebar.registerTab({
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
- icon: function (size) { return icon(size); },
1627
- order: 44,
1628
- single: true,
1629
- component: function (p) {
1630
- return React.createElement(TunnelRoot, {
1631
- ctx: ctx,
1632
- visible: p.visible,
1633
- scope: p.scope,
1634
- });
1635
- },
1636
- });
1637
- }, "dsh-ssh-tunnel: register tab");
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.2",
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.1.7-rc.2 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-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.1.7-rc.2 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
73
- "dsh-better-sidebar": ">=0.12.0"
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",
@@ -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
 
@@ -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
 
@@ -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
- betterSidebar: { registerTab: (d) => { captured.tab = d; return () => {}; } },
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
- const el = captured.tab.component({ visible: true, scope: {} });
99
- const html = renderToString(el);
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
- console.log("probe OK: tab tree renders, length =", html.length);
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");