@hyzyn/dsh-tty 0.18.3 → 0.19.1
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.en.md +61 -30
- package/README.md +49 -27
- package/client.js +39 -32
- package/lib/index.d.ts +367 -1
- package/lib/index.js +417 -135
- package/lib/index.js.map +1 -1
- package/lib/known-hosts.d.ts +7 -1
- package/lib/known-hosts.js +26 -12
- package/lib/known-hosts.js.map +1 -1
- package/lib/probe.d.ts +2 -2
- package/lib/probe.js +7 -35
- package/lib/probe.js.map +1 -1
- package/lib/sftp.d.ts +63 -10
- package/lib/sftp.js +244 -44
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +35 -15
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +21 -7
- package/lib/ssh.js +113 -18
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +67 -13
- package/lib/tunnels.js.map +1 -1
- package/package.json +8 -3
- package/scripts/build-client.mjs +30 -0
- package/scripts/integration.mjs +1691 -0
- package/scripts/lib/test-sshd.mjs +314 -0
- package/scripts/live.mjs +68 -0
- package/scripts/preview/harness.html +75 -0
- package/scripts/preview/harness.js +875 -0
- package/scripts/preview/mock-host.js +365 -0
- package/scripts/preview.mjs +502 -0
- package/scripts/probe-route-smoke.mjs +111 -0
- package/scripts/probe-smoke.mjs +143 -0
- package/scripts/probe.mjs +240 -0
- package/scripts/sftplimits-smoke.mjs +92 -0
- package/scripts/ssh-smoke.mjs +593 -0
- package/scripts/tui-smoke.mjs +112 -0
- package/scripts/windows-smoke.mjs +179 -0
package/lib/index.js
CHANGED
|
@@ -12,13 +12,13 @@ import WebSocket, { WebSocketServer } from 'ws';
|
|
|
12
12
|
// found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
|
|
13
13
|
import xtermHeadless from '@xterm/headless';
|
|
14
14
|
const HeadlessTerminal = xtermHeadless.Terminal;
|
|
15
|
-
import { definePlugin } from '@hyzyn/dsh-kit';
|
|
15
|
+
import { definePlugin, dshHome as resolveDshHome } from '@hyzyn/dsh-kit';
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
17
|
import { spawnSsh, sshTarget, expandHome, setCredentialResolver } from './ssh.js';
|
|
18
18
|
import { probeSsh } from './probe.js';
|
|
19
19
|
import { buildCommandSpawn, buildShellSpawn, defaultShellPath } from './shell-integration.js';
|
|
20
20
|
import { parseSshConfig } from './ssh-config.js';
|
|
21
|
-
import {
|
|
21
|
+
import { parseKnownHostsDetailed } from './known-hosts.js';
|
|
22
22
|
import { TunnelManager } from './tunnels.js';
|
|
23
23
|
import { SftpManager } from './sftp.js';
|
|
24
24
|
import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
|
|
@@ -41,7 +41,8 @@ const SSH_HOST_SCHEMA = z.object({
|
|
|
41
41
|
const HOST_KEY_SCHEMA = z.object({
|
|
42
42
|
host: z.string(),
|
|
43
43
|
port: z.natural().max(65535).default(22),
|
|
44
|
-
|
|
44
|
+
fingerprints: z.array(z.string()).default([]),
|
|
45
|
+
fingerprint: z.string().default(''), // 旧版单指纹字段:仅作迁移输入,清洗后并入 fingerprints
|
|
45
46
|
});
|
|
46
47
|
const TUNNEL_SCHEMA = z.object({
|
|
47
48
|
name: z.string(),
|
|
@@ -143,15 +144,36 @@ function wrapLocalPty(handle) {
|
|
|
143
144
|
},
|
|
144
145
|
terminate: () => handle.terminate(),
|
|
145
146
|
forceKill: () => {
|
|
146
|
-
|
|
147
|
-
handle.terminal?.kill?.('SIGKILL');
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
/* 已退出 */
|
|
151
|
-
}
|
|
147
|
+
killLocalShellTerminal(handle.terminal);
|
|
152
148
|
},
|
|
153
149
|
};
|
|
154
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* 本地 PTY 顶层 shell 的 best-effort 强杀(D48)。
|
|
153
|
+
*
|
|
154
|
+
* **Windows 绝不能带 signal**:node-pty 的 `WindowsTerminal.kill(signal)` 会同步
|
|
155
|
+
* `throw new Error('Signals not supported on windows.')`,而且它内部 `_deferNoArgs`
|
|
156
|
+
* 会把回调排进队列、稍后从 socket 回调里执行——调用方的 try/catch 拦不住,直接变成
|
|
157
|
+
* **宿主进程崩溃**。CI 的 windows-latest 上实测:spawn → kill 跑完就崩在
|
|
158
|
+
* `windowsTerminal.js:161`。不带 signal 时 node-pty 走 `_close()` + `agent.kill()`,
|
|
159
|
+
* 正是 Windows 上正确的终止语义。
|
|
160
|
+
*
|
|
161
|
+
* 导出仅供单测(test/host-frames.test.ts):平台参数注入,两个分支都能在 macOS/Linux 断言。
|
|
162
|
+
*/
|
|
163
|
+
export function killLocalShellTerminal(terminal, platform = process.platform) {
|
|
164
|
+
const kill = terminal?.kill;
|
|
165
|
+
if (typeof kill !== 'function')
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
if (platform === 'win32')
|
|
169
|
+
kill.call(terminal);
|
|
170
|
+
else
|
|
171
|
+
kill.call(terminal, 'SIGKILL');
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* 已退出 */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
155
177
|
/** TERM/COLORTERM 值白名单校验:不合法回退 fallback(防止破坏 -c 包装层)。 */
|
|
156
178
|
function sanitizeTermValue(value, fallback) {
|
|
157
179
|
const trimmed = value.trim();
|
|
@@ -256,6 +278,79 @@ function send(ws, msg) {
|
|
|
256
278
|
return;
|
|
257
279
|
ws.send(JSON.stringify(msg));
|
|
258
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* 文本判定(0.19.0,sftp_read):NUL 之外再加「非法 UTF-8/控制字节占比」——
|
|
283
|
+
* 只看已读前缀是否含 NUL 时,>256KB 的二进制文件前段恰好没 NUL 就被当文本
|
|
284
|
+
* 返回乱码;UTF-16 文本(字节偶位 NUL)由 NUL 判据捕获。样本只取前 64KB。
|
|
285
|
+
*/
|
|
286
|
+
function looksLikeBinary(buf) {
|
|
287
|
+
if (buf.includes(0))
|
|
288
|
+
return true;
|
|
289
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
290
|
+
try {
|
|
291
|
+
decoder.decode(buf);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
let suspicious = 0;
|
|
296
|
+
const sample = buf.subarray(0, 64 * 1024);
|
|
297
|
+
for (let i = 0; i < sample.length; i++) {
|
|
298
|
+
const b = sample[i];
|
|
299
|
+
// 控制字节(除 \t \n \r \f \e):合法 UTF-8 文本里几乎不出现,二进制里大量出现
|
|
300
|
+
if (b < 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d && b !== 0x0c && b !== 0x1b)
|
|
301
|
+
suspicious += 1;
|
|
302
|
+
}
|
|
303
|
+
return suspicious / sample.length > 0.02;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** 去掉截断点上的未完成 UTF-8 序列(≤3 字节残包):解码不再在尾部出 U+FFFD。 */
|
|
307
|
+
function trimIncompleteUtf8Tail(buf) {
|
|
308
|
+
if (buf.length === 0)
|
|
309
|
+
return buf;
|
|
310
|
+
const start = Math.max(0, buf.length - 3);
|
|
311
|
+
for (let i = buf.length - 1; i >= start; i--) {
|
|
312
|
+
const b = buf[i];
|
|
313
|
+
if (b < 0x80)
|
|
314
|
+
return buf; // 末尾就是 ASCII:没有残包
|
|
315
|
+
if ((b & 0xc0) === 0x80)
|
|
316
|
+
continue; // 续字节:向前找 lead
|
|
317
|
+
const need = (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 0;
|
|
318
|
+
if (need === 0)
|
|
319
|
+
return buf; // 非法字节:交给 decode 按错误处理
|
|
320
|
+
return buf.length - i >= need ? buf : buf.subarray(0, i);
|
|
321
|
+
}
|
|
322
|
+
return buf;
|
|
323
|
+
}
|
|
324
|
+
function decodeUtf8ForAgent(buf) {
|
|
325
|
+
return trimIncompleteUtf8Tail(buf).toString('utf8');
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* 保尾截断并避开「切割点落在转义序列 / UTF-16 代理对中间」(0.19.0):
|
|
329
|
+
* 环形回放缓冲按字符 slice 时,起点可能落进 ANSI 序列内部(回放首行出现
|
|
330
|
+
* 残破转义)或代理对之间(单个孤立代理)。找到安全边界后再切。
|
|
331
|
+
*/
|
|
332
|
+
function tailFromSafeBoundary(text, cap) {
|
|
333
|
+
if (text.length <= cap)
|
|
334
|
+
return text;
|
|
335
|
+
let start = text.length - cap;
|
|
336
|
+
// 截断点前 64 字符内的 ESC:序列若跨过截断点,把起点挪到终结符之后
|
|
337
|
+
const esc = text.lastIndexOf('\x1b', start);
|
|
338
|
+
if (esc !== -1 && esc >= start - 64) {
|
|
339
|
+
const m = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/.exec(text.slice(esc, esc + 160));
|
|
340
|
+
if (m === null) {
|
|
341
|
+
start = esc; // 窗口内不见终结符:整个序列丢弃(最多 ~64 字符)
|
|
342
|
+
}
|
|
343
|
+
else if (esc + m.index + m[0].length > start) {
|
|
344
|
+
start = esc + m.index + m[0].length;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (start > 0 && start < text.length) {
|
|
348
|
+
const code = text.charCodeAt(start);
|
|
349
|
+
if (code >= 0xdc00 && code <= 0xdfff)
|
|
350
|
+
start += 1; // 低位代理:跳过,避免孤立
|
|
351
|
+
}
|
|
352
|
+
return text.slice(start);
|
|
353
|
+
}
|
|
259
354
|
/**
|
|
260
355
|
* tty_capture 的默认清洗:剥离 OSC/CSI/杂项转义序列,并把同行内 \r 覆盖
|
|
261
356
|
* 收敛为最后一次覆盖结果(进度条不再刷屏)。逐行近似,不追求完整 VT 语义
|
|
@@ -282,6 +377,21 @@ const OSC133_RE = /\x1b\]133;([ABDCT])(?:;([^\x07\x1b]*))?(?:\x07|\x1b\\)/g;
|
|
|
282
377
|
const OSC7_RE = /\x1b\]7;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
283
378
|
/** 单条命令输出捕获上限(环形,超出丢头部)。 */
|
|
284
379
|
const COMMAND_CAP = 256 * 1024;
|
|
380
|
+
/** 同一会话允许的在途 tty_expect 上限(每个都挂常驻 data 监听器直到 settle)。 */
|
|
381
|
+
const MAX_EXPECT_PER_SESSION = 5;
|
|
382
|
+
/** 在途 tty_expect 计数(按会话弱引用,会话回收不泄漏)。 */
|
|
383
|
+
const expectCounts = new WeakMap();
|
|
384
|
+
/**
|
|
385
|
+
* 整数夹紧(0.19.0):ws 帧输入零信任——`Number('abc')=NaN`、`-5`、`1.5`、
|
|
386
|
+
* `1e9` 都不能原样透传给 node-pty 的 ioctl 与 xterm-headless(后者曾在
|
|
387
|
+
* resize 帧路径直接炸出未捕获异常)。非法值回落 fallback,范围内取整。
|
|
388
|
+
*/
|
|
389
|
+
function clampInt(value, fallback, min, max) {
|
|
390
|
+
const n = Number(value);
|
|
391
|
+
if (!Number.isFinite(n))
|
|
392
|
+
return fallback;
|
|
393
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
394
|
+
}
|
|
285
395
|
/** data 帧合并窗口(毫秒):窗口内的 PTY chunk 合成一帧,显著降帧/降 CPU。 */
|
|
286
396
|
const FLUSH_INTERVAL_MS = 12;
|
|
287
397
|
/** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
|
|
@@ -316,7 +426,8 @@ function osc7Path(body) {
|
|
|
316
426
|
* 命令捕获按「标记之间的文本段」累积——B、输出、D 常在同一 chunk 到达,
|
|
317
427
|
* 先处理段再翻转状态,才能把 B..D 之间的输出完整收进 lastCommand。
|
|
318
428
|
*/
|
|
319
|
-
|
|
429
|
+
/** 导出仅供单测(test/shell-capture.test.ts):B/D 配对与未配对 D 的忽略语义。 */
|
|
430
|
+
export function feedShellIntegration(session, text) {
|
|
320
431
|
const state = session.shellState;
|
|
321
432
|
let data = state.carry + text;
|
|
322
433
|
state.carry = '';
|
|
@@ -324,11 +435,13 @@ function feedShellIntegration(session, text) {
|
|
|
324
435
|
if (lastOpen !== -1) {
|
|
325
436
|
const tail = data.slice(lastOpen);
|
|
326
437
|
if (!/\x07|\x1b\\/.test(tail)) {
|
|
327
|
-
|
|
438
|
+
// 上限按 T 快照量级(512KB):200 行 tmux capture-pane 的 base64 可到
|
|
439
|
+
// 数百 KB,64KB 装不下时会整块照常处理,残余 base64 混进命令缓冲
|
|
440
|
+
if (tail.length <= 512 * 1024) {
|
|
328
441
|
state.carry = tail;
|
|
329
442
|
data = data.slice(0, lastOpen);
|
|
330
443
|
}
|
|
331
|
-
//
|
|
444
|
+
// 超过上限仍不闭合视为垃圾:放弃扣留,整块照常处理
|
|
332
445
|
}
|
|
333
446
|
}
|
|
334
447
|
for (const match of data.matchAll(OSC7_RE)) {
|
|
@@ -526,27 +639,58 @@ function validateSshHosts(input) {
|
|
|
526
639
|
}
|
|
527
640
|
return { hosts: sanitizeSshHosts(input) };
|
|
528
641
|
}
|
|
529
|
-
/**
|
|
642
|
+
/** 单个 host:port 保留的指纹上限(与 known-hosts.ts / ssh.ts 的 TOFU 集合同参数)。 */
|
|
643
|
+
const MAX_FINGERPRINTS_PER_HOST = 8;
|
|
644
|
+
/** 归集一条输入里的指纹(新 fingerprints 数组 + 旧版单指纹字段都收),去重保序。 */
|
|
645
|
+
function collectFingerprints(raw) {
|
|
646
|
+
const out = [];
|
|
647
|
+
const push = (value) => {
|
|
648
|
+
if (typeof value !== 'string')
|
|
649
|
+
return;
|
|
650
|
+
const trimmed = value.trim();
|
|
651
|
+
if (trimmed === '' || trimmed.length > 256 || out.includes(trimmed))
|
|
652
|
+
return;
|
|
653
|
+
out.push(trimmed);
|
|
654
|
+
};
|
|
655
|
+
push(raw.fingerprint); // 旧版单指纹字段:兼容迁移
|
|
656
|
+
if (Array.isArray(raw.fingerprints))
|
|
657
|
+
for (const fp of raw.fingerprints)
|
|
658
|
+
push(fp);
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* 宽松清洗一份 hostKeys 输入;输入不是数组时返回 undefined(表示「未提供,
|
|
663
|
+
* 保持原值」)。0.19.0 起一机多指纹:同 host:port 的多条合并为一条
|
|
664
|
+
* (fingerprints 取并集,上限 8);旧版 `{fingerprint}` 单指纹条目迁移读取。
|
|
665
|
+
*/
|
|
530
666
|
function sanitizeHostKeys(input) {
|
|
531
667
|
if (!Array.isArray(input))
|
|
532
668
|
return undefined;
|
|
533
|
-
const
|
|
669
|
+
const byKey = new Map();
|
|
534
670
|
for (const item of input) {
|
|
535
671
|
if (typeof item !== 'object' || item === null)
|
|
536
672
|
continue;
|
|
537
673
|
const raw = item;
|
|
538
674
|
if (typeof raw.host !== 'string' || raw.host.trim() === '')
|
|
539
675
|
continue;
|
|
540
|
-
|
|
676
|
+
const fps = collectFingerprints(raw);
|
|
677
|
+
if (fps.length === 0)
|
|
541
678
|
continue;
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
679
|
+
const host = raw.host.trim().toLowerCase();
|
|
680
|
+
const portNum = Number(raw.port);
|
|
681
|
+
const port = Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535 ? portNum : 22;
|
|
682
|
+
const key = `${host}:${port}`;
|
|
683
|
+
const existing = byKey.get(key);
|
|
684
|
+
if (existing === undefined) {
|
|
685
|
+
byKey.set(key, { host, port, fingerprints: fps.slice(0, MAX_FINGERPRINTS_PER_HOST) });
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
for (const fp of fps) {
|
|
689
|
+
if (!existing.fingerprints.includes(fp) && existing.fingerprints.length < MAX_FINGERPRINTS_PER_HOST)
|
|
690
|
+
existing.fingerprints.push(fp);
|
|
691
|
+
}
|
|
548
692
|
}
|
|
549
|
-
return
|
|
693
|
+
return [...byKey.values()];
|
|
550
694
|
}
|
|
551
695
|
/** 清洗一份 sftpLimits 输入:每项取 0~上限 的整数(0 = 不限),缺省回落默认值。 */
|
|
552
696
|
function sanitizeSftpLimits(input) {
|
|
@@ -560,26 +704,22 @@ function sanitizeSftpLimits(input) {
|
|
|
560
704
|
maxUploadFiles: num(input?.maxUploadFiles, DEFAULT_SFTP_LIMITS.maxUploadFiles, 100000),
|
|
561
705
|
};
|
|
562
706
|
}
|
|
563
|
-
/** 严格校验一份 hostKeys 输入(HTTP POST 路径);返回错误信息或清洗后的数组。
|
|
707
|
+
/** 严格校验一份 hostKeys 输入(HTTP POST 路径);返回错误信息或清洗后的数组。
|
|
708
|
+
* 同 host:port 允许出现多条(清洗时合并为一条的多指纹集合,见 sanitizeHostKeys)。 */
|
|
564
709
|
function validateHostKeys(input) {
|
|
565
710
|
if (!Array.isArray(input))
|
|
566
711
|
return { error: 'hostKeys 必须是数组' };
|
|
567
|
-
const seen = new Set();
|
|
568
712
|
for (const item of input) {
|
|
569
713
|
if (typeof item !== 'object' || item === null)
|
|
570
714
|
return { error: 'hostKeys 条目必须是对象' };
|
|
571
715
|
const raw = item;
|
|
572
716
|
if (typeof raw.host !== 'string' || raw.host.trim() === '')
|
|
573
717
|
return { error: 'hostKeys.host 必须是非空字符串' };
|
|
574
|
-
if (
|
|
575
|
-
return { error: 'hostKeys
|
|
718
|
+
if (collectFingerprints(raw).length === 0)
|
|
719
|
+
return { error: 'hostKeys 条目需要 fingerprint(s)(至少一个非空指纹)' };
|
|
576
720
|
const port = Number(raw.port ?? 22);
|
|
577
721
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
578
722
|
return { error: 'hostKeys.port 必须是 1~65535 的整数' };
|
|
579
|
-
const key = `${raw.host.trim().toLowerCase()}:${port}`;
|
|
580
|
-
if (seen.has(key))
|
|
581
|
-
return { error: `hostKeys 主机重复: ${key}` };
|
|
582
|
-
seen.add(key);
|
|
583
723
|
}
|
|
584
724
|
return { keys: sanitizeHostKeys(input) };
|
|
585
725
|
}
|
|
@@ -613,12 +753,23 @@ class HostKeyStore {
|
|
|
613
753
|
}
|
|
614
754
|
get(host, port) {
|
|
615
755
|
const key = this.key(host, port);
|
|
616
|
-
|
|
756
|
+
const record = this.live.hostKeys.find((record) => `${record.host}:${record.port}` === key);
|
|
757
|
+
return record !== undefined && record.fingerprints.length > 0 ? record.fingerprints : undefined;
|
|
617
758
|
}
|
|
759
|
+
/** 记录指纹:同 host:port 已有记录则并入集合(一机多把钥匙),否则新建。 */
|
|
618
760
|
record(host, port, fingerprint) {
|
|
619
761
|
const key = this.key(host, port);
|
|
620
|
-
const
|
|
621
|
-
|
|
762
|
+
const existing = this.live.hostKeys.find((record) => `${record.host}:${record.port}` === key);
|
|
763
|
+
if (existing !== undefined) {
|
|
764
|
+
if (!existing.fingerprints.includes(fingerprint)) {
|
|
765
|
+
if (existing.fingerprints.length >= MAX_FINGERPRINTS_PER_HOST)
|
|
766
|
+
existing.fingerprints.shift();
|
|
767
|
+
existing.fingerprints.push(fingerprint);
|
|
768
|
+
}
|
|
769
|
+
this.persist(this.live.hostKeys);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const next = [...this.live.hostKeys, { host: host.trim().toLowerCase(), port, fingerprints: [fingerprint] }];
|
|
622
773
|
this.live.hostKeys = next;
|
|
623
774
|
this.persist(next);
|
|
624
775
|
}
|
|
@@ -655,7 +806,8 @@ function isLoopbackUpgrade(req) {
|
|
|
655
806
|
/* ------------------------------------------------------------------ *
|
|
656
807
|
* 会话管理
|
|
657
808
|
* ------------------------------------------------------------------ */
|
|
658
|
-
|
|
809
|
+
/** 导出仅供单测(test/host-frames.test.ts):上限 / 孤儿回收 / grace 热改的行为护栏。 */
|
|
810
|
+
export class SessionManager {
|
|
659
811
|
sessions = new Map();
|
|
660
812
|
limit;
|
|
661
813
|
/** 回收器销毁孤儿时是否连 tmux 持久会话一起结束(endOnPageClose 策略)。 */
|
|
@@ -749,13 +901,17 @@ class SessionManager {
|
|
|
749
901
|
}
|
|
750
902
|
await forceKill(session.handle);
|
|
751
903
|
}
|
|
752
|
-
/**
|
|
904
|
+
/**
|
|
905
|
+
* 回收孤儿会话(回收器定时调用):超过保活期的回收。graceMs<=0 时立即回收
|
|
906
|
+
* 全部孤儿——孤儿只在「断开瞬间 grace>0」时产生,热改 grace 为 0 不能只管
|
|
907
|
+
* 以后:已存在的孤儿会永久占 PTY 与名额,满额后新标签一直报「会话数已达上限」。
|
|
908
|
+
*/
|
|
753
909
|
async reapOrphans(graceMs) {
|
|
754
|
-
if (graceMs <= 0)
|
|
755
|
-
return;
|
|
756
910
|
const now = Date.now();
|
|
757
911
|
for (const session of [...this.sessions.values()]) {
|
|
758
|
-
if (session.orphanedAt
|
|
912
|
+
if (session.orphanedAt === null)
|
|
913
|
+
continue;
|
|
914
|
+
if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
|
|
759
915
|
void this.destroy(session); // 后台收尾:terminate 最慢可达 ~20s,不阻塞回收器
|
|
760
916
|
}
|
|
761
917
|
}
|
|
@@ -778,13 +934,16 @@ class SessionManager {
|
|
|
778
934
|
/* ------------------------------------------------------------------ *
|
|
779
935
|
* WebSocket 连接处理
|
|
780
936
|
* ------------------------------------------------------------------ */
|
|
781
|
-
|
|
937
|
+
/** 导出仅供单测(test/host-frames.test.ts):帧校验 / 绑定 / 孤儿语义的行为护栏。 */
|
|
938
|
+
export class TtyServer {
|
|
782
939
|
ctx;
|
|
783
940
|
sessions;
|
|
784
941
|
options;
|
|
785
942
|
hostKeyStore;
|
|
786
943
|
trackPersist;
|
|
787
|
-
|
|
944
|
+
// maxPayload:ws 默认 100MiB,恶意/畸形帧会把内存打爆再 JSON.parse 复制一份;
|
|
945
|
+
// 最大的合法帧是 input(128KB 上限,见 input 分支),给 4MiB 余量
|
|
946
|
+
wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 });
|
|
788
947
|
/** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
|
|
789
948
|
pendingTmux = new Map();
|
|
790
949
|
/** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
|
|
@@ -841,22 +1000,22 @@ class TtyServer {
|
|
|
841
1000
|
this.startStats(session);
|
|
842
1001
|
});
|
|
843
1002
|
}
|
|
844
|
-
/** 订阅/退订(tab 可见性驱动):退到 0
|
|
845
|
-
setStatsSub(session,
|
|
1003
|
+
/** 订阅/退订(tab 可见性驱动):退到 0 即停表,任何路径都不会让采集器空转。键 = 绑定键(connId:sid)。 */
|
|
1004
|
+
setStatsSub(session, bindingKey, on) {
|
|
846
1005
|
if (on) {
|
|
847
|
-
session.statsSubs.add(
|
|
1006
|
+
session.statsSubs.add(bindingKey);
|
|
848
1007
|
this.startStats(session);
|
|
849
1008
|
return;
|
|
850
1009
|
}
|
|
851
|
-
session.statsSubs.delete(
|
|
1010
|
+
session.statsSubs.delete(bindingKey);
|
|
852
1011
|
if (session.statsSubs.size === 0)
|
|
853
1012
|
this.stopStats(session);
|
|
854
1013
|
}
|
|
855
1014
|
/** 清掉指向已解绑客户端(WS 关闭 / 标签换 sid 重绑)的订阅,防采集器永不收尾。 */
|
|
856
1015
|
pruneStatsSubs(session) {
|
|
857
|
-
for (const
|
|
858
|
-
if (!session.clients.has(
|
|
859
|
-
this.setStatsSub(session,
|
|
1016
|
+
for (const bindingKey of [...session.statsSubs]) {
|
|
1017
|
+
if (!session.clients.has(bindingKey))
|
|
1018
|
+
this.setStatsSub(session, bindingKey, false);
|
|
860
1019
|
}
|
|
861
1020
|
}
|
|
862
1021
|
/**
|
|
@@ -965,14 +1124,14 @@ class TtyServer {
|
|
|
965
1124
|
stopAllStats() {
|
|
966
1125
|
this.sessions.forEach((session) => this.stopStats(session));
|
|
967
1126
|
}
|
|
968
|
-
/**
|
|
1127
|
+
/** 帧只发给订阅了该会话的客户端(绑定键寻址,回帧带各连接自己的 sid,跨窗口共享也成立)。 */
|
|
969
1128
|
sendStats(session, frame) {
|
|
970
|
-
for (const
|
|
971
|
-
const
|
|
972
|
-
if (
|
|
1129
|
+
for (const bindingKey of session.statsSubs) {
|
|
1130
|
+
const client = session.clients.get(bindingKey);
|
|
1131
|
+
if (client === undefined)
|
|
973
1132
|
continue;
|
|
974
1133
|
try {
|
|
975
|
-
send(ws, { t: 'stats', sid:
|
|
1134
|
+
send(client.ws, { t: 'stats', sid: client.sid, stats: frame });
|
|
976
1135
|
}
|
|
977
1136
|
catch {
|
|
978
1137
|
// readyState 检查与 send 之间对端可能刚关:不 try 的话异常会落在 ws 事件
|
|
@@ -995,6 +1154,8 @@ class TtyServer {
|
|
|
995
1154
|
});
|
|
996
1155
|
}
|
|
997
1156
|
onConnection(ws) {
|
|
1157
|
+
/** 本连接的上下文:id 参与跨连接绑定键(D07);open 供在途异步路径判「连接已死」(D06)。 */
|
|
1158
|
+
const conn = { id: randomUUID(), open: true };
|
|
998
1159
|
/** 本连接上的会话表(sid → session);单连接多会话(标签页)。 */
|
|
999
1160
|
const local = new Map();
|
|
1000
1161
|
const cleanupAll = async () => {
|
|
@@ -1003,8 +1164,8 @@ class TtyServer {
|
|
|
1003
1164
|
await Promise.all(all.map(async ([clientSid, session]) => {
|
|
1004
1165
|
if (session.closed)
|
|
1005
1166
|
return;
|
|
1006
|
-
//
|
|
1007
|
-
session.clients.delete(clientSid);
|
|
1167
|
+
// 解绑本连接的客户端(键 = connId:sid);其余窗口仍绑定着(跨连接共享)时会话继续在线
|
|
1168
|
+
session.clients.delete(conn.id + ':' + clientSid);
|
|
1008
1169
|
// WS 关闭/转孤儿:该端的 stats 订阅一并解绑(退到 0 就停表关 channel)
|
|
1009
1170
|
this.pruneStatsSubs(session);
|
|
1010
1171
|
if (session.clients.size > 0) {
|
|
@@ -1030,9 +1191,10 @@ class TtyServer {
|
|
|
1030
1191
|
catch {
|
|
1031
1192
|
return;
|
|
1032
1193
|
}
|
|
1033
|
-
void this.handleMessage(ws, msg, local, cleanupAll);
|
|
1194
|
+
void this.handleMessage(ws, msg, local, cleanupAll, conn);
|
|
1034
1195
|
});
|
|
1035
1196
|
ws.on('close', () => {
|
|
1197
|
+
conn.open = false;
|
|
1036
1198
|
void cleanupAll();
|
|
1037
1199
|
});
|
|
1038
1200
|
ws.on('error', (error) => {
|
|
@@ -1063,8 +1225,8 @@ class TtyServer {
|
|
|
1063
1225
|
return undefined;
|
|
1064
1226
|
}
|
|
1065
1227
|
/** 把一个客户端连接重绑定到既有会话(跨窗口共享 / 并发恢复收敛共用)。 */
|
|
1066
|
-
rebindClient(session, sid, ws, local) {
|
|
1067
|
-
session.clients.set(sid, ws);
|
|
1228
|
+
rebindClient(session, sid, ws, local, connId) {
|
|
1229
|
+
session.clients.set(connId + ':' + sid, { ws, sid });
|
|
1068
1230
|
session.orphanedAt = null;
|
|
1069
1231
|
this.pruneStatsSubs(session);
|
|
1070
1232
|
if (session.paused) {
|
|
@@ -1144,8 +1306,11 @@ class TtyServer {
|
|
|
1144
1306
|
return null;
|
|
1145
1307
|
}
|
|
1146
1308
|
}
|
|
1147
|
-
async handleMessage(ws, msg, local, cleanupAll) {
|
|
1309
|
+
async handleMessage(ws, msg, local, cleanupAll, conn) {
|
|
1148
1310
|
try {
|
|
1311
|
+
// 连接已关闭(close 后仍有在途帧排队):任何绑定/创建都不再落到死连接上
|
|
1312
|
+
if (!conn.open)
|
|
1313
|
+
return;
|
|
1149
1314
|
if (msg.t === 'spawn') {
|
|
1150
1315
|
const sid = typeof msg.sid === 'string' && msg.sid !== '' ? msg.sid : randomUUID();
|
|
1151
1316
|
if (!SID_RE.test(sid)) {
|
|
@@ -1176,7 +1341,9 @@ class TtyServer {
|
|
|
1176
1341
|
if (persistName !== null) {
|
|
1177
1342
|
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
1178
1343
|
if (existing !== null) {
|
|
1179
|
-
|
|
1344
|
+
if (!conn.open)
|
|
1345
|
+
return; // 等待在途创建期间连接断了:不往死连接上绑
|
|
1346
|
+
this.rebindClient(existing, sid, ws, local, conn.id);
|
|
1180
1347
|
return;
|
|
1181
1348
|
}
|
|
1182
1349
|
}
|
|
@@ -1212,8 +1379,8 @@ class TtyServer {
|
|
|
1212
1379
|
const create = (async () => {
|
|
1213
1380
|
const handle = wrapLocalPty(await subprocess.spawnTerminal({
|
|
1214
1381
|
argv: spawnPlan.argv,
|
|
1215
|
-
rows:
|
|
1216
|
-
cols:
|
|
1382
|
+
rows: clampInt(msg.rows, 24, 2, 200),
|
|
1383
|
+
cols: clampInt(msg.cols, 80, 2, 500),
|
|
1217
1384
|
cwd,
|
|
1218
1385
|
env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
|
|
1219
1386
|
graceMs: 5000,
|
|
@@ -1225,7 +1392,7 @@ class TtyServer {
|
|
|
1225
1392
|
const next = {
|
|
1226
1393
|
id: sid,
|
|
1227
1394
|
handle,
|
|
1228
|
-
clients: new Map([[sid, ws]]),
|
|
1395
|
+
clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
|
|
1229
1396
|
closed: false,
|
|
1230
1397
|
paused: false,
|
|
1231
1398
|
cwd,
|
|
@@ -1233,9 +1400,10 @@ class TtyServer {
|
|
|
1233
1400
|
target: '',
|
|
1234
1401
|
startedAt: Date.now(),
|
|
1235
1402
|
lastOutputAt: Date.now(),
|
|
1403
|
+
lastInputAt: Date.now(),
|
|
1236
1404
|
buffer: '',
|
|
1237
1405
|
decoder: new StringDecoder('utf8'),
|
|
1238
|
-
screen: this.createScreen(
|
|
1406
|
+
screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
|
|
1239
1407
|
orphanedAt: null,
|
|
1240
1408
|
shellState: createShellState(),
|
|
1241
1409
|
pendingOutput: '',
|
|
@@ -1247,6 +1415,14 @@ class TtyServer {
|
|
|
1247
1415
|
};
|
|
1248
1416
|
local.set(sid, next);
|
|
1249
1417
|
this.sessions.add(next);
|
|
1418
|
+
// spawn 在途连接断开(0.19.0):cleanupAll 已跑过、扫不到此刻才入表的
|
|
1419
|
+
// 会话——转孤儿(等重连 attach 或回收器清理)。不处理的话会话绑死已
|
|
1420
|
+
// 关闭的 ws 且 orphanedAt 永为 null:回收器永不扫到,PTY 与名额永久泄漏,
|
|
1421
|
+
// 重连 attach 还被拒并谎报「会话已连接到其它窗口」。
|
|
1422
|
+
if (!conn.open || ws.readyState !== WebSocket.OPEN) {
|
|
1423
|
+
next.clients.clear();
|
|
1424
|
+
next.orphanedAt = Date.now();
|
|
1425
|
+
}
|
|
1250
1426
|
return next;
|
|
1251
1427
|
})();
|
|
1252
1428
|
if (tmuxName !== null) {
|
|
@@ -1261,7 +1437,7 @@ class TtyServer {
|
|
|
1261
1437
|
send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(tmuxName !== null ? { persist: true } : {}) });
|
|
1262
1438
|
if (wantsPersist && tmuxName === null) {
|
|
1263
1439
|
const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
|
|
1264
|
-
next.buffer = (next.buffer + notice
|
|
1440
|
+
next.buffer = tailFromSafeBoundary(next.buffer + notice, BUFFER_CAP);
|
|
1265
1441
|
send(ws, { t: 'data', sid, d: notice });
|
|
1266
1442
|
}
|
|
1267
1443
|
this.attachOutput(next);
|
|
@@ -1291,7 +1467,9 @@ class TtyServer {
|
|
|
1291
1467
|
if (persistName !== null) {
|
|
1292
1468
|
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
1293
1469
|
if (existing !== null && existing.kind === 'ssh' && !existing.closed) {
|
|
1294
|
-
|
|
1470
|
+
if (!conn.open)
|
|
1471
|
+
return; // 等待在途创建期间连接断了:不往死连接上绑
|
|
1472
|
+
this.rebindClient(existing, sid, ws, local, conn.id);
|
|
1295
1473
|
return;
|
|
1296
1474
|
}
|
|
1297
1475
|
}
|
|
@@ -1318,8 +1496,8 @@ class TtyServer {
|
|
|
1318
1496
|
try {
|
|
1319
1497
|
handle = await spawnSsh(spec, {
|
|
1320
1498
|
term: this.options.term,
|
|
1321
|
-
cols:
|
|
1322
|
-
rows:
|
|
1499
|
+
cols: clampInt(msg.cols, 80, 2, 500),
|
|
1500
|
+
rows: clampInt(msg.rows, 24, 2, 200),
|
|
1323
1501
|
logger: { info: (m) => this.ctx.logger.info(m), warn: (m) => this.ctx.logger.warn(m) },
|
|
1324
1502
|
hostKeyStore: this.hostKeyStore,
|
|
1325
1503
|
...(command !== null ? { command } : {}),
|
|
@@ -1334,7 +1512,7 @@ class TtyServer {
|
|
|
1334
1512
|
const next = {
|
|
1335
1513
|
id: sid,
|
|
1336
1514
|
handle,
|
|
1337
|
-
clients: new Map([[sid, ws]]),
|
|
1515
|
+
clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
|
|
1338
1516
|
closed: false,
|
|
1339
1517
|
paused: false,
|
|
1340
1518
|
cwd: '',
|
|
@@ -1342,9 +1520,10 @@ class TtyServer {
|
|
|
1342
1520
|
target,
|
|
1343
1521
|
startedAt: Date.now(),
|
|
1344
1522
|
lastOutputAt: Date.now(),
|
|
1523
|
+
lastInputAt: Date.now(),
|
|
1345
1524
|
buffer: '',
|
|
1346
1525
|
decoder: new StringDecoder('utf8'),
|
|
1347
|
-
screen: this.createScreen(
|
|
1526
|
+
screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
|
|
1348
1527
|
orphanedAt: null,
|
|
1349
1528
|
shellState: createShellState(),
|
|
1350
1529
|
pendingOutput: '',
|
|
@@ -1356,12 +1535,18 @@ class TtyServer {
|
|
|
1356
1535
|
};
|
|
1357
1536
|
local.set(sid, next);
|
|
1358
1537
|
this.sessions.add(next);
|
|
1538
|
+
// spawn 在途连接断开(0.19.0):转孤儿,理由与本地分支相同;SSH 连接
|
|
1539
|
+
// (含远程 tmux 持久会话)保持存活等重连 attach,到点由回收器收尾
|
|
1540
|
+
if (!conn.open || ws.readyState !== WebSocket.OPEN) {
|
|
1541
|
+
next.clients.clear();
|
|
1542
|
+
next.orphanedAt = Date.now();
|
|
1543
|
+
}
|
|
1359
1544
|
send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target, ...(tmuxName !== null ? { persist: true } : {}) });
|
|
1360
1545
|
if (tmuxName !== null)
|
|
1361
1546
|
this.trackPersist(tmuxName, true); // 留存:远程 tmux 本机清单看不到
|
|
1362
1547
|
if (handle.startupNotice !== undefined) {
|
|
1363
1548
|
const notice = `\x1b[2m[dsh-tty] ${handle.startupNotice}\x1b[0m\r\n`;
|
|
1364
|
-
next.buffer = (next.buffer + notice
|
|
1549
|
+
next.buffer = tailFromSafeBoundary(next.buffer + notice, BUFFER_CAP);
|
|
1365
1550
|
send(ws, { t: 'data', sid, d: notice });
|
|
1366
1551
|
}
|
|
1367
1552
|
this.attachOutput(next);
|
|
@@ -1384,12 +1569,21 @@ class TtyServer {
|
|
|
1384
1569
|
}
|
|
1385
1570
|
}
|
|
1386
1571
|
else if (msg.t === 'input') {
|
|
1572
|
+
const data = typeof msg.d === 'string' ? msg.d : '';
|
|
1573
|
+
// 长度上限(对照 sanitizeCommand 的 2000):粘贴大文本是正常用例,
|
|
1574
|
+
// >128KB 的「按键输入」只能是畸形/滥用——拒绝而不是让它进 PTY
|
|
1575
|
+
if (data.length > 128 * 1024) {
|
|
1576
|
+
send(ws, { t: 'error', m: 'input 帧过大(>128K 字符),已拒绝' });
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1387
1579
|
const resolved = this.resolveSid(ws, msg, local);
|
|
1388
1580
|
if (resolved === undefined || 'unknown' in resolved)
|
|
1389
1581
|
return;
|
|
1390
1582
|
const session = local.get(resolved.sid);
|
|
1391
|
-
if (session !== undefined && !session.closed)
|
|
1392
|
-
|
|
1583
|
+
if (session !== undefined && !session.closed) {
|
|
1584
|
+
session.lastInputAt = Date.now();
|
|
1585
|
+
await session.handle.write(data);
|
|
1586
|
+
}
|
|
1393
1587
|
}
|
|
1394
1588
|
else if (msg.t === 'resize') {
|
|
1395
1589
|
const resolved = this.resolveSid(ws, msg, local);
|
|
@@ -1397,8 +1591,8 @@ class TtyServer {
|
|
|
1397
1591
|
return;
|
|
1398
1592
|
const session = local.get(resolved.sid);
|
|
1399
1593
|
if (session !== undefined) {
|
|
1400
|
-
const cols =
|
|
1401
|
-
const rows =
|
|
1594
|
+
const cols = clampInt(msg.cols, 80, 2, 500);
|
|
1595
|
+
const rows = clampInt(msg.rows, 24, 2, 200);
|
|
1402
1596
|
session.handle.resize(cols, rows);
|
|
1403
1597
|
try {
|
|
1404
1598
|
session.screen?.resize(cols, rows);
|
|
@@ -1425,10 +1619,16 @@ class TtyServer {
|
|
|
1425
1619
|
if (resolved === undefined)
|
|
1426
1620
|
return;
|
|
1427
1621
|
if ('unknown' in resolved) {
|
|
1428
|
-
// 本连接没有该 sid
|
|
1429
|
-
//
|
|
1622
|
+
// 本连接没有该 sid:若是孤儿会话(前连接已断、无人绑定)也允许 kill,
|
|
1623
|
+
// 避免「关闭面板杀不掉孤儿」泄漏到保活期结束。仍被任何连接绑定的会话
|
|
1624
|
+
// 绝不在此路径杀——任意 loopback 帧(含失效旧 sid)凭 sid 就能 SIGKILL
|
|
1625
|
+
// 别的窗口正在跑的构建,那是提权级事故(0.19.0 加前提)。
|
|
1430
1626
|
const orphan = this.sessions.get(String(msg.sid ?? ''));
|
|
1431
1627
|
if (orphan !== undefined && !orphan.closed) {
|
|
1628
|
+
if (orphan.clients.size > 0) {
|
|
1629
|
+
send(ws, { t: 'error', sid: String(msg.sid ?? ''), m: '该会话正连接在其它窗口:请到那个窗口关闭标签,或先在本窗口 attach' });
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1432
1632
|
this.flushPendingOutput(orphan);
|
|
1433
1633
|
this.killSessionNow(orphan);
|
|
1434
1634
|
}
|
|
@@ -1465,8 +1665,9 @@ class TtyServer {
|
|
|
1465
1665
|
send(ws, { t: 'error', sid: raw, m: '会话已连接到其它窗口' });
|
|
1466
1666
|
return;
|
|
1467
1667
|
}
|
|
1468
|
-
//
|
|
1469
|
-
|
|
1668
|
+
// 重新绑定到本连接(键 = connId:sid,连接侧 sid 供帧寻址):解孤儿态,
|
|
1669
|
+
// 恢复被背压暂停的输出流
|
|
1670
|
+
session.clients.set(conn.id + ':' + raw, { ws, sid: raw });
|
|
1470
1671
|
session.orphanedAt = null;
|
|
1471
1672
|
local.set(raw, session);
|
|
1472
1673
|
if (session.paused) {
|
|
@@ -1491,7 +1692,7 @@ class TtyServer {
|
|
|
1491
1692
|
}
|
|
1492
1693
|
}
|
|
1493
1694
|
else if (msg.t === 'statsOn' || msg.t === 'statsOff') {
|
|
1494
|
-
this.handleStatsFrame(ws, msg, local);
|
|
1695
|
+
this.handleStatsFrame(ws, msg, local, conn.id);
|
|
1495
1696
|
}
|
|
1496
1697
|
}
|
|
1497
1698
|
catch (error) {
|
|
@@ -1500,16 +1701,17 @@ class TtyServer {
|
|
|
1500
1701
|
}
|
|
1501
1702
|
/**
|
|
1502
1703
|
* 服务器状态条订阅(0.17.0):按「标签可见性」驱动——只有可见标签才发
|
|
1503
|
-
* statsOn。未知 sid
|
|
1704
|
+
* statsOn。未知 sid(客户端竞态)静默忽略,不回错误帧。订阅键与客户端
|
|
1705
|
+
* 绑定键同构(connId:sid),跨连接共享同一 sid 时互不踩。
|
|
1504
1706
|
*/
|
|
1505
|
-
handleStatsFrame(ws, msg, local) {
|
|
1707
|
+
handleStatsFrame(ws, msg, local, connId) {
|
|
1506
1708
|
const resolved = this.resolveSid(ws, msg, local);
|
|
1507
1709
|
if (resolved === undefined || 'unknown' in resolved)
|
|
1508
1710
|
return;
|
|
1509
1711
|
const session = local.get(resolved.sid);
|
|
1510
1712
|
if (session === undefined || session.closed)
|
|
1511
1713
|
return;
|
|
1512
|
-
this.setStatsSub(session, resolved.sid, msg.t === 'statsOn');
|
|
1714
|
+
this.setStatsSub(session, connId + ':' + resolved.sid, msg.t === 'statsOn');
|
|
1513
1715
|
}
|
|
1514
1716
|
/** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
|
|
1515
1717
|
watchDone(session, local) {
|
|
@@ -1534,8 +1736,8 @@ class TtyServer {
|
|
|
1534
1736
|
}
|
|
1535
1737
|
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
1536
1738
|
// exit 广播到所有绑定连接(跨窗口共享),各客户端按自己的 sid 收址
|
|
1537
|
-
for (const
|
|
1538
|
-
send(
|
|
1739
|
+
for (const client of session.clients.values()) {
|
|
1740
|
+
send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
|
|
1539
1741
|
}
|
|
1540
1742
|
session.clients.clear();
|
|
1541
1743
|
}).catch(() => { });
|
|
@@ -1550,14 +1752,14 @@ class TtyServer {
|
|
|
1550
1752
|
return;
|
|
1551
1753
|
session.pendingOutput = '';
|
|
1552
1754
|
let maxBuffered = 0;
|
|
1553
|
-
for (const
|
|
1755
|
+
for (const client of session.clients.values()) {
|
|
1554
1756
|
try {
|
|
1555
|
-
|
|
1556
|
-
if (session.paused &&
|
|
1757
|
+
client.ws.send(JSON.stringify({ t: 'data', sid: client.sid, d: pending }), () => {
|
|
1758
|
+
if (session.paused && client.ws.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
|
|
1557
1759
|
output.resume();
|
|
1558
1760
|
}
|
|
1559
1761
|
});
|
|
1560
|
-
maxBuffered = Math.max(maxBuffered,
|
|
1762
|
+
maxBuffered = Math.max(maxBuffered, client.ws.bufferedAmount);
|
|
1561
1763
|
}
|
|
1562
1764
|
catch {
|
|
1563
1765
|
/* 客户端已断开 */
|
|
@@ -1574,7 +1776,7 @@ class TtyServer {
|
|
|
1574
1776
|
// StringDecoder 兜跨 chunk 多字节序列,再喂 shell 集成解析与虚拟屏
|
|
1575
1777
|
const text = session.decoder.write(chunk);
|
|
1576
1778
|
session.lastOutputAt = Date.now();
|
|
1577
|
-
session.buffer = (session.buffer + text
|
|
1779
|
+
session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
|
|
1578
1780
|
feedShellIntegration(session, text);
|
|
1579
1781
|
try {
|
|
1580
1782
|
session.screen?.write(text);
|
|
@@ -1611,8 +1813,8 @@ class TtyServer {
|
|
|
1611
1813
|
session.pendingOutput = '';
|
|
1612
1814
|
if (session.closed || session.clients.size === 0 || pending === '')
|
|
1613
1815
|
return;
|
|
1614
|
-
for (const
|
|
1615
|
-
send(
|
|
1816
|
+
for (const client of session.clients.values()) {
|
|
1817
|
+
send(client.ws, { t: 'data', sid: client.sid, d: pending });
|
|
1616
1818
|
}
|
|
1617
1819
|
}
|
|
1618
1820
|
close() {
|
|
@@ -1639,7 +1841,7 @@ function readManagedEnvKeys() {
|
|
|
1639
1841
|
// 托管区块标记(与 env 插件 MARK_START/MARK_END 逐字符一致)
|
|
1640
1842
|
const MARK_START = '# --- dsh-env-manager managed (auto-generated; do not edit) ---';
|
|
1641
1843
|
const MARK_END = '# --- end dsh-env-manager managed ---';
|
|
1642
|
-
const dshHome =
|
|
1844
|
+
const dshHome = resolveDshHome();
|
|
1643
1845
|
const file = process.env.DSH_ENV_FILE?.trim() || join(dshHome, 'env.yml');
|
|
1644
1846
|
try {
|
|
1645
1847
|
const lines = readFileSync(file, 'utf8').split('\n');
|
|
@@ -1683,8 +1885,25 @@ function readManagedEnvKeys() {
|
|
|
1683
1885
|
* 路径解析与本地 provider 的默认一致($DSH_HOME 优先,空串视为未设,再退 ~/.dsh)。边界:
|
|
1684
1886
|
* 若有人给 provider 配了自定义 `path` / `dshHome`,这里看不到那些引用(字段仍可手输名字)。
|
|
1685
1887
|
*/
|
|
1686
|
-
|
|
1687
|
-
|
|
1888
|
+
/**
|
|
1889
|
+
* `/api/dsh-tty/credential-refs` 的路由逻辑(导出仅供单测
|
|
1890
|
+
* test/credential-refs.test.ts):loopback 闸门 + 方法闸门 + 只回引用名的载荷。
|
|
1891
|
+
* 值在任何分支都不进响应——SSH 对话框的选择器只需要「我存过哪些名字」。
|
|
1892
|
+
*/
|
|
1893
|
+
export async function handleCredentialRefsRoute(req, res) {
|
|
1894
|
+
if (!isLoopbackHttp(req)) {
|
|
1895
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (req.method !== 'GET') {
|
|
1899
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
writeJson(res, 200, { ok: true, names: readCredentialRefNames() });
|
|
1903
|
+
}
|
|
1904
|
+
/** 导出仅供单测(test/credential-refs.test.ts):只验键名解析,不取值。 */
|
|
1905
|
+
export function readCredentialRefNames() {
|
|
1906
|
+
const dshHome = resolveDshHome();
|
|
1688
1907
|
const file = join(dshHome, '.credentials.yaml');
|
|
1689
1908
|
try {
|
|
1690
1909
|
const names = [];
|
|
@@ -1895,6 +2114,14 @@ async function listLocalDir(rawPath) {
|
|
|
1895
2114
|
}
|
|
1896
2115
|
entries.push({ name: dirent.name, isDir, isFile, isSymlink: dirent.isSymbolicLink(), size, mtime });
|
|
1897
2116
|
}
|
|
2117
|
+
// 与远程栏同一套排序(0.19.0,见 SftpManager.list):目录优先 + localeCompare
|
|
2118
|
+
// ——此前本机栏完全不排,同一面板左右两栏规则不一致,定位文件靠肉眼扫
|
|
2119
|
+
entries.sort((a, b) => {
|
|
2120
|
+
const kindDiff = (a.isDir ? 0 : 1) - (b.isDir ? 0 : 1);
|
|
2121
|
+
if (kindDiff !== 0)
|
|
2122
|
+
return kindDiff;
|
|
2123
|
+
return a.name.localeCompare(b.name);
|
|
2124
|
+
});
|
|
1898
2125
|
return { path: resolved, entries };
|
|
1899
2126
|
}
|
|
1900
2127
|
const plugin = definePlugin({
|
|
@@ -2273,17 +2500,7 @@ const plugin = definePlugin({
|
|
|
2273
2500
|
registerGated({
|
|
2274
2501
|
kind: 'exact',
|
|
2275
2502
|
path: '/api/dsh-tty/credential-refs',
|
|
2276
|
-
handler:
|
|
2277
|
-
if (!isLoopbackHttp(req)) {
|
|
2278
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
2279
|
-
return;
|
|
2280
|
-
}
|
|
2281
|
-
if (req.method !== 'GET') {
|
|
2282
|
-
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
writeJson(res, 200, { ok: true, names: readCredentialRefNames() });
|
|
2286
|
-
},
|
|
2503
|
+
handler: handleCredentialRefsRoute,
|
|
2287
2504
|
});
|
|
2288
2505
|
// known_hosts 指纹导入候选(TOFU 预填充):hashed 条目用连接簿主机名还原
|
|
2289
2506
|
registerGated({
|
|
@@ -2301,7 +2518,9 @@ const plugin = definePlugin({
|
|
|
2301
2518
|
try {
|
|
2302
2519
|
const text = readFileSync(expandHome('~/.ssh/known_hosts'), 'utf8');
|
|
2303
2520
|
const candidates = live.sshHosts.flatMap((entry) => [entry.host, `[${entry.host}]:${entry.port}`]);
|
|
2304
|
-
|
|
2521
|
+
// truncated(0.19.0):超 500 条截断时明确告知,用户不再蒙在鼓里
|
|
2522
|
+
const parsedKnownHosts = parseKnownHostsDetailed(text, candidates);
|
|
2523
|
+
writeJson(res, 200, { ok: true, entries: parsedKnownHosts.entries, truncated: parsedKnownHosts.truncated });
|
|
2305
2524
|
}
|
|
2306
2525
|
catch (error) {
|
|
2307
2526
|
writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/known_hosts: ' + (error instanceof Error ? error.message : String(error)) });
|
|
@@ -2489,7 +2708,9 @@ const plugin = definePlugin({
|
|
|
2489
2708
|
return;
|
|
2490
2709
|
}
|
|
2491
2710
|
catch (error) {
|
|
2492
|
-
|
|
2711
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2712
|
+
// 下载路径不存在 → 404(0.19.0:openDownload 现在会明确抛错而不是 200+断流)
|
|
2713
|
+
writeJson(res, message.includes('远程路径不存在') ? 404 : 500, { error: message });
|
|
2493
2714
|
return;
|
|
2494
2715
|
}
|
|
2495
2716
|
}
|
|
@@ -2524,28 +2745,35 @@ const plugin = definePlugin({
|
|
|
2524
2745
|
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
2525
2746
|
return;
|
|
2526
2747
|
}
|
|
2527
|
-
// 取消上传(0.12.0
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
2748
|
+
// 取消上传(0.12.0):客户端中断 → 打断 pipeline 并清理远端半截产物。
|
|
2749
|
+
// 判据用「close 时请求未完整收到」——req 'aborted' 事件已废弃。
|
|
2750
|
+
// 0.19.0 起覆盖写走临时分片 + rename(见 openUpload):取消清理删的
|
|
2751
|
+
// 是分片(writePath),目标原文件不受影响;追加写内容已进目标尾部,
|
|
2752
|
+
// 没有可回滚的半截,不删整个文件。
|
|
2530
2753
|
const controller = new AbortController();
|
|
2531
2754
|
let abortedByClient = false;
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2755
|
+
let writePath = target;
|
|
2756
|
+
req.on('close', () => {
|
|
2757
|
+
if (req.complete !== true && res.writableEnded !== true && !abortedByClient) {
|
|
2758
|
+
abortedByClient = true;
|
|
2759
|
+
controller.abort();
|
|
2760
|
+
}
|
|
2535
2761
|
});
|
|
2536
2762
|
try {
|
|
2537
|
-
const
|
|
2763
|
+
const upload = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
|
|
2764
|
+
writePath = upload.writePath;
|
|
2538
2765
|
let bytes = 0;
|
|
2539
2766
|
req.on('data', (chunk) => {
|
|
2540
2767
|
bytes += chunk.length;
|
|
2541
2768
|
});
|
|
2542
|
-
await pipeline(req, stream, { signal: controller.signal });
|
|
2543
|
-
await done;
|
|
2769
|
+
await pipeline(req, upload.stream, { signal: controller.signal });
|
|
2770
|
+
await upload.done;
|
|
2544
2771
|
writeJson(res, 200, { ok: true, bytes });
|
|
2545
2772
|
}
|
|
2546
2773
|
catch (error) {
|
|
2547
2774
|
if (abortedByClient) {
|
|
2548
|
-
|
|
2775
|
+
if (meta.append !== true)
|
|
2776
|
+
void sftpManager.deleteRemoteQuiet(parsed.spec, writePath);
|
|
2549
2777
|
return;
|
|
2550
2778
|
}
|
|
2551
2779
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2816,7 +3044,7 @@ const plugin = definePlugin({
|
|
|
2816
3044
|
})));
|
|
2817
3045
|
activeDisposers.push(tools.register(defineTool({
|
|
2818
3046
|
name: 'tty_capture',
|
|
2819
|
-
description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell
|
|
3047
|
+
description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。',
|
|
2820
3048
|
parameters: {
|
|
2821
3049
|
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
2822
3050
|
lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
|
|
@@ -2832,10 +3060,14 @@ const plugin = definePlugin({
|
|
|
2832
3060
|
tail: { type: 'string', required: true },
|
|
2833
3061
|
source: { type: 'string' },
|
|
2834
3062
|
exitCode: { type: 'number' },
|
|
3063
|
+
inProgress: { type: 'boolean' },
|
|
2835
3064
|
},
|
|
2836
3065
|
},
|
|
2837
3066
|
render: (_args, value) => {
|
|
2838
3067
|
const v = value;
|
|
3068
|
+
if (v.inProgress === true) {
|
|
3069
|
+
return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 有命令正在执行(尚未收到完成标记),当前取不到「上一条已完成命令」的结果:稍后重试,或改用 tty_expect 等待特定输出。` }];
|
|
3070
|
+
}
|
|
2839
3071
|
const head = v.source === 'last'
|
|
2840
3072
|
? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
|
|
2841
3073
|
: `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
|
|
@@ -2851,11 +3083,21 @@ const plugin = definePlugin({
|
|
|
2851
3083
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
2852
3084
|
const useRaw = input.raw === true;
|
|
2853
3085
|
if (input.last === true) {
|
|
2854
|
-
const
|
|
3086
|
+
const state = session.shellState;
|
|
3087
|
+
const last = state.lastCommand;
|
|
3088
|
+
// 在途判定(0.19.0):①命令的 D 标记未到(inCommand);②D 到了但
|
|
3089
|
+
// 之后又有 PTY 输入(新命令刚发、B 标记还在路上)。lastCommand
|
|
3090
|
+
// 此时是上一条的旧结果——形状正常却答非所问,必须显式标出来,
|
|
3091
|
+
// 否则 agent 拿旧结果当本次结果用(静默错数据)。
|
|
3092
|
+
if (state.inCommand || (last !== null && last.endedAt < session.lastInputAt)) {
|
|
3093
|
+
return { sid: input.sid, source: 'last', inProgress: true, tail: '' };
|
|
3094
|
+
}
|
|
2855
3095
|
if (last === null) {
|
|
2856
3096
|
throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
|
|
2857
3097
|
}
|
|
2858
|
-
|
|
3098
|
+
// 保尾截断:last.output 本身已是环形保尾(COMMAND_CAP),这里再
|
|
3099
|
+
// 收一刀也保尾——最近输出才是 agent 要的
|
|
3100
|
+
return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
|
|
2859
3101
|
}
|
|
2860
3102
|
const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
|
|
2861
3103
|
const rawTail = tailLines(session, lines);
|
|
@@ -2901,7 +3143,8 @@ const plugin = definePlugin({
|
|
|
2901
3143
|
}
|
|
2902
3144
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
2903
3145
|
lines.pop();
|
|
2904
|
-
|
|
3146
|
+
// 保尾截断:屏幕末尾(提示符行)才是有效区,丢头部不丢尾部
|
|
3147
|
+
return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(-32 * 1024) };
|
|
2905
3148
|
},
|
|
2906
3149
|
})));
|
|
2907
3150
|
activeDisposers.push(tools.register(defineTool({
|
|
@@ -2949,9 +3192,17 @@ const plugin = definePlugin({
|
|
|
2949
3192
|
}
|
|
2950
3193
|
const timeoutSec = Math.max(1, Math.min(600, typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) && input.timeoutSec >= 1 ? input.timeoutSec : 30));
|
|
2951
3194
|
const timeoutMs = timeoutSec * 1000;
|
|
3195
|
+
// 并发上限:每次 expect 挂一个常驻 data 监听器(直到 settle),
|
|
3196
|
+
// 无上限时批量调用会堆出 MaxListenersExceededWarning + 白耗 CPU
|
|
3197
|
+
const inflight = expectCounts.get(session) ?? 0;
|
|
3198
|
+
if (inflight >= MAX_EXPECT_PER_SESSION) {
|
|
3199
|
+
throw new Error(`该会话已有 ${String(inflight)} 个在途 tty_expect(上限 ${String(MAX_EXPECT_PER_SESSION)}):等其中一个返回再发起新的`);
|
|
3200
|
+
}
|
|
3201
|
+
expectCounts.set(session, inflight + 1);
|
|
2952
3202
|
return await new Promise((resolve) => {
|
|
2953
3203
|
const startedAt = Date.now();
|
|
2954
3204
|
const startedInCommand = session.shellState.inCommand;
|
|
3205
|
+
// 尾部窗口:匹配只看最近 16KB,acc 全量囤积对刷屏会话可涨到数百 MB
|
|
2955
3206
|
let acc = '';
|
|
2956
3207
|
let settled = false;
|
|
2957
3208
|
const decoder = new StringDecoder('utf8');
|
|
@@ -2962,10 +3213,11 @@ const plugin = definePlugin({
|
|
|
2962
3213
|
settled = true;
|
|
2963
3214
|
clearTimeout(timer);
|
|
2964
3215
|
output.off('data', onData);
|
|
3216
|
+
expectCounts.set(session, Math.max(0, (expectCounts.get(session) ?? 1) - 1));
|
|
2965
3217
|
resolve(result);
|
|
2966
3218
|
};
|
|
2967
3219
|
const onData = (chunk) => {
|
|
2968
|
-
acc
|
|
3220
|
+
acc = (acc + decoder.write(chunk)).slice(-64 * 1024);
|
|
2969
3221
|
const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
|
|
2970
3222
|
if (re.test(hay)) {
|
|
2971
3223
|
finish({ matched: true, timedOut: false, text: cleanAnsiTail(hay.slice(-6 * 1024)) });
|
|
@@ -3018,6 +3270,7 @@ const plugin = definePlugin({
|
|
|
3018
3270
|
const session = sessions.get(input.sid);
|
|
3019
3271
|
if (session === undefined || session.closed)
|
|
3020
3272
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
3273
|
+
session.lastInputAt = Date.now();
|
|
3021
3274
|
await session.handle.write(input.data);
|
|
3022
3275
|
return { ok: true, sent: input.data.length };
|
|
3023
3276
|
},
|
|
@@ -3064,7 +3317,21 @@ const plugin = definePlugin({
|
|
|
3064
3317
|
},
|
|
3065
3318
|
},
|
|
3066
3319
|
async execute() {
|
|
3067
|
-
|
|
3320
|
+
// 显式挑字段(0.19.0):list() 还带 enabled / lastForwardError,
|
|
3321
|
+
// 整包展开会突破 schema 的 additionalProperties:false——PTC 生成的
|
|
3322
|
+
// TS 类型会漏字段
|
|
3323
|
+
return {
|
|
3324
|
+
tunnels: tunnelManager.list().map((t) => ({
|
|
3325
|
+
name: t.name,
|
|
3326
|
+
bookName: t.bookName,
|
|
3327
|
+
direction: t.direction,
|
|
3328
|
+
rule: t.rule,
|
|
3329
|
+
state: t.state,
|
|
3330
|
+
error: t.error ?? undefined,
|
|
3331
|
+
connections: t.connections,
|
|
3332
|
+
totalConnections: t.totalConnections,
|
|
3333
|
+
})),
|
|
3334
|
+
};
|
|
3068
3335
|
},
|
|
3069
3336
|
})));
|
|
3070
3337
|
// —— SFTP 文件传输工具(0.7.0)——
|
|
@@ -3080,10 +3347,11 @@ const plugin = definePlugin({
|
|
|
3080
3347
|
};
|
|
3081
3348
|
activeDisposers.push(tools.register(defineTool({
|
|
3082
3349
|
name: 'sftp_list',
|
|
3083
|
-
description: '列出 SSH
|
|
3350
|
+
description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前;isSymlink 区分符号链接与真目录)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。默认最多列 500 项(超限 truncated:true,可按子目录分批)。',
|
|
3084
3351
|
parameters: {
|
|
3085
3352
|
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(插件配置 → 终端面板 维护)' },
|
|
3086
3353
|
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
3354
|
+
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
3087
3355
|
},
|
|
3088
3356
|
output: {
|
|
3089
3357
|
schema: {
|
|
@@ -3091,6 +3359,7 @@ const plugin = definePlugin({
|
|
|
3091
3359
|
additionalProperties: false,
|
|
3092
3360
|
properties: {
|
|
3093
3361
|
path: { type: 'string', required: true },
|
|
3362
|
+
truncated: { type: 'boolean', required: true },
|
|
3094
3363
|
entries: {
|
|
3095
3364
|
type: 'array',
|
|
3096
3365
|
required: true,
|
|
@@ -3100,6 +3369,7 @@ const plugin = definePlugin({
|
|
|
3100
3369
|
properties: {
|
|
3101
3370
|
name: { type: 'string', required: true },
|
|
3102
3371
|
isDir: { type: 'boolean', required: true },
|
|
3372
|
+
isSymlink: { type: 'boolean', required: true },
|
|
3103
3373
|
size: { type: 'number', required: true },
|
|
3104
3374
|
mtime: { type: 'number', required: true },
|
|
3105
3375
|
},
|
|
@@ -3112,24 +3382,28 @@ const plugin = definePlugin({
|
|
|
3112
3382
|
const entries = v.entries ?? [];
|
|
3113
3383
|
if (entries.length === 0)
|
|
3114
3384
|
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
3115
|
-
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)}
|
|
3385
|
+
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项${v.truncated === true ? ',已截断——按子目录分批或加大 maxEntries' : ''}):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : e.isSymlink ? '@' : ''} — ${e.isDir ? '目录' : e.isSymlink ? '符号链接' : humanFileSize(e.size)}`).join('');
|
|
3116
3386
|
return [{ type: 'text', text }];
|
|
3117
3387
|
},
|
|
3118
3388
|
},
|
|
3119
3389
|
async execute(args) {
|
|
3120
3390
|
const input = args;
|
|
3121
3391
|
const spec = sftpBookSpec(input.book);
|
|
3392
|
+
const maxEntries = Math.max(1, Math.min(2000, typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) && input.maxEntries >= 1 ? input.maxEntries : 500));
|
|
3122
3393
|
const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
|
|
3123
|
-
|
|
3394
|
+
const truncated = result.entries.length > maxEntries;
|
|
3395
|
+
const entries = result.entries.slice(0, maxEntries).map((e) => ({ name: e.name, isDir: e.isDir, isSymlink: e.isSymlink, size: e.size, mtime: e.mtime }));
|
|
3396
|
+
return { path: result.path, truncated, entries };
|
|
3124
3397
|
},
|
|
3125
3398
|
})));
|
|
3126
3399
|
activeDisposers.push(tools.register(defineTool({
|
|
3127
3400
|
name: 'sftp_read',
|
|
3128
|
-
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB
|
|
3401
|
+
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB,非法值直接报错);offset 可从指定字节起读(配合 maxBytes 分页拿到大文件尾部);二进制判定用 NUL + 非法 UTF-8 占比双重检测,拒绝时说明原因。',
|
|
3129
3402
|
parameters: {
|
|
3130
3403
|
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
3131
3404
|
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
3132
|
-
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144
|
|
3405
|
+
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144;非法值报错不再静默回落)' },
|
|
3406
|
+
offset: { type: 'number', description: '起始字节偏移(0~2^53-1,默认 0;>0 时跳过前缀,适合读日志尾部)' },
|
|
3133
3407
|
},
|
|
3134
3408
|
output: {
|
|
3135
3409
|
schema: {
|
|
@@ -3143,7 +3417,7 @@ const plugin = definePlugin({
|
|
|
3143
3417
|
},
|
|
3144
3418
|
render: (_args, value) => {
|
|
3145
3419
|
const v = value;
|
|
3146
|
-
const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '
|
|
3420
|
+
const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断——加大 maxBytes 或用 offset 分页)' : ''}:`;
|
|
3147
3421
|
return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
|
|
3148
3422
|
},
|
|
3149
3423
|
},
|
|
@@ -3152,8 +3426,15 @@ const plugin = definePlugin({
|
|
|
3152
3426
|
const spec = sftpBookSpec(input.book);
|
|
3153
3427
|
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
3154
3428
|
throw new Error('path 必须是非空字符串');
|
|
3155
|
-
|
|
3156
|
-
|
|
3429
|
+
if (input.maxBytes !== undefined && (typeof input.maxBytes !== 'number' || !Number.isInteger(input.maxBytes) || input.maxBytes < 1 || input.maxBytes > 1024 * 1024)) {
|
|
3430
|
+
throw new Error('maxBytes 必须是 1~1048576 的整数');
|
|
3431
|
+
}
|
|
3432
|
+
const maxBytes = input.maxBytes === undefined ? 256 * 1024 : input.maxBytes;
|
|
3433
|
+
if (input.offset !== undefined && (typeof input.offset !== 'number' || !Number.isInteger(input.offset) || input.offset < 0)) {
|
|
3434
|
+
throw new Error('offset 必须是非负整数');
|
|
3435
|
+
}
|
|
3436
|
+
const offset = input.offset === undefined ? 0 : input.offset;
|
|
3437
|
+
const { stream } = await sftpManager.openDownload(spec, input.path, offset > 0 ? { offset } : undefined);
|
|
3157
3438
|
const chunks = [];
|
|
3158
3439
|
let total = 0;
|
|
3159
3440
|
try {
|
|
@@ -3171,9 +3452,9 @@ const plugin = definePlugin({
|
|
|
3171
3452
|
const buf = Buffer.concat(chunks);
|
|
3172
3453
|
const truncated = buf.length > maxBytes;
|
|
3173
3454
|
const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
|
|
3174
|
-
if (sliced
|
|
3175
|
-
throw new Error('疑似二进制文件(含 NUL
|
|
3176
|
-
return { path: input.path.trim(), content: sliced
|
|
3455
|
+
if (looksLikeBinary(sliced))
|
|
3456
|
+
throw new Error('疑似二进制文件(含 NUL 或非法 UTF-8 占比过高),sftp_read 只支持文本内容');
|
|
3457
|
+
return { path: input.path.trim(), content: decodeUtf8ForAgent(sliced), truncated };
|
|
3177
3458
|
},
|
|
3178
3459
|
})));
|
|
3179
3460
|
activeDisposers.push(tools.register(defineTool({
|
|
@@ -3290,7 +3571,7 @@ const plugin = definePlugin({
|
|
|
3290
3571
|
})));
|
|
3291
3572
|
activeDisposers.push(tools.register(defineTool({
|
|
3292
3573
|
name: 'sftp_remove',
|
|
3293
|
-
description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true
|
|
3574
|
+
description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。根目录 / home / 含 . .. 相对段的路径会被直接拒绝(防整树误删),请先解析出具体的绝对路径。',
|
|
3294
3575
|
parameters: {
|
|
3295
3576
|
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
3296
3577
|
path: { type: 'string', required: true, description: '要删除的远程路径' },
|
|
@@ -3317,6 +3598,7 @@ const plugin = definePlugin({
|
|
|
3317
3598
|
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
3318
3599
|
throw new Error('path 必须是非空字符串');
|
|
3319
3600
|
const recursive = input.recursive === true;
|
|
3601
|
+
// 护栏在 SftpManager.remove 里(agent 工具与面板 HTTP 路由共用同一道)
|
|
3320
3602
|
await sftpManager.remove(spec, input.path, recursive);
|
|
3321
3603
|
return { ok: true, path: input.path.trim(), recursive };
|
|
3322
3604
|
},
|