dsh-remote-workspaces 0.2.0 → 0.3.0

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/src/index.js CHANGED
@@ -6,6 +6,8 @@ import { RoutingFileSystem } from './routing-fs.js'
6
6
  import { SshShellExecutor } from './shell-exec.js'
7
7
  import { registerAnchor, unregisterAnchor, findByCwd, updateAnchorOs } from './registry.js'
8
8
  import { applySearchTools } from './search.js'
9
+ import { createShellSessions } from './shell-sessions.js'
10
+ import { expandLocalTarget, localHome, localDriveRoots, localLevelListing, localListError, LOCAL_DRIVES } from './local-browse.js'
9
11
 
10
12
  export { parseSshConfig, expandTilde } from './ssh-config.js'
11
13
  export { SshClient, hostsFromConfig, shellQuote, defaultSshConfigPath, clientForHost } from './transport.js'
@@ -68,7 +70,16 @@ const INVOCATIONS = [
68
70
  invocation('sshAliasDetail', [jsonParameter('alias')]),
69
71
  invocation('testConnection', [jsonParameter('machine')]),
70
72
  invocation('listRemoteDir', [jsonParameter('machine'), jsonParameter('path')]),
73
+ invocation('listLocalDir', [jsonParameter('path')]),
71
74
  invocation('openRemoteWorkspace', [jsonParameter('machine'), jsonParameter('path')]),
75
+ invocation('openShellLocal', [jsonParameter('opts')]),
76
+ invocation('openShellRemote', [jsonParameter('machine'), jsonParameter('opts')]),
77
+ invocation('openShellAt', [jsonParameter('cwd'), jsonParameter('opts')]),
78
+ invocation('shellWrite', [jsonParameter('id'), jsonParameter('data')]),
79
+ invocation('shellRead', [jsonParameter('id')]),
80
+ invocation('shellResize', [jsonParameter('id'), jsonParameter('rows'), jsonParameter('cols')]),
81
+ invocation('shellClose', [jsonParameter('id')]),
82
+ invocation('shellList'),
72
83
  ]
73
84
 
74
85
  /** Build an `SshClient` from a machine record (alias/host/port/user/identityFile). */
@@ -131,8 +142,17 @@ function kickOsBackfill(cwd) {
131
142
  const profile = await client.profile()
132
143
  if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
133
144
  updateAnchorOs(hit.anchorPath, { family: profile.family, os: profile.os, shell: profile.shell })
145
+ return
134
146
  }
135
- } catch { /* keep the row without os */ }
147
+ // Unknown / undetectable: leave the dedupe queue so the NEXT prompt
148
+ // render re-kicks the probe (failed probes are not cached, so a later
149
+ // attempt really does re-probe instead of reusing a stale unknown).
150
+ OS_BACKFILL_QUEUED.delete(hit.anchorPath)
151
+ console.warn(`[dsh-remote-workspaces] os backfill probe failed for ${hit.host} (family unknown); will retry on next render`)
152
+ } catch (error) {
153
+ OS_BACKFILL_QUEUED.delete(hit.anchorPath)
154
+ console.warn(`[dsh-remote-workspaces] os backfill probe errored for ${hit.host}: ${messageOf(error)}`)
155
+ }
136
156
  })()
137
157
  }
138
158
 
