dsh-remote 0.8.73 → 0.8.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -56,6 +56,7 @@ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, ap
56
56
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
57
57
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup, resolveProbeCredentials } from './registry.js'
58
58
  import { registerHttpTransports } from './http-transport.js'
59
+ import { createHostApi } from './host-api.js'
59
60
  import { SshPool, ActivePool, createPool } from './pool.js'
60
61
  import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
61
62
  import { createFsRoutes } from './routes-fs.js'
@@ -283,6 +284,22 @@ export const Config = z.object({
283
284
  * plaintext is the one outcome you did not ask for.
284
285
  * `plain`: the pre-0.8.54 fallback (write it and warn). */
285
286
  credentialFallback: z.string().default('refuse'),
287
+ /** TUI integration layer (docs/tui.md): register the `/dsh-remote` scene,
288
+ * the workspace provider and the `/dsh-remote` slash command when a
289
+ * dsh-tui profile is present. `false` disables the whole layer — on a Web or
290
+ * Desktop profile it already degrades to a no-op by soft-probing its seams. */
291
+ tui: z.boolean().default(true),
292
+ /** List each machine's recent workspaces in the TUI workspace picker. */
293
+ tuiProviderListRecent: z.boolean().default(true),
294
+ /** How many recent workspaces per machine the picker lists (1–20). */
295
+ tuiProviderRecentLimit: z.number().step(1).min(1).max(20).default(5),
296
+ /** Switch the session to a remote workspace right after adopting it in the
297
+ * TUI scene. */
298
+ tuiAutoSwitchWorkspace: z.boolean().default(true),
299
+ /** Language for the TUI layer's own copy: `''` = auto (zh, or `en` when
300
+ * `DSH_TUI_LANG=en`), `zh` / `en` pins it. The TUI's own screens keep their
301
+ * own language; this only affects `/dsh-remote` and its toasts. */
302
+ tuiLang: z.string().default(''),
286
303
  })
287
304
 
288
305
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -1447,6 +1464,63 @@ export async function apply(ctx, config) {
1447
1464
  * another machine. Tool calls must never route through this — they resolve
1448
1465
  * their own session through `bindingFor()`, which is authoritative. */
1449
1466
  const sessionsSvc = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null)
1467
+
1468
+ /** Whether one session is still open somewhere.
1469
+ *
1470
+ * In-process: the `sessions` service knows every live (including parked)
1471
+ * session of THIS TUI, so the cleanup page can refuse to delete its own.
1472
+ * Cross-process: DSH holds a kernel `flock` on `<session dir>/session.lock`
1473
+ * for the life of the write handle, so a successful `lsof` names the holder.
1474
+ * `deep: false` skips the spawn — list views call it for every row.
1475
+ */
1476
+ const isSessionLive = async (sessionId, dir, opts = {}) => {
1477
+ try {
1478
+ const live = sessionsSvc()
1479
+ if (live && typeof live.get === 'function' && live.get(sessionId)) return true
1480
+ } catch { /* no sessions service (minimal embedding) */ }
1481
+ if (opts && opts.deep === true && process.platform !== 'win32') {
1482
+ const lock = path.join(String(dir || ''), 'session.lock')
1483
+ if (!existsSync(lock)) return false
1484
+ return await new Promise((resolve) => {
1485
+ try {
1486
+ execFile('lsof', ['-t', '--', lock], { timeout: 3000 }, (error, stdout) => {
1487
+ resolve(String(stdout || '').trim().length > 0)
1488
+ })
1489
+ } catch {
1490
+ resolve(false)
1491
+ }
1492
+ })
1493
+ }
1494
+ return false
1495
+ }
1496
+
1497
+ /** Drop one remote path from every machine's recent list and current workspace.
1498
+ *
1499
+ * Deleting a workspace must also forget its remote path, otherwise the TUI
1500
+ * provider re-creates the mirror from `recentWorkspaces` on the next start.
1501
+ *
1502
+ * @param {string} remotePath - remote workspace path to forget.
1503
+ * @returns {{recents: number, current: number}} how many fields changed.
1504
+ */
1505
+ const forgetRemotePath = (remotePath) => {
1506
+ const target = String(remotePath || '').trim()
1507
+ let recents = 0
1508
+ let current = 0
1509
+ if (!target) return { recents, current }
1510
+ for (const machine of machines) {
1511
+ if (Array.isArray(machine.recentWorkspaces)) {
1512
+ const before = machine.recentWorkspaces.length
1513
+ machine.recentWorkspaces = machine.recentWorkspaces.filter((entry) => String(entry) !== target)
1514
+ recents += before - machine.recentWorkspaces.length
1515
+ }
1516
+ if (String(machine.workspace || '') === target) {
1517
+ machine.workspace = ''
1518
+ current += 1
1519
+ }
1520
+ }
1521
+ if (recents > 0 || current > 0) saveMachines(machines, store.currentId, false)
1522
+ return { recents, current }
1523
+ }
1450
1524
  const sessionCwd = (sessionId) => {
1451
1525
  const sessions = sessionsSvc()
1452
1526
  if (sessionId) return lookupSessionCwd(sessionId, { sessions, dshHome: dshBase() })
@@ -3305,6 +3379,533 @@ export async function apply(ctx, config) {
3305
3379
  }
3306
3380
  }
3307
3381
 
