dsh-ssh-workbench 1.6.4 → 1.7.8
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 +133 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +47 -25
- package/SECURITY.md +2 -0
- package/docs/architecture.md +60 -38
- 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 +24 -9
- package/lib/client.js +1249 -218
- package/lib/core/activity.js +5 -0
- package/lib/core/background.js +18 -4
- 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 +287 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +36 -3
- package/lib/core/remote-exec-tool.js +441 -0
- package/lib/core/remote-file-tools.js +642 -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/ssh.js +22 -0
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +121 -157
- package/lib/routes.js +45 -5
- package/package.json +23 -7
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
package/lib/core/remote-bash.js
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route the native `bash` tool to a managed server.
|
|
3
|
-
*
|
|
4
|
-
* `tools/execute` is DSH's around-dispatch waterfall. This handler returns a
|
|
5
|
-
* replacement result for exactly one tool — the built-in `bash` — and only
|
|
6
|
-
* while the session has an active managed target; every other call falls
|
|
7
|
-
* through with `next()`. The replacement value is the native bash foreground
|
|
8
|
-
* value, so the registry validates it against the built-in output schema and
|
|
9
|
-
* renders it with the built-in terminal renderer: no shadow tool, no schema
|
|
10
|
-
* copy, no custom rendering.
|
|
11
|
-
*/
|
|
12
|
-
import { describeResultOutcome } from './activity.js'
|
|
13
|
-
import { buildPosixBackgroundCommand, parseBackgroundStart, renderBackgroundStart } from './background.js'
|
|
14
|
-
import { effectiveDialect } from './dialect.js'
|
|
15
|
-
import { quotePosix } from './posix-quote.js'
|
|
16
|
-
import { findTarget } from './targets.js'
|
|
17
|
-
|
|
18
|
-
export const BASH_TOOL_NAME = 'bash'
|
|
19
|
-
|
|
20
|
-
const MIN_TIMEOUT_MS = 1000
|
|
21
|
-
const POSIX_PLATFORM = 'posix'
|
|
22
|
-
|
|
23
|
-
export { quotePosix }
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Wrap a remote command with the remote `timeout` utility.
|
|
27
|
-
*
|
|
28
|
-
* A client-side timeout only closes the SSH channel; a process that ignores
|
|
29
|
-
* SIGHUP (nohup/background) keeps running on the server. Wrapping the command
|
|
30
|
-
* gives the remote side the same deadline: coreutils `timeout` runs the
|
|
31
|
-
* managed command in its own process group and signals the whole group, so
|
|
32
|
-
* when the client gives up the remote work stops too. `kill` adds `-k <n>`
|
|
33
|
-
* (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
|
|
34
|
-
* capability) leaves the command untouched.
|
|
35
|
-
*
|
|
36
|
-
* The wrapper re-enters the remote login shell (`<shell> -c`) so shell
|
|
37
|
-
* semantics do not change. The shell path comes from the capability probe and
|
|
38
|
-
* is referenced as a quoted literal, so this also works on fish/csh (no POSIX
|
|
39
|
-
* `${SHELL:-...}` expansion). The command itself is single-quote escaped and
|
|
40
|
-
* therefore expanded exactly once, inside that shell.
|
|
41
|
-
*
|
|
42
|
-
* @param {string} command
|
|
43
|
-
* @param {number} timeoutMs
|
|
44
|
-
* @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
|
|
45
|
-
* @param {{ killAfterMs?: number }} [options]
|
|
46
|
-
*/
|
|
47
|
-
export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
|
|
48
|
-
const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
|
|
49
|
-
const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
|
|
50
|
-
if (mode === 'none') return String(command ?? '')
|
|
51
|
-
const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
|
|
52
|
-
const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
|
|
53
|
-
const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
|
|
54
|
-
const seconds = Math.max(1, Number(timeoutMs) / 1000)
|
|
55
|
-
const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
|
|
56
|
-
return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
|
|
61
|
-
*
|
|
62
|
-
* The remote shell starts in the login home; absolute paths are used verbatim,
|
|
63
|
-
* `~`/`~/x` and relative paths are anchored at `$HOME`.
|
|
64
|
-
*
|
|
65
|
-
* @returns {{ command: string, cwd: string } | { error: string }}
|
|
66
|
-
*/
|
|
67
|
-
export function applyWorkdir(command, workdir) {
|
|
68
|
-
const raw = typeof workdir === 'string' ? workdir.trim() : ''
|
|
69
|
-
const body = String(command ?? '')
|
|
70
|
-
if (!raw) return { command: body, cwd: '~' }
|
|
71
|
-
if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
|
|
72
|
-
if (raw.startsWith('~')) {
|
|
73
|
-
const rest = raw === '~' ? '' : raw.slice(1)
|
|
74
|
-
if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
|
|
75
|
-
const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
|
|
76
|
-
return { command: `cd -- ${target} && ${body}`, cwd: raw }
|
|
77
|
-
}
|
|
78
|
-
if (raw.startsWith('/')) {
|
|
79
|
-
return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
|
|
80
|
-
}
|
|
81
|
-
return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Effective timeout for one call, clamped to the configured ceiling. */
|
|
85
|
-
export function resolveTimeoutMs(args, config) {
|
|
86
|
-
const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
|
|
87
|
-
const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
|
|
88
|
-
const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
|
|
89
|
-
const value = requested ?? fallback
|
|
90
|
-
return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Map one runtime exec result onto the native bash foreground value.
|
|
95
|
-
* Non-zero exits are normal results; only "the command never ran" becomes an
|
|
96
|
-
* error result on the caller side.
|
|
97
|
-
*/
|
|
98
|
-
export function toBashValue(result, { cwd, timeoutMs }) {
|
|
99
|
-
const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
|
|
100
|
-
const stderr = typeof result?.stderr === 'string' ? result.stderr : ''
|
|
101
|
-
const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
|
|
102
|
-
const signal = typeof result?.signal === 'string' && result.signal.length > 0 ? result.signal : null
|
|
103
|
-
return {
|
|
104
|
-
kind: 'foreground',
|
|
105
|
-
cwd,
|
|
106
|
-
exitCode,
|
|
107
|
-
signal,
|
|
108
|
-
timedOut: result?.outcome === 'timeout',
|
|
109
|
-
aborted: result?.outcome === 'cancelled',
|
|
110
|
-
timeoutMs,
|
|
111
|
-
stdout: { text: stdout, truncated: result?.truncated?.stdout === true },
|
|
112
|
-
stderr: { text: stderr, truncated: result?.truncated?.stderr === true },
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Whether a runtime result means the command never reached the remote shell.
|
|
118
|
-
* Timeouts and aborts are real command outcomes (timedOut / aborted on the
|
|
119
|
-
* native value), not transport failures.
|
|
120
|
-
*/
|
|
121
|
-
export function isTransportFailure(result) {
|
|
122
|
-
if (!result || typeof result !== 'object') return true
|
|
123
|
-
if (result.exitCode !== null && result.exitCode !== undefined) return false
|
|
124
|
-
if (typeof result.signal === 'string' && result.signal.length > 0) return false
|
|
125
|
-
return result.outcome === 'failed'
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Build the structured error result the pipeline accepts for a failed call. */
|
|
129
|
-
export function createErrorResult(message, code = 'SSH_FAILED') {
|
|
130
|
-
const text = String(message ?? 'remote bash failed')
|
|
131
|
-
return {
|
|
132
|
-
isError: true,
|
|
133
|
-
content: [{ type: 'text', text: `Error: ${code}: ${text}` }],
|
|
134
|
-
error: { message: text, info: { name: 'SshOpsError', code } },
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* @param {object} deps
|
|
140
|
-
* @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
|
|
141
|
-
* @param {object} deps.targets per-session target store
|
|
142
|
-
* @param {object} deps.config resolved plugin config
|
|
143
|
-
* @param {() => object[]} deps.loadInventory
|
|
144
|
-
* @param {(entry: object) => void} [deps.recordActivity]
|
|
145
|
-
* @returns {(exec: object, next: () => Promise<object>) => Promise<object>}
|
|
146
|
-
*/
|
|
147
|
-
export function createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity } = {}) {
|
|
148
|
-
return async function remoteBashRouter(exec, next) {
|
|
149
|
-
if (!exec || exec.name !== BASH_TOOL_NAME) return next()
|
|
150
|
-
const session = exec.agent?.session
|
|
151
|
-
const key = session?.id ?? exec.agent?.id
|
|
152
|
-
const binding = typeof key === 'string' ? targets?.get(key) : undefined
|
|
153
|
-
if (!binding || !binding.active) {
|
|
154
|
-
// Several `@server` mentions clear the active target. Running bash locally
|
|
155
|
-
// in that state would execute commands meant for a server on the DSH host,
|
|
156
|
-
// so make the model choose a target first.
|
|
157
|
-
if (binding && Array.isArray(binding.candidates) && binding.candidates.length > 1) {
|
|
158
|
-
return createErrorResult(
|
|
159
|
-
`several servers were mentioned in this session (${binding.candidates.join(', ')}); ` +
|
|
160
|
-
'call ssh_workbench_target({ server: "<name>" }) before running bash, or server: "local" to run locally',
|
|
161
|
-
'TARGET_NOT_SELECTED',
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
return next()
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const args = exec.arguments ?? {}
|
|
168
|
-
const command = typeof args.command === 'string' ? args.command : ''
|
|
169
|
-
if (!command.trim()) return next()
|
|
170
|
-
|
|
171
|
-
let servers
|
|
172
|
-
try {
|
|
173
|
-
servers = loadInventory()
|
|
174
|
-
} catch (error) {
|
|
175
|
-
return createErrorResult(`cannot read the managed server inventory: ${describeError(error)}`, 'INVENTORY_UNAVAILABLE')
|
|
176
|
-
}
|
|
177
|
-
const server = findTarget(servers, binding.active)
|
|
178
|
-
if (server === undefined) {
|
|
179
|
-
return createErrorResult(
|
|
180
|
-
`target "${binding.active}" is no longer in the managed inventory; call ${'ssh_workbench_target'} to pick another target or "local"`,
|
|
181
|
-
'TARGET_NOT_FOUND',
|
|
182
|
-
)
|
|
183
|
-
}
|
|
184
|
-
if (server.enabled === false) {
|
|
185
|
-
return createErrorResult(
|
|
186
|
-
`target "${server.name}" is disabled; call ssh_workbench_target to pick another target or "local"`,
|
|
187
|
-
'TARGET_DISABLED',
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
if (effectiveDialect(server) !== POSIX_PLATFORM) {
|
|
191
|
-
return createErrorResult(
|
|
192
|
-
`target "${server.name}" is a Windows server; the native bash tool only runs POSIX commands — use remote_pwsh for Windows targets`,
|
|
193
|
-
'TARGET_PLATFORM_UNSUPPORTED',
|
|
194
|
-
)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const workdir = applyWorkdir(command, args.workdir)
|
|
198
|
-
if (workdir.error !== undefined) return createErrorResult(workdir.error, 'INVALID_WORKDIR')
|
|
199
|
-
|
|
200
|
-
// A detached command returns only remote facts that survive across
|
|
201
|
-
// transports: pid + log file. It is not registered in DSH's local jobs.
|
|
202
|
-
if (args.run_in_background === true) {
|
|
203
|
-
const startTimeout = Math.min(resolveTimeoutMs(args, config), 30000)
|
|
204
|
-
let start
|
|
205
|
-
try {
|
|
206
|
-
start = await runtime.exec(server, buildPosixBackgroundCommand(workdir.command), {
|
|
207
|
-
timeoutMs: startTimeout,
|
|
208
|
-
signal: exec.signal,
|
|
209
|
-
readOnly: false,
|
|
210
|
-
cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
|
|
211
|
-
stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
|
|
212
|
-
})
|
|
213
|
-
} catch (error) {
|
|
214
|
-
return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
|
|
215
|
-
}
|
|
216
|
-
try {
|
|
217
|
-
recordActivity?.({ kind: 'exec', server: server.name, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start) })
|
|
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
|
-
return createErrorResult(detail, start?.errorCode ?? 'SSH_EXEC_FAILED')
|
|
224
|
-
}
|
|
225
|
-
const started = parseBackgroundStart(start.stdout)
|
|
226
|
-
if (started.pid === undefined) {
|
|
227
|
-
const detail = String(start?.stderr ?? '').trim() || String(start?.stdout ?? '').trim()
|
|
228
|
-
return createErrorResult(`background start failed${detail ? `: ${detail}` : ''}`, 'REMOTE_BACKGROUND_FAILED')
|
|
229
|
-
}
|
|
230
|
-
const text = renderBackgroundStart({
|
|
231
|
-
pid: started.pid,
|
|
232
|
-
log: started.log,
|
|
233
|
-
errLog: started.errLog,
|
|
234
|
-
target: `${server.name} (${server.username}@${server.host}:${server.port})`,
|
|
235
|
-
command,
|
|
236
|
-
dialect: 'posix',
|
|
237
|
-
})
|
|
238
|
-
const synthetic = {
|
|
239
|
-
outcome: 'success',
|
|
240
|
-
exitCode: 0,
|
|
241
|
-
signal: null,
|
|
242
|
-
stdout: text,
|
|
243
|
-
stderr: '',
|
|
244
|
-
truncated: { stdout: false, stderr: false },
|
|
245
|
-
}
|
|
246
|
-
return { value: toBashValue(synthetic, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs: startTimeout }) }
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const timeoutMs = resolveTimeoutMs(args, config)
|
|
250
|
-
|
|
251
|
-
// Detect (and cache) whether the server can enforce a remote deadline, so
|
|
252
|
-
// a timed-out call also stops the remote work instead of leaving it behind.
|
|
253
|
-
let capability = { mode: 'none', shell: '/bin/sh' }
|
|
254
|
-
try {
|
|
255
|
-
if (typeof runtime.timeoutGuard === 'function') {
|
|
256
|
-
const probed = await runtime.timeoutGuard(server)
|
|
257
|
-
capability = typeof probed === 'string' ? { mode: probed } : (probed ?? capability)
|
|
258
|
-
}
|
|
259
|
-
} catch {
|
|
260
|
-
capability = { mode: 'none', shell: '/bin/sh' }
|
|
261
|
-
}
|
|
262
|
-
const remoteCommand = wrapRemoteTimeout(workdir.command, timeoutMs, capability, {
|
|
263
|
-
killAfterMs: config?.timeoutKillAfterMs,
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
let result
|
|
267
|
-
try {
|
|
268
|
-
result = await runtime.exec(server, remoteCommand, {
|
|
269
|
-
timeoutMs,
|
|
270
|
-
signal: exec.signal,
|
|
271
|
-
readOnly: false,
|
|
272
|
-
cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
|
|
273
|
-
stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
|
|
274
|
-
})
|
|
275
|
-
} catch (error) {
|
|
276
|
-
return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
try {
|
|
280
|
-
recordActivity?.({
|
|
281
|
-
kind: 'exec',
|
|
282
|
-
server: server.name,
|
|
283
|
-
summary: command.slice(0, 200),
|
|
284
|
-
outcome: describeResultOutcome(result),
|
|
285
|
-
})
|
|
286
|
-
} catch {
|
|
287
|
-
// Activity recording is best effort.
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (isTransportFailure(result)) {
|
|
291
|
-
const code = typeof result?.errorCode === 'string' && result.errorCode ? result.errorCode : 'SSH_EXEC_FAILED'
|
|
292
|
-
const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
|
|
293
|
-
const suggestion = typeof result?.suggestedAction === 'string' && result.suggestedAction ? `\n${result.suggestedAction}` : ''
|
|
294
|
-
return createErrorResult(`${detail}${suggestion}`, code)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// The cwd label is also what the native Bash card shows in its expanded
|
|
298
|
-
// prompt; prefixing the target makes a card self-describing in multi-target
|
|
299
|
-
// sessions without shadowing DSH's shipped Bash renderer.
|
|
300
|
-
let value = toBashValue(result, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs })
|
|
301
|
-
const notices = []
|
|
302
|
-
// One line after each target change: a card-level "where did this run" hint.
|
|
303
|
-
if (binding.notifiedVersion !== binding.version && typeof targets?.markNotified === 'function') {
|
|
304
|
-
notices.push(`[remote] target: ${server.name} (${server.username}@${server.host}:${server.port})`)
|
|
305
|
-
targets.markNotified(key, binding.version)
|
|
306
|
-
}
|
|
307
|
-
if (result?.outcome === 'timeout') {
|
|
308
|
-
notices.push(
|
|
309
|
-
capability.mode === 'none'
|
|
310
|
-
? '[remote] client-side timeout only: this server has no usable `timeout` utility, so the remote process may still be running.'
|
|
311
|
-
: `[remote] timeout: the remote command group was terminated (SIGTERM${capability.mode === 'kill' ? ', KILL after the remote deadline' : ''}).`,
|
|
312
|
-
)
|
|
313
|
-
}
|
|
314
|
-
if (result?.outcome === 'cancelled') {
|
|
315
|
-
notices.push(
|
|
316
|
-
capability.mode === 'none'
|
|
317
|
-
? '[remote] cancelled: the SSH channel was closed, but this server has no usable `timeout` utility, so the remote process may still be running.'
|
|
318
|
-
: '[remote] cancelled: the SSH channel was closed; the remote wrapper terminates the command at its deadline if it is still running.',
|
|
319
|
-
)
|
|
320
|
-
}
|
|
321
|
-
if (args.sandbox_permissions !== undefined || args.justification !== undefined) {
|
|
322
|
-
notices.push('[remote] sandbox_permissions does not apply to managed servers; the command ran over SSH.')
|
|
323
|
-
}
|
|
324
|
-
if (notices.length > 0) {
|
|
325
|
-
const text = value.stderr.text
|
|
326
|
-
value = {
|
|
327
|
-
...value,
|
|
328
|
-
stderr: { ...value.stderr, text: `${text}${text ? '\n' : ''}${notices.join('\n')}` },
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return { value }
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function describeError(error) {
|
|
336
|
-
return error instanceof Error ? error.message : String(error)
|
|
337
|
-
}
|
|
@@ -1,299 +0,0 @@
|
|
|
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
|
-
}
|