@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/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
- const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页;新标签默认在当前会话工作目录打开,工作目录可随当前会话切换。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护),像本地终端一样操作远程主机。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧也有配套工具:tty_list 列出活跃终端会话(含 SSH 会话的 target),tty_capture 读取会话近期输出(可查看 dev server/NPM 日志),tty_send 向会话发送按键——操作会实时显示在用户终端里。';
41
- /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合)。 */
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) => handle.terminal.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: () => handle.terminal.kill('SIGKILL'),
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.trim();
150
+ this.term = sanitizeTermValue(partial.term, this.term);
74
151
  if (typeof partial.colorTerm === 'string' && partial.colorTerm.trim() !== '')
75
- this.colorTerm = partial.colorTerm.trim();
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
- /** agent 工具用的只读快照(SSH 会话无本地 pid,该字段省略)。 */
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
- const base = {
244
- sid: session.id,
245
- cwd: session.cwd,
246
- kind: session.kind,
247
- target: session.target,
248
- startedAt: session.startedAt,
249
- lastOutputAt: session.lastOutputAt,
250
- };
251
- return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
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) => forceKill(session.handle)));
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 = true;
292
- this.sessions.remove(session.id);
293
- await forceKill(session.handle);
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: shellArgv(this.options.shell, this.options.term, this.options.colorTerm),
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, ws);
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 profile = typeof msg.name === 'string' && msg.name !== '' ? this.options.findSshHost(msg.name) : undefined;
406
- if (typeof msg.name === 'string' && msg.name !== '' && profile === undefined) {
407
- send(ws, { t: 'error', sid, m: `连接簿中不存在: ${msg.name}` });
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, ws);
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
- session.handle.resize(Number(msg.cols) || 80, Number(msg.rows) || 24);
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 || 'unknown' in resolved)
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.sessions.remove(resolved.sid);
484
- await forceKill(session.handle);
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, ws) {
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
- send(ws, { t: 'exit', sid: session.id, code: outcome.exitCode, signal: outcome.signal });
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 onData = (chunk) => {
508
- if (session.closed)
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 sid = session.id;
515
- ws.send(JSON.stringify({ t: 'data', sid, d: text }), () => {
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
- const server = new TtyServer(ctx, sessions, live);
613
- const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false };
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
- return () => {
748
- server.close();
749
- for (const dispose of disposers) {
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
- dispose();
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
- }, 'dsh-tty: web routes');
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)的近期输出(默认尾部 60 行,最多 500 行)。适合查看用户终端里正在运行的 dev server / watch / 构建输出。',
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
- return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n${v.tail ?? ''}` }];
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
- return { sid: input.sid, tail: tailLines(session, lines) };
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
- return systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
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})`);