dsh-remote 0.8.47 → 0.8.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ignore.js CHANGED
@@ -139,6 +139,52 @@ export const DEFAULT_SEARCH_IGNORE = [
139
139
  'Library/Caches/',
140
140
  ]
141
141
 
142
+ /**
143
+ * Whether a normalized remote path is a FILESYSTEM ROOT: POSIX `/`, a Windows
144
+ * drive root (`D:\`), or a UNC share root (`\\server\share`).
145
+ *
146
+ * Root workspaces are legal (issue #60) but dangerous to walk naively: `/proc`
147
+ * and `/sys` are synthetic (new pids appear between readdirs, `/proc/kcore`
148
+ * advertises 128 TB), `/dev` holds never-ending character devices, and
149
+ * `/var/lib/docker` is tens of GB of image layers. ROOT_IGNORE exists so a
150
+ * root workspace skips exactly those trees.
151
+ */
152
+ export function isFilesystemRoot(p) {
153
+ const s = String(p || '')
154
+ return s === '/' || /^[a-zA-Z]:\\?$/.test(s) || /^\\\\[^\\]+\\[^\\]+$/.test(s)
155
+ }
156
+
157
+ /**
158
+ * Patterns merged in ONLY when the workspace is the filesystem root
159
+ * (issue #60). Every entry is root-anchored AND directory-only, so even when
160
+ * merged they can only match at the workspace top level: `/` skips `/proc`,
161
+ * while a hypothetical `/home/u/proc` under a non-root workspace is ordinary
162
+ * content (and non-root workspaces never receive these patterns at all, so
163
+ * their behaviour is byte-identical to before).
164
+ */
165
+ export const ROOT_IGNORE = [
166
+ '/proc/',
167
+ '/sys/',
168
+ '/dev/',
169
+ '/run/',
170
+ '/boot/',
171
+ '/var/lib/docker/',
172
+ ]
173
+
174
+ /**
175
+ * Effective ignore patterns for a workspace rooted at `root`:
176
+ * the defaults, plus ROOT_IGNORE only for a root workspace, plus the user's
177
+ * own `.dsh-remote-ignore` lines (always last, they stack on top).
178
+ *
179
+ * @param {string} root - workspace root as passed to sync/push/search.
180
+ * @param {{search?: boolean, fromFile?: string[]}} [opts]
181
+ */
182
+ export function ignorePatternsFor(root, { search = false, fromFile = [] } = {}) {
183
+ const base = search ? DEFAULT_SEARCH_IGNORE : DEFAULT_IGNORE
184
+ const extra = isFilesystemRoot(root) ? ROOT_IGNORE : []
185
+ return base.concat(extra, fromFile)
186
+ }
187
+
142
188
  /**
143
189
  * Load the user ignore file (gitignore syntax) and merge with defaults.
144
190
  * Returns `{ patterns, fromFile }`.
package/lib/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  toShellPath, toDisplayPath, remotePathBase, truncate, shortHash,
32
32
  } from './paths.js'
33
33
  import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
34
- import { compileIgnore, DEFAULT_IGNORE, DEFAULT_SEARCH_IGNORE } from './ignore.js'
34
+ import { compileIgnore, ignorePatternsFor, DEFAULT_IGNORE } from './ignore.js'
35
35
  import { friendlyMessage } from './errors.js'
36
36
  import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
37
37
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
@@ -927,26 +927,21 @@ export async function apply(ctx, config) {
927
927
  // NOTE: this matcher is shared by mirror sync (rw_sync/rw_push) and search.
928
928
  // Do NOT add search-only cache excludes here: widening it would silently stop
929
929
  // syncing directories a user may rely on. Search uses searchIgnoreMatcher().
930
- const ignoreMatcher = () => {
930
+ const readUserIgnore = () => {
931
931
  try {
932
- const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
933
- return compileIgnore(DEFAULT_IGNORE.concat(fromFile))
932
+ return readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
934
933
  } catch {
935
- return compileIgnore(DEFAULT_IGNORE)
934
+ return []
936
935
  }
937
936
  }
937
+ /** Mirror-sync matcher for the workspace rooted at `root` (ROOT_IGNORE is
938
+ * merged only for a filesystem-root workspace — issue #60). */
939
+ const ignoreMatcher = (root) => compileIgnore(ignorePatternsFor(root, { fromFile: readUserIgnore() }))
938
940
  /** Search-only matcher: same as ignoreMatcher() plus machine-local cache trees
939
941
  * (`~/.npm`, `~/.cache`, …) that are huge, almost never what a human is
940
942
  * looking for, and the main cause of the issue #44 hang. Mirror sync is
941
943
  * deliberately unaffected. An explicit `path` still searches anywhere. */
