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.
- package/CHANGELOG.md +552 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +146 -0
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +106 -0
- package/lib/client.js +4991 -0
- package/lib/core/activity.js +88 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +802 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +123 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/live-executions.js +101 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/mentions.js +216 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/remote-shell-tool.js +310 -0
- package/lib/core/secrets.js +95 -0
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +409 -0
- package/lib/routes.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- 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
|
+
}
|