dsh-remote 0.8.73 → 0.8.75

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