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/CHANGELOG.md +49 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +41 -22
- package/SECURITY.md +2 -0
- package/docs/architecture.md +58 -36
- 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 +20 -8
- package/lib/client.js +171 -157
- package/lib/core/activity.js +5 -0
- 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 +276 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +1 -1
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -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/win32-adapter.js +1 -1
- package/lib/index.js +113 -154
- package/lib/routes.js +28 -2
- package/package.json +7 -6
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
package/lib/index.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-ssh-workbench host half.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
24
|
+
import { createTargetStore } from './core/mentions.js'
|
|
26
25
|
import { createBlacklistGuard } from './core/guard.js'
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import { hostPlatform } from './core/shell-adapters.js'
|
|
32
|
-
import {
|
|
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
|
|
64
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
232
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
299
|
+
localShellName,
|
|
343
300
|
}),
|
|
344
301
|
'remote pwsh tool',
|
|
345
302
|
) || remoteAvailable
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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({
|
|
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
|
-
// ──
|
|
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
|
|
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
|
-
|
|
348
|
+
installManagedContext(ctx, {
|
|
391
349
|
targets,
|
|
392
350
|
loadInventory,
|
|
351
|
+
loadLastExecuted,
|
|
393
352
|
createUserMessage: deps.createUserMessage,
|
|
394
353
|
remoteAvailable,
|
|
395
354
|
hostPlatform: hostOs,
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "远程工作台:SSH 终端、本地与远程文件、@服务器
|
|
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": {
|