@linxin666/dsh-ssh 0.1.18 → 0.1.20

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.
@@ -2,133 +2,133 @@
2
2
  * dsh-ssh surface copy: zh is the key source, en mirrors every key.
3
3
  */
4
4
  export declare const zh: {
5
- readonly 'entry.label': "SSH";
6
- readonly 'entry.tooltip': "远程 SSH 运维面板";
7
- readonly 'panel.title': "远程 SSH";
8
- readonly 'tab.hosts': "主机";
9
- readonly 'tab.terminal': "终端";
10
- readonly 'tab.transfer': "传输";
11
- readonly 'tab.tunnels': "隧道";
12
- readonly 'tab.cluster': "集群";
13
- readonly 'hosts.empty': "尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。";
14
- readonly 'hosts.add': "新增主机";
15
- readonly 'hosts.import': "导入 ~/.ssh/config";
16
- readonly 'hosts.search': "搜索别名 / 描述 / 标签…";
17
- readonly 'hosts.col.alias': "别名";
18
- readonly 'hosts.col.host': "主机";
19
- readonly 'hosts.col.user': "用户";
20
- readonly 'hosts.col.auth': "认证";
21
- readonly 'hosts.col.environment': "环境";
22
- readonly 'hosts.col.tags': "标签";
23
- readonly 'hosts.col.description': "备注";
24
- readonly 'hosts.col.actions': "操作";
25
- readonly 'hosts.test': "测试";
26
- readonly 'hosts.edit': "编辑";
27
- readonly 'hosts.delete': "删除";
28
- readonly 'hosts.deleteConfirm': "确定删除主机「{alias}」?将同时停止其隧道。";
29
- readonly 'hosts.imported': "解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。";
30
- readonly 'hosts.testing': "连接测试中…";
31
- readonly 'hosts.testOk': "连接成功({latency} ms)";
32
- readonly 'hosts.testFail': "连接失败:{error}";
33
- readonly 'hosts.connected': "连接";
34
- readonly 'hosts.notConnected': "未连接";
35
- readonly 'form.title.create': "新增主机";
36
- readonly 'form.title.edit': "编辑主机 {alias}";
37
- readonly 'form.alias': "别名";
38
- readonly 'form.aliasHint': "小写字母、数字与单连字符,操作时使用";
39
- readonly 'form.host': "主机地址";
40
- readonly 'form.port': "端口";
41
- readonly 'form.user': "用户名";
42
- readonly 'form.auth': "认证方式";
43
- readonly 'form.auth.key': "密钥";
44
- readonly 'form.auth.password': "密码";
45
- readonly 'form.authKeepHint': "编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。";
46
- readonly 'form.keyPath': "私钥路径";
47
- readonly 'form.keyPathHint': "如 ~/.ssh/id_ed25519";
48
- readonly 'form.passphrase': "密钥口令";
49
- readonly 'form.password': "密码";
50
- readonly 'form.passwordHint': "明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)";
51
- readonly 'form.proxyJump': "跳板机别名";
52
- readonly 'form.proxyJumpHint': "逗号分隔的本地主机别名,按顺序连接";
53
- readonly 'form.description': "备注";
54
- readonly 'form.environment': "环境";
55
- readonly 'form.environmentHint': "如 development / production";
56
- readonly 'form.tags': "标签";
57
- readonly 'form.tagsHint': "逗号分隔";
58
- readonly 'form.location': "位置";
59
- readonly 'form.save': "保存";
60
- readonly 'form.cancel': "取消";
61
- readonly 'form.required': "请填写必填字段:别名、主机地址、用户名。";
62
- readonly 'form.portInvalid': "端口必须是 1-65535 之间的整数。";
63
- readonly 'terminal.selectHost': "选择主机";
64
- readonly 'terminal.connect': "连接";
65
- readonly 'terminal.disconnect': "断开";
66
- readonly 'terminal.placeholder': "选择主机后点击「连接」打开远程终端。";
67
- readonly 'terminal.connecting': "正在连接…";
68
- readonly 'terminal.ready': "终端已连接({alias})";
69
- readonly 'terminal.exited': "终端已退出({alias})";
70
- readonly 'terminal.error': "终端错误:{error}";
71
- readonly 'transfer.selectHost': "选择主机";
72
- readonly 'transfer.remotePath': "远程路径";
73
- readonly 'transfer.remotePathHint': "如 /tmp/ 或 /home/user/app.tar.gz";
74
- readonly 'transfer.upload': "上传";
75
- readonly 'transfer.uploading': "正在上传 {file}…";
76
- readonly 'transfer.download': "下载";
77
- readonly 'transfer.downloading': "正在下载…";
78
- readonly 'transfer.done': "完成:{bytes} 字节";
79
- readonly 'transfer.failed': "传输失败:{error}";
80
- readonly 'transfer.browseRemote': "浏览远程目录";
81
- readonly 'transfer.refresh': "刷新";
82
- readonly 'transfer.name': "名称";
83
- readonly 'transfer.upLevel': "上级目录";
84
- readonly 'transfer.size': "大小";
85
- readonly 'transfer.modified': "修改时间";
86
- readonly 'transfer.bytes': "{value} B";
87
- readonly 'transfer.kib': "{value} KB";
88
- readonly 'transfer.mib': "{value} MB";
89
- readonly 'transfer.gib': "{value} GB";
90
- readonly 'transfer.percent': "{value}%";
91
- readonly 'transfer.speed': "{value}/s";
92
- readonly 'transfer.dir': "目录";
93
- readonly 'transfer.file': "文件";
94
- readonly 'tunnel.empty': "没有活跃的隧道。";
95
- readonly 'tunnel.start': "新建隧道";
96
- readonly 'tunnel.alias': "主机别名";
97
- readonly 'tunnel.remotePort': "远程端口";
98
- readonly 'tunnel.remoteHost': "远程主机";
99
- readonly 'tunnel.remoteHostHint': "默认 127.0.0.1(服务器自身)";
100
- readonly 'tunnel.localPort': "本地端口";
101
- readonly 'tunnel.localPortHint': "留空自动分配";
102
- readonly 'tunnel.stop': "停止";
103
- readonly 'tunnel.stopAll': "全部停止";
104
- readonly 'tunnel.stopAllConfirm': "确定停止全部隧道吗?";
105
- readonly 'tunnel.row': "{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}";
106
- readonly 'tunnel.started': "隧道已建立:127.0.0.1:{localPort}";
107
- readonly 'tunnel.failed': "隧道启动失败:{error}";
108
- readonly 'cluster.command': "命令";
109
- readonly 'cluster.run': "执行";
110
- readonly 'cluster.confirm': "确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。";
111
- readonly 'cluster.aliases': "主机别名(逗号分隔,留空为全部)";
112
- readonly 'cluster.environment': "环境过滤";
113
- readonly 'cluster.tags': "标签过滤(逗号分隔)";
114
- readonly 'cluster.results': "执行结果";
115
- readonly 'cluster.empty': "输入命令后点击「执行」。";
116
- readonly 'cluster.noMatch': "没有匹配的主机。";
117
- readonly 'cluster.ok': "成功";
118
- readonly 'cluster.fail': "失败";
119
- readonly 'cluster.timeout': "超时";
120
- readonly 'cluster.col.alias': "主机";
121
- readonly 'cluster.col.status': "状态";
122
- readonly 'cluster.col.exit': "退出码";
123
- readonly 'cluster.col.stdout': "标准输出";
124
- readonly 'cluster.col.stderr': "标准错误";
125
- readonly 'cluster.col.error': "错误";
126
- readonly 'cluster.col.duration': "耗时";
127
- readonly 'common.loading': "加载中…";
128
- readonly 'common.error': "出错:{error}";
129
- readonly 'common.refresh': "刷新";
130
- readonly 'common.close': "关闭";
131
- readonly 'common.confirm': "确定";
5
+ readonly 'entry.label': 'SSH';
6
+ readonly 'entry.tooltip': '远程 SSH 运维面板';
7
+ readonly 'panel.title': '远程 SSH';
8
+ readonly 'panel.backToConversation': '返回会话';
9
+ readonly 'tab.hosts': '主机';
10
+ readonly 'tab.terminal': '终端';
11
+ readonly 'tab.transfer': '传输';
12
+ readonly 'tab.tunnels': '隧道';
13
+ readonly 'tab.cluster': '集群';
14
+ readonly 'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。';
15
+ readonly 'hosts.add': '新增主机';
16
+ readonly 'hosts.import': '导入 ~/.ssh/config';
17
+ readonly 'hosts.search': '搜索别名 / 描述 / 标签…';
18
+ readonly 'hosts.col.alias': '别名';
19
+ readonly 'hosts.col.host': '主机';
20
+ readonly 'hosts.col.user': '用户';
21
+ readonly 'hosts.col.auth': '认证';
22
+ readonly 'hosts.col.environment': '环境';
23
+ readonly 'hosts.col.tags': '标签';
24
+ readonly 'hosts.col.description': '备注';
25
+ readonly 'hosts.col.actions': '操作';
26
+ readonly 'hosts.test': '测试';
27
+ readonly 'hosts.edit': '编辑';
28
+ readonly 'hosts.delete': '删除';
29
+ readonly 'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。';
30
+ readonly 'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。';
31
+ readonly 'hosts.testing': '连接测试中…';
32
+ readonly 'hosts.testOk': '连接成功({latency} ms)';
33
+ readonly 'hosts.testFail': '连接失败:{error}';
34
+ readonly 'hosts.connected': '连接';
35
+ readonly 'hosts.notConnected': '未连接';
36
+ readonly 'form.title.create': '新增主机';
37
+ readonly 'form.title.edit': '编辑主机 {alias}';
38
+ readonly 'form.alias': '别名';
39
+ readonly 'form.aliasHint': '小写字母、数字与单连字符,操作时使用';
40
+ readonly 'form.host': '主机地址';
41
+ readonly 'form.port': '端口';
42
+ readonly 'form.user': '用户名';
43
+ readonly 'form.auth': '认证方式';
44
+ readonly 'form.auth.key': '密钥';
45
+ readonly 'form.auth.password': '密码';
46
+ readonly 'form.authKeepHint': '编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。';
47
+ readonly 'form.keyPath': '私钥路径';
48
+ readonly 'form.keyPathHint': '如 ~/.ssh/id_ed25519';
49
+ readonly 'form.passphrase': '密钥口令';
50
+ readonly 'form.password': '密码';
51
+ readonly 'form.passwordHint': '明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)';
52
+ readonly 'form.proxyJump': '跳板机别名';
53
+ readonly 'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接';
54
+ readonly 'form.description': '备注';
55
+ readonly 'form.environment': '环境';
56
+ readonly 'form.environmentHint': '如 development / production';
57
+ readonly 'form.tags': '标签';
58
+ readonly 'form.tagsHint': '逗号分隔';
59
+ readonly 'form.location': '位置';
60
+ readonly 'form.save': '保存';
61
+ readonly 'form.cancel': '取消';
62
+ readonly 'form.required': '请填写必填字段:别名、主机地址、用户名。';
63
+ readonly 'form.portInvalid': '端口必须是 1-65535 之间的整数。';
64
+ readonly 'terminal.selectHost': '选择主机';
65
+ readonly 'terminal.connect': '连接';
66
+ readonly 'terminal.disconnect': '断开';
67
+ readonly 'terminal.placeholder': '选择主机后点击「连接」打开远程终端。';
68
+ readonly 'terminal.connecting': '正在连接…';
69
+ readonly 'terminal.ready': '终端已连接({alias})';
70
+ readonly 'terminal.exited': '终端已退出({alias})';
71
+ readonly 'terminal.error': '终端错误:{error}';
72
+ readonly 'transfer.selectHost': '选择主机';
73
+ readonly 'transfer.remotePath': '远程路径';
74
+ readonly 'transfer.remotePathHint': '如 /tmp/ 或 /home/user/app.tar.gz';
75
+ readonly 'transfer.upload': '上传';
76
+ readonly 'transfer.uploading': '正在上传 {file}…';
77
+ readonly 'transfer.download': '下载';
78
+ readonly 'transfer.downloading': '正在下载…';
79
+ readonly 'transfer.done': '完成:{bytes} 字节';
80
+ readonly 'transfer.failed': '传输失败:{error}';
81
+ readonly 'transfer.browseRemote': '浏览远程目录';
82
+ readonly 'transfer.refresh': '刷新';
83
+ readonly 'transfer.name': '名称';
84
+ readonly 'transfer.upLevel': '上级目录';
85
+ readonly 'transfer.size': '大小';
86
+ readonly 'transfer.modified': '修改时间';
87
+ readonly 'transfer.bytes': '{value} B';
88
+ readonly 'transfer.kib': '{value} KB';
89
+ readonly 'transfer.mib': '{value} MB';
90
+ readonly 'transfer.gib': '{value} GB';
91
+ readonly 'transfer.percent': '{value}%';
92
+ readonly 'transfer.speed': '{value}/s';
93
+ readonly 'transfer.dir': '目录';
94
+ readonly 'transfer.file': '文件';
95
+ readonly 'tunnel.empty': '没有活跃的隧道。';
96
+ readonly 'tunnel.start': '新建隧道';
97
+ readonly 'tunnel.alias': '主机别名';
98
+ readonly 'tunnel.remotePort': '远程端口';
99
+ readonly 'tunnel.remoteHost': '远程主机';
100
+ readonly 'tunnel.remoteHostHint': '默认 127.0.0.1(服务器自身)';
101
+ readonly 'tunnel.localPort': '本地端口';
102
+ readonly 'tunnel.localPortHint': '留空自动分配';
103
+ readonly 'tunnel.stop': '停止';
104
+ readonly 'tunnel.stopAll': '全部停止';
105
+ readonly 'tunnel.stopAllConfirm': '确定停止全部隧道吗?';
106
+ readonly 'tunnel.row': '{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}';
107
+ readonly 'tunnel.started': '隧道已建立:127.0.0.1:{localPort}';
108
+ readonly 'tunnel.failed': '隧道启动失败:{error}';
109
+ readonly 'cluster.command': '命令';
110
+ readonly 'cluster.run': '执行';
111
+ readonly 'cluster.confirm': '确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。';
112
+ readonly 'cluster.aliases': '主机别名(逗号分隔,留空为全部)';
113
+ readonly 'cluster.environment': '环境过滤';
114
+ readonly 'cluster.tags': '标签过滤(逗号分隔)';
115
+ readonly 'cluster.results': '执行结果';
116
+ readonly 'cluster.empty': '输入命令后点击「执行」。';
117
+ readonly 'cluster.noMatch': '没有匹配的主机。';
118
+ readonly 'cluster.ok': '成功';
119
+ readonly 'cluster.fail': '失败';
120
+ readonly 'cluster.timeout': '超时';
121
+ readonly 'cluster.col.alias': '主机';
122
+ readonly 'cluster.col.status': '状态';
123
+ readonly 'cluster.col.exit': '退出码';
124
+ readonly 'cluster.col.stdout': '标准输出';
125
+ readonly 'cluster.col.stderr': '标准错误';
126
+ readonly 'cluster.col.error': '错误';
127
+ readonly 'cluster.col.duration': '耗时';
128
+ readonly 'common.loading': '加载中…';
129
+ readonly 'common.error': '出错:{error}';
130
+ readonly 'common.refresh': '刷新';
131
+ readonly 'common.confirm': '确定';
132
132
  };
