@hyzyn/dsh-tty 0.24.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -74,6 +74,7 @@ import WebSocket from 'ws';
74
74
  import xtermHeadless from '@xterm/headless';
75
75
  declare const HeadlessTerminal: typeof xtermHeadless.Terminal;
76
76
  type HeadlessTerminal = InstanceType<typeof HeadlessTerminal>;
77
+ import type { TierClass } from '@hyzyn/dsh-kit';
77
78
  import type { HostKeyRecord, SshHostEntry, TermHandle } from './ssh.js';
78
79
  import type { TunnelSpec } from './tunnels.js';
79
80
  import type { StatsFrame } from './stats.js';
@@ -179,6 +180,9 @@ export declare const Config: z;
179
180
  * 条数上限的行为护栏。
180
181
  */
181
182
  export declare const EXITED_RETAIN_MS: number;
183
+ export declare const TTY_TIER_CLASS: Record<string, TierClass>;
184
+ /** 本插件 agent 工具的名单前缀(档位闸只听这些名字,bash / 其它插件工具零介入)。 */
185
+ export declare const TTY_TIER_PREFIXES: readonly ["tty_", "sftp_", "tunnel_"];
182
186
  /**
183
187
  * 只读保留的会话数上限(超出按最旧淘汰,见 SessionManager.capExited)。
184
188
  *
package/lib/index.js CHANGED
@@ -15,13 +15,13 @@ const HeadlessTerminal = xtermHeadless.Terminal;
15
15
  import { definePlugin, dshHome as resolveDshHome, getService, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
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
+ import { sharedGrantStore, auditLoadedGrants, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
19
19
  import { probeSsh } from './probe.js';
20
20
  import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath } from './shell-integration.js';
21
21
  import { KEY_VOCABULARY, resolveKeys } from './keys.js';
22
22
  import { parseSshConfigDetailed } from './ssh-config.js';
23
23
  import { parseKnownHostsDetailed } from './known-hosts.js';
24
- import { TunnelManager } from './tunnels.js';
24
+ import { TunnelManager, probeTunnelPorts } from './tunnels.js';
25
25
  import { SftpManager } from './sftp.js';
26
26
  import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
27
27
  import { buildRemoteStatsCommand, buildWindowsStatsCommand, hasStatsData, localStatsSampler, parseStatsLine } from './stats.js';
@@ -55,6 +55,11 @@ const SSH_HOST_SCHEMA = z.object({
55
55
  passphrase: z.string().default(''),
56
56
  password: z.string().default(''),
57
57
  agentForward: z.boolean().default(false),
58
+ /**
59
+ * TOFU 定位别名(OpenSSH `HostKeyAlias` 语义):只影响主机密钥**记在哪条记录里**,
60
+ * 不改连接地址。缺省空串 = 按 host 定位(与从前一致)。见 SshSpec 的说明。
61
+ */
62
+ hostKeyAlias: z.string().default(''),
58
63
  /** 经跳板机连接(ProxyJump 单跳);缺省 = 直连。 */
59
64
  jump: SSH_JUMP_SCHEMA,
60
65
  /**
@@ -164,6 +169,37 @@ const DEFAULT_RECONNECT_GRACE_SEC = 120;
164
169
  * 条数上限的行为护栏。
165
170
  */
166
171
  export const EXITED_RETAIN_MS = Number.POSITIVE_INFINITY;
