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/README.md +28 -1
- package/README.zh.md +26 -1
- package/lib/binding.js +9 -3
- package/lib/client.js +275 -56
- package/lib/file-reference.js +457 -0
- package/lib/index.js +456 -84
- package/lib/pool.js +49 -18
- package/lib/routes-fs.js +14 -2
- 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
372
|
-
port
|
|
373
|
-
username
|
|
374
|
-
password: (
|
|
375
|
-
privateKeyPath: (
|
|
376
|
-
passphrase: (
|
|
377
|
-
useAgent:
|
|
378
|
-
keyboardInteractive:
|
|
379
|
-
proxy: (
|
|
380
|
-
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(
|
|
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:
|
|
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:
|
|
947
|
-
password: rec.password, 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 ${
|
|
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 ${
|
|
958
|
-
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1369
|
-
const d =
|
|
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 ?
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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}:${
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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 远程暂不支持 ~ 解析,请直接输入绝对路径' })
|