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 +1 -1
- package/src/index.js +10 -1
- package/src/shell-exec.js +13 -1
- package/src/transport.js +51 -7
package/package.json
CHANGED
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
|
-
|
|
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;
|
|
117
|
-
*
|
|
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('
|
|
127
|
-
if (ver.ok
|
|
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
|
|
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
|
-
|
|
656
|
+
if (profile.family === 'posix' || profile.family === 'windows') {
|
|
657
|
+
REMOTE_PROFILE_CACHE.set(key, profile)
|
|
658
|
+
}
|
|
615
659
|
}
|
|
616
660
|
return profile
|
|
617
661
|
}
|