172
+ /*
173
+ * 档位闸的分类表(docs/permission-tier-plan.md §2.1):`read` 纯读;`write` 有界变更(远程
174
+ * ≤1MB 文本 / 目录 / 停隧道转发);`exec` 无界命令面——tty_open / tty_run / tty_send 能在本机
175
+ * 或远程 shell 里跑任意命令(tty_send 是往**活 shell** 里敲命令,同样是执行面),tunnel_start
176
+ * 在本机开监听端口。名单必须与 registerAll 注册的工集一一对应:对账由 test/tier-gate.test.ts
177
+ * 从注册处的启动日志行现算(漏分类 → 红)。悬而未决的格子(R2 tty_close / R3 tunnel_start)
178
+ * 按方案推荐值落,改格子就是改这张表。
179
+ */
180
+ export const TTY_TIER_CLASS = {
181
+ tty_list: 'read',
182
+ tty_capture: 'read',
183
+ tty_screen: 'read',
184
+ tty_expect: 'read',
185
+ tty_stats: 'read',
186
+ tunnel_list: 'read',
187
+ sftp_list: 'read',
188
+ sftp_read: 'read',
189
+ sftp_tree: 'read',
190
+ tty_close: 'read',
191
+ sftp_write: 'write',
192
+ sftp_mkdir: 'write',
193
+ sftp_rename: 'write',
194
+ sftp_remove: 'write',
195
+ tunnel_stop: 'write',
196
+ tty_open: 'exec',
197
+ tty_run: 'exec',
198
+ tty_send: 'exec',
199
+ tunnel_start: 'exec',
200
+ };
201
+ /** 本插件 agent 工具的名单前缀(档位闸只听这些名字,bash / 其它插件工具零介入)。 */
202
+ export const TTY_TIER_PREFIXES = ['tty_', 'sftp_', 'tunnel_'];
167
203
  /**
168
204
  * 只读保留的会话数上限(超出按最旧淘汰,见 SessionManager.capExited)。
169
205
  *
@@ -235,7 +271,7 @@ const STATS_RETRY_MAX_MS = 300_000;
235
271
  * 调用等得起,而写死的小值会把慢首帧误报成「采不到」。
236
272
  */
237
273
  const STATS_ONESHOT_TIMEOUT_MS = 15_000;
238
- 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,以及 `running`——这个会话**有没有命令在跑**,文本里三态写作 `[空闲]` / `[运行中——现在别往里发命令]` / `[命令状态未知]`;命令状态未知**不是**没在跑),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键(控制键/方向键用具名 `keys`,别在 data 里拼转义序列),tty_run 一次调用跑完一条命令并直接拿回尾部输出+退出码(想省掉 open→等→capture→close 四步时用它),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} 拿结果。';
274
+ 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,以及 `running`——这个会话**有没有命令在跑**,文本里三态写作 `[空闲]` / `[运行中——现在别往里发命令]` / `[命令状态未知]`;命令状态未知**不是**没在跑),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键(控制键/方向键用具名 `keys`,别在 data 里拼转义序列),tty_run 一次调用跑完一条命令并直接拿回尾部输出+退出码(想省掉 open→等→capture→close 四步时用它),tunnel_list 列出端口转发隧道状态、tunnel_start / tunnel_stop 启停一条——操作会实时显示在用户终端里。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} 拿结果。';
239
275
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
240
276
  export function wrapLocalPty(handle) {
241
277
  let resizeWarned = false;
@@ -1205,6 +1241,7 @@ function sanitizeSshHosts(input) {
1205
1241
  passphrase: typeof raw.passphrase === 'string' ? raw.passphrase : '',
1206
1242
  password: typeof raw.password === 'string' ? raw.password : '',
1207
1243
  agentForward: raw.agentForward === true,
1244
+ hostKeyAlias: typeof raw.hostKeyAlias === 'string' ? raw.hostKeyAlias.trim() : '',
1208
1245
  persist: raw.persist === true,
1209
1246
  });
1210
1247
  // 跳板机:清洗不出可用对象(host 为空)就当没配——不给下游留半个对象
@@ -1252,6 +1289,21 @@ function validateSshHosts(input) {
1252
1289
  if (raw.persist !== undefined && typeof raw.persist !== 'boolean') {
1253
1290
  return { error: 'sshHosts.persist 必须是布尔值' };
1254
1291
  }
1292
+ if (raw.hostKeyAlias !== undefined && typeof raw.hostKeyAlias !== 'string') {
1293
+ return { error: 'sshHosts.hostKeyAlias 必须是字符串' };
1294
+ }
1295
+ /*
1296
+ * 别名不能含空白(OpenSSH 的 HostKeyAlias 是单个 host 形态)。
1297
+ * 不禁其它字符:它会原样进 hostKeys 记录,而 hostKeys 只是本地存储的键——
1298
+ * 但含 `:` 会被 `host:port` 的拼接读成端口,直接在这里拦掉比事后排查便宜。
1299
+ */
1300
+ if (typeof raw.hostKeyAlias === 'string' && raw.hostKeyAlias.trim() !== '') {
1301
+ const alias = raw.hostKeyAlias.trim();
1302
+ if (/\s/.test(alias))
1303
+ return { error: 'sshHosts.hostKeyAlias 不能含空白' };
1304
+ if (alias.includes(':'))
1305
+ return { error: 'sshHosts.hostKeyAlias 不能含冒号(端口由 port 字段给)' };
1306
+ }
1255
1307
  if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
1256
1308
  return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
1257
1309
  }
