dsh-ssh-workbench 1.6.4 → 1.7.8

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.
@@ -1,13 +1,10 @@
1
1
  /**
2
- * Standalone remote shell tools (`remote_bash`, `remote_pwsh`).
2
+ * Explicit-target remote shell tools (`remote_bash`, `remote_pwsh`).
3
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.
4
+ * Every call names the managed
5
+ * resource it wants. That makes a follow-up without a new `@mention` safe
6
+ * (nothing switches behind the model's back), lets the model compare several
7
+ * resources directly, and keeps local and remote shells in separate worlds.
11
8
  *
12
9
  * The tools return rendered text instead of the native Bash value: the built-in
13
10
  * Bash renderer is reserved for the built-in tool and a remote result has no
@@ -18,9 +15,9 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
18
15
  import { describeResultOutcome } from './activity.js'
19
16
  import { parseBackgroundStart, renderBackgroundStart } from './background.js'
20
17
  import { effectiveDialect, normalizeDialect } from './dialect.js'
21
- import { isTransportFailure } from './remote-bash.js'
18
+ import { isTransportFailure } from './posix-shell.js'
22
19
  import { posixAdapter } from './shell-adapters.js'
23
- import { findTarget } from './targets.js'
20
+ import { requireServerRef } from './server-ref.js'
24
21
 
25
22
  const textOut = {
26
23
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -41,9 +38,8 @@ export function sessionKeyOf(exec) {
41
38
 
42
39
  /**
43
40
  * 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.
41
+ * result; when it is absent the runtime probe runs once and is cached. The
42
+ * record's `platform` hint is only the last fallback.
47
43
  */
48
44
  export async function resolveDialect(server, runtime) {
49
45
  const probed = server?.facts?.dialect
@@ -60,47 +56,29 @@ export async function resolveDialect(server, runtime) {
60
56
  }
61
57
 
62
58
  /**
63
- * Resolve the active target and the dialect for one call.
64
- * @returns {Promise<{ key: string, binding: object, server: object, dialect: string }>}
59
+ * Resolve the explicitly named server and its dialect for one call.
60
+ * @returns {Promise<{ key?: string, server: object, dialect: string }>}
65
61
  */
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
-
62
+ export async function resolveExplicitTarget({
63
+ exec,
64
+ loadInventory,
65
+ adapter,
66
+ runtime,
67
+ serverRef,
68
+ toolName = adapter?.toolName ?? 'remote shell',
69
+ }) {
83
70
  let servers
84
71
  try {
85
72
  servers = loadInventory()
86
73
  } catch (error) {
87
74
  throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
88
75
  }
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
- }
76
+ const server = requireServerRef(servers, serverRef, { label: `${toolName} server` })
99
77
  const dialect = await resolveDialect(server, runtime)
100
78
  if (typeof adapter?.supportsDialect === 'function' && !adapter.supportsDialect(dialect)) {
101
79
  throw new Error(adapter.unsupportedMessage(server, dialect))
102
80
  }
103
- return { key, binding, server, dialect }
81
+ return { key: sessionKeyOf(exec), server, dialect }
104
82
  }
105
83
 
106
84
  /** Render one remote result as model-facing text. */
@@ -137,11 +115,10 @@ function defaultCancelNotice(capability) {
137
115
  }
138
116
 
139
117
  /**
140
- * Create one remote shell tool definition.
118
+ * Create one explicit-target remote shell tool definition.
141
119
  *
142
120
  * @param {object} deps
143
121
  * @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
144
- * @param {object} deps.targets per-session target store
145
122
  * @param {object} deps.config resolved plugin config
146
123
  * @param {() => object[]} deps.loadInventory
147
124
  * @param {(entry: object) => void} [deps.recordActivity]
@@ -150,7 +127,6 @@ function defaultCancelNotice(capability) {
150
127
  */
