@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/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 { parseKnownHosts } from './known-hosts.js';
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
- fingerprint: z.string(),
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
- try {
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
- function feedShellIntegration(session, text) {
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
- if (tail.length <= 64 * 1024) {
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
- // 超过 64KB 仍不闭合视为垃圾:放弃扣留,整块照常处理(上限容纳 T 快照)
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
- /** 宽松清洗一份 hostKeys 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
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 out = [];
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
- if (typeof raw.fingerprint !== 'string' || raw.fingerprint.trim() === '' || raw.fingerprint.length > 256)
676
+ const fps = collectFingerprints(raw);
677
+ if (fps.length === 0)
541
678
  continue;
542
- const port = Number(raw.port);
543
- out.push({
544
- host: raw.host.trim().toLowerCase(),
545
- port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22,
546
- fingerprint: raw.fingerprint.trim(),
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 out;
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 (typeof raw.fingerprint !== 'string' || raw.fingerprint.trim() === '')
575
- return { error: 'hostKeys.fingerprint 必须是非空字符串' };
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
- return this.live.hostKeys.find((record) => `${record.host}:${record.port}` === key)?.fingerprint;
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 next = this.live.hostKeys.filter((record) => `${record.host}:${record.port}` !== key);
621
- next.push({ host: host.trim().toLowerCase(), port, fingerprint });
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
- class SessionManager {
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
- /** 回收超过保活期的孤儿会话(回收器定时调用;graceMs<=0 时不动作)。 */
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 !== null && now - session.orphanedAt >= graceMs) {
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
- class TtyServer {
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
- wss = new WebSocketServer({ noServer: true });
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, clientSid, on) {
1003
+ /** 订阅/退订(tab 可见性驱动):退到 0 即停表,任何路径都不会让采集器空转。键 = 绑定键(connId:sid)。 */
1004
+ setStatsSub(session, bindingKey, on) {
846
1005
  if (on) {
847
- session.statsSubs.add(clientSid);
1006
+ session.statsSubs.add(bindingKey);
848
1007
  this.startStats(session);
849
1008
  return;
850
1009
  }
851
- session.statsSubs.delete(clientSid);
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 clientSid of [...session.statsSubs]) {
858
- if (!session.clients.has(clientSid))
859
- this.setStatsSub(session, clientSid, false);
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
- /** 帧只发给订阅了该会话的客户端(按各客户端自己的 sid 寻址,跨窗口共享也成立)。 */
1127
+ /** 帧只发给订阅了该会话的客户端(绑定键寻址,回帧带各连接自己的 sid,跨窗口共享也成立)。 */
969
1128
  sendStats(session, frame) {
970
- for (const clientSid of session.statsSubs) {
971
- const ws = session.clients.get(clientSid);
972
- if (ws === undefined)
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: clientSid, stats: frame });
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
- this.rebindClient(existing, sid, ws, local);
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: Number(msg.rows) || 24,
1216
- cols: Number(msg.cols) || 80,
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(Number(msg.cols) || 80, Number(msg.rows) || 24),
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).slice(-BUFFER_CAP);
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
- this.rebindClient(existing, sid, ws, local);
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: Number(msg.cols) || 80,
1322
- rows: Number(msg.rows) || 24,
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(Number(msg.cols) || 80, Number(msg.rows) || 24),
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).slice(-BUFFER_CAP);
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
- await session.handle.write(String(msg.d ?? ''));
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 = Number(msg.cols) || 80;
1401
- const rows = Number(msg.rows) || 24;
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:若是孤儿会话(前连接已断)也允许 kill,
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
- // 重新绑定到本连接(key = 本连接侧的 sid):解孤儿态,恢复被背压暂停的输出流
1469
- session.clients.set(raw, ws);
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 [clientSid, clientWs] of session.clients) {
1538
- send(clientWs, { t: 'exit', sid: clientSid, code: outcome.exitCode, signal: outcome.signal });
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 [clientSid, clientWs] of session.clients) {
1755
+ for (const client of session.clients.values()) {
1554
1756
  try {
1555
- clientWs.send(JSON.stringify({ t: 'data', sid: clientSid, d: pending }), () => {
1556
- if (session.paused && clientWs.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
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, clientWs.bufferedAmount);
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).slice(-BUFFER_CAP);
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 [clientSid, clientWs] of session.clients) {
1615
- send(clientWs, { t: 'data', sid: clientSid, d: pending });
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 = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
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
- function readCredentialRefNames() {
1687
- const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
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: async (req, res) => {
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
- writeJson(res, 200, { ok: true, entries: parseKnownHosts(text, candidates) });
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
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
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):客户端 abort → req 'aborted' → 打断 pipeline
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
- req.on('aborted', () => {
2533
- abortedByClient = true;
2534
- controller.abort();
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 { stream, done } = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
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
- void sftpManager.deleteRemoteQuiet(parsed.spec, target);
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 last = session.shellState.lastCommand;
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
- return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(0, 128 * 1024) };
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
- return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(0, 32 * 1024) };
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 += decoder.write(chunk);
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
- return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
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 远程目录内容(名称/类型/大小/修改时间,目录在前)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。用于查找远程文件、确认上传下载结果。',
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)} 项):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : ''} — ${e.isDir ? '目录' : humanFileSize(e.size)}`).join('');
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
- return { path: result.path, entries: result.entries.map((e) => ({ name: e.name, isDir: e.isDir, size: e.size, mtime: e.mtime })) };
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),超出截断;检测到 NUL 字节按二进制文件拒绝。适合查看远程配置、小日志。',
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
- const maxBytes = typeof input.maxBytes === 'number' && Number.isInteger(input.maxBytes) && input.maxBytes >= 1 && input.maxBytes <= 1024 * 1024 ? input.maxBytes : 256 * 1024;
3156
- const { stream } = await sftpManager.openDownload(spec, input.path);
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.includes(0))
3175
- throw new Error('疑似二进制文件(含 NUL 字节),sftp_read 只支持文本内容');
3176
- return { path: input.path.trim(), content: sliced.toString('utf8'), truncated };
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
  },