dsh-remote 0.8.21 → 0.8.23

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/pool.js CHANGED
@@ -41,24 +41,48 @@ export class SshPool {
41
41
  return p
42
42
  }
43
43
 
44
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
45
- if (host !== undefined) this.config.host = String(host)
46
- if (port !== undefined && Number(port)) this.config.port = Number(port)
47
- if (username !== undefined) this.config.username = String(username)
48
- if (password !== undefined && password !== null) this.config.password = String(password)
49
- if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
50
- if (passphrase !== undefined) this.config.passphrase = String(passphrase)
51
- if (workspace !== undefined) this.config.workspace = String(workspace)
52
- if (useAgent !== undefined) this.config.useAgent = !!useAgent
53
- if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
54
- if (proxy !== undefined) this.config.proxy = proxy
55
- if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
56
- // the new target may be a different OS — re-detect on the next command
57
- this.platform = 'unknown'
58
- this.gitBashPath = ''
59
- this.shellMode = 'native'
60
- this._detecting = null
61
- this.close()
44
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
45
+ // Collect the requested changes first: an UNCHANGED target is a no-op. The
46
+ // old behavior closed the live connection and re-ran platform detection
47
+ // even for an identical target, so every picker keystroke
48
+ // (POST /current → applyActiveMachine → setTarget) paid a full SSH
49
+ // reconnect + re-detect — the "slow remote path autocomplete" bug.
50
+ //
51
+ // `previous` is the diff BASELINE. Callers whose shared config was already
52
+ // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
53
+ // live config IN PLACE, and pool.config IS that config object) pass a
54
+ // pre-mutation snapshot — otherwise the comparison runs the new values
55
+ // against themselves, is always equal and never closes even for a machine
56
+ // switch (the wrong-host hazard of issue #25). Defaults to the current
57
+ // config for callers that pass fresh values read independently of it.
58
+ const base = previous || this.config
59
+ const wanted = []
60
+ if (host !== undefined) wanted.push(['host', String(host)])
61
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
62
+ if (username !== undefined) wanted.push(['username', String(username)])
63
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
64
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
65
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
66
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
67
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
68
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
69
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
70
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
71
+ const differs = ([k, v]) => (k === 'proxy'
72
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
73
+ : base[k] !== v)
74
+ // Something actually changed → apply it and force a fresh connection.
75
+ // (A caller that WANTS a reconnect for an identical target uses
76
+ // invalidate(); setTarget semantics stay "re-point when moved".)
77
+ if (wanted.some(differs)) {
78
+ for (const [k, v] of wanted) this.config[k] = v
79
+ // the new target may be a different OS — re-detect on the next command
80
+ this.platform = 'unknown'
81
+ this.gitBashPath = ''
82
+ this.shellMode = 'native'
83
+ this._detecting = null
84
+ this.close()
85
+ }
62
86
  return this
63
87
  }
64
88
 
@@ -484,6 +508,13 @@ export class SshPool {
484
508
  if (client) {
485
509
  try { client.end() } catch {}
486
510
  }
511
+ // Same teardown as close(): the forwards manager must detach from the
512
+ // dying client, or its stale `close -> stopAll()` listener later tears
513
+ // down forwards that were re-attached to the NEW client (onReady does
514
+ // the re-attach on the next successful connect).
515
+ if (this.onCloseHook) {
516
+ try { this.onCloseHook() } catch {}
517
+ }
487
518
  }
488
519
 
