@hyzyn/dsh-tty 0.20.2 → 0.22.0-rc.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,12 +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, dshHome as resolveDshHome, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
15
+ import { definePlugin, dshHome as resolveDshHome, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
- import { spawnSsh, sshTarget, expandHome, setCredentialResolver } from './ssh.js';
17
+ import { sanitizeJumpSpec, sanitizeProxyCommand, spawnSsh, sshTarget, expandHome, setCredentialResolver, setProxyCommandPolicy, validateJumpSpec, validateProxyCommand } from './ssh.js';
18
+ import { sharedGrantStore, auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
18
19
  import { probeSsh } from './probe.js';
19
20
  import { buildCommandSpawn, buildShellSpawn, defaultShellPath } from './shell-integration.js';
20
- import { parseSshConfig } from './ssh-config.js';
21
+ import { parseSshConfigDetailed } from './ssh-config.js';
21
22
  import { parseKnownHostsDetailed } from './known-hosts.js';
22
23
  import { TunnelManager } from './tunnels.js';
23
24
  import { SftpManager } from './sftp.js';
@@ -25,6 +26,22 @@ import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions
25
26
  import { buildRemoteStatsCommand, buildWindowsStatsCommand, hasStatsData, localStatsSampler, parseStatsLine } from './stats.js';
26
27
  /** SFTP 传输限制默认值。 */
27
28
  const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
29
+ /**
30
+ * 跳板机(ProxyJump 单跳)的 settings schema。
31
+ *
32
+ * 刻意**不给 `auth` / `keyPath` / `passphrase` / `password` / `username` 默认值**:
33
+ * 缺省的含义是「继承目标那一跳的凭据」,一旦给默认值(如 auth 默认 agent)就会把
34
+ * 「继承」变成「显式 agent」,用户配了密码的跳板机就会认证失败。
35
+ */
36
+ const SSH_JUMP_SCHEMA = z.object({
37
+ host: z.string(),
38
+ port: z.natural().max(65535).default(22),
39
+ username: z.string().default(''),
40
+ auth: z.union([z.const('agent'), z.const('key'), z.const('password')]),
41
+ keyPath: z.string().default(''),
42
+ passphrase: z.string().default(''),
43
+ password: z.string().default(''),
44
+ });
28
45
  const SSH_HOST_SCHEMA = z.object({
29
46
  name: z.string(),
30
47
  host: z.string(),
@@ -35,6 +52,13 @@ const SSH_HOST_SCHEMA = z.object({
35
52
  passphrase: z.string().default(''),
36
53
  password: z.string().default(''),
37
54
  agentForward: z.boolean().default(false),
55
+ /** 经跳板机连接(ProxyJump 单跳);缺省 = 直连。 */
56
+ jump: SSH_JUMP_SCHEMA,
57
+ /**
58
+ * 代理命令(ProxyCommand):本机执行、stdio 当 SSH 传输。**需要 allowProxyCommand 才生效**。
59
+ * `~/.ssh/config` 导入永不自动带入(那一档信任级要用户自己开开关并手填)。
60
+ */
61
+ proxyCommand: z.string().default(''),
38
62
  /** 该条目的 SSH 标签默认以 tmux 持久会话打开(仅 persistence=tmux 时生效)。 */
39
63
  persist: z.boolean().default(false),
40
64
  });
@@ -85,12 +109,29 @@ export const Config = z.object({
85
109
  maxUploadMb: z.natural().max(1024 * 1024).default(2048),
86
110
  maxUploadFiles: z.natural().max(100000).default(1000),
87
111
  }).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }).volatile(),
112
+ /**
113
+ * 允许 ProxyCommand(本机命令执行):**默认 false**。
114
+ *
115
+ * 这是本插件唯一「由设置字段驱动本机任意命令执行」的开关,与跳板机(只连一跳 TCP)不同档;
116
+ * 关着时携带 proxyCommand 的连接**明确失败**(不退回直连),导入也永不自动带入该字段。
117
+ */
118
+ allowProxyCommand: z.boolean().default(false).volatile(),
88
119
  persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]).volatile(),
89
120
  });
90
121
  /* ------------------------------------------------------------------ *
91
122
  * 常量
92
123
  * ------------------------------------------------------------------ */
93
124
  const WS_PATH = '/api/dsh-tty/ws';
125
+ /**
126
+ * 代理命令这条能力的宿主侧授权(见 kit 的 capability.js 与 docs/architecture.md)。
127
+ *
128
+ * 两条提权通道(见 kit 的 capability.ts):
129
+ * ① 启动环境变量(最严档,判定源是宿主的启动环境快照);
130
+ * ② 就地提权(免重启):卡片上点开关 → 在宿主文件系统上落地一个随机名确认文件。
131
+ * HTTP 侧**不能凭空打开**它——回环围栏与同源证明都拦不住跨站页面与页内脚本,配置路由若能
132
+ * 凭空提权,这道闸门等于没有;而「关掉」永远可用(紧急刹车不依赖重启)。
133
+ */
134
+ const CAP_PROXY_COMMAND = { env: 'DSH_TTY_ALLOW_PROXY_COMMAND', label: '代理命令(ProxyCommand)' };
94
135
  const DEFAULT_MAX_SESSIONS = 4;
95
136
  /** 断线保活默认秒数(reconnectGraceSec;0 = 旧行为,断开立即结束会话)。 */
96
137
  const DEFAULT_RECONNECT_GRACE_SEC = 120;
@@ -134,7 +175,7 @@ const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
134
175
  const REAPER_INTERVAL_MS = 10_000;
135
176
  /** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
136
177
  const STATS_INTERVAL_MS = 1000;
137
- const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
178
+ const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎;连接簿条目可配单跳跳板机 ProxyJump),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
138
179
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
139
180
  function wrapLocalPty(handle) {
140
181
  let resizeWarned = false;
@@ -217,6 +258,8 @@ class LiveConfig {
217
258
  persistSessions;
218
259
  /** SFTP 传输限制(客户端浏览器侧执行)。 */
219
260
  sftpLimits;
