dsh-remote 0.8.21 → 0.8.22

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
@@ -333,12 +418,25 @@ export async function apply(ctx, config) {
333
418
  * An EPHEMERAL connection (rw_connect save:false) is not in the registry but
334
419
  * is still a legitimate credential source for a pool the same process later
335
420
  * 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 || ''))
421
+ * connection would resolve to an empty-credential pool and fail to connect.
422
+ *
423
+ * An ssh-config ALIAS machine (issue #38) is matched through its RESOLVED
424
+ * identity, and directly by the alias the mirror recorded — so a mirror keeps
425
+ * resolving to its machine even after a HostName/port change in ~/.ssh/config,
426
+ * which is the whole point of not copying those values into the registry. */
427
+ const identityOf = (m) => {
428
+ const r = withSshConfig(m)
429
+ return { host: r.host, port: Number(r.port) || 22, username: r.username || '' }
430
+ }
431
+ const identityMatches = (m, { host, port, username }) => {
432
+ if (!m) return false
433
+ const id = identityOf(m)
434
+ return id.host === host && id.port === Number(port) && id.username === (username || '')
435
+ }
436
+ const machineRecordFor = ({ host, port, username, alias }) =>
437
+ (identityMatches(ephemeral, { host, port, username }) ? ephemeral : null)
438
+ || (alias ? machines.find((m) => aliasOf(m) === alias) : null)
439
+ || machines.find((m) => identityMatches(m, { host, port, username }))
342
440
  || null
343
441
 
344
442
  /**
@@ -348,43 +446,57 @@ export async function apply(ctx, config) {
348
446
  */
349
447
  const poolForMachine = (target) => {
350
448
  const key = poolKey(target)
449
+ // `rec` is the registry record (raw); `eff` is its CONNECT identity, with an
450
+ // ssh-config alias resolved (issue #38). For an alias whose HostName/USER/port
451
+ // changed since the mirror was created, the RESOLVED identity wins over the
452
+ // mirror-recorded one — that is what makes an edit to ~/.ssh/config take
453
+ // effect for an existing session instead of dialing the stale address.
351
454
  const rec = machineRecordFor(target)
455
+ const eff = rec ? withSshConfig(rec) : null
456
+ const host = (eff && eff.host) || target.host
457
+ const port = Number((eff && eff.port) || target.port) || 22
458
+ const username = (eff && eff.username) || target.username || 'root'
352
459
  const existing = machinePools.get(key)
353
460
  if (existing) {
354
461
  syncTunables(existing.config)
462
+ // Re-point a live pool whose resolved identity moved (setTarget closes the
463
+ // connection, so only do it when something actually changed).
464
+ if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
465
+ existing.setTarget({ host, port, username })
466
+ }
355
467
  // Re-read credentials and per-machine policy from the registry: editing a
356
468
  // machine in the settings page must reach a pool created earlier, and a
357
469
  // 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
470
+ if (eff) {
471
+ existing.config.password = eff.password || ''
472
+ existing.config.privateKeyPath = eff.privateKeyPath || ''
473
+ existing.config.passphrase = eff.passphrase || ''
474
+ existing.config.useAgent = !!eff.useAgent
475
+ existing.config.keyboardInteractive = !!eff.keyboardInteractive
476
+ existing.config.proxy = eff.proxy || undefined
477
+ existing.config.hostKeyMode = eff.hostKeyMode || config.hostKeyMode
366
478
  }
367
479
  return existing
368
480
  }
369
481
  const poolConfig = syncTunables({
370
482
  ...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,
483
+ host,
484
+ port,
485
+ username,
486
+ password: (eff && eff.password) || '',
487
+ privateKeyPath: (eff && eff.privateKeyPath) || '',
488
+ passphrase: (eff && eff.passphrase) || '',
489
+ useAgent: eff ? !!eff.useAgent : !!config.useAgent,
490
+ keyboardInteractive: eff ? !!eff.keyboardInteractive : !!config.keyboardInteractive,
491
+ proxy: (eff && eff.proxy) || undefined,
492
+ hostKeyMode: (eff && eff.hostKeyMode) || config.hostKeyMode,
381
493
  // Identity-pinned pools never carry the shared active workspace: a
382
494
  // session passes its own mirror-resolved remote path explicitly.
383
495
  workspace: '',
384
496
  })
385
497
  const created = new SshPool(poolConfig, { knownHostsFile })
386
498
  // Keychain-backed passwords resolve against THIS pool's machine record.
387
- created.passwordResolver = async () => machinePassword(rec || { id: '', password: poolConfig.password })
499
+ created.passwordResolver = async () => machinePassword(eff || { id: '', password: poolConfig.password })
388
500
  machinePools.set(key, created)
389
501
  return created
390
502
  }
