dsh-ssh-workbench 0.0.0-stage → 1.7.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +178 -2
  5. package/SECURITY.md +27 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +146 -0
  8. package/docs/execution-model.md +83 -0
  9. package/docs/images/chat-at-resource.png +0 -0
  10. package/docs/images/plugin.png +0 -0
  11. package/docs/images/remote-workbench.png +0 -0
  12. package/docs/images/workbench.png +0 -0
  13. package/docs/quickstart.md +106 -0
  14. package/lib/client.js +4991 -0
  15. package/lib/core/activity.js +88 -0
  16. package/lib/core/background.js +69 -0
  17. package/lib/core/broker.js +802 -0
  18. package/lib/core/dialect.js +28 -0
  19. package/lib/core/errors.js +138 -0
  20. package/lib/core/execution-stats.js +88 -0
  21. package/lib/core/gate-pwsh.js +88 -0
  22. package/lib/core/gate.js +572 -0
  23. package/lib/core/guard.js +123 -0
  24. package/lib/core/known-hosts.js +74 -0
  25. package/lib/core/live-executions.js +101 -0
  26. package/lib/core/local-file-api.js +174 -0
  27. package/lib/core/localfs.js +381 -0
  28. package/lib/core/managed-context.js +276 -0
  29. package/lib/core/mentions.js +216 -0
  30. package/lib/core/paths.js +104 -0
  31. package/lib/core/posix-quote.js +4 -0
  32. package/lib/core/posix-shell.js +89 -0
  33. package/lib/core/prefs.js +38 -0
  34. package/lib/core/remote-exec-tool.js +394 -0
  35. package/lib/core/remote-file-tools.js +559 -0
  36. package/lib/core/remote-shell-tool.js +310 -0
  37. package/lib/core/secrets.js +95 -0
  38. package/lib/core/server-ref.js +61 -0
  39. package/lib/core/shell-adapters.js +48 -0
  40. package/lib/core/ssh.js +761 -0
  41. package/lib/core/store.js +83 -0
  42. package/lib/core/terminals.js +299 -0
  43. package/lib/core/win32-adapter.js +141 -0
  44. package/lib/index.js +409 -0
  45. package/lib/routes.js +759 -0
  46. package/locale/en.json +6 -0
  47. package/locale/zh.json +6 -0
  48. package/package.json +89 -4
  49. package/vendor/NOTICE.md +50 -0
  50. package/vendor/XTERM-LICENSE +21 -0
  51. package/vendor/node_modules/asn1/LICENSE +19 -0
  52. package/vendor/node_modules/asn1/README.md +50 -0
  53. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  54. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  55. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  56. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  57. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  58. package/vendor/node_modules/asn1/lib/index.js +20 -0
  59. package/vendor/node_modules/asn1/package.json +31 -0
  60. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  61. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  62. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  63. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  64. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  65. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  66. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  67. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  68. package/vendor/node_modules/safer-buffer/package.json +34 -0
  69. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  70. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  71. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  72. package/vendor/node_modules/tweetnacl/README.md +459 -0
  73. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  74. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  75. package/vendor/node_modules/tweetnacl/package.json +58 -0
  76. package/vendor/ssh2/LICENSE +19 -0
  77. package/vendor/ssh2/lib/Channel.js +295 -0
  78. package/vendor/ssh2/lib/agent.js +1123 -0
  79. package/vendor/ssh2/lib/client.js +2176 -0
  80. package/vendor/ssh2/lib/http-agents.js +84 -0
  81. package/vendor/ssh2/lib/index.js +44 -0
  82. package/vendor/ssh2/lib/keygen.js +582 -0
  83. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  84. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  85. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  86. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  87. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  88. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  89. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  90. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  91. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  92. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  93. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  94. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  95. package/vendor/ssh2/lib/server.js +1380 -0
  96. package/vendor/ssh2/lib/utils.js +336 -0
  97. package/vendor/ssh2/package.json +34 -0
  98. package/vendor/xterm.js +53 -0
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Explicit-target remote file tools.
3
+ *
4
+ * Shell-only remote access makes ordinary troubleshooting possible, but a
5
+ * single `cat`/`sed`/`grep` command cannot express "read this file", "replace
6
+ * this unique string" or "list files matching a glob" without brittle shell
7
+ * quoting. These tools add a small, deliberately non-transparent file layer:
8
+ * every call names its managed resource, reads and writes go through SFTP,
9
+ * writes are gated by configuration, and every operation is capped.
10
+ *
11
+ * They are additive conveniences, not a remote IDE: the native local
12
+ * `read/write/edit/glob/grep` tools keep their local semantics.
13
+ */
14
+ import { defineTool } from '@deepseek-ai/dsh-tools'
15
+
16
+ import { describeResultOutcome } from './activity.js'
17
+ import { resolveDialect, sessionKeyOf } from './remote-shell-tool.js'
18
+ import { requireServerRef } from './server-ref.js'
19
+ import { win32Adapter } from './win32-adapter.js'
20
+
21
+ export const REMOTE_READ_TOOL_NAME = 'remote_read'
22
+ export const REMOTE_WRITE_TOOL_NAME = 'remote_write'
23
+ export const REMOTE_EDIT_TOOL_NAME = 'remote_edit'
24
+ export const REMOTE_GLOB_TOOL_NAME = 'remote_glob'
25
+ export const REMOTE_GREP_TOOL_NAME = 'remote_grep'
26
+
27
+ const textOut = {
28
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
29
+ render: (_args, value) => [{ type: 'text', text: value.text }],
30
+ }
31
+
32
+ const MAX_READ_HARD_LIMIT = 64 * 1024 * 1024
33
+ const MAX_EDIT_HARD_LIMIT = 8 * 1024 * 1024
34
+ const MAX_RESULTS_HARD_LIMIT = 2000
35
+ const MAX_DEPTH_HARD_LIMIT = 12
36
+
37
+ function describeError(error) {
38
+ return error instanceof Error ? error.message : String(error)
39
+ }
40
+
41
+ function integerIn(value, fallback, min, max) {
42
+ return Number.isInteger(value) ? Math.max(min, Math.min(value, max)) : fallback
43
+ }
44
+
45
+ function loadServer({ loadInventory, serverRef, toolName }) {
46
+ let servers
47
+ try {
48
+ servers = loadInventory()
49
+ } catch (error) {
50
+ throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
51
+ }
52
+ return requireServerRef(servers, serverRef, { label: `${toolName} server` })
53
+ }
54
+
55
+ function normalizeRemotePath(value, dialect) {
56
+ let path = String(value ?? '').trim()
57
+ if (!path) throw new Error('path is required')
58
+ if (path.includes('\0')) throw new Error('path must not contain NUL bytes')
59
+ if (path === '~' || path.startsWith('~/') || path.startsWith('~\\')) {
60
+ return { home: true, path: path.slice(1) }
61
+ }
62
+ if (!(path.startsWith('/') || path.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(path))) {
63
+ throw new Error(`path must be absolute or start with ~: ${path}`)
64
+ }
65
+ if (dialect === 'windows') path = path.replace(/\\/g, '/')
66
+ return { home: false, path }
67
+ }
68
+
69
+ async function resolveRemotePath({ runtime, server, rawPath, dialect }) {
70
+ const normalized = normalizeRemotePath(rawPath, dialect)
71
+ let resolved
72
+ if (normalized.home) {
73
+ let home
74
+ try {
75
+ home = await runtime.sftpHome(server)
76
+ } catch (error) {
77
+ throw new Error(`cannot resolve the remote home directory: ${describeError(error)}`)
78
+ }
79
+ resolved = normalized.path ? `${home.replace(/[\\/]+$/, '')}/${normalized.path.replace(/^[\\/]+/, '')}` : home
80
+ } else {
81
+ resolved = normalized.path
82
+ }
83
+ return dialect === 'windows' ? resolved.replace(/\\/g, '/') : resolved
84
+ }
85
+
86
+ function joinRemotePath(parent, child) {
87
+ return `${String(parent ?? '').replace(/[\\/]+$/, '')}/${String(child ?? '').replace(/^[\\/]+/, '')}`
88
+ }
89
+
90
+ function joinRelative(parent, child) {
91
+ return parent ? `${parent}/${child}` : child
92
+ }
93
+
94
+ function globToRegExp(pattern) {
95
+ let source = '^'
96
+ const value = String(pattern ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
97
+ for (let index = 0; index < value.length; index += 1) {
98
+ const char = value[index]
99
+ if (char === '*') {
100
+ if (value[index + 1] === '*') {
101
+ index += 1
102
+ if (value[index + 1] === '/') {
103
+ index += 1
104
+ source += '(?:.*/)?'
105
+ } else {
106
+ source += '.*'
107
+ }
108
+ } else {
109
+ source += '[^/]*'
110
+ }
111
+ } else if (char === '?') {
112
+ source += '[^/]'
113
+ } else {
114
+ source += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char
115
+ }
116
+ }
117
+ source += '$'
118
+ return new RegExp(source)
119
+ }
120
+
121
+ function matchesGlob(pattern, relativePath) {
122
+ const normalized = String(pattern ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
123
+ const target = normalized.includes('/') ? relativePath : relativePath.split('/').at(-1)
124
+ return globToRegExp(normalized).test(target)
125
+ }
126
+
127
+ function renderHeader({ server, path, extra = [] }) {
128
+ const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
129
+ return [`target: ${server.name} (${target})`, path === undefined ? null : `path: ${path}`, ...extra].filter(
130
+ (line) => line !== null,
131
+ )
132
+ }
133
+
134
+ function describeActivityOutcome(outcome) {
135
+ return typeof outcome === 'string' && outcome.length > 0 ? outcome : 'unknown'
136
+ }
137
+
138
+ function recordFileActivity({ recordActivity, server, sessionId, startedAt, command, outcome }) {
139
+ if (typeof recordActivity !== 'function') return
140
+ try {
141
+ recordActivity({
142
+ kind: 'exec',
143
+ server: server.name,
144
+ command,
145
+ outcome,
146
+ sessionId,
147
+ durationMs: Date.now() - startedAt,
148
+ })
149
+ } catch {
150
+ // Activity is best effort and must never fail a file operation.
151
+ }
152
+ }
153
+
154
+ function assertWritesEnabled(config) {
155
+ if (config?.remoteFileWrite !== true) {
156
+ throw new Error(
157
+ 'remote file writes are disabled by configuration (remoteFileWrite=false); enable them in the plugin config before using remote_write or remote_edit',
158
+ )
159
+ }
160
+ }
161
+
162
+ function byteLength(value) {
163
+ return Buffer.byteLength(String(value ?? ''), 'utf8')
164
+ }
165
+
166
+ export function createRemoteReadTool({ runtime, config, loadInventory, recordActivity }) {
167
+ return defineTool({
168
+ name: REMOTE_READ_TOOL_NAME,
169
+ description:
170
+ 'Read a text file from a managed remote resource through SFTP. The path must be absolute or start with ~. Binary files and files over the read limit are rejected/truncated; use the workbench SFTP panel for large or binary downloads.',
171
+ parameters: {
172
+ server: { type: 'string', required: true, description: 'Managed resource name or id.' },
173
+ path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
174
+ maxBytes: { type: 'number', description: 'Optional read cap in bytes (clamped by plugin config).' },
175
+ },
176
+ output: textOut,
177
+ isConcurrencySafe: () => true,
178
+ async execute(args, exec) {
179
+ const startedAt = Date.now()
180
+ const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_read' })
181
+ const sessionId = sessionKeyOf(exec)
182
+ try {
183
+ const dialect = await resolveDialect(server, runtime)
184
+ const path = await resolveRemotePath({ runtime, server, rawPath: args?.path, dialect })
185
+ const cap = integerIn(config?.remoteFileReadBytes, 262144, 1024, MAX_READ_HARD_LIMIT)
186
+ const maxBytes = integerIn(args?.maxBytes, cap, 1024, cap)
187
+ const result = await runtime.sftpRead(server, path, { maxBytes })
188
+ if (result?.binary === true) throw new Error(`remote file is binary: ${path}`)
189
+ const lines = renderHeader({
190
+ server,
191
+ path,
192
+ extra: [
193
+ `size: ${result?.size ?? 'unknown'} bytes`,
194
+ `read: ${result?.bytesRead ?? 0} bytes`,
195
+ result?.truncated === true ? '[truncated by the configured read limit]' : null,
196
+ ].filter(Boolean),
197
+ })
198
+ lines.push('content:')
199
+ const content = typeof result?.content === 'string' ? result.content : ''
200
+ lines.push(content.length === 0 ? '(empty)' : content.replace(/\n$/, ''))
201
+ recordFileActivity({
202
+ recordActivity,
203
+ server,
204
+ sessionId,
205
+ startedAt,
206
+ command: `remote_read ${path}`,
207
+ outcome: 'success',
208
+ })
209
+ return { text: lines.join('\n') }
210
+ } catch (error) {
211
+ recordFileActivity({
212
+ recordActivity,
213
+ server,
214
+ sessionId,
215
+ startedAt,
216
+ command: `remote_read ${String(args?.path ?? '')}`,
217
+ outcome: `failed (${describeError(error)})`,
218
+ })
219
+ throw error
220
+ }
221
+ },
222
+ })
223
+ }
224
+
225
+ export function createRemoteWriteTool({ runtime, config, loadInventory, recordActivity }) {
226
+ return defineTool({
227
+ name: REMOTE_WRITE_TOOL_NAME,
228
+ description:
229
+ 'Write a UTF-8 text file on a managed remote resource through SFTP. Requires remoteFileWrite to be enabled in the plugin config. The parent directory must already exist; the file is created or overwritten.',
230
+ parameters: {
231
+ server: { type: 'string', required: true, description: 'Managed resource name or id.' },
232
+ path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
233
+ content: { type: 'string', required: true, description: 'Complete UTF-8 file content.' },
234
+ },
235
+ output: textOut,
236
+ isConcurrencySafe: () => false,
237
+ async execute(args, exec) {
238
+ assertWritesEnabled(config)
239
+ const startedAt = Date.now()
240
+ const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_write' })
241
+ const sessionId = sessionKeyOf(exec)
242
+ try {
243
+ const dialect = await resolveDialect(server, runtime)
244
+ const path = await resolveRemotePath({ runtime, server, rawPath: args?.path, dialect })
245
+ const content = String(args?.content ?? '')
246
+ const bytes = byteLength(content)
247
+ const cap = integerIn(config?.remoteFileEditBytes, 2097152, 1024, MAX_EDIT_HARD_LIMIT)
248
+ if (bytes > cap) throw new Error(`file content is ${bytes} bytes, over the ${cap}-byte write limit`)
249
+ const result = await runtime.sftpWrite(server, path, content)
250
+ recordFileActivity({
251
+ recordActivity,
252
+ server,
253
+ sessionId,
254
+ startedAt,
255
+ command: `remote_write ${path}`,
256
+ outcome: 'success',
257
+ })
258
+ return {
259
+ text: renderHeader({
260
+ server,
261
+ path,
262
+ extra: [`wrote: ${result?.bytes ?? bytes} bytes`],
263
+ }).join('\n'),
264
+ }
265
+ } catch (error) {
266
+ recordFileActivity({
267
+ recordActivity,
268
+ server,
269
+ sessionId,
270
+ startedAt,
271
+ command: `remote_write ${String(args?.path ?? '')}`,
272
+ outcome: `failed (${describeError(error)})`,
273
+ })
274
+ throw error
275
+ }
276
+ },
277
+ })
278
+ }
279
+
280
+ export function createRemoteEditTool({ runtime, config, loadInventory, recordActivity }) {
281
+ return defineTool({
282
+ name: REMOTE_EDIT_TOOL_NAME,
283
+ description:
284
+ 'Replace text in a UTF-8 file on a managed remote resource. By default oldText must match exactly once; set replaceAll=true to replace every occurrence. Requires remoteFileWrite to be enabled.',
285
+ parameters: {
286
+ server: { type: 'string', required: true, description: 'Managed resource name or id.' },
287
+ path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
288
+ oldText: { type: 'string', required: true, description: 'Exact text to find.' },
289
+ newText: { type: 'string', required: true, description: 'Replacement text.' },
290
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match.' },
291
+ },
292
+ output: textOut,
293
+ isConcurrencySafe: () => false,
294
+ async execute(args, exec) {
295
+ assertWritesEnabled(config)
296
+ const startedAt = Date.now()
297
+ const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_edit' })
298
+ const sessionId = sessionKeyOf(exec)
299
+ try {
300
+ const dialect = await resolveDialect(server, runtime)
301
+ const path = await resolveRemotePath({ runtime, server, rawPath: args?.path, dialect })
302
+ const oldText = String(args?.oldText ?? '')
303
+ if (!oldText) throw new Error('oldText must not be empty')
304
+ const newText = String(args?.newText ?? '')
305
+ const cap = integerIn(config?.remoteFileEditBytes, 2097152, 1024, MAX_EDIT_HARD_LIMIT)
306
+ const file = await runtime.sftpRead(server, path, { maxBytes: cap })
307
+ if (file?.binary === true) throw new Error(`remote file is binary: ${path}`)
308
+ if (file?.truncated === true) throw new Error(`remote file is larger than the ${cap}-byte edit limit: ${path}`)
309
+ const source = typeof file?.content === 'string' ? file.content : ''
310
+ const occurrences = source.split(oldText).length - 1
311
+ if (occurrences === 0) throw new Error(`oldText was not found in ${path}`)
312
+ if (occurrences > 1 && args?.replaceAll !== true) {
313
+ throw new Error(`oldText occurs ${occurrences} times in ${path}; pass a longer unique string or set replaceAll=true`)
314
+ }
315
+ const next = args?.replaceAll === true ? source.split(oldText).join(newText) : source.replace(oldText, newText)
316
+ if (byteLength(next) > cap) throw new Error(`edited file would exceed the ${cap}-byte write limit`)
317
+ await runtime.sftpWrite(server, path, next)
318
+ recordFileActivity({
319
+ recordActivity,
320
+ server,
321
+ sessionId,
322
+ startedAt,
323
+ command: `remote_edit ${path}`,
324
+ outcome: 'success',
325
+ })
326
+ return {
327
+ text: renderHeader({
328
+ server,
329
+ path,
330
+ extra: [`replacements: ${args?.replaceAll === true ? occurrences : 1}`],
331
+ }).join('\n'),
332
+ }
333
+ } catch (error) {
334
+ recordFileActivity({
335
+ recordActivity,
336
+ server,
337
+ sessionId,
338
+ startedAt,
339
+ command: `remote_edit ${String(args?.path ?? '')}`,
340
+ outcome: `failed (${describeError(error)})`,
341
+ })
342
+ throw error
343
+ }
344
+ },
345
+ })
346
+ }
347
+
348
+ async function walkRemote({ runtime, server, root, pattern, maxDepth, maxResults }) {
349
+ const results = []
350
+ const queue = [{ directory: root, relative: '', depth: 0 }]
351
+ let visited = 0
352
+ while (queue.length > 0 && results.length < maxResults && visited < maxResults * 20) {
353
+ const current = queue.shift()
354
+ let entries
355
+ try {
356
+ entries = await runtime.sftpList(server, current.directory)
357
+ } catch (error) {
358
+ if (current.depth === 0) throw error
359
+ continue
360
+ }
361
+ for (const entry of Array.isArray(entries) ? entries : []) {
362
+ if (!entry || entry.type === 'link' || typeof entry.name !== 'string') continue
363
+ visited += 1
364
+ const relative = joinRelative(current.relative, entry.name)
365
+ const path = entry.path ?? joinRemotePath(current.directory, entry.name)
366
+ if (entry.type === 'dir') {
367
+ if (current.depth < maxDepth) queue.push({ directory: path, relative, depth: current.depth + 1 })
368
+ } else if (matchesGlob(pattern, relative)) {
369
+ results.push({ path, relative, type: entry.type })
370
+ if (results.length >= maxResults) break
371
+ }
372
+ }
373
+ }
374
+ return { results, truncated: results.length >= maxResults }
375
+ }
376
+
377
+ export function createRemoteGlobTool({ runtime, config, loadInventory, recordActivity }) {
378
+ return defineTool({
379
+ name: REMOTE_GLOB_TOOL_NAME,
380
+ description:
381
+ 'Find files on a managed remote resource by glob through SFTP. Supports *, ? and **. Search path defaults to the remote home; symlinks are not followed; depth and result count are capped.',
382
+ parameters: {
383
+ server: { type: 'string', required: true, description: 'Managed resource name or id.' },
384
+ pattern: { type: 'string', required: true, description: 'Glob pattern, e.g. "**/*.log" or "etc/*.conf".' },
385
+ path: { type: 'string', description: 'Directory to search; defaults to the remote home directory.' },
386
+ maxResults: { type: 'number', description: 'Optional result cap (clamped by plugin config).' },
387
+ maxDepth: { type: 'number', description: 'Optional recursion depth (clamped by plugin config).' },
388
+ },
389
+ output: textOut,
390
+ isConcurrencySafe: () => true,
391
+ async execute(args, exec) {
392
+ const startedAt = Date.now()
393
+ const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_glob' })
394
+ const sessionId = sessionKeyOf(exec)
395
+ try {
396
+ const dialect = await resolveDialect(server, runtime)
397
+ const root = args?.path
398
+ ? await resolveRemotePath({ runtime, server, rawPath: args.path, dialect })
399
+ : await resolveRemotePath({ runtime, server, rawPath: '~', dialect })
400
+ const pattern = String(args?.pattern ?? '').trim()
401
+ if (!pattern) throw new Error('pattern is required')
402
+ const maxResults = integerIn(args?.maxResults, config?.remoteGlobMaxResults ?? 200, 1, MAX_RESULTS_HARD_LIMIT)
403
+ const maxDepth = integerIn(args?.maxDepth, config?.remoteGlobMaxDepth ?? 4, 0, MAX_DEPTH_HARD_LIMIT)
404
+ const walked = await walkRemote({ runtime, server, root, pattern, maxDepth, maxResults })
405
+ const lines = renderHeader({
406
+ server,
407
+ path: root,
408
+ extra: [`pattern: ${pattern}`, `matches: ${walked.results.length}`],
409
+ })
410
+ if (walked.results.length === 0) lines.push('(no matches)')
411
+ else for (const match of walked.results) lines.push(match.path)
412
+ if (walked.truncated) lines.push('[results truncated by the configured limit]')
413
+ recordFileActivity({
414
+ recordActivity,
415
+ server,
416
+ sessionId,
417
+ startedAt,
418
+ command: `remote_glob ${pattern}`,
419
+ outcome: 'success',
420
+ })
421
+ return { text: lines.join('\n') }
422
+ } catch (error) {
423
+ recordFileActivity({
424
+ recordActivity,
425
+ server,
426
+ sessionId,
427
+ startedAt,
428
+ command: `remote_glob ${String(args?.pattern ?? '')}`,
429
+ outcome: `failed (${describeError(error)})`,
430
+ })
431
+ throw error
432
+ }
433
+ },
434
+ })
435
+ }
436
+
437
+ function shellQuote(value) {
438
+ return `'${String(value ?? '').replace(/'/g, "'\\''")}'`
439
+ }
440
+
441
+ function powershellQuote(value) {
442
+ return `'${String(value ?? '').replace(/'/g, "''")}'`
443
+ }
444
+
445
+ function posixGrepCommand({ path, pattern, ignoreCase, maxResults }) {
446
+ const flags = ['-R', '-I', '-n', '-E']
447
+ if (ignoreCase === true) flags.push('-i')
448
+ return [
449
+ `grep ${flags.join(' ')} --exclude-dir=.git --exclude-dir=node_modules -e ${shellQuote(pattern)} -- ${shellQuote(path)}`,
450
+ `head -n ${maxResults}`,
451
+ ].join(' | ')
452
+ }
453
+
454
+ function windowsGrepScript({ path, pattern, ignoreCase, maxResults }) {
455
+ const caseSwitch = ignoreCase === true ? '' : ' -CaseSensitive'
456
+ return [
457
+ `$path = ${powershellQuote(path)}`,
458
+ `$item = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue`,
459
+ `if ($null -eq $item) { Write-Error ('path not found: ' + $path); exit 3 }`,
460
+ `if ($item.PSIsContainer) { $files = @(Get-ChildItem -LiteralPath $path -Recurse -File -ErrorAction SilentlyContinue) } else { $files = @($item) }`,
461
+ `$files | Select-String -Pattern ${powershellQuote(pattern)}${caseSwitch} | Select-Object -First ${maxResults} | ForEach-Object { "$($_.Path):$($_.LineNumber):$($_.Line)" }`,
462
+ ].join('\n')
463
+ }
464
+
465
+ export function createRemoteGrepTool({ runtime, config, loadInventory, recordActivity }) {
466
+ return defineTool({
467
+ name: REMOTE_GREP_TOOL_NAME,
468
+ description:
469
+ 'Search text in files on a managed remote resource. Uses grep on POSIX targets and Select-String on Windows targets; the path may be a file or directory and results are capped. Binary files are skipped on POSIX.',
470
+ parameters: {
471
+ server: { type: 'string', required: true, description: 'Managed resource name or id.' },
472
+ pattern: { type: 'string', required: true, description: 'Pattern to search for (POSIX extended regex / PowerShell regex).' },
473
+ path: { type: 'string', description: 'File or directory to search; defaults to the remote home directory.' },
474
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search.' },
475
+ maxResults: { type: 'number', description: 'Optional result cap (clamped by plugin config).' },
476
+ },
477
+ output: textOut,
478
+ isConcurrencySafe: () => true,
479
+ async execute(args, exec) {
480
+ const startedAt = Date.now()
481
+ const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_grep' })
482
+ const sessionId = sessionKeyOf(exec)
483
+ try {
484
+ const dialect = await resolveDialect(server, runtime)
485
+ const path = args?.path
486
+ ? await resolveRemotePath({ runtime, server, rawPath: args.path, dialect })
487
+ : await resolveRemotePath({ runtime, server, rawPath: '~', dialect })
488
+ const pattern = String(args?.pattern ?? '')
489
+ if (!pattern) throw new Error('pattern is required')
490
+ if (pattern.includes('\n') || pattern.includes('\r')) throw new Error('pattern must not contain newlines')
491
+ const maxResults = integerIn(args?.maxResults, config?.remoteGrepMaxResults ?? 200, 1, MAX_RESULTS_HARD_LIMIT)
492
+ let command
493
+ if (dialect === 'windows') {
494
+ const built = win32Adapter.buildCommand(
495
+ windowsGrepScript({ path, pattern, ignoreCase: args?.ignoreCase === true, maxResults }),
496
+ undefined,
497
+ server,
498
+ )
499
+ if (built?.error !== undefined) throw new Error(built.error)
500
+ command = built.command
501
+ } else {
502
+ // Validate the root first so a missing path is an error rather than
503
+ // an empty "no matches" result.
504
+ await runtime.sftpStat(server, path)
505
+ command = posixGrepCommand({ path, pattern, ignoreCase: args?.ignoreCase === true, maxResults })
506
+ }
507
+ const result = await runtime.exec(server, command, {
508
+ timeoutMs: config?.commandTimeoutMs ?? 120000,
509
+ signal: exec?.signal,
510
+ readOnly: true,
511
+ cap: config?.maxOutputBytes ?? 102400,
512
+ stderrCap: config?.maxStderrBytes ?? 16384,
513
+ })
514
+ const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
515
+ if (exitCode !== null && exitCode > 1 && dialect !== 'windows') {
516
+ throw new Error(`remote grep failed (exit ${exitCode}): ${result?.stderr || 'no stderr'}`)
517
+ }
518
+ const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
519
+ const lines = renderHeader({
520
+ server,
521
+ path,
522
+ extra: [`pattern: ${pattern}`, `matches: ${stdout.trim() ? stdout.trim().split('\n').length : 0}`],
523
+ })
524
+ lines.push(stdout.trim() ? stdout.replace(/\n$/, '') : '(no matches)')
525
+ if (result?.truncated?.stdout === true) lines.push('[stdout truncated by the plugin output cap]')
526
+ recordFileActivity({
527
+ recordActivity,
528
+ server,
529
+ sessionId,
530
+ startedAt,
531
+ command: `remote_grep ${pattern}`,
532
+ outcome: describeResultOutcome(result),
533
+ })
534
+ return { text: lines.join('\n') }
535
+ } catch (error) {
536
+ recordFileActivity({
537
+ recordActivity,
538
+ server,
539
+ sessionId,
540
+ startedAt,
541
+ command: `remote_grep ${String(args?.pattern ?? '')}`,
542
+ outcome: `failed (${describeError(error)})`,
543
+ })
544
+ throw error
545
+ }
546
+ },
547
+ })
548
+ }
549
+
550
+ /** Build all five definitions in the canonical registration order. */
551
+ export function createRemoteFileTools({ runtime, config, loadInventory, recordActivity }) {
552
+ return [
553
+ createRemoteReadTool({ runtime, config, loadInventory, recordActivity }),
554
+ createRemoteWriteTool({ runtime, config, loadInventory, recordActivity }),
555
+ createRemoteEditTool({ runtime, config, loadInventory, recordActivity }),
556
+ createRemoteGlobTool({ runtime, config, loadInventory, recordActivity }),
557
+ createRemoteGrepTool({ runtime, config, loadInventory, recordActivity }),
558
+ ]
559
+ }