dsh-remote-workspaces 0.1.1 → 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 +2 -2
- package/src/client.js +8 -0
- package/src/index.js +102 -23
- package/src/registry.js +18 -4
- package/src/search.js +72 -8
- package/src/shell-exec.js +55 -2
- package/src/transport.js +392 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-workspaces",
|
|
3
|
-
"version": "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",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"cordis.patch.yml"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs",
|
|
24
|
+
"test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs && node test/unit-win-remote.mjs && node test/unit-registry-os.mjs",
|
|
25
25
|
"test:integration": "node test/integration-remote.mjs"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
package/src/client.js
CHANGED
|
@@ -809,6 +809,14 @@ window.__ModuleLoader__.load({
|
|
|
809
809
|
'.dsh-rw-btn button:disabled{opacity:.5;cursor:not-allowed}',
|
|
810
810
|
'.dsh-rw-btn button:focus-visible{outline:2px solid #6e56cf;outline-offset:2px}',
|
|
811
811
|
'.dsh-rw-btn input:focus,.dsh-rw-btn select:focus{box-shadow:0 0 0 1px #6e56cf}',
|
|
812
|
+
// The host chooser sits inside the always-dark modal (modalBg #1f1f21,
|
|
813
|
+
// text #e8e8e8), but a native <select> renders its option popup on the
|
|
814
|
+
// OS theme. Without dark color-scheme the light inherited text lands on
|
|
815
|
+
// a light popup and the host list is unreadable. Force the popup dark,
|
|
816
|
+
// and give the options an explicit light-on-dark fallback.
|
|
817
|
+
'.dsh-rw-btn select{color-scheme:dark}',
|
|
818
|
+
'.dsh-rw-btn select option{color:#e8e8e8;background:#1f1f21}',
|
|
819
|
+
'.dsh-rw-btn select option:checked{background:#6e56cf;color:#fff}',
|
|
812
820
|
].join('\n')
|
|
813
821
|
document.head.appendChild(styleEl)
|
|
814
822
|
}
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { loadMachines, sanitizeMachine, upsertMachine, removeMachine, machineByI
|
|
|
4
4
|
import { ensureAnchor } from './anchor.js'
|
|
5
5
|
import { RoutingFileSystem } from './routing-fs.js'
|
|
6
6
|
import { SshShellExecutor } from './shell-exec.js'
|
|
7
|
-
import { registerAnchor, unregisterAnchor, findByCwd } from './registry.js'
|
|
7
|
+
import { registerAnchor, unregisterAnchor, findByCwd, updateAnchorOs } from './registry.js'
|
|
8
8
|
import { applySearchTools } from './search.js'
|
|
9
9
|
|
|
10
10
|
export { parseSshConfig, expandTilde } from './ssh-config.js'
|
|
@@ -93,6 +93,13 @@ function messageOf(error) {
|
|
|
93
93
|
return error instanceof Error ? error.message : String(error)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Directory flag from an SFTP attrs record (protocol-level, locale-free). */
|
|
97
|
+
function attrsIsDir(st) {
|
|
98
|
+
if (st && typeof st.isDirectory === 'function') return st.isDirectory()
|
|
99
|
+
if (st && typeof st.mode === 'number') return (st.mode & 0o170000) === 0o040000
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
/**
|
|
97
104
|
* Build an `SshClient` for a remote host, preferring a saved machine's
|
|
98
105
|
* credentials (password/identityFile/passphrase) and falling back to
|
|
@@ -104,31 +111,71 @@ function clientForRemote(host, user, port) {
|
|
|
104
111
|
return machine ? sshClientFor(machine) : clientForHost(host)
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
const OS_BACKFILL_QUEUED = new Set()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lazily backfill the `os` profile on an anchor row that predates B4 (kicked
|
|
118
|
+
* once per anchor per process, fire-and-forget). Prompt hints re-read the
|
|
119
|
+
* registry on every render, so the next prompt after the probe sees the OS.
|
|
120
|
+
*/
|
|
121
|
+
function kickOsBackfill(cwd) {
|
|
122
|
+
if (typeof cwd !== 'string' || cwd === '') return
|
|
123
|
+
let hit
|
|
124
|
+
try { hit = findByCwd(cwd) } catch { return }
|
|
125
|
+
if (hit === undefined || (hit.os && hit.os.family !== undefined)) return
|
|
126
|
+
if (OS_BACKFILL_QUEUED.has(hit.anchorPath)) return
|
|
127
|
+
OS_BACKFILL_QUEUED.add(hit.anchorPath)
|
|
128
|
+
void (async () => {
|
|
129
|
+
try {
|
|
130
|
+
const client = clientForRemote(hit.host, hit.user, hit.port)
|
|
131
|
+
const profile = await client.profile()
|
|
132
|
+
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
133
|
+
updateAnchorOs(hit.anchorPath, { family: profile.family, os: profile.os, shell: profile.shell })
|
|
134
|
+
return
|
|
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
|
+
}
|
|
145
|
+
})()
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
/**
|
|
108
149
|
* Resolve a raw browse path (home, `~`, `~/x`, relative, or absolute) to an
|
|
109
150
|
* absolute remote path so the client can navigate "up" past home to `/` and
|
|
110
|
-
* display a real path.
|
|
151
|
+
* display a real path. Accepts an optional open SFTP facade to reuse across
|
|
152
|
+
* resolve + listing; otherwise opens its own channel. Resolution goes over
|
|
153
|
+
* SFTP (`realpath`), so it works identically on POSIX and Windows targets and
|
|
154
|
+
* never parses localized shell output.
|
|
111
155
|
*/
|
|
112
|
-
async function resolveRemotePath(client, raw) {
|
|
156
|
+
async function resolveRemotePath(client, raw, sharedSftp) {
|
|
113
157
|
const trimmed = raw === undefined || raw === null ? '' : String(raw).trim()
|
|
114
|
-
let
|
|
158
|
+
let owned
|
|
159
|
+
const sftp = async () => {
|
|
160
|
+
if (sharedSftp !== undefined) return sharedSftp
|
|
161
|
+
owned = owned ?? await client.sftp()
|
|
162
|
+
return owned
|
|
163
|
+
}
|
|
115
164
|
try {
|
|
116
165
|
if (trimmed === '' || trimmed === '~' || trimmed === '~/') {
|
|
117
|
-
|
|
118
|
-
return await sftp.realpath('.')
|
|
166
|
+
return await (await sftp()).realpath('.')
|
|
119
167
|
}
|
|
120
168
|
if (trimmed.startsWith('~/')) {
|
|
121
|
-
|
|
122
|
-
const home = String(await
|
|
169
|
+
const s = await sftp()
|
|
170
|
+
const home = String(await s.realpath('.')).replace(/\/+$/, '')
|
|
123
171
|
return `${home}/${trimmed.slice(2)}`
|
|
124
172
|
}
|
|
125
173
|
if (!trimmed.startsWith('/')) {
|
|
126
|
-
|
|
127
|
-
return await sftp.realpath(trimmed)
|
|
174
|
+
return await (await sftp()).realpath(trimmed)
|
|
128
175
|
}
|
|
129
176
|
return trimmed
|
|
130
177
|
} finally {
|
|
131
|
-
if (
|
|
178
|
+
if (owned !== undefined) owned.end()
|
|
132
179
|
}
|
|
133
180
|
}
|
|
134
181
|
|
|
@@ -183,22 +230,35 @@ function remoteWorkspacesService() {
|
|
|
183
230
|
},
|
|
184
231
|
|
|
185
232
|
async listRemoteDir(machine, path) {
|
|
186
|
-
//
|
|
233
|
+
// Pure-SFTP listing (readdir + attrs) — the SAME channel the fs backend
|
|
234
|
+
// uses, so Windows targets work: no `ls`, no localized output parsing,
|
|
235
|
+
// no dependency on the remote's default shell.
|
|
187
236
|
const client = sshClientFor(machine)
|
|
188
|
-
let
|
|
237
|
+
let sftp
|
|
189
238
|
try {
|
|
190
|
-
|
|
239
|
+
sftp = await client.sftp()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return { ok: false, error: `无法建立 SFTP 连接:${messageOf(error)}` }
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const absPath = await resolveRemotePath(client, path, sftp)
|
|
245
|
+
let list
|
|
246
|
+
try {
|
|
247
|
+
list = await sftp.readdir(absPath)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const missing = error && (error.code === 2 || /no such file|not exist|找不到/i.test(String(error.message)))
|
|
250
|
+
return { ok: false, error: missing ? `目录不存在:${absPath}` : `无法读取目录:${messageOf(error)}` }
|
|
251
|
+
}
|
|
252
|
+
const entries = (Array.isArray(list) ? list : [])
|
|
253
|
+
.filter((e) => e && e.filename !== '.' && e.filename !== '..')
|
|
254
|
+
.map((e) => ({ name: e.filename, dir: attrsIsDir(e.attrs) }))
|
|
255
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
256
|
+
return { ok: true, entries, path: absPath }
|
|
191
257
|
} catch (error) {
|
|
192
258
|
return { ok: false, error: `无法解析目录:${messageOf(error)}` }
|
|
259
|
+
} finally {
|
|
260
|
+
sftp.end()
|
|
193
261
|
}
|
|
194
|
-
const command = absPath === '' ? 'ls -1Ap' : `ls -1Ap ${shellQuote(absPath)}`
|
|
195
|
-
const res = await client.run(command)
|
|
196
|
-
if (!res.ok) return { ok: false, error: (res.stderr ?? '').trim() || res.error || '列出目录失败' }
|
|
197
|
-
const entries = res.stdout
|
|
198
|
-
.split('\n')
|
|
199
|
-
.filter((name) => name !== '')
|
|
200
|
-
.map((name) => ({ name: name.replace(/\/$/, ''), dir: name.endsWith('/') }))
|
|
201
|
-
return { ok: true, entries, path: absPath }
|
|
202
262
|
},
|
|
203
263
|
|
|
204
264
|
/**
|
|
@@ -220,6 +280,16 @@ function remoteWorkspacesService() {
|
|
|
220
280
|
const rel = path === undefined || path === '' ? '.' : path
|
|
221
281
|
const remotePath = await sftp.realpath(rel)
|
|
222
282
|
const anchorPath = ensureAnchor(machine, remotePath)
|
|
283
|
+
// Probe the remote profile once so prompt/dialect hints can declare
|
|
284
|
+
// the actual shell (PowerShell on Windows remotes). Failure to probe
|
|
285
|
+
// only leaves the hint off — never fails the open.
|
|
286
|
+
let os
|
|
287
|
+
try {
|
|
288
|
+
const profile = await client.profile()
|
|
289
|
+
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
290
|
+
os = { family: profile.family, os: profile.os, shell: profile.shell }
|
|
291
|
+
}
|
|
292
|
+
} catch { /* os stays undefined */ }
|
|
223
293
|
registerAnchor({
|
|
224
294
|
anchorPath,
|
|
225
295
|
machineId: machine?.id,
|
|
@@ -227,6 +297,7 @@ function remoteWorkspacesService() {
|
|
|
227
297
|
port: machine?.port ?? null,
|
|
228
298
|
user: machine?.user ?? null,
|
|
229
299
|
remotePath,
|
|
300
|
+
...(os !== undefined ? { os } : {}),
|
|
230
301
|
})
|
|
231
302
|
return { ok: true, localDir: anchorPath, remotePath }
|
|
232
303
|
} catch (error) {
|
|
@@ -285,6 +356,8 @@ export function apply(ctx) {
|
|
|
285
356
|
if (typeof cwd !== 'string' || cwd === '') return cwd
|
|
286
357
|
const hit = findByCwd(cwd)
|
|
287
358
|
if (hit === undefined) return cwd
|
|
359
|
+
// Side effect: backfill the os profile on rows registered before B4.
|
|
360
|
+
kickOsBackfill(cwd)
|
|
288
361
|
return hit.remoteSubpath === '' ? hit.remotePath : `${hit.remotePath.replace(/\/+$/, '')}/${hit.remoteSubpath}`
|
|
289
362
|
})
|
|
290
363
|
})
|
|
@@ -303,8 +376,14 @@ export function apply(ctx) {
|
|
|
303
376
|
if (typeof cwd !== 'string' || cwd === '') return ''
|
|
304
377
|
const hit = findByCwd(cwd)
|
|
305
378
|
if (hit === undefined) return ''
|
|
379
|
+
// Side effect: backfill the os profile on rows registered before B4.
|
|
380
|
+
kickOsBackfill(cwd)
|
|
306
381
|
const host = hit.user ? `${hit.user}@${hit.host}` : hit.host
|
|
307
|
-
|
|
382
|
+
const base = `Remote workspace over SSH (${host}): file/search tools and shell commands run on the remote host; use relative paths (they route to the remote automatically).`
|
|
383
|
+
if (hit.os?.family === 'windows') {
|
|
384
|
+
return base + ' The remote shell is PowerShell — write PowerShell syntax (built-in aliases like ls/cat/cd/pwd work; bash-only syntax such as && or 2>/dev/null does not).'
|
|
385
|
+
}
|
|
386
|
+
return base
|
|
308
387
|
},
|
|
309
388
|
})
|
|
310
389
|
})
|
package/src/registry.js
CHANGED
|
@@ -34,14 +34,28 @@ function saveAnchors(anchors) {
|
|
|
34
34
|
writeFileSync(anchorsPath(), JSON.stringify(anchors, null, 2) + '\n', 'utf8')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Record an anchor (idempotent by anchorPath). Returns the stored record.
|
|
38
|
-
|
|
37
|
+
/** Record an anchor (idempotent by anchorPath). Returns the stored record.
|
|
38
|
+
* `os` (optional) is the probed remote profile `{ family, os, shell }` used
|
|
39
|
+
* by prompt/dialect hints; older rows without it are backfilled lazily. */
|
|
40
|
+
export function registerAnchor({ anchorPath, machineId, host, port, user, remotePath, os }) {
|
|
39
41
|
const anchors = loadAnchors()
|
|
40
|
-
|
|
42
|
+
const rec = { machineId, host, port, user, remotePath, registeredAt: new Date().toISOString() }
|
|
43
|
+
if (os !== undefined && os !== null) rec.os = os
|
|
44
|
+
anchors[anchorPath] = rec
|
|
41
45
|
saveAnchors(anchors)
|
|
42
46
|
return anchors[anchorPath]
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** Backfill (or replace) the probed remote profile on an existing anchor row. */
|
|
50
|
+
export function updateAnchorOs(anchorPath, os) {
|
|
51
|
+
const anchors = loadAnchors()
|
|
52
|
+
const rec = anchors[anchorPath]
|
|
53
|
+
if (rec === undefined) return undefined
|
|
54
|
+
rec.os = os
|
|
55
|
+
saveAnchors(anchors)
|
|
56
|
+
return rec
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/** Remove one anchor by its local path. */
|
|
46
60
|
export function unregisterAnchor(anchorPath) {
|
|
47
61
|
const anchors = loadAnchors()
|
|
@@ -73,4 +87,4 @@ export function findByCwd(cwd) {
|
|
|
73
87
|
return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
export default { loadAnchors, registerAnchor, unregisterAnchor, findByCwd, remoteWorkspacesRoot }
|
|
90
|
+
export default { loadAnchors, registerAnchor, unregisterAnchor, updateAnchorOs, findByCwd, remoteWorkspacesRoot }
|
package/src/search.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { posix } from 'node:path'
|
|
14
|
-
import { shellQuote } from './transport.js'
|
|
14
|
+
import { shellQuote, psQuote, toWinPath, profileCacheKey } from './transport.js'
|
|
15
15
|
import { findByCwd } from './registry.js'
|
|
16
16
|
|
|
17
17
|
const GREP_MAX_MATCHES = 250
|
|
@@ -21,6 +21,9 @@ const SEARCH_TIMEOUT_MS = 30_000
|
|
|
21
21
|
const STDERR_MAX_BYTES = 64 * 1024
|
|
22
22
|
const GRACE_MS = 3_000
|
|
23
23
|
|
|
24
|
+
/** One-time rg-presence verdict per Windows target (process lifetime). */
|
|
25
|
+
const RG_PRESENCE_CACHE = new Map()
|
|
26
|
+
|
|
24
27
|
// ---------------------------------------------------------------------------
|
|
25
28
|
// ripgrep argv (mirrors @deepseek-ai/dsh-tool-fs-search)
|
|
26
29
|
// ---------------------------------------------------------------------------
|
|
@@ -108,11 +111,57 @@ async function localRg(deps, argv, cwd, signal) {
|
|
|
108
111
|
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
|
|
109
112
|
throw new Error(`grep/glob: rg failed (exit ${outcome.exitCode}): ${stderr?.text ?? ''}`)
|
|
110
113
|
}
|
|
111
|
-
return stdout?.text ?? ''
|
|
114
|
+
return { stdout: stdout?.text ?? '', windows: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the remote `rg` command line for one profile. POSIX keeps the legacy
|
|
119
|
+
* single-quoted form. Windows runs rg.exe inside the per-shell PowerShell
|
|
120
|
+
* script, so EVERY token is PowerShell-single-quoted (patterns with spaces or
|
|
121
|
+
* apostrophes stay one argument) and the search root — the token after `--`,
|
|
122
|
+
* which arrives in the canonical `/X:/…` SFTP form — is converted with
|
|
123
|
+
* `toWinPath` because native rg.exe does not understand a leading-slash drive
|
|
124
|
+
* path. `--path-separator=/` forces forward-slash paths in rg's JSON/file
|
|
125
|
+
* output, matching every other path the plugin surfaces (native rg.exe would
|
|
126
|
+
* otherwise print `src\file.js`). `--regexp`/`--glob` values are pattern text
|
|
127
|
+
* and pass through as-is.
|
|
128
|
+
*/
|
|
129
|
+
export function remoteRgCommand(profile, argv) {
|
|
130
|
+
if (profile !== undefined && profile.family === 'windows') {
|
|
131
|
+
const parts = [psQuote('--path-separator=/')]
|
|
132
|
+
let isRoot = false
|
|
133
|
+
for (const token of argv) {
|
|
134
|
+
parts.push(isRoot ? psQuote(toWinPath(token)) : psQuote(token))
|
|
135
|
+
isRoot = token === '--'
|
|
136
|
+
}
|
|
137
|
+
return `rg ${parts.join(' ')}`
|
|
138
|
+
}
|
|
139
|
+
return ['rg', ...argv].map(shellQuote).join(' ')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verify ripgrep exists on a Windows target once per process. A missing `rg`
|
|
144
|
+
* must NOT surface as the glue's exit 1 (which the caller reads as "no
|
|
145
|
+
* matches"), so it is detected up front and reported with an install hint.
|
|
146
|
+
*/
|
|
147
|
+
async function ensureRemoteRg(client) {
|
|
148
|
+
const key = profileCacheKey(client)
|
|
149
|
+
let present = RG_PRESENCE_CACHE.get(key)
|
|
150
|
+
if (present === undefined) {
|
|
151
|
+
const probe = await client.run('rg --version')
|
|
152
|
+
present = probe.ok
|
|
153
|
+
RG_PRESENCE_CACHE.set(key, present)
|
|
154
|
+
}
|
|
155
|
+
if (!present) {
|
|
156
|
+
throw new Error('grep/glob: ripgrep was not found on the remote Windows host. Install it first (e.g. winget install BurntSushi.ripgrep.MSVC or scoop install ripgrep) and retry')
|
|
157
|
+
}
|
|
112
158
|
}
|
|
113
159
|
|
|
114
160
|
async function remoteRg(client, argv, remoteCwd, signal) {
|
|
115
|
-
const
|
|
161
|
+
const profile = await client.profile()
|
|
162
|
+
const windows = profile.family === 'windows'
|
|
163
|
+
if (windows) await ensureRemoteRg(client)
|
|
164
|
+
const command = remoteRgCommand(profile, argv)
|
|
116
165
|
const result = await client.execShell(command, {
|
|
117
166
|
cwd: remoteCwd,
|
|
118
167
|
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
@@ -124,7 +173,16 @@ async function remoteRg(client, argv, remoteCwd, signal) {
|
|
|
124
173
|
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
125
174
|
throw new Error(`grep/glob: remote rg failed (exit ${result.exitCode}): ${result.stderr.text}`)
|
|
126
175
|
}
|
|
127
|
-
return result.stdout.text
|
|
176
|
+
return { stdout: result.stdout.text, windows }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* rg.exe ignores `--path-separator` for the JSON match records it emits, so
|
|
181
|
+
* grep paths come back with native backslashes on Windows remotes. Normalize
|
|
182
|
+
* to forward slashes so every surfaced path matches the canonical form.
|
|
183
|
+
*/
|
|
184
|
+
function normalizeWindowsPath(value) {
|
|
185
|
+
return String(value).replace(/\\/g, '/')
|
|
128
186
|
}
|
|
129
187
|
|
|
130
188
|
// ---------------------------------------------------------------------------
|
|
@@ -216,8 +274,13 @@ export function createSearchTools(deps) {
|
|
|
216
274
|
...(args.path !== undefined ? { path: args.path } : {}),
|
|
217
275
|
...(args.include !== undefined ? { include: args.include } : {}),
|
|
218
276
|
}
|
|
219
|
-
const stdout = await runRg(exec, grepArgv, input)
|
|
220
|
-
|
|
277
|
+
const { stdout, windows } = await runRg(exec, grepArgv, input)
|
|
278
|
+
const matches = parseGrepMatches(stdout).slice(0, GREP_MAX_MATCHES)
|
|
279
|
+
return {
|
|
280
|
+
matches: windows
|
|
281
|
+
? matches.map((m) => ({ ...m, path: normalizeWindowsPath(m.path) }))
|
|
282
|
+
: matches,
|
|
283
|
+
}
|
|
221
284
|
},
|
|
222
285
|
}
|
|
223
286
|
|
|
@@ -248,8 +311,9 @@ export function createSearchTools(deps) {
|
|
|
248
311
|
},
|
|
249
312
|
async execute(args, exec) {
|
|
250
313
|
const input = { pattern: args.pattern, ...(args.path !== undefined ? { path: args.path } : {}) }
|
|
251
|
-
const stdout = await runRg(exec, globArgv, input)
|
|
252
|
-
|
|
314
|
+
const { stdout, windows } = await runRg(exec, globArgv, input)
|
|
315
|
+
const paths = parseGlobPaths(stdout).slice(0, GLOB_MAX_RESULTS)
|
|
316
|
+
return { paths: windows ? paths.map(normalizeWindowsPath) : paths }
|
|
253
317
|
},
|
|
254
318
|
}
|
|
255
319
|
|
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'
|
|
@@ -226,6 +226,7 @@ export class SshShellExecutor {
|
|
|
226
226
|
let buffer = ''
|
|
227
227
|
let spawnError
|
|
228
228
|
let pollTimer
|
|
229
|
+
let streamCtl = null
|
|
229
230
|
|
|
230
231
|
const proc = {
|
|
231
232
|
status: 'running',
|
|
@@ -240,10 +241,62 @@ export class SshShellExecutor {
|
|
|
240
241
|
kill() {
|
|
241
242
|
if (proc.status !== 'running') return false
|
|
242
243
|
proc.status = 'killed'
|
|
243
|
-
if (
|
|
244
|
+
if (streamCtl !== null) streamCtl.terminate()
|
|
245
|
+
else if (pid !== null) void client.run(`kill ${pid} 2>/dev/null || true`)
|
|
244
246
|
return true
|
|
245
247
|
},
|
|
246
248
|
done: (async () => {
|
|
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
|
+
}
|
|
257
|
+
if (profile.family === 'windows') {
|
|
258
|
+
// No nohup-style detach exists on Windows remotes, and closing the
|
|
259
|
+
// channel alone does NOT reap the remote tree (verified) - so the
|
|
260
|
+
// job is a long-lived exec stream whose terminate() actively
|
|
261
|
+
// taskkill /T's the remote PID and then closes the channel.
|
|
262
|
+
const ctl = await client.execStream(spec.command, { cwd: path })
|
|
263
|
+
if (proc.status !== 'running') { ctl.terminate(); return }
|
|
264
|
+
streamCtl = ctl
|
|
265
|
+
const merge = () => {
|
|
266
|
+
const outPart = ctl.readOut()
|
|
267
|
+
const errPart = ctl.readErr()
|
|
268
|
+
if (outPart.delta.length > 0) {
|
|
269
|
+
if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
|
|
270
|
+
buffer += outPart.delta
|
|
271
|
+
}
|
|
272
|
+
if (errPart.delta.length > 0) {
|
|
273
|
+
if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
|
|
274
|
+
buffer += '[stderr]\n' + errPart.delta
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (spec.signal !== undefined) {
|
|
278
|
+
const onAbort = () => { if (streamCtl !== null) streamCtl.terminate() }
|
|
279
|
+
if (spec.signal.aborted) onAbort()
|
|
280
|
+
else spec.signal.addEventListener('abort', onAbort, { once: true })
|
|
281
|
+
}
|
|
282
|
+
const pump = async () => {
|
|
283
|
+
while (proc.status === 'running') {
|
|
284
|
+
merge()
|
|
285
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
void pump()
|
|
289
|
+
const outcome = await ctl.exit
|
|
290
|
+
merge()
|
|
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
|
+
}
|
|
297
|
+
proc.exitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null
|
|
298
|
+
return
|
|
299
|
+
}
|
|
247
300
|
const launched = await client.run(launchScript)
|
|
248
301
|
if (!launched.ok) {
|
|
249
302
|
spawnError = new Error((launched.stderr ?? '').trim() || launched.error || 'background spawn failed')
|
package/src/transport.js
CHANGED
|
@@ -12,6 +12,195 @@ export function shellQuote(value) {
|
|
|
12
12
|
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Convert a canonical REMOTE path — as the SFTP `realpath` reports it, which
|
|
17
|
+
* is POSIX-style even on Windows hosts (`/X:/work/demo`) — into the form a
|
|
18
|
+
* Windows shell accepts (`X:/work/demo`). PowerShell understands forward
|
|
19
|
+
* slashes, so only a leading `/X:` drive prefix is stripped; everything else
|
|
20
|
+
* passes through untouched.
|
|
21
|
+
*/
|
|
22
|
+
export function toWinPath(value) {
|
|
23
|
+
const s = String(value ?? '')
|
|
24
|
+
return /^\/[A-Za-z]:/.test(s) ? s.slice(1) : s
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** PowerShell single-quote escaping for a path/string literal. */
|
|
28
|
+
export function psQuote(value) {
|
|
29
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip PowerShell progress records from a stderr capture. When a nested
|
|
34
|
+
* powershell is launched by a Windows OpenSSH exec channel (e.g. the cmd
|
|
35
|
+
* default-shell wrapper), the engine's first-run module-analysis warm-up emits
|
|
36
|
+
* a `#< CLIXML …</Objs>` progress blob to stderr on every fresh process —
|
|
37
|
+
* real cmdlet errors still arrive as plain text, so removing only CLIXML
|
|
38
|
+
* blocks keeps genuine diagnostics intact.
|
|
39
|
+
*/
|
|
40
|
+
export function stripPsProgressClixml(text) {
|
|
41
|
+
const lines = String(text ?? '').split('\n')
|
|
42
|
+
const out = []
|
|
43
|
+
let inBlob = false
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!inBlob && /#< CLIXML/i.test(line)) {
|
|
46
|
+
inBlob = !/<\/Objs>/i.test(line)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (inBlob) {
|
|
50
|
+
if (/<\/Objs>/i.test(line)) { inBlob = false; continue }
|
|
51
|
+
// XML record lines are dropped; a non-XML text line ends a truncated
|
|
52
|
+
// blob so genuine diagnostics that follow it are preserved.
|
|
53
|
+
if (!/^\s*</.test(line) && line.trim() !== '') { inBlob = false; out.push(line); continue }
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
out.push(line)
|
|
57
|
+
}
|
|
58
|
+
return out.join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whole-text CRLF→LF (Windows remote output normalization). */
|
|
62
|
+
export function crlfToLf(text) {
|
|
63
|
+
return String(text ?? '').replace(/\r\n/g, '\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Streaming CRLF→LF normalizer that carries a trailing `\r` across chunk
|
|
68
|
+
* boundaries (a `\r\n` pair may be split between two data events). `feed`
|
|
69
|
+
* returns the normalized fragment; call `flush()` at stream end for a lone
|
|
70
|
+
* trailing `\r` (isolated CRs are preserved, not folded).
|
|
71
|
+
*/
|
|
72
|
+
export function createCrlfToLf() {
|
|
73
|
+
let carry = ''
|
|
74
|
+
return {
|
|
75
|
+
feed(text) {
|
|
76
|
+
const s = String(text ?? '')
|
|
77
|
+
let out = carry + s
|
|
78
|
+
carry = ''
|
|
79
|
+
out = out.replace(/\r\n/g, '\n')
|
|
80
|
+
if (out.endsWith('\r')) {
|
|
81
|
+
carry = '\r'
|
|
82
|
+
out = out.slice(0, -1)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
},
|
|
86
|
+
flush() {
|
|
87
|
+
const rest = carry
|
|
88
|
+
carry = ''
|
|
89
|
+
return rest
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Exit-code glue appended to every Windows remote script so the channel's
|
|
96
|
+
* exit status mirrors what a POSIX shell reports: the last command's result.
|
|
97
|
+
* `exit N` inside the user's own command terminates first and wins.
|
|
98
|
+
*/
|
|
99
|
+
const PS_EXIT_GLUE = 'if ($?) { exit $LASTEXITCODE } else { exit 1 }'
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remote execution profiles, probed once per target and cached for the
|
|
103
|
+
* process lifetime:
|
|
104
|
+
* { family: 'posix', os: 'linux'|'darwin', shell: 'posix' }
|
|
105
|
+
* { family: 'windows', os: 'windows', shell: 'powershell'|'cmd' }
|
|
106
|
+
* { family: 'unknown', … } — callers fall back to POSIX behaviour.
|
|
107
|
+
*/
|
|
108
|
+
const REMOTE_PROFILE_CACHE = new Map()
|
|
109
|
+
|
|
110
|
+
export function profileCacheKey({ host, user, port }) {
|
|
111
|
+
return `${user ?? ''}@${host ?? ''}:${port ?? 22}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect a target's OS family and default exec shell without parsing
|
|
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
|
|
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).
|
|
125
|
+
*/
|
|
126
|
+
export async function probeRemoteProfile(client) {
|
|
127
|
+
const uname = await client.run('uname -s')
|
|
128
|
+
if (uname.ok) {
|
|
129
|
+
const os = (uname.stdout ?? '').trim().toLowerCase()
|
|
130
|
+
return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
|
|
131
|
+
}
|
|
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 ?? '')) {
|
|
136
|
+
const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
|
|
137
|
+
return ps.ok
|
|
138
|
+
? { family: 'windows', os: 'windows', shell: 'powershell' }
|
|
139
|
+
: { family: 'windows', os: 'windows', shell: 'cmd' }
|
|
140
|
+
}
|
|
141
|
+
const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
|
|
142
|
+
if (ps.ok) return { family: 'windows', os: 'windows', shell: 'powershell' }
|
|
143
|
+
return { family: 'unknown', os: 'unknown', shell: 'unknown' }
|
|
144
|
+
}
|
|
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
|
+
|
|
156
|
+
/** Encode a PowerShell script for quote-safe transport through cmd.exe.
|
|
157
|
+
*
|
|
158
|
+
* `powershell -EncodedCommand` would be ideal (no quoting at all), but a
|
|
159
|
+
* nested -EncodedCommand host serializes BOTH progress and error records to
|
|
160
|
+
* stderr as CLIXML soup. A `-Command` host prints plain text, so instead the
|
|
161
|
+
* script rides inside one fixed, quote-free command line: base64 has no
|
|
162
|
+
* `% ! ^ " $ '` and the wrapper has no `$`, so neither cmd nor a PowerShell
|
|
163
|
+
* outer shell can mangle it, and the inner text is decoded + `iex`'d.
|
|
164
|
+
*/
|
|
165
|
+
export function psCommandEnvelope(script) {
|
|
166
|
+
const b64 = Buffer.from(script, 'utf16le').toString('base64')
|
|
167
|
+
return `powershell -NoProfile -NonInteractive -Command "& { iex ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${b64}'))) }"`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the exec-channel script for one command under a remote profile.
|
|
172
|
+
*
|
|
173
|
+
* POSIX / unknown: unchanged legacy form (`cd 'x' || exit 1` + command).
|
|
174
|
+
*
|
|
175
|
+
* Windows with a PowerShell default shell: the raw script text is executed
|
|
176
|
+
* by the server's default shell directly (cmdlets + aliases like ls/cat/cd
|
|
177
|
+
* make simple commands work), prefixed with a `Set-Location` when a cwd is
|
|
178
|
+
* given and suffixed with the exit-code glue. Windows with a cmd default
|
|
179
|
+
* shell: the same script rides inside a fixed, quote-free
|
|
180
|
+
* `powershell -Command` envelope (base64 + iex), so cmd only ever parses one
|
|
181
|
+
* fixed command line, its exit code is the child's, and stderr stays plain
|
|
182
|
+
* text (no CLIXML records).
|
|
183
|
+
*/
|
|
184
|
+
export function buildExecScript(profile, command, cwd) {
|
|
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') {
|
|
194
|
+
return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
|
|
195
|
+
}
|
|
196
|
+
const script = [
|
|
197
|
+
...(cwd ? [`Set-Location -LiteralPath ${psQuote(toWinPath(cwd))}`] : []),
|
|
198
|
+
command,
|
|
199
|
+
PS_EXIT_GLUE,
|
|
200
|
+
].join('\n')
|
|
201
|
+
return profile.shell === 'cmd' ? psCommandEnvelope(script) : script
|
|
202
|
+
}
|
|
203
|
+
|
|
15
204
|
/** Bounded output collector: keeps the TAIL of a stream, flags truncation. */
|
|
16
205
|
class CapCollector {
|
|
17
206
|
constructor(maxBytes) {
|
|
@@ -193,9 +382,18 @@ export class SshClient {
|
|
|
193
382
|
* bounded (tail-kept) stdout/stderr, stdin, and an abort signal that closes
|
|
194
383
|
* the exec channel (SIGHUP on the remote). Resolves with exitCode/signal,
|
|
195
384
|
* timedOut/aborted first-cause, and `{ text, truncated }` outputs.
|
|
385
|
+
*
|
|
386
|
+
* The command text is executed through the target's default shell. On
|
|
387
|
+
* Windows targets the script is built per detected default shell
|
|
388
|
+
* (`buildExecScript`): raw PowerShell when PowerShell is the default, a
|
|
389
|
+
* quote-safe `powershell -EncodedCommand` wrapper when cmd is.
|
|
196
390
|
*/
|
|
197
391
|
async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
|
|
198
|
-
const
|
|
392
|
+
const profile = await this.profile()
|
|
393
|
+
if (profile.family === 'unknown') {
|
|
394
|
+
return { ok: false, error: PROBE_UNKNOWN_MSG }
|
|
395
|
+
}
|
|
396
|
+
const script = buildExecScript(profile, command, cwd)
|
|
199
397
|
return await new Promise((resolve) => {
|
|
200
398
|
const conn = new Client()
|
|
201
399
|
let settled = false
|
|
@@ -228,14 +426,20 @@ export class SshClient {
|
|
|
228
426
|
s.stderr.on('data', (d) => errc.push(d))
|
|
229
427
|
s.on('close', (code) => {
|
|
230
428
|
const aborted = signal !== undefined && signal.aborted
|
|
429
|
+
const stdout = out.output()
|
|
430
|
+
const stderr = errc.output()
|
|
431
|
+
if (profile.family === 'windows') {
|
|
432
|
+
stderr.text = crlfToLf(stripPsProgressClixml(stderr.text))
|
|
433
|
+
stdout.text = crlfToLf(stdout.text)
|
|
434
|
+
}
|
|
231
435
|
finish({
|
|
232
436
|
ok: true,
|
|
233
437
|
exitCode: code,
|
|
234
438
|
signal: null,
|
|
235
439
|
timedOut: timedOut && !aborted,
|
|
236
440
|
aborted: aborted && !timedOut,
|
|
237
|
-
stdout
|
|
238
|
-
stderr
|
|
441
|
+
stdout,
|
|
442
|
+
stderr,
|
|
239
443
|
})
|
|
240
444
|
})
|
|
241
445
|
if (stdin === undefined) s.end()
|
|
@@ -293,10 +497,178 @@ export class SshClient {
|
|
|
293
497
|
})
|
|
294
498
|
}
|
|
295
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Open a LONG-LIVED exec channel for streaming (used by background/start
|
|
502
|
+
* jobs on Windows remotes, where no nohup-style detach exists and closing
|
|
503
|
+
* the channel alone does NOT reliably reap the remote command tree — a kill
|
|
504
|
+
* must actively `taskkill /T` the remote process first; the channel close
|
|
505
|
+
* is only a fallback).
|
|
506
|
+
*
|
|
507
|
+
* On Windows the remote script is prefixed with a self-reporting PID line
|
|
508
|
+
* (`DWSH_PID=<pid>`), which is parsed out of the first output chunk so the
|
|
509
|
+
* controller can tree-kill the exact process. Resolves a controller:
|
|
510
|
+
* { readOut(), readErr() -> { delta, lossy }, exit: Promise<{exitCode}|{error}>, terminate() }
|
|
511
|
+
*/
|
|
512
|
+
async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
|
|
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
|
+
}
|
|
523
|
+
const windows = profile.family === 'windows'
|
|
524
|
+
const ssh = this
|
|
525
|
+
// The marker runs FIRST inside the same remote shell that owns the exec
|
|
526
|
+
// channel (the raw PowerShell on PS-default hosts, the nested PowerShell
|
|
527
|
+
// inside the -Command envelope on cmd-default hosts), so its $PID is the
|
|
528
|
+
// process whose tree taskkill must reap.
|
|
529
|
+
const markerCommand = windows ? "Write-Output ('DWSH_PID=' + $PID)\n" + command : command
|
|
530
|
+
const script = buildExecScript(profile, markerCommand, cwd)
|
|
531
|
+
return await new Promise((resolve, reject) => {
|
|
532
|
+
const conn = new Client()
|
|
533
|
+
const out = { buf: '', pos: 0, max: stdoutMaxBytes, lossy: false }
|
|
534
|
+
const err = { buf: '', pos: 0, max: stderrMaxBytes, lossy: false }
|
|
535
|
+
let stream
|
|
536
|
+
let settled = false
|
|
537
|
+
let pidResolve
|
|
538
|
+
const pid = new Promise((res) => { pidResolve = res })
|
|
539
|
+
// A stuck launch must not hold terminate() forever.
|
|
540
|
+
const pidTimer = setTimeout(() => pidResolve(null), 8000)
|
|
541
|
+
let exitResolve
|
|
542
|
+
const exit = new Promise((res) => { exitResolve = res })
|
|
543
|
+
const finish = (value) => {
|
|
544
|
+
if (settled) return
|
|
545
|
+
settled = true
|
|
546
|
+
clearTimeout(pidTimer)
|
|
547
|
+
try { conn.end() } catch {}
|
|
548
|
+
exitResolve(value)
|
|
549
|
+
}
|
|
550
|
+
const push = (target) => (chunk) => {
|
|
551
|
+
const piece = String(chunk)
|
|
552
|
+
if (target.buf.length + piece.length > target.max) { target.lossy = true; return }
|
|
553
|
+
target.buf += piece
|
|
554
|
+
}
|
|
555
|
+
const reader = (target) => () => {
|
|
556
|
+
const delta = target.buf.slice(target.pos)
|
|
557
|
+
target.pos = target.buf.length
|
|
558
|
+
return { delta, lossy: target.lossy }
|
|
559
|
+
}
|
|
560
|
+
// Normalize Windows CRLF output to LF at the chunk boundary so a \r\n
|
|
561
|
+
// split across data events still folds (isolated CRs are preserved).
|
|
562
|
+
const outLf = windows ? createCrlfToLf() : null
|
|
563
|
+
const errLf = windows ? createCrlfToLf() : null
|
|
564
|
+
// The PID marker is the FIRST stdout line, but the first data chunk may
|
|
565
|
+
// hold only part of it, so buffer head bytes until the line completes.
|
|
566
|
+
let head = ''
|
|
567
|
+
let markerResolved = false
|
|
568
|
+
const onOut = (chunk) => {
|
|
569
|
+
const text = outLf === null ? String(chunk) : outLf.feed(chunk)
|
|
570
|
+
if (text === '') return
|
|
571
|
+
if (markerResolved) { push(out)(text); return }
|
|
572
|
+
head += text
|
|
573
|
+
if (windows) {
|
|
574
|
+
const m = /^DWSH_PID=(\d+)\r?\n/.exec(head)
|
|
575
|
+
if (m !== null) {
|
|
576
|
+
markerResolved = true
|
|
577
|
+
clearTimeout(pidTimer)
|
|
578
|
+
pidResolve(Number(m[1]))
|
|
579
|
+
const rest = head.slice(m[0].length)
|
|
580
|
+
head = ''
|
|
581
|
+
if (rest.length > 0) push(out)(rest)
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
if (head.length > 4096) {
|
|
585
|
+
// Something else produced output first; give up on the marker.
|
|
586
|
+
markerResolved = true
|
|
587
|
+
clearTimeout(pidTimer)
|
|
588
|
+
pidResolve(null)
|
|
589
|
+
push(out)(head)
|
|
590
|
+
head = ''
|
|
591
|
+
}
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
markerResolved = true
|
|
595
|
+
clearTimeout(pidTimer)
|
|
596
|
+
pidResolve(null)
|
|
597
|
+
push(out)(head)
|
|
598
|
+
head = ''
|
|
599
|
+
}
|
|
600
|
+
const onErr = (chunk) => {
|
|
601
|
+
const text = errLf === null ? String(chunk) : errLf.feed(chunk)
|
|
602
|
+
if (text !== '') push(err)(text)
|
|
603
|
+
}
|
|
604
|
+
conn.on('ready', () => {
|
|
605
|
+
conn.exec(script, (execError, s) => {
|
|
606
|
+
if (execError) { finish({ error: execError.message }); return }
|
|
607
|
+
stream = s
|
|
608
|
+
s.on('data', onOut)
|
|
609
|
+
s.stderr.on('data', onErr)
|
|
610
|
+
s.on('close', (code) => {
|
|
611
|
+
if (outLf !== null) { const rest = outLf.flush(); if (rest !== '') push(out)(rest) }
|
|
612
|
+
if (errLf !== null) { const rest = errLf.flush(); if (rest !== '') push(err)(rest) }
|
|
613
|
+
finish({ exitCode: code })
|
|
614
|
+
})
|
|
615
|
+
s.end()
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
conn.on('error', (connError) => finish({ error: connError.message }))
|
|
619
|
+
try { conn.connect(this.connectConfig()) } catch (connectError) { finish({ error: connectError.message }) }
|
|
620
|
+
resolve({
|
|
621
|
+
readOut: reader(out),
|
|
622
|
+
readErr: reader(err),
|
|
623
|
+
exit,
|
|
624
|
+
terminate() {
|
|
625
|
+
const doClose = () => {
|
|
626
|
+
try { if (stream) stream.close() } catch {}
|
|
627
|
+
try { conn.end() } catch {}
|
|
628
|
+
// Settle explicitly: the remote 'close' event is not guaranteed.
|
|
629
|
+
finish({ exitCode: null })
|
|
630
|
+
}
|
|
631
|
+
pid.then((remotePid) => {
|
|
632
|
+
if (typeof remotePid === 'number' && remotePid > 0) {
|
|
633
|
+
void ssh.execShell(`taskkill /PID ${remotePid} /T /F`, {
|
|
634
|
+
timeoutMs: 15000, stdoutMaxBytes: 4096, stderrMaxBytes: 4096,
|
|
635
|
+
}).catch(() => {}).finally(doClose)
|
|
636
|
+
} else {
|
|
637
|
+
doClose()
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
},
|
|
641
|
+
})
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Cached remote execution profile ({ family, os, shell }); probed once per
|
|
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.
|
|
650
|
+
*/
|
|
651
|
+
async profile() {
|
|
652
|
+
const key = profileCacheKey(this)
|
|
653
|
+
let profile = REMOTE_PROFILE_CACHE.get(key)
|
|
654
|
+
if (profile === undefined) {
|
|
655
|
+
profile = await probeRemoteProfile(this)
|
|
656
|
+
if (profile.family === 'posix' || profile.family === 'windows') {
|
|
657
|
+
REMOTE_PROFILE_CACHE.set(key, profile)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return profile
|
|
661
|
+
}
|
|
662
|
+
|
|
296
663
|
async remoteOs() {
|
|
297
664
|
if (this._os === undefined) {
|
|
298
|
-
const
|
|
299
|
-
|
|
665
|
+
const profile = await this.profile()
|
|
666
|
+
if (profile.family === 'posix') this._os = profile.os === 'darwin' ? 'darwin' : 'linux'
|
|
667
|
+
else if (profile.family === 'windows') this._os = 'windows'
|
|
668
|
+
else {
|
|
669
|
+
const res = await this.run('uname -s')
|
|
670
|
+
this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
|
|
671
|
+
}
|
|
300
672
|
}
|
|
301
673
|
return this._os
|
|
302
674
|
}
|
|
@@ -383,12 +755,25 @@ export class SshClient {
|
|
|
383
755
|
}
|
|
384
756
|
|
|
385
757
|
/**
|
|
386
|
-
* Remote file content hash for post-write verification.
|
|
387
|
-
* `sha256sum` then BSD `shasum -a 256`;
|
|
758
|
+
* Remote file content hash for post-write verification. On POSIX targets
|
|
759
|
+
* tries GNU `sha256sum` then BSD `shasum -a 256`; on Windows targets uses
|
|
760
|
+
* PowerShell `Get-FileHash`. Returns the lowercase hex digest or
|
|
388
761
|
* `undefined` when no such tool exists (verification is then skipped).
|
|
389
762
|
* Locale-independent: the digest is hex, never localized text.
|
|
390
763
|
*/
|
|
391
764
|
async sha256(path) {
|
|
765
|
+
const profile = await this.profile()
|
|
766
|
+
if (profile.family === 'windows') {
|
|
767
|
+
const res = await this.execShell(
|
|
768
|
+
`(Get-FileHash -LiteralPath ${psQuote(toWinPath(path))} -Algorithm SHA256).Hash.ToLower()`,
|
|
769
|
+
{ timeoutMs: 30000 },
|
|
770
|
+
)
|
|
771
|
+
if (res.exitCode === 0) {
|
|
772
|
+
const hash = (res.stdout?.text ?? '').trim().toLowerCase()
|
|
773
|
+
if (/^[0-9a-f]{64}$/.test(hash)) return hash
|
|
774
|
+
}
|
|
775
|
+
return undefined
|
|
776
|
+
}
|
|
392
777
|
for (const cmd of [`sha256sum ${shellQuote(path)}`, `shasum -a 256 ${shellQuote(path)}`]) {
|
|
393
778
|
const res = await this.run(`${cmd} 2>/dev/null`)
|
|
394
779
|
if (res.ok) {
|