261
+ /** 允许 ProxyCommand(本机命令执行):默认关(见 Config.allowProxyCommand)。 */
262
+ allowProxyCommand;
220
263
  constructor(init) {
221
264
  this.shell = init.shell;
222
265
  this.term = sanitizeTermValue(init.term, 'xterm-256color');
@@ -232,6 +275,8 @@ class LiveConfig {
232
275
  // 只有显式 false 才关(缺省/旧配置一律视为开)
233
276
  this.statsEnabled = init.statsEnabled !== false;
234
277
  this.sftpLimits = sanitizeSftpLimits(init.sftpLimits);
278
+ // 缺省/旧配置一律视为**关**(这一档是「本机命令执行」,只有显式 true 才开)
279
+ this.allowProxyCommand = init.allowProxyCommand === true;
235
280
  this.persistSessions = init.persistSessions ?? [];
236
281
  }
237
282
  /** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
@@ -263,6 +308,8 @@ class LiveConfig {
263
308
  this.statsEnabled = partial.statsEnabled;
264
309
  if (partial.sftpLimits !== undefined)
265
310
  this.sftpLimits = sanitizeSftpLimits({ ...this.sftpLimits, ...partial.sftpLimits });
311
+ if (typeof partial.allowProxyCommand === 'boolean')
312
+ this.allowProxyCommand = partial.allowProxyCommand;
266
313
  if (Array.isArray(partial.persistSessions))
267
314
  this.persistSessions = partial.persistSessions;
268
315
  }
@@ -501,6 +548,65 @@ export function feedShellIntegration(session, text) {
501
548
  state.cmdBuffer = (state.cmdBuffer + rest).slice(-COMMAND_CAP);
502
549
  }
503
550
  }
551
+ /**
552
+ * 唯一追加点(D72):环形缓冲与单调字符计数一起维护。水位线用绝对值定位
553
+ * 未读区(`buffer 起点 = outputSeq - buffer.length`,裁剪只会前移起点,
554
+ * 公式恒成立),漏掉任何一处追加都会让它错位——notice 注入那两处此前也是
555
+ * 各写各的 `tailFromSafeBoundary`。
556
+ */
557
+ function appendOutput(session, text) {
558
+ session.outputSeq += text.length;
559
+ session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
560
+ }
561
+ /** 原始流里最后一个「命令开始」B 标记的结束位置;-1 = 窗口内没有(无 shell 集成 → 降级)。 */
562
+ function lastCommandStart(text) {
563
+ OSC133_RE.lastIndex = 0;
564
+ let end = -1;
565
+ for (const match of text.matchAll(OSC133_RE)) {
566
+ if (match[1] === 'B')
567
+ end = (match.index ?? 0) + match[0].length;
568
+ }
569
+ OSC133_RE.lastIndex = 0; // 归位:这个正则是共用的(feedShellIntegration 也用它)
570
+ return end;
571
+ }
572
+ /** 首次被 agent 工具触达时把水位线落在当下——否则第一次回扫会把用户此前的历史输出当成「还没读过」。 */
573
+ function ensureReadMark(session) {
574
+ if (session.readSeq >= 0)
575
+ return;
576
+ session.readSeq = session.outputSeq;
577
+ session.readMarkAt = Date.now();
578
+ }
579
+ /** 读侧工具返回前推进水位线:返回时刻 = 「AI 真正看到的内容」的上界。 */
580
+ function advanceReadMark(session) {
581
+ session.readSeq = session.outputSeq;
582
+ session.readMarkAt = Date.now();
583
+ }
584
+ /**
585
+ * 水位线之后的**未读**原始输出。下界再抬到「最后一个 B 标记之后」:回显落在
586
+ * A(prompt 开始)与 B(命令开始)之间,所以这样能零启发式地排除回显——按
587
+ * 文本比对「跳过与发送内容相同的行」会被折行 / ANSI / 多行粘贴打碎。
588
+ * 没有 B 标记(未开 shell 集成 / Windows PowerShell / 远端未装集成)时无法
589
+ * 区分回显,退化为从水位线起扫(可能匹配到回显本身,文档已声明)。
590
+ */
591
+ function unreadRegion(session) {
592
+ ensureReadMark(session);
593
+ const bufferStart = session.outputSeq - session.buffer.length;
594
+ const text = session.buffer.slice(Math.max(0, session.readSeq - bufferStart));
595
+ const start = lastCommandStart(text);
596
+ return start === -1 ? text : text.slice(start);
597
+ }
598
+ /**
599
+ * pattern 匹配的统一入口(D72):先对含 ANSI 的原始流试(live 路径原语义),
600
+ * 未命中再对清洗后文本试一次——这样承诺才是「tty_capture 里看得到的,
601
+ * tty_expect 也能匹配到」,而不是回扫命中、返回文案里却看不到。
602
+ */
603
+ function testPattern(re, text) {
604
+ re.lastIndex = 0;
605
+ if (re.test(text))
606
+ return true;
607
+ re.lastIndex = 0;
608
+ return re.test(cleanAnsiTail(text));
609
+ }
504
610
  /** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
505
611
  function sanitizeTunnels(input) {
506
612
  if (!Array.isArray(input))
@@ -610,6 +716,14 @@ function sanitizeSshHosts(input) {
610
716
  agentForward: raw.agentForward === true,
611
717
  persist: raw.persist === true,
612
718
  });
719
+ // 跳板机:清洗不出可用对象(host 为空)就当没配——不给下游留半个对象
720
+ const jump = sanitizeJumpSpec(raw.jump);
721
+ if (jump !== undefined)
722
+ out[out.length - 1].jump = jump;
723
+ // 代理命令:形状不合法(含换行 / 超长 / 非字符串)就当没配(**失败方向是关**)
724
+ const proxyCommand = sanitizeProxyCommand(raw.proxyCommand);
725
+ if (proxyCommand !== undefined)
726
+ out[out.length - 1].proxyCommand = proxyCommand;
613
727
  }
614
728
  return out;
615
729
  }
@@ -650,6 +764,16 @@ function validateSshHosts(input) {
650
764
  if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
651
765
  return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
652
766
  }
767
+ if (raw.jump !== undefined) {
768
+ const checked = validateJumpSpec(raw.jump);
769
+ if (checked.error !== undefined)
770
+ return { error: `sshHosts「${String(raw.name)}」${checked.error}` };
771
+ }
772
+ if (raw.proxyCommand !== undefined) {
773
+ const checked = validateProxyCommand(raw.proxyCommand);
774
+ if (checked.error !== undefined)
775
+ return { error: `sshHosts「${String(raw.name)}」${checked.error}` };
776
+ }
653
777
  }
654
778
  return { hosts: sanitizeSshHosts(input) };
655
779
  }
@@ -788,34 +912,16 @@ class HostKeyStore {
788
912
  this.persist(next);
789
913
  }
790
914
  }
791
- /** upgrade 路由的 loopback 信任围栏(与 dsh-mcp 的 HTTP 围栏同思路,socket 版)。 */
915
+ /**
916
+ * upgrade 路由的 loopback 信任围栏(socket 版):直接用 kit 的加固档(docker D31/D80/D110)。
917
+ *
918
+ * 与 HTTP 路由的差别只有一处刻意为之:**不放开 Cookie 例外**。升级请求在浏览器里必带
919
+ * `Origin`,而加固档「Origin 有就必须与 Host 同源」这条已经等价于同源证明;而长连
920
+ * (PTY / 隧道帧)一旦建立就一直活着,没必要为桌面壳那种「只带 Cookie」的转发链开口子
921
+ * ——真要支持,也应该先在桌面壳上验一条真机链路(docker 的 D139 是那样定下来的)。
922
+ */
792
923
  function isLoopbackUpgrade(req) {
793
- const address = req.socket.remoteAddress;
794
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
795
- return false;
796
- const host = req.headers.host;
797
- if (typeof host !== 'string')
798
- return false;
799
- let hostUrl;
800
- try {
801
- hostUrl = new URL('http://' + host);
802
- }
803
- catch {
804
- return false;
805
- }
806
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
807
- return false;
808
- if (req.headers['sec-fetch-site'] === 'cross-site')
809
- return false;
810
- const origin = req.headers.origin;
811
- if (origin === undefined)
812
- return true;
813
- try {
814
- return new URL(origin).host === hostUrl.host;
815
- }
816
- catch {
817
- return false;
818
- }
924
+ return isLoopbackRequestStrict(req);
819
925
  }
820
926
  /* ------------------------------------------------------------------ *
821
927
  * 虚拟屏(xterm-headless)——构造与异常兜底(D57)
@@ -1361,10 +1467,26 @@ export class TtyServer {
1361
1467
  }
1362
1468
  /** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
1363
1469
  handleUpgrade(req, socket, head) {
1364
- if (!isLoopbackUpgrade(req)) {
1470
+ const loopback = isLoopbackUpgrade(req);
1471
+ // 字面量环回同步判定(绝大多数请求:握手第一拍就继续,不引入额外时序);
1472
+ // 只有主机名 / /etc/hosts 别名才等一次 DNS 确认(≤500ms),期间不碰 socket
1473
+ if (loopback instanceof Promise) {
1474
+ void loopback.then((ok) => {
1475
+ if (ok)
1476
+ this.finishUpgrade(req, socket, head);
1477
+ else
1478
+ socket.destroy();
1479
+ }).catch(() => { socket.destroy(); });
1480
+ return;
1481
+ }
1482
+ if (!loopback) {
1365
1483
  socket.destroy();
1366
1484
  return;
1367
1485
  }
1486
+ this.finishUpgrade(req, socket, head);
1487
+ }
1488
+ /** 围栏放行之后的实际握手(与上面的异步分支共用)。 */
1489
+ finishUpgrade(req, socket, head) {
1368
1490
  if (!this.wsGateOpen) {
1369
1491
  socket.destroy();
1370
1492
  return;
@@ -1512,6 +1634,10 @@ export class TtyServer {
1512
1634
  local: new Map(),
1513
1635
  owner: 'agent',
1514
1636
  });
1637
+ // D72:agent 自己开的会话从出生起「什么都没读过」(水位线落在 seq 0)——
1638
+ // 包括 `command` 型会话在第一次 expect 之前打印的启动输出。用户开的标签
1639
+ // 相反:历史输出不算未读,首次被 agent 触达时才把水位线落在当下。
1640
+ ensureReadMark(session);
1515
1641
  // 面板可见性:新会话推给所有已连接的面板(客户端据此建「agent 开的」标签)
1516
1642
  this.broadcastSessions();
1517
1643
  return { sid: session.id, persist: session.tmuxName !== null && !degraded };
@@ -1684,6 +1810,9 @@ export class TtyServer {
1684
1810
  startedAt: Date.now(),
1685
1811
  lastOutputAt: Date.now(),
1686
1812
  lastInputAt: Date.now(),
1813
+ outputSeq: 0,
1814
+ readSeq: -1,
1815
+ readMarkAt: 0,
1687
1816
  buffer: '',
1688
1817
  decoder: new StringDecoder('utf8'),
1689
1818
  screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
@@ -1869,7 +1998,7 @@ export class TtyServer {
1869
1998
  send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(next.tmuxName !== null ? { persist: true } : {}) });
1870
1999
  if (created.wantsPersist && created.degraded) {
1871
2000
  const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
1872
- next.buffer = tailFromSafeBoundary(next.buffer + notice, BUFFER_CAP);
2001
+ appendOutput(next, notice);
1873
2002
  send(ws, { t: 'data', sid, d: notice });
1874
2003
  }
1875
2004
  }
@@ -1952,6 +2081,9 @@ export class TtyServer {
1952
2081
  startedAt: Date.now(),
1953
2082
  lastOutputAt: Date.now(),
1954
2083
  lastInputAt: Date.now(),
2084
+ outputSeq: 0,
2085
+ readSeq: -1,
2086
+ readMarkAt: 0,
1955
2087
  buffer: '',
1956
2088
  decoder: new StringDecoder('utf8'),
1957
2089
  screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
@@ -1980,7 +2112,7 @@ export class TtyServer {
1980
2112
  this.trackPersist(tmuxName, true); // 留存:远程 tmux 本机清单看不到
1981
2113
  if (handle.startupNotice !== undefined) {
1982
2114
  const notice = `\x1b[2m[dsh-tty] ${handle.startupNotice}\x1b[0m\r\n`;
1983
- next.buffer = tailFromSafeBoundary(next.buffer + notice, BUFFER_CAP);
2115
+ appendOutput(next, notice);
1984
2116
  send(ws, { t: 'data', sid, d: notice });
1985
2117
  }
1986
2118
  this.attachOutput(next);
@@ -2219,7 +2351,7 @@ export class TtyServer {
2219
2351
  // StringDecoder 兜跨 chunk 多字节序列,再喂 shell 集成解析与虚拟屏
2220
2352
  const text = session.decoder.write(chunk);
2221
2353
  session.lastOutputAt = Date.now();
2222
- session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
2354
+ appendOutput(session, text);
2223
2355
  feedShellIntegration(session, text);
2224
2356
  const screen = session.screen;
2225
2357
  if (screen !== null) {
@@ -2335,8 +2467,7 @@ function readManagedEnvKeys() {
2335
2467
  * 值在任何分支都不进响应——SSH 对话框的选择器只需要「我存过哪些名字」。
2336
2468
  */
2337
2469
  export async function handleCredentialRefsRoute(req, res) {
2338
- if (!isLoopbackHttp(req)) {
2339
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
2470
+ if (!(await gateRoute(req, res))) {
2340
2471
  return;
2341
2472
  }
2342
2473
  if (req.method !== 'GET') {
@@ -2448,38 +2579,84 @@ function mergeSshSpec(findSshHost, name, inline) {
2448
2579
  password: typeof inline.password === 'string' && inline.password !== '' ? inline.password : profile?.password,
2449
2580
  agentForward: typeof inline.agentForward === 'boolean' ? inline.agentForward : profile?.agentForward ?? false,
2450
2581
  };
2582
+ /*
2583
+ * 跳板机:内联给了就以它为准(清洗不出可用对象则退回连接簿那一份),否则用连接簿的。
2584
+ * `jump` 只在**目标那一跳**之外多一跳,不支持嵌套(单跳),所以这里不做递归解析。
2585
+ */
2586
+ const inlineJump = inline.jump === undefined ? undefined : sanitizeJumpSpec(inline.jump);
2587
+ const jump = inlineJump ?? profile?.jump;
2588
+ if (jump !== undefined)
2589
+ spec.jump = jump;
2590
+ /*
2591
+ * 代理命令:与跳板机同款「内联优先、否则用连接簿那一份」。两者同时存在**不在这里二选一**:
2592
+ * 优先关系(ProxyJump 优先)在拨号处 `attachSshTransport` 一处决定,并记 warn——否则
2593
+ * 「配了代理命令却走了跳板机」会被这里静默掉。
2594
+ */
2595
+ const inlineProxyCommand = inline.proxyCommand === undefined ? undefined : sanitizeProxyCommand(inline.proxyCommand);
2596
+ const proxyCommand = inlineProxyCommand ?? profile?.proxyCommand;
2597
+ if (proxyCommand !== undefined)
2598
+ spec.proxyCommand = proxyCommand;
2451
2599
  if (spec.host === '' || spec.username === '')
2452
2600
  return { error: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' };
2453
2601
  return { spec };
2454
2602
  }
2455
- /** HTTP 路由的 loopback 信任围栏(与 dsh-mcp 同思路)。 */
2456
- function isLoopbackHttp(req) {
2457
- const address = req.socket.remoteAddress;
2458
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
2459
- return false;
2460
- const host = req.headers.host;
2461
- if (typeof host !== 'string')
2462
- return false;
2463
- let hostUrl;
2464
- try {
2465
- hostUrl = new URL('http://' + host);
2466
- }
2467
- catch {
2468
- return false;
2469
- }
2470
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
2471
- return false;
2472
- if (req.headers['sec-fetch-site'] === 'cross-site')
2603
+ /*
2604
+ * 回环围栏与同源证明:**实现已收敛到 `@hyzyn/dsh-kit`**(2026-09-25,项目级 ROADMAP 第 1
2605
+ * 项)。本包此前那份只认 `127.0.0.1` 一个字面量的同步围栏已删除,改走 kit 的加固档
2606
+ * (docker D31/D80/D110),并给变更端点补上同源证明(docker D32/D139)——成因与桌面版
2607
+ * 例外(桌面壳转发会删掉 Origin / Sec-Fetch-Site,但必带宿主 Cookie)的唯一归宿在
2608
+ * `packages/kit/src/http.ts`,这里只留指针。
2609
+ */
2610
+ /**
2611
+ * 数据路由的统一闸门(**导出仅供单测**):回环围栏(加固档)+ 变更端点的同源证明。
2612
+ * 十二处路由此前各抄一遍 403 样板,`mutation` 这条判据一加就会各写各的——收敛成一处后
2613
+ * 「拒绝分支」只有一份,负例也只测这一份。
2614
+ *
2615
+ * 哪些是变更端点(`mutation: true`)——**判据是「这次请求会不会改状态」**:
2616
+ * - `POST /probe`:真的拨号,且连接簿条目测试会当场 TOFU 记录主机指纹;
2617
+ * - `POST /sftp/{mkdir,rename,remove,upload}`、`POST /local-fs/{mkdir,rename,remove,transfer}`:
2618
+ * 写远端 / 写本机 / 起传输任务;
2619
+ * - `POST /config`**刻意不在其列**(与 docker 同口径):它是插件被禁用后唯一的恢复入口
2620
+ * ——卡片靠它渲染、也是重新启用的唯一 UI 入口;跨站 POST 已由上面那条围栏的
2621
+ * `sec-fetch-site: cross-site` 与 Origin 比对拦住。
2622
+ * 只读端点(GET 全家 + `sftp /list` `/download`、`local-fs /list`)维持 loopback-only:
2623
+ * 它们读的是用户自己主动要的东西,读路由加证明只会把旧 Safari / 裸 curl 一起挡在门外。
2624
+ *
2625
+ * **WS upgrade 不走这里**(那是 socket 握手,不是 req/res 路由):见 `handleUpgrade`
2626
+ * 用的 `isLoopbackRequestStrict`——它自带「Origin 有则必须同源」这条判据,等价于给
2627
+ * 升级请求也上了证明,但**刻意不放开 Cookie 例外**:长连的生命周期比一次 POST 长得多。
2628
+ */
2629
+ export async function gateRoute(req, res, options) {
2630
+ if (!(await isLoopbackRequestStrict(req))) {
2631
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
2473
2632
  return false;
2474
- const origin = req.headers.origin;
2475
- if (origin === undefined)
2476
- return true;
2477
- try {
2478
- return new URL(origin).host === hostUrl.host;
2479
2633
  }
2480
- catch {
2634
+ if (options?.mutation === true && !hasSameOriginProof(req)) {
2635
+ writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
2481
2636
  return false;
2482
2637
  }
2638
+ return true;
2639
+ }
2640
+ /** 变更动作的子路由(见 gateRoute 的判据):前缀路由先取 sub、再连 mutation 一起过闸。 */
2641
+ const MUTATION_SUBROUTES = {
2642
+ '/api/dsh-tty/sftp': new Set(['/mkdir', '/rename', '/remove', '/upload']),
2643
+ '/api/dsh-tty/local-fs': new Set(['/mkdir', '/rename', '/remove', '/transfer']),
2644
+ /*
2645
+ * 就地提权:三条子路由**逐条**列(docker D144 的教训)——判据是「精确子路径 + POST」,
2646
+ * 只写一条的话另外两条就是裸的(跨站页面能撤销授权、能反复对着确认挑战试错)。
2647
+ */
2648
+ '/api/dsh-tty/elevate': new Set(['', '/status', '/revoke']),
2649
+ };
2650
+ /**
2651
+ * 这个前缀路由的子路径是否**会改状态**(导出仅供单测)。
2652
+ *
2653
+ * 为什么单独抽出来:`/list` 与 `/download` 也是 POST(凭证走 body、不进 URL),但它们只是
2654
+ * 读——如果把「POST 就要求证明」一刀切下去,读路由会连带把旧 Safari / 裸 curl 挡在门外,
2655
+ * 而它们本来就没有可被跨站利用的副作用。判据是**动作**不是**方法**,所以名单必须显式。
2656
+ * 没见过的子路径一律 false(几步之后就是 404,不给它额外的信息量)。
2657
+ */
2658
+ export function isMutationSubroute(prefix, sub) {
2659
+ return MUTATION_SUBROUTES[prefix]?.has(sub) === true;
2483
2660
  }
2484
2661
  function writeJson(res, status, body) {
2485
2662
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
@@ -2586,6 +2763,24 @@ const plugin = definePlugin({
2586
2763
  // 均拿不到,实测 mcp-client 同款模式),声明后 ctx.get('tools') 才能取到。
2587
2764
  inject: ['tools'],
2588
2765
  apply(ctx, rawConfig) {
2766
+ /*
2767
+ * 授权来源必须在**第一次 capabilityGranted 之前**绑定(见 kit 的 capability.ts)。两条通道:
2768
+ * ① 启动环境快照(最严档,只认继承来的 `process` 层);
2769
+ * ② 就地提权(免重启):卡片上点开关 → 宿主文件系统上落地一个随机名确认文件 → 授权写进
2770
+ * grant-store(HTTP 写不到)。落点全部来自 kit 的 `capabilityPaths`(路径只在那里拼一次)。
2771
+ * 存储每次 apply 新建一个实例 —— 这就是「宿主重启后重新读盘」的语义。
2772
+ */
2773
+ const paths = capabilityPaths(resolveDshHome());
2774
+ // **共享实例**(kit D11):tty 与 docker 同装时各 new 一个会各自缓存一份文件快照,
2775
+ // 后绑定的那个看不到另一个后来写进去的授权(实测:tty 授权成功后自己的快照仍是 false)
2776
+ const grantStore = sharedGrantStore(paths.dir);
2777
+ bindCapabilitySources(ctx, grantStore);
2778
+ /*
2779
+ * 启动期审计:盘上已有的带外授权是**持久**的(重启后直接生效、不再有任何一次确认),所以那次
2780
+ * 「静默继承」必须在日志里留下痕迹(kit D09)。刻意放在 `enabled` 判定**之前**:授权是宿主级的,
2781
+ * 与插件这次是否启用无关。
2782
+ */
2783
+ auditLoadedGrants(grantStore, [CAP_PROXY_COMMAND.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-tty]');
2589
2784
  // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
2590
2785
  const config = plainConfig((rawConfig ?? {}));
2591
2786
  if (config?.enabled === false)
@@ -2609,8 +2804,20 @@ const plugin = definePlugin({
2609
2804
  endOnPageClose: config?.endOnPageClose === true,
2610
2805
  statsEnabled: config?.statsEnabled !== false,
2611
2806
  sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
2807
+ allowProxyCommand: config?.allowProxyCommand === true,
2612
2808
  persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
2613
2809
  });
2810
+ /*
2811
+ * 闸门在**挂载时先初始化一次**(tty D70)。
2812
+ *
2813
+ * 它的唯一写入方是 `applyPatch`,而 `applyPatch` 在启动期**只在「settings 里存过东西」时才跑**
2814
+ * (见那里 `if (Object.keys(startup).length > 0)`)。于是「配置里写着 `allowProxyCommand: true`
2815
+ * + 已授权(环境变量或授权文件)+ 重启宿主」这条最平常的路径会停在模块级默认值
2816
+ * `{granted:false, enabled:false}` 上:fail-closed(不是安全问题),但症状正是本仓最忌讳的
2817
+ * 「配了没反应」——卡片显示已授权,代理命令却仍被拒。这条在接入就地提权后才致命:授权是
2818
+ * **持久**的,重启后更要保证「界面上说已授权」与「闸门真的放行」一致。
2819
+ */
2820
+ setProxyCommandPolicy({ granted: capabilityGranted(CAP_PROXY_COMMAND), enabled: live.allowProxyCommand });
2614
2821
  const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS, () => live.endOnPageClose);
2615
2822
  /** TOFU 指纹记录持久化:写入 settings 命名空间(合并语义),失败不影响连接。 */
2616
2823
  const persistHostKeys = (records) => {
@@ -2672,6 +2879,18 @@ const plugin = definePlugin({
2672
2879
  endOnPageClose: live.endOnPageClose,
2673
2880
  statsEnabled: live.statsEnabled,
2674
2881
  sftpLimits: live.sftpLimits,
2882
+ allowProxyCommand: live.allowProxyCommand,
2883
+ allowProxyCommandGranted: capabilityGranted(CAP_PROXY_COMMAND),
2884
+ /*
2885
+ * 授权来源:'env' = 启动环境变量(界面不给「撤销」按钮,它只能靠改启动环境撤销)、
2886
+ * 'file' = 就地确认写下的带外授权(界面可撤销)、null = 没授权。
2887
+ */
2888
+ allowProxyCommandGrantSource: capabilityGrantVia(CAP_PROXY_COMMAND) ?? null,
2889
+ /*
2890
+ * 授权时刻(Unix 秒;只有 file 通道有值)。授权是**持久**的:重启后它直接生效、不再确认,
2891
+ * 界面至少要能说出它是什么时候来的(kit D09)。
2892
+ */
2893
+ allowProxyCommandGrantedAt: capabilityGrantAt(CAP_PROXY_COMMAND) ?? null,
2675
2894
  toolsRegistered: stateRef.toolsRegistered,
2676
2895
  /**
2677
2896
  * 宿主平台(`process.platform`):客户端据此把「Shell 路径 / shell 集成」的说明与候选
@@ -2695,8 +2914,16 @@ const plugin = definePlugin({
2695
2914
  endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
2696
2915
  statsEnabled: typeof section.statsEnabled === 'boolean' ? section.statsEnabled : undefined,
2697
2916
  sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
2917
+ allowProxyCommand: typeof section.allowProxyCommand === 'boolean' ? section.allowProxyCommand : undefined,
2698
2918
  persistSessions: sanitizePersistSessions(section.persistSessions),
2699
2919
  });
2920
+ /*
2921
+ * ProxyCommand 闸门跟着 settings 走(**每次热应用都写一次**):这一档是「设置字段驱动的
2922
+ * 本机任意命令执行」,关掉之后必须**立刻**对四条建连路径全部生效(终端 / SFTP / 隧道 /
2923
+ * 探针共用 ssh.ts 的模块级策略,见 setProxyCommandPolicy)。启动路径也走这里
2924
+ * (settings 就绪时 applyPatch 会被调用一次),所以不存在「忘了初始化 → 意外为开」。
2925
+ */
2926
+ setProxyCommandPolicy({ granted: capabilityGranted(CAP_PROXY_COMMAND), enabled: live.allowProxyCommand });
2700
2927
  if (typeof section.enabled === 'boolean')
2701
2928
  stateRef.enabled = section.enabled;
2702
2929
  if (typeof section.announceToAgent === 'boolean')
@@ -2717,12 +2944,12 @@ const plugin = definePlugin({
2717
2944
  server.setWsGate(stateRef.enabled);
2718
2945
  // 状态条开关热生效:关的时候停掉全部采集(本地定时器 + 远端 exec channel)
2719
2946
  server.setStatsEnabled(live.statsEnabled);
2720
- console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, enabled=${String(stateRef.enabled)})`);
2947
+ console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, allowProxyCommand=${String(live.allowProxyCommand)}, enabled=${String(stateRef.enabled)})`);
2721
2948
  };
2722
2949
  /** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
2723
2950
  const normalizePatch = (input) => {
2724
2951
  const patch = {};
2725
- const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits']);
2952
+ const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits', 'allowProxyCommand']);
2726
2953
  for (const key of Object.keys(input)) {
2727
2954
  if (!known.has(key))
2728
2955
  return { error: '未知配置项: ' + key };
@@ -2774,6 +3001,19 @@ const plugin = definePlugin({
2774
3001
  return { error: 'statsEnabled 必须是布尔值' };
2775
3002
  patch.statsEnabled = input.statsEnabled;
2776
3003
  }
3004
+ if (input.allowProxyCommand !== undefined) {
3005
+ if (typeof input.allowProxyCommand !== 'boolean')
3006
+ return { error: 'allowProxyCommand 必须是布尔值' };
3007
+ /*
3008
+ * **只能降不能升**:提权只认宿主侧的环境变量(进程启动时采样一次),HTTP 侧给 true
3009
+ * 一律驳回并说清怎么做。这不是「输入不合法」,而是「没获授权」——所以文案必须同时
3010
+ * 给出变量名与「要重启宿主」,否则用户会对着一个点不动的开关反复点。
3011
+ */
3012
+ if (input.allowProxyCommand && !capabilityGranted(CAP_PROXY_COMMAND)) {
3013
+ return { error: capabilityDeniedMessage(CAP_PROXY_COMMAND, { inPlace: true }) };
3014
+ }
3015
+ patch.allowProxyCommand = input.allowProxyCommand;
3016
+ }
2777
3017
  for (const key of ['shell', 'term', 'colorTerm']) {
2778
3018
  if (input[key] === undefined)
2779
3019
  continue;
@@ -2847,6 +3087,30 @@ const plugin = definePlugin({
2847
3087
  setCredentialResolver(credCtx.credentials ?? null);
2848
3088
  return () => { setCredentialResolver(null); };
2849
3089
  });
3090
+ /*
3091
+ * 就地提权(见 kit 的 elevation.ts):卡片上点开关 → 宿主在确认目录里等一个随机名文件出现
3092
+ * → 授权写进 grant-store。
3093
+ *
3094
+ * `onGrantChange` 是这一段里**最容易漏、也最要紧**的一下:`setProxyCommandPolicy` 是终端 /
3095
+ * SFTP / 隧道 / 探针**四条建连路径共用**的模块级闸门,只写存储不重算就会留下两种半个状态——
3096
+ * - 授权侧:授权到了,代理命令仍然被拒(用户以为没生效,再去点开关);
3097
+ * - 撤销侧:记录没了,本机仍在跑连接簿里那条命令(这一侧是安全问题,不是体验问题)。
3098
+ * `applyPatch({})` 用当前 live 兜底重算一次即可(它本来就每次都写一次策略,见那里的注释)。
3099
+ */
3100
+ const elevation = createElevationManager({
3101
+ confirmDir: paths.confirmDir,
3102
+ store: grantStore,
3103
+ logger: { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) },
3104
+ logPrefix: '[dsh-tty]',
3105
+ onGrantChange: () => {
3106
+ applyPatch({});
3107
+ },
3108
+ });
3109
+ ctx.effect(() => {
3110
+ return () => {
3111
+ elevation.dispose();
3112
+ };
3113
+ }, 'dsh-tty: elevation cleanup');
2850
3114
  // webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
2851
3115
  ctx.inject(['webServer'], (webCtx) => {
2852
3116
  webCtx.effect(() => {
@@ -2873,8 +3137,7 @@ const plugin = definePlugin({
2873
3137
  kind: 'exact',
2874
3138
  path: '/api/dsh-tty/config',
2875
3139
  handler: async (req, res) => {
2876
- if (!isLoopbackHttp(req)) {
2877
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3140
+ if (!(await gateRoute(req, res))) {
2878
3141
  return;
2879
3142
  }
2880
3143
  if (req.method === 'GET') {
@@ -2913,13 +3176,74 @@ const plugin = definePlugin({
2913
3176
  writeJson(res, 200, { ok: true, config: snapshot() });
2914
3177
  },
2915
3178
  }));
2916
- // ~/.ssh/config 导入候选(连接簿):loopback 围栏,只回解析结果不落盘
3179
+ /*
3180
+ * 就地提权(`/elevate`、`/elevate/status`、`/elevate/revoke`)。
3181
+ *
3182
+ * 三条**全 POST**、**逐条**进 MUTATION_SUBROUTES(判据是「精确子路径 + POST」,只写一条
3183
+ * 另外两条就是裸的 → docker D144),且分发必须在证明检查**之后**——`sub` 一旦拿去分支,
3184
+ * 后面再补证明就晚了。
3185
+ *
3186
+ * 刻意**不走** registerGated:授权是宿主级的,插件当前禁用时也该能在卡片里提权/撤销
3187
+ * (卡片本身是禁用后唯一的恢复入口,与 /config 同一条理由)。
3188
+ */
3189
+ disposers.push(webServer.register({
3190
+ kind: 'prefix',
3191
+ path: '/api/dsh-tty/elevate',
3192
+ handler: async (req, res) => {
3193
+ const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/elevate'.length);
3194
+ if (!(await gateRoute(req, res, { mutation: isMutationSubroute('/api/dsh-tty/elevate', sub) }))) {
3195
+ return;
3196
+ }
3197
+ if (req.method !== 'POST') {
3198
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
3199
+ return;
3200
+ }
3201
+ if (sub !== '' && sub !== '/status' && sub !== '/revoke') {
3202
+ writeJson(res, 404, { error: 'not found' });
3203
+ return;
3204
+ }
3205
+ const body = await readJsonBody(req);
3206
+ if (body === undefined) {
3207
+ writeJson(res, 400, { error: 'invalid JSON body' });
3208
+ return;
3209
+ }
3210
+ // 白名单:别让请求方决定查哪个键(那张表是宿主侧的策略来源)
3211
+ if (body.capability !== undefined && body.capability !== CAP_PROXY_COMMAND.env) {
3212
+ writeJson(res, 400, { error: '未知能力: ' + String(body.capability) });
3213
+ return;
3214
+ }
3215
+ if (sub === '/status') {
3216
+ writeJson(res, 200, elevation.status(CAP_PROXY_COMMAND.env));
3217
+ return;
3218
+ }
3219
+ if (sub === '/revoke') {
3220
+ // 降权零门槛:撤销不要求任何确认(紧急刹车不等重启)。撤销带来的策略重算由
3221
+ // elevation 的 onGrantChange 负责——不重算就会留下「记录没了、本机还在跑命令」
3222
+ writeJson(res, 200, { revoked: elevation.revoke(CAP_PROXY_COMMAND.env) });
3223
+ return;
3224
+ }
3225
+ const result = elevation.begin(CAP_PROXY_COMMAND.env);
3226
+ if (result.status === 'rate-limited') {
3227
+ writeJson(res, 429, { error: '请求过于频繁,请稍后再试', retryAfterMs: result.retryAfterMs });
3228
+ return;
3229
+ }
3230
+ if (result.status === 'error') {
3231
+ writeJson(res, 500, { error: result.error });
3232
+ return;
3233
+ }
3234
+ // 只回状态与一次性命令;nonce 就在命令里,**不进日志**(见 elevation.ts 的不变量)
3235
+ writeJson(res, 200, result);
3236
+ },
3237
+ }));
3238
+ // ~/.ssh/config 导入候选(连接簿):loopback 围栏,只回解析结果不落盘。
3239
+ // 除了候选,还**如实回报丢弃了什么**(依赖跳板机 / 通配 / 无 User / 超上限):
3240
+ // 静默少列是本仓反复出现的一类缺陷,而「导进来的条目注定连不上」更难排——见
3241
+ // 项目级 ROADMAP 第 2 项。
2917
3242
  registerGated({
2918
3243
  kind: 'exact',
2919
3244
  path: '/api/dsh-tty/ssh-config',
2920
3245
  handler: async (req, res) => {
2921
- if (!isLoopbackHttp(req)) {
2922
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3246
+ if (!(await gateRoute(req, res))) {
2923
3247
  return;
2924
3248
  }
2925
3249
  if (req.method !== 'GET') {
@@ -2928,7 +3252,7 @@ const plugin = definePlugin({
2928
3252
  }
2929
3253
  try {
2930
3254
  const text = readFileSync(expandHome('~/.ssh/config'), 'utf8');
2931
- writeJson(res, 200, { ok: true, entries: parseSshConfig(text) });
3255
+ writeJson(res, 200, { ok: true, ...parseSshConfigDetailed(text) });
2932
3256
  }
2933
3257
  catch (error) {
2934
3258
  writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/config: ' + (error instanceof Error ? error.message : String(error)) });
@@ -2940,8 +3264,7 @@ const plugin = definePlugin({
2940
3264
  kind: 'exact',
2941
3265
  path: '/api/dsh-tty/env-vars',
2942
3266
  handler: async (req, res) => {
2943
- if (!isLoopbackHttp(req)) {
2944
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3267
+ if (!(await gateRoute(req, res))) {
2945
3268
  return;
2946
3269
  }
2947
3270
  if (req.method !== 'GET') {
@@ -2965,8 +3288,7 @@ const plugin = definePlugin({
2965
3288
  kind: 'exact',
2966
3289
  path: '/api/dsh-tty/known-hosts',
2967
3290
  handler: async (req, res) => {
2968
- if (!isLoopbackHttp(req)) {
2969
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3291
+ if (!(await gateRoute(req, res))) {
2970
3292
  return;
2971
3293
  }
2972
3294
  if (req.method !== 'GET') {
@@ -2994,8 +3316,8 @@ const plugin = definePlugin({
2994
3316
  kind: 'exact',
2995
3317
  path: '/api/dsh-tty/probe',
2996
3318
  handler: async (req, res) => {
2997
- if (!isLoopbackHttp(req)) {
2998
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3319
+ // 变更端点:这次请求真的拨号,且连接簿条目测试会当场记录主机指纹(TOFU)
3320
+ if (!(await gateRoute(req, res, { mutation: true }))) {
2999
3321
  return;
3000
3322
  }
3001
3323
  if (req.method !== 'POST') {
@@ -3024,6 +3346,22 @@ const plugin = definePlugin({
3024
3346
  }
3025
3347
  const auth = body.auth === 'key' || body.auth === 'password' ? body.auth : 'agent';
3026
3348
  const spec = { host, port, username, auth };
3349
+ // 对话框「试连」可能带跳板机(连接簿条目由客户端先行展开;跳板机同理)
3350
+ const probeJump = validateJumpSpec(body.jump);
3351
+ if (body.jump !== undefined && probeJump.error !== undefined) {
3352
+ writeJson(res, 200, { ok: false, error: probeJump.error });
3353
+ return;
3354
+ }
3355
+ if (probeJump.jump !== undefined)
3356
+ spec.jump = probeJump.jump;
3357
+ // 代理命令同理(对话框试连会带上当前填写值):形状非法就明确报错,别静默当没填
3358
+ const probeProxy = validateProxyCommand(body.proxyCommand ?? '');
3359
+ if (probeProxy.error !== undefined) {
3360
+ writeJson(res, 200, { ok: false, error: probeProxy.error });
3361
+ return;
3362
+ }
3363
+ if (probeProxy.proxyCommand !== undefined)
3364
+ spec.proxyCommand = probeProxy.proxyCommand;
3027
3365
  if (auth === 'key') {
3028
3366
  const keyPath = typeof body.keyPath === 'string' ? body.keyPath.trim() : '';
3029
3367
  if (keyPath === '') {
@@ -3056,8 +3394,7 @@ const plugin = definePlugin({
3056
3394
  kind: 'exact',
3057
3395
  path: '/api/dsh-tty/shells',
3058
3396
  handler: async (req, res) => {
3059
- if (!isLoopbackHttp(req)) {
3060
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3397
+ if (!(await gateRoute(req, res))) {
3061
3398
  return;
3062
3399
  }
3063
3400
  if (req.method !== 'GET') {
@@ -3072,8 +3409,7 @@ const plugin = definePlugin({
3072
3409
  kind: 'exact',
3073
3410
  path: '/api/dsh-tty/tunnels',
3074
3411
  handler: async (req, res) => {
3075
- if (!isLoopbackHttp(req)) {
3076
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3412
+ if (!(await gateRoute(req, res))) {
3077
3413
  return;
3078
3414
  }
3079
3415
  if (req.method !== 'GET') {
@@ -3093,11 +3429,11 @@ const plugin = definePlugin({
3093
3429
  kind: 'prefix',
3094
3430
  path: '/api/dsh-tty/sftp',
3095
3431
  handler: async (req, res) => {
3096
- if (!isLoopbackHttp(req)) {
3097
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3432
+ const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
3433
+ // 改状态的三个动作 + 上传要同源证明;/list 与 /download 只读(判据见 gateRoute)
3434
+ if (!(await gateRoute(req, res, { mutation: isMutationSubroute('/api/dsh-tty/sftp', sub) }))) {
3098
3435
  return;
3099
3436
  }
3100
- const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
3101
3437
  const jsonAction = ['/list', '/mkdir', '/rename', '/remove', '/download'].find((action) => action === sub);
3102
3438
  if (jsonAction !== undefined) {
3103
3439
  if (req.method !== 'POST') {
@@ -3253,11 +3589,11 @@ const plugin = definePlugin({
3253
3589
  kind: 'prefix',
3254
3590
  path: '/api/dsh-tty/local-fs',
3255
3591
  handler: async (req, res) => {
3256
- if (!isLoopbackHttp(req)) {
3257
- writeJson(res, 403, { error: 'forbidden: loopback-only' });
3592
+ const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
3593
+ // 改状态的四个动作(含起一个传输任务)要同源证明;/list 只读(判据见 gateRoute)
3594
+ if (!(await gateRoute(req, res, { mutation: isMutationSubroute('/api/dsh-tty/local-fs', sub) }))) {
3258
3595
  return;
3259
3596
  }
3260
- const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
3261
3597
  if (req.method !== 'POST') {
3262
3598
  writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
3263
3599
  return;
@@ -3458,6 +3794,8 @@ const plugin = definePlugin({
3458
3794
  }
3459
3795
  activeDisposers.push(tools.register(defineTool({
3460
3796
  name: 'tty_list',
3797
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
3798
+ isConcurrencySafe: () => true,
3461
3799
  description: '列出当前活跃的终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间)。用户开了终端面板后,用 tty_capture 读取某个 sid 的终端输出,用 tty_send 向该终端发送按键。',
3462
3800
  parameters: {},
3463
3801
  output: {
@@ -3500,6 +3838,9 @@ const plugin = definePlugin({
3500
3838
  },
3501
3839
  },
3502
3840
  async execute() {
3841
+ // D72:agent 第一次「看见」这些会话时把水位线落在当下——否则第一次
3842
+ // tty_expect 会把用户早先的历史输出当成「还没读过的输出」回扫过来
3843
+ sessions.forEach((session) => { ensureReadMark(session); });
3503
3844
  return { sessions: sessions.list() };
3504
3845
  },
3505
3846
  })));
@@ -3564,6 +3905,8 @@ const plugin = definePlugin({
3564
3905
  })));
3565
3906
  activeDisposers.push(tools.register(defineTool({
3566
3907
  name: 'tty_stats',
3908
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
3909
+ isConcurrencySafe: () => true,
3567
3910
  description: '读取某个终端会话所在机器的实时指标(CPU / 内存 / 磁盘 / TCP 连接数 / 网速 / 温度 / 在线时长)——本地会话取宿主机,SSH 会话取那台远程主机(另开一条非 PTY 通道,不影响终端)。部署、压测、排查「机器是不是满了」之前先看它。仅 Linux 远端字段齐全,Windows 远端部分字段可采,macOS/BSD 远端取不到。',
3568
3911
  parameters: {
3569
3912
  sid: { type: 'string', required: true, description: '会话 id(tty_list 提供)' },
@@ -3625,6 +3968,8 @@ const plugin = definePlugin({
3625
3968
  })));
3626
3969
  activeDisposers.push(tools.register(defineTool({
3627
3970
  name: 'tty_capture',
3971
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
3972
+ isConcurrencySafe: () => true,
3628
3973
  description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。',
3629
3974
  parameters: {
3630
3975
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
@@ -3663,6 +4008,11 @@ const plugin = definePlugin({
3663
4008
  if (session === undefined || session.closed)
3664
4009
  throw new Error(`会话不存在或已退出: ${input.sid}`);
3665
4010
  const useRaw = input.raw === true;
4011
+ // D72:读侧工具返回前推进水位线——这里读到的内容算「已读」,后续
4012
+ // tty_expect 不再把它们当未读回扫(想回看更早的内容再用本工具)。
4013
+ // tty_screen 刻意**不**推进:它是「当前可见屏幕」这一种表示,不是
4014
+ // 文本流,推进它会把 AI 从未在文本里看过的输出标记成已读。
4015
+ advanceReadMark(session);
3666
4016
  if (input.last === true) {
3667
4017
  const state = session.shellState;
3668
4018
  const last = state.lastCommand;
@@ -3689,6 +4039,8 @@ const plugin = definePlugin({
3689
4039
  })));
3690
4040
  activeDisposers.push(tools.register(defineTool({
3691
4041
  name: 'tty_screen',
4042
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4043
+ isConcurrencySafe: () => true,
3692
4044
  description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。',
3693
4045
  parameters: {
3694
4046
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
@@ -3735,7 +4087,9 @@ const plugin = definePlugin({
3735
4087
  })));
3736
4088
  activeDisposers.push(tools.register(defineTool({
3737
4089
  name: 'tty_expect',
3738
- description: '在某个终端面板会话(tty_list 提供 sid)的后续输出中等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。匹配到立即返回 matched:true 与周边输出;超时不抛错,返回 matched:false + 尾部输出供判断重试或放弃;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。',
4090
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4091
+ isConcurrencySafe: () => true,
4092
+ description: '在某个终端面板会话(tty_list 提供 sid)等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。**先回溯**还没被读过的输出(含「上一条命令」的完整输出),再等后续输出——所以命令瞬间跑完也不会白等;匹配到立即返回 matched:true(matchedFrom 说明匹配来自哪里:live=本次等待期间新产生 / last=上一条命令的输出 / buffered=此前已到达的缓冲输出)与周边输出。超时不抛错,返回 matched:false + 尾部输出;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。注意:只对「还没读过的输出」负责——要回看更早的内容用 tty_capture。',
3739
4093
  parameters: {
3740
4094
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
3741
4095
  pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
@@ -3750,12 +4104,21 @@ const plugin = definePlugin({
3750
4104
  timedOut: { type: 'boolean', required: true },
3751
4105
  text: { type: 'string', required: true },
3752
4106
  exitCode: { type: 'number' },
4107
+ matchedFrom: { type: 'string' },
3753
4108
  },
3754
4109
  },
3755
4110
  render: (_args, value) => {
3756
4111
  const v = value;
3757
- if (v.matched === true)
3758
- return [{ type: 'text', text: `已匹配到等待的模式:\n\n${v.text ?? ''}` }];
4112
+ if (v.matched === true) {
4113
+ const from = v.matchedFrom === 'last'
4114
+ ? '(回溯自「上一条命令」的输出)'
4115
+ : v.matchedFrom === 'buffered' ? '(回溯自此前已到达、还没读过的缓冲输出)' : '';
4116
+ return [{ type: 'text', text: `已匹配到等待的模式${from}:\n\n${v.text ?? ''}` }];
4117
+ }
4118
+ if (v.timedOut === true && (v.text ?? '').trim() === '') {
4119
+ // D72:这段空白此前被当成「命令没执行」——命令若是瞬间完成的,它早就跑完了
4120
+ return [{ type: 'text', text: '等待超时,且注册之后没有任何新输出。命令若是瞬间完成的,它早已在开始等待之前跑完:用 tty_capture{last:true} 复核那条命令的输出与退出码,别把这段空白当成「没执行」。' }];
4121
+ }
3759
4122
  const why = v.timedOut === true ? '等待超时' : `命令已结束(exitCode=${String(v.exitCode ?? '?')})但未出现匹配`;
3760
4123
  return [{ type: 'text', text: `${why}。尾部输出:\n\n${v.text ?? ''}` }];
3761
4124
  },
@@ -3787,36 +4150,65 @@ const plugin = definePlugin({
3787
4150
  expectCounts.set(session, inflight + 1);
3788
4151
  return await new Promise((resolve) => {
3789
4152
  const startedAt = Date.now();
3790
- const startedInCommand = session.shellState.inCommand;
4153
+ const state = session.shellState;
4154
+ const startedInCommand = state.inCommand;
3791
4155
  // 尾部窗口:匹配只看最近 16KB,acc 全量囤积对刷屏会话可涨到数百 MB
3792
4156
  let acc = '';
3793
4157
  let settled = false;
4158
+ let timer = null;
3794
4159
  const decoder = new StringDecoder('utf8');
3795
4160
  const output = session.handle.output;
3796
- const finish = (result) => {
4161
+ /**
4162
+ * 结算时的水位线(D72):
4163
+ * - 匹配成功 / 会话结束 → 推进到当下(这段输出已经交回给 AI 了);
4164
+ * - **超时不动**:那次只把注册之后的增量交回去,注册前就在缓冲里的
4165
+ * 未读输出没被读过,而且下一次换个 pattern 还要靠它回溯——吞掉
4166
+ * 它等于「等一次没等到,这段输出就作废了」。
4167
+ */
4168
+ const finish = (result, consumeBacklog = true) => {
3797
4169
  if (settled)
3798
4170
  return;
3799
4171
  settled = true;
3800
- clearTimeout(timer);
4172
+ if (timer !== null)
4173
+ clearTimeout(timer);
3801
4174
  output.off('data', onData);
3802
4175
  expectCounts.set(session, Math.max(0, (expectCounts.get(session) ?? 1) - 1));
4176
+ if (consumeBacklog)
4177
+ advanceReadMark(session);
3803
4178
  resolve(result);
3804
4179
  };
3805
- const onData = (chunk) => {
4180
+ function onData(chunk) {
3806
4181
  acc = (acc + decoder.write(chunk)).slice(-64 * 1024);
3807
4182
  const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
3808
- if (re.test(hay)) {
3809
- finish({ matched: true, timedOut: false, text: cleanAnsiTail(hay.slice(-6 * 1024)) });
4183
+ if (testPattern(re, hay)) {
4184
+ finish({ matched: true, timedOut: false, matchedFrom: 'live', text: cleanAnsiTail(hay.slice(-6 * 1024)) });
3810
4185
  return;
3811
4186
  }
3812
4187
  // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
3813
- const state = session.shellState;
3814
4188
  if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
3815
4189
  finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
3816
4190
  }
3817
- };
3818
- const timer = setTimeout(() => {
3819
- finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
4191
+ }
4192
+ // ── 注册**之前**就已到达的输出(D72)─────────────────────────
4193
+ // 这段此前完全不匹配:acc 从空开始,所以「命令瞬间完成」时标记
4194
+ // 早就躺在缓冲区里、acc 里永远没有它 → 白等满超时、还只返回空白。
4195
+ ensureReadMark(session);
4196
+ // ① 上一条命令的完整输出(B..D 窗口:无回显、无提示符、自带退出码)。
4197
+ // 两个闸门保证它「还没被读过」:不晚于最后一次输入(否则是上一条
4198
+ // 命令的旧结果),且晚于水位线时刻(否则 AI 已经用 capture 看过了)。
4199
+ const last = state.lastCommand;
4200
+ if (!state.inCommand && last !== null && last.endedAt > session.readMarkAt && last.endedAt >= session.lastInputAt && testPattern(re, last.output)) {
4201
+ finish({ matched: true, timedOut: false, matchedFrom: 'last', ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), text: cleanAnsiTail(last.output.slice(-6 * 1024)) });
4202
+ return;
4203
+ }
4204
+ // ② 泛化:水位线之后的未读缓冲(长驻输出落在多条命令之间、无 shell 集成……)
4205
+ const backlog = unreadRegion(session);
4206
+ if (backlog !== '' && testPattern(re, backlog)) {
4207
+ finish({ matched: true, timedOut: false, matchedFrom: 'buffered', text: cleanAnsiTail(backlog.slice(-6 * 1024)) });
4208
+ return;
4209
+ }
4210
+ timer = setTimeout(() => {
4211
+ finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) }, false);
3820
4212
  }, timeoutMs);
3821
4213
  timer.unref?.();
3822
4214
  output.on('data', onData);
@@ -3857,12 +4249,17 @@ const plugin = definePlugin({
3857
4249
  if (session === undefined || session.closed)
3858
4250
  throw new Error(`会话不存在或已退出: ${input.sid}`);
3859
4251
  session.lastInputAt = Date.now();
4252
+ // D72:只初始化水位线,**不推进**——刚发出去的这条命令的输出 AI 还没看见;
4253
+ // 但这一刻之前的积压不该被第一次 expect 当成「未读」回扫。
4254
+ ensureReadMark(session);
3860
4255
  await session.handle.write(input.data);
3861
4256
  return { ok: true, sent: input.data.length };
3862
4257
  },
3863
4258
  })));
3864
4259
  activeDisposers.push(tools.register(defineTool({
3865
4260
  name: 'tunnel_list',
4261
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4262
+ isConcurrencySafe: () => true,
3866
4263
  description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 插件配置 → 终端面板 卡片维护。',
3867
4264
  parameters: {},
3868
4265
  output: {
@@ -3883,6 +4280,7 @@ const plugin = definePlugin({
3883
4280
  bookName: { type: 'string', required: true },
3884
4281
  state: { type: 'string', required: true },
3885
4282
  error: { type: 'string' },
4283
+ fatal: { type: 'boolean', required: true },
3886
4284
  connections: { type: 'number', required: true },
3887
4285
  totalConnections: { type: 'number', required: true },
3888
4286
  },
@@ -3896,7 +4294,10 @@ const plugin = definePlugin({
3896
4294
  if (tunnels.length === 0)
3897
4295
  return [{ type: 'text', text: '当前没有配置端口转发隧道(插件配置 → 终端面板 卡片可添加)' }];
3898
4296
  const text = '端口转发隧道:' + tunnels.map((t) => {
3899
- const tail = t.error !== null && t.error !== undefined ? `(错误: ${t.error})` : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
4297
+ // fatal 单独措辞(D58):这类故障不会自愈,不说清就会一直等「正在连」
4298
+ const tail = t.error !== null && t.error !== undefined
4299
+ ? (t.fatal === true ? `(错误: ${t.error} —— 不会自动重试,需修配置)` : `(错误: ${t.error})`)
4300
+ : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
3900
4301
  return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
3901
4302
  }).join('');
3902
4303
  return [{ type: 'text', text }];
@@ -3917,6 +4318,7 @@ const plugin = definePlugin({
3917
4318
  rule: t.rule,
3918
4319
  state: t.state,
3919
4320
  ...(t.error === null || t.error === undefined ? {} : { error: t.error }),
4321
+ fatal: t.fatal,
3920
4322
  connections: t.connections,
3921
4323
  totalConnections: t.totalConnections,
3922
4324
  })),
@@ -3936,6 +4338,8 @@ const plugin = definePlugin({
3936
4338
  };
3937
4339
  activeDisposers.push(tools.register(defineTool({
3938
4340
  name: 'sftp_list',
4341
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4342
+ isConcurrencySafe: () => true,
3939
4343
  description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前;isSymlink 区分符号链接与真目录)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。默认最多列 500 项(超限 truncated:true,可按子目录分批)。',
3940
4344
  parameters: {
3941
4345
  book: { type: 'string', required: true, description: 'SSH 连接簿条目名(插件配置 → 终端面板 维护)' },
@@ -3987,6 +4391,8 @@ const plugin = definePlugin({
3987
4391
  })));
3988
4392
  activeDisposers.push(tools.register(defineTool({
3989
4393
  name: 'sftp_read',
4394
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4395
+ isConcurrencySafe: () => true,
3990
4396
  description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB,非法值直接报错);offset 可从指定字节起读(配合 maxBytes 分页拿到大文件尾部);二进制判定用 NUL + 非法 UTF-8 占比双重检测,拒绝时说明原因。',
3991
4397
  parameters: {
3992
4398
  book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
@@ -4194,6 +4600,8 @@ const plugin = definePlugin({
4194
4600
  })));
4195
4601
  activeDisposers.push(tools.register(defineTool({
4196
4602
  name: 'sftp_tree',
4603
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4604
+ isConcurrencySafe: () => true,
4197
4605
  description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
4198
4606
  parameters: {
4199
4607
  book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },