dsh-ssh-workbench 0.0.0-stage → 1.6.4
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 +504 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/SECURITY.md +25 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +124 -0
- package/docs/quickstart.md +94 -0
- package/lib/client.js +4977 -0
- package/lib/core/activity.js +83 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +799 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +71 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-bash-tool.js +321 -0
- package/lib/core/remote-bash.js +337 -0
- package/lib/core/secrets.js +96 -0
- package/lib/core/server-reference.js +299 -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/targets.js +250 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +450 -0
- package/lib/routes.js +733 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session remote target binding.
|
|
3
|
+
*
|
|
4
|
+
* A session owns at most one *active* target: the managed server its native
|
|
5
|
+
* `bash` tool runs on. `@name` mentions in a human message are the primary
|
|
6
|
+
* input; the `ssh_workbench_target` tool switches the active target explicitly so a
|
|
7
|
+
* conversation that mentions several servers stays unambiguous.
|
|
8
|
+
*
|
|
9
|
+
* State is process-local and keyed by Session id. It is a derived cache, not a
|
|
10
|
+
* source of truth: the durable input is the human messages in the Session log,
|
|
11
|
+
* which `replayTargets()` folds back in when a Session resumes.
|
|
12
|
+
*/
|
|
13
|
+
import { loadServers } from './store.js'
|
|
14
|
+
|
|
15
|
+
/** Tool name the model calls to switch targets. */
|
|
16
|
+
export const TARGET_TOOL_NAME = 'ssh_workbench_target'
|
|
17
|
+
|
|
18
|
+
/** Source kind recorded on the injected target-context message. */
|
|
19
|
+
export const TARGET_CONTEXT_KIND = 'ssh-workbench-target'
|
|
20
|
+
|
|
21
|
+
/** The sentinel that detaches the session from any remote target. */
|
|
22
|
+
export const LOCAL_TARGET = 'local'
|
|
23
|
+
|
|
24
|
+
/** Concatenate the text blocks of one message. */
|
|
25
|
+
export function extractUserText(message) {
|
|
26
|
+
if (!message || message.role !== 'user' || !Array.isArray(message.content)) return ''
|
|
27
|
+
return message.content
|
|
28
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
29
|
+
.map((block) => block.text)
|
|
30
|
+
.join('\n')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Find the latest human-authored user message in a step's messages. */
|
|
34
|
+
export function lastHumanMessage(messages) {
|
|
35
|
+
if (!Array.isArray(messages)) return undefined
|
|
36
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
37
|
+
const message = messages[index]
|
|
38
|
+
if (message && message.role === 'user' && message.source?.kind === 'user') return message
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Match `@name` tokens against the managed inventory (name or id,
|
|
45
|
+
* case-insensitive). Returns matched server records in mention order, without
|
|
46
|
+
* duplicates. Unknown tokens are ignored.
|
|
47
|
+
*/
|
|
48
|
+
export function extractServerMentions(text, servers) {
|
|
49
|
+
if (typeof text !== 'string' || !Array.isArray(servers) || servers.length === 0) return []
|
|
50
|
+
const byAlias = new Map()
|
|
51
|
+
for (const server of servers) {
|
|
52
|
+
if (!server || typeof server.name !== 'string' || server.name.length === 0) continue
|
|
53
|
+
byAlias.set(server.name.toLowerCase(), server)
|
|
54
|
+
if (typeof server.id === 'string' && server.id.length > 0) byAlias.set(server.id.toLowerCase(), server)
|
|
55
|
+
}
|
|
56
|
+
// Match known aliases directly instead of a generic `@token` regex: a CJK
|
|
57
|
+
// server name is often followed immediately by CJK text (`@测试机看下磁盘`),
|
|
58
|
+
// so only the known name length tells us where the mention ends.
|
|
59
|
+
const aliases = [...byAlias.keys()].sort((left, right) => right.length - left.length)
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
const matched = []
|
|
62
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
63
|
+
if (text[index] !== '@') continue
|
|
64
|
+
// Like an email address: `ops@example.com` is not a mention.
|
|
65
|
+
const previous = index > 0 ? text[index - 1] : ''
|
|
66
|
+
if (previous && /[A-Za-z0-9._-]/.test(previous)) continue
|
|
67
|
+
for (const alias of aliases) {
|
|
68
|
+
if (text.slice(index + 1, index + 1 + alias.length).toLowerCase() !== alias) continue
|
|
69
|
+
const tail = text[index + 1 + alias.length] ?? ''
|
|
70
|
+
// `@web-12` is a different token than `@web-1`; CJK continuation is fine.
|
|
71
|
+
if (tail && /[A-Za-z0-9._-]/.test(tail)) break
|
|
72
|
+
const server = byAlias.get(alias)
|
|
73
|
+
if (server && !seen.has(server.id ?? server.name)) {
|
|
74
|
+
seen.add(server.id ?? server.name)
|
|
75
|
+
matched.push(server)
|
|
76
|
+
}
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return matched
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sameList(left, right) {
|
|
84
|
+
if (left.length !== right.length) return false
|
|
85
|
+
return left.every((value, index) => value === right[index])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function freshState() {
|
|
89
|
+
return { candidates: [], active: null, version: 0, injectedVersion: -1, notifiedVersion: -1, seeded: false }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fold the mention rules over one session state.
|
|
94
|
+
*
|
|
95
|
+
* - one mention → that server becomes the active target;
|
|
96
|
+
* - several mentions → candidates are remembered, active target is cleared;
|
|
97
|
+
* - no mention → state is unchanged (a follow-up keeps its target).
|
|
98
|
+
*/
|
|
99
|
+
export function applyMentions(state, mentions) {
|
|
100
|
+
if (!Array.isArray(mentions) || mentions.length === 0) return state
|
|
101
|
+
const names = mentions.map((server) => server.name)
|
|
102
|
+
if (mentions.length === 1) {
|
|
103
|
+
if (state.active === names[0] && sameList(state.candidates, names)) return state
|
|
104
|
+
return { ...state, candidates: names, active: names[0], version: state.version + 1 }
|
|
105
|
+
}
|
|
106
|
+
// Several mentions never pick a target by guesswork; the model must call the
|
|
107
|
+
// selector tool. The previous active target is dropped for the same reason.
|
|
108
|
+
const active = null
|
|
109
|
+
if (active === state.active && sameList(state.candidates, names)) return state
|
|
110
|
+
return { ...state, candidates: names, active, version: state.version + 1 }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Replay the mention rules over a Session's historical messages. */
|
|
114
|
+
export function replayTargets(messages, servers) {
|
|
115
|
+
let state = freshState()
|
|
116
|
+
if (Array.isArray(messages)) {
|
|
117
|
+
for (const message of messages) {
|
|
118
|
+
if (message?.role !== 'user' || message.source?.kind !== 'user') continue
|
|
119
|
+
state = applyMentions(state, extractServerMentions(extractUserText(message), servers))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return state
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve one target reference against the inventory (name or id). */
|
|
126
|
+
export function findTarget(servers, ref) {
|
|
127
|
+
const value = String(ref ?? '').trim()
|
|
128
|
+
if (!value) return undefined
|
|
129
|
+
return servers.find((server) => server.id === value || server.name === value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create the per-session target store.
|
|
134
|
+
* @param {{ loadInventory?: () => object[] }} [options]
|
|
135
|
+
*/
|
|
136
|
+
export function createTargetStore(options = {}) {
|
|
137
|
+
const loadInventory = options.loadInventory ?? loadServers
|
|
138
|
+
/** @type {Map<string, object>} */
|
|
139
|
+
const sessions = new Map()
|
|
140
|
+
|
|
141
|
+
function sessionKey(session) {
|
|
142
|
+
if (session === undefined || session === null) return undefined
|
|
143
|
+
if (typeof session === 'string') return session
|
|
144
|
+
const id = session.id
|
|
145
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function get(key) {
|
|
149
|
+
const sessionId = sessionKey(key)
|
|
150
|
+
if (sessionId === undefined) return undefined
|
|
151
|
+
return sessions.get(sessionId)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ensure(key) {
|
|
155
|
+
const sessionId = sessionKey(key)
|
|
156
|
+
if (sessionId === undefined) return undefined
|
|
157
|
+
let state = sessions.get(sessionId)
|
|
158
|
+
if (state === undefined) {
|
|
159
|
+
state = freshState()
|
|
160
|
+
sessions.set(sessionId, state)
|
|
161
|
+
}
|
|
162
|
+
return state
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Seed one session once from its historical messages (resume support). */
|
|
166
|
+
function ensureSeeded(key, messages, servers = loadInventory()) {
|
|
167
|
+
const state = ensure(key)
|
|
168
|
+
if (state === undefined || state.seeded) return state
|
|
169
|
+
const replayed = replayTargets(messages, servers)
|
|
170
|
+
const next = {
|
|
171
|
+
...state,
|
|
172
|
+
candidates: replayed.candidates,
|
|
173
|
+
active: replayed.active,
|
|
174
|
+
version: replayed.version,
|
|
175
|
+
seeded: true,
|
|
176
|
+
}
|
|
177
|
+
sessions.set(sessionKey(key), next)
|
|
178
|
+
return next
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bindMentions(key, mentions) {
|
|
182
|
+
const state = ensure(key)
|
|
183
|
+
if (state === undefined) return undefined
|
|
184
|
+
const next = { ...applyMentions(state, mentions), seeded: true }
|
|
185
|
+
if (next !== state) sessions.set(sessionKey(key), next)
|
|
186
|
+
return next
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Switch the active target explicitly. `local` detaches. */
|
|
190
|
+
function setActive(key, ref, servers = loadInventory()) {
|
|
191
|
+
const state = ensure(key)
|
|
192
|
+
if (state === undefined) return { ok: false, error: 'session is not addressable' }
|
|
193
|
+
if (String(ref).trim().toLowerCase() === LOCAL_TARGET) {
|
|
194
|
+
// Detaching also clears any pending @a @b ambiguity, so bash is plainly
|
|
195
|
+
// local again instead of staying blocked on "choose a target".
|
|
196
|
+
const next = { ...state, active: null, candidates: [], version: state.version + 1, seeded: true }
|
|
197
|
+
sessions.set(sessionKey(key), next)
|
|
198
|
+
return { ok: true, state: next }
|
|
199
|
+
}
|
|
200
|
+
const server = findTarget(servers, ref)
|
|
201
|
+
if (server === undefined) {
|
|
202
|
+
return { ok: false, error: `unknown server "${String(ref)}"`, state }
|
|
203
|
+
}
|
|
204
|
+
if (server.enabled === false) {
|
|
205
|
+
return { ok: false, error: `server "${server.name}" is disabled`, state }
|
|
206
|
+
}
|
|
207
|
+
const next = {
|
|
208
|
+
...state,
|
|
209
|
+
active: server.name,
|
|
210
|
+
candidates: state.candidates.includes(server.name) ? state.candidates : [server.name],
|
|
211
|
+
version: state.version + 1,
|
|
212
|
+
seeded: true,
|
|
213
|
+
}
|
|
214
|
+
sessions.set(sessionKey(key), next)
|
|
215
|
+
return { ok: true, state: next, server }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function markInjected(key, version) {
|
|
219
|
+
const state = get(key)
|
|
220
|
+
if (state === undefined) return
|
|
221
|
+
if (state.injectedVersion === version) return
|
|
222
|
+
sessions.set(sessionKey(key), { ...state, injectedVersion: version })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Remember that the bash router already showed the target to the model/UI
|
|
227
|
+
* (one stderr line after each target change), so it is not repeated per call.
|
|
228
|
+
*/
|
|
229
|
+
function markNotified(key, version) {
|
|
230
|
+
const state = get(key)
|
|
231
|
+
if (state === undefined) return
|
|
232
|
+
if (state.notifiedVersion === version) return
|
|
233
|
+
sessions.set(sessionKey(key), { ...state, notifiedVersion: version })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function clear(key) {
|
|
237
|
+
const sessionId = sessionKey(key)
|
|
238
|
+
if (sessionId !== undefined) sessions.delete(sessionId)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function clearAll() {
|
|
242
|
+
sessions.clear()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function size() {
|
|
246
|
+
return sessions.size
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { get, ensure, ensureSeeded, bindMentions, setActive, markInjected, markNotified, clear, clearAll, size, loadInventory }
|
|
250
|
+
}
|
|
@@ -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
|
+
}
|