dsh-remote 0.8.54 → 0.8.61

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/credential.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // $DSH_HOME/remote-workspaces/.secrets/
13
13
  // linux → `secret-tool` (libsecret / gnome-keyring), optional
14
14
  // else → unsupported (plain)
15
- import { execFile } from 'node:child_process'
15
+ import { spawn } from 'node:child_process'
16
16
  import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
17
17
  import { join as joinPath } from 'node:path'
18
18
 
@@ -26,12 +26,60 @@ const TIMEOUT = 8000
26
26
  // harmless on PowerShell 7, where the type is already available.
27
27
  const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
28
28
 
29
+ /**
30
+ * Run a helper and resolve with its trimmed stdout.
31
+ *
32
+ * WHY `spawn` and not `execFile(..., { input })`: measured on a real Ubuntu box
33
+ * with a real gnome-keyring, `secret-tool store` given its password through
34
+ * `execFile`'s `input` option **never saw EOF**, blocked on the read, and was
35
+ * killed by this timeout — so on Linux "encrypt the password" always failed after
36
+ * 8 seconds with the useless message "Command failed: secret-tool store …" (empty
37
+ * stderr) and the credential was dropped. The same command with an explicitly
38
+ * ended stdin (`spawn` + `stdin.end()`, or `sh -c '… < file'`) finishes in ~20ms.
39
+ * Nothing in this module needs the extra `execFile` machinery, so stdin is now
40
+ * always closed explicitly and the child's stderr is carried on the error (the old
41
+ * path dropped it, which is why the failure had no reason attached).
42
+ */
29
43
  function run(bin, args, input) {
30
44
  return new Promise((resolve, reject) => {
31
- execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
32
- if (err) return reject(err)
33
- resolve(String(stdout || '').trim())
45
+ let child
46
+ try {
47
+ child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
48
+ } catch (err) {
49
+ reject(err)
50
+ return
51
+ }
52
+ let out = ''
53
+ let errOut = ''
54
+ let settled = false
55
+ const timer = setTimeout(() => {
56
+ if (settled) return
57
+ settled = true
58
+ try { child.kill('SIGTERM') } catch { /* already gone */ }
59
+ const err = new Error(`${bin} timed out after ${TIMEOUT}ms`)
60
+ err.stderr = errOut
61
+ err.code = 'ETIMEDOUT'
62
+ reject(err)
63
+ }, TIMEOUT)
64
+ const done = (err, value) => {
65
+ if (settled) return
66
+ settled = true
67
+ clearTimeout(timer)
68
+ if (err) reject(err); else resolve(value)
69
+ }
70
+ child.on('error', (err) => done(err))
71
+ child.on('close', (code) => {
72
+ if (code === 0) return done(null, out.trim())
73
+ const err = new Error(`${bin} exited with ${code}${errOut.trim() ? ': ' + errOut.trim().slice(0, 300) : ''}`)
74
+ err.code = code
75
+ err.stderr = errOut
76
+ done(err)
34
77
  })
78
+ child.stdout.on('data', (d) => { out += d })
79
+ child.stderr.on('data', (d) => { errOut += d })
80
+ // Always end stdin: a helper that reads a secret from stdin needs EOF, and one
81
+ // that does not must not inherit an open pipe.
82
+ try { child.stdin.end(input == null ? '' : String(input)) } catch { /* closed early */ }
35
83
  })
36
84
  }
37
85
 
package/lib/errors.js CHANGED
@@ -24,10 +24,22 @@ export function classifyError(err, opts = {}) {
24
24
  raw: msg,
25
25
  }
26
26
  }
27
+ // Checked BEFORE the generic `no credentials` branch: the actionable failure
28
+ // there is "this process has no ssh-agent", not "you forgot to configure a
29
+ // credential". Telling a Windows user to "set a password or a privateKeyPath"
30
+ // when they already ticked 使用 SSH agent is how a config page gets a
31
+ // reputation for lying.
32
+ if (/no SSH agent available|SSH_AUTH_SOCK/i.test(msg)) {
33
+ return {
34
+ category: 'credentials',
35
+ hint: `SSH agent 不可用${where}:这台机器勾选了「使用 SSH agent」,但 DSH 宿主进程里没有 SSH_AUTH_SOCK(Windows 默认没有 agent 服务)。要么在启动 DSH 之前把 agent 跑起来并导出 SSH_AUTH_SOCK(或用 Pageant),要么取消勾选 agent 并给这台机器保存密码/私钥。`,
36
+ raw: msg,
37
+ }
38
+ }
27
39
  if (/no credentials/i.test(msg)) {
28
40
  return {
29
41
  category: 'credentials',
30
- hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
42
+ hint: `缺少凭据${where}:这台机器没有可用密码、私钥或 agent。请在「远程工作区」设置里填密码(或用私钥路径 / 勾选 agent)后保存;若你确信已经填过,说明保存时密码没有真正落盘(看保存后是否弹出密钥库失败警告),重新填写并保存即可,可用「测试连接」当场验证。`,
31
43
  raw: msg,
32
44
  }
33
45
  }
