dsh-ssh-terminal 0.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/README_EN.md +59 -0
- package/cordis.patch.yml +4 -0
- package/docs/screenshot.png +0 -0
- package/lib/client.js +278 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +497 -0
- package/package.json +78 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-08-29
|
|
6
|
+
|
|
7
|
+
- Initial release.
|
|
8
|
+
- **Agent SSH tools**: `ssh_connect` / `ssh_exec` / `ssh_disconnect` / `ssh_status`.
|
|
9
|
+
- **better-sidebar tab**: registers a "SSH 终端" tab via `ctx.betterSidebar.registerTab`; opens in the sidebar pane with a live command→output transcript.
|
|
10
|
+
- **Transcript UX**: timestamps per step, theme-aware colors (DSH design tokens), scroll-to-bottom only when already pinned at the bottom.
|
|
11
|
+
- **Local history persistence**: the record is written to `~/.dsh/ssh-terminal-history.json` and survives restarts; a "清空记录" button clears it.
|
|
12
|
+
- **Host half**: spawns `ssh -tt` via `subprocess`, keeps the transcript, registers the `/ssh-terminal/api` HTTP route through `webServer` (password prompt auto-detection, failed-login banner tolerated).
|
|
13
|
+
- **Client half**: built with `tsdown` into the DSH `window.__ModuleLoader__.load` bundle format (`react` externalized).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-ssh-terminal authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# dsh-ssh-terminal
|
|
2
|
+
|
|
3
|
+
🌏 [English](README_EN.md) · [中文](README.md)
|
|
4
|
+
|
|
5
|
+
SSH 远程终端:在 `dsh-better-sidebar` 侧边栏里连接远程主机,**逐步查看 agent 输入的命令和输出**,不影响对话主流程;同时为 Agent 提供 SSH 工具。
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
- **Better-sidebar「SSH 终端」tab**:右侧栏 `+` 菜单里出现,点击即打开面板,含连接表单 + 命令输入 + **实时命令→输出转录**(带时间戳、颜色跟随 DSH 皮肤)。
|
|
12
|
+
- **多连接并行**:每次 `ssh_connect` 都会创建独立的 SSH 进程、输出缓冲和命令队列;侧边栏左侧列出各连接,点击即可切换对应转录。
|
|
13
|
+
- **Agent 工具**(独立可用):`ssh_connect` / `ssh_exec` / `ssh_disconnect` / `ssh_status` / `ssh_list`。
|
|
14
|
+
- **记录持久化**:本机会话内跨重启保留(写盘到 `~/.dsh/ssh-terminal-history.json`),面板右上角「清空当前」只清理当前选中连接的记录。
|
|
15
|
+
|
|
16
|
+
### 多任务调用约定
|
|
17
|
+
|
|
18
|
+
并行任务必须保存 `ssh_connect` 返回的 `connectionId`,后续对同一主机的 `ssh_exec`、`ssh_status`、`ssh_disconnect` 都传入它。不同 `connectionId` 的命令可以同时执行;同一个连接内的命令会按调用顺序排队,避免写入同一个 SSH PTY 时互相串线。旧调用不传 ID 时仍兼容当前活动连接。
|
|
19
|
+
|
|
20
|
+
## 依赖
|
|
21
|
+
|
|
22
|
+
- DSH web(`dsh web` / DSH Desktop)
|
|
23
|
+
- `dsh-better-sidebar`(>= 0.12.0)——**只有「侧边栏面板」需要它**;Agent 的 SSH 工具不依赖 better-sidebar,单独装本插件即可用。
|
|
24
|
+
|
|
25
|
+
> **可选增强**:未安装 better-sidebar 时,本插件只提供 Agent 的 SSH 工具(模型可连接、执行、断开,结果在对话里看到);不显示侧边栏「SSH 终端」面板。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dsh plugin add github:sunzhyang1616-ui/dsh-ssh-terminal
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(Client 改动无需重启;仅 Host 改动才需重启 DSH)。
|
|
34
|
+
|
|
35
|
+
## 本地安装(源码 / link 方式)
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
1. 让 profile 使用本包:编辑 ~/.dsh/profiles/<profile>/package.json 的 dependencies 加入
|
|
39
|
+
"dsh-ssh-terminal": "link:<本目录绝对路径>"
|
|
40
|
+
2. 挂载 bundle patch(package.json 已声明 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } })
|
|
41
|
+
3. 在 profile 目录执行 pnpm install
|
|
42
|
+
4. 硬刷新浏览器(Cmd/Ctrl+Shift+R)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 文件说明
|
|
46
|
+
|
|
47
|
+
| 文件 | 说明 |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `lib/index.js` | **Host 半**:按 `connectionId` 启动和隔离多个 `ssh -tt`、维护转录、注册工具 + `/ssh-terminal/api` HTTP 路由 |
|
|
50
|
+
| `lib/client.js` | **Client 半**:`ctx.betterSidebar.registerTab` 注册「SSH 终端」tab + 多连接列表和独立转录 UI |
|
|
51
|
+
| `src/client/index.tsx` | Client 源码(tsdown 构建入口) |
|
|
52
|
+
| `tsdown.config.ts` | client 构建配置 |
|
|
53
|
+
| `cordis.patch.yml` | bundle 挂载补丁 |
|
|
54
|
+
|
|
55
|
+
## 限制 / 说明
|
|
56
|
+
|
|
57
|
+
- SSH 连接为 Host 进程态,DSH 重启即断(连接不自动重连);**操作记录**会本机落盘、跨重启保留。
|
|
58
|
+
- 密码仅本次调用内使用,不回显、不写入转录;推荐 `identity`(私钥文件)认证。
|
|
59
|
+
- 若目标 DSH 版本对已安装插件不暴露 `harness`,Host 已改用 `ctx.tools.register` + `webServer` 路由,无需 `harness`。
|
package/README_EN.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# dsh-ssh-terminal
|
|
2
|
+
|
|
3
|
+
🌏 [English](README_EN.md) · [中文](README.md)
|
|
4
|
+
|
|
5
|
+
An SSH terminal tab for [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar): connect to a remote host and **watch the agent's commands and output step by step**, without touching the main conversation. Also ships SSH tools for the agent.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **better-sidebar "SSH 终端" tab**: appears in the sidebar `+` menu. Opens a panel with a connection form, a command input, and a **live command→output transcript** (timestamps, theme-aware colors).
|
|
12
|
+
- **Concurrent connections**: every `ssh_connect` creates an isolated SSH process, output buffer, and command queue. The sidebar lists all connections and switches to the selected transcript.
|
|
13
|
+
- **Agent tools** (standalone): `ssh_connect` / `ssh_exec` / `ssh_disconnect` / `ssh_status` / `ssh_list`.
|
|
14
|
+
- **Local history persistence**: the record survives restarts on this machine (saved to `~/.dsh/ssh-terminal-history.json`); the "清空当前" button clears only the selected connection's history.
|
|
15
|
+
|
|
16
|
+
### Multi-task usage
|
|
17
|
+
|
|
18
|
+
Parallel tasks must keep the `connectionId` returned by `ssh_connect` and pass it to subsequent `ssh_exec`, `ssh_status`, and `ssh_disconnect` calls. Different connection IDs can run concurrently; commands on one connection are queued in order so they cannot interleave writes to the same SSH PTY. Calls without an ID remain compatible with the current active connection.
|
|
19
|
+
|
|
20
|
+
## Dependencies
|
|
21
|
+
|
|
22
|
+
- DSH web (`dsh web` / DSH Desktop)
|
|
23
|
+
- `dsh-better-sidebar` (>= 0.12.0) — **only needed for the sidebar panel**; the agent SSH tools work standalone.
|
|
24
|
+
|
|
25
|
+
> **Optional**: without better-sidebar, this plugin only exposes the agent SSH tools (the model can connect, run commands, disconnect, and see results in the conversation); the sidebar "SSH 终端" panel is not shown.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dsh plugin add github:sunzhyang1616-ui/dsh-ssh-terminal
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After installing, **hard-refresh** the browser (Cmd/Ctrl+Shift+R). Client changes hot-load; only Host changes need a DSH restart.
|
|
34
|
+
|
|
35
|
+
## Local install (source / link)
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
1. Let a profile use this package: add to ~/.dsh/profiles/<profile>/package.json dependencies
|
|
39
|
+
"dsh-ssh-terminal": "link:<absolute path to this dir>"
|
|
40
|
+
2. The bundle patch is declared by package.json ("dsh": { "bundle": { "patch": "./cordis.patch.yml" } })
|
|
41
|
+
3. Run pnpm install in the profile dir
|
|
42
|
+
4. Hard-refresh the browser (Cmd/Ctrl+Shift+R)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Files
|
|
46
|
+
|
|
47
|
+
| File | Purpose |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `lib/index.js` | **Host half**: spawns and isolates multiple `ssh -tt` sessions by `connectionId`, keeps transcripts, registers tools + `/ssh-terminal/api` HTTP route |
|
|
50
|
+
| `lib/client.js` | **Client half**: registers the "SSH 终端" tab via `ctx.betterSidebar.registerTab` + connection list and isolated transcript UI |
|
|
51
|
+
| `src/client/index.tsx` | Client source (tsdown build entry) |
|
|
52
|
+
| `tsdown.config.ts` | client build config |
|
|
53
|
+
| `cordis.patch.yml` | bundle mount patch |
|
|
54
|
+
|
|
55
|
+
## Limitations / Notes
|
|
56
|
+
|
|
57
|
+
- The SSH connection is Host-process state; it drops on DSH restart (no auto-reconnect). The **operation record** is persisted locally and survives restarts.
|
|
58
|
+
- Passwords are used only for that call and are never echoed or written to the transcript; prefer `identity` (private key file) auth.
|
|
59
|
+
- The Host half uses `ctx.tools.register` + a `webServer` route (no `harness`), so it works for installed plugins.
|
package/cordis.patch.yml
ADDED
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-ssh-terminal",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
//#region src/client/index.tsx
|
|
9
|
+
const API = "/ssh-terminal/api";
|
|
10
|
+
function fmt(ts) {
|
|
11
|
+
const d = new Date(ts);
|
|
12
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
13
|
+
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
14
|
+
}
|
|
15
|
+
const SSH_CSS = `
|
|
16
|
+
.dshssh-body{display:flex;flex-direction:column;gap:8px;height:100%;box-sizing:border-box;padding:10px;color:var(--dsw-alias-label-primary,inherit);}
|
|
17
|
+
.dshssh-head{display:flex;align-items:center;gap:8px;}
|
|
18
|
+
.dshssh-title{font-weight:700;flex:1;}
|
|
19
|
+
.dshssh-status{font-weight:600;}
|
|
20
|
+
.dshssh-status.ok{color:var(--dsw-alias-state-success-primary,#22c55e);}
|
|
21
|
+
.dshssh-status.off{color:var(--dsw-alias-label-tertiary,#94a3b8);}
|
|
22
|
+
.dshssh-layout{display:flex;gap:8px;flex:1;min-height:0;}
|
|
23
|
+
.dshssh-sessions{width:150px;min-width:125px;display:flex;flex-direction:column;gap:6px;min-height:0;}
|
|
24
|
+
.dshssh-session-title{font-size:11px;font-weight:700;color:var(--dsw-alias-label-secondary,#d1d5db);}
|
|
25
|
+
.dshssh-session-list{display:flex;flex-direction:column;gap:5px;overflow:auto;min-height:0;}
|
|
26
|
+
.dshssh-session{display:flex;align-items:center;gap:6px;width:100%;padding:6px 7px;text-align:left;border-radius:7px;border:1px solid transparent;background:var(--dsw-alias-bg-layer-2,rgba(148,163,184,.08));color:var(--dsw-alias-label-primary,inherit);cursor:pointer;font-size:11px;}
|
|
27
|
+
.dshssh-session:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(148,163,184,.15));}
|
|
28
|
+
.dshssh-session.selected{border-color:var(--dsw-alias-accent,#60a5fa);}
|
|
29
|
+
.dshssh-session-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#94a3b8);}
|
|
30
|
+
.dshssh-session-dot.ok{background:var(--dsw-alias-state-success-primary,#22c55e);}
|
|
31
|
+
.dshssh-session-copy{min-width:0;display:flex;flex-direction:column;gap:2px;}
|
|
32
|
+
.dshssh-session-name,.dshssh-session-meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
33
|
+
.dshssh-session-name{font-weight:600;}
|
|
34
|
+
.dshssh-session-meta{color:var(--dsw-alias-label-tertiary,#94a3b8);font-size:10px;}
|
|
35
|
+
.dshssh-empty{padding:6px;color:var(--dsw-alias-label-tertiary,#94a3b8);font-size:11px;}
|
|
36
|
+
.dshssh-main{display:flex;flex-direction:column;gap:8px;flex:1;min-width:0;min-height:0;}
|
|
37
|
+
.dshssh-form{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
|
38
|
+
.dshssh-input{padding:4px 7px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(148,163,184,.4));background:var(--dsw-alias-bg-layer-3,rgba(2,6,23,.5));color:var(--dsw-alias-label-primary,inherit);font-size:12px;min-width:70px;}
|
|
39
|
+
.dshssh-input::placeholder{color:var(--dsw-alias-label-tertiary,#94a3b8);}
|
|
40
|
+
.dshssh-btn{padding:4px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(148,163,184,.4));background:var(--dsw-alias-interactive-bg-hover,rgba(148,163,184,.15));color:var(--dsw-alias-label-primary,inherit);cursor:pointer;font-size:12px;}
|
|
41
|
+
.dshssh-btn:hover{background:var(--dsw-alias-interactive-bg-active,rgba(148,163,184,.28));}
|
|
42
|
+
.dshssh-btn:disabled{opacity:.5;cursor:default;}
|
|
43
|
+
.dshssh-log{flex:1;min-height:120px;overflow:auto;background:var(--dsw-alias-bg-layer-1,rgba(2,6,23,.55));border:1px solid var(--dsw-alias-border-l2,rgba(148,163,184,.25));border-radius:8px;padding:8px;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:12px;line-height:1.45;white-space:pre-wrap;word-break:break-word;}
|
|
44
|
+
.dshssh-step{margin:0 0 6px;}
|
|
45
|
+
.dshssh-cmd{color:var(--dsw-alias-accent,#93c5fd);}
|
|
46
|
+
.dshssh-cmd::before{content:"❯ ";color:var(--dsw-alias-accent,#60a5fa);}
|
|
47
|
+
.dshssh-out{color:var(--dsw-alias-label-secondary,#d1d5db);}
|
|
48
|
+
.dshssh-connect{color:var(--dsw-alias-state-success-primary,#86efac);}
|
|
49
|
+
.dshssh-disconnect{color:var(--dsw-alias-state-error-primary,#fca5a5);}
|
|
50
|
+
.dshssh-system{color:var(--dsw-alias-label-tertiary,#94a3b8);font-style:italic;}
|
|
51
|
+
.dshssh-err{color:var(--dsw-alias-state-error-primary,#fca5a5);}
|
|
52
|
+
`;
|
|
53
|
+
async function callApi(action, extra = void 0) {
|
|
54
|
+
return await (await fetch(API, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
action,
|
|
59
|
+
...extra || {}
|
|
60
|
+
})
|
|
61
|
+
})).json();
|
|
62
|
+
}
|
|
63
|
+
function injectCss() {
|
|
64
|
+
if (typeof document === "undefined") return;
|
|
65
|
+
if (document.querySelector("style[data-plugin-css=\"dsh-ssh-terminal\"]")) return;
|
|
66
|
+
const tag = document.createElement("style");
|
|
67
|
+
tag.dataset.plugin = "dsh-ssh-terminal";
|
|
68
|
+
tag.dataset.pluginCss = "dsh-ssh-terminal";
|
|
69
|
+
tag.textContent = SSH_CSS;
|
|
70
|
+
document.head.appendChild(tag);
|
|
71
|
+
}
|
|
72
|
+
function SshBody(props) {
|
|
73
|
+
const ctx = props.ctx;
|
|
74
|
+
const visible = props.visible === void 0 ? true : props.visible;
|
|
75
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
76
|
+
const [err, setErr] = (0, react.useState)("");
|
|
77
|
+
const [hostV, setHostV] = (0, react.useState)("");
|
|
78
|
+
const [userV, setUserV] = (0, react.useState)("");
|
|
79
|
+
const [portV, setPortV] = (0, react.useState)("22");
|
|
80
|
+
const [passV, setPassV] = (0, react.useState)("");
|
|
81
|
+
const [cmdV, setCmdV] = (0, react.useState)("");
|
|
82
|
+
const [sessions, setSessions] = (0, react.useState)([]);
|
|
83
|
+
const [connectionId, setConnectionId] = (0, react.useState)("");
|
|
84
|
+
const [connected, setConnected] = (0, react.useState)(false);
|
|
85
|
+
const [target, setTarget] = (0, react.useState)("");
|
|
86
|
+
const [steps, setSteps] = (0, react.useState)([]);
|
|
87
|
+
const [logEl, setLogEl] = (0, react.useState)(null);
|
|
88
|
+
const refreshSeq = (0, react.useRef)(0);
|
|
89
|
+
const selectedSession = sessions.find((s) => s.connectionId === connectionId);
|
|
90
|
+
const canRun = connected && !!selectedSession && !!selectedSession.connected;
|
|
91
|
+
async function refresh(preferredId) {
|
|
92
|
+
const seq = ++refreshSeq.current;
|
|
93
|
+
try {
|
|
94
|
+
const list = await callApi("list");
|
|
95
|
+
if (seq !== refreshSeq.current) return;
|
|
96
|
+
const nextSessions = Array.isArray(list && list.sessions) ? list.sessions : [];
|
|
97
|
+
setSessions(nextSessions);
|
|
98
|
+
const preferred = preferredId || connectionId;
|
|
99
|
+
const selected = nextSessions.some((s) => s.connectionId === preferred) ? preferred : list && list.activeConnectionId && nextSessions.some((s) => s.connectionId === list.activeConnectionId) ? list.activeConnectionId : (nextSessions.find((s) => s.connected) || nextSessions[0] || {}).connectionId || "";
|
|
100
|
+
if (selected !== connectionId) setConnectionId(selected);
|
|
101
|
+
if (!selected) {
|
|
102
|
+
if (seq !== refreshSeq.current) return;
|
|
103
|
+
setConnected(false);
|
|
104
|
+
setTarget("");
|
|
105
|
+
setSteps([]);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const t = await callApi("status", { connectionId: selected });
|
|
109
|
+
if (seq !== refreshSeq.current) return;
|
|
110
|
+
setConnected(!!(t && t.connected));
|
|
111
|
+
setTarget(t && t.target || "");
|
|
112
|
+
setSteps(t && t.steps || []);
|
|
113
|
+
} catch (e) {}
|
|
114
|
+
}
|
|
115
|
+
(0, react.useEffect)(() => {
|
|
116
|
+
injectCss();
|
|
117
|
+
if (visible === false) {
|
|
118
|
+
refreshSeq.current += 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
refresh(connectionId);
|
|
122
|
+
const stop = ctx.interval(() => refresh(connectionId), 600);
|
|
123
|
+
return () => {
|
|
124
|
+
refreshSeq.current += 1;
|
|
125
|
+
try {
|
|
126
|
+
stop();
|
|
127
|
+
} catch (e) {}
|
|
128
|
+
};
|
|
129
|
+
}, [visible, connectionId]);
|
|
130
|
+
(0, react.useEffect)(() => {
|
|
131
|
+
const el = logEl;
|
|
132
|
+
if (!el) return;
|
|
133
|
+
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) el.scrollTop = el.scrollHeight;
|
|
134
|
+
}, [steps, logEl]);
|
|
135
|
+
async function connect() {
|
|
136
|
+
setBusy(true);
|
|
137
|
+
setErr("");
|
|
138
|
+
try {
|
|
139
|
+
const r = await callApi("connect", {
|
|
140
|
+
host: hostV,
|
|
141
|
+
user: userV,
|
|
142
|
+
port: Number(portV) || 22,
|
|
143
|
+
password: passV || void 0
|
|
144
|
+
});
|
|
145
|
+
if (r && r.error) setErr(String(r.error));
|
|
146
|
+
await refresh(r && r.connectionId);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
setErr(String(e && e.message || e));
|
|
149
|
+
} finally {
|
|
150
|
+
setBusy(false);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function run() {
|
|
154
|
+
setBusy(true);
|
|
155
|
+
setErr("");
|
|
156
|
+
try {
|
|
157
|
+
const r = await callApi("exec", {
|
|
158
|
+
connectionId,
|
|
159
|
+
command: cmdV,
|
|
160
|
+
waitMs: 15e3
|
|
161
|
+
});
|
|
162
|
+
if (r && r.error) setErr(String(r.error));
|
|
163
|
+
await refresh(connectionId);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
setErr(String(e && e.message || e));
|
|
166
|
+
} finally {
|
|
167
|
+
setBusy(false);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function disconnect() {
|
|
171
|
+
setBusy(true);
|
|
172
|
+
setErr("");
|
|
173
|
+
try {
|
|
174
|
+
await callApi("disconnect", { connectionId });
|
|
175
|
+
await refresh(connectionId);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
setErr(String(e && e.message || e));
|
|
178
|
+
} finally {
|
|
179
|
+
setBusy(false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function clearHistory() {
|
|
183
|
+
try {
|
|
184
|
+
if (window.confirm("确定清空当前连接的 SSH 操作记录吗?")) {
|
|
185
|
+
await callApi("clear", connectionId ? { connectionId } : void 0);
|
|
186
|
+
await refresh(connectionId);
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
setErr(String(e && e.message || e));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const onKey = (e) => {
|
|
193
|
+
if (e.key === "Enter" && canRun && !busy) run();
|
|
194
|
+
};
|
|
195
|
+
const status = connected ? "● " + target : selectedSession ? selectedSession.exited ? "○ 已退出" : "○ 未连接" : "○ 未选择连接";
|
|
196
|
+
return (0, react.createElement)("div", { className: "dshssh-body" }, (0, react.createElement)("div", { className: "dshssh-head" }, (0, react.createElement)("span", { className: "dshssh-title" }, "SSH 远程终端"), (0, react.createElement)("span", { className: "dshssh-status " + (connected ? "ok" : "off") }, status), (0, react.createElement)("button", {
|
|
197
|
+
className: "dshssh-btn dshssh-clear",
|
|
198
|
+
onClick: clearHistory
|
|
199
|
+
}, "清空当前")), (0, react.createElement)("div", { className: "dshssh-layout" }, (0, react.createElement)("div", { className: "dshssh-sessions" }, (0, react.createElement)("div", { className: "dshssh-session-title" }, "连接列表 (" + sessions.length + ")"), (0, react.createElement)("div", { className: "dshssh-session-list" }, sessions.length ? sessions.map((s) => (0, react.createElement)("button", {
|
|
200
|
+
key: s.connectionId,
|
|
201
|
+
className: "dshssh-session" + (s.connectionId === connectionId ? " selected" : ""),
|
|
202
|
+
onClick: () => {
|
|
203
|
+
setConnectionId(s.connectionId);
|
|
204
|
+
setErr("");
|
|
205
|
+
}
|
|
206
|
+
}, (0, react.createElement)("span", { className: "dshssh-session-dot" + (s.connected ? " ok" : "") }), (0, react.createElement)("span", { className: "dshssh-session-copy" }, (0, react.createElement)("span", { className: "dshssh-session-name" }, s.label || s.target || "SSH 连接"), (0, react.createElement)("span", { className: "dshssh-session-meta" }, s.connected ? s.busy ? "执行中" : "已连接" : s.historical ? "历史记录" : "已断开")))) : (0, react.createElement)("div", { className: "dshssh-empty" }, "暂无连接"))), (0, react.createElement)("div", { className: "dshssh-main" }, (0, react.createElement)("div", { className: "dshssh-form" }, (0, react.createElement)("input", {
|
|
207
|
+
className: "dshssh-input",
|
|
208
|
+
placeholder: "主机",
|
|
209
|
+
value: hostV,
|
|
210
|
+
onChange: (e) => setHostV(e.target.value)
|
|
211
|
+
}), (0, react.createElement)("input", {
|
|
212
|
+
className: "dshssh-input",
|
|
213
|
+
placeholder: "用户",
|
|
214
|
+
value: userV,
|
|
215
|
+
onChange: (e) => setUserV(e.target.value)
|
|
216
|
+
}), (0, react.createElement)("input", {
|
|
217
|
+
className: "dshssh-input",
|
|
218
|
+
style: { width: 54 },
|
|
219
|
+
placeholder: "端口",
|
|
220
|
+
value: portV,
|
|
221
|
+
onChange: (e) => setPortV(e.target.value)
|
|
222
|
+
}), (0, react.createElement)("input", {
|
|
223
|
+
className: "dshssh-input",
|
|
224
|
+
style: { width: 96 },
|
|
225
|
+
type: "password",
|
|
226
|
+
placeholder: "密码(可选)",
|
|
227
|
+
value: passV,
|
|
228
|
+
onChange: (e) => setPassV(e.target.value)
|
|
229
|
+
}), (0, react.createElement)("button", {
|
|
230
|
+
className: "dshssh-btn",
|
|
231
|
+
disabled: busy,
|
|
232
|
+
onClick: connect
|
|
233
|
+
}, "新建连接")), (0, react.createElement)("div", { className: "dshssh-form" }, (0, react.createElement)("input", {
|
|
234
|
+
className: "dshssh-input dshssh-cmdline",
|
|
235
|
+
style: { flex: 1 },
|
|
236
|
+
placeholder: "输入命令后回车或点执行",
|
|
237
|
+
value: cmdV,
|
|
238
|
+
onChange: (e) => setCmdV(e.target.value),
|
|
239
|
+
onKeyDown: onKey
|
|
240
|
+
}), (0, react.createElement)("button", {
|
|
241
|
+
className: "dshssh-btn",
|
|
242
|
+
disabled: busy || !canRun,
|
|
243
|
+
onClick: run
|
|
244
|
+
}, "执行"), (0, react.createElement)("button", {
|
|
245
|
+
className: "dshssh-btn",
|
|
246
|
+
disabled: busy || !canRun,
|
|
247
|
+
onClick: disconnect
|
|
248
|
+
}, "断开")), (0, react.createElement)("div", {
|
|
249
|
+
className: "dshssh-log",
|
|
250
|
+
ref: (el) => setLogEl(el)
|
|
251
|
+
}, steps.length ? steps.map((s, i) => (0, react.createElement)("div", {
|
|
252
|
+
key: i,
|
|
253
|
+
className: "dshssh-step dshssh-" + s.kind
|
|
254
|
+
}, fmt(s.ts) + " " + s.text)) : (0, react.createElement)("div", { className: "dshssh-step dshssh-system" }, "还没有操作。")), err ? (0, react.createElement)("div", { className: "dshssh-err" }, String(err)) : null)));
|
|
255
|
+
}
|
|
256
|
+
const inject = ["timer", "betterSidebar"];
|
|
257
|
+
function apply(ctx) {
|
|
258
|
+
const bs = ctx.get("betterSidebar");
|
|
259
|
+
if (!bs) return;
|
|
260
|
+
ctx.effect(() => bs.registerTab({
|
|
261
|
+
id: "ssh-remote:terminal",
|
|
262
|
+
title: "SSH 终端",
|
|
263
|
+
order: 60,
|
|
264
|
+
single: true,
|
|
265
|
+
component: (p) => (0, react.createElement)(SshBody, {
|
|
266
|
+
ctx,
|
|
267
|
+
visible: p.visible
|
|
268
|
+
})
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
exports.apply = apply;
|
|
273
|
+
exports.inject = inject;
|
|
274
|
+
return module.exports;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":["useState","useRef","createElement"],"sources":["../src/client/index.tsx"],"sourcesContent":["import { createElement, useState, useEffect, useRef } from 'react'\n\nconst API = '/ssh-terminal/api'\n\nfunction fmt(ts) {\n const d = new Date(ts)\n const p = (n) => String(n).padStart(2, '0')\n return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())\n}\n\nconst SSH_CSS = `\n.dshssh-body{display:flex;flex-direction:column;gap:8px;height:100%;box-sizing:border-box;padding:10px;color:var(--dsw-alias-label-primary,inherit);}\n.dshssh-head{display:flex;align-items:center;gap:8px;}\n.dshssh-title{font-weight:700;flex:1;}\n.dshssh-status{font-weight:600;}\n.dshssh-status.ok{color:var(--dsw-alias-state-success-primary,#22c55e);}\n.dshssh-status.off{color:var(--dsw-alias-label-tertiary,#94a3b8);}\n.dshssh-layout{display:flex;gap:8px;flex:1;min-height:0;}\n.dshssh-sessions{width:150px;min-width:125px;display:flex;flex-direction:column;gap:6px;min-height:0;}\n.dshssh-session-title{font-size:11px;font-weight:700;color:var(--dsw-alias-label-secondary,#d1d5db);}\n.dshssh-session-list{display:flex;flex-direction:column;gap:5px;overflow:auto;min-height:0;}\n.dshssh-session{display:flex;align-items:center;gap:6px;width:100%;padding:6px 7px;text-align:left;border-radius:7px;border:1px solid transparent;background:var(--dsw-alias-bg-layer-2,rgba(148,163,184,.08));color:var(--dsw-alias-label-primary,inherit);cursor:pointer;font-size:11px;}\n.dshssh-session:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(148,163,184,.15));}\n.dshssh-session.selected{border-color:var(--dsw-alias-accent,#60a5fa);}\n.dshssh-session-dot{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#94a3b8);}\n.dshssh-session-dot.ok{background:var(--dsw-alias-state-success-primary,#22c55e);}\n.dshssh-session-copy{min-width:0;display:flex;flex-direction:column;gap:2px;}\n.dshssh-session-name,.dshssh-session-meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.dshssh-session-name{font-weight:600;}\n.dshssh-session-meta{color:var(--dsw-alias-label-tertiary,#94a3b8);font-size:10px;}\n.dshssh-empty{padding:6px;color:var(--dsw-alias-label-tertiary,#94a3b8);font-size:11px;}\n.dshssh-main{display:flex;flex-direction:column;gap:8px;flex:1;min-width:0;min-height:0;}\n.dshssh-form{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}\n.dshssh-input{padding:4px 7px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(148,163,184,.4));background:var(--dsw-alias-bg-layer-3,rgba(2,6,23,.5));color:var(--dsw-alias-label-primary,inherit);font-size:12px;min-width:70px;}\n.dshssh-input::placeholder{color:var(--dsw-alias-label-tertiary,#94a3b8);}\n.dshssh-btn{padding:4px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(148,163,184,.4));background:var(--dsw-alias-interactive-bg-hover,rgba(148,163,184,.15));color:var(--dsw-alias-label-primary,inherit);cursor:pointer;font-size:12px;}\n.dshssh-btn:hover{background:var(--dsw-alias-interactive-bg-active,rgba(148,163,184,.28));}\n.dshssh-btn:disabled{opacity:.5;cursor:default;}\n.dshssh-log{flex:1;min-height:120px;overflow:auto;background:var(--dsw-alias-bg-layer-1,rgba(2,6,23,.55));border:1px solid var(--dsw-alias-border-l2,rgba(148,163,184,.25));border-radius:8px;padding:8px;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:12px;line-height:1.45;white-space:pre-wrap;word-break:break-word;}\n.dshssh-step{margin:0 0 6px;}\n.dshssh-cmd{color:var(--dsw-alias-accent,#93c5fd);}\n.dshssh-cmd::before{content:\"❯ \";color:var(--dsw-alias-accent,#60a5fa);}\n.dshssh-out{color:var(--dsw-alias-label-secondary,#d1d5db);}\n.dshssh-connect{color:var(--dsw-alias-state-success-primary,#86efac);}\n.dshssh-disconnect{color:var(--dsw-alias-state-error-primary,#fca5a5);}\n.dshssh-system{color:var(--dsw-alias-label-tertiary,#94a3b8);font-style:italic;}\n.dshssh-err{color:var(--dsw-alias-state-error-primary,#fca5a5);}\n`\n\nasync function callApi(action, extra = undefined) {\n const res = await fetch(API, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ action, ...(extra || {}) }),\n })\n return await res.json()\n}\n\nfunction injectCss() {\n if (typeof document === 'undefined') return\n if (document.querySelector('style[data-plugin-css=\"dsh-ssh-terminal\"]')) return\n const tag = document.createElement('style')\n tag.dataset.plugin = 'dsh-ssh-terminal'\n tag.dataset.pluginCss = 'dsh-ssh-terminal'\n tag.textContent = SSH_CSS\n document.head.appendChild(tag)\n}\n\nfunction SshBody(props) {\n const ctx = props.ctx\n const visible = props.visible === undefined ? true : props.visible\n const [busy, setBusy] = useState(false)\n const [err, setErr] = useState('')\n const [hostV, setHostV] = useState('')\n const [userV, setUserV] = useState('')\n const [portV, setPortV] = useState('22')\n const [passV, setPassV] = useState('')\n const [cmdV, setCmdV] = useState('')\n const [sessions, setSessions] = useState([])\n const [connectionId, setConnectionId] = useState('')\n const [connected, setConnected] = useState(false)\n const [target, setTarget] = useState('')\n const [steps, setSteps] = useState([])\n const [logEl, setLogEl] = useState(null)\n const refreshSeq = useRef(0)\n\n const selectedSession = sessions.find((s) => s.connectionId === connectionId)\n const canRun = connected && !!selectedSession && !!selectedSession.connected\n\n async function refresh(preferredId) {\n const seq = ++refreshSeq.current\n try {\n const list = await callApi('list')\n if (seq !== refreshSeq.current) return\n const nextSessions = Array.isArray(list && list.sessions) ? list.sessions : []\n setSessions(nextSessions)\n const preferred = preferredId || connectionId\n const selected = nextSessions.some((s) => s.connectionId === preferred)\n ? preferred\n : (list && list.activeConnectionId && nextSessions.some((s) => s.connectionId === list.activeConnectionId)\n ? list.activeConnectionId\n : ((nextSessions.find((s) => s.connected) || nextSessions[0] || {}).connectionId || ''))\n if (selected !== connectionId) setConnectionId(selected)\n if (!selected) {\n if (seq !== refreshSeq.current) return\n setConnected(false)\n setTarget('')\n setSteps([])\n return\n }\n const t = await callApi('status', { connectionId: selected })\n if (seq !== refreshSeq.current) return\n setConnected(!!(t && t.connected))\n setTarget((t && t.target) || '')\n setSteps((t && t.steps) || [])\n } catch (e) {}\n }\n\n useEffect(() => {\n injectCss()\n if (visible === false) { refreshSeq.current += 1; return }\n refresh(connectionId)\n const stop = ctx.interval(() => refresh(connectionId), 600)\n return () => {\n refreshSeq.current += 1\n try { stop() } catch (e) {}\n }\n }, [visible, connectionId])\n\n useEffect(() => {\n const el = logEl\n if (!el) return\n const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40\n if (nearBottom) el.scrollTop = el.scrollHeight\n }, [steps, logEl])\n\n async function connect() {\n setBusy(true); setErr('')\n try {\n const r = await callApi('connect', { host: hostV, user: userV, port: Number(portV) || 22, password: passV || undefined })\n if (r && r.error) setErr(String(r.error))\n await refresh(r && r.connectionId)\n }\n catch (e) { setErr(String((e && e.message) || e)) } finally { setBusy(false) }\n }\n async function run() {\n setBusy(true); setErr('')\n try {\n const r = await callApi('exec', { connectionId, command: cmdV, waitMs: 15000 })\n if (r && r.error) setErr(String(r.error))\n await refresh(connectionId)\n }\n catch (e) { setErr(String((e && e.message) || e)) } finally { setBusy(false) }\n }\n async function disconnect() {\n setBusy(true); setErr('')\n try { await callApi('disconnect', { connectionId }); await refresh(connectionId) }\n catch (e) { setErr(String((e && e.message) || e)) } finally { setBusy(false) }\n }\n async function clearHistory() {\n try {\n if (window.confirm('确定清空当前连接的 SSH 操作记录吗?')) {\n await callApi('clear', connectionId ? { connectionId } : undefined)\n await refresh(connectionId)\n }\n } catch (e) { setErr(String((e && e.message) || e)) }\n }\n\n const onKey = (e) => { if (e.key === 'Enter' && canRun && !busy) run() }\n const status = connected ? ('● ' + target) : (selectedSession ? (selectedSession.exited ? '○ 已退出' : '○ 未连接') : '○ 未选择连接')\n\n return createElement('div', { className: 'dshssh-body' },\n createElement('div', { className: 'dshssh-head' },\n createElement('span', { className: 'dshssh-title' }, 'SSH 远程终端'),\n createElement('span', { className: 'dshssh-status ' + (connected ? 'ok' : 'off') }, status),\n createElement('button', { className: 'dshssh-btn dshssh-clear', onClick: clearHistory }, '清空当前'),\n ),\n createElement('div', { className: 'dshssh-layout' },\n createElement('div', { className: 'dshssh-sessions' },\n createElement('div', { className: 'dshssh-session-title' }, '连接列表 (' + sessions.length + ')'),\n createElement('div', { className: 'dshssh-session-list' },\n sessions.length\n ? sessions.map((s) => createElement('button', {\n key: s.connectionId,\n className: 'dshssh-session' + (s.connectionId === connectionId ? ' selected' : ''),\n onClick: () => { setConnectionId(s.connectionId); setErr('') },\n },\n createElement('span', { className: 'dshssh-session-dot' + (s.connected ? ' ok' : '') }),\n createElement('span', { className: 'dshssh-session-copy' },\n createElement('span', { className: 'dshssh-session-name' }, s.label || s.target || 'SSH 连接'),\n createElement('span', { className: 'dshssh-session-meta' }, s.connected ? (s.busy ? '执行中' : '已连接') : (s.historical ? '历史记录' : '已断开')),\n )))\n : createElement('div', { className: 'dshssh-empty' }, '暂无连接'),\n ),\n ),\n createElement('div', { className: 'dshssh-main' },\n createElement('div', { className: 'dshssh-form' },\n createElement('input', { className: 'dshssh-input', placeholder: '主机', value: hostV, onChange: (e) => setHostV(e.target.value) }),\n createElement('input', { className: 'dshssh-input', placeholder: '用户', value: userV, onChange: (e) => setUserV(e.target.value) }),\n createElement('input', { className: 'dshssh-input', style: { width: 54 }, placeholder: '端口', value: portV, onChange: (e) => setPortV(e.target.value) }),\n createElement('input', { className: 'dshssh-input', style: { width: 96 }, type: 'password', placeholder: '密码(可选)', value: passV, onChange: (e) => setPassV(e.target.value) }),\n createElement('button', { className: 'dshssh-btn', disabled: busy, onClick: connect }, '新建连接'),\n ),\n createElement('div', { className: 'dshssh-form' },\n createElement('input', { className: 'dshssh-input dshssh-cmdline', style: { flex: 1 }, placeholder: '输入命令后回车或点执行', value: cmdV, onChange: (e) => setCmdV(e.target.value), onKeyDown: onKey }),\n createElement('button', { className: 'dshssh-btn', disabled: busy || !canRun, onClick: run }, '执行'),\n createElement('button', { className: 'dshssh-btn', disabled: busy || !canRun, onClick: disconnect }, '断开'),\n ),\n createElement('div', { className: 'dshssh-log', ref: (el) => setLogEl(el) },\n steps.length\n ? steps.map((s, i) => createElement('div', { key: i, className: 'dshssh-step dshssh-' + s.kind }, (fmt(s.ts) + ' ' + s.text)))\n : createElement('div', { className: 'dshssh-step dshssh-system' }, '还没有操作。'),\n ),\n err ? createElement('div', { className: 'dshssh-err' }, String(err)) : null,\n ),\n ),\n )\n}\n\nexport const inject = ['timer', 'betterSidebar']\n\nexport function apply(ctx) {\n const bs = ctx.get('betterSidebar')\n if (!bs) return\n ctx.effect(() => bs.registerTab({\n id: 'ssh-remote:terminal',\n title: 'SSH 终端',\n order: 60,\n single: true,\n component: (p) => createElement(SshBody, { ctx, visible: p.visible }),\n }))\n}\n"],"mappings":"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"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// dsh-ssh-terminal — Host half
|
|
2
|
+
// Open one isolated interactive SSH session per connectionId, expose agent
|
|
3
|
+
// tools (via the tools service) and an HTTP route (via webServer) that the
|
|
4
|
+
// better-sidebar tab uses for connect/exec/disconnect/status.
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const inject = ['timer', 'subprocess', 'webServer', 'tools']
|
|
11
|
+
|
|
12
|
+
export function apply(ctx) {
|
|
13
|
+
const fs = ctx.get('fs')
|
|
14
|
+
const sessions = new Map()
|
|
15
|
+
const connectionMeta = new Map()
|
|
16
|
+
const HISTORY_PATH = join(homedir(), '.dsh', 'ssh-terminal-history.json')
|
|
17
|
+
const MAX_HISTORY = 1200
|
|
18
|
+
const MAX_STEPS = 600
|
|
19
|
+
const LEGACY_SESSION_ID = 'legacy'
|
|
20
|
+
let history = []
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(HISTORY_PATH, 'utf8')
|
|
23
|
+
const parsed = JSON.parse(raw)
|
|
24
|
+
if (Array.isArray(parsed)) {
|
|
25
|
+
history = parsed.map((entry) => ({
|
|
26
|
+
connectionId: entry && entry.connectionId ? String(entry.connectionId) : LEGACY_SESSION_ID,
|
|
27
|
+
kind: entry && entry.kind ? String(entry.kind) : 'system',
|
|
28
|
+
text: entry && entry.text != null ? String(entry.text) : '',
|
|
29
|
+
ts: entry && Number(entry.ts) ? Number(entry.ts) : Date.now(),
|
|
30
|
+
...(entry && entry.target ? { target: String(entry.target) } : {}),
|
|
31
|
+
...(entry && entry.label ? { label: String(entry.label) } : {}),
|
|
32
|
+
}))
|
|
33
|
+
}
|
|
34
|
+
} catch (e) {}
|
|
35
|
+
if (history.length > MAX_HISTORY) history = history.slice(-MAX_HISTORY)
|
|
36
|
+
|
|
37
|
+
let activeSessionId = null
|
|
38
|
+
let lastTarget = ''
|
|
39
|
+
function persist() {
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(HISTORY_PATH), { recursive: true })
|
|
42
|
+
writeFileSync(HISTORY_PATH, JSON.stringify(history))
|
|
43
|
+
} catch (e) {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureMeta(connectionId) {
|
|
47
|
+
let meta = connectionMeta.get(connectionId)
|
|
48
|
+
if (!meta) {
|
|
49
|
+
meta = { connectionId, target: '', label: '', connected: false, lastActivityAt: 0 }
|
|
50
|
+
connectionMeta.set(connectionId, meta)
|
|
51
|
+
}
|
|
52
|
+
return meta
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const event of history) {
|
|
56
|
+
const meta = ensureMeta(event.connectionId)
|
|
57
|
+
if (event.target) meta.target = event.target
|
|
58
|
+
if (event.label) meta.label = event.label
|
|
59
|
+
meta.lastActivityAt = Math.max(meta.lastActivityAt, event.ts)
|
|
60
|
+
meta.connected = false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cleanStream(s) {
|
|
64
|
+
s = String(s)
|
|
65
|
+
s = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
66
|
+
s = s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
67
|
+
s = s.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
|
68
|
+
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
69
|
+
return s
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendStep(connectionId, kind, text, extra) {
|
|
73
|
+
const event = {
|
|
74
|
+
connectionId: connectionId || LEGACY_SESSION_ID,
|
|
75
|
+
kind,
|
|
76
|
+
text: String(text),
|
|
77
|
+
ts: Date.now(),
|
|
78
|
+
...(extra && extra.target ? { target: String(extra.target) } : {}),
|
|
79
|
+
...(extra && extra.label ? { label: String(extra.label) } : {}),
|
|
80
|
+
}
|
|
81
|
+
history.push(event)
|
|
82
|
+
if (history.length > MAX_HISTORY) history = history.slice(-MAX_HISTORY)
|
|
83
|
+
|
|
84
|
+
const meta = ensureMeta(event.connectionId)
|
|
85
|
+
if (event.target) meta.target = event.target
|
|
86
|
+
if (event.label) meta.label = event.label
|
|
87
|
+
meta.lastActivityAt = event.ts
|
|
88
|
+
if (kind === 'connect') meta.connected = true
|
|
89
|
+
if (kind === 'disconnect') meta.connected = false
|
|
90
|
+
persist()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function startDrain(s) {
|
|
94
|
+
const decoder = new TextDecoder()
|
|
95
|
+
const run = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
for await (const chunk of s.handle.output) {
|
|
98
|
+
if (s.drainState.stopped || sessions.get(s.connectionId) !== s) break
|
|
99
|
+
const text = cleanStream(decoder.decode(chunk, { stream: true }))
|
|
100
|
+
if (!text) continue
|
|
101
|
+
s.log += text
|
|
102
|
+
s.lastActivityAt = Date.now()
|
|
103
|
+
tryPassword(s)
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { try { console.error('ssh output stream error', e) } catch (e2) {} }
|
|
106
|
+
if (sessions.get(s.connectionId) === s && !s.drainState.stopped) {
|
|
107
|
+
s.exited = true
|
|
108
|
+
s.connected = false
|
|
109
|
+
const meta = ensureMeta(s.connectionId)
|
|
110
|
+
meta.connected = false
|
|
111
|
+
meta.lastActivityAt = Date.now()
|
|
112
|
+
}
|
|
113
|
+
})()
|
|
114
|
+
return run
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tail(s, n) { return s ? s.log.slice(-n) : '' }
|
|
118
|
+
|
|
119
|
+
function tryPassword(s) {
|
|
120
|
+
if (!s || !s.passwordPending || s.passwordSent) return
|
|
121
|
+
const t = s.log.trimEnd()
|
|
122
|
+
if (/password\s*:\s*$/i.test(t) || /password.*:\s*$/i.test(t.slice(-90))) {
|
|
123
|
+
const p = s.passwordPending
|
|
124
|
+
s.passwordPending = null
|
|
125
|
+
s.passwordSent = true
|
|
126
|
+
if (s.handle) s.handle.write(p + '\n').catch(() => {})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function makeConnectionId() { return 'ssh-' + randomUUID() }
|
|
131
|
+
|
|
132
|
+
function resolveConnectionId(value, allowHistorical) {
|
|
133
|
+
const requested = value == null || value === '' ? null : String(value)
|
|
134
|
+
if (requested) {
|
|
135
|
+
if (sessions.has(requested)) return requested
|
|
136
|
+
if (allowHistorical && connectionMeta.has(requested)) return requested
|
|
137
|
+
throw new Error('unknown SSH connection: ' + requested)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const active = activeSessionId ? sessions.get(activeSessionId) : null
|
|
141
|
+
if (active && (allowHistorical || (active.connected && !active.exited))) return activeSessionId
|
|
142
|
+
for (const [connectionId, s] of sessions) {
|
|
143
|
+
if (s.connected && !s.exited) {
|
|
144
|
+
activeSessionId = connectionId
|
|
145
|
+
return connectionId
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function doConnect(opts) {
|
|
152
|
+
opts = opts || {}
|
|
153
|
+
const host = opts.host, user = opts.user, port = opts.port || 22
|
|
154
|
+
if (!host || !user) throw new Error('ssh_connect requires host and user')
|
|
155
|
+
|
|
156
|
+
const connectionId = opts.connectionId ? String(opts.connectionId) : makeConnectionId()
|
|
157
|
+
if (sessions.has(connectionId)) await doDisconnect(connectionId)
|
|
158
|
+
|
|
159
|
+
let sshBin = 'ssh'
|
|
160
|
+
try { sshBin = await ctx.subprocess.resolveExecutable('ssh') }
|
|
161
|
+
catch (e) { throw new Error('ssh executable not found: ' + ((e && e.message) || e)) }
|
|
162
|
+
const argv = [sshBin, '-tt', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=20']
|
|
163
|
+
if (port && String(port) !== '22') argv.push('-p', String(port))
|
|
164
|
+
if (opts.identity) argv.push('-i', String(opts.identity))
|
|
165
|
+
argv.push(user + '@' + host)
|
|
166
|
+
let cwd = '.'
|
|
167
|
+
try {
|
|
168
|
+
if (fs) { const t = await fs.resolve('.', {}); const p = fs.processPath(t); if (p) cwd = p }
|
|
169
|
+
} catch (e) {}
|
|
170
|
+
let handle
|
|
171
|
+
try { handle = await ctx.subprocess.spawnTerminal({ argv, cwd, rows: 30, cols: 120, graceMs: 5000 }) }
|
|
172
|
+
catch (e) { throw new Error('failed to spawn ssh: ' + ((e && e.message) || e)) }
|
|
173
|
+
|
|
174
|
+
const target = user + '@' + host + (String(port) !== '22' ? ':' + port : '')
|
|
175
|
+
const label = opts.label ? String(opts.label) : ''
|
|
176
|
+
const drainState = { stopped: false }
|
|
177
|
+
const s = {
|
|
178
|
+
connectionId,
|
|
179
|
+
label,
|
|
180
|
+
handle,
|
|
181
|
+
log: '',
|
|
182
|
+
lastActivityAt: Date.now(),
|
|
183
|
+
target,
|
|
184
|
+
connected: false,
|
|
185
|
+
exited: false,
|
|
186
|
+
exitCode: null,
|
|
187
|
+
busy: false,
|
|
188
|
+
queue: Promise.resolve(),
|
|
189
|
+
passwordPending: opts.password || null, passwordSent: false,
|
|
190
|
+
drainState,
|
|
191
|
+
}
|
|
192
|
+
sessions.set(connectionId, s)
|
|
193
|
+
activeSessionId = connectionId
|
|
194
|
+
lastTarget = target
|
|
195
|
+
const meta = ensureMeta(connectionId)
|
|
196
|
+
meta.target = target
|
|
197
|
+
meta.label = label
|
|
198
|
+
meta.connected = false
|
|
199
|
+
meta.lastActivityAt = s.lastActivityAt
|
|
200
|
+
|
|
201
|
+
s.drain = startDrain(s)
|
|
202
|
+
Promise.resolve(handle.done).then((o) => {
|
|
203
|
+
if (sessions.get(connectionId) !== s) return
|
|
204
|
+
s.exited = true
|
|
205
|
+
s.connected = false
|
|
206
|
+
s.exitCode = o && o.exitCode
|
|
207
|
+
const currentMeta = ensureMeta(connectionId)
|
|
208
|
+
currentMeta.connected = false
|
|
209
|
+
currentMeta.lastActivityAt = Date.now()
|
|
210
|
+
}).catch(() => {
|
|
211
|
+
if (sessions.get(connectionId) !== s) return
|
|
212
|
+
s.exited = true
|
|
213
|
+
s.connected = false
|
|
214
|
+
ensureMeta(connectionId).connected = false
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
appendStep(connectionId, 'system', 'Connecting to ' + target + ' ...', { target, label })
|
|
218
|
+
const start = Date.now()
|
|
219
|
+
let ready = false
|
|
220
|
+
let lateFail = null
|
|
221
|
+
while (Date.now() - start < 25000) {
|
|
222
|
+
if (sessions.get(connectionId) !== s) throw new Error('SSH connection was closed')
|
|
223
|
+
if (s.exited) break
|
|
224
|
+
tryPassword(s)
|
|
225
|
+
const t = s.log.trimEnd()
|
|
226
|
+
if (t.length > 0 && /[$#%>]\s*$/.test(t)) { ready = true; break }
|
|
227
|
+
if (/PERMISSION DENIED|CONNECTION REFUSED|CONNECTION TIMED OUT|NO ROUTE TO HOST|HOST KEY VERIFICATION FAILED|TOO MANY AUTHENTICATION|AUTHENTICATION FAILED|UNABLE TO NEGOTIATE/.test(s.log.toUpperCase())) { lateFail = s.log; break }
|
|
228
|
+
await ctx.timeout(250)
|
|
229
|
+
}
|
|
230
|
+
if (ready) {
|
|
231
|
+
s.connected = true
|
|
232
|
+
lastTarget = s.target
|
|
233
|
+
meta.connected = true
|
|
234
|
+
appendStep(connectionId, 'connect', 'Connected to ' + s.target, { target: s.target, label: s.label })
|
|
235
|
+
return { ok: true, connectionId, label: s.label, target: s.target, connected: true, logLength: s.log.length, logTail: tail(s, 300) }
|
|
236
|
+
}
|
|
237
|
+
if (s.exited || lateFail) {
|
|
238
|
+
const info = ((lateFail || tail(s, 400)) || 'host unreachable').trim()
|
|
239
|
+
await doDisconnect(connectionId)
|
|
240
|
+
throw new Error('SSH connection failed: ' + info)
|
|
241
|
+
}
|
|
242
|
+
s.connected = true
|
|
243
|
+
lastTarget = s.target
|
|
244
|
+
meta.connected = true
|
|
245
|
+
appendStep(connectionId, 'connect', 'Connected to ' + s.target, { target: s.target, label: s.label })
|
|
246
|
+
return { ok: true, connectionId, label: s.label, target: s.target, connected: true, logLength: s.log.length, logTail: tail(s, 300) }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function stripEcho(delta, cmd) {
|
|
250
|
+
if (!cmd) return delta
|
|
251
|
+
const nl = delta.indexOf('\n')
|
|
252
|
+
if (nl >= 0) { const first = delta.slice(0, nl); if (first.indexOf(cmd) >= 0) return delta.slice(nl + 1) }
|
|
253
|
+
else if (delta === cmd) return ''
|
|
254
|
+
return delta
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function enqueue(s, task) {
|
|
258
|
+
const previous = s.queue || Promise.resolve()
|
|
259
|
+
const current = previous.catch(() => {}).then(task)
|
|
260
|
+
s.queue = current.catch(() => {})
|
|
261
|
+
return current
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function doExecNow(s, command, opts) {
|
|
265
|
+
if (sessions.get(s.connectionId) !== s || !s.connected || s.exited) throw new Error('no active SSH connection; call ssh_connect first')
|
|
266
|
+
const waitMs = opts.waitMs && Number(opts.waitMs) > 0 ? Number(opts.waitMs) : 12000
|
|
267
|
+
const idleMs = 700
|
|
268
|
+
s.busy = true
|
|
269
|
+
try {
|
|
270
|
+
appendStep(s.connectionId, 'cmd', command, { target: s.target, label: s.label })
|
|
271
|
+
const offset = s.log.length
|
|
272
|
+
s.lastActivityAt = Date.now()
|
|
273
|
+
try { await s.handle.write(command + '\n') }
|
|
274
|
+
catch (e) { throw new Error('failed to write to SSH: ' + ((e && e.message) || e)) }
|
|
275
|
+
const start = Date.now()
|
|
276
|
+
while (Date.now() - start < waitMs) {
|
|
277
|
+
await ctx.timeout(150)
|
|
278
|
+
if (s.exited) break
|
|
279
|
+
if (Date.now() - s.lastActivityAt >= idleMs && Date.now() - start >= 300) break
|
|
280
|
+
}
|
|
281
|
+
let delta = s.log.slice(offset)
|
|
282
|
+
delta = stripEcho(delta, command)
|
|
283
|
+
delta = delta.replace(/\r/g, '').replace(/[\t ]+$/gm, '').replace(/\s+$/, '')
|
|
284
|
+
if (delta) appendStep(s.connectionId, 'out', delta, { target: s.target, label: s.label })
|
|
285
|
+
return { ok: true, connectionId: s.connectionId, output: delta, exited: !!s.exited, exitCode: s.exitCode, logLength: s.log.length, logTail: tail(s, 300) }
|
|
286
|
+
} finally {
|
|
287
|
+
s.busy = false
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function doExec(command, opts) {
|
|
292
|
+
opts = opts || {}
|
|
293
|
+
const cmd = String(command == null ? '' : command)
|
|
294
|
+
if (!cmd) throw new Error('command is required')
|
|
295
|
+
const connectionId = resolveConnectionId(opts.connectionId, false)
|
|
296
|
+
const s = connectionId ? sessions.get(connectionId) : null
|
|
297
|
+
if (!s) throw new Error('no active SSH connection; call ssh_connect first')
|
|
298
|
+
return await enqueue(s, () => doExecNow(s, cmd, opts))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function doDisconnect(requestedId) {
|
|
302
|
+
const connectionId = resolveConnectionId(requestedId, true)
|
|
303
|
+
if (!connectionId) return { ok: true, disconnected: false }
|
|
304
|
+
const s = sessions.get(connectionId)
|
|
305
|
+
if (!s) return { ok: true, connectionId, disconnected: false }
|
|
306
|
+
|
|
307
|
+
appendStep(connectionId, 'disconnect', 'Disconnected from ' + s.target, { target: s.target, label: s.label })
|
|
308
|
+
s.connected = false
|
|
309
|
+
s.exited = true
|
|
310
|
+
sessions.delete(connectionId)
|
|
311
|
+
if (s.drainState) s.drainState.stopped = true
|
|
312
|
+
if (activeSessionId === connectionId) {
|
|
313
|
+
activeSessionId = null
|
|
314
|
+
for (const [nextId, next] of sessions) {
|
|
315
|
+
if (next.connected && !next.exited) { activeSessionId = nextId; break }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const meta = ensureMeta(connectionId)
|
|
319
|
+
meta.connected = false
|
|
320
|
+
meta.lastActivityAt = Date.now()
|
|
321
|
+
try { if (s.handle) await s.handle.terminate() } catch (e) {}
|
|
322
|
+
return { ok: true, connectionId, disconnected: true, target: s.target }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function sessionSummary(s) {
|
|
326
|
+
return {
|
|
327
|
+
connectionId: s.connectionId,
|
|
328
|
+
label: s.label,
|
|
329
|
+
target: s.target,
|
|
330
|
+
connected: !!(s.connected && !s.exited),
|
|
331
|
+
exited: !!s.exited,
|
|
332
|
+
busy: !!s.busy,
|
|
333
|
+
historical: false,
|
|
334
|
+
lastActivityAt: s.lastActivityAt,
|
|
335
|
+
stepCount: history.reduce((count, event) => count + (event.connectionId === s.connectionId ? 1 : 0), 0),
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function listSessions() {
|
|
340
|
+
const listed = new Map()
|
|
341
|
+
for (const s of sessions.values()) listed.set(s.connectionId, sessionSummary(s))
|
|
342
|
+
for (const meta of connectionMeta.values()) {
|
|
343
|
+
if (listed.has(meta.connectionId)) continue
|
|
344
|
+
listed.set(meta.connectionId, {
|
|
345
|
+
connectionId: meta.connectionId,
|
|
346
|
+
label: meta.label || (meta.connectionId === LEGACY_SESSION_ID ? '历史记录' : ''),
|
|
347
|
+
target: meta.target || (meta.connectionId === LEGACY_SESSION_ID ? '历史记录' : ''),
|
|
348
|
+
connected: false,
|
|
349
|
+
exited: true,
|
|
350
|
+
busy: false,
|
|
351
|
+
historical: true,
|
|
352
|
+
lastActivityAt: meta.lastActivityAt,
|
|
353
|
+
stepCount: history.reduce((count, event) => count + (event.connectionId === meta.connectionId ? 1 : 0), 0),
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
const sessionsList = Array.from(listed.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
357
|
+
return { sessions: sessionsList, activeConnectionId: activeSessionId && sessions.has(activeSessionId) ? activeSessionId : null }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function transcriptFor(connectionId) {
|
|
361
|
+
const s = sessions.get(connectionId)
|
|
362
|
+
const meta = connectionMeta.get(connectionId)
|
|
363
|
+
return {
|
|
364
|
+
connectionId,
|
|
365
|
+
label: s ? s.label : (meta && meta.label) || (connectionId === LEGACY_SESSION_ID ? '历史记录' : ''),
|
|
366
|
+
connected: !!(s && s.connected && !s.exited),
|
|
367
|
+
exited: !!(!s || s.exited),
|
|
368
|
+
busy: !!(s && s.busy),
|
|
369
|
+
target: s ? s.target : (meta && meta.target) || (connectionId === LEGACY_SESSION_ID ? '历史记录' : lastTarget),
|
|
370
|
+
steps: history.filter((event) => event.connectionId === connectionId).slice(-MAX_STEPS),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function transcript(requestedId) {
|
|
375
|
+
const connectionId = resolveConnectionId(requestedId, true)
|
|
376
|
+
if (connectionId) return transcriptFor(connectionId)
|
|
377
|
+
return { connectionId: null, connected: false, target: lastTarget, steps: history.slice(-MAX_STEPS) }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function clearHistory(requestedId) {
|
|
381
|
+
const connectionId = requestedId == null || requestedId === '' ? null : String(requestedId)
|
|
382
|
+
if (connectionId) {
|
|
383
|
+
history = history.filter((event) => event.connectionId !== connectionId)
|
|
384
|
+
if (!sessions.has(connectionId)) connectionMeta.delete(connectionId)
|
|
385
|
+
} else {
|
|
386
|
+
history = []
|
|
387
|
+
connectionMeta.clear()
|
|
388
|
+
}
|
|
389
|
+
persist()
|
|
390
|
+
return { ok: true, cleared: true, connectionId }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function textOut(a, v) {
|
|
394
|
+
let s = (v && typeof v === 'object') ? ((v.output !== undefined ? String(v.output) : JSON.stringify(v))) : String(v)
|
|
395
|
+
if (s.length > 6000) s = s.slice(0, 6000) + '\n...[truncated]'
|
|
396
|
+
return [{ type: 'text', text: s }]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const objParams = (properties, required) => ({ type: 'object', additionalProperties: true, properties, ...(required && required.length ? { required } : {}) })
|
|
400
|
+
const connectionIdParam = { type: 'string', description: 'Connection ID returned by ssh_connect; omit only for legacy active-session behavior' }
|
|
401
|
+
|
|
402
|
+
ctx.effect(() => ctx.tools.register({
|
|
403
|
+
name: 'ssh_connect',
|
|
404
|
+
description: 'Open an interactive SSH session. Each call creates an independent connection unless connectionId is supplied to replace that connection. Always keep and reuse the returned connectionId when several tasks are active.',
|
|
405
|
+
parameters: objParams({
|
|
406
|
+
connectionId: connectionIdParam,
|
|
407
|
+
label: { type: 'string', description: 'Optional human-readable name for this task/connection' },
|
|
408
|
+
host: { type: 'string', description: 'Remote hostname or IP' },
|
|
409
|
+
user: { type: 'string', description: 'SSH username' },
|
|
410
|
+
port: { type: 'number', description: 'Port (default 22)' },
|
|
411
|
+
password: { type: 'string', description: 'Password for password auth' },
|
|
412
|
+
identity: { type: 'string', description: 'Path to a private key file (-i)' },
|
|
413
|
+
}, ['host', 'user']),
|
|
414
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: textOut },
|
|
415
|
+
timeoutMs: 30000,
|
|
416
|
+
async execute(args) { try { return await doConnect(args) } catch (e) { return { ok: false, error: ((e && e.message) || String(e)) } } },
|
|
417
|
+
}))
|
|
418
|
+
|
|
419
|
+
ctx.effect(() => ctx.tools.register({
|
|
420
|
+
name: 'ssh_exec',
|
|
421
|
+
description: 'Send one command to a specific SSH connection and return its output. Pass the connectionId from ssh_connect to keep concurrent tasks isolated; omit it only for the legacy active connection.',
|
|
422
|
+
parameters: objParams({
|
|
423
|
+
connectionId: connectionIdParam,
|
|
424
|
+
command: { type: 'string', description: 'Shell command to run on the remote host' },
|
|
425
|
+
waitMs: { type: 'number', description: 'Max milliseconds to wait for the command to settle (default 12000)' },
|
|
426
|
+
}, ['command']),
|
|
427
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: textOut },
|
|
428
|
+
timeoutMs: 16000,
|
|
429
|
+
async execute(args) { try { return await doExec(args.command, args) } catch (e) { return { ok: false, error: ((e && e.message) || String(e)) } } },
|
|
430
|
+
}))
|
|
431
|
+
|
|
432
|
+
ctx.effect(() => ctx.tools.register({
|
|
433
|
+
name: 'ssh_disconnect',
|
|
434
|
+
description: 'Close one SSH connection. Pass connectionId for a specific task; omit it only for the legacy active connection.',
|
|
435
|
+
parameters: objParams({ connectionId: connectionIdParam }, []),
|
|
436
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: textOut },
|
|
437
|
+
timeoutMs: 8000,
|
|
438
|
+
async execute(args) { try { return await doDisconnect(args && args.connectionId) } catch (e) { return { ok: false, error: ((e && e.message) || String(e)) } } },
|
|
439
|
+
}))
|
|
440
|
+
|
|
441
|
+
ctx.effect(() => ctx.tools.register({
|
|
442
|
+
name: 'ssh_status',
|
|
443
|
+
description: 'Show one SSH connection transcript. Pass connectionId to inspect a specific task; omit it for the legacy active-session behavior.',
|
|
444
|
+
parameters: objParams({ connectionId: connectionIdParam }, []),
|
|
445
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: textOut },
|
|
446
|
+
async execute(args) { return transcript(args && args.connectionId) },
|
|
447
|
+
}))
|
|
448
|
+
|
|
449
|
+
ctx.effect(() => ctx.tools.register({
|
|
450
|
+
name: 'ssh_list',
|
|
451
|
+
description: 'List active and recently recorded SSH connections so concurrent tasks can select the correct connectionId.',
|
|
452
|
+
parameters: objParams({}, []),
|
|
453
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: textOut },
|
|
454
|
+
async execute() { return listSessions() },
|
|
455
|
+
}))
|
|
456
|
+
|
|
457
|
+
async function readBody(req) {
|
|
458
|
+
const chunks = []
|
|
459
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
460
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
461
|
+
return text ? JSON.parse(text) : {}
|
|
462
|
+
}
|
|
463
|
+
function send(res, obj) {
|
|
464
|
+
res.setHeader('content-type', 'application/json')
|
|
465
|
+
res.end(JSON.stringify(obj))
|
|
466
|
+
}
|
|
467
|
+
function isLocal(req) {
|
|
468
|
+
const host = String(req.headers.host || '')
|
|
469
|
+
return /127\.0\.0\.1|localhost|\[::1\]/.test(host)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
ctx.effect(() => ctx.webServer.register({
|
|
473
|
+
kind: 'exact',
|
|
474
|
+
path: '/ssh-terminal/api',
|
|
475
|
+
handler: async (req, res) => {
|
|
476
|
+
if (!isLocal(req)) { send(res, { ok: false, error: 'forbidden' }); return }
|
|
477
|
+
try {
|
|
478
|
+
const data = await readBody(req)
|
|
479
|
+
const action = data && data.action
|
|
480
|
+
let out
|
|
481
|
+
if (action === 'clear') out = clearHistory(data.connectionId)
|
|
482
|
+
else if (action === 'list') out = listSessions()
|
|
483
|
+
else if (action === 'connect') out = await doConnect(data)
|
|
484
|
+
else if (action === 'exec') out = await doExec(data.command, data)
|
|
485
|
+
else if (action === 'disconnect') out = await doDisconnect(data.connectionId)
|
|
486
|
+
else out = transcript(data.connectionId)
|
|
487
|
+
send(res, out)
|
|
488
|
+
} catch (e) {
|
|
489
|
+
send(res, { ok: false, error: ((e && e.message) || String(e)) })
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
}))
|
|
493
|
+
|
|
494
|
+
ctx.effect(() => () => {
|
|
495
|
+
for (const connectionId of Array.from(sessions.keys())) void doDisconnect(connectionId)
|
|
496
|
+
})
|
|
497
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-ssh-terminal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SSH 远程终端:在 dsh-better-sidebar 侧边栏里连接远程主机,逐步查看命令与输出。",
|
|
5
|
+
"author": "sunzhyang1616-ui",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/sunzhyang1616-ui/dsh-ssh-terminal.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/sunzhyang1616-ui/dsh-ssh-terminal#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/sunzhyang1616-ui/dsh-ssh-terminal/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"ssh",
|
|
24
|
+
"cordis",
|
|
25
|
+
"better-sidebar",
|
|
26
|
+
"dsh-better-sidebar"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"main": "lib/index.js",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./lib/index.js",
|
|
34
|
+
"./client": "./lib/client.js",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsdown",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
},
|
|
45
|
+
"client": {
|
|
46
|
+
"inject": [],
|
|
47
|
+
"platform": "web"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
+
"react": "^18.3.1",
|
|
53
|
+
"dsh-better-sidebar": ">=0.12.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"dsh-better-sidebar": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^22.20.0",
|
|
62
|
+
"@types/react": "^18.3.31",
|
|
63
|
+
"@types/react-dom": "^18.3.1",
|
|
64
|
+
"lightningcss": "^1.33.0",
|
|
65
|
+
"react": "^18.3.1",
|
|
66
|
+
"tsdown": "^0.22.14",
|
|
67
|
+
"typescript": "^5.6.3"
|
|
68
|
+
},
|
|
69
|
+
"files": [
|
|
70
|
+
"CHANGELOG.md",
|
|
71
|
+
"LICENSE",
|
|
72
|
+
"README.md",
|
|
73
|
+
"README_EN.md",
|
|
74
|
+
"cordis.patch.yml",
|
|
75
|
+
"docs",
|
|
76
|
+
"lib"
|
|
77
|
+
]
|
|
78
|
+
}
|