dsh-ssh-workbench 0.0.0-stage → 1.7.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +178 -2
  5. package/SECURITY.md +27 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +146 -0
  8. package/docs/execution-model.md +83 -0
  9. package/docs/images/chat-at-resource.png +0 -0
  10. package/docs/images/plugin.png +0 -0
  11. package/docs/images/remote-workbench.png +0 -0
  12. package/docs/images/workbench.png +0 -0
  13. package/docs/quickstart.md +106 -0
  14. package/lib/client.js +4991 -0
  15. package/lib/core/activity.js +88 -0
  16. package/lib/core/background.js +69 -0
  17. package/lib/core/broker.js +802 -0
  18. package/lib/core/dialect.js +28 -0
  19. package/lib/core/errors.js +138 -0
  20. package/lib/core/execution-stats.js +88 -0
  21. package/lib/core/gate-pwsh.js +88 -0
  22. package/lib/core/gate.js +572 -0
  23. package/lib/core/guard.js +123 -0
  24. package/lib/core/known-hosts.js +74 -0
  25. package/lib/core/live-executions.js +101 -0
  26. package/lib/core/local-file-api.js +174 -0
  27. package/lib/core/localfs.js +381 -0
  28. package/lib/core/managed-context.js +276 -0
  29. package/lib/core/mentions.js +216 -0
  30. package/lib/core/paths.js +104 -0
  31. package/lib/core/posix-quote.js +4 -0
  32. package/lib/core/posix-shell.js +89 -0
  33. package/lib/core/prefs.js +38 -0
  34. package/lib/core/remote-exec-tool.js +394 -0
  35. package/lib/core/remote-file-tools.js +559 -0
  36. package/lib/core/remote-shell-tool.js +310 -0
  37. package/lib/core/secrets.js +95 -0
  38. package/lib/core/server-ref.js +61 -0
  39. package/lib/core/shell-adapters.js +48 -0
  40. package/lib/core/ssh.js +761 -0
  41. package/lib/core/store.js +83 -0
  42. package/lib/core/terminals.js +299 -0
  43. package/lib/core/win32-adapter.js +141 -0
  44. package/lib/index.js +409 -0
  45. package/lib/routes.js +759 -0
  46. package/locale/en.json +6 -0
  47. package/locale/zh.json +6 -0
  48. package/package.json +89 -4
  49. package/vendor/NOTICE.md +50 -0
  50. package/vendor/XTERM-LICENSE +21 -0
  51. package/vendor/node_modules/asn1/LICENSE +19 -0
  52. package/vendor/node_modules/asn1/README.md +50 -0
  53. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  54. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  55. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  56. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  57. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  58. package/vendor/node_modules/asn1/lib/index.js +20 -0
  59. package/vendor/node_modules/asn1/package.json +31 -0
  60. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  61. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  62. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  63. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  64. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  65. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  66. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  67. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  68. package/vendor/node_modules/safer-buffer/package.json +34 -0
  69. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  70. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  71. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  72. package/vendor/node_modules/tweetnacl/README.md +459 -0
  73. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  74. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  75. package/vendor/node_modules/tweetnacl/package.json +58 -0
  76. package/vendor/ssh2/LICENSE +19 -0
  77. package/vendor/ssh2/lib/Channel.js +295 -0
  78. package/vendor/ssh2/lib/agent.js +1123 -0
  79. package/vendor/ssh2/lib/client.js +2176 -0
  80. package/vendor/ssh2/lib/http-agents.js +84 -0
  81. package/vendor/ssh2/lib/index.js +44 -0
  82. package/vendor/ssh2/lib/keygen.js +582 -0
  83. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  84. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  85. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  86. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  87. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  88. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  89. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  90. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  91. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  92. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  93. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  94. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  95. package/vendor/ssh2/lib/server.js +1380 -0
  96. package/vendor/ssh2/lib/utils.js +336 -0
  97. package/vendor/ssh2/package.json +34 -0
  98. package/vendor/xterm.js +53 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Server inventory: JSON document under the harness home.
