dsh-ssh-workbench 1.6.4 → 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 +49 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +41 -22
- package/SECURITY.md +2 -0
- package/docs/architecture.md +58 -36
- 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 +20 -8
- package/lib/client.js +171 -157
- package/lib/core/activity.js +5 -0
- package/lib/core/broker.js +10 -7
- package/lib/core/dialect.js +1 -1
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +1 -1
- package/lib/core/guard.js +86 -34
- package/lib/core/live-executions.js +101 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +1 -1
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/{remote-bash-tool.js → remote-shell-tool.js} +58 -69
- package/lib/core/secrets.js +0 -1
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +1 -1
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +113 -154
- package/lib/routes.js +28 -2
- package/package.json +7 -6
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Explicit-target remote shell tools (`remote_bash`, `remote_pwsh`).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* through the POSIX shell. These tools are the portable entry points — they are
|
|
9
|
-
* registered where the native shell cannot cover the target dialect, never
|
|
10
|
-
* shadow a native tool, and keep the local shell strictly local.
|
|
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.
|
|
11
8
|
*
|
|
12
9
|
* The tools return rendered text instead of the native Bash value: the built-in
|
|
13
10
|
* Bash renderer is reserved for the built-in tool and a remote result has no
|
|
@@ -18,9 +15,9 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
18
15
|
import { describeResultOutcome } from './activity.js'
|
|
19
16
|
import { parseBackgroundStart, renderBackgroundStart } from './background.js'
|
|
20
17
|
import { effectiveDialect, normalizeDialect } from './dialect.js'
|
|
21
|
-
import { isTransportFailure } from './
|
|
18
|
+
import { isTransportFailure } from './posix-shell.js'
|
|
22
19
|
import { posixAdapter } from './shell-adapters.js'
|
|
23
|
-
import {
|
|
20
|
+
import { requireServerRef } from './server-ref.js'
|
|
24
21
|
|
|
25
22
|
const textOut = {
|
|
26
23
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
@@ -41,9 +38,8 @@ export function sessionKeyOf(exec) {
|
|
|
41
38
|
|
|
42
39
|
/**
|
|
43
40
|
* Resolve the dialect a target actually runs. `facts.dialect` is a probe
|
|
44
|
-
* result; when it is absent
|
|
45
|
-
*
|
|
46
|
-
* fallback.
|
|
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.
|
|
47
43
|
*/
|
|
48
44
|
export async function resolveDialect(server, runtime) {
|
|
49
45
|
const probed = server?.facts?.dialect
|
|
@@ -60,47 +56,29 @@ export async function resolveDialect(server, runtime) {
|
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
/**
|
|
63
|
-
* Resolve the
|
|
64
|
-
* @returns {Promise<{ key
|
|
59
|
+
* Resolve the explicitly named server and its dialect for one call.
|
|
60
|
+
* @returns {Promise<{ key?: string, server: object, dialect: string }>}
|
|
65
61
|
*/
|
|
66
|
-
export async function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`call ssh_workbench_target({ server: "<name>" }) before ${toolName}`,
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
throw new Error(
|
|
78
|
-
`no active target — call ssh_workbench_target({ server: "<name>" }) before ${toolName}, ` +
|
|
79
|
-
'or ssh_workbench_target({ server: "local" }) to stay on the local DSH host',
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
62
|
+
export async function resolveExplicitTarget({
|
|
63
|
+
exec,
|
|
64
|
+
loadInventory,
|
|
65
|
+
adapter,
|
|
66
|
+
runtime,
|
|
67
|
+
serverRef,
|
|
68
|
+
toolName = adapter?.toolName ?? 'remote shell',
|
|
69
|
+
}) {
|
|
83
70
|
let servers
|
|
84
71
|
try {
|
|
85
72
|
servers = loadInventory()
|
|
86
73
|
} catch (error) {
|
|
87
74
|
throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
|
|
88
75
|
}
|
|
89
|
-
const server =
|
|
90
|
-
if (server === undefined) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
`target "${binding.active}" is no longer in the managed inventory; ` +
|
|
93
|
-
'call ssh_workbench_target to pick another target or "local"',
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
if (server.enabled === false) {
|
|
97
|
-
throw new Error(`target "${server.name}" is disabled; call ssh_workbench_target to pick another target or "local"`)
|
|
98
|
-
}
|
|
76
|
+
const server = requireServerRef(servers, serverRef, { label: `${toolName} server` })
|
|
99
77
|
const dialect = await resolveDialect(server, runtime)
|
|
100
78
|
if (typeof adapter?.supportsDialect === 'function' && !adapter.supportsDialect(dialect)) {
|
|
101
79
|
throw new Error(adapter.unsupportedMessage(server, dialect))
|
|
102
80
|
}
|
|
103
|
-
return { key
|
|
81
|
+
return { key: sessionKeyOf(exec), server, dialect }
|
|
104
82
|
}
|
|
105
83
|
|
|
106
84
|
/** Render one remote result as model-facing text. */
|
|
@@ -137,11 +115,10 @@ function defaultCancelNotice(capability) {
|
|
|
137
115
|
}
|
|
138
116
|
|
|
139
117
|
/**
|
|
140
|
-
* Create one remote shell tool definition.
|
|
118
|
+
* Create one explicit-target remote shell tool definition.
|
|
141
119
|
*
|
|
142
120
|
* @param {object} deps
|
|
143
121
|
* @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
|
|
144
|
-
* @param {object} deps.targets per-session target store
|
|
145
122
|
* @param {object} deps.config resolved plugin config
|
|
146
123
|
* @param {() => object[]} deps.loadInventory
|
|
147
124
|
* @param {(entry: object) => void} [deps.recordActivity]
|
|
@@ -150,7 +127,6 @@ function defaultCancelNotice(capability) {
|
|
|
150
127
|
*/
|
|
151
128
|
export function createRemoteShellTool({
|
|
152
129
|
runtime,
|
|
153
|
-
targets,
|
|
154
130
|
config,
|
|
155
131
|
loadInventory,
|
|
156
132
|
recordActivity,
|
|
@@ -158,18 +134,25 @@ export function createRemoteShellTool({
|
|
|
158
134
|
localShellName = 'bash',
|
|
159
135
|
} = {}) {
|
|
160
136
|
const dialectName = normalizeDialect(adapter.platform) === 'windows' ? 'Windows/PowerShell' : 'POSIX shell'
|
|
137
|
+
const toolName = adapter.toolName
|
|
161
138
|
return defineTool({
|
|
162
|
-
name:
|
|
139
|
+
name: toolName,
|
|
163
140
|
description:
|
|
164
|
-
`Run a ${dialectName} command on
|
|
165
|
-
|
|
166
|
-
|
|
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. ' +
|
|
167
144
|
'Pass workdir to change the working directory and timeoutMs for a foreground deadline. ' +
|
|
168
145
|
'Set run_in_background to start a detached process: the result gives its pid and log files, ' +
|
|
169
146
|
'which you can read with tail/Get-Content and stop with kill/Stop-Process. ' +
|
|
170
|
-
|
|
171
|
-
|
|
147
|
+
(normalizeDialect(adapter.platform) === 'windows'
|
|
148
|
+
? 'Use remote_bash for POSIX resources.'
|
|
149
|
+
: 'Use remote_pwsh for Windows resources.'),
|
|
172
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
|
+
},
|
|
173
156
|
command: { type: 'string', required: true, description: `Command to run on the remote ${dialectName} target.` },
|
|
174
157
|
workdir: {
|
|
175
158
|
type: 'string',
|
|
@@ -187,20 +170,28 @@ export function createRemoteShellTool({
|
|
|
187
170
|
},
|
|
188
171
|
output: textOut,
|
|
189
172
|
async execute(args, exec) {
|
|
190
|
-
const {
|
|
173
|
+
const { server, dialect } = await resolveExplicitTarget({
|
|
191
174
|
exec,
|
|
192
|
-
targets,
|
|
193
175
|
loadInventory,
|
|
194
176
|
runtime,
|
|
195
177
|
adapter,
|
|
178
|
+
serverRef: args?.server,
|
|
179
|
+
toolName,
|
|
196
180
|
})
|
|
197
181
|
|
|
198
182
|
const command = typeof args?.command === 'string' ? args.command : ''
|
|
199
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
|
+
}
|
|
200
191
|
|
|
201
192
|
if (args?.run_in_background === true) {
|
|
202
193
|
if (typeof adapter.buildBackgroundCommand !== 'function') {
|
|
203
|
-
throw new Error(`${
|
|
194
|
+
throw new Error(`${toolName} does not support run_in_background`)
|
|
204
195
|
}
|
|
205
196
|
const background = adapter.buildBackgroundCommand(command, args?.workdir, server, dialect)
|
|
206
197
|
if (background.error !== undefined) throw new Error(background.error)
|
|
@@ -215,15 +206,15 @@ export function createRemoteShellTool({
|
|
|
215
206
|
stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
|
|
216
207
|
})
|
|
217
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
|
+
}
|
|
218
214
|
throw new Error(`remote execution failed: ${describeError(error)}`)
|
|
219
215
|
}
|
|
220
216
|
try {
|
|
221
|
-
recordActivity?.({
|
|
222
|
-
kind: 'exec',
|
|
223
|
-
server: server.name,
|
|
224
|
-
summary: `[background] ${command.slice(0, 190)}`,
|
|
225
|
-
outcome: describeResultOutcome(start),
|
|
226
|
-
})
|
|
217
|
+
recordActivity?.({ ...activityBase, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start), durationMs: Date.now() - startedAt })
|
|
227
218
|
} catch {
|
|
228
219
|
// Activity recording is best effort.
|
|
229
220
|
}
|
|
@@ -280,11 +271,16 @@ export function createRemoteShellTool({
|
|
|
280
271
|
stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
|
|
281
272
|
})
|
|
282
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
|
+
}
|
|
283
279
|
throw new Error(`remote execution failed: ${describeError(error)}`)
|
|
284
280
|
}
|
|
285
281
|
|
|
286
282
|
try {
|
|
287
|
-
recordActivity?.({
|
|
283
|
+
recordActivity?.({ ...activityBase, summary: command.slice(0, 200), outcome: describeResultOutcome(result), durationMs: Date.now() - startedAt })
|
|
288
284
|
} catch {
|
|
289
285
|
// Activity recording is best effort.
|
|
290
286
|
}
|
|
@@ -297,12 +293,6 @@ export function createRemoteShellTool({
|
|
|
297
293
|
}
|
|
298
294
|
|
|
299
295
|
const notices = []
|
|
300
|
-
// One line after each target change: makes a plain-text result
|
|
301
|
-
// self-describing in multi-target sessions.
|
|
302
|
-
if (binding.notifiedVersion !== binding.version && typeof targets?.markNotified === 'function') {
|
|
303
|
-
notices.push(`[remote] target: ${server.name} (${server.username}@${server.host}:${server.port})`)
|
|
304
|
-
targets.markNotified(key, binding.version)
|
|
305
|
-
}
|
|
306
296
|
if (result?.outcome === 'timeout') {
|
|
307
297
|
notices.push(
|
|
308
298
|
typeof adapter.timeoutNotice === 'function' ? adapter.timeoutNotice(capability) : defaultTimeoutNotice(capability),
|
|
@@ -318,4 +308,3 @@ export function createRemoteShellTool({
|
|
|
318
308
|
},
|
|
319
309
|
})
|
|
320
310
|
}
|
|
321
|
-
|
package/lib/core/secrets.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* plaintext off disk without blocking the plugin.
|
|
9
9
|
*/
|
|
10
10
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
11
|
-
import fs from 'node:fs'
|
|
12
11
|
import { readTextFile, secretsKeyPath, secretsPath, writePrivateFile } from './paths.js'
|
|
13
12
|
|
|
14
13
|
const ALGO = 'aes-256-gcm'
|
|
@@ -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
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { buildPosixBackgroundCommand } from './background.js'
|
|
13
13
|
import { normalizeDialect } from './dialect.js'
|
|
14
|
-
import { applyWorkdir, resolveTimeoutMs, wrapRemoteTimeout } from './
|
|
14
|
+
import { applyWorkdir, resolveTimeoutMs, wrapRemoteTimeout } from './posix-shell.js'
|
|
15
15
|
import { win32Adapter } from './win32-adapter.js'
|
|
16
16
|
|
|
17
17
|
/** Normalize the Host process platform to the two worlds the plugin cares about. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { BG_ERR_MARKER, BG_LOG_MARKER, BG_PID_MARKER } from './background.js'
|
|
16
16
|
import { normalizeDialect } from './dialect.js'
|
|
17
|
-
import { resolveTimeoutMs } from './
|
|
17
|
+
import { resolveTimeoutMs } from './posix-shell.js'
|
|
18
18
|
|
|
19
19
|
const POWERSHELL_UTF8 = [
|
|
20
20
|
"$ErrorActionPreference = 'Continue'",
|