dsh-remote 0.8.54 → 0.8.60

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
@@ -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
@@ -3788,17 +3858,46 @@ export async function apply(ctx, config) {
3788
3858
  let body = {}
3789
3859
  try {
3790
3860
  body = parseJsonObject(await readBody(req))
3861
+ // ── The probe must dial with the credentials a REAL connect would use.
3862
+ //
3863
+ // Stored secrets never travel back to the browser (see
3864
+ // sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
3865
+ // EMPTY password while the machine itself connected fine through the
3866
+ // pool's keychain/plaintext resolver. Every such probe died with
3867
+ // `no credentials: set a password, a privateKeyPath, or enable
3868
+ // useAgent to connect` — the error said "you configured nothing" about
3869
+ // a machine that was visibly working.
3870
+ //
3871
+ // So: resolve the saved record (by id when the form is editing one,
3872
+ // else by identity) and merge — form values override, blanks fall back
3873
+ // to the stored record. The resolved password is then ALSO exposed as a
3874
+ // lazy resolver so an OS-keychain machine behaves exactly like a real
3875
+ // connect (read on demand, never logged).
3876
+ const savedRec = (() => {
3877
+ const byId = body.id ? machines[machineIndex(String(body.id))] : null
3878
+ if (byId) return byId
3879
+ // No id (a fresh form that happens to match a saved machine): match on
3880
+ // the identity the form is about to dial.
3881
+ const hit = machineRecordFor({
3882
+ host: String(body.host || ''),
3883
+ port: Number(body.port) || 22,
3884
+ username: String(body.username || ''),
3885
+ })
3886
+ return hit || null
3887
+ })()
3888
+ const savedPassword = savedRec ? await machinePassword(savedRec) : ''
3889
+ const cred = resolveProbeCredentials(body, savedRec, savedPassword)
3791
3890
  // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
3792
3891
  // the form holds the alias name, not the host it points at.
3793
- const probeBackend = String(body.backend || config.backend || 'native')
3892
+ const probeBackend = String(cred.backend || config.backend || 'native')
3794
3893
  const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
3795
3894
  const aliasTarget = body.useSshConfig
3796
3895
  ? resolveMachineSshConfig({
3797
3896
  host: String(body.host || ''),
3798
3897
  port: Number(body.port) || 22,
3799
3898
  username: String(body.username || ''),
3800
- privateKeyPath: String(body.privateKeyPath || ''),
3801
- proxy: body.proxy,
3899
+ privateKeyPath: String(cred.privateKeyPath || ''),
3900
+ proxy: cred.proxy,
3802
3901
  useSshConfig: true,
3803
3902
  }, { text: sshConfigText() })
3804
3903
  : null
@@ -3818,19 +3917,24 @@ export async function apply(ctx, config) {
3818
3917
  host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
3819
3918
  port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
3820
3919
  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),
3920
+ password: cred.password,
3921
+ privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
3922
+ passphrase: cred.passphrase,
3923
+ // Auth METHOD flags come from the merge too: a machine that
3924
+ // authenticates through an agent or keyboard-interactive used to be
3925
+ // probed with `useAgent: false`, i.e. with no auth method at all.
3926
+ useAgent: cred.useAgent,
3927
+ keyboardInteractive: cred.keyboardInteractive,
3928
+ ...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
3929
+ ...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
3930
+ proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
3831
3931
  connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
3832
3932
  commandTimeoutMs: 10000,
3833
3933
  }, { knownHostsFile })
3934
+ // Same lazy resolution the active pool uses: if the merged password was
3935
+ // empty because the secret lives in the OS keychain, the pool asks again
3936
+ // at dial time instead of failing with "no credentials".
3937
+ probe.passwordResolver = async () => machinePassword(savedRec || {})
3834
3938
  const started = Date.now()
3835
3939
  await probe.connect()
3836
3940
  await probe.exec('true', { timeoutMs: 10000 })
@@ -3853,6 +3957,9 @@ export async function apply(ctx, config) {
3853
3957
  platform: probe.platform,
3854
3958
  shell: probe.shellMode,
3855
3959
  gitBash: probe.gitBashPath || '',
3960
+ // Tell the UI whether the probe leaned on the SAVED credential: with
3961
+ // an empty password box, "测试成功" otherwise looks impossible.
3962
+ usedSavedCredential: cred.usedSavedCredential,
3856
3963
  // Issue #38: tell the settings page where an alias resolved to (and
3857
3964
  // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
3858
3965
  alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
@@ -3903,11 +4010,23 @@ export async function apply(ctx, config) {
3903
4010
  const body = JSON.parse((await readBody(req)) || '{}')
3904
4011
  const action = String(body.action || '')
3905
4012
  if (action === 'define') {
4013
+ // Validate the port HERE, not at start time: `listen(0)` would silently
4014
+ // pick an ephemeral port and the user would be looking at a forward
4015
+ // that listens nowhere they asked for. The settings UI cannot produce
4016
+ // this, but the HTTP API (and therefore an agent) can.
4017
+ const listenPort = Number(body.listenPort)
4018
+ if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) {
4019
+ return sendJson(res, 400, { ok: false, error: 'listenPort 必须是 1–65535 的整数' })
4020
+ }
4021
+ const targetPort = Number(body.targetPort) || listenPort
4022
+ if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
4023
+ return sendJson(res, 400, { ok: false, error: 'targetPort 必须是 1–65535 的整数' })
4024
+ }
3906
4025
  const d = forwards.define({
3907
4026
  direction: body.direction === 'reverse' ? 'reverse' : 'local',
3908
- listenPort: Number(body.listenPort),
4027
+ listenPort,
3909
4028
  targetHost: body.targetHost || '127.0.0.1',
3910
- targetPort: Number(body.targetPort) || Number(body.listenPort),
4029
+ targetPort,
3911
4030
  autoStart: !!body.autoStart,
3912
4031
  machineId: store.currentId,
3913
4032
  })
@@ -4286,7 +4405,16 @@ export async function apply(ctx, config) {
4286
4405
  if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
4287
4406
  return sendJson(res, 200, { ok: true, home })
4288
4407
  } catch (err) {
4289
- return sendJson(res, 200, { ok: true, home: null, error: String((err && err.message) || err) })
4408
+ // `ok:false` — reporting `ok:true` WITH an `error` is a contradiction:
4409
+ // any caller that trusts `ok` treats "could not resolve the home
4410
+ // directory" as success. The picker only reads `home`, so it still falls
4411
+ // back to `hint`/`error` exactly as before.
4412
+ return sendJson(res, 200, {
4413
+ ok: false,
4414
+ home: null,
4415
+ hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径',
4416
+ error: String((err && err.message) || err),
4417
+ })
4290
4418
  }
4291
4419
  },
4292
4420
  },