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.
Files changed (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Target context for @server mentions.
3
+ *
4
+ * The composer inserts an `@name` chip as plain text, so the model can read it
5
+ * as a teammate mention. This module turns matches against the managed
6
+ * inventory into (a) the session's active remote target and (b) one compact
7
+ * user-role context message that tells the model where `bash` runs and how to
8
+ * switch targets.
9
+ *
10
+ * The injected message enters the same step (`agent/pre-step` replaces the
11
+ * messages that enter a step), which `agent.inject()` cannot promise because it
12
+ * splices into the next step. The decision is spread so fields such as
13
+ * `startsRequestSeries` survive.
14
+ *
15
+ * Scope: every agent mode; no preset gating.
16
+ */
17
+ import { randomUUID } from 'node:crypto'
18
+
19
+ import { effectiveDialect } from './dialect.js'
20
+ import { loadServers } from './store.js'
21
+ import {
22
+ LOCAL_TARGET,
23
+ TARGET_CONTEXT_KIND,
24
+ TARGET_TOOL_NAME,
25
+ createTargetStore,
26
+ extractServerMentions,
27
+ extractUserText,
28
+ lastHumanMessage,
29
+ } from './targets.js'
30
+
31
+ export { TARGET_CONTEXT_KIND, TARGET_TOOL_NAME }
32
+
33
+ function describeError(error) {
34
+ return error instanceof Error ? error.message : String(error)
35
+ }
36
+
37
+ function serverLine(server) {
38
+ const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
39
+ const state = server.enabled === false ? ' · disabled' : ''
40
+ const dialect = effectiveDialect(server)
41
+ return `- ${server.name} — ${target} · ${dialect}${state}${factsSummary(server)}`
42
+ }
43
+
44
+ /** Compact facts summary (hostname only: stable, not a stale capacity snapshot). */
45
+ function factsSummary(server) {
46
+ const facts = server?.facts
47
+ if (!facts || typeof facts !== 'object') return ''
48
+ const hostname = typeof facts.hostname === 'string' ? facts.hostname.trim() : ''
49
+ return hostname ? ` · ${hostname}` : ''
50
+ }
51
+
52
+ /**
53
+ * Resolve the copy dialect for one Host.
54
+ *
55
+ * `remoteToolName` is the tool that actually executes on the target: the native
56
+ * `bash` tool on POSIX Hosts, the plugin-owned `remote_bash` tool on Windows
57
+ * Hosts (where DSH ships no bash). The context must describe the tool the model
58
+ * really has, never a tool name that cannot exist on this Host.
59
+ */
60
+ function contextOptions(options = {}) {
61
+ const hostOs = options.hostPlatform === 'win32' ? 'win32' : 'posix'
62
+ const remoteToolName =
63
+ typeof options.remoteToolName === 'string' && options.remoteToolName.length > 0
64
+ ? options.remoteToolName
65
+ : hostOs === 'win32'
66
+ ? 'remote_bash'
67
+ : 'bash'
68
+ const windowsToolName =
69
+ typeof options.windowsToolName === 'string' && options.windowsToolName.length > 0 ? options.windowsToolName : 'remote_pwsh'
70
+ return { hostOs, remoteToolName, windowsToolName }
71
+ }
72
+
73
+ /**
74
+ * Render the model-facing target context.
75
+ * @param {{ active: string|null, candidates: string[] }} state
76
+ * @param {object[]} servers managed inventory
77
+ * @param {{ hostPlatform?: 'win32'|'posix', remoteToolName?: string, windowsToolName?: string }} [options]
78
+ */
79
+ export function renderTargetContext(state, servers, options = {}) {
80
+ const { hostOs, remoteToolName, windowsToolName } = contextOptions(options)
81
+ const byName = new Map()
82
+ for (const server of servers ?? []) byName.set(server.name, server)
83
+ const lines = ['[ssh-workbench] Managed server context:']
84
+ if (typeof state?.active === 'string' && state.active.length > 0) {
85
+ const server = byName.get(state.active)
86
+ lines.push(server ? serverLine(server) : `- ${state.active}`)
87
+ const activeDialect = effectiveDialect(server)
88
+ if (activeDialect === 'windows') {
89
+ lines.push(
90
+ `The ${windowsToolName} tool runs PowerShell commands on ${state.active} over SSH; pass ${windowsToolName}.workdir to change its working directory.`,
91
+ )
92
+ lines.push(`The native ${hostOs === 'win32' ? 'pwsh' : 'bash'} tool always runs on the local DSH host: never use it for commands meant for ${state.active}.`)
93
+ lines.push(
94
+ `read/write/edit/glob/grep always run on the local DSH host: never use write/edit for remote files — change them through ${windowsToolName} or the GUI SFTP panel.`,
95
+ )
96
+ lines.push(`Do not use ssh/scp inside ${windowsToolName} to reach other servers; switch targets with ${TARGET_TOOL_NAME}.`)
97
+ lines.push('After a state change, verify the result before reporting; exit code 0 does not prove success.')
98
+ lines.push(`Switch target: ${TARGET_TOOL_NAME}({ server: "<name>" }); detach: server: "${LOCAL_TARGET}".`)
99
+ return lines.join('\n')
100
+ }
101
+ if (hostOs === 'win32') {
102
+ lines.push(
103
+ `The ${remoteToolName} tool runs POSIX shell commands on ${state.active} over SSH; pass ${remoteToolName}.workdir to change its working directory.`,
104
+ )
105
+ lines.push(`The native pwsh tool always runs on the local DSH host: never use it for commands meant for ${state.active}.`)
106
+ lines.push(
107
+ `read/write/edit/glob/grep always run on the local DSH host: never use write/edit for remote files — change them through ${remoteToolName} (heredoc, sed -i) or the GUI SFTP panel.`,
108
+ )
109
+ lines.push(`Do not use ssh/scp inside ${remoteToolName} to reach other servers; switch targets with ${TARGET_TOOL_NAME}.`)
110
+ } else {
111
+ lines.push(`The ${remoteToolName} tool runs on ${state.active}; pass ${remoteToolName}.workdir to change its working directory.`)
112
+ lines.push(
113
+ `read/write/edit/glob/grep always run on the local DSH host: never use write/edit for remote files — change them through ${remoteToolName} (heredoc, sed -i) or the GUI SFTP panel.`,
114
+ )
115
+ lines.push(`Do not use ssh/scp inside ${remoteToolName} to reach other servers; switch targets with ${TARGET_TOOL_NAME}.`)
116
+ }
117
+ lines.push('After a state change, verify the result before reporting; exit code 0 does not prove success.')
118
+ lines.push(`Switch target: ${TARGET_TOOL_NAME}({ server: "<name>" }); detach: server: "${LOCAL_TARGET}".`)
119
+ return lines.join('\n')
120
+ }
121
+ const candidates = Array.isArray(state?.candidates) ? state.candidates : []
122
+ const listed = candidates.map((name) => byName.get(name)).filter(Boolean)
123
+ for (const server of listed) lines.push(serverLine(server))
124
+ if (listed.length === 0) lines.push(`- (unresolved: ${candidates.join(', ')})`)
125
+ if (hostOs === 'win32') {
126
+ lines.push(
127
+ `The native pwsh tool runs on the local DSH host; ${remoteToolName} only runs after you choose a target with ${TARGET_TOOL_NAME}({ server: "<name>" }).`,
128
+ )
129
+ lines.push(
130
+ `read/write/edit/glob/grep always run on the local DSH host: remote files are only reachable through ${remoteToolName}.`,
131
+ )
132
+ } else {
133
+ lines.push(
134
+ `${remoteToolName} still runs on the local DSH host until you choose a target with ${TARGET_TOOL_NAME}({ server: "<name>" }).`,
135
+ )
136
+ lines.push(`read/write/edit/glob/grep always run on the local DSH host: remote files are only reachable through ${remoteToolName}.`)
137
+ }
138
+ return lines.join('\n')
139
+ }
140
+
141
+ /**
142
+ * Build one user-role context message, preferring the host's message factory.
143
+ * `@` symbols are avoided so this message can never be re-matched as a mention.
144
+ */
145
+ export function buildTargetContextMessage(state, servers, createUserMessage, options = {}) {
146
+ const text = renderTargetContext(state, servers, options)
147
+ const source = {
148
+ kind: TARGET_CONTEXT_KIND,
149
+ form: 'notice',
150
+ summary: state?.active ? `remote target: ${state.active}` : 'remote target not selected',
151
+ }
152
+ const input = { content: [{ type: 'text', text }], source }
153
+ if (typeof createUserMessage === 'function') return createUserMessage(input)
154
+ return { role: 'user', id: `ssh-workbench-target-${randomUUID()}`, ...structuredClone(input) }
155
+ }
156
+
157
+ /**
158
+ * Install the target binding + context provider.
159
+ *
160
+ * @param {object} ctx plugin context
161
+ * @param {{
162
+ * enabled?: boolean,
163
+ * remoteAvailable?: boolean,
164
+ * hostPlatform?: 'win32'|'posix',
165
+ * remoteToolName?: string,
166
+ * windowsToolName?: string,
167
+ * ensureDialect?: (server: object) => Promise<string|undefined>,
168
+ * targets?: object,
169
+ * loadInventory?: () => object[],
170
+ * createUserMessage?: Function,
171
+ * }} [options]
172
+ */
173
+ export function installTargetContext(ctx, options = {}) {
174
+ const enabled = options.enabled !== false
175
+ const remoteAvailable = options.remoteAvailable !== false
176
+ const contextOptions = {
177
+ hostPlatform: options.hostPlatform,
178
+ remoteToolName: options.remoteToolName,
179
+ windowsToolName: options.windowsToolName,
180
+ }
181
+ const loadInventory = options.loadInventory ?? loadServers
182
+ const targets = options.targets ?? createTargetStore({ loadInventory })
183
+ const warn = (message) => {
184
+ try {
185
+ ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
186
+ } catch {
187
+ // Logging must never fail the plugin.
188
+ }
189
+ }
190
+ if (!ctx || typeof ctx.on !== 'function') return { targets }
191
+
192
+ let factory = options.createUserMessage
193
+ const messageFactory = async () => {
194
+ if (factory !== undefined) return factory
195
+ try {
196
+ const mod = await import('@deepseek-ai/dsh-llm')
197
+ factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
198
+ } catch {
199
+ factory = null
200
+ }
201
+ return factory
202
+ }
203
+
204
+ const handler = async (payload, next) => {
205
+ const decision = await next()
206
+ try {
207
+ if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
208
+ if (enabled === false) return decision
209
+ // Never describe a remote world the Host cannot execute in: without a
210
+ // working remote tool the model must keep treating every tool as local.
211
+ if (!remoteAvailable) return decision
212
+ const agent = payload?.agent
213
+ const session = agent?.session
214
+ const key = session?.id ?? agent?.id
215
+ if (typeof key !== 'string' || key.length === 0) return decision
216
+ let servers = loadInventory()
217
+ // Restore a resumed Session's target from its durable human messages once;
218
+ // deriveMessages() is intentionally skipped on every later step.
219
+ if (targets.get(key)?.seeded !== true) {
220
+ try {
221
+ const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
222
+ targets.ensureSeeded(key, history, servers)
223
+ } catch {
224
+ targets.ensureSeeded(key, [], servers)
225
+ }
226
+ }
227
+ const human = lastHumanMessage(decision.messages)
228
+ const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
229
+ const state = targets.bindMentions(key, mentions)
230
+ if (!state) return decision
231
+ // Describe the target in its real dialect. A fresh record may not have a
232
+ // probe result yet; probing here (bounded) avoids telling the model to
233
+ // use a POSIX shell against a Windows host for the whole first turn.
234
+ const activeServer = state.active ? servers.find((entry) => entry.name === state.active) : undefined
235
+ if (activeServer && typeof options.ensureDialect === 'function' && !activeServer.facts?.dialect) {
236
+ try {
237
+ await Promise.race([
238
+ Promise.resolve().then(() => options.ensureDialect(activeServer)),
239
+ new Promise((resolve) => {
240
+ const timer = setTimeout(resolve, 2500)
241
+ timer.unref?.()
242
+ }),
243
+ ])
244
+ servers = loadInventory()
245
+ } catch {
246
+ // Keep the record hint when probing fails.
247
+ }
248
+ }
249
+ const needsChoice = state.candidates.length > 1 && !state.active
250
+ if (!state.active && !needsChoice) return decision
251
+ const changed = state.version !== state.injectedVersion
252
+ const firstStep = payload?.step === 1
253
+ if (!changed) {
254
+ if (!firstStep) return decision
255
+ // One context message per turn is enough while it is still present in
256
+ // the step messages (it is committed to the session like other runtime
257
+ // context). Re-inject after compaction or when the target changed.
258
+ const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
259
+ if (hasContext) return decision
260
+ }
261
+ const message = buildTargetContextMessage(state, servers, await messageFactory(), contextOptions)
262
+ targets.markInjected(key, state.version)
263
+ return { ...decision, messages: [...decision.messages, message] }
264
+ } catch (error) {
265
+ warn(`target context skipped: ${describeError(error)}`)
266
+ return decision
267
+ }
268
+ }
269
+
270
+ const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
271
+ const offDisposed = ctx.on('agent/disposed', (disposed) => {
272
+ try {
273
+ const session = disposed?.agent?.session
274
+ const key = session?.id ?? disposed?.agent?.id
275
+ if (typeof key === 'string') targets.clear(key)
276
+ } catch {
277
+ // Best effort cleanup.
278
+ }
279
+ })
280
+ if (typeof ctx.effect === 'function') {
281
+ ctx.effect(
282
+ () => () => {
283
+ try {
284
+ offPreStep?.()
285
+ } catch {
286
+ // Disposal is best effort.
287
+ }
288
+ try {
289
+ offDisposed?.()
290
+ } catch {
291
+ // Disposal is best effort.
292
+ }
293
+ targets.clearAll?.()
294
+ },
295
+ 'dsh-ssh-workbench: target context',
296
+ )
297
+ }
298
+ return { targets, offPreStep, offDisposed }
299
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shell adapters: one managed target dialect → the commands the plugin builds.
3
+ *
4
+ * A Host does not always ship a native shell tool for every target dialect.
5
+ * Windows Desktop builds have `pwsh` but no `bash` (DSH disables `bash` by
6
+ * platform), so remote POSIX execution must come from a plugin-owned tool
7
+ * rather than from the native `bash` interception. Adapters keep everything
8
+ * dialect-specific — quoting, working-directory prefix, timeout wrapper, exit
9
+ * code propagation, user-facing labels — in one object so adding a dialect
10
+ * never grows another branch inside a tool body.
11
+ */
12
+ import { buildPosixBackgroundCommand } from './background.js'
13
+ import { normalizeDialect } from './dialect.js'
14
+ import { applyWorkdir, resolveTimeoutMs, wrapRemoteTimeout } from './remote-bash.js'
15
+ import { win32Adapter } from './win32-adapter.js'
16
+
17
+ /** Normalize the Host process platform to the two worlds the plugin cares about. */
18
+ export function hostPlatform(platform = process.platform) {
19
+ return platform === 'win32' ? 'win32' : 'posix'
20
+ }
21
+
22
+ /**
23
+ * POSIX adapter: Linux/Unix targets reached through the remote login shell.
24
+ *
25
+ * `remoteTimeout` means the target can run the coreutils `timeout` wrapper, so
26
+ * a client-side deadline can also stop the remote process group.
27
+ */
28
+ export const posixAdapter = {
29
+ id: 'posix',
30
+ platform: 'posix',
31
+ toolName: 'remote_bash',
32
+ label: 'Linux/Unix (POSIX)',
33
+ remoteTimeout: true,
34
+ supportsDialect: (dialect) => normalizeDialect(dialect) !== 'windows',
35
+ unsupportedMessage: (server) =>
36
+ `target "${server?.name ?? 'unknown'}" is a Windows server; remote_bash runs POSIX commands only — use remote_pwsh for Windows targets`,
37
+ buildCommand: (command, workdir) => applyWorkdir(command, workdir),
38
+ buildBackgroundCommand(command, workdir) {
39
+ const built = applyWorkdir(command, workdir)
40
+ if (built.error !== undefined) return built
41
+ return { command: buildPosixBackgroundCommand(built.command), cwd: built.cwd }
42
+ },
43
+ wrapTimeout: wrapRemoteTimeout,
44
+ resolveTimeoutMs,
45
+ }
46
+
47
+
48
+ export { win32Adapter }