dsh-remote 0.8.13 → 0.8.15

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/client.js CHANGED
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // A unified workspace directory picker fills ui-workspace's two directory-flow
8
8
  // holes (sidebar + conversation hero):
9
- // • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
9
+ // • 本机 tab → opens the NATIVE OS folder chooser (/dsh-remote/local-pick)
10
10
  // and returns the picked local path (works with local workspaces).
11
11
  // • 远程 tab → pick a machine (dropdown), list its directories over
12
12
  // /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
@@ -109,6 +109,7 @@ window.__ModuleLoader__.load({
109
109
  'settings.deleted': '已删除',
110
110
  'settings.saved': '已保存更新',
111
111
  'settings.added': '已添加 — 可设为当前',
112
+ 'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已回退为明文保存在 machines.json,连接仍可用。',
112
113
  'settings.switched': '已切换为当前远程机',
113
114
  'settings.cleared': '已取消当前远程机 — 本地会话不再自动使用远程上下文',
114
115
  'settings.filledSsh': '已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)',
@@ -308,6 +309,7 @@ window.__ModuleLoader__.load({
308
309
  'settings.deleted': 'Deleted',
309
310
  'settings.saved': 'Saved',
310
311
  'settings.added': 'Added — you can set it as current',
312
+ 'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); it was saved as plaintext in machines.json and the machine still connects.',
311
313
  'settings.switched': 'Switched to current remote machine',
312
314
  'settings.cleared': 'Cleared current remote — local sessions no longer use remote context automatically',
313
315
  'settings.filledSsh': 'Filled from ~/.ssh/config (only references the private key path, never reads its content)',
@@ -598,8 +600,7 @@ window.__ModuleLoader__.load({
598
600
  }
599
601
  }
600
602
 
601
- let WORKSPACES = null // ctx.get('workspaces'), set in apply()
602
- let SESSIONS = null // ctx.get('sessions'), set in apply()
603
+ let SESSIONS = null // optional sessions service, tracked by ctx.inject in apply()
603
604
 
604
605
  // ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
605
606
  function RemoteWorkspacePage() {
@@ -716,7 +717,13 @@ window.__ModuleLoader__.load({
716
717
  workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
717
718
  encryptPassword: form.encryptPassword,
718
719
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
719
- }).then((r) => { refresh(); clearForm(); setMsg(form.id ? tr('settings.saved') : tr('settings.added')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
720
+ }).then((r) => {
721
+ refresh(); clearForm()
722
+ // Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
723
+ // 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
724
+ if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
725
+ else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
726
+ }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
720
727
  }
721
728
  const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg(tr('settings.switched')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
722
729
  // Issue #13: "active remote = none" — saved machines stay, but no machine
@@ -1955,36 +1962,31 @@ window.__ModuleLoader__.load({
1955
1962
 
1956
1963
  function apply(ctx) {
1957
1964
  const slots = ctx.get('slots')
1958
- if (slots === undefined) return
1959
1965
  wireLocale(ctx)
1960
- // Issue #26: on dsh 0.1.2-rc.1+ the cordis ctx proxy throws
1961
- // "cannot get property \"workspaces\" without inject" on ANY bare property
1962
- // read of an undeclared/unavailable service. ctx.get(name) stays an
1963
- // optional lookup that returns undefined instead of throwing, so never
1964
- // fall back to ctx.<name> property access — keep it purely optional.
1965
- WORKSPACES = (ctx && ctx.get && ctx.get('workspaces')) || null
1966
- SESSIONS = (ctx && ctx.get && ctx.get('sessions')) || null
1966
+ // Optional integrations must not gate the settings page. In particular,
1967
+ // replacement sidebars may disable the native ui-workspace package.
1968
+ ctx.inject(['sessions'], (inner) => {
1969
+ const sessions = inner.get('sessions')
1970
+ SESSIONS = sessions
1971
+ inner.effect(() => () => {
1972
+ if (SESSIONS === sessions) SESSIONS = null
1973
+ }, 'dsh-remote.sessions')
1974
+ })
1967
1975
  slots.inject('settings.section', () =>
1968
- slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => tr('settings.title') }, () => React.createElement(RemoteWorkspacePage, null)),
1969
- )
1970
- slots.inject(
1971
- 'conversation.hero.workspace.directoryFlow',
1972
- () => slots.inject('sidebar.workspaces.directoryFlow',
1973
- function* () {
1974
- yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1975
- yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1976
- },
1977
- ),
1976
+ slots.register({ name: 'settings.section', id: 'dsh-remote', order: 40, label: () => tr('settings.title') }, () => React.createElement(RemoteWorkspacePage, null)),
1978
1977
  )
1978
+ for (const slot of ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow']) {
1979
+ slots.inject(slot, () =>
1980
+ slots.register({ name: slot, id: 'dsh-remote', priority: -100 }, DirPicker),
1981
+ )
1982
+ }
1979
1983
  ctx.inject(['betterSidebar'], (inner) => {
1980
- const bsDispose = registerSidebarIntegration(inner)
1981
- if (bsDispose) {
1982
- inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
1983
- }
1984
+ inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
1984
1985
  })
1985
1986
  }
1986
1987
 
1987
1988
  exports.name = name
1989
+ exports.inject = ['slots', 'locale']
1988
1990
  exports.apply = apply
1989
1991
  return module.exports
1990
1992
  },
package/lib/credential.js CHANGED
@@ -1,107 +1,177 @@
1
- // dsh-remote — optional OS-keychain password storage.
2
- // Machines keep their password in the OS credential store instead of the
3
- // plaintext machines.json when the user enables "加密保存密码" per machine.
4
- // Every backend is best-effort: any failure resolves to {ok:false} and the
5
- // caller falls back to plaintext (the feature must never block connecting).
6
- //
7
- // Backends:
8
- // darwin → `security` (login keychain, generic password)
9
- // win32 → DPAPI via PowerShell (CurrentUser scope), files under
10
- // $DSH_HOME/remote-workspaces/.secrets/
11
- // linux → `secret-tool` (libsecret / gnome-keyring), optional
12
- // else → unsupported (plain)
13
- import { execFile } from 'node:child_process'
14
- import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
15
- import { join as joinPath } from 'node:path'
16
-
17
- const SERVICE = 'dsh-remote'
18
- const TIMEOUT = 8000
19
-
20
- function run(bin, args, input) {
21
- return new Promise((resolve, reject) => {
22
- execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
23
- if (err) return reject(err)
24
- resolve(String(stdout || '').trim())
25
- })
26
- })
27
- }
28
-
29
- export function platformBackend() {
30
- if (process.platform === 'darwin') return 'keychain'
31
- if (process.platform === 'win32') return 'windows'
32
- return 'secret' // linux (secret-tool may be absent → falls back)
33
- }
34
-
35
- function account(machineId) {
36
- // Only machine ids are stored; sanitize just in case.
37
- return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
38
- }
39
-
40
- /** Save a password to the OS store. Resolves {ok, backend} — ok:false on any
41
- * failure (caller falls back to plaintext). */
42
- export async function saveSecret(machineId, password, secretsDir) {
43
- const acc = account(machineId)
44
- try {
45
- if (process.platform === 'darwin') {
46
- await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
47
- return { ok: true, backend: 'keychain' }
48
- }
49
- if (process.platform === 'win32') {
50
- // DPAPI-encrypt and store under the harness home.
51
- const script =
52
- `$p='${String(password).replace(/'/g, "''")}';` +
53
- `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
54
- `[Convert]::ToBase64String($s)`
55
- const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
56
- if (!b64) return { ok: false, backend: 'windows' }
57
- mkdirSync(secretsDir || '.', { recursive: true })
58
- writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
59
- return { ok: true, backend: 'windows' }
60
- }
61
- if (process.platform === 'linux') {
62
- await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
63
- return { ok: true, backend: 'secret' }
64
- }
65
- } catch { /* fall through */ }
66
- return { ok: false, backend: platformBackend() }
67
- }
68
-
69
- /** Fetch a stored password. Resolves string|null. */
70
- export async function getSecret(machineId, secretsDir) {
71
- const acc = account(machineId)
72
- try {
73
- if (process.platform === 'darwin') {
74
- return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
75
- }
76
- if (process.platform === 'win32') {
77
- const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
78
- if (!b64) return null
79
- const script =
80
- `$s=[Convert]::FromBase64String('${b64}');` +
81
- `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
82
- return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
83
- }
84
- if (process.platform === 'linux') {
85
- return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
86
- }
87
- } catch { /* fall through */ }
88
- return null
89
- }
90
-
91
- /** Delete a stored password (idempotent). */
92
- export async function deleteSecret(machineId, secretsDir) {
93
- const acc = account(machineId)
94
- try {
95
- if (process.platform === 'darwin') {
96
- await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
97
- return
98
- }
99
- if (process.platform === 'win32') {
100
- try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
101
- return
102
- }
103
- if (process.platform === 'linux') {
104
- await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
105
- }
106
- } catch { /* best effort */ }
107
- }
1
+ // dsh-remote — optional OS-keychain password storage.
2
+ // Machines keep their password in the OS credential store instead of the
3
+ // plaintext machines.json when the user enables "加密保存密码" per machine.
4
+ // Every backend is best-effort: any failure resolves to {ok:false} and the
5
+ // caller (persistPassword) falls back to plaintext + a user-visible warning
6
+ // (the feature must never block connecting, and must never silently drop the
7
+ // credential — issue #30).
8
+ //
9
+ // Backends:
10
+ // darwin → `security` (login keychain, generic password)
11
+ // win32 → DPAPI via PowerShell (CurrentUser scope), files under
12
+ // $DSH_HOME/remote-workspaces/.secrets/
13
+ // linux → `secret-tool` (libsecret / gnome-keyring), optional
14
+ // else → unsupported (plain)
15
+ import { execFile } from 'node:child_process'
16
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
17
+ import { join as joinPath } from 'node:path'
18
+
19
+ const SERVICE = 'dsh-remote'
20
+ const TIMEOUT = 8000
21
+
22
+ // Windows PowerShell 5.1 does not preload System.Security, so
23
+ // [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
24
+ // is added (issue #30: without this the whole DPAPI call failed and the caller
25
+ // silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
26
+ // harmless on PowerShell 7, where the type is already available.
27
+ const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
28
+
29
+ function run(bin, args, input) {
30
+ 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())
34
+ })
35
+ })
36
+ }
37
+
38
+ export function platformBackend() {
39
+ if (process.platform === 'darwin') return 'keychain'
40
+ if (process.platform === 'win32') return 'windows'
41
+ return 'secret' // linux (secret-tool may be absent → falls back)
42
+ }
43
+
44
+ function account(machineId) {
45
+ // Only machine ids are stored; sanitize just in case.
46
+ return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
47
+ }
48
+
49
+ /** Redact a failed command's diagnostics: execFile puts the FULL command line
50
+ * (which carries the password as an argv) into `err.message`, and this string is
51
+ * shown in the settings UI — so the secret must never survive it. */
52
+ function safeReason(err, password) {
53
+ const raw = [err && err.stderr, err && err.message, err && err.code]
54
+ .filter(Boolean).map(String).join(' ')
55
+ const secret = String(password == null ? '' : password)
56
+ const scrubbed = secret ? raw.split(secret).join('***') : raw
57
+ return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
58
+ }
59
+
60
+ /** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
61
+ * Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
62
+ * observable here — a Windows host cannot be exercised from the test suite. */
63
+ export function dpapiProtectScript(password) {
64
+ return PS_SECURITY_ASSEMBLY +
65
+ `$p='${String(password).replace(/'/g, "''")}';` +
66
+ `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
67
+ `[Convert]::ToBase64String($s)`
68
+ }
69
+
70
+ /** PowerShell one-liner that reverses dpapiProtectScript(). */
71
+ export function dpapiUnprotectScript(b64) {
72
+ return PS_SECURITY_ASSEMBLY +
73
+ `$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
74
+ `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
75
+ }
76
+
77
+ /** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
78
+ * any failure (caller falls back to plaintext). `error` is a password-free
79
+ * diagnostic for the UI. */
80
+ export async function saveSecret(machineId, password, secretsDir) {
81
+ const acc = account(machineId)
82
+ try {
83
+ if (process.platform === 'darwin') {
84
+ await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
85
+ return { ok: true, backend: 'keychain', error: '' }
86
+ }
87
+ if (process.platform === 'win32') {
88
+ // DPAPI-encrypt and store under the harness home.
89
+ const script = dpapiProtectScript(password)
90
+ const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
91
+ if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
92
+ mkdirSync(secretsDir || '.', { recursive: true })
93
+ writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
94
+ return { ok: true, backend: 'windows', error: '' }
95
+ }
96
+ if (process.platform === 'linux') {
97
+ await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
98
+ return { ok: true, backend: 'secret', error: '' }
99
+ }
100
+ } catch (err) {
101
+ return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
102
+ }
103
+ return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
104
+ }
105
+
106
+ /** Decide where a machine password is persisted (issue #30).
107
+ *
108
+ * The contract the settings UI relies on: a password is NEVER dropped. When the
109
+ * requested OS store fails (missing DPAPI assembly, locked keychain, absent
110
+ * secret-tool), this falls back to plaintext in machines.json and reports
111
+ * `warning: 'secret-store-failed'` so the caller can tell the user instead of
112
+ * silently saving a credential-less machine.
113
+ *
114
+ * `save` is injectable so the fallback is unit-testable on any platform.
115
+ *
116
+ * @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
117
+ */
118
+ export async function persistPassword({ backend, machineId, password, secretsDir, save = saveSecret }) {
119
+ const pw = password == null ? '' : String(password)
120
+ const want = backend && backend !== 'plain' ? backend : 'plain'
121
+ if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
122
+ if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
123
+ let res
124
+ try {
125
+ res = await save(machineId, pw, secretsDir)
126
+ } catch (err) {
127
+ res = { ok: false, error: safeReason(err, pw) }
128
+ }
129
+ if (res && res.ok) {
130
+ return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
131
+ }
132
+ return {
133
+ stored: 'plain',
134
+ credentialBackend: 'plain',
135
+ password: pw,
136
+ warning: 'secret-store-failed',
137
+ error: String((res && res.error) || '') || 'secret store write failed',
138
+ }
139
+ }
140
+
141
+ /** Fetch a stored password. Resolves string|null. */
142
+ export async function getSecret(machineId, secretsDir) {
143
+ const acc = account(machineId)
144
+ try {
145
+ if (process.platform === 'darwin') {
146
+ return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
147
+ }
148
+ if (process.platform === 'win32') {
149
+ const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
150
+ if (!b64) return null
151
+ const script = dpapiUnprotectScript(b64)
152
+ return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
153
+ }
154
+ if (process.platform === 'linux') {
155
+ return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
156
+ }
157
+ } catch { /* fall through */ }
158
+ return null
159
+ }
160
+
161
+ /** Delete a stored password (idempotent). */
162
+ export async function deleteSecret(machineId, secretsDir) {
163
+ const acc = account(machineId)
164
+ try {
165
+ if (process.platform === 'darwin') {
166
+ await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
167
+ return
168
+ }
169
+ if (process.platform === 'win32') {
170
+ try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
171
+ return
172
+ }
173
+ if (process.platform === 'linux') {
174
+ await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
175
+ }
176
+ } catch { /* best effort */ }
177
+ }
package/lib/errors.js CHANGED
@@ -1,78 +1,78 @@
1
- // dsh-remote — SSH/SFTP error classification → actionable hints.
2
- // Pure function so it is unit-testable and reused by tools, slash commands,
3
- // the test-connect route and the settings UI.
4
-
5
- /** Classify an error thrown by the ssh pool / sftp wrapper.
6
- * @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
7
- */
8
- export function classifyError(err, opts = {}) {
9
- const msg = String((err && err.message) || err || '')
10
- const { host, port } = opts
11
- const where = host ? ` (${host}:${port || 22})` : ''
12
-
13
- if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
14
- return {
15
- category: 'hostkey',
16
- hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
17
- raw: msg,
18
- }
19
- }
20
- if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
21
- return {
22
- category: 'auth',
23
- hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
24
- raw: msg,
25
- }
26
- }
27
- if (/no credentials/i.test(msg)) {
28
- return {
29
- category: 'credentials',
30
- hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
31
- raw: msg,
32
- }
33
- }
34
- if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
35
- return {
36
- category: 'network',
37
- hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
38
- raw: msg,
39
- }
40
- }
41
- if (/ECONNREFUSED|connection refused/i.test(msg)) {
42
- return {
43
- category: 'network',
44
- hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
45
- raw: msg,
46
- }
47
- }
48
- if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
49
- return {
50
- category: 'sftp',
51
- hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
52
- raw: msg,
53
- }
54
- }
55
- if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
56
- return {
57
- category: 'timeout',
58
- hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
59
- raw: msg,
60
- }
61
- }
62
- if (/cannot read private key/i.test(msg)) {
63
- return {
64
- category: 'credentials',
65
- hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
66
- raw: msg,
67
- }
68
- }
69
- return { category: 'other', hint: msg, raw: msg }
70
- }
71
-
72
- /** One-line friendly message: hint (+ raw when it adds information). */
73
- export function friendlyMessage(err, opts = {}) {
74
- const c = classifyError(err, opts)
75
- if (c.category === 'other') return c.raw
76
- if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
77
- return c.hint
78
- }
1
+ // dsh-remote — SSH/SFTP error classification → actionable hints.
2
+ // Pure function so it is unit-testable and reused by tools, slash commands,
3
+ // the test-connect route and the settings UI.
4
+
5
+ /** Classify an error thrown by the ssh pool / sftp wrapper.
6
+ * @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
7
+ */
8
+ export function classifyError(err, opts = {}) {
9
+ const msg = String((err && err.message) || err || '')
10
+ const { host, port } = opts
11
+ const where = host ? ` (${host}:${port || 22})` : ''
12
+
13
+ if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
14
+ return {
15
+ category: 'hostkey',
16
+ hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
17
+ raw: msg,
18
+ }
19
+ }
20
+ if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
21
+ return {
22
+ category: 'auth',
23
+ hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
24
+ raw: msg,
25
+ }
26
+ }
27
+ if (/no credentials/i.test(msg)) {
28
+ return {
29
+ category: 'credentials',
30
+ hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
31
+ raw: msg,
32
+ }
33
+ }
34
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
35
+ return {
36
+ category: 'network',
37
+ hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
38
+ raw: msg,
39
+ }
40
+ }
41
+ if (/ECONNREFUSED|connection refused/i.test(msg)) {
42
+ return {
43
+ category: 'network',
44
+ hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
45
+ raw: msg,
46
+ }
47
+ }
48
+ if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
49
+ return {
50
+ category: 'sftp',
51
+ hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
52
+ raw: msg,
53
+ }
54
+ }
55
+ if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
56
+ return {
57
+ category: 'timeout',
58
+ hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
59
+ raw: msg,
60
+ }
61
+ }
62
+ if (/cannot read private key/i.test(msg)) {
63
+ return {
64
+ category: 'credentials',
65
+ hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
66
+ raw: msg,
67
+ }
68
+ }
69
+ return { category: 'other', hint: msg, raw: msg }
70
+ }
71
+
72
+ /** One-line friendly message: hint (+ raw when it adds information). */
73
+ export function friendlyMessage(err, opts = {}) {
74
+ const c = classifyError(err, opts)
75
+ if (c.category === 'other') return c.raw
76
+ if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
77
+ return c.hint
78
+ }