@tiphareth/dsh-hardssh 0.1.2 → 0.2.3
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/LICENSE +30 -0
- package/README.md +77 -50
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +333 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +42 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +59 -26
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +171 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/ssh/store.ts
CHANGED
|
@@ -29,6 +29,75 @@ interface StoreFile {
|
|
|
29
29
|
hosts: SshHostEntry[]
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** One parsed Host block from `~/.ssh/config`. */
|
|
33
|
+
interface SshConfigBlock {
|
|
34
|
+
pattern: string
|
|
35
|
+
props: Record<string, string>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse `~/.ssh/config` into importable candidate payloads. Pure (no store
|
|
39
|
+
* writes): the caller applies each candidate through ITS OWN create, so the
|
|
40
|
+
* plaintext `HostStore` and the secret-aware `SecureHostStore` share one
|
|
41
|
+
* parser while differing only in how an entry's secrets are stored. */
|
|
42
|
+
function parseSshConfigToImport(configPath: string): {
|
|
43
|
+
candidates: HostPayload[]
|
|
44
|
+
/** Wildcard / no-HostName patterns (reported as skipped, not importable). */
|
|
45
|
+
invalidNames: string[]
|
|
46
|
+
/** Total Host blocks parsed (including non-importable ones). */
|
|
47
|
+
totalBlocks: number
|
|
48
|
+
} {
|
|
49
|
+
const lines = readFileSync(configPath, 'utf8').split(/\r?\n/)
|
|
50
|
+
const blocks: SshConfigBlock[] = []
|
|
51
|
+
let current: SshConfigBlock | undefined
|
|
52
|
+
for (const raw of lines) {
|
|
53
|
+
const line = raw.trim()
|
|
54
|
+
if (line === '' || line.startsWith('#')) continue
|
|
55
|
+
const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
|
|
56
|
+
if (match === null) continue
|
|
57
|
+
const key = match[1].toLowerCase()
|
|
58
|
+
const value = match[2].trim()
|
|
59
|
+
if (key === 'host') {
|
|
60
|
+
current = { pattern: value, props: {} }
|
|
61
|
+
blocks.push(current)
|
|
62
|
+
} else if (current !== undefined) {
|
|
63
|
+
current.props[key] = value
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const candidates: HostPayload[] = []
|
|
67
|
+
const invalidNames: string[] = []
|
|
68
|
+
for (const block of blocks) {
|
|
69
|
+
const pattern = block.pattern.split(/\s+/)[0]
|
|
70
|
+
if (pattern.includes('*') || pattern.includes('?')) {
|
|
71
|
+
if (pattern !== '') invalidNames.push(pattern)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const hostName = block.props.hostname
|
|
75
|
+
if (hostName === undefined || hostName === '') {
|
|
76
|
+
if (pattern !== '') invalidNames.push(pattern)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
candidates.push({
|
|
80
|
+
alias: pattern,
|
|
81
|
+
host: hostName,
|
|
82
|
+
port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
|
|
83
|
+
user: block.props.user ?? process.env.USER ?? 'root',
|
|
84
|
+
auth: {
|
|
85
|
+
kind: block.props.identityfile !== undefined ? 'key' : 'password',
|
|
86
|
+
keyPath: block.props.identityfile,
|
|
87
|
+
password: block.props.password,
|
|
88
|
+
},
|
|
89
|
+
proxyJump: block.props.proxyjump !== undefined
|
|
90
|
+
? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
|
|
91
|
+
: [],
|
|
92
|
+
description: block.props.description,
|
|
93
|
+
environment: block.props.environment,
|
|
94
|
+
tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
95
|
+
location: block.props.location,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return { candidates, invalidNames, totalBlocks: blocks.length }
|
|
99
|
+
}
|
|
100
|
+
|
|
32
101
|
/** Payload shape the validator enforces: full create vs partial patch. */
|
|
33
102
|
export type HostPayloadValidationMode = 'create' | 'patch'
|
|
34
103
|
|
|
@@ -151,6 +220,11 @@ export class HostStore {
|
|
|
151
220
|
return file.hosts
|
|
152
221
|
}
|
|
153
222
|
|
|
223
|
+
/** The ssh-config path this store reads (override or the default). */
|
|
224
|
+
get sshConfigOverridePath(): string | undefined {
|
|
225
|
+
return this.sshConfigOverride
|
|
226
|
+
}
|
|
227
|
+
|
|
154
228
|
/** Find one entry by alias. */
|
|
155
229
|
find(alias: string): SshHostEntry | undefined {
|
|
156
230
|
return this.list().find(entry => entry.alias === alias)
|
|
@@ -290,7 +364,8 @@ export class HostStore {
|
|
|
290
364
|
/**
|
|
291
365
|
* Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
|
|
292
366
|
* pattern and a HostName become entries (key auth via IdentityFile, jump
|
|
293
|
-
* hosts via ProxyJump). Existing aliases are skipped.
|
|
367
|
+
* hosts via ProxyJump). Existing aliases are skipped. Each entry is created
|
|
368
|
+
* through this store's own `create`, which keeps plaintext secrets inline.
|
|
294
369
|
* @returns import statistics.
|
|
295
370
|
*/
|
|
296
371
|
importFromSshConfig(): ImportResult {
|
|
@@ -298,73 +373,24 @@ export class HostStore {
|
|
|
298
373
|
this.skippedNames = new Set<string>()
|
|
299
374
|
const configPath = this.sshConfigOverride ?? sshConfigPath()
|
|
300
375
|
if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
let current: { pattern: string; props: Record<string, string> } | undefined
|
|
304
|
-
const skip = (name: string, seen: Set<string>): void => {
|
|
305
|
-
if (name !== '' && !seen.has(name)) {
|
|
306
|
-
seen.add(name)
|
|
307
|
-
this.skippedNames.add(name)
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
for (const raw of lines) {
|
|
311
|
-
const line = raw.trim()
|
|
312
|
-
if (line === '' || line.startsWith('#')) continue
|
|
313
|
-
const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
|
|
314
|
-
if (match === null) continue
|
|
315
|
-
const key = match[1].toLowerCase()
|
|
316
|
-
const value = match[2].trim()
|
|
317
|
-
if (key === 'host') {
|
|
318
|
-
current = { pattern: value, props: {} }
|
|
319
|
-
blocks.push(current)
|
|
320
|
-
} else if (current !== undefined) {
|
|
321
|
-
current.props[key] = value
|
|
322
|
-
}
|
|
323
|
-
}
|
|
376
|
+
const { candidates, invalidNames, totalBlocks } = parseSshConfigToImport(configPath)
|
|
377
|
+
for (const name of invalidNames) this.skippedNames.add(name)
|
|
324
378
|
let added = 0
|
|
325
|
-
for (const
|
|
326
|
-
const
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
continue
|
|
330
|
-
}
|
|
331
|
-
const hostName = block.props.hostname
|
|
332
|
-
if (hostName === undefined || hostName === '') {
|
|
333
|
-
skip(pattern, this.skippedNames)
|
|
334
|
-
continue
|
|
335
|
-
}
|
|
336
|
-
const existing = this.list().some(entry => entry.alias === pattern)
|
|
337
|
-
if (existing) {
|
|
338
|
-
skip(pattern, this.skippedNames)
|
|
379
|
+
for (const payload of candidates) {
|
|
380
|
+
const alias = payload.alias ?? ''
|
|
381
|
+
if (alias !== '' && this.list().some(entry => entry.alias === alias)) {
|
|
382
|
+
this.skippedNames.add(alias)
|
|
339
383
|
continue
|
|
340
384
|
}
|
|
341
|
-
const payload: HostPayload = {
|
|
342
|
-
alias: pattern,
|
|
343
|
-
host: hostName,
|
|
344
|
-
port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
|
|
345
|
-
user: block.props.user ?? process.env.USER ?? 'root',
|
|
346
|
-
auth: {
|
|
347
|
-
kind: block.props.identityfile !== undefined ? 'key' : 'password',
|
|
348
|
-
keyPath: block.props.identityfile,
|
|
349
|
-
password: block.props.password,
|
|
350
|
-
},
|
|
351
|
-
proxyJump: block.props.proxyjump !== undefined
|
|
352
|
-
? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
|
|
353
|
-
: [],
|
|
354
|
-
description: block.props.description,
|
|
355
|
-
environment: block.props.environment,
|
|
356
|
-
tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
357
|
-
location: block.props.location,
|
|
358
|
-
}
|
|
359
385
|
try {
|
|
360
386
|
this.create(payload)
|
|
361
387
|
added += 1
|
|
362
388
|
} catch {
|
|
363
389
|
// Unusable entry (bad alias grammar etc.) — count as skipped.
|
|
364
|
-
|
|
390
|
+
if (alias !== '') this.skippedNames.add(alias)
|
|
365
391
|
}
|
|
366
392
|
}
|
|
367
|
-
return { parsed:
|
|
393
|
+
return { parsed: totalBlocks, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
|
|
368
394
|
}
|
|
369
395
|
|
|
370
396
|
private skippedNames = new Set<string>()
|
|
@@ -426,13 +452,15 @@ export interface ResolvedAuth {
|
|
|
426
452
|
*
|
|
427
453
|
* - **'none' (default, VSCode Remote-SSH style)**: secrets are NEVER
|
|
428
454
|
* persisted. create/update strip the password/passphrase from the entry
|
|
429
|
-
* (kind + keyPath stay); the engine prompts once
|
|
430
|
-
*
|
|
455
|
+
* (kind + keyPath stay); the engine prompts once and holds the credential
|
|
456
|
+
* in-memory for that CONNECTION's lifetime (the pool's retirement drops it).
|
|
457
|
+
* The plaintext HostStore
|
|
431
458
|
* remains untouched, so existing v1 tests and dual-format reads keep
|
|
432
459
|
* working.
|
|
433
460
|
*/
|
|
434
461
|
export class SecureHostStore {
|
|
435
462
|
private readonly inner: HostStore
|
|
463
|
+
private readonly pendingSecretCleanup = new Set<string>()
|
|
436
464
|
|
|
437
465
|
constructor(
|
|
438
466
|
private readonly vault: import('./vault.ts').Vault | undefined,
|
|
@@ -447,20 +475,71 @@ export class SecureHostStore {
|
|
|
447
475
|
find(alias: string): SshHostEntry | undefined { return this.inner.find(alias) }
|
|
448
476
|
summarize(entry: SshHostEntry): SshHostSummary { return this.inner.summarize(entry) }
|
|
449
477
|
get path(): string { return this.inner.path }
|
|
450
|
-
/**
|
|
451
|
-
|
|
452
|
-
|
|
478
|
+
/** The ssh-config path the underlying store reads (override or default). */
|
|
479
|
+
get sshConfigOverridePath(): string | undefined { return this.inner.sshConfigOverridePath }
|
|
480
|
+
|
|
481
|
+
/** ssh-config import routed through THIS store's `create`, so every imported
|
|
482
|
+
* credential is handled per `secretStorage` (never persisted in 'none' mode,
|
|
483
|
+
* vault-encrypted in 'vault' mode) instead of being stored inline. */
|
|
484
|
+
async importFromSshConfig(): Promise<ImportResult> {
|
|
485
|
+
const configPath = this.inner.sshConfigOverridePath ?? sshConfigPath()
|
|
486
|
+
if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
|
|
487
|
+
const { candidates, invalidNames, totalBlocks } = parseSshConfigToImport(configPath)
|
|
488
|
+
const skipped = new Set<string>(invalidNames.filter(name => name !== ''))
|
|
489
|
+
let added = 0
|
|
490
|
+
for (const payload of candidates) {
|
|
491
|
+
const alias = payload.alias ?? ''
|
|
492
|
+
if (alias !== '' && this.inner.list().some(entry => entry.alias === alias)) {
|
|
493
|
+
skipped.add(alias)
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
await this.create(payload)
|
|
498
|
+
added += 1
|
|
499
|
+
} catch {
|
|
500
|
+
if (alias !== '') skipped.add(alias)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return { parsed: totalBlocks, added, skipped: skipped.size, skippedNames: [...skipped] }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Deferred best-effort cleanup queue (diagnostics/tests; contains no plaintext). */
|
|
507
|
+
pendingSecretCleanupRefs(): string[] { return [...this.pendingSecretCleanup] }
|
|
508
|
+
|
|
509
|
+
private async removeSecret(ref: string): Promise<unknown | undefined> {
|
|
510
|
+
if (this.vault === undefined) return undefined
|
|
511
|
+
try {
|
|
512
|
+
await this.vault.remove(ref)
|
|
513
|
+
this.pendingSecretCleanup.delete(ref)
|
|
514
|
+
return undefined
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.pendingSecretCleanup.add(ref)
|
|
517
|
+
return error
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async retryPendingCleanup(): Promise<void> {
|
|
522
|
+
for (const ref of [...this.pendingSecretCleanup]) await this.removeSecret(ref)
|
|
523
|
+
}
|
|
453
524
|
|
|
454
|
-
/** Create: secrets are
|
|
525
|
+
/** Create: secrets are staged, then rolled back if host persistence fails. */
|
|
455
526
|
async create(payload: HostPayload): Promise<SshHostEntry> {
|
|
456
527
|
// Validate the raw wire input first. In 'none' mode a password-kind host
|
|
457
528
|
// may omit the password (typed at connect time, VSCode-style); 'vault'
|
|
458
529
|
// mode keeps the strict rule so every credential gets stashed.
|
|
459
530
|
const rawError = validateHostPayload(payload, 'create', this.mode === 'none')
|
|
460
531
|
if (rawError !== undefined) throw new Error(rawError)
|
|
532
|
+
await this.retryPendingCleanup()
|
|
461
533
|
const ref = await this.stashSecrets(payload.alias ?? '', payload.auth)
|
|
462
|
-
|
|
463
|
-
|
|
534
|
+
try {
|
|
535
|
+
return this.inner.create(stripSecrets(payload, ref, this.mode), true)
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (ref !== undefined) {
|
|
538
|
+
const cleanupError = await this.removeSecret(ref)
|
|
539
|
+
if (cleanupError !== undefined) throw new AggregateError([error, cleanupError], 'host create failed and staged vault secret rollback failed')
|
|
540
|
+
}
|
|
541
|
+
throw error
|
|
542
|
+
}
|
|
464
543
|
}
|
|
465
544
|
|
|
466
545
|
/** Update: auth present -> re-stash (or strip in none mode); absent → keep. */
|
|
@@ -480,18 +559,36 @@ export class SecureHostStore {
|
|
|
480
559
|
&& existing.auth.secretRef !== undefined
|
|
481
560
|
if (!keepOldRef) throw new Error(rawError)
|
|
482
561
|
}
|
|
562
|
+
await this.retryPendingCleanup()
|
|
483
563
|
const ref = await this.stashSecrets(alias, patch.auth)
|
|
484
|
-
const
|
|
485
|
-
|
|
564
|
+
const sameCredential = patch.auth.kind === existing.auth.kind
|
|
565
|
+
&& (patch.auth.kind === 'password'
|
|
566
|
+
|| (patch.auth.keyPath === undefined || expandHome(patch.auth.keyPath.trim()) === existing.auth.keyPath))
|
|
567
|
+
const effectiveRef = ref ?? (this.mode === 'vault' && sameCredential ? existing.auth.secretRef : undefined)
|
|
568
|
+
let updated: SshHostEntry
|
|
569
|
+
try {
|
|
570
|
+
updated = this.inner.update(alias, stripSecrets(patch, effectiveRef, this.mode), true)
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (ref !== undefined) {
|
|
573
|
+
const cleanupError = await this.removeSecret(ref)
|
|
574
|
+
if (cleanupError !== undefined) throw new AggregateError([error, cleanupError], 'host update failed and staged vault secret rollback failed')
|
|
575
|
+
}
|
|
576
|
+
throw error
|
|
577
|
+
}
|
|
578
|
+
if (existing.auth.secretRef !== undefined && existing.auth.secretRef !== effectiveRef) {
|
|
579
|
+
// Host persistence is committed. Cleanup failure is deferred and must not
|
|
580
|
+
// make callers retry an already-applied host update.
|
|
581
|
+
await this.removeSecret(existing.auth.secretRef)
|
|
582
|
+
}
|
|
583
|
+
return updated
|
|
486
584
|
}
|
|
487
585
|
|
|
488
|
-
/** Delete
|
|
586
|
+
/** Delete the host first; vault cleanup can then only leave an orphan. */
|
|
489
587
|
async delete(alias: string): Promise<void> {
|
|
588
|
+
await this.retryPendingCleanup()
|
|
490
589
|
const entry = this.inner.find(alias)
|
|
491
|
-
if (entry !== undefined && entry.auth.secretRef !== undefined && this.vault !== undefined) {
|
|
492
|
-
await this.vault.remove(entry.auth.secretRef).catch(() => undefined)
|
|
493
|
-
}
|
|
494
590
|
this.inner.delete(alias)
|
|
591
|
+
if (entry?.auth.secretRef !== undefined) await this.removeSecret(entry.auth.secretRef)
|
|
495
592
|
}
|
|
496
593
|
|
|
497
594
|
/** Resolve the authentication for one entry (vault reveal when needed). */
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Client, ClientChannel } from 'ssh2'
|
|
2
|
+
import type { ExecSession, ShellSession } from '../engine.ts'
|
|
3
|
+
|
|
4
|
+
/** A fresh SSH channel (shell/exec) must open within this budget; a dead or
|
|
5
|
+
* half-open connection would otherwise leave the open promise hanging. */
|
|
6
|
+
const CHANNEL_OPEN_TIMEOUT_MS = 10_000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Narrow connection dependency used by the standalone terminal transports.
|
|
10
|
+
* `connectStandalone` must reach the store itself (the not-found wording is
|
|
11
|
+
* this component's own contract) and must always return a PRIVATE connection
|
|
12
|
+
* — never a pooled one.
|
|
13
|
+
*/
|
|
14
|
+
export interface TerminalConnectionAccess {
|
|
15
|
+
connectStandalone(alias: string): Promise<{ client: Client; hops: Client[] }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns the standalone PTY-shell / streaming-exec transports: one exclusive
|
|
20
|
+
* connection per session (closing a shell can therefore never tear down a
|
|
21
|
+
* pooled exec or tunnel sharing the alias), the channel-open deadline,
|
|
22
|
+
* idempotent teardown and session assembly.
|
|
23
|
+
*/
|
|
24
|
+
export class TerminalService {
|
|
25
|
+
/** Teardowns of live standalone transports, so dispose() can close sessions
|
|
26
|
+
* that the caller never closed explicitly (openShell/openExec own their
|
|
27
|
+
* whole connection and would otherwise outlive the engine). */
|
|
28
|
+
private readonly activeTeardowns = new Set<() => void>()
|
|
29
|
+
|
|
30
|
+
constructor(private readonly connection: TerminalConnectionAccess) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open one standalone channel on its own connection (never a pooled one).
|
|
34
|
+
* Shared by openShell/openExec: alias lookup, jump chain, channel-open
|
|
35
|
+
* timeout, idempotent teardown, and late-callback cleanup live here, so the
|
|
36
|
+
* two public methods keep only their session-specific assembly.
|
|
37
|
+
*/
|
|
38
|
+
private async openStandaloneChannel(
|
|
39
|
+
alias: string,
|
|
40
|
+
open: (client: Client, callback: (error: Error | undefined, stream?: ClientChannel) => void) => void,
|
|
41
|
+
): Promise<{ stream: ClientChannel; teardown: () => void }> {
|
|
42
|
+
const { client, hops } = await this.connection.connectStandalone(alias)
|
|
43
|
+
return await new Promise<{ stream: ClientChannel; teardown: () => void }>((resolve, reject) => {
|
|
44
|
+
let settled = false
|
|
45
|
+
let tornDown = false
|
|
46
|
+
const teardown = (): void => {
|
|
47
|
+
if (tornDown) return
|
|
48
|
+
tornDown = true
|
|
49
|
+
this.activeTeardowns.delete(teardown)
|
|
50
|
+
try { client.end() } catch { /* closed */ }
|
|
51
|
+
for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
|
|
52
|
+
}
|
|
53
|
+
this.activeTeardowns.add(teardown)
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
if (settled) return
|
|
56
|
+
settled = true
|
|
57
|
+
teardown()
|
|
58
|
+
reject(new Error(`channel on '${alias}' did not open within ${CHANNEL_OPEN_TIMEOUT_MS} ms`))
|
|
59
|
+
}, CHANNEL_OPEN_TIMEOUT_MS)
|
|
60
|
+
timer.unref?.()
|
|
61
|
+
open(client, (error, stream) => {
|
|
62
|
+
if (settled) {
|
|
63
|
+
// Late arrival after the timeout: the connection is being torn
|
|
64
|
+
// down; close any channel that finally opened instead of leaking it.
|
|
65
|
+
if (stream !== undefined) {
|
|
66
|
+
try { stream.close() } catch { /* already closed */ }
|
|
67
|
+
}
|
|
68
|
+
teardown()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
settled = true
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
if (error !== undefined) {
|
|
74
|
+
teardown()
|
|
75
|
+
reject(error)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (stream === undefined) {
|
|
79
|
+
teardown()
|
|
80
|
+
reject(new Error(`channel on '${alias}' opened without a stream`))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
resolve({ stream, teardown })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
89
|
+
async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
|
|
90
|
+
// The shell is a long-lived exclusive stream: use its own connection so
|
|
91
|
+
// closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
92
|
+
const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
|
|
93
|
+
client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, callback)
|
|
94
|
+
})
|
|
95
|
+
const session: ShellSession = {
|
|
96
|
+
send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
|
|
97
|
+
resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
|
|
98
|
+
signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
|
|
99
|
+
close: () => {
|
|
100
|
+
try { stream.close() } catch { /* channel gone */ }
|
|
101
|
+
teardown()
|
|
102
|
+
},
|
|
103
|
+
pause: () => { try { stream.pause() } catch { /* channel gone */ } },
|
|
104
|
+
resume: () => { try { stream.resume() } catch { /* channel gone */ } },
|
|
105
|
+
}
|
|
106
|
+
stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
|
|
107
|
+
stream.on('close', (code: number | null) => {
|
|
108
|
+
teardown()
|
|
109
|
+
session.onExit?.(code)
|
|
110
|
+
})
|
|
111
|
+
stream.on('error', (streamError: Error) => {
|
|
112
|
+
teardown()
|
|
113
|
+
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
|
|
114
|
+
})
|
|
115
|
+
return session
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Open a streaming exec channel (no PTY) for the remote subprocess seam.
|
|
120
|
+
* Like the PTY shell, the channel rides its own connection so closing it
|
|
121
|
+
* can never tear down a pooled exec/tunnel sharing the alias.
|
|
122
|
+
*/
|
|
123
|
+
async openExec(alias: string, command: string): Promise<ExecSession> {
|
|
124
|
+
const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
|
|
125
|
+
client.exec(command, callback)
|
|
126
|
+
})
|
|
127
|
+
let exitListener: ExecSession['onExit']
|
|
128
|
+
let exitRecord: { code: number | null; error?: string } | undefined
|
|
129
|
+
let exitDelivered = false
|
|
130
|
+
const deliverExit = (): void => {
|
|
131
|
+
if (exitDelivered || exitRecord === undefined || exitListener === undefined) return
|
|
132
|
+
exitDelivered = true
|
|
133
|
+
exitListener(exitRecord.code, exitRecord.error)
|
|
134
|
+
}
|
|
135
|
+
const finish = (code: number | null, error?: string): void => {
|
|
136
|
+
if (exitRecord !== undefined) return
|
|
137
|
+
exitRecord = error === undefined ? { code } : { code, error }
|
|
138
|
+
teardown()
|
|
139
|
+
deliverExit()
|
|
140
|
+
}
|
|
141
|
+
const session: ExecSession = {
|
|
142
|
+
stdin: stream,
|
|
143
|
+
send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
|
|
144
|
+
end: (data) => {
|
|
145
|
+
try {
|
|
146
|
+
if (data !== undefined && data !== '') stream.write(data)
|
|
147
|
+
stream.end()
|
|
148
|
+
} catch { /* channel gone */ }
|
|
149
|
+
},
|
|
150
|
+
get onExit() { return exitListener },
|
|
151
|
+
set onExit(listener) {
|
|
152
|
+
exitListener = listener
|
|
153
|
+
deliverExit()
|
|
154
|
+
},
|
|
155
|
+
resize: () => { /* exec channels have no PTY */ },
|
|
156
|
+
signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
|
|
157
|
+
close: () => {
|
|
158
|
+
try { stream.close() } catch { /* channel gone */ }
|
|
159
|
+
teardown()
|
|
160
|
+
},
|
|
161
|
+
pause: () => { try { stream.pause() } catch { /* channel gone */ } },
|
|
162
|
+
resume: () => { try { stream.resume() } catch { /* channel gone */ } },
|
|
163
|
+
}
|
|
164
|
+
stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
|
|
165
|
+
stream.stderr.on('data', (chunk: Buffer) => { session.onErrData?.(chunk) })
|
|
166
|
+
stream.on('close', (code: number | null) => { finish(code) })
|
|
167
|
+
stream.on('error', (streamError: Error) => {
|
|
168
|
+
finish(null, streamError instanceof Error ? streamError.message : String(streamError))
|
|
169
|
+
})
|
|
170
|
+
return session
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Close every standalone transport this service still owns. */
|
|
174
|
+
dispose(): void {
|
|
175
|
+
for (const teardown of [...this.activeTeardowns]) teardown()
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/ssh/tools.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
14
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
15
15
|
import type { SshEngine } from './engine.ts'
|
|
16
|
+
import { secureLocalTransferPath } from './local-transfer-policy.ts'
|
|
16
17
|
import type { ClusterResult, ExecResult, SshHostSummary, TunnelInfo } from './protocol.ts'
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -23,15 +24,23 @@ export type ToolEnvelope<T extends object> =
|
|
|
23
24
|
| ({ ok: true } & T)
|
|
24
25
|
| { ok: false; error: string }
|
|
25
26
|
|
|
26
|
-
/** Run one tool operation and fold every thrown error into a failure envelope.
|
|
27
|
+
/** Run one tool operation and fold every thrown error into a failure envelope.
|
|
28
|
+
*
|
|
29
|
+
* The message passes the engine's leak guard before it reaches the model: a
|
|
30
|
+
* rejected connect/resolve can embed a credential, and this envelope is the
|
|
31
|
+
* one place every tool's failure text is produced. */
|
|
27
32
|
async function captureToolResult<T extends object>(
|
|
33
|
+
engine: Pick<SshEngine, 'redact'>,
|
|
28
34
|
operation: () => T | Promise<T>,
|
|
29
35
|
): Promise<ToolEnvelope<T>> {
|
|
30
36
|
try {
|
|
31
37
|
const value = await operation()
|
|
32
38
|
return { ok: true, ...value }
|
|
33
39
|
} catch (error) {
|
|
34
|
-
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
41
|
+
// Tolerate engine doubles that predate the redactor (tests inject minimal
|
|
42
|
+
// fakes); the production engine always provides it.
|
|
43
|
+
return { ok: false, error: typeof engine.redact === 'function' ? engine.redact(message) : message }
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
|
|
@@ -135,7 +144,7 @@ export function sshListTool(engine: SshEngine) {
|
|
|
135
144
|
},
|
|
136
145
|
},
|
|
137
146
|
async execute(args) {
|
|
138
|
-
return captureToolResult(() => ({ hosts: engine.list(args.query) }))
|
|
147
|
+
return captureToolResult(engine, () => ({ hosts: engine.list(args.query) }))
|
|
139
148
|
},
|
|
140
149
|
})
|
|
141
150
|
}
|
|
@@ -149,7 +158,7 @@ export function sshExecTool(engine: SshEngine) {
|
|
|
149
158
|
parameters: {
|
|
150
159
|
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
151
160
|
command: { type: 'string', required: true, description: 'The shell command to run remotely.' },
|
|
152
|
-
timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000).' },
|
|
161
|
+
timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000). Enforced on the server as well (coreutils timeout), so a timed-out command is stopped instead of left running.' },
|
|
153
162
|
},
|
|
154
163
|
output: {
|
|
155
164
|
schema: {
|
|
@@ -172,7 +181,7 @@ export function sshExecTool(engine: SshEngine) {
|
|
|
172
181
|
},
|
|
173
182
|
},
|
|
174
183
|
async execute(args) {
|
|
175
|
-
return captureToolResult(() => engine.exec(args.alias, args.command, args.timeoutMs))
|
|
184
|
+
return captureToolResult(engine, () => engine.exec(args.alias, args.command, args.timeoutMs))
|
|
176
185
|
},
|
|
177
186
|
})
|
|
178
187
|
}
|
|
@@ -181,11 +190,11 @@ export function sshExecTool(engine: SshEngine) {
|
|
|
181
190
|
export function sshUploadTool(engine: SshEngine) {
|
|
182
191
|
return defineTool({
|
|
183
192
|
name: 'ssh_upload',
|
|
184
|
-
description: 'Upload a local file to a configured SSH host.
|
|
193
|
+
description: 'Upload a local file from the invoking session workspace to a configured SSH host. Local paths outside that workspace are rejected. ' +
|
|
185
194
|
'Triggers: upload file to server, deploy artifact, copy config to server.',
|
|
186
195
|
parameters: {
|
|
187
196
|
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
188
|
-
localPath: { type: 'string', required: true, description: 'Absolute
|
|
197
|
+
localPath: { type: 'string', required: true, description: 'Absolute source path inside the invoking session workspace.' },
|
|
189
198
|
remotePath: { type: 'string', required: true, description: 'Destination path on the remote host (parent dirs are created).' },
|
|
190
199
|
},
|
|
191
200
|
output: {
|
|
@@ -204,9 +213,10 @@ export function sshUploadTool(engine: SshEngine) {
|
|
|
204
213
|
return text(`uploaded ${value.files ?? 1} file(s), ${value.transferredBytes ?? 0} bytes`)
|
|
205
214
|
},
|
|
206
215
|
},
|
|
207
|
-
async execute(args) {
|
|
208
|
-
return captureToolResult(async () => {
|
|
209
|
-
const
|
|
216
|
+
async execute(args, exec) {
|
|
217
|
+
return captureToolResult(engine, async () => {
|
|
218
|
+
const source = secureLocalTransferPath(exec, args.localPath, 'upload-source')
|
|
219
|
+
const outcome = await engine.upload(args.alias, source, args.remotePath, false)
|
|
210
220
|
return { transferredBytes: outcome.bytes, files: outcome.files }
|
|
211
221
|
})
|
|
212
222
|
},
|
|
@@ -217,12 +227,12 @@ export function sshUploadTool(engine: SshEngine) {
|
|
|
217
227
|
export function sshDownloadTool(engine: SshEngine) {
|
|
218
228
|
return defineTool({
|
|
219
229
|
name: 'ssh_download',
|
|
220
|
-
description: 'Download a remote FILE
|
|
230
|
+
description: 'Download a remote FILE into the invoking session workspace. Local paths outside that workspace are rejected; directory download is unsupported. ' +
|
|
221
231
|
'Triggers: download file from server, fetch remote log/artifact.',
|
|
222
232
|
parameters: {
|
|
223
233
|
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
224
234
|
remotePath: { type: 'string', required: true, description: 'Remote file path.' },
|
|
225
|
-
localPath: { type: 'string', required: true, description: 'Absolute destination path
|
|
235
|
+
localPath: { type: 'string', required: true, description: 'Absolute destination path inside the invoking session workspace.' },
|
|
226
236
|
},
|
|
227
237
|
output: {
|
|
228
238
|
schema: {
|
|
@@ -239,9 +249,10 @@ export function sshDownloadTool(engine: SshEngine) {
|
|
|
239
249
|
return text(`downloaded ${value.bytes ?? 0} bytes`)
|
|
240
250
|
},
|
|
241
251
|
},
|
|
242
|
-
async execute(args) {
|
|
243
|
-
return captureToolResult(async () => {
|
|
244
|
-
const
|
|
252
|
+
async execute(args, exec) {
|
|
253
|
+
return captureToolResult(engine, async () => {
|
|
254
|
+
const destination = secureLocalTransferPath(exec, args.localPath, 'download-destination')
|
|
255
|
+
const outcome = await engine.download(args.alias, args.remotePath, destination)
|
|
245
256
|
return { bytes: outcome.bytes }
|
|
246
257
|
})
|
|
247
258
|
},
|
|
@@ -315,7 +326,7 @@ export function sshTunnelTool(engine: SshEngine) {
|
|
|
315
326
|
},
|
|
316
327
|
async execute(args) {
|
|
317
328
|
if (args.action === 'list') {
|
|
318
|
-
return captureToolResult(() => ({ tunnels: engine.listTunnels() }))
|
|
329
|
+
return captureToolResult(engine, () => ({ tunnels: engine.listTunnels() }))
|
|
319
330
|
}
|
|
320
331
|
if (args.action === 'start') {
|
|
321
332
|
const alias = args.alias
|
|
@@ -323,7 +334,7 @@ export function sshTunnelTool(engine: SshEngine) {
|
|
|
323
334
|
if (alias === undefined || remotePort === undefined) {
|
|
324
335
|
return { ok: false, error: 'alias and remotePort are required for start' }
|
|
325
336
|
}
|
|
326
|
-
return captureToolResult(async () => ({
|
|
337
|
+
return captureToolResult(engine, async () => ({
|
|
327
338
|
tunnel: await engine.startTunnel(alias, {
|
|
328
339
|
remotePort,
|
|
329
340
|
remoteHost: args.remoteHost,
|
|
@@ -336,14 +347,14 @@ export function sshTunnelTool(engine: SshEngine) {
|
|
|
336
347
|
if (tunnelId === undefined) {
|
|
337
348
|
return { ok: false, error: 'tunnelId is required for stop' }
|
|
338
349
|
}
|
|
339
|
-
return captureToolResult(() => {
|
|
350
|
+
return captureToolResult(engine, () => {
|
|
340
351
|
const stopped = engine.stopTunnel(tunnelId)
|
|
341
352
|
if (!stopped) throw new Error(`tunnel '${tunnelId}' not found`)
|
|
342
353
|
return { stopped: 1 }
|
|
343
354
|
})
|
|
344
355
|
}
|
|
345
356
|
if (args.action === 'stop-all') {
|
|
346
|
-
return captureToolResult(() => ({ stopped: engine.stopAllTunnels(args.alias) }))
|
|
357
|
+
return captureToolResult(engine, () => ({ stopped: engine.stopAllTunnels(args.alias) }))
|
|
347
358
|
}
|
|
348
359
|
return { ok: false, error: `unknown action '${String(args.action)}'` }
|
|
349
360
|
},
|
|
@@ -384,6 +395,14 @@ export function sshClusterTool(engine: SshEngine) {
|
|
|
384
395
|
stderr: { type: 'string' },
|
|
385
396
|
durationMs: { type: 'integer' },
|
|
386
397
|
error: { type: 'string' },
|
|
398
|
+
// Typed per-host failure detail (B-14): additive optional
|
|
399
|
+
// fields, so an agent can retry by machine code instead of
|
|
400
|
+
// parsing a message. Never carries a credential value.
|
|
401
|
+
code: { type: 'string', enum: ['NEEDS_PASSWORD', 'HOST_KEY_UNKNOWN', 'HOST_KEY_MISMATCH', 'ALIAS_NOT_FOUND', 'ABORTED', 'TIMEOUT', 'ERROR'] },
|
|
402
|
+
secret: { type: 'string', enum: ['password', 'passphrase'] },
|
|
403
|
+
hostKeyFingerprint: { type: 'string' },
|
|
404
|
+
expected: { type: 'string' },
|
|
405
|
+
actual: { type: 'string' },
|
|
387
406
|
},
|
|
388
407
|
},
|
|
389
408
|
},
|
|
@@ -396,7 +415,10 @@ export function sshClusterTool(engine: SshEngine) {
|
|
|
396
415
|
},
|
|
397
416
|
},
|
|
398
417
|
async execute(args) {
|
|
399
|
-
|
|
418
|
+
// The engine returns a bare array; the envelope (and this tool's own
|
|
419
|
+
// output schema) requires a `results` field. Spreading the array
|
|
420
|
+
// produced numeric keys instead, so the payload was unusable.
|
|
421
|
+
return captureToolResult(engine, async () => ({ results: await engine.cluster(args) }))
|
|
400
422
|
},
|
|
401
423
|
})
|
|
402
424
|
}
|