dsh-remote-workspaces 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-workspaces",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Open folders on remote hosts over SSH as DeepSeek Harness workspaces — a non-invasive DSH bundle.",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -131,8 +131,17 @@ function kickOsBackfill(cwd) {
131
131
  const profile = await client.profile()
132
132
  if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
133
133
  updateAnchorOs(hit.anchorPath, { family: profile.family, os: profile.os, shell: profile.shell })
134
+ return
134
135
  }
135
- } catch { /* keep the row without os */ }
136
+ // Unknown / undetectable: leave the dedupe queue so the NEXT prompt
137
+ // render re-kicks the probe (failed probes are not cached, so a later
138
+ // attempt really does re-probe instead of reusing a stale unknown).
139
+ OS_BACKFILL_QUEUED.delete(hit.anchorPath)
140
+ console.warn(`[dsh-remote-workspaces] os backfill probe failed for ${hit.host} (family unknown); will retry on next render`)
141
+ } catch (error) {
142
+ OS_BACKFILL_QUEUED.delete(hit.anchorPath)
143
+ console.warn(`[dsh-remote-workspaces] os backfill probe errored for ${hit.host}: ${messageOf(error)}`)
144
+ }
136
145
  })()
137
146
  }
138
147
 
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
  }
package/src/transport.js CHANGED
@@ -113,9 +113,15 @@ export function profileCacheKey({ host, user, port }) {
113
113
 
114
114
  /**
115
115
  * Detect a target's OS family and default exec shell without parsing
116
- * localized output: `uname -s` proves a POSIX shell; `cmd /c ver` proves
117
- * Windows (it runs from cmd, PowerShell and Git Bash defaults alike); a
116
+ * localized output: `uname -s` proves a POSIX shell; the bare `ver` builtin
117
+ * proves a cmd-default Windows host; a quoted nested `cmd /c "ver"` proves
118
+ * Windows under a PowerShell default (PowerShell itself has no `ver`); a
118
119
  * `$PSVersionTable` expression then tells cmd from PowerShell as the default.
120
+ *
121
+ * Quoting matters on real cmd-default Windows hosts: OpenSSH wraps the exec
122
+ * payload for cmd.exe in a way that mangles inner unquoted spaces — `cmd /c
123
+ * ver` arrives as `ver"` and fails, while a bare single token (`ver`) and a
124
+ * quoted inner command (`cmd /c "ver"`) both pass (verified on a real host).
119
125
  */
120
126
  export async function probeRemoteProfile(client) {
121
127
  const uname = await client.run('uname -s')
@@ -123,16 +129,30 @@ export async function probeRemoteProfile(client) {
123
129
  const os = (uname.stdout ?? '').trim().toLowerCase()
124
130
  return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
125
131
  }
126
- const ver = await client.run('cmd /c ver')
127
- if (ver.ok && /windows/i.test(ver.stdout ?? '')) {
132
+ const ver = await client.run('ver')
133
+ if (ver.ok) return { family: 'windows', os: 'windows', shell: 'cmd' }
134
+ const nested = await client.run('cmd /c "ver"')
135
+ if (nested.ok && /windows/i.test(nested.stdout ?? '')) {
128
136
  const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
129
137
  return ps.ok
130
138
  ? { family: 'windows', os: 'windows', shell: 'powershell' }
131
139
  : { family: 'windows', os: 'windows', shell: 'cmd' }
132
140
  }
141
+ const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
142
+ if (ps.ok) return { family: 'windows', os: 'windows', shell: 'powershell' }
133
143
  return { family: 'unknown', os: 'unknown', shell: 'unknown' }
134
144
  }
135
145
 
146
+ /**
147
+ * Error text used whenever a command cannot run because the remote profile
148
+ * probe failed (family 'unknown'). Explicit instead of guessing a dialect:
149
+ * sending a POSIX `cd 'x' || exit 1` script to a Windows/cmd host produces the
150
+ * misleading "文件名、目录名或卷标语法不正确" (Win32 123) failure, and the
151
+ * other way around is equally wrong — so callers refuse loudly.
152
+ */
153
+ export const PROBE_UNKNOWN_MSG =
154
+ 'cannot determine the remote shell type (exec probe failed); command not executed. Check the host side: the account home/profile directory must exist, the OpenSSH DefaultShell must be valid, and the exec channel itself must be usable.'
155
+
136
156
  /** Encode a PowerShell script for quote-safe transport through cmd.exe.
137
157
  *
138
158
  * `powershell -EncodedCommand` would be ideal (no quoting at all), but a
@@ -162,7 +182,15 @@ export function psCommandEnvelope(script) {
162
182
  * text (no CLIXML records).
163
183
  */
164
184
  export function buildExecScript(profile, command, cwd) {
165
- if (profile === undefined || profile.family !== 'windows') {
185
+ if (profile === undefined) {
186
+ return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
187
+ }
188
+ if (profile.family === 'unknown') {
189
+ // Never guess: a POSIX script against a Windows/cmd host dies on the first
190
+ // line with Win32 123-style errors and never reaches the command.
191
+ throw new Error(PROBE_UNKNOWN_MSG)
192
+ }
193
+ if (profile.family !== 'windows') {
166
194
  return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
167
195
  }
168
196
  const script = [
@@ -362,6 +390,9 @@ export class SshClient {
362
390
  */
363
391
  async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
364
392
  const profile = await this.profile()
393
+ if (profile.family === 'unknown') {
394
+ return { ok: false, error: PROBE_UNKNOWN_MSG }
395
+ }
365
396
  const script = buildExecScript(profile, command, cwd)
366
397
  return await new Promise((resolve) => {
367
398
  const conn = new Client()
@@ -480,6 +511,15 @@ export class SshClient {
480
511
  */
481
512
  async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
482
513
  const profile = await this.profile()
514
+ if (profile.family === 'unknown') {
515
+ // Same explicit refusal as execShell: no dialect to build the script in.
516
+ return {
517
+ readOut: () => ({ delta: '', lossy: false }),
518
+ readErr: () => ({ delta: '', lossy: false }),
519
+ exit: Promise.resolve({ error: PROBE_UNKNOWN_MSG }),
520
+ terminate() {},
521
+ }
522
+ }
483
523
  const windows = profile.family === 'windows'
484
524
  const ssh = this
485
525
  // The marker runs FIRST inside the same remote shell that owns the exec
@@ -604,14 +644,18 @@ export class SshClient {
604
644
 
605
645
  /**
606
646
  * Cached remote execution profile ({ family, os, shell }); probed once per
607
- * target and reused for the process lifetime.
647
+ * target and reused for the process lifetime. FAILED probes (family
648
+ * 'unknown') are never cached, so the next call re-probes: a transient
649
+ * failure or a host-side fix is picked up without restarting the process.
608
650
  */
609
651
  async profile() {
610
652
  const key = profileCacheKey(this)
611
653
  let profile = REMOTE_PROFILE_CACHE.get(key)
612
654
  if (profile === undefined) {
613
655
  profile = await probeRemoteProfile(this)
614
- REMOTE_PROFILE_CACHE.set(key, profile)
656
+ if (profile.family === 'posix' || profile.family === 'windows') {
657
+ REMOTE_PROFILE_CACHE.set(key, profile)
658
+ }
615
659
  }
616
660
  return profile
617
661
  }