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,104 @@
1
+ /**
2
+ * Filesystem layout for dsh-ssh-workbench.
3
+ *
4
+ * Everything the plugin persists lives under the harness home so a profile
5
+ * export/import carries it, and so tests can redirect it with DSH_SSH_WORKBENCH_DIR.
6
+ */
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import fs from 'node:fs'
10
+
11
+ function dshHome() {
12
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
13
+ }
14
+
15
+ /** New storage directory name, plus the pre-1.6.4 name for one-time migration. */
16
+ const DIR_NAME = 'ssh-workbench'
17
+ const LEGACY_DIR_NAME = 'ssh-ops'
18
+
19
+ /**
20
+ * Directory that holds servers, credentials, known hosts and preferences.
21
+ *
22
+ * `DSH_SSH_WORKBENCH_DIR` wins in tests and for power users. Otherwise the
23
+ * plugin uses `$DSH_HOME/ssh-workbench` and, on the first launch after the
24
+ * 1.6.4 rename, moves an existing `$DSH_HOME/ssh-ops` directory into place so
25
+ * credentials and trusted host keys are not lost. If the move fails (for
26
+ * example because of permissions) the legacy directory stays in use.
27
+ */
28
+ export function workbenchDir() {
29
+ const configured = process.env.DSH_SSH_WORKBENCH_DIR
30
+ if (configured) return configured
31
+ const current = path.join(dshHome(), DIR_NAME)
32
+ if (!fs.existsSync(current)) {
33
+ const legacy = path.join(dshHome(), LEGACY_DIR_NAME)
34
+ if (fs.existsSync(legacy)) {
35
+ try {
36
+ fs.renameSync(legacy, current)
37
+ } catch {
38
+ return legacy
39
+ }
40
+ }
41
+ }
42
+ return current
43
+ }
44
+
45
+ export function serversPath() {
46
+ return path.join(workbenchDir(), 'servers.json')
47
+ }
48
+
49
+ export function activityPath() {
50
+ return path.join(workbenchDir(), 'activity.json')
51
+ }
52
+
53
+ export function knownHostsPath() {
54
+ return path.join(workbenchDir(), 'known_hosts')
55
+ }
56
+
57
+ export function secretsPath() {
58
+ return path.join(workbenchDir(), 'secrets.json')
59
+ }
60
+
61
+ export function secretsKeyPath() {
62
+ return path.join(workbenchDir(), 'secrets.key')
63
+ }
64
+
65
+ export function prefsPath() {
66
+ return path.join(workbenchDir(), 'prefs.json')
67
+ }
68
+
69
+ /** Create a private directory (0700) if missing. */
70
+ function ensurePrivateDir(dir = workbenchDir()) {
71
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
72
+ try {
73
+ fs.chmodSync(dir, 0o700)
74
+ } catch {
75
+ // Windows cannot honour POSIX modes; the mkdir mode is best effort there.
76
+ }
77
+ return dir
78
+ }
79
+
80
+ /** Write a private file atomically (temp file + rename, 0600). */
81
+ export function writePrivateFile(file, text) {
82
+ ensurePrivateDir(path.dirname(file))
83
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
84
+ fs.writeFileSync(tmp, text, { mode: 0o600 })
85
+ try {
86
+ fs.chmodSync(tmp, 0o600)
87
+ } catch {
88
+ // best effort on Windows
89
+ }
90
+ fs.renameSync(tmp, file)
91
+ try {
92
+ fs.chmodSync(file, 0o600)
93
+ } catch {
94
+ // best effort on Windows
95
+ }
96
+ }
97
+
98
+ export function readTextFile(file, fallback = '') {
99
+ try {
100
+ return fs.readFileSync(file, 'utf8')
101
+ } catch {
102
+ return fallback
103
+ }
104
+ }
@@ -0,0 +1,4 @@
1
+ /** POSIX single-quote escaping for a remote shell word. */
2
+ export function quotePosix(value) {
3
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
4
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Small user-visible UI preferences that are not part of the server inventory.
3
+ *
4
+ * Kept in the same private data directory as the inventory so a profile export
5
+ * carries them, and so the plugin manager's sidebar switch can change them
6
+ * without rewriting the profile's cordis.patch.yml.
7
+ */
8
+ import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
9
+
10
+ export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
11
+
12
+ /** Coerce a stored document into the current preference shape. */
13
+ export function normalizePrefs(raw) {
14
+ const prefs = { ...DEFAULT_PREFS }
15
+ if (!raw || typeof raw !== 'object') return prefs
16
+ if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar
17
+ return prefs
18
+ }
19
+
20
+ export function loadPrefs() {
21
+ const text = readTextFile(prefsPath(), '')
22
+ if (!text.trim()) return { ...DEFAULT_PREFS }
23
+ try {
24
+ return normalizePrefs(JSON.parse(text))
25
+ } catch {
26
+ // A corrupt preferences file must never make the plugin unusable; fall
27
+ // back to the defaults and let the next save rewrite it.
28
+ return { ...DEFAULT_PREFS }
29
+ }
30
+ }
31
+
32
+ /** Merge one patch into the stored preferences and persist the result. */
33
+ export function savePrefs(patch) {
34
+ if (!patch || typeof patch !== 'object') throw new Error('prefs patch must be an object')
35
+ const next = normalizePrefs({ ...loadPrefs(), ...patch })
36
+ writePrivateFile(prefsPath(), `${JSON.stringify(next, null, 2)}\n`)
37
+ return next
38
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Standalone remote shell tools (`remote_bash`, `remote_pwsh`).
3
+ *
4
+ * The plugin normally routes DSH's native `bash` tool to the active target.
5
+ * That only works on Hosts that ship `bash`: Windows Desktop builds ship
6
+ * `pwsh` instead and disable `bash` by platform, so the interception handler is
7
+ * never called there. A Windows target is also unreachable from any Host
8
+ * through the POSIX shell. These tools are the portable entry points — they are
9
+ * registered where the native shell cannot cover the target dialect, never
10
+ * shadow a native tool, and keep the local shell strictly local.
11
+ *
12
+ * The tools return rendered text instead of the native Bash value: the built-in
13
+ * Bash renderer is reserved for the built-in tool and a remote result has no
14
+ * native card. Text keeps command, target, cwd and exit status explicit.
15
+ */
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+
18
+ import { describeResultOutcome } from './activity.js'
19
+ import { parseBackgroundStart, renderBackgroundStart } from './background.js'
20
+ import { effectiveDialect, normalizeDialect } from './dialect.js'
21
+ import { isTransportFailure } from './remote-bash.js'
22
+ import { posixAdapter } from './shell-adapters.js'
23
+ import { findTarget } from './targets.js'
24
+
25
+ const textOut = {
26
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
27
+ render: (_args, value) => [{ type: 'text', text: value.text }],
28
+ }
29
+
30
+ function describeError(error) {
31
+ return error instanceof Error ? error.message : String(error)
32
+ }
33
+
34
+ /** Read the session key off one tool execution, tolerating test doubles. */
35
+ export function sessionKeyOf(exec) {
36
+ const agent = exec?.agent
37
+ const session = agent?.session
38
+ const id = session?.id ?? agent?.id
39
+ return typeof id === 'string' && id.length > 0 ? id : undefined
40
+ }
41
+
42
+ /**
43
+ * Resolve the dialect a target actually runs. `facts.dialect` is a probe
44
+ * result; when it is absent (fresh record, first selection) the runtime probe
45
+ * runs once and is cached. The record's `platform` hint is only the last
46
+ * fallback.
47
+ */
48
+ export async function resolveDialect(server, runtime) {
49
+ const probed = server?.facts?.dialect
50
+ if (probed === 'posix' || probed === 'windows') return probed
51
+ if (runtime && typeof runtime.probeDialect === 'function') {
52
+ try {
53
+ const dialect = await runtime.probeDialect(server)
54
+ if (dialect === 'posix' || dialect === 'windows') return dialect
55
+ } catch {
56
+ // Fall through to the record hint.
57
+ }
58
+ }
59
+ return effectiveDialect(server)
60
+ }
61
+
62
+ /**
63
+ * Resolve the active target and the dialect for one call.
64
+ * @returns {Promise<{ key: string, binding: object, server: object, dialect: string }>}
65
+ */
66
+ export async function resolveRemoteTarget({ exec, targets, loadInventory, adapter, runtime, toolName = adapter?.toolName ?? 'remote shell' }) {
67
+ const key = sessionKeyOf(exec)
68
+ if (key === undefined) throw new Error(`${toolName} requires an agent session`)
69
+ const binding = typeof targets?.get === 'function' ? targets.get(key) : undefined
70
+ if (!binding || !binding.active) {
71
+ if (binding && Array.isArray(binding.candidates) && binding.candidates.length > 1) {
72
+ throw new Error(
73
+ `several servers were mentioned in this session (${binding.candidates.join(', ')}); ` +
74
+ `call ssh_workbench_target({ server: "<name>" }) before ${toolName}`,
75
+ )
76
+ }
77
+ throw new Error(
78
+ `no active target — call ssh_workbench_target({ server: "<name>" }) before ${toolName}, ` +
79
+ 'or ssh_workbench_target({ server: "local" }) to stay on the local DSH host',
80
+ )
81
+ }
82
+
83
+ let servers
84
+ try {
85
+ servers = loadInventory()
86
+ } catch (error) {
87
+ throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
88
+ }
89
+ const server = findTarget(servers, binding.active)
90
+ if (server === undefined) {
91
+ throw new Error(
92
+ `target "${binding.active}" is no longer in the managed inventory; ` +
93
+ 'call ssh_workbench_target to pick another target or "local"',
94
+ )
95
+ }
96
+ if (server.enabled === false) {
97
+ throw new Error(`target "${server.name}" is disabled; call ssh_workbench_target to pick another target or "local"`)
98
+ }
99
+ const dialect = await resolveDialect(server, runtime)
100
+ if (typeof adapter?.supportsDialect === 'function' && !adapter.supportsDialect(dialect)) {
101
+ throw new Error(adapter.unsupportedMessage(server, dialect))
102
+ }
103
+ return { key, binding, server, dialect }
104
+ }
105
+
106
+ /** Render one remote result as model-facing text. */
107
+ export function renderRemoteResult({ server, command, cwd, result, notices }) {
108
+ const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
109
+ const stderr = typeof result?.stderr === 'string' ? result.stderr : ''
110
+ const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
111
+ const lines = [`target: ${server.name} (${server.username}@${server.host}:${server.port})`, `command: ${command}`, `cwd: ${cwd}`]
112
+ if (exitCode !== null) lines.push(`exit code: ${exitCode}`)
113
+ if (result?.outcome === 'timeout') lines.push('outcome: timed out')
114
+ if (result?.outcome === 'cancelled') lines.push('outcome: cancelled')
115
+ lines.push('stdout:')
116
+ lines.push(stdout.length > 0 ? stdout.replace(/\n$/, '') : '(empty)')
117
+ if (stderr.length > 0) {
118
+ lines.push('stderr:')
119
+ lines.push(stderr.replace(/\n$/, ''))
120
+ }
121
+ if (result?.truncated?.stdout === true) lines.push('[stdout truncated by the plugin output cap]')
122
+ if (result?.truncated?.stderr === true) lines.push('[stderr truncated by the plugin output cap]')
123
+ for (const notice of notices ?? []) lines.push(notice)
124
+ return { text: lines.join('\n') }
125
+ }
126
+
127
+ function defaultTimeoutNotice(capability) {
128
+ return capability.mode === 'none'
129
+ ? '[remote] client-side timeout only: the server has no usable `timeout` utility, so the remote process may still be running.'
130
+ : `[remote] timeout: the remote command group was terminated (SIGTERM${capability.mode === 'kill' ? ', KILL after the remote deadline' : ''}).`
131
+ }
132
+
133
+ function defaultCancelNotice(capability) {
134
+ return capability.mode === 'none'
135
+ ? '[remote] cancelled: the SSH channel was closed, but the remote process may still be running.'
136
+ : '[remote] cancelled: the SSH channel was closed; the remote wrapper terminates the command at its deadline if it is still running.'
137
+ }
138
+
139
+ /**
140
+ * Create one remote shell tool definition.
141
+ *
142
+ * @param {object} deps
143
+ * @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
144
+ * @param {object} deps.targets per-session target store
145
+ * @param {object} deps.config resolved plugin config
146
+ * @param {() => object[]} deps.loadInventory
147
+ * @param {(entry: object) => void} [deps.recordActivity]
148
+ * @param {object} [deps.adapter] shell adapter (defaults to POSIX `remote_bash`)
149
+ * @param {string} [deps.localShellName] native local shell label, for copy
150
+ */
151
+ export function createRemoteShellTool({
152
+ runtime,
153
+ targets,
154
+ config,
155
+ loadInventory,
156
+ recordActivity,
157
+ adapter = posixAdapter,
158
+ localShellName = 'bash',
159
+ } = {}) {
160
+ const dialectName = normalizeDialect(adapter.platform) === 'windows' ? 'Windows/PowerShell' : 'POSIX shell'
161
+ return defineTool({
162
+ name: adapter.toolName,
163
+ description:
164
+ `Run a ${dialectName} command on the managed remote target selected with ssh_workbench_target. ` +
165
+ 'This is the remote execution tool: the native ' +
166
+ `${localShellName} tool always runs on the local DSH host, so use this tool only for work that belongs on the target. ` +
167
+ 'Pass workdir to change the working directory and timeoutMs for a foreground deadline. ' +
168
+ 'Set run_in_background to start a detached process: the result gives its pid and log files, ' +
169
+ 'which you can read with tail/Get-Content and stop with kill/Stop-Process. ' +
170
+ 'If no target is active the tool fails; call ssh_workbench_target({ server: "<name>" }) first, ' +
171
+ 'or ssh_workbench_target({ server: "local" }) to detach.',
172
+ parameters: {
173
+ command: { type: 'string', required: true, description: `Command to run on the remote ${dialectName} target.` },
174
+ workdir: {
175
+ type: 'string',
176
+ description: 'Remote working directory: an absolute path, ~/path, or a path relative to the login home.',
177
+ },
178
+ timeoutMs: {
179
+ type: 'integer',
180
+ description: 'Foreground timeout in milliseconds (default from config, capped by maxCommandTimeoutMs).',
181
+ },
182
+ run_in_background: {
183
+ type: 'boolean',
184
+ description:
185
+ 'Start the command detached instead of waiting. Returns the remote pid and log files; timeoutMs does not apply.',
186
+ },
187
+ },
188
+ output: textOut,
189
+ async execute(args, exec) {
190
+ const { key, binding, server, dialect } = await resolveRemoteTarget({
191
+ exec,
192
+ targets,
193
+ loadInventory,
194
+ runtime,
195
+ adapter,
196
+ })
197
+
198
+ const command = typeof args?.command === 'string' ? args.command : ''
199
+ if (!command.trim()) throw new Error('command must not be empty')
200
+
201
+ if (args?.run_in_background === true) {
202
+ if (typeof adapter.buildBackgroundCommand !== 'function') {
203
+ throw new Error(`${adapter.toolName} does not support run_in_background`)
204
+ }
205
+ const background = adapter.buildBackgroundCommand(command, args?.workdir, server, dialect)
206
+ if (background.error !== undefined) throw new Error(background.error)
207
+ const startTimeout = Math.min(adapter.resolveTimeoutMs(args, config), 30000)
208
+ let start
209
+ try {
210
+ start = await runtime.exec(server, background.command, {
211
+ timeoutMs: startTimeout,
212
+ signal: exec?.signal,
213
+ readOnly: false,
214
+ cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
215
+ stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
216
+ })
217
+ } catch (error) {
218
+ throw new Error(`remote execution failed: ${describeError(error)}`)
219
+ }
220
+ try {
221
+ recordActivity?.({
222
+ kind: 'exec',
223
+ server: server.name,
224
+ summary: `[background] ${command.slice(0, 190)}`,
225
+ outcome: describeResultOutcome(start),
226
+ })
227
+ } catch {
228
+ // Activity recording is best effort.
229
+ }
230
+ if (isTransportFailure(start)) {
231
+ const detail = typeof start?.stderr === 'string' && start.stderr.trim() ? start.stderr.trim() : 'remote execution failed'
232
+ throw new Error(detail)
233
+ }
234
+ const started = parseBackgroundStart(start?.stdout)
235
+ if (started.pid === undefined) {
236
+ const detail = String(start?.stderr ?? '').trim() || String(start?.stdout ?? '').trim()
237
+ throw new Error(`background start failed${detail ? `: ${detail}` : ''}`)
238
+ }
239
+ return {
240
+ text: renderBackgroundStart({
241
+ pid: started.pid,
242
+ log: started.log,
243
+ errLog: started.errLog,
244
+ target: `${server.name} (${server.username}@${server.host}:${server.port})`,
245
+ command,
246
+ dialect,
247
+ }),
248
+ }
249
+ }
250
+
251
+ const workdir = adapter.buildCommand(command, args?.workdir, server, dialect)
252
+ if (workdir.error !== undefined) throw new Error(workdir.error)
253
+ const timeoutMs = adapter.resolveTimeoutMs(args, config)
254
+
255
+ // A remote deadline wrapper stops the remote process group when the
256
+ // client gives up; adapters without one fall back to the SSH channel
257
+ // close and say so in the result.
258
+ let capability = { mode: 'none', shell: '/bin/sh' }
259
+ if (adapter.remoteTimeout) {
260
+ try {
261
+ if (typeof runtime.timeoutGuard === 'function') {
262
+ const probed = await runtime.timeoutGuard(server)
263
+ capability = typeof probed === 'string' ? { mode: probed } : (probed ?? capability)
264
+ }
265
+ } catch {
266
+ capability = { mode: 'none', shell: '/bin/sh' }
267
+ }
268
+ }
269
+ const remoteCommand = adapter.wrapTimeout(workdir.command, timeoutMs, capability, {
270
+ killAfterMs: config?.timeoutKillAfterMs,
271
+ })
272
+
273
+ let result
274
+ try {
275
+ result = await runtime.exec(server, remoteCommand, {
276
+ timeoutMs,
277
+ signal: exec?.signal,
278
+ readOnly: false,
279
+ cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
280
+ stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
281
+ })
282
+ } catch (error) {
283
+ throw new Error(`remote execution failed: ${describeError(error)}`)
284
+ }
285
+
286
+ try {
287
+ recordActivity?.({ kind: 'exec', server: server.name, summary: command.slice(0, 200), outcome: describeResultOutcome(result) })
288
+ } catch {
289
+ // Activity recording is best effort.
290
+ }
291
+
292
+ if (isTransportFailure(result)) {
293
+ const code = typeof result?.errorCode === 'string' && result.errorCode ? `${result.errorCode}: ` : ''
294
+ const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
295
+ const suggestion = typeof result?.suggestedAction === 'string' && result.suggestedAction ? `\n${result.suggestedAction}` : ''
296
+ throw new Error(`${code}${detail}${suggestion}`)
297
+ }
298
+
299
+ const notices = []
300
+ // One line after each target change: makes a plain-text result
301
+ // self-describing in multi-target sessions.
302
+ if (binding.notifiedVersion !== binding.version && typeof targets?.markNotified === 'function') {
303
+ notices.push(`[remote] target: ${server.name} (${server.username}@${server.host}:${server.port})`)
304
+ targets.markNotified(key, binding.version)
305
+ }
306
+ if (result?.outcome === 'timeout') {
307
+ notices.push(
308
+ typeof adapter.timeoutNotice === 'function' ? adapter.timeoutNotice(capability) : defaultTimeoutNotice(capability),
309
+ )
310
+ }
311
+ if (result?.outcome === 'cancelled') {
312
+ notices.push(
313
+ typeof adapter.cancelNotice === 'function' ? adapter.cancelNotice(capability) : defaultCancelNotice(capability),
314
+ )
315
+ }
316
+
317
+ return renderRemoteResult({ server, command, cwd: workdir.cwd, result, notices })
318
+ },
319
+ })
320
+ }
321
+