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,394 @@
1
+ /**
2
+ * `remote_exec`: run one or more commands on explicitly named managed servers.
3
+ *
4
+ * Every task carries its own server, so the model can fan out in one assistant
5
+ * step and DSH's bounded parallel tool executor can overlap the SSH channels.
6
+ * Nothing in this tool depends on session state or a remembered target.
7
+ *
8
+ * Tasks are validated before any command starts: a typo in the second server
9
+ * must not run the first command and then report a half-completed batch. Each
10
+ * task keeps its own dialect (POSIX shell or PowerShell), timeout, working
11
+ * directory, output cap and error, and results are rendered in task order even
12
+ * when the network completes them out of order.
13
+ */
14
+ import { defineTool } from '@deepseek-ai/dsh-tools'
15
+
16
+ import { describeResultOutcome } from './activity.js'
17
+ import { effectiveDialect } from './dialect.js'
18
+ import { isTransportFailure } from './posix-shell.js'
19
+ import { resolveDialect, sessionKeyOf } from './remote-shell-tool.js'
20
+ import { requireServerRef } from './server-ref.js'
21
+ import { posixAdapter, win32Adapter } from './shell-adapters.js'
22
+
23
+ export const REMOTE_EXEC_TOOL_NAME = 'remote_exec'
24
+
25
+ const DEFAULT_CONCURRENCY = 4
26
+ const MAX_CONCURRENCY = 8
27
+ const DEFAULT_MAX_TASKS = 8
28
+ const DEFAULT_MAX_BATCH_OUTPUT_BYTES = 262144
29
+ const MIN_PER_TASK_CAP = 4096
30
+
31
+ const textOut = {
32
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
33
+ render: (_args, value) => [{ type: 'text', text: value.text }],
34
+ }
35
+
36
+ function describeError(error) {
37
+ return error instanceof Error ? error.message : String(error)
38
+ }
39
+
40
+ function integerConfig(value, fallback) {
41
+ return Number.isInteger(value) && value > 0 ? value : fallback
42
+ }
43
+
44
+ function clampConcurrency(value, taskCount, configured) {
45
+ const configuredCap = Math.max(1, Math.min(integerConfig(configured, DEFAULT_CONCURRENCY), MAX_CONCURRENCY))
46
+ const requested = Number.isInteger(value) && value > 0 ? value : configuredCap
47
+ return Math.max(1, Math.min(requested, configuredCap, MAX_CONCURRENCY, Math.max(1, taskCount)))
48
+ }
49
+
50
+ function clipText(value, cap) {
51
+ const text = typeof value === 'string' ? value : ''
52
+ if (text.length <= cap) return { text, clipped: false }
53
+ return { text: text.slice(0, cap), clipped: true }
54
+ }
55
+
56
+ function timeoutNotice(adapter, capability) {
57
+ if (typeof adapter?.timeoutNotice === 'function') return adapter.timeoutNotice(capability)
58
+ return capability?.mode === 'none'
59
+ ? '[remote] client-side timeout only: the remote process may still be running.'
60
+ : '[remote] timeout: the remote command group was terminated.'
61
+ }
62
+
63
+ function cancelNotice(adapter) {
64
+ if (typeof adapter?.cancelNotice === 'function') return adapter.cancelNotice()
65
+ return '[remote] cancelled: the SSH channel was closed; the remote process may still be running.'
66
+ }
67
+
68
+ function adapterFor(dialect) {
69
+ return dialect === 'windows' ? win32Adapter : posixAdapter
70
+ }
71
+
72
+ /**
73
+ * Resolve and validate every task before any of them runs.
74
+ * @returns {Promise<object[]>} one resolved task per input, in input order
75
+ */
76
+ async function resolveTasks({ tasks, servers, runtime, config, probe }) {
77
+ const maxTasks = integerConfig(config?.maxBatchTasks, DEFAULT_MAX_TASKS)
78
+ if (tasks.length > maxTasks) {
79
+ throw new Error(`too many tasks: at most ${maxTasks} tasks are allowed per remote_exec call`)
80
+ }
81
+
82
+ const pending = tasks.map(async (task, index) => {
83
+ const label = `task ${index + 1}`
84
+ if (!task || typeof task !== 'object') throw new Error(`${label} must be an object`)
85
+ const serverRef = typeof task.server === 'string' ? task.server.trim() : ''
86
+ if (!serverRef) throw new Error(`${label} is missing server`)
87
+ let server
88
+ try {
89
+ server = requireServerRef(servers, serverRef, { label: `${label} server` })
90
+ } catch (error) {
91
+ throw new Error(`${label}: ${describeError(error)}`)
92
+ }
93
+
94
+ const command = typeof task.command === 'string' ? task.command : ''
95
+ if (!command.trim()) throw new Error(`${label}: command must not be empty`)
96
+
97
+ const dialect = probe === false ? effectiveDialect(server) : await resolveDialect(server, runtime)
98
+ const adapter = adapterFor(dialect)
99
+ if (typeof adapter.supportsDialect === 'function' && !adapter.supportsDialect(dialect)) {
100
+ throw new Error(`${label}: ${adapter.unsupportedMessage(server, dialect)}`)
101
+ }
102
+
103
+ const built = adapter.buildCommand(command, task.workdir, server, dialect)
104
+ if (built?.error !== undefined) throw new Error(`${label}: ${built.error}`)
105
+ return {
106
+ index,
107
+ task,
108
+ server,
109
+ dialect,
110
+ adapter,
111
+ command,
112
+ cwd: built.cwd,
113
+ remoteCommand: built.command,
114
+ timeoutMs: adapter.resolveTimeoutMs(task, config),
115
+ capability: undefined,
116
+ }
117
+ })
118
+
119
+ const settled = await Promise.allSettled(pending)
120
+ const errors = []
121
+ const resolved = []
122
+ for (let index = 0; index < settled.length; index += 1) {
123
+ const outcome = settled[index]
124
+ if (outcome.status === 'fulfilled') resolved[index] = outcome.value
125
+ else errors.push(describeError(outcome.reason))
126
+ }
127
+ if (errors.length > 0) throw new Error(`remote_exec validation failed:\n- ${errors.join('\n- ')}`)
128
+ return resolved
129
+ }
130
+
131
+ async function resolveCapabilities(tasks, runtime, probe = true) {
132
+ if (probe === false) {
133
+ for (const task of tasks) task.capability = { mode: 'none', shell: '/bin/sh' }
134
+ return
135
+ }
136
+ const cache = new Map()
137
+ await Promise.all(
138
+ tasks.map(async (task) => {
139
+ if (task.adapter?.remoteTimeout !== true) {
140
+ task.capability = { mode: 'none', shell: '/bin/sh' }
141
+ return
142
+ }
143
+ const key = task.server.name
144
+ if (!cache.has(key)) {
145
+ cache.set(
146
+ key,
147
+ (async () => {
148
+ try {
149
+ if (typeof runtime?.timeoutGuard !== 'function') return { mode: 'none', shell: '/bin/sh' }
150
+ const probed = await runtime.timeoutGuard(task.server)
151
+ return typeof probed === 'string' ? { mode: probed } : (probed ?? { mode: 'none', shell: '/bin/sh' })
152
+ } catch {
153
+ return { mode: 'none', shell: '/bin/sh' }
154
+ }
155
+ })(),
156
+ )
157
+ }
158
+ task.capability = await cache.get(key)
159
+ }),
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Activity is best effort and must never change a batch result. Recording the
165
+ * session id and duration is what lets the dock summarize this conversation
166
+ * without touching DSH's private metrics.
167
+ */
168
+ function recordTaskActivity(task, { sessionId, durationMs, outcome }) {
169
+ try {
170
+ task.recordActivity?.({
171
+ kind: 'exec',
172
+ server: task.server.name,
173
+ summary: task.command.slice(0, 200),
174
+ outcome,
175
+ sessionId,
176
+ durationMs,
177
+ })
178
+ } catch {
179
+ // Best effort.
180
+ }
181
+ }
182
+
183
+ async function runOne(task, { runtime, config, perTaskOutputCap, perTaskStderrCap, signal, sessionId, connectTimeoutMs }) {
184
+ const startedAt = Date.now()
185
+ let result
186
+ try {
187
+ result = await runtime.exec(task.server, task.adapter.wrapTimeout(task.remoteCommand, task.timeoutMs, task.capability, {
188
+ killAfterMs: config?.timeoutKillAfterMs,
189
+ }), {
190
+ timeoutMs: task.timeoutMs,
191
+ signal,
192
+ readOnly: false,
193
+ cap: perTaskOutputCap,
194
+ stderrCap: perTaskStderrCap,
195
+ connectTimeoutMs,
196
+ })
197
+ } catch (error) {
198
+ const durationMs = Date.now() - startedAt
199
+ recordTaskActivity(task, { sessionId, durationMs, outcome: 'failed' })
200
+ return { index: task.index, task, error: describeError(error), durationMs }
201
+ }
202
+
203
+ const durationMs = Date.now() - startedAt
204
+ recordTaskActivity(task, { sessionId, durationMs, outcome: describeResultOutcome(result) })
205
+
206
+ if (isTransportFailure(result)) {
207
+ const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
208
+ const code = typeof result?.errorCode === 'string' && result.errorCode ? `${result.errorCode}: ` : ''
209
+ return {
210
+ index: task.index,
211
+ task,
212
+ error: `${code}${detail}`,
213
+ result,
214
+ durationMs,
215
+ }
216
+ }
217
+ return { index: task.index, task, result, durationMs }
218
+ }
219
+
220
+ /** Run `worker` over `tasks` with at most `concurrency` active promises. */
221
+ async function runPool(tasks, concurrency, run) {
222
+ const results = new Array(tasks.length)
223
+ let next = 0
224
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
225
+ while (true) {
226
+ const index = next
227
+ next += 1
228
+ if (index >= tasks.length) return
229
+ results[index] = await run(tasks[index])
230
+ }
231
+ })
232
+ await Promise.all(workers)
233
+ return results
234
+ }
235
+
236
+ function renderTask(outcome, { perTaskOutputCap, perTaskStderrCap }) {
237
+ const { task } = outcome
238
+ const target = `${task.server.username}@${task.server.host}:${task.server.port}`
239
+ const lines = [
240
+ `[${task.index + 1}] ${task.server.name} (${target}) · ${task.dialect}`,
241
+ `command: ${task.command}`,
242
+ `cwd: ${task.cwd}`,
243
+ `duration: ${outcome.durationMs}ms`,
244
+ ]
245
+ if (outcome.error !== undefined) {
246
+ lines.push(`error: ${outcome.error}`)
247
+ return lines
248
+ }
249
+
250
+ const result = outcome.result ?? {}
251
+ if (Number.isInteger(result.exitCode)) lines.push(`exit code: ${result.exitCode}`)
252
+ if (result.outcome === 'timeout') lines.push('outcome: timed out')
253
+ if (result.outcome === 'cancelled') lines.push('outcome: cancelled')
254
+
255
+ const stdout = clipText(result.stdout, perTaskOutputCap)
256
+ const stderr = clipText(result.stderr, perTaskStderrCap)
257
+ lines.push('stdout:')
258
+ lines.push(stdout.text.length > 0 ? stdout.text.replace(/\n$/, '') : '(empty)')
259
+ if (stderr.text.length > 0) {
260
+ lines.push('stderr:')
261
+ lines.push(stderr.text.replace(/\n$/, ''))
262
+ }
263
+ if (stdout.clipped || result.truncated?.stdout === true) lines.push('[stdout truncated by the plugin output cap]')
264
+ if (stderr.clipped || result.truncated?.stderr === true) lines.push('[stderr truncated by the plugin output cap]')
265
+ if (result.outcome === 'timeout') lines.push(timeoutNotice(task.adapter, task.capability))
266
+ if (result.outcome === 'cancelled') lines.push(cancelNotice(task.adapter))
267
+ return lines
268
+ }
269
+
270
+ /**
271
+ * @param {object} deps
272
+ * @param {object} deps.runtime SSH runtime facade
273
+ * @param {object} deps.config resolved plugin config
274
+ * @param {() => object[]} deps.loadInventory
275
+ * @param {(entry: object) => void} [deps.recordActivity]
276
+ */
277
+ export function createRemoteExecTool({ runtime, config, loadInventory, recordActivity } = {}) {
278
+ return defineTool({
279
+ name: REMOTE_EXEC_TOOL_NAME,
280
+ description:
281
+ 'Run one or more commands on explicitly named managed remote resources in one call. ' +
282
+ 'Each task carries its own server, so tasks on different resources run concurrently and no task depends on ' +
283
+ 'any session-level target state. Use it for multi-resource questions such as comparing versions ' +
284
+ 'or collecting status from several @mentioned resources in one step. POSIX targets use their login shell; ' +
285
+ 'Windows targets use PowerShell. This tool is foreground-only; use remote_bash/remote_pwsh for a detached process. ' +
286
+ 'The native shell tool always runs on the local DSH host, so never use it for these commands. ' +
287
+ 'Set probe:false to skip dialect/capability probing; combine it with connectTimeoutMs for quick status checks.',
288
+ parameters: {
289
+ tasks: {
290
+ type: 'array',
291
+ required: true,
292
+ description: 'Commands to run, one per managed resource. Each task must name its server explicitly.',
293
+ items: {
294
+ type: 'object',
295
+ additionalProperties: false,
296
+ properties: {
297
+ server: {
298
+ type: 'string',
299
+ required: true,
300
+ description: 'Managed resource name or id.',
301
+ },
302
+ command: {
303
+ type: 'string',
304
+ required: true,
305
+ description: 'Command to run on that resource.',
306
+ },
307
+ workdir: {
308
+ type: 'string',
309
+ description: 'Remote working directory: absolute path, ~/path, or a path relative to the login home.',
310
+ },
311
+ timeoutMs: {
312
+ type: 'integer',
313
+ description: 'Foreground timeout for this task in milliseconds (default from config, capped by maxCommandTimeoutMs).',
314
+ },
315
+ },
316
+ },
317
+ },
318
+ concurrency: {
319
+ type: 'integer',
320
+ description: `Maximum tasks to run at once (1–${MAX_CONCURRENCY}; default ${DEFAULT_CONCURRENCY}, capped by the configured batch concurrency).`,
321
+ },
322
+ probe: {
323
+ type: 'boolean',
324
+ description:
325
+ 'Probe each target dialect and timeout capability before running (default true). Set false for quick status checks: it skips the two extra SSH round-trips and uses the recorded facts/platform instead.',
326
+ },
327
+ connectTimeoutMs: {
328
+ type: 'integer',
329
+ description:
330
+ 'Per-connection timeout in milliseconds (default from config). Use a short value with probe:false so unreachable resources fail fast instead of hanging on the SSH handshake.',
331
+ },
332
+ },
333
+ output: textOut,
334
+ isConcurrencySafe: () => true,
335
+ async execute(args, exec) {
336
+ const tasks = Array.isArray(args?.tasks) ? args.tasks : []
337
+ if (tasks.length === 0) throw new Error('remote_exec requires at least one task')
338
+ const maxTasks = integerConfig(config?.maxBatchTasks, DEFAULT_MAX_TASKS)
339
+ if (tasks.length > maxTasks) throw new Error(`too many tasks: at most ${maxTasks} tasks are allowed per remote_exec call`)
340
+
341
+ let servers
342
+ try {
343
+ servers = loadInventory()
344
+ } catch (error) {
345
+ throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
346
+ }
347
+
348
+ const probe = args?.probe !== false
349
+ const configuredConnectTimeout = integerConfig(config?.connectTimeoutMs, 15000)
350
+ const connectTimeoutMs = Number.isInteger(args?.connectTimeoutMs) && args.connectTimeoutMs > 0
351
+ ? Math.max(1000, Math.min(args.connectTimeoutMs, configuredConnectTimeout))
352
+ : undefined
353
+ const resolved = await resolveTasks({ tasks, servers, runtime, config, probe })
354
+ await resolveCapabilities(resolved, runtime, probe)
355
+ for (const task of resolved) task.recordActivity = recordActivity
356
+
357
+ const concurrency = clampConcurrency(args?.concurrency, resolved.length, config?.batchConcurrency)
358
+ const maxBatch = integerConfig(config?.maxBatchOutputBytes, DEFAULT_MAX_BATCH_OUTPUT_BYTES)
359
+ const maxPerTask = integerConfig(config?.maxOutputBytes, 102400)
360
+ const perTaskOutputCap = Math.max(
361
+ MIN_PER_TASK_CAP,
362
+ Math.min(maxPerTask, Math.floor(maxBatch / Math.max(1, resolved.length))),
363
+ )
364
+ const perTaskStderrCap = Math.min(integerConfig(config?.maxStderrBytes, 16384), perTaskOutputCap)
365
+
366
+ const sessionId = sessionKeyOf(exec)
367
+ const outcomes = await runPool(resolved, concurrency, (task) =>
368
+ runOne(task, {
369
+ runtime,
370
+ config,
371
+ perTaskOutputCap,
372
+ perTaskStderrCap,
373
+ signal: exec?.signal,
374
+ sessionId,
375
+ connectTimeoutMs,
376
+ }),
377
+ )
378
+
379
+ const serverCount = new Set(resolved.map((task) => task.server.name)).size
380
+ const lines = [
381
+ `remote_exec: ${resolved.length} task(s) on ${serverCount} server(s), concurrency ${concurrency}`,
382
+ ]
383
+ for (const outcome of outcomes) {
384
+ lines.push('')
385
+ lines.push(...renderTask(outcome, { perTaskOutputCap, perTaskStderrCap }))
386
+ }
387
+ let text = lines.join('\n')
388
+ if (text.length > maxBatch) {
389
+ text = `${text.slice(0, maxBatch)}\n[remote_exec output truncated at the ${maxBatch}-byte batch cap]`
390
+ }
391
+ return { text }
392
+ },
393
+ })
394
+ }