dsh-remote 0.8.51 → 0.8.52
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.en.md +7 -0
- package/README.md +21 -0
- package/lib/client.js +15 -3
- package/lib/index.js +193 -7
- package/lib/instructions.js +288 -0
- package/lib/openssh.js +10 -1
- package/lib/pool.js +29 -1
- package/lib/remote-fs.js +26 -1
- package/lib/sftp-surface.js +46 -3
- package/lib/sshconfig.js +13 -3
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -285,6 +285,13 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
285
285
|
**`@` lists remote files but the built-in read tool cannot open them** — the harness's own file tools see the **local mirror**, which stays empty until `rw_sync` downloads it. Read remote files with `rw_read_file` or the sidebar remote tab.
|
|
286
286
|
|
|
287
287
|
**A file link in a remote Session does not open** (issue #55) — fixed. When a link is clicked the harness turns it into `dsh-resource://file/session/<session>/<path>` and hands it to the built-in text preview, which reads the **local** filesystem; a remote Session's cwd is a **local mirror directory** (empty until `rw_sync`), so the open simply failed. The plugin now registers a higher-priority handler for that address and opens it over SFTP through the same editor as the sidebar "Remote Files" tab. The safety rule is deliberately conservative: it takes over only when the Session's cwd is **provably inside a mirror** and the mirror registry has loaded, so local Sessions — and the instant right after a page load — keep the built-in preview and no local file is ever mistaken for a remote one. The two cases that still cannot be mapped say so instead of showing a blank page: an address with no mirror mapping, and a Session whose workspace is not a remote mirror directory.
|
|
288
|
+
Two exceptions (0.8.52+): `.md`/`.markdown` links are **not** taken over and stay with the built-in Markdown renderer (issue #64 — the remote editor would only show source, a net loss for Markdown; it reads the local mirror copy, so `rw_sync` first for the latest content). And images never had a renderer on either path anyway: the sidebar remote tree now renders png/jpg/gif/webp/svg/bmp inline (issue #66; files over 2MB fall back to the download button).
|
|
289
|
+
|
|
290
|
+
**One SFTP timeout wedged every SFTP tool on that machine** (issue #65) — fixed (0.8.52+). A single operation hitting the 20s timeout used to poison the whole process for that machine (every later SFTP call paid its own 20s, while `rw_exec` stayed fine) until a disconnect/reconnect. Now a timeout kills the channel so later requests fail fast; the native backend tears the connection down and re-handshakes after two consecutive timeouts, and the openssh backend rebuilds its sftp child — a transient network blip no longer ends with "disconnect and reconnect". As a bonus, `rw_edit` now writes atomically (temp file + rename), so an interrupted write can no longer truncate the remote file to zero bytes, and the original permission bits are preserved.
|
|
291
|
+
|
|
292
|
+
**The machine's IP / username changed — must every workspace be recreated?** (issue #67) — no (0.8.52+). Edit the machine's new address on the settings page and save, or call `rw_connect(machineId=…, host=…, username=…, port=…)` with the new address: every workspace mirror bound to that machine is **rebound in place** (its `.dsh-remote-meta.json` is rewritten) — local content is kept, no re-sync needed, and the save response says how many mirrors were rebound. The mirror directory name does not follow (live Sessions hold cwd paths inside it); the name is a display label only and does not affect connectivity.
|
|
293
|
+
|
|
294
|
+
**Remote Sessions could not see the workspace AGENTS.md** (issue #68) — fixed (0.8.52+). The core instruction loader only knows the local filesystem, and a remote Session's cwd is the local mirror, so the remote repo's AGENTS.md never reached the model's context. Now, when `rw_pick_workspace` succeeds, the remote workspace gets a shallow scan (root + at most two levels down, matching `AGENTS.md`/`CLAUDE.md` only); the found files are synced into the local mirror under a three-way guard (root-level copies thus enter the core's own injection path, exactly like a local Session), and an index of paths + headings is rendered into the system prompt, telling the model to read nested instruction files with `rw_read_file` before editing. Full-tree recursion and plugin-side full-text injection are deliberately **not** done (the former would scan through multi-project roots; the latter would run a second injection pipeline in parallel with the core's).
|
|
288
295
|
|
|
289
296
|
**The terminal in a remote Session is still the local one** (issue #55) — that is **by design, and a plugin cannot change it**, but there is an equivalent: DSH's sidebar terminal is core, its PTY comes from the **context-global** `subprocess` service, and its cwd is the Session's own workspace directory (the local mirror for a remote Session). A plugin can neither replace that service nor retarget the core terminal onto SSH. For a **genuine remote terminal** use the "Remote DSH Web UI" (above): it opens the **remote machine's own** DSH in this browser, and that interface's terminal runs on the remote host, with no extra configuration. To run a single remote command instead, use `rw_exec` (add `pty: true` for an interactive PTY).
|
|
290
297
|
|
package/README.md
CHANGED
|
@@ -274,6 +274,27 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
274
274
|
本地会话与页面刚加载的那一瞬间仍然走内置预览 —— 不会把本地文件误当成远程文件。
|
|
275
275
|
两个仍然开不了的情况会给出明确提示而不是空白页:链接指向的是**绝对**远端路径之外无法映射的地址,
|
|
276
276
|
或该会话的工作区不是一个远程镜像目录。
|
|
277
|
+
两个例外(0.8.52+):`.md`/`.markdown` 链接**不被接管**、交还内置 Markdown 渲染(issue #64——远程编辑器只能看
|
|
278
|
+
源码,对 Markdown 是净损失;它读本地镜像副本,要最新内容先 `rw_sync`);图片文件则在内置链路与远程链路
|
|
279
|
+
都谈不上「渲染」,侧栏远程文件树现在直接内联显示 png/jpg/gif/webp/svg/bmp(issue #66,>2MB 退回下载按钮)。
|
|
280
|
+
|
|
281
|
+
**SFTP 操作一旦超时就永远卡死**(issue #65)—— 已修(0.8.52+)。旧版里一次撞满 20s 超时的 SFTP 操作会把
|
|
282
|
+
该机器在本进程内的全部 SFTP 工具拖到永久超时(`rw_exec` 却正常),只能断连重连。现在超时即置死该通道、
|
|
283
|
+
后续请求立即失败;native 后端连续两次超时会废弃整条连接重新握手,openssh 后端会重建 sftp 子进程——
|
|
284
|
+
一次网络抖动不再需要用「断开重连」来收场。顺带,`rw_edit` 改成了「临时文件 + rename」原子写,
|
|
285
|
+
写一半超时不会再把远端文件截成 0 字节,原文件的权限位也会保留。
|
|
286
|
+
|
|
287
|
+
**机器换了 IP / 用户名,工作区要全部重建吗?**(issue #67)—— 不用(0.8.52+)。在设置页编辑该机器的新
|
|
288
|
+
地址并保存,或 `rw_connect(machineId=…, host=…, username=…, port=…)` 同传新地址:该机器名下所有工作区
|
|
289
|
+
镜像会**原地重绑**(改写镜像的 `.dsh-remote-meta.json`),本地内容保留、无需重新同步,保存后会提示重绑了
|
|
290
|
+
几个镜像。镜像目录名不会跟着改(活 session 的 cwd 在其中),目录名只是显示标签、不影响连接。
|
|
291
|
+
|
|
292
|
+
**远程会话看不到工作区的 AGENTS.md**(issue #68)—— 已修(0.8.52+)。核心的指令加载只认本地文件系统,
|
|
293
|
+
而远程会话的 cwd 是本地镜像,远端仓库的 AGENTS.md 以前永远进不了模型上下文。现在 `rw_pick_workspace`
|
|
294
|
+
成功时会对远程工作区做浅扫(根 + 向下 ≤2 层,只匹配 `AGENTS.md`/`CLAUDE.md`),把命中的文件按三向安全
|
|
295
|
+
规则同步进本地镜像(根级文件由此进入核心自己的注入链路,与本地会话一致),并把路径+标题索引渲染进
|
|
296
|
+
system prompt,提示模型编辑前用 `rw_read_file` 阅读嵌套层的指令。全树递归扫描与插件侧全文注入都**刻意
|
|
297
|
+
不做**(前者会扫穿多项目根目录,后者会与核心的注入管线并行打架)。
|
|
277
298
|
|
|
278
299
|
**远程会话里的终端还是本机的**(issue #55)—— 这是**设计如此,插件改不了**,但有等效方案。
|
|
279
300
|
DSH 的侧栏终端是核心功能:它的 PTY 来自**全局唯一**的 `subprocess` 服务,cwd 取会话自己的
|
package/lib/client.js
CHANGED
|
@@ -179,6 +179,7 @@ window.__ModuleLoader__.load({
|
|
|
179
179
|
'settings.feedback': '💬 反馈建议 / 提 issue',
|
|
180
180
|
'settings.deleted': '已删除',
|
|
181
181
|
'settings.saved': '已保存更新',
|
|
182
|
+
'settings.rebound': '已将 {n} 个本地工作区镜像重新绑定到新地址(内容保留,无需重新同步)',
|
|
182
183
|
'settings.added': '已添加 — 可设为当前',
|
|
183
184
|
'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已回退为明文保存在 machines.json,连接仍可用。',
|
|
184
185
|
'settings.switched': '已切换为当前远程机',
|
|
@@ -467,6 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
467
468
|
'settings.feedback': '💬 Feedback / report issue',
|
|
468
469
|
'settings.deleted': 'Deleted',
|
|
469
470
|
'settings.saved': 'Saved',
|
|
471
|
+
'settings.rebound': 'Rebound {n} local workspace mirror(s) to the new address (content kept — no re-sync needed)',
|
|
470
472
|
'settings.added': 'Added — you can set it as current',
|
|
471
473
|
'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); it was saved as plaintext in machines.json and the machine still connects.',
|
|
472
474
|
'settings.switched': 'Switched to current remote machine',
|
|
@@ -1190,6 +1192,9 @@ window.__ModuleLoader__.load({
|
|
|
1190
1192
|
// Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
|
|
1191
1193
|
// 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
|
|
1192
1194
|
if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
|
|
1195
|
+
// Issue #67: an edited address rebinds the machine's existing mirrors —
|
|
1196
|
+
// say so, or the user cannot tell their workspaces came along.
|
|
1197
|
+
else if (r && r.reboundMirrors) setMsg(tr('settings.saved') + ' — ' + tr('settings.rebound', { n: r.reboundMirrors }))
|
|
1193
1198
|
else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
|
|
1194
1199
|
}).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
1195
1200
|
}
|
|
@@ -3237,7 +3242,7 @@ window.__ModuleLoader__.load({
|
|
|
3237
3242
|
React.useEffect(load, [path])
|
|
3238
3243
|
|
|
3239
3244
|
const startEdit = () => {
|
|
3240
|
-
if (!data || data.binary) return
|
|
3245
|
+
if (!data || data.binary || data.image) return
|
|
3241
3246
|
setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
|
|
3242
3247
|
setEdit(true); setErr('')
|
|
3243
3248
|
}
|
|
@@ -3276,7 +3281,7 @@ window.__ModuleLoader__.load({
|
|
|
3276
3281
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving || !dirty, title: tr('file.saveHint') }, saving ? tr('file.saving') : tr('file.saveToRemote')),
|
|
3277
3282
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, tr('file.cancel')))
|
|
3278
3283
|
: React.createElement(React.Fragment, null,
|
|
3279
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, tr('file.edit')),
|
|
3284
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary || data.image }, tr('file.edit')),
|
|
3280
3285
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, tr('file.download'))),
|
|
3281
3286
|
),
|
|
3282
3287
|
React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0, display: 'flex', flexDirection: 'column' } },
|
|
@@ -3285,7 +3290,10 @@ window.__ModuleLoader__.load({
|
|
|
3285
3290
|
: (data && data.binary
|
|
3286
3291
|
? React.createElement('div', { style: { padding: 12, fontSize: 13, color: T.warn } },
|
|
3287
3292
|
tr('file.binary', { size: (data.size != null ? data.size + ' bytes' : tr('file.unknownSize')) }))
|
|
3288
|
-
:
|
|
3293
|
+
: data && data.image
|
|
3294
|
+
? React.createElement('div', { style: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12, minHeight: 0 } },
|
|
3295
|
+
React.createElement('img', { src: `data:${data.mime};base64,${data.content}`, alt: baseName, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }))
|
|
3296
|
+
: (edit
|
|
3289
3297
|
? React.createElement('textarea', {
|
|
3290
3298
|
value: draft,
|
|
3291
3299
|
onChange: (e) => setDraft(e.target.value),
|
|
@@ -3636,6 +3644,10 @@ window.__ModuleLoader__.load({
|
|
|
3636
3644
|
canOpen: (address) => {
|
|
3637
3645
|
const parsed = parseCoreFileAddress(address)
|
|
3638
3646
|
if (!parsed) return false
|
|
3647
|
+
// Let the built-in Markdown preview keep .md/.markdown files: it can
|
|
3648
|
+
// render them, whereas the remote-link editor would only show source.
|
|
3649
|
+
const name = String(parsed.path || '').split(/[\\/]/).pop()
|
|
3650
|
+
if (/\.(md|markdown)$/i.test(name)) return false
|
|
3639
3651
|
const cwd = sessionCwdFor(parsed.sessionId)
|
|
3640
3652
|
// `MIRRORS.loaded` is checked so a cold start NEVER steals a local
|
|
3641
3653
|
// link: an empty registry cannot prove a mirror, and guessing would
|
package/lib/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshC
|
|
|
38
38
|
import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
|
|
39
39
|
import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
|
|
40
40
|
import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
|
|
41
|
+
import { createInstructionIndex, formatInstructionIndex } from './instructions.js'
|
|
41
42
|
import { searchRemote } from './search.js'
|
|
42
43
|
import { installTerminalShell } from './remote-shell.js'
|
|
43
44
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
@@ -1124,6 +1125,14 @@ export async function apply(ctx, config) {
|
|
|
1124
1125
|
autoPushWatchers.clear()
|
|
1125
1126
|
}, 'dsh-remote.autopush')
|
|
1126
1127
|
|
|
1128
|
+
// ── remote instruction-file index (issue #68) ────────────────────────────
|
|
1129
|
+
// The core AGENTS.md loader only sees the LOCAL mirror, so a remote repo's
|
|
1130
|
+
// instruction files never reach the model's context. This index shallow-
|
|
1131
|
+
// scans a bound workspace (root + ≤2 levels, AGENTS.md / CLAUDE.md only),
|
|
1132
|
+
// mirrors the finds into the local mirror (where the core loader looks),
|
|
1133
|
+
// and lists them in the system-prompt section below. See lib/instructions.js.
|
|
1134
|
+
const instructionIndex = createInstructionIndex()
|
|
1135
|
+
|
|
1127
1136
|
// Helpers shared by session-bound tools and the machine-scoped web routes take
|
|
1128
1137
|
// the pool explicitly (`p`), defaulting to the active-machine pool so route
|
|
1129
1138
|
// callers — which are machine-scoped by design — stay unchanged.
|
|
@@ -1225,6 +1234,106 @@ export async function apply(ctx, config) {
|
|
|
1225
1234
|
}
|
|
1226
1235
|
return out
|
|
1227
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Rebind every mirror recorded for one machine identity to a NEW identity
|
|
1240
|
+
* (issue #67): when a machine's IP/user/port changes, its workspaces must
|
|
1241
|
+
* follow instead of being recreated. The mirror's `.dsh-remote-meta.json` is
|
|
1242
|
+
* the authoritative binding — session routing (bindingFor), pool keying
|
|
1243
|
+
* (poolForMachine) and the sidebar badges all read identity FROM THE META,
|
|
1244
|
+
* not from the directory name — so rewriting the meta re-points every live
|
|
1245
|
+
* session at the new address while the local mirror content stays put.
|
|
1246
|
+
*
|
|
1247
|
+
* The host directory itself is deliberately NOT renamed: live sessions hold
|
|
1248
|
+
* cwd paths inside it, and a rename would dangle them into "session is
|
|
1249
|
+
* LOCAL" refusals (on Windows the rename usually fails outright while a
|
|
1250
|
+
* shell sits inside). The directory name is a display label only; a stale
|
|
1251
|
+
* label after a rebind is the documented cost of keeping sessions alive.
|
|
1252
|
+
*
|
|
1253
|
+
* Matching covers both machine kinds: an ALIAS machine's mirrors are found
|
|
1254
|
+
* through the recorded `alias` (their meta host is the resolved address at
|
|
1255
|
+
* creation time), a literal machine's through host+port+username.
|
|
1256
|
+
*
|
|
1257
|
+
* @param {object} prev - the machine record BEFORE the identity change.
|
|
1258
|
+
* @param {object} next - the machine record AFTER it.
|
|
1259
|
+
* @returns {number} how many mirrors were rebound.
|
|
1260
|
+
*/
|
|
1261
|
+
const rebindMirrors = (prev, next) => {
|
|
1262
|
+
if (!prev || !next) return 0
|
|
1263
|
+
const prevAlias = aliasOf(prev)
|
|
1264
|
+
const prevId = identityOf(prev)
|
|
1265
|
+
const prevRaw = { host: String(prev.host || ''), port: Number(prev.port) || 22, username: String(prev.username || '') }
|
|
1266
|
+
const nextAlias = aliasOf(next)
|
|
1267
|
+
const nextEff = withSshConfig(next) || next
|
|
1268
|
+
const nextId = {
|
|
1269
|
+
host: String(nextEff.host || next.host || ''),
|
|
1270
|
+
port: Number(nextEff.port || next.port) || 22,
|
|
1271
|
+
username: String(nextEff.username || next.username || ''),
|
|
1272
|
+
}
|
|
1273
|
+
const nextRaw = { host: String(next.host || ''), port: Number(next.port) || 22, username: String(next.username || '') }
|
|
1274
|
+
const sameIdentity = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username
|
|
1275
|
+
if (!prevAlias && sameIdentity(prevId, nextId) && sameIdentity(prevRaw, nextRaw)) return 0
|
|
1276
|
+
if (prevAlias && prevAlias === nextAlias) return 0
|
|
1277
|
+
const root = remoteWorkspacesRoot()
|
|
1278
|
+
let rebound = 0
|
|
1279
|
+
let hostDirs = []
|
|
1280
|
+
try { hostDirs = readdirSync(root) } catch { return 0 }
|
|
1281
|
+
for (const hostDir of hostDirs) {
|
|
1282
|
+
const hostPath = path.join(root, hostDir)
|
|
1283
|
+
if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1284
|
+
let entries = []
|
|
1285
|
+
try { entries = readdirSync(hostPath) } catch { continue }
|
|
1286
|
+
for (const entry of entries) {
|
|
1287
|
+
const metaPath = path.join(hostPath, entry, '.dsh-remote-meta.json')
|
|
1288
|
+
let meta = null
|
|
1289
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch { continue }
|
|
1290
|
+
if (!meta || !meta.host) continue
|
|
1291
|
+
const byAlias = prevAlias && String(meta.alias || '') === prevAlias
|
|
1292
|
+
// A meta records the RESOLVED identity at pick time, but that identity
|
|
1293
|
+
// can also have been written raw — match on either form of the old
|
|
1294
|
+
// identity so a rebind still finds mirrors recorded before a later
|
|
1295
|
+
// ssh-config resolution rewrote what "this machine" meant.
|
|
1296
|
+
const metaId = { host: String(meta.host), port: Number(meta.port) || 22, username: String(meta.username || '') }
|
|
1297
|
+
const byIdentity = sameIdentity(metaId, prevId) || sameIdentity(metaId, prevRaw)
|
|
1298
|
+
if (!byAlias && !byIdentity) continue
|
|
1299
|
+
const updated = { ...meta }
|
|
1300
|
+
if (nextAlias) {
|
|
1301
|
+
// Alias machine: the recorded alias is what future resolution keys
|
|
1302
|
+
// on; the resolved address refreshes only when it actually resolved
|
|
1303
|
+
// (an unmatched alias keeps the old literal values rather than
|
|
1304
|
+
// stamping the alias NAME into the address fields).
|
|
1305
|
+
updated.alias = nextAlias
|
|
1306
|
+
if (nextEff.sshConfigMatched || nextEff.sshAlias) {
|
|
1307
|
+
updated.host = nextId.host
|
|
1308
|
+
updated.port = nextId.port
|
|
1309
|
+
updated.username = nextId.username
|
|
1310
|
+
}
|
|
1311
|
+
} else {
|
|
1312
|
+
delete updated.alias
|
|
1313
|
+
updated.host = nextRaw.host
|
|
1314
|
+
updated.port = nextRaw.port
|
|
1315
|
+
updated.username = nextRaw.username
|
|
1316
|
+
}
|
|
1317
|
+
updated.reboundAt = new Date().toISOString()
|
|
1318
|
+
try {
|
|
1319
|
+
writeFileSync(metaPath, JSON.stringify(updated, null, 2))
|
|
1320
|
+
rebound++
|
|
1321
|
+
} catch { /* leave that mirror as-is */ }
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (rebound) {
|
|
1325
|
+
// The old identity's pooled connection dials an address the user just
|
|
1326
|
+
// abandoned: drop it so nothing keeps a dead SSH session warm. The new
|
|
1327
|
+
// identity gets its pool lazily on the next tool call.
|
|
1328
|
+
const stale = machinePools.get(poolKey(prevId))
|
|
1329
|
+
if (stale) {
|
|
1330
|
+
machinePools.delete(poolKey(prevId))
|
|
1331
|
+
try { stale.close() } catch { /* already gone */ }
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return rebound
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1228
1337
|
if (config.autoPush && wsPath()) {
|
|
1229
1338
|
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
1230
1339
|
}
|
|
@@ -1614,10 +1723,10 @@ export async function apply(ctx, config) {
|
|
|
1614
1723
|
defineTool({
|
|
1615
1724
|
name: 'rw_connect',
|
|
1616
1725
|
description:
|
|
1617
|
-
'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down.',
|
|
1726
|
+
'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down. Rebinding (issue #67): pass machineId TOGETHER with a new host/username/port to update that saved machine in place — every workspace mirror bound to its old address is re-pointed (local content kept, no re-sync) instead of forcing new workspaces.',
|
|
1618
1727
|
parameters: {
|
|
1619
1728
|
host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
|
|
1620
|
-
machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
|
|
1729
|
+
machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed — EXCEPT for rebinding (issue #67): combine machineId with a new host/username/port to update the machine in place and re-point its existing workspace mirrors.' },
|
|
1621
1730
|
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
1622
1731
|
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
1623
1732
|
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
@@ -1634,6 +1743,9 @@ export async function apply(ctx, config) {
|
|
|
1634
1743
|
},
|
|
1635
1744
|
output: textOut,
|
|
1636
1745
|
async execute(args) {
|
|
1746
|
+
// Issue #67: set by the machineId+overrides path, prepended to the
|
|
1747
|
+
// success line so the rebind is visible in the tool result.
|
|
1748
|
+
let reboundNote = ''
|
|
1637
1749
|
const mid = String(args.machineId || '').trim()
|
|
1638
1750
|
if (mid) {
|
|
1639
1751
|
// Connect with an ALREADY SAVED machine (issue #48): the registry
|
|
@@ -1641,6 +1753,25 @@ export async function apply(ctx, config) {
|
|
|
1641
1753
|
// keychain-backed passwords, stored passphrases, proxies, aliases.
|
|
1642
1754
|
const i = machineIndex(mid)
|
|
1643
1755
|
if (i < 0) throw new Error(`rw_connect: no saved machine "${mid}" — list saved machines with rw_machines`)
|
|
1756
|
+
// Issue #67: passing host/username/port ALONGSIDE a machineId rebinds
|
|
1757
|
+
// the saved machine in place — the fix for "the IP changed, do not
|
|
1758
|
+
// make me recreate every workspace". The record keeps its id (and
|
|
1759
|
+
// every credential), and each mirror bound to the old identity is
|
|
1760
|
+
// re-pointed so live sessions keep working against the new address.
|
|
1761
|
+
const prev = machines[i]
|
|
1762
|
+
const overrides = {}
|
|
1763
|
+
if (args.host !== undefined && String(args.host).trim()) overrides.host = String(args.host).trim()
|
|
1764
|
+
if (args.username !== undefined && String(args.username).trim()) overrides.username = String(args.username).trim()
|
|
1765
|
+
if (args.port !== undefined && Number(args.port)) overrides.port = Number(args.port)
|
|
1766
|
+
let rebound = 0
|
|
1767
|
+
if (Object.keys(overrides).length) {
|
|
1768
|
+
const next = { ...prev, ...overrides }
|
|
1769
|
+
try { rebound = rebindMirrors(prev, next) } catch { /* best effort */ }
|
|
1770
|
+
machines[i] = next
|
|
1771
|
+
}
|
|
1772
|
+
if (rebound) {
|
|
1773
|
+
reboundNote = `Rebound ${rebound} workspace mirror(s) from ${prev.username}@${prev.host}:${prev.port} to the new address (local content kept — no re-sync needed).\n`
|
|
1774
|
+
}
|
|
1644
1775
|
store.currentId = machines[i].id
|
|
1645
1776
|
ephemeral = null
|
|
1646
1777
|
saveMachines(machines, store.currentId)
|
|
@@ -1749,7 +1880,7 @@ export async function apply(ctx, config) {
|
|
|
1749
1880
|
const via = active && active.sshAlias
|
|
1750
1881
|
? `\n(ssh-config alias "${active.sshAlias}" → ${effUser}@${effHost}:${effPort}` + (active.sshConfigWarnings && active.sshConfigWarnings.length ? `; ${active.sshConfigWarnings.join(' ')}` : '') + ')'
|
|
1751
1882
|
: ''
|
|
1752
|
-
return { text:
|
|
1883
|
+
return { text: `${reboundNote}Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
1753
1884
|
} catch (err) {
|
|
1754
1885
|
throw new Error(friendlyMessage(err, { host: effHost, port: effPort }))
|
|
1755
1886
|
}
|
|
@@ -1825,6 +1956,12 @@ export async function apply(ctx, config) {
|
|
|
1825
1956
|
if (!b.bound) persistWorkspace(p)
|
|
1826
1957
|
const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
|
|
1827
1958
|
startAutoPush(local)
|
|
1959
|
+
// Issue #68: discover the workspace's AGENTS.md / CLAUDE.md NOW — the
|
|
1960
|
+
// first prompt assembly can come before any rw_sync, and the mirror
|
|
1961
|
+
// copy must already be in place for the core instruction loader to
|
|
1962
|
+
// pick it up. Awaited (the scan is a handful of shallow readdirs) but
|
|
1963
|
+
// fully best-effort: a scan failure never fails the pick.
|
|
1964
|
+
try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
|
|
1828
1965
|
// The closing hint depends on where THIS session stands (issue #63):
|
|
1829
1966
|
// in a LOCAL session "Run rw_sync" is a dead instruction — rw_sync
|
|
1830
1967
|
// refuses a local session — so say the mirror must be OPENED first.
|
|
@@ -1878,11 +2015,21 @@ export async function apply(ctx, config) {
|
|
|
1878
2015
|
}
|
|
1879
2016
|
return { text }
|
|
1880
2017
|
}
|
|
2018
|
+
// Issue #68: a real sync can pull in (or obsolete) instruction files —
|
|
2019
|
+
// refresh the prompt index afterwards. Kept inside the task body so a
|
|
2020
|
+
// background sync refreshes when IT finishes, not when it is queued.
|
|
2021
|
+
const runSyncAndIndex = async () => {
|
|
2022
|
+
const r = await runSync()
|
|
2023
|
+
if (!args.dryRun) {
|
|
2024
|
+
try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
|
|
2025
|
+
}
|
|
2026
|
+
return r
|
|
2027
|
+
}
|
|
1881
2028
|
if (args.async) {
|
|
1882
|
-
const t = tasks.start('sync', `sync ${p}`,
|
|
2029
|
+
const t = tasks.start('sync', `sync ${p}`, runSyncAndIndex)
|
|
1883
2030
|
return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
|
|
1884
2031
|
}
|
|
1885
|
-
return
|
|
2032
|
+
return runSyncAndIndex()
|
|
1886
2033
|
},
|
|
1887
2034
|
}),
|
|
1888
2035
|
|
|
@@ -2137,7 +2284,26 @@ export async function apply(ctx, config) {
|
|
|
2137
2284
|
if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
|
|
2138
2285
|
throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
|
|
2139
2286
|
}
|
|
2140
|
-
|
|
2287
|
+
// Atomic write: stage to a temp file and rename over the target so a
|
|
2288
|
+
// mid-write failure cannot truncate the original (issue #65). Same
|
|
2289
|
+
// directory keeps the rename on one filesystem.
|
|
2290
|
+
const tmpPath = `${p}.dsh-remote-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
2291
|
+
try {
|
|
2292
|
+
await sftp.writeFile(tmpPath, encodeText(out, args.encoding || config.encoding))
|
|
2293
|
+
// renameOverwrite, not rename: the target EXISTS by definition here,
|
|
2294
|
+
// and plain SSH_FXP_RENAME refuses to replace it (issue #65).
|
|
2295
|
+
await sftp.renameOverwrite(tmpPath, p)
|
|
2296
|
+
// The rename swaps in a FRESH inode, so the original permission bits
|
|
2297
|
+
// (an executable script's +x, a 0600 secret) would silently reset to
|
|
2298
|
+
// the umask default. Restore them; a chmod failure must not fail the
|
|
2299
|
+
// edit itself — the content is already safely in place.
|
|
2300
|
+
if (st0 && typeof st0.mode === 'number') {
|
|
2301
|
+
try { await sftp.setstat(p, { mode: st0.mode & 0o7777 }) } catch { /* best effort */ }
|
|
2302
|
+
}
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
try { await sftp.unlink(tmpPath) } catch { /* best effort */ }
|
|
2305
|
+
throw err
|
|
2306
|
+
}
|
|
2141
2307
|
audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
|
|
2142
2308
|
return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
|
|
2143
2309
|
},
|
|
@@ -2549,7 +2715,7 @@ export async function apply(ctx, config) {
|
|
|
2549
2715
|
const session = agent && agent.session
|
|
2550
2716
|
const cwd = session && session.header && session.header.cwd
|
|
2551
2717
|
if (!cwd) return '' // no session cwd → nothing to map → stay quiet
|
|
2552
|
-
const { remotePath, machine } = resolveMirrorForLocal(cwd)
|
|
2718
|
+
const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd)
|
|
2553
2719
|
if (!remotePath) return '' // this session is NOT a remote session (issue #13)
|
|
2554
2720
|
// Name the machine the MIRROR records, not the active one: pairing this
|
|
2555
2721
|
// session's remote path with whichever machine happens to be active
|
|
@@ -2562,6 +2728,17 @@ export async function apply(ctx, config) {
|
|
|
2562
2728
|
const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
|
|
2563
2729
|
let extra = ''
|
|
2564
2730
|
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
2731
|
+
// Issue #68: surface the workspace's AGENTS.md / CLAUDE.md. The scan is
|
|
2732
|
+
// lazy (fire-and-forget on first assembly; negative-cached on failure),
|
|
2733
|
+
// so the index appears from the NEXT assembly once known — rw_pick_workspace
|
|
2734
|
+
// scans eagerly, so a picked workspace normally has it from the start.
|
|
2735
|
+
if (machine && mirrorDir) {
|
|
2736
|
+
instructionIndex.note({ pool: poolForMachine(machine), ws: remotePath, mirrorDir })
|
|
2737
|
+
const instr = instructionIndex.get(mirrorDir)
|
|
2738
|
+
if (instr && instr.files && instr.files.length) {
|
|
2739
|
+
extra += `\n${formatInstructionIndex(instr.files, (p) => toDisplayPath(p, platform))}`
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2565
2742
|
// Issue #39: `@` mentions are resolved from the REMOTE tree by
|
|
2566
2743
|
// lib/file-reference.js, so the model must know they are remote-relative
|
|
2567
2744
|
// and that the built-in read tool sees only the local mirror.
|
|
@@ -3193,6 +3370,14 @@ export async function apply(ctx, config) {
|
|
|
3193
3370
|
// alone, so a fresh registry stays "no choice yet" (not an
|
|
3194
3371
|
// explicit none) and a previously-cleared registry stays cleared.
|
|
3195
3372
|
saveMachines(machines, store.currentId, false)
|
|
3373
|
+
// Issue #67: an EDITED connection identity (host/user/port) must
|
|
3374
|
+
// carry the machine's existing workspaces over — without this the
|
|
3375
|
+
// mirrors stayed bound to the old address and the user had to
|
|
3376
|
+
// create (and re-sync) a brand-new workspace for the new IP.
|
|
3377
|
+
let reboundMirrors = 0
|
|
3378
|
+
if (i >= 0 && prev) {
|
|
3379
|
+
try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
|
|
3380
|
+
}
|
|
3196
3381
|
// Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
|
|
3197
3382
|
// Saving a literal machine upserts a marker-tagged Host block,
|
|
3198
3383
|
// so the file and the registry can never drift; the record's
|
|
@@ -3234,6 +3419,7 @@ export async function apply(ctx, config) {
|
|
|
3234
3419
|
machine: sanitizeMachine(rec),
|
|
3235
3420
|
machines: machinesForClient(),
|
|
3236
3421
|
currentId: store.currentId,
|
|
3422
|
+
...(reboundMirrors ? { reboundMirrors } : {}),
|
|
3237
3423
|
...(warning ? { warning, warningDetail } : {}),
|
|
3238
3424
|
...(sshConfigWarning ? { sshConfigWarning } : {}),
|
|
3239
3425
|
})
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// dsh-remote — remote instruction-file index (issue #68).
|
|
2
|
+
//
|
|
3
|
+
// The core instruction loader (@deepseek-ai/dsh-agent-instructions) discovers
|
|
4
|
+
// AGENTS.md through the LOCAL filesystem only: from two user-global roots plus
|
|
5
|
+
// the projectRoot→cwd ancestor chain. A remote session's cwd is the local
|
|
6
|
+
// MIRROR of the remote workspace, and that mirror stays empty until rw_sync —
|
|
7
|
+
// so a 20 KB AGENTS.md that governs the remote repo never enters the model's
|
|
8
|
+
// context, while the same file is auto-injected in a local session. File
|
|
9
|
+
// ACCESS was never the problem (rw_read_file reads it fine); what is missing
|
|
10
|
+
// is the model knowing it should.
|
|
11
|
+
//
|
|
12
|
+
// This module closes the gap on the plugin side, along the two cheapest axes
|
|
13
|
+
// the issue lists (the third — a remote ctx.fs provider — belongs to core):
|
|
14
|
+
//
|
|
15
|
+
// 1. INDEX in the system prompt: a shallow scan of the remote workspace
|
|
16
|
+
// (root + at most two directory levels down, AGENTS.md / CLAUDE.md only)
|
|
17
|
+
// is rendered into the dsh-remote prompt section as a path list with
|
|
18
|
+
// headings, telling every new session the files exist and to read them
|
|
19
|
+
// with rw_read_file before editing that tree. This is an INDEX, not a
|
|
20
|
+
// second injection pipeline — the full text stays out of the prompt and
|
|
21
|
+
// out of any parallel baseline, exactly as the issue demands.
|
|
22
|
+
// 2. MIRROR the found files into the local mirror at their relative paths
|
|
23
|
+
// (three-way safe — a local edit is never clobbered, see below). A
|
|
24
|
+
// root-level AGENTS.md then sits exactly where the CORE loader looks in
|
|
25
|
+
// a local session, so the normal baseline/incremental machinery picks it
|
|
26
|
+
// up unchanged; no plugin-side injection logic runs in parallel with it.
|
|
27
|
+
//
|
|
28
|
+
// Deliberate non-goals (the issue's "请不要做"):
|
|
29
|
+
// - NO unbounded recursion of the remote root (multi-project roots like
|
|
30
|
+
// /root would scan forever): the walk is depth- and count-capped.
|
|
31
|
+
// - NO full-text injection from the plugin.
|
|
32
|
+
|
|
33
|
+
import { existsSync, mkdirSync, statSync, writeFileSync, utimesSync } from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { joinRemotePath, relPathUnder } from './paths.js'
|
|
36
|
+
import { loadSyncState, saveSyncState } from './sync.js'
|
|
37
|
+
|
|
38
|
+
/** File names treated as agent instruction files (compared case-insensitively;
|
|
39
|
+
* the mirrored copy keeps the remote's exact casing). */
|
|
40
|
+
export const INSTRUCTION_NAMES = ['agents.md', 'claude.md']
|
|
41
|
+
|
|
42
|
+
/** An instruction file bigger than this is indexed (path + note) but not read. */
|
|
43
|
+
export const INSTRUCTION_MAX_BYTES = 256 * 1024
|
|
44
|
+
|
|
45
|
+
/** Caps that keep the shallow scan shallow. */
|
|
46
|
+
export const INSTRUCTION_MAX_FILES = 20
|
|
47
|
+
export const INSTRUCTION_MAX_DIRS = 60
|
|
48
|
+
export const INSTRUCTION_MAX_DEPTH = 2 // 0 = workspace root, 1 = sub/, 2 = sub/sub/
|
|
49
|
+
|
|
50
|
+
/** Directories that never contain a project's governing instructions. */
|
|
51
|
+
const SKIP_DIRS = new Set([
|
|
52
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
|
|
53
|
+
'.next', '.nuxt', '.cache', '.turbo', 'coverage', 'target', 'vendor',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
export const isInstructionName = (name) => INSTRUCTION_NAMES.includes(String(name || '').toLowerCase())
|
|
57
|
+
|
|
58
|
+
/** Pull the first Markdown headings out of an instruction file, for the index. */
|
|
59
|
+
export function extractHeadings(text, max = 8) {
|
|
60
|
+
const out = []
|
|
61
|
+
for (const line of String(text || '').split('\n')) {
|
|
62
|
+
const m = /^#{1,3}\s+(.+?)\s*#*\s*$/.exec(line)
|
|
63
|
+
if (!m) continue
|
|
64
|
+
out.push(m[1].slice(0, 60))
|
|
65
|
+
if (out.length >= max) break
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shallow-scan a remote workspace for instruction files: the root directory
|
|
72
|
+
* plus up to INSTRUCTION_MAX_DEPTH levels of subdirectories, matching
|
|
73
|
+
* AGENTS.md / CLAUDE.md basenames. Bounded by INSTRUCTION_MAX_DIRS readdirs
|
|
74
|
+
* and INSTRUCTION_MAX_FILES files; anything past the caps is silently
|
|
75
|
+
* skipped (the index is a hint, not an inventory).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} sftp - the promisified sftp surface (readdir/stat/readFile).
|
|
78
|
+
* @param {string} remoteRoot - absolute remote workspace path (normalized).
|
|
79
|
+
* @returns {Promise<Array<{rel: string, remote: string, size: number, mtime: number, content: Buffer|null, headings: string[]}>>}
|
|
80
|
+
* `content`/`headings` are null/empty for files over INSTRUCTION_MAX_BYTES.
|
|
81
|
+
*/
|
|
82
|
+
export async function scanInstructionFiles(sftp, remoteRoot, opts = {}) {
|
|
83
|
+
const maxDepth = Math.max(0, Math.min(Number(opts.maxDepth ?? INSTRUCTION_MAX_DEPTH) || 0, 4))
|
|
84
|
+
const maxDirs = Math.max(1, Number(opts.maxDirs ?? INSTRUCTION_MAX_DIRS) || INSTRUCTION_MAX_DIRS)
|
|
85
|
+
const maxFiles = Math.max(1, Number(opts.maxFiles ?? INSTRUCTION_MAX_FILES) || INSTRUCTION_MAX_FILES)
|
|
86
|
+
const files = []
|
|
87
|
+
// Breadth-first so a wide root cannot push the walk deep before the shallow
|
|
88
|
+
// (more commonly governing) files are seen.
|
|
89
|
+
const queue = [{ dir: remoteRoot, depth: 0 }]
|
|
90
|
+
let dirsScanned = 0
|
|
91
|
+
while (queue.length && files.length < maxFiles && dirsScanned < maxDirs) {
|
|
92
|
+
const { dir, depth } = queue.shift()
|
|
93
|
+
dirsScanned++
|
|
94
|
+
let entries = []
|
|
95
|
+
try { entries = (await sftp.readdir(dir)) || [] } catch { continue }
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const name = String(e.filename || '')
|
|
98
|
+
if (!name || name === '.' || name === '..' || name.startsWith('.')) {
|
|
99
|
+
// Hidden entries are skipped wholesale EXCEPT the names themselves
|
|
100
|
+
// never start with a dot, so nothing is lost.
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const isDir = !!(e.attrs && typeof e.attrs.isDirectory === 'function' && e.attrs.isDirectory())
|
|
104
|
+
const isLink = !!(e.attrs && typeof e.attrs.isSymbolicLink === 'function' && e.attrs.isSymbolicLink())
|
|
105
|
+
if (isDir) {
|
|
106
|
+
// Symlinked dirs are not followed: the walk stays inside the workspace
|
|
107
|
+
// tree and cannot loop through a link back to an ancestor.
|
|
108
|
+
if (isLink) continue
|
|
109
|
+
if (depth < maxDepth && !SKIP_DIRS.has(name.toLowerCase()) && queue.length + dirsScanned < maxDirs) {
|
|
110
|
+
queue.push({ dir: joinRemotePath(dir, name), depth: depth + 1 })
|
|
111
|
+
}
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (!isInstructionName(name)) continue
|
|
115
|
+
const remote = joinRemotePath(dir, name)
|
|
116
|
+
const rel = relPathUnder(remoteRoot, remote) || name
|
|
117
|
+
let st = null
|
|
118
|
+
try { st = await sftp.stat(remote) } catch { continue }
|
|
119
|
+
const size = st && typeof st.size === 'number' ? st.size : 0
|
|
120
|
+
const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
|
|
121
|
+
let content = null
|
|
122
|
+
let headings = []
|
|
123
|
+
if (size <= INSTRUCTION_MAX_BYTES) {
|
|
124
|
+
try {
|
|
125
|
+
content = await sftp.readFile(remote)
|
|
126
|
+
headings = extractHeadings(content.toString('utf8'))
|
|
127
|
+
} catch { content = null }
|
|
128
|
+
}
|
|
129
|
+
files.push({ rel, remote, size, mtime, content, headings })
|
|
130
|
+
if (files.length >= maxFiles) break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return files
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The three-way keys mirror lib/sync.js exactly (size + whole-second mtime):
|
|
137
|
+
// a file this module places into the mirror must be indistinguishable from
|
|
138
|
+
// one rw_sync pulled, or the next sync would report a phantom conflict.
|
|
139
|
+
const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
|
|
140
|
+
const localKeyOf = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Write scanned instruction files into the local mirror at their workspace-
|
|
144
|
+
* relative paths, three-way safe against `.dsh-remote-sync-state.json`:
|
|
145
|
+
*
|
|
146
|
+
* - local missing → write (+ align mtime, record state)
|
|
147
|
+
* - local identical to remote → keep (record state if absent)
|
|
148
|
+
* - remote changed, local untouched (L==S) → write (a plain pull)
|
|
149
|
+
* - local edited (L!=S), remote unchanged → SKIP — a local edit is never
|
|
150
|
+
* clobbered; rw_sync surfaces it as a conflict like any other file
|
|
151
|
+
* - both changed / unknown local content → SKIP (rw_sync arbitrates)
|
|
152
|
+
*
|
|
153
|
+
* @returns {{written: string[], present: Set<string>}} rel paths written now,
|
|
154
|
+
* and every rel that exists locally afterwards (for the index's "mirrored"
|
|
155
|
+
* badge).
|
|
156
|
+
*/
|
|
157
|
+
export function syncInstructionsToMirror(files, mirrorDir) {
|
|
158
|
+
const written = []
|
|
159
|
+
const present = new Set()
|
|
160
|
+
if (!mirrorDir || !Array.isArray(files) || !files.length) return { written, present }
|
|
161
|
+
const state = loadSyncState(mirrorDir)
|
|
162
|
+
let stateDirty = false
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
if (!f || !f.rel || !f.content) continue
|
|
165
|
+
const lp = path.join(mirrorDir, ...String(f.rel).split('/'))
|
|
166
|
+
const rk = keyOf(f.size, f.mtime)
|
|
167
|
+
const sk = state[f.rel] ? keyOf(state[f.rel].size, state[f.rel].mtime) : null
|
|
168
|
+
let lk = null
|
|
169
|
+
try { lk = existsSync(lp) ? localKeyOf(statSync(lp)) : null } catch { lk = null }
|
|
170
|
+
if (lk === rk) {
|
|
171
|
+
// Identical bytes in place already (same size AND mtime second) — only
|
|
172
|
+
// make sure the snapshot knows about it.
|
|
173
|
+
present.add(f.rel)
|
|
174
|
+
if (!sk) { state[f.rel] = { size: f.size, mtime: f.mtime }; stateDirty = true }
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const canWrite = lk === null || (sk !== null && rk !== sk && lk === sk)
|
|
178
|
+
if (!canWrite) continue
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(path.dirname(lp), { recursive: true })
|
|
181
|
+
writeFileSync(lp, f.content)
|
|
182
|
+
// Align the local mtime to the remote's so the next rw_sync sees
|
|
183
|
+
// L==R==S and skips the file instead of flagging a local modification.
|
|
184
|
+
try { utimesSync(lp, new Date(f.mtime * 1000), new Date(f.mtime * 1000)) } catch { /* cosmetic */ }
|
|
185
|
+
state[f.rel] = { size: f.size, mtime: f.mtime }
|
|
186
|
+
stateDirty = true
|
|
187
|
+
written.push(f.rel)
|
|
188
|
+
present.add(f.rel)
|
|
189
|
+
} catch { /* a mirror write failure must not break the index */ }
|
|
190
|
+
}
|
|
191
|
+
if (stateDirty) {
|
|
192
|
+
try { saveSyncState(mirrorDir, state) } catch { /* best effort */ }
|
|
193
|
+
}
|
|
194
|
+
return { written, present }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render the prompt-section index of remote instruction files. Paths are
|
|
199
|
+
* shown through `display` (the platform-aware display formatter), headings
|
|
200
|
+
* give the model a content hint without injecting the full text.
|
|
201
|
+
*/
|
|
202
|
+
export function formatInstructionIndex(files, display = (p) => p) {
|
|
203
|
+
const lines = files.slice(0, 10).map((f) => {
|
|
204
|
+
const h = f.headings && f.headings.length ? ` (headings: ${f.headings.slice(0, 6).join(' / ')})` : ''
|
|
205
|
+
const over = f.size > INSTRUCTION_MAX_BYTES ? ` (${Math.round(f.size / 1024)} KB)` : ''
|
|
206
|
+
const mirrored = f.mirrored ? '; synced into the local mirror' : ''
|
|
207
|
+
return `- ${display(f.remote)}${h}${over}${mirrored}`
|
|
208
|
+
})
|
|
209
|
+
return (
|
|
210
|
+
'Remote instruction files found in this workspace (they govern how to work in their directories; '
|
|
211
|
+
+ 'root-level copies were synced into the local mirror, but nested ones are NOT auto-injected — '
|
|
212
|
+
+ 'read them with rw_read_file before editing that tree):\n'
|
|
213
|
+
+ lines.join('\n')
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Per-process index of remote instruction scans, keyed by mirror directory.
|
|
219
|
+
* Deduplicates in-flight scans, negative-caches failures for a minute (an
|
|
220
|
+
* offline machine must not turn every prompt assembly into a connect
|
|
221
|
+
* attempt), and never throws — the index is strictly best-effort.
|
|
222
|
+
*/
|
|
223
|
+
export function createInstructionIndex() {
|
|
224
|
+
const cache = new Map() // resolvedMirrorDir → { scannedAt, files } | { failedAt }
|
|
225
|
+
const inflight = new Map() // resolvedMirrorDir → Promise
|
|
226
|
+
const RETRY_AFTER_MS = 60 * 1000
|
|
227
|
+
|
|
228
|
+
const keyOfDir = (mirrorDir) => {
|
|
229
|
+
try { return path.resolve(String(mirrorDir)) } catch { return String(mirrorDir || '') }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Scan (or return the cached scan of) one mirror's remote workspace.
|
|
234
|
+
* @param {{pool: {sftp: Function}, ws: string, mirrorDir: string, force?: boolean}} arg
|
|
235
|
+
* @returns {Promise<{scannedAt: number, files: Array}|null>}
|
|
236
|
+
*/
|
|
237
|
+
const refresh = ({ pool, ws, mirrorDir, force = false }) => {
|
|
238
|
+
if (!pool || typeof pool.sftp !== 'function' || !ws || !mirrorDir) return Promise.resolve(null)
|
|
239
|
+
const key = keyOfDir(mirrorDir)
|
|
240
|
+
const cur = cache.get(key)
|
|
241
|
+
if (!force && cur && cur.files) return Promise.resolve(cur)
|
|
242
|
+
if (!force && cur && cur.failedAt && Date.now() - cur.failedAt < RETRY_AFTER_MS) return Promise.resolve(null)
|
|
243
|
+
if (inflight.has(key)) return inflight.get(key)
|
|
244
|
+
const job = (async () => {
|
|
245
|
+
try {
|
|
246
|
+
const sftp = await pool.sftp()
|
|
247
|
+
const files = await scanInstructionFiles(sftp, ws)
|
|
248
|
+
const synced = syncInstructionsToMirror(files, mirrorDir)
|
|
249
|
+
const entry = {
|
|
250
|
+
scannedAt: Date.now(),
|
|
251
|
+
files: files.map((f) => ({
|
|
252
|
+
rel: f.rel,
|
|
253
|
+
remote: f.remote,
|
|
254
|
+
size: f.size,
|
|
255
|
+
headings: f.headings,
|
|
256
|
+
mirrored: synced.present.has(f.rel),
|
|
257
|
+
})),
|
|
258
|
+
}
|
|
259
|
+
cache.set(key, entry)
|
|
260
|
+
return entry
|
|
261
|
+
} catch {
|
|
262
|
+
// Negative-cache: the machine may simply be offline; retry at most
|
|
263
|
+
// once a minute, not once per prompt assembly.
|
|
264
|
+
cache.set(key, { failedAt: Date.now() })
|
|
265
|
+
return null
|
|
266
|
+
} finally {
|
|
267
|
+
inflight.delete(key)
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
inflight.set(key, job)
|
|
271
|
+
return job
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The cached index for a mirror, or null when unknown/failed. */
|
|
275
|
+
const get = (mirrorDir) => {
|
|
276
|
+
const e = cache.get(keyOfDir(mirrorDir))
|
|
277
|
+
return e && e.files ? e : null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Fire-and-forget refresh for prompt-assembly time: starts a scan only
|
|
281
|
+
* when nothing is known about the mirror yet (recent failures included). */
|
|
282
|
+
const note = ({ pool, ws, mirrorDir }) => {
|
|
283
|
+
if (cache.has(keyOfDir(mirrorDir))) return
|
|
284
|
+
refresh({ pool, ws, mirrorDir }).catch(() => { /* guarded inside */ })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { refresh, get, note, _cache: cache }
|
|
288
|
+
}
|
package/lib/openssh.js
CHANGED
|
@@ -785,7 +785,16 @@ export class SystemOpenSshPool {
|
|
|
785
785
|
throw new Error('ssh target changed while opening sftp')
|
|
786
786
|
}
|
|
787
787
|
this._sftpChild = child
|
|
788
|
-
return buildSftpSurface(sftp, {
|
|
788
|
+
return buildSftpSurface(sftp, {
|
|
789
|
+
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
790
|
+
onTimeout: () => {
|
|
791
|
+
// A timed-out SFTP session is dead: kill the child and drop the cached
|
|
792
|
+
// session so the next call re-handshakes instead of queueing behind the
|
|
793
|
+
// wedged one (issue #65).
|
|
794
|
+
try { child.kill() } catch { /* already gone */ }
|
|
795
|
+
drop()
|
|
796
|
+
},
|
|
797
|
+
})
|
|
789
798
|
})
|
|
790
799
|
// Never cache a rejected open: the next call must retry rather than get the
|
|
791
800
|
// same error back forever (a failed handshake is usually transient).
|
package/lib/pool.js
CHANGED
|
@@ -197,6 +197,8 @@ export class SshPool {
|
|
|
197
197
|
this.onReady = null
|
|
198
198
|
/** Optional hook called when the pool closes. */
|
|
199
199
|
this.onCloseHook = null
|
|
200
|
+
/** Consecutive SFTP subchannel-open timeouts (issue #65). */
|
|
201
|
+
this._sftpTimeouts = 0
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
resolveKeyPath() {
|
|
@@ -324,6 +326,7 @@ export class SshPool {
|
|
|
324
326
|
return
|
|
325
327
|
}
|
|
326
328
|
this.client = client
|
|
329
|
+
this._sftpTimeouts = 0
|
|
327
330
|
resolve(client)
|
|
328
331
|
if (this.onReady) { try { this.onReady(client) } catch {} }
|
|
329
332
|
})
|
|
@@ -684,10 +687,25 @@ export class SshPool {
|
|
|
684
687
|
}
|
|
685
688
|
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
686
689
|
}
|
|
690
|
+
// A successful open proves the connection healthy: the strike
|
|
691
|
+
// counter counts CONSECUTIVE timeouts, so it resets here —
|
|
692
|
+
// two unrelated transient blips hours apart must not tear down
|
|
693
|
+
// a fine connection.
|
|
694
|
+
this._sftpTimeouts = 0
|
|
687
695
|
// The promisified surface (timeouts, dead-channel fail-fast,
|
|
688
696
|
// range reads) is shared verbatim with the system-OpenSSH
|
|
689
697
|
// backend — see lib/sftp-surface.js for the rules it encodes.
|
|
690
|
-
resolve(buildSftpSurface(sftp, {
|
|
698
|
+
resolve(buildSftpSurface(sftp, {
|
|
699
|
+
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
700
|
+
onTimeout: () => {
|
|
701
|
+
// A timed-out SFTP subchannel is dead: close it so the next
|
|
702
|
+
// operation does not queue behind the wedged one, and count
|
|
703
|
+
// the strike so a second consecutive timeout tears down the
|
|
704
|
+
// whole pooled connection (issue #65).
|
|
705
|
+
try { sftp.end() } catch { /* already gone */ }
|
|
706
|
+
this._noteSftpTimeout()
|
|
707
|
+
},
|
|
708
|
+
}))
|
|
691
709
|
})
|
|
692
710
|
} catch (e) {
|
|
693
711
|
clearAttempt()
|
|
@@ -699,6 +717,16 @@ export class SshPool {
|
|
|
699
717
|
)
|
|
700
718
|
}
|
|
701
719
|
|
|
720
|
+
/**
|
|
721
|
+
* Count a timed-out SFTP subchannel open. A second consecutive timeout means
|
|
722
|
+
* the pooled connection itself is wedged (issue #65): tear it down so the
|
|
723
|
+
* next sftp() call re-handshakes instead of queueing behind the dead one.
|
|
724
|
+
*/
|
|
725
|
+
_noteSftpTimeout() {
|
|
726
|
+
this._sftpTimeouts = (this._sftpTimeouts || 0) + 1
|
|
727
|
+
if (this._sftpTimeouts >= 2) this.invalidate()
|
|
728
|
+
}
|
|
729
|
+
|
|
702
730
|
/**
|
|
703
731
|
* Drop the cached client and force a fresh connection on the next call.
|
|
704
732
|
* Called when a channel open fails (e.g. "Channel open failure: open
|
package/lib/remote-fs.js
CHANGED
|
@@ -98,6 +98,17 @@ export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
|
|
|
98
98
|
return removed
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** MIME types for image formats the preview panel can render inline. */
|
|
102
|
+
const IMAGE_MIME_TYPES = {
|
|
103
|
+
png: 'image/png',
|
|
104
|
+
jpg: 'image/jpeg',
|
|
105
|
+
jpeg: 'image/jpeg',
|
|
106
|
+
gif: 'image/gif',
|
|
107
|
+
webp: 'image/webp',
|
|
108
|
+
svg: 'image/svg+xml',
|
|
109
|
+
bmp: 'image/bmp',
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
function looksBinary(buf) {
|
|
102
113
|
const n = Math.min(buf.length, 8192)
|
|
103
114
|
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
|
@@ -107,6 +118,14 @@ function looksBinary(buf) {
|
|
|
107
118
|
/**
|
|
108
119
|
* Read a remote file for the sidebar/editor. Oversized files use a range read
|
|
109
120
|
* (`readPartial`) so we never `fastGet` the whole blob onto disk.
|
|
121
|
+
*
|
|
122
|
+
* Images are the exception (issue #66): a PREFIX of an image is a broken
|
|
123
|
+
* picture, not a preview, so for a recognised image extension the caller's
|
|
124
|
+
* text-oriented cap is skipped and the file is read whole up to the hard 2MB
|
|
125
|
+
* ceiling; the panel renders it inline as `<img src="data:…">`. An image past
|
|
126
|
+
* the ceiling degrades to the plain binary stub + download button rather than
|
|
127
|
+
* shipping a guaranteed-broken half render. (`<img>` is also the safe sink
|
|
128
|
+
* for SVG: unlike inline markup it cannot run the file's scripts.)
|
|
110
129
|
*/
|
|
111
130
|
export async function previewRemoteFile(sftp, target, opts = {}) {
|
|
112
131
|
const {
|
|
@@ -114,7 +133,10 @@ export async function previewRemoteFile(sftp, target, opts = {}) {
|
|
|
114
133
|
encoding = 'utf-8',
|
|
115
134
|
decodeBuf = (buf) => buf.toString('utf8'),
|
|
116
135
|
} = opts
|
|
117
|
-
const
|
|
136
|
+
const hardCap = 2 * 1024 * 1024
|
|
137
|
+
const name = String(target).split(/[\\/]/).pop() || ''
|
|
138
|
+
const imageMime = IMAGE_MIME_TYPES[(name.split('.').pop() || '').toLowerCase()]
|
|
139
|
+
const cap = imageMime ? hardCap : Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), hardCap)
|
|
118
140
|
const st = await sftp.stat(target)
|
|
119
141
|
const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
|
|
120
142
|
const size = st && typeof st.size === 'number' ? st.size : 0
|
|
@@ -131,6 +153,9 @@ export async function previewRemoteFile(sftp, target, opts = {}) {
|
|
|
131
153
|
} else {
|
|
132
154
|
buf = await sftp.readFile(target)
|
|
133
155
|
}
|
|
156
|
+
if (imageMime && !truncated) {
|
|
157
|
+
return { ok: true, binary: false, image: true, mime: imageMime, content: buf.toString('base64'), truncated, size, mtime }
|
|
158
|
+
}
|
|
134
159
|
if (looksBinary(buf)) {
|
|
135
160
|
return {
|
|
136
161
|
ok: true, binary: true, size, mtime,
|
package/lib/sftp-surface.js
CHANGED
|
@@ -26,7 +26,7 @@ import { toSftpPath } from './paths.js'
|
|
|
26
26
|
* @param {number} opts.commandTimeoutMs - per-operation timeout.
|
|
27
27
|
* @returns {object} promisified sftp surface.
|
|
28
28
|
*/
|
|
29
|
-
export function buildSftpSurface(sftp, { commandTimeoutMs }) {
|
|
29
|
+
export function buildSftpSurface(sftp, { commandTimeoutMs, onTimeout }) {
|
|
30
30
|
// A dead channel must fail fast (see the header). ssh2's SFTP instance and the
|
|
31
31
|
// system-OpenSSH engine both emit these, so one hook covers both backends.
|
|
32
32
|
let dead = false
|
|
@@ -39,12 +39,19 @@ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
|
|
|
39
39
|
const withTimeout = (fn) => (...args) =>
|
|
40
40
|
new Promise((resolve, reject) => {
|
|
41
41
|
if (dead) return reject(new Error('sftp channel closed'))
|
|
42
|
-
|
|
42
|
+
let timedOut = false
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true
|
|
45
|
+
markDead()
|
|
46
|
+
if (typeof onTimeout === 'function') { try { onTimeout() } catch { /* best effort */ } }
|
|
47
|
+
reject(new Error('sftp operation timed out'))
|
|
48
|
+
}, commandTimeoutMs)
|
|
43
49
|
const finish = (err, value) => {
|
|
44
50
|
clearTimeout(timer)
|
|
51
|
+
if (timedOut) return
|
|
45
52
|
err ? reject(err) : resolve(value)
|
|
46
53
|
}
|
|
47
|
-
try { fn(...args, finish) } catch (err) { clearTimeout(timer); reject(err) }
|
|
54
|
+
try { fn(...args, finish) } catch (err) { clearTimeout(timer); if (!timedOut) reject(err) }
|
|
48
55
|
})
|
|
49
56
|
const P = (p) => toSftpPath(p)
|
|
50
57
|
return {
|
|
@@ -55,7 +62,43 @@ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
|
|
|
55
62
|
rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
|
|
56
63
|
unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
|
|
57
64
|
rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
|
|
65
|
+
// POSIX rename(2) semantics: atomically REPLACE an existing destination.
|
|
66
|
+
// Plain SSH_FXP_RENAME fails when the target exists (OpenSSH's sftp-server
|
|
67
|
+
// enforces that even though it advertises posix-rename@openssh.com — ssh2
|
|
68
|
+
// only sends the extension when asked explicitly, verified against
|
|
69
|
+
// OpenSSH_9.6 in issue #65). Servers with the extension get one atomic
|
|
70
|
+
// request; servers without it get the move-aside dance, whose every hop
|
|
71
|
+
// targets a path that does not exist, which plain RENAME always permits.
|
|
72
|
+
// rw_edit's atomic write depends on this — without it every edit of an
|
|
73
|
+
// existing file fails with a bare "Failure".
|
|
74
|
+
renameOverwrite: (p, d) => withTimeout((a, b, cb) => {
|
|
75
|
+
const ext = sftp._extensions && sftp._extensions['posix-rename@openssh.com']
|
|
76
|
+
if (ext === '1' && typeof sftp.ext_openssh_rename === 'function') {
|
|
77
|
+
return sftp.ext_openssh_rename(a, b, cb)
|
|
78
|
+
}
|
|
79
|
+
const bak = `${b}.dsh-remote-bak-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
80
|
+
sftp.rename(b, bak, (e1) => {
|
|
81
|
+
if (e1) {
|
|
82
|
+
// Destination absent → a plain rename is already overwrite-safe.
|
|
83
|
+
if (/no such/i.test(String((e1 && e1.message) || e1))) return sftp.rename(a, b, cb)
|
|
84
|
+
return cb(e1)
|
|
85
|
+
}
|
|
86
|
+
sftp.rename(a, b, (e2) => {
|
|
87
|
+
if (e2) {
|
|
88
|
+
// Roll the original back into place before reporting the failure.
|
|
89
|
+
return sftp.rename(bak, b, () => cb(e2))
|
|
90
|
+
}
|
|
91
|
+
// Best effort: a leftover backup must not fail the operation.
|
|
92
|
+
sftp.unlink(bak, () => cb(null))
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
})(P(p), P(d)),
|
|
58
96
|
realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
|
|
97
|
+
// Attribute write (chmod semantics live in setstat at the protocol level,
|
|
98
|
+
// so BOTH backends — ssh2's SFTPWrapper and its raw protocol SFTP — answer
|
|
99
|
+
// this one shape). Used by rw_edit to restore the original mode after an
|
|
100
|
+
// atomic rename replace (issue #65).
|
|
101
|
+
setstat: (p, attrs) => withTimeout((d, a, cb) => sftp.setstat(d, a, cb))(P(p), attrs),
|
|
59
102
|
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
60
103
|
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
61
104
|
fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
|
package/lib/sshconfig.js
CHANGED
|
@@ -32,11 +32,21 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
|
|
|
32
32
|
import { homedir } from 'node:os'
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
|
|
35
|
-
/** Default location of the user's ssh config.
|
|
36
|
-
|
|
35
|
+
/** Default location of the user's ssh config. An env override exists so tests
|
|
36
|
+
* (and any deployment that isolates its home directory) can point the plugin
|
|
37
|
+
* at a substitute file instead of the real ~/.ssh/config. */
|
|
38
|
+
export const sshConfigPath = () => {
|
|
39
|
+
const env = process.env.DSH_REMOTE_SSH_CONFIG
|
|
40
|
+
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
41
|
+
return path.join(homedir(), '.ssh', 'config')
|
|
42
|
+
}
|
|
37
43
|
|
|
38
44
|
/** Directory `Include` paths are resolved against (ssh_config(5)). */
|
|
39
|
-
export const sshConfigDir = () =>
|
|
45
|
+
export const sshConfigDir = () => {
|
|
46
|
+
const env = process.env.DSH_REMOTE_SSH_CONFIG
|
|
47
|
+
if (env && String(env).trim()) return path.dirname(path.resolve(String(env).trim()))
|
|
48
|
+
return path.join(homedir(), '.ssh')
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
/** Parameters the resolver cares about, lowercase (as parsed). */
|
|
42
52
|
const KNOWN_PARAMS = new Set([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.52",
|
|
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 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|