3
+ * Pure data logic; no DSH imports so it stays unit testable without the host
4
+ * runtime.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+ import { serversPath, writePrivateFile, readTextFile } from './paths.js'
8
+
9
+ const PLATFORMS = new Set(['posix', 'windows'])
10
+
11
+ function defaultShellFor(platform) {
12
+ return platform === 'windows' ? 'powershell' : 'bash'
13
+ }
14
+
15
+ function asString(value, fallback = '') {
16
+ return typeof value === 'string' ? value.trim() : fallback
17
+ }
18
+
19
+ function asStringArray(value) {
20
+ if (!Array.isArray(value)) return []
21
+ return value.map((item) => asString(item)).filter(Boolean)
22
+ }
23
+
24
+ /**
25
+ * Validate and canonicalise one stored server record. Throws on records the
26
+ * plugin could never execute, so a corrupt inventory fails loud at the
27
+ * boundary instead of deep inside an SSH call.
28
+ */
29
+ export function normalizeServer(raw, { idFactory = () => `srv_${randomUUID()}` } = {}) {
30
+ if (!raw || typeof raw !== 'object') throw new Error('server record must be an object')
31
+ const name = asString(raw.name)
32
+ const host = asString(raw.host)
33
+ if (!name) throw new Error('server.name is required')
34
+ if (!host) throw new Error(`server "${name}" needs a host`)
35
+ const port = Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536 ? raw.port : 22
36
+ const username = asString(raw.username, 'root') || 'root'
37
+ const platform = PLATFORMS.has(raw.platform) ? raw.platform : 'posix'
38
+ const authRef = normalizeAuthRef(raw.authRef, name)
39
+ return {
40
+ id: asString(raw.id, idFactory()) || idFactory(),
41
+ name,
42
+ host,
43
+ port,
44
+ username,
45
+ platform,
46
+ defaultShell: asString(raw.defaultShell, defaultShellFor(platform)) || defaultShellFor(platform),
47
+ tags: asStringArray(raw.tags),
48
+ description: asString(raw.description),
49
+ enabled: raw.enabled !== false,
50
+ facts: raw.facts && typeof raw.facts === 'object' ? { ...raw.facts } : {},
51
+ authRef,
52
+ }
53
+ }
54
+
55
+ function normalizeAuthRef(raw, serverName) {
56
+ if (!raw || typeof raw !== 'object') throw new Error(`server "${serverName}" needs authRef`)
57
+ if (raw.type === 'sshKey') {
58
+ const keyPath = asString(raw.path)
59
+ if (!keyPath) throw new Error(`server "${serverName}" sshKey authRef needs a path`)
60
+ return { type: 'sshKey', path: keyPath }
61
+ }
62
+ if (raw.type === 'password') return { type: 'password' }
63
+ throw new Error(`server "${serverName}" has unsupported authRef type`)
64
+ }
65
+
66
+ export function loadServers(file = serversPath()) {
67
+ const text = readTextFile(file)
68
+ if (!text.trim()) return []
69
+ let parsed
70
+ try {
71
+ parsed = JSON.parse(text)
72
+ } catch (error) {
73
+ throw new Error(`servers.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
74
+ }
75
+ if (!Array.isArray(parsed)) throw new Error('servers.json must be an array')
76
+ return parsed.map((record) => normalizeServer(record))
77
+ }
78
+
79
+ export function saveServers(servers, file = serversPath()) {
80
+ const normalized = servers.map((record) => normalizeServer(record))
81
+ writePrivateFile(file, `${JSON.stringify(normalized, null, 2)}\n`)
82
+ return normalized
83
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Interactive terminal sessions for the workbench.
3
+ *
4
+ * Remote sessions run on the existing ssh2 broker shell channel; local ones use
5
+ * the Host subprocess service's PTY (`spawnTerminal`). The browser renders them
6
+ * with xterm and polls `read`, so the transport needs no new socket protocol
7
+ * and stays on the plugin's authenticated routes.
8
+ *
9
+ * Sessions are process-local, buffered (bounded) and reclaimed after a period
10
+ * of inactivity or when their server/plugin goes away. Closing a session ends
11
+ * the remote channel or the local PTY; nothing survives a Host restart.
12
+ */
13
+ import { randomUUID } from 'node:crypto'
14
+ import { StringDecoder } from 'node:string_decoder'
15
+
16
+ const MAX_BUFFER_CHARS = 512 * 1024
17
+ const MAX_SESSIONS = 12
18
+ const MAX_WAIT_MS = 20000
19
+ const DEFAULT_IDLE_MS = 30 * 60 * 1000
20
+
21
+ function clampDimension(value, fallback, max) {
22
+ const number = Number(value)
23
+ if (!Number.isFinite(number) || number < 1) return fallback
24
+ return Math.min(Math.max(Math.floor(number), 1), max)
25
+ }
26
+
27
+ function describe(error) {
28
+ return error instanceof Error ? error.message : String(error)
29
+ }
30
+
31
+ export function createTerminalHub({ runtime, getSubprocess, getLocalCwd, log = () => {} } = {}) {
32
+ /** @type {Map<string, object>} */
33
+ const sessions = new Map()
34
+ // Slots reserved by opens that are still waiting for their transport. The
35
+ // cap must be checked atomically: `open()` awaits the transport, so counting
36
+ // only inserted sessions lets concurrent calls all pass the same check.
37
+ let pendingOpens = 0
38
+ let disposed = false
39
+
40
+ function notify(session) {
41
+ for (const wake of [...session.waiters]) {
42
+ session.waiters.delete(wake)
43
+ try { wake() } catch { /* a waiter must never break the session */ }
44
+ }
45
+ }
46
+
47
+ function append(session, text) {
48
+ if (!text) return
49
+ session.text += text
50
+ if (session.text.length > MAX_BUFFER_CHARS) {
51
+ let drop = session.text.length - MAX_BUFFER_CHARS
52
+ // Never cut a UTF-16 surrogate pair in half: a slice starting on the low
53
+ // half would leave an orphan surrogate that the client renders as U+FFFD.
54
+ const first = session.text.charCodeAt(drop)
55
+ if (first >= 0xdc00 && first <= 0xdfff) drop += 1
56
+ session.text = session.text.slice(drop)
57
+ session.base += drop
58
+ }
59
+ notify(session)
60
+ }
61
+
62
+ function finish(session, exitCode, signal) {
63
+ if (session.exited) return
64
+ append(session, session.decoder.end())
65
+ session.exited = true
66
+ session.exitCode = Number.isInteger(exitCode) ? exitCode : null
67
+ session.signal = typeof signal === 'string' ? signal : null
68
+ notify(session)
69
+ }
70
+
71
+ function snapshot(session, cursor) {
72
+ let offset = Number.isInteger(cursor) && cursor >= 0 ? cursor : 0
73
+ let reset = false
74
+ if (offset < session.base) {
75
+ reset = true
76
+ offset = session.base
77
+ }
78
+ return {
79
+ session: session.id,
80
+ cursor: session.base + session.text.length,
81
+ data: session.text.slice(offset - session.base),
82
+ reset,
83
+ exited: session.exited,
84
+ exitCode: session.exitCode,
85
+ signal: session.signal,
86
+ title: session.title,
87
+ kind: session.kind,
88
+ }
89
+ }
90
+
91
+ function publicSession(session) {
92
+ return {
93
+ session: session.id,
94
+ kind: session.kind,
95
+ title: session.title,
96
+ server: session.server?.name ?? null,
97
+ cols: session.cols,
98
+ rows: session.rows,
99
+ cwd: session.cwd,
100
+ }
101
+ }
102
+
103
+ function closeSession(session, reason) {
104
+ if (!session || session.closed) return
105
+ session.closed = true
106
+ sessions.delete(session.id)
107
+ // Mark the session exited *before* waking readers so a suspended
108
+ // `/terminal/read` long-poll returns `exited:true` immediately instead of
109
+ // holding the request until its wait timeout (or reporting the stale
110
+ // "not found" of a removed session).
111
+ session.exited = true
112
+ session.exitCode = null
113
+ session.signal = null
114
+ notify(session)
115
+ session.waiters.clear()
116
+ const handle = session.handle
117
+ session.handle = null
118
+ if (handle) {
119
+ try {
120
+ if (typeof handle.close === 'function') handle.close()
121
+ else if (typeof handle.terminate === 'function') handle.terminate()
122
+ } catch (error) {
123
+ log(`terminal close failed (${reason ?? 'close'})`, { error: describe(error) })
124
+ }
125
+ }
126
+ }
127
+
128
+ async function open({ kind, server, cols, rows, cwd } = {}) {
129
+ if (disposed) throw new Error('terminal hub is disposed')
130
+ // Reserve the slot synchronously, before the first await, so two concurrent
131
+ // opens cannot both pass the cap check and overshoot MAX_SESSIONS.
132
+ if (sessions.size + pendingOpens >= MAX_SESSIONS) {
133
+ throw new Error(`too many terminals (${MAX_SESSIONS}); close one first`)
134
+ }
135
+ pendingOpens += 1
136
+ const session = {
137
+ id: `term_${randomUUID()}`,
138
+ kind: kind === 'remote' ? 'remote' : 'local',
139
+ title: kind === 'remote' ? (server?.name ?? 'remote') : 'local',
140
+ server: server ?? null,
141
+ cwd: typeof cwd === 'string' ? cwd : undefined,
142
+ cols: clampDimension(cols, 80, 500),
143
+ rows: clampDimension(rows, 24, 300),
144
+ text: '',
145
+ base: 0,
146
+ waiters: new Set(),
147
+ exited: false,
148
+ exitCode: null,
149
+ signal: null,
150
+ closed: false,
151
+ decoder: new StringDecoder('utf8'),
152
+ handle: null,
153
+ lastUsed: Date.now(),
154
+ }
155
+ try {
156
+ if (session.kind === 'remote') {
157
+ if (!server) throw new Error('remote terminal needs a managed resource')
158
+ const handle = await runtime.shell(server, { cols: session.cols, rows: session.rows })
159
+ session.handle = handle
160
+ session.title = server.name
161
+ handle.onData((data) => {
162
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data))
163
+ append(session, session.decoder.write(buffer))
164
+ })
165
+ handle.onClose(() => finish(session, null, null))
166
+ handle.onError((error) => {
167
+ append(session, `\r\n[remote shell error: ${describe(error)}]\r\n`)
168
+ finish(session, null, null)
169
+ })
170
+ } else {
171
+ const subprocess = getSubprocess?.()
172
+ if (!subprocess || typeof subprocess.spawnTerminal !== 'function') {
173
+ throw new Error('local terminals are unavailable on this host')
174
+ }
175
+ const environment = await subprocess.terminalEnvironment?.()
176
+ // The Host PTY provider names its platform (`windows` on win32) and the
177
+ // preferred shell (ComSpec on Windows, $SHELL/login shell on POSIX).
178
+ // `-i` is a POSIX login-shell flag: cmd.exe/pwsh reject it, so only
179
+ // POSIX shells get it.
180
+ const localPlatform = environment?.platform === 'windows' || (environment?.platform === undefined && process.platform === 'win32')
181
+ ? 'windows'
182
+ : 'posix'
183
+ const shell = environment?.defaultShell || (localPlatform === 'windows' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh')
184
+ const argv = localPlatform === 'windows' ? [shell] : [shell, '-i']
185
+ const handle = await subprocess.spawnTerminal({
186
+ argv,
187
+ cwd: typeof cwd === 'string' && cwd ? cwd : getLocalCwd?.(),
188
+ cols: session.cols,
189
+ rows: session.rows,
190
+ terminalType: 'xterm-256color',
191
+ graceMs: 1000,
192
+ })
193
+ session.handle = {
194
+ write: (data) => handle.write(data),
195
+ resize: (nextCols, nextRows) => handle.resize(nextCols, nextRows),
196
+ close: () => handle.terminate(),
197
+ }
198
+ handle.output.on('data', (data) => {
199
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data))
200
+ append(session, session.decoder.write(buffer))
201
+ })
202
+ handle.done.then(
203
+ (outcome) => finish(session, outcome?.exitCode, outcome?.signal),
204
+ (error) => {
205
+ append(session, `\r\n[local terminal error: ${describe(error)}]\r\n`)
206
+ finish(session, null, null)
207
+ },
208
+ )
209
+ }
210
+ // The hub may have been disposed while the transport was connecting: do
211
+ // not leak a session (or a PTY) into a torn-down hub.
212
+ if (disposed) throw new Error('terminal hub is disposed')
213
+ } catch (error) {
214
+ closeSession(session, 'open failed')
215
+ throw error
216
+ } finally {
217
+ pendingOpens -= 1
218
+ }
219
+ sessions.set(session.id, session)
220
+ session.lastUsed = Date.now()
221
+ return publicSession(session)
222
+ }
223
+
224
+ function input(id, data) {
225
+ const session = sessions.get(id)
226
+ if (!session || session.exited || !session.handle) return false
227
+ session.lastUsed = Date.now()
228
+ session.handle.write(String(data ?? ''))
229
+ return true
230
+ }
231
+
232
+ function resize(id, cols, rows) {
233
+ const session = sessions.get(id)
234
+ if (!session || session.exited || !session.handle) return false
235
+ session.lastUsed = Date.now()
236
+ session.cols = clampDimension(cols, session.cols, 500)
237
+ session.rows = clampDimension(rows, session.rows, 300)
238
+ try {
239
+ session.handle.resize(session.cols, session.rows)
240
+ } catch (error) {
241
+ log('terminal resize failed', { error: describe(error) })
242
+ }
243
+ return true
244
+ }
245
+
246
+ async function read(id, { cursor = 0, waitMs = 0 } = {}) {
247
+ const session = sessions.get(id)
248
+ if (!session) throw new Error('terminal session not found')
249
+ session.lastUsed = Date.now()
250
+ const initial = snapshot(session, cursor)
251
+ const wait = Math.min(Math.max(Number(waitMs) || 0, 0), MAX_WAIT_MS)
252
+ if (initial.data || initial.exited || wait === 0) return initial
253
+ await new Promise((resolve) => {
254
+ const wake = () => {
255
+ clearTimeout(timer)
256
+ session.waiters.delete(wake)
257
+ resolve()
258
+ }
259
+ const timer = setTimeout(wake, wait)
260
+ timer.unref?.()
261
+ session.waiters.add(wake)
262
+ })
263
+ return snapshot(session, cursor)
264
+ }
265
+
266
+ function close(id) {
267
+ const session = sessions.get(id)
268
+ if (!session) return false
269
+ closeSession(session, 'requested')
270
+ return true
271
+ }
272
+
273
+ function closeForServer(serverId) {
274
+ let closed = 0
275
+ for (const session of [...sessions.values()]) {
276
+ if (session.server?.id !== serverId) continue
277
+ closeSession(session, 'server changed')
278
+ closed += 1
279
+ }
280
+ return closed
281
+ }
282
+
283
+ function dispose() {
284
+ disposed = true
285
+ for (const session of [...sessions.values()]) closeSession(session, 'plugin unloaded')
286
+ if (cleanup) clearInterval(cleanup)
287
+ }
288
+
289
+ const cleanup = setInterval(() => {
290
+ const cutoff = Date.now() - DEFAULT_IDLE_MS
291
+ for (const session of [...sessions.values()]) {
292
+ if (session.waiters.size > 0) continue
293
+ if (session.lastUsed < cutoff) closeSession(session, 'idle')
294
+ }
295
+ }, 60 * 1000)
296
+ cleanup.unref?.()
297
+
298
+ return { open, read, input, resize, close, closeForServer, dispose, count: () => sessions.size }
299
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Windows (PowerShell) shell adapter.
3
+ *
4
+ * Windows targets expose an SSH server (Microsoft OpenSSH Server) whose login
5
+ * shell is either `cmd.exe` or PowerShell. A command is therefore not sent as
6
+ * a raw string: it is wrapped into a small PowerShell script, encoded as
7
+ * UTF-16LE base64 and executed with `-EncodedCommand`, which removes every
8
+ * quoting/escaping failure mode and works no matter which default shell sshd
9
+ * picked.
10
+ *
11
+ * The wrapper also forces UTF-8 console output (Windows defaults to an OEM
12
+ * code page or UTF-16, which would arrive at ssh2 as mojibake) and propagates
13
+ * the real exit code (`-EncodedCommand` alone exits 0 for a failed cmdlet).
14
+ */
15
+ import { BG_ERR_MARKER, BG_LOG_MARKER, BG_PID_MARKER } from './background.js'
16
+ import { normalizeDialect } from './dialect.js'
17
+ import { resolveTimeoutMs } from './posix-shell.js'
18
+
19
+ const POWERSHELL_UTF8 = [
20
+ "$ErrorActionPreference = 'Continue'",
21
+ '$__sdhUtf8 = New-Object System.Text.UTF8Encoding $false',
22
+ '[Console]::OutputEncoding = $__sdhUtf8',
23
+ '$OutputEncoding = $__sdhUtf8',
24
+ ].join('; ')
25
+
26
+ /**
27
+ * Deterministic exit status for a PowerShell script:
28
+ * - a failed statement without a native exit code → 1;
29
+ * - a failed/native command with an exit code → that code;
30
+ * - everything else → 0.
31
+ */
32
+ const EXIT_SUFFIX = [
33
+ 'if (-not $?) { if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) { exit [int]$LASTEXITCODE }; exit 1 }',
34
+ 'if ($LASTEXITCODE -ne $null) { exit [int]$LASTEXITCODE }',
35
+ 'exit 0',
36
+ ].join('; ')
37
+
38
+ /** PowerShell single-quoted literal. */
39
+ export function quotePowerShell(value) {
40
+ return `'${String(value).replace(/'/g, "''")}'`
41
+ }
42
+
43
+ /**
44
+ * Classify one model-supplied workdir.
45
+ * @returns {{ kind: 'none'|'home'|'absolute'|'relative', path?: string, label: string } | { error: string }}
46
+ */
47
+ export function normalizeWindowsWorkdir(workdir) {
48
+ const raw = typeof workdir === 'string' ? workdir.trim() : ''
49
+ if (!raw) return { kind: 'none', label: '~' }
50
+ if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
51
+ if (raw === '~') return { kind: 'home', label: '~' }
52
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) {
53
+ const rest = raw.slice(2)
54
+ if (!rest) return { kind: 'home', label: '~' }
55
+ return { kind: 'relative', path: rest, label: `~/${rest.replace(/\\/g, '/')}` }
56
+ }
57
+ // Drive path, UNC path, or a root-relative POSIX-style path (Windows treats
58
+ // `/foo` as the current drive root).
59
+ if (/^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('\\\\') || raw.startsWith('/')) {
60
+ return { kind: 'absolute', path: raw, label: raw }
61
+ }
62
+ return { kind: 'relative', path: raw, label: `~/${raw.replace(/\\/g, '/')}` }
63
+ }
64
+
65
+ function locationScript(dir) {
66
+ if (dir.kind === 'none') return ''
67
+ const literal =
68
+ dir.kind === 'home'
69
+ ? '$HOME'
70
+ : dir.kind === 'relative'
71
+ ? `(Join-Path $HOME ${quotePowerShell(dir.path)})`
72
+ : quotePowerShell(dir.path)
73
+ return `try { Set-Location -LiteralPath ${literal} -ErrorAction Stop } catch { Write-Error ('cannot change directory: ' + $_.Exception.Message); exit 2 }`
74
+ }
75
+
76
+ /** The PowerShell executable to invoke on the target. */
77
+ function powershellExe(server) {
78
+ const shell = String(server?.defaultShell ?? '')
79
+ return /pwsh/i.test(shell) ? 'pwsh.exe' : 'powershell.exe'
80
+ }
81
+
82
+ /** Wrap a PowerShell script into one SSH command line. */
83
+ export function buildPowerShellInvocation(script, server) {
84
+ const encoded = Buffer.from(String(script), 'utf16le').toString('base64')
85
+ return `${powershellExe(server)} -NoProfile -NonInteractive -EncodedCommand ${encoded}`
86
+ }
87
+
88
+ export const win32Adapter = {
89
+ id: 'win32',
90
+ platform: 'windows',
91
+ toolName: 'remote_pwsh',
92
+ label: 'Windows/PowerShell',
93
+ // No POSIX `timeout` utility on Windows: the deadline is client-side only.
94
+ remoteTimeout: false,
95
+ supportsDialect: (dialect) => normalizeDialect(dialect) === 'windows',
96
+ unsupportedMessage: (server) =>
97
+ `target "${server?.name ?? 'unknown'}" is a POSIX server; remote_pwsh runs PowerShell on Windows targets — use remote_bash for POSIX targets`,
98
+ buildCommand(command, workdir, server) {
99
+ const dir = normalizeWindowsWorkdir(workdir)
100
+ if (dir.error !== undefined) return dir
101
+ const script = [POWERSHELL_UTF8, locationScript(dir), String(command ?? ''), EXIT_SUFFIX]
102
+ .filter((part) => typeof part === 'string' && part.length > 0)
103
+ .join('\n')
104
+ return { command: buildPowerShellInvocation(script, server), cwd: dir.label }
105
+ },
106
+ /**
107
+ * Start a detached PowerShell script and report its pid plus log files.
108
+ *
109
+ * The inner script (the same one a foreground call would run) is launched by
110
+ * `Start-Process`, which detaches it from the SSH channel; stdout/stderr are
111
+ * redirected into files the model can read with `Get-Content -Wait`.
112
+ */
113
+ buildBackgroundCommand(command, workdir, server) {
114
+ const dir = normalizeWindowsWorkdir(workdir)
115
+ if (dir.error !== undefined) return dir
116
+ const inner = [POWERSHELL_UTF8, locationScript(dir), String(command ?? ''), EXIT_SUFFIX]
117
+ .filter((part) => typeof part === 'string' && part.length > 0)
118
+ .join('\n')
119
+ const innerEncoded = Buffer.from(inner, 'utf16le').toString('base64')
120
+ const exe = quotePowerShell(powershellExe(server))
121
+ const outer = [
122
+ '$__sdhLog = Join-Path $env:TEMP ("dsh-ssh-workbench-bg-" + [Guid]::NewGuid().ToString("N") + ".log")',
123
+ '$__sdhErr = "$__sdhLog.err"',
124
+ `$__sdhExe = ${exe}`,
125
+ `$__sdhArgs = @('-NoProfile','-NonInteractive','-EncodedCommand','${innerEncoded}')`,
126
+ '$__sdhProc = Start-Process -FilePath $__sdhExe -ArgumentList $__sdhArgs -RedirectStandardOutput $__sdhLog -RedirectStandardError $__sdhErr -WindowStyle Hidden -PassThru',
127
+ `Write-Output "${BG_PID_MARKER}$($__sdhProc.Id)"`,
128
+ `Write-Output "${BG_LOG_MARKER}$__sdhLog"`,
129
+ `Write-Output "${BG_ERR_MARKER}$__sdhErr"`,
130
+ ].join('\n')
131
+ return { command: buildPowerShellInvocation(outer, server), cwd: dir.label }
132
+ },
133
+ // The scripts already carry their own exit code handling; there is no
134
+ // remote deadline utility to wrap around them.
135
+ wrapTimeout: (command) => String(command ?? ''),
136
+ resolveTimeoutMs,
137
+ timeoutNotice: () =>
138
+ '[remote] client-side timeout only: this Windows target has no POSIX `timeout` wrapper, so the PowerShell process may still be running.',
139
+ cancelNotice: () =>
140
+ '[remote] cancelled: the SSH channel was closed, but the PowerShell process may still be running.',
141
+ }