133
133
  export declare const en: Record<keyof typeof zh, string>;
134
134
  /** Locale key union. */
@@ -47,7 +47,7 @@ export interface PoolEngine {
47
47
  readonly acquireQueue: Map<string, Promise<PoolRecord>>;
48
48
  }
49
49
  /** Build the ssh2 connect config for one entry (key read from disk). */
50
- export declare function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig;
50
+ export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
51
51
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
52
52
  export declare function connectClient(config: ConnectConfig): Promise<Client>;
53
53
  /** Cap captured output at the configured byte budget (marks truncation). */
@@ -72,6 +72,8 @@ export declare function acquire(engine: PoolEngine, alias: string): Promise<Pool
72
72
  * is torn down — the connection belongs to someone else now.
73
73
  */
74
74
  export declare function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void;
75
+ /** End one record's client and hop chain (best-effort, safe to repeat). */
76
+ export declare function endRecordChain(record: PoolRecord): void;
75
77
  /** Close connections idle beyond the threshold (skips pinned and in-flight). */
76
78
  export declare function sweepPool(engine: PoolEngine): void;
77
79
  /**
@@ -5,12 +5,14 @@
5
5
  */
6
6
  import { type Server as NetServer, type Socket } from 'node:net';
7
7
  import type { TunnelInfo } from '../protocol.ts';
