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 +9 -0
- package/lib/client.js +25 -8
- package/lib/index.js +174 -56
- package/lib/registry.js +89 -0
- package/package.json +1 -1
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
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1698
|
-
|
|
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}:${
|
|
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')
|
|
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.
|
|
1863
|
-
//
|
|
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
|
-
|
|
1940
|
-
|
|
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
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
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
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
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
|
|
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) {
|
package/lib/registry.js
ADDED
|
@@ -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.
|
|
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>",
|