@hyzyn/dsh-tty 0.5.0 → 0.10.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/README.md +168 -23
- package/client.js +38 -38
- package/lib/index.d.ts +31 -8
- package/lib/index.js +1142 -135
- package/lib/index.js.map +1 -1
- package/lib/sftp.d.ts +110 -0
- package/lib/sftp.js +449 -0
- package/lib/sftp.js.map +1 -0
- package/lib/shell-integration.d.ts +11 -0
- package/lib/shell-integration.js +230 -56
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +24 -0
- package/lib/ssh.js +114 -2
- package/lib/ssh.js.map +1 -1
- package/lib/tmux.d.ts +48 -0
- package/lib/tmux.js +176 -0
- package/lib/tmux.js.map +1 -0
- package/package.json +29 -13
- package/LICENSE +0 -21
package/lib/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { existsSync, readFileSync } 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';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
6
7
|
import { PassThrough } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
9
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
10
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
9
11
|
// @xterm/headless 是 CJS 包:ESM 具名导入在 Node 运行时会炸(Named export not
|
|
@@ -17,6 +19,8 @@ import { buildShellSpawn } from './shell-integration.js';
|
|
|
17
19
|
import { parseSshConfig } from './ssh-config.js';
|
|
18
20
|
import { parseKnownHosts } from './known-hosts.js';
|
|
19
21
|
import { TunnelManager } from './tunnels.js';
|
|
22
|
+
import { SftpManager } from './sftp.js';
|
|
23
|
+
import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
|
|
20
24
|
const SSH_HOST_SCHEMA = z.object({
|
|
21
25
|
name: z.string(),
|
|
22
26
|
host: z.string(),
|
|
@@ -27,6 +31,8 @@ const SSH_HOST_SCHEMA = z.object({
|
|
|
27
31
|
passphrase: z.string().default(''),
|
|
28
32
|
password: z.string().default(''),
|
|
29
33
|
agentForward: z.boolean().default(false),
|
|
34
|
+
/** 该条目的 SSH 标签默认以 tmux 持久会话打开(仅 persistence=tmux 时生效)。 */
|
|
35
|
+
persist: z.boolean().default(false),
|
|
30
36
|
});
|
|
31
37
|
const HOST_KEY_SCHEMA = z.object({
|
|
32
38
|
host: z.string(),
|
|
@@ -58,6 +64,10 @@ const TTY_SETTINGS_SCHEMA = z.object({
|
|
|
58
64
|
hostKeys: z.array(HOST_KEY_SCHEMA).default([]),
|
|
59
65
|
tunnels: z.array(TUNNEL_SCHEMA).default([]),
|
|
60
66
|
shellIntegration: z.boolean().default(true),
|
|
67
|
+
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
|
|
68
|
+
persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
|
|
69
|
+
endOnPageClose: z.boolean().default(false),
|
|
70
|
+
persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]),
|
|
61
71
|
});
|
|
62
72
|
/* ------------------------------------------------------------------ *
|
|
63
73
|
* 常量
|
|
@@ -75,7 +85,7 @@ const BUFFER_CAP = 256 * 1024;
|
|
|
75
85
|
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
76
86
|
/** 孤儿会话回收器的扫描间隔。 */
|
|
77
87
|
const REAPER_INTERVAL_MS = 10_000;
|
|
78
|
-
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU
|
|
88
|
+
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} 拿结果。';
|
|
79
89
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
80
90
|
function wrapLocalPty(handle) {
|
|
81
91
|
let resizeWarned = false;
|
|
@@ -127,6 +137,12 @@ class LiveConfig {
|
|
|
127
137
|
shellIntegration;
|
|
128
138
|
/** 端口转发隧道规格。 */
|
|
129
139
|
tunnels;
|
|
140
|
+
/** 会话持久化模式(off / tmux)。 */
|
|
141
|
+
persistence;
|
|
142
|
+
/** 页面断开且保活期结束时是否结束 tmux 持久会话(默认 false = 留存)。 */
|
|
143
|
+
endOnPageClose;
|
|
144
|
+
/** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
|
|
145
|
+
persistSessions;
|
|
130
146
|
constructor(init) {
|
|
131
147
|
this.shell = init.shell;
|
|
132
148
|
this.term = sanitizeTermValue(init.term, 'xterm-256color');
|
|
@@ -137,6 +153,9 @@ class LiveConfig {
|
|
|
137
153
|
this.hostKeys = init.hostKeys ?? [];
|
|
138
154
|
this.shellIntegration = init.shellIntegration;
|
|
139
155
|
this.tunnels = init.tunnels ?? [];
|
|
156
|
+
this.persistence = init.persistence === 'tmux' ? 'tmux' : 'off';
|
|
157
|
+
this.endOnPageClose = init.endOnPageClose === true;
|
|
158
|
+
this.persistSessions = init.persistSessions ?? [];
|
|
140
159
|
}
|
|
141
160
|
/** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
|
|
142
161
|
apply(partial) {
|
|
@@ -159,6 +178,12 @@ class LiveConfig {
|
|
|
159
178
|
this.shellIntegration = partial.shellIntegration;
|
|
160
179
|
if (Array.isArray(partial.tunnels))
|
|
161
180
|
this.tunnels = partial.tunnels;
|
|
181
|
+
if (partial.persistence === 'tmux' || partial.persistence === 'off')
|
|
182
|
+
this.persistence = partial.persistence;
|
|
183
|
+
if (typeof partial.endOnPageClose === 'boolean')
|
|
184
|
+
this.endOnPageClose = partial.endOnPageClose;
|
|
185
|
+
if (Array.isArray(partial.persistSessions))
|
|
186
|
+
this.persistSessions = partial.persistSessions;
|
|
162
187
|
}
|
|
163
188
|
findSshHost(name) {
|
|
164
189
|
return this.sshHosts.find((entry) => entry.name === name);
|
|
@@ -203,8 +228,11 @@ function cleanAnsiTail(raw) {
|
|
|
203
228
|
return idx === -1 ? line : line.slice(idx + 1);
|
|
204
229
|
}).join('\n');
|
|
205
230
|
}
|
|
206
|
-
/** OSC 133 命令标记帧:\x1b]133;<A|B|D>[;<
|
|
207
|
-
|
|
231
|
+
/** OSC 133 命令标记帧:\x1b]133;<A|B|D|T>[;<payload>](BEL|ST)。
|
|
232
|
+
* T(0.10.0)= tmux 持久标签的 pane 内容快照(base64):tmux 的 pane 重画是
|
|
233
|
+
* 异步批量的,命令输出会落在 D 标记之后逃出 B..D 捕获窗口,故由钩子在发 D
|
|
234
|
+
* 前 capture-pane 随流直送,宿主优先采用。 */
|
|
235
|
+
const OSC133_RE = /\x1b\]133;([ABDCT])(?:;([^\x07\x1b]*))?(?:\x07|\x1b\\)/g;
|
|
208
236
|
/** OSC 7 cwd 上报帧:\x1b]7;file://<host><path>(BEL|ST)。 */
|
|
209
237
|
const OSC7_RE = /\x1b\]7;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
210
238
|
/** 单条命令输出捕获上限(环形,超出丢头部)。 */
|
|
@@ -214,7 +242,14 @@ const FLUSH_INTERVAL_MS = 12;
|
|
|
214
242
|
/** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
|
|
215
243
|
const FLUSH_SIZE_CHARS = 64 * 1024;
|
|
216
244
|
function createShellState() {
|
|
217
|
-
return { carry: '', inCommand: false, cmdBuffer: '', lastCommand: null };
|
|
245
|
+
return { carry: '', inCommand: false, cmdBuffer: '', pendingT: null, lastCommand: null };
|
|
246
|
+
}
|
|
247
|
+
/** OSC 133;T 的 base64 payload → utf8 文本(无效输入返回 null)。 */
|
|
248
|
+
function decodeBase64Utf8(payload) {
|
|
249
|
+
if (payload === undefined || payload === '')
|
|
250
|
+
return null;
|
|
251
|
+
const text = Buffer.from(payload, 'base64').toString('utf8');
|
|
252
|
+
return text !== '' ? text : null;
|
|
218
253
|
}
|
|
219
254
|
/** OSC 7 body(file://host/path)→ 解码后的路径;解析失败返回 undefined。 */
|
|
220
255
|
function osc7Path(body) {
|
|
@@ -244,11 +279,11 @@ function feedShellIntegration(session, text) {
|
|
|
244
279
|
if (lastOpen !== -1) {
|
|
245
280
|
const tail = data.slice(lastOpen);
|
|
246
281
|
if (!/\x07|\x1b\\/.test(tail)) {
|
|
247
|
-
if (tail.length <=
|
|
282
|
+
if (tail.length <= 64 * 1024) {
|
|
248
283
|
state.carry = tail;
|
|
249
284
|
data = data.slice(0, lastOpen);
|
|
250
285
|
}
|
|
251
|
-
// 超过
|
|
286
|
+
// 超过 64KB 仍不闭合视为垃圾:放弃扣留,整块照常处理(上限容纳 T 快照)
|
|
252
287
|
}
|
|
253
288
|
}
|
|
254
289
|
for (const match of data.matchAll(OSC7_RE)) {
|
|
@@ -267,17 +302,22 @@ function feedShellIntegration(session, text) {
|
|
|
267
302
|
if (kind === 'B') {
|
|
268
303
|
state.inCommand = true;
|
|
269
304
|
state.cmdBuffer = '';
|
|
305
|
+
state.pendingT = null;
|
|
306
|
+
}
|
|
307
|
+
else if (kind === 'T') {
|
|
308
|
+
state.pendingT = decodeBase64Utf8(match[2]);
|
|
270
309
|
}
|
|
271
310
|
else if (kind === 'D') {
|
|
272
311
|
if (state.inCommand) {
|
|
273
|
-
const exitCode = match[2] !== undefined ? Number(match[2]) : null;
|
|
312
|
+
const exitCode = match[2] !== undefined && /^\d+$/.test(match[2]) ? Number(match[2]) : null;
|
|
274
313
|
state.lastCommand = {
|
|
275
|
-
output: state.cmdBuffer.slice(-COMMAND_CAP),
|
|
314
|
+
output: (state.pendingT ?? state.cmdBuffer).slice(-COMMAND_CAP),
|
|
276
315
|
exitCode: exitCode !== null && Number.isFinite(exitCode) ? exitCode : null,
|
|
277
316
|
endedAt: Date.now(),
|
|
278
317
|
};
|
|
279
318
|
state.inCommand = false;
|
|
280
319
|
state.cmdBuffer = '';
|
|
320
|
+
state.pendingT = null;
|
|
281
321
|
}
|
|
282
322
|
}
|
|
283
323
|
// A(prompt 开始)无需记录
|
|
@@ -396,6 +436,7 @@ function sanitizeSshHosts(input) {
|
|
|
396
436
|
passphrase: typeof raw.passphrase === 'string' ? raw.passphrase : '',
|
|
397
437
|
password: typeof raw.password === 'string' ? raw.password : '',
|
|
398
438
|
agentForward: raw.agentForward === true,
|
|
439
|
+
persist: raw.persist === true,
|
|
399
440
|
});
|
|
400
441
|
}
|
|
401
442
|
return out;
|
|
@@ -431,6 +472,9 @@ function validateSshHosts(input) {
|
|
|
431
472
|
if (raw.agentForward !== undefined && typeof raw.agentForward !== 'boolean') {
|
|
432
473
|
return { error: 'sshHosts.agentForward 必须是布尔值' };
|
|
433
474
|
}
|
|
475
|
+
if (raw.persist !== undefined && typeof raw.persist !== 'boolean') {
|
|
476
|
+
return { error: 'sshHosts.persist 必须是布尔值' };
|
|
477
|
+
}
|
|
434
478
|
if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
|
|
435
479
|
return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
|
|
436
480
|
}
|
|
@@ -482,6 +526,20 @@ function validateHostKeys(input) {
|
|
|
482
526
|
}
|
|
483
527
|
return { keys: sanitizeHostKeys(input) };
|
|
484
528
|
}
|
|
529
|
+
/** 宽松清洗一份 persistSessions(内部状态:SSH 持久会话名);非法条目丢弃。 */
|
|
530
|
+
function sanitizePersistSessions(input) {
|
|
531
|
+
if (!Array.isArray(input))
|
|
532
|
+
return undefined;
|
|
533
|
+
const out = [];
|
|
534
|
+
for (const item of input) {
|
|
535
|
+
if (typeof item !== 'object' || item === null)
|
|
536
|
+
continue;
|
|
537
|
+
const name = item.tmuxName;
|
|
538
|
+
if (typeof name === 'string' && /^dsh-[A-Za-z0-9_-]{1,64}$/.test(name) && !out.includes(name))
|
|
539
|
+
out.push(name);
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
485
543
|
/**
|
|
486
544
|
* TOFU 主机指纹存储:get/record 面向 spawnSsh 的 hostVerifier;
|
|
487
545
|
* record 时经 persist 回调写入 settings(宿主重启后钉扎仍在)。
|
|
@@ -543,8 +601,12 @@ function isLoopbackUpgrade(req) {
|
|
|
543
601
|
class SessionManager {
|
|
544
602
|
sessions = new Map();
|
|
545
603
|
limit;
|
|
546
|
-
|
|
604
|
+
/** 回收器销毁孤儿时是否连 tmux 持久会话一起结束(endOnPageClose 策略)。 */
|
|
605
|
+
endTmuxOnReap = () => false;
|
|
606
|
+
constructor(maxSessions, endTmuxOnReap) {
|
|
547
607
|
this.limit = maxSessions;
|
|
608
|
+
if (endTmuxOnReap !== undefined)
|
|
609
|
+
this.endTmuxOnReap = endTmuxOnReap;
|
|
548
610
|
}
|
|
549
611
|
get limitValue() {
|
|
550
612
|
return this.limit;
|
|
@@ -568,7 +630,7 @@ class SessionManager {
|
|
|
568
630
|
get(id) {
|
|
569
631
|
return this.sessions.get(id);
|
|
570
632
|
}
|
|
571
|
-
/** 会话的只读快照(SSH 会话无本地 pid
|
|
633
|
+
/** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist)。 */
|
|
572
634
|
snapshotOf(session) {
|
|
573
635
|
const base = {
|
|
574
636
|
sid: session.id,
|
|
@@ -577,6 +639,7 @@ class SessionManager {
|
|
|
577
639
|
target: session.target,
|
|
578
640
|
startedAt: session.startedAt,
|
|
579
641
|
lastOutputAt: session.lastOutputAt,
|
|
642
|
+
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
580
643
|
};
|
|
581
644
|
return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
|
|
582
645
|
}
|
|
@@ -588,9 +651,17 @@ class SessionManager {
|
|
|
588
651
|
listForAttach() {
|
|
589
652
|
return [...this.sessions.values()].map((session) => ({
|
|
590
653
|
...this.snapshotOf(session),
|
|
591
|
-
attachable: session.
|
|
654
|
+
attachable: session.clients.size === 0 && !session.closed,
|
|
592
655
|
}));
|
|
593
656
|
}
|
|
657
|
+
/** 按 tmux 持久会话名查找存活会话(跨窗口共享用);不存在/已关闭返回 undefined。 */
|
|
658
|
+
findByTmuxName(tmuxName) {
|
|
659
|
+
for (const session of this.sessions.values()) {
|
|
660
|
+
if (session.tmuxName === tmuxName && !session.closed)
|
|
661
|
+
return session;
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
594
665
|
/** 同步退役:移出全局表 + 释放虚拟屏(幂等,不杀进程)。 */
|
|
595
666
|
retire(session) {
|
|
596
667
|
session.closed = true;
|
|
@@ -602,9 +673,18 @@ class SessionManager {
|
|
|
602
673
|
/* 已释放 */
|
|
603
674
|
}
|
|
604
675
|
}
|
|
605
|
-
/** 释放并销毁会话:退役 + 树级终止(等待 terminate 完成,最慢 ~20s)。
|
|
676
|
+
/** 释放并销毁会话:退役 + 树级终止(等待 terminate 完成,最慢 ~20s)。
|
|
677
|
+
* endOnPageClose 策略下,回收器销毁孤儿时连 tmux 持久会话一起结束。 */
|
|
606
678
|
async destroy(session) {
|
|
607
679
|
this.retire(session);
|
|
680
|
+
if (session.tmuxName !== null && this.endTmuxOnReap()) {
|
|
681
|
+
try {
|
|
682
|
+
await session.handle.tmuxTeardown?.();
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
/* tmux 收尾失败不阻断回收 */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
608
688
|
await forceKill(session.handle);
|
|
609
689
|
}
|
|
610
690
|
/** 回收超过保活期的孤儿会话(回收器定时调用;graceMs<=0 时不动作)。 */
|
|
@@ -641,12 +721,18 @@ class TtyServer {
|
|
|
641
721
|
sessions;
|
|
642
722
|
options;
|
|
643
723
|
hostKeyStore;
|
|
724
|
+
trackPersist;
|
|
644
725
|
wss = new WebSocketServer({ noServer: true });
|
|
645
|
-
|
|
726
|
+
/** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
|
|
727
|
+
pendingTmux = new Map();
|
|
728
|
+
constructor(ctx, sessions, options, hostKeyStore,
|
|
729
|
+
/** SSH 持久会话名留存回调(apply 闭包实现,settings 落盘)。 */
|
|
730
|
+
trackPersist) {
|
|
646
731
|
this.ctx = ctx;
|
|
647
732
|
this.sessions = sessions;
|
|
648
733
|
this.options = options;
|
|
649
734
|
this.hostKeyStore = hostKeyStore;
|
|
735
|
+
this.trackPersist = trackPersist;
|
|
650
736
|
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
651
737
|
}
|
|
652
738
|
/** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
|
|
@@ -663,16 +749,21 @@ class TtyServer {
|
|
|
663
749
|
/** 本连接上的会话表(sid → session);单连接多会话(标签页)。 */
|
|
664
750
|
const local = new Map();
|
|
665
751
|
const cleanupAll = async () => {
|
|
666
|
-
const all = [...local.
|
|
752
|
+
const all = [...local.entries()];
|
|
667
753
|
local.clear();
|
|
668
|
-
await Promise.all(all.map(async (session) => {
|
|
754
|
+
await Promise.all(all.map(async ([clientSid, session]) => {
|
|
669
755
|
if (session.closed)
|
|
670
756
|
return;
|
|
757
|
+
// 解绑本连接的客户端;其余窗口仍绑定着(跨连接共享)时会话继续在线
|
|
758
|
+
session.clients.delete(clientSid);
|
|
759
|
+
if (session.clients.size > 0) {
|
|
760
|
+
this.flushPendingOutput(session);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
671
763
|
if (this.options.reconnectGraceMs > 0) {
|
|
672
764
|
// 客户端正常关面板会先逐个 kill(会话已移出 local),走到这里的都是
|
|
673
765
|
// 「异常断开仍有存活会话」:转孤儿保活,等待新连接 attach,到点由回收器清理
|
|
674
766
|
this.flushPendingOutput(session); // 没了收件人,待发帧直接丢弃(回放走环形缓冲)
|
|
675
|
-
session.ws = null;
|
|
676
767
|
session.orphanedAt = Date.now();
|
|
677
768
|
return;
|
|
678
769
|
}
|
|
@@ -720,13 +811,66 @@ class TtyServer {
|
|
|
720
811
|
send(ws, { t: 'error', m: local.size === 0 ? '没有可用会话(先发 spawn)' : '存在多个会话,请指定 sid' });
|
|
721
812
|
return undefined;
|
|
722
813
|
}
|
|
814
|
+
/** 把一个客户端连接重绑定到既有会话(跨窗口共享 / 并发恢复收敛共用)。 */
|
|
815
|
+
rebindClient(session, sid, ws, local) {
|
|
816
|
+
session.clients.set(sid, ws);
|
|
817
|
+
session.orphanedAt = null;
|
|
818
|
+
if (session.paused) {
|
|
819
|
+
session.paused = false;
|
|
820
|
+
try {
|
|
821
|
+
session.handle.output.resume();
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
/* 已退出 */
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
local.set(sid, session);
|
|
828
|
+
send(ws, {
|
|
829
|
+
t: 'ready',
|
|
830
|
+
sid,
|
|
831
|
+
pid: session.handle.pid,
|
|
832
|
+
kind: session.kind,
|
|
833
|
+
target: session.target !== '' ? session.target : undefined,
|
|
834
|
+
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
835
|
+
});
|
|
836
|
+
if (session.tmuxName !== null)
|
|
837
|
+
void session.handle.tmuxRefresh?.();
|
|
838
|
+
}
|
|
839
|
+
/** 等待同 tmuxName 的在途创建完成;返回可重绑定的会话(null = 无在途/已失败)。 */
|
|
840
|
+
async waitPendingTmux(tmuxName) {
|
|
841
|
+
const inflight = this.pendingTmux.get(tmuxName);
|
|
842
|
+
if (inflight === undefined)
|
|
843
|
+
return null;
|
|
844
|
+
try {
|
|
845
|
+
return await inflight;
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
723
851
|
/**
|
|
724
852
|
* 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
|
|
725
853
|
* 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
|
|
726
854
|
* 可拖 ~20s)由 terminate 在后台继续收尾,不阻塞 kill 帧处理。
|
|
855
|
+
* tmux 背书会话先向 tmux server 发 kill-session(杀客户端只会 detach,
|
|
856
|
+
* 会话会留在 tmux server 上);2.5s 兜底 forceKill 防收尾悬挂。
|
|
727
857
|
*/
|
|
728
858
|
killSessionNow(session) {
|
|
729
859
|
this.sessions.retire(session);
|
|
860
|
+
const teardown = session.handle.tmuxTeardown;
|
|
861
|
+
if (teardown !== undefined) {
|
|
862
|
+
let settled = false;
|
|
863
|
+
const finish = () => {
|
|
864
|
+
if (settled)
|
|
865
|
+
return;
|
|
866
|
+
settled = true;
|
|
867
|
+
void forceKill(session.handle);
|
|
868
|
+
};
|
|
869
|
+
const timer = setTimeout(finish, 2500);
|
|
870
|
+
timer.unref?.();
|
|
871
|
+
void teardown().catch(() => { }).then(finish);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
730
874
|
try {
|
|
731
875
|
session.handle.forceKill?.();
|
|
732
876
|
}
|
|
@@ -757,8 +901,25 @@ class TtyServer {
|
|
|
757
901
|
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
758
902
|
return;
|
|
759
903
|
}
|
|
904
|
+
// 持久化(0.10.0):配置 persistence=tmux 且帧带 persist 时,spawn 包装层
|
|
905
|
+
// 换成 `exec tmux -L dsh-tty -A -s <名>`(tmux 托管);tmux 未安装则降级
|
|
906
|
+
// 普通会话并回灰字提示。持久名稳定(客户端生成、随标签规格保存),
|
|
907
|
+
// 宿主重启后重开标签按同名 attach 回原 tmux 会话
|
|
908
|
+
const persistName = msg.persist === true && this.options.persistence === 'tmux'
|
|
909
|
+
? sanitizePersistName(msg.persistName, sid)
|
|
910
|
+
: null;
|
|
911
|
+
// 跨窗口共享(0.10.1):同 tmuxName 的宿主会话还活着(别的窗口接回过,
|
|
912
|
+
// 或同刻并发恢复的在途创建)时不再新建 PTY,本连接重绑定到它——
|
|
913
|
+
// 单 PTY 多客户端扇出,名额不翻倍
|
|
914
|
+
if (persistName !== null) {
|
|
915
|
+
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
916
|
+
if (existing !== null) {
|
|
917
|
+
this.rebindClient(existing, sid, ws, local);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
760
921
|
if (!this.sessions.canSpawn()) {
|
|
761
|
-
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue}
|
|
922
|
+
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})——每个窗口的每个标签各占一个名额:关闭不用的窗口/标签,或在设置卡片调大「并发会话上限」` });
|
|
762
923
|
return;
|
|
763
924
|
}
|
|
764
925
|
// 客户端(当前会话)cwd 优先;校验存在性,避免 node-pty 抛难懂错误
|
|
@@ -772,37 +933,70 @@ class TtyServer {
|
|
|
772
933
|
send(ws, { t: 'error', sid, m: 'subprocess 服务不可用' });
|
|
773
934
|
return;
|
|
774
935
|
}
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
936
|
+
const wantsPersist = persistName !== null;
|
|
937
|
+
let spawnPlan = buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
|
|
938
|
+
let tmuxName = null;
|
|
939
|
+
if (wantsPersist) {
|
|
940
|
+
const probe = await probeTmux();
|
|
941
|
+
if (probe.available) {
|
|
942
|
+
tmuxName = persistName;
|
|
943
|
+
ensureTmuxAssets({ shell: this.options.shell, colorTerm: this.options.colorTerm, shellIntegration: this.options.shellIntegration, passthrough: probe.passthrough });
|
|
944
|
+
spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// 在途注册:tmuxName 相同的并发 spawn 等本次创建完成后重绑定(防竞态翻倍)
|
|
948
|
+
const create = (async () => {
|
|
949
|
+
const handle = wrapLocalPty(await subprocess.spawnTerminal({
|
|
950
|
+
argv: spawnPlan.argv,
|
|
951
|
+
rows: Number(msg.rows) || 24,
|
|
952
|
+
cols: Number(msg.cols) || 80,
|
|
953
|
+
cwd,
|
|
954
|
+
env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
|
|
955
|
+
graceMs: 5000,
|
|
956
|
+
}));
|
|
957
|
+
if (tmuxName !== null) {
|
|
958
|
+
handle.tmuxTeardown = () => killTmuxSession(tmuxName);
|
|
959
|
+
handle.tmuxRefresh = () => refreshTmuxClient(tmuxName);
|
|
960
|
+
}
|
|
961
|
+
const next = {
|
|
962
|
+
id: sid,
|
|
963
|
+
handle,
|
|
964
|
+
clients: new Map([[sid, ws]]),
|
|
965
|
+
closed: false,
|
|
966
|
+
paused: false,
|
|
967
|
+
cwd,
|
|
968
|
+
kind: 'local',
|
|
969
|
+
target: '',
|
|
970
|
+
startedAt: Date.now(),
|
|
971
|
+
lastOutputAt: Date.now(),
|
|
972
|
+
buffer: '',
|
|
973
|
+
decoder: new StringDecoder('utf8'),
|
|
974
|
+
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
975
|
+
orphanedAt: null,
|
|
976
|
+
shellState: createShellState(),
|
|
977
|
+
pendingOutput: '',
|
|
978
|
+
flushTimer: null,
|
|
979
|
+
tmuxName,
|
|
980
|
+
};
|
|
981
|
+
local.set(sid, next);
|
|
982
|
+
this.sessions.add(next);
|
|
983
|
+
return next;
|
|
984
|
+
})();
|
|
985
|
+
if (tmuxName !== null) {
|
|
986
|
+
const registered = create.catch(() => null);
|
|
987
|
+
this.pendingTmux.set(tmuxName, registered);
|
|
988
|
+
void registered.finally(() => {
|
|
989
|
+
if (this.pendingTmux.get(tmuxName) === registered)
|
|
990
|
+
this.pendingTmux.delete(tmuxName);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
const next = await create;
|
|
994
|
+
send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(tmuxName !== null ? { persist: true } : {}) });
|
|
995
|
+
if (wantsPersist && tmuxName === null) {
|
|
996
|
+
const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
|
|
997
|
+
next.buffer = (next.buffer + notice).slice(-BUFFER_CAP);
|
|
998
|
+
send(ws, { t: 'data', sid, d: notice });
|
|
999
|
+
}
|
|
806
1000
|
this.attachOutput(next);
|
|
807
1001
|
this.watchDone(next, local);
|
|
808
1002
|
}
|
|
@@ -816,77 +1010,108 @@ class TtyServer {
|
|
|
816
1010
|
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
817
1011
|
return;
|
|
818
1012
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1013
|
+
// 跨窗口共享(0.10.1):同 tmuxName 的 SSH 持久会话还活着时不重建远程
|
|
1014
|
+
// 连接,本连接重绑定到现有会话(单 PTY 多客户端扇出)
|
|
1015
|
+
const persistName = msg.persist === true && this.options.persistence === 'tmux'
|
|
1016
|
+
? sanitizePersistName(msg.persistName, sid)
|
|
1017
|
+
: null;
|
|
1018
|
+
if (persistName !== null) {
|
|
1019
|
+
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
1020
|
+
if (existing !== null && existing.kind === 'ssh' && !existing.closed) {
|
|
1021
|
+
this.rebindClient(existing, sid, ws, local);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
822
1024
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
if (typeof msg.name === 'string' && msg.name !== '' && profile === undefined) {
|
|
826
|
-
send(ws, { t: 'error', sid, m: `连接簿中不存在: ${msg.name}` });
|
|
1025
|
+
if (!this.sessions.canSpawn()) {
|
|
1026
|
+
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})——每个窗口的每个标签各占一个名额:关闭不用的窗口/标签,或在设置卡片调大「并发会话上限」` });
|
|
827
1027
|
return;
|
|
828
1028
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
auth: msg.auth === 'key' || msg.auth === 'password' || msg.auth === 'agent' ? msg.auth : profile?.auth ?? 'agent',
|
|
834
|
-
keyPath: typeof msg.keyPath === 'string' && msg.keyPath !== '' ? msg.keyPath : profile?.keyPath,
|
|
835
|
-
passphrase: typeof msg.passphrase === 'string' && msg.passphrase !== '' ? msg.passphrase : profile?.passphrase,
|
|
836
|
-
password: typeof msg.password === 'string' && msg.password !== '' ? msg.password : profile?.password,
|
|
837
|
-
agentForward: typeof msg.agentForward === 'boolean' ? msg.agentForward : profile?.agentForward ?? false,
|
|
838
|
-
};
|
|
839
|
-
if (spec.host === '' || spec.username === '') {
|
|
840
|
-
send(ws, { t: 'error', sid, m: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' });
|
|
1029
|
+
// name 引用连接簿条目作基底,内联字段可逐项覆盖(与 SFTP 路由共用 mergeSshSpec)
|
|
1030
|
+
const merged = mergeSshSpec((name) => this.options.findSshHost(name), msg.name, msg);
|
|
1031
|
+
if (merged.error !== undefined || merged.spec === undefined) {
|
|
1032
|
+
send(ws, { t: 'error', sid, m: merged.error ?? 'SSH 连接参数缺失' });
|
|
841
1033
|
return;
|
|
842
1034
|
}
|
|
1035
|
+
const spec = merged.spec;
|
|
843
1036
|
const target = sshTarget(spec);
|
|
844
1037
|
send(ws, { t: 'data', sid, d: `\x1b[2mConnecting ${target} …\x1b[0m\r\n` });
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1038
|
+
// 持久化(0.10.0):远程 `exec tmux new-session -A` 托管;远程无 tmux 时
|
|
1039
|
+
// spawnSsh 降级普通 shell channel 并经 startupNotice 回灰字提示
|
|
1040
|
+
const wantsPersist = persistName !== null;
|
|
1041
|
+
const persistOpt = wantsPersist ? { name: persistName } : undefined;
|
|
1042
|
+
// 在途注册:同 persistName 的并发恢复等待本次创建完成后重绑定(防竞态翻倍)
|
|
1043
|
+
const create = (async () => {
|
|
1044
|
+
let handle;
|
|
1045
|
+
try {
|
|
1046
|
+
handle = await spawnSsh(spec, {
|
|
1047
|
+
term: this.options.term,
|
|
1048
|
+
cols: Number(msg.cols) || 80,
|
|
1049
|
+
rows: Number(msg.rows) || 24,
|
|
1050
|
+
logger: { info: (m) => this.ctx.logger.info(m), warn: (m) => this.ctx.logger.warn(m) },
|
|
1051
|
+
hostKeyStore: this.hostKeyStore,
|
|
1052
|
+
...(persistOpt !== undefined ? { persist: persistOpt } : {}),
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
send(ws, { t: 'error', sid, m: error instanceof Error ? error.message : String(error) });
|
|
1057
|
+
throw error;
|
|
1058
|
+
}
|
|
1059
|
+
const tmuxName = persistOpt !== undefined && handle.startupNotice === undefined ? persistOpt.name : null;
|
|
1060
|
+
const next = {
|
|
1061
|
+
id: sid,
|
|
1062
|
+
handle,
|
|
1063
|
+
clients: new Map([[sid, ws]]),
|
|
1064
|
+
closed: false,
|
|
1065
|
+
paused: false,
|
|
1066
|
+
cwd: '',
|
|
1067
|
+
kind: 'ssh',
|
|
1068
|
+
target,
|
|
1069
|
+
startedAt: Date.now(),
|
|
1070
|
+
lastOutputAt: Date.now(),
|
|
1071
|
+
buffer: '',
|
|
1072
|
+
decoder: new StringDecoder('utf8'),
|
|
1073
|
+
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
1074
|
+
orphanedAt: null,
|
|
1075
|
+
shellState: createShellState(),
|
|
1076
|
+
pendingOutput: '',
|
|
1077
|
+
flushTimer: null,
|
|
1078
|
+
tmuxName,
|
|
1079
|
+
};
|
|
1080
|
+
local.set(sid, next);
|
|
1081
|
+
this.sessions.add(next);
|
|
1082
|
+
send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target, ...(tmuxName !== null ? { persist: true } : {}) });
|
|
1083
|
+
if (tmuxName !== null)
|
|
1084
|
+
this.trackPersist(tmuxName, true); // 留存:远程 tmux 本机清单看不到
|
|
1085
|
+
if (handle.startupNotice !== undefined) {
|
|
1086
|
+
const notice = `\x1b[2m[dsh-tty] ${handle.startupNotice}\x1b[0m\r\n`;
|
|
1087
|
+
next.buffer = (next.buffer + notice).slice(-BUFFER_CAP);
|
|
1088
|
+
send(ws, { t: 'data', sid, d: notice });
|
|
1089
|
+
}
|
|
1090
|
+
this.attachOutput(next);
|
|
1091
|
+
this.watchDone(next, local);
|
|
1092
|
+
return next;
|
|
1093
|
+
})();
|
|
1094
|
+
if (persistName !== null) {
|
|
1095
|
+
const registered = create.catch(() => null);
|
|
1096
|
+
this.pendingTmux.set(persistName, registered);
|
|
1097
|
+
void registered.finally(() => {
|
|
1098
|
+
if (this.pendingTmux.get(persistName) === registered)
|
|
1099
|
+
this.pendingTmux.delete(persistName);
|
|
853
1100
|
});
|
|
854
1101
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1102
|
+
try {
|
|
1103
|
+
await create;
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
return; // 错误帧已在创建闭包内发送
|
|
858
1107
|
}
|
|
859
|
-
const next = {
|
|
860
|
-
id: sid,
|
|
861
|
-
handle,
|
|
862
|
-
ws,
|
|
863
|
-
closed: false,
|
|
864
|
-
paused: false,
|
|
865
|
-
cwd: '',
|
|
866
|
-
kind: 'ssh',
|
|
867
|
-
target,
|
|
868
|
-
startedAt: Date.now(),
|
|
869
|
-
lastOutputAt: Date.now(),
|
|
870
|
-
buffer: '',
|
|
871
|
-
decoder: new StringDecoder('utf8'),
|
|
872
|
-
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
873
|
-
orphanedAt: null,
|
|
874
|
-
shellState: createShellState(),
|
|
875
|
-
pendingOutput: '',
|
|
876
|
-
flushTimer: null,
|
|
877
|
-
};
|
|
878
|
-
local.set(sid, next);
|
|
879
|
-
this.sessions.add(next);
|
|
880
|
-
send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target });
|
|
881
|
-
this.attachOutput(next);
|
|
882
|
-
this.watchDone(next, local);
|
|
883
1108
|
}
|
|
884
1109
|
else if (msg.t === 'input') {
|
|
885
1110
|
const resolved = this.resolveSid(ws, msg, local);
|
|
886
1111
|
if (resolved === undefined || 'unknown' in resolved)
|
|
887
1112
|
return;
|
|
888
1113
|
const session = local.get(resolved.sid);
|
|
889
|
-
if (session !== undefined)
|
|
1114
|
+
if (session !== undefined && !session.closed)
|
|
890
1115
|
await session.handle.write(String(msg.d ?? ''));
|
|
891
1116
|
}
|
|
892
1117
|
else if (msg.t === 'resize') {
|
|
@@ -906,6 +1131,18 @@ class TtyServer {
|
|
|
906
1131
|
}
|
|
907
1132
|
}
|
|
908
1133
|
}
|
|
1134
|
+
else if (msg.t === 'refresh') {
|
|
1135
|
+
// 强制 tmux 重画(0.10.1):客户端 reset 清掉残 scrollback 后请宿主
|
|
1136
|
+
// refresh-client 重画现场——不碰尺寸,规避隐藏标签下 proposeDimensions
|
|
1137
|
+
// 返回垃圾尺寸把 pane 压扁的隐患;非 tmux 会话为无害 no-op
|
|
1138
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
1139
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
1140
|
+
return;
|
|
1141
|
+
const session = local.get(resolved.sid);
|
|
1142
|
+
if (session !== undefined && !session.closed && session.tmuxName !== null) {
|
|
1143
|
+
void session.handle.tmuxRefresh?.();
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
909
1146
|
else if (msg.t === 'kill') {
|
|
910
1147
|
const resolved = this.resolveSid(ws, msg, local);
|
|
911
1148
|
if (resolved === undefined)
|
|
@@ -927,7 +1164,12 @@ class TtyServer {
|
|
|
927
1164
|
this.killSessionNow(session);
|
|
928
1165
|
}
|
|
929
1166
|
else if (msg.t === 'sessions') {
|
|
930
|
-
|
|
1167
|
+
// tmux 字段(0.10.1):本机 socket 现存持久会话 + SSH 持久会话名(远程
|
|
1168
|
+
// tmux 托管、本机清单看不到,从 settings 留存读取)——客户端用它确认
|
|
1169
|
+
// localStorage 里的持久标签规格是否仍可恢复(新窗口/新浏览器)
|
|
1170
|
+
const localTmux = await listTmuxSessions();
|
|
1171
|
+
const tmuxSessions = [...new Set([...localTmux, ...this.options.persistSessions])];
|
|
1172
|
+
send(ws, { t: 'sessions', list: this.sessions.listForAttach(), tmux: tmuxSessions });
|
|
931
1173
|
}
|
|
932
1174
|
else if (msg.t === 'attach') {
|
|
933
1175
|
const raw = msg.sid;
|
|
@@ -940,14 +1182,16 @@ class TtyServer {
|
|
|
940
1182
|
send(ws, { t: 'error', sid: raw, m: `会话不存在或已结束: ${raw}` });
|
|
941
1183
|
return;
|
|
942
1184
|
}
|
|
943
|
-
|
|
1185
|
+
// 跨连接共享(0.10.1):tmux 持久会话允许多窗口同时绑定(单 PTY 扇出);
|
|
1186
|
+
// 非 tmux 会话仍独占(两个视图交错输入无意义)
|
|
1187
|
+
if (session.clients.size > 0 && session.tmuxName === null) {
|
|
944
1188
|
send(ws, { t: 'error', sid: raw, m: '会话已连接到其它窗口' });
|
|
945
1189
|
return;
|
|
946
1190
|
}
|
|
947
|
-
//
|
|
948
|
-
session.
|
|
1191
|
+
// 重新绑定到本连接(key = 本连接侧的 sid):解孤儿态,恢复被背压暂停的输出流
|
|
1192
|
+
session.clients.set(raw, ws);
|
|
949
1193
|
session.orphanedAt = null;
|
|
950
|
-
local.set(
|
|
1194
|
+
local.set(raw, session);
|
|
951
1195
|
if (session.paused) {
|
|
952
1196
|
session.paused = false;
|
|
953
1197
|
try {
|
|
@@ -957,10 +1201,17 @@ class TtyServer {
|
|
|
957
1201
|
/* 已退出 */
|
|
958
1202
|
}
|
|
959
1203
|
}
|
|
960
|
-
send(ws, { t: 'ready', sid:
|
|
961
|
-
// 断线期间的输出经 256KB
|
|
962
|
-
|
|
963
|
-
|
|
1204
|
+
send(ws, { t: 'ready', sid: raw, pid: session.handle.pid, kind: session.kind, target: session.target !== '' ? session.target : undefined, reattached: true, ...(session.tmuxName !== null ? { persist: true } : {}) });
|
|
1205
|
+
// 断线期间的输出经 256KB 环形缓冲回放(缓冲为空则跳过)。
|
|
1206
|
+
// tmux 背书会话例外:现场由 tmux 负责重画——回放会把缓冲里的可见屏
|
|
1207
|
+
// 先写进全新 xterm(制造屏外幽灵滚动历史 → 莫名滚动条),随后 tmux
|
|
1208
|
+
// 整屏重画再画一遍(内容重影);故跳过回放,强制 tmux 重画一次
|
|
1209
|
+
if (session.tmuxName !== null) {
|
|
1210
|
+
void session.handle.tmuxRefresh?.();
|
|
1211
|
+
}
|
|
1212
|
+
else if (session.buffer !== '') {
|
|
1213
|
+
send(ws, { t: 'data', sid: raw, d: session.buffer });
|
|
1214
|
+
}
|
|
964
1215
|
}
|
|
965
1216
|
}
|
|
966
1217
|
catch (error) {
|
|
@@ -978,6 +1229,8 @@ class TtyServer {
|
|
|
978
1229
|
session.closed = true;
|
|
979
1230
|
local.delete(session.id);
|
|
980
1231
|
this.sessions.remove(session.id);
|
|
1232
|
+
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
1233
|
+
this.trackPersist(session.tmuxName, false);
|
|
981
1234
|
try {
|
|
982
1235
|
session.screen?.dispose();
|
|
983
1236
|
}
|
|
@@ -985,7 +1238,11 @@ class TtyServer {
|
|
|
985
1238
|
/* 已释放 */
|
|
986
1239
|
}
|
|
987
1240
|
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
988
|
-
|
|
1241
|
+
// exit 广播到所有绑定连接(跨窗口共享),各客户端按自己的 sid 收址
|
|
1242
|
+
for (const [clientSid, clientWs] of session.clients) {
|
|
1243
|
+
send(clientWs, { t: 'exit', sid: clientSid, code: outcome.exitCode, signal: outcome.signal });
|
|
1244
|
+
}
|
|
1245
|
+
session.clients.clear();
|
|
989
1246
|
}).catch(() => { });
|
|
990
1247
|
}
|
|
991
1248
|
/** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
|
|
@@ -993,17 +1250,25 @@ class TtyServer {
|
|
|
993
1250
|
const output = session.handle.output;
|
|
994
1251
|
const flush = () => {
|
|
995
1252
|
session.flushTimer = null;
|
|
996
|
-
const ws = session.ws;
|
|
997
1253
|
const pending = session.pendingOutput;
|
|
998
|
-
if (session.closed ||
|
|
1254
|
+
if (session.closed || session.clients.size === 0 || pending === '')
|
|
999
1255
|
return;
|
|
1000
1256
|
session.pendingOutput = '';
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1257
|
+
let maxBuffered = 0;
|
|
1258
|
+
for (const [clientSid, clientWs] of session.clients) {
|
|
1259
|
+
try {
|
|
1260
|
+
clientWs.send(JSON.stringify({ t: 'data', sid: clientSid, d: pending }), () => {
|
|
1261
|
+
if (session.paused && clientWs.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
|
|
1262
|
+
output.resume();
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
maxBuffered = Math.max(maxBuffered, clientWs.bufferedAmount);
|
|
1004
1266
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1267
|
+
catch {
|
|
1268
|
+
/* 客户端已断开 */
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (!session.paused && maxBuffered > BACKPRESSURE_HIGH) {
|
|
1007
1272
|
session.paused = true;
|
|
1008
1273
|
output.pause();
|
|
1009
1274
|
}
|
|
@@ -1022,8 +1287,7 @@ class TtyServer {
|
|
|
1022
1287
|
catch {
|
|
1023
1288
|
/* 虚拟屏异常不阻断输出链路 */
|
|
1024
1289
|
}
|
|
1025
|
-
|
|
1026
|
-
if (ws === null)
|
|
1290
|
+
if (session.clients.size === 0)
|
|
1027
1291
|
return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
|
|
1028
1292
|
// data 帧合并:窗口内攒批,超阈值立即冲刷;exit/kill 前会强制 flush 保序
|
|
1029
1293
|
session.pendingOutput += text;
|
|
@@ -1048,12 +1312,13 @@ class TtyServer {
|
|
|
1048
1312
|
clearTimeout(session.flushTimer);
|
|
1049
1313
|
session.flushTimer = null;
|
|
1050
1314
|
}
|
|
1051
|
-
const ws = session.ws;
|
|
1052
1315
|
const pending = session.pendingOutput;
|
|
1053
1316
|
session.pendingOutput = '';
|
|
1054
|
-
if (session.closed ||
|
|
1317
|
+
if (session.closed || session.clients.size === 0 || pending === '')
|
|
1055
1318
|
return;
|
|
1056
|
-
|
|
1319
|
+
for (const [clientSid, clientWs] of session.clients) {
|
|
1320
|
+
send(clientWs, { t: 'data', sid: clientSid, d: pending });
|
|
1321
|
+
}
|
|
1057
1322
|
}
|
|
1058
1323
|
close() {
|
|
1059
1324
|
for (const client of this.wss.clients) {
|
|
@@ -1098,6 +1363,71 @@ function readManagedEnvKeys() {
|
|
|
1098
1363
|
return [];
|
|
1099
1364
|
}
|
|
1100
1365
|
}
|
|
1366
|
+
/**
|
|
1367
|
+
* 设置卡片「Shell 路径」候选(可选可输入的数据源):/etc/shells + $SHELL +
|
|
1368
|
+
* 常见安装路径,去重后过滤「存在且可执行」,$SHELL 排最前。只回路径,
|
|
1369
|
+
* 不做任何执行。
|
|
1370
|
+
*/
|
|
1371
|
+
function listCandidateShells() {
|
|
1372
|
+
const candidates = [];
|
|
1373
|
+
const push = (value) => {
|
|
1374
|
+
const path = value?.trim() ?? '';
|
|
1375
|
+
if (path !== '' && !candidates.includes(path))
|
|
1376
|
+
candidates.push(path);
|
|
1377
|
+
};
|
|
1378
|
+
try {
|
|
1379
|
+
for (const line of readFileSync('/etc/shells', 'utf8').split('\n')) {
|
|
1380
|
+
const path = line.trim();
|
|
1381
|
+
if (path !== '' && !path.startsWith('#'))
|
|
1382
|
+
push(path);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
/* 无 /etc/shells(如 Windows)时跳过 */
|
|
1387
|
+
}
|
|
1388
|
+
push(process.env.SHELL);
|
|
1389
|
+
for (const path of [
|
|
1390
|
+
'/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/homebrew/bin/zsh',
|
|
1391
|
+
'/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash',
|
|
1392
|
+
'/bin/fish', '/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish',
|
|
1393
|
+
'/bin/sh', '/bin/dash', '/bin/ksh', '/bin/tcsh', '/bin/csh',
|
|
1394
|
+
])
|
|
1395
|
+
push(path);
|
|
1396
|
+
const usable = candidates.filter((path) => {
|
|
1397
|
+
try {
|
|
1398
|
+
accessSync(path, fsConstants.X_OK);
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
const shell = process.env.SHELL?.trim() ?? '';
|
|
1406
|
+
usable.sort((a, b) => (a === shell ? -1 : b === shell ? 1 : a.localeCompare(b)));
|
|
1407
|
+
return usable;
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* 「连接簿条目作基底 + 内联字段逐项覆盖」的共享解析(WS ssh 帧与 SFTP 路由共用)。
|
|
1411
|
+
* name 指向连接簿缺失条目、或解析结果缺 host/username 时返回 error。
|
|
1412
|
+
*/
|
|
1413
|
+
function mergeSshSpec(findSshHost, name, inline) {
|
|
1414
|
+
const profile = typeof name === 'string' && name !== '' ? findSshHost(name) : undefined;
|
|
1415
|
+
if (typeof name === 'string' && name !== '' && profile === undefined)
|
|
1416
|
+
return { error: `连接簿中不存在: ${name}` };
|
|
1417
|
+
const spec = {
|
|
1418
|
+
host: typeof inline.host === 'string' && inline.host.trim() !== '' ? inline.host.trim() : profile?.host ?? '',
|
|
1419
|
+
port: Number(inline.port) || profile?.port || 22,
|
|
1420
|
+
username: typeof inline.username === 'string' && inline.username.trim() !== '' ? inline.username.trim() : profile?.username ?? '',
|
|
1421
|
+
auth: inline.auth === 'key' || inline.auth === 'password' || inline.auth === 'agent' ? inline.auth : profile?.auth ?? 'agent',
|
|
1422
|
+
keyPath: typeof inline.keyPath === 'string' && inline.keyPath !== '' ? inline.keyPath : profile?.keyPath,
|
|
1423
|
+
passphrase: typeof inline.passphrase === 'string' && inline.passphrase !== '' ? inline.passphrase : profile?.passphrase,
|
|
1424
|
+
password: typeof inline.password === 'string' && inline.password !== '' ? inline.password : profile?.password,
|
|
1425
|
+
agentForward: typeof inline.agentForward === 'boolean' ? inline.agentForward : profile?.agentForward ?? false,
|
|
1426
|
+
};
|
|
1427
|
+
if (spec.host === '' || spec.username === '')
|
|
1428
|
+
return { error: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' };
|
|
1429
|
+
return { spec };
|
|
1430
|
+
}
|
|
1101
1431
|
/** HTTP 路由的 loopback 信任围栏(与 dsh-mcp 同思路)。 */
|
|
1102
1432
|
function isLoopbackHttp(req) {
|
|
1103
1433
|
const address = req.socket.remoteAddress;
|
|
@@ -1131,6 +1461,31 @@ function writeJson(res, status, body) {
|
|
|
1131
1461
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
1132
1462
|
res.end(JSON.stringify(body));
|
|
1133
1463
|
}
|
|
1464
|
+
/** 下载响应的 content-disposition:ASCII 兜底 + RFC 5987 UTF-8 扩展(非 ASCII 文件名)。 */
|
|
1465
|
+
function contentDispositionValue(name) {
|
|
1466
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
1467
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
1468
|
+
}
|
|
1469
|
+
/** 远程路径取末段(下载文件名展示用);空串/根路径退化为 'download'。 */
|
|
1470
|
+
function remoteBasename(path) {
|
|
1471
|
+
const trimmed = path.trim().replace(/\/+$/, '');
|
|
1472
|
+
const index = trimmed.lastIndexOf('/');
|
|
1473
|
+
const base = index >= 0 ? trimmed.slice(index + 1) : trimmed;
|
|
1474
|
+
return base === '' ? 'download' : base;
|
|
1475
|
+
}
|
|
1476
|
+
/** 人类可读文件大小(sftp_list render 用)。 */
|
|
1477
|
+
function humanFileSize(bytes) {
|
|
1478
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
1479
|
+
return '0 B';
|
|
1480
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
1481
|
+
let value = bytes;
|
|
1482
|
+
let index = 0;
|
|
1483
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
1484
|
+
value /= 1024;
|
|
1485
|
+
index += 1;
|
|
1486
|
+
}
|
|
1487
|
+
return `${value >= 100 || index === 0 ? Math.round(value) : Math.round(value * 10) / 10} ${units[index]}`;
|
|
1488
|
+
}
|
|
1134
1489
|
async function readJsonBody(req) {
|
|
1135
1490
|
const chunks = [];
|
|
1136
1491
|
let size = 0;
|
|
@@ -1153,6 +1508,34 @@ async function readJsonBody(req) {
|
|
|
1153
1508
|
return undefined;
|
|
1154
1509
|
}
|
|
1155
1510
|
}
|
|
1511
|
+
/**
|
|
1512
|
+
* 本机目录列表(/api/dsh-tty/local-fs/list):path 空 = 用户 home,返回实际
|
|
1513
|
+
* 绝对路径。stat 失败的条目(悬空符号链接等)按 size/mtime = 0 占位仍列出。
|
|
1514
|
+
*/
|
|
1515
|
+
async function listLocalDir(rawPath) {
|
|
1516
|
+
const root = rawPath !== '' ? expandHome(rawPath) : homedir();
|
|
1517
|
+
const resolved = resolve(root);
|
|
1518
|
+
const dirents = await fsReaddir(resolved, { withFileTypes: true });
|
|
1519
|
+
const entries = [];
|
|
1520
|
+
for (const dirent of dirents) {
|
|
1521
|
+
let isDir = dirent.isDirectory();
|
|
1522
|
+
let isFile = dirent.isFile();
|
|
1523
|
+
let size = 0;
|
|
1524
|
+
let mtime = 0;
|
|
1525
|
+
try {
|
|
1526
|
+
const stats = await fsStat(join(resolved, dirent.name));
|
|
1527
|
+
isDir = stats.isDirectory();
|
|
1528
|
+
isFile = stats.isFile();
|
|
1529
|
+
size = Number(stats.size ?? 0);
|
|
1530
|
+
mtime = Number(stats.mtimeMs ?? 0);
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
/* stat 失败:保留 dirent 类型判断,占位展示 */
|
|
1534
|
+
}
|
|
1535
|
+
entries.push({ name: dirent.name, isDir, isFile, isSymlink: dirent.isSymbolicLink(), size, mtime });
|
|
1536
|
+
}
|
|
1537
|
+
return { path: resolved, entries };
|
|
1538
|
+
}
|
|
1156
1539
|
const plugin = definePlugin({
|
|
1157
1540
|
name: 'tty',
|
|
1158
1541
|
// 声明 inject:tools 服务只有声明式 inject 才能解析(动态 ctx.inject/ctx.get
|
|
@@ -1171,8 +1554,11 @@ const plugin = definePlugin({
|
|
|
1171
1554
|
hostKeys: Array.isArray(config?.hostKeys) ? config.hostKeys : [],
|
|
1172
1555
|
shellIntegration: config?.shellIntegration !== false,
|
|
1173
1556
|
tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
|
|
1557
|
+
persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
|
|
1558
|
+
endOnPageClose: config?.endOnPageClose === true,
|
|
1559
|
+
persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
|
|
1174
1560
|
});
|
|
1175
|
-
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS);
|
|
1561
|
+
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS, () => live.endOnPageClose);
|
|
1176
1562
|
/** TOFU 指纹记录持久化:写入 settings 命名空间(合并语义),失败不影响连接。 */
|
|
1177
1563
|
const persistHostKeys = (records) => {
|
|
1178
1564
|
const scope = settingsScope;
|
|
@@ -1183,9 +1569,31 @@ const plugin = definePlugin({
|
|
|
1183
1569
|
});
|
|
1184
1570
|
};
|
|
1185
1571
|
const hostKeyStore = new HostKeyStore(live, persistHostKeys);
|
|
1572
|
+
/** SSH 持久会话名留存(远程 tmux 本机 socket 看不到;新窗口恢复确认的数据源)。 */
|
|
1573
|
+
const persistPersistSessions = () => {
|
|
1574
|
+
const scope = settingsScope;
|
|
1575
|
+
if (scope === undefined)
|
|
1576
|
+
return;
|
|
1577
|
+
void Promise.resolve(scope.update({ persistSessions: live.persistSessions.map((name) => ({ tmuxName: name })) })).catch((error) => {
|
|
1578
|
+
console.warn('[dsh-tty] 持久会话名留存失败: ' + (error instanceof Error ? error.message : String(error)));
|
|
1579
|
+
});
|
|
1580
|
+
};
|
|
1581
|
+
/** 记录/移除一个 SSH 持久会话名并落盘(幂等)。 */
|
|
1582
|
+
const trackPersistSession = (tmuxName, present) => {
|
|
1583
|
+
const next = present
|
|
1584
|
+
? (live.persistSessions.includes(tmuxName) ? live.persistSessions : [...live.persistSessions, tmuxName])
|
|
1585
|
+
: live.persistSessions.filter((name) => name !== tmuxName);
|
|
1586
|
+
if (next.join('|') === live.persistSessions.join('|'))
|
|
1587
|
+
return;
|
|
1588
|
+
live.persistSessions = next;
|
|
1589
|
+
persistPersistSessions();
|
|
1590
|
+
};
|
|
1186
1591
|
const tunnelManager = new TunnelManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore, (bookName) => live.findSshHost(bookName));
|
|
1187
|
-
|
|
1188
|
-
|
|
1592
|
+
// SFTP 文件传输:懒连接池 + TOFU 同源(见 src/sftp.ts);spec 由各请求携带
|
|
1593
|
+
// (连接簿名或内联字段),连接簿凭证热改后天然生效
|
|
1594
|
+
const sftpManager = new SftpManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore);
|
|
1595
|
+
const server = new TtyServer(ctx, sessions, live, hostKeyStore, trackPersistSession);
|
|
1596
|
+
const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false, sftpStyle: config?.sftpStyle === 'dual' ? 'dual' : 'dialog' };
|
|
1189
1597
|
let settingsScope;
|
|
1190
1598
|
const snapshot = () => ({
|
|
1191
1599
|
enabled: stateRef.enabled,
|
|
@@ -1200,6 +1608,9 @@ const plugin = definePlugin({
|
|
|
1200
1608
|
hostKeys: live.hostKeys,
|
|
1201
1609
|
tunnels: live.tunnels,
|
|
1202
1610
|
shellIntegration: live.shellIntegration,
|
|
1611
|
+
sftpStyle: stateRef.sftpStyle,
|
|
1612
|
+
persistence: live.persistence,
|
|
1613
|
+
endOnPageClose: live.endOnPageClose,
|
|
1203
1614
|
toolsRegistered: stateRef.toolsRegistered,
|
|
1204
1615
|
});
|
|
1205
1616
|
/** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
|
|
@@ -1214,6 +1625,9 @@ const plugin = definePlugin({
|
|
|
1214
1625
|
hostKeys: sanitizeHostKeys(section.hostKeys),
|
|
1215
1626
|
shellIntegration: typeof section.shellIntegration === 'boolean' ? section.shellIntegration : undefined,
|
|
1216
1627
|
tunnels: sanitizeTunnels(section.tunnels),
|
|
1628
|
+
persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
|
|
1629
|
+
endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
|
|
1630
|
+
persistSessions: sanitizePersistSessions(section.persistSessions),
|
|
1217
1631
|
});
|
|
1218
1632
|
// 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
|
|
1219
1633
|
tunnelManager.reconcile(live.tunnels);
|
|
@@ -1224,12 +1638,14 @@ const plugin = definePlugin({
|
|
|
1224
1638
|
stateRef.enabled = section.enabled;
|
|
1225
1639
|
if (typeof section.announceToAgent === 'boolean')
|
|
1226
1640
|
stateRef.announceToAgent = section.announceToAgent;
|
|
1641
|
+
if (section.sftpStyle === 'dialog' || section.sftpStyle === 'dual')
|
|
1642
|
+
stateRef.sftpStyle = section.sftpStyle;
|
|
1227
1643
|
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length})`);
|
|
1228
1644
|
};
|
|
1229
1645
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
1230
1646
|
const normalizePatch = (input) => {
|
|
1231
1647
|
const patch = {};
|
|
1232
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration']);
|
|
1648
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose']);
|
|
1233
1649
|
for (const key of Object.keys(input)) {
|
|
1234
1650
|
if (!known.has(key))
|
|
1235
1651
|
return { error: '未知配置项: ' + key };
|
|
@@ -1261,6 +1677,21 @@ const plugin = definePlugin({
|
|
|
1261
1677
|
return { error: 'shellIntegration 必须是布尔值' };
|
|
1262
1678
|
patch.shellIntegration = input.shellIntegration;
|
|
1263
1679
|
}
|
|
1680
|
+
if (input.sftpStyle !== undefined) {
|
|
1681
|
+
if (input.sftpStyle !== 'dialog' && input.sftpStyle !== 'dual')
|
|
1682
|
+
return { error: 'sftpStyle 必须是 dialog 或 dual' };
|
|
1683
|
+
patch.sftpStyle = input.sftpStyle;
|
|
1684
|
+
}
|
|
1685
|
+
if (input.persistence !== undefined) {
|
|
1686
|
+
if (input.persistence !== 'off' && input.persistence !== 'tmux')
|
|
1687
|
+
return { error: 'persistence 必须是 off 或 tmux' };
|
|
1688
|
+
patch.persistence = input.persistence;
|
|
1689
|
+
}
|
|
1690
|
+
if (input.endOnPageClose !== undefined) {
|
|
1691
|
+
if (typeof input.endOnPageClose !== 'boolean')
|
|
1692
|
+
return { error: 'endOnPageClose 必须是布尔值' };
|
|
1693
|
+
patch.endOnPageClose = input.endOnPageClose;
|
|
1694
|
+
}
|
|
1264
1695
|
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
1265
1696
|
if (input[key] === undefined)
|
|
1266
1697
|
continue;
|
|
@@ -1416,6 +1847,22 @@ const plugin = definePlugin({
|
|
|
1416
1847
|
}
|
|
1417
1848
|
},
|
|
1418
1849
|
}));
|
|
1850
|
+
// 已安装 shell 候选(设置卡片「Shell 路径」可选可输入):loopback 围栏,只回路径不执行
|
|
1851
|
+
disposers.push(webServer.register({
|
|
1852
|
+
kind: 'exact',
|
|
1853
|
+
path: '/api/dsh-tty/shells',
|
|
1854
|
+
handler: async (req, res) => {
|
|
1855
|
+
if (!isLoopbackHttp(req)) {
|
|
1856
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (req.method !== 'GET') {
|
|
1860
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
writeJson(res, 200, { ok: true, shells: listCandidateShells(), current: process.env.SHELL ?? '' });
|
|
1864
|
+
},
|
|
1865
|
+
}));
|
|
1419
1866
|
// 端口转发隧道实时状态(设置卡片轮询徽标 + tunnel_list 工具数据源)
|
|
1420
1867
|
disposers.push(webServer.register({
|
|
1421
1868
|
kind: 'exact',
|
|
@@ -1432,6 +1879,228 @@ const plugin = definePlugin({
|
|
|
1432
1879
|
writeJson(res, 200, { ok: true, tunnels: tunnelManager.list() });
|
|
1433
1880
|
},
|
|
1434
1881
|
}));
|
|
1882
|
+
// SFTP 文件传输(0.7.0,src/sftp.ts):loopback 围栏;spec 解析与 WS ssh
|
|
1883
|
+
// 帧同款(mergeSshSpec:连接簿条目作基底 + 内联字段覆盖)。list/mkdir/
|
|
1884
|
+
// rename/remove/download 走 JSON 体(凭证不进 URL/查询串);download 响应
|
|
1885
|
+
// 为文件字节流(stat 成功时带 content-length);upload 以 x-dsh-sftp-meta
|
|
1886
|
+
// 头携带 base64url(JSON)(spec + path + append),请求体即原始文件字节,
|
|
1887
|
+
// pipeline 直灌 SFTP 写流——上传下载都不整文件进内存。
|
|
1888
|
+
disposers.push(webServer.register({
|
|
1889
|
+
kind: 'prefix',
|
|
1890
|
+
path: '/api/dsh-tty/sftp',
|
|
1891
|
+
handler: async (req, res) => {
|
|
1892
|
+
if (!isLoopbackHttp(req)) {
|
|
1893
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
|
|
1897
|
+
const jsonAction = ['/list', '/mkdir', '/rename', '/remove', '/download'].find((action) => action === sub);
|
|
1898
|
+
if (jsonAction !== undefined) {
|
|
1899
|
+
if (req.method !== 'POST') {
|
|
1900
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
const body = await readJsonBody(req);
|
|
1904
|
+
if (body === undefined) {
|
|
1905
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), body.name, body);
|
|
1909
|
+
if (parsed.spec === undefined) {
|
|
1910
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const spec = parsed.spec;
|
|
1914
|
+
const strField = (key) => (typeof body[key] === 'string' ? body[key].trim() : '');
|
|
1915
|
+
try {
|
|
1916
|
+
if (jsonAction === '/list') {
|
|
1917
|
+
const result = await sftpManager.list(spec, strField('path'));
|
|
1918
|
+
writeJson(res, 200, { ok: true, path: result.path, entries: result.entries });
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (jsonAction === '/mkdir') {
|
|
1922
|
+
if (strField('path') === '')
|
|
1923
|
+
throw new Error('path 必填');
|
|
1924
|
+
await sftpManager.mkdir(spec, strField('path'), body.parents === true);
|
|
1925
|
+
writeJson(res, 200, { ok: true });
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
if (jsonAction === '/rename') {
|
|
1929
|
+
if (strField('from') === '' || strField('to') === '')
|
|
1930
|
+
throw new Error('from/to 必填');
|
|
1931
|
+
await sftpManager.rename(spec, strField('from'), strField('to'));
|
|
1932
|
+
writeJson(res, 200, { ok: true });
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (jsonAction === '/remove') {
|
|
1936
|
+
if (strField('path') === '')
|
|
1937
|
+
throw new Error('path 必填');
|
|
1938
|
+
await sftpManager.remove(spec, strField('path'), body.recursive === true);
|
|
1939
|
+
writeJson(res, 200, { ok: true });
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
// download:路径必填(无 home 兜底),流式回包
|
|
1943
|
+
const target = strField('path');
|
|
1944
|
+
if (target === '')
|
|
1945
|
+
throw new Error('path 必填');
|
|
1946
|
+
const { stream, size } = await sftpManager.openDownload(spec, target);
|
|
1947
|
+
res.writeHead(200, {
|
|
1948
|
+
'content-type': 'application/octet-stream',
|
|
1949
|
+
'content-disposition': contentDispositionValue(remoteBasename(target)),
|
|
1950
|
+
...(size !== null ? { 'content-length': String(size) } : {}),
|
|
1951
|
+
});
|
|
1952
|
+
// 客户端中断或写流失败都要回收 SFTP 读流,避免连接池通道悬挂
|
|
1953
|
+
res.on('close', () => {
|
|
1954
|
+
if (res.writableEnded !== true)
|
|
1955
|
+
stream.destroy();
|
|
1956
|
+
});
|
|
1957
|
+
stream.on('error', (error) => {
|
|
1958
|
+
ctx.logger.warn('[dsh-tty] sftp 下载流错误: ' + error.message);
|
|
1959
|
+
res.destroy();
|
|
1960
|
+
});
|
|
1961
|
+
stream.pipe(res);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
catch (error) {
|
|
1965
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
if (sub === '/upload') {
|
|
1970
|
+
if (req.method !== 'POST') {
|
|
1971
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
const metaRaw = req.headers['x-dsh-sftp-meta'];
|
|
1975
|
+
let meta;
|
|
1976
|
+
if (typeof metaRaw === 'string' && metaRaw !== '') {
|
|
1977
|
+
try {
|
|
1978
|
+
const parsedMeta = JSON.parse(Buffer.from(metaRaw, 'base64url').toString('utf8'));
|
|
1979
|
+
if (typeof parsedMeta === 'object' && parsedMeta !== null && !Array.isArray(parsedMeta))
|
|
1980
|
+
meta = parsedMeta;
|
|
1981
|
+
}
|
|
1982
|
+
catch {
|
|
1983
|
+
/* 落到下面的 400 */
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
if (meta === undefined) {
|
|
1987
|
+
writeJson(res, 400, { error: '缺少或非法的 x-dsh-sftp-meta 头' });
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const target = typeof meta.path === 'string' ? meta.path.trim() : '';
|
|
1991
|
+
if (target === '') {
|
|
1992
|
+
writeJson(res, 400, { error: 'meta.path 必填' });
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), meta.name, meta);
|
|
1996
|
+
if (parsed.spec === undefined) {
|
|
1997
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
try {
|
|
2001
|
+
const { stream, done } = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
|
|
2002
|
+
let bytes = 0;
|
|
2003
|
+
req.on('data', (chunk) => {
|
|
2004
|
+
bytes += chunk.length;
|
|
2005
|
+
});
|
|
2006
|
+
await pipeline(req, stream);
|
|
2007
|
+
await done;
|
|
2008
|
+
writeJson(res, 200, { ok: true, bytes });
|
|
2009
|
+
}
|
|
2010
|
+
catch (error) {
|
|
2011
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2012
|
+
if (res.headersSent)
|
|
2013
|
+
res.destroy();
|
|
2014
|
+
else
|
|
2015
|
+
writeJson(res, 500, { error: message });
|
|
2016
|
+
}
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
writeJson(res, 404, { error: 'not found: ' + sub });
|
|
2020
|
+
},
|
|
2021
|
+
}));
|
|
2022
|
+
// 本机文件浏览(0.9.0,双栏 SFTP 的本机一侧):loopback 围栏。信任模型
|
|
2023
|
+
// 与终端/SFTP 一致——浏览器仅同源可访问,且本机能做的 SSH 会话也能做;
|
|
2024
|
+
// list/mkdir/rename/remove 操作本机路径;transfer 在服务端把本机路径与
|
|
2025
|
+
// 远程路径流式对拷(凭证不落浏览器,字节不经过浏览器)。
|
|
2026
|
+
disposers.push(webServer.register({
|
|
2027
|
+
kind: 'prefix',
|
|
2028
|
+
path: '/api/dsh-tty/local-fs',
|
|
2029
|
+
handler: async (req, res) => {
|
|
2030
|
+
if (!isLoopbackHttp(req)) {
|
|
2031
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
|
|
2035
|
+
if (req.method !== 'POST') {
|
|
2036
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
const body = await readJsonBody(req);
|
|
2040
|
+
if (body === undefined) {
|
|
2041
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const strField = (key) => (typeof body[key] === 'string' ? body[key].trim() : '');
|
|
2045
|
+
try {
|
|
2046
|
+
if (sub === '/list') {
|
|
2047
|
+
const result = await listLocalDir(strField('path'));
|
|
2048
|
+
writeJson(res, 200, { ok: true, path: result.path, entries: result.entries });
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (sub === '/mkdir') {
|
|
2052
|
+
const target = strField('path');
|
|
2053
|
+
if (target === '')
|
|
2054
|
+
throw new Error('path 必填');
|
|
2055
|
+
await fsMkdir(target, { recursive: body.parents === true });
|
|
2056
|
+
writeJson(res, 200, { ok: true });
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
if (sub === '/rename') {
|
|
2060
|
+
const from = strField('from');
|
|
2061
|
+
const to = strField('to');
|
|
2062
|
+
if (from === '' || to === '')
|
|
2063
|
+
throw new Error('from/to 必填');
|
|
2064
|
+
await fsRename(from, to);
|
|
2065
|
+
writeJson(res, 200, { ok: true });
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
if (sub === '/remove') {
|
|
2069
|
+
const target = strField('path');
|
|
2070
|
+
if (target === '')
|
|
2071
|
+
throw new Error('path 必填');
|
|
2072
|
+
await fsRm(target, { recursive: body.recursive === true });
|
|
2073
|
+
writeJson(res, 200, { ok: true });
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (sub === '/transfer') {
|
|
2077
|
+
// up = 本机→远程(上传),down = 远程→本机(下载);目录递归
|
|
2078
|
+
const direction = strField('direction');
|
|
2079
|
+
const localPath = strField('localPath');
|
|
2080
|
+
const remotePath = strField('remotePath');
|
|
2081
|
+
if (localPath === '' || remotePath === '')
|
|
2082
|
+
throw new Error('localPath/remotePath 必填');
|
|
2083
|
+
if (direction !== 'up' && direction !== 'down')
|
|
2084
|
+
throw new Error('direction 必须是 up 或 down');
|
|
2085
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), body.name, body);
|
|
2086
|
+
if (parsed.spec === undefined) {
|
|
2087
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
if (direction === 'up')
|
|
2091
|
+
await sftpManager.uploadFromLocal(parsed.spec, localPath, remotePath);
|
|
2092
|
+
else
|
|
2093
|
+
await sftpManager.downloadToLocal(parsed.spec, remotePath, localPath);
|
|
2094
|
+
writeJson(res, 200, { ok: true });
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
writeJson(res, 404, { error: 'not found: ' + sub });
|
|
2098
|
+
}
|
|
2099
|
+
catch (error) {
|
|
2100
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2101
|
+
}
|
|
2102
|
+
},
|
|
2103
|
+
}));
|
|
1435
2104
|
return () => {
|
|
1436
2105
|
server.close();
|
|
1437
2106
|
for (const dispose of disposers) {
|
|
@@ -1474,6 +2143,15 @@ const plugin = definePlugin({
|
|
|
1474
2143
|
}
|
|
1475
2144
|
if (stored.shellIntegration === false)
|
|
1476
2145
|
startup.shellIntegration = false;
|
|
2146
|
+
if (stored.sftpStyle === 'dual')
|
|
2147
|
+
startup.sftpStyle = 'dual';
|
|
2148
|
+
if (stored.persistence === 'tmux')
|
|
2149
|
+
startup.persistence = 'tmux';
|
|
2150
|
+
if (stored.endOnPageClose === true)
|
|
2151
|
+
startup.endOnPageClose = true;
|
|
2152
|
+
const storedPersistSessions = sanitizePersistSessions(stored.persistSessions);
|
|
2153
|
+
if (storedPersistSessions !== undefined && storedPersistSessions.length > 0)
|
|
2154
|
+
startup.persistSessions = storedPersistSessions;
|
|
1477
2155
|
const storedHosts = sanitizeSshHosts(stored.sshHosts);
|
|
1478
2156
|
if (storedHosts !== undefined && storedHosts.length > 0)
|
|
1479
2157
|
startup.sshHosts = storedHosts;
|
|
@@ -1533,6 +2211,7 @@ const plugin = definePlugin({
|
|
|
1533
2211
|
cwd: { type: 'string', required: true },
|
|
1534
2212
|
startedAt: { type: 'number', required: true },
|
|
1535
2213
|
lastOutputAt: { type: 'number', required: true },
|
|
2214
|
+
persist: { type: 'boolean' },
|
|
1536
2215
|
},
|
|
1537
2216
|
},
|
|
1538
2217
|
},
|
|
@@ -1544,7 +2223,8 @@ const plugin = definePlugin({
|
|
|
1544
2223
|
? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
|
|
1545
2224
|
: '终端面板会话:' + sessions.map((s) => {
|
|
1546
2225
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
1547
|
-
|
|
2226
|
+
const persist = s.persist === true ? ' [tmux 持久]' : '';
|
|
2227
|
+
return `\n- sid=${s.sid} [${s.kind}]${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
|
|
1548
2228
|
}).join('');
|
|
1549
2229
|
return [{ type: 'text', text }];
|
|
1550
2230
|
},
|
|
@@ -1806,8 +2486,334 @@ const plugin = definePlugin({
|
|
|
1806
2486
|
return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
|
|
1807
2487
|
},
|
|
1808
2488
|
})));
|
|
2489
|
+
// —— SFTP 文件传输工具(0.7.0)——
|
|
2490
|
+
// 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
|
|
2491
|
+
// 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
|
|
2492
|
+
const sftpBookSpec = (book) => {
|
|
2493
|
+
if (typeof book !== 'string' || book.trim() === '')
|
|
2494
|
+
throw new Error('book 必须是 SSH 连接簿条目名');
|
|
2495
|
+
const entry = live.findSshHost(book.trim());
|
|
2496
|
+
if (entry === undefined)
|
|
2497
|
+
throw new Error(`连接簿中不存在: ${book.trim()}`);
|
|
2498
|
+
return entry;
|
|
2499
|
+
};
|
|
2500
|
+
disposers.push(tools.register(defineTool({
|
|
2501
|
+
name: 'sftp_list',
|
|
2502
|
+
description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。用于查找远程文件、确认上传下载结果。',
|
|
2503
|
+
parameters: {
|
|
2504
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
|
|
2505
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
2506
|
+
},
|
|
2507
|
+
output: {
|
|
2508
|
+
schema: {
|
|
2509
|
+
type: 'object',
|
|
2510
|
+
additionalProperties: false,
|
|
2511
|
+
properties: {
|
|
2512
|
+
path: { type: 'string', required: true },
|
|
2513
|
+
entries: {
|
|
2514
|
+
type: 'array',
|
|
2515
|
+
required: true,
|
|
2516
|
+
items: {
|
|
2517
|
+
type: 'object',
|
|
2518
|
+
additionalProperties: false,
|
|
2519
|
+
properties: {
|
|
2520
|
+
name: { type: 'string', required: true },
|
|
2521
|
+
isDir: { type: 'boolean', required: true },
|
|
2522
|
+
size: { type: 'number', required: true },
|
|
2523
|
+
mtime: { type: 'number', required: true },
|
|
2524
|
+
},
|
|
2525
|
+
},
|
|
2526
|
+
},
|
|
2527
|
+
},
|
|
2528
|
+
},
|
|
2529
|
+
render: (_args, value) => {
|
|
2530
|
+
const v = value;
|
|
2531
|
+
const entries = v.entries ?? [];
|
|
2532
|
+
if (entries.length === 0)
|
|
2533
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
2534
|
+
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : ''} — ${e.isDir ? '目录' : humanFileSize(e.size)}`).join('');
|
|
2535
|
+
return [{ type: 'text', text }];
|
|
2536
|
+
},
|
|
2537
|
+
},
|
|
2538
|
+
async execute(args) {
|
|
2539
|
+
const input = args;
|
|
2540
|
+
const spec = sftpBookSpec(input.book);
|
|
2541
|
+
const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
|
|
2542
|
+
return { path: result.path, entries: result.entries.map((e) => ({ name: e.name, isDir: e.isDir, size: e.size, mtime: e.mtime })) };
|
|
2543
|
+
},
|
|
2544
|
+
})));
|
|
2545
|
+
disposers.push(tools.register(defineTool({
|
|
2546
|
+
name: 'sftp_read',
|
|
2547
|
+
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB),超出截断;检测到 NUL 字节按二进制文件拒绝。适合查看远程配置、小日志。',
|
|
2548
|
+
parameters: {
|
|
2549
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2550
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
2551
|
+
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144)' },
|
|
2552
|
+
},
|
|
2553
|
+
output: {
|
|
2554
|
+
schema: {
|
|
2555
|
+
type: 'object',
|
|
2556
|
+
additionalProperties: false,
|
|
2557
|
+
properties: {
|
|
2558
|
+
path: { type: 'string', required: true },
|
|
2559
|
+
content: { type: 'string', required: true },
|
|
2560
|
+
truncated: { type: 'boolean', required: true },
|
|
2561
|
+
},
|
|
2562
|
+
},
|
|
2563
|
+
render: (_args, value) => {
|
|
2564
|
+
const v = value;
|
|
2565
|
+
const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断)' : ''}:`;
|
|
2566
|
+
return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
|
|
2567
|
+
},
|
|
2568
|
+
},
|
|
2569
|
+
async execute(args) {
|
|
2570
|
+
const input = args;
|
|
2571
|
+
const spec = sftpBookSpec(input.book);
|
|
2572
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2573
|
+
throw new Error('path 必须是非空字符串');
|
|
2574
|
+
const maxBytes = typeof input.maxBytes === 'number' && Number.isInteger(input.maxBytes) && input.maxBytes >= 1 && input.maxBytes <= 1024 * 1024 ? input.maxBytes : 256 * 1024;
|
|
2575
|
+
const { stream } = await sftpManager.openDownload(spec, input.path);
|
|
2576
|
+
const chunks = [];
|
|
2577
|
+
let total = 0;
|
|
2578
|
+
try {
|
|
2579
|
+
for await (const chunk of stream) {
|
|
2580
|
+
const piece = chunk;
|
|
2581
|
+
chunks.push(piece);
|
|
2582
|
+
total += piece.length;
|
|
2583
|
+
if (total > maxBytes)
|
|
2584
|
+
break; // 只多读一段用于判定截断,其余丢弃
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
finally {
|
|
2588
|
+
stream.destroy();
|
|
2589
|
+
}
|
|
2590
|
+
const buf = Buffer.concat(chunks);
|
|
2591
|
+
const truncated = buf.length > maxBytes;
|
|
2592
|
+
const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
|
|
2593
|
+
if (sliced.includes(0))
|
|
2594
|
+
throw new Error('疑似二进制文件(含 NUL 字节),sftp_read 只支持文本内容');
|
|
2595
|
+
return { path: input.path.trim(), content: sliced.toString('utf8'), truncated };
|
|
2596
|
+
},
|
|
2597
|
+
})));
|
|
2598
|
+
disposers.push(tools.register(defineTool({
|
|
2599
|
+
name: 'sftp_write',
|
|
2600
|
+
description: '写 SSH 远程文本文件(book 连接簿条目 + path + content)。默认覆盖写入,append:true 追加到文件尾;单次最多 1MB。适合远程写配置、落结果文件。',
|
|
2601
|
+
parameters: {
|
|
2602
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2603
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
2604
|
+
content: { type: 'string', required: true, description: '要写入的文本内容(≤1MB)' },
|
|
2605
|
+
append: { type: 'boolean', description: 'true 追加到文件尾(默认覆盖)' },
|
|
2606
|
+
},
|
|
2607
|
+
output: {
|
|
2608
|
+
schema: {
|
|
2609
|
+
type: 'object',
|
|
2610
|
+
additionalProperties: false,
|
|
2611
|
+
properties: {
|
|
2612
|
+
ok: { type: 'boolean', required: true },
|
|
2613
|
+
path: { type: 'string', required: true },
|
|
2614
|
+
bytes: { type: 'number', required: true },
|
|
2615
|
+
append: { type: 'boolean', required: true },
|
|
2616
|
+
},
|
|
2617
|
+
},
|
|
2618
|
+
render: (_args, value) => {
|
|
2619
|
+
const v = value;
|
|
2620
|
+
return [{ type: 'text', text: `已${v.append === true ? '追加' : '写入'}远程文件 ${v.path ?? '?'}(${String(v.bytes ?? 0)} 字节)` }];
|
|
2621
|
+
},
|
|
2622
|
+
},
|
|
2623
|
+
async execute(args) {
|
|
2624
|
+
const input = args;
|
|
2625
|
+
const spec = sftpBookSpec(input.book);
|
|
2626
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2627
|
+
throw new Error('path 必须是非空字符串');
|
|
2628
|
+
if (typeof input.content !== 'string')
|
|
2629
|
+
throw new Error('content 必须是字符串');
|
|
2630
|
+
const bytes = Buffer.byteLength(input.content, 'utf8');
|
|
2631
|
+
if (bytes > 1024 * 1024)
|
|
2632
|
+
throw new Error(`content 超过上限:${String(bytes)} 字节 > 1MB(大文件请用终端 scp 或面板上传)`);
|
|
2633
|
+
const append = input.append === true;
|
|
2634
|
+
const { stream, done } = await sftpManager.openUpload(spec, input.path, append);
|
|
2635
|
+
stream.write(input.content, 'utf8');
|
|
2636
|
+
stream.end();
|
|
2637
|
+
await done;
|
|
2638
|
+
return { ok: true, path: input.path.trim(), bytes, append };
|
|
2639
|
+
},
|
|
2640
|
+
})));
|
|
2641
|
+
// —— SFTP 管理闭环(0.8.0)——
|
|
2642
|
+
// mkdir(可逐级补齐)/ rename(可跨目录,等效移动)/ remove(目录
|
|
2643
|
+
// 递归)/ tree(限深限数的递归列举),与 sftp_list/read/write 一起
|
|
2644
|
+
// 让 agent 不开面板也能完整管理远程文件;同样只收连接簿条目名。
|
|
2645
|
+
disposers.push(tools.register(defineTool({
|
|
2646
|
+
name: 'sftp_mkdir',
|
|
2647
|
+
description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
|
|
2648
|
+
parameters: {
|
|
2649
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
|
|
2650
|
+
path: { type: 'string', required: true, description: '要创建的远程目录路径' },
|
|
2651
|
+
parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
|
|
2652
|
+
},
|
|
2653
|
+
output: {
|
|
2654
|
+
schema: {
|
|
2655
|
+
type: 'object',
|
|
2656
|
+
additionalProperties: false,
|
|
2657
|
+
properties: {
|
|
2658
|
+
ok: { type: 'boolean', required: true },
|
|
2659
|
+
path: { type: 'string', required: true },
|
|
2660
|
+
},
|
|
2661
|
+
},
|
|
2662
|
+
render: (_args, value) => {
|
|
2663
|
+
const v = value;
|
|
2664
|
+
return [{ type: 'text', text: `已创建远程目录 ${v.path ?? '?'}` }];
|
|
2665
|
+
},
|
|
2666
|
+
},
|
|
2667
|
+
async execute(args) {
|
|
2668
|
+
const input = args;
|
|
2669
|
+
const spec = sftpBookSpec(input.book);
|
|
2670
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2671
|
+
throw new Error('path 必须是非空字符串');
|
|
2672
|
+
await sftpManager.mkdir(spec, input.path, input.parents === true);
|
|
2673
|
+
return { ok: true, path: input.path.trim() };
|
|
2674
|
+
},
|
|
2675
|
+
})));
|
|
2676
|
+
disposers.push(tools.register(defineTool({
|
|
2677
|
+
name: 'sftp_rename',
|
|
2678
|
+
description: '在 SSH 远程重命名 / 移动文件或目录(book 连接簿条目 + from + to)。to 与 from 不同目录即为移动(目标目录需已存在);不会覆盖已存在的目标(服务端 rename 语义)。',
|
|
2679
|
+
parameters: {
|
|
2680
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2681
|
+
from: { type: 'string', required: true, description: '原远程路径' },
|
|
2682
|
+
to: { type: 'string', required: true, description: '新远程路径(跨目录即移动)' },
|
|
2683
|
+
},
|
|
2684
|
+
output: {
|
|
2685
|
+
schema: {
|
|
2686
|
+
type: 'object',
|
|
2687
|
+
additionalProperties: false,
|
|
2688
|
+
properties: {
|
|
2689
|
+
ok: { type: 'boolean', required: true },
|
|
2690
|
+
from: { type: 'string', required: true },
|
|
2691
|
+
to: { type: 'string', required: true },
|
|
2692
|
+
},
|
|
2693
|
+
},
|
|
2694
|
+
render: (_args, value) => {
|
|
2695
|
+
const v = value;
|
|
2696
|
+
return [{ type: 'text', text: `已将远程 ${v.from ?? '?'} 重命名/移动为 ${v.to ?? '?'}` }];
|
|
2697
|
+
},
|
|
2698
|
+
},
|
|
2699
|
+
async execute(args) {
|
|
2700
|
+
const input = args;
|
|
2701
|
+
const spec = sftpBookSpec(input.book);
|
|
2702
|
+
if (typeof input.from !== 'string' || input.from.trim() === '')
|
|
2703
|
+
throw new Error('from 必须是非空字符串');
|
|
2704
|
+
if (typeof input.to !== 'string' || input.to.trim() === '')
|
|
2705
|
+
throw new Error('to 必须是非空字符串');
|
|
2706
|
+
await sftpManager.rename(spec, input.from, input.to);
|
|
2707
|
+
return { ok: true, from: input.from.trim(), to: input.to.trim() };
|
|
2708
|
+
},
|
|
2709
|
+
})));
|
|
2710
|
+
disposers.push(tools.register(defineTool({
|
|
2711
|
+
name: 'sftp_remove',
|
|
2712
|
+
description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。',
|
|
2713
|
+
parameters: {
|
|
2714
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2715
|
+
path: { type: 'string', required: true, description: '要删除的远程路径' },
|
|
2716
|
+
recursive: { type: 'boolean', description: '目录 true 时递归删除全部内容(默认 false)' },
|
|
2717
|
+
},
|
|
2718
|
+
output: {
|
|
2719
|
+
schema: {
|
|
2720
|
+
type: 'object',
|
|
2721
|
+
additionalProperties: false,
|
|
2722
|
+
properties: {
|
|
2723
|
+
ok: { type: 'boolean', required: true },
|
|
2724
|
+
path: { type: 'string', required: true },
|
|
2725
|
+
recursive: { type: 'boolean', required: true },
|
|
2726
|
+
},
|
|
2727
|
+
},
|
|
2728
|
+
render: (_args, value) => {
|
|
2729
|
+
const v = value;
|
|
2730
|
+
return [{ type: 'text', text: `已删除远程 ${v.path ?? '?'}${v.recursive === true ? '(含全部内容)' : ''}` }];
|
|
2731
|
+
},
|
|
2732
|
+
},
|
|
2733
|
+
async execute(args) {
|
|
2734
|
+
const input = args;
|
|
2735
|
+
const spec = sftpBookSpec(input.book);
|
|
2736
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2737
|
+
throw new Error('path 必须是非空字符串');
|
|
2738
|
+
const recursive = input.recursive === true;
|
|
2739
|
+
await sftpManager.remove(spec, input.path, recursive);
|
|
2740
|
+
return { ok: true, path: input.path.trim(), recursive };
|
|
2741
|
+
},
|
|
2742
|
+
})));
|
|
2743
|
+
disposers.push(tools.register(defineTool({
|
|
2744
|
+
name: 'sftp_tree',
|
|
2745
|
+
description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
|
|
2746
|
+
parameters: {
|
|
2747
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2748
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
2749
|
+
maxDepth: { type: 'number', description: '最大下钻层数(1~8,默认 3)' },
|
|
2750
|
+
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
2751
|
+
},
|
|
2752
|
+
output: {
|
|
2753
|
+
schema: {
|
|
2754
|
+
type: 'object',
|
|
2755
|
+
additionalProperties: false,
|
|
2756
|
+
properties: {
|
|
2757
|
+
path: { type: 'string', required: true },
|
|
2758
|
+
entries: {
|
|
2759
|
+
type: 'array',
|
|
2760
|
+
required: true,
|
|
2761
|
+
items: {
|
|
2762
|
+
type: 'object',
|
|
2763
|
+
additionalProperties: false,
|
|
2764
|
+
properties: {
|
|
2765
|
+
path: { type: 'string', required: true },
|
|
2766
|
+
name: { type: 'string', required: true },
|
|
2767
|
+
depth: { type: 'number', required: true },
|
|
2768
|
+
isDir: { type: 'boolean', required: true },
|
|
2769
|
+
size: { type: 'number', required: true },
|
|
2770
|
+
mtime: { type: 'number', required: true },
|
|
2771
|
+
},
|
|
2772
|
+
},
|
|
2773
|
+
},
|
|
2774
|
+
truncated: { type: 'boolean', required: true },
|
|
2775
|
+
errors: {
|
|
2776
|
+
type: 'array',
|
|
2777
|
+
required: true,
|
|
2778
|
+
items: {
|
|
2779
|
+
type: 'object',
|
|
2780
|
+
additionalProperties: false,
|
|
2781
|
+
properties: {
|
|
2782
|
+
path: { type: 'string', required: true },
|
|
2783
|
+
message: { type: 'string', required: true },
|
|
2784
|
+
},
|
|
2785
|
+
},
|
|
2786
|
+
},
|
|
2787
|
+
},
|
|
2788
|
+
},
|
|
2789
|
+
render: (_args, value) => {
|
|
2790
|
+
const v = value;
|
|
2791
|
+
const entries = v.entries ?? [];
|
|
2792
|
+
if (entries.length === 0)
|
|
2793
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
2794
|
+
const head = `远程目录 ${v.path ?? '?'} 结构(${String(entries.length)} 项${v.truncated === true ? ',已截断' : ''}):`;
|
|
2795
|
+
const lines = entries.map((e) => {
|
|
2796
|
+
const indent = ' '.repeat(Math.max(0, e.depth - 1));
|
|
2797
|
+
const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
|
|
2798
|
+
return `${indent}- ${e.name}${tail}`;
|
|
2799
|
+
});
|
|
2800
|
+
for (const item of v.errors ?? [])
|
|
2801
|
+
lines.push(`! ${item.path}(${item.message})`);
|
|
2802
|
+
return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
|
|
2803
|
+
},
|
|
2804
|
+
},
|
|
2805
|
+
async execute(args) {
|
|
2806
|
+
const input = args;
|
|
2807
|
+
const spec = sftpBookSpec(input.book);
|
|
2808
|
+
const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
|
|
2809
|
+
maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
|
|
2810
|
+
maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
|
|
2811
|
+
});
|
|
2812
|
+
return result;
|
|
2813
|
+
},
|
|
2814
|
+
})));
|
|
1809
2815
|
stateRef.toolsRegistered = true;
|
|
1810
|
-
console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list)');
|
|
2816
|
+
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)');
|
|
1811
2817
|
return () => {
|
|
1812
2818
|
stateRef.toolsRegistered = false;
|
|
1813
2819
|
for (const dispose of disposers) {
|
|
@@ -1838,7 +2844,7 @@ const plugin = definePlugin({
|
|
|
1838
2844
|
return '当前没有活跃的终端面板会话(可引导用户打开「终端」面板,或用 spawn 类工作流替代)。';
|
|
1839
2845
|
return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,sid 如下):\n' + list.map((s) => {
|
|
1840
2846
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
1841
|
-
return `- sid=${s.sid} [${s.kind}] ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
2847
|
+
return `- sid=${s.sid} [${s.kind}]${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
1842
2848
|
}).join('\n');
|
|
1843
2849
|
},
|
|
1844
2850
|
});
|
|
@@ -1857,11 +2863,12 @@ const plugin = definePlugin({
|
|
|
1857
2863
|
}, REAPER_INTERVAL_MS);
|
|
1858
2864
|
reaperTimer.unref?.();
|
|
1859
2865
|
ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
|
|
1860
|
-
//
|
|
2866
|
+
// 插件卸载时回收全部会话、隧道与 SFTP 连接
|
|
1861
2867
|
ctx.effect(() => {
|
|
1862
2868
|
return () => {
|
|
1863
2869
|
void sessions.disposeAll();
|
|
1864
2870
|
tunnelManager.disposeAll();
|
|
2871
|
+
sftpManager.disposeAll();
|
|
1865
2872
|
};
|
|
1866
2873
|
}, 'dsh-tty: session cleanup');
|
|
1867
2874
|
console.log(`[dsh-tty] mounted (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);
|