@hyzyn/dsh-tty 0.10.2 → 0.11.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
@@ -93,9 +93,20 @@ export interface Config {
93
93
  persistence?: 'off' | 'tmux';
94
94
  /** 页面(最后一个连接)断开且保活期结束时,是否连 tmux 持久会话一起结束(默认 false = 留存可恢复)。 */
95
95
  endOnPageClose?: boolean;
96
+ /** SFTP 传输限制(0 = 不限)。 */
97
+ sftpLimits?: Partial<SftpLimits>;
96
98
  /** 内部状态:SSH 持久会话名(远程 tmux 托管,本机 socket 清单看不到,随 settings 留存供新窗口恢复确认)。 */
97
99
  persistSessions?: Array<{
98
100
  tmuxName: string;
99
101
  }>;
100
102
  }
103
+ /** SFTP 传输限制(均为 0 = 不限;客户端浏览器侧执行,宿主不做总量闸)。 */
104
+ export interface SftpLimits {
105
+ /** 单文件下载上限(MB)。默认 1024。 */
106
+ maxDownloadMb: number;
107
+ /** 单文件上传上限(MB)。默认 2048。 */
108
+ maxUploadMb: number;
109
+ /** 一次批量/拖拽上传的文件数上限。默认 1000。 */
110
+ maxUploadFiles: number;
111
+ }
101
112
  export declare const name: string, inject: string[] | undefined, apply: (ctx: Context, config?: Config | undefined) => void;
package/lib/index.js CHANGED
@@ -15,12 +15,15 @@ const HeadlessTerminal = xtermHeadless.Terminal;
15
15
  import { definePlugin } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
17
  import { spawnSsh, sshTarget, expandHome } from './ssh.js';
18
+ import { probeSsh } from './probe.js';
18
19
  import { buildShellSpawn } from './shell-integration.js';
19
20
  import { parseSshConfig } from './ssh-config.js';
20
21
  import { parseKnownHosts } from './known-hosts.js';
21
22
  import { TunnelManager } from './tunnels.js';
22
23
  import { SftpManager } from './sftp.js';
23
24
  import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
25
+ /** SFTP 传输限制默认值。 */
26
+ const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
24
27
  const SSH_HOST_SCHEMA = z.object({
25
28
  name: z.string(),
26
29
  host: z.string(),
@@ -67,6 +70,11 @@ const TTY_SETTINGS_SCHEMA = z.object({
67
70
  sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
68
71
  persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
69
72
  endOnPageClose: z.boolean().default(false),
73
+ sftpLimits: z.object({
74
+ maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
75
+ maxUploadMb: z.natural().max(1024 * 1024).default(2048),
76
+ maxUploadFiles: z.natural().max(100000).default(1000),
77
+ }).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }),
70
78
  persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]),
71
79
  });