@@ -174,7 +194,11 @@ async function resolveRemotePath(client, raw, sharedSftp) {
174
194
  * Host owner of the `remoteWorkspaces` Remote namespace. Every method returns
175
195
  * only lossless-JSON data and never echoes stored secrets back to the browser.
176
196
  */
177
- function remoteWorkspacesService() {
197
+ function remoteWorkspacesService(remote) {
198
+ const shells = createShellSessions({
199
+ getSubprocess: () => remote.getSubprocess(),
200
+ openRemote: (machine, opts) => sshClientFor(machine).openShell(opts),
201
+ })
178
202
  return {
179
203
  listMachines() {
180
204
  return { ok: true, machines: loadMachines().map(sanitizeMachine) }
@@ -252,6 +276,28 @@ function remoteWorkspacesService() {
252
276
  }
253
277
  },
254
278
 
279
+ async listLocalDir(path) {
280
+ // Virtual Windows drive-selection level: '上一级' at a drive root.
281
+ if (typeof path === 'string' && path === LOCAL_DRIVES) {
282
+ try {
283
+ const roots = await localDriveRoots()
284
+ const entries = roots.map((r) => ({ name: r.slice(0, 2), dir: true }))
285
+ return { ok: true, path: '', entries, truncated: false }
286
+ } catch (error) {
287
+ return { ok: false, error: '无法读取盘符:' + messageOf(error) }
288
+ }
289
+ }
290
+ let target = ''
291
+ try {
292
+ const home = localHome()
293
+ target = expandLocalTarget(path, home)
294
+ const level = await localLevelListing(target, {})
295
+ return { ok: true, path: level.path, entries: level.entries, truncated: level.truncated }
296
+ } catch (error) {
297
+ return { ok: false, error: localListError(error, target || (typeof path === 'string' ? path : '')) }
298
+ }
299
+ },
300
+
255
301
  /**
256
302
  * Open a remote directory as a workspace: create an EMPTY local anchor
257
303
  * directory (the harness's workspace identity — `fs.realpath` must resolve
@@ -297,6 +343,93 @@ function remoteWorkspacesService() {
297
343
  sftp.end()
298
344
  }
299
345
  },
346
+
347
+ // Shell tool: interactive local shell (ctx.subprocess.spawnTerminal) and
348
+ // remote shell (ssh2 conn.shell + pty). No approval (D6/D7): opening a
349
+ // shell is a user GUI action, equivalent to xshell — a separate line from
350
+ // the agent-side sandboxPolicy/approval gate. Credentials never ride the
351
+ // wire: the client sends only the sanitized machine (no secrets) and the
352
+ // host recovers password/passphrase from the store by id.
353
+ async openShellLocal(opts) {
354
+ try {
355
+ const session = await shells.openLocal(opts ?? {})
356
+ return { ok: true, ...session }
357
+ } catch (error) {
358
+ return { ok: false, error: messageOf(error) }
359
+ }
360
+ },
361
+
362
+ async openShellRemote(machine, opts) {
363
+ try {
364
+ const session = await shells.openRemote(machine ?? {}, opts ?? {})
365
+ return { ok: true, ...session }
366
+ } catch (error) {
367
+ return { ok: false, error: messageOf(error) }
368
+ }
369
+ },
370
+
371
+ /**
372
+ * Open a shell AT the current workspace cwd, resolving the target without
373
+ * the user choosing: a cwd under a registered remote anchor opens a remote
374
+ * shell on that machine, chdir'd to the anchor's remote path (+ subpath);
375
+ * anything else opens a local shell at that cwd. This is the default
376
+ * "click Shell" entry — no local/remote chooser.
377
+ */
378
+ async openShellAt(cwd, opts) {
379
+ try {
380
+ const anchor = typeof cwd === 'string' && cwd !== '' ? findByCwd(cwd) : undefined
381
+ if (anchor !== undefined) {
382
+ const machine = (anchor.machineId !== undefined && anchor.machineId !== null ? machineById(anchor.machineId) : undefined)
383
+ ?? { host: anchor.host, port: anchor.port, user: anchor.user }
384
+ const remoteCwd = anchor.remoteSubpath === ''
385
+ ? anchor.remotePath
386
+ : `${anchor.remotePath.replace(/\/+$/, '')}/${anchor.remoteSubpath}`
387
+ const session = await shells.openRemote(machine, { ...(opts ?? {}), cwd: remoteCwd })
388
+ return { ok: true, ...session, cwd: remoteCwd, label: machine.alias || machine.host || '远程' }
389
+ }
390
+ const session = await shells.openLocal({ ...(opts ?? {}), cwd })
391
+ return { ok: true, ...session, cwd: typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd(), label: '本机' }
392
+ } catch (error) {
393
+ return { ok: false, error: messageOf(error) }
394
+ }
395
+ },
396
+
397
+ async shellWrite(id, data) {
398
+ try {
399
+ await shells.write(id, data)
400
+ return { ok: true }
401
+ } catch (error) {
402
+ return { ok: false, error: messageOf(error) }
403
+ }
404
+ },
405
+
406
+ shellRead(id) {
407
+ try {
408
+ return { ok: true, ...shells.read(id) }
409
+ } catch (error) {
410
+ return { ok: false, error: messageOf(error) }
411
+ }
412
+ },
413
+
414
+ async shellResize(id, rows, cols) {
415
+ try {
416
+ return { ok: true, ...(await shells.resize(id, rows, cols)) }
417
+ } catch (error) {
418
+ return { ok: false, error: messageOf(error) }
419
+ }
420
+ },
421
+
422
+ async shellClose(id) {
423
+ try {
424
+ return { ok: true, ...(await shells.close(id)) }
425
+ } catch (error) {
426
+ return { ok: false, error: messageOf(error) }
427
+ }
428
+ },
429
+
430
+ shellList() {
431
+ return { ok: true, sessions: shells.list() }
432
+ },
300
433
  }
301
434
  }
302
435
 
@@ -325,7 +458,7 @@ export function apply(ctx) {
325
458
  ctx.provide('fs', new RoutingFileSystem(remote))
326
459
  ctx.provide('shell', new SshShellExecutor(remote))
327
460
 
328
- const service = remoteWorkspacesService()
461
+ const service = remoteWorkspacesService(remote)
329
462
  service.typertRemote = Object.freeze({ service, serviceKey: NAMESPACE, namespace: NAMESPACE })
330
463
  ctx.provide(NAMESPACE, service)
331
464
 
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Local-directory listing for the workspace-add flow's "本地文件夹" tab when
3
+ * the page is NOT served from the host's own loopback (remote access): the OS
4
+ * chooser would open on an unattended desktop, so the plugin browses the host
5
+ * filesystem in-app instead. Pure Node stdlib (the plugin cannot import
6
+ * harness packages); mirrors the `listRemoteDir` contract so the client reuses
7
+ * the same entry rendering.
8
+ *
9
+ * Security posture: read-only one-level directory listing, driven by the user's
10
+ * own GUI action. Never writes, never executes, and never enters the agent
11
+ * toolchain's sandboxPolicy/approval line (same trust surface as the harness
12
+ * `directory-picker-browse` backend).
13
+ */
14
+
15
+ import { access, readdir, stat } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { join, posix, win32 } from 'node:path'
18
+
19
+ /**
20
+ * Wire value for the virtual Windows drive-selection level: listing it returns
21
+ * the available drive roots. It is not a filesystem path, so it is fenced out
22
+ * of every real-path branch (never fully qualified). The client sends it only
23
+ * when a drive root's "上一级" is pressed on a win32 host (the client keeps an
24
+ * identical copy of this constant).
25
+ */
26
+ export const LOCAL_DRIVES = '::drives::'
27
+
28
+ /** The host account's home directory (listing default + `~` expansion). */
29
+ export function localHome() {
30
+ return homedir()
31
+ }
32
+
33
+ /**
34
+ * Available Windows drive roots (`C:\`, `D:\`, …) for the virtual drive level.
35
+ * Empty on non-win32 platforms. Probes `A:`–`Z:` with fs access.
36
+ * @param platform - replaces `process.platform` for deterministic tests.
37
+ */
38
+ export async function localDriveRoots(platform = process.platform) {
39
+ if (platform !== 'win32') return []
40
+ const found = []
41
+ for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
42
+ const root = `${letter}:\\`
43
+ try {
44
+ await access(root)
45
+ found.push(root)
46
+ } catch { /* absent drive */ }
47
+ }
48
+ return found
49
+ }
50
+
51
+ /**
52
+ * True when the path names one fixed filesystem location regardless of process
53
+ * state: POSIX-absolute on POSIX; on Windows only drive-qualified (`C:\…`) or
54
+ * complete UNC (`\\server\share…`) forms. Rooted drive-less forms and
55
+ * incomplete UNC prefixes pass `isAbsolute` yet still resolve against the
56
+ * process's current drive — mirroring the harness browse backend's fence so a
57
+ * wire value never rebases under the host cwd.
58
+ * @param path - candidate path.
59
+ * @param platform - replaces `process.platform` for deterministic tests.
60
+ */
61
+ export function localFullyQualified(path, platform = process.platform) {
62
+ if (typeof path !== 'string') return false
63
+ if (platform === 'win32') {
64
+ return win32.isAbsolute(path)
65
+ && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
66
+ }
67
+ return posix.isAbsolute(path)
68
+ }
69
+
70
+ /** Not-absolute / non-`~` input: never rebase under the host cwd. */
71
+ function notAbsolute(raw) {
72
+ const error = new Error(`不是完整路径(需要绝对路径或以 ~ 开头):${String(raw)}`)
73
+ error.code = 'DSH_NOT_ABSOLUTE'
74
+ return error
75
+ }
76
+
77
+ /** Resolve + normalize one fully qualified target on the platform. */
78
+ function normalizeTarget(input, platform) {
79
+ return platform === 'win32' ? win32.resolve(input) : posix.resolve(input)
80
+ }
81
+
82
+ /** Platform-consistent segment join (node's default join is host-flavored). */
83
+ function platformJoin(a, b, platform) {
84
+ return platform === 'win32' ? win32.join(a, b) : posix.join(a, b)
85
+ }
86
+
87
+ /**
88
+ * Turn the RPC's raw `path` argument into a concrete absolute listing target.
89
+ * `''`/undefined/`~`/`~/…` expand to the home directory; anything else must be
90
+ * fully qualified (client-browsed paths are, and `..` segments are resolved by
91
+ * the platform resolver, so the client's "上一级" can send `<path>/..`).
92
+ * @param raw - the wire value (may be undefined).
93
+ * @param home - home directory to expand against.
94
+ * @param platform - replaces `process.platform` for deterministic tests.
95
+ * @returns the absolute listing target.
96
+ * @throws {Error} with `code === 'DSH_NOT_ABSOLUTE'` for non-`~` relative input.
97
+ */
98
+ export function expandLocalTarget(raw, home, platform = process.platform) {
99
+ const input = typeof raw === 'string' ? raw.trim() : ''
100
+ if (input === '' || input === '~') return normalizeTarget(home, platform)
101
+ if (input.startsWith('~/') || input.startsWith('~\\')) {
102
+ return normalizeTarget(platformJoin(home, input.slice(2), platform), platform)
103
+ }
104
+ if (!localFullyQualified(input, platform)) throw notAbsolute(raw)
105
+ return normalizeTarget(input, platform)
106
+ }
107
+
108
+ /** Classify one dirent: directories (symlinked dirs probed) are enterable rows. */
109
+ async function entryRow(target, dirent) {
110
+ const isDirectory = dirent.isDirectory()
111
+ if (!isDirectory && dirent.isSymbolicLink()) {
112
+ try {
113
+ return { name: dirent.name, dir: (await stat(join(target, dirent.name))).isDirectory() }
114
+ } catch {
115
+ // Broken/cyclic link: keep a plain (non-enterable) row.
116
+ return { name: dirent.name, dir: false }
117
+ }
118
+ }
119
+ return { name: dirent.name, dir: isDirectory }
120
+ }
121
+
122
+ /**
123
+ * List one directory level. Directories and files both return (files render
124
+ * muted and non-clickable, mirroring the remote tab); `.`/`..` are dropped;
125
+ * rows are name-sorted. The complete level is bounded at `maxEntries` rows with
126
+ * `truncated` flagging a cut tail.
127
+ * @param target - absolute listing target (already expanded).
128
+ * @param opts - `maxEntries` bound (default 1000, like the harness browse
129
+ * backend); `platform` for tests.
130
+ * @returns `{ path, entries: [{name, dir}], truncated }`.
131
+ * @throws filesystem errors (`ENOENT`/`ENOTDIR`/`EACCES`…) unchanged.
132
+ */
133
+ export async function localLevelListing(target, opts = {}) {
134
+ const maxEntries = opts.maxEntries === undefined ? 1000 : opts.maxEntries
135
+ const entries = (await readdir(target, { withFileTypes: true }))
136
+ .filter((d) => d.name !== '.' && d.name !== '..')
137
+ const rows = []
138
+ for (const dirent of entries) {
139
+ if (rows.length === maxEntries) break
140
+ // eslint-disable-next-line no-await-in-loop -- per-entry symlink probes are sequential like the remote backend.
141
+ rows.push(await entryRow(target, dirent))
142
+ }
143
+ rows.sort((a, b) => a.name.localeCompare(b.name))
144
+ return {
145
+ path: target,
146
+ entries: rows,
147
+ truncated: entries.length > maxEntries,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Operator-facing text for a failed local listing.
153
+ * @param error - thrown value (filesystem error or DSH_NOT_ABSOLUTE).
154
+ * @param target - resolved target (for messages naming the directory).
155
+ */
156
+ export function localListError(error, target) {
157
+ const code = error && error.code
158
+ if (code === 'DSH_NOT_ABSOLUTE') {
159
+ return error.message || '路径无效'
160
+ }
161
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
162
+ return `目录不存在:${target}`
163
+ }
164
+ if (code === 'EACCES' || code === 'EPERM') {
165
+ return `无权限读取:${target}`
166
+ }
167
+ return `无法读取目录:${target}(${error instanceof Error ? error.message : String(error)})`
168
+ }
package/src/registry.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
- import { join, sep } from 'node:path'
12
+ import { join } from 'node:path'
13
13
  import { remoteWorkspacesRoot } from './anchor.js'
14
14
 
15
15
  function anchorsPath() {
@@ -64,27 +64,36 @@ export function unregisterAnchor(anchorPath) {
64
64
  }
65
65
 
66
66
  /**
67
- * Resolve a session cwd (the anchor path, or any descendant) to its remote
68
- * origin. Returns `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }`
69
- * or `undefined` when the cwd is not under any registered anchor.
67
+ * Resolve a session cwd the anchor path, any descendant, or the Files tree's
68
+ * `/`-joined child spelling of either to its remote origin. Returns
69
+ * `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }` or
70
+ * `undefined` when the cwd is not under any registered anchor.
71
+ *
72
+ * Matching is done on separator-normalized forms: anchor keys are stored with
73
+ * the native `sep` (`\` on Windows), while harness session cwds and the right
74
+ * Sidebar Files tree spell paths with `/` (and children are `root + '/' + name`
75
+ * even when the root is `\`-spelled). `remoteSubpath` is always `/`-joined.
70
76
  */
71
77
  export function findByCwd(cwd) {
72
78
  if (typeof cwd !== 'string' || cwd === '') return undefined
73
79
  const anchors = loadAnchors()
80
+ const flat = (p) => p.replace(/\\/g, '/')
81
+ const query = flat(cwd)
74
82
  let best
75
83
  let bestLen = -1
76
84
  for (const [anchorPath, rec] of Object.entries(anchors)) {
77
- const base = anchorPath.endsWith(sep) ? anchorPath : anchorPath + sep
78
- if (cwd === anchorPath || cwd.startsWith(base)) {
79
- if (anchorPath.length > bestLen) {
80
- bestLen = anchorPath.length
81
- best = { anchorPath, ...rec }
85
+ const base = flat(anchorPath)
86
+ const prefix = base.endsWith('/') ? base : `${base}/`
87
+ if (query === base || query.startsWith(prefix)) {
88
+ if (base.length > bestLen) {
89
+ bestLen = base.length
90
+ best = { anchorPath, base, rec }
82
91
  }
83
92
  }
84
93
  }
85
94
  if (best === undefined) return undefined
86
- const rel = cwd === best.anchorPath ? '' : cwd.slice(best.anchorPath.length + sep.length)
87
- return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
95
+ const rel = query === best.base ? '' : query.slice(best.base.length + 1)
96
+ return { ...best.rec, anchorPath: best.anchorPath, remoteSubpath: rel }
88
97
  }
89
98
 
90
99
  export default { loadAnchors, registerAnchor, unregisterAnchor, updateAnchorOs, findByCwd, remoteWorkspacesRoot }
package/src/routing-fs.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Routing filesystem: the plugin's `ctx.fs` provider.
3
3
  *
4
- * Routes by the session cwd. Two remote triggers:
5
- * - an `ssh://[user@]host[:port]/path` cwd (URI form), and
4
+ * Routes by the session cwd OR by the path itself. Remote triggers:
5
+ * - an `ssh://[user@]host[:port]/path` cwd (URI form),
6
6
  * - a LOCAL anchor directory registered in the remote-workspace registry
7
- * (`anchors.json`), whose real content lives on the remote host.
7
+ * (`anchors.json`), whose real content lives on the remote host, and
8
+ * - any absolute path that IS a registered anchor or sits under one (the
9
+ * anchor path is the remote world's local alias — the Files tree lists,
10
+ * expands and opens entirely in this spelling, so routing must answer it
11
+ * with or without an anchor cwd).
8
12
  * Everything else goes to the local backend (with the workspace-write fence).
9
13
  *
10
14
  * The world identity is ENCODED into the target key (`ssh://host/path` for
@@ -75,6 +79,41 @@ export class RoutingFileSystem {
75
79
  return `ssh://${user ? `${user}@` : ''}${host}${port ? `:${port}` : ''}${subKey}`
76
80
  }
77
81
 
82
+ /**
83
+ * Anchor alias of an absolute LOCAL path: a registered anchor dir — or any
84
+ * path under one — is the remote world's local spelling, so the registry
85
+ * lookup that maps a session cwd maps the path itself. Returns the remote
86
+ * origin (`{ host, user, port, remotePath }`) when `path` is an anchor dir or
87
+ * a descendant of one, else null. This is what lets the right Sidebar Files
88
+ * tree (which roots at the anchor path and joins children with `/`) reach the
89
+ * remote: those strings ARE remote aliases.
90
+ */
91
+ aliasOfPath(path) {
92
+ if (typeof path !== 'string' || path === '') return null
93
+ const hit = findByCwd(path)
94
+ if (hit === undefined) return null
95
+ return {
96
+ host: hit.host,
97
+ user: hit.user,
98
+ port: hit.port,
99
+ remotePath: hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath),
100
+ }
101
+ }
102
+
103
+ /** Remote routing params for a call, from its cwd first and its path second. */
104
+ routeRemote(path, cwd) {
105
+ const byCwd = this.remoteCwd(cwd)
106
+ if (byCwd !== null) return byCwd
107
+ const byPath = this.aliasOfPath(path)
108
+ return byPath === null ? null : { ...byPath, remoteCwd: byPath.remotePath }
109
+ }
110
+
111
+ /** The path argument translated to the remote world when it is itself an alias. */
112
+ remotePathArg(path) {
113
+ const alias = this.aliasOfPath(path)
114
+ return alias === null ? path : alias.remotePath
115
+ }
116
+
78
117
  /** Decode a target key into { backend, target } using the encoded world prefix. */
79
118
  splitTarget(target) {
80
119
  const key = String(target.targetKey)
@@ -92,10 +131,17 @@ export class RoutingFileSystem {
92
131
 
93
132
  async resolve(path, opts) {
94
133
  const cwd = opts && opts.cwd
95
- const remote = this.remoteCwd(cwd)
134
+ // Remote by cwd (session anchor), or by the path itself when no remote cwd
135
+ // is given — the Files endpoint resolves the workspace root WITHOUT a cwd,
136
+ // and the root is the anchor path, so the path must trigger routing alone.
137
+ const remote = this.routeRemote(path, cwd)
96
138
  if (remote !== null) {
97
139
  const backend = this.remoteBackend(remote.host, remote.user, remote.port)
98
- const sub = await backend.resolve(path, { cwd: remote.remoteCwd })
140
+ // An anchor-absolute path (the Files vocabulary) is translated to its
141
+ // remote spelling here; a relative path under an anchor cwd passes
142
+ // through unchanged so the backend's cwd resolution applies as before.
143
+ const arg = this.remotePathArg(path)
144
+ const sub = await backend.resolve(arg, { cwd: remote.remoteCwd })
99
145
  return {
100
146
  targetKey: this.encodeTarget(remote.host, remote.user, remote.port, sub.targetKey),
101
147
  displayPath: sub.displayPath,
@@ -135,10 +181,11 @@ export class RoutingFileSystem {
135
181
 
136
182
  lstat(path, opts, signal) {
137
183
  const cwd = opts && opts.cwd
138
- const remote = this.remoteCwd(cwd)
184
+ const remote = this.routeRemote(path, cwd)
139
185
  if (remote !== null) {
140
186
  const backend = this.remoteBackend(remote.host, remote.user, remote.port)
141
- return backend.lstat(path, { cwd: remote.remoteCwd })
187
+ const arg = this.remotePathArg(path)
188
+ return backend.lstat(arg, { cwd: remote.remoteCwd })
142
189
  }
143
190
  return this.local.lstat(path, opts)
144
191
  }
package/src/shell-exec.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * harness's `bash-sandbox`/`pwsh-sandbox` apply).
10
10
  */
11
11
 
12
- import { SshClient, shellQuote } from './transport.js'
12
+ import { SshClient, shellQuote, PROBE_UNKNOWN_MSG } from './transport.js'
13
13
  import { isRemoteCwd, parseSshUri } from './ssh-uri.js'
14
14
  import { findByCwd } from './registry.js'
15
15
  import { lstatSync } from 'node:fs'
@@ -247,6 +247,13 @@ export class SshShellExecutor {
247
247
  },
248
248
  done: (async () => {
249
249
  const profile = await client.profile()
250
+ if (profile.family === 'unknown') {
251
+ // Never run the POSIX nohup launcher against an undetected host (a
252
+ // Windows/cmd host would die on the `cd '…'` line with Win32 123).
253
+ spawnError = new Error(PROBE_UNKNOWN_MSG)
254
+ proc.status = 'killed'
255
+ return
256
+ }
250
257
  if (profile.family === 'windows') {
251
258
  // No nohup-style detach exists on Windows remotes, and closing the
252
259
  // channel alone does NOT reap the remote tree (verified) - so the
@@ -282,6 +289,11 @@ export class SshShellExecutor {
282
289
  const outcome = await ctl.exit
283
290
  merge()
284
291
  if (proc.status === 'running') proc.status = 'completed'
292
+ if (outcome.error !== undefined) {
293
+ spawnError = new Error(outcome.error)
294
+ proc.status = 'killed'
295
+ return
296
+ }
285
297
  proc.exitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null
286
298
  return
287
299
  }