dsh-ssh-workbench 1.6.4 → 1.7.8
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 +133 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +47 -25
- package/SECURITY.md +2 -0
- package/docs/architecture.md +60 -38
- 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 +24 -9
- package/lib/client.js +1249 -218
- package/lib/core/activity.js +5 -0
- package/lib/core/background.js +18 -4
- 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 +287 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +36 -3
- package/lib/core/remote-exec-tool.js +441 -0
- package/lib/core/remote-file-tools.js +642 -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/ssh.js +22 -0
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +121 -157
- package/lib/routes.js +45 -5
- package/package.json +23 -7
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
package/lib/index.js
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
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
|
-
import { loadPrefs, savePrefs } from './core/prefs.js'
|
|
17
|
+
import { loadPrefs, savePrefs, resolveRemoteFileWrite, resolveSystemFileHierarchy } from './core/prefs.js'
|
|
19
18
|
import { createTerminalHub } from './core/terminals.js'
|
|
20
19
|
import { createLocalFs } from './core/localfs.js'
|
|
21
20
|
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,22 @@ 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
|
+
// Replaces the native right-sidebar files page with the hierarchical local
|
|
63
|
+
// browser when enabled. Off by default so DSH's own file page is untouched.
|
|
64
|
+
systemFileHierarchy: z.boolean().default(false),
|
|
65
|
+
remoteGlobMaxResults: z.natural().default(200),
|
|
66
|
+
remoteGlobMaxDepth: z.natural().default(4),
|
|
67
|
+
remoteGrepMaxResults: z.natural().default(200),
|
|
53
68
|
// A stalled SFTP chunk (half-open connection) fails after this long instead
|
|
54
69
|
// of hanging the file panel; the client can resume from its offset.
|
|
55
70
|
transferChunkTimeoutMs: z.natural().default(30000),
|
|
@@ -60,8 +75,8 @@ export const Config = z.object({
|
|
|
60
75
|
maxChannelsPerServer: z.natural().default(6),
|
|
61
76
|
// Warm a server connection when the user selects it in the UI.
|
|
62
77
|
prewarm: z.boolean().default(true),
|
|
63
|
-
// Inject the
|
|
64
|
-
//
|
|
78
|
+
// Inject the managed-resource context into every human turn. Disable only
|
|
79
|
+
// for a deliberately remote-blind session.
|
|
65
80
|
targetContext: z.boolean().default(true),
|
|
66
81
|
// Root of the workbench's local side (local terminal cwd + local file
|
|
67
82
|
// panel). Empty = the OS user home directory, matching the local pane of
|
|
@@ -77,6 +92,16 @@ const APPLY_DEFAULTS = {
|
|
|
77
92
|
timeoutKillAfterMs: 5000,
|
|
78
93
|
maxOutputBytes: 102400,
|
|
79
94
|
maxStderrBytes: 16384,
|
|
95
|
+
maxBatchTasks: 8,
|
|
96
|
+
batchConcurrency: 4,
|
|
97
|
+
maxBatchOutputBytes: 262144,
|
|
98
|
+
remoteFileReadBytes: 262144,
|
|
99
|
+
remoteFileEditBytes: 2097152,
|
|
100
|
+
remoteFileWrite: false,
|
|
101
|
+
systemFileHierarchy: false,
|
|
102
|
+
remoteGlobMaxResults: 200,
|
|
103
|
+
remoteGlobMaxDepth: 4,
|
|
104
|
+
remoteGrepMaxResults: 200,
|
|
80
105
|
transferChunkTimeoutMs: 30000,
|
|
81
106
|
hostKeyMode: 'strict',
|
|
82
107
|
connectionPool: true,
|
|
@@ -96,7 +121,7 @@ const textOut = {
|
|
|
96
121
|
export { APPLY_DEFAULTS }
|
|
97
122
|
|
|
98
123
|
/** Public settings safe to show in the Client settings/state page. */
|
|
99
|
-
export function publicConfig(config) {
|
|
124
|
+
export function publicConfig(config, prefs) {
|
|
100
125
|
return {
|
|
101
126
|
hostKeyMode: config.hostKeyMode,
|
|
102
127
|
connectTimeoutMs: config.connectTimeoutMs,
|
|
@@ -105,6 +130,16 @@ export function publicConfig(config) {
|
|
|
105
130
|
timeoutKillAfterMs: config.timeoutKillAfterMs,
|
|
106
131
|
maxOutputBytes: config.maxOutputBytes,
|
|
107
132
|
maxStderrBytes: config.maxStderrBytes,
|
|
133
|
+
maxBatchTasks: config.maxBatchTasks,
|
|
134
|
+
batchConcurrency: config.batchConcurrency,
|
|
135
|
+
maxBatchOutputBytes: config.maxBatchOutputBytes,
|
|
136
|
+
remoteFileReadBytes: config.remoteFileReadBytes,
|
|
137
|
+
remoteFileEditBytes: config.remoteFileEditBytes,
|
|
138
|
+
remoteFileWrite: resolveRemoteFileWrite(config, prefs),
|
|
139
|
+
systemFileHierarchy: resolveSystemFileHierarchy(config, prefs),
|
|
140
|
+
remoteGlobMaxResults: config.remoteGlobMaxResults,
|
|
141
|
+
remoteGlobMaxDepth: config.remoteGlobMaxDepth,
|
|
142
|
+
remoteGrepMaxResults: config.remoteGrepMaxResults,
|
|
108
143
|
transferChunkTimeoutMs: config.transferChunkTimeoutMs,
|
|
109
144
|
connectionPool: config.connectionPool !== false,
|
|
110
145
|
poolIdleMs: config.poolIdleMs,
|
|
@@ -113,90 +148,8 @@ export function publicConfig(config) {
|
|
|
113
148
|
}
|
|
114
149
|
}
|
|
115
150
|
|
|
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
151
|
/** Expand a configured `~` prefix; relative/invalid values are left for the caller. */
|
|
199
|
-
|
|
152
|
+
function expandLocalRoot(value) {
|
|
200
153
|
const raw = typeof value === 'string' ? value.trim() : ''
|
|
201
154
|
if (!raw) return ''
|
|
202
155
|
return raw === '~'
|
|
@@ -228,12 +181,11 @@ export function resolveLocalRoot(config = {}) {
|
|
|
228
181
|
*/
|
|
229
182
|
export function apply(ctx, config, deps = {}) {
|
|
230
183
|
config = { ...APPLY_DEFAULTS, ...(config ?? {}) }
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// global assumption; tests can pin it through deps.hostPlatform.
|
|
184
|
+
// The native shell tool differs per Host, but remote tools are explicit:
|
|
185
|
+
// both dialect tools are registered on both Hosts.
|
|
234
186
|
const hostOs = hostPlatform(deps.hostPlatform ?? process.platform)
|
|
235
187
|
const isWindowsHost = hostOs === 'win32'
|
|
236
|
-
const
|
|
188
|
+
const localShellName = isWindowsHost ? 'pwsh' : 'bash'
|
|
237
189
|
let remoteAvailable = false
|
|
238
190
|
if (config.hostKeyMode !== 'strict' && config.hostKeyMode !== 'accept-new') {
|
|
239
191
|
ctx.logger?.warn?.('dsh-ssh-workbench: unknown hostKeyMode value, falling back to strict', { value: config.hostKeyMode })
|
|
@@ -272,6 +224,7 @@ export function apply(ctx, config, deps = {}) {
|
|
|
272
224
|
}
|
|
273
225
|
|
|
274
226
|
const targets = deps.targets ?? createTargetStore({ loadInventory })
|
|
227
|
+
const liveExecutions = deps.liveExecutions ?? createLiveExecutions()
|
|
275
228
|
|
|
276
229
|
/**
|
|
277
230
|
* Collect a host's facts once and persist them on the server record.
|
|
@@ -299,21 +252,8 @@ export function apply(ctx, config, deps = {}) {
|
|
|
299
252
|
}
|
|
300
253
|
}
|
|
301
254
|
|
|
302
|
-
// ── model-facing tools: target
|
|
255
|
+
// ── model-facing tools: explicit-target remote shells + fan-out ────────────
|
|
303
256
|
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
257
|
const registerRemote = (definition, label) => {
|
|
318
258
|
try {
|
|
319
259
|
const off = ctx.tools.register(definition)
|
|
@@ -329,42 +269,66 @@ export function apply(ctx, config, deps = {}) {
|
|
|
329
269
|
}
|
|
330
270
|
}
|
|
331
271
|
|
|
332
|
-
|
|
272
|
+
remoteAvailable =
|
|
273
|
+
registerRemote(
|
|
274
|
+
createRemoteExecTool({
|
|
275
|
+
runtime,
|
|
276
|
+
config,
|
|
277
|
+
loadInventory,
|
|
278
|
+
recordActivity,
|
|
279
|
+
}),
|
|
280
|
+
'remote exec tool',
|
|
281
|
+
) || remoteAvailable
|
|
282
|
+
|
|
283
|
+
remoteAvailable =
|
|
284
|
+
registerRemote(
|
|
285
|
+
createRemoteShellTool({
|
|
286
|
+
runtime,
|
|
287
|
+
config,
|
|
288
|
+
loadInventory,
|
|
289
|
+
recordActivity,
|
|
290
|
+
adapter: posixAdapter,
|
|
291
|
+
localShellName,
|
|
292
|
+
}),
|
|
293
|
+
'remote bash tool',
|
|
294
|
+
) || remoteAvailable
|
|
295
|
+
|
|
333
296
|
remoteAvailable =
|
|
334
297
|
registerRemote(
|
|
335
298
|
createRemoteShellTool({
|
|
336
299
|
runtime,
|
|
337
|
-
targets,
|
|
338
300
|
config,
|
|
339
301
|
loadInventory,
|
|
340
302
|
recordActivity,
|
|
341
303
|
adapter: win32Adapter,
|
|
342
|
-
localShellName
|
|
304
|
+
localShellName,
|
|
343
305
|
}),
|
|
344
306
|
'remote pwsh tool',
|
|
345
307
|
) || remoteAvailable
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
308
|
+
|
|
309
|
+
for (const tool of createRemoteFileTools({ runtime, config, loadInventory, recordActivity })) {
|
|
310
|
+
remoteAvailable = registerRemote(tool, `remote file tool: ${tool.name}`) || remoteAvailable
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── live execution indicator: display-only, never selects a target ─────────
|
|
315
|
+
if (remoteAvailable) {
|
|
316
|
+
const offLive = ctx.on?.('tools/execute', async (exec, next) => {
|
|
317
|
+
const token = liveExecutions.begin(exec)
|
|
318
|
+
try {
|
|
319
|
+
return await next()
|
|
320
|
+
} finally {
|
|
321
|
+
liveExecutions.end(token)
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
if (typeof offLive === 'function' && typeof ctx.effect === 'function') {
|
|
325
|
+
ctx.effect(() => offLive, 'dsh-ssh-workbench: live execution indicator')
|
|
362
326
|
}
|
|
363
327
|
}
|
|
364
328
|
|
|
365
329
|
// ── fallback blacklist: deny-only, synchronous, monotonic ──────────────────
|
|
366
330
|
if (typeof ctx.tools?.guard === 'function') {
|
|
367
|
-
const offGuard = ctx.tools.guard(createBlacklistGuard({
|
|
331
|
+
const offGuard = ctx.tools.guard(createBlacklistGuard({ recordActivity, loadInventory }))
|
|
368
332
|
if (typeof offGuard === 'function' && typeof ctx.effect === 'function') {
|
|
369
333
|
ctx.effect(() => offGuard, 'dsh-ssh-workbench: blacklist guard')
|
|
370
334
|
}
|
|
@@ -372,30 +336,29 @@ export function apply(ctx, config, deps = {}) {
|
|
|
372
336
|
ctx.logger?.warn?.('dsh-ssh-workbench: ctx.tools.guard() is unavailable; the destructive-command blacklist is disabled')
|
|
373
337
|
}
|
|
374
338
|
|
|
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 ────────
|
|
339
|
+
// ── managed-resource context: one compact inventory + tool rules per turn ──
|
|
386
340
|
// The context is only injected when this Host really has a remote execution
|
|
387
341
|
// path; otherwise it would tell the model that a tool runs remotely when no
|
|
388
|
-
// such tool exists
|
|
342
|
+
// such tool exists.
|
|
343
|
+
const loadLastExecuted = (sessionId) => {
|
|
344
|
+
try {
|
|
345
|
+
const entries = activity.list(200)
|
|
346
|
+
const last = entries.find((entry) => entry?.kind === 'exec' && entry.sessionId === sessionId)
|
|
347
|
+
return typeof last?.server === 'string' && last.server.length > 0 ? last.server : null
|
|
348
|
+
} catch {
|
|
349
|
+
return null
|
|
350
|
+
}
|
|
351
|
+
}
|
|
389
352
|
if (config.targetContext !== false) {
|
|
390
|
-
|
|
353
|
+
installManagedContext(ctx, {
|
|
391
354
|
targets,
|
|
392
355
|
loadInventory,
|
|
356
|
+
loadLastExecuted,
|
|
393
357
|
createUserMessage: deps.createUserMessage,
|
|
394
358
|
remoteAvailable,
|
|
395
359
|
hostPlatform: hostOs,
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
ensureDialect: (server) => refreshServerFacts(server),
|
|
360
|
+
maxContextServers: 20,
|
|
361
|
+
loadRemoteFileWrite: () => resolveRemoteFileWrite(config, loadPrefs()),
|
|
399
362
|
})
|
|
400
363
|
}
|
|
401
364
|
|
|
@@ -436,12 +399,13 @@ export function apply(ctx, config, deps = {}) {
|
|
|
436
399
|
secrets,
|
|
437
400
|
knownHosts,
|
|
438
401
|
targets,
|
|
402
|
+
liveExecutions,
|
|
439
403
|
refreshFacts: (server) => refreshServerFacts(server),
|
|
440
404
|
activity,
|
|
441
405
|
prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
|
|
442
406
|
terminals,
|
|
443
407
|
localfs,
|
|
444
|
-
publicConfig: () => publicConfig(config),
|
|
408
|
+
publicConfig: () => publicConfig(config, loadPrefs()),
|
|
445
409
|
}),
|
|
446
410
|
)
|
|
447
411
|
if (typeof ctx.effect === 'function') {
|
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') {
|
|
@@ -361,10 +387,24 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
if (rel === '/prefs') {
|
|
364
|
-
|
|
365
|
-
|
|
390
|
+
const patch = {}
|
|
391
|
+
if (typeof body.sidebar === 'boolean') patch.sidebar = body.sidebar
|
|
392
|
+
// `null` clears the override and falls back to the plugin config.
|
|
393
|
+
if (typeof body.remoteFileWrite === 'boolean' || body.remoteFileWrite === null) {
|
|
394
|
+
patch.remoteFileWrite = body.remoteFileWrite
|
|
366
395
|
}
|
|
367
|
-
|
|
396
|
+
if (typeof body.systemFileHierarchy === 'boolean' || body.systemFileHierarchy === null) {
|
|
397
|
+
patch.systemFileHierarchy = body.systemFileHierarchy
|
|
398
|
+
}
|
|
399
|
+
if (Object.keys(patch).length === 0) {
|
|
400
|
+
return send(res, 400, { ok: false, error: 'prefs patch must contain sidebar, remoteFileWrite and/or systemFileHierarchy' })
|
|
401
|
+
}
|
|
402
|
+
const saved = prefsApi.save(patch)
|
|
403
|
+
return send(res, 200, {
|
|
404
|
+
ok: true,
|
|
405
|
+
prefs: saved,
|
|
406
|
+
config: typeof publicConfig === 'function' ? publicConfig() : undefined,
|
|
407
|
+
})
|
|
368
408
|
}
|
|
369
409
|
|
|
370
410
|
if (rel === '/terminal/open') {
|
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.8",
|
|
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,
|
|
@@ -36,7 +38,10 @@
|
|
|
36
38
|
"platform": "web",
|
|
37
39
|
"inject": [
|
|
38
40
|
"@deepseek-ai/dsh-client-ui-renderer",
|
|
39
|
-
"@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"
|
|
40
45
|
]
|
|
41
46
|
}
|
|
42
47
|
},
|
|
@@ -46,6 +51,9 @@
|
|
|
46
51
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
47
52
|
"@deepseek-ai/dsh-client-ui-renderer": ">=0.2.1-rc.1 <0.3.0",
|
|
48
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",
|
|
49
57
|
"react": "^18.3.1",
|
|
50
58
|
"react-dom": "^18.3.1"
|
|
51
59
|
},
|
|
@@ -55,6 +63,15 @@
|
|
|
55
63
|
},
|
|
56
64
|
"@deepseek-ai/dsh-client-locale": {
|
|
57
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
|
|
58
75
|
}
|
|
59
76
|
},
|
|
60
77
|
"scripts": {
|
|
@@ -62,8 +79,7 @@
|
|
|
62
79
|
"pack": "node -e \"require('node:fs').mkdirSync('dist',{recursive:true})\" && npm pack --ignore-scripts --pack-destination dist",
|
|
63
80
|
"test:ssh": "node --import ./test/ssh.register.mjs test/ssh.integration.mjs",
|
|
64
81
|
"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"
|
|
82
|
+
"bench": "node --import ./test/ssh.register.mjs test/bench/latency.mjs"
|
|
67
83
|
},
|
|
68
84
|
"license": "MIT",
|
|
69
85
|
"keywords": [
|
|
@@ -81,7 +97,7 @@
|
|
|
81
97
|
"author": "duobaobox",
|
|
82
98
|
"repository": {
|
|
83
99
|
"type": "git",
|
|
84
|
-
"url": "https://github.com/duobaobox/dsh-ssh-workbench.git"
|
|
100
|
+
"url": "git+https://github.com/duobaobox/dsh-ssh-workbench.git"
|
|
85
101
|
},
|
|
86
102
|
"homepage": "https://github.com/duobaobox/dsh-ssh-workbench",
|
|
87
103
|
"bugs": {
|