72
80
  /* ------------------------------------------------------------------ *
@@ -143,6 +151,8 @@ class LiveConfig {
143
151
  endOnPageClose;
144
152
  /** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
145
153
  persistSessions;
154
+ /** SFTP 传输限制(客户端浏览器侧执行)。 */
155
+ sftpLimits;
146
156
  constructor(init) {
147
157
  this.shell = init.shell;
148
158
  this.term = sanitizeTermValue(init.term, 'xterm-256color');
@@ -155,6 +165,7 @@ class LiveConfig {
155
165
  this.tunnels = init.tunnels ?? [];
156
166
  this.persistence = init.persistence === 'tmux' ? 'tmux' : 'off';
157
167
  this.endOnPageClose = init.endOnPageClose === true;
168
+ this.sftpLimits = sanitizeSftpLimits(init.sftpLimits);
158
169
  this.persistSessions = init.persistSessions ?? [];
159
170
  }
160
171
  /** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
@@ -182,6 +193,8 @@ class LiveConfig {
182
193
  this.persistence = partial.persistence;
183
194
  if (typeof partial.endOnPageClose === 'boolean')
184
195
  this.endOnPageClose = partial.endOnPageClose;
196
+ if (partial.sftpLimits !== undefined)
197
+ this.sftpLimits = sanitizeSftpLimits({ ...this.sftpLimits, ...partial.sftpLimits });
185
198
  if (Array.isArray(partial.persistSessions))
186
199
  this.persistSessions = partial.persistSessions;
187
200
  }
@@ -503,6 +516,18 @@ function sanitizeHostKeys(input) {
503
516
  }
504
517
  return out;
505
518
  }
519
+ /** 清洗一份 sftpLimits 输入:每项取 0~上限 的整数(0 = 不限),缺省回落默认值。 */
520
+ function sanitizeSftpLimits(input) {
521
+ const num = (value, fallback, max) => {
522
+ const n = Number(value);
523
+ return Number.isInteger(n) && n >= 0 && n <= max ? n : fallback;
524
+ };
525
+ return {
526
+ maxDownloadMb: num(input?.maxDownloadMb, DEFAULT_SFTP_LIMITS.maxDownloadMb, 1024 * 1024),
527
+ maxUploadMb: num(input?.maxUploadMb, DEFAULT_SFTP_LIMITS.maxUploadMb, 1024 * 1024),
528
+ maxUploadFiles: num(input?.maxUploadFiles, DEFAULT_SFTP_LIMITS.maxUploadFiles, 100000),
529
+ };
530
+ }
506
531
  /** 严格校验一份 hostKeys 输入(HTTP POST 路径);返回错误信息或清洗后的数组。 */
507
532
  function validateHostKeys(input) {
508
533
  if (!Array.isArray(input))
@@ -1556,6 +1581,7 @@ const plugin = definePlugin({
1556
1581
  tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
1557
1582
  persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
1558
1583
  endOnPageClose: config?.endOnPageClose === true,
1584
+ sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
1559
1585
  persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
1560
1586
  });
1561
1587
  const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS, () => live.endOnPageClose);
@@ -1611,6 +1637,7 @@ const plugin = definePlugin({
1611
1637
  sftpStyle: stateRef.sftpStyle,
1612
1638
  persistence: live.persistence,
1613
1639
  endOnPageClose: live.endOnPageClose,
1640
+ sftpLimits: live.sftpLimits,
1614
1641
  toolsRegistered: stateRef.toolsRegistered,
1615
1642
  });
1616
1643
  /** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
@@ -1627,6 +1654,7 @@ const plugin = definePlugin({
1627
1654
  tunnels: sanitizeTunnels(section.tunnels),
1628
1655
  persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
1629
1656
  endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
1657
+ sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
1630
1658
  persistSessions: sanitizePersistSessions(section.persistSessions),
1631
1659
  });
1632
1660
  // 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
@@ -1645,7 +1673,7 @@ const plugin = definePlugin({
1645
1673
  /** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
1646
1674
  const normalizePatch = (input) => {
1647
1675
  const patch = {};
1648
- const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose']);
1676
+ const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'sftpLimits']);
1649
1677
  for (const key of Object.keys(input)) {
1650
1678
  if (!known.has(key))
1651
1679
  return { error: '未知配置项: ' + key };
@@ -1731,6 +1759,22 @@ const plugin = definePlugin({
1731
1759
  return { error: validated.error };
1732
1760
  patch.tunnels = validated.tunnels;
1733
1761
  }
1762
+ if (input.sftpLimits !== undefined) {
1763
+ if (typeof input.sftpLimits !== 'object' || input.sftpLimits === null || Array.isArray(input.sftpLimits))
1764
+ return { error: 'sftpLimits 必须是对象' };
1765
+ const raw = input.sftpLimits;
1766
+ const next = {};
1767
+ for (const key of ['maxDownloadMb', 'maxUploadMb', 'maxUploadFiles']) {
1768
+ if (raw[key] === undefined)
1769
+ continue;
1770
+ const value = Number(raw[key]);
1771
+ if (!Number.isInteger(value) || value < 0 || value > (key === 'maxUploadFiles' ? 100000 : 1024 * 1024)) {
1772
+ return { error: key + ' 必须是 0~' + (key === 'maxUploadFiles' ? '100000' : '1048576') + ' 的整数(0 = 不限)' };
1773
+ }
1774
+ next[key] = value;
1775
+ }
1776
+ patch.sftpLimits = next;
1777
+ }
1734
1778
  return { patch };
1735
1779
  };
1736
1780
  // webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
@@ -1847,6 +1891,72 @@ const plugin = definePlugin({
1847
1891
  }
1848
1892
  },
1849
1893
  }));
1894
+ // SSH 连接测试(0.11.0,src/probe.ts):连接簿行「测试」与 SSH 对话框
1895
+ // 「试连」共用。body 携带完整内联 SSH 规格(不引用连接簿——卡片测试
1896
+ // 由客户端先行展开条目),免去服务端按 name 解析;只诊断不建会话。
1897
+ // 连接簿条目测试传 store(新指纹当场 TOFU record);对话框试连不带
1898
+ // store(只比对不落盘,避免给未保存草稿建立钉扎)。
1899
+ disposers.push(webServer.register({
1900
+ kind: 'exact',
1901
+ path: '/api/dsh-tty/probe',
1902
+ handler: async (req, res) => {
1903
+ if (!isLoopbackHttp(req)) {
1904
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
1905
+ return;
1906
+ }
1907
+ if (req.method !== 'POST') {
1908
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1909
+ return;
1910
+ }
1911
+ const body = await readJsonBody(req);
1912
+ if (body === undefined) {
1913
+ writeJson(res, 400, { error: 'invalid JSON body' });
1914
+ return;
1915
+ }
1916
+ const host = typeof body.host === 'string' ? body.host.trim() : '';
1917
+ const username = typeof body.username === 'string' ? body.username.trim() : '';
1918
+ if (host === '' || username === '') {
1919
+ writeJson(res, 200, { ok: false, error: '主机与用户名必填' });
1920
+ return;
1921
+ }
1922
+ let port = 22;
1923
+ if (body.port !== undefined && body.port !== '') {
1924
+ const value = Number(body.port);
1925
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
1926
+ writeJson(res, 200, { ok: false, error: '端口必须是 1~65535 的整数' });
1927
+ return;
1928
+ }
1929
+ port = value;
1930
+ }
1931
+ const auth = body.auth === 'key' || body.auth === 'password' ? body.auth : 'agent';
1932
+ const spec = { host, port, username, auth };
1933
+ if (auth === 'key') {
1934
+ const keyPath = typeof body.keyPath === 'string' ? body.keyPath.trim() : '';
1935
+ if (keyPath === '') {
1936
+ writeJson(res, 200, { ok: false, error: 'auth=key 需要私钥路径' });
1937
+ return;
1938
+ }
1939
+ spec.keyPath = keyPath;
1940
+ const passphrase = typeof body.passphrase === 'string' ? body.passphrase : '';
1941
+ if (passphrase !== '')
1942
+ spec.passphrase = passphrase;
1943
+ }
1944
+ if (auth === 'password') {
1945
+ const password = typeof body.password === 'string' ? body.password : '';
1946
+ if (password === '') {
1947
+ writeJson(res, 200, { ok: false, error: 'auth=password 需要密码' });
1948
+ return;
1949
+ }
1950
+ spec.password = password;
1951
+ }
1952
+ if (body.agentForward === true)
1953
+ spec.agentForward = true;
1954
+ // bookRecord=true:来自连接簿条目的完整 TOFU(store 记录新指纹);
1955
+ // 缺省(对话框试连)只比对不落盘
1956
+ const result = await probeSsh(spec, body.bookRecord === true ? hostKeyStore : undefined);
1957
+ writeJson(res, 200, { ok: result.auth.ok, result });
1958
+ },
1959
+ }));
1850
1960
  // 已安装 shell 候选(设置卡片「Shell 路径」可选可输入):loopback 围栏,只回路径不执行
1851
1961
  disposers.push(webServer.register({
1852
1962
  kind: 'exact',