dsh-remote 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -33,7 +33,8 @@ import {
33
33
  import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
34
34
  import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
35
35
  import { friendlyMessage } from './errors.js'
36
- import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig.js'
36
+ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
37
+ import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
37
38
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
38
39
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
39
40
  import { searchRemote } from './search.js'
@@ -45,7 +46,7 @@ import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeM
45
46
  import { registerHttpTransports } from './http-transport.js'
46
47
  import { SshPool } from './pool.js'
47
48
  import { createFsRoutes } from './routes-fs.js'
48
- import { removeRemoteTree } from './remote-fs.js'
49
+ import { listDirStructured, removeRemoteTree } from './remote-fs.js'
49
50
 
50
51
  export const name = 'dsh-remote'
51
52
 
@@ -107,6 +108,21 @@ export const Config = z.object({
107
108
  auditLog: z.boolean().default(true),
108
109
  /** Text encoding for remote file reads/writes (utf-8 default; gbk etc.). */
109
110
  encoding: z.string().default('utf-8'),
111
+ /** Remote `@` completion (issue #39). When on, a session whose workspace is a
112
+ * dsh-remote mirror lists the REMOTE tree for `@` (over SFTP, bounded) instead
113
+ * of the local mirror — which `ensureMirror()` leaves empty until a sync, so
114
+ * `@` used to find nothing at all. Sessions on a local workspace are
115
+ * untouched, and a host that cannot be reached falls back to the mirror. */
116
+ fileReference: z.boolean().default(true),
117
+ /** Max `@` candidates rendered for one query (mirrors the local provider). */
118
+ fileReferenceMaxResults: z.number().step(1).min(1).default(20),
119
+ /** Max entries retained in the remote `@` index of one workspace. */
120
+ fileReferenceMaxEntries: z.number().step(1).min(1).default(3000),
121
+ /** Directory basenames the remote `@` traversal skips. */
122
+ fileReferenceExcludedDirectories: z.array(z.string()).default([...DEFAULT_EXCLUDED_DIRECTORIES]),
123
+ /** Wall-clock budget (ms) for one remote `@` index pass; on expiry the
124
+ * partial index answers rather than making the caret wait. */
125
+ fileReferenceTimeoutMs: z.number().step(1).min(200).default(4000),
110
126
  /** Update mode: `manual` (default) only checks when asked; `auto` checks on
111
127
  * load and periodically, applying a newer npm release automatically;
112
128
  * `off` disables version checks entirely. (schemastery 3.18 has no .enum —
@@ -154,14 +170,16 @@ function mirrorDirFor(remotePath, host, user, port) {
154
170
  return path.join(root, base + '-' + shortHash(norm))
155
171
  }
156
172
 
157
- /** Create the local mirror dir + a meta file describing its remote origin. */
158
- function ensureMirror(remotePath, host, user, port) {
173
+ /** Create the local mirror dir + a meta file describing its remote origin.
174
+ * `extra.alias` records the ~/.ssh/config alias a machine was saved as (issue
175
+ * #38), so the mirror keeps resolving to its machine even if the alias's
176
+ * HostName/user/port change in that file afterwards. */
177
+ function ensureMirror(remotePath, host, user, port, extra = {}) {
159
178
  const dir = mirrorDirFor(remotePath, host, user, port)
160
179
  mkdirSync(dir, { recursive: true })
161
- writeFileSync(
162
- path.join(dir, '.dsh-remote-meta.json'),
163
- JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
164
- )
180
+ const meta = { host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }
181
+ if (extra.alias) meta.alias = String(extra.alias)
182
+ writeFileSync(path.join(dir, '.dsh-remote-meta.json'), JSON.stringify(meta, null, 2))
165
183
  return dir
166
184
  }
167
185
 
@@ -245,16 +263,83 @@ export async function apply(ctx, config) {
245
263
  const machines = store.list
246
264
  const machineIndex = (id) => machines.findIndex((m) => m.id === id)
247
265
 
266
+ // ── ssh-config aliases (issue #38) ────────────────────────────────────────
267
+ //
268
+ // A machine may be saved as an ALIAS (`useSshConfig: true`, `host` = the Host
269
+ // line of ~/.ssh/config). The registry then holds NO HostName/user/port/key —
270
+ // every connect resolves the alias against the config file as it is right now
271
+ // (VSCode Remote-SSH behaviour). The text is memoised for a couple of seconds
272
+ // so one request that resolves several machines reads the file once, while an
273
+ // edit to ~/.ssh/config still lands almost immediately.
274
+ let sshConfigCache = { text: '', at: 0 }
275
+ const sshConfigText = () => {
276
+ const now = Date.now()
277
+ if (now - sshConfigCache.at > 2000) sshConfigCache = { text: readSshConfigText(), at: now }
278
+ return sshConfigCache.text
279
+ }
280
+
281
+ /**
282
+ * A machine's CONNECT identity: alias machines are resolved through
283
+ * ~/.ssh/config; every other machine is returned untouched. The returned
284
+ * record keeps the registry fields (id, password, credentialBackend, …) so
285
+ * keychain lookups and the settings UI keep working.
286
+ */
287
+ const loggedSshWarnings = new Map()
288
+ const withSshConfig = (m) => {
289
+ // Short-circuit BEFORE reading ~/.ssh/config: a deployment with no alias
290
+ // machine must never touch that file, and the 2s text memo must not be
291
+ // primed by unrelated machines.
292
+ if (!m || !m.useSshConfig) return m
293
+ const resolved = resolveMachineSshConfig(m, { text: sshConfigText() })
294
+ if (!resolved) return m
295
+ // Warn once per distinct warning set: this runs on every tool call, and an
296
+ // unmatched alias would otherwise fill the log with the same line.
297
+ const signature = resolved.warnings.join(' | ')
298
+ if (signature && loggedSshWarnings.get(resolved.alias) !== signature) {
299
+ loggedSshWarnings.set(resolved.alias, signature)
300
+ try {
301
+ const logger = typeof ctx.get === 'function' ? ctx.get('logger') : undefined
302
+ if (logger && typeof logger.warn === 'function') {
303
+ logger.warn(`[dsh-remote] ssh-config alias "${resolved.alias}": ${resolved.warnings.join(' ')}`)
304
+ }
305
+ } catch { /* logging is best-effort */ }
306
+ }
307
+ return {
308
+ ...m,
309
+ sshAlias: resolved.alias,
310
+ sshConfigMatched: resolved.matched,
311
+ sshConfigWarnings: resolved.warnings,
312
+ host: resolved.host,
313
+ port: resolved.port,
314
+ username: resolved.username || m.username,
315
+ privateKeyPath: resolved.privateKeyPath || '',
316
+ proxy: resolved.proxy,
317
+ }
318
+ }
319
+
320
+ /** The alias a machine was saved as ('' for a machine with literal values). */
321
+ const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
322
+
323
+ /** Machines as the settings UI sees them: secrets stripped, plus the
324
+ * ~/.ssh/config resolution of an alias machine so a row can show where the
325
+ * alias actually points without duplicating those values in the registry
326
+ * (issue #38). */
327
+ const machinesForClient = () => machines.map((m) => ({
328
+ ...sanitizeMachine(m),
329
+ // Read ~/.ssh/config only when a machine actually uses an alias.
330
+ sshConfigResolved: m.useSshConfig ? resolveMachineSshConfig(m, { text: sshConfigText() }) : null,
331
+ }))
332
+
248
333
  /** The machine the pool is currently bound to: an ephemeral tool connection
249
334
  * (rw_connect save:false) wins, then the stored current, then config default. */
250
335
  let ephemeral = null
251
336
  const activeMachine = () => {
252
- if (ephemeral) return ephemeral
337
+ if (ephemeral) return withSshConfig(ephemeral)
253
338
  if (store.currentId) {
254
339
  const i = machineIndex(store.currentId)
255
- if (i >= 0) return machines[i]
340
+ if (i >= 0) return withSshConfig(machines[i])
256
341
  }
257
- if (config.host) return { id: machineId(), name: config.host, host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase }
342
+ if (config.host) return withSshConfig({ id: machineId(), name: config.host, host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
258
343
  return null
259
344
  }
260
345
  const currentMachine = activeMachine
@@ -275,18 +360,47 @@ export async function apply(ctx, config) {
275
360
  return machinePassword(m)
276
361
  }
277
362
 
363
+ /** The identity fields pool.setTarget diffs. Used to snapshot the diff
364
+ * baseline BEFORE applyMachine mutates the shared config in place. */
365
+ const targetFields = (c) => ({
366
+ host: c.host, port: c.port, username: c.username,
367
+ password: c.password, privateKeyPath: c.privateKeyPath,
368
+ passphrase: c.passphrase, workspace: c.workspace,
369
+ useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
370
+ proxy: c.proxy, hostKeyMode: c.hostKeyMode,
371
+ })
372
+
373
+ /** Fingerprint of the machine record last applied to the shared pool config.
374
+ * Re-applying an identical record is a no-op: the picker posts /current before
375
+ * every /ls and used to rewrite the registry + re-read credentials + re-point
376
+ * the pool on every autocomplete keystroke. A record change (settings edit)
377
+ * changes the fingerprint, so the next /current re-applies it. The value is
378
+ * maintained INSIDE applyActiveMachine / clearActiveMachine (not in
379
+ * setCurrent) so every apply path — boot restore, rw_connect save:true,
380
+ * POST /current — keeps the "fingerprint == applied" invariant without extra
381
+ * bookkeeping. */
382
+ let lastAppliedMachine = ''
383
+
384
+ /** Fingerprint of the registry record the shared config is bound to ('' when
385
+ * the binding is ephemeral or no machine is current). */
386
+ const appliedFingerprint = () => {
387
+ if (ephemeral || !store.currentId) return ''
388
+ const i = machineIndex(store.currentId)
389
+ return i >= 0 ? JSON.stringify(machines[i]) : ''
390
+ }
391
+
278
392
  const applyActiveMachine = async () => {
279
393
  const m = activeMachine()
280
394
  if (!m || !m.host) return
281
395
  const pw = await machinePassword(m)
396
+ // Snapshot the diff baseline BEFORE applyMachine mutates the shared config
397
+ // IN PLACE: pool.config IS this config object, so setTarget would otherwise
398
+ // compare the new values against themselves, find them equal and keep the
399
+ // OLD connection alive across a machine switch (issue #25 hazard).
400
+ const previous = targetFields(config)
282
401
  applyMachine(config, { ...m, password: pw || m.password || '' })
283
- pool.setTarget({
284
- host: config.host, port: config.port, username: config.username,
285
- password: config.password, privateKeyPath: config.privateKeyPath,
286
- passphrase: config.passphrase, workspace: config.workspace,
287
- useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
288
- proxy: config.proxy, hostKeyMode: config.hostKeyMode,
289
- })
402
+ pool.setTarget(targetFields(config), previous)
403
+ lastAppliedMachine = appliedFingerprint()
290
404
  }
291
405
 
292
406
  // ── per-machine SSH pools (session-bound) ─────────────────────────────────
@@ -333,12 +447,25 @@ export async function apply(ctx, config) {
333
447
  * An EPHEMERAL connection (rw_connect save:false) is not in the registry but
334
448
  * is still a legitimate credential source for a pool the same process later
335
449
  * resolves for that identity: without it a mirror picked on a temporary
336
- * connection would resolve to an empty-credential pool and fail to connect. */
337
- const machineRecordFor = ({ host, port, username }) =>
338
- (ephemeral && ephemeral.host === host && Number(ephemeral.port) === Number(port) && (ephemeral.username || '') === (username || '')
339
- ? ephemeral
340
- : null)
341
- || machines.find((m) => m.host === host && Number(m.port) === Number(port) && (m.username || '') === (username || ''))
450
+ * connection would resolve to an empty-credential pool and fail to connect.
451
+ *
452
+ * An ssh-config ALIAS machine (issue #38) is matched through its RESOLVED
453
+ * identity, and directly by the alias the mirror recorded — so a mirror keeps
454
+ * resolving to its machine even after a HostName/port change in ~/.ssh/config,
455
+ * which is the whole point of not copying those values into the registry. */
456
+ const identityOf = (m) => {
457
+ const r = withSshConfig(m)
458
+ return { host: r.host, port: Number(r.port) || 22, username: r.username || '' }
459
+ }
460
+ const identityMatches = (m, { host, port, username }) => {
461
+ if (!m) return false
462
+ const id = identityOf(m)
463
+ return id.host === host && id.port === Number(port) && id.username === (username || '')
464
+ }
465
+ const machineRecordFor = ({ host, port, username, alias }) =>
466
+ (identityMatches(ephemeral, { host, port, username }) ? ephemeral : null)
467
+ || (alias ? machines.find((m) => aliasOf(m) === alias) : null)
468
+ || machines.find((m) => identityMatches(m, { host, port, username }))
342
469
  || null
343
470
 
344
471
  /**
@@ -348,47 +475,89 @@ export async function apply(ctx, config) {
348
475
  */
349
476
  const poolForMachine = (target) => {
350
477
  const key = poolKey(target)
478
+ // `rec` is the registry record (raw); `eff` is its CONNECT identity, with an
479
+ // ssh-config alias resolved (issue #38). For an alias whose HostName/USER/port
480
+ // changed since the mirror was created, the RESOLVED identity wins over the
481
+ // mirror-recorded one — that is what makes an edit to ~/.ssh/config take
482
+ // effect for an existing session instead of dialing the stale address.
351
483
  const rec = machineRecordFor(target)
484
+ const eff = rec ? withSshConfig(rec) : null
485
+ const host = (eff && eff.host) || target.host
486
+ const port = Number((eff && eff.port) || target.port) || 22
487
+ const username = (eff && eff.username) || target.username || 'root'
352
488
  const existing = machinePools.get(key)
353
489
  if (existing) {
354
490
  syncTunables(existing.config)
491
+ // Re-point a live pool whose resolved identity moved (setTarget closes the
492
+ // connection, so only do it when something actually changed).
493
+ if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
494
+ existing.setTarget({ host, port, username })
495
+ }
355
496
  // Re-read credentials and per-machine policy from the registry: editing a
356
497
  // machine in the settings page must reach a pool created earlier, and a
357
498
  // pool holding a stale password would keep failing to reconnect.
358
- if (rec) {
359
- existing.config.password = rec.password || ''
360
- existing.config.privateKeyPath = rec.privateKeyPath || ''
361
- existing.config.passphrase = rec.passphrase || ''
362
- existing.config.useAgent = !!rec.useAgent
363
- existing.config.keyboardInteractive = !!rec.keyboardInteractive
364
- existing.config.proxy = rec.proxy || undefined
365
- existing.config.hostKeyMode = rec.hostKeyMode || config.hostKeyMode
499
+ if (eff) {
500
+ existing.config.password = eff.password || ''
501
+ existing.config.privateKeyPath = eff.privateKeyPath || ''
502
+ existing.config.passphrase = eff.passphrase || ''
503
+ existing.config.useAgent = !!eff.useAgent
504
+ existing.config.keyboardInteractive = !!eff.keyboardInteractive
505
+ existing.config.proxy = eff.proxy || undefined
506
+ existing.config.hostKeyMode = eff.hostKeyMode || config.hostKeyMode
366
507
  }
367
508
  return existing
368
509
  }
369
510
  const poolConfig = syncTunables({
370
511
  ...config,
371
- host: target.host,
372
- port: Number(target.port) || 22,
373
- username: target.username || (rec && rec.username) || 'root',
374
- password: (rec && rec.password) || '',
375
- privateKeyPath: (rec && rec.privateKeyPath) || '',
376
- passphrase: (rec && rec.passphrase) || '',
377
- useAgent: rec ? !!rec.useAgent : !!config.useAgent,
378
- keyboardInteractive: rec ? !!rec.keyboardInteractive : !!config.keyboardInteractive,
379
- proxy: (rec && rec.proxy) || undefined,
380
- hostKeyMode: (rec && rec.hostKeyMode) || config.hostKeyMode,
512
+ host,
513
+ port,
514
+ username,
515
+ password: (eff && eff.password) || '',
516
+ privateKeyPath: (eff && eff.privateKeyPath) || '',
517
+ passphrase: (eff && eff.passphrase) || '',
518
+ useAgent: eff ? !!eff.useAgent : !!config.useAgent,
519
+ keyboardInteractive: eff ? !!eff.keyboardInteractive : !!config.keyboardInteractive,
520
+ proxy: (eff && eff.proxy) || undefined,
521
+ hostKeyMode: (eff && eff.hostKeyMode) || config.hostKeyMode,
381
522
  // Identity-pinned pools never carry the shared active workspace: a
382
523
  // session passes its own mirror-resolved remote path explicitly.
383
524
  workspace: '',
384
525
  })
385
526
  const created = new SshPool(poolConfig, { knownHostsFile })
386
527
  // Keychain-backed passwords resolve against THIS pool's machine record.
387
- created.passwordResolver = async () => machinePassword(rec || { id: '', password: poolConfig.password })
528
+ created.passwordResolver = async () => machinePassword(eff || { id: '', password: poolConfig.password })
388
529
  machinePools.set(key, created)
389
530
  return created
390
531
  }
391
532
 
533
+ /**
534
+ * Re-assert the ACTIVE machine that a machine-scoped request names.
535
+ *
536
+ * The workspace picker (and the settings page's workspace flow) acts on the
537
+ * shared active pool, but it remembers its own `machineId` locally. Nothing
538
+ * on `/mirror` / `/home` / `/fs` used to carry that identity, so a switch
539
+ * performed by ANOTHER actor between the picker's last fetch and its commit —
540
+ * the settings page's "set as current", or an `rw_connect` in another agent
541
+ * session — silently redirected the commit onto that other machine. Before
542
+ * this PR the picker re-posted /current on every keystroke, which masked the
543
+ * window; the per-keystroke reconnect fix removes that accident. Let the
544
+ * request state its intent so the host can honour it atomically instead.
545
+ *
546
+ * An absent / empty `machineId` keeps the old behaviour (the caller is
547
+ * machine-scoped through the active pool by design). An UNKNOWN id is
548
+ * refused rather than ignored: quietly mirroring onto whichever machine
549
+ * happens to be active is exactly the hazard this closes.
550
+ *
551
+ * @returns {Promise<boolean>} false when the request was already answered.
552
+ */
553
+ const applyRequestedMachine = async (body, res) => {
554
+ const id = String((body && body.machineId) || '').trim()
555
+ if (!id) return true
556
+ if (await setCurrent(id)) return true
557
+ sendJson(res, 404, { ok: false, error: `machine not found: ${id}` })
558
+ return false
559
+ }
560
+
392
561
  const setCurrent = async (id) => {
393
562
  if (!id) {
394
563
  // Explicit "active remote = none": saved machines stay in the registry
@@ -401,6 +570,11 @@ export async function apply(ctx, config) {
401
570
  }
402
571
  const i = machineIndex(id)
403
572
  if (i < 0) return false
573
+ // Idempotent: same machine, no ephemeral override, already applied → keep
574
+ // the registry file, the credentials cache and the live pool untouched.
575
+ // (The fingerprint is maintained by applyActiveMachine itself.)
576
+ const fingerprint = JSON.stringify(machines[i])
577
+ if (store.currentId === id && !ephemeral && config.host && lastAppliedMachine === fingerprint) return true
404
578
  store.currentId = id
405
579
  ephemeral = null
406
580
  saveMachines(machines, id)
@@ -413,7 +587,13 @@ export async function apply(ctx, config) {
413
587
  * tool fallback, but no machine workspace leaks into this session. */
414
588
  const clearActiveMachine = async () => {
415
589
  ephemeral = null
590
+ // Nothing is applied anymore — reset the applied-machine fingerprint too.
591
+ lastAppliedMachine = ''
416
592
  if (!config.host) return
593
+ // Snapshot the diff baseline BEFORE zeroing: pool.config IS this config
594
+ // object, so setTarget would otherwise see an already-zeroed config and
595
+ // keep the old connection alive ("active remote = none" must disconnect).
596
+ const previous = targetFields(config)
417
597
  // Clear host + workspace directly (applyMachine keeps the old workspace
418
598
  // when the incoming value is empty — it must not be used for a full reset).
419
599
  config.host = ''
@@ -421,7 +601,7 @@ export async function apply(ctx, config) {
421
601
  config.username = ''
422
602
  config.password = ''
423
603
  config.workspace = ''
424
- pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
604
+ pool.setTarget({ host: '', port: 22, username: '', workspace: '' }, previous)
425
605
  // Also stop any auto-push watcher bound to the old machine workspace.
426
606
  // (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
427
607
  if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
@@ -682,6 +862,7 @@ export async function apply(ctx, config) {
682
862
  host: machine.host,
683
863
  username: machine.username,
684
864
  port: machine.port,
865
+ alias: machine.alias || '',
685
866
  mirrorDir,
686
867
  bound: true,
687
868
  local: false,
@@ -737,6 +918,7 @@ export async function apply(ctx, config) {
737
918
  host: machine.host,
738
919
  username: machine.username,
739
920
  port: machine.port,
921
+ alias: machine.alias || '',
740
922
  mirrorDir,
741
923
  bound: true,
742
924
  local: false,
@@ -824,7 +1006,7 @@ export async function apply(ctx, config) {
824
1006
  // active — saved machines stay standby-only.
825
1007
  sessionMode: sessionRemotePath() ? 'remote' : (store.currentId ? 'standby' : 'local'),
826
1008
  sessionRemotePath: sessionRemotePath(),
827
- machines: machines.map(sanitizeMachine),
1009
+ machines: machinesForClient(),
828
1010
  hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
829
1011
  hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
830
1012
  forwards: forwards.list(),
@@ -835,6 +1017,125 @@ export async function apply(ctx, config) {
835
1017
  gitBash: pool.gitBashPath || '',
836
1018
  })
837
1019
 
1020
+ // ── remote `@` completion (issue #39) ─────────────────────────────────────
1021
+ //
1022
+ // `@` candidates come from `ctx.fileReferences`, whose only shipped provider
1023
+ // indexes the agent session's LOCAL cwd. A remote session's cwd is the (empty
1024
+ // until synced) mirror, so `@` listed nothing. The seam is a single-owner
1025
+ // service, so lib/file-reference.js WRAPS `fileReferences.list` and answers
1026
+ // remote-bound sessions from the remote host over SFTP.
1027
+ //
1028
+ // Everything below is glue: which remote root an agent is bound to, and how a
1029
+ // workspace-relative directory is listed over the machine's own pool.
1030
+
1031
+ /** Join a workspace-relative directory onto a remote root, segment by
1032
+ * segment, so the root's separator style is preserved on Windows hosts. */
1033
+ const joinRemoteRel = (base, rel) => {
1034
+ let out = base
1035
+ for (const segment of String(rel || '').split('/')) {
1036
+ if (!segment || segment === '.') continue
1037
+ out = joinRemotePath(out, segment)
1038
+ }
1039
+ return out
1040
+ }
1041
+
1042
+ const fileReferenceLog = (msg) => {
1043
+ try {
1044
+ const logger = typeof ctx.get === 'function' ? ctx.get('logger') : undefined
1045
+ if (logger && typeof logger.warn === 'function') logger.warn('[dsh-remote] ' + msg)
1046
+ } catch { /* logging is best-effort */ }
1047
+ }
1048
+
1049
+ /** Providers, one per (machine, remote root): the index cache is expensive to
1050
+ * rebuild, so typing in the SAME workspace keeps reusing it. */
1051
+ const fileReferenceProviders = new Map()
1052
+ const FILE_REFERENCE_PROVIDER_CAP = 32
1053
+
1054
+ /**
1055
+ * The remote discovery provider for one agent, or null when that agent is not
1056
+ * a remote session (or the feature is off) — in which case the original local
1057
+ * provider answers, unchanged.
1058
+ */
1059
+ const remoteFileReferenceFor = (agent) => {
1060
+ // `=== false` rather than falsy: a partially-specified config (a scripted
1061
+ // apply(), an older profile) must still get the remote listing, which is
1062
+ // what the schema default promises.
1063
+ if (config.fileReference === false) return null
1064
+ const cwd = agent && agent.session && agent.session.header ? agent.session.header.cwd : ''
1065
+ if (!cwd) return null
1066
+ const { remotePath, machine } = resolveMirrorForLocal(String(cwd))
1067
+ if (!remotePath || !machine) return null
1068
+ const root = normalizeRemotePath(remotePath)
1069
+ const key = poolKey(machine) + '\u0000' + root
1070
+ const cached = fileReferenceProviders.get(key)
1071
+ if (cached) return cached
1072
+ const provider = createRemoteFileReference({
1073
+ root,
1074
+ // '' = the workspace root; every other value is a workspace-relative dir.
1075
+ listDir: async (rel) => {
1076
+ const p = poolForMachine(machine)
1077
+ const sftp = await p.sftp()
1078
+ const out = await listDirStructured(sftp, rel ? joinRemoteRel(root, rel) : root)
1079
+ return out.items.map((item) => ({
1080
+ name: item.name,
1081
+ kind: item.type === 'dir' ? 'directory' : 'file',
1082
+ }))
1083
+ },
1084
+ config: {
1085
+ root,
1086
+ maxResults: config.fileReferenceMaxResults,
1087
+ maxEntries: config.fileReferenceMaxEntries,
1088
+ excludedDirectories: config.fileReferenceExcludedDirectories,
1089
+ timeoutMs: config.fileReferenceTimeoutMs,
1090
+ },
1091
+ onError: (err) => fileReferenceLog(`@ listing failed for ${machine.host}:${root}: ${(err && err.message) || err}`),
1092
+ })
1093
+ if (fileReferenceProviders.size >= FILE_REFERENCE_PROVIDER_CAP) {
1094
+ const oldest = fileReferenceProviders.keys().next().value
1095
+ try { fileReferenceProviders.get(oldest)?.dispose() } catch { /* already disposed */ }
1096
+ fileReferenceProviders.delete(oldest)
1097
+ }
1098
+ fileReferenceProviders.set(key, provider)
1099
+ return provider
1100
+ }
1101
+
1102
+ const fileReferenceOverlay = installFileReferenceOverlay(ctx, {
1103
+ resolve: remoteFileReferenceFor,
1104
+ onError: (err) => fileReferenceLog(`@ completion overlay: ${(err && err.message) || err}`),
1105
+ })
1106
+ ctx.effect(() => () => {
1107
+ try { fileReferenceOverlay?.dispose?.() } catch { /* already disposed */ }
1108
+ for (const provider of fileReferenceProviders.values()) {
1109
+ try { provider.dispose() } catch { /* already disposed */ }
1110
+ }
1111
+ fileReferenceProviders.clear()
1112
+ }, 'dsh-remote.file-reference')
1113
+
1114
+ /**
1115
+ * Resolve a PATH ARGUMENT a tool received against the session's remote
1116
+ * workspace root when it is not already absolute (issue #39).
1117
+ *
1118
+ * An `@path` mention in a remote session is workspace-relative — the same
1119
+ * shape the local provider produces — so `rw_read_file("src/main.c")` must
1120
+ * mean `<remote workspace>/src/main.c`, not the filesystem root's `/src`.
1121
+ * Absolute POSIX (`/a/b`), Windows (`D:\a`, `/D:/a`) and UNC (`\\host\share`)
1122
+ * forms pass through normalizeRemotePath() unchanged, so behaviour for every
1123
+ * existing caller is preserved. `..` segments collapse inside the joined path
1124
+ * exactly as normalizeRemotePath() already did.
1125
+ *
1126
+ * @param {{ws: string}} b - the binding resolved for this call.
1127
+ * @param {unknown} raw - the raw argument value.
1128
+ * @returns {string} a normalized remote path.
1129
+ */
1130
+ const resolveRemoteArg = (b, raw) => {
1131
+ const s = String(raw ?? '').trim()
1132
+ const absolute = s.startsWith('/') || s.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(s)
1133
+ if (absolute) return normalizeRemotePath(s)
1134
+ const rel = s.replace(/^\.\//, '').replace(/^[\\/]+/, '')
1135
+ if (!rel) return normalizeRemotePath(b.ws || '')
1136
+ return normalizeRemotePath(b.ws ? joinRemoteRel(b.ws, rel.replace(/\\/g, '/')) : rel)
1137
+ }
1138
+
838
1139
  // ── tools ─────────────────────────────────────────────────────────────────
839
1140
 
840
1141
  const textOut = {
@@ -902,19 +1203,21 @@ export async function apply(ctx, config) {
902
1203
  defineTool({
903
1204
  name: 'rw_connect',
904
1205
  description:
905
- 'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
1206
+ 'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time.',
906
1207
  parameters: {
907
- host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
1208
+ host: { type: 'string', required: true, description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true)' },
908
1209
  username: { type: 'string', description: 'SSH user (default from config or root)' },
909
1210
  port: { type: 'integer', description: 'SSH port (default 22)' },
910
1211
  password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
911
1212
  privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
1213
+ useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
912
1214
  save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
913
1215
  },
914
1216
  output: textOut,
915
1217
  async execute(args) {
916
1218
  const host = String(args.host || '').trim()
917
1219
  if (!host) throw new Error('rw_connect: host is required')
1220
+ const useSshConfig = !!args.useSshConfig
918
1221
  const user = args.username || config.username || 'root'
919
1222
  const port = Number(args.port) || undefined
920
1223
  const rec = {
@@ -924,12 +1227,18 @@ export async function apply(ctx, config) {
924
1227
  password: args.password !== undefined ? String(args.password) : '',
925
1228
  privateKeyPath: args.privateKeyPath || '',
926
1229
  }
1230
+ if (useSshConfig) rec.useSshConfig = true
1231
+ // An alias connection must dial the RESOLVED identity (~/.ssh/config),
1232
+ // while the registry keeps the alias itself.
1233
+ const eff = useSshConfig ? withSshConfig(rec) : rec
1234
+ const effUser = eff.username || user
1235
+ const effPort = Number(eff.port) || port || 22
927
1236
  if (args.save !== false) {
928
1237
  // Upsert into the registry and make it the current machine so the
929
1238
  // settings UI and the tools always agree on who is active.
930
1239
  const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
931
1240
  if (i >= 0) {
932
- machines[i] = { ...machines[i], ...rec, id: machines[i].id, password: rec.password || machines[i].password || '' }
1241
+ machines[i] = { ...machines[i], ...rec, useSshConfig, id: machines[i].id, password: rec.password || machines[i].password || '' }
933
1242
  store.currentId = machines[i].id
934
1243
  saveMachines(machines, store.currentId)
935
1244
  } else {
@@ -939,25 +1248,35 @@ export async function apply(ctx, config) {
939
1248
  saveMachines(machines, store.currentId)
940
1249
  }
941
1250
  ephemeral = null
1251
+ // rw_connect means "connect now": force a fresh dial even for an
1252
+ // identical target (setTarget alone would reuse a possibly half-open
1253
+ // cached client — same reconnect-probe reasoning as /connect).
1254
+ pool.invalidate()
942
1255
  await applyActiveMachine()
943
1256
  } else {
944
1257
  ephemeral = { id: machineId(), name: host, ...rec }
1258
+ // rw_connect means "connect now": force a fresh dial even for an
1259
+ // identical target (setTarget alone would reuse a possibly half-open
1260
+ // cached client — same reconnect-probe reasoning as /connect).
1261
+ pool.invalidate()
945
1262
  pool.setTarget({
946
- host, port: port || 22, username: user,
947
- password: rec.password, privateKeyPath: rec.privateKeyPath,
1263
+ host: eff.host, port: effPort, username: effUser,
1264
+ password: rec.password, privateKeyPath: eff.privateKeyPath || '',
1265
+ proxy: eff.proxy,
948
1266
  workspace: config.workspace,
949
1267
  })
950
1268
  }
951
1269
  try {
952
1270
  const res = await pool.exec('echo ok', { timeoutMs: 8000 })
953
1271
  if (res.code !== 0 && !res.stdout) {
954
- audit('connect', `connect ${user}@${host}:${port || 22}`, res.code)
1272
+ audit('connect', `connect ${effUser}@${eff.host}:${effPort}`, res.code)
955
1273
  return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
956
1274
  }
957
- audit('connect', `connect ${user}@${host}:${port || 22}`, 0)
958
- return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1275
+ audit('connect', `connect ${effUser}@${eff.host}:${effPort}`, 0)
1276
+ const via = useSshConfig ? `\n(ssh-config alias "${host}" → ${effUser}@${eff.host}:${effPort}` + (eff.sshConfigWarnings && eff.sshConfigWarnings.length ? `; ${eff.sshConfigWarnings.join(' ')}` : '') + ')' : ''
1277
+ return { text: `Connected to ${eff.host} as ${config.username}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
959
1278
  } catch (err) {
960
- throw new Error(friendlyMessage(err, { host, port }))
1279
+ throw new Error(friendlyMessage(err, { host: eff.host, port: effPort }))
961
1280
  }
962
1281
  },
963
1282
  }),
@@ -988,7 +1307,7 @@ export async function apply(ctx, config) {
988
1307
  // A bound session's workspace lives in its own mirror meta, so the
989
1308
  // shared active-machine workspace is only updated for the unbound flow.
990
1309
  if (!b.bound) persistWorkspace(p)
991
- const local = ensureMirror(p, b.host, b.username, b.port)
1310
+ const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
992
1311
  startAutoPush(local)
993
1312
  return {
994
1313
  text: `Remote workspace set to ${shown} on ${b.username}@${b.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
@@ -1108,7 +1427,7 @@ export async function apply(ctx, config) {
1108
1427
  output: textOut,
1109
1428
  async execute(args, exec) {
1110
1429
  const b = requireBinding(exec, 'rw_list_dir')
1111
- const p = args.path ? normalizeRemotePath(String(args.path)) : b.ws
1430
+ const p = args.path ? resolveRemoteArg(b, args.path) : b.ws
1112
1431
  if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1113
1432
  let list
1114
1433
  try {
@@ -1145,7 +1464,7 @@ export async function apply(ctx, config) {
1145
1464
  output: textOut,
1146
1465
  async execute(args, exec) {
1147
1466
  const b = requireMachine(exec, 'rw_stat')
1148
- const p = normalizeRemotePath(String(args.path || ''))
1467
+ const p = resolveRemoteArg(b, args.path)
1149
1468
  if (!p) throw new Error('rw_stat: path is required')
1150
1469
  const sftp = await b.pool.sftp()
1151
1470
  let st
@@ -1180,7 +1499,7 @@ export async function apply(ctx, config) {
1180
1499
  output: textOut,
1181
1500
  async execute(args, exec) {
1182
1501
  const b = requireMachine(exec, 'rw_read_file')
1183
- const p = normalizeRemotePath(String(args.path || ''))
1502
+ const p = resolveRemoteArg(b, args.path)
1184
1503
  if (!p) throw new Error('rw_read_file: path is required')
1185
1504
  const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1186
1505
  let from = Math.max(Number(args.startLine) || 1, 1)
@@ -1221,7 +1540,7 @@ export async function apply(ctx, config) {
1221
1540
  output: okOut,
1222
1541
  async execute(args, exec) {
1223
1542
  const b = requireMachine(exec, 'rw_write_file')
1224
- const p = normalizeRemotePath(String(args.path || ''))
1543
+ const p = resolveRemoteArg(b, args.path)
1225
1544
  if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1226
1545
  const content = String(args.content == null ? '' : args.content)
1227
1546
  const sftp = await b.pool.sftp()
@@ -1248,7 +1567,7 @@ export async function apply(ctx, config) {
1248
1567
  output: okOut,
1249
1568
  async execute(args, exec) {
1250
1569
  const b = requireMachine(exec, 'rw_edit')
1251
- const p = normalizeRemotePath(String(args.path || ''))
1570
+ const p = resolveRemoteArg(b, args.path)
1252
1571
  if (!p || p === '/') throw new Error('rw_edit: a file path is required')
1253
1572
  const oldS = String(args.old ?? '')
1254
1573
  const newS = String(args.new ?? '')
@@ -1294,7 +1613,7 @@ export async function apply(ctx, config) {
1294
1613
  output: okOut,
1295
1614
  async execute(args, exec) {
1296
1615
  const b = requireMachine(exec, 'rw_append')
1297
- const p = normalizeRemotePath(String(args.path || ''))
1616
+ const p = resolveRemoteArg(b, args.path)
1298
1617
  if (!p || p === '/') throw new Error('rw_append: a file path is required')
1299
1618
  const sftp = await b.pool.sftp()
1300
1619
  let existing = ''
@@ -1317,7 +1636,7 @@ export async function apply(ctx, config) {
1317
1636
  output: textOut,
1318
1637
  async execute(args, exec) {
1319
1638
  const b = requireMachine(exec, 'rw_mkdir')
1320
- const p = normalizeRemotePath(String(args.path || ''))
1639
+ const p = resolveRemoteArg(b, args.path)
1321
1640
  if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
1322
1641
  const sftp = await b.pool.sftp()
1323
1642
  await mkdirRemoteDirs(sftp, p)
@@ -1337,7 +1656,7 @@ export async function apply(ctx, config) {
1337
1656
  output: okOut,
1338
1657
  async execute(args, exec) {
1339
1658
  const b = requireMachine(exec, 'rw_remove')
1340
- const p = normalizeRemotePath(String(args.path || ''))
1659
+ const p = resolveRemoteArg(b, args.path)
1341
1660
  if (!p || p === '/') throw new Error('rw_remove: a path is required')
1342
1661
  const sftp = await b.pool.sftp()
1343
1662
  const st = await sftp.stat(p).catch(() => null)
@@ -1365,8 +1684,8 @@ export async function apply(ctx, config) {
1365
1684
  output: textOut,
1366
1685
  async execute(args, exec) {
1367
1686
  const b = requireMachine(exec, 'rw_move')
1368
- const p = normalizeRemotePath(String(args.path || ''))
1369
- const d = normalizeRemotePath(String(args.dest || ''))
1687
+ const p = resolveRemoteArg(b, args.path)
1688
+ const d = resolveRemoteArg(b, args.dest)
1370
1689
  if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
1371
1690
  const sftp = await b.pool.sftp()
1372
1691
  await mkdirRemoteDirs(sftp, remoteDirname(d))
@@ -1392,7 +1711,7 @@ export async function apply(ctx, config) {
1392
1711
  const cmd = String(args.command || '')
1393
1712
  if (!cmd) throw new Error('rw_exec: command is required')
1394
1713
  const ws = b.ws
1395
- const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1714
+ const cwd = args.cwd ? resolveRemoteArg(b, args.cwd) : (ws || '')
1396
1715
  let full = cmd
1397
1716
  if (cwd) {
1398
1717
  // Detect before choosing the cwd form: a per-machine pool starts
@@ -1442,7 +1761,7 @@ export async function apply(ctx, config) {
1442
1761
  const pattern = String(args.pattern || '')
1443
1762
  if (!pattern) throw new Error('rw_search: pattern is required')
1444
1763
  const ws = b.ws
1445
- const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1764
+ const dir = args.path ? resolveRemoteArg(b, args.path) : (ws || '')
1446
1765
  if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1447
1766
  let regex
1448
1767
  try {
@@ -1481,7 +1800,7 @@ export async function apply(ctx, config) {
1481
1800
  output: okOut,
1482
1801
  async execute(args, exec) {
1483
1802
  const b = requireMachine(exec, 'rw_download')
1484
- const p = normalizeRemotePath(String(args.path || ''))
1803
+ const p = resolveRemoteArg(b, args.path)
1485
1804
  if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1486
1805
  const sftp = await b.pool.sftp()
1487
1806
  let local
@@ -1516,7 +1835,7 @@ export async function apply(ctx, config) {
1516
1835
  output: okOut,
1517
1836
  async execute(args, exec) {
1518
1837
  const b = requireMachine(exec, 'rw_upload')
1519
- const rp = normalizeRemotePath(String(args.path || ''))
1838
+ const rp = resolveRemoteArg(b, args.path)
1520
1839
  const lp = String(args.localPath || '')
1521
1840
  if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1522
1841
  if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
@@ -1615,13 +1934,20 @@ export async function apply(ctx, config) {
1615
1934
  ? `${machine.username || 'user'}@${machine.host}`
1616
1935
  : `${config.username || 'user'}@${config.host}`
1617
1936
  const platform = machine ? poolForMachine(machine).platform : pool.platform
1937
+ const shown = toDisplayPath(remotePath, platform)
1618
1938
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
1619
1939
  let extra = ''
1620
1940
  if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
1941
+ // Issue #39: `@` mentions are resolved from the REMOTE tree by
1942
+ // lib/file-reference.js, so the model must know they are remote-relative
1943
+ // and that the built-in read tool sees only the local mirror.
1621
1944
  return (
1622
1945
  '## Remote workspace\n' +
1623
- `Current remote workspace: ${who}:${toDisplayPath(remotePath, platform)}\n` +
1624
- 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
1946
+ `Current remote workspace: ${who}:${shown}\n` +
1947
+ 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.\n' +
1948
+ `\`@path\` mentions in this conversation name files under THAT remote root (\`@src/main.c\` = ${shown}/src/main.c). ` +
1949
+ 'The rw_* tools take such paths as workspace-relative and resolve them against this root. ' +
1950
+ 'The harness\'s built-in read/list tools only see the LOCAL mirror of this directory, which stays empty until rw_sync — use rw_read_file for remote files.' +
1625
1951
  extra
1626
1952
  )
1627
1953
  },
@@ -1931,6 +2257,12 @@ export async function apply(ctx, config) {
1931
2257
  let payload = {}
1932
2258
  try {
1933
2259
  payload = parseJsonObject(await readBody(req))
2260
+ // This route is a reconnect PROBE ("Connect" button): with the
2261
+ // unchanged-target no-op in setTarget, an identical target would
2262
+ // reuse a possibly half-open cached client and the probe could hang.
2263
+ // invalidate() is the design's forced-reconnect escape hatch — drop
2264
+ // the cached client so the `echo ok` below always re-dials.
2265
+ pool.invalidate()
1934
2266
  pool.setTarget({
1935
2267
  host: payload.host,
1936
2268
  port: payload.port,
@@ -1962,7 +2294,7 @@ export async function apply(ctx, config) {
1962
2294
  const okDir = await isRemoteDir(p)
1963
2295
  if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1964
2296
  persistWorkspace(p)
1965
- const local = ensureMirror(p, config.host, config.username, config.port)
2297
+ const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
1966
2298
  startAutoPush(local)
1967
2299
  return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
1968
2300
  } catch (err) {
@@ -1980,9 +2312,14 @@ export async function apply(ctx, config) {
1980
2312
  const p = normalizeRemotePath(String(payload.path || ''))
1981
2313
  if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
1982
2314
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
2315
+ // The picker names the machine it was browsing. Re-assert it so a
2316
+ // switch made elsewhere between the listing and this commit cannot
2317
+ // mirror onto a machine the user never looked at (the picker sends
2318
+ // /current only once per machine now, so nothing else re-applies it).
2319
+ if (!(await applyRequestedMachine(payload, res))) return
1983
2320
  const okDir = await isRemoteDir(p)
1984
2321
  if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
1985
- const local = ensureMirror(p, config.host, config.username, config.port)
2322
+ const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
1986
2323
  persistWorkspace(p)
1987
2324
  startAutoPush(local)
1988
2325
  return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
@@ -2088,7 +2425,7 @@ export async function apply(ctx, config) {
2088
2425
  path: '/dsh-remote/machines',
2089
2426
  handler: async (req, res) => {
2090
2427
  if (req.method === 'GET') {
2091
- return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
2428
+ return sendJson(res, 200, { machines: machinesForClient(), currentId: store.currentId })
2092
2429
  }
2093
2430
  if (req.method === 'POST') {
2094
2431
  try {
@@ -2108,6 +2445,10 @@ export async function apply(ctx, config) {
2108
2445
  id,
2109
2446
  name: String(body.name || '').trim() || host,
2110
2447
  host,
2448
+ // Issue #38: an ALIAS machine stores only the Host line of
2449
+ // ~/.ssh/config — HostName/user/port/key are resolved live at
2450
+ // every connect instead of being copied into the registry.
2451
+ useSshConfig: !!body.useSshConfig,
2111
2452
  port: Number(body.port) || 22,
2112
2453
  username: String(body.username || '').trim() || 'root',
2113
2454
  password: '',
@@ -2162,7 +2503,7 @@ export async function apply(ctx, config) {
2162
2503
  return sendJson(res, 200, {
2163
2504
  ok: true,
2164
2505
  machine: sanitizeMachine(rec),
2165
- machines: machines.map(sanitizeMachine),
2506
+ machines: machinesForClient(),
2166
2507
  currentId: store.currentId,
2167
2508
  ...(warning ? { warning, warningDetail } : {}),
2168
2509
  })
@@ -2186,7 +2527,7 @@ export async function apply(ctx, config) {
2186
2527
  } else {
2187
2528
  saveMachines(machines, store.currentId)
2188
2529
  }
2189
- return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
2530
+ return sendJson(res, 200, { ok: true, machines: machinesForClient(), currentId: store.currentId })
2190
2531
  }
2191
2532
  return sendJson(res, 400, { ok: false, error: 'unknown action' })
2192
2533
  } catch (err) {
@@ -2209,21 +2550,33 @@ export async function apply(ctx, config) {
2209
2550
  let body = {}
2210
2551
  try {
2211
2552
  body = parseJsonObject(await readBody(req))
2553
+ // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
2554
+ // the form holds the alias name, not the host it points at.
2555
+ const aliasTarget = body.useSshConfig
2556
+ ? resolveMachineSshConfig({
2557
+ host: String(body.host || ''),
2558
+ port: Number(body.port) || 22,
2559
+ username: String(body.username || ''),
2560
+ privateKeyPath: String(body.privateKeyPath || ''),
2561
+ proxy: body.proxy,
2562
+ useSshConfig: true,
2563
+ }, { text: sshConfigText() })
2564
+ : null
2212
2565
  const probe = new SshPool({
2213
2566
  ...config,
2214
- host: String(body.host || config.host),
2215
- port: Number(body.port) || config.port,
2216
- username: String(body.username || config.username),
2567
+ host: aliasTarget ? aliasTarget.host : String(body.host || config.host),
2568
+ port: aliasTarget ? aliasTarget.port : (Number(body.port) || config.port),
2569
+ username: aliasTarget ? aliasTarget.username : String(body.username || config.username),
2217
2570
  password: String(body.password || ''),
2218
- privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
2571
+ privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
2219
2572
  passphrase: String(body.passphrase || ''),
2220
- proxy: body.proxy && body.proxy.host ? {
2573
+ proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
2221
2574
  host: String(body.proxy.host),
2222
2575
  port: Number(body.proxy.port) || 22,
2223
2576
  username: String(body.proxy.username || ''),
2224
2577
  password: String(body.proxy.password || ''),
2225
2578
  privateKeyPath: String(body.proxy.privateKeyPath || ''),
2226
- } : undefined,
2579
+ } : undefined),
2227
2580
  connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
2228
2581
  commandTimeoutMs: 10000,
2229
2582
  }, { knownHostsFile })
@@ -2232,13 +2585,27 @@ export async function apply(ctx, config) {
2232
2585
  await probe.exec('true', { timeoutMs: 10000 })
2233
2586
  probe.close()
2234
2587
  const latencyMs = Date.now() - started
2235
- const mi = machines.findIndex((m) => m.host === probe.config.host && m.username === probe.config.username && Number(m.port) === probe.config.port)
2588
+ // Alias-aware: the probe connects to the RESOLVED host, while the
2589
+ // registry stores the alias, so matching must go through the identity.
2590
+ const mi = machines.findIndex((m) => identityMatches(m, { host: probe.config.host, port: probe.config.port, username: probe.config.username }))
2236
2591
  if (mi >= 0) {
2237
2592
  machines[mi].lastConnectedAt = new Date().toISOString()
2238
2593
  machines[mi].latencyMs = latencyMs
2239
2594
  saveMachines(machines, store.currentId)
2240
2595
  }
2241
- return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString(), platform: probe.platform, shell: probe.shellMode, gitBash: probe.gitBashPath || '' })
2596
+ return sendJson(res, 200, {
2597
+ ok: true,
2598
+ host: probe.config.host,
2599
+ user: probe.config.username,
2600
+ latencyMs,
2601
+ lastConnectedAt: new Date().toISOString(),
2602
+ platform: probe.platform,
2603
+ shell: probe.shellMode,
2604
+ gitBash: probe.gitBashPath || '',
2605
+ // Issue #38: tell the settings page where an alias resolved to (and
2606
+ // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
2607
+ alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
2608
+ })
2242
2609
  } catch (err) {
2243
2610
  return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body.host, port: body.port }) })
2244
2611
  }
@@ -2379,6 +2746,11 @@ export async function apply(ctx, config) {
2379
2746
  handler: async (req, res) => {
2380
2747
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2381
2748
  try {
2749
+ const body = parseJsonObject(await readBody(req))
2750
+ // The picker names the machine it is showing: re-assert it before
2751
+ // answering, so a switch made elsewhere cannot make /home describe a
2752
+ // different host than the tree the user is looking at.
2753
+ if (!(await applyRequestedMachine(body, res))) return
2382
2754
  const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
2383
2755
  const home = String(out.stdout || '').replace(/\s+/g, '').trim()
2384
2756
  if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })