dsh-ssh-workbench 0.0.0-stage → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +178 -2
  5. package/SECURITY.md +27 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +146 -0
  8. package/docs/execution-model.md +83 -0
  9. package/docs/images/chat-at-resource.png +0 -0
  10. package/docs/images/plugin.png +0 -0
  11. package/docs/images/remote-workbench.png +0 -0
  12. package/docs/images/workbench.png +0 -0
  13. package/docs/quickstart.md +106 -0
  14. package/lib/client.js +4991 -0
  15. package/lib/core/activity.js +88 -0
  16. package/lib/core/background.js +69 -0
  17. package/lib/core/broker.js +802 -0
  18. package/lib/core/dialect.js +28 -0
  19. package/lib/core/errors.js +138 -0
  20. package/lib/core/execution-stats.js +88 -0
  21. package/lib/core/gate-pwsh.js +88 -0
  22. package/lib/core/gate.js +572 -0
  23. package/lib/core/guard.js +123 -0
  24. package/lib/core/known-hosts.js +74 -0
  25. package/lib/core/live-executions.js +101 -0
  26. package/lib/core/local-file-api.js +174 -0
  27. package/lib/core/localfs.js +381 -0
  28. package/lib/core/managed-context.js +276 -0
  29. package/lib/core/mentions.js +216 -0
  30. package/lib/core/paths.js +104 -0
  31. package/lib/core/posix-quote.js +4 -0
  32. package/lib/core/posix-shell.js +89 -0
  33. package/lib/core/prefs.js +38 -0
  34. package/lib/core/remote-exec-tool.js +394 -0
  35. package/lib/core/remote-file-tools.js +559 -0
  36. package/lib/core/remote-shell-tool.js +310 -0
  37. package/lib/core/secrets.js +95 -0
  38. package/lib/core/server-ref.js +61 -0
  39. package/lib/core/shell-adapters.js +48 -0
  40. package/lib/core/ssh.js +761 -0
  41. package/lib/core/store.js +83 -0
  42. package/lib/core/terminals.js +299 -0
  43. package/lib/core/win32-adapter.js +141 -0
  44. package/lib/index.js +409 -0
  45. package/lib/routes.js +759 -0
  46. package/locale/en.json +6 -0
  47. package/locale/zh.json +6 -0
  48. package/package.json +89 -4
  49. package/vendor/NOTICE.md +50 -0
  50. package/vendor/XTERM-LICENSE +21 -0
  51. package/vendor/node_modules/asn1/LICENSE +19 -0
  52. package/vendor/node_modules/asn1/README.md +50 -0
  53. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  54. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  55. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  56. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  57. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  58. package/vendor/node_modules/asn1/lib/index.js +20 -0
  59. package/vendor/node_modules/asn1/package.json +31 -0
  60. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  61. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  62. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  63. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  64. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  65. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  66. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  67. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  68. package/vendor/node_modules/safer-buffer/package.json +34 -0
  69. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  70. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  71. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  72. package/vendor/node_modules/tweetnacl/README.md +459 -0
  73. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  74. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  75. package/vendor/node_modules/tweetnacl/package.json +58 -0
  76. package/vendor/ssh2/LICENSE +19 -0
  77. package/vendor/ssh2/lib/Channel.js +295 -0
  78. package/vendor/ssh2/lib/agent.js +1123 -0
  79. package/vendor/ssh2/lib/client.js +2176 -0
  80. package/vendor/ssh2/lib/http-agents.js +84 -0
  81. package/vendor/ssh2/lib/index.js +44 -0
  82. package/vendor/ssh2/lib/keygen.js +582 -0
  83. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  84. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  85. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  86. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  87. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  88. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  89. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  90. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  91. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  92. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  93. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  94. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  95. package/vendor/ssh2/lib/server.js +1380 -0
  96. package/vendor/ssh2/lib/utils.js +336 -0
  97. package/vendor/ssh2/package.json +34 -0
  98. package/vendor/xterm.js +53 -0
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
+ }