151
128
  export function createRemoteShellTool({
152
129
  runtime,
153
- targets,
154
130
  config,
155
131
  loadInventory,
156
132
  recordActivity,
@@ -158,18 +134,25 @@ export function createRemoteShellTool({
158
134
  localShellName = 'bash',
159
135
  } = {}) {
160
136
  const dialectName = normalizeDialect(adapter.platform) === 'windows' ? 'Windows/PowerShell' : 'POSIX shell'
137
+ const toolName = adapter.toolName
161
138
  return defineTool({
162
- name: adapter.toolName,
139
+ name: toolName,
163
140
  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. ` +
141
+ `Run a ${dialectName} command on an explicitly named managed remote resource. ` +
142
+ `Pass server on every call (name or id). The native ${localShellName} tool always runs on the local DSH host, ` +
143
+ 'so use this tool only for work that belongs on the remote resource. ' +
167
144
  'Pass workdir to change the working directory and timeoutMs for a foreground deadline. ' +
168
145
  'Set run_in_background to start a detached process: the result gives its pid and log files, ' +
169
146
  '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.',
147
+ (normalizeDialect(adapter.platform) === 'windows'
148
+ ? 'Use remote_bash for POSIX resources.'
149
+ : 'Use remote_pwsh for Windows resources.'),
172
150
  parameters: {
151
+ server: {
152
+ type: 'string',
153
+ required: true,
154
+ description: 'Managed remote resource name or id. Required; there is no remembered target.',
155
+ },
173
156
  command: { type: 'string', required: true, description: `Command to run on the remote ${dialectName} target.` },
174
157
  workdir: {
175
158
  type: 'string',
@@ -187,20 +170,28 @@ export function createRemoteShellTool({
187
170
  },
188
171
  output: textOut,
189
172
  async execute(args, exec) {
190
- const { key, binding, server, dialect } = await resolveRemoteTarget({
173
+ const { server, dialect } = await resolveExplicitTarget({
191
174
  exec,
192
- targets,
193
175
  loadInventory,
194
176
  runtime,
195
177
  adapter,
178
+ serverRef: args?.server,
179
+ toolName,
196
180
  })
197
181
 
198
182
  const command = typeof args?.command === 'string' ? args.command : ''
199
183
  if (!command.trim()) throw new Error('command must not be empty')
184
+ const sessionId = sessionKeyOf(exec)
185
+ const startedAt = Date.now()
186
+ const activityBase = {
187
+ kind: 'exec',
188
+ server: server.name,
189
+ sessionId,
190
+ }
200
191
 
201
192
  if (args?.run_in_background === true) {
202
193
  if (typeof adapter.buildBackgroundCommand !== 'function') {
203
- throw new Error(`${adapter.toolName} does not support run_in_background`)
194
+ throw new Error(`${toolName} does not support run_in_background`)
204
195
  }
205
196
  const background = adapter.buildBackgroundCommand(command, args?.workdir, server, dialect)
206
197
  if (background.error !== undefined) throw new Error(background.error)
@@ -215,15 +206,15 @@ export function createRemoteShellTool({
215
206
  stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
216
207
  })
217
208
  } catch (error) {
209
+ try {
210
+ recordActivity?.({ ...activityBase, summary: `[background] ${command.slice(0, 190)}`, outcome: 'failed', durationMs: Date.now() - startedAt })
211
+ } catch {
212
+ // Activity recording is best effort.
213
+ }
218
214
  throw new Error(`remote execution failed: ${describeError(error)}`)
219
215
  }
220
216
  try {
221
- recordActivity?.({
222
- kind: 'exec',
223
- server: server.name,
224
- summary: `[background] ${command.slice(0, 190)}`,
225
- outcome: describeResultOutcome(start),
226
- })
217
+ recordActivity?.({ ...activityBase, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start), durationMs: Date.now() - startedAt })
227
218
  } catch {
228
219
  // Activity recording is best effort.
229
220
  }
@@ -280,11 +271,16 @@ export function createRemoteShellTool({
280
271
  stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
281
272
  })
282
273
  } catch (error) {
274
+ try {
275
+ recordActivity?.({ ...activityBase, summary: command.slice(0, 200), outcome: 'failed', durationMs: Date.now() - startedAt })
276
+ } catch {
277
+ // Activity recording is best effort.
278
+ }
283
279
  throw new Error(`remote execution failed: ${describeError(error)}`)
284
280
  }
285
281
 
286
282
  try {
287
- recordActivity?.({ kind: 'exec', server: server.name, summary: command.slice(0, 200), outcome: describeResultOutcome(result) })
283
+ recordActivity?.({ ...activityBase, summary: command.slice(0, 200), outcome: describeResultOutcome(result), durationMs: Date.now() - startedAt })
288
284
  } catch {
289
285
  // Activity recording is best effort.
290
286
  }
@@ -297,12 +293,6 @@ export function createRemoteShellTool({
297
293
  }
298
294
 
299
295
  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
296
  if (result?.outcome === 'timeout') {
307
297
  notices.push(
308
298
  typeof adapter.timeoutNotice === 'function' ? adapter.timeoutNotice(capability) : defaultTimeoutNotice(capability),
@@ -318,4 +308,3 @@ export function createRemoteShellTool({
318
308
  },
319
309
  })
320
310
  }
321
-
@@ -8,7 +8,6 @@
8
8
  * plaintext off disk without blocking the plugin.
9
9
  */
10
10
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
11
- import fs from 'node:fs'
12
11
  import { readTextFile, secretsKeyPath, secretsPath, writePrivateFile } from './paths.js'
13
12
 
14
13
  const ALGO = 'aes-256-gcm'
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Resolve one managed-resource reference (name or id) against the inventory.
3
+ *
4
+ * Every remote tool call carries its own `server`. Matching is
5
+ * case-insensitive so a hand-typed name still works, but the resolved record is
6
+ * always the inventory record the runtime uses.
7
+ */
8
+
9
+ /** @param {object[]} servers @param {string} ref */
10
+ export function findServerRef(servers, ref) {
11
+ const value = String(ref ?? '').trim()
12
+ if (!value) return undefined
13
+ const wanted = value.toLowerCase()
14
+ return (Array.isArray(servers) ? servers : []).find((server) => {
15
+ if (!server || typeof server !== 'object') return false
16
+ return (
17
+ String(server.id ?? '').toLowerCase() === wanted ||
18
+ String(server.name ?? '').toLowerCase() === wanted
19
+ )
20
+ })
21
+ }
22
+
23
+ /** Compact, model-facing list of enabled resource names. */
24
+ function serverNameList(servers, limit = 20) {
25
+ return (Array.isArray(servers) ? servers : [])
26
+ .filter((server) => server && server.enabled !== false && server.name)
27
+ .slice(0, Math.max(1, limit))
28
+ .map((server) => String(server.name))
29
+ }
30
+
31
+ /**
32
+ * Resolve and validate a server reference for a tool call.
33
+ * Throws a model-readable error that lists the available resources.
34
+ *
35
+ * @param {object[]} servers
36
+ * @param {string} ref
37
+ * @param {{ label?: string, allowDisabled?: boolean }} [options]
38
+ */
39
+ export function requireServerRef(servers, ref, options = {}) {
40
+ const label = options.label ?? 'server'
41
+ const value = String(ref ?? '').trim()
42
+ if (!value) {
43
+ throw new Error(`${label} is required; pass a managed resource name or id`)
44
+ }
45
+ if (value.toLowerCase() === 'local') {
46
+ throw new Error(
47
+ `${label} "${value}" is not a remote resource: the remote shell tools do not run on the local DSH host; use the native shell tool for local work`,
48
+ )
49
+ }
50
+ const server = findServerRef(servers, value)
51
+ if (server === undefined) {
52
+ const names = serverNameList(servers)
53
+ throw new Error(
54
+ `unknown ${label} "${value}". Available resources: ${names.length > 0 ? names.join(', ') : '(none)'}`,
55
+ )
56
+ }
57
+ if (server.enabled === false && options.allowDisabled !== true) {
58
+ throw new Error(`${label} "${server.name}" is disabled`)
59
+ }
60
+ return server
61
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { buildPosixBackgroundCommand } from './background.js'
13
13
  import { normalizeDialect } from './dialect.js'
14
- import { applyWorkdir, resolveTimeoutMs, wrapRemoteTimeout } from './remote-bash.js'
14
+ import { applyWorkdir, resolveTimeoutMs, wrapRemoteTimeout } from './posix-shell.js'
15
15
  import { win32Adapter } from './win32-adapter.js'
16
16
 
17
17
  /** Normalize the Host process platform to the two worlds the plugin cares about. */
package/lib/core/ssh.js CHANGED
@@ -269,6 +269,26 @@ export function createSshRuntime({ getSecret, knownHosts, config = {}, log = ()
269
269
  }))
270
270
  }
271
271
 
272
+ /**
273
+ * `stat` follows symlinks; `lstat` reports the link itself. Atomic writes use
274
+ * this to avoid replacing a symlink with a regular file.
275
+ */
276
+ async function sftpLstat(server, remotePath) {
277
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
278
+ sftp.lstat(remotePath, (error, attrs) => {
279
+ if (error) return reject(toSftpError(error))
280
+ const type = typeof attrs?.isSymbolicLink === 'function' && attrs.isSymbolicLink() ? 'link' : undefined
281
+ resolve({ path: remotePath, ...statOf(attrs), ...(type ? { type } : {}) })
282
+ })
283
+ }))
284
+ }
285
+
286
+ async function sftpChmod(server, remotePath, mode) {
287
+ return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
288
+ sftp.chmod(remotePath, mode, (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, mode })))
289
+ }))
290
+ }
291
+
272
292
  function clampReadBytes(value, fallback) {
273
293
  const hardCap = 64 * 1024 * 1024
274
294
  return Number.isInteger(value) ? Math.max(1024, Math.min(value, hardCap)) : fallback
@@ -707,6 +727,8 @@ export function createSshRuntime({ getSecret, knownHosts, config = {}, log = ()
707
727
  withSftp,
708
728
  sftpList,
709
729
  sftpStat,
730
+ sftpLstat,
731
+ sftpChmod,
710
732
  sftpHome,
711
733
  sftpRead,
712
734
  sftpReadBuffer,
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { BG_ERR_MARKER, BG_LOG_MARKER, BG_PID_MARKER } from './background.js'
16
16
  import { normalizeDialect } from './dialect.js'
17
- import { resolveTimeoutMs } from './remote-bash.js'
17
+ import { resolveTimeoutMs } from './posix-shell.js'
18
18
 
19
19
  const POWERSHELL_UTF8 = [
20
20
  "$ErrorActionPreference = 'Continue'",