dsh-ssh-workbench 1.6.4 → 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/lib/index.js CHANGED
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * dsh-ssh-workbench host half.
3
3
  *
4
- * Remote execution is deliberately not a second toolbox: on POSIX Hosts the
5
- * plugin gives DSH's own `bash` tool a remote execution world, and when a
6
- * Session has no active managed target every tool behaves exactly as shipped.
7
- * Windows Hosts ship no `bash` tool at all (DSH disables it by platform), so
8
- * there the plugin registers the standalone `remote_bash` tool instead. A
9
- * single `ssh_workbench_target` tool switches the active target, and a synchronous
10
- * `ctx.tools.guard()` blacklist is the fallback safety net.
4
+ * Local and remote execution are two separate worlds. The native shell tools
5
+ * always run on the local DSH Host; remote work always names its target on the
6
+ * tool call (`remote_bash`, `remote_pwsh`, `remote_exec`). There is no
7
+ * session-scoped target state and no native shell interception, so a follow-up
8
+ * without a new `@mention` can never move a command silently to a different
9
+ * machine. A synchronous `ctx.tools.guard()` blacklist is the fallback safety
10
+ * net for every explicit remote target.
11
11
  */
12
12
  import os from 'node:os'
13
13
  import path from 'node:path'
14
14
  import z from '@deepseek-ai/schemastery'
15
- import { defineTool } from '@deepseek-ai/dsh-tools'
16
15
 
17
16
  import { loadServers, saveServers } from './core/store.js'
18
17
  import { loadPrefs, savePrefs } from './core/prefs.js'
@@ -22,14 +21,14 @@ import { createSecretStore } from './core/secrets.js'
22
21
  import { createKnownHosts } from './core/known-hosts.js'
23
22
  import { createSshRuntime } from './core/ssh.js'
24
23
  import { createActivityStore } from './core/activity.js'
25
- import { createTargetStore, TARGET_TOOL_NAME, LOCAL_TARGET } from './core/targets.js'
24
+ import { createTargetStore } from './core/mentions.js'
26
25
  import { createBlacklistGuard } from './core/guard.js'
27
- import { createRemoteBashRouter } from './core/remote-bash.js'
28
- import { createRemoteShellTool } from './core/remote-bash-tool.js'
29
- import { posixAdapter, win32Adapter } from './core/shell-adapters.js'
30
- import { effectiveDialect } from './core/dialect.js'
31
- import { hostPlatform } from './core/shell-adapters.js'
32
- import { installTargetContext } from './core/server-reference.js'
26
+ import { createRemoteShellTool } from './core/remote-shell-tool.js'
27
+ import { createRemoteExecTool } from './core/remote-exec-tool.js'
28
+ import { createRemoteFileTools } from './core/remote-file-tools.js'
29
+ import { createLiveExecutions } from './core/live-executions.js'
30
+ import { posixAdapter, win32Adapter, hostPlatform } from './core/shell-adapters.js'
31
+ import { installManagedContext } from './core/managed-context.js'
33
32
  import { createRoutes, registerRoutes } from './routes.js'
34
33
 
35
34
  export const name = 'dsh-ssh-workbench'
