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,310 @@
1
+ /**
2
+ * Explicit-target remote shell tools (`remote_bash`, `remote_pwsh`).
3
+ *
4
+ * Every call names the managed
5
+ * resource it wants. That makes a follow-up without a new `@mention` safe
6
+ * (nothing switches behind the model's back), lets the model compare several
7
+ * resources directly, and keeps local and remote shells in separate worlds.
8
+ *
9
+ * The tools return rendered text instead of the native Bash value: the built-in
10
+ * Bash renderer is reserved for the built-in tool and a remote result has no
11
+ * native card. Text keeps command, target, cwd and exit status explicit.
12
+ */
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+
15
+ import { describeResultOutcome } from './activity.js'
16
+ import { parseBackgroundStart, renderBackgroundStart } from './background.js'
17
+ import { effectiveDialect, normalizeDialect } from './dialect.js'
18
+ import { isTransportFailure } from './posix-shell.js'
19
+ import { posixAdapter } from './shell-adapters.js'
20
+ import { requireServerRef } from './server-ref.js'
21
+
22
+ const textOut = {
23
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
24
+ render: (_args, value) => [{ type: 'text', text: value.text }],
25
+ }
26
+
27
+ function describeError(error) {
28
+ return error instanceof Error ? error.message : String(error)
29
+ }
30
+
31
+ /** Read the session key off one tool execution, tolerating test doubles. */
32
+ export function sessionKeyOf(exec) {
33
+ const agent = exec?.agent
34
+ const session = agent?.session
35
+ const id = session?.id ?? agent?.id
36
+ return typeof id === 'string' && id.length > 0 ? id : undefined
37
+ }
38
+
39
+ /**
40
+ * Resolve the dialect a target actually runs. `facts.dialect` is a probe
41
+ * result; when it is absent the runtime probe runs once and is cached. The
42
+ * record's `platform` hint is only the last fallback.
43
+ */
44
+ export async function resolveDialect(server, runtime) {
45
+ const probed = server?.facts?.dialect
46
+ if (probed === 'posix' || probed === 'windows') return probed
47
+ if (runtime && typeof runtime.probeDialect === 'function') {
48
+ try {
49
+ const dialect = await runtime.probeDialect(server)
50
+ if (dialect === 'posix' || dialect === 'windows') return dialect
51
+ } catch {
52
+ // Fall through to the record hint.
53
+ }
54
+ }
55
+ return effectiveDialect(server)
56
+ }
57
+
58
+ /**
59
+ * Resolve the explicitly named server and its dialect for one call.
60
+ * @returns {Promise<{ key?: string, server: object, dialect: string }>}
61
+ */
62
+ export async function resolveExplicitTarget({
63
+ exec,
64
+ loadInventory,
65
+ adapter,
66
+ runtime,
67
+ serverRef,
68
+ toolName = adapter?.toolName ?? 'remote shell',
69
+ }) {
70
+ let servers
71
+ try {
72
+ servers = loadInventory()
73
+ } catch (error) {
74
+ throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
75
+ }
76
+ const server = requireServerRef(servers, serverRef, { label: `${toolName} server` })
77
+ const dialect = await resolveDialect(server, runtime)
78
+ if (typeof adapter?.supportsDialect === 'function' && !adapter.supportsDialect(dialect)) {
79
+ throw new Error(adapter.unsupportedMessage(server, dialect))
80
+ }
81
+ return { key: sessionKeyOf(exec), server, dialect }
82
+ }
83
+
84
+ /** Render one remote result as model-facing text. */
85
+ export function renderRemoteResult({ server, command, cwd, result, notices }) {
86
+ const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
87
+ const stderr = typeof result?.stderr === 'string' ? result.stderr : ''
88
+ const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
89
+ const lines = [`target: ${server.name} (${server.username}@${server.host}:${server.port})`, `command: ${command}`, `cwd: ${cwd}`]
90
+ if (exitCode !== null) lines.push(`exit code: ${exitCode}`)
91
+ if (result?.outcome === 'timeout') lines.push('outcome: timed out')
92
+ if (result?.outcome === 'cancelled') lines.push('outcome: cancelled')
93
+ lines.push('stdout:')
94
+ lines.push(stdout.length > 0 ? stdout.replace(/\n$/, '') : '(empty)')
95
+ if (stderr.length > 0) {
96
+ lines.push('stderr:')
97
+ lines.push(stderr.replace(/\n$/, ''))
98
+ }
99
+ if (result?.truncated?.stdout === true) lines.push('[stdout truncated by the plugin output cap]')
100
+ if (result?.truncated?.stderr === true) lines.push('[stderr truncated by the plugin output cap]')
101
+ for (const notice of notices ?? []) lines.push(notice)
102
+ return { text: lines.join('\n') }
103
+ }
104
+
105
+ function defaultTimeoutNotice(capability) {
106
+ return capability.mode === 'none'
107
+ ? '[remote] client-side timeout only: the server has no usable `timeout` utility, so the remote process may still be running.'
108
+ : `[remote] timeout: the remote command group was terminated (SIGTERM${capability.mode === 'kill' ? ', KILL after the remote deadline' : ''}).`
109
+ }
110
+
111
+ function defaultCancelNotice(capability) {
112
+ return capability.mode === 'none'
113
+ ? '[remote] cancelled: the SSH channel was closed, but the remote process may still be running.'
114
+ : '[remote] cancelled: the SSH channel was closed; the remote wrapper terminates the command at its deadline if it is still running.'
115
+ }
116
+
117
+ /**
118
+ * Create one explicit-target remote shell tool definition.
119
+ *
120
+ * @param {object} deps
121
+ * @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
122
+ * @param {object} deps.config resolved plugin config
123
+ * @param {() => object[]} deps.loadInventory
124
+ * @param {(entry: object) => void} [deps.recordActivity]
125
+ * @param {object} [deps.adapter] shell adapter (defaults to POSIX `remote_bash`)
126
+ * @param {string} [deps.localShellName] native local shell label, for copy
127
+ */
128
+ export function createRemoteShellTool({
129
+ runtime,
130
+ config,
131
+ loadInventory,
132
+ recordActivity,
133
+ adapter = posixAdapter,
134
+ localShellName = 'bash',
135
+ } = {}) {
136
+ const dialectName = normalizeDialect(adapter.platform) === 'windows' ? 'Windows/PowerShell' : 'POSIX shell'
137
+ const toolName = adapter.toolName
138
+ return defineTool({
139
+ name: toolName,
140
+ description:
141
+ `Run a ${dialectName} command on an explicitly named managed remote resource. ` +
142
+ `Pass server on every call (name or id). The native ${localShellName} tool always runs on the local DSH host, ` +
143
+ 'so use this tool only for work that belongs on the remote resource. ' +
144
+ 'Pass workdir to change the working directory and timeoutMs for a foreground deadline. ' +
145
+ 'Set run_in_background to start a detached process: the result gives its pid and log files, ' +
146
+ 'which you can read with tail/Get-Content and stop with kill/Stop-Process. ' +
147
+ (normalizeDialect(adapter.platform) === 'windows'
148
+ ? 'Use remote_bash for POSIX resources.'
149
+ : 'Use remote_pwsh for Windows resources.'),
150
+ parameters: {
151
+ server: {
152
+ type: 'string',
153
+ required: true,
154
+ description: 'Managed remote resource name or id. Required; there is no remembered target.',
155
+ },
156
+ command: { type: 'string', required: true, description: `Command to run on the remote ${dialectName} target.` },
157
+ workdir: {
158
+ type: 'string',
159
+ description: 'Remote working directory: an absolute path, ~/path, or a path relative to the login home.',
160
+ },
161
+ timeoutMs: {
162
+ type: 'integer',
163
+ description: 'Foreground timeout in milliseconds (default from config, capped by maxCommandTimeoutMs).',
164
+ },
165
+ run_in_background: {
166
+ type: 'boolean',
167
+ description:
168
+ 'Start the command detached instead of waiting. Returns the remote pid and log files; timeoutMs does not apply.',
169
+ },
170
+ },
171
+ output: textOut,
172
+ async execute(args, exec) {
173
+ const { server, dialect } = await resolveExplicitTarget({
174
+ exec,
175
+ loadInventory,
176
+ runtime,
177
+ adapter,
178
+ serverRef: args?.server,
179
+ toolName,
180
+ })
181
+
182
+ const command = typeof args?.command === 'string' ? args.command : ''
183
+ if (!command.trim()) throw new Error('command must not be empty')
184
+ const sessionId = sessionKeyOf(exec)
185
+ const startedAt = Date.now()
186
+ const activityBase = {
187
+ kind: 'exec',
188
+ server: server.name,
189
+ sessionId,
190
+ }
191
+
192
+ if (args?.run_in_background === true) {
193
+ if (typeof adapter.buildBackgroundCommand !== 'function') {
194
+ throw new Error(`${toolName} does not support run_in_background`)
195
+ }
196
+ const background = adapter.buildBackgroundCommand(command, args?.workdir, server, dialect)
197
+ if (background.error !== undefined) throw new Error(background.error)
198
+ const startTimeout = Math.min(adapter.resolveTimeoutMs(args, config), 30000)
199
+ let start
200
+ try {
201
+ start = await runtime.exec(server, background.command, {
202
+ timeoutMs: startTimeout,
203
+ signal: exec?.signal,
204
+ readOnly: false,
205
+ cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
206
+ stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
207
+ })
208
+ } catch (error) {
209
+ try {
210
+ recordActivity?.({ ...activityBase, summary: `[background] ${command.slice(0, 190)}`, outcome: 'failed', durationMs: Date.now() - startedAt })
211
+ } catch {
212
+ // Activity recording is best effort.
213
+ }
214
+ throw new Error(`remote execution failed: ${describeError(error)}`)
215
+ }
216
+ try {
217
+ recordActivity?.({ ...activityBase, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start), durationMs: Date.now() - startedAt })
218
+ } catch {
219
+ // Activity recording is best effort.
220
+ }
221
+ if (isTransportFailure(start)) {
222
+ const detail = typeof start?.stderr === 'string' && start.stderr.trim() ? start.stderr.trim() : 'remote execution failed'
223
+ throw new Error(detail)
224
+ }
225
+ const started = parseBackgroundStart(start?.stdout)
226
+ if (started.pid === undefined) {
227
+ const detail = String(start?.stderr ?? '').trim() || String(start?.stdout ?? '').trim()
228
+ throw new Error(`background start failed${detail ? `: ${detail}` : ''}`)
229
+ }
230
+ return {
231
+ text: renderBackgroundStart({
232
+ pid: started.pid,
233
+ log: started.log,
234
+ errLog: started.errLog,
235
+ target: `${server.name} (${server.username}@${server.host}:${server.port})`,
236
+ command,
237
+ dialect,
238
+ }),
239
+ }
240
+ }
241
+
242
+ const workdir = adapter.buildCommand(command, args?.workdir, server, dialect)
243
+ if (workdir.error !== undefined) throw new Error(workdir.error)
244
+ const timeoutMs = adapter.resolveTimeoutMs(args, config)
245
+
246
+ // A remote deadline wrapper stops the remote process group when the
247
+ // client gives up; adapters without one fall back to the SSH channel
248
+ // close and say so in the result.
249
+ let capability = { mode: 'none', shell: '/bin/sh' }
250
+ if (adapter.remoteTimeout) {
251
+ try {
252
+ if (typeof runtime.timeoutGuard === 'function') {
253
+ const probed = await runtime.timeoutGuard(server)
254
+ capability = typeof probed === 'string' ? { mode: probed } : (probed ?? capability)
255
+ }
256
+ } catch {
257
+ capability = { mode: 'none', shell: '/bin/sh' }
258
+ }
259
+ }
260
+ const remoteCommand = adapter.wrapTimeout(workdir.command, timeoutMs, capability, {
261
+ killAfterMs: config?.timeoutKillAfterMs,
262
+ })
263
+
264
+ let result
265
+ try {
266
+ result = await runtime.exec(server, remoteCommand, {
267
+ timeoutMs,
268
+ signal: exec?.signal,
269
+ readOnly: false,
270
+ cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
271
+ stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
272
+ })
273
+ } catch (error) {
274
+ try {
275
+ recordActivity?.({ ...activityBase, summary: command.slice(0, 200), outcome: 'failed', durationMs: Date.now() - startedAt })
276
+ } catch {
277
+ // Activity recording is best effort.
278
+ }
279
+ throw new Error(`remote execution failed: ${describeError(error)}`)
280
+ }
281
+
282
+ try {
283
+ recordActivity?.({ ...activityBase, summary: command.slice(0, 200), outcome: describeResultOutcome(result), durationMs: Date.now() - startedAt })
284
+ } catch {
285
+ // Activity recording is best effort.
286
+ }
287
+
288
+ if (isTransportFailure(result)) {
289
+ const code = typeof result?.errorCode === 'string' && result.errorCode ? `${result.errorCode}: ` : ''
290
+ const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
291
+ const suggestion = typeof result?.suggestedAction === 'string' && result.suggestedAction ? `\n${result.suggestedAction}` : ''
292
+ throw new Error(`${code}${detail}${suggestion}`)
293
+ }
294
+
295
+ const notices = []
296
+ if (result?.outcome === 'timeout') {
297
+ notices.push(
298
+ typeof adapter.timeoutNotice === 'function' ? adapter.timeoutNotice(capability) : defaultTimeoutNotice(capability),
299
+ )
300
+ }
301
+ if (result?.outcome === 'cancelled') {
302
+ notices.push(
303
+ typeof adapter.cancelNotice === 'function' ? adapter.cancelNotice(capability) : defaultCancelNotice(capability),
304
+ )
305
+ }
306
+
307
+ return renderRemoteResult({ server, command, cwd: workdir.cwd, result, notices })
308
+ },
309
+ })
310
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Secret storage for SSH passwords/passphrases.
3
+ *
4
+ * Secrets live in a private JSON document, encrypted with AES-256-GCM using a
5
+ * per-installation random key (0600) generated on first use. The DSH
6
+ * credentials service is the long-term home for these values; until that
7
+ * contract is verified on the desktop runtime this self-contained store keeps
8
+ * plaintext off disk without blocking the plugin.
9
+ */
10
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
11
+ import { readTextFile, secretsKeyPath, secretsPath, writePrivateFile } from './paths.js'
12
+
13
+ const ALGO = 'aes-256-gcm'
14
+ const PREFIX = 'enc:v1:'
15
+
16
+ function loadKey(file = secretsKeyPath()) {
17
+ const text = readTextFile(file).trim()
18
+ if (text) {
19
+ const key = Buffer.from(text, 'base64')
20
+ if (key.length === 32) return key
21
+ }
22
+ const key = randomBytes(32)
23
+ writePrivateFile(file, `${key.toString('base64')}\n`)
24
+ return key
25
+ }
26
+
27
+ function readDoc(file) {
28
+ const text = readTextFile(file)
29
+ if (!text.trim()) return { version: 1, entries: {} }
30
+ try {
31
+ const parsed = JSON.parse(text)
32
+ if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
33
+ return parsed
34
+ }
35
+ } catch {
36
+ // corrupt document: fall through to an empty one rather than crashing boot
37
+ }
38
+ return { version: 1, entries: {} }
39
+ }
40
+
41
+ function encrypt(plain, key) {
42
+ const iv = randomBytes(12)
43
+ const cipher = createCipheriv(ALGO, key, iv)
44
+ const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()])
45
+ const tag = cipher.getAuthTag()
46
+ return PREFIX + Buffer.concat([iv, tag, data]).toString('base64')
47
+ }
48
+
49
+ function decrypt(token, key) {
50
+ if (typeof token !== 'string' || !token.startsWith(PREFIX)) return undefined
51
+ const payload = Buffer.from(token.slice(PREFIX.length), 'base64')
52
+ if (payload.length <= 28) return undefined
53
+ const iv = payload.subarray(0, 12)
54
+ const tag = payload.subarray(12, 28)
55
+ const data = payload.subarray(28)
56
+ const decipher = createDecipheriv(ALGO, key, iv)
57
+ decipher.setAuthTag(tag)
58
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
59
+ }
60
+
61
+ export function createSecretStore({ file = secretsPath(), keyFile = secretsKeyPath() } = {}) {
62
+ let key
63
+ const keyOf = () => {
64
+ if (!key) key = loadKey(keyFile)
65
+ return key
66
+ }
67
+ return {
68
+ get(recordKey) {
69
+ const doc = readDoc(file)
70
+ const token = doc.entries[recordKey]
71
+ if (token === undefined) return undefined
72
+ try {
73
+ return decrypt(token, keyOf())
74
+ } catch {
75
+ return undefined
76
+ }
77
+ },
78
+ has(recordKey) {
79
+ const doc = readDoc(file)
80
+ return doc.entries[recordKey] !== undefined
81
+ },
82
+ set(recordKey, value) {
83
+ const doc = readDoc(file)
84
+ doc.entries[recordKey] = encrypt(value, keyOf())
85
+ writePrivateFile(file, `${JSON.stringify(doc, null, 2)}\n`)
86
+ },
87
+ delete(recordKey) {
88
+ const doc = readDoc(file)
89
+ if (doc.entries[recordKey] === undefined) return false
90
+ delete doc.entries[recordKey]
91
+ writePrivateFile(file, `${JSON.stringify(doc, null, 2)}\n`)
92
+ return true
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Resolve one managed-resource reference (name or id) against the inventory.
3
+ *
4
+ * Every remote tool call carries its own `server`. Matching is
5
+ * case-insensitive so a hand-typed name still works, but the resolved record is
6
+ * always the inventory record the runtime uses.
7
+ */
8
+
9
+ /** @param {object[]} servers @param {string} ref */
10
+ export function findServerRef(servers, ref) {
11
+ const value = String(ref ?? '').trim()
12
+ if (!value) return undefined
13
+ const wanted = value.toLowerCase()
14
+ return (Array.isArray(servers) ? servers : []).find((server) => {
15
+ if (!server || typeof server !== 'object') return false
16
+ return (
17
+ String(server.id ?? '').toLowerCase() === wanted ||
18
+ String(server.name ?? '').toLowerCase() === wanted
19
+ )
20
+ })
21
+ }
22
+
23
+ /** Compact, model-facing list of enabled resource names. */
24
+ function serverNameList(servers, limit = 20) {
25
+ return (Array.isArray(servers) ? servers : [])
26
+ .filter((server) => server && server.enabled !== false && server.name)
27
+ .slice(0, Math.max(1, limit))
28
+ .map((server) => String(server.name))
29
+ }
30
+
31
+ /**
32
+ * Resolve and validate a server reference for a tool call.
33
+ * Throws a model-readable error that lists the available resources.
34
+ *
35
+ * @param {object[]} servers
36
+ * @param {string} ref
37
+ * @param {{ label?: string, allowDisabled?: boolean }} [options]
38
+ */
39
+ export function requireServerRef(servers, ref, options = {}) {
40
+ const label = options.label ?? 'server'
41
+ const value = String(ref ?? '').trim()
42
+ if (!value) {
43
+ throw new Error(`${label} is required; pass a managed resource name or id`)
44
+ }
45
+ if (value.toLowerCase() === 'local') {
46
+ throw new Error(
47
+ `${label} "${value}" is not a remote resource: the remote shell tools do not run on the local DSH host; use the native shell tool for local work`,
48
+ )
49
+ }
50
+ const server = findServerRef(servers, value)
51
+ if (server === undefined) {
52
+ const names = serverNameList(servers)
53
+ throw new Error(
54
+ `unknown ${label} "${value}". Available resources: ${names.length > 0 ? names.join(', ') : '(none)'}`,
55
+ )
56
+ }
57
+ if (server.enabled === false && options.allowDisabled !== true) {
58
+ throw new Error(`${label} "${server.name}" is disabled`)
59
+ }
60
+ return server
61
+ }
@@ -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 './posix-shell.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 }