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.
- package/CHANGELOG.md +552 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +146 -0
- 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 +106 -0
- package/lib/client.js +4991 -0
- package/lib/core/activity.js +88 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +802 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +123 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/live-executions.js +101 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/mentions.js +216 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/remote-shell-tool.js +310 -0
- package/lib/core/secrets.js +95 -0
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +409 -0
- package/lib/routes.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded local-filesystem browser for the workbench's local side.
|
|
3
|
+
*
|
|
4
|
+
* The workbench is a user-driven surface, not a model tool, but it still must
|
|
5
|
+
* not become a privilege escalation around the session sandbox. Every path is
|
|
6
|
+
* resolved inside one root (the Host's default working directory) and escapes
|
|
7
|
+
* (`..`, absolute paths outside the root, symlinks leaving it) are rejected
|
|
8
|
+
* before any filesystem call.
|
|
9
|
+
*
|
|
10
|
+
* Two different notions of "resolve" are needed:
|
|
11
|
+
* - read/list follow the final component (`resolve`), because following a
|
|
12
|
+
* symlink inside the root is legitimate for reading;
|
|
13
|
+
* - remove/rename operate on the directory entry itself (`resolveEntry`), so
|
|
14
|
+
* they never follow a final symlink (deleting or renaming the link must not
|
|
15
|
+
* touch its target).
|
|
16
|
+
* Writes/mkdirs are the strictest case: an existing final symlink is refused
|
|
17
|
+
* outright and every existing path component must canonicalize inside the real
|
|
18
|
+
* root, including when the final component does not exist yet.
|
|
19
|
+
*/
|
|
20
|
+
import fsp from 'node:fs/promises'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
|
|
24
|
+
const MAX_READ_BYTES = 5 * 1024 * 1024
|
|
25
|
+
const MAX_WRITE_BYTES = 20 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
const OUTSIDE_ERROR = 'path is outside the local workspace root'
|
|
28
|
+
const SYMLINK_WRITE_ERROR = 'refusing to write through a symlink'
|
|
29
|
+
const ROOT_REMOVE_ERROR = 'refusing to remove the local workspace root'
|
|
30
|
+
const ROOT_RENAME_ERROR = 'refusing to rename the local workspace root'
|
|
31
|
+
|
|
32
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
33
|
+
|
|
34
|
+
function isMissing(error) {
|
|
35
|
+
return Boolean(
|
|
36
|
+
error &&
|
|
37
|
+
typeof error === 'object' &&
|
|
38
|
+
(error.code === 'ENOENT' || error.code === 'ENOTDIR'),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `target` is `root` itself or a path below it (handles root === '/'). */
|
|
43
|
+
function withinRoot(target, root) {
|
|
44
|
+
if (target === root) return true
|
|
45
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep
|
|
46
|
+
return target.startsWith(prefix)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a canonical `real` path is the root itself or lives under it. */
|
|
50
|
+
function isUnder(real, realRoot) {
|
|
51
|
+
// A root that does not exist yet has no canonical form; lexical containment
|
|
52
|
+
// (checked by the caller) is then the strongest guarantee available.
|
|
53
|
+
if (realRoot == null) return true
|
|
54
|
+
return withinRoot(real, realRoot)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertInside(real, realRoot) {
|
|
58
|
+
if (!isUnder(real, realRoot)) throw new Error(OUTSIDE_ERROR)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Render the permission bits of a lstat mode as `rwxrwxrwx`. */
|
|
62
|
+
function permissionsOf(mode) {
|
|
63
|
+
const bits = []
|
|
64
|
+
for (let shift = 8; shift >= 0; shift -= 1) {
|
|
65
|
+
const bit = 1 << shift
|
|
66
|
+
bits.push(mode & bit ? 'rwx'[(8 - shift) % 3] : '-')
|
|
67
|
+
}
|
|
68
|
+
return bits.join('')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Synthesize an `ls -l`-style longname. The remote SFTP side gets this string
|
|
73
|
+
* from the server's readdir longname field; locally we build an equivalent so
|
|
74
|
+
* the shared files panel receives the same entry shape.
|
|
75
|
+
*/
|
|
76
|
+
function longnameOf(info, name) {
|
|
77
|
+
const type = info.isSymbolicLink() ? 'l' : info.isDirectory() ? 'd' : '-'
|
|
78
|
+
const when = new Date(info.mtimeMs)
|
|
79
|
+
const day = String(when.getDate()).padStart(2, ' ')
|
|
80
|
+
const hour = String(when.getHours()).padStart(2, '0')
|
|
81
|
+
const minute = String(when.getMinutes()).padStart(2, '0')
|
|
82
|
+
const stamp = `${MONTHS[when.getMonth()]} ${day} ${hour}:${minute}`
|
|
83
|
+
return `${type}${permissionsOf(info.mode)} ${info.nlink ?? 1} ${info.uid ?? 0} ${info.gid ?? 0} ${info.size} ${stamp} ${name}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createLocalFs({ getRoot } = {}) {
|
|
87
|
+
/** Configured (lexical) root; kept synchronous for route/config callers. */
|
|
88
|
+
function rootPath() {
|
|
89
|
+
const value = typeof getRoot === 'function' ? getRoot() : undefined
|
|
90
|
+
return path.resolve(value || os.homedir())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Canonical root, or `null` when the configured root does not exist yet. */
|
|
94
|
+
async function rootRealOrNull() {
|
|
95
|
+
try {
|
|
96
|
+
return await fsp.realpath(rootPath())
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (isMissing(error)) return null
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Canonical root (`realpath`); falls back to the lexical root if missing. */
|
|
104
|
+
async function rootReal() {
|
|
105
|
+
return (await rootRealOrNull()) ?? rootPath()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function lstatOrNull(target) {
|
|
109
|
+
try {
|
|
110
|
+
return await fsp.lstat(target)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (isMissing(error)) return null
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Map a caller path onto an absolute lexical candidate.
|
|
119
|
+
*
|
|
120
|
+
* Both the configured root and its canonical realpath are accepted, so the
|
|
121
|
+
* canonical `entry.path` values returned by `list()` remain usable as
|
|
122
|
+
* navigation inputs when the configured root itself contains symlinked
|
|
123
|
+
* components (macOS `/tmp` -> `/private/tmp`, linked homes, etc.).
|
|
124
|
+
*/
|
|
125
|
+
function candidateFor(input, realRoot) {
|
|
126
|
+
const lexicalRoot = rootPath()
|
|
127
|
+
const canonicalRoot = realRoot ?? lexicalRoot
|
|
128
|
+
const raw = typeof input === 'string' && input.trim() ? input.trim() : ''
|
|
129
|
+
const candidate =
|
|
130
|
+
raw === '' ? lexicalRoot : path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(lexicalRoot, raw)
|
|
131
|
+
const insideLexical = withinRoot(candidate, lexicalRoot)
|
|
132
|
+
const insideReal = withinRoot(candidate, canonicalRoot)
|
|
133
|
+
if (!insideLexical && !insideReal) throw new Error(OUTSIDE_ERROR)
|
|
134
|
+
return candidate
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Canonicalize a path that may not exist yet by walking up to the deepest
|
|
139
|
+
* existing ancestor. That ancestor must canonicalize inside the real root;
|
|
140
|
+
* the missing remainder cannot contain symlinks and is appended lexically.
|
|
141
|
+
*
|
|
142
|
+
* A dangling symlink anywhere in the chain is never silently followed:
|
|
143
|
+
* `dangling: 'propagate'` surfaces ENOENT (read/list semantics) and
|
|
144
|
+
* `dangling: 'reject'` refuses the operation (write/mkdir semantics).
|
|
145
|
+
*/
|
|
146
|
+
async function canonicalizeMissing(candidate, realRoot, dangling) {
|
|
147
|
+
let cursor = candidate
|
|
148
|
+
const missing = []
|
|
149
|
+
for (;;) {
|
|
150
|
+
const info = await lstatOrNull(cursor)
|
|
151
|
+
if (info) {
|
|
152
|
+
if (info.isSymbolicLink()) {
|
|
153
|
+
let real
|
|
154
|
+
try {
|
|
155
|
+
real = await fsp.realpath(cursor)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (isMissing(error)) {
|
|
158
|
+
if (dangling === 'reject') throw new Error(SYMLINK_WRITE_ERROR)
|
|
159
|
+
throw error
|
|
160
|
+
}
|
|
161
|
+
throw error
|
|
162
|
+
}
|
|
163
|
+
assertInside(real, realRoot)
|
|
164
|
+
return path.join(real, ...missing)
|
|
165
|
+
}
|
|
166
|
+
const real = await fsp.realpath(cursor)
|
|
167
|
+
assertInside(real, realRoot)
|
|
168
|
+
return path.join(real, ...missing)
|
|
169
|
+
}
|
|
170
|
+
const parent = path.dirname(cursor)
|
|
171
|
+
if (parent === cursor) throw new Error(OUTSIDE_ERROR)
|
|
172
|
+
missing.unshift(path.basename(cursor))
|
|
173
|
+
cursor = parent
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a caller path for read/list: symlinks are followed and the result
|
|
179
|
+
* is the canonical real path. Non-existent final components are allowed
|
|
180
|
+
* through (the caller's fs call reports ENOENT) but their existing parent
|
|
181
|
+
* chain must still canonicalize inside the root.
|
|
182
|
+
*/
|
|
183
|
+
async function resolveInside(input) {
|
|
184
|
+
const realRoot = await rootRealOrNull()
|
|
185
|
+
const candidate = candidateFor(input, realRoot)
|
|
186
|
+
try {
|
|
187
|
+
const real = await fsp.realpath(candidate)
|
|
188
|
+
assertInside(real, realRoot)
|
|
189
|
+
return real
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (isMissing(error)) return await canonicalizeMissing(candidate, realRoot, 'propagate')
|
|
192
|
+
throw error
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve a caller path as a *directory entry*: parents are followed and
|
|
198
|
+
* canonicalized inside the root, but the final component is not followed.
|
|
199
|
+
* Used by remove/rename so a symlink is operated on as a link, never as its
|
|
200
|
+
* target.
|
|
201
|
+
*/
|
|
202
|
+
async function resolveEntry(input) {
|
|
203
|
+
const realRoot = await rootRealOrNull()
|
|
204
|
+
const lexicalRoot = rootPath()
|
|
205
|
+
const candidate = candidateFor(input, realRoot)
|
|
206
|
+
// The root itself has no directory-entry form; hand it back so remove/
|
|
207
|
+
// rename can reject it explicitly (B14) instead of recursing into it.
|
|
208
|
+
if (candidate === lexicalRoot || (realRoot != null && candidate === realRoot)) return candidate
|
|
209
|
+
const realParent = await fsp.realpath(path.dirname(candidate))
|
|
210
|
+
assertInside(realParent, realRoot)
|
|
211
|
+
return path.join(realParent, path.basename(candidate))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a write/mkdir target.
|
|
216
|
+
*
|
|
217
|
+
* - an existing final symlink is refused (`refusing to write through a
|
|
218
|
+
* symlink`), even when it points inside the root;
|
|
219
|
+
* - parents are canonicalized through symlinks and must stay inside root;
|
|
220
|
+
* - non-existent targets validate their deepest existing ancestor, so a
|
|
221
|
+
* symlinked parent plus a brand-new child name cannot escape (S1).
|
|
222
|
+
*/
|
|
223
|
+
async function resolveCreateTarget(input) {
|
|
224
|
+
const realRoot = await rootRealOrNull()
|
|
225
|
+
const candidate = candidateFor(input, realRoot)
|
|
226
|
+
const direct = await lstatOrNull(candidate)
|
|
227
|
+
if (direct) {
|
|
228
|
+
if (direct.isSymbolicLink()) throw new Error(SYMLINK_WRITE_ERROR)
|
|
229
|
+
const real = await fsp.realpath(candidate)
|
|
230
|
+
assertInside(real, realRoot)
|
|
231
|
+
return real
|
|
232
|
+
}
|
|
233
|
+
return canonicalizeMissing(candidate, realRoot, 'reject')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function list(input) {
|
|
237
|
+
const directory = await resolveInside(input)
|
|
238
|
+
const realRoot = await rootReal()
|
|
239
|
+
const stat = await fsp.stat(directory)
|
|
240
|
+
if (!stat.isDirectory()) throw new Error('not a directory')
|
|
241
|
+
const rows = await fsp.readdir(directory, { withFileTypes: true })
|
|
242
|
+
const entries = []
|
|
243
|
+
for (const row of rows) {
|
|
244
|
+
const full = path.join(directory, row.name)
|
|
245
|
+
// A file removed mid-listing (or an entry we cannot stat) still lists by
|
|
246
|
+
// name, as before; type detection prefers lstat because following a
|
|
247
|
+
// symlink mis-typed link-to-directory entries and enabled S2 data loss.
|
|
248
|
+
let info = null
|
|
249
|
+
try {
|
|
250
|
+
info = await fsp.lstat(full)
|
|
251
|
+
} catch {
|
|
252
|
+
info = null
|
|
253
|
+
}
|
|
254
|
+
const type = info
|
|
255
|
+
? info.isSymbolicLink()
|
|
256
|
+
? 'link'
|
|
257
|
+
: info.isDirectory()
|
|
258
|
+
? 'dir'
|
|
259
|
+
: 'file'
|
|
260
|
+
: row.isSymbolicLink()
|
|
261
|
+
? 'link'
|
|
262
|
+
: row.isDirectory()
|
|
263
|
+
? 'dir'
|
|
264
|
+
: 'file'
|
|
265
|
+
entries.push({
|
|
266
|
+
name: row.name,
|
|
267
|
+
path: full,
|
|
268
|
+
type,
|
|
269
|
+
size: info ? (info.isDirectory() ? 0 : info.size) : 0,
|
|
270
|
+
mtime: info ? info.mtimeMs : null,
|
|
271
|
+
atime: info ? info.atimeMs : null,
|
|
272
|
+
mode: info ? info.mode : null,
|
|
273
|
+
uid: info ? info.uid : null,
|
|
274
|
+
gid: info ? info.gid : null,
|
|
275
|
+
longname: info ? longnameOf(info, row.name) : row.name,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
entries.sort((left, right) => {
|
|
279
|
+
if (left.type !== right.type) {
|
|
280
|
+
if (left.type === 'dir') return -1
|
|
281
|
+
if (right.type === 'dir') return 1
|
|
282
|
+
}
|
|
283
|
+
return left.name.localeCompare(right.name)
|
|
284
|
+
})
|
|
285
|
+
return {
|
|
286
|
+
root: realRoot,
|
|
287
|
+
path: directory,
|
|
288
|
+
entries,
|
|
289
|
+
parent: directory === realRoot ? null : path.dirname(directory),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function read(input, maxBytes = MAX_READ_BYTES) {
|
|
294
|
+
const file = await resolveInside(input)
|
|
295
|
+
const stat = await fsp.stat(file)
|
|
296
|
+
if (!stat.isFile()) throw new Error('not a file')
|
|
297
|
+
const size = stat.size
|
|
298
|
+
const limit = Math.min(Math.max(Number(maxBytes) || MAX_READ_BYTES, 1), MAX_READ_BYTES)
|
|
299
|
+
const handle = await fsp.open(file, 'r')
|
|
300
|
+
try {
|
|
301
|
+
const length = Math.min(size, limit)
|
|
302
|
+
const buffer = Buffer.alloc(length)
|
|
303
|
+
const { bytesRead } = await handle.read(buffer, 0, length, 0)
|
|
304
|
+
return {
|
|
305
|
+
path: file,
|
|
306
|
+
size,
|
|
307
|
+
mtime: stat.mtimeMs,
|
|
308
|
+
truncated: size > bytesRead,
|
|
309
|
+
base64: buffer.subarray(0, bytesRead).toString('base64'),
|
|
310
|
+
}
|
|
311
|
+
} finally {
|
|
312
|
+
await handle.close()
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function write(input, contentBase64) {
|
|
317
|
+
if (typeof contentBase64 !== 'string') throw new Error('contentBase64 is required')
|
|
318
|
+
const buffer = Buffer.from(contentBase64, 'base64')
|
|
319
|
+
if (buffer.length > MAX_WRITE_BYTES) throw new Error(`file exceeds ${MAX_WRITE_BYTES} bytes`)
|
|
320
|
+
const file = await resolveCreateTarget(input)
|
|
321
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
322
|
+
await fsp.writeFile(file, buffer)
|
|
323
|
+
return { path: file, size: buffer.length }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function mkdir(input) {
|
|
327
|
+
const directory = await resolveCreateTarget(input)
|
|
328
|
+
await fsp.mkdir(directory, { recursive: true })
|
|
329
|
+
return { path: directory }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function remove(input) {
|
|
333
|
+
const target = await resolveEntry(input)
|
|
334
|
+
const lexicalRoot = rootPath()
|
|
335
|
+
const realRoot = await rootReal()
|
|
336
|
+
// Empty input resolves to the root; deleting it must never be possible.
|
|
337
|
+
if (target === lexicalRoot || target === realRoot) throw new Error(ROOT_REMOVE_ERROR)
|
|
338
|
+
const info = await lstatOrNull(target)
|
|
339
|
+
if (!info) return { path: target, removed: false }
|
|
340
|
+
if (info.isSymbolicLink()) {
|
|
341
|
+
// Unlink the link itself; never follow it (a link can point outside the
|
|
342
|
+
// root and still be safely removable without touching its target, B7).
|
|
343
|
+
await fsp.unlink(target)
|
|
344
|
+
} else if (info.isDirectory()) {
|
|
345
|
+
await fsp.rm(target, { recursive: true, force: true })
|
|
346
|
+
} else {
|
|
347
|
+
await fsp.unlink(target)
|
|
348
|
+
}
|
|
349
|
+
return { path: target, removed: true }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function rename(from, to) {
|
|
353
|
+
// Operate on directory entries: a symlink is moved as a link, not as its
|
|
354
|
+
// target (B4), and the destination's parents are canonicalized inside root.
|
|
355
|
+
const source = await resolveEntry(from)
|
|
356
|
+
const destination = await resolveEntry(to)
|
|
357
|
+
const lexicalRoot = rootPath()
|
|
358
|
+
const realRoot = await rootReal()
|
|
359
|
+
for (const target of [source, destination]) {
|
|
360
|
+
if (target === lexicalRoot || target === realRoot) throw new Error(ROOT_RENAME_ERROR)
|
|
361
|
+
}
|
|
362
|
+
// Match the remote SFTP semantics: rename must not silently overwrite.
|
|
363
|
+
const existing = await lstatOrNull(destination)
|
|
364
|
+
if (existing) throw new Error(`destination already exists: ${destination}`)
|
|
365
|
+
await fsp.rename(source, destination)
|
|
366
|
+
return { path: destination }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
root: rootPath,
|
|
371
|
+
rootReal,
|
|
372
|
+
resolve: resolveInside,
|
|
373
|
+
resolveEntry,
|
|
374
|
+
list,
|
|
375
|
+
read,
|
|
376
|
+
write,
|
|
377
|
+
mkdir,
|
|
378
|
+
remove,
|
|
379
|
+
rename,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn managed-resource context.
|
|
3
|
+
*
|
|
4
|
+
* The composer inserts an `@name` chip as plain text. This module treats it as a
|
|
5
|
+
* hint, not an execution binding: every remote tool call names its own server.
|
|
6
|
+
* This module injects one compact inventory + tool-rules message per human
|
|
7
|
+
* turn, so the model never has to read `servers.json` or switch a hidden
|
|
8
|
+
* session target before acting.
|
|
9
|
+
*
|
|
10
|
+
* The message enters the same step (`agent/pre-step` replaces the messages that
|
|
11
|
+
* enter a step), which `agent.inject()` cannot promise because it splices into
|
|
12
|
+
* the next step.
|
|
13
|
+
*
|
|
14
|
+
* Scope: every agent mode; no preset gating.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
import { effectiveDialect } from './dialect.js'
|
|
19
|
+
import { loadServers } from './store.js'
|
|
20
|
+
import {
|
|
21
|
+
TARGET_CONTEXT_KIND,
|
|
22
|
+
createTargetStore,
|
|
23
|
+
extractServerMentions,
|
|
24
|
+
extractUserText,
|
|
25
|
+
lastHumanMessage,
|
|
26
|
+
} from './mentions.js'
|
|
27
|
+
|
|
28
|
+
export { TARGET_CONTEXT_KIND }
|
|
29
|
+
|
|
30
|
+
const DEFAULT_MAX_CONTEXT_SERVERS = 20
|
|
31
|
+
|
|
32
|
+
function describeError(error) {
|
|
33
|
+
return error instanceof Error ? error.message : String(error)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function serverLine(server) {
|
|
37
|
+
const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
|
|
38
|
+
const state = server.enabled === false ? ' · disabled' : ''
|
|
39
|
+
const dialect = effectiveDialect(server)
|
|
40
|
+
return `- ${server.name} — ${target} · ${dialect}${state}${factsSummary(server)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Compact facts summary (hostname only: stable, not a stale capacity snapshot). */
|
|
44
|
+
function factsSummary(server) {
|
|
45
|
+
const facts = server?.facts
|
|
46
|
+
if (!facts || typeof facts !== 'object') return ''
|
|
47
|
+
const hostname = typeof facts.hostname === 'string' ? facts.hostname.trim() : ''
|
|
48
|
+
return hostname ? ` · ${hostname}` : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function localShellName(hostPlatform) {
|
|
52
|
+
return hostPlatform === 'win32' ? 'pwsh' : 'bash'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render the model-facing managed-resource context.
|
|
57
|
+
*
|
|
58
|
+
* @param {object[]} servers managed inventory
|
|
59
|
+
* @param {{
|
|
60
|
+
* candidates?: string[],
|
|
61
|
+
* lastExecuted?: string|null,
|
|
62
|
+
* hostPlatform?: 'win32'|'posix',
|
|
63
|
+
* maxServers?: number,
|
|
64
|
+
* }} [hints]
|
|
65
|
+
*/
|
|
66
|
+
export function renderManagedContext(servers, hints = {}) {
|
|
67
|
+
const inventory = Array.isArray(servers) ? servers.filter(Boolean) : []
|
|
68
|
+
if (inventory.length === 0) return ''
|
|
69
|
+
const maxServers = Number.isInteger(hints.maxServers) && hints.maxServers > 0
|
|
70
|
+
? hints.maxServers
|
|
71
|
+
: DEFAULT_MAX_CONTEXT_SERVERS
|
|
72
|
+
const listed = inventory.slice(0, maxServers)
|
|
73
|
+
const lines = [`[ssh-workbench] Managed remote resources (${inventory.length}):`]
|
|
74
|
+
for (const server of listed) lines.push(serverLine(server))
|
|
75
|
+
if (inventory.length > listed.length) {
|
|
76
|
+
lines.push(`- … and ${inventory.length - listed.length} more; call remote_exec with a resource name from the workbench UI.`)
|
|
77
|
+
}
|
|
78
|
+
lines.push('Remote execution rules:')
|
|
79
|
+
lines.push(
|
|
80
|
+
`- The native ${localShellName(hints.hostPlatform)} tool always runs on the local DSH host. Never use it for commands meant for a remote resource.`,
|
|
81
|
+
)
|
|
82
|
+
lines.push(
|
|
83
|
+
'- POSIX resources: remote_bash({ server: "<name>", command: "<command>", workdir?, timeoutMs?, run_in_background? }).',
|
|
84
|
+
)
|
|
85
|
+
lines.push(
|
|
86
|
+
'- Windows resources: remote_pwsh({ server: "<name>", command: "<command>", ... }).',
|
|
87
|
+
)
|
|
88
|
+
lines.push(
|
|
89
|
+
'- Several resources in one step: remote_exec({ tasks: [{ server: "<name>", command: "<command>" }], concurrency? }). Tasks run concurrently and never depend on a remembered target.',
|
|
90
|
+
)
|
|
91
|
+
lines.push(
|
|
92
|
+
'- Pass server explicitly on every call. The resource names above are authoritative; never read ~/.dsh/ssh-workbench/servers.json or other config files to find them.',
|
|
93
|
+
)
|
|
94
|
+
lines.push(
|
|
95
|
+
'- read/write/edit/glob/grep are the native local file tools; they never touch a remote resource.',
|
|
96
|
+
)
|
|
97
|
+
lines.push(
|
|
98
|
+
'- Remote files: remote_read({ server, path, maxBytes? }), remote_glob({ server, pattern, path?, maxResults?, maxDepth? }) and remote_grep({ server, pattern, path?, ignoreCase?, maxResults? }). Use absolute paths or paths under ~.',
|
|
99
|
+
)
|
|
100
|
+
if (hints.remoteFileWrite === true) {
|
|
101
|
+
lines.push(
|
|
102
|
+
'- Remote writes are enabled: remote_write({ server, path, content }) and remote_edit({ server, path, oldText, newText, replaceAll? }). remote_edit requires one exact match unless replaceAll=true.',
|
|
103
|
+
)
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(
|
|
106
|
+
'- Remote writes are disabled by config: remote_write/remote_edit will refuse. Use the workbench SFTP panel for uploads, or ask the user to enable remoteFileWrite.',
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
const candidates = Array.isArray(hints.candidates) ? hints.candidates.filter(Boolean) : []
|
|
110
|
+
if (candidates.length > 0) {
|
|
111
|
+
lines.push(
|
|
112
|
+
`- The user mentioned: ${candidates.join(', ')}. Prefer these resources when the request is ambiguous, but still pass server explicitly.`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const lastExecuted = typeof hints.lastExecuted === 'string' && hints.lastExecuted.length > 0
|
|
116
|
+
? hints.lastExecuted
|
|
117
|
+
: null
|
|
118
|
+
if (lastExecuted) lines.push(`- Last remote execution in this conversation: ${lastExecuted}.`)
|
|
119
|
+
lines.push(
|
|
120
|
+
'- If no resource is named and the request does not identify one, ask the user or use the resource named in the request; do not guess silently.',
|
|
121
|
+
)
|
|
122
|
+
return lines.join('\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build one user-role context message, preferring the host's message factory.
|
|
127
|
+
* `@` symbols are avoided so this message can never be re-matched as a mention.
|
|
128
|
+
*/
|
|
129
|
+
export function buildManagedContextMessage(servers, hints, createUserMessage) {
|
|
130
|
+
const text = renderManagedContext(servers, hints)
|
|
131
|
+
const source = {
|
|
132
|
+
kind: TARGET_CONTEXT_KIND,
|
|
133
|
+
form: 'notice',
|
|
134
|
+
summary: 'managed remote resources',
|
|
135
|
+
}
|
|
136
|
+
const input = { content: [{ type: 'text', text }], source }
|
|
137
|
+
if (typeof createUserMessage === 'function') return createUserMessage(input)
|
|
138
|
+
return { role: 'user', id: `ssh-workbench-context-${randomUUID()}`, ...structuredClone(input) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Install the resource-context provider.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} ctx plugin context
|
|
145
|
+
* @param {{
|
|
146
|
+
* enabled?: boolean,
|
|
147
|
+
* remoteAvailable?: boolean,
|
|
148
|
+
* hostPlatform?: 'win32'|'posix',
|
|
149
|
+
* targets?: object,
|
|
150
|
+
* loadInventory?: () => object[],
|
|
151
|
+
* loadLastExecuted?: (sessionId: string) => string|null,
|
|
152
|
+
* createUserMessage?: Function,
|
|
153
|
+
* }} [options]
|
|
154
|
+
*/
|
|
155
|
+
export function installManagedContext(ctx, options = {}) {
|
|
156
|
+
const enabled = options.enabled !== false
|
|
157
|
+
const remoteAvailable = options.remoteAvailable !== false
|
|
158
|
+
const loadInventory = options.loadInventory ?? loadServers
|
|
159
|
+
const targets = options.targets ?? createTargetStore({ loadInventory })
|
|
160
|
+
const maxContextServers = Number.isInteger(options.maxContextServers) && options.maxContextServers > 0
|
|
161
|
+
? options.maxContextServers
|
|
162
|
+
: DEFAULT_MAX_CONTEXT_SERVERS
|
|
163
|
+
const warn = (message) => {
|
|
164
|
+
try {
|
|
165
|
+
ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
|
|
166
|
+
} catch {
|
|
167
|
+
// Logging must never fail the plugin.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!ctx || typeof ctx.on !== 'function') return { targets }
|
|
171
|
+
|
|
172
|
+
let factory = options.createUserMessage
|
|
173
|
+
const messageFactory = async () => {
|
|
174
|
+
if (factory !== undefined) return factory
|
|
175
|
+
try {
|
|
176
|
+
const mod = await import('@deepseek-ai/dsh-llm')
|
|
177
|
+
factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
|
|
178
|
+
} catch {
|
|
179
|
+
factory = null
|
|
180
|
+
}
|
|
181
|
+
return factory
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const handler = async (payload, next) => {
|
|
185
|
+
const decision = await next()
|
|
186
|
+
try {
|
|
187
|
+
if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
|
|
188
|
+
if (enabled === false) return decision
|
|
189
|
+
// Never describe a remote world the Host cannot execute in.
|
|
190
|
+
if (!remoteAvailable) return decision
|
|
191
|
+
const agent = payload?.agent
|
|
192
|
+
const session = agent?.session
|
|
193
|
+
const key = session?.id ?? agent?.id
|
|
194
|
+
if (typeof key !== 'string' || key.length === 0) return decision
|
|
195
|
+
let servers
|
|
196
|
+
try {
|
|
197
|
+
servers = loadInventory()
|
|
198
|
+
} catch (error) {
|
|
199
|
+
warn(`inventory unavailable: ${describeError(error)}`)
|
|
200
|
+
return decision
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(servers) || servers.length === 0) return decision
|
|
203
|
+
// Restore resumed-Session mention hints once; deriveMessages() is
|
|
204
|
+
// intentionally skipped on later steps.
|
|
205
|
+
if (targets.get(key)?.seeded !== true) {
|
|
206
|
+
try {
|
|
207
|
+
const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
|
|
208
|
+
targets.ensureSeeded(key, history, servers)
|
|
209
|
+
} catch {
|
|
210
|
+
targets.ensureSeeded(key, [], servers)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const human = lastHumanMessage(decision.messages)
|
|
214
|
+
const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
|
|
215
|
+
const state = targets.bindMentions(key, mentions, human?.id)
|
|
216
|
+
if (!state) return decision
|
|
217
|
+
const changed = state.version !== state.injectedVersion
|
|
218
|
+
const firstStep = payload?.step === 1
|
|
219
|
+
if (!changed) {
|
|
220
|
+
if (!firstStep) return decision
|
|
221
|
+
// One context message per turn is enough while it is still present in
|
|
222
|
+
// the step messages. Re-inject after compaction or a new human turn.
|
|
223
|
+
const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
|
|
224
|
+
if (hasContext) return decision
|
|
225
|
+
}
|
|
226
|
+
let lastExecuted = null
|
|
227
|
+
try {
|
|
228
|
+
lastExecuted = typeof options.loadLastExecuted === 'function' ? options.loadLastExecuted(key) : null
|
|
229
|
+
} catch {
|
|
230
|
+
lastExecuted = null
|
|
231
|
+
}
|
|
232
|
+
const hints = {
|
|
233
|
+
candidates: state.candidates,
|
|
234
|
+
lastExecuted: typeof lastExecuted === 'string' && lastExecuted.length > 0 ? lastExecuted : null,
|
|
235
|
+
hostPlatform: options.hostPlatform,
|
|
236
|
+
maxServers: maxContextServers,
|
|
237
|
+
}
|
|
238
|
+
const message = buildManagedContextMessage(servers, hints, await messageFactory())
|
|
239
|
+
targets.markInjected(key, state.version)
|
|
240
|
+
return { ...decision, messages: [...decision.messages, message] }
|
|
241
|
+
} catch (error) {
|
|
242
|
+
warn(`resource context skipped: ${describeError(error)}`)
|
|
243
|
+
return decision
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
|
|
248
|
+
const offDisposed = ctx.on('agent/disposed', (disposed) => {
|
|
249
|
+
try {
|
|
250
|
+
const session = disposed?.agent?.session
|
|
251
|
+
const key = session?.id ?? disposed?.agent?.id
|
|
252
|
+
if (typeof key === 'string') targets.clear(key)
|
|
253
|
+
} catch {
|
|
254
|
+
// Best effort cleanup.
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
if (typeof ctx.effect === 'function') {
|
|
258
|
+
ctx.effect(
|
|
259
|
+
() => () => {
|
|
260
|
+
try {
|
|
261
|
+
offPreStep?.()
|
|
262
|
+
} catch {
|
|
263
|
+
// Disposal is best effort.
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
offDisposed?.()
|
|
267
|
+
} catch {
|
|
268
|
+
// Disposal is best effort.
|
|
269
|
+
}
|
|
270
|
+
targets.clearAll?.()
|
|
271
|
+
},
|
|
272
|
+
'dsh-ssh-workbench: resource context',
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return { targets, offPreStep, offDisposed }
|
|
276
|
+
}
|