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.
@@ -63,6 +63,11 @@ export function createActivityStore({ file = activityPath(), maxEntries = MAX_AC
63
63
  ...(entry?.server !== undefined ? { server: String(entry.server) } : {}),
64
64
  ...(entry?.summary !== undefined ? { summary: String(entry.summary) } : {}),
65
65
  ...(entry?.outcome !== undefined ? { outcome: String(entry.outcome) } : {}),
66
+ // Session/duration let the composer dock summarize this session's remote
67
+ // executions without reading DSH's private session metrics. Absent on
68
+ // older entries; readers must tolerate that.
69
+ ...(typeof entry?.sessionId === 'string' && entry.sessionId.length > 0 ? { sessionId: entry.sessionId } : {}),
70
+ ...(Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? { durationMs: Math.round(entry.durationMs) } : {}),
66
71
  }
67
72
  const next = [normalized, ...load()].slice(0, maxEntries)
68
73
  save(next)
@@ -39,10 +39,17 @@ export function parseBackgroundStart(stdout) {
39
39
  * `nohup` + redirected stdio is what keeps the child alive after the SSH
40
40
  * channel closes; the whole launcher runs under `sh -c` so a fish/csh login
41
41
  * shell only has to parse the simple outer command.
42
+ *
43
+ * Logs default to `~/.dsh/ssh-workbench/logs` instead of `/tmp`, because a
44
+ * detached process may outlive the tmp cleanup window; `DSH_SSH_WORKBENCH_LOG_DIR`
45
+ * overrides the directory remotely, and `/tmp` stays as the fallback when the
46
+ * home directory is not writable.
42
47
  */
43
48
  export function buildPosixBackgroundCommand(builtCommand) {
44
49
  const script = [
45
- 'LOG=$(mktemp /tmp/dsh-ssh-workbench-bg.XXXXXX 2>/dev/null || echo /tmp/dsh-ssh-workbench-bg-$$-$(date +%s).log)',
50
+ 'LOG_DIR=${DSH_SSH_WORKBENCH_LOG_DIR:-$HOME/.dsh/ssh-workbench/logs}',
51
+ 'mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR=/tmp',
52
+ 'LOG=$(mktemp "$LOG_DIR/dsh-ssh-workbench-bg.XXXXXX" 2>/dev/null || echo "$LOG_DIR/dsh-ssh-workbench-bg-$$-$(date +%s).log")',
46
53
  `nohup "\${SHELL:-/bin/sh}" -lc ${quotePosix(builtCommand)} >"$LOG" 2>&1 &`,
47
54
  `echo "${BG_PID_MARKER}$!"`,
48
55
  `echo "${BG_LOG_MARKER}$LOG"`,
@@ -50,10 +57,10 @@ export function buildPosixBackgroundCommand(builtCommand) {
50
57
  return `sh -c ${quotePosix(script)}`
51
58
  }
52
59
 
53
- /** One model-facing description of a started background process. */
54
- export function renderBackgroundStart({ pid, log, errLog, target, command, dialect = 'posix' }) {
60
+ /** The pid/log/stop lines shared by every background result renderer. */
61
+ export function renderBackgroundDetails({ pid, log, errLog, dialect = 'posix' }) {
55
62
  const windows = dialect === 'windows'
56
- const lines = [`background: started${target ? ` on ${target}` : ''}`, `command: ${command}`]
63
+ const lines = []
57
64
  if (pid !== undefined) lines.push(`pid: ${pid}`)
58
65
  if (log) lines.push(`log: ${log}`)
59
66
  if (errLog) lines.push(`stderr log: ${errLog}`)
@@ -65,5 +72,12 @@ export function renderBackgroundStart({ pid, log, errLog, target, command, diale
65
72
  )
66
73
  if (pid !== undefined) lines.push(windows ? `stop: Stop-Process -Id ${pid} -Force` : `stop: kill ${pid}`)
67
74
  lines.push('The process is detached from this tool call: timeoutMs does not apply and it keeps running until it exits or is stopped.')
75
+ return lines
76
+ }
77
+
78
+ /** One model-facing description of a started background process. */
79
+ export function renderBackgroundStart({ pid, log, errLog, target, command, dialect = 'posix' }) {
80
+ const lines = [`background: started${target ? ` on ${target}` : ''}`, `command: ${command}`]
81
+ lines.push(...renderBackgroundDetails({ pid, log, errLog, dialect }))
68
82
  return lines.join('\n')
69
83
  }
@@ -177,13 +177,16 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
177
177
  return `${server.host}|${server.port}|${server.username}|${auth}`
178
178
  }
179
179
 
180
- function connectOptions(server, state) {
180
+ function connectOptions(server, state, connectTimeoutMs) {
181
181
  const hostKeyMode = settings.hostKeyMode === 'accept-new' ? 'accept-new' : 'strict'
182
+ const readyTimeout = Number.isInteger(connectTimeoutMs) && connectTimeoutMs > 0
183
+ ? Math.min(connectTimeoutMs, settings.connectTimeoutMs)
184
+ : settings.connectTimeoutMs
182
185
  return {
183
186
  host: server.host,
184
187
  port: server.port,
185
188
  username: server.username,
186
- readyTimeout: settings.connectTimeoutMs,
189
+ readyTimeout,
187
190
  keepaliveInterval: settings.keepaliveInterval,
188
191
  keepaliveCountMax: settings.keepaliveCountMax,
189
192
  hostHash: 'sha256',
@@ -197,7 +200,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
197
200
  }
198
201
 
199
202
  /** Open a fresh client and resolve on `ready`. */
200
- async function connectClient(server) {
203
+ async function connectClient(server, connectTimeoutMs) {
201
204
  const { Client } = await loadSsh2()
202
205
  const state = {}
203
206
  const client = new Client()
@@ -226,7 +229,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
226
229
  log('ssh client error', { server: server.name, error: error instanceof Error ? error.message : String(error) })
227
230
  })
228
231
  try {
229
- client.connect(connectOptions(server, state))
232
+ client.connect(connectOptions(server, state, connectTimeoutMs))
230
233
  } catch (error) {
231
234
  fail(error)
232
235
  }
@@ -281,7 +284,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
281
284
  }
282
285
 
283
286
 
284
- async function acquire(server) {
287
+ async function acquire(server, options = {}) {
285
288
  const id = server.id ?? server.name
286
289
  const key = serverKey(server)
287
290
  let entry = entries.get(id)
@@ -328,7 +331,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
328
331
  }
329
332
  created.pending = (async () => {
330
333
  try {
331
- const { client, fingerprint, connectMs } = await connectClient(server)
334
+ const { client, fingerprint, connectMs } = await connectClient(server, options.connectTimeoutMs)
332
335
  created.client = client
333
336
  created.fingerprint = fingerprint
334
337
  created.connectMs = connectMs
@@ -589,7 +592,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
589
592
  for (;;) {
590
593
  let acquired
591
594
  try {
592
- acquired = await acquire(server)
595
+ acquired = await acquire(server, { connectTimeoutMs: options.connectTimeoutMs })
593
596
  } catch (error) {
594
597
  const normalized = error instanceof SshError ? error : normalizeError(error)
595
598
  const result = errorResult(normalized.code, normalized.message, { durationMs: now() - startedAt })
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  /** The dialect values this plugin understands. */
12
- export const DIALECTS = Object.freeze({ POSIX: 'posix', WINDOWS: 'windows' })
12
+ const DIALECTS = Object.freeze({ POSIX: 'posix', WINDOWS: 'windows' })
13
13
 
14
14
  /** Normalize one raw dialect/platform value. */
15
15
  export function normalizeDialect(value) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Per-session execution summary for the composer target dock.
3
+ *
4
+ * DSH owns conversation/model metrics (turns, TTFT, TPS); the plugin cannot
5
+ * read them. This module only summarizes the activity entries the plugin
6
+ * writes for remote executions, so the dock can show accurate SSH-side counts
7
+ * and latency without inventing values it does not own.
8
+ */
9
+
10
+ function numericDuration(entry) {
11
+ return Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? entry.durationMs : 0
12
+ }
13
+
14
+ function emptyBucket(name) {
15
+ return { name, count: 0, success: 0, failed: 0, durationMs: 0, averageMs: 0, lastAt: null }
16
+ }
17
+
18
+ function addToBucket(bucket, entry) {
19
+ bucket.count += 1
20
+ if (entry.outcome === 'success') bucket.success += 1
21
+ else bucket.failed += 1
22
+ bucket.durationMs += numericDuration(entry)
23
+ if (typeof entry.at === 'string' && (bucket.lastAt === null || entry.at > bucket.lastAt)) bucket.lastAt = entry.at
24
+ }
25
+
26
+ /**
27
+ * Summarize the plugin's remote execution activity for one Session.
28
+ *
29
+ * @param {object[]} entries newest-first activity entries
30
+ * @param {{ sessionId?: string, candidates?: string[] }} options
31
+ * @returns {{
32
+ * sessionId: string|null,
33
+ * count: number,
34
+ * success: number,
35
+ * failed: number,
36
+ * durationMs: number,
37
+ * averageMs: number,
38
+ * lastAt: string|null,
39
+ * servers: Array<{name:string,count:number,success:number,failed:number,durationMs:number,averageMs:number,lastAt:string|null}>
40
+ * }}
41
+ */
42
+ export function summarizeExecution(entries, { sessionId, candidates = [] } = {}) {
43
+ const wanted = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null
44
+ const byServer = new Map()
45
+ const summary = {
46
+ sessionId: wanted,
47
+ count: 0,
48
+ success: 0,
49
+ failed: 0,
50
+ durationMs: 0,
51
+ averageMs: 0,
52
+ lastAt: null,
53
+ servers: [],
54
+ }
55
+
56
+ if (wanted !== null) {
57
+ for (const entry of Array.isArray(entries) ? entries : []) {
58
+ if (!entry || entry.kind !== 'exec' || entry.sessionId !== wanted) continue
59
+ summary.count += 1
60
+ if (entry.outcome === 'success') summary.success += 1
61
+ else summary.failed += 1
62
+ summary.durationMs += numericDuration(entry)
63
+ if (typeof entry.at === 'string' && (summary.lastAt === null || entry.at > summary.lastAt)) summary.lastAt = entry.at
64
+ const name = typeof entry.server === 'string' && entry.server.length > 0 ? entry.server : null
65
+ if (name === null) continue
66
+ if (!byServer.has(name)) byServer.set(name, emptyBucket(name))
67
+ addToBucket(byServer.get(name), entry)
68
+ }
69
+ }
70
+
71
+ summary.averageMs = summary.count > 0 ? Math.round(summary.durationMs / summary.count) : 0
72
+
73
+ // Keep the mentioned resources in mention order, then append any target that
74
+ // ran from an explicit remote_exec task without being a session candidate.
75
+ const names = []
76
+ for (const name of Array.isArray(candidates) ? candidates : []) {
77
+ const value = String(name)
78
+ if (value && !names.includes(value)) names.push(value)
79
+ }
80
+ for (const name of [...byServer.keys()].sort()) {
81
+ if (!names.includes(name)) names.push(name)
82
+ }
83
+ summary.servers = names.map((name) => {
84
+ const bucket = byServer.get(name) ?? emptyBucket(name)
85
+ return { ...bucket, averageMs: bucket.count > 0 ? Math.round(bucket.durationMs / bucket.count) : 0 }
86
+ })
87
+ return summary
88
+ }
@@ -58,7 +58,7 @@ const RULES = [
58
58
  ]
59
59
 
60
60
  /** Split one PowerShell command into independently-executed statements. */
61
- export function powerShellStatements(command) {
61
+ function powerShellStatements(command) {
62
62
  return String(command ?? '')
63
63
  .split(/[;\r\n]|\|\||&&|\|/)
64
64
  .map((part) => part.trim())
package/lib/core/guard.js CHANGED
@@ -1,25 +1,27 @@
1
1
  /**
2
- * Fallback blacklist for remote bash.
2
+ * Fallback blacklist for remote commands.
3
3
  *
4
4
  * Registered through `ctx.tools.guard()`: synchronous, monotonic, and checked
5
5
  * after DSH's own pre-execute policy and approval, so no later listener can
6
- * turn a denial back into a permission. It only inspects commands that will
7
- * actually run on a managed server (the session has an active target and the
8
- * tool is a POSIX remote shell: the native `bash` tool on POSIX Hosts, the
9
- * plugin-owned `remote_bash` tool on Windows Hosts); local bash, file tools and
10
- * every other tool are untouched.
6
+ * turn a denial back into a permission.
7
+ *
8
+ * There is no session-scoped target state: every remote tool names its own
9
+ * `server`, so the guard resolves that explicit reference. The native local
10
+ * `bash`/`pwsh` tools are never inspected — local work is outside this
11
+ * plugin's remote-command boundary.
11
12
  */
12
13
  import { classifyCommand } from './gate.js'
13
14
  import { classifyPowerShellCommand } from './gate-pwsh.js'
15
+ import { findServerRef } from './server-ref.js'
14
16
 
15
- /** The plugin-owned POSIX shell tool (Windows Hosts have no native bash). */
16
- export const REMOTE_BASH_TOOL_NAME = 'remote_bash'
17
+ /** The plugin-owned POSIX shell tool. */
18
+ const REMOTE_BASH_TOOL_NAME = 'remote_bash'
17
19
 
18
20
  /** The plugin-owned PowerShell tool (Windows targets). */
19
- export const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
21
+ const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
20
22
 
21
- /** Tools whose command argument runs in a POSIX shell on a managed target. */
22
- const POSIX_REMOTE_TOOL_NAMES = new Set(['bash', REMOTE_BASH_TOOL_NAME])
23
+ /** Explicit-target batch tool: every task names its own server. */
24
+ const REMOTE_EXEC_TOOL_NAME = 'remote_exec'
23
25
 
24
26
  export const BLOCK_REASON_PREFIX = 'dsh-ssh-workbench blocked this command: '
25
27
 
@@ -31,38 +33,88 @@ export function sessionKeyOf(exec) {
31
33
  return typeof id === 'string' && id.length > 0 ? id : undefined
32
34
  }
33
35
 
36
+ /**
37
+ * Pick the gate for one explicit server. A record's `platform` is only a hint:
38
+ * the executor probes the real dialect before running. When facts are not
39
+ * collected yet, run both blacklists so a stale hint cannot hide a
40
+ * Windows-only destructive command (and vice versa).
41
+ */
42
+ function classifyForServer(server, command) {
43
+ const dialect = server?.facts?.dialect
44
+ if (dialect === 'windows') return classifyPowerShellCommand(command)
45
+ if (dialect === 'posix') return classifyCommand(command)
46
+ const posix = classifyCommand(command)
47
+ if (posix.decision === 'deny') return posix
48
+ return classifyPowerShellCommand(command)
49
+ }
50
+
51
+ function summaryOf(prefix, command) {
52
+ const text = `${prefix}${command}`.slice(0, 200)
53
+ return text
54
+ }
55
+
34
56
  /**
35
57
  * @param {object} deps
36
- * @param {object} deps.targets per-session target store
37
58
  * @param {(entry: object) => void} [deps.recordActivity]
59
+ * @param {() => object[]} [deps.loadInventory]
38
60
  * @returns {(exec: object) => string|undefined} a `ctx.tools.guard()` handler
39
61
  */
40
- export function createBlacklistGuard({ targets, recordActivity } = {}) {
62
+ export function createBlacklistGuard({ recordActivity, loadInventory } = {}) {
63
+ function inventory() {
64
+ if (typeof loadInventory !== 'function') return undefined
65
+ try {
66
+ const servers = loadInventory()
67
+ return Array.isArray(servers) ? servers : undefined
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ function guardOne(exec, serverRef, command, label = '') {
74
+ const ref = typeof serverRef === 'string' ? serverRef.trim() : ''
75
+ if (!ref || ref.toLowerCase() === 'local') return undefined
76
+ if (!command.trim()) return undefined
77
+ const servers = inventory()
78
+ if (servers === undefined) return undefined
79
+ const server = findServerRef(servers, ref)
80
+ if (server === undefined) return undefined
81
+ const classification = classifyForServer(server, command)
82
+ if (classification.decision !== 'deny') return undefined
83
+ try {
84
+ recordActivity?.({
85
+ kind: 'exec',
86
+ server: server.name,
87
+ sessionId: sessionKeyOf(exec),
88
+ summary: summaryOf(label, command),
89
+ outcome: `blocked: ${classification.ruleId}`,
90
+ })
91
+ } catch {
92
+ // Activity recording is best effort and must never change the decision.
93
+ }
94
+ return `${BLOCK_REASON_PREFIX}${label}${classification.reason}`
95
+ }
96
+
97
+ /** Check every task independently; one denied task denies the whole batch. */
98
+ function guardBatch(exec) {
99
+ const tasks = exec.arguments?.tasks
100
+ if (!Array.isArray(tasks) || tasks.length === 0) return undefined
101
+ for (let index = 0; index < tasks.length; index += 1) {
102
+ const task = tasks[index]
103
+ if (!task || typeof task !== 'object') continue
104
+ const command = typeof task.command === 'string' ? task.command : ''
105
+ const denied = guardOne(exec, task.server, command, `task ${index + 1}: `)
106
+ if (denied !== undefined) return denied
107
+ }
108
+ return undefined
109
+ }
110
+
41
111
  return function blacklistGuard(exec) {
42
112
  try {
43
113
  if (!exec) return undefined
44
- const isPosix = POSIX_REMOTE_TOOL_NAMES.has(exec.name)
45
- const isPowerShell = exec.name === REMOTE_PWSH_TOOL_NAME
46
- if (!isPosix && !isPowerShell) return undefined
47
- const key = sessionKeyOf(exec)
48
- if (key === undefined || !targets) return undefined
49
- const binding = targets.get(key)
50
- if (!binding || !binding.active) return undefined
114
+ if (exec.name === REMOTE_EXEC_TOOL_NAME) return guardBatch(exec)
115
+ if (exec.name !== REMOTE_BASH_TOOL_NAME && exec.name !== REMOTE_PWSH_TOOL_NAME) return undefined
51
116
  const command = typeof exec.arguments?.command === 'string' ? exec.arguments.command : ''
52
- if (!command.trim()) return undefined
53
- const classification = isPowerShell ? classifyPowerShellCommand(command) : classifyCommand(command)
54
- if (classification.decision !== 'deny') return undefined
55
- try {
56
- recordActivity?.({
57
- kind: 'exec',
58
- server: binding.active,
59
- summary: command.slice(0, 200),
60
- outcome: `blocked: ${classification.ruleId}`,
61
- })
62
- } catch {
63
- // Activity recording is best effort and must never change the decision.
64
- }
65
- return `${BLOCK_REASON_PREFIX}${classification.reason}`
117
+ return guardOne(exec, exec.arguments?.server, command)
66
118
  } catch {
67
119
  // A guard must never turn its own failure into a denial of unrelated tools.
68
120
  return undefined
@@ -0,0 +1,101 @@
1
+ /**
2
+ * In-flight remote executions, for the composer's "current target" indicator.
3
+ *
4
+ * This is a display-only projection. It never selects a target, never reaches
5
+ * a tool, and disappears as soon as the dispatch settles. Every remote tool is
6
+ * explicit-server, so this map is the only place where the UI can learn what a
7
+ * running tool is touching.
8
+ */
9
+ import { sessionKeyOf } from './remote-shell-tool.js'
10
+ import {
11
+ REMOTE_EDIT_TOOL_NAME,
12
+ REMOTE_GLOB_TOOL_NAME,
13
+ REMOTE_GREP_TOOL_NAME,
14
+ REMOTE_READ_TOOL_NAME,
15
+ REMOTE_WRITE_TOOL_NAME,
16
+ } from './remote-file-tools.js'
17
+ import { REMOTE_EXEC_TOOL_NAME } from './remote-exec-tool.js'
18
+
19
+ const SINGLE_SERVER_TOOLS = new Set([
20
+ 'remote_bash',
21
+ 'remote_pwsh',
22
+ REMOTE_READ_TOOL_NAME,
23
+ REMOTE_WRITE_TOOL_NAME,
24
+ REMOTE_EDIT_TOOL_NAME,
25
+ REMOTE_GLOB_TOOL_NAME,
26
+ REMOTE_GREP_TOOL_NAME,
27
+ ])
28
+
29
+ /** Extract the explicit server names a remote tool call will touch. */
30
+ export function serversOf(exec) {
31
+ const name = exec?.name
32
+ const args = exec?.arguments
33
+ if (!name || !args || typeof args !== 'object') return []
34
+ if (name === REMOTE_EXEC_TOOL_NAME) {
35
+ const tasks = Array.isArray(args.tasks) ? args.tasks : []
36
+ const names = []
37
+ for (const task of tasks) {
38
+ if (task && typeof task.server === 'string' && task.server.length > 0 && !names.includes(task.server)) {
39
+ names.push(task.server)
40
+ }
41
+ }
42
+ return names
43
+ }
44
+ if (!SINGLE_SERVER_TOOLS.has(name)) return []
45
+ return typeof args.server === 'string' && args.server.length > 0 ? [args.server] : []
46
+ }
47
+
48
+ /** @param {{ now?: () => number }} [options] */
49
+ export function createLiveExecutions(options = {}) {
50
+ const now = options.now ?? Date.now
51
+ let nextToken = 0
52
+ /** @type {Map<number, { sessionId: string, tool: string, servers: string[], startedAt: number }>} */
53
+ const active = new Map()
54
+
55
+ /** Register one dispatch; returns undefined when this is not a remote call. */
56
+ function begin(exec) {
57
+ const servers = serversOf(exec)
58
+ if (servers.length === 0) return undefined
59
+ const sessionId = sessionKeyOf(exec)
60
+ if (!sessionId) return undefined
61
+ const token = ++nextToken
62
+ active.set(token, {
63
+ sessionId,
64
+ tool: typeof exec.name === 'string' ? exec.name : '',
65
+ servers,
66
+ startedAt: now(),
67
+ })
68
+ return token
69
+ }
70
+
71
+ function end(token) {
72
+ if (token !== undefined) active.delete(token)
73
+ }
74
+
75
+ /** @returns {{ count: number, servers: { name: string, count: number, startedAt: number }[] }} */
76
+ function snapshot(sessionId) {
77
+ const byName = new Map()
78
+ let calls = 0
79
+ for (const entry of active.values()) {
80
+ if (entry.sessionId !== sessionId) continue
81
+ calls += 1
82
+ for (const name of entry.servers) {
83
+ const previous = byName.get(name)
84
+ byName.set(name, {
85
+ name,
86
+ count: (previous?.count ?? 0) + 1,
87
+ startedAt: previous ? Math.min(previous.startedAt, entry.startedAt) : entry.startedAt,
88
+ })
89
+ }
90
+ }
91
+ return { count: calls, servers: [...byName.values()] }
92
+ }
93
+
94
+ function clear(sessionId) {
95
+ for (const [token, entry] of active) {
96
+ if (sessionId === undefined || entry.sessionId === sessionId) active.delete(token)
97
+ }
98
+ }
99
+
100
+ return { begin, end, snapshot, clear, size: () => active.size }
101
+ }