dsh-remote 0.8.7 → 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/README.zh.md CHANGED
@@ -70,6 +70,9 @@ dsh plugin add dsh-remote # 添加 bundle
70
70
  ## 快速上手
71
71
 
72
72
  1. **加一台机器** —— 设置 → 远程工作区 → 填 host/port/user + 密码或 key →(可选)设为当前。
73
+ > **保存 ≠ 激活(v0.8.8+)**:保存的机器只是备用连接,不会自动进入任何 session 的
74
+ > remote context。只有「设为当前」(或 Agent 显式调用 `rw_connect`)才激活当前机器;
75
+ > 「取消设为当前」可回到 `active remote = none`。
73
76
  2. **选工作区** —— 点侧边栏/会话的 **Add workspace**:
74
77
  - **本机** → 系统文件夹选择(或输入本地路径)→ 本地工作区。
75
78
  - **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。
@@ -80,6 +83,12 @@ dsh plugin add dsh-remote # 添加 bundle
80
83
  - `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录)
81
84
  - `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
82
85
 
86
+ > **Remote context 是 session 级的(v0.8.8+)**:system prompt 只会在**当前 session 的
87
+ > cwd 位于某个远程 mirror 内**(即你把远程目录选成了这个 session 的工作区)时注入
88
+ > 「Remote workspace」段落;普通本地 session 不注入、侧边栏「远程文件」也不显示任何
89
+ > 机器默认目录,模型不会主动调用 `rw_*`。混合访问(本地 + 远程同屏比较)请通过显式
90
+ > 选择远程工作区进行。
91
+
83
92
  ## 可选:CLI 默认机
84
93
 
85
94
  可在 `cordis.patch.yml` 提供默认机:
package/lib/client.js CHANGED
@@ -204,6 +204,9 @@ window.__ModuleLoader__.load({
204
204
  }).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
205
205
  }
206
206
  const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg('已切换为当前远程机') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
207
+ // Issue #13: "active remote = none" — saved machines stay, but no machine
208
+ // is current, so local sessions are never dragged into a remote context.
209
+ const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg('已取消当前远程机 — 本地会话不再自动使用远程上下文') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
207
210
  const del = (id) => { if (window.confirm('确定删除这台机器?')) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg('已删除')).catch((e) => setErr(String((e && e.message) || e))) } }
208
211
 
209
212
  const importSsh = () => {
@@ -261,6 +264,11 @@ window.__ModuleLoader__.load({
261
264
  React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
262
265
  React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
263
266
  : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
267
+ currentId
268
+ ? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
269
+ '当前远程机:' + ((machines.find((m) => m.id === currentId) || {}).name || currentId),
270
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, '取消设为当前(active remote = none)'))
271
+ : React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。'),
264
272
  ),
265
273
  React.createElement('div', { style: boxS },
266
274
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
@@ -830,10 +838,12 @@ window.__ModuleLoader__.load({
830
838
  return api('GET', '/dsh-remote/status').catch(() => null)
831
839
  }
832
840
 
833
- // Resolve the remote path this session should show: prefer the session's
834
- // own cwd (a remote mirror dir) mapped through /dsh-remote/resolve-mirror;
835
- // fall back to the machine workspace when the session has no cwd or the
836
- // host side has no mirror match.
841
+ // Resolve the remote path this session shows. Issue #13: the ONLY source
842
+ // of truth is the session's own cwd mapped through /dsh-remote/resolve-
843
+ // mirror — a cwd outside any mirror dir means "this session is NOT a
844
+ // remote session", and the sidebar must NOT fall back to the machine
845
+ // workspace (that made an unrelated saved machine leak into local
846
+ // sessions).
837
847
  function resolveSessionRemote(sessionId) {
838
848
  if (!sessionId) return Promise.resolve('')
839
849
  // 1) Prefer the client-side session list cwd (mirror dir under
@@ -969,7 +979,10 @@ window.__ModuleLoader__.load({
969
979
  resolveRoot().then((r) => {
970
980
  fetchRemoteStatus().then((s) => {
971
981
  setStatus(s)
972
- applyRoot(r || (s && s.workspace) || '')
982
+ // Issue #13: NEVER fall back to the machine-level workspace here —
983
+ // a session whose cwd is not a mirror is a LOCAL session and shows
984
+ // the "no remote workspace" empty state instead.
985
+ applyRoot(r || '')
973
986
  })
974
987
  })
975
988
  }, [scopeCwd, sessionId])
@@ -1087,15 +1100,15 @@ window.__ModuleLoader__.load({
1087
1100
  }, '新建目录'),
1088
1101
  React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
1089
1102
  ),
1090
- status && status.workspace
1103
+ status && status.workspace && root
1091
1104
  ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
1092
- : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区'),
1105
+ : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? '未设置远程工作区' : '当前会话未使用远程工作区'),
1093
1106
  picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
1094
1107
  open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
1095
1108
  })) : null,
