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/README.md +28 -1
- package/README.zh.md +26 -1
- package/lib/binding.js +9 -3
- package/lib/client.js +83 -11
- package/lib/file-reference.js +457 -0
- package/lib/index.js +356 -76
- package/lib/sshconfig.js +415 -18
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 (
|
|
359
|
-
existing.config.password =
|
|
360
|
-
existing.config.privateKeyPath =
|
|
361
|
-
existing.config.passphrase =
|
|
362
|
-
existing.config.useAgent = !!
|
|
363
|
-
existing.config.keyboardInteractive = !!
|
|
364
|
-
existing.config.proxy =
|
|
365
|
-
existing.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
|
|
372
|
-
port
|
|
373
|
-
username
|
|
374
|
-
password: (
|
|
375
|
-
privateKeyPath: (
|
|
376
|
-
passphrase: (
|
|
377
|
-
useAgent:
|
|
378
|
-
keyboardInteractive:
|
|
379
|
-
proxy: (
|
|
380
|
-
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(
|
|
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:
|
|
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:
|
|
947
|
-
password: rec.password, 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 ${
|
|
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 ${
|
|
958
|
-
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1369
|
-
const d =
|
|
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 ?
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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}:${
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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
|
}
|