dsh-ssh-workbench 1.7.0 → 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 +84 -0
- package/README.md +13 -10
- package/docs/architecture.md +8 -8
- package/docs/execution-model.md +1 -1
- package/docs/quickstart.md +8 -5
- package/lib/client.js +1078 -61
- 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 +10 -5
- package/lib/routes.js +17 -3
- package/package.json +17 -2
package/lib/core/background.js
CHANGED
|
@@ -39,10 +39,17 @@ export function parseBackgroundStart(stdout) {
|
|
|
39
39
|
* `nohup` + redirected stdio is what keeps the child alive after the SSH
|
|
40
40
|
* channel closes; the whole launcher runs under `sh -c` so a fish/csh login
|
|
41
41
|
* shell only has to parse the simple outer command.
|
|
42
|
+
*
|
|
43
|
+
* Logs default to `~/.dsh/ssh-workbench/logs` instead of `/tmp`, because a
|
|
44
|
+
* detached process may outlive the tmp cleanup window; `DSH_SSH_WORKBENCH_LOG_DIR`
|
|
45
|
+
* overrides the directory remotely, and `/tmp` stays as the fallback when the
|
|
46
|
+
* home directory is not writable.
|
|
42
47
|
*/
|
|
43
48
|
export function buildPosixBackgroundCommand(builtCommand) {
|
|
44
49
|
const script = [
|
|
45
|
-
'
|
|
50
|
+
'LOG_DIR=${DSH_SSH_WORKBENCH_LOG_DIR:-$HOME/.dsh/ssh-workbench/logs}',
|
|
51
|
+
'mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR=/tmp',
|
|
52
|
+
'LOG=$(mktemp "$LOG_DIR/dsh-ssh-workbench-bg.XXXXXX" 2>/dev/null || echo "$LOG_DIR/dsh-ssh-workbench-bg-$$-$(date +%s).log")',
|
|
46
53
|
`nohup "\${SHELL:-/bin/sh}" -lc ${quotePosix(builtCommand)} >"$LOG" 2>&1 &`,
|
|
47
54
|
`echo "${BG_PID_MARKER}$!"`,
|
|
48
55
|
`echo "${BG_LOG_MARKER}$LOG"`,
|
|
@@ -50,10 +57,10 @@ export function buildPosixBackgroundCommand(builtCommand) {
|
|
|
50
57
|
return `sh -c ${quotePosix(script)}`
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
/**
|
|
54
|
-
export function
|
|
60
|
+
/** The pid/log/stop lines shared by every background result renderer. */
|
|
61
|
+
export function renderBackgroundDetails({ pid, log, errLog, dialect = 'posix' }) {
|
|
55
62
|
const windows = dialect === 'windows'
|
|
56
|
-
const lines = [
|
|
63
|
+
const lines = []
|
|
57
64
|
if (pid !== undefined) lines.push(`pid: ${pid}`)
|
|
58
65
|
if (log) lines.push(`log: ${log}`)
|
|
59
66
|
if (errLog) lines.push(`stderr log: ${errLog}`)
|
|
@@ -65,5 +72,12 @@ export function renderBackgroundStart({ pid, log, errLog, target, command, diale
|
|
|
65
72
|
)
|
|
66
73
|
if (pid !== undefined) lines.push(windows ? `stop: Stop-Process -Id ${pid} -Force` : `stop: kill ${pid}`)
|
|
67
74
|
lines.push('The process is detached from this tool call: timeoutMs does not apply and it keeps running until it exits or is stopped.')
|
|
75
|
+
return lines
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One model-facing description of a started background process. */
|
|
79
|
+
export function renderBackgroundStart({ pid, log, errLog, target, command, dialect = 'posix' }) {
|
|
80
|
+
const lines = [`background: started${target ? ` on ${target}` : ''}`, `command: ${command}`]
|
|
81
|
+
lines.push(...renderBackgroundDetails({ pid, log, errLog, dialect }))
|
|
68
82
|
return lines.join('\n')
|
|
69
83
|
}
|
|
@@ -103,7 +103,7 @@ export function renderManagedContext(servers, hints = {}) {
|
|
|
103
103
|
)
|
|
104
104
|
} else {
|
|
105
105
|
lines.push(
|
|
106
|
-
'- Remote writes are disabled
|
|
106
|
+
'- Remote writes are disabled: remote_write/remote_edit will refuse. Use the workbench SFTP panel for uploads, or ask the user to turn on remote writes in the plugin settings.',
|
|
107
107
|
)
|
|
108
108
|
}
|
|
109
109
|
const candidates = Array.isArray(hints.candidates) ? hints.candidates.filter(Boolean) : []
|
|
@@ -149,6 +149,8 @@ export function buildManagedContextMessage(servers, hints, createUserMessage) {
|
|
|
149
149
|
* targets?: object,
|
|
150
150
|
* loadInventory?: () => object[],
|
|
151
151
|
* loadLastExecuted?: (sessionId: string) => string|null,
|
|
152
|
+
* remoteFileWrite?: boolean,
|
|
153
|
+
* loadRemoteFileWrite?: () => boolean,
|
|
152
154
|
* createUserMessage?: Function,
|
|
153
155
|
* }} [options]
|
|
154
156
|
*/
|
|
@@ -229,11 +231,20 @@ export function installManagedContext(ctx, options = {}) {
|
|
|
229
231
|
} catch {
|
|
230
232
|
lastExecuted = null
|
|
231
233
|
}
|
|
234
|
+
let remoteFileWrite = options.remoteFileWrite === true
|
|
235
|
+
if (typeof options.loadRemoteFileWrite === 'function') {
|
|
236
|
+
try {
|
|
237
|
+
remoteFileWrite = options.loadRemoteFileWrite() === true
|
|
238
|
+
} catch {
|
|
239
|
+
remoteFileWrite = false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
232
242
|
const hints = {
|
|
233
243
|
candidates: state.candidates,
|
|
234
244
|
lastExecuted: typeof lastExecuted === 'string' && lastExecuted.length > 0 ? lastExecuted : null,
|
|
235
245
|
hostPlatform: options.hostPlatform,
|
|
236
246
|
maxServers: maxContextServers,
|
|
247
|
+
remoteFileWrite,
|
|
237
248
|
}
|
|
238
249
|
const message = buildManagedContextMessage(servers, hints, await messageFactory())
|
|
239
250
|
targets.markInjected(key, state.version)
|
package/lib/core/prefs.js
CHANGED
|
@@ -2,18 +2,25 @@
|
|
|
2
2
|
* Small user-visible UI preferences that are not part of the server inventory.
|
|
3
3
|
*
|
|
4
4
|
* Kept in the same private data directory as the inventory so a profile export
|
|
5
|
-
* carries them, and so the plugin manager's
|
|
5
|
+
* carries them, and so the plugin manager's settings form can change them
|
|
6
6
|
* without rewriting the profile's cordis.patch.yml.
|
|
7
|
+
*
|
|
8
|
+
* `remoteFileWrite` is deliberately tri-state: `null` means "follow the plugin
|
|
9
|
+
* config", a boolean means the user overrode it from the settings UI. Keeping
|
|
10
|
+
* the override separate from the config lets the plugin detail page show a
|
|
11
|
+
* switch without making the config file the only way in.
|
|
7
12
|
*/
|
|
8
13
|
import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
|
|
9
14
|
|
|
10
|
-
export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
|
|
15
|
+
export const DEFAULT_PREFS = Object.freeze({ sidebar: true, remoteFileWrite: null, systemFileHierarchy: null })
|
|
11
16
|
|
|
12
17
|
/** Coerce a stored document into the current preference shape. */
|
|
13
18
|
function normalizePrefs(raw) {
|
|
14
19
|
const prefs = { ...DEFAULT_PREFS }
|
|
15
20
|
if (!raw || typeof raw !== 'object') return prefs
|
|
16
21
|
if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar
|
|
22
|
+
if (typeof raw.remoteFileWrite === 'boolean') prefs.remoteFileWrite = raw.remoteFileWrite
|
|
23
|
+
if (typeof raw.systemFileHierarchy === 'boolean') prefs.systemFileHierarchy = raw.systemFileHierarchy
|
|
17
24
|
return prefs
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -36,3 +43,29 @@ export function savePrefs(patch) {
|
|
|
36
43
|
writePrivateFile(prefsPath(), `${JSON.stringify(next, null, 2)}\n`)
|
|
37
44
|
return next
|
|
38
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the write switch the tools and the model context should honour.
|
|
49
|
+
*
|
|
50
|
+
* A stored override wins; otherwise the plugin config decides. Anything that
|
|
51
|
+
* is neither `true` nor `false` falls back to the config, so a corrupt prefs
|
|
52
|
+
* file can never silently enable remote writes.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveRemoteFileWrite(config, prefs) {
|
|
55
|
+
const override = prefs?.remoteFileWrite
|
|
56
|
+
if (override === true || override === false) return override
|
|
57
|
+
return config?.remoteFileWrite === true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the hierarchical system file browser switch.
|
|
62
|
+
*
|
|
63
|
+
* Same tri-state contract as the write switch: a settings-page override wins,
|
|
64
|
+
* otherwise the plugin config decides. Defaults to off so the native files tab
|
|
65
|
+
* is untouched unless the user explicitly asks for the hierarchical browser.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveSystemFileHierarchy(config, prefs) {
|
|
68
|
+
const override = prefs?.systemFileHierarchy
|
|
69
|
+
if (override === true || override === false) return override
|
|
70
|
+
return config?.systemFileHierarchy === true
|
|
71
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
15
|
|
|
16
16
|
import { describeResultOutcome } from './activity.js'
|
|
17
|
+
import { parseBackgroundStart, renderBackgroundDetails } from './background.js'
|
|
17
18
|
import { effectiveDialect } from './dialect.js'
|
|
18
19
|
import { isTransportFailure } from './posix-shell.js'
|
|
19
20
|
import { resolveDialect, sessionKeyOf } from './remote-shell-tool.js'
|
|
@@ -102,6 +103,17 @@ async function resolveTasks({ tasks, servers, runtime, config, probe }) {
|
|
|
102
103
|
|
|
103
104
|
const built = adapter.buildCommand(command, task.workdir, server, dialect)
|
|
104
105
|
if (built?.error !== undefined) throw new Error(`${label}: ${built.error}`)
|
|
106
|
+
|
|
107
|
+
let background = null
|
|
108
|
+
if (task.run_in_background === true) {
|
|
109
|
+
if (typeof adapter.buildBackgroundCommand !== 'function') {
|
|
110
|
+
throw new Error(`${label}: background execution is not supported for ${dialect} targets`)
|
|
111
|
+
}
|
|
112
|
+
const bg = adapter.buildBackgroundCommand(command, task.workdir, server, dialect)
|
|
113
|
+
if (bg?.error !== undefined) throw new Error(`${label}: ${bg.error}`)
|
|
114
|
+
background = { command: bg.command, cwd: bg.cwd ?? built.cwd }
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
return {
|
|
106
118
|
index,
|
|
107
119
|
task,
|
|
@@ -111,6 +123,7 @@ async function resolveTasks({ tasks, servers, runtime, config, probe }) {
|
|
|
111
123
|
command,
|
|
112
124
|
cwd: built.cwd,
|
|
113
125
|
remoteCommand: built.command,
|
|
126
|
+
background,
|
|
114
127
|
timeoutMs: adapter.resolveTimeoutMs(task, config),
|
|
115
128
|
capability: undefined,
|
|
116
129
|
}
|
|
@@ -136,7 +149,7 @@ async function resolveCapabilities(tasks, runtime, probe = true) {
|
|
|
136
149
|
const cache = new Map()
|
|
137
150
|
await Promise.all(
|
|
138
151
|
tasks.map(async (task) => {
|
|
139
|
-
if (task.adapter?.remoteTimeout !== true) {
|
|
152
|
+
if (task.background || task.adapter?.remoteTimeout !== true) {
|
|
140
153
|
task.capability = { mode: 'none', shell: '/bin/sh' }
|
|
141
154
|
return
|
|
142
155
|
}
|
|
@@ -182,11 +195,14 @@ function recordTaskActivity(task, { sessionId, durationMs, outcome }) {
|
|
|
182
195
|
|
|
183
196
|
async function runOne(task, { runtime, config, perTaskOutputCap, perTaskStderrCap, signal, sessionId, connectTimeoutMs }) {
|
|
184
197
|
const startedAt = Date.now()
|
|
198
|
+
const remoteCommand = task.background
|
|
199
|
+
? task.background.command
|
|
200
|
+
: task.adapter.wrapTimeout(task.remoteCommand, task.timeoutMs, task.capability, {
|
|
201
|
+
killAfterMs: config?.timeoutKillAfterMs,
|
|
202
|
+
})
|
|
185
203
|
let result
|
|
186
204
|
try {
|
|
187
|
-
result = await runtime.exec(task.server,
|
|
188
|
-
killAfterMs: config?.timeoutKillAfterMs,
|
|
189
|
-
}), {
|
|
205
|
+
result = await runtime.exec(task.server, remoteCommand, {
|
|
190
206
|
timeoutMs: task.timeoutMs,
|
|
191
207
|
signal,
|
|
192
208
|
readOnly: false,
|
|
@@ -201,6 +217,21 @@ async function runOne(task, { runtime, config, perTaskOutputCap, perTaskStderrCa
|
|
|
201
217
|
}
|
|
202
218
|
|
|
203
219
|
const durationMs = Date.now() - startedAt
|
|
220
|
+
|
|
221
|
+
if (task.background) {
|
|
222
|
+
const started = parseBackgroundStart(result?.stdout)
|
|
223
|
+
const detail =
|
|
224
|
+
(typeof result?.stderr === 'string' && result.stderr.trim()) ||
|
|
225
|
+
(typeof result?.stdout === 'string' && result.stdout.trim()) ||
|
|
226
|
+
'the launcher did not report a pid'
|
|
227
|
+
if (isTransportFailure(result) || started.pid === undefined) {
|
|
228
|
+
recordTaskActivity(task, { sessionId, durationMs, outcome: 'failed' })
|
|
229
|
+
return { index: task.index, task, error: `background start failed: ${detail}`, result, durationMs }
|
|
230
|
+
}
|
|
231
|
+
recordTaskActivity(task, { sessionId, durationMs, outcome: 'success' })
|
|
232
|
+
return { index: task.index, task, result, background: started, durationMs }
|
|
233
|
+
}
|
|
234
|
+
|
|
204
235
|
recordTaskActivity(task, { sessionId, durationMs, outcome: describeResultOutcome(result) })
|
|
205
236
|
|
|
206
237
|
if (isTransportFailure(result)) {
|
|
@@ -240,7 +271,7 @@ function renderTask(outcome, { perTaskOutputCap, perTaskStderrCap }) {
|
|
|
240
271
|
`[${task.index + 1}] ${task.server.name} (${target}) · ${task.dialect}`,
|
|
241
272
|
`command: ${task.command}`,
|
|
242
273
|
`cwd: ${task.cwd}`,
|
|
243
|
-
|
|
274
|
+
`${outcome.background !== undefined ? 'start duration' : 'duration'}: ${outcome.durationMs}ms`,
|
|
244
275
|
]
|
|
245
276
|
if (outcome.error !== undefined) {
|
|
246
277
|
lines.push(`error: ${outcome.error}`)
|
|
@@ -248,6 +279,16 @@ function renderTask(outcome, { perTaskOutputCap, perTaskStderrCap }) {
|
|
|
248
279
|
}
|
|
249
280
|
|
|
250
281
|
const result = outcome.result ?? {}
|
|
282
|
+
if (outcome.background !== undefined) {
|
|
283
|
+
lines.push('background: started')
|
|
284
|
+
lines.push(...renderBackgroundDetails({ ...outcome.background, dialect: task.dialect }))
|
|
285
|
+
const stderr = clipText(result.stderr, perTaskStderrCap)
|
|
286
|
+
if (stderr.text.length > 0) {
|
|
287
|
+
lines.push('launcher stderr:')
|
|
288
|
+
lines.push(stderr.text.replace(/\n$/, ''))
|
|
289
|
+
}
|
|
290
|
+
return lines
|
|
291
|
+
}
|
|
251
292
|
if (Number.isInteger(result.exitCode)) lines.push(`exit code: ${result.exitCode}`)
|
|
252
293
|
if (result.outcome === 'timeout') lines.push('outcome: timed out')
|
|
253
294
|
if (result.outcome === 'cancelled') lines.push('outcome: cancelled')
|
|
@@ -282,7 +323,8 @@ export function createRemoteExecTool({ runtime, config, loadInventory, recordAct
|
|
|
282
323
|
'Each task carries its own server, so tasks on different resources run concurrently and no task depends on ' +
|
|
283
324
|
'any session-level target state. Use it for multi-resource questions such as comparing versions ' +
|
|
284
325
|
'or collecting status from several @mentioned resources in one step. POSIX targets use their login shell; ' +
|
|
285
|
-
'Windows targets use PowerShell.
|
|
326
|
+
'Windows targets use PowerShell. Use run_in_background on a task for a detached process: the task returns ' +
|
|
327
|
+
'the remote pid and log file immediately, and timeoutMs only covers the start. ' +
|
|
286
328
|
'The native shell tool always runs on the local DSH host, so never use it for these commands. ' +
|
|
287
329
|
'Set probe:false to skip dialect/capability probing; combine it with connectTimeoutMs for quick status checks.',
|
|
288
330
|
parameters: {
|
|
@@ -310,7 +352,12 @@ export function createRemoteExecTool({ runtime, config, loadInventory, recordAct
|
|
|
310
352
|
},
|
|
311
353
|
timeoutMs: {
|
|
312
354
|
type: 'integer',
|
|
313
|
-
description: 'Foreground timeout for this task in milliseconds (default from config, capped by maxCommandTimeoutMs).',
|
|
355
|
+
description: 'Foreground timeout for this task in milliseconds (default from config, capped by maxCommandTimeoutMs). For run_in_background it bounds the launcher only.',
|
|
356
|
+
},
|
|
357
|
+
run_in_background: {
|
|
358
|
+
type: 'boolean',
|
|
359
|
+
description:
|
|
360
|
+
'Start this task as a detached remote process and return its pid and log file. The process keeps running after this tool call; timeoutMs does not apply to it.',
|
|
314
361
|
},
|
|
315
362
|
},
|
|
316
363
|
},
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
15
|
|
|
16
16
|
import { describeResultOutcome } from './activity.js'
|
|
17
|
+
import { loadPrefs, resolveRemoteFileWrite } from './prefs.js'
|
|
17
18
|
import { resolveDialect, sessionKeyOf } from './remote-shell-tool.js'
|
|
18
19
|
import { requireServerRef } from './server-ref.js'
|
|
19
20
|
import { win32Adapter } from './win32-adapter.js'
|
|
@@ -151,10 +152,14 @@ function recordFileActivity({ recordActivity, server, sessionId, startedAt, comm
|
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The write switch can come from the plugin config or from the settings UI
|
|
157
|
+
* override; both are resolved on every call so a toggle takes effect at once.
|
|
158
|
+
*/
|
|
154
159
|
function assertWritesEnabled(config) {
|
|
155
|
-
if (config
|
|
160
|
+
if (resolveRemoteFileWrite(config, loadPrefs()) !== true) {
|
|
156
161
|
throw new Error(
|
|
157
|
-
'remote file writes are disabled
|
|
162
|
+
'remote file writes are disabled; enable them in the plugin settings (远程工作台 → 远程写入) or with remoteFileWrite in the plugin config before using remote_write or remote_edit',
|
|
158
163
|
)
|
|
159
164
|
}
|
|
160
165
|
}
|
|
@@ -163,6 +168,75 @@ function byteLength(value) {
|
|
|
163
168
|
return Buffer.byteLength(String(value ?? ''), 'utf8')
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
function randomTempSuffix() {
|
|
172
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Write one remote file with replace-on-success semantics.
|
|
177
|
+
*
|
|
178
|
+
* POSIX `rename(2)` over an existing path is atomic, so writing a sibling temp
|
|
179
|
+
* file and renaming it into place means a dropped connection or a failed write
|
|
180
|
+
* never leaves a half-written target. Windows OpenSSH cannot reliably rename
|
|
181
|
+
* over an existing file, so Windows targets keep the direct write. A symlink
|
|
182
|
+
* target is written through instead of replaced, so the link itself survives.
|
|
183
|
+
*/
|
|
184
|
+
async function writeRemoteFile({ runtime, server, path, content, dialect }) {
|
|
185
|
+
const writeDirect = async (fallback) => {
|
|
186
|
+
const result = await runtime.sftpWrite(server, path, content)
|
|
187
|
+
return { ...result, atomic: false, fallback }
|
|
188
|
+
}
|
|
189
|
+
if (dialect === 'windows') return writeDirect('windows')
|
|
190
|
+
if (typeof runtime.sftpRename !== 'function' || typeof runtime.sftpRemove !== 'function') {
|
|
191
|
+
return writeDirect('no-rename-support')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let existing = null
|
|
195
|
+
if (typeof runtime.sftpLstat === 'function') {
|
|
196
|
+
try {
|
|
197
|
+
existing = await runtime.sftpLstat(server, path)
|
|
198
|
+
} catch {
|
|
199
|
+
existing = null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Never turn a symlink into a regular file; write through the link instead.
|
|
203
|
+
if (existing?.type === 'link') return writeDirect('symlink')
|
|
204
|
+
|
|
205
|
+
const temp = `${path}.sdh-tmp-${randomTempSuffix()}`
|
|
206
|
+
try {
|
|
207
|
+
await runtime.sftpWrite(server, temp, content)
|
|
208
|
+
if (existing && Number.isInteger(existing.mode) && typeof runtime.sftpChmod === 'function') {
|
|
209
|
+
try {
|
|
210
|
+
await runtime.sftpChmod(server, temp, existing.mode & 0o7777)
|
|
211
|
+
} catch {
|
|
212
|
+
// Preserving the mode is best effort; the content is what matters.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await runtime.sftpRename(server, temp, path)
|
|
217
|
+
return { path, bytes: byteLength(content), atomic: true }
|
|
218
|
+
} catch {
|
|
219
|
+
// A server may refuse to rename over an existing file. Fall back to a
|
|
220
|
+
// direct write instead of deleting the target first (which would leave a
|
|
221
|
+
// window where the file does not exist).
|
|
222
|
+
await runtime.sftpWrite(server, path, content)
|
|
223
|
+
try {
|
|
224
|
+
await runtime.sftpRemove(server, temp)
|
|
225
|
+
} catch {
|
|
226
|
+
// Best effort cleanup.
|
|
227
|
+
}
|
|
228
|
+
return { path, bytes: byteLength(content), atomic: false, fallback: 'rename-refused' }
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
try {
|
|
232
|
+
await runtime.sftpRemove(server, temp)
|
|
233
|
+
} catch {
|
|
234
|
+
// Best effort cleanup; the original target is untouched.
|
|
235
|
+
}
|
|
236
|
+
throw error
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
166
240
|
export function createRemoteReadTool({ runtime, config, loadInventory, recordActivity }) {
|
|
167
241
|
return defineTool({
|
|
168
242
|
name: REMOTE_READ_TOOL_NAME,
|
|
@@ -226,7 +300,7 @@ export function createRemoteWriteTool({ runtime, config, loadInventory, recordAc
|
|
|
226
300
|
return defineTool({
|
|
227
301
|
name: REMOTE_WRITE_TOOL_NAME,
|
|
228
302
|
description:
|
|
229
|
-
'Write a UTF-8 text file on a managed remote resource through SFTP. Requires
|
|
303
|
+
'Write a UTF-8 text file on a managed remote resource through SFTP. Requires remote writes to be enabled in the plugin settings. The parent directory must already exist; the file is created or overwritten.',
|
|
230
304
|
parameters: {
|
|
231
305
|
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
232
306
|
path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
|
|
@@ -246,7 +320,7 @@ export function createRemoteWriteTool({ runtime, config, loadInventory, recordAc
|
|
|
246
320
|
const bytes = byteLength(content)
|
|
247
321
|
const cap = integerIn(config?.remoteFileEditBytes, 2097152, 1024, MAX_EDIT_HARD_LIMIT)
|
|
248
322
|
if (bytes > cap) throw new Error(`file content is ${bytes} bytes, over the ${cap}-byte write limit`)
|
|
249
|
-
const result = await runtime
|
|
323
|
+
const result = await writeRemoteFile({ runtime, server, path, content, dialect })
|
|
250
324
|
recordFileActivity({
|
|
251
325
|
recordActivity,
|
|
252
326
|
server,
|
|
@@ -255,11 +329,19 @@ export function createRemoteWriteTool({ runtime, config, loadInventory, recordAc
|
|
|
255
329
|
command: `remote_write ${path}`,
|
|
256
330
|
outcome: 'success',
|
|
257
331
|
})
|
|
332
|
+
const note =
|
|
333
|
+
result?.atomic === true
|
|
334
|
+
? ' (atomic replace)'
|
|
335
|
+
: result?.fallback === 'symlink'
|
|
336
|
+
? ' (written through symlink)'
|
|
337
|
+
: result?.fallback === 'windows'
|
|
338
|
+
? ' (direct write: atomic replace is not available on Windows targets)'
|
|
339
|
+
: ''
|
|
258
340
|
return {
|
|
259
341
|
text: renderHeader({
|
|
260
342
|
server,
|
|
261
343
|
path,
|
|
262
|
-
extra: [`wrote: ${result?.bytes ?? bytes} bytes`],
|
|
344
|
+
extra: [`wrote: ${result?.bytes ?? bytes} bytes${note}`],
|
|
263
345
|
}).join('\n'),
|
|
264
346
|
}
|
|
265
347
|
} catch (error) {
|
|
@@ -281,7 +363,7 @@ export function createRemoteEditTool({ runtime, config, loadInventory, recordAct
|
|
|
281
363
|
return defineTool({
|
|
282
364
|
name: REMOTE_EDIT_TOOL_NAME,
|
|
283
365
|
description:
|
|
284
|
-
'Replace text in a UTF-8 file on a managed remote resource. By default oldText must match exactly once; set replaceAll=true to replace every occurrence. Requires
|
|
366
|
+
'Replace text in a UTF-8 file on a managed remote resource. By default oldText must match exactly once; set replaceAll=true to replace every occurrence. Requires remote writes to be enabled in the plugin settings.',
|
|
285
367
|
parameters: {
|
|
286
368
|
server: { type: 'string', required: true, description: 'Managed resource name or id.' },
|
|
287
369
|
path: { type: 'string', required: true, description: 'Absolute remote path, or a path under ~.' },
|
|
@@ -314,7 +396,7 @@ export function createRemoteEditTool({ runtime, config, loadInventory, recordAct
|
|
|
314
396
|
}
|
|
315
397
|
const next = args?.replaceAll === true ? source.split(oldText).join(newText) : source.replace(oldText, newText)
|
|
316
398
|
if (byteLength(next) > cap) throw new Error(`edited file would exceed the ${cap}-byte write limit`)
|
|
317
|
-
await runtime
|
|
399
|
+
const written = await writeRemoteFile({ runtime, server, path, content: next, dialect })
|
|
318
400
|
recordFileActivity({
|
|
319
401
|
recordActivity,
|
|
320
402
|
server,
|
|
@@ -323,11 +405,12 @@ export function createRemoteEditTool({ runtime, config, loadInventory, recordAct
|
|
|
323
405
|
command: `remote_edit ${path}`,
|
|
324
406
|
outcome: 'success',
|
|
325
407
|
})
|
|
408
|
+
const modeNote = written?.atomic === true ? ', atomic replace' : ''
|
|
326
409
|
return {
|
|
327
410
|
text: renderHeader({
|
|
328
411
|
server,
|
|
329
412
|
path,
|
|
330
|
-
extra: [`replacements: ${args?.replaceAll === true ? occurrences : 1}`],
|
|
413
|
+
extra: [`replacements: ${args?.replaceAll === true ? occurrences : 1}${modeNote}`],
|
|
331
414
|
}).join('\n'),
|
|
332
415
|
}
|
|
333
416
|
} catch (error) {
|
package/lib/core/ssh.js
CHANGED
|
@@ -269,6 +269,26 @@ export function createSshRuntime({ getSecret, knownHosts, config = {}, log = ()
|
|
|
269
269
|
}))
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* `stat` follows symlinks; `lstat` reports the link itself. Atomic writes use
|
|
274
|
+
* this to avoid replacing a symlink with a regular file.
|
|
275
|
+
*/
|
|
276
|
+
async function sftpLstat(server, remotePath) {
|
|
277
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
278
|
+
sftp.lstat(remotePath, (error, attrs) => {
|
|
279
|
+
if (error) return reject(toSftpError(error))
|
|
280
|
+
const type = typeof attrs?.isSymbolicLink === 'function' && attrs.isSymbolicLink() ? 'link' : undefined
|
|
281
|
+
resolve({ path: remotePath, ...statOf(attrs), ...(type ? { type } : {}) })
|
|
282
|
+
})
|
|
283
|
+
}))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function sftpChmod(server, remotePath, mode) {
|
|
287
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
288
|
+
sftp.chmod(remotePath, mode, (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, mode })))
|
|
289
|
+
}))
|
|
290
|
+
}
|
|
291
|
+
|
|
272
292
|
function clampReadBytes(value, fallback) {
|
|
273
293
|
const hardCap = 64 * 1024 * 1024
|
|
274
294
|
return Number.isInteger(value) ? Math.max(1024, Math.min(value, hardCap)) : fallback
|
|
@@ -707,6 +727,8 @@ export function createSshRuntime({ getSecret, knownHosts, config = {}, log = ()
|
|
|
707
727
|
withSftp,
|
|
708
728
|
sftpList,
|
|
709
729
|
sftpStat,
|
|
730
|
+
sftpLstat,
|
|
731
|
+
sftpChmod,
|
|
710
732
|
sftpHome,
|
|
711
733
|
sftpRead,
|
|
712
734
|
sftpReadBuffer,
|
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import path from 'node:path'
|
|
|
14
14
|
import z from '@deepseek-ai/schemastery'
|
|
15
15
|
|
|
16
16
|
import { loadServers, saveServers } from './core/store.js'
|
|
17
|
-
import { loadPrefs, savePrefs } from './core/prefs.js'
|
|
17
|
+
import { loadPrefs, savePrefs, resolveRemoteFileWrite, resolveSystemFileHierarchy } from './core/prefs.js'
|
|
18
18
|
import { createTerminalHub } from './core/terminals.js'
|
|
19
19
|
import { createLocalFs } from './core/localfs.js'
|
|
20
20
|
import { createSecretStore } from './core/secrets.js'
|
|
@@ -59,6 +59,9 @@ export const Config = z.object({
|
|
|
59
59
|
remoteFileReadBytes: z.natural().default(262144),
|
|
60
60
|
remoteFileEditBytes: z.natural().default(2097152),
|
|
61
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),
|
|
62
65
|
remoteGlobMaxResults: z.natural().default(200),
|
|
63
66
|
remoteGlobMaxDepth: z.natural().default(4),
|
|
64
67
|
remoteGrepMaxResults: z.natural().default(200),
|
|
@@ -95,6 +98,7 @@ const APPLY_DEFAULTS = {
|
|
|
95
98
|
remoteFileReadBytes: 262144,
|
|
96
99
|
remoteFileEditBytes: 2097152,
|
|
97
100
|
remoteFileWrite: false,
|
|
101
|
+
systemFileHierarchy: false,
|
|
98
102
|
remoteGlobMaxResults: 200,
|
|
99
103
|
remoteGlobMaxDepth: 4,
|
|
100
104
|
remoteGrepMaxResults: 200,
|
|
@@ -117,7 +121,7 @@ const textOut = {
|
|
|
117
121
|
export { APPLY_DEFAULTS }
|
|
118
122
|
|
|
119
123
|
/** Public settings safe to show in the Client settings/state page. */
|
|
120
|
-
export function publicConfig(config) {
|
|
124
|
+
export function publicConfig(config, prefs) {
|
|
121
125
|
return {
|
|
122
126
|
hostKeyMode: config.hostKeyMode,
|
|
123
127
|
connectTimeoutMs: config.connectTimeoutMs,
|
|
@@ -131,7 +135,8 @@ export function publicConfig(config) {
|
|
|
131
135
|
maxBatchOutputBytes: config.maxBatchOutputBytes,
|
|
132
136
|
remoteFileReadBytes: config.remoteFileReadBytes,
|
|
133
137
|
remoteFileEditBytes: config.remoteFileEditBytes,
|
|
134
|
-
remoteFileWrite: config
|
|
138
|
+
remoteFileWrite: resolveRemoteFileWrite(config, prefs),
|
|
139
|
+
systemFileHierarchy: resolveSystemFileHierarchy(config, prefs),
|
|
135
140
|
remoteGlobMaxResults: config.remoteGlobMaxResults,
|
|
136
141
|
remoteGlobMaxDepth: config.remoteGlobMaxDepth,
|
|
137
142
|
remoteGrepMaxResults: config.remoteGrepMaxResults,
|
|
@@ -353,7 +358,7 @@ export function apply(ctx, config, deps = {}) {
|
|
|
353
358
|
remoteAvailable,
|
|
354
359
|
hostPlatform: hostOs,
|
|
355
360
|
maxContextServers: 20,
|
|
356
|
-
|
|
361
|
+
loadRemoteFileWrite: () => resolveRemoteFileWrite(config, loadPrefs()),
|
|
357
362
|
})
|
|
358
363
|
}
|
|
359
364
|
|
|
@@ -400,7 +405,7 @@ export function apply(ctx, config, deps = {}) {
|
|
|
400
405
|
prefs: { load: () => loadPrefs(), save: (patch) => savePrefs(patch) },
|
|
401
406
|
terminals,
|
|
402
407
|
localfs,
|
|
403
|
-
publicConfig: () => publicConfig(config),
|
|
408
|
+
publicConfig: () => publicConfig(config, loadPrefs()),
|
|
404
409
|
}),
|
|
405
410
|
)
|
|
406
411
|
if (typeof ctx.effect === 'function') {
|
package/lib/routes.js
CHANGED
|
@@ -387,10 +387,24 @@ export function createRoutes({ loadServers, saveServers, runtime, secrets, known
|
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
if (rel === '/prefs') {
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
392
395
|
}
|
|
393
|
-
|
|
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
|
+
})
|
|
394
408
|
}
|
|
395
409
|
|
|
396
410
|
if (rel === '/terminal/open') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ssh-workbench",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.8",
|
|
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": {
|