8
- import { type PoolEngine } from './connection-pool.ts';
9
- /** One active tunnel record (server + pinned client + live sockets). */
8
+ import { type PoolEngine, type PoolRecord } from './connection-pool.ts';
9
+ /** One active tunnel record (server + pinned connection + live sockets). */
10
10
  export interface TunnelRecord {
11
11
  info: TunnelInfo;
12
12
  server: NetServer;
13
13
  alias: string;
14
+ /** The pooled connection this tunnel pins; siblings on one alias may share it. */
15
+ record: PoolRecord;
14
16
  sockets: Set<Socket>;
15
17
  }
16
18
  /** The engine slice the tunnel module needs (adds the tunnel registry). */
@@ -74,6 +74,13 @@ export declare class SshEngine {
74
74
  stopTunnel(id: string): boolean;
75
75
  /** Stop all tunnels (optionally for one alias). */
76
76
  stopAllTunnels(alias?: string): number;
77
+ /**
78
+ * Drop every live artifact bound to one alias: stop its tunnels and close
79
+ * the pooled connection. Host entries that are deleted or whose connection
80
+ * fields change must never keep serving a stale, previously authenticated
81
+ * connection — the next operation re-connects from the current config.
82
+ */
83
+ dropAlias(alias: string): void;
77
84
  /** Probe connectivity: connect, run `true`, close. */
78
85
  test(alias: string): Promise<TestResult>;
79
86
  /** Close every pooled connection and tunnel. */
@@ -37,4 +37,6 @@ export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63
37
37
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
38
38
  * @param config - resolved plugin config (schema defaults applied by the loader).
39
39
  */
40
- export declare function apply(ctx: Context, config?: Config): void;
40
+ export declare const apply: typeof applyImpl;
41
+ declare function applyImpl(ctx: Context, config?: Config): void;
42
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Loopback trust fence shared by the host route families: socket address,
3
+ * Host header, and browser same-origin markers. Packages receive this file as
4
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
5
+ * re-run the sync instead of editing a copy.
6
+ *
7
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
8
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
9
+ * markers (sec-fetch-site and Origin) for the request-level fence.
10
+ * @module dsh-web-ui-shared/host/loopback
11
+ */
12
+ import type { IncomingMessage } from 'node:http';
13
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
14
+ export declare function isIPv4Loopback(v4: string): boolean;
15
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
16
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
17
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
18
+ export declare function isLoopbackHostname(hostname: string): boolean;
19
+ /**
20
+ * Request-level trust fence: a loopback socket address AND a loopback Host
21
+ * header, plus browser same-origin markers. The socket address is
22
+ * authoritative; X-Forwarded-For is never trusted.
23
+ */
24
+ export declare function isLoopbackRequest(request: IncomingMessage): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Host single-instance guard shared by the plugin family. The family bundle
3
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
4
+ * the loader accepts a standalone install of the same package side by side;
5
+ * without this guard the second instance would still re-register the same
6
+ * webserver routes, tools, settings namespaces, and system-prompt sections
7
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
8
+ * lifetime of the first instance (the browser half is already deduped by
9
+ * package name in the client module host).
10
+ *
11
+ * The registry rides a global symbol so two module instances of the same
12
+ * package (npm copy vs repository link) still share one verdict. cordis
13
+ * `ctx.effect` runs its callback immediately and treats the callback's
14
+ * return value as the fiber disposer, so the unmarker is returned, not run.
15
+ */
16
+ /**
17
+ * Wrap a cordis plugin apply so the package runs at most once per process.
18
+ * The first mount registers normally and unmarks when its fiber disposes;
19
+ * any later mount of the same package name is a no-op.
20
+ * @param packageName - npm package identity shared by every install source.
21
+ * @param fn - the original plugin apply.
22
+ * @returns an apply of the same shape.
23
+ */
24
+ export declare function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T;
@@ -164,7 +164,7 @@ export type TerminalClientFrame = {
164
164
  rows: number;
165
165
  };
166
166
  /** Route paths the client calls (shared literals). */
167
- export declare const SSH_API_BASE: "/api/dsh-ssh";
167
+ export declare const SSH_API_BASE: '/api/dsh-ssh';
168
168
  export declare const SSH_API: {
169
169
  readonly hosts: string;
170
170
  readonly importSshConfig: string;
@@ -17,6 +17,8 @@ export interface SshRoutesDeps {
17
17
  engine: SshEngine;
18
18
  /** Temp dir for upload/download staging (tests inject a sandbox). */
19
19
  stagingDir?: string;
20
+ /** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
21
+ maxUploadBytes?: number;
20
22
  }
21
23
  /**
22
24
  * Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
@@ -47,6 +47,12 @@ export declare class HostStore {
47
47
  */
48
48
  importFromSshConfig(): ImportResult;
49
49
  private skippedNames;
50
+ /**
51
+ * Last parsed store keyed by file identity. list/find ride every acquire
52
+ * and GUI refresh; re-reading and re-parsing the whole file each call is
53
+ * wasted work when the file has not changed. Any save invalidates.
54
+ */
55
+ private cache;
50
56
  private load;
51
57
  private save;
52
58
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.18",
4
+ "version": "0.1.20",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -6,6 +6,7 @@ export const zh = {
6
6
  'entry.label': 'SSH',
7
7
  'entry.tooltip': '远程 SSH 运维面板',
8
8
  'panel.title': '远程 SSH',
9
+ 'panel.backToConversation': '返回会话',
9
10
  'tab.hosts': '主机',
10
11
  'tab.terminal': '终端',
11
12
  'tab.transfer': '传输',
@@ -135,7 +136,6 @@ export const zh = {
135
136
  'common.loading': '加载中…',
136
137
  'common.error': '出错:{error}',
137
138
  'common.refresh': '刷新',
138
- 'common.close': '关闭',
139
139
  'common.confirm': '确定',
140
140
  } as const
141
141
 
@@ -143,6 +143,7 @@ export const en: Record<keyof typeof zh, string> = {
143
143
  'entry.label': 'SSH',
144
144
  'entry.tooltip': 'Remote SSH operations panel',
145
145
  'panel.title': 'Remote SSH',
146
+ 'panel.backToConversation': 'Back to chat',
146
147
  'tab.hosts': 'Hosts',
147
148
  'tab.terminal': 'Terminal',
148
149
  'tab.transfer': 'Transfer',
@@ -265,7 +266,6 @@ export const en: Record<keyof typeof zh, string> = {
265
266
  'common.loading': 'Loading…',
266
267
  'common.error': 'Error: {error}',
267
268
  'common.refresh': 'Refresh',
268
- 'common.close': 'Close',
269
269
  'common.confirm': 'Confirm',
270
270
  }
271
271
 
@@ -55,8 +55,16 @@ export function SshPanel({ controller, api }: SshPanelProps) {
55
55
  return (
56
56
  <div className={css.panel}>
57
57
  <div className={css.panelHeader}>
58
+ <button
59
+ type="button"
60
+ className={`${css.ghostButton} ${css.backButton}`}
61
+ aria-label={tt('panel.backToConversation')}
62
+ onClick={() => { controller.close() }}
63
+ >
64
+ <span aria-hidden="true">‹</span>
65
+ <span>{tt('panel.backToConversation')}</span>
66
+ </button>
58
67
  <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
59
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
60
68
  </div>
61
69
  <div className={css.tabBar} role="tablist">
62
70
  {TABS.map(tab => (
@@ -133,6 +133,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
133
133
  white-space: nowrap;
134
134
  }
135
135
 
136
+ .backButton {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 4px;
140
+ }
141
+
136
142
  /* --- tab bar ------------------------------------------------------------------ */
137
143
 
138
144
  .tabBar {
@@ -62,13 +62,13 @@ export interface PoolEngine {
62
62
  }
63
63
 
64
64
  /** Build the ssh2 connect config for one entry (key read from disk). */
65
- export function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig {
65
+ export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig {
66
66
  const config: ConnectConfig = {
67
67
  host: entry.host,
68
68
  port: entry.port,
69
69
  username: entry.user,
70
- readyTimeout: 15_000,
71
- keepaliveInterval: 15_000,
70
+ readyTimeout: opts.connectTimeoutMs,
71
+ keepaliveInterval: opts.keepaliveIntervalMs,
72
72
  keepaliveCountMax: 3,
73
73
  }
74
74
  if (sock !== undefined) config.sock = sock
@@ -147,7 +147,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
147
147
  for (const client of hops) client.end()
148
148
  throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
149
  }
150
- const hopClient = await connectClient(buildConnectConfig(hop, sock))
150
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
151
151
  hops.push(hopClient)
152
152
  const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
153
  const nextHost = next !== undefined ? next.host : entry.host
@@ -165,7 +165,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
165
165
  }
166
166
  let target: Client | undefined
167
167
  try {
168
- target = await connectClient(buildConnectConfig(entry, sock))
168
+ target = await connectClient(buildConnectConfig(entry, sock, engine.opts))
169
169
  return { client: target, hops }
170
170
  } catch (error) {
171
171
  for (const client of hops) client.end()
@@ -213,8 +213,13 @@ export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRe
213
213
  if (record !== undefined && current !== record) return
214
214
  if (current === undefined) return
215
215
  engine.pool.delete(alias)
216
- try { current.client.end() } catch { /* already closed */ }
217
- for (const hop of current.hops) {
216
+ endRecordChain(current)
217
+ }
218
+
219
+ /** End one record's client and hop chain (best-effort, safe to repeat). */
220
+ export function endRecordChain(record: PoolRecord): void {
221
+ try { record.client.end() } catch { /* already closed */ }
222
+ for (const hop of record.hops) {
218
223
  try { hop.end() } catch { /* already closed */ }
219
224
  }
220
225
  }
@@ -247,6 +252,14 @@ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (clie
247
252
  const result = await fn(record.client)
248
253
  record.idleAt = Date.now()
249
254
  return result
255
+ } catch (error) {
256
+ lastError = error
257
+ // Retry only when the connection actually broke mid-flight: drop the
258
+ // corpse and let the next attempt reconnect (a reconnect may replay a
259
+ // non-idempotent command — the documented trade-off). A failure on a
260
+ // healthy connection is a logic error and is rethrown, not replayed.
261
+ if (!record.broken) throw error
262
+ disposeRecord(engine, alias, record)
250
263
  } finally {
251
264
  record.inFlight -= 1
252
265
  }
@@ -297,8 +310,15 @@ export async function execCommand(engine: PoolEngine, alias: string, command: st
297
310
  if (settled) return
298
311
  settled = true
299
312
  clearTimeout(timer)
313
+ if (typeof code !== 'number' && !timedOut) {
314
+ // The channel closed without an exit status: the connection
315
+ // dropped mid-flight. Reject so withClient can reconnect and
316
+ // retry within the attempt budget.
317
+ reject(new Error('ssh: connection lost mid-flight (channel closed without an exit status)'))
318
+ return
319
+ }
300
320
  resolve({
301
- success: code === 0 && !timedOut,
321
+ success: code === 0,
302
322
  exitCode: code,
303
323
  timedOut,
304
324
  stdout: stdout.text,