@hyzyn/dsh-tty 0.11.0 → 0.15.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
@@ -16,7 +16,7 @@ import { definePlugin } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
17
  import { spawnSsh, sshTarget, expandHome } from './ssh.js';
18
18
  import { probeSsh } from './probe.js';
19
- import { buildShellSpawn } from './shell-integration.js';
19
+ import { buildCommandSpawn, buildShellSpawn } from './shell-integration.js';
20
20
  import { parseSshConfig } from './ssh-config.js';
21
21
  import { parseKnownHosts } from './known-hosts.js';
22
22
  import { TunnelManager } from './tunnels.js';
@@ -88,12 +88,34 @@ const DEFAULT_RECONNECT_GRACE_SEC = 120;
88
88
  const BACKPRESSURE_HIGH = 512 * 1024;
89
89
  const BACKPRESSURE_LOW = 128 * 1024;
90
90
  const SID_RE = /^[A-Za-z0-9_-]{1,64}$/;
91
+ /** 自定义命令标签(0.14.0)的长度上限:单条命令,防误传超长脚本。 */
92
+ const COMMAND_MAX = 2000;
93
+ /**
94
+ * 清洗帧里的 `command`(0.14.0,ttyTerminal 服务用):必须是单行、非空、
95
+ * 长度受控的字符串。命令来自**宿主侧插件**(如 dsh-docker 的
96
+ * `docker exec -it <容器> sh`),信任级与插件本身相同;这里只做形状校验,
97
+ * 避免换行破坏本地 `-c` 包装层、或超长内容拖垮帧解析。
98
+ */
99
+ function sanitizeCommand(value) {
100
+ if (value === undefined)
101
+ return {};
102
+ if (typeof value !== 'string')
103
+ return { error: 'command 必须是字符串' };
104
+ const trimmed = value.trim();
105
+ if (trimmed === '')
106
+ return { error: 'command 不能为空' };
107
+ if (trimmed.length > COMMAND_MAX)
108
+ return { error: `command 过长(≤${String(COMMAND_MAX)} 字符)` };
109
+ if (/[\r\n\0]/.test(trimmed))
110
+ return { error: 'command 必须是单行(不能含换行/NUL)' };
111
+ return { command: trimmed };
112
+ }
91
113
  const BUFFER_CAP = 256 * 1024;
92
114
  /** TERM/COLORTERM 白名单:防止值里的引号破坏 -c 包装层命令(shellArgv 单引号包裹)。 */
93
115
  const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
94
116
  /** 孤儿会话回收器的扫描间隔。 */
95
117
  const REAPER_INTERVAL_MS = 10_000;