@@ -38,6 +50,18 @@ export function classifyError(err, opts = {}) {
38
50
  raw: msg,
39
51
  }
40
52
  }
53
+ // ssh2 reports a socket that dies DURING the handshake as the bare string
54
+ // "Connection lost before handshake" — no host, no cause, no advice. That is
55
+ // the message a user sees when the port is open but nothing speaks SSH on it,
56
+ // or when a firewall/security group cuts the connection after the TCP accept;
57
+ // it is indistinguishable from "wrong credentials" unless it is classified.
58
+ if (/connection lost before handshake|handshake failed|no matching (key exchange|host key|cipher)/i.test(msg)) {
59
+ return {
60
+ category: 'network',
61
+ hint: `SSH 握手未完成${where}:TCP 连上了但 SSH 握手没走完——端口上没有跑 sshd、连接被防火墙/安全组拒绝、或远端只支持不兼容的加密算法。确认 host/port 指向 sshd,必要时换端口或让运维放行。`,
62
+ raw: msg,
63
+ }
64
+ }
41
65
  if (/ECONNREFUSED|connection refused/i.test(msg)) {
42
66
  return {
43
67
  category: 'network',
@@ -78,10 +102,17 @@ export function classifyError(err, opts = {}) {
78
102
  return { category: 'other', hint: msg, raw: msg }
79
103
  }
80
104
 
81
- /** One-line friendly message: hint (+ raw when it adds information). */
105
+ /** One-line friendly message: hint (+ raw when it adds information).
106
+ *
107
+ * An UNCLASSIFIED failure still gets the host appended: "Connection lost before
108
+ * handshake" or a raw errno on its own leaves the user unable to tell WHICH
109
+ * machine failed, which is the one thing a multi-machine plugin must never hide. */
82
110
  export function friendlyMessage(err, opts = {}) {
83
111
  const c = classifyError(err, opts)
84
- if (c.category === 'other') return c.raw
112
+ const where = opts.host ? ` (${opts.host}:${opts.port || 22})` : ''
113
+ if (c.category === 'other') {
114
+ return where && !c.raw.includes(opts.host) ? `${c.raw}${where}` : c.raw
115
+ }
85
116
  if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
86
117
  return c.hint
87
118
  }
package/lib/index.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
35
35
  import { compileIgnore, ignorePatternsFor, DEFAULT_IGNORE } from './ignore.js'
36
36
  import { friendlyMessage } from './errors.js'
37
- import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig, resolveLiteralSshConfig, readRawSshConfig, upsertManagedHostBlock, removeManagedHostBlock, writeSshConfig } from './sshconfig.js'
37
+ import { listAliases, sshConfigPath, readSshConfigText, resolveMachineSshConfig, resolveLiteralSshConfig, readRawSshConfig, upsertManagedHostBlock, removeManagedHostBlock, writeSshConfig } from './sshconfig.js'
38
38
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
39
39
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
40
40
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
@@ -58,7 +58,7 @@ import {
58
58
  import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
59
59
  import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
60
60
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
61
- import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup } from './registry.js'
61
+ import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup, resolveProbeCredentials } from './registry.js'
62
62
  import { registerHttpTransports } from './http-transport.js'
63
63
  import { SshPool, ActivePool, createPool } from './pool.js'
64
64
  import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
@@ -1495,6 +1495,29 @@ export async function apply(ctx, config) {
1495
1495
  err.httpStatus = 403
1496
1496
  throw err
1497
1497
  }