1096
1109
  React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
1097
1110
  !root
1098
- ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区')
1111
+ ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。')
1099
1112
  : React.createElement('div', { key: root },
1100
1113
  React.createElement('div', {
1101
1114
  role: 'button', tabIndex: 0,
@@ -1243,6 +1256,10 @@ window.__ModuleLoader__.load({
1243
1256
  if (!snap || !snap.sessionId) return
1244
1257
  if (autoOpenedFor.has(snap.sessionId)) return
1245
1258
  const sessionId = snap.sessionId
1259
+ // Issue #13: only auto-open the Remote Files tab for sessions that are
1260
+ // genuinely remote (cwd inside a mirror). A plain local session must
1261
+ // not get a remote tab popped open by a machine that happens to be
1262
+ // saved as "current".
1246
1263
  resolveSessionRemote(sessionId).then((remotePath) => {
1247
1264
  if (!remotePath || autoOpenedFor.has(sessionId)) return
1248
1265
  autoOpenedFor.add(sessionId)
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
  )
@@ -1852,15 +1936,23 @@ export async function apply(ctx, config) {
1852
1936
  kind: 'exact',
1853
1937
  path: '/dsh-remote/status',
1854
1938
  handler: async (req, res) => {
1855
- 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
+ }
1856
1946
  sendJson(res, 405, { error: 'method not allowed' })
1857
1947
  },
1858
1948
  },
1859
1949
  {
1860
1950
  // Map a LOCAL path (typically a session cwd that sits inside a remote
1861
1951
  // mirror dir) to the remote path it mirrors, by reading each mirror
1862
- // dir's .dsh-remote-meta.json. Falls back to the machine workspace so
1863
- // 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.
1864
1956
  // Accepts either ?local=<abs path> or ?sessionId=<id> (resolved via
1865
1957
  // the host sessions service header.cwd).
1866
1958
  kind: 'exact',
@@ -1909,7 +2001,6 @@ export async function apply(ctx, config) {
1909
2001
  } catch { /* session log scan failed */ }
1910
2002
  }
1911
2003
  }
1912
- const remote = status().workspace || ''
1913
2004
  const root = remoteWorkspacesRoot()
1914
2005
  const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
1915
2006
  let matched = ''
@@ -1936,8 +2027,16 @@ export async function apply(ctx, config) {
1936
2027
  }
1937
2028
  }
1938
2029
  }
1939
- const remotePath = matchedRemote || remote
1940
- 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
+ })
1941
2040
  } catch (err) {
1942
2041
  return sendJson(res, 500, { error: String((err && err.message) || err) })
1943
2042
  }
@@ -2347,9 +2446,13 @@ export async function apply(ctx, config) {
2347
2446
  rec.password = prev.password
2348
2447
  }
2349
2448
  if (i >= 0) machines[i] = rec; else machines.push(rec)
2350
- if (!store.currentId) store.currentId = id
2351
- saveMachines(machines, store.currentId)
2352
- 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)
2353
2456
  return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
2354
2457
  }
2355
2458
  if (action === 'delete') {
@@ -2360,9 +2463,17 @@ export async function apply(ctx, config) {
2360
2463
  await deleteSecret(m.id, secretsDir()).catch(() => {})
2361
2464
  }
2362
2465
  machines.splice(i, 1)
2363
- if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
2364
- saveMachines(machines, store.currentId)
2365
- 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
+ }
2366
2477
  return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
2367
2478
  }
2368
2479
  return sendJson(res, 400, { ok: false, error: 'unknown action' })
