dsh-remote 0.8.6 → 0.8.8

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
@@ -42,6 +42,7 @@ import { searchTree } from './search.js'
42
42
  import { TaskManager } from './tasks.js'
43
43
  import { ForwardManager } from './forwards.js'
44
44
  import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
45
+ import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
45
46
 
46
47
  const { Client } = ssh2
47
48
 
@@ -220,6 +221,9 @@ function migrateLegacyData() {
220
221
  }
221
222
 
222
223
  // ── persistent multi-machine registry ─────────────────────────────────────
224
+ // Pure registry logic lives in lib/registry.js (unit-tested): saved machines
225
+ // are STANDBY connections; only an explicit "set current" activates one.
226
+ // (issue #13 — Saved Connections != Active Remote Context)
223
227
  const MACHINES_FILE = 'machines.json'
224
228
  const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
225
229
  const secretsDir = () => path.join(remoteWorkspacesRoot(), '.secrets')
@@ -227,40 +231,11 @@ const forwardsFile = () => path.join(remoteWorkspacesRoot(), 'forwards.json')
227
231
  const auditFile = () => path.join(remoteWorkspacesRoot(), 'audit.log')
228
232
  const ignoreFile = () => path.join(remoteWorkspacesRoot(), '.dsh-remote-ignore')
229
233
 
230
- function loadMachines() {
231
- try {
232
- const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
233
- if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
234
- } catch {}
235
- return { list: [], currentId: null }
236
- }
237
- function saveMachines(list, currentId) {
238
- try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
239
- writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
240
- }
241
- function sanitizeMachine(m) {
242
- if (!m) return m
243
- const { password, proxy, ...rest } = m
244
- const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
245
- if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
246
- return out
247
- }
248
- /** Apply a machine's fields onto the live config object (pool + tools read it). */
249
- function applyMachine(config, m) {
250
- if (!m) return
251
- config.host = m.host
252
- config.port = Number(m.port) || 22
253
- config.username = m.username || ''
254
- config.password = m.password || ''
255
- config.privateKeyPath = m.privateKeyPath || ''
256
- config.passphrase = m.passphrase || ''
257
- config.workspace = m.workspace || (config.workspace || '')
258
- if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
259
- if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
260
- if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
261
- config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
262
- }
263
- function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
234
+ const loadMachines = () => _loadMachines(machinesFile())
235
+ const saveMachines = (list, currentId, keepCurrentKey) => _saveMachines(machinesFile(), list, currentId, keepCurrentKey)
236
+ const sanitizeMachine = _sanitizeMachine
237
+ const applyMachine = _applyMachine
238
+ const machineId = _machineId
264
239
 
265
240
  // ── host-key registry (TOFU) ──────────────────────────────────────────────
266
241
  const KNOWN_HOSTS_FILE = 'known_hosts.json'
@@ -753,6 +728,15 @@ export async function apply(ctx, config) {
753
728
  }
754
729
 
