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,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route the native `bash` tool to a managed server.
|
|
3
|
+
*
|
|
4
|
+
* `tools/execute` is DSH's around-dispatch waterfall. This handler returns a
|
|
5
|
+
* replacement result for exactly one tool — the built-in `bash` — and only
|
|
6
|
+
* while the session has an active managed target; every other call falls
|
|
7
|
+
* through with `next()`. The replacement value is the native bash foreground
|
|
8
|
+
* value, so the registry validates it against the built-in output schema and
|
|
9
|
+
* renders it with the built-in terminal renderer: no shadow tool, no schema
|
|
10
|
+
* copy, no custom rendering.
|
|
11
|
+
*/
|
|
12
|
+
import { describeResultOutcome } from './activity.js'
|
|
13
|
+
import { buildPosixBackgroundCommand, parseBackgroundStart, renderBackgroundStart } from './background.js'
|
|
14
|
+
import { effectiveDialect } from './dialect.js'
|
|
15
|
+
import { quotePosix } from './posix-quote.js'
|
|
16
|
+
import { findTarget } from './targets.js'
|
|
17
|
+
|
|
18
|
+
export const BASH_TOOL_NAME = 'bash'
|
|
19
|
+
|
|
20
|
+
const MIN_TIMEOUT_MS = 1000
|
|
21
|
+
const POSIX_PLATFORM = 'posix'
|
|
22
|
+
|
|
23
|
+
export { quotePosix }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a remote command with the remote `timeout` utility.
|
|
27
|
+
*
|
|
28
|
+
* A client-side timeout only closes the SSH channel; a process that ignores
|
|
29
|
+
* SIGHUP (nohup/background) keeps running on the server. Wrapping the command
|
|
30
|
+
* gives the remote side the same deadline: coreutils `timeout` runs the
|
|
31
|
+
* managed command in its own process group and signals the whole group, so
|
|
32
|
+
* when the client gives up the remote work stops too. `kill` adds `-k <n>`
|
|
33
|
+
* (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
|
|
34
|
+
* capability) leaves the command untouched.
|
|
35
|
+
*
|
|
36
|
+
* The wrapper re-enters the remote login shell (`<shell> -c`) so shell
|
|
37
|
+
* semantics do not change. The shell path comes from the capability probe and
|
|
38
|
+
* is referenced as a quoted literal, so this also works on fish/csh (no POSIX
|
|
39
|
+
* `${SHELL:-...}` expansion). The command itself is single-quote escaped and
|
|
40
|
+
* therefore expanded exactly once, inside that shell.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} command
|
|
43
|
+
* @param {number} timeoutMs
|
|
44
|
+
* @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
|
|
45
|
+
* @param {{ killAfterMs?: number }} [options]
|
|
46
|
+
*/
|
|
47
|
+
export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
|
|
48
|
+
const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
|
|
49
|
+
const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
|
|
50
|
+
if (mode === 'none') return String(command ?? '')
|
|
51
|
+
const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
|
|
52
|
+
const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
|
|
53
|
+
const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
|
|
54
|
+
const seconds = Math.max(1, Number(timeoutMs) / 1000)
|
|
55
|
+
const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
|
|
56
|
+
return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
|
|
61
|
+
*
|
|
62
|
+
* The remote shell starts in the login home; absolute paths are used verbatim,
|
|
63
|
+
* `~`/`~/x` and relative paths are anchored at `$HOME`.
|
|
64
|
+
*
|
|
65
|
+
* @returns {{ command: string, cwd: string } | { error: string }}
|
|
66
|
+
*/
|
|
67
|
+
export function applyWorkdir(command, workdir) {
|
|
68
|
+
const raw = typeof workdir === 'string' ? workdir.trim() : ''
|
|
69
|
+
const body = String(command ?? '')
|
|
70
|
+
if (!raw) return { command: body, cwd: '~' }
|
|
71
|
+
if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
|
|
72
|
+
if (raw.startsWith('~')) {
|
|
73
|
+
const rest = raw === '~' ? '' : raw.slice(1)
|
|
74
|
+
if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
|
|
75
|
+
const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
|
|
76
|
+
return { command: `cd -- ${target} && ${body}`, cwd: raw }
|
|
77
|
+
}
|
|
78
|
+
if (raw.startsWith('/')) {
|
|
79
|
+
return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
|
|
80
|
+
}
|
|
81
|
+
return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Effective timeout for one call, clamped to the configured ceiling. */
|
|
85
|
+
export function resolveTimeoutMs(args, config) {
|
|
86
|
+
const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
|
|
87
|
+
const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
|
|
88
|
+
const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
|
|
89
|
+
const value = requested ?? fallback
|
|
90
|
+
return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Map one runtime exec result onto the native bash foreground value.
|
|
95
|
+
* Non-zero exits are normal results; only "the command never ran" becomes an
|
|
96
|
+
* error result on the caller side.
|
|
97
|
+
*/
|
|
98
|
+
export function toBashValue(result, { cwd, timeoutMs }) {
|
|
99
|
+
const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
|
|
100
|
+
const stderr = typeof result?.stderr === 'string' ? result.stderr : ''
|
|
101
|
+
const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
|
|
102
|
+
const signal = typeof result?.signal === 'string' && result.signal.length > 0 ? result.signal : null
|
|
103
|
+
return {
|
|
104
|
+
kind: 'foreground',
|
|
105
|
+
cwd,
|
|
106
|
+
exitCode,
|
|
107
|
+
signal,
|
|
108
|
+
timedOut: result?.outcome === 'timeout',
|
|
109
|
+
aborted: result?.outcome === 'cancelled',
|
|
110
|
+
timeoutMs,
|
|
111
|
+
stdout: { text: stdout, truncated: result?.truncated?.stdout === true },
|
|
112
|
+
stderr: { text: stderr, truncated: result?.truncated?.stderr === true },
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether a runtime result means the command never reached the remote shell.
|
|
118
|
+
* Timeouts and aborts are real command outcomes (timedOut / aborted on the
|
|
119
|
+
* native value), not transport failures.
|
|
120
|
+
*/
|
|
121
|
+
export function isTransportFailure(result) {
|
|
122
|
+
if (!result || typeof result !== 'object') return true
|
|
123
|
+
if (result.exitCode !== null && result.exitCode !== undefined) return false
|
|
124
|
+
if (typeof result.signal === 'string' && result.signal.length > 0) return false
|
|
125
|
+
return result.outcome === 'failed'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build the structured error result the pipeline accepts for a failed call. */
|
|
129
|
+
export function createErrorResult(message, code = 'SSH_FAILED') {
|
|
130
|
+
const text = String(message ?? 'remote bash failed')
|
|
131
|
+
return {
|
|
132
|
+
isError: true,
|
|
133
|
+
content: [{ type: 'text', text: `Error: ${code}: ${text}` }],
|
|
134
|
+
error: { message: text, info: { name: 'SshOpsError', code } },
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} deps
|
|
140
|
+
* @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
|
|
141
|
+
* @param {object} deps.targets per-session target store
|
|
142
|
+
* @param {object} deps.config resolved plugin config
|
|
143
|
+
* @param {() => object[]} deps.loadInventory
|
|
144
|
+
* @param {(entry: object) => void} [deps.recordActivity]
|
|
145
|
+
* @returns {(exec: object, next: () => Promise<object>) => Promise<object>}
|
|
146
|
+
*/
|
|
147
|
+
export function createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity } = {}) {
|
|
148
|
+
return async function remoteBashRouter(exec, next) {
|
|
149
|
+
if (!exec || exec.name !== BASH_TOOL_NAME) return next()
|
|
150
|
+
const session = exec.agent?.session
|
|
151
|
+
const key = session?.id ?? exec.agent?.id
|
|
152
|
+
const binding = typeof key === 'string' ? targets?.get(key) : undefined
|
|
153
|
+
if (!binding || !binding.active) {
|
|
154
|
+
// Several `@server` mentions clear the active target. Running bash locally
|
|
155
|
+
// in that state would execute commands meant for a server on the DSH host,
|
|
156
|
+
// so make the model choose a target first.
|
|
157
|
+
if (binding && Array.isArray(binding.candidates) && binding.candidates.length > 1) {
|
|
158
|
+
return createErrorResult(
|
|
159
|
+
`several servers were mentioned in this session (${binding.candidates.join(', ')}); ` +
|
|
160
|
+
'call ssh_workbench_target({ server: "<name>" }) before running bash, or server: "local" to run locally',
|
|
161
|
+
'TARGET_NOT_SELECTED',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return next()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const args = exec.arguments ?? {}
|
|
168
|
+
const command = typeof args.command === 'string' ? args.command : ''
|
|
169
|
+
if (!command.trim()) return next()
|
|
170
|
+
|
|
171
|
+
let servers
|
|
172
|
+
try {
|
|
173
|
+
servers = loadInventory()
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return createErrorResult(`cannot read the managed server inventory: ${describeError(error)}`, 'INVENTORY_UNAVAILABLE')
|
|
176
|
+
}
|
|
177
|
+
const server = findTarget(servers, binding.active)
|
|
178
|
+
if (server === undefined) {
|
|
179
|
+
return createErrorResult(
|
|
180
|
+
`target "${binding.active}" is no longer in the managed inventory; call ${'ssh_workbench_target'} to pick another target or "local"`,
|
|
181
|
+
'TARGET_NOT_FOUND',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (server.enabled === false) {
|
|
185
|
+
return createErrorResult(
|
|
186
|
+
`target "${server.name}" is disabled; call ssh_workbench_target to pick another target or "local"`,
|
|
187
|
+
'TARGET_DISABLED',
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
if (effectiveDialect(server) !== POSIX_PLATFORM) {
|
|
191
|
+
return createErrorResult(
|
|
192
|
+
`target "${server.name}" is a Windows server; the native bash tool only runs POSIX commands — use remote_pwsh for Windows targets`,
|
|
193
|
+
'TARGET_PLATFORM_UNSUPPORTED',
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const workdir = applyWorkdir(command, args.workdir)
|
|
198
|
+
if (workdir.error !== undefined) return createErrorResult(workdir.error, 'INVALID_WORKDIR')
|
|
199
|
+
|
|
200
|
+
// A detached command returns only remote facts that survive across
|
|
201
|
+
// transports: pid + log file. It is not registered in DSH's local jobs.
|
|
202
|
+
if (args.run_in_background === true) {
|
|
203
|
+
const startTimeout = Math.min(resolveTimeoutMs(args, config), 30000)
|
|
204
|
+
let start
|
|
205
|
+
try {
|
|
206
|
+
start = await runtime.exec(server, buildPosixBackgroundCommand(workdir.command), {
|
|
207
|
+
timeoutMs: startTimeout,
|
|
208
|
+
signal: exec.signal,
|
|
209
|
+
readOnly: false,
|
|
210
|
+
cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
|
|
211
|
+
stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
|
|
212
|
+
})
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
recordActivity?.({ kind: 'exec', server: server.name, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start) })
|
|
218
|
+
} catch {
|
|
219
|
+
// Activity recording is best effort.
|
|
220
|
+
}
|
|
221
|
+
if (isTransportFailure(start)) {
|
|
222
|
+
const detail = typeof start?.stderr === 'string' && start.stderr.trim() ? start.stderr.trim() : 'remote execution failed'
|
|
223
|
+
return createErrorResult(detail, start?.errorCode ?? 'SSH_EXEC_FAILED')
|
|
224
|
+
}
|
|
225
|
+
const started = parseBackgroundStart(start.stdout)
|
|
226
|
+
if (started.pid === undefined) {
|
|
227
|
+
const detail = String(start?.stderr ?? '').trim() || String(start?.stdout ?? '').trim()
|
|
228
|
+
return createErrorResult(`background start failed${detail ? `: ${detail}` : ''}`, 'REMOTE_BACKGROUND_FAILED')
|
|
229
|
+
}
|
|
230
|
+
const text = renderBackgroundStart({
|
|
231
|
+
pid: started.pid,
|
|
232
|
+
log: started.log,
|
|
233
|
+
errLog: started.errLog,
|
|
234
|
+
target: `${server.name} (${server.username}@${server.host}:${server.port})`,
|
|
235
|
+
command,
|
|
236
|
+
dialect: 'posix',
|
|
237
|
+
})
|
|
238
|
+
const synthetic = {
|
|
239
|
+
outcome: 'success',
|
|
240
|
+
exitCode: 0,
|
|
241
|
+
signal: null,
|
|
242
|
+
stdout: text,
|
|
243
|
+
stderr: '',
|
|
244
|
+
truncated: { stdout: false, stderr: false },
|
|
245
|
+
}
|
|
246
|
+
return { value: toBashValue(synthetic, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs: startTimeout }) }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const timeoutMs = resolveTimeoutMs(args, config)
|
|
250
|
+
|
|
251
|
+
// Detect (and cache) whether the server can enforce a remote deadline, so
|
|
252
|
+
// a timed-out call also stops the remote work instead of leaving it behind.
|
|
253
|
+
let capability = { mode: 'none', shell: '/bin/sh' }
|
|
254
|
+
try {
|
|
255
|
+
if (typeof runtime.timeoutGuard === 'function') {
|
|
256
|
+
const probed = await runtime.timeoutGuard(server)
|
|
257
|
+
capability = typeof probed === 'string' ? { mode: probed } : (probed ?? capability)
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
capability = { mode: 'none', shell: '/bin/sh' }
|
|
261
|
+
}
|
|
262
|
+
const remoteCommand = wrapRemoteTimeout(workdir.command, timeoutMs, capability, {
|
|
263
|
+
killAfterMs: config?.timeoutKillAfterMs,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
let result
|
|
267
|
+
try {
|
|
268
|
+
result = await runtime.exec(server, remoteCommand, {
|
|
269
|
+
timeoutMs,
|
|
270
|
+
signal: exec.signal,
|
|
271
|
+
readOnly: false,
|
|
272
|
+
cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
|
|
273
|
+
stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
|
|
274
|
+
})
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
recordActivity?.({
|
|
281
|
+
kind: 'exec',
|
|
282
|
+
server: server.name,
|
|
283
|
+
summary: command.slice(0, 200),
|
|
284
|
+
outcome: describeResultOutcome(result),
|
|
285
|
+
})
|
|
286
|
+
} catch {
|
|
287
|
+
// Activity recording is best effort.
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (isTransportFailure(result)) {
|
|
291
|
+
const code = typeof result?.errorCode === 'string' && result.errorCode ? result.errorCode : 'SSH_EXEC_FAILED'
|
|
292
|
+
const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
|
|
293
|
+
const suggestion = typeof result?.suggestedAction === 'string' && result.suggestedAction ? `\n${result.suggestedAction}` : ''
|
|
294
|
+
return createErrorResult(`${detail}${suggestion}`, code)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The cwd label is also what the native Bash card shows in its expanded
|
|
298
|
+
// prompt; prefixing the target makes a card self-describing in multi-target
|
|
299
|
+
// sessions without shadowing DSH's shipped Bash renderer.
|
|
300
|
+
let value = toBashValue(result, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs })
|
|
301
|
+
const notices = []
|
|
302
|
+
// One line after each target change: a card-level "where did this run" hint.
|
|
303
|
+
if (binding.notifiedVersion !== binding.version && typeof targets?.markNotified === 'function') {
|
|
304
|
+
notices.push(`[remote] target: ${server.name} (${server.username}@${server.host}:${server.port})`)
|
|
305
|
+
targets.markNotified(key, binding.version)
|
|
306
|
+
}
|
|
307
|
+
if (result?.outcome === 'timeout') {
|
|
308
|
+
notices.push(
|
|
309
|
+
capability.mode === 'none'
|
|
310
|
+
? '[remote] client-side timeout only: this server has no usable `timeout` utility, so the remote process may still be running.'
|
|
311
|
+
: `[remote] timeout: the remote command group was terminated (SIGTERM${capability.mode === 'kill' ? ', KILL after the remote deadline' : ''}).`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
if (result?.outcome === 'cancelled') {
|
|
315
|
+
notices.push(
|
|
316
|
+
capability.mode === 'none'
|
|
317
|
+
? '[remote] cancelled: the SSH channel was closed, but this server has no usable `timeout` utility, so the remote process may still be running.'
|
|
318
|
+
: '[remote] cancelled: the SSH channel was closed; the remote wrapper terminates the command at its deadline if it is still running.',
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
if (args.sandbox_permissions !== undefined || args.justification !== undefined) {
|
|
322
|
+
notices.push('[remote] sandbox_permissions does not apply to managed servers; the command ran over SSH.')
|
|
323
|
+
}
|
|
324
|
+
if (notices.length > 0) {
|
|
325
|
+
const text = value.stderr.text
|
|
326
|
+
value = {
|
|
327
|
+
...value,
|
|
328
|
+
stderr: { ...value.stderr, text: `${text}${text ? '\n' : ''}${notices.join('\n')}` },
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { value }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function describeError(error) {
|
|
336
|
+
return error instanceof Error ? error.message : String(error)
|
|
337
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret storage for SSH passwords/passphrases.
|
|
3
|
+
*
|
|
4
|
+
* Secrets live in a private JSON document, encrypted with AES-256-GCM using a
|
|
5
|
+
* per-installation random key (0600) generated on first use. The DSH
|
|
6
|
+
* credentials service is the long-term home for these values; until that
|
|
7
|
+
* contract is verified on the desktop runtime this self-contained store keeps
|
|
8
|
+
* plaintext off disk without blocking the plugin.
|
|
9
|
+
*/
|
|
10
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import { readTextFile, secretsKeyPath, secretsPath, writePrivateFile } from './paths.js'
|
|
13
|
+
|
|
14
|
+
const ALGO = 'aes-256-gcm'
|
|
15
|
+
const PREFIX = 'enc:v1:'
|
|
16
|
+
|
|
17
|
+
function loadKey(file = secretsKeyPath()) {
|
|
18
|
+
const text = readTextFile(file).trim()
|
|
19
|
+
if (text) {
|
|
20
|
+
const key = Buffer.from(text, 'base64')
|
|
21
|
+
if (key.length === 32) return key
|
|
22
|
+
}
|
|
23
|
+
const key = randomBytes(32)
|
|
24
|
+
writePrivateFile(file, `${key.toString('base64')}\n`)
|
|
25
|
+
return key
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readDoc(file) {
|
|
29
|
+
const text = readTextFile(file)
|
|
30
|
+
if (!text.trim()) return { version: 1, entries: {} }
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(text)
|
|
33
|
+
if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
|
|
34
|
+
return parsed
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// corrupt document: fall through to an empty one rather than crashing boot
|
|
38
|
+
}
|
|
39
|
+
return { version: 1, entries: {} }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encrypt(plain, key) {
|
|
43
|
+
const iv = randomBytes(12)
|
|
44
|
+
const cipher = createCipheriv(ALGO, key, iv)
|
|
45
|
+
const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()])
|
|
46
|
+
const tag = cipher.getAuthTag()
|
|
47
|
+
return PREFIX + Buffer.concat([iv, tag, data]).toString('base64')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decrypt(token, key) {
|
|
51
|
+
if (typeof token !== 'string' || !token.startsWith(PREFIX)) return undefined
|
|
52
|
+
const payload = Buffer.from(token.slice(PREFIX.length), 'base64')
|
|
53
|
+
if (payload.length <= 28) return undefined
|
|
54
|
+
const iv = payload.subarray(0, 12)
|
|
55
|
+
const tag = payload.subarray(12, 28)
|
|
56
|
+
const data = payload.subarray(28)
|
|
57
|
+
const decipher = createDecipheriv(ALGO, key, iv)
|
|
58
|
+
decipher.setAuthTag(tag)
|
|
59
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createSecretStore({ file = secretsPath(), keyFile = secretsKeyPath() } = {}) {
|
|
63
|
+
let key
|
|
64
|
+
const keyOf = () => {
|
|
65
|
+
if (!key) key = loadKey(keyFile)
|
|
66
|
+
return key
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
get(recordKey) {
|
|
70
|
+
const doc = readDoc(file)
|
|
71
|
+
const token = doc.entries[recordKey]
|
|
72
|
+
if (token === undefined) return undefined
|
|
73
|
+
try {
|
|
74
|
+
return decrypt(token, keyOf())
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
has(recordKey) {
|
|
80
|
+
const doc = readDoc(file)
|
|
81
|
+
return doc.entries[recordKey] !== undefined
|
|
82
|
+
},
|
|
83
|
+
set(recordKey, value) {
|
|
84
|
+
const doc = readDoc(file)
|
|
85
|
+
doc.entries[recordKey] = encrypt(value, keyOf())
|
|
86
|
+
writePrivateFile(file, `${JSON.stringify(doc, null, 2)}\n`)
|
|
87
|
+
},
|
|
88
|
+
delete(recordKey) {
|
|
89
|
+
const doc = readDoc(file)
|
|
90
|
+
if (doc.entries[recordKey] === undefined) return false
|
|
91
|
+
delete doc.entries[recordKey]
|
|
92
|
+
writePrivateFile(file, `${JSON.stringify(doc, null, 2)}\n`)
|
|
93
|
+
return true
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|