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.
- package/CHANGELOG.md +504 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/SECURITY.md +25 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +124 -0
- package/docs/quickstart.md +94 -0
- package/lib/client.js +4977 -0
- package/lib/core/activity.js +83 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +799 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +71 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-bash-tool.js +321 -0
- package/lib/core/remote-bash.js +337 -0
- package/lib/core/secrets.js +96 -0
- package/lib/core/server-reference.js +299 -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/targets.js +250 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +450 -0
- package/lib/routes.js +733 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -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
|
@@ -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
|
+
}
|