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/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
- if (typeof localFiles?.sftpHome === 'function') sources.push(() => localFiles.sftpHome())
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
- const root = typeof localfs.rootReal === 'function'
371
- ? await localfs.rootReal()
372
- : await Promise.resolve(localfs.root())
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
- if (typeof body.sidebar !== 'boolean') {
391
- return send(res, 400, { ok: false, error: 'prefs.sidebar must be a boolean' })
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
- return send(res, 200, { ok: true, prefs: prefsApi.save({ sidebar: body.sidebar }) })
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 = localFiles && sessionId.startsWith('loc') ? localFiles : null
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
- target = localFiles.server
506
- fileApi = localFiles
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
- return send(res, 200, { ok: true, result: await fileOp('open for writing', body.path, () => fileApi.sftpOpenWrite(target, body.path, body.truncate !== false)) })
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
- return send(res, 200, { ok: true, result: await fileOp('open for reading', body.path, () => fileApi.sftpOpenRead(target, body.path)) })
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.0",
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": {