@@ -2582,7 +2634,8 @@ export class TtyServer {
2582
2634
  }
2583
2635
  this.retireStaleExited(sid); // D77:同 sid 上的只读保留态先摘掉,别让 add() 把它顶成泄漏
2584
2636
  // 持久化(0.10.0):配置 persistence=tmux 且帧带 persist 时,spawn 包装层
2585
- // 换成 `exec tmux -L dsh-tty -A -s <名>`(tmux 托管);tmux 未安装则降级
2637
+ // 换成 `exec tmux -L <socket> -A -s <名>`(tmux 托管;socket 按 profile 区分,
2638
+ // 见 tmuxSocketName,tty D60);tmux 未安装则降级
2586
2639
  // 普通会话并回灰字提示。持久名稳定(客户端生成、随标签规格保存),
2587
2640
  // 宿主重启后重开标签按同名 attach 回原 tmux 会话
2588
2641
  // 命令标签(0.14.0):直接跑一条命令,不做 tmux 持久化(命令短命)
@@ -3297,6 +3350,10 @@ function mergeSshSpec(findSshHost, name, inline) {
3297
3350
  passphrase: typeof inline.passphrase === 'string' && inline.passphrase !== '' ? inline.passphrase : profile?.passphrase,
3298
3351
  password: typeof inline.password === 'string' && inline.password !== '' ? inline.password : profile?.password,
3299
3352
  agentForward: typeof inline.agentForward === 'boolean' ? inline.agentForward : profile?.agentForward ?? false,
3353
+ // TOFU 别名同款「内联优先、否则用连接簿那一份」:空串 = 没给(不改连接簿的值)
3354
+ hostKeyAlias: typeof inline.hostKeyAlias === 'string' && inline.hostKeyAlias.trim() !== ''
3355
+ ? inline.hostKeyAlias.trim()
3356
+ : profile?.hostKeyAlias ?? '',
3300
3357
  };
3301
3358
  /*
3302
3359
  * 跳板机:内联给了就以它为准(清洗不出可用对象则退回连接簿那一份),否则用连接簿的。
@@ -3499,7 +3556,7 @@ const plugin = definePlugin({
3499
3556
  * 「静默继承」必须在日志里留下痕迹(kit D09)。刻意放在 `enabled` 判定**之前**:授权是宿主级的,
3500
3557
  * 与插件这次是否启用无关。
3501
3558
  */