@@ -682,6 +794,7 @@ export async function apply(ctx, config) {
682
794
  host: machine.host,
683
795
  username: machine.username,
684
796
  port: machine.port,
797
+ alias: machine.alias || '',
685
798
  mirrorDir,
686
799
  bound: true,
687
800
  local: false,
@@ -737,6 +850,7 @@ export async function apply(ctx, config) {
737
850
  host: machine.host,
738
851
  username: machine.username,
739
852
  port: machine.port,
853
+ alias: machine.alias || '',
740
854
  mirrorDir,
741
855
  bound: true,
742
856
  local: false,
@@ -824,7 +938,7 @@ export async function apply(ctx, config) {
824
938
  // active — saved machines stay standby-only.
825
939
  sessionMode: sessionRemotePath() ? 'remote' : (store.currentId ? 'standby' : 'local'),
826
940
  sessionRemotePath: sessionRemotePath(),
827
- machines: machines.map(sanitizeMachine),
941
+ machines: machinesForClient(),
828
942
  hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
829
943
  hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
830
944
  forwards: forwards.list(),
@@ -835,6 +949,125 @@ export async function apply(ctx, config) {
835
949
  gitBash: pool.gitBashPath || '',
836
950
  })
837
951
 
952
+ // ── remote `@` completion (issue #39) ─────────────────────────────────────
953
+ //
954
+ // `@` candidates come from `ctx.fileReferences`, whose only shipped provider
955
+ // indexes the agent session's LOCAL cwd. A remote session's cwd is the (empty
956
+ // until synced) mirror, so `@` listed nothing. The seam is a single-owner
957
+ // service, so lib/file-reference.js WRAPS `fileReferences.list` and answers
958
+ // remote-bound sessions from the remote host over SFTP.
959
+ //
960
+ // Everything below is glue: which remote root an agent is bound to, and how a
961
+ // workspace-relative directory is listed over the machine's own pool.
962
+
963
+ /** Join a workspace-relative directory onto a remote root, segment by
964
+ * segment, so the root's separator style is preserved on Windows hosts. */
965
+ const joinRemoteRel = (base, rel) => {
966
+ let out = base
967
+ for (const segment of String(rel || '').split('/')) {
968
+ if (!segment || segment === '.') continue
969
+ out = joinRemotePath(out, segment)
970
+ }
971
+ return out
972
+ }
973
+
974
+ const fileReferenceLog = (msg) => {
975
+ try {
976
+ const logger = typeof ctx.get === 'function' ? ctx.get('logger') : undefined
977
+ if (logger && typeof logger.warn === 'function') logger.warn('[dsh-remote] ' + msg)
978
+ } catch { /* logging is best-effort */ }
979
+ }
980
+
981
+ /** Providers, one per (machine, remote root): the index cache is expensive to
982
+ * rebuild, so typing in the SAME workspace keeps reusing it. */
983
+ const fileReferenceProviders = new Map()
984
+ const FILE_REFERENCE_PROVIDER_CAP = 32
985
+
986
+ /**
987
+ * The remote discovery provider for one agent, or null when that agent is not
988
+ * a remote session (or the feature is off) — in which case the original local
989
+ * provider answers, unchanged.
990
+ */
991
+ const remoteFileReferenceFor = (agent) => {
992
+ // `=== false` rather than falsy: a partially-specified config (a scripted
993
+ // apply(), an older profile) must still get the remote listing, which is
994
+ // what the schema default promises.
995
+ if (config.fileReference === false) return null
996
+ const cwd = agent && agent.session && agent.session.header ? agent.session.header.cwd : ''
997
+ if (!cwd) return null
998
+ const { remotePath, machine } = resolveMirrorForLocal(String(cwd))
999
+ if (!remotePath || !machine) return null
1000
+ const root = normalizeRemotePath(remotePath)
1001
+ const key = poolKey(machine) + '\u0000' + root
1002
+ const cached = fileReferenceProviders.get(key)
1003
+ if (cached) return cached
1004
+ const provider = createRemoteFileReference({
1005
+ root,
1006
+ // '' = the workspace root; every other value is a workspace-relative dir.
1007
+ listDir: async (rel) => {
1008
+ const p = poolForMachine(machine)
1009
+ const sftp = await p.sftp()
1010
+ const out = await listDirStructured(sftp, rel ? joinRemoteRel(root, rel) : root)
1011
+ return out.items.map((item) => ({
1012
+ name: item.name,
1013
+ kind: item.type === 'dir' ? 'directory' : 'file',
1014
+ }))
1015
+ },
1016
+ config: {
1017
+ root,
1018
+ maxResults: config.fileReferenceMaxResults,
1019
+ maxEntries: config.fileReferenceMaxEntries,
1020
+ excludedDirectories: config.fileReferenceExcludedDirectories,
1021
+ timeoutMs: config.fileReferenceTimeoutMs,
1022
+ },
1023
+ onError: (err) => fileReferenceLog(`@ listing failed for ${machine.host}:${root}: ${(err && err.message) || err}`),
1024
+ })
1025
+ if (fileReferenceProviders.size >= FILE_REFERENCE_PROVIDER_CAP) {
1026
+ const oldest = fileReferenceProviders.keys().next().value
1027
+ try { fileReferenceProviders.get(oldest)?.dispose() } catch { /* already disposed */ }
1028
+ fileReferenceProviders.delete(oldest)
1029
+ }
1030
+ fileReferenceProviders.set(key, provider)
1031
+ return provider
1032
+ }
1033
+
1034
+ const fileReferenceOverlay = installFileReferenceOverlay(ctx, {
1035
+ resolve: remoteFileReferenceFor,
1036
+ onError: (err) => fileReferenceLog(`@ completion overlay: ${(err && err.message) || err}`),
1037
+ })
1038
+ ctx.effect(() => () => {
1039
+ try { fileReferenceOverlay?.dispose?.() } catch { /* already disposed */ }
1040
+ for (const provider of fileReferenceProviders.values()) {
1041
+ try { provider.dispose() } catch { /* already disposed */ }
1042
+ }
1043
+ fileReferenceProviders.clear()
1044
+ }, 'dsh-remote.file-reference')
1045
+
1046
+ /**
1047
+ * Resolve a PATH ARGUMENT a tool received against the session's remote
1048
+ * workspace root when it is not already absolute (issue #39).
1049
+ *
1050
+ * An `@path` mention in a remote session is workspace-relative — the same
1051
+ * shape the local provider produces — so `rw_read_file("src/main.c")` must
1052
+ * mean `<remote workspace>/src/main.c`, not the filesystem root's `/src`.
1053
+ * Absolute POSIX (`/a/b`), Windows (`D:\a`, `/D:/a`) and UNC (`\\host\share`)
1054
+ * forms pass through normalizeRemotePath() unchanged, so behaviour for every
1055
+ * existing caller is preserved. `..` segments collapse inside the joined path
1056
+ * exactly as normalizeRemotePath() already did.
1057
+ *
1058
+ * @param {{ws: string}} b - the binding resolved for this call.
1059
+ * @param {unknown} raw - the raw argument value.
1060
+ * @returns {string} a normalized remote path.
1061
+ */
1062
+ const resolveRemoteArg = (b, raw) => {
1063
+ const s = String(raw ?? '').trim()
1064
+ const absolute = s.startsWith('/') || s.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(s)
1065
+ if (absolute) return normalizeRemotePath(s)
1066
+ const rel = s.replace(/^\.\//, '').replace(/^[\\/]+/, '')
1067
+ if (!rel) return normalizeRemotePath(b.ws || '')
1068
+ return normalizeRemotePath(b.ws ? joinRemoteRel(b.ws, rel.replace(/\\/g, '/')) : rel)
1069
+ }
1070
+
838
1071
  // ── tools ─────────────────────────────────────────────────────────────────
839
1072
 
840
1073
  const textOut = {
@@ -902,19 +1135,21 @@ export async function apply(ctx, config) {
902
1135
  defineTool({
903
1136
  name: 'rw_connect',
904
1137
  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.',
1138
+ '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
1139
  parameters: {
907
- host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
1140
+ host: { type: 'string', required: true, description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true)' },
908
1141
  username: { type: 'string', description: 'SSH user (default from config or root)' },
909
1142
  port: { type: 'integer', description: 'SSH port (default 22)' },
910
1143
  password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
911
1144
  privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
1145
+ useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
912
1146
  save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
913
1147
  },
914
1148
  output: textOut,
915
1149
  async execute(args) {
916
1150
  const host = String(args.host || '').trim()
917
1151
  if (!host) throw new Error('rw_connect: host is required')
1152
+ const useSshConfig = !!args.useSshConfig
918
1153
  const user = args.username || config.username || 'root'
919
1154
  const port = Number(args.port) || undefined
920
1155
  const rec = {
@@ -924,12 +1159,18 @@ export async function apply(ctx, config) {
924
1159
  password: args.password !== undefined ? String(args.password) : '',
925
1160
  privateKeyPath: args.privateKeyPath || '',
926
1161
  }
1162
+ if (useSshConfig) rec.useSshConfig = true
1163
+ // An alias connection must dial the RESOLVED identity (~/.ssh/config),
1164
+ // while the registry keeps the alias itself.
1165
+ const eff = useSshConfig ? withSshConfig(rec) : rec
1166
+ const effUser = eff.username || user
1167
+ const effPort = Number(eff.port) || port || 22
927
1168
  if (args.save !== false) {
928
1169
  // Upsert into the registry and make it the current machine so the
929
1170
  // settings UI and the tools always agree on who is active.
930
1171
  const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
931
1172
  if (i >= 0) {
932
- machines[i] = { ...machines[i], ...rec, id: machines[i].id, password: rec.password || machines[i].password || '' }
1173
+ machines[i] = { ...machines[i], ...rec, useSshConfig, id: machines[i].id, password: rec.password || machines[i].password || '' }
933
1174
  store.currentId = machines[i].id
934
1175
  saveMachines(machines, store.currentId)
935
1176
  } else {
@@ -943,21 +1184,23 @@ export async function apply(ctx, config) {
943
1184
  } else {
944
1185
  ephemeral = { id: machineId(), name: host, ...rec }
945
1186
  pool.setTarget({
946
- host, port: port || 22, username: user,
947
- password: rec.password, privateKeyPath: rec.privateKeyPath,
1187
+ host: eff.host, port: effPort, username: effUser,
1188
+ password: rec.password, privateKeyPath: eff.privateKeyPath || '',
1189
+ proxy: eff.proxy,
948
1190
  workspace: config.workspace,
949
1191
  })
950
1192
  }
951
1193
  try {
952
1194
  const res = await pool.exec('echo ok', { timeoutMs: 8000 })
953
1195
  if (res.code !== 0 && !res.stdout) {
954
- audit('connect', `connect ${user}@${host}:${port || 22}`, res.code)
1196
+ audit('connect', `connect ${effUser}@${eff.host}:${effPort}`, res.code)
955
1197
  return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
956
1198
  }
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>).` }
1199
+ audit('connect', `connect ${effUser}@${eff.host}:${effPort}`, 0)
1200
+ const via = useSshConfig ? `\n(ssh-config alias "${host}" → ${effUser}@${eff.host}:${effPort}` + (eff.sshConfigWarnings && eff.sshConfigWarnings.length ? `; ${eff.sshConfigWarnings.join(' ')}` : '') + ')' : ''
1201
+ return { text: `Connected to ${eff.host} as ${config.username}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
959
1202
  } catch (err) {
960
- throw new Error(friendlyMessage(err, { host, port }))
1203
+ throw new Error(friendlyMessage(err, { host: eff.host, port: effPort }))
961
1204
  }
962
1205
  },
963
1206
  }),
@@ -988,7 +1231,7 @@ export async function apply(ctx, config) {
988
1231
  // A bound session's workspace lives in its own mirror meta, so the
989
1232
  // shared active-machine workspace is only updated for the unbound flow.
990
1233
  if (!b.bound) persistWorkspace(p)
991
- const local = ensureMirror(p, b.host, b.username, b.port)
1234
+ const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
992
1235
  startAutoPush(local)
993
1236
  return {
994
1237
  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 +1351,7 @@ export async function apply(ctx, config) {
1108
1351
  output: textOut,
1109
1352
  async execute(args, exec) {
1110
1353
  const b = requireBinding(exec, 'rw_list_dir')
1111
- const p = args.path ? normalizeRemotePath(String(args.path)) : b.ws
1354
+ const p = args.path ? resolveRemoteArg(b, args.path) : b.ws
1112
1355
  if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
1113
1356
  let list
1114
1357
  try {
@@ -1145,7 +1388,7 @@ export async function apply(ctx, config) {
1145
1388
  output: textOut,
1146
1389
  async execute(args, exec) {
1147
1390
  const b = requireMachine(exec, 'rw_stat')
1148
- const p = normalizeRemotePath(String(args.path || ''))
1391
+ const p = resolveRemoteArg(b, args.path)
1149
1392
  if (!p) throw new Error('rw_stat: path is required')
1150
1393
  const sftp = await b.pool.sftp()
1151
1394
  let st
@@ -1180,7 +1423,7 @@ export async function apply(ctx, config) {
1180
1423
  output: textOut,
1181
1424
  async execute(args, exec) {
1182
1425
  const b = requireMachine(exec, 'rw_read_file')
1183
- const p = normalizeRemotePath(String(args.path || ''))
1426
+ const p = resolveRemoteArg(b, args.path)
1184
1427
  if (!p) throw new Error('rw_read_file: path is required')
1185
1428
  const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
1186
1429
  let from = Math.max(Number(args.startLine) || 1, 1)
@@ -1221,7 +1464,7 @@ export async function apply(ctx, config) {
1221
1464
  output: okOut,
1222
1465
  async execute(args, exec) {
1223
1466
  const b = requireMachine(exec, 'rw_write_file')
1224
- const p = normalizeRemotePath(String(args.path || ''))
1467
+ const p = resolveRemoteArg(b, args.path)
1225
1468
  if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
1226
1469
  const content = String(args.content == null ? '' : args.content)
1227
1470
  const sftp = await b.pool.sftp()
@@ -1248,7 +1491,7 @@ export async function apply(ctx, config) {
1248
1491
  output: okOut,
1249
1492
  async execute(args, exec) {
1250
1493
  const b = requireMachine(exec, 'rw_edit')
1251
- const p = normalizeRemotePath(String(args.path || ''))
1494
+ const p = resolveRemoteArg(b, args.path)
1252
1495
  if (!p || p === '/') throw new Error('rw_edit: a file path is required')
1253
1496
  const oldS = String(args.old ?? '')
1254
1497
  const newS = String(args.new ?? '')
@@ -1294,7 +1537,7 @@ export async function apply(ctx, config) {
1294
1537
  output: okOut,
1295
1538
  async execute(args, exec) {
1296
1539
  const b = requireMachine(exec, 'rw_append')
1297
- const p = normalizeRemotePath(String(args.path || ''))
1540
+ const p = resolveRemoteArg(b, args.path)
1298
1541
  if (!p || p === '/') throw new Error('rw_append: a file path is required')
1299
1542
  const sftp = await b.pool.sftp()
1300
1543
  let existing = ''
@@ -1317,7 +1560,7 @@ export async function apply(ctx, config) {
1317
1560
  output: textOut,
1318
1561
  async execute(args, exec) {
1319
1562
  const b = requireMachine(exec, 'rw_mkdir')
1320
- const p = normalizeRemotePath(String(args.path || ''))
1563
+ const p = resolveRemoteArg(b, args.path)
1321
1564
  if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
1322
1565
  const sftp = await b.pool.sftp()
1323
1566
  await mkdirRemoteDirs(sftp, p)
@@ -1337,7 +1580,7 @@ export async function apply(ctx, config) {
1337
1580
  output: okOut,
1338
1581
  async execute(args, exec) {
1339
1582
  const b = requireMachine(exec, 'rw_remove')
1340
- const p = normalizeRemotePath(String(args.path || ''))
1583
+ const p = resolveRemoteArg(b, args.path)
1341
1584
  if (!p || p === '/') throw new Error('rw_remove: a path is required')
1342
1585
  const sftp = await b.pool.sftp()
1343
1586
  const st = await sftp.stat(p).catch(() => null)
@@ -1365,8 +1608,8 @@ export async function apply(ctx, config) {
1365
1608
  output: textOut,
1366
1609
  async execute(args, exec) {
1367
1610
  const b = requireMachine(exec, 'rw_move')
1368
- const p = normalizeRemotePath(String(args.path || ''))
1369
- const d = normalizeRemotePath(String(args.dest || ''))
1611
+ const p = resolveRemoteArg(b, args.path)
1612
+ const d = resolveRemoteArg(b, args.dest)
1370
1613
  if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
1371
1614
  const sftp = await b.pool.sftp()
1372
1615
  await mkdirRemoteDirs(sftp, remoteDirname(d))
@@ -1392,7 +1635,7 @@ export async function apply(ctx, config) {
1392
1635
  const cmd = String(args.command || '')
1393
1636
  if (!cmd) throw new Error('rw_exec: command is required')
1394
1637
  const ws = b.ws
1395
- const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1638
+ const cwd = args.cwd ? resolveRemoteArg(b, args.cwd) : (ws || '')
1396
1639
  let full = cmd
1397
1640
  if (cwd) {
1398
1641
  // Detect before choosing the cwd form: a per-machine pool starts
@@ -1442,7 +1685,7 @@ export async function apply(ctx, config) {
1442
1685
  const pattern = String(args.pattern || '')
1443
1686
  if (!pattern) throw new Error('rw_search: pattern is required')
1444
1687
  const ws = b.ws
1445
- const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1688
+ const dir = args.path ? resolveRemoteArg(b, args.path) : (ws || '')
1446
1689
  if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1447
1690
  let regex
1448
1691
  try {
@@ -1481,7 +1724,7 @@ export async function apply(ctx, config) {
1481
1724
  output: okOut,
1482
1725
  async execute(args, exec) {
1483
1726
  const b = requireMachine(exec, 'rw_download')
1484
- const p = normalizeRemotePath(String(args.path || ''))
1727
+ const p = resolveRemoteArg(b, args.path)
1485
1728
  if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1486
1729
  const sftp = await b.pool.sftp()
1487
1730
  let local
@@ -1516,7 +1759,7 @@ export async function apply(ctx, config) {
1516
1759
  output: okOut,
1517
1760
  async execute(args, exec) {
1518
1761
  const b = requireMachine(exec, 'rw_upload')
1519
- const rp = normalizeRemotePath(String(args.path || ''))
1762
+ const rp = resolveRemoteArg(b, args.path)
1520
1763
  const lp = String(args.localPath || '')
1521
1764
  if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1522
1765
  if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
@@ -1615,13 +1858,20 @@ export async function apply(ctx, config) {
1615
1858
  ? `${machine.username || 'user'}@${machine.host}`
1616
1859
  : `${config.username || 'user'}@${config.host}`
1617
1860
  const platform = machine ? poolForMachine(machine).platform : pool.platform
1861
+ const shown = toDisplayPath(remotePath, platform)
1618
1862
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
1619
1863
  let extra = ''
1620
1864
  if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
1865
+ // Issue #39: `@` mentions are resolved from the REMOTE tree by
1866
+ // lib/file-reference.js, so the model must know they are remote-relative
1867
+ // and that the built-in read tool sees only the local mirror.
1621
1868
  return (
1622
1869
  '## 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.' +
1870
+ `Current remote workspace: ${who}:${shown}\n` +
1871
+ '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' +
1872
+ `\`@path\` mentions in this conversation name files under THAT remote root (\`@src/main.c\` = ${shown}/src/main.c). ` +
1873
+ 'The rw_* tools take such paths as workspace-relative and resolve them against this root. ' +
1874
+ '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
1875
  extra
1626
1876
  )
1627
1877
  },
@@ -1962,7 +2212,7 @@ export async function apply(ctx, config) {
1962
2212
  const okDir = await isRemoteDir(p)
1963
2213
  if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1964
2214
  persistWorkspace(p)
1965
- const local = ensureMirror(p, config.host, config.username, config.port)
2215
+ const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
1966
2216
  startAutoPush(local)
1967
2217
  return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
1968
2218
  } catch (err) {
@@ -1982,7 +2232,7 @@ export async function apply(ctx, config) {
1982
2232
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
1983
2233
  const okDir = await isRemoteDir(p)
1984
2234
  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)
2235
+ const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
1986
2236
  persistWorkspace(p)
1987
2237
  startAutoPush(local)
1988
2238
  return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
@@ -2088,7 +2338,7 @@ export async function apply(ctx, config) {
2088
2338
  path: '/dsh-remote/machines',
2089
2339
  handler: async (req, res) => {
2090
2340
  if (req.method === 'GET') {
2091
- return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
2341
+ return sendJson(res, 200, { machines: machinesForClient(), currentId: store.currentId })
2092
2342
  }
2093
2343
  if (req.method === 'POST') {
2094
2344
  try {
@@ -2108,6 +2358,10 @@ export async function apply(ctx, config) {
2108
2358
  id,
2109
2359
  name: String(body.name || '').trim() || host,
2110
2360
  host,
2361
+ // Issue #38: an ALIAS machine stores only the Host line of
2362
+ // ~/.ssh/config — HostName/user/port/key are resolved live at
2363
+ // every connect instead of being copied into the registry.
2364
+ useSshConfig: !!body.useSshConfig,
2111
2365
  port: Number(body.port) || 22,
2112
2366
  username: String(body.username || '').trim() || 'root',
2113
2367
  password: '',
@@ -2162,7 +2416,7 @@ export async function apply(ctx, config) {
2162
2416
  return sendJson(res, 200, {
2163
2417
  ok: true,
2164
2418
  machine: sanitizeMachine(rec),
2165
- machines: machines.map(sanitizeMachine),
2419
+ machines: machinesForClient(),
2166
2420
  currentId: store.currentId,
2167
2421
  ...(warning ? { warning, warningDetail } : {}),
2168
2422
  })
@@ -2186,7 +2440,7 @@ export async function apply(ctx, config) {
2186
2440
  } else {
2187
2441
  saveMachines(machines, store.currentId)
2188
2442
  }
2189
- return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
2443
+ return sendJson(res, 200, { ok: true, machines: machinesForClient(), currentId: store.currentId })
2190
2444
  }
2191
2445
  return sendJson(res, 400, { ok: false, error: 'unknown action' })
2192
2446
  } catch (err) {
@@ -2209,21 +2463,33 @@ export async function apply(ctx, config) {
2209
2463
  let body = {}
2210
2464
  try {
2211
2465
  body = parseJsonObject(await readBody(req))
2466
+ // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
2467
+ // the form holds the alias name, not the host it points at.
2468
+ const aliasTarget = body.useSshConfig
2469
+ ? resolveMachineSshConfig({
2470
+ host: String(body.host || ''),
2471
+ port: Number(body.port) || 22,
2472
+ username: String(body.username || ''),
2473
+ privateKeyPath: String(body.privateKeyPath || ''),
2474
+ proxy: body.proxy,
2475
+ useSshConfig: true,
2476
+ }, { text: sshConfigText() })
2477
+ : null
2212
2478
  const probe = new SshPool({
2213
2479
  ...config,
2214
- host: String(body.host || config.host),
2215
- port: Number(body.port) || config.port,
2216
- username: String(body.username || config.username),
2480
+ host: aliasTarget ? aliasTarget.host : String(body.host || config.host),
2481
+ port: aliasTarget ? aliasTarget.port : (Number(body.port) || config.port),
2482
+ username: aliasTarget ? aliasTarget.username : String(body.username || config.username),
2217
2483
  password: String(body.password || ''),
2218
- privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
2484
+ privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
2219
2485
  passphrase: String(body.passphrase || ''),
2220
- proxy: body.proxy && body.proxy.host ? {
2486
+ proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
2221
2487
  host: String(body.proxy.host),
2222
2488
  port: Number(body.proxy.port) || 22,
2223
2489
  username: String(body.proxy.username || ''),
2224
2490
  password: String(body.proxy.password || ''),
2225
2491
  privateKeyPath: String(body.proxy.privateKeyPath || ''),
2226
- } : undefined,
2492
+ } : undefined),
2227
2493
  connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
2228
2494
  commandTimeoutMs: 10000,
2229
2495
  }, { knownHostsFile })
@@ -2232,13 +2498,27 @@ export async function apply(ctx, config) {
2232
2498
  await probe.exec('true', { timeoutMs: 10000 })
2233
2499
  probe.close()
2234
2500
  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)
2501
+ // Alias-aware: the probe connects to the RESOLVED host, while the
2502
+ // registry stores the alias, so matching must go through the identity.
2503
+ const mi = machines.findIndex((m) => identityMatches(m, { host: probe.config.host, port: probe.config.port, username: probe.config.username }))
2236
2504
  if (mi >= 0) {
2237
2505
  machines[mi].lastConnectedAt = new Date().toISOString()
2238
2506
  machines[mi].latencyMs = latencyMs
2239
2507
  saveMachines(machines, store.currentId)
2240
2508
  }
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 || '' })
2509
+ return sendJson(res, 200, {
2510
+ ok: true,
2511
+ host: probe.config.host,
2512
+ user: probe.config.username,
2513
+ latencyMs,
2514
+ lastConnectedAt: new Date().toISOString(),
2515
+ platform: probe.platform,
2516
+ shell: probe.shellMode,
2517
+ gitBash: probe.gitBashPath || '',
2518
+ // Issue #38: tell the settings page where an alias resolved to (and
2519
+ // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
2520
+ alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
2521
+ })
2242
2522
  } catch (err) {
2243
2523
  return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body.host, port: body.port }) })
2244
2524
  }