942
- const searchIgnoreMatcher = () => {
943
- try {
944
- const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
945
- return compileIgnore(DEFAULT_SEARCH_IGNORE.concat(fromFile))
946
- } catch {
947
- return compileIgnore(DEFAULT_SEARCH_IGNORE)
948
- }
949
- }
944
+ const searchIgnoreMatcher = (root) => compileIgnore(ignorePatternsFor(root, { search: true, fromFile: readUserIgnore() }))
950
945
 
951
946
  // ── task + forward managers ───────────────────────────────────────────────
952
947
  const tasks = new TaskManager()
@@ -991,7 +986,7 @@ export async function apply(ctx, config) {
991
986
  const target = poolForMachine(machine)
992
987
  let sftp
993
988
  try { sftp = await target.sftp() } catch { return }
994
- const matcher = ignoreMatcher()
989
+ const matcher = ignoreMatcher(ws)
995
990
  const state = loadSyncState(localDir)
996
991
  const next = { ...state }
997
992
  if (rels.includes('*')) {
@@ -1711,8 +1706,15 @@ export async function apply(ctx, config) {
1711
1706
  },
1712
1707
  output: textOut,
1713
1708
  async execute(args, exec) {
1714
- const p = normalizeRemotePath(String(args.path || ''))
1715
- if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
1709
+ // Blank input is the only upfront rejection: normalizeRemotePath('')
1710
+ // returns '/', so the old `!p || p === '/'` guard actually meant "no
1711
+ // root" — but a filesystem root is a legal workspace (issue #60), and
1712
+ // its synthetic-tree risk is handled by ROOT_IGNORE in sync/search.
1713
+ // Any non-blank path, root included, is answered by the real directory
1714
+ // probe below (a missing path still fails there).
1715
+ const rawPath = String(args.path || '').trim()
1716
+ if (!rawPath) throw new Error('rw_pick_workspace: path must be an absolute directory')
1717
+ const p = normalizeRemotePath(rawPath)
1716
1718
  // Picking a workspace targets the machine this session is already bound
1717
1719
  // to when it has one. A LOCAL session deliberately falls back to the
1718
1720
  // active machine here: this is the setup flow that runs right after
@@ -1755,7 +1757,7 @@ export async function apply(ctx, config) {
1755
1757
  mkdirSync(local, { recursive: true })
1756
1758
  const depth = Math.min(Math.max(Number(args.depth) || 8, 1), 16)
1757
1759
  const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 2000, 1), 20000)
1758
- const isIgnored = ignoreMatcher()
1760
+ const isIgnored = ignoreMatcher(p)
1759
1761
  const body = { depth, maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1760
1762
  const runSync = async () => {
1761
1763
  let sftp
@@ -1805,7 +1807,7 @@ export async function apply(ctx, config) {
1805
1807
  const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
1806
1808
  if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
1807
1809
  const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 2000, 1), 20000)
1808
- const isIgnored = ignoreMatcher()
1810
+ const isIgnored = ignoreMatcher(p)
1809
1811
  const body = { maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
1810
1812
  const runPush = async () => {
1811
1813
  let sftp
@@ -2227,7 +2229,7 @@ export async function apply(ctx, config) {
2227
2229
  // `running` and cancel/steer did nothing (issue #44).
2228
2230
  const maxFiles = Math.min(Math.max(Number(args.maxEntries) || config.searchMaxEntries, 1), 500000)
2229
2231
  const maxDurationMs = Math.min(Math.max(Number(args.maxDurationMs) || config.searchTimeoutMs, 1000), 600000)
2230
- const matcher = searchIgnoreMatcher()
2232
+ const matcher = searchIgnoreMatcher(dir)
2231
2233
  const { matches, scanned, truncated, cancelled } = await searchRemote(b.pool, dir, {
2232
2234
  pattern,
2233
2235
  regex,
@@ -2832,8 +2834,11 @@ export async function apply(ctx, config) {
2832
2834
  if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
2833
2835
  try {
2834
2836
  const payload = JSON.parse((await readBody(req)) || '{}')
2835
- const p = normalizeRemotePath(String(payload.path || ''))
2836
- if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
2837
+ // Blank-only guard: a filesystem root is a legal workspace (issue
2838
+ // #60); the real directory probe below answers everything else.
2839
+ const rawPath = String(payload.path || '').trim()
2840
+ if (!rawPath) return sendJson(res, 400, { error: 'path must be an absolute directory' })
2841
+ const p = normalizeRemotePath(rawPath)
2837
2842
  const okDir = await isRemoteDir(p)
2838
2843
  if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
2839
2844
  persistWorkspace(p)
@@ -2852,8 +2857,11 @@ export async function apply(ctx, config) {
2852
2857
  if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
2853
2858
  try {
2854
2859
  const payload = JSON.parse((await readBody(req)) || '{}')
2855
- const p = normalizeRemotePath(String(payload.path || ''))
2856
- if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
2860
+ // Blank-only guard: a filesystem root is a legal workspace (issue
2861
+ // #60); the real directory probe below answers everything else.
2862
+ const rawPath = String(payload.path || '').trim()
2863
+ if (!rawPath) return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
2864
+ const p = normalizeRemotePath(rawPath)
2857
2865
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
2858
2866
  // The picker names the machine it was browsing. Re-assert it so a
2859
2867
  // switch made elsewhere between the listing and this commit cannot
package/lib/search.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Replaces `find … -exec grep` (which needs a POSIX shell and dies on
3
3
  // Windows remotes). Walks the remote tree over SFTP readdir/stat/readFile with
4
4
  // the same bounded-parallelism + ignore rules as the mirror sync.
5
- import { joinRemotePath, shq } from './paths.js'
5
+ import { joinRemotePath, relPathUnder, shq } from './paths.js'
6
6
 
7
7
  function mapLimit(items, limit, fn) {
8
8
  const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
@@ -105,7 +105,12 @@ export async function searchTree(sftp, rootDir, opts = {}) {
105
105
  if (shouldStop() || scanned >= maxFiles) return
106
106
  if (depth <= 0) continue
107
107
  const sub = joinRemotePath(dir, name)
108
- if (isIgnored(name, true)) continue
108
+ // isIgnored receives the path RELATIVE to the search root (the same
109
+ // contract sync has), so root-anchored ignore rules — `/proc/` when the
110
+ // workspace is the filesystem root (issue #60), `Library/Caches/` in the
111
+ // default search set — actually match here; a bare basename would never
112
+ // satisfy an anchored pattern and those rules were silently dead.
113
+ if (isIgnored(relPathUnder(rootDir, sub) || name, true)) continue
109
114
  await walk(sub, depth - 1)
110
115
  }
111
116
  if (shouldStop() || scanned >= maxFiles) return
@@ -118,8 +123,8 @@ export async function searchTree(sftp, rootDir, opts = {}) {
118
123
  // Checked per entry: this is the loop that used to run for hours.
119
124
  if (shouldStop() || scanned >= maxFiles || matches.length >= maxMatches) return
120
125
  if (glob && !matchGlob(name, glob)) return
121
- if (isIgnored(name, false)) return
122
126
  const fp = joinRemotePath(dir, name)
127
+ if (isIgnored(relPathUnder(rootDir, fp) || name, false)) return
123
128
  scanned++
124
129
  let st
125
130
  try { st = await sftp.stat(fp) } catch { return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.47",
3
+ "version": "0.8.48",
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>",
@@ -93,3 +93,4 @@
93
93
  "test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=20000 --test-force-exit"
94
94
  }
95
95
  }
96
+