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