3502
- auditLoadedGrants(grantStore, [CAP_PROXY_COMMAND.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-tty]');
3559
+ auditLoadedGrants(grantStore, [CAP_PROXY_COMMAND.env], '[dsh-tty]');
3503
3560
  // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
3504
3561
  const config = plainConfig((rawConfig ?? {}));
3505
3562
  if (config?.enabled === false)
@@ -3581,6 +3638,20 @@ const plugin = definePlugin({
3581
3638
  // (连接簿名或内联字段),连接簿凭证热改后天然生效
3582
3639
  const sftpManager = new SftpManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore);
3583
3640
  const server = new TtyServer(ctx, sessions, live, hostKeyStore, trackPersistSession);
3641
+ /*
3642
+ * 隧道配置回写(agent 的 tunnel_start / tunnel_stop 用):与卡片保存走**同一个**
3643
+ * settings 通道(`scope.update` → 热应用 → applyPatch → reconcile),所以配置是唯一
3644
+ * 真相源,agent 的操作与用户在卡片上点那个勾结果完全一致。settings 服务缺失
3645
+ * (单测 / 极早期)时回调保持 null,`setEnabled` 退化成「本次进程内生效」。
3646
+ */
3647
+ tunnelManager.setPersist((specs) => {
3648
+ const scope = settingsScope;
3649
+ if (scope === undefined)
3650
+ return;
3651
+ void Promise.resolve(scope.update({ tunnels: specs })).catch((error) => {
3652
+ console.warn('[dsh-tty] 隧道配置回写失败: ' + (error instanceof Error ? error.message : String(error)));
3653
+ });
3654
+ });
3584
3655
  const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false, sftpStyle: config?.sftpStyle === 'dual' ? 'dual' : 'dialog' };
3585
3656
  let settingsScope;
3586
3657
  // 工具/公告的重注册钩子:真正的实现由各自的注入 effect 挂载时回填。
@@ -3909,6 +3980,27 @@ const plugin = definePlugin({
3909
3980
  return;
3910
3981
  }
3911
3982
  const patch = normalized.patch ?? {};
3983
+ /*
3984
+ * 保存前端口探测(tty D60 前半):只警告、不拒绝。
3985
+ *
3986
+ * **必须在 `applyPatch` 之前探**(这一点是实测出来的,不是推演):applyPatch 会
3987
+ * 立刻 `reconcile`,于是本进程自己刚起来的隧道已经占住了那个端口——探测就会把
3988
+ * 「自己占的」报成冲突。实测形状:往一个**空闲**端口新增一条隧道,回的是
3989
+ * `warnings: ["…已被占用…"]`(假警告,而且是最常见的那条路径)。
3990
+ * 豁免集取**改动前**的 `localPortsInUse()`:这正是「编辑一条在跑的隧道」与
3991
+ * 「删掉 A、同端口加 B」两种合法情形。
3992
+ *
3993
+ * 为什么不能拒(400):这条路由是**整表提交**(卡片把全部配置一起发上来),
3994
+ * 配置里已经躺着一条重名端口的隧道时(老配置 / 另一个窗口写的),硬拒会把
3995
+ * 用户锁死在「任何一项都存不下去」上——而修它恰恰要先能保存。所以探测结果
3996
+ * 走**响应里的 warnings**,由卡片点名通报(客户端只认 `data.warnings`)。
3997
+ *
3998
+ * 只在这次真的提交了 tunnels 时探:其余字段的保存与端口无关,白探一轮是
3999
+ * 白白拖慢每一次保存(探测含一次 listen/close)。
4000
+ */
4001
+ const warnings = patch.tunnels !== undefined
4002
+ ? await probeTunnelPorts(patch.tunnels, undefined, tunnelManager.localPortsInUse())
4003
+ : [];
3912
4004
  const scope = settingsScope;
3913
4005
  if (scope !== undefined) {
3914
4006
  try {
@@ -3923,7 +4015,7 @@ const plugin = definePlugin({
3923
4015
  }
3924
4016
  // 无 settings 服务(或 stub)时直接应用;有服务时也再应用一次(幂等)
3925
4017
  applyPatch(patch);
3926
- writeJson(res, 200, { ok: true, config: snapshot() });
4018
+ writeJson(res, 200, { ok: true, config: snapshot(), ...(warnings.length > 0 ? { warnings } : {}) });
3927
4019
  },
3928
4020
  }));
3929
4021
  /*
@@ -4367,15 +4459,49 @@ const plugin = definePlugin({
4367
4459
  writeJson(res, 200, { ok: true });
4368
4460
  return;
4369
4461
  }
4370
- // download:路径必填(无 home 兜底),流式回包
4462
+ /*
4463
+ * download:路径必填(无 home 兜底),流式回包。
4464
+ *
4465
+ * **offset 续传**(0.25.0):`offset > 0` 时从该字节起回包,状态行用 206 +
4466
+ * `content-range`——浏览器侧据此知道「这是个片段」,也能自己续。此前
4467
+ * `openDownload` 早就支持 `offset`(agent 的 sftp_read 分页在用),但**路由
4468
+ * 没接**:面板下载一旦断线就只能从头再来。这是「能力已有、只差接线」的一档。
4469
+ *
4470
+ * offset 越界(≥ 文件大小)按 416 明确回报,而不是回一个 0 字节的 200——
4471
+ * 后者会让客户端把「文件是空的」与「要的东西已经在本地了」混成一件事。
4472
+ * 目录 / 不存在仍在 `openDownload` 里抛(下面的 catch 分流 404 / 500)。
4473
+ */
4371
4474
  const target = strField('path');
4372
4475
  if (target === '')
4373
4476
  throw new Error('path 必填');
4374
- const { stream, size } = await sftpManager.openDownload(spec, target);
4375
- res.writeHead(200, {
4477
+ const rawOffset = Number(body.offset ?? 0);
4478
+ /*
4479
+ * offset 非法 = **调用方给错了**,按 400 回(而不是落到下面那个 500)。
4480
+ * 这条分支是新增的,所以自己把状态码说清楚,不去改那层共用的
4481
+ * 「业务失败一律 500」的口径(那会连带改掉其它动作的语义)。
4482
+ */
4483
+ if (!Number.isInteger(rawOffset) || rawOffset < 0) {
4484
+ writeJson(res, 400, { error: 'offset 必须是非负整数' });
4485
+ return;
4486
+ }
4487
+ const offset = rawOffset;
4488
+ const { stream, size } = await sftpManager.openDownload(spec, target, offset > 0 ? { offset } : undefined);
4489
+ if (offset > 0 && size !== null && offset >= size) {
4490
+ // 释放这一条读流:416 之后没人再读它,留着会占着连接池的通道
4491
+ stream.destroy();
4492
+ res.writeHead(416, {
4493
+ 'content-type': 'text/plain; charset=utf-8',
4494
+ 'content-range': `bytes */${String(size)}`,
4495
+ });
4496
+ res.end(`offset ${String(offset)} 已到达或越过文件末尾(${String(size)} 字节)`);
4497
+ return;
4498
+ }
4499
+ const remaining = size !== null ? size - offset : null;
4500
+ res.writeHead(offset > 0 ? 206 : 200, {
4376
4501
  'content-type': 'application/octet-stream',
4377
4502
  'content-disposition': contentDispositionValue(remoteBasename(target)),
4378
- ...(size !== null ? { 'content-length': String(size) } : {}),
4503
+ ...(remaining !== null ? { 'content-length': String(remaining) } : {}),
4504
+ ...(offset > 0 && size !== null ? { 'content-range': `bytes ${String(offset)}-${String(size - 1)}/${String(size)}` } : {}),
4379
4505
  });
4380
4506
  // 客户端中断或写流失败都要回收 SFTP 读流,避免连接池通道悬挂
4381
4507
  res.on('close', () => {
@@ -4652,9 +4778,11 @@ const plugin = definePlugin({
4652
4778
  }, 'dsh-tty: settings');
4653
4779
  });
4654
4780
  // agent 工具集(P1):tty_list / tty_open / tty_close / tty_stats / tty_capture / tty_send …
4655
- // 信任模型:与 bash 工具同权(agent 本就能执行任意命令),不额外加确认层;
4656
- // agent 对终端的操作会实时出现在浏览器面板里(同一 PTY),天然可被用户观察。
4657
- // inject: ['tools'] 声明后(见上方),ctx.get('tools') 才能解析到服务。
4781
+ // 信任模型(2026-10-07 起,档位闸落地):注册仍只受 enabled 门控,但**每次调用**受会话权限
4782
+ // 档位约束——受限档(仅可查看 / 工作区内修改)下 write/exec 类要经宿主 approval 服务逐次
4783
+ // 确认,完全权限档零询问(机制见 kit 的 tier-gate.ts,方案见 docs/permission-tier-plan.md)。
4784
+ // 「agent 对终端的操作实时出现在面板里」的可见性仍然成立,但不再是唯一的监督手段——
4785
+ // 旧注释「与 bash 工具同权、不额外加确认层」描述的是档位闸落地前的模型,已作废。
4658
4786
  const toolsHost = ctx.get('tools');
4659
4787
  if (toolsHost !== undefined) {
4660
4788
  ctx.effect(() => {
@@ -5399,6 +5527,66 @@ const plugin = definePlugin({
5399
5527
  };
5400
5528
  },
5401
5529
  })));
5530
+ /*
5531
+ * 隧道启停(agent 侧,0.25.0)——`tunnel_list` 只读,此前「停掉 → 重开 → 再看」
5532
+ * 这个诊断闭环只能让用户去卡片上点。
5533
+ *
5534
+ * 两个工具刻意**分成两个**(而不是一个 `tunnel_action(name, action)`):启停都是
5535
+ * 真变更(会掐断/建立真连接),合成一个工具会让「改配置」这件事在模型眼里可合并
5536
+ * 成一次可重试的调用;分开后每个名字只表达一件事,`enabled` 这个布尔也没有翻转歧义。
5537
+ *
5538
+ * 两者都**改配置**(见 TunnelManager.setEnabled 的说明):与卡片上那个勾同一条路,
5539
+ * 写完 settings 热应用 → reconcile,配置始终是唯一真相源。
5540
+ */
5541
+ for (const [toolName, enable, verb] of [['tunnel_start', true, '启动'], ['tunnel_stop', false, '停止']]) {
5542
+ activeDisposers.push(tools.register(defineTool({
5543
+ name: toolName,
5544
+ description: `${verb}一条端口转发隧道(name 来自 tunnel_list)。**会改写配置**——与在 插件配置 → 终端面板 卡片上勾选/取消「启用」是同一件事,所以是持久的、用户看得到。返回的是操作后的状态快照:state 通常还是 connecting(拨号异步),要最终结论稍后 tunnel_list。`,
5545
+ parameters: {
5546
+ name: { type: 'string', required: true, description: '隧道名(tunnel_list 的 name 字段,形如 <连接簿条目>-L<端口>)' },
5547
+ },
5548
+ output: {
5549
+ schema: {
5550
+ type: 'object',
5551
+ additionalProperties: false,
5552
+ properties: {
5553
+ name: { type: 'string', required: true },
5554
+ enabled: { type: 'boolean', required: true },
5555
+ state: { type: 'string', required: true },
5556
+ rule: { type: 'string', required: true },
5557
+ bookName: { type: 'string', required: true },
5558
+ connections: { type: 'number', required: true },
5559
+ error: { type: 'string' },
5560
+ },
5561
+ },
5562
+ render: (args, value) => {
5563
+ const a = args;
5564
+ const v = value;
5565
+ const tail = typeof v.error === 'string' && v.error !== '' ? `(${v.error})` : '';
5566
+ return [{ type: 'text', text: `隧道「${String(a.name ?? '?')}」已${verb}:${String(v.rule ?? '')} · ${String(v.state ?? '')}${tail}` }];
5567
+ },
5568
+ },
5569
+ async execute(args) {
5570
+ const input = args;
5571
+ if (typeof input.name !== 'string' || input.name.trim() === '')
5572
+ throw new Error('name 必须是非空字符串(隧道名来自 tunnel_list)');
5573
+ const result = tunnelManager.setEnabled(input.name.trim(), enable);
5574
+ if (!result.ok)
5575
+ throw new Error(result.error);
5576
+ const t = result.status;
5577
+ return {
5578
+ name: t.name,
5579
+ enabled: t.enabled,
5580
+ state: t.state,
5581
+ rule: t.rule,
5582
+ bookName: t.bookName,
5583
+ connections: t.connections,
5584
+ // error 键必须「不存在」而不是 undefined(同 tunnel_list 的 D52 教训)
5585
+ ...(t.error === null || t.error === undefined ? {} : { error: t.error }),
5586
+ };
5587
+ },
5588
+ })));
5589
+ }
5402
5590
  // —— SFTP 文件传输工具(0.7.0)——
5403
5591
  // 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
5404
5592
  // 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
@@ -5746,8 +5934,14 @@ const plugin = definePlugin({
5746
5934
  return result;
5747
5935
  },
5748
5936
  })));
5937
+ // 档位闸与工具同生命周期:enabled 热切换撤工具时一并撤下(activeDisposers 统一管理)。
5938
+ activeDisposers.push(attachTierGate(ctx, {
5939
+ pkg: 'tty',
5940
+ prefixes: TTY_TIER_PREFIXES,
5941
+ classify: (tool) => TTY_TIER_CLASS[tool],
5942
+ }));
5749
5943
  stateRef.toolsRegistered = true;
5750
- console.log('[dsh-tty] agent tools registered (tty_list, tty_open, tty_close, tty_run, tty_stats, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
5944
+ console.log('[dsh-tty] agent tools registered (tty_list, tty_open, tty_close, tty_run, tty_stats, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, tunnel_start, tunnel_stop, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
5751
5945
  };
5752
5946
  refreshToolsHook = registerAll;
5753
5947
  registerAll();