1498
+ // No session context: fall back to the ACTIVE machine. Boot restore runs on
1499
+ // `setImmediate`, so a request that arrives first would see `config.host`
1500
+ // still empty even though the registry already names a current machine —
1501
+ // apply it here instead of answering "no machine" to a user who HAS one.
1502
+ if (!String(config.host || '').trim()) {
1503
+ const m = activeMachine()
1504
+ if (m && m.host) {
1505
+ try { await applyActiveMachine() } catch { /* reported below */ }
1506
+ }
1507
+ }
1508
+ // Still nothing: answer with the REAL reason. Falling through used to hand the
1509
+ // caller the un-configured pool, whose first command failed with
1510
+ // "no credentials: set a password, a privateKeyPath…" — i.e. a missing
1511
+ // *selection* was reported as a missing *credential*, which sends people to the
1512
+ // wrong settings page.
1513
+ if (!String(config.host || '').trim()) {
1514
+ const err = new Error(
1515
+ '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。'
1516
+ + '(保存的机器只是备用连接,不会自动成为当前机器。)',
1517
+ )
1518
+ err.httpStatus = 409
1519
+ throw err
1520
+ }
1498
1521
  return {
1499
1522
  pool,
1500
1523
  ws: wsPath(),
@@ -3199,6 +3222,13 @@ export async function apply(ctx, config) {
3199
3222
  })
3200
3223
  const pickLocalNative = async () => {
3201
3224
  const platform = process.platform
3225
+ // Escape hatch for non-interactive hosts and for the test suite: opening a
3226
+ // real OS folder dialog from a request handler means the REQUEST waits for a
3227
+ // human. In a headless host (or CI) that is a hang nobody can see or cancel —
3228
+ // the route then falls back to its browse capability / "type the path" hint.
3229
+ if (String(process.env.DSH_REMOTE_NO_NATIVE_DIALOG || '') && process.env.DSH_REMOTE_NO_NATIVE_DIALOG !== '0') {
3230
+ throw new Error('已禁用本机目录选择器对话框(DSH_REMOTE_NO_NATIVE_DIALOG)—— 请直接在输入框填本地路径')
3231
+ }
3202
3232
  if (platform === 'darwin') {
3203
3233
  return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
3204
3234
  }
@@ -3622,8 +3652,20 @@ export async function apply(ctx, config) {
3622
3652
  port: Number(body.port) || 22,
3623
3653
  username: String(body.username || '').trim() || 'root',
3624
3654
  password: '',
3625
- privateKeyPath: String(body.privateKeyPath || '').trim(),
3626
- passphrase: body.passphrase || '',
3655
+ // An OMITTED `privateKeyPath` keeps the stored one; an explicit
3656
+ // empty string clears it (that is what the form sends when the user
3657
+ // empties the box). A partial update from an API/agent caller used
3658
+ // to silently wipe the key path, leaving a machine that can only
3659
+ // fail to authenticate — the same data-loss class as the passphrase.
3660
+ privateKeyPath: body.privateKeyPath === undefined
3661
+ ? String((prev && prev.privateKeyPath) || '')
3662
+ : String(body.privateKeyPath || '').trim(),
3663
+ // An EMPTY passphrase from the form means "unchanged", not
3664
+ // "delete it": the value is never echoed back to the browser, so
3665
+ // an edit that touched any other field used to silently wipe a
3666
+ // saved private-key passphrase (and the next connect then failed
3667
+ // with "no passphrase given" for a key that had been working).
3668
+ passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
3627
3669
  workspace: String(body.workspace || '').trim(),
3628
3670
  hostKeyMode: body.hostKeyMode || '',
3629
3671
  useAgent: !!body.useAgent,
@@ -3649,13 +3691,20 @@ export async function apply(ctx, config) {
3649
3691
  description: body.description === undefined ? (prev && prev.description) : body.description,
3650
3692
  tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
3651
3693
  }),
3652
- proxy: body.proxy && body.proxy.host ? {
3653
- host: String(body.proxy.host),
3654
- port: Number(body.proxy.port) || 22,
3655
- username: String(body.proxy.username || '').trim(),
3656
- password: String(body.proxy.password || ''),
3657
- privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
3658
- } : undefined,
3694
+ // A jump host the form did not mention is KEPT (omitted ≠
3695
+ // removed), and a jump PASSWORD the form did not re-supply is
3696
+ // kept too: the settings page never receives it back, so an edit
3697
+ // used to strip the bastion credential and the machine could
3698
+ // only fail at connect time.
3699
+ proxy: body.proxy === undefined
3700
+ ? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
3701
+ : (body.proxy && body.proxy.host ? {
3702
+ host: String(body.proxy.host),
3703
+ port: Number(body.proxy.port) || 22,
3704
+ username: String(body.proxy.username || '').trim(),
3705
+ password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
3706
+ privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
3707
+ } : undefined),
3659
3708
  credentialBackend: credBackend,
3660
3709
  recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
3661
3710
  lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
@@ -3677,12 +3726,33 @@ export async function apply(ctx, config) {
3677
3726
  })