96
- const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(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} 拿结果。';
118
+ const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(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} 拿结果。';
97
119
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
98
120
  function wrapLocalPty(handle) {
99
121
  let resizeWarned = false;
@@ -930,7 +952,14 @@ class TtyServer {
930
952
  // 换成 `exec tmux -L dsh-tty -A -s <名>`(tmux 托管);tmux 未安装则降级
931
953
  // 普通会话并回灰字提示。持久名稳定(客户端生成、随标签规格保存),
932
954
  // 宿主重启后重开标签按同名 attach 回原 tmux 会话
933
- const persistName = msg.persist === true && this.options.persistence === 'tmux'
955
+ // 命令标签(0.14.0):直接跑一条命令,不做 tmux 持久化(命令短命)
956
+ const parsedCommand = sanitizeCommand(msg.command);
957
+ if (parsedCommand.error !== undefined) {
958
+ send(ws, { t: 'error', sid, m: parsedCommand.error });
959
+ return;
960
+ }
961
+ const command = parsedCommand.command ?? null;
962
+ const persistName = command === null && msg.persist === true && this.options.persistence === 'tmux'
934
963
  ? sanitizePersistName(msg.persistName, sid)
935
964
  : null;
936
965
  // 跨窗口共享(0.10.1):同 tmuxName 的宿主会话还活着(别的窗口接回过,
@@ -959,7 +988,9 @@ class TtyServer {
959
988
  return;
960
989
  }
961
990
  const wantsPersist = persistName !== null;
962
- let spawnPlan = buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
991
+ let spawnPlan = command !== null
992
+ ? buildCommandSpawn(this.options.shell, this.options.term, this.options.colorTerm, command)
993
+ : buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
963
994
  let tmuxName = null;
964
995
  if (wantsPersist) {
965
996
  const probe = await probeTmux();
@@ -1037,7 +1068,13 @@ class TtyServer {
1037
1068
  }
1038
1069
  // 跨窗口共享(0.10.1):同 tmuxName 的 SSH 持久会话还活着时不重建远程
1039
1070
  // 连接,本连接重绑定到现有会话(单 PTY 多客户端扇出)
1040
- const persistName = msg.persist === true && this.options.persistence === 'tmux'
1071
+ const parsedCommand = sanitizeCommand(msg.command);
1072
+ if (parsedCommand.error !== undefined) {
1073
+ send(ws, { t: 'error', sid, m: parsedCommand.error });
1074
+ return;
1075
+ }
1076
+ const command = parsedCommand.command ?? null;
1077
+ const persistName = command === null && msg.persist === true && this.options.persistence === 'tmux'
1041
1078
  ? sanitizePersistName(msg.persistName, sid)
1042
1079
  : null;
1043
1080
  if (persistName !== null) {
@@ -1074,6 +1111,7 @@ class TtyServer {
1074
1111
  rows: Number(msg.rows) || 24,
1075
1112
  logger: { info: (m) => this.ctx.logger.info(m), warn: (m) => this.ctx.logger.warn(m) },
1076
1113
  hostKeyStore: this.hostKeyStore,
1114
+ ...(command !== null ? { command } : {}),
1077
1115
  ...(persistOpt !== undefined ? { persist: persistOpt } : {}),
1078
1116
  });
1079
1117
  }
@@ -2107,17 +2145,30 @@ const plugin = definePlugin({
2107
2145
  writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
2108
2146
  return;
2109
2147
  }
2148
+ // 取消上传(0.12.0):客户端 abort → req 'aborted' → 打断 pipeline
2149
+ // 并删掉远端半截文件。不清理的话远端会留一个同名残留文件,用户
2150
+ // 只看得到「已取消」,却有个打不开的文件占着位。
2151
+ const controller = new AbortController();
2152
+ let abortedByClient = false;
2153
+ req.on('aborted', () => {
2154
+ abortedByClient = true;
2155
+ controller.abort();
2156
+ });
2110
2157
  try {
2111
2158
  const { stream, done } = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
2112
2159
  let bytes = 0;
2113
2160
  req.on('data', (chunk) => {
2114
2161
  bytes += chunk.length;
2115
2162
  });
2116
- await pipeline(req, stream);
2163
+ await pipeline(req, stream, { signal: controller.signal });
2117
2164
  await done;
2118
2165
  writeJson(res, 200, { ok: true, bytes });
2119
2166
  }
2120
2167
  catch (error) {
2168
+ if (abortedByClient) {
2169
+ void sftpManager.deleteRemoteQuiet(parsed.spec, target);
2170
+ return;
2171
+ }
2121
2172
  const message = error instanceof Error ? error.message : String(error);
2122
2173
  if (res.headersSent)
2123
2174
  res.destroy();
@@ -2185,6 +2236,34 @@ const plugin = definePlugin({
2185
2236
  }
2186
2237
  if (sub === '/transfer') {
2187
2238
  // up = 本机→远程(上传),down = 远程→本机(下载);目录递归
2239
+ // 0.12.0:任务化(start 返回 jobId,浏览器可轮询进度 / 取消),
2240
+ // 代替原先「一个 HTTP 请求同步跑完、无法打断」的直传。
2241
+ const action = strField('action');
2242
+ const jobId = strField('job');
2243
+ if (action === 'status') {
2244
+ if (jobId === '')
2245
+ throw new Error('job 必填');
2246
+ const job = sftpManager.getTransfer(jobId);
2247
+ if (job === undefined) {
2248
+ writeJson(res, 404, { error: '传输任务不存在或已回收' });
2249
+ return;
2250
+ }
2251
+ writeJson(res, 200, { ok: true, job });
2252
+ return;
2253
+ }
2254
+ if (action === 'cancel') {
2255
+ if (jobId === '')
2256
+ throw new Error('job 必填');
2257
+ const job = sftpManager.cancelTransfer(jobId);
2258
+ if (job === undefined) {
2259
+ writeJson(res, 404, { error: '传输任务不存在或已回收' });
2260
+ return;
2261
+ }
2262
+ writeJson(res, 200, { ok: true, job });
2263
+ return;
2264
+ }
2265
+ if (action !== '' && action !== 'start')
2266
+ throw new Error('action 必须是 start/status/cancel');
2188
2267
  const direction = strField('direction');
2189
2268
  const localPath = strField('localPath');
2190
2269
  const remotePath = strField('remotePath');
@@ -2197,11 +2276,8 @@ const plugin = definePlugin({
2197
2276
  writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
2198
2277
  return;
2199
2278
  }
2200
- if (direction === 'up')
2201
- await sftpManager.uploadFromLocal(parsed.spec, localPath, remotePath);
2202
- else
2203
- await sftpManager.downloadToLocal(parsed.spec, remotePath, localPath);
2204
- writeJson(res, 200, { ok: true });
2279
+ const job = sftpManager.startTransfer(parsed.spec, direction, localPath, remotePath);
2280
+ writeJson(res, 200, { ok: true, job });
2205
2281
  return;
2206
2282
  }
2207
2283
  writeJson(res, 404, { error: 'not found: ' + sub });