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.
@@ -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
- 'LOG=$(mktemp /tmp/dsh-ssh-workbench-bg.XXXXXX 2>/dev/null || echo /tmp/dsh-ssh-workbench-bg-$$-$(date +%s).log)',
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
- /** One model-facing description of a started background process. */
54
- export function renderBackgroundStart({ pid, log, errLog, target, command, dialect = 'posix' }) {
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 = [`background: started${target ? ` on ${target}` : ''}`, `command: ${command}`]
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 by config: remote_write/remote_edit will refuse. Use the workbench SFTP panel for uploads, or ask the user to enable remoteFileWrite.',
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 sidebar switch can change them
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, task.adapter.wrapTimeout(task.remoteCommand, task.timeoutMs, task.capability, {
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
- `duration: ${outcome.durationMs}ms`,
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. This tool is foreground-only; use remote_bash/remote_pwsh for a detached process. ' +
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?.remoteFileWrite !== true) {
160
+ if (resolveRemoteFileWrite(config, loadPrefs()) !== true) {
156
161
  throw new Error(
157
- 'remote file writes are disabled by configuration (remoteFileWrite=false); enable them in the plugin config before using remote_write or remote_edit',
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 remoteFileWrite to be enabled in the plugin config. The parent directory must already exist; the file is created or overwritten.',
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.sftpWrite(server, path, content)
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 remoteFileWrite to be enabled.',
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.sftpWrite(server, path, next)
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.remoteFileWrite === true,
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
- remoteFileWrite: config.remoteFileWrite === true,
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
- if (typeof body.sidebar !== 'boolean') {
391
- return send(res, 400, { ok: false, error: 'prefs.sidebar must be a boolean' })
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
- return send(res, 200, { ok: true, prefs: prefsApi.save({ sidebar: body.sidebar }) })
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.0",
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": {