@hyzyn/dsh-tty 0.2.1 → 0.9.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/LICENSE +21 -0
- package/README.md +233 -43
- package/client.js +121 -24
- package/lib/index.d.ts +35 -4
- package/lib/index.js +1692 -94
- package/lib/index.js.map +1 -1
- package/lib/known-hosts.d.ts +6 -0
- package/lib/known-hosts.js +129 -0
- package/lib/known-hosts.js.map +1 -0
- package/lib/sftp.d.ts +110 -0
- package/lib/sftp.js +449 -0
- package/lib/sftp.js.map +1 -0
- package/lib/shell-integration.d.ts +11 -0
- package/lib/shell-integration.js +184 -0
- package/lib/shell-integration.js.map +1 -0
- package/lib/ssh-config.d.ts +15 -0
- package/lib/ssh-config.js +74 -0
- package/lib/ssh-config.js.map +1 -0
- package/lib/ssh.d.ts +40 -0
- package/lib/ssh.js +54 -14
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.d.ts +65 -0
- package/lib/tunnels.js +327 -0
- package/lib/tunnels.js.map +1 -0
- package/package.json +13 -11
package/lib/index.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { accessSync, constants as fsConstants, existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { mkdir as fsMkdir, readdir as fsReaddir, rename as fsRename, rm as fsRm, stat as fsStat } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
4
7
|
import { PassThrough } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
10
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
11
|
+
// @xterm/headless 是 CJS 包:ESM 具名导入在 Node 运行时会炸(Named export not
|
|
12
|
+
// found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
|
|
13
|
+
import xtermHeadless from '@xterm/headless';
|
|
14
|
+
const HeadlessTerminal = xtermHeadless.Terminal;
|
|
6
15
|
import { definePlugin } from '@hyzyn/dsh-kit';
|
|
7
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
|
-
import { spawnSsh, sshTarget } from './ssh.js';
|
|
17
|
+
import { spawnSsh, sshTarget, expandHome } from './ssh.js';
|
|
18
|
+
import { buildShellSpawn } from './shell-integration.js';
|
|
19
|
+
import { parseSshConfig } from './ssh-config.js';
|
|
20
|
+
import { parseKnownHosts } from './known-hosts.js';
|
|
21
|
+
import { TunnelManager } from './tunnels.js';
|
|
22
|
+
import { SftpManager } from './sftp.js';
|
|
9
23
|
const SSH_HOST_SCHEMA = z.object({
|
|
10
24
|
name: z.string(),
|
|
11
25
|
host: z.string(),
|
|
@@ -15,6 +29,23 @@ const SSH_HOST_SCHEMA = z.object({
|
|
|
15
29
|
keyPath: z.string().default(''),
|
|
16
30
|
passphrase: z.string().default(''),
|
|
17
31
|
password: z.string().default(''),
|
|
32
|
+
agentForward: z.boolean().default(false),
|
|
33
|
+
});
|
|
34
|
+
const HOST_KEY_SCHEMA = z.object({
|
|
35
|
+
host: z.string(),
|
|
36
|
+
port: z.natural().max(65535).default(22),
|
|
37
|
+
fingerprint: z.string(),
|
|
38
|
+
});
|
|
39
|
+
const TUNNEL_SCHEMA = z.object({
|
|
40
|
+
name: z.string(),
|
|
41
|
+
bookName: z.string(),
|
|
42
|
+
direction: z.union([z.const('local'), z.const('remote')]).default('local'),
|
|
43
|
+
localPort: z.natural().max(65535).default(0),
|
|
44
|
+
remoteHost: z.string().default(''),
|
|
45
|
+
remotePort: z.natural().max(65535).default(0),
|
|
46
|
+
localTargetHost: z.string().default(''),
|
|
47
|
+
localTargetPort: z.natural().max(65535).default(0),
|
|
48
|
+
enabled: z.boolean().default(true),
|
|
18
49
|
});
|
|
19
50
|
/** 与「设置 → 插件 → 终端面板」卡片表单对齐的 schema。 */
|
|
20
51
|
const TTY_SETTINGS_SCHEMA = z.object({
|
|
@@ -25,58 +56,113 @@ const TTY_SETTINGS_SCHEMA = z.object({
|
|
|
25
56
|
term: z.string().default('xterm-256color'),
|
|
26
57
|
colorTerm: z.string().default('truecolor'),
|
|
27
58
|
cwd: z.string().default(''),
|
|
59
|
+
reconnectGraceSec: z.natural().max(3600).default(120),
|
|
28
60
|
sshHosts: z.array(SSH_HOST_SCHEMA).default([]),
|
|
61
|
+
hostKeys: z.array(HOST_KEY_SCHEMA).default([]),
|
|
62
|
+
tunnels: z.array(TUNNEL_SCHEMA).default([]),
|
|
63
|
+
shellIntegration: z.boolean().default(true),
|
|
64
|
+
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
|
|
29
65
|
});
|
|
30
66
|
/* ------------------------------------------------------------------ *
|
|
31
67
|
* 常量
|
|
32
68
|
* ------------------------------------------------------------------ */
|
|
33
69
|
const WS_PATH = '/api/dsh-tty/ws';
|
|
34
70
|
const DEFAULT_MAX_SESSIONS = 4;
|
|
71
|
+
/** 断线保活默认秒数(reconnectGraceSec;0 = 旧行为,断开立即结束会话)。 */
|
|
72
|
+
const DEFAULT_RECONNECT_GRACE_SEC = 120;
|
|
35
73
|
/** 下行背压阈值(ws.bufferedAmount 字节)。 */
|
|
36
74
|
const BACKPRESSURE_HIGH = 512 * 1024;
|
|
37
75
|
const BACKPRESSURE_LOW = 128 * 1024;
|
|
38
76
|
const SID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
39
77
|
const BUFFER_CAP = 256 * 1024;
|
|
40
|
-
|
|
41
|
-
|
|
78
|
+
/** TERM/COLORTERM 白名单:防止值里的引号破坏 -c 包装层命令(shellArgv 单引号包裹)。 */
|
|
79
|
+
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
80
|
+
/** 孤儿会话回收器的扫描间隔。 */
|
|
81
|
+
const REAPER_INTERVAL_MS = 10_000;
|
|
82
|
+
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
83
|
+
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
42
84
|
function wrapLocalPty(handle) {
|
|
85
|
+
let resizeWarned = false;
|
|
43
86
|
return {
|
|
44
87
|
kind: 'local',
|
|
45
88
|
pid: handle.pid,
|
|
46
89
|
output: handle.output,
|
|
47
90
|
done: handle.done,
|
|
48
91
|
write: (data) => handle.write(data),
|
|
49
|
-
resize: (cols, rows) =>
|
|
92
|
+
resize: (cols, rows) => {
|
|
93
|
+
try {
|
|
94
|
+
handle.terminal?.resize?.(cols, rows);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// DSH 升级若改内部结构,降级为固定尺寸而不是每帧抛错
|
|
98
|
+
if (!resizeWarned) {
|
|
99
|
+
resizeWarned = true;
|
|
100
|
+
console.warn('[dsh-tty] resize 透传失败(DSH 内部结构可能已变化,退化为固定尺寸): ' + String(error?.message ?? error));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
50
104
|
terminate: () => handle.terminate(),
|
|
51
|
-
forceKill: () =>
|
|
105
|
+
forceKill: () => {
|
|
106
|
+
try {
|
|
107
|
+
handle.terminal?.kill?.('SIGKILL');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* 已退出 */
|
|
111
|
+
}
|
|
112
|
+
},
|
|
52
113
|
};
|
|
53
114
|
}
|
|
115
|
+
/** TERM/COLORTERM 值白名单校验:不合法回退 fallback(防止破坏 -c 包装层)。 */
|
|
116
|
+
function sanitizeTermValue(value, fallback) {
|
|
117
|
+
const trimmed = value.trim();
|
|
118
|
+
return TERM_RE.test(trimmed) ? trimmed : fallback;
|
|
119
|
+
}
|
|
54
120
|
/** 可热更新的运行时配置(settings/updated 动态应用)。 */
|
|
55
121
|
class LiveConfig {
|
|
56
122
|
shell;
|
|
57
123
|
term;
|
|
58
124
|
colorTerm;
|
|
59
125
|
cwd;
|
|
126
|
+
/** 异常断开后会话保活毫秒数(0 = 立即结束)。 */
|
|
127
|
+
reconnectGraceMs;
|
|
60
128
|
sshHosts;
|
|
129
|
+
hostKeys;
|
|
130
|
+
/** 是否注入 OSC 133/7 shell 集成。 */
|
|
131
|
+
shellIntegration;
|
|
132
|
+
/** 端口转发隧道规格。 */
|
|
133
|
+
tunnels;
|
|
61
134
|
constructor(init) {
|
|
62
135
|
this.shell = init.shell;
|
|
63
|
-
this.term = init.term;
|
|
64
|
-
this.colorTerm = init.colorTerm;
|
|
136
|
+
this.term = sanitizeTermValue(init.term, 'xterm-256color');
|
|
137
|
+
this.colorTerm = sanitizeTermValue(init.colorTerm, 'truecolor');
|
|
65
138
|
this.cwd = init.cwd;
|
|
139
|
+
this.reconnectGraceMs = Math.max(0, Math.min(3600, init.reconnectGraceSec)) * 1000;
|
|
66
140
|
this.sshHosts = init.sshHosts ?? [];
|
|
141
|
+
this.hostKeys = init.hostKeys ?? [];
|
|
142
|
+
this.shellIntegration = init.shellIntegration;
|
|
143
|
+
this.tunnels = init.tunnels ?? [];
|
|
67
144
|
}
|
|
68
|
-
/** 合并部分更新;空字符串/undefined 保持原值;sshHosts 传数组即整体替换。 */
|
|
145
|
+
/** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
|
|
69
146
|
apply(partial) {
|
|
70
147
|
if (typeof partial.shell === 'string' && partial.shell.trim() !== '')
|
|
71
148
|
this.shell = partial.shell.trim();
|
|
72
149
|
if (typeof partial.term === 'string' && partial.term.trim() !== '')
|
|
73
|
-
this.term = partial.term.
|
|
150
|
+
this.term = sanitizeTermValue(partial.term, this.term);
|
|
74
151
|
if (typeof partial.colorTerm === 'string' && partial.colorTerm.trim() !== '')
|
|
75
|
-
this.colorTerm = partial.colorTerm.
|
|
152
|
+
this.colorTerm = sanitizeTermValue(partial.colorTerm, this.colorTerm);
|
|
76
153
|
if (typeof partial.cwd === 'string' && partial.cwd.trim() !== '')
|
|
77
154
|
this.cwd = partial.cwd.trim();
|
|
155
|
+
if (typeof partial.reconnectGraceSec === 'number' && Number.isInteger(partial.reconnectGraceSec) && partial.reconnectGraceSec >= 0 && partial.reconnectGraceSec <= 3600) {
|
|
156
|
+
this.reconnectGraceMs = partial.reconnectGraceSec * 1000;
|
|
157
|
+
}
|
|
78
158
|
if (Array.isArray(partial.sshHosts))
|
|
79
159
|
this.sshHosts = partial.sshHosts;
|
|
160
|
+
if (Array.isArray(partial.hostKeys))
|
|
161
|
+
this.hostKeys = partial.hostKeys;
|
|
162
|
+
if (typeof partial.shellIntegration === 'boolean')
|
|
163
|
+
this.shellIntegration = partial.shellIntegration;
|
|
164
|
+
if (Array.isArray(partial.tunnels))
|
|
165
|
+
this.tunnels = partial.tunnels;
|
|
80
166
|
}
|
|
81
167
|
findSshHost(name) {
|
|
82
168
|
return this.sshHosts.find((entry) => entry.name === name);
|
|
@@ -99,18 +185,192 @@ async function forceKill(handle) {
|
|
|
99
185
|
}
|
|
100
186
|
}
|
|
101
187
|
}
|
|
102
|
-
/**
|
|
103
|
-
* shell argv。node-pty 的 name 优先于 env.TERM,而 DSH 硬编码 name:"dumb",
|
|
104
|
-
* 只能在 exec 真正的 shell 之前 export(M0 A2 实测结论)。
|
|
105
|
-
*/
|
|
106
|
-
function shellArgv(shell, term, colorTerm) {
|
|
107
|
-
return [shell, '-c', `export TERM='${term}'; export COLORTERM='${colorTerm}'; exec "${shell}"`];
|
|
108
|
-
}
|
|
109
188
|
function send(ws, msg) {
|
|
110
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
189
|
+
if (ws === null || ws.readyState !== WebSocket.OPEN)
|
|
111
190
|
return;
|
|
112
191
|
ws.send(JSON.stringify(msg));
|
|
113
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* tty_capture 的默认清洗:剥离 OSC/CSI/杂项转义序列,并把同行内 \r 覆盖
|
|
195
|
+
* 收敛为最后一次覆盖结果(进度条不再刷屏)。逐行近似,不追求完整 VT 语义
|
|
196
|
+
* (要完整画面用 tty_screen / xterm-headless 虚拟屏)。
|
|
197
|
+
*/
|
|
198
|
+
function cleanAnsiTail(raw) {
|
|
199
|
+
const withoutOsc = raw.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
200
|
+
const withoutCsi = withoutOsc.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
201
|
+
const withoutEsc = withoutCsi.replace(/\x1b[@-Z\\-_]/g, '');
|
|
202
|
+
// 先把「行尾 \r\n」(zsh 行结束常为 \r\r\n)归一成 \n,再按同行覆盖处理
|
|
203
|
+
// 剩余孤立的 \r —— 否则回显/输出行会被误判为覆盖而整行抹掉
|
|
204
|
+
const normalized = withoutEsc.replace(/\r+\n/g, '\n');
|
|
205
|
+
return normalized.split('\n').map((line) => {
|
|
206
|
+
const idx = line.lastIndexOf('\r');
|
|
207
|
+
return idx === -1 ? line : line.slice(idx + 1);
|
|
208
|
+
}).join('\n');
|
|
209
|
+
}
|
|
210
|
+
/** OSC 133 命令标记帧:\x1b]133;<A|B|D>[;<exit>](BEL|ST)。 */
|
|
211
|
+
const OSC133_RE = /\x1b\]133;([ABDC])(?:;(\d+))?(?:\x07|\x1b\\)/g;
|
|
212
|
+
/** OSC 7 cwd 上报帧:\x1b]7;file://<host><path>(BEL|ST)。 */
|
|
213
|
+
const OSC7_RE = /\x1b\]7;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
214
|
+
/** 单条命令输出捕获上限(环形,超出丢头部)。 */
|
|
215
|
+
const COMMAND_CAP = 256 * 1024;
|
|
216
|
+
/** data 帧合并窗口(毫秒):窗口内的 PTY chunk 合成一帧,显著降帧/降 CPU。 */
|
|
217
|
+
const FLUSH_INTERVAL_MS = 12;
|
|
218
|
+
/** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
|
|
219
|
+
const FLUSH_SIZE_CHARS = 64 * 1024;
|
|
220
|
+
function createShellState() {
|
|
221
|
+
return { carry: '', inCommand: false, cmdBuffer: '', lastCommand: null };
|
|
222
|
+
}
|
|
223
|
+
/** OSC 7 body(file://host/path)→ 解码后的路径;解析失败返回 undefined。 */
|
|
224
|
+
function osc7Path(body) {
|
|
225
|
+
try {
|
|
226
|
+
const url = new URL(body);
|
|
227
|
+
if (url.protocol !== 'file:')
|
|
228
|
+
return undefined;
|
|
229
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
230
|
+
return decoded !== '' ? decoded : undefined;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 把一块输出喂进 shell 集成解析(cwd 跟随 + 命令边界捕获)。
|
|
238
|
+
* 残包处理:尾部若有未闭合的 OSC 序列(lastIndexOf('\x1b]') 起无终结符),
|
|
239
|
+
* 扣回 carry 等下一块拼齐;扣留部分不进命令捕获,避免半截序列混入。
|
|
240
|
+
* 命令捕获按「标记之间的文本段」累积——B、输出、D 常在同一 chunk 到达,
|
|
241
|
+
* 先处理段再翻转状态,才能把 B..D 之间的输出完整收进 lastCommand。
|
|
242
|
+
*/
|
|
243
|
+
function feedShellIntegration(session, text) {
|
|
244
|
+
const state = session.shellState;
|
|
245
|
+
let data = state.carry + text;
|
|
246
|
+
state.carry = '';
|
|
247
|
+
const lastOpen = data.lastIndexOf('\x1b]');
|
|
248
|
+
if (lastOpen !== -1) {
|
|
249
|
+
const tail = data.slice(lastOpen);
|
|
250
|
+
if (!/\x07|\x1b\\/.test(tail)) {
|
|
251
|
+
if (tail.length <= 512) {
|
|
252
|
+
state.carry = tail;
|
|
253
|
+
data = data.slice(0, lastOpen);
|
|
254
|
+
}
|
|
255
|
+
// 超过 512B 仍不闭合视为垃圾:放弃扣留,整块照常处理
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const match of data.matchAll(OSC7_RE)) {
|
|
259
|
+
const path = osc7Path(match[1]);
|
|
260
|
+
if (path !== undefined)
|
|
261
|
+
session.cwd = path;
|
|
262
|
+
}
|
|
263
|
+
OSC133_RE.lastIndex = 0;
|
|
264
|
+
let cursor = 0;
|
|
265
|
+
for (const match of data.matchAll(OSC133_RE)) {
|
|
266
|
+
const segment = data.slice(cursor, match.index).replace(OSC7_RE, '');
|
|
267
|
+
if (state.inCommand && segment !== '') {
|
|
268
|
+
state.cmdBuffer = (state.cmdBuffer + segment).slice(-COMMAND_CAP);
|
|
269
|
+
}
|
|
270
|
+
const kind = match[1];
|
|
271
|
+
if (kind === 'B') {
|
|
272
|
+
state.inCommand = true;
|
|
273
|
+
state.cmdBuffer = '';
|
|
274
|
+
}
|
|
275
|
+
else if (kind === 'D') {
|
|
276
|
+
if (state.inCommand) {
|
|
277
|
+
const exitCode = match[2] !== undefined ? Number(match[2]) : null;
|
|
278
|
+
state.lastCommand = {
|
|
279
|
+
output: state.cmdBuffer.slice(-COMMAND_CAP),
|
|
280
|
+
exitCode: exitCode !== null && Number.isFinite(exitCode) ? exitCode : null,
|
|
281
|
+
endedAt: Date.now(),
|
|
282
|
+
};
|
|
283
|
+
state.inCommand = false;
|
|
284
|
+
state.cmdBuffer = '';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// A(prompt 开始)无需记录
|
|
288
|
+
cursor = match.index + match[0].length;
|
|
289
|
+
}
|
|
290
|
+
if (state.inCommand) {
|
|
291
|
+
const rest = data.slice(cursor).replace(OSC7_RE, '');
|
|
292
|
+
if (rest !== '')
|
|
293
|
+
state.cmdBuffer = (state.cmdBuffer + rest).slice(-COMMAND_CAP);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
|
|
297
|
+
function sanitizeTunnels(input) {
|
|
298
|
+
if (!Array.isArray(input))
|
|
299
|
+
return undefined;
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const item of input) {
|
|
302
|
+
if (typeof item !== 'object' || item === null)
|
|
303
|
+
continue;
|
|
304
|
+
const raw = item;
|
|
305
|
+
if (typeof raw.name !== 'string' || raw.name.trim() === '')
|
|
306
|
+
continue;
|
|
307
|
+
if (typeof raw.bookName !== 'string' || raw.bookName.trim() === '')
|
|
308
|
+
continue;
|
|
309
|
+
const num = (value) => {
|
|
310
|
+
const n = Number(value);
|
|
311
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : 0;
|
|
312
|
+
};
|
|
313
|
+
out.push({
|
|
314
|
+
name: raw.name.trim(),
|
|
315
|
+
bookName: raw.bookName.trim(),
|
|
316
|
+
direction: raw.direction === 'remote' ? 'remote' : 'local',
|
|
317
|
+
localPort: num(raw.localPort),
|
|
318
|
+
remoteHost: typeof raw.remoteHost === 'string' ? raw.remoteHost.trim() : '',
|
|
319
|
+
remotePort: num(raw.remotePort),
|
|
320
|
+
localTargetHost: typeof raw.localTargetHost === 'string' ? raw.localTargetHost.trim() : '',
|
|
321
|
+
localTargetPort: num(raw.localTargetPort),
|
|
322
|
+
enabled: raw.enabled !== false,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
/** 严格校验 tunnels(HTTP POST 路径);bookNames 为同次提交(或现有)的连接簿名字集合。 */
|
|
328
|
+
function validateTunnels(input, bookNames) {
|
|
329
|
+
if (!Array.isArray(input))
|
|
330
|
+
return { error: 'tunnels 必须是数组' };
|
|
331
|
+
const names = new Set();
|
|
332
|
+
for (const item of input) {
|
|
333
|
+
if (typeof item !== 'object' || item === null)
|
|
334
|
+
return { error: 'tunnels 条目必须是对象' };
|
|
335
|
+
const raw = item;
|
|
336
|
+
if (typeof raw.name !== 'string' || raw.name.trim() === '')
|
|
337
|
+
return { error: 'tunnels.name 必须是非空字符串' };
|
|
338
|
+
const name = raw.name.trim();
|
|
339
|
+
if (names.has(name))
|
|
340
|
+
return { error: `tunnels.name 重复: ${name}` };
|
|
341
|
+
names.add(name);
|
|
342
|
+
if (typeof raw.bookName !== 'string' || raw.bookName.trim() === '')
|
|
343
|
+
return { error: `tunnels「${name}」bookName 必须是非空字符串` };
|
|
344
|
+
if (!bookNames.has(raw.bookName.trim()))
|
|
345
|
+
return { error: `tunnels「${name}」引用的连接簿条目不存在: ${String(raw.bookName)}` };
|
|
346
|
+
const direction = raw.direction === 'remote' ? 'remote' : 'local';
|
|
347
|
+
const intIn = (value) => {
|
|
348
|
+
const n = Number(value);
|
|
349
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
350
|
+
};
|
|
351
|
+
if (direction === 'local') {
|
|
352
|
+
if (intIn(raw.localPort) === null)
|
|
353
|
+
return { error: `tunnels「${name}」local 方向需要 localPort(1~65535)` };
|
|
354
|
+
if (typeof raw.remoteHost !== 'string' || raw.remoteHost.trim() === '')
|
|
355
|
+
return { error: `tunnels「${name}」local 方向需要 remoteHost` };
|
|
356
|
+
if (intIn(raw.remotePort) === null)
|
|
357
|
+
return { error: `tunnels「${name}」local 方向需要 remotePort(1~65535)` };
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
if (intIn(raw.remotePort) === null)
|
|
361
|
+
return { error: `tunnels「${name}」remote 方向需要 remotePort(服务端监听端口 1~65535)` };
|
|
362
|
+
if (intIn(raw.localTargetPort) === null)
|
|
363
|
+
return { error: `tunnels「${name}」remote 方向需要 localTargetPort(1~65535)` };
|
|
364
|
+
if (raw.remoteHost !== undefined && typeof raw.remoteHost !== 'string')
|
|
365
|
+
return { error: `tunnels「${name}」remoteHost 必须是字符串` };
|
|
366
|
+
if (raw.localTargetHost !== undefined && typeof raw.localTargetHost !== 'string')
|
|
367
|
+
return { error: `tunnels「${name}」localTargetHost 必须是字符串` };
|
|
368
|
+
}
|
|
369
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean')
|
|
370
|
+
return { error: `tunnels「${name}」enabled 必须是布尔值` };
|
|
371
|
+
}
|
|
372
|
+
return { tunnels: sanitizeTunnels(input) };
|
|
373
|
+
}
|
|
114
374
|
/**
|
|
115
375
|
* 宽松清洗一份 sshHosts 输入(settings 存储/热更新事件路径):
|
|
116
376
|
* 不合法条目直接丢弃;输入不是数组时返回 undefined(表示「未提供,保持原值」)。
|
|
@@ -139,6 +399,7 @@ function sanitizeSshHosts(input) {
|
|
|
139
399
|
keyPath: typeof raw.keyPath === 'string' ? raw.keyPath : '',
|
|
140
400
|
passphrase: typeof raw.passphrase === 'string' ? raw.passphrase : '',
|
|
141
401
|
password: typeof raw.password === 'string' ? raw.password : '',
|
|
402
|
+
agentForward: raw.agentForward === true,
|
|
142
403
|
});
|
|
143
404
|
}
|
|
144
405
|
return out;
|
|
@@ -171,12 +432,86 @@ function validateSshHosts(input) {
|
|
|
171
432
|
if (raw[key] !== undefined && typeof raw[key] !== 'string')
|
|
172
433
|
return { error: `sshHosts.${key} 必须是字符串` };
|
|
173
434
|
}
|
|
435
|
+
if (raw.agentForward !== undefined && typeof raw.agentForward !== 'boolean') {
|
|
436
|
+
return { error: 'sshHosts.agentForward 必须是布尔值' };
|
|
437
|
+
}
|
|
174
438
|
if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
|
|
175
439
|
return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
|
|
176
440
|
}
|
|
177
441
|
}
|
|
178
442
|
return { hosts: sanitizeSshHosts(input) };
|
|
179
443
|
}
|
|
444
|
+
/** 宽松清洗一份 hostKeys 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
|
|
445
|
+
function sanitizeHostKeys(input) {
|
|
446
|
+
if (!Array.isArray(input))
|
|
447
|
+
return undefined;
|
|
448
|
+
const out = [];
|
|
449
|
+
for (const item of input) {
|
|
450
|
+
if (typeof item !== 'object' || item === null)
|
|
451
|
+
continue;
|
|
452
|
+
const raw = item;
|
|
453
|
+
if (typeof raw.host !== 'string' || raw.host.trim() === '')
|
|
454
|
+
continue;
|
|
455
|
+
if (typeof raw.fingerprint !== 'string' || raw.fingerprint.trim() === '' || raw.fingerprint.length > 256)
|
|
456
|
+
continue;
|
|
457
|
+
const port = Number(raw.port);
|
|
458
|
+
out.push({
|
|
459
|
+
host: raw.host.trim().toLowerCase(),
|
|
460
|
+
port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22,
|
|
461
|
+
fingerprint: raw.fingerprint.trim(),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
/** 严格校验一份 hostKeys 输入(HTTP POST 路径);返回错误信息或清洗后的数组。 */
|
|
467
|
+
function validateHostKeys(input) {
|
|
468
|
+
if (!Array.isArray(input))
|
|
469
|
+
return { error: 'hostKeys 必须是数组' };
|
|
470
|
+
const seen = new Set();
|
|
471
|
+
for (const item of input) {
|
|
472
|
+
if (typeof item !== 'object' || item === null)
|
|
473
|
+
return { error: 'hostKeys 条目必须是对象' };
|
|
474
|
+
const raw = item;
|
|
475
|
+
if (typeof raw.host !== 'string' || raw.host.trim() === '')
|
|
476
|
+
return { error: 'hostKeys.host 必须是非空字符串' };
|
|
477
|
+
if (typeof raw.fingerprint !== 'string' || raw.fingerprint.trim() === '')
|
|
478
|
+
return { error: 'hostKeys.fingerprint 必须是非空字符串' };
|
|
479
|
+
const port = Number(raw.port ?? 22);
|
|
480
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
481
|
+
return { error: 'hostKeys.port 必须是 1~65535 的整数' };
|
|
482
|
+
const key = `${raw.host.trim().toLowerCase()}:${port}`;
|
|
483
|
+
if (seen.has(key))
|
|
484
|
+
return { error: `hostKeys 主机重复: ${key}` };
|
|
485
|
+
seen.add(key);
|
|
486
|
+
}
|
|
487
|
+
return { keys: sanitizeHostKeys(input) };
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* TOFU 主机指纹存储:get/record 面向 spawnSsh 的 hostVerifier;
|
|
491
|
+
* record 时经 persist 回调写入 settings(宿主重启后钉扎仍在)。
|
|
492
|
+
*/
|
|
493
|
+
class HostKeyStore {
|
|
494
|
+
live;
|
|
495
|
+
persist;
|
|
496
|
+
constructor(live, persist) {
|
|
497
|
+
this.live = live;
|
|
498
|
+
this.persist = persist;
|
|
499
|
+
}
|
|
500
|
+
key(host, port) {
|
|
501
|
+
return `${host.trim().toLowerCase()}:${port}`;
|
|
502
|
+
}
|
|
503
|
+
get(host, port) {
|
|
504
|
+
const key = this.key(host, port);
|
|
505
|
+
return this.live.hostKeys.find((record) => `${record.host}:${record.port}` === key)?.fingerprint;
|
|
506
|
+
}
|
|
507
|
+
record(host, port, fingerprint) {
|
|
508
|
+
const key = this.key(host, port);
|
|
509
|
+
const next = this.live.hostKeys.filter((record) => `${record.host}:${record.port}` !== key);
|
|
510
|
+
next.push({ host: host.trim().toLowerCase(), port, fingerprint });
|
|
511
|
+
this.live.hostKeys = next;
|
|
512
|
+
this.persist(next);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
180
515
|
/** upgrade 路由的 loopback 信任围栏(与 dsh-mcp 的 HTTP 围栏同思路,socket 版)。 */
|
|
181
516
|
function isLoopbackUpgrade(req) {
|
|
182
517
|
const address = req.socket.remoteAddress;
|
|
@@ -237,24 +572,69 @@ class SessionManager {
|
|
|
237
572
|
get(id) {
|
|
238
573
|
return this.sessions.get(id);
|
|
239
574
|
}
|
|
240
|
-
/**
|
|
575
|
+
/** 会话的只读快照(SSH 会话无本地 pid,该字段省略)。 */
|
|
576
|
+
snapshotOf(session) {
|
|
577
|
+
const base = {
|
|
578
|
+
sid: session.id,
|
|
579
|
+
cwd: session.cwd,
|
|
580
|
+
kind: session.kind,
|
|
581
|
+
target: session.target,
|
|
582
|
+
startedAt: session.startedAt,
|
|
583
|
+
lastOutputAt: session.lastOutputAt,
|
|
584
|
+
};
|
|
585
|
+
return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
|
|
586
|
+
}
|
|
587
|
+
/** agent 工具用的只读快照。 */
|
|
241
588
|
list() {
|
|
242
|
-
return [...this.sessions.values()].map((session) =>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
589
|
+
return [...this.sessions.values()].map((session) => this.snapshotOf(session));
|
|
590
|
+
}
|
|
591
|
+
/** sessions 帧用:额外带 attachable(孤儿且未关闭的会话可被新连接 attach)。 */
|
|
592
|
+
listForAttach() {
|
|
593
|
+
return [...this.sessions.values()].map((session) => ({
|
|
594
|
+
...this.snapshotOf(session),
|
|
595
|
+
attachable: session.ws === null && !session.closed,
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
/** 同步退役:移出全局表 + 释放虚拟屏(幂等,不杀进程)。 */
|
|
599
|
+
retire(session) {
|
|
600
|
+
session.closed = true;
|
|
601
|
+
this.sessions.delete(session.id);
|
|
602
|
+
try {
|
|
603
|
+
session.screen?.dispose();
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
/* 已释放 */
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** 释放并销毁会话:退役 + 树级终止(等待 terminate 完成,最慢 ~20s)。 */
|
|
610
|
+
async destroy(session) {
|
|
611
|
+
this.retire(session);
|
|
612
|
+
await forceKill(session.handle);
|
|
613
|
+
}
|
|
614
|
+
/** 回收超过保活期的孤儿会话(回收器定时调用;graceMs<=0 时不动作)。 */
|
|
615
|
+
async reapOrphans(graceMs) {
|
|
616
|
+
if (graceMs <= 0)
|
|
617
|
+
return;
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
for (const session of [...this.sessions.values()]) {
|
|
620
|
+
if (session.orphanedAt !== null && now - session.orphanedAt >= graceMs) {
|
|
621
|
+
void this.destroy(session); // 后台收尾:terminate 最慢可达 ~20s,不阻塞回收器
|
|
622
|
+
}
|
|
623
|
+
}
|
|
253
624
|
}
|
|
254
625
|
async disposeAll() {
|
|
255
626
|
const all = [...this.sessions.values()];
|
|
256
627
|
this.sessions.clear();
|
|
257
|
-
await Promise.all(all.map((session) =>
|
|
628
|
+
await Promise.all(all.map((session) => {
|
|
629
|
+
session.closed = true;
|
|
630
|
+
try {
|
|
631
|
+
session.screen?.dispose();
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* 已释放 */
|
|
635
|
+
}
|
|
636
|
+
return forceKill(session.handle);
|
|
637
|
+
}));
|
|
258
638
|
}
|
|
259
639
|
}
|
|
260
640
|
/* ------------------------------------------------------------------ *
|
|
@@ -264,11 +644,13 @@ class TtyServer {
|
|
|
264
644
|
ctx;
|
|
265
645
|
sessions;
|
|
266
646
|
options;
|
|
647
|
+
hostKeyStore;
|
|
267
648
|
wss = new WebSocketServer({ noServer: true });
|
|
268
|
-
constructor(ctx, sessions, options) {
|
|
649
|
+
constructor(ctx, sessions, options, hostKeyStore) {
|
|
269
650
|
this.ctx = ctx;
|
|
270
651
|
this.sessions = sessions;
|
|
271
652
|
this.options = options;
|
|
653
|
+
this.hostKeyStore = hostKeyStore;
|
|
272
654
|
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
273
655
|
}
|
|
274
656
|
/** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
|
|
@@ -288,9 +670,18 @@ class TtyServer {
|
|
|
288
670
|
const all = [...local.values()];
|
|
289
671
|
local.clear();
|
|
290
672
|
await Promise.all(all.map(async (session) => {
|
|
291
|
-
session.closed
|
|
292
|
-
|
|
293
|
-
|
|
673
|
+
if (session.closed)
|
|
674
|
+
return;
|
|
675
|
+
if (this.options.reconnectGraceMs > 0) {
|
|
676
|
+
// 客户端正常关面板会先逐个 kill(会话已移出 local),走到这里的都是
|
|
677
|
+
// 「异常断开仍有存活会话」:转孤儿保活,等待新连接 attach,到点由回收器清理
|
|
678
|
+
this.flushPendingOutput(session); // 没了收件人,待发帧直接丢弃(回放走环形缓冲)
|
|
679
|
+
session.ws = null;
|
|
680
|
+
session.orphanedAt = Date.now();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
this.flushPendingOutput(session);
|
|
684
|
+
this.killSessionNow(session);
|
|
294
685
|
}));
|
|
295
686
|
};
|
|
296
687
|
ws.on('message', (raw) => {
|
|
@@ -333,6 +724,31 @@ class TtyServer {
|
|
|
333
724
|
send(ws, { t: 'error', m: local.size === 0 ? '没有可用会话(先发 spawn)' : '存在多个会话,请指定 sid' });
|
|
334
725
|
return undefined;
|
|
335
726
|
}
|
|
727
|
+
/**
|
|
728
|
+
* 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
|
|
729
|
+
* 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
|
|
730
|
+
* 可拖 ~20s)由 terminate 在后台继续收尾,不阻塞 kill 帧处理。
|
|
731
|
+
*/
|
|
732
|
+
killSessionNow(session) {
|
|
733
|
+
this.sessions.retire(session);
|
|
734
|
+
try {
|
|
735
|
+
session.handle.forceKill?.();
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
/* 已退出 */
|
|
739
|
+
}
|
|
740
|
+
void forceKill(session.handle);
|
|
741
|
+
}
|
|
742
|
+
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。 */
|
|
743
|
+
createScreen(cols, rows) {
|
|
744
|
+
try {
|
|
745
|
+
// buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
|
|
746
|
+
return new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
336
752
|
async handleMessage(ws, msg, local, cleanupAll) {
|
|
337
753
|
try {
|
|
338
754
|
if (msg.t === 'spawn') {
|
|
@@ -360,12 +776,13 @@ class TtyServer {
|
|
|
360
776
|
send(ws, { t: 'error', sid, m: 'subprocess 服务不可用' });
|
|
361
777
|
return;
|
|
362
778
|
}
|
|
779
|
+
const spawnPlan = buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
|
|
363
780
|
const handle = wrapLocalPty(await subprocess.spawnTerminal({
|
|
364
|
-
argv:
|
|
781
|
+
argv: spawnPlan.argv,
|
|
365
782
|
rows: Number(msg.rows) || 24,
|
|
366
783
|
cols: Number(msg.cols) || 80,
|
|
367
784
|
cwd,
|
|
368
|
-
env: { TERM: this.options.term, COLORTERM: this.options.colorTerm },
|
|
785
|
+
env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
|
|
369
786
|
graceMs: 5000,
|
|
370
787
|
}));
|
|
371
788
|
const next = {
|
|
@@ -380,12 +797,18 @@ class TtyServer {
|
|
|
380
797
|
startedAt: Date.now(),
|
|
381
798
|
lastOutputAt: Date.now(),
|
|
382
799
|
buffer: '',
|
|
800
|
+
decoder: new StringDecoder('utf8'),
|
|
801
|
+
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
802
|
+
orphanedAt: null,
|
|
803
|
+
shellState: createShellState(),
|
|
804
|
+
pendingOutput: '',
|
|
805
|
+
flushTimer: null,
|
|
383
806
|
};
|
|
384
807
|
local.set(sid, next);
|
|
385
808
|
this.sessions.add(next);
|
|
386
809
|
send(ws, { t: 'ready', sid, pid: handle.pid, kind: 'local' });
|
|
387
810
|
this.attachOutput(next);
|
|
388
|
-
this.watchDone(next, local
|
|
811
|
+
this.watchDone(next, local);
|
|
389
812
|
}
|
|
390
813
|
else if (msg.t === 'ssh') {
|
|
391
814
|
const sid = typeof msg.sid === 'string' && msg.sid !== '' ? msg.sid : randomUUID();
|
|
@@ -401,25 +824,13 @@ class TtyServer {
|
|
|
401
824
|
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})` });
|
|
402
825
|
return;
|
|
403
826
|
}
|
|
404
|
-
// name
|
|
405
|
-
const
|
|
406
|
-
if (
|
|
407
|
-
send(ws, { t: 'error', sid, m:
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
const spec = {
|
|
411
|
-
host: typeof msg.host === 'string' && msg.host.trim() !== '' ? msg.host.trim() : profile?.host ?? '',
|
|
412
|
-
port: Number(msg.port) || profile?.port || 22,
|
|
413
|
-
username: typeof msg.username === 'string' && msg.username.trim() !== '' ? msg.username.trim() : profile?.username ?? '',
|
|
414
|
-
auth: msg.auth === 'key' || msg.auth === 'password' || msg.auth === 'agent' ? msg.auth : profile?.auth ?? 'agent',
|
|
415
|
-
keyPath: typeof msg.keyPath === 'string' && msg.keyPath !== '' ? msg.keyPath : profile?.keyPath,
|
|
416
|
-
passphrase: typeof msg.passphrase === 'string' && msg.passphrase !== '' ? msg.passphrase : profile?.passphrase,
|
|
417
|
-
password: typeof msg.password === 'string' && msg.password !== '' ? msg.password : profile?.password,
|
|
418
|
-
};
|
|
419
|
-
if (spec.host === '' || spec.username === '') {
|
|
420
|
-
send(ws, { t: 'error', sid, m: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' });
|
|
827
|
+
// name 引用连接簿条目作基底,内联字段可逐项覆盖(与 SFTP 路由共用 mergeSshSpec)
|
|
828
|
+
const merged = mergeSshSpec((name) => this.options.findSshHost(name), msg.name, msg);
|
|
829
|
+
if (merged.error !== undefined || merged.spec === undefined) {
|
|
830
|
+
send(ws, { t: 'error', sid, m: merged.error ?? 'SSH 连接参数缺失' });
|
|
421
831
|
return;
|
|
422
832
|
}
|
|
833
|
+
const spec = merged.spec;
|
|
423
834
|
const target = sshTarget(spec);
|
|
424
835
|
send(ws, { t: 'data', sid, d: `\x1b[2mConnecting ${target} …\x1b[0m\r\n` });
|
|
425
836
|
let handle;
|
|
@@ -429,6 +840,7 @@ class TtyServer {
|
|
|
429
840
|
cols: Number(msg.cols) || 80,
|
|
430
841
|
rows: Number(msg.rows) || 24,
|
|
431
842
|
logger: { info: (m) => this.ctx.logger.info(m), warn: (m) => this.ctx.logger.warn(m) },
|
|
843
|
+
hostKeyStore: this.hostKeyStore,
|
|
432
844
|
});
|
|
433
845
|
}
|
|
434
846
|
catch (error) {
|
|
@@ -447,12 +859,18 @@ class TtyServer {
|
|
|
447
859
|
startedAt: Date.now(),
|
|
448
860
|
lastOutputAt: Date.now(),
|
|
449
861
|
buffer: '',
|
|
862
|
+
decoder: new StringDecoder('utf8'),
|
|
863
|
+
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
864
|
+
orphanedAt: null,
|
|
865
|
+
shellState: createShellState(),
|
|
866
|
+
pendingOutput: '',
|
|
867
|
+
flushTimer: null,
|
|
450
868
|
};
|
|
451
869
|
local.set(sid, next);
|
|
452
870
|
this.sessions.add(next);
|
|
453
871
|
send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target });
|
|
454
872
|
this.attachOutput(next);
|
|
455
|
-
this.watchDone(next, local
|
|
873
|
+
this.watchDone(next, local);
|
|
456
874
|
}
|
|
457
875
|
else if (msg.t === 'input') {
|
|
458
876
|
const resolved = this.resolveSid(ws, msg, local);
|
|
@@ -468,20 +886,72 @@ class TtyServer {
|
|
|
468
886
|
return;
|
|
469
887
|
const session = local.get(resolved.sid);
|
|
470
888
|
if (session !== undefined) {
|
|
471
|
-
|
|
889
|
+
const cols = Number(msg.cols) || 80;
|
|
890
|
+
const rows = Number(msg.rows) || 24;
|
|
891
|
+
session.handle.resize(cols, rows);
|
|
892
|
+
try {
|
|
893
|
+
session.screen?.resize(cols, rows);
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
/* 非法尺寸或已释放 */
|
|
897
|
+
}
|
|
472
898
|
}
|
|
473
899
|
}
|
|
474
900
|
else if (msg.t === 'kill') {
|
|
475
901
|
const resolved = this.resolveSid(ws, msg, local);
|
|
476
|
-
if (resolved === undefined
|
|
902
|
+
if (resolved === undefined)
|
|
903
|
+
return;
|
|
904
|
+
if ('unknown' in resolved) {
|
|
905
|
+
// 本连接没有该 sid:若是孤儿会话(前连接已断)也允许 kill,
|
|
906
|
+
// 避免「关闭面板杀不掉孤儿」泄漏到保活期结束
|
|
907
|
+
const orphan = this.sessions.get(String(msg.sid ?? ''));
|
|
908
|
+
if (orphan !== undefined && !orphan.closed) {
|
|
909
|
+
this.flushPendingOutput(orphan);
|
|
910
|
+
this.killSessionNow(orphan);
|
|
911
|
+
}
|
|
477
912
|
return;
|
|
913
|
+
}
|
|
478
914
|
const session = local.get(resolved.sid);
|
|
479
915
|
if (session === undefined)
|
|
480
916
|
return;
|
|
481
|
-
session.closed = true;
|
|
482
917
|
local.delete(resolved.sid);
|
|
483
|
-
this.
|
|
484
|
-
|
|
918
|
+
this.killSessionNow(session);
|
|
919
|
+
}
|
|
920
|
+
else if (msg.t === 'sessions') {
|
|
921
|
+
send(ws, { t: 'sessions', list: this.sessions.listForAttach() });
|
|
922
|
+
}
|
|
923
|
+
else if (msg.t === 'attach') {
|
|
924
|
+
const raw = msg.sid;
|
|
925
|
+
if (typeof raw !== 'string' || raw === '' || !SID_RE.test(raw)) {
|
|
926
|
+
send(ws, { t: 'error', m: 'attach 需要合法 sid' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const session = this.sessions.get(raw);
|
|
930
|
+
if (session === undefined || session.closed) {
|
|
931
|
+
send(ws, { t: 'error', sid: raw, m: `会话不存在或已结束: ${raw}` });
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (session.ws !== null) {
|
|
935
|
+
send(ws, { t: 'error', sid: raw, m: '会话已连接到其它窗口' });
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// 重新绑定到本连接:解孤儿态,恢复被背压暂停的输出流
|
|
939
|
+
session.ws = ws;
|
|
940
|
+
session.orphanedAt = null;
|
|
941
|
+
local.set(session.id, session);
|
|
942
|
+
if (session.paused) {
|
|
943
|
+
session.paused = false;
|
|
944
|
+
try {
|
|
945
|
+
session.handle.output.resume();
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
/* 已退出 */
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
send(ws, { t: 'ready', sid: session.id, pid: session.handle.pid, kind: session.kind, target: session.target !== '' ? session.target : undefined, reattached: true });
|
|
952
|
+
// 断线期间的输出经 256KB 环形缓冲回放(缓冲为空则跳过)
|
|
953
|
+
if (session.buffer !== '')
|
|
954
|
+
send(ws, { t: 'data', sid: session.id, d: session.buffer });
|
|
485
955
|
}
|
|
486
956
|
}
|
|
487
957
|
catch (error) {
|
|
@@ -489,30 +959,37 @@ class TtyServer {
|
|
|
489
959
|
}
|
|
490
960
|
}
|
|
491
961
|
/** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
|
|
492
|
-
watchDone(session, local
|
|
962
|
+
watchDone(session, local) {
|
|
493
963
|
session.handle.done.then((outcome) => {
|
|
494
|
-
// kill 主动关闭时会话可能已被移出 local,用 exitSent 保证 exit
|
|
964
|
+
// kill 主动关闭时会话可能已被移出 local,用 exitSent 保证 exit 帧恰好一次;
|
|
965
|
+
// 发送走 session.ws 动态取值——attach 换连接后 exit 也能跟着新连接走
|
|
495
966
|
if (session.exitSent === true)
|
|
496
967
|
return;
|
|
497
968
|
session.exitSent = true;
|
|
498
969
|
session.closed = true;
|
|
499
970
|
local.delete(session.id);
|
|
500
971
|
this.sessions.remove(session.id);
|
|
501
|
-
|
|
972
|
+
try {
|
|
973
|
+
session.screen?.dispose();
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
/* 已释放 */
|
|
977
|
+
}
|
|
978
|
+
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
979
|
+
send(session.ws, { t: 'exit', sid: session.id, code: outcome.exitCode, signal: outcome.signal });
|
|
502
980
|
}).catch(() => { });
|
|
503
981
|
}
|
|
504
982
|
/** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
|
|
505
983
|
attachOutput(session) {
|
|
506
984
|
const output = session.handle.output;
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
return;
|
|
510
|
-
const text = chunk.toString('utf8');
|
|
511
|
-
session.lastOutputAt = Date.now();
|
|
512
|
-
session.buffer = (session.buffer + text).slice(-BUFFER_CAP);
|
|
985
|
+
const flush = () => {
|
|
986
|
+
session.flushTimer = null;
|
|
513
987
|
const ws = session.ws;
|
|
514
|
-
const
|
|
515
|
-
|
|
988
|
+
const pending = session.pendingOutput;
|
|
989
|
+
if (session.closed || ws === null || pending === '')
|
|
990
|
+
return;
|
|
991
|
+
session.pendingOutput = '';
|
|
992
|
+
ws.send(JSON.stringify({ t: 'data', sid: session.id, d: pending }), () => {
|
|
516
993
|
if (session.paused && ws.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
|
|
517
994
|
output.resume();
|
|
518
995
|
}
|
|
@@ -522,8 +999,53 @@ class TtyServer {
|
|
|
522
999
|
output.pause();
|
|
523
1000
|
}
|
|
524
1001
|
};
|
|
1002
|
+
const onData = (chunk) => {
|
|
1003
|
+
if (session.closed)
|
|
1004
|
+
return;
|
|
1005
|
+
// StringDecoder 兜跨 chunk 多字节序列,再喂 shell 集成解析与虚拟屏
|
|
1006
|
+
const text = session.decoder.write(chunk);
|
|
1007
|
+
session.lastOutputAt = Date.now();
|
|
1008
|
+
session.buffer = (session.buffer + text).slice(-BUFFER_CAP);
|
|
1009
|
+
feedShellIntegration(session, text);
|
|
1010
|
+
try {
|
|
1011
|
+
session.screen?.write(text);
|
|
1012
|
+
}
|
|
1013
|
+
catch {
|
|
1014
|
+
/* 虚拟屏异常不阻断输出链路 */
|
|
1015
|
+
}
|
|
1016
|
+
const ws = session.ws;
|
|
1017
|
+
if (ws === null)
|
|
1018
|
+
return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
|
|
1019
|
+
// data 帧合并:窗口内攒批,超阈值立即冲刷;exit/kill 前会强制 flush 保序
|
|
1020
|
+
session.pendingOutput += text;
|
|
1021
|
+
if (session.pendingOutput.length >= FLUSH_SIZE_CHARS) {
|
|
1022
|
+
if (session.flushTimer !== null) {
|
|
1023
|
+
clearTimeout(session.flushTimer);
|
|
1024
|
+
session.flushTimer = null;
|
|
1025
|
+
}
|
|
1026
|
+
flush();
|
|
1027
|
+
}
|
|
1028
|
+
else if (session.flushTimer === null) {
|
|
1029
|
+
const timer = setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
1030
|
+
timer.unref?.();
|
|
1031
|
+
session.flushTimer = timer;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
525
1034
|
output.on('data', onData);
|
|
526
1035
|
}
|
|
1036
|
+
/** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。 */
|
|
1037
|
+
flushPendingOutput(session) {
|
|
1038
|
+
if (session.flushTimer !== null) {
|
|
1039
|
+
clearTimeout(session.flushTimer);
|
|
1040
|
+
session.flushTimer = null;
|
|
1041
|
+
}
|
|
1042
|
+
const ws = session.ws;
|
|
1043
|
+
const pending = session.pendingOutput;
|
|
1044
|
+
session.pendingOutput = '';
|
|
1045
|
+
if (session.closed || ws === null || pending === '')
|
|
1046
|
+
return;
|
|
1047
|
+
send(ws, { t: 'data', sid: session.id, d: pending });
|
|
1048
|
+
}
|
|
527
1049
|
close() {
|
|
528
1050
|
for (const client of this.wss.clients) {
|
|
529
1051
|
try {
|
|
@@ -538,6 +1060,100 @@ class TtyServer {
|
|
|
538
1060
|
/* ------------------------------------------------------------------ *
|
|
539
1061
|
* 插件本体
|
|
540
1062
|
* ------------------------------------------------------------------ */
|
|
1063
|
+
/**
|
|
1064
|
+
* env 插件托管变量名(~/.dsh/env.yml 托管区块内的 key,路径解析与 env 插件
|
|
1065
|
+
* 一致:DSH_ENV_FILE / DSH_HOME 优先)。只提取键名、绝不读值——这些是用户
|
|
1066
|
+
* 明确交给 dsh-env-manager 托管的变量,才是 env:VAR 引用的推荐来源;键行由
|
|
1067
|
+
* env 插件以 yaml 数组渲染(`- key: NAME`),逐行宽容提取即可,不引 YAML 依赖。
|
|
1068
|
+
*/
|
|
1069
|
+
function readManagedEnvKeys() {
|
|
1070
|
+
const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
|
|
1071
|
+
const file = process.env.DSH_ENV_FILE?.trim() || join(dshHome, 'env.yml');
|
|
1072
|
+
try {
|
|
1073
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
1074
|
+
const start = lines.findIndex((line) => line.includes('dsh-env-manager managed'));
|
|
1075
|
+
if (start === -1)
|
|
1076
|
+
return [];
|
|
1077
|
+
const end = lines.findIndex((line, index) => index > start && line.includes('end dsh-env-manager managed'));
|
|
1078
|
+
if (end === -1)
|
|
1079
|
+
return [];
|
|
1080
|
+
const keys = [];
|
|
1081
|
+
for (const line of lines.slice(start + 1, end)) {
|
|
1082
|
+
const match = line.match(/^\s*-\s*key:\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1\s*$/);
|
|
1083
|
+
if (match !== null)
|
|
1084
|
+
keys.push(match[2]);
|
|
1085
|
+
}
|
|
1086
|
+
return [...new Set(keys)].sort().slice(0, 200);
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
return [];
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* 设置卡片「Shell 路径」候选(可选可输入的数据源):/etc/shells + $SHELL +
|
|
1094
|
+
* 常见安装路径,去重后过滤「存在且可执行」,$SHELL 排最前。只回路径,
|
|
1095
|
+
* 不做任何执行。
|
|
1096
|
+
*/
|
|
1097
|
+
function listCandidateShells() {
|
|
1098
|
+
const candidates = [];
|
|
1099
|
+
const push = (value) => {
|
|
1100
|
+
const path = value?.trim() ?? '';
|
|
1101
|
+
if (path !== '' && !candidates.includes(path))
|
|
1102
|
+
candidates.push(path);
|
|
1103
|
+
};
|
|
1104
|
+
try {
|
|
1105
|
+
for (const line of readFileSync('/etc/shells', 'utf8').split('\n')) {
|
|
1106
|
+
const path = line.trim();
|
|
1107
|
+
if (path !== '' && !path.startsWith('#'))
|
|
1108
|
+
push(path);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
/* 无 /etc/shells(如 Windows)时跳过 */
|
|
1113
|
+
}
|
|
1114
|
+
push(process.env.SHELL);
|
|
1115
|
+
for (const path of [
|
|
1116
|
+
'/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/homebrew/bin/zsh',
|
|
1117
|
+
'/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash',
|
|
1118
|
+
'/bin/fish', '/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish',
|
|
1119
|
+
'/bin/sh', '/bin/dash', '/bin/ksh', '/bin/tcsh', '/bin/csh',
|
|
1120
|
+
])
|
|
1121
|
+
push(path);
|
|
1122
|
+
const usable = candidates.filter((path) => {
|
|
1123
|
+
try {
|
|
1124
|
+
accessSync(path, fsConstants.X_OK);
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
const shell = process.env.SHELL?.trim() ?? '';
|
|
1132
|
+
usable.sort((a, b) => (a === shell ? -1 : b === shell ? 1 : a.localeCompare(b)));
|
|
1133
|
+
return usable;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* 「连接簿条目作基底 + 内联字段逐项覆盖」的共享解析(WS ssh 帧与 SFTP 路由共用)。
|
|
1137
|
+
* name 指向连接簿缺失条目、或解析结果缺 host/username 时返回 error。
|
|
1138
|
+
*/
|
|
1139
|
+
function mergeSshSpec(findSshHost, name, inline) {
|
|
1140
|
+
const profile = typeof name === 'string' && name !== '' ? findSshHost(name) : undefined;
|
|
1141
|
+
if (typeof name === 'string' && name !== '' && profile === undefined)
|
|
1142
|
+
return { error: `连接簿中不存在: ${name}` };
|
|
1143
|
+
const spec = {
|
|
1144
|
+
host: typeof inline.host === 'string' && inline.host.trim() !== '' ? inline.host.trim() : profile?.host ?? '',
|
|
1145
|
+
port: Number(inline.port) || profile?.port || 22,
|
|
1146
|
+
username: typeof inline.username === 'string' && inline.username.trim() !== '' ? inline.username.trim() : profile?.username ?? '',
|
|
1147
|
+
auth: inline.auth === 'key' || inline.auth === 'password' || inline.auth === 'agent' ? inline.auth : profile?.auth ?? 'agent',
|
|
1148
|
+
keyPath: typeof inline.keyPath === 'string' && inline.keyPath !== '' ? inline.keyPath : profile?.keyPath,
|
|
1149
|
+
passphrase: typeof inline.passphrase === 'string' && inline.passphrase !== '' ? inline.passphrase : profile?.passphrase,
|
|
1150
|
+
password: typeof inline.password === 'string' && inline.password !== '' ? inline.password : profile?.password,
|
|
1151
|
+
agentForward: typeof inline.agentForward === 'boolean' ? inline.agentForward : profile?.agentForward ?? false,
|
|
1152
|
+
};
|
|
1153
|
+
if (spec.host === '' || spec.username === '')
|
|
1154
|
+
return { error: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' };
|
|
1155
|
+
return { spec };
|
|
1156
|
+
}
|
|
541
1157
|
/** HTTP 路由的 loopback 信任围栏(与 dsh-mcp 同思路)。 */
|
|
542
1158
|
function isLoopbackHttp(req) {
|
|
543
1159
|
const address = req.socket.remoteAddress;
|
|
@@ -571,6 +1187,31 @@ function writeJson(res, status, body) {
|
|
|
571
1187
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
572
1188
|
res.end(JSON.stringify(body));
|
|
573
1189
|
}
|
|
1190
|
+
/** 下载响应的 content-disposition:ASCII 兜底 + RFC 5987 UTF-8 扩展(非 ASCII 文件名)。 */
|
|
1191
|
+
function contentDispositionValue(name) {
|
|
1192
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
1193
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
1194
|
+
}
|
|
1195
|
+
/** 远程路径取末段(下载文件名展示用);空串/根路径退化为 'download'。 */
|
|
1196
|
+
function remoteBasename(path) {
|
|
1197
|
+
const trimmed = path.trim().replace(/\/+$/, '');
|
|
1198
|
+
const index = trimmed.lastIndexOf('/');
|
|
1199
|
+
const base = index >= 0 ? trimmed.slice(index + 1) : trimmed;
|
|
1200
|
+
return base === '' ? 'download' : base;
|
|
1201
|
+
}
|
|
1202
|
+
/** 人类可读文件大小(sftp_list render 用)。 */
|
|
1203
|
+
function humanFileSize(bytes) {
|
|
1204
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
1205
|
+
return '0 B';
|
|
1206
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
1207
|
+
let value = bytes;
|
|
1208
|
+
let index = 0;
|
|
1209
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
1210
|
+
value /= 1024;
|
|
1211
|
+
index += 1;
|
|
1212
|
+
}
|
|
1213
|
+
return `${value >= 100 || index === 0 ? Math.round(value) : Math.round(value * 10) / 10} ${units[index]}`;
|
|
1214
|
+
}
|
|
574
1215
|
async function readJsonBody(req) {
|
|
575
1216
|
const chunks = [];
|
|
576
1217
|
let size = 0;
|
|
@@ -593,6 +1234,34 @@ async function readJsonBody(req) {
|
|
|
593
1234
|
return undefined;
|
|
594
1235
|
}
|
|
595
1236
|
}
|
|
1237
|
+
/**
|
|
1238
|
+
* 本机目录列表(/api/dsh-tty/local-fs/list):path 空 = 用户 home,返回实际
|
|
1239
|
+
* 绝对路径。stat 失败的条目(悬空符号链接等)按 size/mtime = 0 占位仍列出。
|
|
1240
|
+
*/
|
|
1241
|
+
async function listLocalDir(rawPath) {
|
|
1242
|
+
const root = rawPath !== '' ? expandHome(rawPath) : homedir();
|
|
1243
|
+
const resolved = resolve(root);
|
|
1244
|
+
const dirents = await fsReaddir(resolved, { withFileTypes: true });
|
|
1245
|
+
const entries = [];
|
|
1246
|
+
for (const dirent of dirents) {
|
|
1247
|
+
let isDir = dirent.isDirectory();
|
|
1248
|
+
let isFile = dirent.isFile();
|
|
1249
|
+
let size = 0;
|
|
1250
|
+
let mtime = 0;
|
|
1251
|
+
try {
|
|
1252
|
+
const stats = await fsStat(join(resolved, dirent.name));
|
|
1253
|
+
isDir = stats.isDirectory();
|
|
1254
|
+
isFile = stats.isFile();
|
|
1255
|
+
size = Number(stats.size ?? 0);
|
|
1256
|
+
mtime = Number(stats.mtimeMs ?? 0);
|
|
1257
|
+
}
|
|
1258
|
+
catch {
|
|
1259
|
+
/* stat 失败:保留 dirent 类型判断,占位展示 */
|
|
1260
|
+
}
|
|
1261
|
+
entries.push({ name: dirent.name, isDir, isFile, isSymlink: dirent.isSymbolicLink(), size, mtime });
|
|
1262
|
+
}
|
|
1263
|
+
return { path: resolved, entries };
|
|
1264
|
+
}
|
|
596
1265
|
const plugin = definePlugin({
|
|
597
1266
|
name: 'tty',
|
|
598
1267
|
// 声明 inject:tools 服务只有声明式 inject 才能解析(动态 ctx.inject/ctx.get
|
|
@@ -606,11 +1275,29 @@ const plugin = definePlugin({
|
|
|
606
1275
|
term: config?.term?.trim() || 'xterm-256color',
|
|
607
1276
|
colorTerm: config?.colorTerm?.trim() || 'truecolor',
|
|
608
1277
|
cwd: config?.cwd?.trim() || process.cwd(),
|
|
1278
|
+
reconnectGraceSec: typeof config?.reconnectGraceSec === 'number' && Number.isInteger(config.reconnectGraceSec) && config.reconnectGraceSec >= 0 ? config.reconnectGraceSec : DEFAULT_RECONNECT_GRACE_SEC,
|
|
609
1279
|
sshHosts: Array.isArray(config?.sshHosts) ? config.sshHosts : [],
|
|
1280
|
+
hostKeys: Array.isArray(config?.hostKeys) ? config.hostKeys : [],
|
|
1281
|
+
shellIntegration: config?.shellIntegration !== false,
|
|
1282
|
+
tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
|
|
610
1283
|
});
|
|
611
1284
|
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS);
|
|
612
|
-
|
|
613
|
-
const
|
|
1285
|
+
/** TOFU 指纹记录持久化:写入 settings 命名空间(合并语义),失败不影响连接。 */
|
|
1286
|
+
const persistHostKeys = (records) => {
|
|
1287
|
+
const scope = settingsScope;
|
|
1288
|
+
if (scope === undefined)
|
|
1289
|
+
return;
|
|
1290
|
+
void Promise.resolve(scope.update({ hostKeys: records })).catch((error) => {
|
|
1291
|
+
console.warn('[dsh-tty] 主机密钥记录持久化失败: ' + (error instanceof Error ? error.message : String(error)));
|
|
1292
|
+
});
|
|
1293
|
+
};
|
|
1294
|
+
const hostKeyStore = new HostKeyStore(live, persistHostKeys);
|
|
1295
|
+
const tunnelManager = new TunnelManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore, (bookName) => live.findSshHost(bookName));
|
|
1296
|
+
// SFTP 文件传输:懒连接池 + TOFU 同源(见 src/sftp.ts);spec 由各请求携带
|
|
1297
|
+
// (连接簿名或内联字段),连接簿凭证热改后天然生效
|
|
1298
|
+
const sftpManager = new SftpManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore);
|
|
1299
|
+
const server = new TtyServer(ctx, sessions, live, hostKeyStore);
|
|
1300
|
+
const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false, sftpStyle: config?.sftpStyle === 'dual' ? 'dual' : 'dialog' };
|
|
614
1301
|
let settingsScope;
|
|
615
1302
|
const snapshot = () => ({
|
|
616
1303
|
enabled: stateRef.enabled,
|
|
@@ -620,7 +1307,12 @@ const plugin = definePlugin({
|
|
|
620
1307
|
term: live.term,
|
|
621
1308
|
colorTerm: live.colorTerm,
|
|
622
1309
|
cwd: live.cwd,
|
|
1310
|
+
reconnectGraceSec: Math.round(live.reconnectGraceMs / 1000),
|
|
623
1311
|
sshHosts: live.sshHosts,
|
|
1312
|
+
hostKeys: live.hostKeys,
|
|
1313
|
+
tunnels: live.tunnels,
|
|
1314
|
+
shellIntegration: live.shellIntegration,
|
|
1315
|
+
sftpStyle: stateRef.sftpStyle,
|
|
624
1316
|
toolsRegistered: stateRef.toolsRegistered,
|
|
625
1317
|
});
|
|
626
1318
|
/** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
|
|
@@ -630,8 +1322,14 @@ const plugin = definePlugin({
|
|
|
630
1322
|
term: typeof section.term === 'string' ? section.term : undefined,
|
|
631
1323
|
colorTerm: typeof section.colorTerm === 'string' ? section.colorTerm : undefined,
|
|
632
1324
|
cwd: typeof section.cwd === 'string' ? section.cwd : undefined,
|
|
1325
|
+
reconnectGraceSec: typeof section.reconnectGraceSec === 'number' ? section.reconnectGraceSec : undefined,
|
|
633
1326
|
sshHosts: sanitizeSshHosts(section.sshHosts),
|
|
1327
|
+
hostKeys: sanitizeHostKeys(section.hostKeys),
|
|
1328
|
+
shellIntegration: typeof section.shellIntegration === 'boolean' ? section.shellIntegration : undefined,
|
|
1329
|
+
tunnels: sanitizeTunnels(section.tunnels),
|
|
634
1330
|
});
|
|
1331
|
+
// 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
|
|
1332
|
+
tunnelManager.reconcile(live.tunnels);
|
|
635
1333
|
if (typeof section.maxSessions === 'number' && Number.isInteger(section.maxSessions) && section.maxSessions >= 1 && section.maxSessions <= 16) {
|
|
636
1334
|
sessions.setLimit(section.maxSessions);
|
|
637
1335
|
}
|
|
@@ -639,12 +1337,14 @@ const plugin = definePlugin({
|
|
|
639
1337
|
stateRef.enabled = section.enabled;
|
|
640
1338
|
if (typeof section.announceToAgent === 'boolean')
|
|
641
1339
|
stateRef.announceToAgent = section.announceToAgent;
|
|
1340
|
+
if (section.sftpStyle === 'dialog' || section.sftpStyle === 'dual')
|
|
1341
|
+
stateRef.sftpStyle = section.sftpStyle;
|
|
642
1342
|
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length})`);
|
|
643
1343
|
};
|
|
644
1344
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
645
1345
|
const normalizePatch = (input) => {
|
|
646
1346
|
const patch = {};
|
|
647
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'sshHosts']);
|
|
1347
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle']);
|
|
648
1348
|
for (const key of Object.keys(input)) {
|
|
649
1349
|
if (!known.has(key))
|
|
650
1350
|
return { error: '未知配置项: ' + key };
|
|
@@ -665,6 +1365,22 @@ const plugin = definePlugin({
|
|
|
665
1365
|
return { error: 'maxSessions 必须是 1~16 的整数' };
|
|
666
1366
|
patch.maxSessions = value;
|
|
667
1367
|
}
|
|
1368
|
+
if (input.reconnectGraceSec !== undefined) {
|
|
1369
|
+
const value = Number(input.reconnectGraceSec);
|
|
1370
|
+
if (!Number.isInteger(value) || value < 0 || value > 3600)
|
|
1371
|
+
return { error: 'reconnectGraceSec 必须是 0~3600 的整数' };
|
|
1372
|
+
patch.reconnectGraceSec = value;
|
|
1373
|
+
}
|
|
1374
|
+
if (input.shellIntegration !== undefined) {
|
|
1375
|
+
if (typeof input.shellIntegration !== 'boolean')
|
|
1376
|
+
return { error: 'shellIntegration 必须是布尔值' };
|
|
1377
|
+
patch.shellIntegration = input.shellIntegration;
|
|
1378
|
+
}
|
|
1379
|
+
if (input.sftpStyle !== undefined) {
|
|
1380
|
+
if (input.sftpStyle !== 'dialog' && input.sftpStyle !== 'dual')
|
|
1381
|
+
return { error: 'sftpStyle 必须是 dialog 或 dual' };
|
|
1382
|
+
patch.sftpStyle = input.sftpStyle;
|
|
1383
|
+
}
|
|
668
1384
|
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
669
1385
|
if (input[key] === undefined)
|
|
670
1386
|
continue;
|
|
@@ -689,6 +1405,21 @@ const plugin = definePlugin({
|
|
|
689
1405
|
return { error: validated.error };
|
|
690
1406
|
patch.sshHosts = validated.hosts;
|
|
691
1407
|
}
|
|
1408
|
+
if (input.hostKeys !== undefined) {
|
|
1409
|
+
const validated = validateHostKeys(input.hostKeys);
|
|
1410
|
+
if (validated.error !== undefined)
|
|
1411
|
+
return { error: validated.error };
|
|
1412
|
+
patch.hostKeys = validated.keys;
|
|
1413
|
+
}
|
|
1414
|
+
if (input.tunnels !== undefined) {
|
|
1415
|
+
// bookName 交叉校验:优先用同次提交的 sshHosts(整体替换语义),否则用现有连接簿
|
|
1416
|
+
const bookSource = Array.isArray(patch.sshHosts) ? patch.sshHosts : live.sshHosts;
|
|
1417
|
+
const bookNames = new Set(bookSource.map((host) => host.name));
|
|
1418
|
+
const validated = validateTunnels(input.tunnels, bookNames);
|
|
1419
|
+
if (validated.error !== undefined)
|
|
1420
|
+
return { error: validated.error };
|
|
1421
|
+
patch.tunnels = validated.tunnels;
|
|
1422
|
+
}
|
|
692
1423
|
return { patch };
|
|
693
1424
|
};
|
|
694
1425
|
// webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
|
|
@@ -744,19 +1475,334 @@ const plugin = definePlugin({
|
|
|
744
1475
|
writeJson(res, 200, { ok: true, config: snapshot() });
|
|
745
1476
|
},
|
|
746
1477
|
}));
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1478
|
+
// ~/.ssh/config 导入候选(连接簿):loopback 围栏,只回解析结果不落盘
|
|
1479
|
+
disposers.push(webServer.register({
|
|
1480
|
+
kind: 'exact',
|
|
1481
|
+
path: '/api/dsh-tty/ssh-config',
|
|
1482
|
+
handler: async (req, res) => {
|
|
1483
|
+
if (!isLoopbackHttp(req)) {
|
|
1484
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (req.method !== 'GET') {
|
|
1488
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
750
1491
|
try {
|
|
751
|
-
|
|
1492
|
+
const text = readFileSync(expandHome('~/.ssh/config'), 'utf8');
|
|
1493
|
+
writeJson(res, 200, { ok: true, entries: parseSshConfig(text) });
|
|
752
1494
|
}
|
|
753
|
-
catch {
|
|
754
|
-
|
|
1495
|
+
catch (error) {
|
|
1496
|
+
writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/config: ' + (error instanceof Error ? error.message : String(error)) });
|
|
755
1497
|
}
|
|
756
|
-
}
|
|
757
|
-
};
|
|
758
|
-
|
|
759
|
-
|
|
1498
|
+
},
|
|
1499
|
+
}));
|
|
1500
|
+
// env:VAR 下拉数据源(SSH 对话框):只回 env 插件托管变量名,绝不含值
|
|
1501
|
+
disposers.push(webServer.register({
|
|
1502
|
+
kind: 'exact',
|
|
1503
|
+
path: '/api/dsh-tty/env-vars',
|
|
1504
|
+
handler: async (req, res) => {
|
|
1505
|
+
if (!isLoopbackHttp(req)) {
|
|
1506
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
if (req.method !== 'GET') {
|
|
1510
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
writeJson(res, 200, { ok: true, names: readManagedEnvKeys() });
|
|
1514
|
+
},
|
|
1515
|
+
}));
|
|
1516
|
+
// known_hosts 指纹导入候选(TOFU 预填充):hashed 条目用连接簿主机名还原
|
|
1517
|
+
disposers.push(webServer.register({
|
|
1518
|
+
kind: 'exact',
|
|
1519
|
+
path: '/api/dsh-tty/known-hosts',
|
|
1520
|
+
handler: async (req, res) => {
|
|
1521
|
+
if (!isLoopbackHttp(req)) {
|
|
1522
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (req.method !== 'GET') {
|
|
1526
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
try {
|
|
1530
|
+
const text = readFileSync(expandHome('~/.ssh/known_hosts'), 'utf8');
|
|
1531
|
+
const candidates = live.sshHosts.flatMap((entry) => [entry.host, `[${entry.host}]:${entry.port}`]);
|
|
1532
|
+
writeJson(res, 200, { ok: true, entries: parseKnownHosts(text, candidates) });
|
|
1533
|
+
}
|
|
1534
|
+
catch (error) {
|
|
1535
|
+
writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/known_hosts: ' + (error instanceof Error ? error.message : String(error)) });
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
}));
|
|
1539
|
+
// 已安装 shell 候选(设置卡片「Shell 路径」可选可输入):loopback 围栏,只回路径不执行
|
|
1540
|
+
disposers.push(webServer.register({
|
|
1541
|
+
kind: 'exact',
|
|
1542
|
+
path: '/api/dsh-tty/shells',
|
|
1543
|
+
handler: async (req, res) => {
|
|
1544
|
+
if (!isLoopbackHttp(req)) {
|
|
1545
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (req.method !== 'GET') {
|
|
1549
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
writeJson(res, 200, { ok: true, shells: listCandidateShells(), current: process.env.SHELL ?? '' });
|
|
1553
|
+
},
|
|
1554
|
+
}));
|
|
1555
|
+
// 端口转发隧道实时状态(设置卡片轮询徽标 + tunnel_list 工具数据源)
|
|
1556
|
+
disposers.push(webServer.register({
|
|
1557
|
+
kind: 'exact',
|
|
1558
|
+
path: '/api/dsh-tty/tunnels',
|
|
1559
|
+
handler: async (req, res) => {
|
|
1560
|
+
if (!isLoopbackHttp(req)) {
|
|
1561
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (req.method !== 'GET') {
|
|
1565
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
writeJson(res, 200, { ok: true, tunnels: tunnelManager.list() });
|
|
1569
|
+
},
|
|
1570
|
+
}));
|
|
1571
|
+
// SFTP 文件传输(0.7.0,src/sftp.ts):loopback 围栏;spec 解析与 WS ssh
|
|
1572
|
+
// 帧同款(mergeSshSpec:连接簿条目作基底 + 内联字段覆盖)。list/mkdir/
|
|
1573
|
+
// rename/remove/download 走 JSON 体(凭证不进 URL/查询串);download 响应
|
|
1574
|
+
// 为文件字节流(stat 成功时带 content-length);upload 以 x-dsh-sftp-meta
|
|
1575
|
+
// 头携带 base64url(JSON)(spec + path + append),请求体即原始文件字节,
|
|
1576
|
+
// pipeline 直灌 SFTP 写流——上传下载都不整文件进内存。
|
|
1577
|
+
disposers.push(webServer.register({
|
|
1578
|
+
kind: 'prefix',
|
|
1579
|
+
path: '/api/dsh-tty/sftp',
|
|
1580
|
+
handler: async (req, res) => {
|
|
1581
|
+
if (!isLoopbackHttp(req)) {
|
|
1582
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
|
|
1586
|
+
const jsonAction = ['/list', '/mkdir', '/rename', '/remove', '/download'].find((action) => action === sub);
|
|
1587
|
+
if (jsonAction !== undefined) {
|
|
1588
|
+
if (req.method !== 'POST') {
|
|
1589
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const body = await readJsonBody(req);
|
|
1593
|
+
if (body === undefined) {
|
|
1594
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), body.name, body);
|
|
1598
|
+
if (parsed.spec === undefined) {
|
|
1599
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const spec = parsed.spec;
|
|
1603
|
+
const strField = (key) => (typeof body[key] === 'string' ? body[key].trim() : '');
|
|
1604
|
+
try {
|
|
1605
|
+
if (jsonAction === '/list') {
|
|
1606
|
+
const result = await sftpManager.list(spec, strField('path'));
|
|
1607
|
+
writeJson(res, 200, { ok: true, path: result.path, entries: result.entries });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (jsonAction === '/mkdir') {
|
|
1611
|
+
if (strField('path') === '')
|
|
1612
|
+
throw new Error('path 必填');
|
|
1613
|
+
await sftpManager.mkdir(spec, strField('path'), body.parents === true);
|
|
1614
|
+
writeJson(res, 200, { ok: true });
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (jsonAction === '/rename') {
|
|
1618
|
+
if (strField('from') === '' || strField('to') === '')
|
|
1619
|
+
throw new Error('from/to 必填');
|
|
1620
|
+
await sftpManager.rename(spec, strField('from'), strField('to'));
|
|
1621
|
+
writeJson(res, 200, { ok: true });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (jsonAction === '/remove') {
|
|
1625
|
+
if (strField('path') === '')
|
|
1626
|
+
throw new Error('path 必填');
|
|
1627
|
+
await sftpManager.remove(spec, strField('path'), body.recursive === true);
|
|
1628
|
+
writeJson(res, 200, { ok: true });
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
// download:路径必填(无 home 兜底),流式回包
|
|
1632
|
+
const target = strField('path');
|
|
1633
|
+
if (target === '')
|
|
1634
|
+
throw new Error('path 必填');
|
|
1635
|
+
const { stream, size } = await sftpManager.openDownload(spec, target);
|
|
1636
|
+
res.writeHead(200, {
|
|
1637
|
+
'content-type': 'application/octet-stream',
|
|
1638
|
+
'content-disposition': contentDispositionValue(remoteBasename(target)),
|
|
1639
|
+
...(size !== null ? { 'content-length': String(size) } : {}),
|
|
1640
|
+
});
|
|
1641
|
+
// 客户端中断或写流失败都要回收 SFTP 读流,避免连接池通道悬挂
|
|
1642
|
+
res.on('close', () => {
|
|
1643
|
+
if (res.writableEnded !== true)
|
|
1644
|
+
stream.destroy();
|
|
1645
|
+
});
|
|
1646
|
+
stream.on('error', (error) => {
|
|
1647
|
+
ctx.logger.warn('[dsh-tty] sftp 下载流错误: ' + error.message);
|
|
1648
|
+
res.destroy();
|
|
1649
|
+
});
|
|
1650
|
+
stream.pipe(res);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
catch (error) {
|
|
1654
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (sub === '/upload') {
|
|
1659
|
+
if (req.method !== 'POST') {
|
|
1660
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const metaRaw = req.headers['x-dsh-sftp-meta'];
|
|
1664
|
+
let meta;
|
|
1665
|
+
if (typeof metaRaw === 'string' && metaRaw !== '') {
|
|
1666
|
+
try {
|
|
1667
|
+
const parsedMeta = JSON.parse(Buffer.from(metaRaw, 'base64url').toString('utf8'));
|
|
1668
|
+
if (typeof parsedMeta === 'object' && parsedMeta !== null && !Array.isArray(parsedMeta))
|
|
1669
|
+
meta = parsedMeta;
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
/* 落到下面的 400 */
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
if (meta === undefined) {
|
|
1676
|
+
writeJson(res, 400, { error: '缺少或非法的 x-dsh-sftp-meta 头' });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const target = typeof meta.path === 'string' ? meta.path.trim() : '';
|
|
1680
|
+
if (target === '') {
|
|
1681
|
+
writeJson(res, 400, { error: 'meta.path 必填' });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), meta.name, meta);
|
|
1685
|
+
if (parsed.spec === undefined) {
|
|
1686
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
try {
|
|
1690
|
+
const { stream, done } = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
|
|
1691
|
+
let bytes = 0;
|
|
1692
|
+
req.on('data', (chunk) => {
|
|
1693
|
+
bytes += chunk.length;
|
|
1694
|
+
});
|
|
1695
|
+
await pipeline(req, stream);
|
|
1696
|
+
await done;
|
|
1697
|
+
writeJson(res, 200, { ok: true, bytes });
|
|
1698
|
+
}
|
|
1699
|
+
catch (error) {
|
|
1700
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1701
|
+
if (res.headersSent)
|
|
1702
|
+
res.destroy();
|
|
1703
|
+
else
|
|
1704
|
+
writeJson(res, 500, { error: message });
|
|
1705
|
+
}
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
writeJson(res, 404, { error: 'not found: ' + sub });
|
|
1709
|
+
},
|
|
1710
|
+
}));
|
|
1711
|
+
// 本机文件浏览(0.9.0,双栏 SFTP 的本机一侧):loopback 围栏。信任模型
|
|
1712
|
+
// 与终端/SFTP 一致——浏览器仅同源可访问,且本机能做的 SSH 会话也能做;
|
|
1713
|
+
// list/mkdir/rename/remove 操作本机路径;transfer 在服务端把本机路径与
|
|
1714
|
+
// 远程路径流式对拷(凭证不落浏览器,字节不经过浏览器)。
|
|
1715
|
+
disposers.push(webServer.register({
|
|
1716
|
+
kind: 'prefix',
|
|
1717
|
+
path: '/api/dsh-tty/local-fs',
|
|
1718
|
+
handler: async (req, res) => {
|
|
1719
|
+
if (!isLoopbackHttp(req)) {
|
|
1720
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
|
|
1724
|
+
if (req.method !== 'POST') {
|
|
1725
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
const body = await readJsonBody(req);
|
|
1729
|
+
if (body === undefined) {
|
|
1730
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const strField = (key) => (typeof body[key] === 'string' ? body[key].trim() : '');
|
|
1734
|
+
try {
|
|
1735
|
+
if (sub === '/list') {
|
|
1736
|
+
const result = await listLocalDir(strField('path'));
|
|
1737
|
+
writeJson(res, 200, { ok: true, path: result.path, entries: result.entries });
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (sub === '/mkdir') {
|
|
1741
|
+
const target = strField('path');
|
|
1742
|
+
if (target === '')
|
|
1743
|
+
throw new Error('path 必填');
|
|
1744
|
+
await fsMkdir(target, { recursive: body.parents === true });
|
|
1745
|
+
writeJson(res, 200, { ok: true });
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (sub === '/rename') {
|
|
1749
|
+
const from = strField('from');
|
|
1750
|
+
const to = strField('to');
|
|
1751
|
+
if (from === '' || to === '')
|
|
1752
|
+
throw new Error('from/to 必填');
|
|
1753
|
+
await fsRename(from, to);
|
|
1754
|
+
writeJson(res, 200, { ok: true });
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (sub === '/remove') {
|
|
1758
|
+
const target = strField('path');
|
|
1759
|
+
if (target === '')
|
|
1760
|
+
throw new Error('path 必填');
|
|
1761
|
+
await fsRm(target, { recursive: body.recursive === true });
|
|
1762
|
+
writeJson(res, 200, { ok: true });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
if (sub === '/transfer') {
|
|
1766
|
+
// up = 本机→远程(上传),down = 远程→本机(下载);目录递归
|
|
1767
|
+
const direction = strField('direction');
|
|
1768
|
+
const localPath = strField('localPath');
|
|
1769
|
+
const remotePath = strField('remotePath');
|
|
1770
|
+
if (localPath === '' || remotePath === '')
|
|
1771
|
+
throw new Error('localPath/remotePath 必填');
|
|
1772
|
+
if (direction !== 'up' && direction !== 'down')
|
|
1773
|
+
throw new Error('direction 必须是 up 或 down');
|
|
1774
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), body.name, body);
|
|
1775
|
+
if (parsed.spec === undefined) {
|
|
1776
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (direction === 'up')
|
|
1780
|
+
await sftpManager.uploadFromLocal(parsed.spec, localPath, remotePath);
|
|
1781
|
+
else
|
|
1782
|
+
await sftpManager.downloadToLocal(parsed.spec, remotePath, localPath);
|
|
1783
|
+
writeJson(res, 200, { ok: true });
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
writeJson(res, 404, { error: 'not found: ' + sub });
|
|
1787
|
+
}
|
|
1788
|
+
catch (error) {
|
|
1789
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1790
|
+
}
|
|
1791
|
+
},
|
|
1792
|
+
}));
|
|
1793
|
+
return () => {
|
|
1794
|
+
server.close();
|
|
1795
|
+
for (const dispose of disposers) {
|
|
1796
|
+
try {
|
|
1797
|
+
dispose();
|
|
1798
|
+
}
|
|
1799
|
+
catch {
|
|
1800
|
+
/* 路由已释放 */
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
}, 'dsh-tty: web routes');
|
|
1805
|
+
});
|
|
760
1806
|
// settings 命名空间:注册 + 启动合并持久化值 + settings/updated 热应用
|
|
761
1807
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
762
1808
|
settingsCtx.effect(() => {
|
|
@@ -781,9 +1827,22 @@ const plugin = definePlugin({
|
|
|
781
1827
|
startup.enabled = false;
|
|
782
1828
|
if (stored.announceToAgent === false)
|
|
783
1829
|
startup.announceToAgent = false;
|
|
1830
|
+
if (stored.reconnectGraceSec !== 120 && typeof stored.reconnectGraceSec === 'number' && Number.isInteger(stored.reconnectGraceSec) && stored.reconnectGraceSec >= 0 && stored.reconnectGraceSec <= 3600) {
|
|
1831
|
+
startup.reconnectGraceSec = stored.reconnectGraceSec;
|
|
1832
|
+
}
|
|
1833
|
+
if (stored.shellIntegration === false)
|
|
1834
|
+
startup.shellIntegration = false;
|
|
1835
|
+
if (stored.sftpStyle === 'dual')
|
|
1836
|
+
startup.sftpStyle = 'dual';
|
|
784
1837
|
const storedHosts = sanitizeSshHosts(stored.sshHosts);
|
|
785
1838
|
if (storedHosts !== undefined && storedHosts.length > 0)
|
|
786
1839
|
startup.sshHosts = storedHosts;
|
|
1840
|
+
const storedKeys = sanitizeHostKeys(stored.hostKeys);
|
|
1841
|
+
if (storedKeys !== undefined && storedKeys.length > 0)
|
|
1842
|
+
startup.hostKeys = storedKeys;
|
|
1843
|
+
const storedTunnels = sanitizeTunnels(stored.tunnels);
|
|
1844
|
+
if (storedTunnels !== undefined && storedTunnels.length > 0)
|
|
1845
|
+
startup.tunnels = storedTunnels;
|
|
787
1846
|
if (Object.keys(startup).length > 0)
|
|
788
1847
|
applyPatch(startup);
|
|
789
1848
|
const events = settingsCtx;
|
|
@@ -856,10 +1915,12 @@ const plugin = definePlugin({
|
|
|
856
1915
|
})));
|
|
857
1916
|
disposers.push(tools.register(defineTool({
|
|
858
1917
|
name: 'tty_capture',
|
|
859
|
-
description: '读取某个终端面板会话(tty_list 提供 sid
|
|
1918
|
+
description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)。',
|
|
860
1919
|
parameters: {
|
|
861
1920
|
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
862
|
-
lines: { type: 'number', description: '读取尾部行数(1~500,默认 60
|
|
1921
|
+
lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
|
|
1922
|
+
last: { type: 'boolean', description: 'true 只返回上一条命令的输出+退出码(默认 false 读尾部)' },
|
|
1923
|
+
raw: { type: 'boolean', description: 'true 返回含 ANSI 转义序列的原始输出(默认 false 清洗为纯文本)' },
|
|
863
1924
|
},
|
|
864
1925
|
output: {
|
|
865
1926
|
schema: {
|
|
@@ -868,22 +1929,162 @@ const plugin = definePlugin({
|
|
|
868
1929
|
properties: {
|
|
869
1930
|
sid: { type: 'string', required: true },
|
|
870
1931
|
tail: { type: 'string', required: true },
|
|
1932
|
+
source: { type: 'string' },
|
|
1933
|
+
exitCode: { type: 'number' },
|
|
871
1934
|
},
|
|
872
1935
|
},
|
|
873
1936
|
render: (_args, value) => {
|
|
874
1937
|
const v = value;
|
|
875
|
-
|
|
1938
|
+
const head = v.source === 'last'
|
|
1939
|
+
? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
|
|
1940
|
+
: `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
|
|
1941
|
+
return [{ type: 'text', text: head + (v.tail ?? '') }];
|
|
876
1942
|
},
|
|
877
1943
|
},
|
|
878
1944
|
async execute(args) {
|
|
879
1945
|
const input = args;
|
|
880
1946
|
if (typeof input.sid !== 'string' || input.sid === '')
|
|
881
1947
|
throw new Error('sid 必须是非空字符串');
|
|
1948
|
+
const session = sessions.get(input.sid);
|
|
1949
|
+
if (session === undefined || session.closed)
|
|
1950
|
+
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
1951
|
+
const useRaw = input.raw === true;
|
|
1952
|
+
if (input.last === true) {
|
|
1953
|
+
const last = session.shellState.lastCommand;
|
|
1954
|
+
if (last === null) {
|
|
1955
|
+
throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
|
|
1956
|
+
}
|
|
1957
|
+
return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(0, 128 * 1024) };
|
|
1958
|
+
}
|
|
882
1959
|
const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
|
|
1960
|
+
const rawTail = tailLines(session, lines);
|
|
1961
|
+
return { sid: input.sid, source: 'tail', tail: useRaw ? rawTail : cleanAnsiTail(rawTail) };
|
|
1962
|
+
},
|
|
1963
|
+
})));
|
|
1964
|
+
disposers.push(tools.register(defineTool({
|
|
1965
|
+
name: 'tty_screen',
|
|
1966
|
+
description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。',
|
|
1967
|
+
parameters: {
|
|
1968
|
+
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
1969
|
+
},
|
|
1970
|
+
output: {
|
|
1971
|
+
schema: {
|
|
1972
|
+
type: 'object',
|
|
1973
|
+
additionalProperties: false,
|
|
1974
|
+
properties: {
|
|
1975
|
+
sid: { type: 'string', required: true },
|
|
1976
|
+
cols: { type: 'number', required: true },
|
|
1977
|
+
rows: { type: 'number', required: true },
|
|
1978
|
+
text: { type: 'string', required: true },
|
|
1979
|
+
},
|
|
1980
|
+
},
|
|
1981
|
+
render: (_args, value) => {
|
|
1982
|
+
const v = value;
|
|
1983
|
+
return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')}):\n\n${v.text ?? ''}` }];
|
|
1984
|
+
},
|
|
1985
|
+
},
|
|
1986
|
+
async execute(args) {
|
|
1987
|
+
const input = args;
|
|
1988
|
+
if (typeof input.sid !== 'string' || input.sid === '')
|
|
1989
|
+
throw new Error('sid 必须是非空字符串');
|
|
883
1990
|
const session = sessions.get(input.sid);
|
|
884
1991
|
if (session === undefined || session.closed)
|
|
885
1992
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
886
|
-
|
|
1993
|
+
const screen = session.screen;
|
|
1994
|
+
if (screen === null)
|
|
1995
|
+
throw new Error(`虚拟屏不可用: ${input.sid}`);
|
|
1996
|
+
const buffer = screen.buffer.active;
|
|
1997
|
+
const lines = [];
|
|
1998
|
+
for (let row = 0; row < screen.rows; row++) {
|
|
1999
|
+
lines.push(buffer.getLine(row)?.translateToString(true) ?? '');
|
|
2000
|
+
}
|
|
2001
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
2002
|
+
lines.pop();
|
|
2003
|
+
return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(0, 32 * 1024) };
|
|
2004
|
+
},
|
|
2005
|
+
})));
|
|
2006
|
+
disposers.push(tools.register(defineTool({
|
|
2007
|
+
name: 'tty_expect',
|
|
2008
|
+
description: '在某个终端面板会话(tty_list 提供 sid)的后续输出中等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。匹配到立即返回 matched:true 与周边输出;超时不抛错,返回 matched:false + 尾部输出供判断重试或放弃;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。',
|
|
2009
|
+
parameters: {
|
|
2010
|
+
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
2011
|
+
pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
|
|
2012
|
+
timeoutSec: { type: 'number', description: '等待秒数(1~600,默认 30)' },
|
|
2013
|
+
},
|
|
2014
|
+
output: {
|
|
2015
|
+
schema: {
|
|
2016
|
+
type: 'object',
|
|
2017
|
+
additionalProperties: false,
|
|
2018
|
+
properties: {
|
|
2019
|
+
matched: { type: 'boolean', required: true },
|
|
2020
|
+
timedOut: { type: 'boolean', required: true },
|
|
2021
|
+
text: { type: 'string', required: true },
|
|
2022
|
+
exitCode: { type: 'number' },
|
|
2023
|
+
},
|
|
2024
|
+
},
|
|
2025
|
+
render: (_args, value) => {
|
|
2026
|
+
const v = value;
|
|
2027
|
+
if (v.matched === true)
|
|
2028
|
+
return [{ type: 'text', text: `已匹配到等待的模式:\n\n${v.text ?? ''}` }];
|
|
2029
|
+
const why = v.timedOut === true ? '等待超时' : `命令已结束(exitCode=${String(v.exitCode ?? '?')})但未出现匹配`;
|
|
2030
|
+
return [{ type: 'text', text: `${why}。尾部输出:\n\n${v.text ?? ''}` }];
|
|
2031
|
+
},
|
|
2032
|
+
},
|
|
2033
|
+
async execute(args) {
|
|
2034
|
+
const input = args;
|
|
2035
|
+
if (typeof input.sid !== 'string' || input.sid === '')
|
|
2036
|
+
throw new Error('sid 必须是非空字符串');
|
|
2037
|
+
if (typeof input.pattern !== 'string' || input.pattern === '')
|
|
2038
|
+
throw new Error('pattern 必须是非空字符串');
|
|
2039
|
+
const session = sessions.get(input.sid);
|
|
2040
|
+
if (session === undefined || session.closed)
|
|
2041
|
+
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
2042
|
+
let re;
|
|
2043
|
+
try {
|
|
2044
|
+
re = new RegExp(input.pattern);
|
|
2045
|
+
}
|
|
2046
|
+
catch (error) {
|
|
2047
|
+
throw new Error('pattern 不是合法的正则表达式: ' + (error instanceof Error ? error.message : String(error)));
|
|
2048
|
+
}
|
|
2049
|
+
const timeoutSec = Math.max(1, Math.min(600, typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) && input.timeoutSec >= 1 ? input.timeoutSec : 30));
|
|
2050
|
+
const timeoutMs = timeoutSec * 1000;
|
|
2051
|
+
return await new Promise((resolve) => {
|
|
2052
|
+
const startedAt = Date.now();
|
|
2053
|
+
const startedInCommand = session.shellState.inCommand;
|
|
2054
|
+
let acc = '';
|
|
2055
|
+
let settled = false;
|
|
2056
|
+
const decoder = new StringDecoder('utf8');
|
|
2057
|
+
const output = session.handle.output;
|
|
2058
|
+
const finish = (result) => {
|
|
2059
|
+
if (settled)
|
|
2060
|
+
return;
|
|
2061
|
+
settled = true;
|
|
2062
|
+
clearTimeout(timer);
|
|
2063
|
+
output.off('data', onData);
|
|
2064
|
+
resolve(result);
|
|
2065
|
+
};
|
|
2066
|
+
const onData = (chunk) => {
|
|
2067
|
+
acc += decoder.write(chunk);
|
|
2068
|
+
const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
|
|
2069
|
+
if (re.test(hay)) {
|
|
2070
|
+
finish({ matched: true, timedOut: false, text: cleanAnsiTail(hay.slice(-6 * 1024)) });
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
// 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
|
|
2074
|
+
const state = session.shellState;
|
|
2075
|
+
if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
|
|
2076
|
+
finish({ matched: false, timedOut: false, exitCode: state.lastCommand.exitCode ?? undefined, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
|
|
2077
|
+
}
|
|
2078
|
+
};
|
|
2079
|
+
const timer = setTimeout(() => {
|
|
2080
|
+
finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
|
|
2081
|
+
}, timeoutMs);
|
|
2082
|
+
timer.unref?.();
|
|
2083
|
+
output.on('data', onData);
|
|
2084
|
+
void session.handle.done.then(() => {
|
|
2085
|
+
finish({ matched: false, timedOut: false, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
|
|
2086
|
+
});
|
|
2087
|
+
});
|
|
887
2088
|
},
|
|
888
2089
|
})));
|
|
889
2090
|
disposers.push(tools.register(defineTool({
|
|
@@ -920,8 +2121,379 @@ const plugin = definePlugin({
|
|
|
920
2121
|
return { ok: true, sent: input.data.length };
|
|
921
2122
|
},
|
|
922
2123
|
})));
|
|
2124
|
+
disposers.push(tools.register(defineTool({
|
|
2125
|
+
name: 'tunnel_list',
|
|
2126
|
+
description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 设置 → 插件 → 终端面板 卡片维护。',
|
|
2127
|
+
parameters: {},
|
|
2128
|
+
output: {
|
|
2129
|
+
schema: {
|
|
2130
|
+
type: 'object',
|
|
2131
|
+
additionalProperties: false,
|
|
2132
|
+
properties: {
|
|
2133
|
+
tunnels: {
|
|
2134
|
+
type: 'array',
|
|
2135
|
+
required: true,
|
|
2136
|
+
items: {
|
|
2137
|
+
type: 'object',
|
|
2138
|
+
additionalProperties: false,
|
|
2139
|
+
properties: {
|
|
2140
|
+
name: { type: 'string', required: true },
|
|
2141
|
+
direction: { type: 'string', required: true },
|
|
2142
|
+
rule: { type: 'string', required: true },
|
|
2143
|
+
bookName: { type: 'string', required: true },
|
|
2144
|
+
state: { type: 'string', required: true },
|
|
2145
|
+
error: { type: 'string' },
|
|
2146
|
+
connections: { type: 'number', required: true },
|
|
2147
|
+
totalConnections: { type: 'number', required: true },
|
|
2148
|
+
},
|
|
2149
|
+
},
|
|
2150
|
+
},
|
|
2151
|
+
},
|
|
2152
|
+
},
|
|
2153
|
+
render: (_args, value) => {
|
|
2154
|
+
const v = value;
|
|
2155
|
+
const tunnels = v.tunnels ?? [];
|
|
2156
|
+
if (tunnels.length === 0)
|
|
2157
|
+
return [{ type: 'text', text: '当前没有配置端口转发隧道(设置 → 插件 → 终端面板 卡片可添加)' }];
|
|
2158
|
+
const text = '端口转发隧道:' + tunnels.map((t) => {
|
|
2159
|
+
const tail = t.error !== null && t.error !== undefined ? `(错误: ${t.error})` : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
|
|
2160
|
+
return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
|
|
2161
|
+
}).join('');
|
|
2162
|
+
return [{ type: 'text', text }];
|
|
2163
|
+
},
|
|
2164
|
+
},
|
|
2165
|
+
async execute() {
|
|
2166
|
+
return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
|
|
2167
|
+
},
|
|
2168
|
+
})));
|
|
2169
|
+
// —— SFTP 文件传输工具(0.7.0)——
|
|
2170
|
+
// 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
|
|
2171
|
+
// 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
|
|
2172
|
+
const sftpBookSpec = (book) => {
|
|
2173
|
+
if (typeof book !== 'string' || book.trim() === '')
|
|
2174
|
+
throw new Error('book 必须是 SSH 连接簿条目名');
|
|
2175
|
+
const entry = live.findSshHost(book.trim());
|
|
2176
|
+
if (entry === undefined)
|
|
2177
|
+
throw new Error(`连接簿中不存在: ${book.trim()}`);
|
|
2178
|
+
return entry;
|
|
2179
|
+
};
|
|
2180
|
+
disposers.push(tools.register(defineTool({
|
|
2181
|
+
name: 'sftp_list',
|
|
2182
|
+
description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。用于查找远程文件、确认上传下载结果。',
|
|
2183
|
+
parameters: {
|
|
2184
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
|
|
2185
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
2186
|
+
},
|
|
2187
|
+
output: {
|
|
2188
|
+
schema: {
|
|
2189
|
+
type: 'object',
|
|
2190
|
+
additionalProperties: false,
|
|
2191
|
+
properties: {
|
|
2192
|
+
path: { type: 'string', required: true },
|
|
2193
|
+
entries: {
|
|
2194
|
+
type: 'array',
|
|
2195
|
+
required: true,
|
|
2196
|
+
items: {
|
|
2197
|
+
type: 'object',
|
|
2198
|
+
additionalProperties: false,
|
|
2199
|
+
properties: {
|
|
2200
|
+
name: { type: 'string', required: true },
|
|
2201
|
+
isDir: { type: 'boolean', required: true },
|
|
2202
|
+
size: { type: 'number', required: true },
|
|
2203
|
+
mtime: { type: 'number', required: true },
|
|
2204
|
+
},
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
render: (_args, value) => {
|
|
2210
|
+
const v = value;
|
|
2211
|
+
const entries = v.entries ?? [];
|
|
2212
|
+
if (entries.length === 0)
|
|
2213
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
2214
|
+
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : ''} — ${e.isDir ? '目录' : humanFileSize(e.size)}`).join('');
|
|
2215
|
+
return [{ type: 'text', text }];
|
|
2216
|
+
},
|
|
2217
|
+
},
|
|
2218
|
+
async execute(args) {
|
|
2219
|
+
const input = args;
|
|
2220
|
+
const spec = sftpBookSpec(input.book);
|
|
2221
|
+
const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
|
|
2222
|
+
return { path: result.path, entries: result.entries.map((e) => ({ name: e.name, isDir: e.isDir, size: e.size, mtime: e.mtime })) };
|
|
2223
|
+
},
|
|
2224
|
+
})));
|
|
2225
|
+
disposers.push(tools.register(defineTool({
|
|
2226
|
+
name: 'sftp_read',
|
|
2227
|
+
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB),超出截断;检测到 NUL 字节按二进制文件拒绝。适合查看远程配置、小日志。',
|
|
2228
|
+
parameters: {
|
|
2229
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2230
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
2231
|
+
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144)' },
|
|
2232
|
+
},
|
|
2233
|
+
output: {
|
|
2234
|
+
schema: {
|
|
2235
|
+
type: 'object',
|
|
2236
|
+
additionalProperties: false,
|
|
2237
|
+
properties: {
|
|
2238
|
+
path: { type: 'string', required: true },
|
|
2239
|
+
content: { type: 'string', required: true },
|
|
2240
|
+
truncated: { type: 'boolean', required: true },
|
|
2241
|
+
},
|
|
2242
|
+
},
|
|
2243
|
+
render: (_args, value) => {
|
|
2244
|
+
const v = value;
|
|
2245
|
+
const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断)' : ''}:`;
|
|
2246
|
+
return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
|
|
2247
|
+
},
|
|
2248
|
+
},
|
|
2249
|
+
async execute(args) {
|
|
2250
|
+
const input = args;
|
|
2251
|
+
const spec = sftpBookSpec(input.book);
|
|
2252
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2253
|
+
throw new Error('path 必须是非空字符串');
|
|
2254
|
+
const maxBytes = typeof input.maxBytes === 'number' && Number.isInteger(input.maxBytes) && input.maxBytes >= 1 && input.maxBytes <= 1024 * 1024 ? input.maxBytes : 256 * 1024;
|
|
2255
|
+
const { stream } = await sftpManager.openDownload(spec, input.path);
|
|
2256
|
+
const chunks = [];
|
|
2257
|
+
let total = 0;
|
|
2258
|
+
try {
|
|
2259
|
+
for await (const chunk of stream) {
|
|
2260
|
+
const piece = chunk;
|
|
2261
|
+
chunks.push(piece);
|
|
2262
|
+
total += piece.length;
|
|
2263
|
+
if (total > maxBytes)
|
|
2264
|
+
break; // 只多读一段用于判定截断,其余丢弃
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
finally {
|
|
2268
|
+
stream.destroy();
|
|
2269
|
+
}
|
|
2270
|
+
const buf = Buffer.concat(chunks);
|
|
2271
|
+
const truncated = buf.length > maxBytes;
|
|
2272
|
+
const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
|
|
2273
|
+
if (sliced.includes(0))
|
|
2274
|
+
throw new Error('疑似二进制文件(含 NUL 字节),sftp_read 只支持文本内容');
|
|
2275
|
+
return { path: input.path.trim(), content: sliced.toString('utf8'), truncated };
|
|
2276
|
+
},
|
|
2277
|
+
})));
|
|
2278
|
+
disposers.push(tools.register(defineTool({
|
|
2279
|
+
name: 'sftp_write',
|
|
2280
|
+
description: '写 SSH 远程文本文件(book 连接簿条目 + path + content)。默认覆盖写入,append:true 追加到文件尾;单次最多 1MB。适合远程写配置、落结果文件。',
|
|
2281
|
+
parameters: {
|
|
2282
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2283
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
2284
|
+
content: { type: 'string', required: true, description: '要写入的文本内容(≤1MB)' },
|
|
2285
|
+
append: { type: 'boolean', description: 'true 追加到文件尾(默认覆盖)' },
|
|
2286
|
+
},
|
|
2287
|
+
output: {
|
|
2288
|
+
schema: {
|
|
2289
|
+
type: 'object',
|
|
2290
|
+
additionalProperties: false,
|
|
2291
|
+
properties: {
|
|
2292
|
+
ok: { type: 'boolean', required: true },
|
|
2293
|
+
path: { type: 'string', required: true },
|
|
2294
|
+
bytes: { type: 'number', required: true },
|
|
2295
|
+
append: { type: 'boolean', required: true },
|
|
2296
|
+
},
|
|
2297
|
+
},
|
|
2298
|
+
render: (_args, value) => {
|
|
2299
|
+
const v = value;
|
|
2300
|
+
return [{ type: 'text', text: `已${v.append === true ? '追加' : '写入'}远程文件 ${v.path ?? '?'}(${String(v.bytes ?? 0)} 字节)` }];
|
|
2301
|
+
},
|
|
2302
|
+
},
|
|
2303
|
+
async execute(args) {
|
|
2304
|
+
const input = args;
|
|
2305
|
+
const spec = sftpBookSpec(input.book);
|
|
2306
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2307
|
+
throw new Error('path 必须是非空字符串');
|
|
2308
|
+
if (typeof input.content !== 'string')
|
|
2309
|
+
throw new Error('content 必须是字符串');
|
|
2310
|
+
const bytes = Buffer.byteLength(input.content, 'utf8');
|
|
2311
|
+
if (bytes > 1024 * 1024)
|
|
2312
|
+
throw new Error(`content 超过上限:${String(bytes)} 字节 > 1MB(大文件请用终端 scp 或面板上传)`);
|
|
2313
|
+
const append = input.append === true;
|
|
2314
|
+
const { stream, done } = await sftpManager.openUpload(spec, input.path, append);
|
|
2315
|
+
stream.write(input.content, 'utf8');
|
|
2316
|
+
stream.end();
|
|
2317
|
+
await done;
|
|
2318
|
+
return { ok: true, path: input.path.trim(), bytes, append };
|
|
2319
|
+
},
|
|
2320
|
+
})));
|
|
2321
|
+
// —— SFTP 管理闭环(0.8.0)——
|
|
2322
|
+
// mkdir(可逐级补齐)/ rename(可跨目录,等效移动)/ remove(目录
|
|
2323
|
+
// 递归)/ tree(限深限数的递归列举),与 sftp_list/read/write 一起
|
|
2324
|
+
// 让 agent 不开面板也能完整管理远程文件;同样只收连接簿条目名。
|
|
2325
|
+
disposers.push(tools.register(defineTool({
|
|
2326
|
+
name: 'sftp_mkdir',
|
|
2327
|
+
description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
|
|
2328
|
+
parameters: {
|
|
2329
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
|
|
2330
|
+
path: { type: 'string', required: true, description: '要创建的远程目录路径' },
|
|
2331
|
+
parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
|
|
2332
|
+
},
|
|
2333
|
+
output: {
|
|
2334
|
+
schema: {
|
|
2335
|
+
type: 'object',
|
|
2336
|
+
additionalProperties: false,
|
|
2337
|
+
properties: {
|
|
2338
|
+
ok: { type: 'boolean', required: true },
|
|
2339
|
+
path: { type: 'string', required: true },
|
|
2340
|
+
},
|
|
2341
|
+
},
|
|
2342
|
+
render: (_args, value) => {
|
|
2343
|
+
const v = value;
|
|
2344
|
+
return [{ type: 'text', text: `已创建远程目录 ${v.path ?? '?'}` }];
|
|
2345
|
+
},
|
|
2346
|
+
},
|
|
2347
|
+
async execute(args) {
|
|
2348
|
+
const input = args;
|
|
2349
|
+
const spec = sftpBookSpec(input.book);
|
|
2350
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2351
|
+
throw new Error('path 必须是非空字符串');
|
|
2352
|
+
await sftpManager.mkdir(spec, input.path, input.parents === true);
|
|
2353
|
+
return { ok: true, path: input.path.trim() };
|
|
2354
|
+
},
|
|
2355
|
+
})));
|
|
2356
|
+
disposers.push(tools.register(defineTool({
|
|
2357
|
+
name: 'sftp_rename',
|
|
2358
|
+
description: '在 SSH 远程重命名 / 移动文件或目录(book 连接簿条目 + from + to)。to 与 from 不同目录即为移动(目标目录需已存在);不会覆盖已存在的目标(服务端 rename 语义)。',
|
|
2359
|
+
parameters: {
|
|
2360
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2361
|
+
from: { type: 'string', required: true, description: '原远程路径' },
|
|
2362
|
+
to: { type: 'string', required: true, description: '新远程路径(跨目录即移动)' },
|
|
2363
|
+
},
|
|
2364
|
+
output: {
|
|
2365
|
+
schema: {
|
|
2366
|
+
type: 'object',
|
|
2367
|
+
additionalProperties: false,
|
|
2368
|
+
properties: {
|
|
2369
|
+
ok: { type: 'boolean', required: true },
|
|
2370
|
+
from: { type: 'string', required: true },
|
|
2371
|
+
to: { type: 'string', required: true },
|
|
2372
|
+
},
|
|
2373
|
+
},
|
|
2374
|
+
render: (_args, value) => {
|
|
2375
|
+
const v = value;
|
|
2376
|
+
return [{ type: 'text', text: `已将远程 ${v.from ?? '?'} 重命名/移动为 ${v.to ?? '?'}` }];
|
|
2377
|
+
},
|
|
2378
|
+
},
|
|
2379
|
+
async execute(args) {
|
|
2380
|
+
const input = args;
|
|
2381
|
+
const spec = sftpBookSpec(input.book);
|
|
2382
|
+
if (typeof input.from !== 'string' || input.from.trim() === '')
|
|
2383
|
+
throw new Error('from 必须是非空字符串');
|
|
2384
|
+
if (typeof input.to !== 'string' || input.to.trim() === '')
|
|
2385
|
+
throw new Error('to 必须是非空字符串');
|
|
2386
|
+
await sftpManager.rename(spec, input.from, input.to);
|
|
2387
|
+
return { ok: true, from: input.from.trim(), to: input.to.trim() };
|
|
2388
|
+
},
|
|
2389
|
+
})));
|
|
2390
|
+
disposers.push(tools.register(defineTool({
|
|
2391
|
+
name: 'sftp_remove',
|
|
2392
|
+
description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。',
|
|
2393
|
+
parameters: {
|
|
2394
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2395
|
+
path: { type: 'string', required: true, description: '要删除的远程路径' },
|
|
2396
|
+
recursive: { type: 'boolean', description: '目录 true 时递归删除全部内容(默认 false)' },
|
|
2397
|
+
},
|
|
2398
|
+
output: {
|
|
2399
|
+
schema: {
|
|
2400
|
+
type: 'object',
|
|
2401
|
+
additionalProperties: false,
|
|
2402
|
+
properties: {
|
|
2403
|
+
ok: { type: 'boolean', required: true },
|
|
2404
|
+
path: { type: 'string', required: true },
|
|
2405
|
+
recursive: { type: 'boolean', required: true },
|
|
2406
|
+
},
|
|
2407
|
+
},
|
|
2408
|
+
render: (_args, value) => {
|
|
2409
|
+
const v = value;
|
|
2410
|
+
return [{ type: 'text', text: `已删除远程 ${v.path ?? '?'}${v.recursive === true ? '(含全部内容)' : ''}` }];
|
|
2411
|
+
},
|
|
2412
|
+
},
|
|
2413
|
+
async execute(args) {
|
|
2414
|
+
const input = args;
|
|
2415
|
+
const spec = sftpBookSpec(input.book);
|
|
2416
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2417
|
+
throw new Error('path 必须是非空字符串');
|
|
2418
|
+
const recursive = input.recursive === true;
|
|
2419
|
+
await sftpManager.remove(spec, input.path, recursive);
|
|
2420
|
+
return { ok: true, path: input.path.trim(), recursive };
|
|
2421
|
+
},
|
|
2422
|
+
})));
|
|
2423
|
+
disposers.push(tools.register(defineTool({
|
|
2424
|
+
name: 'sftp_tree',
|
|
2425
|
+
description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
|
|
2426
|
+
parameters: {
|
|
2427
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2428
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
2429
|
+
maxDepth: { type: 'number', description: '最大下钻层数(1~8,默认 3)' },
|
|
2430
|
+
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
2431
|
+
},
|
|
2432
|
+
output: {
|
|
2433
|
+
schema: {
|
|
2434
|
+
type: 'object',
|
|
2435
|
+
additionalProperties: false,
|
|
2436
|
+
properties: {
|
|
2437
|
+
path: { type: 'string', required: true },
|
|
2438
|
+
entries: {
|
|
2439
|
+
type: 'array',
|
|
2440
|
+
required: true,
|
|
2441
|
+
items: {
|
|
2442
|
+
type: 'object',
|
|
2443
|
+
additionalProperties: false,
|
|
2444
|
+
properties: {
|
|
2445
|
+
path: { type: 'string', required: true },
|
|
2446
|
+
name: { type: 'string', required: true },
|
|
2447
|
+
depth: { type: 'number', required: true },
|
|
2448
|
+
isDir: { type: 'boolean', required: true },
|
|
2449
|
+
size: { type: 'number', required: true },
|
|
2450
|
+
mtime: { type: 'number', required: true },
|
|
2451
|
+
},
|
|
2452
|
+
},
|
|
2453
|
+
},
|
|
2454
|
+
truncated: { type: 'boolean', required: true },
|
|
2455
|
+
errors: {
|
|
2456
|
+
type: 'array',
|
|
2457
|
+
required: true,
|
|
2458
|
+
items: {
|
|
2459
|
+
type: 'object',
|
|
2460
|
+
additionalProperties: false,
|
|
2461
|
+
properties: {
|
|
2462
|
+
path: { type: 'string', required: true },
|
|
2463
|
+
message: { type: 'string', required: true },
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
},
|
|
2467
|
+
},
|
|
2468
|
+
},
|
|
2469
|
+
render: (_args, value) => {
|
|
2470
|
+
const v = value;
|
|
2471
|
+
const entries = v.entries ?? [];
|
|
2472
|
+
if (entries.length === 0)
|
|
2473
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
2474
|
+
const head = `远程目录 ${v.path ?? '?'} 结构(${String(entries.length)} 项${v.truncated === true ? ',已截断' : ''}):`;
|
|
2475
|
+
const lines = entries.map((e) => {
|
|
2476
|
+
const indent = ' '.repeat(Math.max(0, e.depth - 1));
|
|
2477
|
+
const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
|
|
2478
|
+
return `${indent}- ${e.name}${tail}`;
|
|
2479
|
+
});
|
|
2480
|
+
for (const item of v.errors ?? [])
|
|
2481
|
+
lines.push(`! ${item.path}(${item.message})`);
|
|
2482
|
+
return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
|
|
2483
|
+
},
|
|
2484
|
+
},
|
|
2485
|
+
async execute(args) {
|
|
2486
|
+
const input = args;
|
|
2487
|
+
const spec = sftpBookSpec(input.book);
|
|
2488
|
+
const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
|
|
2489
|
+
maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
|
|
2490
|
+
maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
|
|
2491
|
+
});
|
|
2492
|
+
return result;
|
|
2493
|
+
},
|
|
2494
|
+
})));
|
|
923
2495
|
stateRef.toolsRegistered = true;
|
|
924
|
-
console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_send)');
|
|
2496
|
+
console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
|
|
925
2497
|
return () => {
|
|
926
2498
|
stateRef.toolsRegistered = false;
|
|
927
2499
|
for (const dispose of disposers) {
|
|
@@ -938,19 +2510,45 @@ const plugin = definePlugin({
|
|
|
938
2510
|
else {
|
|
939
2511
|
console.log('[dsh-tty] tools service unavailable; agent tools skipped');
|
|
940
2512
|
}
|
|
941
|
-
// 向 agent
|
|
2513
|
+
// 向 agent 公告终端面板能力(静态 section)+ 每轮注入活跃会话快照(动态 context)
|
|
942
2514
|
if (config?.announceToAgent !== false) {
|
|
943
2515
|
ctx.inject(['systemPrompt'], (promptCtx) => {
|
|
944
2516
|
promptCtx.effect(() => {
|
|
945
2517
|
const systemPrompt = promptCtx.systemPrompt;
|
|
946
|
-
|
|
2518
|
+
const contextDisposable = systemPrompt.context({
|
|
2519
|
+
name: 'plugin:dsh-tty:terminals',
|
|
2520
|
+
order: 150,
|
|
2521
|
+
text: () => {
|
|
2522
|
+
const list = sessions.list();
|
|
2523
|
+
if (list.length === 0)
|
|
2524
|
+
return '当前没有活跃的终端面板会话(可引导用户打开「终端」面板,或用 spawn 类工作流替代)。';
|
|
2525
|
+
return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,sid 如下):\n' + list.map((s) => {
|
|
2526
|
+
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
2527
|
+
return `- sid=${s.sid} [${s.kind}] ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
2528
|
+
}).join('\n');
|
|
2529
|
+
},
|
|
2530
|
+
});
|
|
2531
|
+
const sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
|
|
2532
|
+
return () => {
|
|
2533
|
+
sectionDisposable();
|
|
2534
|
+
contextDisposable();
|
|
2535
|
+
};
|
|
947
2536
|
}, 'dsh-tty: announcement');
|
|
948
2537
|
});
|
|
949
2538
|
}
|
|
950
|
-
//
|
|
2539
|
+
// 孤儿会话回收器:超过保活期的异常断开会话定期清理(grace=0 时为 no-op,
|
|
2540
|
+
// 断开时立即结束);插件卸载时随 effect 一起停掉
|
|
2541
|
+
const reaperTimer = setInterval(() => {
|
|
2542
|
+
void sessions.reapOrphans(live.reconnectGraceMs);
|
|
2543
|
+
}, REAPER_INTERVAL_MS);
|
|
2544
|
+
reaperTimer.unref?.();
|
|
2545
|
+
ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
|
|
2546
|
+
// 插件卸载时回收全部会话、隧道与 SFTP 连接
|
|
951
2547
|
ctx.effect(() => {
|
|
952
2548
|
return () => {
|
|
953
2549
|
void sessions.disposeAll();
|
|
2550
|
+
tunnelManager.disposeAll();
|
|
2551
|
+
sftpManager.disposeAll();
|
|
954
2552
|
};
|
|
955
2553
|
}, 'dsh-tty: session cleanup');
|
|
956
2554
|
console.log(`[dsh-tty] mounted (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);
|