755
730
  const setCurrent = async (id) => {
731
+ if (!id) {
732
+ // Explicit "active remote = none": saved machines stay in the registry
733
+ // (issue #13) but nothing is bound to the pool anymore.
734
+ store.currentId = null
735
+ ephemeral = null
736
+ saveMachines(machines, null)
737
+ await clearActiveMachine()
738
+ return true
739
+ }
756
740
  const i = machineIndex(id)
757
741
  if (i < 0) return false
758
742
  store.currentId = id
@@ -762,10 +746,41 @@ export async function apply(ctx, config) {
762
746
  return true
763
747
  }
764
748
 
765
- // If no stored current, adopt a CLI-provided default as the active machine.
749
+ /** Unbind the pool + live config from any machine: the plugin starts in a
750
+ * pure "no remote context" state (issue #13). The host stays configured for
751
+ * tool fallback, but no machine workspace leaks into this session. */
752
+ const clearActiveMachine = async () => {
753
+ ephemeral = null
754
+ if (!config.host) return
755
+ // Clear host + workspace directly (applyMachine keeps the old workspace
756
+ // when the incoming value is empty — it must not be used for a full reset).
757
+ config.host = ''
758
+ config.port = 22
759
+ config.username = ''
760
+ config.password = ''
761
+ config.workspace = ''
762
+ pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
763
+ // Also stop any auto-push watcher bound to the old machine workspace.
764
+ // (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
765
+ if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
766
+ for (const [, entry] of autoPushWatchers) {
767
+ if (!entry) continue
768
+ try { entry.watcher && entry.watcher.close() } catch {}
769
+ if (entry.timer) clearTimeout(entry.timer)
770
+ }
771
+ autoPushWatchers.clear()
772
+ }
773
+ }
774
+
775
+ // If no stored current, adopt a CLI-provided default as the active machine —
776
+ // UNLESS the user explicitly cleared the current machine (currentId: null in
777
+ // the registry = "active remote = none", issue #13). A registry that exists
778
+ // with currentId null must stay inert; only a fresh/absent registry lets the
779
+ // config host serve as the bootstrap default.
766
780
  {
767
781
  const cur = currentMachine()
768
- if (cur && cur.host && !store.currentId) applyMachine(config, cur)
782
+ const registryExplicitNone = store.explicitNone
783
+ if (cur && cur.host && !store.currentId && !registryExplicitNone) applyMachine(config, cur)
769
784
  }
770
785
 
771
786
  /** Persist the active workspace on the machine the pool is actually bound to
@@ -980,9 +995,61 @@ export async function apply(ctx, config) {
980
995
 
981
996
  // ── remote workspace state ────────────────────────────────────────────────
982
997
  const wsPath = () => (config.workspace || '').trim()
998
+ /** Map a LOCAL path to the remote path its mirror represents ('' when the
999
+ * path is not inside any dsh-remote mirror). Shared by the resolve-mirror
1000
+ * route, the session-aware system-prompt injection, and status(). */
1001
+ const resolveMirrorForLocal = (local) => {
1002
+ const root = remoteWorkspacesRoot()
1003
+ const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
1004
+ let matched = ''
1005
+ let matchedRemote = ''
1006
+ if (local && existsSync(root)) {
1007
+ const base = norm(local)
1008
+ for (const hostDir of readdirSync(root)) {
1009
+ const hostPath = path.join(root, hostDir)
1010
+ if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
1011
+ for (const mirrorDir of readdirSync(hostPath)) {
1012
+ const metaPath = path.join(hostPath, mirrorDir, '.dsh-remote-meta.json')
1013
+ if (!existsSync(metaPath)) continue
1014
+ try {
1015
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1016
+ const mirrorAbs = norm(path.join(hostPath, mirrorDir))
1017
+ if (mirrorAbs === base || base.startsWith(mirrorAbs + path.sep) || base.startsWith(mirrorAbs + '/')) {
1018
+ if (mirrorAbs.length > matched.length) {
1019
+ matched = mirrorAbs
1020
+ matchedRemote = String(meta.remotePath || '')
1021
+ }
1022
+ }
1023
+ } catch { /* skip unparsable meta */ }
1024
+ }
1025
+ }
1026
+ }
1027
+ return { mirrorDir: matched || null, remotePath: matchedRemote || '' }
1028
+ }
983
1029
  if (config.autoPush && wsPath()) {
984
1030
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
985
1031
  }
