dsh-ssh-workbench 0.0.0-stage → 1.6.4

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 (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Windows (PowerShell) shell adapter.
3
+ *
4
+ * Windows targets expose an SSH server (Microsoft OpenSSH Server) whose login
5
+ * shell is either `cmd.exe` or PowerShell. A command is therefore not sent as
6
+ * a raw string: it is wrapped into a small PowerShell script, encoded as
7
+ * UTF-16LE base64 and executed with `-EncodedCommand`, which removes every
8
+ * quoting/escaping failure mode and works no matter which default shell sshd
9
+ * picked.
10
+ *
11
+ * The wrapper also forces UTF-8 console output (Windows defaults to an OEM
12
+ * code page or UTF-16, which would arrive at ssh2 as mojibake) and propagates
13
+ * the real exit code (`-EncodedCommand` alone exits 0 for a failed cmdlet).
14
+ */
15
+ import { BG_ERR_MARKER, BG_LOG_MARKER, BG_PID_MARKER } from './background.js'
16
+ import { normalizeDialect } from './dialect.js'
17
+ import { resolveTimeoutMs } from './remote-bash.js'
18
+
19
+ const POWERSHELL_UTF8 = [
20
+ "$ErrorActionPreference = 'Continue'",
21
+ '$__sdhUtf8 = New-Object System.Text.UTF8Encoding $false',
22
+ '[Console]::OutputEncoding = $__sdhUtf8',
23
+ '$OutputEncoding = $__sdhUtf8',
24
+ ].join('; ')
25
+
26
+ /**
27
+ * Deterministic exit status for a PowerShell script:
28
+ * - a failed statement without a native exit code → 1;
29
+ * - a failed/native command with an exit code → that code;
30
+ * - everything else → 0.
31
+ */
32
+ const EXIT_SUFFIX = [
33
+ 'if (-not $?) { if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) { exit [int]$LASTEXITCODE }; exit 1 }',
34
+ 'if ($LASTEXITCODE -ne $null) { exit [int]$LASTEXITCODE }',
35
+ 'exit 0',
36
+ ].join('; ')
37
+
38
+ /** PowerShell single-quoted literal. */
39
+ export function quotePowerShell(value) {
40
+ return `'${String(value).replace(/'/g, "''")}'`
41
+ }
42
+
43
+ /**
44
+ * Classify one model-supplied workdir.
45
+ * @returns {{ kind: 'none'|'home'|'absolute'|'relative', path?: string, label: string } | { error: string }}
46
+ */
47
+ export function normalizeWindowsWorkdir(workdir) {
48
+ const raw = typeof workdir === 'string' ? workdir.trim() : ''
49
+ if (!raw) return { kind: 'none', label: '~' }
50
+ if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
51
+ if (raw === '~') return { kind: 'home', label: '~' }
52
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) {
53
+ const rest = raw.slice(2)
54
+ if (!rest) return { kind: 'home', label: '~' }
55
+ return { kind: 'relative', path: rest, label: `~/${rest.replace(/\\/g, '/')}` }
56
+ }
57
+ // Drive path, UNC path, or a root-relative POSIX-style path (Windows treats
58
+ // `/foo` as the current drive root).
59
+ if (/^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('\\\\') || raw.startsWith('/')) {
60
+ return { kind: 'absolute', path: raw, label: raw }
61
+ }
62
+ return { kind: 'relative', path: raw, label: `~/${raw.replace(/\\/g, '/')}` }
63
+ }
64
+
65
+ function locationScript(dir) {
66
+ if (dir.kind === 'none') return ''
67
+ const literal =
68
+ dir.kind === 'home'
69
+ ? '$HOME'
70
+ : dir.kind === 'relative'
71
+ ? `(Join-Path $HOME ${quotePowerShell(dir.path)})`
72
+ : quotePowerShell(dir.path)
73
+ return `try { Set-Location -LiteralPath ${literal} -ErrorAction Stop } catch { Write-Error ('cannot change directory: ' + $_.Exception.Message); exit 2 }`
74
+ }
75
+
76
+ /** The PowerShell executable to invoke on the target. */
77
+ function powershellExe(server) {
78
+ const shell = String(server?.defaultShell ?? '')
79
+ return /pwsh/i.test(shell) ? 'pwsh.exe' : 'powershell.exe'
80
+ }
81
+
82
+ /** Wrap a PowerShell script into one SSH command line. */
83
+ export function buildPowerShellInvocation(script, server) {
84
+ const encoded = Buffer.from(String(script), 'utf16le').toString('base64')
85
+ return `${powershellExe(server)} -NoProfile -NonInteractive -EncodedCommand ${encoded}`
86
+ }
87
+
88
+ export const win32Adapter = {
89
+ id: 'win32',
90
+ platform: 'windows',
91
+ toolName: 'remote_pwsh',
92
+ label: 'Windows/PowerShell',
93
+ // No POSIX `timeout` utility on Windows: the deadline is client-side only.
94
+ remoteTimeout: false,
95
+ supportsDialect: (dialect) => normalizeDialect(dialect) === 'windows',
96
+ unsupportedMessage: (server) =>
97
+ `target "${server?.name ?? 'unknown'}" is a POSIX server; remote_pwsh runs PowerShell on Windows targets — use remote_bash for POSIX targets`,
98
+ buildCommand(command, workdir, server) {
99
+ const dir = normalizeWindowsWorkdir(workdir)
100
+ if (dir.error !== undefined) return dir
101
+ const script = [POWERSHELL_UTF8, locationScript(dir), String(command ?? ''), EXIT_SUFFIX]
102
+ .filter((part) => typeof part === 'string' && part.length > 0)
103
+ .join('\n')
104
+ return { command: buildPowerShellInvocation(script, server), cwd: dir.label }
105
+ },
106
+ /**
107
+ * Start a detached PowerShell script and report its pid plus log files.
108
+ *
109
+ * The inner script (the same one a foreground call would run) is launched by
110
+ * `Start-Process`, which detaches it from the SSH channel; stdout/stderr are
111
+ * redirected into files the model can read with `Get-Content -Wait`.
112
+ */
113
+ buildBackgroundCommand(command, workdir, server) {
114
+ const dir = normalizeWindowsWorkdir(workdir)
115
+ if (dir.error !== undefined) return dir
116
+ const inner = [POWERSHELL_UTF8, locationScript(dir), String(command ?? ''), EXIT_SUFFIX]
117
+ .filter((part) => typeof part === 'string' && part.length > 0)
118
+ .join('\n')
119
+ const innerEncoded = Buffer.from(inner, 'utf16le').toString('base64')
120
+ const exe = quotePowerShell(powershellExe(server))
121
+ const outer = [
122
+ '$__sdhLog = Join-Path $env:TEMP ("dsh-ssh-workbench-bg-" + [Guid]::NewGuid().ToString("N") + ".log")',
123
+ '$__sdhErr = "$__sdhLog.err"',
124
+ `$__sdhExe = ${exe}`,
125
+ `$__sdhArgs = @('-NoProfile','-NonInteractive','-EncodedCommand','${innerEncoded}')`,
126
+ '$__sdhProc = Start-Process -FilePath $__sdhExe -ArgumentList $__sdhArgs -RedirectStandardOutput $__sdhLog -RedirectStandardError $__sdhErr -WindowStyle Hidden -PassThru',
127
+ `Write-Output "${BG_PID_MARKER}$($__sdhProc.Id)"`,
128
+ `Write-Output "${BG_LOG_MARKER}$__sdhLog"`,
129
+ `Write-Output "${BG_ERR_MARKER}$__sdhErr"`,
130
+ ].join('\n')
131
+ return { command: buildPowerShellInvocation(outer, server), cwd: dir.label }
132
+ },
133
+ // The scripts already carry their own exit code handling; there is no
134
+ // remote deadline utility to wrap around them.
135
+ wrapTimeout: (command) => String(command ?? ''),
136
+ resolveTimeoutMs,
137
+ timeoutNotice: () =>
138
+ '[remote] client-side timeout only: this Windows target has no POSIX `timeout` wrapper, so the PowerShell process may still be running.',
139
+ cancelNotice: () =>
140
+ '[remote] cancelled: the SSH channel was closed, but the PowerShell process may still be running.',
141
+ }
package/lib/index.js ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * dsh-ssh-workbench host half.
3
+ *
4
+ * Remote execution is deliberately not a second toolbox: on POSIX Hosts the
5
+ * plugin gives DSH's own `bash` tool a remote execution world, and when a
6
+ * Session has no active managed target every tool behaves exactly as shipped.
7
+ * Windows Hosts ship no `bash` tool at all (DSH disables it by platform), so
8
+ * there the plugin registers the standalone `remote_bash` tool instead. A
9
+ * single `ssh_workbench_target` tool switches the active target, and a synchronous
10
+ * `ctx.tools.guard()` blacklist is the fallback safety net.
11
+ */
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+ import z from '@deepseek-ai/schemastery'
15
+ import { defineTool } from '@deepseek-ai/dsh-tools'
16
+
17
+ import { loadServers, saveServers } from './core/store.js'
18
+ import { loadPrefs, savePrefs } from './core/prefs.js'
19
+ import { createTerminalHub } from './core/terminals.js'
20
+ import { createLocalFs } from './core/localfs.js'
21
+ import { createSecretStore } from './core/secrets.js'
22
+ import { createKnownHosts } from './core/known-hosts.js'
23
+ import { createSshRuntime } from './core/ssh.js'
24
+ import { createActivityStore } from './core/activity.js'
25
+ import { createTargetStore, TARGET_TOOL_NAME, LOCAL_TARGET } from './core/targets.js'
26
+ import { createBlacklistGuard } from './core/guard.js'
27
+ import { createRemoteBashRouter } from './core/remote-bash.js'
28
+ import { createRemoteShellTool } from './core/remote-bash-tool.js'
29
+ import { posixAdapter, win32Adapter } from './core/shell-adapters.js'
30
+ import { effectiveDialect } from './core/dialect.js'
31
+ import { hostPlatform } from './core/shell-adapters.js'
32
+ import { installTargetContext } from './core/server-reference.js'
33
+ import { createRoutes, registerRoutes } from './routes.js'
34
+
35
+ export const name = 'dsh-ssh-workbench'
36
+
37
+ /** Hard dependencies only; optional services are resolved lazily. */
38
+ export const inject = ['tools']
39
+
40
+ export const Config = z.object({
41
+ connectTimeoutMs: z.natural().default(15000),
42
+ // Default timeout for one remote bash command. The model may override it per
43
+ // call through bash's own `timeoutMs` parameter, up to maxCommandTimeoutMs.
44
+ commandTimeoutMs: z.natural().default(120000),
45
+ maxCommandTimeoutMs: z.natural().default(1800000),
46
+ // How long the remote `timeout -k` wrapper waits after SIGTERM before
47
+ // SIGKILL. 0 disables the hard kill (TERM only).
48
+ timeoutKillAfterMs: z.natural().default(5000),
49
+ maxOutputBytes: z.natural().default(102400),
50
+ // stderr is capped separately so one chatty failing command cannot flood the
51
+ // model context (stdout keeps maxOutputBytes).
52
+ maxStderrBytes: z.natural().default(16384),
53
+ // A stalled SFTP chunk (half-open connection) fails after this long instead
54
+ // of hanging the file panel; the client can resume from its offset.
55
+ transferChunkTimeoutMs: z.natural().default(30000),
56
+ hostKeyMode: z.string().default('strict'),
57
+ connectionPool: z.boolean().default(true),
58
+ poolIdleMs: z.natural().default(90000),
59
+ poolMaxServers: z.natural().default(8),
60
+ maxChannelsPerServer: z.natural().default(6),
61
+ // Warm a server connection when the user selects it in the UI.
62
+ prewarm: z.boolean().default(true),
63
+ // Inject the `@server` target context into the step and bind the session's
64
+ // remote target. Disable to keep every native tool strictly local.
65
+ targetContext: z.boolean().default(true),
66
+ // Root of the workbench's local side (local terminal cwd + local file
67
+ // panel). Empty = the OS user home directory, matching the local pane of
68
+ // regular SSH clients. Set an absolute path (or `~/...`) to pin another
69
+ // directory; it is still confined to that root by localfs.
70
+ localRoot: z.string().default(''),
71
+ })
72
+
73
+ const APPLY_DEFAULTS = {
74
+ connectTimeoutMs: 15000,
75
+ commandTimeoutMs: 120000,
76
+ maxCommandTimeoutMs: 1800000,
77
+ timeoutKillAfterMs: 5000,
78
+ maxOutputBytes: 102400,
79
+ maxStderrBytes: 16384,
80
+ transferChunkTimeoutMs: 30000,
81
+ hostKeyMode: 'strict',
82
+ connectionPool: true,
83
+ poolIdleMs: 90000,
84
+ poolMaxServers: 8,
85
+ maxChannelsPerServer: 6,
86
+ prewarm: true,
87
+ targetContext: true,
88
+ localRoot: '',
89
+ }
90
+
91
+ const textOut = {
92
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
93
+ render: (_args, value) => [{ type: 'text', text: value.text }],
94
+ }
95
+
96
+ export { APPLY_DEFAULTS }
97
+
98
+ /** Public settings safe to show in the Client settings/state page. */
99
+ export function publicConfig(config) {
100
+ return {
101
+ hostKeyMode: config.hostKeyMode,
102
+ connectTimeoutMs: config.connectTimeoutMs,
103
+ commandTimeoutMs: config.commandTimeoutMs,
104
+ maxCommandTimeoutMs: config.maxCommandTimeoutMs,
105
+ timeoutKillAfterMs: config.timeoutKillAfterMs,
106
+ maxOutputBytes: config.maxOutputBytes,
107
+ maxStderrBytes: config.maxStderrBytes,
108
+ transferChunkTimeoutMs: config.transferChunkTimeoutMs,
109
+ connectionPool: config.connectionPool !== false,
110
+ poolIdleMs: config.poolIdleMs,
111
+ prewarm: config.prewarm !== false,
112
+ targetContext: config.targetContext !== false,
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The only model-facing tool this plugin registers. It never executes a
118
+ * command; it selects the managed server the native `bash` tool runs on.
119
+ */
120
+ export function createTargetTool({
121
+ targets,
122
+ loadInventory,
123
+ onActivated,
124
+ remoteToolName = 'bash',
125
+ windowsToolName = 'remote_pwsh',
126
+ hostPlatform: hostOs = 'posix',
127
+ }) {
128
+ const description =
129
+ hostOs === 'win32'
130
+ ? `Select the managed remote resource (server or VM) that this session's remote shell tools run on. Pass a managed resource name or id, or "local" to return to the local DSH host. Only one target is active at a time; the native pwsh tool and read/write/edit/glob/grep always run on the local DSH host. Remote POSIX targets run through ${remoteToolName}; Windows targets run through ${windowsToolName}.`
131
+ : `Select the managed remote resource (server or VM) that this session's remote shell runs on. Pass a managed resource name or id, or "local" to return to the local DSH host. Only one target is active at a time; read/write/edit/glob/grep always run on the local DSH host. POSIX targets run through the native ${remoteToolName} tool; Windows targets run through ${windowsToolName}.`
132
+ return defineTool({
133
+ name: TARGET_TOOL_NAME,
134
+ description,
135
+ parameters: {
136
+ server: { type: 'string', required: true, description: `Managed resource name or id, or "${LOCAL_TARGET}" to detach` },
137
+ },
138
+ output: textOut,
139
+ async execute(args, exec) {
140
+ const session = exec?.agent?.session
141
+ const key = session?.id ?? exec?.agent?.id
142
+ if (typeof key !== 'string' || key.length === 0) {
143
+ throw new Error('ssh_workbench_target requires an agent session')
144
+ }
145
+ const servers = loadInventory()
146
+ const outcome = targets.setActive(key, String(args.server ?? '').trim(), servers)
147
+ if (!outcome.ok) {
148
+ const available = servers
149
+ .filter((server) => server.enabled !== false)
150
+ .slice(0, 20)
151
+ .map((server) => server.name)
152
+ .join(', ')
153
+ throw new Error(`${outcome.error}. Available targets: ${available || '(none)'}; or "${LOCAL_TARGET}".`)
154
+ }
155
+ const state = outcome.state
156
+ if (!state.active) {
157
+ return { text: `active target: ${LOCAL_TARGET} — ${localShellSentence(hostOs)}.` }
158
+ }
159
+ const server = servers.find((entry) => entry.name === state.active)
160
+ const target = server ? `${server.username}@${server.host}:${server.port}` : state.active
161
+ // Probing the dialect before answering makes the next injected context
162
+ // name the right tool. It is bounded: a slow/unreachable host must not
163
+ // stall target selection.
164
+ let dialect = server ? effectiveDialect(server) : undefined
165
+ if (server && typeof onActivated === 'function') {
166
+ try {
167
+ const facts = await Promise.race([
168
+ Promise.resolve().then(() => onActivated(server)),
169
+ new Promise((resolve) => {
170
+ const timer = setTimeout(resolve, 4000)
171
+ timer.unref?.()
172
+ }),
173
+ ])
174
+ if (facts?.dialect) dialect = facts.dialect
175
+ } catch {
176
+ // Keep the record hint.
177
+ }
178
+ }
179
+ if (dialect === 'windows') {
180
+ return {
181
+ text: `active target: ${state.active} (${target}); ${windowsToolName} runs PowerShell commands there; read/write/edit/glob/grep run on the local DSH host.`,
182
+ }
183
+ }
184
+ return {
185
+ text: `active target: ${state.active} (${target}); ${remoteToolName} runs remotely on this resource; read/write/edit/glob/grep run on the local DSH host.`,
186
+ }
187
+ },
188
+ })
189
+ }
190
+
191
+ /** One sentence describing where the native shell tool runs on this Host. */
192
+ function localShellSentence(hostOs) {
193
+ return hostOs === 'win32'
194
+ ? 'the native pwsh tool runs on the local DSH host; no remote target is selected'
195
+ : 'bash runs on the local DSH host'
196
+ }
197
+
198
+ /** Expand a configured `~` prefix; relative/invalid values are left for the caller. */
199
+ export function expandLocalRoot(value) {
200
+ const raw = typeof value === 'string' ? value.trim() : ''
201
+ if (!raw) return ''
202
+ return raw === '~'
203
+ ? os.homedir()
204
+ : raw.startsWith('~/') || raw.startsWith('~' + path.sep)
205
+ ? path.join(os.homedir(), raw.slice(2))
206
+ : raw
207
+ }
208
+
209
+ /** Workbench local root: configured absolute path (or `~/...`), else user home. */
210
+ export function resolveLocalRoot(config = {}) {
211
+ const expanded = expandLocalRoot(config?.localRoot)
212
+ if (!expanded || !path.isAbsolute(expanded)) return os.homedir()
213
+ return path.normalize(expanded)
214
+ }
215
+
216
+ /**
217
+ * @param {object} ctx Cordis plugin context
218
+ * @param {object} [config] plugin Config
219
+ * @param {{
220
+ * runtime?: object,
221
+ * secrets?: object,
222
+ * knownHosts?: object,
223
+ * targets?: object,
224
+ * loadInventory?: () => object[],
225
+ * createUserMessage?: Function,
226
+ * hostPlatform?: 'win32'|'posix',
227
+ * }} [deps] explicit seams used by tests
228
+ */
229
+ export function apply(ctx, config, deps = {}) {
230
+ config = { ...APPLY_DEFAULTS, ...(config ?? {}) }
231
+ // Hosts ship different native shell tools: DSH disables `bash` on win32 and
232
+ // `pwsh` elsewhere. The remote tool therefore depends on the Host, not on a
233
+ // global assumption; tests can pin it through deps.hostPlatform.
234
+ const hostOs = hostPlatform(deps.hostPlatform ?? process.platform)
235
+ const isWindowsHost = hostOs === 'win32'
236
+ const remoteToolName = isWindowsHost ? 'remote_bash' : 'bash'
237
+ let remoteAvailable = false
238
+ if (config.hostKeyMode !== 'strict' && config.hostKeyMode !== 'accept-new') {
239
+ ctx.logger?.warn?.('dsh-ssh-workbench: unknown hostKeyMode value, falling back to strict', { value: config.hostKeyMode })
240
+ config.hostKeyMode = 'strict'
241
+ }
242
+
243
+ const loadInventory = deps.loadInventory ?? (() => loadServers())
244
+ const secrets = deps.secrets ?? createSecretStore()
245
+ const knownHosts = deps.knownHosts ?? createKnownHosts()
246
+ const ownedRuntime = deps.runtime === undefined
247
+ const runtime = deps.runtime ?? createSshRuntime({
248
+ getSecret: (key) => secrets.get(key),
249
+ knownHosts,
250
+ config,
251
+ log: (message, fields) => ctx.logger?.debug?.(message, fields),
252
+ })
253
+ if (ownedRuntime && typeof runtime.preload === 'function') {
254
+ // Warm the optional native binding at load instead of paying it on the
255
+ // first remote call.
256
+ void runtime.preload().catch(() => {})
257
+ }
258
+ if (ownedRuntime && typeof ctx.effect === 'function') {
259
+ ctx.effect(() => () => {
260
+ void runtime.close?.()
261
+ }, 'dsh-ssh-workbench: pooled ssh connections')
262
+ }
263
+
264
+ // Bounded local activity log for the overview page (not an audit log).
265
+ const activity = deps.activity ?? createActivityStore()
266
+ const recordActivity = (entry) => {
267
+ try {
268
+ activity.append(entry)
269
+ } catch {
270
+ // Activity recording is best effort and must never fail an operation.
271
+ }
272
+ }
273
+
274
+ const targets = deps.targets ?? createTargetStore({ loadInventory })
275
+
276
+ /**
277
+ * Collect a host's facts once and persist them on the server record.
278
+ * Used by connection test / explicit refresh / target selection: the UI and
279
+ * the injected target context can then show hostname and disk without a new
280
+ * SSH round trip. Never throws (facts are optional metadata).
281
+ */
282
+ const refreshServerFacts = async (server) => {
283
+ if (!server || typeof runtime.facts !== 'function') return undefined
284
+ try {
285
+ const result = await runtime.facts(server, { refresh: true })
286
+ const facts = result?.facts
287
+ if (!facts || typeof facts !== 'object') return undefined
288
+ const merged = {
289
+ ...(server.facts && typeof server.facts === 'object' ? server.facts : {}),
290
+ ...facts,
291
+ collectedAt: result.collectedAt ?? new Date().toISOString(),
292
+ }
293
+ const inventory = loadInventory()
294
+ if (!inventory.some((entry) => entry.id === server.id)) return merged
295
+ saveServers(inventory.map((entry) => (entry.id === server.id ? { ...entry, facts: merged } : entry)))
296
+ return merged
297
+ } catch {
298
+ return undefined
299
+ }
300
+ }
301
+
302
+ // ── model-facing tools: target selector + dialect-specific remote shells ───
303
+ if (typeof ctx.tools?.register === 'function') {
304
+ const targetTool = createTargetTool({
305
+ targets,
306
+ loadInventory,
307
+ onActivated: (server) => refreshServerFacts(server),
308
+ remoteToolName,
309
+ windowsToolName: 'remote_pwsh',
310
+ hostPlatform: hostOs,
311
+ })
312
+ const offTool = ctx.tools.register(targetTool)
313
+ if (typeof offTool === 'function' && typeof ctx.effect === 'function') {
314
+ ctx.effect(() => offTool, 'dsh-ssh-workbench: target tool')
315
+ }
316
+
317
+ const registerRemote = (definition, label) => {
318
+ try {
319
+ const off = ctx.tools.register(definition)
320
+ if (typeof off === 'function' && typeof ctx.effect === 'function') {
321
+ ctx.effect(() => off, `dsh-ssh-workbench: ${label}`)
322
+ }
323
+ return true
324
+ } catch (error) {
325
+ ctx.logger?.warn?.(
326
+ `dsh-ssh-workbench: ${definition?.name ?? label} could not be registered (${error instanceof Error ? error.message : String(error)}); remote execution for that dialect is unavailable`,
327
+ )
328
+ return false
329
+ }
330
+ }
331
+
332
+ // Windows targets are not covered by any native tool on either Host.
333
+ remoteAvailable =
334
+ registerRemote(
335
+ createRemoteShellTool({
336
+ runtime,
337
+ targets,
338
+ config,
339
+ loadInventory,
340
+ recordActivity,
341
+ adapter: win32Adapter,
342
+ localShellName: isWindowsHost ? 'pwsh' : 'bash',
343
+ }),
344
+ 'remote pwsh tool',
345
+ ) || remoteAvailable
346
+ // Windows Hosts also have no native bash, so POSIX targets need the
347
+ // plugin-owned tool there; POSIX Hosts keep the native bash router below.
348
+ if (isWindowsHost) {
349
+ remoteAvailable =
350
+ registerRemote(
351
+ createRemoteShellTool({
352
+ runtime,
353
+ targets,
354
+ config,
355
+ loadInventory,
356
+ recordActivity,
357
+ adapter: posixAdapter,
358
+ localShellName: 'pwsh',
359
+ }),
360
+ 'remote bash tool',
361
+ ) || remoteAvailable
362
+ }
363
+ }
364
+
365
+ // ── fallback blacklist: deny-only, synchronous, monotonic ──────────────────
366
+ if (typeof ctx.tools?.guard === 'function') {
367
+ const offGuard = ctx.tools.guard(createBlacklistGuard({ targets, recordActivity }))
368
+ if (typeof offGuard === 'function' && typeof ctx.effect === 'function') {
369
+ ctx.effect(() => offGuard, 'dsh-ssh-workbench: blacklist guard')
370
+ }
371
+ } else {
372
+ ctx.logger?.warn?.('dsh-ssh-workbench: ctx.tools.guard() is unavailable; the destructive-command blacklist is disabled')
373
+ }
374
+
375
+ // ── route the native bash tool to the active managed target (POSIX Hosts) ──
376
+ if (!isWindowsHost && typeof ctx.on === 'function') {
377
+ const router = createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity })
378
+ const offExecute = ctx.on('tools/execute', router)
379
+ if (typeof offExecute === 'function' && typeof ctx.effect === 'function') {
380
+ ctx.effect(() => offExecute, 'dsh-ssh-workbench: remote bash router')
381
+ }
382
+ remoteAvailable = true
383
+ }
384
+
385
+ // ── @server → session target + one compact context message per turn ────────
386
+ // The context is only injected when this Host really has a remote execution
387
+ // path; otherwise it would tell the model that a tool runs remotely when no
388
+ // such tool exists (the Windows-without-remote_bash failure mode).
389
+ if (config.targetContext !== false) {
390
+ installTargetContext(ctx, {
391
+ targets,
392
+ loadInventory,
393
+ createUserMessage: deps.createUserMessage,
394
+ remoteAvailable,
395
+ hostPlatform: hostOs,
396
+ remoteToolName,
397
+ windowsToolName: 'remote_pwsh',
398
+ ensureDialect: (server) => refreshServerFacts(server),
399
+ })
400
+ }
401
+
402
+ // ── UI data plane: inventory, credentials, connection test, SFTP panel ─────
403
+ // Local workbench services: the terminal hub needs the Host PTY provider,
404
+ // and the local file browser / terminal cwd share one root. The default is
405
+ // the OS user home (what users expect from the local pane of an SSH client);
406
+ // `localRoot` can pin another directory. All access stays confined by localfs.
407
+ let subprocessService = null
408
+ if (typeof ctx.inject === 'function') {
409
+ ctx.inject(['subprocess'], (inner) => {
410
+ subprocessService = inner.get('subprocess') ?? null
411
+ })
412
+ }
413
+ const expandedLocalRoot = expandLocalRoot(config.localRoot)
414
+ if (expandedLocalRoot && !path.isAbsolute(expandedLocalRoot)) {
415
+ ctx.logger?.warn?.('dsh-ssh-workbench: localRoot is not an absolute path, falling back to the user home directory', {
416
+ localRoot: config.localRoot,
417
+ })
418
+ }
419
+ const localRoot = () => resolveLocalRoot(config)
420
+ const terminals = deps.terminals ?? createTerminalHub({
421
+ runtime,
422
+ getSubprocess: () => subprocessService,
423
+ getLocalCwd: localRoot,
424
+ })
425
+ const localfs = deps.localfs ?? createLocalFs({ getRoot: localRoot })
426
+ if (typeof ctx.effect === 'function') {
427
+ ctx.effect(() => () => terminals.dispose(), 'dsh-ssh-workbench: terminals')
428
+ }
429
+
430
+ const disposeRoutes = registerRoutes(
431
+ ctx,
432
+ createRoutes({
433
+ loadServers: () => loadInventory(),
434
+ saveServers: (servers) => saveServers(servers),
435
+ runtime,
436
+ secrets,
437
+ knownHosts,
438
+ targets,
439
+ refreshFacts: (server) => refreshServerFacts(server),
440
+ activity,
441
+ prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
442
+ terminals,
443
+ localfs,
444
+ publicConfig: () => publicConfig(config),
445
+ }),
446
+ )
447
+ if (typeof ctx.effect === 'function') {
448
+ ctx.effect(() => disposeRoutes, 'dsh-ssh-workbench: routes')
449
+ }
450
+ }