@@ -50,6 +49,19 @@ export const Config = z.object({
50
49
  // stderr is capped separately so one chatty failing command cannot flood the
51
50
  // model context (stdout keeps maxOutputBytes).
52
51
  maxStderrBytes: z.natural().default(16384),
52
+ // `remote_exec` fan-out: tasks per call, concurrent SSH channels, and the
53
+ // total stdout/stderr budget for one batch (shared across all tasks).
54
+ maxBatchTasks: z.natural().default(8),
55
+ batchConcurrency: z.natural().default(4),
56
+ maxBatchOutputBytes: z.natural().default(262144),
57
+ // Explicit remote file tools: read/glob/grep are always available; write and
58
+ // edit stay disabled until an operator opts in. All sizes are bytes.
59
+ remoteFileReadBytes: z.natural().default(262144),
60
+ remoteFileEditBytes: z.natural().default(2097152),
61
+ remoteFileWrite: z.boolean().default(false),
62
+ remoteGlobMaxResults: z.natural().default(200),
63
+ remoteGlobMaxDepth: z.natural().default(4),
64
+ remoteGrepMaxResults: z.natural().default(200),
53
65
  // A stalled SFTP chunk (half-open connection) fails after this long instead
54
66
  // of hanging the file panel; the client can resume from its offset.
55
67
  transferChunkTimeoutMs: z.natural().default(30000),
@@ -60,8 +72,8 @@ export const Config = z.object({
60
72
  maxChannelsPerServer: z.natural().default(6),
61
73
  // Warm a server connection when the user selects it in the UI.
62
74
  prewarm: z.boolean().default(true),
63
- // Inject the `@server` target context into the step and bind the session's
64
- // remote target. Disable to keep every native tool strictly local.
75
+ // Inject the managed-resource context into every human turn. Disable only
76
+ // for a deliberately remote-blind session.
65
77
  targetContext: z.boolean().default(true),
66
78
  // Root of the workbench's local side (local terminal cwd + local file
67
79
  // panel). Empty = the OS user home directory, matching the local pane of
@@ -77,6 +89,15 @@ const APPLY_DEFAULTS = {
77
89
  timeoutKillAfterMs: 5000,
78
90
  maxOutputBytes: 102400,
79
91
  maxStderrBytes: 16384,
92
+ maxBatchTasks: 8,
93
+ batchConcurrency: 4,
94
+ maxBatchOutputBytes: 262144,
95
+ remoteFileReadBytes: 262144,
96
+ remoteFileEditBytes: 2097152,
97
+ remoteFileWrite: false,
98
+ remoteGlobMaxResults: 200,
99
+ remoteGlobMaxDepth: 4,
100
+ remoteGrepMaxResults: 200,
80
101
  transferChunkTimeoutMs: 30000,
81
102
  hostKeyMode: 'strict',
82
103
  connectionPool: true,
@@ -105,6 +126,15 @@ export function publicConfig(config) {
105
126
  timeoutKillAfterMs: config.timeoutKillAfterMs,
106
127
  maxOutputBytes: config.maxOutputBytes,
107
128
  maxStderrBytes: config.maxStderrBytes,
129
+ maxBatchTasks: config.maxBatchTasks,
130
+ batchConcurrency: config.batchConcurrency,
131
+ maxBatchOutputBytes: config.maxBatchOutputBytes,
132
+ remoteFileReadBytes: config.remoteFileReadBytes,
133
+ remoteFileEditBytes: config.remoteFileEditBytes,
134
+ remoteFileWrite: config.remoteFileWrite === true,
135
+ remoteGlobMaxResults: config.remoteGlobMaxResults,
136
+ remoteGlobMaxDepth: config.remoteGlobMaxDepth,
137
+ remoteGrepMaxResults: config.remoteGrepMaxResults,
108
138
  transferChunkTimeoutMs: config.transferChunkTimeoutMs,
109
139
  connectionPool: config.connectionPool !== false,
110
140
  poolIdleMs: config.poolIdleMs,
@@ -113,90 +143,8 @@ export function publicConfig(config) {
113
143
  }
114
144
  }
115
145
 
116
- /**
117
- * The only model-facing tool this plugin registers. It never executes a
118
- * command; it selects the managed server the native `bash` tool runs on.
119
- */
120
- export function createTargetTool({
121
- targets,
122
- loadInventory,
123
- onActivated,
124
- remoteToolName = 'bash',
125
- windowsToolName = 'remote_pwsh',
126
- hostPlatform: hostOs = 'posix',
127
- }) {
128
- const description =
129
- hostOs === 'win32'
130
- ? `Select the managed remote resource (server or VM) that this session's remote shell tools run on. Pass a managed resource name or id, or "local" to return to the local DSH host. Only one target is active at a time; the native pwsh tool and read/write/edit/glob/grep always run on the local DSH host. Remote POSIX targets run through ${remoteToolName}; Windows targets run through ${windowsToolName}.`
131
- : `Select the managed remote resource (server or VM) that this session's remote shell runs on. Pass a managed resource name or id, or "local" to return to the local DSH host. Only one target is active at a time; read/write/edit/glob/grep always run on the local DSH host. POSIX targets run through the native ${remoteToolName} tool; Windows targets run through ${windowsToolName}.`
132
- return defineTool({
133
- name: TARGET_TOOL_NAME,
134
- description,
135
- parameters: {
136
- server: { type: 'string', required: true, description: `Managed resource name or id, or "${LOCAL_TARGET}" to detach` },
137
- },
138
- output: textOut,
139
- async execute(args, exec) {
140
- const session = exec?.agent?.session
141
- const key = session?.id ?? exec?.agent?.id
142
- if (typeof key !== 'string' || key.length === 0) {
143
- throw new Error('ssh_workbench_target requires an agent session')
144
- }
145
- const servers = loadInventory()
146
- const outcome = targets.setActive(key, String(args.server ?? '').trim(), servers)
147
- if (!outcome.ok) {
148
- const available = servers
149
- .filter((server) => server.enabled !== false)
150
- .slice(0, 20)
151
- .map((server) => server.name)
152
- .join(', ')
153
- throw new Error(`${outcome.error}. Available targets: ${available || '(none)'}; or "${LOCAL_TARGET}".`)
154
- }
155
- const state = outcome.state
156
- if (!state.active) {
157
- return { text: `active target: ${LOCAL_TARGET} — ${localShellSentence(hostOs)}.` }
158
- }
159
- const server = servers.find((entry) => entry.name === state.active)
160
- const target = server ? `${server.username}@${server.host}:${server.port}` : state.active
161
- // Probing the dialect before answering makes the next injected context
162
- // name the right tool. It is bounded: a slow/unreachable host must not
163
- // stall target selection.
164
- let dialect = server ? effectiveDialect(server) : undefined
165
- if (server && typeof onActivated === 'function') {
166
- try {
167
- const facts = await Promise.race([
168
- Promise.resolve().then(() => onActivated(server)),
169
- new Promise((resolve) => {
170
- const timer = setTimeout(resolve, 4000)
171
- timer.unref?.()
172
- }),
173
- ])
174
- if (facts?.dialect) dialect = facts.dialect
175
- } catch {
176
- // Keep the record hint.
177
- }
178
- }
179
- if (dialect === 'windows') {
180
- return {
181
- text: `active target: ${state.active} (${target}); ${windowsToolName} runs PowerShell commands there; read/write/edit/glob/grep run on the local DSH host.`,
182
- }
183
- }
184
- return {
185
- text: `active target: ${state.active} (${target}); ${remoteToolName} runs remotely on this resource; read/write/edit/glob/grep run on the local DSH host.`,
186
- }
187
- },
188
- })
189
- }
190
-
191
- /** One sentence describing where the native shell tool runs on this Host. */
192
- function localShellSentence(hostOs) {
193
- return hostOs === 'win32'
194
- ? 'the native pwsh tool runs on the local DSH host; no remote target is selected'
195
- : 'bash runs on the local DSH host'
196
- }
197
-
198
146
  /** Expand a configured `~` prefix; relative/invalid values are left for the caller. */
199
- export function expandLocalRoot(value) {
147
+ function expandLocalRoot(value) {
200
148
  const raw = typeof value === 'string' ? value.trim() : ''
201
149
  if (!raw) return ''
202
150
  return raw === '~'
@@ -228,12 +176,11 @@ export function resolveLocalRoot(config = {}) {
228
176
  */
229
177
  export function apply(ctx, config, deps = {}) {
230
178
  config = { ...APPLY_DEFAULTS, ...(config ?? {}) }
231
- // Hosts ship different native shell tools: DSH disables `bash` on win32 and
232
- // `pwsh` elsewhere. The remote tool therefore depends on the Host, not on a
233
- // global assumption; tests can pin it through deps.hostPlatform.
179
+ // The native shell tool differs per Host, but remote tools are explicit:
180
+ // both dialect tools are registered on both Hosts.
234
181
  const hostOs = hostPlatform(deps.hostPlatform ?? process.platform)
235
182
  const isWindowsHost = hostOs === 'win32'
236
- const remoteToolName = isWindowsHost ? 'remote_bash' : 'bash'
183
+ const localShellName = isWindowsHost ? 'pwsh' : 'bash'
237
184
  let remoteAvailable = false
238
185
  if (config.hostKeyMode !== 'strict' && config.hostKeyMode !== 'accept-new') {
239
186
  ctx.logger?.warn?.('dsh-ssh-workbench: unknown hostKeyMode value, falling back to strict', { value: config.hostKeyMode })
@@ -272,6 +219,7 @@ export function apply(ctx, config, deps = {}) {
272
219
  }
273
220
 
274
221
  const targets = deps.targets ?? createTargetStore({ loadInventory })
222
+ const liveExecutions = deps.liveExecutions ?? createLiveExecutions()
275
223
 
276
224
  /**
277
225
  * Collect a host's facts once and persist them on the server record.
@@ -299,21 +247,8 @@ export function apply(ctx, config, deps = {}) {
299
247
  }
300
248
  }
301
249
 
302
- // ── model-facing tools: target selector + dialect-specific remote shells ───
250
+ // ── model-facing tools: explicit-target remote shells + fan-out ────────────
303
251
  if (typeof ctx.tools?.register === 'function') {
304
- const targetTool = createTargetTool({
305
- targets,
306
- loadInventory,
307
- onActivated: (server) => refreshServerFacts(server),
308
- remoteToolName,
309
- windowsToolName: 'remote_pwsh',
310
- hostPlatform: hostOs,
311
- })
312
- const offTool = ctx.tools.register(targetTool)
313
- if (typeof offTool === 'function' && typeof ctx.effect === 'function') {
314
- ctx.effect(() => offTool, 'dsh-ssh-workbench: target tool')
315
- }
316
-
317
252
  const registerRemote = (definition, label) => {
318
253
  try {
319
254
  const off = ctx.tools.register(definition)
@@ -329,42 +264,66 @@ export function apply(ctx, config, deps = {}) {
329
264
  }
330
265
  }
331
266
 
332
- // Windows targets are not covered by any native tool on either Host.
267
+ remoteAvailable =
268
+ registerRemote(
269
+ createRemoteExecTool({
270
+ runtime,
271
+ config,
272
+ loadInventory,
273
+ recordActivity,
274
+ }),
275
+ 'remote exec tool',
276
+ ) || remoteAvailable
277
+
278
+ remoteAvailable =
279
+ registerRemote(
280
+ createRemoteShellTool({
281
+ runtime,
282
+ config,
283
+ loadInventory,
284
+ recordActivity,
285
+ adapter: posixAdapter,
286
+ localShellName,
287
+ }),
288
+ 'remote bash tool',
289
+ ) || remoteAvailable
290
+
333
291
  remoteAvailable =
334
292
  registerRemote(
335
293
  createRemoteShellTool({
336
294
  runtime,
337
- targets,
338
295
  config,
339
296
  loadInventory,
340
297
  recordActivity,
341
298
  adapter: win32Adapter,
342
- localShellName: isWindowsHost ? 'pwsh' : 'bash',
299
+ localShellName,
343
300
  }),
344
301
  'remote pwsh tool',
345
302
  ) || remoteAvailable
346
- // Windows Hosts also have no native bash, so POSIX targets need the
347
- // plugin-owned tool there; POSIX Hosts keep the native bash router below.
348
- if (isWindowsHost) {
349
- remoteAvailable =
350
- registerRemote(
351
- createRemoteShellTool({
352
- runtime,
353
- targets,
354
- config,
355
- loadInventory,
356
- recordActivity,
357
- adapter: posixAdapter,
358
- localShellName: 'pwsh',
359
- }),
360
- 'remote bash tool',
361
- ) || remoteAvailable
303
+
304
+ for (const tool of createRemoteFileTools({ runtime, config, loadInventory, recordActivity })) {
305
+ remoteAvailable = registerRemote(tool, `remote file tool: ${tool.name}`) || remoteAvailable
306
+ }
307
+ }
308
+
309
+ // ── live execution indicator: display-only, never selects a target ─────────
310
+ if (remoteAvailable) {
311
+ const offLive = ctx.on?.('tools/execute', async (exec, next) => {
312
+ const token = liveExecutions.begin(exec)
313
+ try {
314
+ return await next()
315
+ } finally {
316
+ liveExecutions.end(token)
317
+ }
318
+ })
319
+ if (typeof offLive === 'function' && typeof ctx.effect === 'function') {
320
+ ctx.effect(() => offLive, 'dsh-ssh-workbench: live execution indicator')
362
321
  }
363
322
  }
364
323
 
365
324
  // ── fallback blacklist: deny-only, synchronous, monotonic ──────────────────
366
325
  if (typeof ctx.tools?.guard === 'function') {
367
- const offGuard = ctx.tools.guard(createBlacklistGuard({ targets, recordActivity }))
326
+ const offGuard = ctx.tools.guard(createBlacklistGuard({ recordActivity, loadInventory }))
368
327
  if (typeof offGuard === 'function' && typeof ctx.effect === 'function') {
369
328
  ctx.effect(() => offGuard, 'dsh-ssh-workbench: blacklist guard')
370
329
  }
@@ -372,30 +331,29 @@ export function apply(ctx, config, deps = {}) {
372
331
  ctx.logger?.warn?.('dsh-ssh-workbench: ctx.tools.guard() is unavailable; the destructive-command blacklist is disabled')
373
332
  }
374
333
 
375
- // ── route the native bash tool to the active managed target (POSIX Hosts) ──
376
- if (!isWindowsHost && typeof ctx.on === 'function') {
377
- const router = createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity })
378
- const offExecute = ctx.on('tools/execute', router)
379
- if (typeof offExecute === 'function' && typeof ctx.effect === 'function') {
380
- ctx.effect(() => offExecute, 'dsh-ssh-workbench: remote bash router')
381
- }
382
- remoteAvailable = true
383
- }
384
-
385
- // ── @server → session target + one compact context message per turn ────────
334
+ // ── managed-resource context: one compact inventory + tool rules per turn ──
386
335
  // The context is only injected when this Host really has a remote execution
387
336
  // path; otherwise it would tell the model that a tool runs remotely when no
388
- // such tool exists (the Windows-without-remote_bash failure mode).
337
+ // such tool exists.
338
+ const loadLastExecuted = (sessionId) => {
339
+ try {
340
+ const entries = activity.list(200)
341
+ const last = entries.find((entry) => entry?.kind === 'exec' && entry.sessionId === sessionId)
342
+ return typeof last?.server === 'string' && last.server.length > 0 ? last.server : null
343
+ } catch {
344
+ return null
345
+ }
346
+ }
389
347
  if (config.targetContext !== false) {
390
- installTargetContext(ctx, {
348
+ installManagedContext(ctx, {
391
349
  targets,
392
350
  loadInventory,
351
+ loadLastExecuted,
393
352
  createUserMessage: deps.createUserMessage,
394
353
  remoteAvailable,
395
354
  hostPlatform: hostOs,
396
- remoteToolName,
397
- windowsToolName: 'remote_pwsh',
398
- ensureDialect: (server) => refreshServerFacts(server),
355
+ maxContextServers: 20,
356
+ remoteFileWrite: config.remoteFileWrite === true,
399
357
  })
400
358
  }
401
359
 
@@ -436,6 +394,7 @@ export function apply(ctx, config, deps = {}) {
436
394
  secrets,
437
395
  knownHosts,
438
396
  targets,
397
+ liveExecutions,
439
398
  refreshFacts: (server) => refreshServerFacts(server),
440
399
  activity,
441
400
  prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
package/lib/routes.js CHANGED
@@ -15,6 +15,7 @@ import { Readable } from 'node:stream'
15
15
  import { createLocalFileApi, LOCAL_FILE_REF } from './core/local-file-api.js'
16
16
  import { effectiveDialect } from './core/dialect.js'
17
17
  import { SshError } from './core/errors.js'
18
+ import { summarizeExecution } from './core/execution-stats.js'
18
19
 
19
20
  const PREFIX = '/dsh-ssh-workbench'
20
21
  const MAX_BODY = 8 * 1024 * 1024
@@ -147,7 +148,24 @@ function resolveRef(value) {
147
148
  * @param {() => object} deps.publicConfig
148
149
  * @param {{ append: Function, list: Function }} [deps.activity] bounded local activity store
149
150
  */
150
- export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
151
+ /** Read the bounded activity log without letting a store failure break /target. */
152
+ function targetActivityEntries(activity, limit = 200) {
153
+ try {
154
+ return typeof activity?.list === 'function' ? activity.list(limit) : []
155
+ } catch {
156
+ return []
157
+ }
158
+ }
159
+
160
+ /** Latest remote execution for one Session, derived from the activity ledger. */
161
+ function lastExecutedServer(entries, session) {
162
+ const entry = (Array.isArray(entries) ? entries : []).find(
163
+ (item) => item?.kind === 'exec' && item.sessionId === session && typeof item.server === 'string',
164
+ )
165
+ return entry?.server ?? null
166
+ }
167
+
168
+ export function createRoutes({ loadServers, saveServers, runtime, secrets, knownHosts, targets, liveExecutions, refreshFacts, prefs, terminals, localfs, publicConfig, activity }) {
151
169
  const prefsApi = prefs ?? {
152
170
  load: () => ({ sidebar: true }),
153
171
  save: () => ({ sidebar: true }),
@@ -308,11 +326,19 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
308
326
  if (req.method === 'GET' && rel === '/target') {
309
327
  const session = String(searchParams.get('session') ?? '')
310
328
  const state = session && typeof targets?.get === 'function' ? targets.get(session) : undefined
329
+ const entries = targetActivityEntries(activity)
311
330
  return send(res, 200, {
312
331
  ok: true,
313
- target: state?.active ?? null,
314
332
  candidates: state?.candidates ?? [],
333
+ lastExecuted: lastExecutedServer(entries, session),
334
+ running: typeof liveExecutions?.snapshot === 'function'
335
+ ? liveExecutions.snapshot(session)
336
+ : { count: 0, servers: [] },
315
337
  version: state?.version ?? 0,
338
+ // The dock is a read-only projection of this Session's executions:
339
+ // only servers that actually ran appear (zero-count candidates would
340
+ // make the total disagree with the rows).
341
+ stats: summarizeExecution(entries, { sessionId: session, candidates: [] }),
316
342
  })
317
343
  }
318
344
  if (req.method === 'GET' && rel === '/activity') {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ssh-workbench",
3
- "version": "1.6.4",
4
- "description": "远程工作台:SSH 终端、本地与远程文件、@服务器 对话执行;Windows 宿主和目标都能用。DeepSeek Harness SSH workbench plugin.",
3
+ "version": "1.7.0",
4
+ "description": "远程工作台:SSH 终端、本地与远程文件、@服务器 对话执行;显式目标的远程 shell 与 read/glob/grep,Windows 宿主和目标都能用。DeepSeek Harness SSH workbench plugin.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -22,7 +22,9 @@
22
22
  "locale",
23
23
  "vendor",
24
24
  "docs/quickstart.md",
25
- "docs/architecture.md"
25
+ "docs/architecture.md",
26
+ "docs/execution-model.md",
27
+ "docs/images"
26
28
  ],
27
29
  "dsh": {
28
30
  "manifestVersion": 1,
@@ -62,8 +64,7 @@
62
64
  "pack": "node -e \"require('node:fs').mkdirSync('dist',{recursive:true})\" && npm pack --ignore-scripts --pack-destination dist",
63
65
  "test:ssh": "node --import ./test/ssh.register.mjs test/ssh.integration.mjs",
64
66
  "test:ssh:sftp": "node --import ./test/ssh.register.mjs test/sftp.integration.mjs",
65
- "bench": "node --import ./test/ssh.register.mjs test/bench/latency.mjs",
66
- "test:live": "node --import ./test/register.mjs --import ./test/ssh.register.mjs test/regression/live-p0.mjs"
67
+ "bench": "node --import ./test/ssh.register.mjs test/bench/latency.mjs"
67
68
  },
68
69
  "license": "MIT",
69
70
  "keywords": [
@@ -81,7 +82,7 @@
81
82
  "author": "duobaobox",
82
83
  "repository": {
83
84
  "type": "git",
84
- "url": "https://github.com/duobaobox/dsh-ssh-workbench.git"
85
+ "url": "git+https://github.com/duobaobox/dsh-ssh-workbench.git"
85
86
  },
86
87
  "homepage": "https://github.com/duobaobox/dsh-ssh-workbench",
87
88
  "bugs": {