dsh-remote 0.8.53 → 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/index.js CHANGED
@@ -41,6 +41,9 @@ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from '.
41
41
  import { createInstructionIndex, formatInstructionIndex } from './instructions.js'
42
42
  import { resolveExecTimeout, formatExecResult, startExecJob, execWithOptionalPromotion } from './exec-job.js'
43
43
  import { createInjectionTracker, refreshTouched, buildInstructionMessage } from './instructions-dynamic.js'
44
+ import { REMOTE_SANDBOX_MODES, checkRemoteSandbox, remoteSandboxDenial } from './remote-sandbox.js'
45
+ import { createTerminalManager } from './terminal.js'
46
+ import { createTerminalRoutes } from './routes-term.js'
44
47
  import { searchRemote } from './search.js'
45
48
  import { installTerminalShell } from './remote-shell.js'
46
49
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
@@ -55,7 +58,7 @@ import {
55
58
  import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
56
59
  import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
57
60
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
58
- import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } 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'
59
62
  import { registerHttpTransports } from './http-transport.js'
60
63
  import { SshPool, ActivePool, createPool } from './pool.js'
61
64
  import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
@@ -277,6 +280,24 @@ export const Config = z.object({
277
280
  * inside a mirror) always sees all tools, and an invalid value falls back
278
281
  * to `bootstrap`. (schemastery 3.18 has no .enum — validated in code.) */
279
282
  localTools: z.string().default('bootstrap'),
283
+ /** Remote sandbox fence (parity with the local session sandbox).
284
+ *
285
+ * `inherit` (default) reads the SAME `ctx.sandboxPolicy` the local tools use
286
+ * and applies it to remote operations: read-only refuses remote writes and
287
+ * commands, workspace-write fences remote writes to the workspace root,
288
+ * danger-full-access allows everything. `off` restores the pre-0.8.54
289
+ * behaviour (no fence) for deployments whose remotes enforce their own
290
+ * boundary. A per-call `sandbox_permissions: 'danger-full-access'` also
291
+ * works, but only when the composition advertises escalation. */
292
+ remoteSandbox: z.string().default('inherit'),
293
+ /** What to do when the OS secure store refuses a password.
294
+ *
295
+ * `refuse` (default): never write a password to machines.json. The machine is
296
+ * saved without a credential and the UI reports the store failure plus the
297
+ * repair hint -- you asked for encryption, so silently downgrading to
298
+ * plaintext is the one outcome you did not ask for.
299
+ * `plain`: the pre-0.8.54 fallback (write it and warn). */
300
+ credentialFallback: z.string().default('refuse'),
280
301
  })
281
302
 
282
303
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -1029,6 +1050,28 @@ export async function apply(ctx, config) {
1029
1050
  return null
1030
1051
  }
1031
1052
  }
