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,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,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
|
+
|