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
package/lib/index.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ssh-workbench host half.
|
|
3
|
+
*
|
|
4
|
+
* Local and remote execution are two separate worlds. The native shell tools
|
|
5
|
+
* always run on the local DSH Host; remote work always names its target on the
|
|
6
|
+
* tool call (`remote_bash`, `remote_pwsh`, `remote_exec`). There is no
|
|
7
|
+
* session-scoped target state and no native shell interception, so a follow-up
|
|
8
|
+
* without a new `@mention` can never move a command silently to a different
|
|
9
|
+
* machine. A synchronous `ctx.tools.guard()` blacklist is the fallback safety
|
|
10
|
+
* net for every explicit remote target.
|
|
11
|
+
*/
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import z from '@deepseek-ai/schemastery'
|
|
15
|
+
|
|
16
|
+
import { loadServers, saveServers } from './core/store.js'
|
|
17
|
+
import { loadPrefs, savePrefs } from './core/prefs.js'
|
|
18
|
+
import { createTerminalHub } from './core/terminals.js'
|
|
19
|
+
import { createLocalFs } from './core/localfs.js'
|
|
20
|
+
import { createSecretStore } from './core/secrets.js'
|
|
21
|
+
import { createKnownHosts } from './core/known-hosts.js'
|
|
22
|
+
import { createSshRuntime } from './core/ssh.js'
|
|
23
|
+
import { createActivityStore } from './core/activity.js'
|
|
24
|
+
import { createTargetStore } from './core/mentions.js'
|
|
25
|
+
import { createBlacklistGuard } from './core/guard.js'
|
|
26
|
+
import { createRemoteShellTool } from './core/remote-shell-tool.js'
|
|
27
|
+
import { createRemoteExecTool } from './core/remote-exec-tool.js'
|
|
28
|
+
import { createRemoteFileTools } from './core/remote-file-tools.js'
|
|
29
|
+
import { createLiveExecutions } from './core/live-executions.js'
|
|
30
|
+
import { posixAdapter, win32Adapter, hostPlatform } from './core/shell-adapters.js'
|
|
31
|
+
import { installManagedContext } from './core/managed-context.js'
|
|
32
|
+
import { createRoutes, registerRoutes } from './routes.js'
|
|
33
|
+
|
|
34
|
+
export const name = 'dsh-ssh-workbench'
|
|
35
|
+
|
|
36
|
+
/** Hard dependencies only; optional services are resolved lazily. */
|
|
37
|
+
export const inject = ['tools']
|
|
38
|
+
|
|
39
|
+
export const Config = z.object({
|
|
40
|
+
connectTimeoutMs: z.natural().default(15000),
|
|
41
|
+
// Default timeout for one remote bash command. The model may override it per
|
|
42
|
+
// call through bash's own `timeoutMs` parameter, up to maxCommandTimeoutMs.
|
|
43
|
+
commandTimeoutMs: z.natural().default(120000),
|
|
44
|
+
maxCommandTimeoutMs: z.natural().default(1800000),
|
|
45
|
+
// How long the remote `timeout -k` wrapper waits after SIGTERM before
|
|
46
|
+
// SIGKILL. 0 disables the hard kill (TERM only).
|
|
47
|
+
timeoutKillAfterMs: z.natural().default(5000),
|
|
48
|
+
maxOutputBytes: z.natural().default(102400),
|
|
49
|
+
// stderr is capped separately so one chatty failing command cannot flood the
|
|
50
|
+
// model context (stdout keeps maxOutputBytes).
|
|
51
|
+
maxStderrBytes: z.natural().default(16384),
|
|
52
|
+
// `remote_exec` fan-out: tasks per call, concurrent SSH channels, and the
|
|
53
|
+
// total stdout/stderr budget for one batch (shared across all tasks).
|
|
54
|
+
maxBatchTasks: z.natural().default(8),
|
|
55
|
+
batchConcurrency: z.natural().default(4),
|
|
56
|
+
maxBatchOutputBytes: z.natural().default(262144),
|
|
57
|
+
// Explicit remote file tools: read/glob/grep are always available; write and
|
|
58
|
+
// edit stay disabled until an operator opts in. All sizes are bytes.
|
|
59
|
+
remoteFileReadBytes: z.natural().default(262144),
|
|
60
|
+
remoteFileEditBytes: z.natural().default(2097152),
|
|
61
|
+
remoteFileWrite: z.boolean().default(false),
|
|
62
|
+
remoteGlobMaxResults: z.natural().default(200),
|
|
63
|
+
remoteGlobMaxDepth: z.natural().default(4),
|
|
64
|
+
remoteGrepMaxResults: z.natural().default(200),
|
|
65
|
+
// A stalled SFTP chunk (half-open connection) fails after this long instead
|
|
66
|
+
// of hanging the file panel; the client can resume from its offset.
|
|
67
|
+
transferChunkTimeoutMs: z.natural().default(30000),
|
|
68
|
+
hostKeyMode: z.string().default('strict'),
|
|
69
|
+
connectionPool: z.boolean().default(true),
|
|
70
|
+
poolIdleMs: z.natural().default(90000),
|
|
71
|
+
poolMaxServers: z.natural().default(8),
|
|
72
|
+
maxChannelsPerServer: z.natural().default(6),
|
|
73
|
+
// Warm a server connection when the user selects it in the UI.
|
|
74
|
+
prewarm: z.boolean().default(true),
|
|
75
|
+
// Inject the managed-resource context into every human turn. Disable only
|
|
76
|
+
// for a deliberately remote-blind session.
|
|
77
|
+
targetContext: z.boolean().default(true),
|
|
78
|
+
// Root of the workbench's local side (local terminal cwd + local file
|
|
79
|
+
// panel). Empty = the OS user home directory, matching the local pane of
|
|
80
|
+
// regular SSH clients. Set an absolute path (or `~/...`) to pin another
|
|
81
|
+
// directory; it is still confined to that root by localfs.
|
|
82
|
+
localRoot: z.string().default(''),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const APPLY_DEFAULTS = {
|
|
86
|
+
connectTimeoutMs: 15000,
|
|
87
|
+
commandTimeoutMs: 120000,
|
|
88
|
+
maxCommandTimeoutMs: 1800000,
|
|
89
|
+
timeoutKillAfterMs: 5000,
|
|
90
|
+
maxOutputBytes: 102400,
|
|
91
|
+
maxStderrBytes: 16384,
|
|
92
|
+
maxBatchTasks: 8,
|
|
93
|
+
batchConcurrency: 4,
|
|
94
|
+
maxBatchOutputBytes: 262144,
|
|
95
|
+
remoteFileReadBytes: 262144,
|
|
96
|
+
remoteFileEditBytes: 2097152,
|
|
97
|
+
remoteFileWrite: false,
|
|
98
|
+
remoteGlobMaxResults: 200,
|
|
99
|
+
remoteGlobMaxDepth: 4,
|
|
100
|
+
remoteGrepMaxResults: 200,
|
|
101
|
+
transferChunkTimeoutMs: 30000,
|
|
102
|
+
hostKeyMode: 'strict',
|
|
103
|
+
connectionPool: true,
|
|
104
|
+
poolIdleMs: 90000,
|
|
105
|
+
poolMaxServers: 8,
|
|
106
|
+
maxChannelsPerServer: 6,
|
|
107
|
+
prewarm: true,
|
|
108
|
+
targetContext: true,
|
|
109
|
+
localRoot: '',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const textOut = {
|
|
113
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
114
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { APPLY_DEFAULTS }
|
|
118
|
+
|
|
119
|
+
/** Public settings safe to show in the Client settings/state page. */
|
|
120
|
+
export function publicConfig(config) {
|
|
121
|
+
return {
|
|
122
|
+
hostKeyMode: config.hostKeyMode,
|
|
123
|
+
connectTimeoutMs: config.connectTimeoutMs,
|
|
124
|
+
commandTimeoutMs: config.commandTimeoutMs,
|
|
125
|
+
maxCommandTimeoutMs: config.maxCommandTimeoutMs,
|
|
126
|
+
timeoutKillAfterMs: config.timeoutKillAfterMs,
|
|
127
|
+
maxOutputBytes: config.maxOutputBytes,
|
|
128
|
+
maxStderrBytes: config.maxStderrBytes,
|
|
129
|
+
maxBatchTasks: config.maxBatchTasks,
|
|
130
|
+
batchConcurrency: config.batchConcurrency,
|
|
131
|
+
maxBatchOutputBytes: config.maxBatchOutputBytes,
|
|
132
|
+
remoteFileReadBytes: config.remoteFileReadBytes,
|
|
133
|
+
remoteFileEditBytes: config.remoteFileEditBytes,
|
|
134
|
+
remoteFileWrite: config.remoteFileWrite === true,
|
|
135
|
+
remoteGlobMaxResults: config.remoteGlobMaxResults,
|
|
136
|
+
remoteGlobMaxDepth: config.remoteGlobMaxDepth,
|
|
137
|
+
remoteGrepMaxResults: config.remoteGrepMaxResults,
|
|
138
|
+
transferChunkTimeoutMs: config.transferChunkTimeoutMs,
|
|
139
|
+
connectionPool: config.connectionPool !== false,
|
|
140
|
+
poolIdleMs: config.poolIdleMs,
|
|
141
|
+
prewarm: config.prewarm !== false,
|
|
142
|
+
targetContext: config.targetContext !== false,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Expand a configured `~` prefix; relative/invalid values are left for the caller. */
|
|
147
|
+
function expandLocalRoot(value) {
|
|
148
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
149
|
+
if (!raw) return ''
|
|
150
|
+
return raw === '~'
|
|
151
|
+
? os.homedir()
|
|
152
|
+
: raw.startsWith('~/') || raw.startsWith('~' + path.sep)
|
|
153
|
+
? path.join(os.homedir(), raw.slice(2))
|
|
154
|
+
: raw
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Workbench local root: configured absolute path (or `~/...`), else user home. */
|
|
158
|
+
export function resolveLocalRoot(config = {}) {
|
|
159
|
+
const expanded = expandLocalRoot(config?.localRoot)
|
|
160
|
+
if (!expanded || !path.isAbsolute(expanded)) return os.homedir()
|
|
161
|
+
return path.normalize(expanded)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {object} ctx Cordis plugin context
|
|
166
|
+
* @param {object} [config] plugin Config
|
|
167
|
+
* @param {{
|
|
168
|
+
* runtime?: object,
|
|
169
|
+
* secrets?: object,
|
|
170
|
+
* knownHosts?: object,
|
|
171
|
+
* targets?: object,
|
|
172
|
+
* loadInventory?: () => object[],
|
|
173
|
+
* createUserMessage?: Function,
|
|
174
|
+
* hostPlatform?: 'win32'|'posix',
|
|
175
|
+
* }} [deps] explicit seams used by tests
|
|
176
|
+
*/
|
|
177
|
+
export function apply(ctx, config, deps = {}) {
|
|
178
|
+
config = { ...APPLY_DEFAULTS, ...(config ?? {}) }
|
|
179
|
+
// The native shell tool differs per Host, but remote tools are explicit:
|
|
180
|
+
// both dialect tools are registered on both Hosts.
|
|
181
|
+
const hostOs = hostPlatform(deps.hostPlatform ?? process.platform)
|
|
182
|
+
const isWindowsHost = hostOs === 'win32'
|
|
183
|
+
const localShellName = isWindowsHost ? 'pwsh' : 'bash'
|
|
184
|
+
let remoteAvailable = false
|
|
185
|
+
if (config.hostKeyMode !== 'strict' && config.hostKeyMode !== 'accept-new') {
|
|
186
|
+
ctx.logger?.warn?.('dsh-ssh-workbench: unknown hostKeyMode value, falling back to strict', { value: config.hostKeyMode })
|
|
187
|
+
config.hostKeyMode = 'strict'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const loadInventory = deps.loadInventory ?? (() => loadServers())
|
|
191
|
+
const secrets = deps.secrets ?? createSecretStore()
|
|
192
|
+
const knownHosts = deps.knownHosts ?? createKnownHosts()
|
|
193
|
+
const ownedRuntime = deps.runtime === undefined
|
|
194
|
+
const runtime = deps.runtime ?? createSshRuntime({
|
|
195
|
+
getSecret: (key) => secrets.get(key),
|
|
196
|
+
knownHosts,
|
|
197
|
+
config,
|
|
198
|
+
log: (message, fields) => ctx.logger?.debug?.(message, fields),
|
|
199
|
+
})
|
|
200
|
+
if (ownedRuntime && typeof runtime.preload === 'function') {
|
|
201
|
+
// Warm the optional native binding at load instead of paying it on the
|
|
202
|
+
// first remote call.
|
|
203
|
+
void runtime.preload().catch(() => {})
|
|
204
|
+
}
|
|
205
|
+
if (ownedRuntime && typeof ctx.effect === 'function') {
|
|
206
|
+
ctx.effect(() => () => {
|
|
207
|
+
void runtime.close?.()
|
|
208
|
+
}, 'dsh-ssh-workbench: pooled ssh connections')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Bounded local activity log for the overview page (not an audit log).
|
|
212
|
+
const activity = deps.activity ?? createActivityStore()
|
|
213
|
+
const recordActivity = (entry) => {
|
|
214
|
+
try {
|
|
215
|
+
activity.append(entry)
|
|
216
|
+
} catch {
|
|
217
|
+
// Activity recording is best effort and must never fail an operation.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const targets = deps.targets ?? createTargetStore({ loadInventory })
|
|
222
|
+
const liveExecutions = deps.liveExecutions ?? createLiveExecutions()
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Collect a host's facts once and persist them on the server record.
|
|
226
|
+
* Used by connection test / explicit refresh / target selection: the UI and
|
|
227
|
+
* the injected target context can then show hostname and disk without a new
|
|
228
|
+
* SSH round trip. Never throws (facts are optional metadata).
|
|
229
|
+
*/
|
|
230
|
+
const refreshServerFacts = async (server) => {
|
|
231
|
+
if (!server || typeof runtime.facts !== 'function') return undefined
|
|
232
|
+
try {
|
|
233
|
+
const result = await runtime.facts(server, { refresh: true })
|
|
234
|
+
const facts = result?.facts
|
|
235
|
+
if (!facts || typeof facts !== 'object') return undefined
|
|
236
|
+
const merged = {
|
|
237
|
+
...(server.facts && typeof server.facts === 'object' ? server.facts : {}),
|
|
238
|
+
...facts,
|
|
239
|
+
collectedAt: result.collectedAt ?? new Date().toISOString(),
|
|
240
|
+
}
|
|
241
|
+
const inventory = loadInventory()
|
|
242
|
+
if (!inventory.some((entry) => entry.id === server.id)) return merged
|
|
243
|
+
saveServers(inventory.map((entry) => (entry.id === server.id ? { ...entry, facts: merged } : entry)))
|
|
244
|
+
return merged
|
|
245
|
+
} catch {
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── model-facing tools: explicit-target remote shells + fan-out ────────────
|
|
251
|
+
if (typeof ctx.tools?.register === 'function') {
|
|
252
|
+
const registerRemote = (definition, label) => {
|
|
253
|
+
try {
|
|
254
|
+
const off = ctx.tools.register(definition)
|
|
255
|
+
if (typeof off === 'function' && typeof ctx.effect === 'function') {
|
|
256
|
+
ctx.effect(() => off, `dsh-ssh-workbench: ${label}`)
|
|
257
|
+
}
|
|
258
|
+
return true
|
|
259
|
+
} catch (error) {
|
|
260
|
+
ctx.logger?.warn?.(
|
|
261
|
+
`dsh-ssh-workbench: ${definition?.name ?? label} could not be registered (${error instanceof Error ? error.message : String(error)}); remote execution for that dialect is unavailable`,
|
|
262
|
+
)
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
remoteAvailable =
|
|
268
|
+
registerRemote(
|
|
269
|
+
createRemoteExecTool({
|
|
270
|
+
runtime,
|
|
271
|
+
config,
|
|
272
|
+
loadInventory,
|
|
273
|
+
recordActivity,
|
|
274
|
+
}),
|
|
275
|
+
'remote exec tool',
|
|
276
|
+
) || remoteAvailable
|
|
277
|
+
|
|
278
|
+
remoteAvailable =
|
|
279
|
+
registerRemote(
|
|
280
|
+
createRemoteShellTool({
|
|
281
|
+
runtime,
|
|
282
|
+
config,
|
|
283
|
+
loadInventory,
|
|
284
|
+
recordActivity,
|
|
285
|
+
adapter: posixAdapter,
|
|
286
|
+
localShellName,
|
|
287
|
+
}),
|
|
288
|
+
'remote bash tool',
|
|
289
|
+
) || remoteAvailable
|
|
290
|
+
|
|
291
|
+
remoteAvailable =
|
|
292
|
+
registerRemote(
|
|
293
|
+
createRemoteShellTool({
|
|
294
|
+
runtime,
|
|
295
|
+
config,
|
|
296
|
+
loadInventory,
|
|
297
|
+
recordActivity,
|
|
298
|
+
adapter: win32Adapter,
|
|
299
|
+
localShellName,
|
|
300
|
+
}),
|
|
301
|
+
'remote pwsh tool',
|
|
302
|
+
) || remoteAvailable
|
|
303
|
+
|
|
304
|
+
for (const tool of createRemoteFileTools({ runtime, config, loadInventory, recordActivity })) {
|
|
305
|
+
remoteAvailable = registerRemote(tool, `remote file tool: ${tool.name}`) || remoteAvailable
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── live execution indicator: display-only, never selects a target ─────────
|
|
310
|
+
if (remoteAvailable) {
|
|
311
|
+
const offLive = ctx.on?.('tools/execute', async (exec, next) => {
|
|
312
|
+
const token = liveExecutions.begin(exec)
|
|
313
|
+
try {
|
|
314
|
+
return await next()
|
|
315
|
+
} finally {
|
|
316
|
+
liveExecutions.end(token)
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
if (typeof offLive === 'function' && typeof ctx.effect === 'function') {
|
|
320
|
+
ctx.effect(() => offLive, 'dsh-ssh-workbench: live execution indicator')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── fallback blacklist: deny-only, synchronous, monotonic ──────────────────
|
|
325
|
+
if (typeof ctx.tools?.guard === 'function') {
|
|
326
|
+
const offGuard = ctx.tools.guard(createBlacklistGuard({ recordActivity, loadInventory }))
|
|
327
|
+
if (typeof offGuard === 'function' && typeof ctx.effect === 'function') {
|
|
328
|
+
ctx.effect(() => offGuard, 'dsh-ssh-workbench: blacklist guard')
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
ctx.logger?.warn?.('dsh-ssh-workbench: ctx.tools.guard() is unavailable; the destructive-command blacklist is disabled')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── managed-resource context: one compact inventory + tool rules per turn ──
|
|
335
|
+
// The context is only injected when this Host really has a remote execution
|
|
336
|
+
// path; otherwise it would tell the model that a tool runs remotely when no
|
|
337
|
+
// such tool exists.
|
|
338
|
+
const loadLastExecuted = (sessionId) => {
|
|
339
|
+
try {
|
|
340
|
+
const entries = activity.list(200)
|
|
341
|
+
const last = entries.find((entry) => entry?.kind === 'exec' && entry.sessionId === sessionId)
|
|
342
|
+
return typeof last?.server === 'string' && last.server.length > 0 ? last.server : null
|
|
343
|
+
} catch {
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (config.targetContext !== false) {
|
|
348
|
+
installManagedContext(ctx, {
|
|
349
|
+
targets,
|
|
350
|
+
loadInventory,
|
|
351
|
+
loadLastExecuted,
|
|
352
|
+
createUserMessage: deps.createUserMessage,
|
|
353
|
+
remoteAvailable,
|
|
354
|
+
hostPlatform: hostOs,
|
|
355
|
+
maxContextServers: 20,
|
|
356
|
+
remoteFileWrite: config.remoteFileWrite === true,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── UI data plane: inventory, credentials, connection test, SFTP panel ─────
|
|
361
|
+
// Local workbench services: the terminal hub needs the Host PTY provider,
|
|
362
|
+
// and the local file browser / terminal cwd share one root. The default is
|
|
363
|
+
// the OS user home (what users expect from the local pane of an SSH client);
|
|
364
|
+
// `localRoot` can pin another directory. All access stays confined by localfs.
|
|
365
|
+
let subprocessService = null
|
|
366
|
+
if (typeof ctx.inject === 'function') {
|
|
367
|
+
ctx.inject(['subprocess'], (inner) => {
|
|
368
|
+
subprocessService = inner.get('subprocess') ?? null
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
const expandedLocalRoot = expandLocalRoot(config.localRoot)
|
|
372
|
+
if (expandedLocalRoot && !path.isAbsolute(expandedLocalRoot)) {
|
|
373
|
+
ctx.logger?.warn?.('dsh-ssh-workbench: localRoot is not an absolute path, falling back to the user home directory', {
|
|
374
|
+
localRoot: config.localRoot,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
const localRoot = () => resolveLocalRoot(config)
|
|
378
|
+
const terminals = deps.terminals ?? createTerminalHub({
|
|
379
|
+
runtime,
|
|
380
|
+
getSubprocess: () => subprocessService,
|
|
381
|
+
getLocalCwd: localRoot,
|
|
382
|
+
})
|
|
383
|
+
const localfs = deps.localfs ?? createLocalFs({ getRoot: localRoot })
|
|
384
|
+
if (typeof ctx.effect === 'function') {
|
|
385
|
+
ctx.effect(() => () => terminals.dispose(), 'dsh-ssh-workbench: terminals')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const disposeRoutes = registerRoutes(
|
|
389
|
+
ctx,
|
|
390
|
+
createRoutes({
|
|
391
|
+
loadServers: () => loadInventory(),
|
|
392
|
+
saveServers: (servers) => saveServers(servers),
|
|
393
|
+
runtime,
|
|
394
|
+
secrets,
|
|
395
|
+
knownHosts,
|
|
396
|
+
targets,
|
|
397
|
+
liveExecutions,
|
|
398
|
+
refreshFacts: (server) => refreshServerFacts(server),
|
|
399
|
+
activity,
|
|
400
|
+
prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
|
|
401
|
+
terminals,
|
|
402
|
+
localfs,
|
|
403
|
+
publicConfig: () => publicConfig(config),
|
|
404
|
+
}),
|
|
405
|
+
)
|
|
406
|
+
if (typeof ctx.effect === 'function') {
|
|
407
|
+
ctx.effect(() => disposeRoutes, 'dsh-ssh-workbench: routes')
|
|
408
|
+
}
|
|
409
|
+
}
|