@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.0

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/host/tools.js ADDED
@@ -0,0 +1,1086 @@
1
+ /**
2
+ * Agent tools for dsh-ssh-helper.
3
+ *
4
+ * Exactly seven tools are registered:
5
+ * - `ssh_helper_accounts` lists/test SSH accounts without exposing credentials;
6
+ * - `ssh_helper_discover` scans/inspects deployment directories (read-only);
7
+ * - `ssh_helper_resources` manages the resource catalogue and history;
8
+ * - `ssh_helper_update_plan` builds a read-only, approval-ready plan;
9
+ * - `ssh_helper_update_apply` executes a plan (guarded by `tools/pre-execute`);
10
+ * - `ssh_helper_update_rollback` restores a backup (guarded the same way);
11
+ * - `ssh_helper_upload` pushes one local file over SFTP (guarded the same way).
12
+ *
13
+ * There is deliberately no generic remote-exec tool and no `confirm`, `force`
14
+ * or `skip_approval` parameter anywhere.
15
+ *
16
+ * @module host/tools
17
+ */
18
+
19
+ /** Maximum rendered text length. */
20
+ const RENDER_LIMIT = 8000
21
+
22
+ /**
23
+ * @param {unknown} error - thrown value.
24
+ * @returns {string} safe message.
25
+ */
26
+ function errorMessage(error) {
27
+ try {
28
+ if (error && typeof error.message === 'string' && error.message !== '') return error.message
29
+ } catch {
30
+ /* fall through */
31
+ }
32
+ return ''
33
+ }
34
+
35
+ /**
36
+ * Clamp a `*_seconds` tool argument.
37
+ * @param {unknown} value - caller value.
38
+ * @param {number} min - minimum seconds.
39
+ * @param {number} max - maximum seconds.
40
+ * @param {number} fallback - default seconds.
41
+ * @returns {number} clamped seconds.
42
+ */
43
+ function clampSeconds(value, min, max, fallback) {
44
+ if (value === undefined || value === null || value === '') return fallback
45
+ const numeric = Number(value)
46
+ if (!Number.isFinite(numeric)) return fallback
47
+ return Math.min(max, Math.max(min, Math.round(numeric)))
48
+ }
49
+
50
+ /**
51
+ * Coerce a value to display text.
52
+ * @param {unknown} value - raw value.
53
+ * @returns {string} text.
54
+ */
55
+ function textOf(value) {
56
+ return typeof value === 'string' ? value : ''
57
+ }
58
+
59
+ /**
60
+ * Coerce a value to a display number.
61
+ * @param {unknown} value - raw value.
62
+ * @param {number} fallback - default.
63
+ * @returns {number} number.
64
+ */
65
+ function numberOf(value, fallback) {
66
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
67
+ }
68
+
69
+ /**
70
+ * Shorten rendered text.
71
+ * @param {unknown} value - raw text.
72
+ * @returns {string} limited text.
73
+ */
74
+ function limitedText(value) {
75
+ const text = textOf(value)
76
+ return text.length <= RENDER_LIMIT ? text : `${text.slice(0, RENDER_LIMIT)}…`
77
+ }
78
+
79
+ /**
80
+ * Wrap one text block into tool content.
81
+ * @param {string} text - rendered text.
82
+ * @returns {{ type: 'text', text: string }[]} content blocks.
83
+ */
84
+ function textContent(text) {
85
+ return [{ type: 'text', text: limitedText(text) }]
86
+ }
87
+
88
+ /** Account public view schema. */
89
+ const ACCOUNT_VIEW_SCHEMA = {
90
+ type: 'object',
91
+ properties: {
92
+ id: { type: 'string' },
93
+ name: { type: 'string' },
94
+ host: { type: 'string' },
95
+ port: { type: 'number' },
96
+ username: { type: 'string' },
97
+ authType: { type: 'string' },
98
+ hasPassword: { type: 'boolean' },
99
+ hasKey: { type: 'boolean' },
100
+ note: { type: 'string' },
101
+ createdAt: { type: 'string' },
102
+ updatedAt: { type: 'string' },
103
+ },
104
+ required: ['id', 'name', 'host', 'port', 'username', 'authType', 'hasPassword', 'hasKey', 'createdAt', 'updatedAt'],
105
+ }
106
+
107
+ /** Discovered fact schema inside a resource view. */
108
+ const DISCOVERED_SCHEMA = {
109
+ type: 'object',
110
+ properties: {
111
+ os: { type: 'string' },
112
+ user: { type: 'string' },
113
+ sizeKB: { type: 'number' },
114
+ markers: { type: 'array', items: { type: 'string' } },
115
+ gitRemote: { type: 'string' },
116
+ gitBranch: { type: 'string' },
117
+ serviceUnits: { type: 'array', items: { type: 'string' } },
118
+ inspectedAt: { type: 'string' },
119
+ },
120
+ }
121
+
122
+ /** Resource public view schema. */
123
+ const RESOURCE_VIEW_SCHEMA = {
124
+ type: 'object',
125
+ properties: {
126
+ id: { type: 'string' },
127
+ name: { type: 'string' },
128
+ serverId: { type: 'string' },
129
+ serverName: { type: 'string' },
130
+ serviceName: { type: 'string' },
131
+ appDir: { type: 'string' },
132
+ updateCommand: { type: 'string' },
133
+ restartCommand: { type: 'string' },
134
+ verifyCommand: { type: 'string' },
135
+ backupEnabled: { type: 'boolean' },
136
+ backupDir: { type: 'string' },
137
+ backupKeep: { type: 'number' },
138
+ timeoutSeconds: { type: 'number' },
139
+ note: { type: 'string' },
140
+ tags: { type: 'array', items: { type: 'string' } },
141
+ discovered: { oneOf: [DISCOVERED_SCHEMA, { type: 'null' }] },
142
+ lastUpdateAt: { type: 'string' },
143
+ lastUpdateStatus: { type: 'string' },
144
+ lastBackupPath: { type: 'string' },
145
+ createdAt: { type: 'string' },
146
+ updatedAt: { type: 'string' },
147
+ },
148
+ required: [
149
+ 'id',
150
+ 'name',
151
+ 'serverId',
152
+ 'serverName',
153
+ 'appDir',
154
+ 'updateCommand',
155
+ 'backupEnabled',
156
+ 'backupKeep',
157
+ 'timeoutSeconds',
158
+ 'tags',
159
+ 'discovered',
160
+ 'createdAt',
161
+ 'updatedAt',
162
+ ],
163
+ }
164
+
165
+ /** History step schema. */
166
+ const HISTORY_STEP_SCHEMA = {
167
+ type: 'object',
168
+ properties: {
169
+ id: { type: 'string' },
170
+ title: { type: 'string' },
171
+ status: { type: 'string' },
172
+ command: { type: 'string' },
173
+ exitCode: { oneOf: [{ type: 'number' }, { type: 'null' }] },
174
+ durationMs: { type: 'number' },
175
+ stdoutTail: { type: 'string' },
176
+ stderrTail: { type: 'string' },
177
+ truncated: { type: 'boolean' },
178
+ },
179
+ required: ['id', 'title', 'status', 'exitCode', 'durationMs', 'stdoutTail', 'stderrTail', 'truncated'],
180
+ }
181
+
182
+ /** History entry schema. */
183
+ const HISTORY_ENTRY_SCHEMA = {
184
+ type: 'object',
185
+ properties: {
186
+ id: { type: 'string' },
187
+ action: { type: 'string' },
188
+ resourceId: { type: 'string' },
189
+ resourceName: { type: 'string' },
190
+ serverId: { type: 'string' },
191
+ serverName: { type: 'string' },
192
+ appDir: { type: 'string' },
193
+ backupPath: { type: 'string' },
194
+ status: { type: 'string' },
195
+ startedAt: { type: 'string' },
196
+ finishedAt: { type: 'string' },
197
+ durationMs: { type: 'number' },
198
+ steps: { type: 'array', items: HISTORY_STEP_SCHEMA },
199
+ message: { type: 'string' },
200
+ },
201
+ required: ['id', 'action', 'resourceId', 'resourceName', 'serverId', 'serverName', 'appDir', 'backupPath', 'status', 'startedAt', 'finishedAt', 'durationMs', 'steps'],
202
+ }
203
+
204
+ /** Git facts schema. */
205
+ const GIT_SCHEMA = {
206
+ type: 'object',
207
+ properties: {
208
+ remote: { type: 'string' },
209
+ branch: { type: 'string' },
210
+ commit: { type: 'string' },
211
+ dirty: { type: 'boolean' },
212
+ },
213
+ required: ['remote', 'branch', 'commit', 'dirty'],
214
+ }
215
+
216
+ /** Plan step schema. */
217
+ const PLAN_STEP_SCHEMA = {
218
+ type: 'object',
219
+ properties: {
220
+ id: { type: 'string' },
221
+ title: { type: 'string' },
222
+ detail: { type: 'string' },
223
+ },
224
+ required: ['id', 'title', 'detail'],
225
+ }
226
+
227
+ /** Preflight schema. */
228
+ const PREFLIGHT_SCHEMA = {
229
+ type: 'object',
230
+ properties: {
231
+ exists: { type: 'boolean' },
232
+ writable: { type: 'boolean' },
233
+ physicalPath: { type: 'string' },
234
+ sizeKB: { type: 'number' },
235
+ freeKB: { type: 'number' },
236
+ markers: { type: 'array', items: { type: 'string' } },
237
+ git: { oneOf: [GIT_SCHEMA, { type: 'null' }] },
238
+ serviceUnits: { type: 'array', items: { type: 'string' } },
239
+ },
240
+ required: ['exists', 'writable', 'physicalPath', 'sizeKB', 'freeKB', 'markers', 'git', 'serviceUnits'],
241
+ }
242
+
243
+ /** Backup schema. */
244
+ const BACKUP_SCHEMA = {
245
+ type: 'object',
246
+ properties: {
247
+ enabled: { type: 'boolean' },
248
+ dir: { type: 'string' },
249
+ file: { type: 'string' },
250
+ },
251
+ required: ['enabled', 'dir', 'file'],
252
+ }
253
+
254
+ /** Update plan schema (all-optional wrapper so `{ message }` failures validate too). */
255
+ const PLAN_OUTPUT_SCHEMA = {
256
+ type: 'object',
257
+ properties: {
258
+ id: { type: 'string' },
259
+ agentId: { type: 'string' },
260
+ resourceId: { type: 'string' },
261
+ resourceName: { type: 'string' },
262
+ serverId: { type: 'string' },
263
+ serverName: { type: 'string' },
264
+ appDir: { type: 'string' },
265
+ updateCommand: { type: 'string' },
266
+ restartCommand: { type: 'string' },
267
+ verifyCommand: { type: 'string' },
268
+ backup: BACKUP_SCHEMA,
269
+ preflight: PREFLIGHT_SCHEMA,
270
+ steps: { type: 'array', items: PLAN_STEP_SCHEMA },
271
+ warnings: { type: 'array', items: { type: 'string' } },
272
+ blockers: { type: 'array', items: { type: 'string' } },
273
+ createdAt: { type: 'string' },
274
+ expiresAt: { type: 'string' },
275
+ summary: {
276
+ type: 'object',
277
+ properties: { en: { type: 'string' }, zh: { type: 'string' } },
278
+ required: ['en', 'zh'],
279
+ },
280
+ message: { type: 'string' },
281
+ },
282
+ }
283
+
284
+ /** Scan/inspect output schema. */
285
+ const DISCOVER_OUTPUT_SCHEMA = {
286
+ type: 'object',
287
+ properties: {
288
+ server: { type: 'string' },
289
+ os: { type: 'string' },
290
+ user: { type: 'string' },
291
+ candidates: {
292
+ type: 'array',
293
+ items: {
294
+ type: 'object',
295
+ properties: {
296
+ path: { type: 'string' },
297
+ markers: { type: 'array', items: { type: 'string' } },
298
+ },
299
+ required: ['path', 'markers'],
300
+ },
301
+ },
302
+ listening: { type: 'array', items: { type: 'string' } },
303
+ services: { type: 'array', items: { type: 'string' } },
304
+ truncated: { type: 'boolean' },
305
+ path: { type: 'string' },
306
+ exists: { type: 'boolean' },
307
+ writable: { type: 'boolean' },
308
+ physicalPath: { type: 'string' },
309
+ sizeKB: { type: 'number' },
310
+ topLevel: {
311
+ type: 'array',
312
+ items: {
313
+ type: 'object',
314
+ properties: {
315
+ name: { type: 'string' },
316
+ type: { type: 'string' },
317
+ sizeBytes: { type: 'number' },
318
+ },
319
+ required: ['name', 'type', 'sizeBytes'],
320
+ },
321
+ },
322
+ markers: { type: 'array', items: { type: 'string' } },
323
+ git: { oneOf: [GIT_SCHEMA, { type: 'null' }] },
324
+ serviceUnits: { type: 'array', items: { type: 'string' } },
325
+ suggestedUpdateCommand: { type: 'string' },
326
+ suggestedRestartCommand: { type: 'string' },
327
+ message: { type: 'string' },
328
+ },
329
+ }
330
+
331
+ /** Accounts tool output schema. */
332
+ const ACCOUNTS_OUTPUT_SCHEMA = {
333
+ type: 'object',
334
+ properties: {
335
+ accounts: { type: 'array', items: ACCOUNT_VIEW_SCHEMA },
336
+ result: {
337
+ type: 'object',
338
+ properties: {
339
+ account: { type: 'string' },
340
+ success: { type: 'boolean' },
341
+ latencyMs: { type: 'number' },
342
+ message: { type: 'string' },
343
+ output: { type: 'string' },
344
+ },
345
+ required: ['account', 'success', 'latencyMs'],
346
+ },
347
+ message: { type: 'string' },
348
+ },
349
+ }
350
+
351
+ /** Resources tool output schema. */
352
+ const RESOURCES_OUTPUT_SCHEMA = {
353
+ type: 'object',
354
+ properties: {
355
+ resources: { type: 'array', items: RESOURCE_VIEW_SCHEMA },
356
+ resource: RESOURCE_VIEW_SCHEMA,
357
+ history: { type: 'array', items: HISTORY_ENTRY_SCHEMA },
358
+ deleted: { type: 'boolean' },
359
+ message: { type: 'string' },
360
+ },
361
+ }
362
+
363
+ /** Apply/rollback output schema (history entry or message). */
364
+ const HISTORY_OUTPUT_SCHEMA = {
365
+ type: 'object',
366
+ properties: {
367
+ ...HISTORY_ENTRY_SCHEMA.properties,
368
+ message: { type: 'string' },
369
+ },
370
+ }
371
+
372
+ /**
373
+ * Render the accounts tool result.
374
+ * @param {object} args - tool arguments.
375
+ * @param {object} value - tool result.
376
+ * @returns {{ type: 'text', text: string }[]} content.
377
+ */
378
+ function renderAccounts(args, value) {
379
+ try {
380
+ if (value && Array.isArray(value.accounts)) {
381
+ if (value.accounts.length === 0) return textContent('No SSH accounts are configured.')
382
+ const lines = [`SSH accounts (${value.accounts.length}):`]
383
+ for (const account of value.accounts) {
384
+ const auth = account.authType === 'key' ? 'private key' : 'password'
385
+ const credential = account.authType === 'key' ? (account.hasKey ? 'key stored' : 'no key') : account.hasPassword ? 'password stored' : 'no password'
386
+ lines.push(`- ${textOf(account.name)} — ${textOf(account.username)}@${textOf(account.host)}:${numberOf(account.port, 22)} (${auth} auth, ${credential})`)
387
+ if (typeof account.note === 'string' && account.note !== '') lines.push(` note: ${account.note}`)
388
+ }
389
+ return textContent(lines.join('\n'))
390
+ }
391
+ if (value && value.result && typeof value.result === 'object') {
392
+ const result = value.result
393
+ if (result.success === true) {
394
+ const header = `Account ${textOf(result.account)} reachable in ${numberOf(result.latencyMs, 0)} ms`
395
+ return textContent(textOf(result.output) ? `${header}\n${textOf(result.output)}` : header)
396
+ }
397
+ return textContent(`Account ${textOf(result.account)} connection failed — ${textOf(result.message) || 'unknown error'}`)
398
+ }
399
+ return textContent(textOf(value && value.message) || 'No account information is available.')
400
+ } catch {
401
+ return textContent('Account information is unavailable.')
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Render the discover tool result.
407
+ * @param {object} args - tool arguments.
408
+ * @param {object} value - tool result.
409
+ * @returns {{ type: 'text', text: string }[]} content.
410
+ */
411
+ function renderDiscover(args, value) {
412
+ try {
413
+ if (value && Array.isArray(value.candidates)) {
414
+ const lines = [`Scan on ${textOf(value.server) || 'server'} (${textOf(value.os) || 'unknown OS'}, user ${textOf(value.user) || 'unknown'}):`]
415
+ if (value.candidates.length === 0) lines.push('No candidate deployment directories were found.')
416
+ for (const candidate of value.candidates) {
417
+ lines.push(`- ${textOf(candidate.path)} — markers: ${Array.isArray(candidate.markers) ? candidate.markers.join(', ') : ''}`)
418
+ }
419
+ if (Array.isArray(value.listening) && value.listening.length > 0) {
420
+ lines.push('', 'Listening (raw):', ...value.listening.slice(0, 20).map((entry) => ` ${entry}`))
421
+ }
422
+ if (Array.isArray(value.services) && value.services.length > 0) {
423
+ lines.push('', 'Services (raw):', ...value.services.slice(0, 20).map((entry) => ` ${entry}`))
424
+ }
425
+ if (value.truncated === true) lines.push('', 'Output was truncated.')
426
+ return textContent(lines.join('\n'))
427
+ }
428
+ if (value && typeof value.path === 'string') {
429
+ const lines = [
430
+ `Directory ${value.path}`,
431
+ `exists: ${value.exists === true ? 'yes' : 'no'} · writable: ${value.writable === true ? 'yes' : 'no'} · size: ${numberOf(value.sizeKB, 0)} KB`,
432
+ ]
433
+ if (typeof value.physicalPath === 'string' && value.physicalPath !== '') lines.push(`physical: ${value.physicalPath}`)
434
+ if (Array.isArray(value.markers) && value.markers.length > 0) lines.push(`markers: ${value.markers.join(', ')}`)
435
+ if (value.git && typeof value.git === 'object') {
436
+ lines.push(`git: ${textOf(value.git.branch) || '?'} @ ${textOf(value.git.commit).slice(0, 12) || '?'}${value.git.dirty === true ? ' (dirty)' : ''}`)
437
+ if (textOf(value.git.remote) !== '') lines.push(`remote: ${value.git.remote}`)
438
+ }
439
+ if (Array.isArray(value.topLevel) && value.topLevel.length > 0) {
440
+ lines.push('', 'Top level:')
441
+ for (const entry of value.topLevel.slice(0, 30)) {
442
+ lines.push(`- ${textOf(entry.name)} (${textOf(entry.type)}, ${numberOf(entry.sizeBytes, 0)} bytes)`)
443
+ }
444
+ }
445
+ if (Array.isArray(value.serviceUnits) && value.serviceUnits.length > 0) lines.push('', `service units: ${value.serviceUnits.join(', ')}`)
446
+ if (textOf(value.suggestedUpdateCommand) !== '') lines.push('', `suggested update: ${value.suggestedUpdateCommand}`)
447
+ if (textOf(value.suggestedRestartCommand) !== '') lines.push(`suggested restart: ${value.suggestedRestartCommand}`)
448
+ return textContent(lines.join('\n'))
449
+ }
450
+ return textContent(textOf(value && value.message) || 'Discovery returned no data.')
451
+ } catch {
452
+ return textContent('Discovery result is unavailable.')
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Format one resource row.
458
+ * @param {object} resource - resource view.
459
+ * @returns {string} row.
460
+ */
461
+ function resourceRow(resource) {
462
+ const status = textOf(resource.lastUpdateStatus) || 'never'
463
+ return `- ${textOf(resource.name)} — ${textOf(resource.serverName) || 'unknown server'}:${textOf(resource.appDir)} (last: ${status})`
464
+ }
465
+
466
+ /**
467
+ * Render the resources tool result.
468
+ * @param {object} args - tool arguments.
469
+ * @param {object} value - tool result.
470
+ * @returns {{ type: 'text', text: string }[]} content.
471
+ */
472
+ function renderResources(args, value) {
473
+ try {
474
+ if (value && Array.isArray(value.resources)) {
475
+ if (value.resources.length === 0) return textContent('No deployment resources are configured.')
476
+ return textContent([`Deployment resources (${value.resources.length}):`, ...value.resources.map(resourceRow)].join('\n'))
477
+ }
478
+ if (value && value.resource && typeof value.resource === 'object') {
479
+ const resource = value.resource
480
+ const lines = [
481
+ `Resource ${textOf(resource.name)}`,
482
+ `server: ${textOf(resource.serverName) || 'unknown'} (${textOf(resource.serverId)})`,
483
+ `directory: ${textOf(resource.appDir)}`,
484
+ `update: ${textOf(resource.updateCommand)}`,
485
+ ]
486
+ if (textOf(resource.restartCommand) !== '') lines.push(`restart: ${resource.restartCommand}`)
487
+ if (textOf(resource.verifyCommand) !== '') lines.push(`verify: ${resource.verifyCommand}`)
488
+ lines.push(`backup: ${resource.backupEnabled === true ? `enabled (keep ${numberOf(resource.backupKeep, 5)})` : 'disabled'}`)
489
+ if (textOf(resource.lastUpdateStatus) !== '') {
490
+ lines.push(`last update: ${resource.lastUpdateStatus} at ${textOf(resource.lastUpdateAt) || 'unknown'}`)
491
+ }
492
+ if (Array.isArray(resource.history) || (value && Array.isArray(value.history))) {
493
+ const entries = Array.isArray(value.history) ? value.history : []
494
+ if (entries.length > 0) {
495
+ lines.push('', 'Recent history:')
496
+ for (const entry of entries) {
497
+ lines.push(`- ${textOf(entry.startedAt)} ${textOf(entry.action)} ${textOf(entry.status)} (${numberOf(entry.durationMs, 0)} ms)`)
498
+ }
499
+ }
500
+ }
501
+ return textContent(lines.join('\n'))
502
+ }
503
+ if (value && Array.isArray(value.history)) {
504
+ if (value.history.length === 0) return textContent('No update history is available.')
505
+ const lines = [`Update history (${value.history.length}):`]
506
+ for (const entry of value.history) {
507
+ lines.push(
508
+ `- ${textOf(entry.startedAt)} ${textOf(entry.action)} ${textOf(entry.status)} — ${textOf(entry.resourceName)} (${numberOf(entry.durationMs, 0)} ms)`,
509
+ )
510
+ }
511
+ return textContent(lines.join('\n'))
512
+ }
513
+ if (value && value.deleted === true) return textContent('Resource deleted.')
514
+ return textContent(textOf(value && value.message) || 'No resource information is available.')
515
+ } catch {
516
+ return textContent('Resource information is unavailable.')
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Render the update plan.
522
+ * @param {object} args - tool arguments.
523
+ * @param {object} value - tool result.
524
+ * @returns {{ type: 'text', text: string }[]} content.
525
+ */
526
+ function renderPlan(args, value) {
527
+ try {
528
+ if (!value || typeof value.id !== 'string') return textContent(textOf(value && value.message) || 'No plan was produced.')
529
+ const plan = value
530
+ const lines = [
531
+ `Update plan ${plan.id}`,
532
+ `resource: ${textOf(plan.resourceName)} on ${textOf(plan.serverName)}`,
533
+ `directory: ${textOf(plan.appDir)}`,
534
+ `update command: ${textOf(plan.updateCommand)}`,
535
+ ]
536
+ if (plan.backup && typeof plan.backup === 'object') {
537
+ lines.push(`backup: ${plan.backup.enabled === true ? textOf(plan.backup.file) : 'disabled'}`)
538
+ }
539
+ if (typeof plan.restartCommand === 'string' && plan.restartCommand !== '') lines.push(`restart command: ${plan.restartCommand}`)
540
+ if (typeof plan.verifyCommand === 'string' && plan.verifyCommand !== '') lines.push(`verify command: ${plan.verifyCommand}`)
541
+ if (Array.isArray(plan.steps) && plan.steps.length > 0) {
542
+ lines.push('', 'Steps:')
543
+ for (const step of plan.steps) lines.push(`- ${textOf(step.title)}: ${textOf(step.detail)}`)
544
+ }
545
+ if (Array.isArray(plan.blockers) && plan.blockers.length > 0) {
546
+ lines.push('', 'Blockers:')
547
+ for (const blocker of plan.blockers) lines.push(`- ${blocker}`)
548
+ }
549
+ if (Array.isArray(plan.warnings) && plan.warnings.length > 0) {
550
+ lines.push('', 'Warnings:')
551
+ for (const warning of plan.warnings) lines.push(`- ${warning}`)
552
+ }
553
+ lines.push('', `expires: ${textOf(plan.expiresAt)}`, 'Call ssh_helper_update_apply with this plan_id to request approval; nothing is executed until the user approves.')
554
+ return textContent(lines.join('\n'))
555
+ } catch {
556
+ return textContent('Update plan is unavailable.')
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Render one stored history entry.
562
+ * @param {object} args - tool arguments (unused).
563
+ * @param {object} value - history entry or `{ message }` failure.
564
+ * @returns {{ type: 'text', text: string }[]} content.
565
+ */
566
+ function renderHistory(args, value) {
567
+ try {
568
+ if (!value || typeof value.status !== 'string') return textContent(textOf(value && value.message) || 'No update result is available.')
569
+ const entry = value
570
+ const lines = [
571
+ `${textOf(entry.action)} ${textOf(entry.status)} — ${textOf(entry.resourceName)} on ${textOf(entry.serverName)}`,
572
+ `directory: ${textOf(entry.appDir)}`,
573
+ `backup: ${textOf(entry.backupPath) || '(none)'}`,
574
+ `duration: ${numberOf(entry.durationMs, 0)} ms`,
575
+ ]
576
+ if (textOf(entry.message) !== '') lines.push(`message: ${entry.message}`)
577
+ if (Array.isArray(entry.steps)) {
578
+ for (const step of entry.steps) {
579
+ lines.push('', `[${textOf(step.status)}] ${textOf(step.title)}${typeof step.exitCode === 'number' ? ` (exit ${step.exitCode})` : ''}`)
580
+ if (textOf(step.stderrTail) !== '') lines.push(`stderr: ${tailText(step.stderrTail)}`)
581
+ if (textOf(step.stdoutTail) !== '') lines.push(`stdout: ${tailText(step.stdoutTail)}`)
582
+ }
583
+ }
584
+ return textContent(lines.join('\n'))
585
+ } catch {
586
+ return textContent('Update result is unavailable.')
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Limit one step output block for rendering.
592
+ * @param {unknown} value - raw text.
593
+ * @returns {string} clipped text.
594
+ */
595
+ function tailText(value) {
596
+ const text = textOf(value)
597
+ return text.length <= 2000 ? text : `…${text.slice(text.length - 2000)}`
598
+ }
599
+
600
+ /**
601
+ * Build the `ssh_helper_accounts` tool.
602
+ * @param {{ accounts: object, ssh: object }} services - services.
603
+ * @returns {object} tool definition.
604
+ */
605
+ function accountsTool({ accounts, ssh }) {
606
+ return {
607
+ name: 'ssh_helper_accounts',
608
+ description:
609
+ 'List the SSH accounts configured in dsh-ssh-helper or test connectivity to one account. Credentials are never returned; use the account name or id from action="list" with action="test".',
610
+ parameters: {
611
+ type: 'object',
612
+ properties: {
613
+ action: { type: 'string', enum: ['list', 'test'], description: 'list accounts or test one account' },
614
+ account: { type: 'string', description: 'Account id or name; required for action=test' },
615
+ timeout_seconds: { type: 'number', description: 'Connection test timeout in seconds (1-120, default 20)' },
616
+ },
617
+ required: ['action'],
618
+ },
619
+ output: { schema: ACCOUNTS_OUTPUT_SCHEMA, render: renderAccounts },
620
+ presentCall: (args) => ({
621
+ card: 'generic',
622
+ title: args && args.action === 'test' ? `Test SSH account ${textOf(args.account) || ''}`.trim() : 'List SSH accounts',
623
+ kind: 'other',
624
+ }),
625
+ async execute(args) {
626
+ try {
627
+ const action = args && typeof args.action === 'string' ? args.action : ''
628
+ if (action === 'list') {
629
+ try {
630
+ return { accounts: accounts.list() }
631
+ } catch (error) {
632
+ return { message: errorMessage(error) || '读取账户列表失败' }
633
+ }
634
+ }
635
+ if (action === 'test') {
636
+ const query = args && typeof args.account === 'string' ? args.account.trim() : ''
637
+ if (query === '') return { message: '缺少账户参数:请提供要测试的账户 id 或名称' }
638
+ const view = accounts.find(query)
639
+ if (!view) return { message: `未找到账户 "${query}",请先用 ssh_helper_accounts 的 list 操作查看` }
640
+ const record = accounts.getSecretRecord(view.id)
641
+ if (!record) return { message: `无法读取账户 "${view.name}" 的连接凭据` }
642
+ const seconds = clampSeconds(args && args.timeout_seconds, 1, 120, 20)
643
+ const startedAt = Date.now()
644
+ try {
645
+ const probed = await ssh.probe(record, { timeoutMs: seconds * 1000 })
646
+ return {
647
+ result: {
648
+ account: view.name,
649
+ success: true,
650
+ latencyMs: typeof probed.latencyMs === 'number' ? probed.latencyMs : Date.now() - startedAt,
651
+ message: '连接成功',
652
+ output: textOf(probed.output),
653
+ },
654
+ }
655
+ } catch (error) {
656
+ return {
657
+ result: {
658
+ account: view.name,
659
+ success: false,
660
+ latencyMs: Date.now() - startedAt,
661
+ message: errorMessage(error) || '连接失败',
662
+ },
663
+ }
664
+ }
665
+ }
666
+ return { message: `不支持的操作 "${textOf(action)}":仅支持 list 或 test` }
667
+ } catch (error) {
668
+ return { message: errorMessage(error) || '操作失败' }
669
+ }
670
+ },
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Build the `ssh_helper_discover` tool.
676
+ * @param {{ accounts: object, discover: object }} services - services.
677
+ * @returns {object} tool definition.
678
+ */
679
+ function discoverTool({ accounts, discover }) {
680
+ /**
681
+ * Resolve an account and its secret record.
682
+ * @param {unknown} query - account id/name.
683
+ * @returns {object | { message: string }} record or failure.
684
+ */
685
+ function resolveAccount(query) {
686
+ const text = typeof query === 'string' ? query.trim() : ''
687
+ if (text === '') return { message: '缺少服务器参数:请提供账户 id 或名称' }
688
+ const view = accounts.find(text)
689
+ if (!view) return { message: `未找到账户 "${text}"` }
690
+ const record = accounts.getSecretRecord(view.id)
691
+ if (!record) return { message: `无法读取账户 "${view.name}" 的连接凭据` }
692
+ return record
693
+ }
694
+
695
+ return {
696
+ name: 'ssh_helper_discover',
697
+ description:
698
+ 'Read-only discovery of deployment directories on a configured SSH account. action="scan" searches common roots (or explicit roots) for project markers; action="inspect" reports whether one directory exists, its top-level entries, markers, git state and running services. File contents such as .env are never read. No command in this tool changes the remote host.',
699
+ parameters: {
700
+ type: 'object',
701
+ properties: {
702
+ action: { type: 'string', enum: ['scan', 'inspect'], description: 'scan candidate roots or inspect one directory' },
703
+ server: { type: 'string', description: 'Account id or name' },
704
+ roots: { type: 'array', items: { type: 'string' }, description: 'Absolute scan roots (scan only)' },
705
+ path: { type: 'string', description: 'Absolute directory path for inspect' },
706
+ max_depth: { type: 'number', description: 'Scan depth 1-4 (default 3)' },
707
+ limit: { type: 'number', description: 'Maximum candidates 1-200 (default 80)' },
708
+ timeout_seconds: { type: 'number', description: 'Timeout in seconds (1-600, default 60)' },
709
+ },
710
+ required: ['action', 'server'],
711
+ },
712
+ output: { schema: DISCOVER_OUTPUT_SCHEMA, render: renderDiscover },
713
+ presentCall: (args) => ({
714
+ card: 'generic',
715
+ title: args && args.action === 'inspect' ? `Inspect ${textOf(args.path) || 'directory'}` : `Scan ${textOf(args.server) || 'server'}`,
716
+ kind: 'other',
717
+ }),
718
+ async execute(args) {
719
+ try {
720
+ const action = args && typeof args.action === 'string' ? args.action : ''
721
+ const record = resolveAccount(args && args.server)
722
+ if (record.message !== undefined) return record
723
+ const seconds = clampSeconds(args && args.timeout_seconds, 1, 600, 60)
724
+ if (action === 'scan') {
725
+ const roots = Array.isArray(args && args.roots) ? args.roots.filter((root) => typeof root === 'string' && root.trim() !== '') : undefined
726
+ return await discover.scan(record, {
727
+ roots: roots === undefined || roots.length === 0 ? undefined : roots,
728
+ maxDepth: args && args.max_depth,
729
+ limit: args && args.limit,
730
+ timeoutMs: seconds * 1000,
731
+ })
732
+ }
733
+ if (action === 'inspect') {
734
+ const remotePath = args && typeof args.path === 'string' ? args.path.trim() : ''
735
+ if (remotePath === '') return { message: '缺少 path 参数:请提供要勘察的绝对目录路径' }
736
+ return await discover.inspect(record, { path: remotePath, timeoutMs: seconds * 1000 })
737
+ }
738
+ return { message: `不支持的操作 "${textOf(action)}":仅支持 scan 或 inspect` }
739
+ } catch (error) {
740
+ return { message: errorMessage(error) || '目录采集失败' }
741
+ }
742
+ },
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Build the `ssh_helper_resources` tool.
748
+ * @param {{ accounts: object, resources: object, history: object }} services - services.
749
+ * @returns {object} tool definition.
750
+ */
751
+ function resourcesTool({ accounts, resources, history }) {
752
+ /**
753
+ * Resolve one resource query.
754
+ * @param {unknown} query - id or name.
755
+ * @returns {object | { message: string }} view or failure.
756
+ */
757
+ function resolveResource(query) {
758
+ const text = typeof query === 'string' ? query.trim() : ''
759
+ if (text === '') return { message: '缺少资源参数:请提供资源 id 或名称' }
760
+ const view = resources.find(text)
761
+ if (!view) return { message: `未找到资源 "${text}",请先用 action="list" 查看` }
762
+ return view
763
+ }
764
+
765
+ return {
766
+ name: 'ssh_helper_resources',
767
+ description:
768
+ 'Manage the deployment resource catalogue: list resources, get one with recent history, list history, save a resource discovered on an SSH account, or delete one. Saving stores metadata and commands only; it never executes anything.',
769
+ parameters: {
770
+ type: 'object',
771
+ properties: {
772
+ action: { type: 'string', enum: ['list', 'get', 'history', 'save', 'delete'], description: 'operation' },
773
+ resource: { type: 'string', description: 'Resource id or name (get/history/delete, and save to update)' },
774
+ server: { type: 'string', description: 'Account id or name (list filter or save target)' },
775
+ name: { type: 'string', description: 'Resource display name' },
776
+ service_name: { type: 'string', description: 'systemd/docker service label' },
777
+ app_dir: { type: 'string', description: 'Absolute deployment directory' },
778
+ update_command: { type: 'string', description: 'Update command run in app_dir' },
779
+ restart_command: { type: 'string', description: 'Optional restart command' },
780
+ verify_command: { type: 'string', description: 'Optional verify command' },
781
+ backup_enabled: { type: 'boolean', description: 'Whether to back up before updates (default true)' },
782
+ backup_dir: { type: 'string', description: 'Optional absolute backup directory' },
783
+ backup_keep: { type: 'number', description: 'Backups to keep, 1-50 (default 5)' },
784
+ timeout_seconds: { type: 'number', description: 'Step timeout, 30-3600 (default 600)' },
785
+ note: { type: 'string', description: 'Optional note (max 500 chars)' },
786
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags' },
787
+ discovered: { type: 'object', description: 'Discovery facts to store (markers/git/services)' },
788
+ },
789
+ required: ['action'],
790
+ },
791
+ output: { schema: RESOURCES_OUTPUT_SCHEMA, render: renderResources },
792
+ presentCall: (args) => ({
793
+ card: 'generic',
794
+ title: args && args.action ? `Resources: ${textOf(args.action)}` : 'Deployment resources',
795
+ kind: 'other',
796
+ }),
797
+ async execute(args) {
798
+ try {
799
+ const action = args && typeof args.action === 'string' ? args.action : ''
800
+ if (action === 'list') {
801
+ const server = args && typeof args.server === 'string' ? args.server : undefined
802
+ return { resources: resources.list({ server }) }
803
+ }
804
+ if (action === 'get') {
805
+ const view = resolveResource(args && args.resource)
806
+ if (view.message !== undefined) return view
807
+ return { resource: view, history: history.list({ resourceId: view.id, limit: 20 }) }
808
+ }
809
+ if (action === 'history') {
810
+ let resourceId
811
+ if (args && typeof args.resource === 'string' && args.resource.trim() !== '') {
812
+ const view = resources.find(args.resource.trim())
813
+ if (!view) return { message: `未找到资源 "${args.resource}"` }
814
+ resourceId = view.id
815
+ }
816
+ const limit = clampSeconds(args && args.limit, 1, 200, 20)
817
+ return { history: history.list({ resourceId, limit }) }
818
+ }
819
+ if (action === 'save') {
820
+ const draft = {}
821
+ if (args && args.name !== undefined) draft.name = args.name
822
+ if (args && args.server !== undefined) draft.server = args.server
823
+ if (args && args.server_id !== undefined) draft.serverId = args.server_id
824
+ if (args && args.service_name !== undefined) draft.serviceName = args.service_name
825
+ if (args && args.app_dir !== undefined) draft.appDir = args.app_dir
826
+ if (args && args.update_command !== undefined) draft.updateCommand = args.update_command
827
+ if (args && args.restart_command !== undefined) draft.restartCommand = args.restart_command
828
+ if (args && args.verify_command !== undefined) draft.verifyCommand = args.verify_command
829
+ if (args && args.backup_enabled !== undefined) draft.backupEnabled = args.backup_enabled
830
+ if (args && args.backup_dir !== undefined) draft.backupDir = args.backup_dir
831
+ if (args && args.backup_keep !== undefined) draft.backupKeep = args.backup_keep
832
+ if (args && args.timeout_seconds !== undefined) draft.timeoutSeconds = args.timeout_seconds
833
+ if (args && args.note !== undefined) draft.note = args.note
834
+ if (args && args.tags !== undefined) draft.tags = args.tags
835
+ if (args && args.discovered !== undefined) draft.discovered = args.discovered
836
+ if (args && typeof args.resource === 'string' && args.resource.trim() !== '') {
837
+ const view = resolveResource(args.resource)
838
+ if (view.message !== undefined) return view
839
+ draft.id = view.id
840
+ }
841
+ return { resource: await resources.save(draft) }
842
+ }
843
+ if (action === 'delete') {
844
+ const view = resolveResource(args && args.resource)
845
+ if (view.message !== undefined) return view
846
+ const deleted = await resources.remove(view.id)
847
+ if (!deleted) return { message: `未找到资源 "${view.name}"` }
848
+ return { deleted: true, resource: view }
849
+ }
850
+ return { message: `不支持的操作 "${textOf(action)}":仅支持 list/get/history/save/delete` }
851
+ } catch (error) {
852
+ return { message: errorMessage(error) || '资源操作失败' }
853
+ }
854
+ },
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Build the `ssh_helper_update_plan` tool.
860
+ * @param {{ accounts: object, resources: object, update: object }} services - services.
861
+ * @returns {object} tool definition.
862
+ */
863
+ function planTool({ accounts, resources, update }) {
864
+ return {
865
+ name: 'ssh_helper_update_plan',
866
+ description:
867
+ 'Build a read-only update plan for one deployment resource. Preflight checks the directory, writability, free space, markers and git state; the returned plan_id can be passed to ssh_helper_update_apply. Nothing on the remote host is modified.',
868
+ parameters: {
869
+ type: 'object',
870
+ properties: {
871
+ resource: { type: 'string', description: 'Resource id or name' },
872
+ timeout_seconds: { type: 'number', description: 'Preflight timeout in seconds (1-600, default 60)' },
873
+ },
874
+ required: ['resource'],
875
+ },
876
+ output: { schema: PLAN_OUTPUT_SCHEMA, render: renderPlan },
877
+ presentCall: (args) => ({ card: 'generic', title: `Plan update for ${textOf(args && args.resource) || 'resource'}`, kind: 'other' }),
878
+ timeoutMs: 600_000,
879
+ async execute(args, exec) {
880
+ try {
881
+ const query = args && typeof args.resource === 'string' ? args.resource.trim() : ''
882
+ if (query === '') return { message: '缺少资源参数:请提供资源 id 或名称' }
883
+ const resource = resources.find(query)
884
+ if (!resource) return { message: `未找到资源 "${query}",请先用 ssh_helper_resources 的 list 操作查看` }
885
+ const account = accounts.getSecretRecord(resource.serverId)
886
+ if (!account) return { message: `资源 "${resource.name}" 关联的账户不存在,请先修复资源` }
887
+ const seconds = clampSeconds(args && args.timeout_seconds, 1, 600, 60)
888
+ return await update.plan(account, resource, {
889
+ agentId: exec && exec.agent ? exec.agent.id : undefined,
890
+ timeoutMs: seconds * 1000,
891
+ })
892
+ } catch (error) {
893
+ return { message: errorMessage(error) || '生成更新计划失败' }
894
+ }
895
+ },
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Build the `ssh_helper_update_apply` tool.
901
+ * @param {{ accounts: object, update: object }} services - services.
902
+ * @returns {object} tool definition.
903
+ */
904
+ function applyTool({ accounts, update }) {
905
+ return {
906
+ name: 'ssh_helper_update_apply',
907
+ description:
908
+ 'Execute a previously generated update plan: back up the app directory, run the update/restart/verify commands, prune old backups and record history. This tool is always gated by the harness approval prompt; without an approved allowed-once decision nothing runs.',
909
+ parameters: {
910
+ type: 'object',
911
+ properties: {
912
+ plan_id: { type: 'string', description: 'plan_id returned by ssh_helper_update_plan' },
913
+ },
914
+ required: ['plan_id'],
915
+ },
916
+ output: { schema: HISTORY_OUTPUT_SCHEMA, render: renderHistory },
917
+ presentCall: () => ({ card: 'generic', title: 'Apply approved SSH update', kind: 'other' }),
918
+ isConcurrencySafe: () => false,
919
+ timeoutMs: 3_600_000,
920
+ async execute(args, exec) {
921
+ try {
922
+ const planId = args && typeof args.plan_id === 'string' ? args.plan_id.trim() : ''
923
+ if (planId === '') return { message: '缺少 plan_id:请先调用 ssh_helper_update_plan' }
924
+ const agentId = exec && exec.agent ? exec.agent.id : undefined
925
+ const plan = update.peekPlan(planId, agentId)
926
+ if (!plan) return { message: '更新计划不存在或已过期,请重新调用 ssh_helper_update_plan 生成计划' }
927
+ if (Array.isArray(plan.blockers) && plan.blockers.length > 0) {
928
+ return { message: `更新计划存在阻塞项,无法执行:${plan.blockers.join('; ')}` }
929
+ }
930
+ const account = accounts.getSecretRecord(plan.serverId)
931
+ if (!account) return { message: '更新计划关联的账户不存在' }
932
+ return await update.apply(account, planId, agentId)
933
+ } catch (error) {
934
+ return { message: errorMessage(error) || '执行更新失败' }
935
+ }
936
+ },
937
+ }
938
+ }
939
+
940
+ /**
941
+ * Build the `ssh_helper_update_rollback` tool.
942
+ * @param {{ accounts: object, resources: object, update: object }} services - services.
943
+ * @returns {object} tool definition.
944
+ */
945
+ function rollbackTool({ accounts, resources, update }) {
946
+ return {
947
+ name: 'ssh_helper_update_rollback',
948
+ description:
949
+ 'Roll one deployment resource back to the latest recorded backup (or a specific history entry). The current directory is moved aside and the backup is extracted, then optional restart/verify commands run. Always gated by the harness approval prompt.',
950
+ parameters: {
951
+ type: 'object',
952
+ properties: {
953
+ resource: { type: 'string', description: 'Resource id or name' },
954
+ history_id: { type: 'string', description: 'Optional history entry id to restore instead of the latest backup' },
955
+ },
956
+ required: ['resource'],
957
+ },
958
+ output: { schema: HISTORY_OUTPUT_SCHEMA, render: renderHistory },
959
+ presentCall: (args) => ({ card: 'generic', title: `Roll back ${textOf(args && args.resource) || 'resource'}`, kind: 'other' }),
960
+ isConcurrencySafe: () => false,
961
+ timeoutMs: 3_600_000,
962
+ async execute(args, exec) {
963
+ try {
964
+ const query = args && typeof args.resource === 'string' ? args.resource.trim() : ''
965
+ if (query === '') return { message: '缺少资源参数:请提供资源 id 或名称' }
966
+ const resource = resources.find(query)
967
+ if (!resource) return { message: `未找到资源 "${query}"` }
968
+ const account = accounts.getSecretRecord(resource.serverId)
969
+ if (!account) return { message: `资源 "${resource.name}" 关联的账户不存在` }
970
+ const historyId = args && typeof args.history_id === 'string' && args.history_id.trim() !== '' ? args.history_id.trim() : undefined
971
+ return await update.rollback(account, resource.id, {
972
+ agentId: exec && exec.agent ? exec.agent.id : undefined,
973
+ historyId,
974
+ })
975
+ } catch (error) {
976
+ return { message: errorMessage(error) || '回滚失败' }
977
+ }
978
+ },
979
+ }
980
+ }
981
+
982
+ /**
983
+ * Build the `ssh_helper_upload` tool.
984
+ * @param {{ accounts: object, upload: object }} services - services.
985
+ * @returns {object} tool definition.
986
+ */
987
+ function uploadTool({ accounts, upload }) {
988
+ return {
989
+ name: 'ssh_helper_upload',
990
+ description:
991
+ 'Upload one local file to a remote server over SFTP and verify its size and SHA-256 on the server. Use it to push build artifacts (for example a dist tarball) before running ssh_helper_update_plan and ssh_helper_update_apply. This tool is always gated by the harness approval prompt; without an approved allowed-once decision nothing is transferred.',
992
+ parameters: {
993
+ type: 'object',
994
+ properties: {
995
+ server: { type: 'string', description: 'Account id or name of the target server' },
996
+ local_path: { type: 'string', description: 'Absolute path of the local file to upload' },
997
+ remote_path: { type: 'string', description: 'Absolute path of the destination file on the server, e.g. /tmp/app-dist.tgz' },
998
+ resource: { type: 'string', description: 'Optional deployment resource id or name this upload is for' },
999
+ timeout_seconds: { type: 'number', description: 'Upload timeout in seconds (5-3600, default 300)' },
1000
+ },
1001
+ required: ['server', 'local_path', 'remote_path'],
1002
+ },
1003
+ output: { schema: HISTORY_OUTPUT_SCHEMA, render: renderHistory },
1004
+ presentCall: (args) => ({ card: 'generic', title: `Upload to ${textOf(args && args.server) || 'server'}`, kind: 'other' }),
1005
+ isConcurrencySafe: () => false,
1006
+ timeoutMs: 3_600_000,
1007
+ async execute(args, exec) {
1008
+ try {
1009
+ const query = args && typeof args.server === 'string' ? args.server.trim() : ''
1010
+ if (query === '') return { message: '缺少服务器参数:请提供账户 id 或名称' }
1011
+ if (!accounts.find(query)) return { message: `未找到账户 "${query}",请先用 ssh_helper_accounts 的 list 操作查看` }
1012
+ if (typeof (args && args.local_path) !== 'string' || args.local_path.trim() === '') {
1013
+ return { message: '缺少 local_path:请提供要上传的本地文件的绝对路径' }
1014
+ }
1015
+ if (typeof (args && args.remote_path) !== 'string' || args.remote_path.trim() === '') {
1016
+ return { message: '缺少 remote_path:请提供目标文件的绝对路径' }
1017
+ }
1018
+ let target
1019
+ try {
1020
+ target = upload.resolve(args)
1021
+ } catch (error) {
1022
+ return { message: errorMessage(error) || '上传参数无效' }
1023
+ }
1024
+ return await upload.execute(target, { agentId: exec && exec.agent ? exec.agent.id : undefined })
1025
+ } catch (error) {
1026
+ return { message: errorMessage(error) || '上传失败' }
1027
+ }
1028
+ },
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * Register the agent tools.
1034
+ *
1035
+ * The three remote-write tools (`ssh_helper_update_apply`,
1036
+ * `ssh_helper_update_rollback`, `ssh_helper_upload`) are registered only when
1037
+ * `allowWrites` is true. The host entry point passes `false` when the approval
1038
+ * gate could not be mounted, because a write tool without a gate is a write
1039
+ * with no approval: the DSH `tools/pre-execute` waterfall defaults to
1040
+ * `{ kind: 'allow' }`, so an absent gate means "allow". Failing closed here
1041
+ * keeps the read-only tools usable while never shipping an unguarded writer.
1042
+ *
1043
+ * @param {object} ctx - host plugin context (`tools` service).
1044
+ * @param {{ accounts: object, resources: object, history: object, ssh: object, discover: object, update: object, upload: object, allowWrites?: boolean }} services - services.
1045
+ * @returns {(() => void)[]} tool disposers.
1046
+ */
1047
+ export function registerTools(ctx, services = {}) {
1048
+ if (!ctx || !ctx.tools || typeof ctx.tools.register !== 'function') {
1049
+ throw new Error('dsh-ssh-helper: tools service is unavailable')
1050
+ }
1051
+ const { accounts, resources, history, ssh, discover, update, upload } = services
1052
+ if (!accounts || !resources || !history || !ssh || !discover || !update || !upload) {
1053
+ throw new Error('dsh-ssh-helper: tool services are unavailable')
1054
+ }
1055
+ const allowWrites = services.allowWrites !== false
1056
+ if (!allowWrites) {
1057
+ try {
1058
+ ctx?.logger?.warn?.(
1059
+ 'dsh-ssh-helper: approval gate unavailable, registering read-only tools only (update/rollback/upload are disabled)',
1060
+ )
1061
+ } catch {
1062
+ /* logging is best effort */
1063
+ }
1064
+ }
1065
+ const definitions = [
1066
+ accountsTool({ accounts, ssh }),
1067
+ discoverTool({ accounts, discover }),
1068
+ resourcesTool({ accounts, resources, history }),
1069
+ planTool({ accounts, resources, update }),
1070
+ ]
1071
+ if (allowWrites) definitions.push(applyTool({ accounts, update }), rollbackTool({ accounts, resources, update }), uploadTool({ accounts, upload }))
1072
+ const disposers = []
1073
+ for (const definition of definitions) {
1074
+ try {
1075
+ const disposer = ctx.tools.register(definition)
1076
+ if (typeof disposer === 'function') disposers.push(disposer)
1077
+ } catch (error) {
1078
+ try {
1079
+ ctx?.logger?.warn?.(`dsh-ssh-helper: failed to register tool "${definition.name}": ${errorMessage(error)}`)
1080
+ } catch {
1081
+ /* logging is best effort */
1082
+ }
1083
+ }
1084
+ }
1085
+ return disposers
1086
+ }