3678
3727
  rec.credentialBackend = persisted.credentialBackend
3679
3728
  rec.password = persisted.password
3729
+ // Record WHERE the secret ended up, so the settings page can say
3730
+ // "已保存" for a keychain machine too (its record carries an
3731
+ // empty `password` by design).
3732
+ rec.secretStored = persisted.stored === 'secret'
3680
3733
  if (persisted.warning) {
3681
3734
  warning = persisted.warning
3682
3735
  warningDetail = persisted.error
3683
3736
  }
3684
- } else if (prev && prev.password) {
3685
- rec.password = prev.password
3737
+ } else if (prev) {
3738
+ // No password typed: keep whatever the machine already had —
3739
+ // the plaintext copy AND the keychain flag. Turning the
3740
+ // "加密保存密码" checkbox OFF is handled next: it must actually
3741
+ // MOVE the secret, or the checkbox is a lie.
3742
+ rec.password = prev.password || ''
3743
+ rec.secretStored = prev.secretStored
3744
+ const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
3745
+ if (credBackend === 'plain' && prevKeychain) {
3746
+ try {
3747
+ const existing = await machinePassword(prev)
3748
+ if (existing) {
3749
+ rec.password = existing
3750
+ rec.secretStored = false
3751
+ rec.credentialBackend = 'plain'
3752
+ await deleteSecret(id, secretsDir()).catch(() => {})
3753
+ }
3754
+ } catch { /* keep the keychain copy; the next connect still works */ }
3755
+ }
3686
3756
  }
3687
3757
  if (i >= 0) machines[i] = rec; else machines.push(rec)
3688
3758
  // Issue #13: saving a machine must NOT make it the active
@@ -3746,6 +3816,62 @@ export async function apply(ctx, config) {
3746
3816
  ...(sshConfigWarning ? { sshConfigWarning } : {}),
3747
3817
  })
3748
3818
  }