3382
+ /** Add/update one machine. Shared by POST /dsh-remote/machines and the
3383
+ * in-process API (`upsertMachine`). Returns the shape the route sends minus
3384
+ * `ok`; throws HOST_REQUIRED (httpStatus 400) for a missing host, which the
3385
+ * route maps back to its historical 400 response. */
3386
+ const upsertMachineRecord = async (body) => {
3387
+ let warning = ''
3388
+ let warningDetail = ''
3389
+ const host = String(body.host || '').trim()
3390
+ if (!host) throw codedError('HOST_REQUIRED', 'host required', { httpStatus: 400 })
3391
+ const id = body.id || machineId()
3392
+ const i = machineIndex(id)
3393
+ const prev = i >= 0 ? machines[i] : null
3394
+ const credBackend = body.credentialBackend === 'plain' ? 'plain'
3395
+ : (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
3396
+ const rec = {
3397
+ id,
3398
+ name: String(body.name || '').trim() || host,
3399
+ host,
3400
+ // Issue #38: an ALIAS machine stores only the Host line of
3401
+ // ~/.ssh/config — HostName/user/port/key are resolved live at
3402
+ // every connect instead of being copied into the registry.
3403
+ useSshConfig: !!body.useSshConfig,
3404
+ port: Number(body.port) || 22,
3405
+ username: String(body.username || '').trim() || 'root',
3406
+ password: '',
3407
+ // An OMITTED `privateKeyPath` keeps the stored one; an explicit
3408
+ // empty string clears it (that is what the form sends when the user
3409
+ // empties the box). A partial update from an API/agent caller used
3410
+ // to silently wipe the key path, leaving a machine that can only
3411
+ // fail to authenticate — the same data-loss class as the passphrase.
3412
+ privateKeyPath: body.privateKeyPath === undefined
3413
+ ? String((prev && prev.privateKeyPath) || '')
3414
+ : String(body.privateKeyPath || '').trim(),
3415
+ // An EMPTY passphrase from the form means "unchanged", not
3416
+ // "delete it": the value is never echoed back to the browser, so
3417
+ // an edit that touched any other field used to silently wipe a
3418
+ // saved private-key passphrase (and the next connect then failed
3419
+ // with "no passphrase given" for a key that had been working).
3420
+ passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
3421
+ workspace: String(body.workspace || '').trim(),
3422
+ hostKeyMode: body.hostKeyMode || '',
3423
+ useAgent: !!body.useAgent,
3424
+ keyboardInteractive: !!body.keyboardInteractive,
3425
+ // Issue #51: the transport backend is per machine. `native` is
3426
+ // the default, so an unknown value is not persisted as-is.
3427
+ backend: body.backend === 'openssh' ? 'openssh' : 'native',
3428
+ sshBinary: String(body.sshBinary || '').trim(),
3429
+ // Issue #54: the per-machine `dsh web` launch command was only
3430
+ // ever writable by the deploy flow or by hand-editing
3431
+ // machines.json — the README told users to set it and the
3432
+ // settings UI offered nowhere to type it. It is an ordinary
3433
+ // machine field now; empty means "fall back to the global
3434
+ // webAttachCommand".
3435
+ webAttachCommand: body.webAttachCommand === undefined
3436
+ ? String((prev && prev.webAttachCommand) || '')
3437
+ : String(body.webAttachCommand || '').trim(),
3438
+ // Group labels (host organisation). Merge-only: a request that
3439
+ // omits them keeps the stored values instead of wiping them.
3440
+ ...normalizeMachineGroup({
3441
+ environment: body.environment === undefined ? (prev && prev.environment) : body.environment,
3442
+ location: body.location === undefined ? (prev && prev.location) : body.location,
3443
+ description: body.description === undefined ? (prev && prev.description) : body.description,
3444
+ tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
3445
+ }),
3446
+ // A jump host the form did not mention is KEPT (omitted ≠
3447
+ // removed), and a jump PASSWORD the form did not re-supply is
3448
+ // kept too: the settings page never receives it back, so an edit
3449
+ // used to strip the bastion credential and the machine could
3450
+ // only fail at connect time.
3451
+ proxy: body.proxy === undefined
3452
+ ? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
3453
+ : (body.proxy && body.proxy.host ? {
3454
+ host: String(body.proxy.host),
3455
+ port: Number(body.proxy.port) || 22,
3456
+ username: String(body.proxy.username || '').trim(),
3457
+ password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
3458
+ privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
3459
+ } : undefined),
3460
+ credentialBackend: credBackend,
3461
+ recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
3462
+ lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
3463
+ latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
3464
+ }
3465
+ if (body.password) {
3466
+ // Issue #30: `saveSecret()` is best-effort — the old code threw
3467
+ // its result away and kept `password: ''`, so on Windows (DPAPI
3468
+ // missing the System.Security assembly) the machine was saved
3469
+ // WITHOUT any credential and every later connect failed with no
3470
+ // visible reason. Now a failed OS-store write falls back to
3471
+ // plaintext and is reported to the UI.
3472
+ const persisted = await persistPassword({
3473
+ backend: credBackend,
3474
+ machineId: id,
3475
+ password: body.password,
3476
+ secretsDir: secretsDir(),
3477
+ onStoreFailure: config.credentialFallback === 'plain' ? 'plain' : 'refuse',
3478
+ })
3479
+ rec.credentialBackend = persisted.credentialBackend
3480
+ rec.password = persisted.password
3481
+ // Record WHERE the secret ended up, so the settings page can say
3482
+ // "已保存" for a keychain machine too (its record carries an
3483
+ // empty `password` by design).
3484
+ rec.secretStored = persisted.stored === 'secret'
3485
+ if (persisted.warning) {
3486
+ warning = persisted.warning
3487
+ warningDetail = persisted.error
3488
+ }
3489
+ } else if (prev) {
3490
+ // No password typed: keep whatever the machine already had —
3491
+ // the plaintext copy AND the keychain flag. Turning the
3492
+ // "加密保存密码" checkbox OFF is handled next: it must actually
3493
+ // MOVE the secret, or the checkbox is a lie.
3494
+ rec.password = prev.password || ''
3495
+ rec.secretStored = prev.secretStored
3496
+ const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
3497
+ if (credBackend === 'plain' && prevKeychain) {
3498
+ try {
3499
+ const existing = await machinePassword(prev)
3500
+ if (existing) {
3501
+ rec.password = existing
3502
+ rec.secretStored = false
3503
+ rec.credentialBackend = 'plain'
3504
+ await deleteSecret(id, secretsDir()).catch(() => {})
3505
+ }
3506
+ } catch { /* keep the keychain copy; the next connect still works */ }
3507
+ }
3508
+ }
3509
+ if (i >= 0) machines[i] = rec; else machines.push(rec)
3510
+ // Issue #13: saving a machine must NOT make it the active
3511
+ // remote context. `currentId` stays untouched on add/update —
3512
+ // only an explicit "设为当前" (or rw_connect) activates one.
3513
+ // keepCurrentKey:false leaves the stored `currentId` byte-for-byte
3514
+ // alone, so a fresh registry stays "no choice yet" (not an
3515
+ // explicit none) and a previously-cleared registry stays cleared.
3516
+ saveMachines(machines, store.currentId, false)
3517
+ // Issue #67: an EDITED connection identity (host/user/port) must
3518
+ // carry the machine's existing workspaces over — without this the
3519
+ // mirrors stayed bound to the old address and the user had to
3520
+ // create (and re-sync) a brand-new workspace for the new IP.
3521
+ let reboundMirrors = 0
3522
+ if (i >= 0 && prev) {
3523
+ try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
3524
+ }
3525
+ // Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
3526
+ // Saving a literal machine upserts a marker-tagged Host block,
3527
+ // so the file and the registry can never drift; the record's
3528
+ // copy remains only as a fallback for a missing/renamed block.
3529
+ // A Host block the USER wrote for the same name is never
3530
+ // touched — the conflict is reported instead. Failures here are
3531
+ // best-effort: the registry copy is already saved.
3532
+ let sshConfigWarning = ''
3533
+ if (!rec.useSshConfig) {
3534
+ try {
3535
+ const raw = readRawSshConfig(sshConfigPath())
3536
+ let next = upsertManagedHostBlock(raw, {
3537
+ alias: rec.host,
3538
+ hostName: rec.host,
3539
+ user: rec.username,
3540
+ port: rec.port,
3541
+ identityFile: rec.privateKeyPath,
3542
+ })
3543
+ // A RENAMED machine leaves the old managed block behind, and
3544
+ // that block is the plugin's own artifact of the edit —
3545
+ // retire it. (Deleting a machine removes nothing: the Host
3546
+ // block is the user's ssh access, not plugin state.)
3547
+ if (prev && prev.host && prev.host !== rec.host && !prev.useSshConfig) {
3548
+ const rm = removeManagedHostBlock(next.text, prev.host)
3549
+ next = { status: rm.status === 'removed' && next.status === 'unchanged' ? 'updated' : next.status, text: rm.text }
3550
+ }
3551
+ if (next.status === 'conflict') {
3552
+ sshConfigWarning = `~/.ssh/config already names "${rec.host}" in a Host block that is not managed by dsh-remote — it was left untouched, and it now wins over the saved copy.`
3553
+ } else if (next.status === 'appended' || next.status === 'updated') {
3554
+ writeSshConfig(next.text, sshConfigPath())
3555
+ sshConfigCache = { text: '', at: 0 }
3556
+ }
3557
+ } catch (sshErr) {
3558
+ sshConfigWarning = `could not update ~/.ssh/config (${String((sshErr && sshErr.message) || sshErr)}); the registry copy was saved.`
3559
+ }
3560
+ }
3561
+ return {
3562
+ machine: sanitizeMachine(rec),
3563
+ machines: machinesForClient(),
3564
+ currentId: store.currentId,
3565
+ ...(reboundMirrors ? { reboundMirrors } : {}),
3566
+ ...(warning ? { warning, warningDetail } : {}),
3567
+ ...(sshConfigWarning ? { sshConfigWarning } : {}),
3568
+ }
3569
+ }
3570
+
3571
+ /** Delete one machine (never auto-promotes a sibling). Shared by the
3572
+ * delete route and the in-process API; throws MACHINE_NOT_FOUND.
3573
+ * @param {string} id - machine id. */
3574
+ const removeMachineRecord = async (id) => {
3575
+ const i = machineIndex(String(id || ''))
3576
+ if (i < 0) throw codedError('MACHINE_NOT_FOUND', 'machine not found', { httpStatus: 404 })
3577
+ const m = machines[i]
3578
+ if (m.credentialBackend && m.credentialBackend !== 'plain') {
3579
+ await deleteSecret(m.id, secretsDir()).catch(() => {})
3580
+ }
3581
+ machines.splice(i, 1)
3582
+ const wasCurrent = store.currentId === m.id
3583
+ if (wasCurrent) {
3584
+ // Issue #13: deleting the current machine leaves NO active
3585
+ // remote context (never auto-promote a sibling — that would
3586
+ // make an unrelated machine leak into the session again).
3587
+ store.currentId = null
3588
+ saveMachines(machines, null)
3589
+ await clearActiveMachine()
3590
+ } else {
3591
+ saveMachines(machines, store.currentId)
3592
+ }
3593
+ return { machines: machinesForClient(), currentId: store.currentId }
3594
+ }
3595
+
3596
+ /** Unpersisted connectivity probe: dial once with the credentials a REAL
3597
+ * connect would use (form values override, blanks fall back to the stored
3598
+ * record), report latency/platform/shell. Shared by POST
3599
+ * /dsh-remote/test-connect and the in-process API; throws on failure. */
3600
+ const probeMachineRecord = async (body) => {
3601
+ // ── The probe must dial with the credentials a REAL connect would use.
3602
+ //
3603
+ // Stored secrets never travel back to the browser (see
3604
+ // sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
3605
+ // EMPTY password while the machine itself connected fine through the
3606
+ // pool's keychain/plaintext resolver. Every such probe died with
3607
+ // `no credentials: set a password, a privateKeyPath, or enable
3608
+ // useAgent to connect` — the error said "you configured nothing" about
3609
+ // a machine that was visibly working.
3610
+ //
3611
+ // So: resolve the saved record (by id when the form is editing one,
3612
+ // else by identity) and merge — form values override, blanks fall back
3613
+ // to the stored record. The resolved password is then ALSO exposed as a
3614
+ // lazy resolver so an OS-keychain machine behaves exactly like a real
3615
+ // connect (read on demand, never logged).
3616
+ const savedRec = (() => {
3617
+ const byId = body.id ? machines[machineIndex(String(body.id))] : null
3618
+ if (byId) return byId
3619
+ // No id (a fresh form that happens to match a saved machine): match on
3620
+ // the identity the form is about to dial.
3621
+ const hit = machineRecordFor({
3622
+ host: String(body.host || ''),
3623
+ port: Number(body.port) || 22,
3624
+ username: String(body.username || ''),
3625
+ })
3626
+ return hit || null
3627
+ })()
3628
+ const savedPassword = savedRec ? await machinePassword(savedRec) : ''
3629
+ const cred = resolveProbeCredentials(body, savedRec, savedPassword)
3630
+ // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
3631
+ // the form holds the alias name, not the host it points at.
3632
+ const probeBackend = String(cred.backend || config.backend || 'native')
3633
+ const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
3634
+ const aliasTarget = body.useSshConfig
3635
+ ? resolveMachineSshConfig({
3636
+ host: String(body.host || ''),
3637
+ port: Number(body.port) || 22,
3638
+ username: String(body.username || ''),
3639
+ privateKeyPath: String(cred.privateKeyPath || ''),
3640
+ proxy: cred.proxy,
3641
+ useSshConfig: true,
3642
+ }, { text: sshConfigText() })
3643
+ : null
3644
+ // Issue #50: a LITERAL host named by a Host block resolves through
3645
+ // the file as well — the probe must dial what a real connect would.
3646
+ const literalTarget = !body.useSshConfig
3647
+ ? resolveLiteralSshConfig({ host: String(body.host || '') }, { text: sshConfigText() })
3648
+ : null
3649
+ const probeTarget = aliasTarget || literalTarget
3650
+ // Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
3651
+ // VERBATIM (see connectTargetFor) — resolving it here would stop the
3652
+ // `Host` block from matching, so the ~/.ssh/config settings the probe
3653
+ // is supposed to validate would be silently skipped.
3654
+ const probe = createPool({
3655
+ ...config,
3656
+ backend: probeBackend,
3657
+ host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
3658
+ port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
3659
+ username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
3660
+ password: cred.password,
3661
+ privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
3662
+ passphrase: cred.passphrase,
3663
+ // Auth METHOD flags come from the merge too: a machine that
3664
+ // authenticates through an agent or keyboard-interactive used to be
3665
+ // probed with `useAgent: false`, i.e. with no auth method at all.
3666
+ useAgent: cred.useAgent,
3667
+ agentSocket: (probeTarget && !probeIsOpenssh && probeTarget.agentSocket) || '',
3668
+ identitiesOnly: !!(probeTarget && !probeIsOpenssh && probeTarget.identitiesOnly),
3669
+ keyboardInteractive: cred.keyboardInteractive,
3670
+ ...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
3671
+ ...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
3672
+ proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
3673
+ connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
3674
+ commandTimeoutMs: 10000,
3675
+ }, { knownHostsFile })
3676
+ // Same lazy resolution the active pool uses: if the merged password was
3677
+ // empty because the secret lives in the OS keychain, the pool asks again
3678
+ // at dial time instead of failing with "no credentials".
3679
+ probe.passwordResolver = async () => machinePassword(savedRec || {})
3680
+ const started = Date.now()
3681
+ await probe.connect()
3682
+ await probe.exec('true', { timeoutMs: 10000 })
3683
+ probe.close()
3684
+ const latencyMs = Date.now() - started
3685
+ // Alias-aware: the probe connects to the RESOLVED host, while the
3686
+ // registry stores the alias, so matching must go through the identity.
3687
+ const mi = machines.findIndex((m) => identityMatches(m, { host: probe.config.host, port: probe.config.port, username: probe.config.username }))
3688
+ if (mi >= 0) {
3689
+ machines[mi].lastConnectedAt = new Date().toISOString()
3690
+ machines[mi].latencyMs = latencyMs
3691
+ saveMachines(machines, store.currentId)
3692
+ }
3693
+ return {
3694
+ ok: true,
3695
+ host: probe.config.host,
3696
+ user: probe.config.username,
3697
+ latencyMs,
3698
+ lastConnectedAt: new Date().toISOString(),
3699
+ platform: probe.platform,
3700
+ shell: probe.shellMode,
3701
+ gitBash: probe.gitBashPath || '',
3702
+ // Tell the UI whether the probe leaned on the SAVED credential: with
3703
+ // an empty password box, "测试成功" otherwise looks impossible.
3704
+ usedSavedCredential: cred.usedSavedCredential,
3705
+ // Issue #38: tell the settings page where an alias resolved to (and
3706
+ // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
3707
+ alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
3708
+ }
3709
+ }
3710
+ // ── in-process host API (ctx.dshRemote) building blocks ───────────────────
3711
+ // lib/host-api.js owns the contract shaping (pure data, error codes,
3712
+ // subscribe); the functions below are the only bridge to this closure's
3713
+ // state. ONE implementation is the point: a second reader of machines.json
3714
+ // would see a stale snapshot, a second SSH pool would double every
3715
+ // connection, and two writers of saveMachines() race its whole-file rewrite.
3716
+ /** Log through the optional harness logger; never lets logging fail a call. */
3717
+ const hostLog = (level, message) => {
3718
+ try {
3719
+ const logger = ctx && typeof ctx.get === 'function' ? ctx.get('logger') : undefined
3720
+ if (logger && typeof logger[level] === 'function') logger[level](message)
3721
+ } catch { /* logging is best-effort */ }
3722
+ }
3723
+
3724
+ /** One coded error for the in-process API. Host-side callers get a stable
3725
+ * `.code` (contract vocabulary) plus `httpStatus` where the HTTP route has
3726
+ * a historical status to preserve. */
3727
+ const codedError = (code, message, extra = {}) => {
3728
+ const e = new Error(message)
3729
+ e.code = code
3730
+ if (extra && typeof extra === 'object') Object.assign(e, extra)
3731
+ return e
3732
+ }
3733
+
3734
+ /** Resolve a machine for the API: by id when given, else the ACTIVE machine
3735
+ * (explicit current > ephemeral > config default, exactly like status()).
3736
+ * Returns the RESOLVED connect identity — ssh-config aliases expanded, and
3737
+ * folded back to the alias for the system-OpenSSH backend (which dials the
3738
+ * alias itself), i.e. what applyActiveMachine writes into the shared config. */
3739
+ const resolveMachineForApi = (machineId) => {
3740
+ const id = String(machineId || '').trim()
3741
+ if (id) {
3742
+ const i = machineIndex(id)
3743
+ if (i < 0) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${id}`)
3744
+ const rec = machines[i]
3745
+ const eff = withSshConfig(rec)
3746
+ const t = connectTargetFor(eff)
3747
+ return {
3748
+ record: rec,
3749
+ machineId: rec.id,
3750
+ name: String(rec.name || rec.host || ''),
3751
+ host: String(t.aliasNative ? t.host : (eff.host || '')),
3752
+ port: t.aliasNative ? 22 : (Number(eff.port) || 22),
3753
+ username: t.aliasNative ? '' : String(eff.username || ''),
3754
+ alias: aliasOf(eff),
3755
+ }
3756
+ }
3757
+ const rec = store.currentId && machineIndex(store.currentId) >= 0 ? machines[machineIndex(store.currentId)] : null
3758
+ const m = activeMachine()
3759
+ if (!m || !m.host) {
3760
+ throw codedError('NO_ACTIVE_MACHINE', '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。(保存的机器只是备用连接,不会自动成为当前机器。)')
3761
+ }
3762
+ const t = connectTargetFor(m)
3763
+ return {
3764
+ record: rec,
3765
+ machineId: rec ? rec.id : '',
3766
+ name: String((rec && rec.name) || m.name || m.host || ''),
3767
+ host: String(t.aliasNative ? t.host : (m.host || '')),
3768
+ port: t.aliasNative ? 22 : (Number(m.port) || 22),
3769
+ username: t.aliasNative ? '' : String(m.username || ''),
3770
+ alias: aliasOf(m),
3771
+ }
3772
+ }
3773
+
3774
+ /** Binding for the API's listDir. Addressing priority (contract):
3775
+ * machineId (that machine's own pool, no side effects) > the active machine
3776
+ * (the same fallback the /ls route gets from resolveRequestBinding). */
3777
+ const listDirBindingForApi = async (opts = {}) => {
3778
+ const id = String((opts && opts.machineId) || '').trim()
3779
+ if (id) {
3780
+ const m = resolveMachineForApi(id)
3781
+ // The per-machine pool keeps browsing off the shared active pool: a
3782
+ // directory listing must never switch the user's current machine.
3783
+ const targetPool = poolForMachine({ host: m.host, port: m.port, username: m.username, alias: m.alias })
3784
+ const tgt = (targetPool && targetPool.config) || {}
3785
+ return {
3786
+ pool: targetPool,
3787
+ ws: '',
3788
+ bound: false,
3789
+ machine: {
3790
+ id: m.machineId,
3791
+ name: m.name,
3792
+ host: String(tgt.host || m.host),
3793
+ port: Number(tgt.port) || m.port,
3794
+ username: String(tgt.username != null ? tgt.username : m.username),
3795
+ },
3796
+ }
3797
+ }
3798
+ // No hint: the /ls route's fallback. resolveRequestBinding throws a 409 with
3799
+ // the actionable Chinese message when no machine is active; host-api maps
3800
+ // that to NO_ACTIVE_MACHINE.
3801
+ const b = await resolveRequestBinding({ url: '/' }, {})
3802
+ let name = ''
3803
+ try {
3804
+ const am = activeMachine()
3805
+ name = String((am && (am.name || am.host)) || b.host || '')
3806
+ } catch { /* display only */ }
3807
+ const tgt = (b.pool && b.pool.config) || {}
3808
+ return {
3809
+ pool: b.pool,
3810
+ ws: b.ws,
3811
+ bound: !!b.bound,
3812
+ machine: {
3813
+ id: store.currentId || '',
3814
+ name,
3815
+ host: String(tgt.host || b.host || ''),
3816
+ port: Number(tgt.port) || Number(b.port) || 22,
3817
+ username: String(tgt.username != null ? tgt.username : (b.username || '')),
3818
+ },
3819
+ }
3820
+ }
3821
+
3822
+ /** Every local mirror + the registry machine its meta resolves to. Pure
3823
+ * filesystem read (mirrorRegistry), so it never dials. */
3824
+ const listRemoteProjectsForApi = () => mirrorRegistry().map((p) => {
3825
+ const rec = machineRecordFor({ host: p.host, port: p.port, username: p.username, alias: p.alias })
3826
+ return { ...p, machineId: rec && rec.id ? String(rec.id) : null }
3827
+ })
3828
+
3829
+ /** SSH `stat` validation used by pickRemoteWorkspace. With a machineId it
3830
+ * runs on that machine's OWN pool (validation must not switch the active
3831
+ * machine); without one it behaves like the /mirror route (active pool). */
3832
+ const checkRemoteDirForApi = async (remotePath, machineId) => {
3833
+ const id = String(machineId || '').trim()
3834
+ if (id) {
3835
+ const m = resolveMachineForApi(id)
3836
+ return isRemoteDir(remotePath, poolForMachine({ host: m.host, port: m.port, username: m.username, alias: m.alias }))
3837
+ }
3838
+ if (!String(config.host || '').trim()) {
3839
+ const m = activeMachine()
3840
+ if (m && m.host) {
3841
+ try { await applyActiveMachine() } catch { /* reported by the probe below */ }
3842
+ }
3843
+ }
3844
+ if (!String(config.host || '').trim()) {
3845
+ throw codedError('NO_ACTIVE_MACHINE', '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。(保存的机器只是备用连接,不会自动成为当前机器。)')
3846
+ }
3847
+ return isRemoteDir(remotePath)
3848
+ }
3849
+
3850
+ /** Persist "this machine is current" for the API (null = explicit none). */
3851
+ const setCurrentForApi = async (id) => {
3852
+ const clean = id == null ? '' : String(id).trim()
3853
+ if (!clean) {
3854
+ await setCurrent('')
3855
+ return
3856
+ }
3857
+ if (machineIndex(clean) < 0) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${clean}`)
3858
+ if (!(await setCurrent(clean))) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${clean}`)
3859
+ }
3860
+
3861
+ /** Connect (activate) a machine and probe a real SSH connection.
3862
+ * `persist:false` keeps the registry's currentId untouched (rw_connect
3863
+ * save:false semantics): the target is applied as an EPHEMERAL machine. */
3864
+ const connectMachineForApi = async (opts = {}) => {
3865
+ const id = String((opts && opts.machineId) || '').trim()
3866
+ const persist = !(opts && opts.persist === false)
3867
+ let rec = null
3868
+ if (id) {
3869
+ const i = machineIndex(id)
3870
+ if (i < 0) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${id}`)
3871
+ rec = machines[i]
3872
+ } else if (store.currentId && machineIndex(store.currentId) >= 0) {
3873
+ rec = machines[machineIndex(store.currentId)]
3874
+ }
3875
+ if (!rec) {
3876
+ const m = activeMachine()
3877
+ if (!m || !m.host) {
3878
+ throw codedError('NO_ACTIVE_MACHINE', '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。(保存的机器只是备用连接,不会自动成为当前机器。)')
3879
+ }
3880
+ }
3881
+ if (persist) {
3882
+ ephemeral = null
3883
+ if (rec) await setCurrent(rec.id)
3884
+ else await applyActiveMachine()
3885
+ } else {
3886
+ ephemeral = rec ? { ...rec } : { ...activeMachine() }
3887
+ await applyActiveMachine()
3888
+ }
3889
+ // "Connect" means dial NOW: drop a cached (possibly half-open) client so the
3890
+ // probe below really reconnects — the same reasoning as the /connect route.
3891
+ pool.invalidate()
3892
+ const connectHost = config.host
3893
+ const connectPort = Number(config.port) || 22
3894
+ try {
3895
+ await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs || 20000, 8000) })
3896
+ } catch (err) {
3897
+ if (err && typeof err === 'object') {
3898
+ err.host = connectHost
3899
+ err.port = connectPort
3900
+ }
3901
+ audit('connect', `connect ${config.username}@${connectHost}:${connectPort}`, (err && typeof err.code === 'number') ? err.code : -1)
3902
+ throw err
3903
+ }
3904
+ audit('connect', `connect ${config.username}@${connectHost}:${connectPort}`, 0)
3905
+ return status()
3906
+ }
3907
+
3908
+
3308
3909
  // ── route helpers ─────────────────────────────────────────────────────────