@@ -2422,7 +2533,14 @@ export async function apply(ctx, config) {
2422
2533
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2423
2534
  try {
2424
2535
  const body = JSON.parse((await readBody(req)) || '{}')
2425
- 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)
2426
2544
  if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
2427
2545
  return sendJson(res, 200, { ok: true, ...status() })
2428
2546
  } catch (err) {
@@ -0,0 +1,89 @@
1
+ // Machine-registry pure logic for dsh-remote.
2
+ //
3
+ // Issue #13: a SAVED machine is only a standby connection — it must never
4
+ // silently become the "active remote context" of every session. The registry
5
+ // therefore keeps `currentId` as an EXPLICIT user choice:
6
+ // • loadMachines never falls back to the first saved machine,
7
+ // • a stale currentId (machine deleted meanwhile) resolves to null,
8
+ // • add/update never auto-activate; only an explicit "set current" does,
9
+ // • deleting the current machine leaves NO current (no auto-promotion).
10
+ // Keeping this logic in one module makes the guarantee unit-testable and
11
+ // shared between the HTTP routes and the tools (rw_connect).
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ /** Load { list, currentId, explicitNone } from the registry file. */
17
+ export function loadMachines(file) {
18
+ try {
19
+ const j = JSON.parse(readFileSync(file, 'utf8'))
20
+ if (Array.isArray(j.list)) {
21
+ const list = j.list
22
+ // Explicit choice only — no fallback to the first machine, no activation
23
+ // of a stale/deleted id (issue #13).
24
+ const currentId = j.currentId && list.some((m) => m.id === j.currentId) ? j.currentId : null
25
+ // A persisted `currentId: null` is a deliberate "active remote = none"
26
+ // (the user cleared the current machine). It must survive restarts so a
27
+ // config-level default host cannot silently reactivate a context the user
28
+ // explicitly turned off. `explicitNone` is true when the file carries an
29
+ // explicit null (vs. a fresh file where currentId is simply absent).
30
+ const explicitNone = Object.prototype.hasOwnProperty.call(j, 'currentId') && j.currentId == null
31
+ return { list, currentId, explicitNone }
32
+ }
33
+ } catch {}
34
+ return { list: [], currentId: null, explicitNone: false }
35
+ }
36
+
37
+ /** Persist { list, currentId }. `currentId` may be null → active remote = none.
38
+ * Pass `keepCurrentKey: false` (add/update a machine without touching the
39
+ * current selection) to PRESERVE whatever `currentId` the file already holds
40
+ * — so merely saving a machine never flips a fresh registry into "explicit
41
+ * none", nor clears an explicit choice. When no previous file exists the
42
+ * `currentId` key is omitted entirely (fresh registry = "no choice yet"). */
43
+ export function saveMachines(file, list, currentId, keepCurrentKey = true) {
44
+ try { mkdirSync(path.dirname(file), { recursive: true }) } catch {}
45
+ let stored = currentId
46
+ let hasStored = keepCurrentKey
47
+ if (!keepCurrentKey) {
48
+ try {
49
+ const prev = JSON.parse(readFileSync(file, 'utf8'))
50
+ if (prev && Object.prototype.hasOwnProperty.call(prev, 'currentId')) {
51
+ stored = prev.currentId
52
+ hasStored = true
53
+ }
54
+ } catch { /* no previous file → omit the key */ }
55
+ }
56
+ const payload = { list }
57
+ if (hasStored) payload.currentId = stored
58
+ writeFileSync(file, JSON.stringify(payload, null, 2))
59
+ }
60
+
61
+ /** Strip secrets before sending a machine to the client. */
62
+ export function sanitizeMachine(m) {
63
+ if (!m) return m
64
+ const { password, proxy, ...rest } = m
65
+ const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
66
+ if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
67
+ return out
68
+ }
69
+
70
+ /** Apply a machine's fields onto the live config object (pool + tools read it). */
71
+ export function applyMachine(config, m) {
72
+ if (!m) return
73
+ config.host = m.host
74
+ config.port = Number(m.port) || 22
75
+ config.username = m.username || ''
76
+ config.password = m.password || ''
77
+ config.privateKeyPath = m.privateKeyPath || ''
78
+ config.passphrase = m.passphrase || ''
79
+ config.workspace = m.workspace || (config.workspace || '')
80
+ if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
81
+ if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
82
+ if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
83
+ config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
84
+ }
85
+
86
+ /** Mint a fresh machine id. */
87
+ export function machineId() {
88
+ return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 21 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",