dsh-ssh-workbench 1.6.4 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +47 -25
- package/SECURITY.md +2 -0
- package/docs/architecture.md +60 -38
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +24 -9
- package/lib/client.js +1249 -218
- package/lib/core/activity.js +5 -0
- package/lib/core/background.js +18 -4
- package/lib/core/broker.js +10 -7
- package/lib/core/dialect.js +1 -1
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +1 -1
- package/lib/core/guard.js +86 -34
- package/lib/core/live-executions.js +101 -0
- package/lib/core/managed-context.js +287 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +36 -3
- package/lib/core/remote-exec-tool.js +441 -0
- package/lib/core/remote-file-tools.js +642 -0
- package/lib/core/{remote-bash-tool.js → remote-shell-tool.js} +58 -69
- package/lib/core/secrets.js +0 -1
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +1 -1
- package/lib/core/ssh.js +22 -0
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +121 -157
- package/lib/routes.js +45 -5
- package/package.json +23 -7
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
|
@@ -0,0 +1,642 @@
|
|
|
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 { loadPrefs, resolveRemoteFileWrite } from './prefs.js'
|
|
18
|
+
import { resolveDialect, sessionKeyOf } from './remote-shell-tool.js'
|
|
19
|
+
import { requireServerRef } from './server-ref.js'
|
|
20
|
+
import { win32Adapter } from './win32-adapter.js'
|
|
21
|
+
|
|
22
|
+
export const REMOTE_READ_TOOL_NAME = 'remote_read'
|
|
23
|
+
export const REMOTE_WRITE_TOOL_NAME = 'remote_write'
|
|
24
|
+
export const REMOTE_EDIT_TOOL_NAME = 'remote_edit'
|
|
25
|
+
export const REMOTE_GLOB_TOOL_NAME = 'remote_glob'
|
|
26
|
+
export const REMOTE_GREP_TOOL_NAME = 'remote_grep'
|
|
27
|
+
|
|
28
|
+
const textOut = {
|
|
29
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
30
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_READ_HARD_LIMIT = 64 * 1024 * 1024
|
|
34
|
+
const MAX_EDIT_HARD_LIMIT = 8 * 1024 * 1024
|
|
35
|
+
const MAX_RESULTS_HARD_LIMIT = 2000
|
|
36
|
+
const MAX_DEPTH_HARD_LIMIT = 12
|
|
37
|
+
|
|
38
|
+
function describeError(error) {
|
|
39
|
+
return error instanceof Error ? error.message : String(error)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function integerIn(value, fallback, min, max) {
|
|
43
|
+
return Number.isInteger(value) ? Math.max(min, Math.min(value, max)) : fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadServer({ loadInventory, serverRef, toolName }) {
|
|
47
|
+
let servers
|
|
48
|
+
try {
|
|
49
|
+
servers = loadInventory()
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(`cannot read the managed server inventory: ${describeError(error)}`)
|
|
52
|
+
}
|
|
53
|
+
return requireServerRef(servers, serverRef, { label: `${toolName} server` })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeRemotePath(value, dialect) {
|
|
57
|
+
let path = String(value ?? '').trim()
|
|
58
|
+
if (!path) throw new Error('path is required')
|
|
59
|
+
if (path.includes('\0')) throw new Error('path must not contain NUL bytes')
|
|
60
|
+
if (path === '~' || path.startsWith('~/') || path.startsWith('~\\')) {
|
|
61
|
+
return { home: true, path: path.slice(1) }
|
|
62
|
+
}
|
|
63
|
+
if (!(path.startsWith('/') || path.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(path))) {
|
|
64
|
+
throw new Error(`path must be absolute or start with ~: ${path}`)
|
|
65
|
+
}
|
|
66
|
+
if (dialect === 'windows') path = path.replace(/\\/g, '/')
|
|
67
|
+
return { home: false, path }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveRemotePath({ runtime, server, rawPath, dialect }) {
|
|
71
|
+
const normalized = normalizeRemotePath(rawPath, dialect)
|
|
72
|
+
let resolved
|
|
73
|
+
if (normalized.home) {
|
|
74
|
+
let home
|
|
75
|
+
try {
|
|
76
|
+
home = await runtime.sftpHome(server)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error(`cannot resolve the remote home directory: ${describeError(error)}`)
|
|
79
|
+
}
|
|
80
|
+
resolved = normalized.path ? `${home.replace(/[\\/]+$/, '')}/${normalized.path.replace(/^[\\/]+/, '')}` : home
|
|
81
|
+
} else {
|
|
82
|
+
resolved = normalized.path
|
|
83
|
+
}
|
|
84
|
+
return dialect === 'windows' ? resolved.replace(/\\/g, '/') : resolved
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function joinRemotePath(parent, child) {
|
|
88
|
+
return `${String(parent ?? '').replace(/[\\/]+$/, '')}/${String(child ?? '').replace(/^[\\/]+/, '')}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function joinRelative(parent, child) {
|
|
92
|
+
return parent ? `${parent}/${child}` : child
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function globToRegExp(pattern) {
|
|
96
|
+
let source = '^'
|
|
97
|
+
const value = String(pattern ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
98
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
99
|
+
const char = value[index]
|
|
100
|
+
if (char === '*') {
|
|
101
|
+
if (value[index + 1] === '*') {
|
|
102
|
+
index += 1
|
|
103
|
+
if (value[index + 1] === '/') {
|
|
104
|
+
index += 1
|
|
105
|
+
source += '(?:.*/)?'
|
|
106
|
+
} else {
|
|
107
|
+
source += '.*'
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
source += '[^/]*'
|
|
111
|
+
}
|
|
112
|
+
} else if (char === '?') {
|
|
113
|
+
source += '[^/]'
|
|
114
|
+
} else {
|
|
115
|
+
source += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
source += '$'
|
|
119
|
+
return new RegExp(source)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function matchesGlob(pattern, relativePath) {
|
|
123
|
+
const normalized = String(pattern ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
124
|
+
const target = normalized.includes('/') ? relativePath : relativePath.split('/').at(-1)
|
|
125
|
+
return globToRegExp(normalized).test(target)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderHeader({ server, path, extra = [] }) {
|
|
129
|
+
const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
|
|
130
|
+
return [`target: ${server.name} (${target})`, path === undefined ? null : `path: ${path}`, ...extra].filter(
|
|
131
|
+
(line) => line !== null,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function describeActivityOutcome(outcome) {
|
|
136
|
+
return typeof outcome === 'string' && outcome.length > 0 ? outcome : 'unknown'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function recordFileActivity({ recordActivity, server, sessionId, startedAt, command, outcome }) {
|
|
140
|
+
if (typeof recordActivity !== 'function') return
|
|
141
|
+
try {
|
|
142
|
+
recordActivity({
|
|
143
|
+
kind: 'exec',
|
|
144
|
+
server: server.name,
|
|
145
|
+
command,
|
|
146
|
+
outcome,
|
|
147
|
+
sessionId,
|
|
148
|
+
durationMs: Date.now() - startedAt,
|
|
149
|
+
})
|
|
150
|
+
} catch {
|
|
151
|
+
// Activity is best effort and must never fail a file operation.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The write switch can come from the plugin config or from the settings UI
|
|
157
|
+
* override; both are resolved on every call so a toggle takes effect at once.
|
|
158
|
+
*/
|
|
159
|
+
function assertWritesEnabled(config) {
|
|
160
|
+
if (resolveRemoteFileWrite(config, loadPrefs()) !== true) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
'remote file writes are disabled; enable them in the plugin settings (远程工作台 → 远程写入) or with remoteFileWrite in the plugin config before using remote_write or remote_edit',
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function byteLength(value) {
|
|
168
|
+
return Buffer.byteLength(String(value ?? ''), 'utf8')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function randomTempSuffix() {
|
|
172
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Write one remote file with replace-on-success semantics.
|
|
177
|
+
*
|
|
178
|
+
* POSIX `rename(2)` over an existing path is atomic, so writing a sibling temp
|
|
179
|
+
* file and renaming it into place means a dropped connection or a failed write
|
|
180
|
+
* never leaves a half-written target. Windows OpenSSH cannot reliably rename
|
|
181
|
+
* over an existing file, so Windows targets keep the direct write. A symlink
|
|
182
|
+
* target is written through instead of replaced, so the link itself survives.
|
|
183
|
+
*/
|
|
184
|
+
async function writeRemoteFile({ runtime, server, path, content, dialect }) {
|
|
185
|
+
const writeDirect = async (fallback) => {
|
|
186
|
+
const result = await runtime.sftpWrite(server, path, content)
|
|
187
|
+
return { ...result, atomic: false, fallback }
|
|
188
|
+
}
|
|
189
|
+
if (dialect === 'windows') return writeDirect('windows')
|
|
190
|
+
if (typeof runtime.sftpRename !== 'function' || typeof runtime.sftpRemove !== 'function') {
|
|
191
|
+
return writeDirect('no-rename-support')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let existing = null
|
|
195
|
+
if (typeof runtime.sftpLstat === 'function') {
|
|
196
|
+
try {
|
|
197
|
+
existing = await runtime.sftpLstat(server, path)
|
|
198
|
+
} catch {
|
|
199
|
+
existing = null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Never turn a symlink into a regular file; write through the link instead.
|
|
203
|
+
if (existing?.type === 'link') return writeDirect('symlink')
|
|
204
|
+
|
|
205
|
+
const temp = `${path}.sdh-tmp-${randomTempSuffix()}`
|
|
206
|
+
try {
|
|
207
|
+
await runtime.sftpWrite(server, temp, content)
|
|
208
|
+
if (existing && Number.isInteger(existing.mode) && typeof runtime.sftpChmod === 'function') {
|
|
209
|
+
try {
|
|
210
|
+
await runtime.sftpChmod(server, temp, existing.mode & 0o7777)
|
|
211
|
+
} catch {
|
|
212
|
+
// Preserving the mode is best effort; the content is what matters.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await runtime.sftpRename(server, temp, path)
|
|
217
|
+
return { path, bytes: byteLength(content), atomic: true }
|
|
218
|
+
} catch {
|
|
219
|
+
// A server may refuse to rename over an existing file. Fall back to a
|
|
220
|
+
// direct write instead of deleting the target first (which would leave a
|
|
221
|
+
// window where the file does not exist).
|
|
222
|
+
await runtime.sftpWrite(server, path, content)
|
|
223
|
+
try {
|
|
224
|
+
await runtime.sftpRemove(server, temp)
|
|
225
|
+
} catch {
|
|
226
|
+
// Best effort cleanup.
|
|
227
|
+
}
|
|
228
|
+
return { path, bytes: byteLength(content), atomic: false, fallback: 'rename-refused' }
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
try {
|
|
232
|
+
await runtime.sftpRemove(server, temp)
|
|
233
|
+
} catch {
|
|
234
|
+
// Best effort cleanup; the original target is untouched.
|
|
235
|
+
}
|
|
236
|
+
throw error
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function createRemoteReadTool({ runtime, config, loadInventory, recordActivity }) {
|
|
241
|
+
return defineTool({
|
|
242
|
+
name: REMOTE_READ_TOOL_NAME,
|
|
243
|
+
description:
|
|
244
|
+
'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.',
|
|
245
|
+
parameters: {
|
|
246
|
+
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
247
|
+
path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
|
|
248
|
+
maxBytes: { type: 'number', description: 'Optional read cap in bytes (clamped by plugin config).' },
|
|
249
|
+
},
|
|
250
|
+
output: textOut,
|
|
251
|
+
isConcurrencySafe: () => true,
|
|
252
|
+
async execute(args, exec) {
|
|
253
|
+
const startedAt = Date.now()
|
|
254
|
+
const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_read' })
|
|
255
|
+
const sessionId = sessionKeyOf(exec)
|
|
256
|
+
try {
|
|
257
|
+
const dialect = await resolveDialect(server, runtime)
|
|
258
|
+
const path = await resolveRemotePath({ runtime, server, rawPath: args?.path, dialect })
|
|
259
|
+
const cap = integerIn(config?.remoteFileReadBytes, 262144, 1024, MAX_READ_HARD_LIMIT)
|
|
260
|
+
const maxBytes = integerIn(args?.maxBytes, cap, 1024, cap)
|
|
261
|
+
const result = await runtime.sftpRead(server, path, { maxBytes })
|
|
262
|
+
if (result?.binary === true) throw new Error(`remote file is binary: ${path}`)
|
|
263
|
+
const lines = renderHeader({
|
|
264
|
+
server,
|
|
265
|
+
path,
|
|
266
|
+
extra: [
|
|
267
|
+
`size: ${result?.size ?? 'unknown'} bytes`,
|
|
268
|
+
`read: ${result?.bytesRead ?? 0} bytes`,
|
|
269
|
+
result?.truncated === true ? '[truncated by the configured read limit]' : null,
|
|
270
|
+
].filter(Boolean),
|
|
271
|
+
})
|
|
272
|
+
lines.push('content:')
|
|
273
|
+
const content = typeof result?.content === 'string' ? result.content : ''
|
|
274
|
+
lines.push(content.length === 0 ? '(empty)' : content.replace(/\n$/, ''))
|
|
275
|
+
recordFileActivity({
|
|
276
|
+
recordActivity,
|
|
277
|
+
server,
|
|
278
|
+
sessionId,
|
|
279
|
+
startedAt,
|
|
280
|
+
command: `remote_read ${path}`,
|
|
281
|
+
outcome: 'success',
|
|
282
|
+
})
|
|
283
|
+
return { text: lines.join('\n') }
|
|
284
|
+
} catch (error) {
|
|
285
|
+
recordFileActivity({
|
|
286
|
+
recordActivity,
|
|
287
|
+
server,
|
|
288
|
+
sessionId,
|
|
289
|
+
startedAt,
|
|
290
|
+
command: `remote_read ${String(args?.path ?? '')}`,
|
|
291
|
+
outcome: `failed (${describeError(error)})`,
|
|
292
|
+
})
|
|
293
|
+
throw error
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function createRemoteWriteTool({ runtime, config, loadInventory, recordActivity }) {
|
|
300
|
+
return defineTool({
|
|
301
|
+
name: REMOTE_WRITE_TOOL_NAME,
|
|
302
|
+
description:
|
|
303
|
+
'Write a UTF-8 text file on a managed remote resource through SFTP. Requires remote writes to be enabled in the plugin settings. The parent directory must already exist; the file is created or overwritten.',
|
|
304
|
+
parameters: {
|
|
305
|
+
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
306
|
+
path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
|
|
307
|
+
content: { type: 'string', required: true, description: 'Complete UTF-8 file content.' },
|
|
308
|
+
},
|
|
309
|
+
output: textOut,
|
|
310
|
+
isConcurrencySafe: () => false,
|
|
311
|
+
async execute(args, exec) {
|
|
312
|
+
assertWritesEnabled(config)
|
|
313
|
+
const startedAt = Date.now()
|
|
314
|
+
const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_write' })
|
|
315
|
+
const sessionId = sessionKeyOf(exec)
|
|
316
|
+
try {
|
|
317
|
+
const dialect = await resolveDialect(server, runtime)
|
|
318
|
+
const path = await resolveRemotePath({ runtime, server, rawPath: args?.path, dialect })
|
|
319
|
+
const content = String(args?.content ?? '')
|
|
320
|
+
const bytes = byteLength(content)
|
|
321
|
+
const cap = integerIn(config?.remoteFileEditBytes, 2097152, 1024, MAX_EDIT_HARD_LIMIT)
|
|
322
|
+
if (bytes > cap) throw new Error(`file content is ${bytes} bytes, over the ${cap}-byte write limit`)
|
|
323
|
+
const result = await writeRemoteFile({ runtime, server, path, content, dialect })
|
|
324
|
+
recordFileActivity({
|
|
325
|
+
recordActivity,
|
|
326
|
+
server,
|
|
327
|
+
sessionId,
|
|
328
|
+
startedAt,
|
|
329
|
+
command: `remote_write ${path}`,
|
|
330
|
+
outcome: 'success',
|
|
331
|
+
})
|
|
332
|
+
const note =
|
|
333
|
+
result?.atomic === true
|
|
334
|
+
? ' (atomic replace)'
|
|
335
|
+
: result?.fallback === 'symlink'
|
|
336
|
+
? ' (written through symlink)'
|
|
337
|
+
: result?.fallback === 'windows'
|
|
338
|
+
? ' (direct write: atomic replace is not available on Windows targets)'
|
|
339
|
+
: ''
|
|
340
|
+
return {
|
|
341
|
+
text: renderHeader({
|
|
342
|
+
server,
|
|
343
|
+
path,
|
|
344
|
+
extra: [`wrote: ${result?.bytes ?? bytes} bytes${note}`],
|
|
345
|
+
}).join('\n'),
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
recordFileActivity({
|
|
349
|
+
recordActivity,
|
|
350
|
+
server,
|
|
351
|
+
sessionId,
|
|
352
|
+
startedAt,
|
|
353
|
+
command: `remote_write ${String(args?.path ?? '')}`,
|
|
354
|
+
outcome: `failed (${describeError(error)})`,
|
|
355
|
+
})
|
|
356
|
+
throw error
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function createRemoteEditTool({ runtime, config, loadInventory, recordActivity }) {
|
|
363
|
+
return defineTool({
|
|
364
|
+
name: REMOTE_EDIT_TOOL_NAME,
|
|
365
|
+
description:
|
|
366
|
+
'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 remote writes to be enabled in the plugin settings.',
|
|
367
|
+
parameters: {
|
|
368
|
+
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
369
|
+
path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
|
|
370
|
+
oldText: { type: 'string', required: true, description: 'Exact text to find.' },
|
|
371
|
+
newText: { type: 'string', required: true, description: 'Replacement text.' },
|
|
372
|
+
replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match.' },
|
|
373
|
+
},
|
|
374
|
+
output: textOut,
|
|
375
|
+
isConcurrencySafe: () => false,
|
|
376
|
+
async execute(args, exec) {
|
|
377
|
+
assertWritesEnabled(config)
|
|
378
|
+
const startedAt = Date.now()
|
|
379
|
+
const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_edit' })
|
|
380
|
+
const sessionId = sessionKeyOf(exec)
|
|
381
|
+
try {
|
|
382
|
+
const dialect = await resolveDialect(server, runtime)
|
|
383
|
+
const path = await resolveRemotePath({ runtime, server, rawPath: args?.path, dialect })
|
|
384
|
+
const oldText = String(args?.oldText ?? '')
|
|
385
|
+
if (!oldText) throw new Error('oldText must not be empty')
|
|
386
|
+
const newText = String(args?.newText ?? '')
|
|
387
|
+
const cap = integerIn(config?.remoteFileEditBytes, 2097152, 1024, MAX_EDIT_HARD_LIMIT)
|
|
388
|
+
const file = await runtime.sftpRead(server, path, { maxBytes: cap })
|
|
389
|
+
if (file?.binary === true) throw new Error(`remote file is binary: ${path}`)
|
|
390
|
+
if (file?.truncated === true) throw new Error(`remote file is larger than the ${cap}-byte edit limit: ${path}`)
|
|
391
|
+
const source = typeof file?.content === 'string' ? file.content : ''
|
|
392
|
+
const occurrences = source.split(oldText).length - 1
|
|
393
|
+
if (occurrences === 0) throw new Error(`oldText was not found in ${path}`)
|
|
394
|
+
if (occurrences > 1 && args?.replaceAll !== true) {
|
|
395
|
+
throw new Error(`oldText occurs ${occurrences} times in ${path}; pass a longer unique string or set replaceAll=true`)
|
|
396
|
+
}
|
|
397
|
+
const next = args?.replaceAll === true ? source.split(oldText).join(newText) : source.replace(oldText, newText)
|
|
398
|
+
if (byteLength(next) > cap) throw new Error(`edited file would exceed the ${cap}-byte write limit`)
|
|
399
|
+
const written = await writeRemoteFile({ runtime, server, path, content: next, dialect })
|
|
400
|
+
recordFileActivity({
|
|
401
|
+
recordActivity,
|
|
402
|
+
server,
|
|
403
|
+
sessionId,
|
|
404
|
+
startedAt,
|
|
405
|
+
command: `remote_edit ${path}`,
|
|
406
|
+
outcome: 'success',
|
|
407
|
+
})
|
|
408
|
+
const modeNote = written?.atomic === true ? ', atomic replace' : ''
|
|
409
|
+
return {
|
|
410
|
+
text: renderHeader({
|
|
411
|
+
server,
|
|
412
|
+
path,
|
|
413
|
+
extra: [`replacements: ${args?.replaceAll === true ? occurrences : 1}${modeNote}`],
|
|
414
|
+
}).join('\n'),
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
recordFileActivity({
|
|
418
|
+
recordActivity,
|
|
419
|
+
server,
|
|
420
|
+
sessionId,
|
|
421
|
+
startedAt,
|
|
422
|
+
command: `remote_edit ${String(args?.path ?? '')}`,
|
|
423
|
+
outcome: `failed (${describeError(error)})`,
|
|
424
|
+
})
|
|
425
|
+
throw error
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function walkRemote({ runtime, server, root, pattern, maxDepth, maxResults }) {
|
|
432
|
+
const results = []
|
|
433
|
+
const queue = [{ directory: root, relative: '', depth: 0 }]
|
|
434
|
+
let visited = 0
|
|
435
|
+
while (queue.length > 0 && results.length < maxResults && visited < maxResults * 20) {
|
|
436
|
+
const current = queue.shift()
|
|
437
|
+
let entries
|
|
438
|
+
try {
|
|
439
|
+
entries = await runtime.sftpList(server, current.directory)
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (current.depth === 0) throw error
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
445
|
+
if (!entry || entry.type === 'link' || typeof entry.name !== 'string') continue
|
|
446
|
+
visited += 1
|
|
447
|
+
const relative = joinRelative(current.relative, entry.name)
|
|
448
|
+
const path = entry.path ?? joinRemotePath(current.directory, entry.name)
|
|
449
|
+
if (entry.type === 'dir') {
|
|
450
|
+
if (current.depth < maxDepth) queue.push({ directory: path, relative, depth: current.depth + 1 })
|
|
451
|
+
} else if (matchesGlob(pattern, relative)) {
|
|
452
|
+
results.push({ path, relative, type: entry.type })
|
|
453
|
+
if (results.length >= maxResults) break
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return { results, truncated: results.length >= maxResults }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function createRemoteGlobTool({ runtime, config, loadInventory, recordActivity }) {
|
|
461
|
+
return defineTool({
|
|
462
|
+
name: REMOTE_GLOB_TOOL_NAME,
|
|
463
|
+
description:
|
|
464
|
+
'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.',
|
|
465
|
+
parameters: {
|
|
466
|
+
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
467
|
+
pattern: { type: 'string', required: true, description: 'Glob pattern, e.g. "**/*.log" or "etc/*.conf".' },
|
|
468
|
+
path: { type: 'string', description: 'Directory to search; defaults to the remote home directory.' },
|
|
469
|
+
maxResults: { type: 'number', description: 'Optional result cap (clamped by plugin config).' },
|
|
470
|
+
maxDepth: { type: 'number', description: 'Optional recursion depth (clamped by plugin config).' },
|
|
471
|
+
},
|
|
472
|
+
output: textOut,
|
|
473
|
+
isConcurrencySafe: () => true,
|
|
474
|
+
async execute(args, exec) {
|
|
475
|
+
const startedAt = Date.now()
|
|
476
|
+
const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_glob' })
|
|
477
|
+
const sessionId = sessionKeyOf(exec)
|
|
478
|
+
try {
|
|
479
|
+
const dialect = await resolveDialect(server, runtime)
|
|
480
|
+
const root = args?.path
|
|
481
|
+
? await resolveRemotePath({ runtime, server, rawPath: args.path, dialect })
|
|
482
|
+
: await resolveRemotePath({ runtime, server, rawPath: '~', dialect })
|
|
483
|
+
const pattern = String(args?.pattern ?? '').trim()
|
|
484
|
+
if (!pattern) throw new Error('pattern is required')
|
|
485
|
+
const maxResults = integerIn(args?.maxResults, config?.remoteGlobMaxResults ?? 200, 1, MAX_RESULTS_HARD_LIMIT)
|
|
486
|
+
const maxDepth = integerIn(args?.maxDepth, config?.remoteGlobMaxDepth ?? 4, 0, MAX_DEPTH_HARD_LIMIT)
|
|
487
|
+
const walked = await walkRemote({ runtime, server, root, pattern, maxDepth, maxResults })
|
|
488
|
+
const lines = renderHeader({
|
|
489
|
+
server,
|
|
490
|
+
path: root,
|
|
491
|
+
extra: [`pattern: ${pattern}`, `matches: ${walked.results.length}`],
|
|
492
|
+
})
|
|
493
|
+
if (walked.results.length === 0) lines.push('(no matches)')
|
|
494
|
+
else for (const match of walked.results) lines.push(match.path)
|
|
495
|
+
if (walked.truncated) lines.push('[results truncated by the configured limit]')
|
|
496
|
+
recordFileActivity({
|
|
497
|
+
recordActivity,
|
|
498
|
+
server,
|
|
499
|
+
sessionId,
|
|
500
|
+
startedAt,
|
|
501
|
+
command: `remote_glob ${pattern}`,
|
|
502
|
+
outcome: 'success',
|
|
503
|
+
})
|
|
504
|
+
return { text: lines.join('\n') }
|
|
505
|
+
} catch (error) {
|
|
506
|
+
recordFileActivity({
|
|
507
|
+
recordActivity,
|
|
508
|
+
server,
|
|
509
|
+
sessionId,
|
|
510
|
+
startedAt,
|
|
511
|
+
command: `remote_glob ${String(args?.pattern ?? '')}`,
|
|
512
|
+
outcome: `failed (${describeError(error)})`,
|
|
513
|
+
})
|
|
514
|
+
throw error
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function shellQuote(value) {
|
|
521
|
+
return `'${String(value ?? '').replace(/'/g, "'\\''")}'`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function powershellQuote(value) {
|
|
525
|
+
return `'${String(value ?? '').replace(/'/g, "''")}'`
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function posixGrepCommand({ path, pattern, ignoreCase, maxResults }) {
|
|
529
|
+
const flags = ['-R', '-I', '-n', '-E']
|
|
530
|
+
if (ignoreCase === true) flags.push('-i')
|
|
531
|
+
return [
|
|
532
|
+
`grep ${flags.join(' ')} --exclude-dir=.git --exclude-dir=node_modules -e ${shellQuote(pattern)} -- ${shellQuote(path)}`,
|
|
533
|
+
`head -n ${maxResults}`,
|
|
534
|
+
].join(' | ')
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function windowsGrepScript({ path, pattern, ignoreCase, maxResults }) {
|
|
538
|
+
const caseSwitch = ignoreCase === true ? '' : ' -CaseSensitive'
|
|
539
|
+
return [
|
|
540
|
+
`$path = ${powershellQuote(path)}`,
|
|
541
|
+
`$item = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue`,
|
|
542
|
+
`if ($null -eq $item) { Write-Error ('path not found: ' + $path); exit 3 }`,
|
|
543
|
+
`if ($item.PSIsContainer) { $files = @(Get-ChildItem -LiteralPath $path -Recurse -File -ErrorAction SilentlyContinue) } else { $files = @($item) }`,
|
|
544
|
+
`$files | Select-String -Pattern ${powershellQuote(pattern)}${caseSwitch} | Select-Object -First ${maxResults} | ForEach-Object { "$($_.Path):$($_.LineNumber):$($_.Line)" }`,
|
|
545
|
+
].join('\n')
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function createRemoteGrepTool({ runtime, config, loadInventory, recordActivity }) {
|
|
549
|
+
return defineTool({
|
|
550
|
+
name: REMOTE_GREP_TOOL_NAME,
|
|
551
|
+
description:
|
|
552
|
+
'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.',
|
|
553
|
+
parameters: {
|
|
554
|
+
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
555
|
+
pattern: { type: 'string', required: true, description: 'Pattern to search for (POSIX extended regex / PowerShell regex).' },
|
|
556
|
+
path: { type: 'string', description: 'File or directory to search; defaults to the remote home directory.' },
|
|
557
|
+
ignoreCase: { type: 'boolean', description: 'Case-insensitive search.' },
|
|
558
|
+
maxResults: { type: 'number', description: 'Optional result cap (clamped by plugin config).' },
|
|
559
|
+
},
|
|
560
|
+
output: textOut,
|
|
561
|
+
isConcurrencySafe: () => true,
|
|
562
|
+
async execute(args, exec) {
|
|
563
|
+
const startedAt = Date.now()
|
|
564
|
+
const server = loadServer({ loadInventory, serverRef: args?.server, toolName: 'remote_grep' })
|
|
565
|
+
const sessionId = sessionKeyOf(exec)
|
|
566
|
+
try {
|
|
567
|
+
const dialect = await resolveDialect(server, runtime)
|
|
568
|
+
const path = args?.path
|
|
569
|
+
? await resolveRemotePath({ runtime, server, rawPath: args.path, dialect })
|
|
570
|
+
: await resolveRemotePath({ runtime, server, rawPath: '~', dialect })
|
|
571
|
+
const pattern = String(args?.pattern ?? '')
|
|
572
|
+
if (!pattern) throw new Error('pattern is required')
|
|
573
|
+
if (pattern.includes('\n') || pattern.includes('\r')) throw new Error('pattern must not contain newlines')
|
|
574
|
+
const maxResults = integerIn(args?.maxResults, config?.remoteGrepMaxResults ?? 200, 1, MAX_RESULTS_HARD_LIMIT)
|
|
575
|
+
let command
|
|
576
|
+
if (dialect === 'windows') {
|
|
577
|
+
const built = win32Adapter.buildCommand(
|
|
578
|
+
windowsGrepScript({ path, pattern, ignoreCase: args?.ignoreCase === true, maxResults }),
|
|
579
|
+
undefined,
|
|
580
|
+
server,
|
|
581
|
+
)
|
|
582
|
+
if (built?.error !== undefined) throw new Error(built.error)
|
|
583
|
+
command = built.command
|
|
584
|
+
} else {
|
|
585
|
+
// Validate the root first so a missing path is an error rather than
|
|
586
|
+
// an empty "no matches" result.
|
|
587
|
+
await runtime.sftpStat(server, path)
|
|
588
|
+
command = posixGrepCommand({ path, pattern, ignoreCase: args?.ignoreCase === true, maxResults })
|
|
589
|
+
}
|
|
590
|
+
const result = await runtime.exec(server, command, {
|
|
591
|
+
timeoutMs: config?.commandTimeoutMs ?? 120000,
|
|
592
|
+
signal: exec?.signal,
|
|
593
|
+
readOnly: true,
|
|
594
|
+
cap: config?.maxOutputBytes ?? 102400,
|
|
595
|
+
stderrCap: config?.maxStderrBytes ?? 16384,
|
|
596
|
+
})
|
|
597
|
+
const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
|
|
598
|
+
if (exitCode !== null && exitCode > 1 && dialect !== 'windows') {
|
|
599
|
+
throw new Error(`remote grep failed (exit ${exitCode}): ${result?.stderr || 'no stderr'}`)
|
|
600
|
+
}
|
|
601
|
+
const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
|
|
602
|
+
const lines = renderHeader({
|
|
603
|
+
server,
|
|
604
|
+
path,
|
|
605
|
+
extra: [`pattern: ${pattern}`, `matches: ${stdout.trim() ? stdout.trim().split('\n').length : 0}`],
|
|
606
|
+
})
|
|
607
|
+
lines.push(stdout.trim() ? stdout.replace(/\n$/, '') : '(no matches)')
|
|
608
|
+
if (result?.truncated?.stdout === true) lines.push('[stdout truncated by the plugin output cap]')
|
|
609
|
+
recordFileActivity({
|
|
610
|
+
recordActivity,
|
|
611
|
+
server,
|
|
612
|
+
sessionId,
|
|
613
|
+
startedAt,
|
|
614
|
+
command: `remote_grep ${pattern}`,
|
|
615
|
+
outcome: describeResultOutcome(result),
|
|
616
|
+
})
|
|
617
|
+
return { text: lines.join('\n') }
|
|
618
|
+
} catch (error) {
|
|
619
|
+
recordFileActivity({
|
|
620
|
+
recordActivity,
|
|
621
|
+
server,
|
|
622
|
+
sessionId,
|
|
623
|
+
startedAt,
|
|
624
|
+
command: `remote_grep ${String(args?.pattern ?? '')}`,
|
|
625
|
+
outcome: `failed (${describeError(error)})`,
|
|
626
|
+
})
|
|
627
|
+
throw error
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Build all five definitions in the canonical registration order. */
|
|
634
|
+
export function createRemoteFileTools({ runtime, config, loadInventory, recordActivity }) {
|
|
635
|
+
return [
|
|
636
|
+
createRemoteReadTool({ runtime, config, loadInventory, recordActivity }),
|
|
637
|
+
createRemoteWriteTool({ runtime, config, loadInventory, recordActivity }),
|
|
638
|
+
createRemoteEditTool({ runtime, config, loadInventory, recordActivity }),
|
|
639
|
+
createRemoteGlobTool({ runtime, config, loadInventory, recordActivity }),
|
|
640
|
+
createRemoteGrepTool({ runtime, config, loadInventory, recordActivity }),
|
|
641
|
+
]
|
|
642
|
+
}
|