489
520
  close() {
package/lib/routes-fs.js CHANGED
@@ -40,6 +40,17 @@ export function createFsRoutes({
40
40
  const hint = requestSessionHint(req)
41
41
  const b = await bindOrReply(req, res, hint)
42
42
  if (!b) return
43
+ // Identity echo (external-switch defense): name the machine this
44
+ // listing is answered for, straight from the pool's target — the
45
+ // picker validates every listing against the machine it INTENDED and
46
+ // re-switches on mismatch (the settings page can change the active
47
+ // machine under a memoized picker).
48
+ const tgt = (b.pool && b.pool.config) || {}
49
+ const machine = {
50
+ host: String(tgt.host || b.host || ''),
51
+ port: Number(tgt.port) || Number(b.port) || 22,
52
+ username: String(tgt.username || b.username || ''),
53
+ }
43
54
  await b.pool.detect()
44
55
  const q = new URL(req.url, 'http://localhost').searchParams
45
56
  const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
@@ -48,9 +59,9 @@ export function createFsRoutes({
48
59
  if (canon === '/' || raw === '' || raw === '/') {
49
60
  if (win || b.pool.platform !== 'posix') {
50
61
  const drives = await listRemoteDrives(b.pool)
51
- if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound })
62
+ if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
52
63
  }
53
- if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound })
64
+ if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
54
65
  }
55
66
  const sftp = await b.pool.sftp()
56
67
  const out = await listDirStructured(sftp, canon)
@@ -64,6 +75,7 @@ export function createFsRoutes({
64
75
  items,
65
76
  missing: !!out.missing,
66
77
  bound: b.bound,
78
+ machine,
67
79
  })
68
80
  } catch (err) {
69
81
  return sendJson(res, 500, { error: String((err && err.message) || err) })
package/lib/sshconfig.js CHANGED
@@ -1,49 +1,229 @@
1
- // dsh-remote — ~/.ssh/config parser for the "import from ssh config" feature.
2
- // Pure parsing (no auth material is ever read: only Host/HostName/User/Port/
3
- // IdentityFile/ProxyJump). IdentityFile is imported as a PATH reference only —
4
- // the plugin never auto-reads keys (v0.5.5 policy).
5
- import { readFileSync } from 'node:fs'
1
+ // dsh-remote — ~/.ssh/config reading and OpenSSH alias resolution (issue #38).
2
+ //
3
+ // ## Why this module exists
4
+ //
5
+ // A saved machine can be an ALIAS from the user's ssh config: the registry keeps
6
+ // `{ host: '<alias>', useSshConfig: true }` and NOTHING else — no HostName, no
7
+ // user, no port, no key path. Every connect resolves that alias against
8
+ // `~/.ssh/config` as it is RIGHT NOW, which is what VSCode Remote-SSH does with
9
+ // the same file: editing the config (a new jump host, a rotated IdentityFile, a
10
+ // different port) is picked up without re-importing anything into the plugin.
11
+ //
12
+ // ## What is read
13
+ //
14
+ // Host / HostName / User / Port / IdentityFile / ProxyJump / ProxyCommand.
15
+ // `IdentityFile` is imported as a PATH reference only — the plugin never opens
16
+ // key material (v0.5.5 policy).
17
+ //
18
+ // ## OpenSSH semantics implemented
19
+ //
20
+ // • `Host a b` / `Host *.example.com` / `Host !x *.y` pattern lists, evaluated
21
+ // with `*` and `?` wildcards, case-insensitively (see hostBlockMatches).
22
+ // • first-obtained-value-wins ACROSS all matching blocks, not first-block-wins:
23
+ // ssh_config(5) — "for each parameter, the first obtained value will be used".
24
+ // • `Include` directives, globbed, expanded in place, relative to ~/.ssh
25
+ // (falling back to the including file's directory), depth- and cycle-limited.
26
+ // • backslash line continuations.
27
+ //
28
+ // The module is pure (I/O is injectable), so it is unit-testable without a real
29
+ // home directory — see test/sshconfig.test.js.
30
+
31
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
6
32
  import { homedir } from 'node:os'
7
33
  import path from 'node:path'
8
34
 
35
+ /** Default location of the user's ssh config. */
9
36
  export const sshConfigPath = () => path.join(homedir(), '.ssh', 'config')
10
37
 
11
- /** Read the user's ~/.ssh/config text ('' when absent/unreadable). */
38
+ /** Directory `Include` paths are resolved against (ssh_config(5)). */
39
+ export const sshConfigDir = () => path.join(homedir(), '.ssh')
40
+
41
+ /** Parameters the resolver cares about, lowercase (as parsed). */
42
+ const KNOWN_PARAMS = new Set([
43
+ 'hostname', 'user', 'port', 'identityfile', 'proxyjump', 'proxycommand', 'identitiesonly',
44
+ ])
45
+
46
+ /** Read the user's ~/.ssh/config text, Includes expanded ('' when absent). */
12
47
  export function readSshConfigText(filePath = sshConfigPath()) {
48
+ return loadSshConfigText(filePath)
49
+ }
50
+
51
+ /**
52
+ * Read an ssh config file and expand its `Include` directives in place.
53
+ *
54
+ * Relative includes resolve against `~/.ssh` first (ssh_config(5)) and then
55
+ * against the including file's directory, because real-world configs use both.
56
+ * A missing or unreadable include is skipped: one broken line must not hide the
57
+ * rest of the config.
58
+ *
59
+ * @param {string} [filePath] - the config file (default ~/.ssh/config).
60
+ * @param {object} [io] - injectable filesystem hooks (tests).
61
+ * @param {(p: string) => string} [io.readFile]
62
+ * @param {(p: string) => boolean} [io.exists]
63
+ * @param {(p: string) => string[]} [io.listDir]
64
+ * @param {number} [io.maxDepth]
65
+ * @returns {string} the expanded config text ('' when nothing could be read).
66
+ */
67
+ export function loadSshConfigText(filePath = sshConfigPath(), io = {}) {
68
+ const readFile = io.readFile || ((p) => readFileSync(p, 'utf8'))
69
+ const listDir = io.listDir || ((p) => readdirSync(p))
70
+ return expandFile(filePath, { readFile, listDir, maxDepth: io.maxDepth ?? 3, depth: 0, seen: new Set() })
71
+ }
72
+
73
+ function expandFile(file, state) {
74
+ let text
13
75
  try {
14
- return readFileSync(filePath, 'utf8')
76
+ text = state.readFile(file)
15
77
  } catch {
16
78
  return ''
17
79
  }
80
+ if (typeof text !== 'string') return ''
81
+ if (state.depth >= state.maxDepth) return text
82
+ const out = []
83
+ for (const line of joinContinuations(text)) {
84
+ const patterns = matchInclude(line)
85
+ if (!patterns) { out.push(line); continue }
86
+ for (const pattern of patterns) {
87
+ for (const included of expandIncludePath(pattern, path.dirname(file), state.listDir)) {
88
+ const key = path.resolve(included)
89
+ if (state.seen.has(key)) continue
90
+ state.seen.add(key)
91
+ const child = expandFile(included, { ...state, depth: state.depth + 1 })
92
+ if (child.trim()) out.push(child)
93
+ }
94
+ }
95
+ }
96
+ return out.join('\n')
97
+ }
98
+
99
+ /** The value tokens of `Include <pattern> [pattern...]`, or null. */
100
+ function matchInclude(line) {
101
+ const m = String(line).match(/^\s*include\s+(.+?)\s*$/i)
102
+ if (!m) return null
103
+ return splitValue(m[1])
104
+ }
105
+
106
+ /** Expand one include pattern.
107
+ *
108
+ * ssh_config(5): a relative path is relative to `~/.ssh`. The including file's
109
+ * own directory is tried only when that yields nothing — which is what makes
110
+ * the plugin able to read a config the user pointed it at elsewhere, without
111
+ * double-resolving (and double-reading) the normal `~/.ssh` case.
112
+ */
113
+ function expandIncludePath(pattern, callerDir, listDir) {
114
+ const raw = stripQuotes(pattern)
115
+ if (!raw) return []
116
+ if (path.isAbsolute(raw)) return globFiles(raw, listDir).sort()
117
+ if (raw.startsWith('~')) return globFiles(path.join(homedir(), raw.replace(/^~[\\/]?/, '')), listDir).sort()
118
+ const primary = globFiles(path.join(sshConfigDir(), raw), listDir)
119
+ if (primary.length) return primary.sort()
120
+ if (callerDir && path.resolve(callerDir) !== path.resolve(sshConfigDir())) {
121
+ return globFiles(path.join(callerDir, raw), listDir).sort()
122
+ }
123
+ return []
124
+ }
125
+
126
+ /** Files matching a path whose basename may contain `*` / `?` (one level). */
127
+ function globFiles(target, listDir) {
128
+ const base = path.basename(target)
129
+ if (!/[*?]/.test(base)) return [target]
130
+ const dir = path.dirname(target)
131
+ let entries
132
+ try {
133
+ entries = listDir(dir)
134
+ } catch {
135
+ return []
136
+ }
137
+ const re = globRegex(base)
138
+ return entries
139
+ .filter((name) => re.test(name))
140
+ .map((name) => path.join(dir, name))
141
+ }
142
+
143
+ /** Join lines ending in a backslash (ssh config line continuation). */
144
+ function joinContinuations(text) {
145
+ const lines = []
146
+ let pending = ''
147
+ for (const raw of String(text).split('\n')) {
148
+ const line = pending + raw
149
+ if (/\\\s*$/.test(line) && !/\\\\\s*$/.test(line)) {
150
+ pending = line.replace(/\\\s*$/, ' ') + ' '
151
+ continue
152
+ }
153
+ pending = ''
154
+ lines.push(line)
155
+ }
156
+ if (pending) lines.push(pending)
157
+ return lines
158
+ }
159
+
160
+ /** Config lines with continuations joined — the form every parser consumes. */
161
+ const configLines = (text) => joinContinuations(text)
162
+
163
+ /** Split a parameter value into whitespace-separated tokens, honoring quotes. */
164
+ export function splitValue(value) {
165
+ return (String(value || '').match(/"[^"]*"|'[^']*'|\S+/g) || [])
166
+ .map(stripQuotes)
167
+ .filter((token) => token !== '')
18
168
  }
19
169
 
170
+ const stripQuotes = (s) => String(s || '').replace(/^"(.*)"$/s, '$1').replace(/^'(.*)'$/s, '$1')
171
+
20
172
  /**
21
- * Parse OpenSSH config text into host entries.
173
+ * Parse OpenSSH config text into Host BLOCKS, preserving pattern lists.
174
+ * @param {string} text
175
+ * @returns {Array<{patterns: string[], values: Record<string, string|number>, line: number}>}
176
+ */
177
+ export function parseHostBlocks(text) {
178
+ const blocks = []
179
+ let current = null
180
+ const lines = configLines(text)
181
+ for (let i = 0; i < lines.length; i += 1) {
182
+ const line = lines[i].trim()
183
+ if (!line || line.startsWith('#')) continue
184
+ const m = line.match(/^(\S+)\s+(.*)$/)
185
+ if (!m) continue
186
+ const key = m[1].toLowerCase()
187
+ const value = m[2].trim()
188
+ if (key === 'host') {
189
+ current = { patterns: splitValue(value), values: {}, line: i + 1 }
190
+ blocks.push(current)
191
+ continue
192
+ }
193
+ if (!current || !KNOWN_PARAMS.has(key)) continue
194
+ // First value wins inside a block too (a repeated parameter is ignored).
195
+ if (Object.prototype.hasOwnProperty.call(current.values, key)) continue
196
+ current.values[key] = key === 'port' ? (Number(value) || value) : stripQuotes(value)
197
+ }
198
+ return blocks
199
+ }
200
+
201
+ /**
202
+ * Legacy block list: one entry per `Host` line, flattened to the fields the
203
+ * import UI shows. Kept stable for the existing importer + tests.
22
204
  * @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
23
- * host = alias pattern (may be '*' / '!foo' / comma list — caller filters).
24
205
  */
25
206
  export function parseSshConfig(text) {
26
207
  const entries = []
27
208
  let cur = null
28
- const lines = String(text || '').split('\n')
29
- for (const raw of lines) {
209
+ for (const raw of configLines(text)) {
30
210
  const line = raw.trim()
31
- if (!line || line.startsWith('#') || line.startsWith('Include ')) continue
211
+ if (!line || line.startsWith('#')) continue
32
212
  const m = line.match(/^(\S+)\s+(.*)$/)
33
213
  if (!m) continue
34
214
  const key = m[1].toLowerCase()
35
215
  const val = m[2].trim()
36
216
  if (key === 'host') {
37
- cur = { host: val, hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
217
+ cur = { host: stripQuotes(val), hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
38
218
  entries.push(cur)
39
219
  continue
40
220
  }
41
221
  if (!cur) continue
42
- if (key === 'hostname') cur.hostName = val
43
- else if (key === 'user') cur.user = val
44
- else if (key === 'port') cur.port = Number(val) || 22
45
- else if (key === 'identityfile') cur.identityFile = val
46
- else if (key === 'proxyjump') cur.proxyJump = val
222
+ if (key === 'hostname' && !cur.hostName) cur.hostName = stripQuotes(val)
223
+ else if (key === 'user' && !cur.user) cur.user = stripQuotes(val)
224
+ else if (key === 'port' && cur.port === 22) cur.port = Number(val) || 22
225
+ else if (key === 'identityfile' && !cur.identityFile) cur.identityFile = stripQuotes(val)
226
+ else if (key === 'proxyjump' && !cur.proxyJump) cur.proxyJump = stripQuotes(val)
47
227
  }
48
228
  return entries
49
229
  }
@@ -55,3 +235,220 @@ export function importableEntries(text) {
55
235
  return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
56
236
  })
57
237
  }
238
+
239
+ /**
240
+ * Every concrete (non-wildcard) alias the config defines, with the effective
241
+ * values after OpenSSH first-wins resolution — the list the import UI offers.
242
+ * @param {string} text
243
+ * @returns {Array<{alias: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string}>}
244
+ */
245
+ export function listAliases(text) {
246
+ const seen = new Set()
247
+ const out = []
248
+ for (const block of parseHostBlocks(text)) {
249
+ for (const pattern of block.patterns) {
250
+ if (!pattern || /[*?!]/.test(pattern) || seen.has(pattern)) continue
251
+ seen.add(pattern)
252
+ out.push(resolveSshHost(pattern, text))
253
+ }
254
+ }
255
+ return out.sort((a, b) => compareText(a.alias, b.alias))
256
+ }
257
+
258
+ /** Glob match for ssh host patterns (`*`, `?`), case-insensitive. */
259
+ export function globMatch(pattern, value) {
260
+ return globRegex(pattern).test(String(value || ''))
261
+ }
262
+
263
+ function globRegex(pattern) {
264
+ let re = ''
265
+ for (const ch of String(pattern || '')) {
266
+ if (ch === '*') re += '.*'
267
+ else if (ch === '?') re += '.'
268
+ else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
269
+ }
270
+ return new RegExp(`^${re}$`, 'i')
271
+ }
272
+
273
+ /**
274
+ * Whether a `Host` pattern list applies to a name: any positive pattern matches
275
+ * AND no negated (`!`) pattern does.
276
+ * @param {string[]|string} patterns
277
+ * @param {string} name
278
+ */
279
+ export function hostBlockMatches(patterns, name) {
280
+ const list = Array.isArray(patterns) ? patterns : splitValue(patterns)
281
+ const negative = list.filter((p) => p.startsWith('!')).map((p) => p.slice(1))
282
+ const positive = list.filter((p) => !p.startsWith('!'))
283
+ if (negative.some((p) => globMatch(p, name))) return false
284
+ return positive.some((p) => globMatch(p, name))
285
+ }
286
+
287
+ /**
288
+ * Effective configuration for one alias, with OpenSSH first-wins semantics.
289
+ *
290
+ * `hostName` falls back to the alias itself (ssh's own behaviour), so a block
291
+ * that only sets `User`/`IdentityFile` still yields a connectable host.
292
+ *
293
+ * @param {string} alias
294
+ * @param {string} text - FULL config text (Include-expanded).
295
+ * @returns {{alias: string, matched: boolean, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string, identitiesOnly: string}}
296
+ */
297
+ export function resolveSshHost(alias, text) {
298
+ const name = String(alias || '').trim()
299
+ const out = {
300
+ alias: name,
301
+ matched: false,
302
+ hostName: '',
303
+ user: '',
304
+ port: 0,
305
+ identityFile: '',
306
+ proxyJump: '',
307
+ proxyCommand: '',
308
+ identitiesOnly: '',
309
+ }
310
+ if (!name) return out
311
+ for (const block of parseHostBlocks(text)) {
312
+ if (!hostBlockMatches(block.patterns, name)) continue
313
+ out.matched = true
314
+ // first obtained value wins, ACROSS blocks in file order
315
+ for (const [key, value] of Object.entries(block.values)) {
316
+ if (key === 'hostname') { if (!out.hostName) out.hostName = String(value) }
317
+ else if (key === 'user') { if (!out.user) out.user = String(value) }
318
+ else if (key === 'port') { if (!out.port) out.port = Number(value) || 0 }
319
+ else if (key === 'identityfile') { if (!out.identityFile) out.identityFile = String(value) }
320
+ else if (key === 'proxyjump') { if (!out.proxyJump) out.proxyJump = String(value) }
321
+ else if (key === 'proxycommand') { if (!out.proxyCommand) out.proxyCommand = String(value) }
322
+ else if (key === 'identitiesonly') { if (!out.identitiesOnly) out.identitiesOnly = String(value) }
323
+ }
324
+ }
325
+ if (!out.hostName) out.hostName = name
326
+ return out
327
+ }
328
+
329
+ /**
330
+ * Resolve an alias from a config FILE (Include-expanded) or from given text.
331
+ * @param {string} alias
332
+ * @param {{file?: string, text?: string, io?: object}} [opts]
333
+ */
334
+ export function resolveSshAlias(alias, opts = {}) {
335
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
336
+ return resolveSshHost(alias, text)
337
+ }
338
+
339
+ /** Expand a leading `~` in an ssh config path (IdentityFile). */
340
+ export function expandHome(p) {
341
+ const s = String(p || '')
342
+ if (!s.startsWith('~')) return s
343
+ if (s === '~') return homedir()
344
+ if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(homedir(), s.slice(2))
345
+ return s
346
+ }
347
+
348
+ /**
349
+ * Split a ProxyJump value into hops: `[user@]host[:port]`, comma-separated, and
350
+ * `none` meaning "no jump".
351
+ * @param {string} value
352
+ * @returns {Array<{user: string, host: string, port: number}>}
353
+ */
354
+ export function parseProxyJump(value) {
355
+ const raw = String(value || '').trim()
356
+ if (!raw || /^none$/i.test(raw)) return []
357
+ return raw.split(',').map((hop) => hop.trim()).filter(Boolean).map((hop) => {
358
+ const m = hop.match(/^(?:([^@]+)@)?([^:[\] ]+)(?::(\d+))?$/)
359
+ if (!m) return { user: '', host: hop, port: 0 }
360
+ return { user: m[1] || '', host: m[2], port: Number(m[3]) || 0 }
361
+ })
362
+ }
363
+
364
+ /**
365
+ * Connect target for an ssh-config alias: the shape the plugin's pool expects.
366
+ *
367
+ * `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
368
+ * be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
369
+ * — silently connecting through the wrong path would be worse. `ProxyCommand`
370
+ * (an arbitrary local command) is reported but never executed.
371
+ *
372
+ * @param {string} alias
373
+ * @param {object} [opts]
374
+ * @param {string} [opts.text] - config text (else the real file is read).
375
+ * @param {string} [opts.file]
376
+ * @param {object} [opts.io]
377
+ * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
378
+ */
379
+ export function sshAliasTarget(alias, opts = {}) {
380
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
381
+ const resolved = resolveSshHost(alias, text)
382
+ const warnings = []
383
+ let proxy
384
+ const hops = parseProxyJump(resolved.proxyJump)
385
+ if (hops.length > 1) {
386
+ warnings.push(`ProxyJump has ${hops.length} hops; dsh-remote supports one jump host — using the first (${hops[0].host}).`)
387
+ }
388
+ if (hops.length) {
389
+ const hop = hops[0]
390
+ const hopResolved = resolveSshHost(hop.host, text)
391
+ proxy = {
392
+ host: hopResolved.hostName || hop.host,
393
+ port: hop.port || hopResolved.port || 22,
394
+ username: hop.user || hopResolved.user || '',
395
+ privateKeyPath: hopResolved.identityFile ? expandHome(hopResolved.identityFile) : '',
396
+ passphrase: '',
397
+ password: '',
398
+ }
399
+ } else if (resolved.proxyCommand) {
400
+ warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
401
+ }
402
+ return {
403
+ alias,
404
+ matched: resolved.matched,
405
+ host: resolved.hostName || alias,
406
+ port: resolved.port || 22,
407
+ username: resolved.user || '',
408
+ privateKeyPath: resolved.identityFile ? expandHome(resolved.identityFile) : '',
409
+ proxy,
410
+ proxyJump: resolved.proxyJump,
411
+ warnings,
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Effective connect identity for a saved machine.
417
+ *
418
+ * A machine in ALIAS mode (`useSshConfig: true`, `host` = the alias) inherits
419
+ * everything from ~/.ssh/config; a field the user filled in explicitly still
420
+ * wins. "Explicitly" cannot be distinguished from "form default" for the two
421
+ * fields that have defaults (port 22, user `root`), so those inherit unless they
422
+ * differ from the default — stated in the settings UI.
423
+ *
424
+ * @param {object} machine
425
+ * @param {{text?: string, file?: string, io?: object}} [opts]
426
+ * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, warnings: string[]}|null}
427
+ * null when the machine is not in alias mode.
428
+ */
429
+ export function resolveMachineSshConfig(machine, opts = {}) {
430
+ if (!machine || !machine.useSshConfig) return null
431
+ const alias = String(machine.host || '').trim()
432
+ if (!alias) return null
433
+ const target = sshAliasTarget(alias, opts)
434
+ const warnings = [...target.warnings]
435
+ if (!target.matched) warnings.push(`no ~/.ssh/config block matches "${alias}" — using the name itself.`)
436
+ const port = machine.port && Number(machine.port) !== 22 ? Number(machine.port) : target.port
437
+ const username = machine.username && machine.username !== 'root' ? String(machine.username) : (target.username || machine.username || '')
438
+ const privateKeyPath = machine.privateKeyPath ? String(machine.privateKeyPath) : target.privateKeyPath
439
+ const proxy = machine.proxy && machine.proxy.host ? machine.proxy : target.proxy
440
+ return {
441
+ alias,
442
+ matched: target.matched,
443
+ host: target.host,
444
+ port: Number(port) || 22,
445
+ username: String(username || ''),
446
+ privateKeyPath,
447
+ proxy,
448
+ warnings,
449
+ }
450
+ }
451
+
452
+ function compareText(left, right) {
453
+ return left < right ? -1 : left > right ? 1 : 0
454
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",