dsh-ssh-workbench 1.7.0 → 1.7.9
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 +94 -0
- package/README.md +14 -11
- package/docs/architecture.md +8 -8
- package/docs/execution-model.md +1 -1
- package/docs/quickstart.md +8 -5
- package/lib/client.js +1162 -99
- package/lib/core/background.js +18 -4
- package/lib/core/managed-context.js +12 -1
- package/lib/core/prefs.js +35 -2
- package/lib/core/remote-exec-tool.js +54 -7
- package/lib/core/remote-file-tools.js +91 -8
- package/lib/core/ssh.js +22 -0
- package/lib/index.js +25 -5
- package/lib/routes.js +78 -19
- package/package.json +17 -2
package/lib/routes.js
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* Every browser-facing request is fenced by `ctx.connection.requestRejection`
|
|
11
11
|
* when the Connection service exists.
|
|
12
12
|
*/
|
|
13
|
+
import path from 'node:path'
|
|
13
14
|
import { Readable } from 'node:stream'
|
|
14
15
|
|
|
15
16
|
import { createLocalFileApi, LOCAL_FILE_REF } from './core/local-file-api.js'
|
|
17
|
+
import { createLocalFs } from './core/localfs.js'
|
|
16
18
|
import { effectiveDialect } from './core/dialect.js'
|
|
17
19
|
import { SshError } from './core/errors.js'
|
|
18
20
|
import { summarizeExecution } from './core/execution-stats.js'
|
|
@@ -165,7 +167,7 @@ function lastExecutedServer(entries, session) {
|
|
|
165
167
|
return entry?.server ?? null
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, liveExecutions, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
|
|
170
|
+
export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, liveExecutions, refreshFacts, prefs, terminals, localfs, getSessionCwd, publicConfig, activity }) {
|
|
169
171
|
const prefsApi = prefs ?? {
|
|
170
172
|
load: () => ({ sidebar: true }),
|
|
171
173
|
save: () => ({ sidebar: true }),
|
|
@@ -173,6 +175,36 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
173
175
|
// Reuse the files panel for the local workspace by giving it the same
|
|
174
176
|
// method surface as the SSH runtime under the synthetic `__local__` ref.
|
|
175
177
|
const localFiles = localfs ? createLocalFileApi(localfs) : null
|
|
178
|
+
/**
|
|
179
|
+
* Session-scoped local file APIs. The hierarchy page browses a session's
|
|
180
|
+
* cwd, which may sit outside the local-pane root (e.g. `C:\A\B\lumina` on
|
|
181
|
+
* Windows). The scoped API keeps the same boundary checks, just rooted at the
|
|
182
|
+
* live session workspace instead of `localRoot`.
|
|
183
|
+
*/
|
|
184
|
+
const scopedLocalFiles = new Map()
|
|
185
|
+
/** Local transfer session id → the API instance that opened it. */
|
|
186
|
+
const localTransferOwners = new Map()
|
|
187
|
+
|
|
188
|
+
function localApiForSession(sessionId) {
|
|
189
|
+
if (!localfs || !getSessionCwd || typeof sessionId !== 'string' || !sessionId) return localFiles
|
|
190
|
+
let cwd
|
|
191
|
+
try {
|
|
192
|
+
cwd = getSessionCwd(sessionId)
|
|
193
|
+
} catch {
|
|
194
|
+
return localFiles
|
|
195
|
+
}
|
|
196
|
+
if (typeof cwd !== 'string' || !cwd || !path.isAbsolute(cwd)) return localFiles
|
|
197
|
+
let api = scopedLocalFiles.get(cwd)
|
|
198
|
+
if (!api) {
|
|
199
|
+
api = createLocalFileApi(createLocalFs({ getRoot: () => cwd }))
|
|
200
|
+
scopedLocalFiles.set(cwd, api)
|
|
201
|
+
}
|
|
202
|
+
return api
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function localTransferApi(sessionId) {
|
|
206
|
+
return localTransferOwners.get(sessionId) ?? (localFiles && sessionId.startsWith('loc') ? localFiles : null)
|
|
207
|
+
}
|
|
176
208
|
// Small UI-facing caches: a repeated directory view or connection test should
|
|
177
209
|
// not pay another round trip. Write operations invalidate the affected server.
|
|
178
210
|
const TEST_TTL_MS = 30000
|
|
@@ -262,9 +294,10 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
262
294
|
* workstream); fall back to `localfs.rootReal()` and finally the lexical
|
|
263
295
|
* `localfs.root()` so this route keeps working while the two land.
|
|
264
296
|
*/
|
|
265
|
-
async function localHomeDirectory() {
|
|
297
|
+
async function localHomeDirectory(sessionId) {
|
|
266
298
|
const sources = []
|
|
267
|
-
|
|
299
|
+
const api = localApiForSession(sessionId)
|
|
300
|
+
if (typeof api?.sftpHome === 'function') sources.push(() => api.sftpHome())
|
|
268
301
|
if (typeof localfs?.rootReal === 'function') sources.push(() => localfs.rootReal())
|
|
269
302
|
if (typeof localfs?.root === 'function') sources.push(() => localfs.root())
|
|
270
303
|
if (sources.length === 0) throw new Error('local files are unavailable on this host')
|
|
@@ -291,11 +324,11 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
291
324
|
}
|
|
292
325
|
|
|
293
326
|
/** GET/POST /sftp/home → `{ ok:true, result:{ home } }` (U5). */
|
|
294
|
-
async function serveSftpHome(res, ref) {
|
|
327
|
+
async function serveSftpHome(res, ref, sessionId) {
|
|
295
328
|
if (!ref) return send(res, 400, { ok: false, error: 'ref is required' })
|
|
296
329
|
try {
|
|
297
330
|
if (ref === LOCAL_FILE_REF) {
|
|
298
|
-
const home = await localHomeDirectory()
|
|
331
|
+
const home = await localHomeDirectory(sessionId)
|
|
299
332
|
return send(res, 200, { ok: true, result: { home } })
|
|
300
333
|
}
|
|
301
334
|
const server = findTarget(loadServers(), ref)
|
|
@@ -366,15 +399,19 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
366
399
|
if (req.method === 'GET' && rel === '/localfs/root') {
|
|
367
400
|
if (!localfs) return send(res, 503, { ok: false, error: 'local files are unavailable on this host' })
|
|
368
401
|
// S3: list()/entry paths are canonicalized through realpath, so the
|
|
369
|
-
// navigation root handed to the client must use the same form.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
402
|
+
// navigation root handed to the client must use the same form. A
|
|
403
|
+
// `session` query scopes this to the session workspace (hierarchy page).
|
|
404
|
+
const api = localApiForSession(searchParams.get('session'))
|
|
405
|
+
const root = typeof api?.sftpHome === 'function'
|
|
406
|
+
? await api.sftpHome()
|
|
407
|
+
: typeof localfs.rootReal === 'function'
|
|
408
|
+
? await localfs.rootReal()
|
|
409
|
+
: await Promise.resolve(localfs.root())
|
|
373
410
|
return send(res, 200, { ok: true, result: { root } })
|
|
374
411
|
}
|
|
375
412
|
if (req.method === 'GET' && rel === '/sftp/home') {
|
|
376
413
|
const ref = resolveRef(searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'))
|
|
377
|
-
return await serveSftpHome(res, ref)
|
|
414
|
+
return await serveSftpHome(res, ref, searchParams.get('session'))
|
|
378
415
|
}
|
|
379
416
|
if (req.method !== 'POST') return send(res, 404, { ok: false, error: 'not found' })
|
|
380
417
|
const body = await readBody(req)
|
|
@@ -383,14 +420,28 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
383
420
|
const ref = resolveRef(
|
|
384
421
|
body.ref ?? body.server ?? body.name ?? searchParams.get('ref') ?? searchParams.get('server') ?? searchParams.get('name'),
|
|
385
422
|
)
|
|
386
|
-
return await serveSftpHome(res, ref)
|
|
423
|
+
return await serveSftpHome(res, ref, body.session)
|
|
387
424
|
}
|
|
388
425
|
|
|
389
426
|
if (rel === '/prefs') {
|
|
390
|
-
|
|
391
|
-
|
|
427
|
+
const patch = {}
|
|
428
|
+
if (typeof body.sidebar === 'boolean') patch.sidebar = body.sidebar
|
|
429
|
+
// `null` clears the override and falls back to the plugin config.
|
|
430
|
+
if (typeof body.remoteFileWrite === 'boolean' || body.remoteFileWrite === null) {
|
|
431
|
+
patch.remoteFileWrite = body.remoteFileWrite
|
|
432
|
+
}
|
|
433
|
+
if (typeof body.systemFileHierarchy === 'boolean' || body.systemFileHierarchy === null) {
|
|
434
|
+
patch.systemFileHierarchy = body.systemFileHierarchy
|
|
392
435
|
}
|
|
393
|
-
|
|
436
|
+
if (Object.keys(patch).length === 0) {
|
|
437
|
+
return send(res, 400, { ok: false, error: 'prefs patch must contain sidebar, remoteFileWrite and/or systemFileHierarchy' })
|
|
438
|
+
}
|
|
439
|
+
const saved = prefsApi.save(patch)
|
|
440
|
+
return send(res, 200, {
|
|
441
|
+
ok: true,
|
|
442
|
+
prefs: saved,
|
|
443
|
+
config: typeof publicConfig === 'function' ? publicConfig() : undefined,
|
|
444
|
+
})
|
|
394
445
|
}
|
|
395
446
|
|
|
396
447
|
if (rel === '/terminal/open') {
|
|
@@ -437,7 +488,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
437
488
|
// Chunked transfer sessions: the client holds a session id, so these
|
|
438
489
|
// routes do not take a server ref. They must run before ref resolution.
|
|
439
490
|
const sessionId = typeof body.session === 'string' ? body.session : ''
|
|
440
|
-
const localTransfer =
|
|
491
|
+
const localTransfer = localTransferApi(sessionId)
|
|
441
492
|
if (rel === '/sftp/write-chunk') {
|
|
442
493
|
if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
|
|
443
494
|
// B10: offset used to be silently coerced to 0 by the runtime, which
|
|
@@ -463,6 +514,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
463
514
|
return send(res, 200, { ok: true, result })
|
|
464
515
|
}
|
|
465
516
|
const result = await fileOp('finish upload', undefined, () => api.sftpCloseTransfer(sessionId, { verify: true }))
|
|
517
|
+
localTransferOwners.delete(sessionId)
|
|
466
518
|
// F1/B5/B8: the local adapter used to return no `server`, so the dir
|
|
467
519
|
// cache was cleared for `undefined` and the activity log said
|
|
468
520
|
// "unknown". Prefer the reported id, fall back to the synthetic local
|
|
@@ -494,6 +546,7 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
494
546
|
if (!sessionId) return send(res, 400, { ok: false, error: 'session is required' })
|
|
495
547
|
const api = localTransfer ?? runtime
|
|
496
548
|
const result = await fileOp('close download', undefined, () => api.sftpCloseTransfer(sessionId))
|
|
549
|
+
localTransferOwners.delete(sessionId)
|
|
497
550
|
return send(res, 200, { ok: true, result })
|
|
498
551
|
}
|
|
499
552
|
|
|
@@ -501,9 +554,11 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
501
554
|
const servers = loadServers()
|
|
502
555
|
let target
|
|
503
556
|
let fileApi = runtime
|
|
557
|
+
let localApi = null
|
|
504
558
|
if (ref === LOCAL_FILE_REF && localFiles) {
|
|
505
|
-
|
|
506
|
-
|
|
559
|
+
localApi = localApiForSession(body.session)
|
|
560
|
+
target = localApi.server
|
|
561
|
+
fileApi = localApi
|
|
507
562
|
} else {
|
|
508
563
|
target = ref ? findTarget(servers, ref) : undefined
|
|
509
564
|
}
|
|
@@ -605,11 +660,15 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
605
660
|
|
|
606
661
|
if (rel === '/sftp/open-write') {
|
|
607
662
|
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
608
|
-
|
|
663
|
+
const result = await fileOp('open for writing', body.path, () => fileApi.sftpOpenWrite(target, body.path, body.truncate !== false))
|
|
664
|
+
if (localApi && typeof result?.session === 'string') localTransferOwners.set(result.session, localApi)
|
|
665
|
+
return send(res, 200, { ok: true, result })
|
|
609
666
|
}
|
|
610
667
|
if (rel === '/sftp/open-read') {
|
|
611
668
|
if (typeof body.path !== 'string') return send(res, 400, { ok: false, error: 'path is required' })
|
|
612
|
-
|
|
669
|
+
const result = await fileOp('open for reading', body.path, () => fileApi.sftpOpenRead(target, body.path))
|
|
670
|
+
if (localApi && typeof result?.session === 'string') localTransferOwners.set(result.session, localApi)
|
|
671
|
+
return send(res, 200, { ok: true, result })
|
|
613
672
|
}
|
|
614
673
|
|
|
615
674
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ssh-workbench",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.9",
|
|
4
4
|
"description": "远程工作台:SSH 终端、本地与远程文件、@服务器 对话执行;显式目标的远程 shell 与 read/glob/grep,Windows 宿主和目标都能用。DeepSeek Harness SSH workbench plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
"platform": "web",
|
|
39
39
|
"inject": [
|
|
40
40
|
"@deepseek-ai/dsh-client-ui-renderer",
|
|
41
|
-
"@deepseek-ai/dsh-client-locale"
|
|
41
|
+
"@deepseek-ai/dsh-client-locale",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-context-menu",
|
|
43
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
44
|
+
"@deepseek-ai/dsh-api-session-controller"
|
|
42
45
|
]
|
|
43
46
|
}
|
|
44
47
|
},
|
|
@@ -48,6 +51,9 @@
|
|
|
48
51
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
49
52
|
"@deepseek-ai/dsh-client-ui-renderer": ">=0.2.1-rc.1 <0.3.0",
|
|
50
53
|
"@deepseek-ai/dsh-client-locale": ">=0.2.1-rc.1 <0.3.0",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-context-menu": ">=0.2.1-rc.1 <0.3.0",
|
|
55
|
+
"@deepseek-ai/dsh-api-remotes": ">=0.2.1-rc.1 <0.3.0",
|
|
56
|
+
"@deepseek-ai/dsh-api-session-controller": ">=0.2.1-rc.1 <0.3.0",
|
|
51
57
|
"react": "^18.3.1",
|
|
52
58
|
"react-dom": "^18.3.1"
|
|
53
59
|
},
|
|
@@ -57,6 +63,15 @@
|
|
|
57
63
|
},
|
|
58
64
|
"@deepseek-ai/dsh-client-locale": {
|
|
59
65
|
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-context-menu": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@deepseek-ai/dsh-api-remotes": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"@deepseek-ai/dsh-api-session-controller": {
|
|
74
|
+
"optional": true
|
|
60
75
|
}
|
|
61
76
|
},
|
|
62
77
|
"scripts": {
|