3819
+ // Issue #73: one click on a host listed from ~/.ssh/config. The settings
3820
+ // page no longer asks the user to understand "alias" vs "copy": it lists
3821
+ // the file's hosts and this action adopts one — upsert an alias machine
3822
+ // (never a duplicate), make it the current machine, and let the caller
3823
+ // connect/probe next. Nothing about the user's file is rewritten here:
3824
+ // the alias IS the source of truth (issue #50).
3825
+ if (action === 'from-ssh-config') {
3826
+ const alias = String(body.alias || body.host || '').trim()
3827
+ if (!alias) return sendJson(res, 400, { ok: false, error: 'alias required' })
3828
+ const known = listAliases(sshConfigText()).find((a) => a.alias === alias)
3829
+ if (!known) {
3830
+ return sendJson(res, 404, {
3831
+ ok: false,
3832
+ error: `~/.ssh/config 里没有 "${alias}"(改完文件后重新打开设置页即可刷新)`,
3833
+ })
3834
+ }
3835
+ const existing = machines.find((m) => m.useSshConfig && String(m.host) === alias)
3836
+ let rec = existing
3837
+ if (!rec) {
3838
+ rec = {
3839
+ id: machineId(),
3840
+ name: alias,
3841
+ host: alias,
3842
+ useSshConfig: true,
3843
+ port: Number(known.port) || 22,
3844
+ username: String(known.user || ''),
3845
+ password: '',
3846
+ privateKeyPath: '',
3847
+ passphrase: '',
3848
+ workspace: '',
3849
+ hostKeyMode: '',
3850
+ useAgent: false,
3851
+ keyboardInteractive: false,
3852
+ backend: 'native',
3853
+ sshBinary: '',
3854
+ credentialBackend: 'plain',
3855
+ recentWorkspaces: [],
3856
+ lastConnectedAt: null,
3857
+ latencyMs: null,
3858
+ }
3859
+ machines.push(rec)
3860
+ saveMachines(machines, store.currentId, false)
3861
+ }
3862
+ // Making it current is the whole point of the click: the picker and
3863
+ // every session route then act on this machine.
3864
+ const okSet = await setCurrent(rec.id)
3865
+ if (!okSet) return sendJson(res, 500, { ok: false, error: 'could not activate the machine' })
3866
+ return sendJson(res, 200, {
3867
+ ok: true,
3868
+ created: !existing,
3869
+ machine: sanitizeMachine(rec),
3870
+ machines: machinesForClient(),
3871
+ currentId: store.currentId,
3872
+ ...status(),
3873
+ })
3874
+ }
3749
3875
  if (action === 'delete') {
3750
3876
  const i = machineIndex(String(body.id || ''))
3751
3877
  if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
@@ -3788,17 +3914,46 @@ export async function apply(ctx, config) {
3788
3914
  let body = {}
3789
3915
  try {
3790
3916
  body = parseJsonObject(await readBody(req))
3917
+ // ── The probe must dial with the credentials a REAL connect would use.
3918
+ //
3919
+ // Stored secrets never travel back to the browser (see
3920
+ // sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
3921
+ // EMPTY password while the machine itself connected fine through the
3922
+ // pool's keychain/plaintext resolver. Every such probe died with
3923
+ // `no credentials: set a password, a privateKeyPath, or enable
3924
+ // useAgent to connect` — the error said "you configured nothing" about
3925
+ // a machine that was visibly working.
3926
+ //
3927
+ // So: resolve the saved record (by id when the form is editing one,
3928
+ // else by identity) and merge — form values override, blanks fall back
3929
+ // to the stored record. The resolved password is then ALSO exposed as a
3930
+ // lazy resolver so an OS-keychain machine behaves exactly like a real
3931
+ // connect (read on demand, never logged).
3932
+ const savedRec = (() => {
3933
+ const byId = body.id ? machines[machineIndex(String(body.id))] : null
3934
+ if (byId) return byId
3935
+ // No id (a fresh form that happens to match a saved machine): match on
3936
+ // the identity the form is about to dial.
3937
+ const hit = machineRecordFor({
3938
+ host: String(body.host || ''),
3939
+ port: Number(body.port) || 22,
3940
+ username: String(body.username || ''),
3941
+ })
3942
+ return hit || null
3943
+ })()
3944
+ const savedPassword = savedRec ? await machinePassword(savedRec) : ''
3945
+ const cred = resolveProbeCredentials(body, savedRec, savedPassword)
3791
3946
  // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
3792
3947
  // the form holds the alias name, not the host it points at.
3793
- const probeBackend = String(body.backend || config.backend || 'native')
3948
+ const probeBackend = String(cred.backend || config.backend || 'native')
3794
3949
  const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
3795
3950
  const aliasTarget = body.useSshConfig
3796
3951
  ? resolveMachineSshConfig({
3797
3952
  host: String(body.host || ''),
3798
3953
  port: Number(body.port) || 22,
3799
3954
  username: String(body.username || ''),
3800
- privateKeyPath: String(body.privateKeyPath || ''),
3801
- proxy: body.proxy,
3955
+ privateKeyPath: String(cred.privateKeyPath || ''),
3956
+ proxy: cred.proxy,
3802
3957
  useSshConfig: true,
3803
3958
  }, { text: sshConfigText() })
3804
3959
  : null
@@ -3818,19 +3973,24 @@ export async function apply(ctx, config) {
3818
3973
  host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
3819
3974
  port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
3820
3975
  username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
3821
- password: String(body.password || ''),
3822
- privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3823
- passphrase: String(body.passphrase || ''),
3824
- proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : (body.proxy && body.proxy.host ? {
3825
- host: String(body.proxy.host),
3826
- port: Number(body.proxy.port) || 22,
3827
- username: String(body.proxy.username || ''),
3828
- password: String(body.proxy.password || ''),
3829
- privateKeyPath: String(body.proxy.privateKeyPath || ''),
3830
- } : undefined),
3976
+ password: cred.password,
3977
+ privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
3978
+ passphrase: cred.passphrase,
3979
+ // Auth METHOD flags come from the merge too: a machine that
3980
+ // authenticates through an agent or keyboard-interactive used to be
3981
+ // probed with `useAgent: false`, i.e. with no auth method at all.
3982
+ useAgent: cred.useAgent,
3983
+ keyboardInteractive: cred.keyboardInteractive,
3984
+ ...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
3985
+ ...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
3986
+ proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
3831
3987
  connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
3832
3988
  commandTimeoutMs: 10000,
3833
3989
  }, { knownHostsFile })
3990
+ // Same lazy resolution the active pool uses: if the merged password was
3991
+ // empty because the secret lives in the OS keychain, the pool asks again
3992
+ // at dial time instead of failing with "no credentials".
3993
+ probe.passwordResolver = async () => machinePassword(savedRec || {})
3834
3994
  const started = Date.now()
3835
3995
  await probe.connect()
3836
3996
  await probe.exec('true', { timeoutMs: 10000 })
@@ -3853,6 +4013,9 @@ export async function apply(ctx, config) {
3853
4013
  platform: probe.platform,
3854
4014
  shell: probe.shellMode,
3855
4015
  gitBash: probe.gitBashPath || '',
4016
+ // Tell the UI whether the probe leaned on the SAVED credential: with
4017
+ // an empty password box, "测试成功" otherwise looks impossible.
4018
+ usedSavedCredential: cred.usedSavedCredential,
3856
4019
  // Issue #38: tell the settings page where an alias resolved to (and
3857
4020
  // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
3858
4021
  alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
@@ -3903,11 +4066,23 @@ export async function apply(ctx, config) {
3903
4066
  const body = JSON.parse((await readBody(req)) || '{}')
3904
4067
  const action = String(body.action || '')
3905
4068
  if (action === 'define') {
4069
+ // Validate the port HERE, not at start time: `listen(0)` would silently
4070
+ // pick an ephemeral port and the user would be looking at a forward
4071
+ // that listens nowhere they asked for. The settings UI cannot produce
4072
+ // this, but the HTTP API (and therefore an agent) can.
4073
+ const listenPort = Number(body.listenPort)
4074
+ if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) {
4075
+ return sendJson(res, 400, { ok: false, error: 'listenPort 必须是 1–65535 的整数' })
4076
+ }
4077
+ const targetPort = Number(body.targetPort) || listenPort
4078
+ if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
4079
+ return sendJson(res, 400, { ok: false, error: 'targetPort 必须是 1–65535 的整数' })
4080
+ }
3906
4081
  const d = forwards.define({
3907
4082
  direction: body.direction === 'reverse' ? 'reverse' : 'local',
3908
- listenPort: Number(body.listenPort),
4083
+ listenPort,
3909
4084
  targetHost: body.targetHost || '127.0.0.1',
3910
- targetPort: Number(body.targetPort) || Number(body.listenPort),
4085
+ targetPort,
3911
4086
  autoStart: !!body.autoStart,
3912
4087
  machineId: store.currentId,
3913
4088
  })
@@ -3985,7 +4160,22 @@ export async function apply(ctx, config) {
3985
4160
  if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
3986
4161
  try {
3987
4162
  const text = readSshConfigText()
3988
- return sendJson(res, 200, { ok: true, file: sshConfigPath(), present: !!text, entries: importableEntries(text) })
4163
+ // Issue #73: the settings page lists the hosts of ~/.ssh/config the way
4164
+ // VSCode Remote-SSH does, so this must include EVERY concrete alias —
4165
+ // `importableEntries` filtered out a bare `Host mybox` with no options,
4166
+ // which is a perfectly normal entry to connect to. `host` is kept as an
4167
+ // alias of `alias` so existing callers keep working.
4168
+ const entries = listAliases(text).map((a) => ({
4169
+ host: a.alias,
4170
+ alias: a.alias,
4171
+ hostName: a.hostName || a.alias,
4172
+ user: a.user || '',
4173
+ port: Number(a.port) || 22,
4174
+ identityFile: a.identityFile || '',
4175
+ proxyJump: a.proxyJump || '',
4176
+ proxyCommand: a.proxyCommand || '',
4177
+ }))
4178
+ return sendJson(res, 200, { ok: true, file: sshConfigPath(), present: !!text, entries })
3989
4179
  } catch (err) {
3990
4180
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
3991
4181
  }
@@ -4286,7 +4476,16 @@ export async function apply(ctx, config) {
4286
4476
  if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
4287
4477
  return sendJson(res, 200, { ok: true, home })
4288
4478
  } catch (err) {
4289
- return sendJson(res, 200, { ok: true, home: null, error: String((err && err.message) || err) })
4479
+ // `ok:false` — reporting `ok:true` WITH an `error` is a contradiction:
4480
+ // any caller that trusts `ok` treats "could not resolve the home
4481
+ // directory" as success. The picker only reads `home`, so it still falls
4482
+ // back to `hint`/`error` exactly as before.
4483
+ return sendJson(res, 200, {
4484
+ ok: false,
4485
+ home: null,
4486
+ hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径',
4487
+ error: String((err && err.message) || err),
4488
+ })
4290
4489
  }
4291
4490
  },
4292
4491
  },