3309
3910
  const routes = [
3310
3911
  {
@@ -3668,190 +4269,12 @@ export async function apply(ctx, config) {
3668
4269
  try {
3669
4270
  const body = JSON.parse((await readBody(req)) || '{}')
3670
4271
  const action = body.action || 'add'
3671
- let warning = ''
3672
- let warningDetail = ''
3673
4272
  if (action === 'add' || action === 'update') {
3674
- const host = String(body.host || '').trim()
3675
- if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
3676
- const id = body.id || machineId()
3677
- const i = machineIndex(id)
3678
- const prev = i >= 0 ? machines[i] : null
3679
- const credBackend = body.credentialBackend === 'plain' ? 'plain'
3680
- : (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
3681
- const rec = {
3682
- id,
3683
- name: String(body.name || '').trim() || host,
3684
- host,
3685
- // Issue #38: an ALIAS machine stores only the Host line of
3686
- // ~/.ssh/config — HostName/user/port/key are resolved live at
3687
- // every connect instead of being copied into the registry.
3688
- useSshConfig: !!body.useSshConfig,
3689
- port: Number(body.port) || 22,
3690
- username: String(body.username || '').trim() || 'root',
3691
- password: '',
3692
- // An OMITTED `privateKeyPath` keeps the stored one; an explicit
3693
- // empty string clears it (that is what the form sends when the user
3694
- // empties the box). A partial update from an API/agent caller used
3695
- // to silently wipe the key path, leaving a machine that can only
3696
- // fail to authenticate — the same data-loss class as the passphrase.
3697
- privateKeyPath: body.privateKeyPath === undefined
3698
- ? String((prev && prev.privateKeyPath) || '')
3699
- : String(body.privateKeyPath || '').trim(),
3700
- // An EMPTY passphrase from the form means "unchanged", not
3701
- // "delete it": the value is never echoed back to the browser, so
3702
- // an edit that touched any other field used to silently wipe a
3703
- // saved private-key passphrase (and the next connect then failed
3704
- // with "no passphrase given" for a key that had been working).
3705
- passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
3706
- workspace: String(body.workspace || '').trim(),
3707
- hostKeyMode: body.hostKeyMode || '',
3708
- useAgent: !!body.useAgent,
3709
- keyboardInteractive: !!body.keyboardInteractive,
3710
- // Issue #51: the transport backend is per machine. `native` is
3711
- // the default, so an unknown value is not persisted as-is.
3712
- backend: body.backend === 'openssh' ? 'openssh' : 'native',
3713
- sshBinary: String(body.sshBinary || '').trim(),
3714
- // Issue #54: the per-machine `dsh web` launch command was only
3715
- // ever writable by the deploy flow or by hand-editing
3716
- // machines.json — the README told users to set it and the
3717
- // settings UI offered nowhere to type it. It is an ordinary
3718
- // machine field now; empty means "fall back to the global
3719
- // webAttachCommand".
3720
- webAttachCommand: body.webAttachCommand === undefined
3721
- ? String((prev && prev.webAttachCommand) || '')
3722
- : String(body.webAttachCommand || '').trim(),
3723
- // Group labels (host organisation). Merge-only: a request that
3724
- // omits them keeps the stored values instead of wiping them.
3725
- ...normalizeMachineGroup({
3726
- environment: body.environment === undefined ? (prev && prev.environment) : body.environment,
3727
- location: body.location === undefined ? (prev && prev.location) : body.location,
3728
- description: body.description === undefined ? (prev && prev.description) : body.description,
3729
- tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
3730
- }),
3731
- // A jump host the form did not mention is KEPT (omitted ≠
3732
- // removed), and a jump PASSWORD the form did not re-supply is
3733
- // kept too: the settings page never receives it back, so an edit
3734
- // used to strip the bastion credential and the machine could
3735
- // only fail at connect time.
3736
- proxy: body.proxy === undefined
3737
- ? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
3738
- : (body.proxy && body.proxy.host ? {
3739
- host: String(body.proxy.host),
3740
- port: Number(body.proxy.port) || 22,
3741
- username: String(body.proxy.username || '').trim(),
3742
- password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
3743
- privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
3744
- } : undefined),
3745
- credentialBackend: credBackend,
3746
- recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
3747
- lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
3748
- latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
3749
- }
3750
- if (body.password) {
3751
- // Issue #30: `saveSecret()` is best-effort — the old code threw
3752
- // its result away and kept `password: ''`, so on Windows (DPAPI
3753
- // missing the System.Security assembly) the machine was saved
3754
- // WITHOUT any credential and every later connect failed with no
3755
- // visible reason. Now a failed OS-store write falls back to
3756
- // plaintext and is reported to the UI.
3757
- const persisted = await persistPassword({
3758
- backend: credBackend,
3759
- machineId: id,
3760
- password: body.password,
3761
- secretsDir: secretsDir(),
3762
- onStoreFailure: config.credentialFallback === 'plain' ? 'plain' : 'refuse',
3763
- })
3764
- rec.credentialBackend = persisted.credentialBackend
3765
- rec.password = persisted.password
3766
- // Record WHERE the secret ended up, so the settings page can say
3767
- // "已保存" for a keychain machine too (its record carries an
3768
- // empty `password` by design).
3769
- rec.secretStored = persisted.stored === 'secret'
3770
- if (persisted.warning) {
3771
- warning = persisted.warning
3772
- warningDetail = persisted.error
3773
- }
3774
- } else if (prev) {
3775
- // No password typed: keep whatever the machine already had —
3776
- // the plaintext copy AND the keychain flag. Turning the
3777
- // "加密保存密码" checkbox OFF is handled next: it must actually
3778
- // MOVE the secret, or the checkbox is a lie.
3779
- rec.password = prev.password || ''
3780
- rec.secretStored = prev.secretStored
3781
- const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
3782
- if (credBackend === 'plain' && prevKeychain) {
3783
- try {
3784
- const existing = await machinePassword(prev)
3785
- if (existing) {
3786
- rec.password = existing
3787
- rec.secretStored = false
3788
- rec.credentialBackend = 'plain'
3789
- await deleteSecret(id, secretsDir()).catch(() => {})
3790
- }
3791
- } catch { /* keep the keychain copy; the next connect still works */ }
3792
- }
3793
- }
3794
- if (i >= 0) machines[i] = rec; else machines.push(rec)
3795
- // Issue #13: saving a machine must NOT make it the active
3796
- // remote context. `currentId` stays untouched on add/update —
3797
- // only an explicit "设为当前" (or rw_connect) activates one.
3798
- // keepCurrentKey:false leaves the stored `currentId` byte-for-byte
3799
- // alone, so a fresh registry stays "no choice yet" (not an
3800
- // explicit none) and a previously-cleared registry stays cleared.
3801
- saveMachines(machines, store.currentId, false)
3802
- // Issue #67: an EDITED connection identity (host/user/port) must
3803
- // carry the machine's existing workspaces over — without this the
3804
- // mirrors stayed bound to the old address and the user had to
3805
- // create (and re-sync) a brand-new workspace for the new IP.
3806
- let reboundMirrors = 0
3807
- if (i >= 0 && prev) {
3808
- try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
3809
- }
3810
- // Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
3811
- // Saving a literal machine upserts a marker-tagged Host block,
3812
- // so the file and the registry can never drift; the record's
3813
- // copy remains only as a fallback for a missing/renamed block.
3814
- // A Host block the USER wrote for the same name is never
3815
- // touched — the conflict is reported instead. Failures here are
3816
- // best-effort: the registry copy is already saved.
3817
- let sshConfigWarning = ''
3818
- if (!rec.useSshConfig) {
3819
- try {
3820
- const raw = readRawSshConfig(sshConfigPath())
3821
- let next = upsertManagedHostBlock(raw, {
3822
- alias: rec.host,
3823
- hostName: rec.host,
3824
- user: rec.username,
3825
- port: rec.port,
3826
- identityFile: rec.privateKeyPath,
3827
- })
3828
- // A RENAMED machine leaves the old managed block behind, and
3829
- // that block is the plugin's own artifact of the edit —
3830
- // retire it. (Deleting a machine removes nothing: the Host
3831
- // block is the user's ssh access, not plugin state.)
3832
- if (prev && prev.host && prev.host !== rec.host && !prev.useSshConfig) {
3833
- const rm = removeManagedHostBlock(next.text, prev.host)
3834
- next = { status: rm.status === 'removed' && next.status === 'unchanged' ? 'updated' : next.status, text: rm.text }
3835
- }
3836
- if (next.status === 'conflict') {
3837
- sshConfigWarning = `~/.ssh/config already names "${rec.host}" in a Host block that is not managed by dsh-remote — it was left untouched, and it now wins over the saved copy.`
3838
- } else if (next.status === 'appended' || next.status === 'updated') {
3839
- writeSshConfig(next.text, sshConfigPath())
3840
- sshConfigCache = { text: '', at: 0 }
3841
- }
3842
- } catch (sshErr) {
3843
- sshConfigWarning = `could not update ~/.ssh/config (${String((sshErr && sshErr.message) || sshErr)}); the registry copy was saved.`
3844
- }
4273
+ try {
4274
+ return sendJson(res, 200, { ok: true, ...(await upsertMachineRecord(body)) })
4275
+ } catch (err) {
4276
+ return sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
3845
4277
  }
3846
- return sendJson(res, 200, {
3847
- ok: true,
3848
- machine: sanitizeMachine(rec),
3849
- machines: machinesForClient(),
3850
- currentId: store.currentId,
3851
- ...(reboundMirrors ? { reboundMirrors } : {}),
3852
- ...(warning ? { warning, warningDetail } : {}),
3853
- ...(sshConfigWarning ? { sshConfigWarning } : {}),
3854
- })
3855
4278
  }
3856
4279
  // Issue #73: one click on a host listed from ~/.ssh/config. The settings
3857
4280
  // page no longer asks the user to understand "alias" vs "copy": it lists
@@ -3910,25 +4333,11 @@ export async function apply(ctx, config) {
3910
4333
  })
3911
4334
  }
3912
4335
  if (action === 'delete') {
3913
- const i = machineIndex(String(body.id || ''))
3914
- if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
3915
- const m = machines[i]
3916
- if (m.credentialBackend && m.credentialBackend !== 'plain') {
3917
- await deleteSecret(m.id, secretsDir()).catch(() => {})
3918
- }
3919
- machines.splice(i, 1)
3920
- const wasCurrent = store.currentId === m.id
3921
- if (wasCurrent) {
3922
- // Issue #13: deleting the current machine leaves NO active
3923
- // remote context (never auto-promote a sibling — that would
3924
- // make an unrelated machine leak into the session again).
3925
- store.currentId = null
3926
- saveMachines(machines, null)
3927
- await clearActiveMachine()
3928
- } else {
3929
- saveMachines(machines, store.currentId)
4336
+ try {
4337
+ return sendJson(res, 200, { ok: true, ...(await removeMachineRecord(String(body.id || ''))) })
4338
+ } catch (err) {
4339
+ return sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
3930
4340
  }
3931
- return sendJson(res, 200, { ok: true, machines: machinesForClient(), currentId: store.currentId })
3932
4341
  }
3933
4342
  return sendJson(res, 400, { ok: false, error: 'unknown action' })
3934
4343
  } catch (err) {
@@ -3951,114 +4360,7 @@ export async function apply(ctx, config) {
3951
4360
  let body = {}
3952
4361
  try {
3953
4362
  body = parseJsonObject(await readBody(req))
3954
- // ── The probe must dial with the credentials a REAL connect would use.
3955
- //
3956
- // Stored secrets never travel back to the browser (see
3957
- // sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
3958
- // EMPTY password while the machine itself connected fine through the
3959
- // pool's keychain/plaintext resolver. Every such probe died with
3960
- // `no credentials: set a password, a privateKeyPath, or enable
3961
- // useAgent to connect` — the error said "you configured nothing" about
3962
- // a machine that was visibly working.
3963
- //
3964
- // So: resolve the saved record (by id when the form is editing one,
3965
- // else by identity) and merge — form values override, blanks fall back
3966
- // to the stored record. The resolved password is then ALSO exposed as a
3967
- // lazy resolver so an OS-keychain machine behaves exactly like a real
3968
- // connect (read on demand, never logged).
3969
- const savedRec = (() => {
3970
- const byId = body.id ? machines[machineIndex(String(body.id))] : null
3971
- if (byId) return byId
3972
- // No id (a fresh form that happens to match a saved machine): match on
3973
- // the identity the form is about to dial.
3974
- const hit = machineRecordFor({
3975
- host: String(body.host || ''),
3976
- port: Number(body.port) || 22,
3977
- username: String(body.username || ''),
3978
- })
3979
- return hit || null
3980
- })()
3981
- const savedPassword = savedRec ? await machinePassword(savedRec) : ''
3982
- const cred = resolveProbeCredentials(body, savedRec, savedPassword)
3983
- // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
3984
- // the form holds the alias name, not the host it points at.
3985
- const probeBackend = String(cred.backend || config.backend || 'native')
3986
- const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
3987
- const aliasTarget = body.useSshConfig
3988
- ? resolveMachineSshConfig({
3989
- host: String(body.host || ''),
3990
- port: Number(body.port) || 22,
3991
- username: String(body.username || ''),
3992
- privateKeyPath: String(cred.privateKeyPath || ''),
3993
- proxy: cred.proxy,
3994
- useSshConfig: true,
3995
- }, { text: sshConfigText() })
3996
- : null
3997
- // Issue #50: a LITERAL host named by a Host block resolves through
3998
- // the file as well — the probe must dial what a real connect would.
3999
- const literalTarget = !body.useSshConfig
4000
- ? resolveLiteralSshConfig({ host: String(body.host || '') }, { text: sshConfigText() })
4001
- : null
4002
- const probeTarget = aliasTarget || literalTarget
4003
- // Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
4004
- // VERBATIM (see connectTargetFor) — resolving it here would stop the
4005
- // `Host` block from matching, so the ~/.ssh/config settings the probe
4006
- // is supposed to validate would be silently skipped.
4007
- const probe = createPool({
4008
- ...config,
4009
- backend: probeBackend,
4010
- host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
4011
- port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
4012
- username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
4013
- password: cred.password,
4014
- privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
4015
- passphrase: cred.passphrase,
4016
- // Auth METHOD flags come from the merge too: a machine that
4017
- // authenticates through an agent or keyboard-interactive used to be
4018
- // probed with `useAgent: false`, i.e. with no auth method at all.
4019
- useAgent: cred.useAgent,
4020
- agentSocket: (probeTarget && !probeIsOpenssh && probeTarget.agentSocket) || '',
4021
- identitiesOnly: !!(probeTarget && !probeIsOpenssh && probeTarget.identitiesOnly),
4022
- keyboardInteractive: cred.keyboardInteractive,
4023
- ...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
4024
- ...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
4025
- proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
4026
- connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
4027
- commandTimeoutMs: 10000,
4028
- }, { knownHostsFile })
4029
- // Same lazy resolution the active pool uses: if the merged password was
4030
- // empty because the secret lives in the OS keychain, the pool asks again
4031
- // at dial time instead of failing with "no credentials".
4032
- probe.passwordResolver = async () => machinePassword(savedRec || {})
4033
- const started = Date.now()
4034
- await probe.connect()
4035
- await probe.exec('true', { timeoutMs: 10000 })
4036
- probe.close()
4037
- const latencyMs = Date.now() - started
4038
- // Alias-aware: the probe connects to the RESOLVED host, while the
4039
- // registry stores the alias, so matching must go through the identity.
4040
- const mi = machines.findIndex((m) => identityMatches(m, { host: probe.config.host, port: probe.config.port, username: probe.config.username }))
4041
- if (mi >= 0) {
4042
- machines[mi].lastConnectedAt = new Date().toISOString()
4043
- machines[mi].latencyMs = latencyMs
4044
- saveMachines(machines, store.currentId)
4045
- }
4046
- return sendJson(res, 200, {
4047
- ok: true,
4048
- host: probe.config.host,
4049
- user: probe.config.username,
4050
- latencyMs,
4051
- lastConnectedAt: new Date().toISOString(),
4052
- platform: probe.platform,
4053
- shell: probe.shellMode,
4054
- gitBash: probe.gitBashPath || '',
4055
- // Tell the UI whether the probe leaned on the SAVED credential: with
4056
- // an empty password box, "测试成功" otherwise looks impossible.
4057
- usedSavedCredential: cred.usedSavedCredential,
4058
- // Issue #38: tell the settings page where an alias resolved to (and
4059
- // anything the plugin could not honour, e.g. a multi-hop ProxyJump).
4060
- alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
4061
- })
4363
+ return sendJson(res, 200, await probeMachineRecord(body))
4062
4364
  } catch (err) {
4063
4365
  return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body.host, port: body.port }) })
4064
4366
  }
@@ -4548,4 +4850,74 @@ export async function apply(ctx, config) {
4548
4850
  if (typeof updateTimer.unref === 'function') updateTimer.unref()
4549
4851
  ctx.effect(() => () => clearInterval(updateTimer), 'dsh-remote.update-timer')
4550
4852
  }
4551
- }
4853
+
4854
+ // ── in-process API for other plugins (TUI) ────────────────────────────────
4855
+ // Same closure state the HTTP routes use. A second implementation would hold
4856
+ // a stale `machines` snapshot, open a second SSH connection per host, and
4857
+ // race saveMachines()'s whole-file rewrite against this one. `lib/host-api.js`
4858
+ // only shapes these functions into pure data + coded errors.
4859
+ let hostApi
4860
+ try {
4861
+ hostApi = createHostApi({
4862
+ listMachinesView: () => ({ machines: machinesForClient(), currentId: store.currentId || null }),
4863
+ upsertMachineRecord,
4864
+ removeMachineRecord,
4865
+ setCurrentForApi,
4866
+ connectMachine: connectMachineForApi,
4867
+ probeMachineRecord,
4868
+ statusSnapshot: () => status(),
4869
+ resolveDirBinding: listDirBindingForApi,
4870
+ listRemoteProjects: listRemoteProjectsForApi,
4871
+ resolveMachine: resolveMachineForApi,
4872
+ ensureMirror,
4873
+ registerMirroredWorkspace,
4874
+ checkRemoteDir: checkRemoteDirForApi,
4875
+ workspaceRoot: remoteWorkspacesRoot,
4876
+ sessionsRoot: () => path.join(dshBase(), 'sessions'),
4877
+ workspaceRegistry: () => (ctx && typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined),
4878
+ forgetRemotePath,
4879
+ isSessionLive,
4880
+ })
4881
+ // cordis 4: `ctx.provide(name, value)` is the raw service registry (the
4882
+ // `Service` base class is only sugar around it). Guarded because a minimal
4883
+ // embedding — and the test harness before it grew `provide` — has no
4884
+ // context service registry; a bare call would abort apply.
4885
+ if (ctx && typeof ctx.provide === 'function') {
4886
+ try {
4887
+ ctx.provide('dshRemote', hostApi)
4888
+ hostLog('info', '[dsh-remote] in-process API available as ctx.dshRemote (apiVersion 1)')
4889
+ } catch (err) {
4890
+ hostLog('warn', `[dsh-remote] could not register ctx.dshRemote: ${String((err && err.message) || err)}`)
4891
+ }
4892
+ }
4893
+ } catch (err) {
4894
+ // The API is additive: never let it break the plugin.
4895
+ hostLog('warn', `[dsh-remote] in-process API unavailable: ${String((err && err.message) || err)}`)
4896
+ }
4897
+
4898
+ // ── TUI integration layer (dsh-tui) ───────────────────────────────────────
4899
+ // Additive and optional: lib/tui/index.js probes its seams with
4900
+ // `ctx.get(name, false)` and is a no-op on a Web/Desktop profile. A broken
4901
+ // TUI layer must never take the host down, hence the guard here too.
4902
+ //
4903
+ // Loaded LAZILY on purpose: `lib/tui/` is the only SUBDIRECTORY under lib/,
4904
+ // and a deployment that copies just `lib/*.js` (a hand-rolled sync, a partial
4905
+ // unpacks) would otherwise fail at module-import time and take the whole
4906
+ // plugin down in the Web profile as well. A missing TUI directory now means
4907
+ // "no terminal layer", which is exactly the pre-TUI behaviour.
4908
+ if (config.tui !== false && hostApi) {
4909
+ import('./tui/index.js').then((mod) => {
4910
+ if (typeof mod.installTui !== 'function') {
4911
+ hostLog('warn', '[dsh-remote] TUI layer has no installTui export; skipped')
4912
+ return
4913
+ }
4914
+ try {
4915
+ mod.installTui(ctx, hostApi, config)
4916
+ } catch (err) {
4917
+ hostLog('warn', `[dsh-remote] TUI layer failed to install: ${String((err && err.message) || err)}`)
4918
+ }
4919
+ }).catch((err) => {
4920
+ hostLog('warn', `[dsh-remote] TUI layer unavailable: ${String((err && err.message) || err)}`)
4921
+ })
4922
+ }
4923
+ }