1032
+ /** The remote path the CURRENT agent session is really bound to, or '' when
1033
+ * the session is a plain local one. Walks the mirror registry (host side
1034
+ * copy of the resolve-mirror logic) so status / prompt / sidebar agree.
1035
+ * `sessionId` narrows to one session; without it the first live session's
1036
+ * cwd is used (best effort — status is informational, the per-session
1037
+ * resolve-mirror endpoint is authoritative). */
1038
+ const sessionRemotePath = (sessionId) => {
1039
+ let cwd = ''
1040
+ try {
1041
+ const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null
1042
+ if (sessions && typeof sessions.get === 'function' && sessionId) {
1043
+ const s = sessions.get(sessionId)
1044
+ if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
1045
+ } else if (sessions && typeof sessions.list === 'function') {
1046
+ const s = sessions.list()[0]
1047
+ if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
1048
+ }
1049
+ } catch { /* sessions service unavailable */ }
1050
+ if (!cwd) return ''
1051
+ return resolveMirrorForLocal(cwd).remotePath
1052
+ }
986
1053
  const status = () => ({
987
1054
  host: config.host,
988
1055
  port: config.port,
@@ -992,6 +1059,11 @@ export async function apply(ctx, config) {
992
1059
  localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
993
1060
  currentId: store.currentId || null,
994
1061
  activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
1062
+ // Issue #13: whether THIS session is in remote mode (cwd inside a mirror).
1063
+ // `none`/`local` means the plugin is present but no remote context is
1064
+ // active — saved machines stay standby-only.
1065
+ sessionMode: sessionRemotePath() ? 'remote' : (store.currentId ? 'standby' : 'local'),
1066
+ sessionRemotePath: sessionRemotePath(),
995
1067
  machines: machines.map(sanitizeMachine),
996
1068
  hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
997
1069
  hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
@@ -1015,15 +1087,16 @@ export async function apply(ctx, config) {
1015
1087
  defineTool({
1016
1088
  name: 'rw_info',
1017
1089
  description:
1018
- 'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity.',
1090
+ 'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity. Note: remote context is session-scoped — a saved machine only becomes active when you explicitly rw_connect to it (issue #13).',
1019
1091
  parameters: {},
1020
1092
  output: textOut,
1021
1093
  async execute() {
1022
1094
  const s = status()
1023
1095
  const lines = [
1024
1096
  `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
1025
- `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
1097
+ `Current remote workspace: ${s.workspace || '(none — call rw_connect then rw_pick_workspace to set one)'}`,
1026
1098
  `Local mirror: ${s.localMirror || '(none)'}`,
1099
+ `Session remote context: ${s.sessionRemotePath ? s.sessionRemotePath : '(this session is LOCAL — no remote workspace bound)'}`,
1027
1100
  `Connected: ${s.connected ? 'yes' : 'no'}`,
1028
1101
  `Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
1029
1102
  `Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
@@ -1689,19 +1762,30 @@ export async function apply(ctx, config) {
1689
1762
  ctx.tools.register(t)
1690
1763
  }
1691
1764
 
1692
- // ── system-prompt injection: the current remote workspace ─────────────────
1765
+ // ── system-prompt injection: session-aware remote workspace ──────────────
1766
+ // Issue #13: remote context must be SESSION-scoped, not machine-global.
1767
+ // The section text is evaluated per assembly with the current agent's
1768
+ // session; it is injected ONLY when that session's cwd actually maps to a
1769
+ // dsh-remote mirror (i.e. the user picked a remote workspace as the session
1770
+ // workspace). A plain local session gets no remote section — saved machines
1771
+ // never leak their workspace into an unrelated local session's prompt.
1772
+ // (resolveMirrorForLocal is defined above, next to wsPath.)
1693
1773
  ctx.systemPrompt.section({
1694
1774
  name: 'dsh-remote',
1695
1775
  order: 88,
1696
- text: () => {
1697
- const w = wsPath()
1698
- if (!w || !config.host) return ''
1776
+ text: (promptContext) => {
1777
+ const agent = promptContext && promptContext.agent
1778
+ const session = agent && agent.session
1779
+ const cwd = session && session.header && session.header.cwd
1780
+ if (!cwd) return '' // no session cwd → nothing to map → stay quiet
1781
+ const { remotePath } = resolveMirrorForLocal(cwd)
1782
+ if (!remotePath) return '' // this session is NOT a remote session (issue #13)
1699
1783
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
1700
1784
  let extra = ''
1701
1785
  if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
1702
1786
  return (
1703
1787
  '## Remote workspace\n' +
1704
- `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
1788
+ `Current remote workspace: ${config.username || 'user'}@${config.host}:${remotePath}\n` +
1705
1789
  'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
1706
1790
  extra
1707
1791
  )
@@ -1818,21 +1902,57 @@ export async function apply(ctx, config) {
1818
1902
  throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1819
1903
  }
1820
1904
 
1905
+ /** The ctx.directoryPicker capability object, or null when the service is
1906
+ * absent/unusable. Never throws — callers fall through to pickLocalNative. */
1907
+ const localPickCapability = async () => {
1908
+ const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
1909
+ if (!dp || typeof dp.capability !== 'function') return null
1910
+ try {
1911
+ return await Promise.resolve(dp.capability())
1912
+ } catch {
1913
+ return null
1914
+ }
1915
+ }
1916
+
1917
+ /** Windows has no single filesystem root — enumerate fixed/mapped drive
1918
+ * letters so the in-app browser can switch between them (the browse
1919
+ * backend's crumbs stop at the current drive's root). Empty on POSIX. */
1920
+ const listLocalDrives = () => {
1921
+ if (process.platform !== 'win32') return []
1922
+ const out = []
1923
+ for (let i = 67; i <= 90; i++) { // C..Z (A/B are legacy floppy — skip)
1924
+ const root = String.fromCharCode(i) + ':\\'
1925
+ try {
1926
+ statSync(root)
1927
+ out.push({ name: root.slice(0, -1), path: root })
1928
+ } catch {}
1929
+ }
1930
+ return out
1931
+ }
1932
+
1821
1933
  // ── route helpers ─────────────────────────────────────────────────────────
1822
1934
  const routes = [
1823
1935
  {
1824
1936
  kind: 'exact',
1825
1937
  path: '/dsh-remote/status',
1826
1938
  handler: async (req, res) => {
1827
- if (req.method === 'GET') return sendJson(res, 200, status())
1939
+ if (req.method === 'GET') {
1940
+ const q = new URL(req.url, 'http://localhost').searchParams
1941
+ const sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : ''
1942
+ // Per-session remote context (issue #13): when a sessionId is given,
1943
+ // sessionMode reflects THAT session's cwd, not the machine default.
1944
+ return sendJson(res, 200, sessionId ? { ...status(), sessionMode: sessionRemotePath(sessionId) ? 'remote' : 'local', sessionRemotePath: sessionRemotePath(sessionId) } : status())
1945
+ }
1828
1946
  sendJson(res, 405, { error: 'method not allowed' })
1829
1947
  },
1830
1948
  },
1831
1949
  {
1832
1950
  // Map a LOCAL path (typically a session cwd that sits inside a remote
1833
1951
  // mirror dir) to the remote path it mirrors, by reading each mirror
1834
- // dir's .dsh-remote-meta.json. Falls back to the machine workspace so
1835
- // the sidebar still shows something sensible for non-mirror cwds.
1952
+ // dir's .dsh-remote-meta.json. NO machine-workspace fallback (issue #13):
1953
+ // a session whose cwd is not inside any mirror is a pure LOCAL session —
1954
+ // the sidebar must show "no remote workspace", never another machine's
1955
+ // remembered default.
1836
1956
  // Accepts either ?local=<abs path> or ?sessionId=<id> (resolved via
1837
1957
  // the host sessions service header.cwd).
1838
1958
  kind: 'exact',
@@ -1881,7 +2001,6 @@ export async function apply(ctx, config) {
1881
2001
  } catch { /* session log scan failed */ }
1882
2002
  }
1883
2003
  }
1884
- const remote = status().workspace || ''
1885
2004
  const root = remoteWorkspacesRoot()
1886
2005
  const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
1887
2006
  let matched = ''
@@ -1908,8 +2027,16 @@ export async function apply(ctx, config) {
1908
2027
  }
1909
2028
  }
1910
2029
  }
1911
- const remotePath = matchedRemote || remote
1912
- return sendJson(res, 200, { local, remotePath, mirrorDir: matched || null, fallback: !matchedRemote, resolvedVia })
2030
+ // Issue #13: a non-mirror session is LOCAL — remotePath stays empty.
2031
+ const remotePath = matchedRemote || ''
2032
+ return sendJson(res, 200, {
2033
+ local, remotePath, mirrorDir: matched || null,
2034
+ // `fallback:true` now means "this session is NOT a remote session"
2035
+ // (used to mean "fell back to machine workspace").
2036
+ fallback: !matchedRemote,
2037
+ mode: matchedRemote ? 'remote' : 'local',
2038
+ resolvedVia,
2039
+ })
1913
2040
  } catch (err) {
1914
2041
  return sendJson(res, 500, { error: String((err && err.message) || err) })
1915
2042
  }
@@ -2180,25 +2307,35 @@ export async function apply(ctx, config) {
2180
2307
  try {
2181
2308
  let outcome = null
2182
2309
  let via = 'service'
2183
- const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
2184
- if (dp && typeof dp.capability === 'function') {
2310
+ const cap = await localPickCapability()
2311
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
2185
2312
  try {
2186
- const cap = await Promise.resolve(dp.capability())
2187
- if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
2188
- const pickAbort = new AbortController()
2189
- const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
2190
- pickAbort.abort()
2191
- outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
2192
- }
2313
+ const pickAbort = new AbortController()
2314
+ const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
2315
+ pickAbort.abort()
2316
+ outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
2193
2317
  } catch (err) {
2194
2318
  outcome = null
2195
2319
  }
2196
2320
  }
2321
+ // Prefer a REAL OS dialog over the browse backend: operators expect
2322
+ // an Explorer-style chooser, and the own spawn (PowerShell
2323
+ // FolderBrowserDialog / osascript / zenity-kdialog) works even where
2324
+ // the host mounted browse — DSH Desktop (Electron) deliberately
2325
+ // mounts browse on win32 because the native backend's koffi dialog
2326
+ // worker cannot run under Electron, but a plain child process can.
2197
2327
  if (!outcome) {
2198
2328
  via = 'own'
2199
2329
  try {
2200
2330
  outcome = await pickLocalNative()
2201
2331
  } catch (err) {
2332
+ // No usable OS dialog (headless host, missing chooser binary):
2333
+ // fall back to the host's browse capability — fs-only, works
2334
+ // display-less — served as an in-app browser by the client
2335
+ // (backed by /dsh-remote/local-list + /dsh-remote/local-mkdir).
2336
+ if (cap && cap.kind === 'browse' && typeof cap.list === 'function') {
2337
+ return sendJson(res, 200, { ok: true, kind: 'browse', via: 'browse' })
2338
+ }
2202
2339
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
2203
2340
  }
2204
2341
  }
@@ -2209,6 +2346,51 @@ export async function apply(ctx, config) {
2209
2346
  }
2210
2347
  },
2211
2348
  },
2349
+ {
2350
+ kind: 'exact',
2351
+ path: '/dsh-remote/local-list',
2352
+ handler: async (req, res) => {
2353
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2354
+ try {
2355
+ const cap = await localPickCapability()
2356
+ if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function') return sendJson(res, 400, { ok: false, error: '当前目录选择器不是浏览后端,无法列出本机目录' })
2357
+ const m = (req.url || '').match(/path=([^&]*)/)
2358
+ const raw = m ? decodeURIComponent(m[1]) : ''
2359
+ // No path → the browse backend lists the host home directory.
2360
+ try {
2361
+ const out = await Promise.resolve(cap.list(raw ? raw : undefined))
2362
+ return sendJson(res, 200, { ok: true, ...out, drives: listLocalDrives() })
2363
+ } catch (listErr) {
2364
+ return sendJson(res, 400, { ok: false, error: String((listErr && listErr.message) || listErr), code: (listErr && listErr.code) || 'directory-unreadable' })
2365
+ }
2366
+ } catch (err) {
2367
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2368
+ }
2369
+ },
2370
+ },
2371
+ {
2372
+ kind: 'exact',
2373
+ path: '/dsh-remote/local-mkdir',
2374
+ handler: async (req, res) => {
2375
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2376
+ try {
2377
+ const cap = await localPickCapability()
2378
+ if (!cap || cap.kind !== 'browse' || typeof cap.createDirectory !== 'function') return sendJson(res, 400, { ok: false, error: '当前目录选择器不是浏览后端,无法新建文件夹' })
2379
+ const payload = JSON.parse((await readBody(req)) || '{}')
2380
+ const p = String(payload.path || '')
2381
+ const name = String(payload.name || '')
2382
+ if (!p.trim() || !name.trim()) return sendJson(res, 400, { ok: false, error: 'path and name required' })
2383
+ try {
2384
+ const created = await Promise.resolve(cap.createDirectory(p, name))
2385
+ return sendJson(res, 200, { ok: true, path: created })
2386
+ } catch (mkErr) {
2387
+ return sendJson(res, 400, { ok: false, error: String((mkErr && mkErr.message) || mkErr), code: (mkErr && mkErr.code) || 'directory-create-failed' })
2388
+ }
2389
+ } catch (err) {
2390
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2391
+ }
2392
+ },
2393
+ },
2212
2394
  {
2213
2395
  kind: 'exact',
2214
2396
  path: '/dsh-remote/machines',
@@ -2264,9 +2446,13 @@ export async function apply(ctx, config) {
2264
2446
  rec.password = prev.password
2265
2447
  }
2266
2448
  if (i >= 0) machines[i] = rec; else machines.push(rec)
2267
- if (!store.currentId) store.currentId = id
2268
- saveMachines(machines, store.currentId)
2269
- if (store.currentId === id) await applyActiveMachine()
2449
+ // Issue #13: saving a machine must NOT make it the active
2450
+ // remote context. `currentId` stays untouched on add/update —
2451
+ // only an explicit "设为当前" (or rw_connect) activates one.
2452
+ // keepCurrentKey:false leaves the stored `currentId` byte-for-byte
2453
+ // alone, so a fresh registry stays "no choice yet" (not an
2454
+ // explicit none) and a previously-cleared registry stays cleared.
2455
+ saveMachines(machines, store.currentId, false)
2270
2456
  return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
2271
2457
  }
2272
2458
  if (action === 'delete') {
@@ -2277,9 +2463,17 @@ export async function apply(ctx, config) {
2277
2463
  await deleteSecret(m.id, secretsDir()).catch(() => {})
2278
2464
  }
2279
2465
  machines.splice(i, 1)
2280
- if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
2281
- saveMachines(machines, store.currentId)
2282
- if (store.currentId) await applyActiveMachine()
2466
+ const wasCurrent = store.currentId === m.id
2467
+ if (wasCurrent) {
2468
+ // Issue #13: deleting the current machine leaves NO active
2469
+ // remote context (never auto-promote a sibling — that would
2470
+ // make an unrelated machine leak into the session again).
2471
+ store.currentId = null
2472
+ saveMachines(machines, null)
2473
+ await clearActiveMachine()
2474
+ } else {
2475
+ saveMachines(machines, store.currentId)
2476
+ }
2283
2477
  return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
2284
2478
  }
2285
2479
  return sendJson(res, 400, { ok: false, error: 'unknown action' })
@@ -2339,7 +2533,14 @@ export async function apply(ctx, config) {
2339
2533
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2340
2534
  try {
2341
2535
  const body = JSON.parse((await readBody(req)) || '{}')
2342
- const okSet = await setCurrent(String(body.id || ''))
2536
+ const id = String(body.id || '').trim()
2537
+ // Issue #13: an empty id is an explicit "active remote = none" —
2538
+ // saved machines stay in the registry but nothing is current.
2539
+ if (!id) {
2540
+ await setCurrent('')
2541
+ return sendJson(res, 200, { ok: true, currentId: null, ...status() })
2542
+ }
2543
+ const okSet = await setCurrent(id)
2343
2544
  if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
2344
2545
  return sendJson(res, 200, { ok: true, ...status() })
2345
2546
  } catch (err) {