1053
+ /**
1054
+ * One-line-per-group inventory of the saved machines: which environments,
1055
+ * locations and tags exist. This is what makes the filters discoverable — a
1056
+ * user who has never set a label sees nothing to filter by, so the empty
1057
+ * result reports what IS there.
1058
+ *
1059
+ * @param {Array<object>} list
1060
+ * @returns {string} '' when no machine carries any label.
1061
+ */
1062
+ const groupSummary = (list) => {
1063
+ const envs = new Map(); const locs = new Map(); const tags = new Map()
1064
+ const bump = (map, key) => { if (key) map.set(key, (map.get(key) || 0) + 1) }
1065
+ for (const m of Array.isArray(list) ? list : []) {
1066
+ if (!m) continue
1067
+ bump(envs, m.environment)
1068
+ bump(locs, m.location)
1069
+ for (const t of (Array.isArray(m.tags) ? m.tags : [])) bump(tags, t)
1070
+ }
1071
+ const fmt = (label, map) => (map.size ? ` ${label}: ` + [...map.entries()].map(([k, n]) => `${k} (${n})`).join(', ') : '')
1072
+ return [fmt('environment', envs), fmt('location', locs), fmt('tags', tags)].filter(Boolean).join('\n')
1073
+ }
1074
+
1032
1075
  const readAudit = (limit) => {
1033
1076
  try {
1034
1077
  const text = readFileSync(auditFile(), 'utf8')
@@ -1159,6 +1202,12 @@ export async function apply(ctx, config) {
1159
1202
  // already delivered, plus the sweep throttle. See the tools/result hook below.
1160
1203
  const instructionTracker = createInjectionTracker()
1161
1204
 
1205
+ // Embedded interactive terminal sessions (P1). Host-owned PTY channels; the
1206
+ // browser polls for output. Disposed with the plugin fiber so an unload hangs
1207
+ // up every remote shell.
1208
+ const terminals = createTerminalManager()
1209
+ ctx.effect(() => () => { try { terminals.dispose() } catch { /* already gone */ } }, 'dsh-remote.terminals')
1210
+
1162
1211
  /**
1163
1212
  * The core's user-message factory, when this composition exposes one.
1164
1213
  *
@@ -1446,6 +1495,29 @@ export async function apply(ctx, config) {
1446
1495
  err.httpStatus = 403
1447
1496
  throw err
1448
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
+ }
1449
1521
  return {
1450
1522
  pool,
1451
1523
  ws: wsPath(),
@@ -1542,6 +1614,69 @@ export async function apply(ctx, config) {
1542
1614
  return b
1543
1615
  }
1544
1616
 
1617
+ // ── remote sandbox semantics ──────────────────────────────────────────────
1618
+ // A local session is confined by ctx.sandboxPolicy; a REMOTE command used to
1619
+ // run under no policy at all, so a read-only session could mutate any file on
1620
+ // any reachable host. `ctx.sandbox.confine()` cannot help here — it wraps argv
1621
+ // for a spawn on THIS machine, while the process we start lives on the other
1622
+ // end of an SSH channel. So the fence is applied at the tool boundary, using
1623
+ // the SAME resolved policy the local tools consume (see lib/remote-sandbox.js
1624
+ // for the threat model: containment, not a security boundary).
1625
+ //
1626
+ // Optional by design: a composition without ctx.sandboxPolicy (a minimal
1627
+ // embedding, a test double, an agentless call) keeps the pre-existing
1628
+ // unconfined behaviour instead of failing to load, and `remoteSandbox` config
1629
+ // can switch the fence off for deployments whose remotes enforce their own.
1630
+ const remoteSandboxEnabled = () => config.remoteSandbox !== 'off'
1631
+
1632
+ /**
1633
+ * Resolve the sandbox mode for one tool call.
1634
+ *
1635
+ * Reads the core policy service when present; otherwise honours the plugin's
1636
+ * own `remoteSandbox` fallback (`inherit` ⇒ danger-full-access, i.e. the old
1637
+ * unconfined behaviour). Any resolution failure is treated as read-only — fail
1638
+ * closed, never open.
1639
+ *
1640
+ * @param {object} [exec] - the tool run context.
1641
+ * @returns {string} the mode name.
1642
+ */
1643
+ const sandboxModeFor = (exec) => {
1644
+ if (!remoteSandboxEnabled()) return 'danger-full-access'
1645
+ try {
1646
+ const svc = ctx.get('sandboxPolicy')
1647
+ if (svc && typeof svc.resolve === 'function') {
1648
+ const session = exec && exec.agent ? exec.agent.session : undefined
1649
+ const resolved = svc.resolve(session ? { session } : {})
1650
+ if (resolved && REMOTE_SANDBOX_MODES.includes(resolved.mode)) return resolved.mode
1651
+ }
1652
+ } catch { /* fall through to the configured default */ }
1653
+ const fallback = String(config.remoteSandbox || 'inherit')
1654
+ return REMOTE_SANDBOX_MODES.includes(fallback) ? fallback : 'danger-full-access'
1655
+ }
1656
+
1657
+ /**
1658
+ * Assert that one remote operation is permitted, throwing the official denial
1659
+ * shape when it is not.
1660
+ *
1661
+ * @param {object} exec - the tool run context.
1662
+ * @param {'read'|'write'|'exec'|'tunnel'|'connect'} operation
1663
+ * @param {object} [opts]
1664
+ * @param {string} [opts.path] - the remote path a `write` targets.
1665
+ * @param {string} [opts.tool] - tool name for the message.
1666
+ */
1667
+ const requireRemoteSandbox = (exec, operation, opts = {}) => {
1668
+ if (!remoteSandboxEnabled()) return
1669
+ const mode = sandboxModeFor(exec)
1670
+ if (mode === 'danger-full-access') return
1671
+ const b = (() => { try { return bindingFor(exec) } catch { return null } })()
1672
+ const workspaceRoot = (b && b.ws) || ''
1673
+ const verdict = checkRemoteSandbox({ mode, operation, path: opts.path, workspaceRoot })
1674
+ if (verdict.allowed) return
1675
+ const err = remoteSandboxDenial(verdict)
1676
+ if (opts.tool) err.message = `${opts.tool}: ${err.message}`
1677
+ throw err
1678
+ }
1679
+
1545
1680
  /**
1546
1681
  * Binding for a tool that takes an explicit absolute remote path: it needs a
1547
1682
  * resolvable MACHINE but not a workspace root. A local session is refused for
@@ -1849,6 +1984,9 @@ export async function apply(ctx, config) {
1849
1984
  if (args.passphrase !== undefined) rec.passphrase = String(args.passphrase)
1850
1985
  if (args.useAgent !== undefined) rec.useAgent = !!args.useAgent
1851
1986
  if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
1987
+ // Group labels (host organisation): merge-only, so a partial call
1988
+ // never wipes existing labels.
1989
+ { const g = normalizeMachineGroup(args); for (const k of Object.keys(g)) rec[k] = g[k] }
1852
1990
  if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
1853
1991
  if (args.name) rec.name = String(args.name)
1854
1992
  // Issue #51: pick the transport backend. `openssh` is what reaches
@@ -1941,14 +2079,30 @@ export async function apply(ctx, config) {
1941
2079
  defineTool({
1942
2080
  name: 'rw_machines',
1943
2081
  description:
1944
- 'List the machines saved in the registry, secrets stripped: id, name, host/port/username, auth method flags (password / key + whether a passphrase is set / agent / keyboard-interactive), ssh-config alias resolution, workspace, and which one is current. Then pass rw_connect(machineId=<id>) to connect with a machine\'s full stored settings — the fix for "the agent cannot retype the whole config" (issue #48).',
1945
- parameters: {},
2082
+ 'List the machines saved in the registry, secrets stripped: id, name, host/port/username, auth method flags, ssh-config alias resolution, workspace, grouping labels (environment / tags / location), and which one is current. Optional filters (environment / tags / location / query) narrow the list - useful once you have many machines. Pass rw_connect(machineId=<id>) to connect with the full stored settings of a machine (issue #48).',
2083
+ parameters: {
2084
+ environment: { type: 'string', description: 'Only machines in this environment (e.g. prod, staging)' },
2085
+ location: { type: 'string', description: 'Only machines in this location (e.g. shanghai, gpu-lab)' },
2086
+ tags: { type: 'string', description: 'Only machines carrying ANY of these tags (comma or space separated)' },
2087
+ query: { type: 'string', description: 'Free-text match over name/host/user/environment/location/description/tags' },
2088
+ },
1946
2089
  output: textOut,
1947
- async execute() {
2090
+ async execute(args = {}) {
1948
2091
  if (!machines.length) {
1949
2092
  return { text: 'No saved machines yet — add one in Settings → 远程工作区, or connect with rw_connect(host=..., save defaults to true).' }
1950
2093
  }
1951
- const lines = machines.map((m) => {
2094
+ // Group filters (host organisation): a flat list stops being usable once
2095
+ // a user has a dozen machines, and picking the wrong one on a destructive
2096
+ // tool is how accidents happen.
2097
+ const filtered = filterMachinesByGroup(machines, {
2098
+ environment: args.environment, location: args.location, tags: args.tags, query: args.query,
2099
+ })
2100
+ const anyFilter = !!(args.environment || args.location || args.tags || args.query)
2101
+ if (anyFilter && !filtered.length) {
2102
+ const groups = groupSummary(machines)
2103
+ return { text: 'No machine matches that filter.' + (groups ? '\n\nAvailable groups:\n' + groups : '') }
2104
+ }
2105
+ const rows = filtered.map((m) => {
1952
2106
  const cur = store.currentId === m.id ? ' [CURRENT]' : ''
1953
2107
  const auth = []
1954
2108
  if (m.password || (m.credentialBackend && m.credentialBackend !== 'plain')) auth.push('password')
@@ -1966,9 +2120,19 @@ export async function apply(ctx, config) {
1966
2120
  via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
1967
2121
  }
1968
2122
  const ws = m.workspace ? ` workspace=${m.workspace}` : ''
1969
- return `- ${m.id} "${m.name || m.host}" [${backend}] ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}`
2123
+ // Grouping labels, shown inline so a wrong-host pick is visible.
2124
+ const meta = []
2125
+ if (m.environment) meta.push('env=' + m.environment)
2126
+ if (m.location) meta.push('loc=' + m.location)
2127
+ if (Array.isArray(m.tags) && m.tags.length) meta.push('tags=' + m.tags.join(','))
2128
+ if (m.description) meta.push(m.description)
2129
+ const group = meta.length ? '\n ' + meta.join(' ') : ''
2130
+ return `- ${m.id} "${m.name || m.host}" [${backend}] ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}${group}`
1970
2131
  })
1971
- return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
2132
+ const suffix = anyFilter
2133
+ ? `\n\n${rows.length} of ${machines.length} machine(s) match. connect with rw_connect(machineId=<id>).`
2134
+ : '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.'
2135
+ return { text: rows.join('\n') + suffix }
1972
2136
  },
1973
2137
  }),
1974
2138
 
@@ -2257,6 +2421,8 @@ export async function apply(ctx, config) {
2257
2421
  output: okOut,
2258
2422
  async execute(args, exec) {
2259
2423
  const b = requireMachine(exec, 'rw_write_file')
2424
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2425
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_write_file', path: String(args.path || '') })
2260
2426
  const p = resolveRemoteArg(b, args.path)
2261
2427
  if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
2262
2428
  const content = String(args.content == null ? '' : args.content)
@@ -2294,6 +2460,8 @@ export async function apply(ctx, config) {
2294
2460
  output: okOut,
2295
2461
  async execute(args, exec) {
2296
2462
  const b = requireMachine(exec, 'rw_edit')
2463
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2464
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_edit', path: String(args.path || args.file_path || '') })
2297
2465
  // Presence must be checked on the RAW argument, not on the resolved
2298
2466
  // path: resolveRemoteArg falls back to the workspace root for an empty
2299
2467
  // string, so `!p` would never fire and a pathless rw_edit would silently
@@ -2372,6 +2540,8 @@ export async function apply(ctx, config) {
2372
2540
  output: okOut,
2373
2541
  async execute(args, exec) {
2374
2542
  const b = requireMachine(exec, 'rw_append')
2543
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2544
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_append', path: String(args.path || '') })
2375
2545
  const p = resolveRemoteArg(b, args.path)
2376
2546
  if (!p || p === '/') throw new Error('rw_append: a file path is required')
2377
2547
  const sftp = await b.pool.sftp()
@@ -2395,6 +2565,8 @@ export async function apply(ctx, config) {
2395
2565
  output: textOut,
2396
2566
  async execute(args, exec) {
2397
2567
  const b = requireMachine(exec, 'rw_mkdir')
2568
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2569
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_mkdir', path: String(args.path || '') })
2398
2570
  const p = resolveRemoteArg(b, args.path)
2399
2571
  if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
2400
2572
  const sftp = await b.pool.sftp()
@@ -2415,6 +2587,8 @@ export async function apply(ctx, config) {
2415
2587
  output: okOut,
2416
2588
  async execute(args, exec) {
2417
2589
  const b = requireMachine(exec, 'rw_remove')
2590
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2591
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_remove', path: String(args.path || '') })
2418
2592
  const p = resolveRemoteArg(b, args.path)
2419
2593
  if (!p || p === '/') throw new Error('rw_remove: a path is required')
2420
2594
  const sftp = await b.pool.sftp()
@@ -2443,6 +2617,8 @@ export async function apply(ctx, config) {
2443
2617
  output: textOut,
2444
2618
  async execute(args, exec) {
2445
2619
  const b = requireMachine(exec, 'rw_move')
2620
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2621
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_move', path: String(args.source || args.path || '') })
2446
2622
  const p = resolveRemoteArg(b, args.path)
2447
2623
  const d = resolveRemoteArg(b, args.dest)
2448
2624
  if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
@@ -2470,6 +2646,8 @@ export async function apply(ctx, config) {
2470
2646
  output: textOut,
2471
2647
  async execute(args, exec) {
2472
2648
  const b = requireBinding(exec, 'rw_exec')
2649
+ // Remote sandbox fence: commands change remote state.
2650
+ requireRemoteSandbox(exec, 'exec', { tool: 'rw_exec' })
2473
2651
  const cmd = String(args.command || '')
2474
2652
  if (!cmd) throw new Error('rw_exec: command is required')
2475
2653
  const ws = b.ws
@@ -2642,6 +2820,8 @@ export async function apply(ctx, config) {
2642
2820
  output: okOut,
2643
2821
  async execute(args, exec) {
2644
2822
  const b = requireMachine(exec, 'rw_upload')
2823
+ // Remote sandbox fence: a write must be permitted by this session's resolved mode.
2824
+ requireRemoteSandbox(exec, 'write', { tool: 'rw_upload', path: String(args.path || '') })
2645
2825
  const rp = resolveRemoteArg(b, args.path)
2646
2826
  const lp = String(args.localPath || '')
2647
2827
  if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
@@ -2726,9 +2906,11 @@ export async function apply(ctx, config) {
2726
2906
  remove: { type: 'boolean', description: 'Remove an existing forward by listenPort (default false)' },
2727
2907
  },
2728
2908
  output: textOut,
2729
- async execute(args) {
2909
+ async execute(args, exec) {
2730
2910
  const port = Number(args.listenPort)
2731
2911
  if (!port || port < 1 || port > 65535) throw new Error('rw_forward: a valid listenPort is required')
2912
+ // Remote sandbox fence: a forward opens a network path into the remote.
2913
+ requireRemoteSandbox(exec, 'tunnel', { tool: 'rw_forward' })
2732
2914
  const dir = args.direction === 'reverse' ? 'reverse' : 'local'
2733
2915
  const existing = forwards.list().find((f) => Number(f.listenPort) === port && f.direction === dir)
2734
2916
  if (args.remove) {
@@ -3040,6 +3222,13 @@ export async function apply(ctx, config) {
3040
3222
  })
3041
3223
  const pickLocalNative = async () => {
3042
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
+ }
3043
3232
  if (platform === 'darwin') {
3044
3233
  return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
3045
3234
  }
@@ -3278,6 +3467,15 @@ export async function apply(ctx, config) {
3278
3467
  sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
3279
3468
  }),
3280
3469
 
3470
+ // Embedded terminal (P1): one host-owned PTY session per machine, polled.
3471
+ ...createTerminalRoutes({
3472
+ sendJson, readBody, resolveRequestBinding, terminals,
3473
+ // The machine fence: a session is identified by the mirror-recorded
3474
+ // origin, so a terminal opened on host A can never be driven from a
3475
+ // request bound to host B.
3476
+ machineKey: (b) => poolKey({ host: b.host, port: b.port, username: b.username }),
3477
+ }),
3478
+
3281
3479
  {
3282
3480
  kind: 'exact',
3283
3481
  path: '/dsh-remote/workspace',
@@ -3454,8 +3652,20 @@ export async function apply(ctx, config) {
3454
3652
  port: Number(body.port) || 22,
3455
3653
  username: String(body.username || '').trim() || 'root',
3456
3654
  password: '',
3457
- privateKeyPath: String(body.privateKeyPath || '').trim(),
3458
- 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) || ''),
3459
3669
  workspace: String(body.workspace || '').trim(),
3460
3670
  hostKeyMode: body.hostKeyMode || '',
3461
3671
  useAgent: !!body.useAgent,
@@ -3473,13 +3683,28 @@ export async function apply(ctx, config) {
3473
3683
  webAttachCommand: body.webAttachCommand === undefined
3474
3684
  ? String((prev && prev.webAttachCommand) || '')
3475
3685
  : String(body.webAttachCommand || '').trim(),
3476
- proxy: body.proxy && body.proxy.host ? {
3477
- host: String(body.proxy.host),
3478
- port: Number(body.proxy.port) || 22,
3479
- username: String(body.proxy.username || '').trim(),
3480
- password: String(body.proxy.password || ''),
3481
- privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
3482
- } : undefined,
3686
+ // Group labels (host organisation). Merge-only: a request that
3687
+ // omits them keeps the stored values instead of wiping them.
3688
+ ...normalizeMachineGroup({
3689
+ environment: body.environment === undefined ? (prev && prev.environment) : body.environment,
3690
+ location: body.location === undefined ? (prev && prev.location) : body.location,
3691
+ description: body.description === undefined ? (prev && prev.description) : body.description,
3692
+ tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
3693
+ }),
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),
3483
3708
  credentialBackend: credBackend,
3484
3709
  recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
3485
3710
  lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
@@ -3497,15 +3722,37 @@ export async function apply(ctx, config) {
3497
3722
  machineId: id,
3498
3723
  password: body.password,
3499
3724
  secretsDir: secretsDir(),
3725
+ onStoreFailure: config.credentialFallback === 'plain' ? 'plain' : 'refuse',
3500
3726
  })
3501
3727
  rec.credentialBackend = persisted.credentialBackend
3502
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'
3503
3733
  if (persisted.warning) {
3504
3734
  warning = persisted.warning
3505
3735
  warningDetail = persisted.error
3506
3736
  }
3507
- } else if (prev && prev.password) {
3508
- 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
+ }
3509
3756
  }
3510
3757
  if (i >= 0) machines[i] = rec; else machines.push(rec)
3511
3758
  // Issue #13: saving a machine must NOT make it the active
@@ -3611,17 +3858,46 @@ export async function apply(ctx, config) {
3611
3858
  let body = {}
3612
3859
  try {
3613
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)
3614
3890
  // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
3615
3891
  // the form holds the alias name, not the host it points at.
3616
- const probeBackend = String(body.backend || config.backend || 'native')
3892
+ const probeBackend = String(cred.backend || config.backend || 'native')
3617
3893
  const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
3618
3894
  const aliasTarget = body.useSshConfig
3619
3895
  ? resolveMachineSshConfig({
3620
3896
  host: String(body.host || ''),
3621
3897
  port: Number(body.port) || 22,
3622
3898
  username: String(body.username || ''),
3623
- privateKeyPath: String(body.privateKeyPath || ''),
3624
- proxy: body.proxy,
3899
+ privateKeyPath: String(cred.privateKeyPath || ''),
3900
+ proxy: cred.proxy,
3625
3901
  useSshConfig: true,
3626
3902
  }, { text: sshConfigText() })
3627
3903
  : null
@@ -3641,19 +3917,24 @@ export async function apply(ctx, config) {
3641
3917
  host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
3642
3918
  port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
3643
3919
  username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
3644
- password: String(body.password || ''),
3645
- privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3646
- passphrase: String(body.passphrase || ''),
3647
- proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : (body.proxy && body.proxy.host ? {
3648
- host: String(body.proxy.host),
3649
- port: Number(body.proxy.port) || 22,
3650
- username: String(body.proxy.username || ''),
3651
- password: String(body.proxy.password || ''),
3652
- privateKeyPath: String(body.proxy.privateKeyPath || ''),
3653
- } : 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,
3654
3931
  connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
3655
3932
  commandTimeoutMs: 10000,
3656
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 || {})
3657
3938
  const started = Date.now()
3658
3939
  await probe.connect()
3659
3940
  await probe.exec('true', { timeoutMs: 10000 })
@@ -3676,6 +3957,9 @@ export async function apply(ctx, config) {
3676
3957
  platform: probe.platform,
3677
3958
  shell: probe.shellMode,
3678
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,
3679
3963
  // Issue #38: tell the settings page where an alias resolved to (and
3680
3964
  // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
3681
3965
  alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
@@ -3726,11 +4010,23 @@ export async function apply(ctx, config) {
3726
4010
  const body = JSON.parse((await readBody(req)) || '{}')
3727
4011
  const action = String(body.action || '')
3728
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
+ }
3729
4025
  const d = forwards.define({
3730
4026
  direction: body.direction === 'reverse' ? 'reverse' : 'local',
3731
- listenPort: Number(body.listenPort),
4027
+ listenPort,
3732
4028
  targetHost: body.targetHost || '127.0.0.1',
3733
- targetPort: Number(body.targetPort) || Number(body.listenPort),
4029
+ targetPort,
3734
4030
  autoStart: !!body.autoStart,
3735
4031
  machineId: store.currentId,
3736
4032
  })
@@ -4109,7 +4405,16 @@ export async function apply(ctx, config) {
4109
4405
  if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
4110
4406
  return sendJson(res, 200, { ok: true, home })
4111
4407
  } catch (err) {
4112
- 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
+ })
4113
4418
  }
4114
4419
  },
4115
4420
  },