@tiphareth/dsh-hardssh 0.1.2-alpha
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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
package/src/ssh/tools.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools: the DSH-native counterpart of ssh-skill's CLI. Every tool
|
|
3
|
+
* talks to the same engine the web UI uses, so a host configured in the GUI
|
|
4
|
+
* is immediately operable by any agent, and vice versa.
|
|
5
|
+
*
|
|
6
|
+
* Result contract (P1-20): every tool resolves to an envelope — `ok` is a
|
|
7
|
+
* required discriminator, business fields appear only on success, and a
|
|
8
|
+
* single `error` string carries failures. Business errors never reject;
|
|
9
|
+
* parameter and engine errors never masquerade as fake payloads. The output
|
|
10
|
+
* schema mirrors this: `ok` required, payload fields optional.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
15
|
+
import type { SshEngine } from './engine.ts'
|
|
16
|
+
import type { ClusterResult, ExecResult, SshHostSummary, TunnelInfo } from './protocol.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Uniform tool result: `{ ok: true, ...payload }` or `{ ok: false, error }`.
|
|
20
|
+
* Business fields only appear on success — no zero-value placeholders.
|
|
21
|
+
*/
|
|
22
|
+
export type ToolEnvelope<T extends object> =
|
|
23
|
+
| ({ ok: true } & T)
|
|
24
|
+
| { ok: false; error: string }
|
|
25
|
+
|
|
26
|
+
/** Run one tool operation and fold every thrown error into a failure envelope. */
|
|
27
|
+
async function captureToolResult<T extends object>(
|
|
28
|
+
operation: () => T | Promise<T>,
|
|
29
|
+
): Promise<ToolEnvelope<T>> {
|
|
30
|
+
try {
|
|
31
|
+
const value = await operation()
|
|
32
|
+
return { ok: true, ...value }
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One text content block (the only render shape these tools emit). */
|
|
39
|
+
function text(value: string): ContentBlock[] {
|
|
40
|
+
return [{ type: 'text', text: value }]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** First line shared by every failure render. */
|
|
44
|
+
function failure(name: string, error: string): ContentBlock[] {
|
|
45
|
+
return text(`${name} failed: ${error}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Host table render shared by list surfaces. */
|
|
49
|
+
function renderHosts(hosts: SshHostSummary[]): string {
|
|
50
|
+
if (hosts.length === 0) return 'no hosts configured'
|
|
51
|
+
const rows = hosts.map(host => [
|
|
52
|
+
host.alias,
|
|
53
|
+
host.host,
|
|
54
|
+
String(host.port),
|
|
55
|
+
host.user,
|
|
56
|
+
host.auth,
|
|
57
|
+
host.environment ?? '-',
|
|
58
|
+
(host.tags.length > 0 ? host.tags.join(',') : '-'),
|
|
59
|
+
host.description ?? '',
|
|
60
|
+
].join(' | '))
|
|
61
|
+
return ['alias | host | port | user | auth | environment | tags | description', '--- | --- | --- | --- | --- | --- | --- | ---', ...rows].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render one exec result (mirrors the bash-tool exit-code convention). */
|
|
65
|
+
function renderExec(result: ExecResult): string {
|
|
66
|
+
const marker = result.timedOut
|
|
67
|
+
? '[timed out]'
|
|
68
|
+
: `[exit code: ${result.exitCode ?? 'null'}]`
|
|
69
|
+
const parts = [marker]
|
|
70
|
+
if (result.stdout !== '') parts.push('stdout:\n' + result.stdout)
|
|
71
|
+
if (result.stderr !== '') parts.push('stderr:\n' + result.stderr)
|
|
72
|
+
if (result.error !== undefined) parts.push('error: ' + result.error)
|
|
73
|
+
parts.push(`duration: ${result.durationMs} ms`)
|
|
74
|
+
return parts.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Render cluster outcomes compactly. */
|
|
78
|
+
function renderCluster(results: ClusterResult[]): string {
|
|
79
|
+
if (results.length === 0) return 'no hosts matched'
|
|
80
|
+
return results.map(result => {
|
|
81
|
+
const status = result.ok ? 'ok' : result.timedOut === true ? 'timed out' : 'failed'
|
|
82
|
+
const tail = result.error !== undefined ? ' (' + result.error + ')' : ''
|
|
83
|
+
return `${result.alias}: ${status} [exit code: ${result.exitCode ?? 'null'}]${tail}`
|
|
84
|
+
}).join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One tunnel line. */
|
|
88
|
+
function renderTunnel(tunnel: TunnelInfo): string {
|
|
89
|
+
return `${tunnel.id} ${tunnel.alias} 127.0.0.1:${tunnel.localPort} -> ${tunnel.remoteHost}:${tunnel.remotePort} [${tunnel.state}]`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The host-list tool. */
|
|
93
|
+
export function sshListTool(engine: SshEngine) {
|
|
94
|
+
return defineTool({
|
|
95
|
+
name: 'ssh_list',
|
|
96
|
+
description: 'List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias. ' +
|
|
97
|
+
'Triggers: SSH, remote server, server IP/hostname, connect/login, check server/status, deploy, upload/download, jump host, tunnel, port forward.',
|
|
98
|
+
parameters: {
|
|
99
|
+
query: { type: 'string', description: 'Optional fuzzy match against alias, description, host, and tags.' },
|
|
100
|
+
},
|
|
101
|
+
output: {
|
|
102
|
+
schema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
properties: {
|
|
106
|
+
ok: { type: 'boolean', required: true },
|
|
107
|
+
hosts: {
|
|
108
|
+
type: 'array',
|
|
109
|
+
items: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
properties: {
|
|
113
|
+
alias: { type: 'string', required: true },
|
|
114
|
+
host: { type: 'string', required: true },
|
|
115
|
+
port: { type: 'integer', required: true },
|
|
116
|
+
user: { type: 'string', required: true },
|
|
117
|
+
auth: { type: 'string', enum: ['key', 'password'], required: true },
|
|
118
|
+
keyReady: { type: 'boolean', required: true },
|
|
119
|
+
proxyJump: { type: 'array', items: { type: 'string' }, required: true },
|
|
120
|
+
description: { type: 'string' },
|
|
121
|
+
environment: { type: 'string' },
|
|
122
|
+
tags: { type: 'array', items: { type: 'string' }, required: true },
|
|
123
|
+
location: { type: 'string' },
|
|
124
|
+
createdAt: { type: 'integer', required: true },
|
|
125
|
+
updatedAt: { type: 'integer', required: true },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
error: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
render: (_args, value) => {
|
|
133
|
+
if (!value.ok) return failure('ssh_list', value.error ?? 'unknown error')
|
|
134
|
+
return text(renderHosts(value.hosts ?? []))
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
async execute(args) {
|
|
138
|
+
return captureToolResult(() => ({ hosts: engine.list(args.query) }))
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The command-execution tool. */
|
|
144
|
+
export function sshExecTool(engine: SshEngine) {
|
|
145
|
+
return defineTool({
|
|
146
|
+
name: 'ssh_exec',
|
|
147
|
+
description: 'Execute a command on a configured SSH host by alias. Prefer combining independent read-only queries into one command. ' +
|
|
148
|
+
'Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.',
|
|
149
|
+
parameters: {
|
|
150
|
+
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
151
|
+
command: { type: 'string', required: true, description: 'The shell command to run remotely.' },
|
|
152
|
+
timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000).' },
|
|
153
|
+
},
|
|
154
|
+
output: {
|
|
155
|
+
schema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
properties: {
|
|
159
|
+
ok: { type: 'boolean', required: true },
|
|
160
|
+
success: { type: 'boolean' },
|
|
161
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
162
|
+
timedOut: { type: 'boolean' },
|
|
163
|
+
stdout: { type: 'string' },
|
|
164
|
+
stderr: { type: 'string' },
|
|
165
|
+
durationMs: { type: 'integer' },
|
|
166
|
+
error: { type: 'string' },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
render: (_args, value) => {
|
|
170
|
+
if (!value.ok) return failure('ssh_exec', value.error ?? 'unknown error')
|
|
171
|
+
return text(renderExec(value as ExecResult))
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
async execute(args) {
|
|
175
|
+
return captureToolResult(() => engine.exec(args.alias, args.command, args.timeoutMs))
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The upload tool. */
|
|
181
|
+
export function sshUploadTool(engine: SshEngine) {
|
|
182
|
+
return defineTool({
|
|
183
|
+
name: 'ssh_upload',
|
|
184
|
+
description: 'Upload a local file to a configured SSH host. The local path is on THIS machine (the dsh host). ' +
|
|
185
|
+
'Triggers: upload file to server, deploy artifact, copy config to server.',
|
|
186
|
+
parameters: {
|
|
187
|
+
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
188
|
+
localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
|
|
189
|
+
remotePath: { type: 'string', required: true, description: 'Destination path on the remote host (parent dirs are created).' },
|
|
190
|
+
},
|
|
191
|
+
output: {
|
|
192
|
+
schema: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
additionalProperties: false,
|
|
195
|
+
properties: {
|
|
196
|
+
ok: { type: 'boolean', required: true },
|
|
197
|
+
transferredBytes: { type: 'integer' },
|
|
198
|
+
files: { type: 'integer' },
|
|
199
|
+
error: { type: 'string' },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
render: (_args, value) => {
|
|
203
|
+
if (!value.ok) return failure('ssh_upload', value.error ?? 'unknown error')
|
|
204
|
+
return text(`uploaded ${value.files ?? 1} file(s), ${value.transferredBytes ?? 0} bytes`)
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
async execute(args) {
|
|
208
|
+
return captureToolResult(async () => {
|
|
209
|
+
const outcome = await engine.upload(args.alias, args.localPath, args.remotePath, false)
|
|
210
|
+
return { transferredBytes: outcome.bytes, files: outcome.files }
|
|
211
|
+
})
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The download tool. */
|
|
217
|
+
export function sshDownloadTool(engine: SshEngine) {
|
|
218
|
+
return defineTool({
|
|
219
|
+
name: 'ssh_download',
|
|
220
|
+
description: 'Download a remote FILE from a configured SSH host to a local path on this machine. Directory download is not supported — download files individually. ' +
|
|
221
|
+
'Triggers: download file from server, fetch remote log/artifact.',
|
|
222
|
+
parameters: {
|
|
223
|
+
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
224
|
+
remotePath: { type: 'string', required: true, description: 'Remote file path.' },
|
|
225
|
+
localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
|
|
226
|
+
},
|
|
227
|
+
output: {
|
|
228
|
+
schema: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
additionalProperties: false,
|
|
231
|
+
properties: {
|
|
232
|
+
ok: { type: 'boolean', required: true },
|
|
233
|
+
bytes: { type: 'integer' },
|
|
234
|
+
error: { type: 'string' },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
render: (_args, value) => {
|
|
238
|
+
if (!value.ok) return failure('ssh_download', value.error ?? 'unknown error')
|
|
239
|
+
return text(`downloaded ${value.bytes ?? 0} bytes`)
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
async execute(args) {
|
|
243
|
+
return captureToolResult(async () => {
|
|
244
|
+
const outcome = await engine.download(args.alias, args.remotePath, args.localPath)
|
|
245
|
+
return { bytes: outcome.bytes }
|
|
246
|
+
})
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The tunnel tool. */
|
|
252
|
+
export function sshTunnelTool(engine: SshEngine) {
|
|
253
|
+
return defineTool({
|
|
254
|
+
name: 'ssh_tunnel',
|
|
255
|
+
description: 'Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine. ' +
|
|
256
|
+
'Triggers: tunnel, port forward, connect database, access internal service.',
|
|
257
|
+
parameters: {
|
|
258
|
+
action: { type: 'string', required: true, enum: ['start', 'list', 'stop', 'stop-all'], description: 'start / list / stop / stop-all.' },
|
|
259
|
+
alias: { type: 'string', description: 'Host alias (required for start, optional for stop-all).' },
|
|
260
|
+
remotePort: { type: 'integer', description: 'Port on the remote side (required for start).' },
|
|
261
|
+
remoteHost: { type: 'string', description: 'Remote host to forward to (default 127.0.0.1 — the server itself).' },
|
|
262
|
+
localPort: { type: 'integer', description: 'Local listening port (default: auto-assigned).' },
|
|
263
|
+
tunnelId: { type: 'string', description: 'Tunnel id (required for stop).' },
|
|
264
|
+
},
|
|
265
|
+
output: {
|
|
266
|
+
schema: {
|
|
267
|
+
type: 'object',
|
|
268
|
+
additionalProperties: false,
|
|
269
|
+
properties: {
|
|
270
|
+
ok: { type: 'boolean', required: true },
|
|
271
|
+
tunnel: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
additionalProperties: false,
|
|
274
|
+
properties: {
|
|
275
|
+
id: { type: 'string', required: true },
|
|
276
|
+
alias: { type: 'string', required: true },
|
|
277
|
+
localPort: { type: 'integer', required: true },
|
|
278
|
+
remoteHost: { type: 'string', required: true },
|
|
279
|
+
remotePort: { type: 'integer', required: true },
|
|
280
|
+
state: { type: 'string', enum: ['forwarding', 'connecting', 'failed'], required: true },
|
|
281
|
+
error: { type: 'string' },
|
|
282
|
+
startedAt: { type: 'integer', required: true },
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
tunnels: {
|
|
286
|
+
type: 'array',
|
|
287
|
+
items: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
properties: {
|
|
291
|
+
id: { type: 'string', required: true },
|
|
292
|
+
alias: { type: 'string', required: true },
|
|
293
|
+
localPort: { type: 'integer', required: true },
|
|
294
|
+
remoteHost: { type: 'string', required: true },
|
|
295
|
+
remotePort: { type: 'integer', required: true },
|
|
296
|
+
state: { type: 'string', enum: ['forwarding', 'connecting', 'failed'], required: true },
|
|
297
|
+
error: { type: 'string' },
|
|
298
|
+
startedAt: { type: 'integer', required: true },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
stopped: { type: 'integer' },
|
|
303
|
+
error: { type: 'string' },
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
render: (_args, value) => {
|
|
307
|
+
if (!value.ok) return failure('ssh_tunnel', value.error ?? 'unknown error')
|
|
308
|
+
if (value.tunnel !== undefined) {
|
|
309
|
+
if (value.tunnel.state === 'failed') return text(`tunnel failed: ${value.tunnel.error ?? 'unknown error'}`)
|
|
310
|
+
return text(`tunnel started: ${renderTunnel(value.tunnel)}`)
|
|
311
|
+
}
|
|
312
|
+
if (value.tunnels !== undefined) return text(value.tunnels.length === 0 ? 'no active tunnels' : value.tunnels.map(renderTunnel).join('\n'))
|
|
313
|
+
return text(`stopped ${value.stopped ?? 0} tunnel(s)`)
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
async execute(args) {
|
|
317
|
+
if (args.action === 'list') {
|
|
318
|
+
return captureToolResult(() => ({ tunnels: engine.listTunnels() }))
|
|
319
|
+
}
|
|
320
|
+
if (args.action === 'start') {
|
|
321
|
+
const alias = args.alias
|
|
322
|
+
const remotePort = args.remotePort
|
|
323
|
+
if (alias === undefined || remotePort === undefined) {
|
|
324
|
+
return { ok: false, error: 'alias and remotePort are required for start' }
|
|
325
|
+
}
|
|
326
|
+
return captureToolResult(async () => ({
|
|
327
|
+
tunnel: await engine.startTunnel(alias, {
|
|
328
|
+
remotePort,
|
|
329
|
+
remoteHost: args.remoteHost,
|
|
330
|
+
localPort: args.localPort,
|
|
331
|
+
}),
|
|
332
|
+
}))
|
|
333
|
+
}
|
|
334
|
+
if (args.action === 'stop') {
|
|
335
|
+
const tunnelId = args.tunnelId
|
|
336
|
+
if (tunnelId === undefined) {
|
|
337
|
+
return { ok: false, error: 'tunnelId is required for stop' }
|
|
338
|
+
}
|
|
339
|
+
return captureToolResult(() => {
|
|
340
|
+
const stopped = engine.stopTunnel(tunnelId)
|
|
341
|
+
if (!stopped) throw new Error(`tunnel '${tunnelId}' not found`)
|
|
342
|
+
return { stopped: 1 }
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
if (args.action === 'stop-all') {
|
|
346
|
+
return captureToolResult(() => ({ stopped: engine.stopAllTunnels(args.alias) }))
|
|
347
|
+
}
|
|
348
|
+
return { ok: false, error: `unknown action '${String(args.action)}'` }
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The cluster tool. */
|
|
354
|
+
export function sshClusterTool(engine: SshEngine) {
|
|
355
|
+
return defineTool({
|
|
356
|
+
name: 'ssh_cluster',
|
|
357
|
+
description: 'Run one command concurrently across many SSH hosts (all hosts, or filtered by aliases / environment / tags). ' +
|
|
358
|
+
'Triggers: run on all servers, batch operation, production servers, cluster command.',
|
|
359
|
+
parameters: {
|
|
360
|
+
command: { type: 'string', required: true, description: 'The shell command to run on every matched host.' },
|
|
361
|
+
aliases: { type: 'array', items: { type: 'string' }, description: 'Explicit alias list; when absent every configured host matches.' },
|
|
362
|
+
environment: { type: 'string', description: 'Only hosts with this environment label.' },
|
|
363
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Only hosts carrying ALL these tags.' },
|
|
364
|
+
timeoutMs: { type: 'integer', description: 'Per-host timeout in milliseconds.' },
|
|
365
|
+
maxWorkers: { type: 'integer', description: 'Concurrency cap (default 8).' },
|
|
366
|
+
},
|
|
367
|
+
output: {
|
|
368
|
+
schema: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
additionalProperties: false,
|
|
371
|
+
properties: {
|
|
372
|
+
ok: { type: 'boolean', required: true },
|
|
373
|
+
results: {
|
|
374
|
+
type: 'array',
|
|
375
|
+
items: {
|
|
376
|
+
type: 'object',
|
|
377
|
+
additionalProperties: false,
|
|
378
|
+
properties: {
|
|
379
|
+
alias: { type: 'string', required: true },
|
|
380
|
+
ok: { type: 'boolean', required: true },
|
|
381
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
382
|
+
timedOut: { type: 'boolean' },
|
|
383
|
+
stdout: { type: 'string' },
|
|
384
|
+
stderr: { type: 'string' },
|
|
385
|
+
durationMs: { type: 'integer' },
|
|
386
|
+
error: { type: 'string' },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
error: { type: 'string' },
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
render: (_args, value) => {
|
|
394
|
+
if (!value.ok) return failure('ssh_cluster', value.error ?? 'unknown error')
|
|
395
|
+
return text(renderCluster(value.results ?? []))
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
async execute(args) {
|
|
399
|
+
return captureToolResult(() => engine.cluster(args))
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transfer-progress tracker for fastPut/fastGet (P1-19): one
|
|
3
|
+
* throttle/speed/percent rule set so uploads and downloads cannot drift.
|
|
4
|
+
* Sends an initial 0-frame on creation, never throttles the final step,
|
|
5
|
+
* keeps the last known progress on failure, and always reports 100% on done.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TransferProgress } from '../protocol.ts'
|
|
9
|
+
|
|
10
|
+
/** Throttle window between progress frames (~10 fps). */
|
|
11
|
+
const THROTTLE_MS = 100
|
|
12
|
+
|
|
13
|
+
export interface TransferProgressTracker {
|
|
14
|
+
step(transferred: number, total: number): void
|
|
15
|
+
done(): void
|
|
16
|
+
fail(error: unknown): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createTransferProgressTracker(
|
|
20
|
+
file: string,
|
|
21
|
+
initialTotal: number,
|
|
22
|
+
emit?: (progress: TransferProgress) => void,
|
|
23
|
+
now: () => number = Date.now,
|
|
24
|
+
): TransferProgressTracker {
|
|
25
|
+
let total = initialTotal
|
|
26
|
+
let lastTransferred = 0
|
|
27
|
+
let lastEmit = now()
|
|
28
|
+
let lastTime = now()
|
|
29
|
+
let hasEmitted = false
|
|
30
|
+
let finished = false
|
|
31
|
+
|
|
32
|
+
// Initial 0% frame: marks the transfer as started. Deliberately does NOT
|
|
33
|
+
// advance the throttle state, so the first real step always goes through.
|
|
34
|
+
emit?.({ phase: 'transferring', file, transferred: 0, total, percent: 0, speedBps: undefined })
|
|
35
|
+
|
|
36
|
+
const emitFrame = (transferred: number, phase: TransferProgress['phase'], extra?: Partial<TransferProgress>): void => {
|
|
37
|
+
const intervalSec = (now() - lastTime) / 1000
|
|
38
|
+
emit?.({
|
|
39
|
+
phase,
|
|
40
|
+
file,
|
|
41
|
+
transferred,
|
|
42
|
+
total,
|
|
43
|
+
percent: phase === 'done' ? 100 : total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
44
|
+
...(phase === 'transferring'
|
|
45
|
+
? { speedBps: intervalSec > 0 ? Math.round((transferred - lastTransferred) / intervalSec) : undefined }
|
|
46
|
+
: {}),
|
|
47
|
+
...extra,
|
|
48
|
+
})
|
|
49
|
+
if (phase !== 'transferring') return
|
|
50
|
+
hasEmitted = true
|
|
51
|
+
lastTransferred = transferred
|
|
52
|
+
lastEmit = now()
|
|
53
|
+
lastTime = now()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
step(transferred, stepTotal) {
|
|
58
|
+
if (finished) return
|
|
59
|
+
total = stepTotal // the SFTP step total is authoritative
|
|
60
|
+
if (hasEmitted && now() - lastEmit < THROTTLE_MS && transferred < total) return
|
|
61
|
+
emitFrame(transferred, 'transferring')
|
|
62
|
+
},
|
|
63
|
+
done() {
|
|
64
|
+
if (finished) return
|
|
65
|
+
finished = true
|
|
66
|
+
emitFrame(total, 'done')
|
|
67
|
+
},
|
|
68
|
+
fail(error) {
|
|
69
|
+
if (finished) return
|
|
70
|
+
finished = true
|
|
71
|
+
// Keep the last known progress instead of zeroing it.
|
|
72
|
+
emitFrame(lastTransferred, 'error', { error: error instanceof Error ? error.message : String(error) })
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|