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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Per-turn managed-resource context.
3
+ *
4
+ * The composer inserts an `@name` chip as plain text. This module treats it as a
5
+ * hint, not an execution binding: every remote tool call names its own server.
6
+ * This module injects one compact inventory + tool-rules message per human
7
+ * turn, so the model never has to read `servers.json` or switch a hidden
8
+ * session target before acting.
9
+ *
10
+ * The message enters the same step (`agent/pre-step` replaces the messages that
11
+ * enter a step), which `agent.inject()` cannot promise because it splices into
12
+ * the next step.
13
+ *
14
+ * Scope: every agent mode; no preset gating.
15
+ */
16
+ import { randomUUID } from 'node:crypto'
17
+
18
+ import { effectiveDialect } from './dialect.js'
19
+ import { loadServers } from './store.js'
20
+ import {
21
+ TARGET_CONTEXT_KIND,
22
+ createTargetStore,
23
+ extractServerMentions,
24
+ extractUserText,
25
+ lastHumanMessage,
26
+ } from './mentions.js'
27
+
28
+ export { TARGET_CONTEXT_KIND }
29
+
30
+ const DEFAULT_MAX_CONTEXT_SERVERS = 20
31
+
32
+ function describeError(error) {
33
+ return error instanceof Error ? error.message : String(error)
34
+ }
35
+
36
+ function serverLine(server) {
37
+ const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
38
+ const state = server.enabled === false ? ' · disabled' : ''
39
+ const dialect = effectiveDialect(server)
40
+ return `- ${server.name} — ${target} · ${dialect}${state}${factsSummary(server)}`
41
+ }
42
+
43
+ /** Compact facts summary (hostname only: stable, not a stale capacity snapshot). */
44
+ function factsSummary(server) {
45
+ const facts = server?.facts
46
+ if (!facts || typeof facts !== 'object') return ''
47
+ const hostname = typeof facts.hostname === 'string' ? facts.hostname.trim() : ''
48
+ return hostname ? ` · ${hostname}` : ''
49
+ }
50
+
51
+ function localShellName(hostPlatform) {
52
+ return hostPlatform === 'win32' ? 'pwsh' : 'bash'
53
+ }
54
+
55
+ /**
56
+ * Render the model-facing managed-resource context.
57
+ *
58
+ * @param {object[]} servers managed inventory
59
+ * @param {{
60
+ * candidates?: string[],
61
+ * lastExecuted?: string|null,
62
+ * hostPlatform?: 'win32'|'posix',
63
+ * maxServers?: number,
64
+ * }} [hints]
65
+ */
66
+ export function renderManagedContext(servers, hints = {}) {
67
+ const inventory = Array.isArray(servers) ? servers.filter(Boolean) : []
68
+ if (inventory.length === 0) return ''
69
+ const maxServers = Number.isInteger(hints.maxServers) && hints.maxServers > 0
70
+ ? hints.maxServers
71
+ : DEFAULT_MAX_CONTEXT_SERVERS
72
+ const listed = inventory.slice(0, maxServers)
73
+ const lines = [`[ssh-workbench] Managed remote resources (${inventory.length}):`]
74
+ for (const server of listed) lines.push(serverLine(server))
75
+ if (inventory.length > listed.length) {
76
+ lines.push(`- … and ${inventory.length - listed.length} more; call remote_exec with a resource name from the workbench UI.`)
77
+ }
78
+ lines.push('Remote execution rules:')
79
+ lines.push(
80
+ `- The native ${localShellName(hints.hostPlatform)} tool always runs on the local DSH host. Never use it for commands meant for a remote resource.`,
81
+ )
82
+ lines.push(
83
+ '- POSIX resources: remote_bash({ server: "<name>", command: "<command>", workdir?, timeoutMs?, run_in_background? }).',
84
+ )
85
+ lines.push(
86
+ '- Windows resources: remote_pwsh({ server: "<name>", command: "<command>", ... }).',
87
+ )
88
+ lines.push(
89
+ '- Several resources in one step: remote_exec({ tasks: [{ server: "<name>", command: "<command>" }], concurrency? }). Tasks run concurrently and never depend on a remembered target.',
90
+ )
91
+ lines.push(
92
+ '- Pass server explicitly on every call. The resource names above are authoritative; never read ~/.dsh/ssh-workbench/servers.json or other config files to find them.',
93
+ )
94
+ lines.push(
95
+ '- read/write/edit/glob/grep are the native local file tools; they never touch a remote resource.',
96
+ )
97
+ lines.push(
98
+ '- Remote files: remote_read({ server, path, maxBytes? }), remote_glob({ server, pattern, path?, maxResults?, maxDepth? }) and remote_grep({ server, pattern, path?, ignoreCase?, maxResults? }). Use absolute paths or paths under ~.',
99
+ )
100
+ if (hints.remoteFileWrite === true) {
101
+ lines.push(
102
+ '- Remote writes are enabled: remote_write({ server, path, content }) and remote_edit({ server, path, oldText, newText, replaceAll? }). remote_edit requires one exact match unless replaceAll=true.',
103
+ )
104
+ } else {
105
+ lines.push(
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
+ )
108
+ }
109
+ const candidates = Array.isArray(hints.candidates) ? hints.candidates.filter(Boolean) : []
110
+ if (candidates.length > 0) {
111
+ lines.push(
112
+ `- The user mentioned: ${candidates.join(', ')}. Prefer these resources when the request is ambiguous, but still pass server explicitly.`,
113
+ )
114
+ }
115
+ const lastExecuted = typeof hints.lastExecuted === 'string' && hints.lastExecuted.length > 0
116
+ ? hints.lastExecuted
117
+ : null
118
+ if (lastExecuted) lines.push(`- Last remote execution in this conversation: ${lastExecuted}.`)
119
+ lines.push(
120
+ '- If no resource is named and the request does not identify one, ask the user or use the resource named in the request; do not guess silently.',
121
+ )
122
+ return lines.join('\n')
123
+ }
124
+
125
+ /**
126
+ * Build one user-role context message, preferring the host's message factory.
127
+ * `@` symbols are avoided so this message can never be re-matched as a mention.
128
+ */
129
+ export function buildManagedContextMessage(servers, hints, createUserMessage) {
130
+ const text = renderManagedContext(servers, hints)
131
+ const source = {
132
+ kind: TARGET_CONTEXT_KIND,
133
+ form: 'notice',
134
+ summary: 'managed remote resources',
135
+ }
136
+ const input = { content: [{ type: 'text', text }], source }
137
+ if (typeof createUserMessage === 'function') return createUserMessage(input)
138
+ return { role: 'user', id: `ssh-workbench-context-${randomUUID()}`, ...structuredClone(input) }
139
+ }
140
+
141
+ /**
142
+ * Install the resource-context provider.
143
+ *
144
+ * @param {object} ctx plugin context
145
+ * @param {{
146
+ * enabled?: boolean,
147
+ * remoteAvailable?: boolean,
148
+ * hostPlatform?: 'win32'|'posix',
149
+ * targets?: object,
150
+ * loadInventory?: () => object[],
151
+ * loadLastExecuted?: (sessionId: string) => string|null,
152
+ * remoteFileWrite?: boolean,
153
+ * loadRemoteFileWrite?: () => boolean,
154
+ * createUserMessage?: Function,
155
+ * }} [options]
156
+ */
157
+ export function installManagedContext(ctx, options = {}) {
158
+ const enabled = options.enabled !== false
159
+ const remoteAvailable = options.remoteAvailable !== false
160
+ const loadInventory = options.loadInventory ?? loadServers
161
+ const targets = options.targets ?? createTargetStore({ loadInventory })
162
+ const maxContextServers = Number.isInteger(options.maxContextServers) && options.maxContextServers > 0
163
+ ? options.maxContextServers
164
+ : DEFAULT_MAX_CONTEXT_SERVERS
165
+ const warn = (message) => {
166
+ try {
167
+ ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
168
+ } catch {
169
+ // Logging must never fail the plugin.
170
+ }
171
+ }
172
+ if (!ctx || typeof ctx.on !== 'function') return { targets }
173
+
174
+ let factory = options.createUserMessage
175
+ const messageFactory = async () => {
176
+ if (factory !== undefined) return factory
177
+ try {
178
+ const mod = await import('@deepseek-ai/dsh-llm')
179
+ factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
180
+ } catch {
181
+ factory = null
182
+ }
183
+ return factory
184
+ }
185
+
186
+ const handler = async (payload, next) => {
187
+ const decision = await next()
188
+ try {
189
+ if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
190
+ if (enabled === false) return decision
191
+ // Never describe a remote world the Host cannot execute in.
192
+ if (!remoteAvailable) return decision
193
+ const agent = payload?.agent
194
+ const session = agent?.session
195
+ const key = session?.id ?? agent?.id
196
+ if (typeof key !== 'string' || key.length === 0) return decision
197
+ let servers
198
+ try {
199
+ servers = loadInventory()
200
+ } catch (error) {
201
+ warn(`inventory unavailable: ${describeError(error)}`)
202
+ return decision
203
+ }
204
+ if (!Array.isArray(servers) || servers.length === 0) return decision
205
+ // Restore resumed-Session mention hints once; deriveMessages() is
206
+ // intentionally skipped on later steps.
207
+ if (targets.get(key)?.seeded !== true) {
208
+ try {
209
+ const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
210
+ targets.ensureSeeded(key, history, servers)
211
+ } catch {
212
+ targets.ensureSeeded(key, [], servers)
213
+ }
214
+ }
215
+ const human = lastHumanMessage(decision.messages)
216
+ const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
217
+ const state = targets.bindMentions(key, mentions, human?.id)
218
+ if (!state) return decision
219
+ const changed = state.version !== state.injectedVersion
220
+ const firstStep = payload?.step === 1
221
+ if (!changed) {
222
+ if (!firstStep) return decision
223
+ // One context message per turn is enough while it is still present in
224
+ // the step messages. Re-inject after compaction or a new human turn.
225
+ const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
226
+ if (hasContext) return decision
227
+ }
228
+ let lastExecuted = null
229
+ try {
230
+ lastExecuted = typeof options.loadLastExecuted === 'function' ? options.loadLastExecuted(key) : null
231
+ } catch {
232
+ lastExecuted = null
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
+ }
242
+ const hints = {
243
+ candidates: state.candidates,
244
+ lastExecuted: typeof lastExecuted === 'string' && lastExecuted.length > 0 ? lastExecuted : null,
245
+ hostPlatform: options.hostPlatform,
246
+ maxServers: maxContextServers,
247
+ remoteFileWrite,
248
+ }
249
+ const message = buildManagedContextMessage(servers, hints, await messageFactory())
250
+ targets.markInjected(key, state.version)
251
+ return { ...decision, messages: [...decision.messages, message] }
252
+ } catch (error) {
253
+ warn(`resource context skipped: ${describeError(error)}`)
254
+ return decision
255
+ }
256
+ }
257
+
258
+ const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
259
+ const offDisposed = ctx.on('agent/disposed', (disposed) => {
260
+ try {
261
+ const session = disposed?.agent?.session
262
+ const key = session?.id ?? disposed?.agent?.id
263
+ if (typeof key === 'string') targets.clear(key)
264
+ } catch {
265
+ // Best effort cleanup.
266
+ }
267
+ })
268
+ if (typeof ctx.effect === 'function') {
269
+ ctx.effect(
270
+ () => () => {
271
+ try {
272
+ offPreStep?.()
273
+ } catch {
274
+ // Disposal is best effort.
275
+ }
276
+ try {
277
+ offDisposed?.()
278
+ } catch {
279
+ // Disposal is best effort.
280
+ }
281
+ targets.clearAll?.()
282
+ },
283
+ 'dsh-ssh-workbench: resource context',
284
+ )
285
+ }
286
+ return { targets, offPreStep, offDisposed }
287
+ }
@@ -1,26 +1,18 @@
1
1
  /**
2
- * Per-session remote target binding.
2
+ * Per-session resource hints for the composer and the injected context.
3
3
  *
4
- * A session owns at most one *active* target: the managed server its native
5
- * `bash` tool runs on. `@name` mentions in a human message are the primary
6
- * input; the `ssh_workbench_target` tool switches the active target explicitly so a
7
- * conversation that mentions several servers stays unambiguous.
8
- *
9
- * State is process-local and keyed by Session id. It is a derived cache, not a
10
- * source of truth: the durable input is the human messages in the Session log,
11
- * which `replayTargets()` folds back in when a Session resumes.
4
+ * Every remote tool call carries its own `server`, so this store only
5
+ * remembers which managed resources the human mentioned with `@name` in the
6
+ * current conversation. It is a hint for the model and the UI, never an
7
+ * execution binding. The durable input is still the Session log, which
8
+ * `replayTargets()` folds back in after a resume.
12
9
  */
13
10
  import { loadServers } from './store.js'
11
+ import { findServerRef } from './server-ref.js'
14
12
 
15
- /** Tool name the model calls to switch targets. */
16
- export const TARGET_TOOL_NAME = 'ssh_workbench_target'
17
-
18
- /** Source kind recorded on the injected target-context message. */
13
+ /** Source kind recorded on the injected resource-context message. */
19
14
  export const TARGET_CONTEXT_KIND = 'ssh-workbench-target'
20
15
 
21
- /** The sentinel that detaches the session from any remote target. */
22
- export const LOCAL_TARGET = 'local'
23
-
24
16
  /** Concatenate the text blocks of one message. */
25
17
  export function extractUserText(message) {
26
18
  if (!message || message.role !== 'user' || !Array.isArray(message.content)) return ''
@@ -85,52 +77,47 @@ function sameList(left, right) {
85
77
  return left.every((value, index) => value === right[index])
86
78
  }
87
79
 
80
+ function mentionNames(mentions) {
81
+ const names = []
82
+ for (const server of Array.isArray(mentions) ? mentions : []) {
83
+ const name = typeof server?.name === 'string' ? server.name : ''
84
+ if (name && !names.includes(name)) names.push(name)
85
+ }
86
+ return names
87
+ }
88
+
88
89
  function freshState() {
89
- return { candidates: [], active: null, version: 0, injectedVersion: -1, notifiedVersion: -1, seeded: false }
90
+ return { candidates: [], lastHumanId: null, version: 0, injectedVersion: -1, seeded: false }
90
91
  }
91
92
 
92
- /**
93
- * Fold the mention rules over one session state.
94
- *
95
- * - one mention → that server becomes the active target;
96
- * - several mentions → candidates are remembered, active target is cleared;
97
- * - no mention → state is unchanged (a follow-up keeps its target).
98
- */
93
+ /** Remember the latest @mentions. No target is selected or cleared. */
99
94
  export function applyMentions(state, mentions) {
100
- if (!Array.isArray(mentions) || mentions.length === 0) return state
101
- const names = mentions.map((server) => server.name)
102
- if (mentions.length === 1) {
103
- if (state.active === names[0] && sameList(state.candidates, names)) return state
104
- return { ...state, candidates: names, active: names[0], version: state.version + 1 }
105
- }
106
- // Several mentions never pick a target by guesswork; the model must call the
107
- // selector tool. The previous active target is dropped for the same reason.
108
- const active = null
109
- if (active === state.active && sameList(state.candidates, names)) return state
110
- return { ...state, candidates: names, active, version: state.version + 1 }
95
+ const names = mentionNames(mentions)
96
+ if (names.length === 0) return state
97
+ if (sameList(state.candidates, names)) return state
98
+ return { ...state, candidates: names, version: state.version + 1 }
111
99
  }
112
100
 
113
- /** Replay the mention rules over a Session's historical messages. */
101
+ /** Replay the mention hints over a Session's historical messages. */
114
102
  export function replayTargets(messages, servers) {
115
103
  let state = freshState()
116
104
  if (Array.isArray(messages)) {
117
105
  for (const message of messages) {
118
106
  if (message?.role !== 'user' || message.source?.kind !== 'user') continue
119
- state = applyMentions(state, extractServerMentions(extractUserText(message), servers))
107
+ const mentions = extractServerMentions(extractUserText(message), servers)
108
+ if (mentions.length > 0) state = applyMentions(state, mentions)
120
109
  }
121
110
  }
122
111
  return state
123
112
  }
124
113
 
125
- /** Resolve one target reference against the inventory (name or id). */
114
+ /** Resolve one resource reference against the inventory (name or id). */
126
115
  export function findTarget(servers, ref) {
127
- const value = String(ref ?? '').trim()
128
- if (!value) return undefined
129
- return servers.find((server) => server.id === value || server.name === value)
116
+ return findServerRef(servers, ref)
130
117
  }
131
118
 
132
119
  /**
133
- * Create the per-session target store.
120
+ * Create the per-session resource-hint store.
134
121
  * @param {{ loadInventory?: () => object[] }} [options]
135
122
  */
136
123
  export function createTargetStore(options = {}) {
@@ -167,52 +154,42 @@ export function createTargetStore(options = {}) {
167
154
  const state = ensure(key)
168
155
  if (state === undefined || state.seeded) return state
169
156
  const replayed = replayTargets(messages, servers)
157
+ const history = Array.isArray(messages) ? messages : []
158
+ const lastHuman = [...history].reverse().find(
159
+ (message) => message?.role === 'user' && message.source?.kind === 'user',
160
+ )
170
161
  const next = {
171
162
  ...state,
172
163
  candidates: replayed.candidates,
173
- active: replayed.active,
174
- version: replayed.version,
164
+ lastHumanId: typeof lastHuman?.id === 'string' ? lastHuman.id : state.lastHumanId,
165
+ version: replayed.candidates.length > 0 ? 1 : 0,
175
166
  seeded: true,
176
167
  }
177
168
  sessions.set(sessionKey(key), next)
178
169
  return next
179
170
  }
180
171
 
181
- function bindMentions(key, mentions) {
172
+ /**
173
+ * Fold one human message into the hint state.
174
+ * A new human message always bumps `version` so the per-turn resource
175
+ * context is injected even when the message contains no @mention.
176
+ */
177
+ function bindMentions(key, mentions, humanId) {
182
178
  const state = ensure(key)
183
179
  if (state === undefined) return undefined
184
- const next = { ...applyMentions(state, mentions), seeded: true }
185
- if (next !== state) sessions.set(sessionKey(key), next)
186
- return next
187
- }
188
-
189
- /** Switch the active target explicitly. `local` detaches. */
190
- function setActive(key, ref, servers = loadInventory()) {
191
- const state = ensure(key)
192
- if (state === undefined) return { ok: false, error: 'session is not addressable' }
193
- if (String(ref).trim().toLowerCase() === LOCAL_TARGET) {
194
- // Detaching also clears any pending @a @b ambiguity, so bash is plainly
195
- // local again instead of staying blocked on "choose a target".
196
- const next = { ...state, active: null, candidates: [], version: state.version + 1, seeded: true }
197
- sessions.set(sessionKey(key), next)
198
- return { ok: true, state: next }
199
- }
200
- const server = findTarget(servers, ref)
201
- if (server === undefined) {
202
- return { ok: false, error: `unknown server "${String(ref)}"`, state }
203
- }
204
- if (server.enabled === false) {
205
- return { ok: false, error: `server "${server.name}" is disabled`, state }
206
- }
180
+ const newHuman = typeof humanId === 'string' && humanId.length > 0 && humanId !== state.lastHumanId
181
+ const names = mentionNames(mentions)
182
+ const changedMentions = names.length > 0 && !sameList(state.candidates, names)
183
+ if (!newHuman && !changedMentions) return state
207
184
  const next = {
208
185
  ...state,
209
- active: server.name,
210
- candidates: state.candidates.includes(server.name) ? state.candidates : [server.name],
186
+ candidates: names.length > 0 ? names : state.candidates,
187
+ lastHumanId: typeof humanId === 'string' && humanId.length > 0 ? humanId : state.lastHumanId,
211
188
  version: state.version + 1,
212
189
  seeded: true,
213
190
  }
214
191
  sessions.set(sessionKey(key), next)
215
- return { ok: true, state: next, server }
192
+ return next
216
193
  }
217
194
 
218
195
  function markInjected(key, version) {
@@ -222,17 +199,6 @@ export function createTargetStore(options = {}) {
222
199
  sessions.set(sessionKey(key), { ...state, injectedVersion: version })
223
200
  }
224
201
 
225
- /**
226
- * Remember that the bash router already showed the target to the model/UI
227
- * (one stderr line after each target change), so it is not repeated per call.
228
- */
229
- function markNotified(key, version) {
230
- const state = get(key)
231
- if (state === undefined) return
232
- if (state.notifiedVersion === version) return
233
- sessions.set(sessionKey(key), { ...state, notifiedVersion: version })
234
- }
235
-
236
202
  function clear(key) {
237
203
  const sessionId = sessionKey(key)
238
204
  if (sessionId !== undefined) sessions.delete(sessionId)
@@ -246,5 +212,5 @@ export function createTargetStore(options = {}) {
246
212
  return sessions.size
247
213
  }
248
214
 
249
- return { get, ensure, ensureSeeded, bindMentions, setActive, markInjected, markNotified, clear, clearAll, size, loadInventory }
215
+ return { get, ensure, ensureSeeded, bindMentions, markInjected, clear, clearAll, size, loadInventory }
250
216
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared POSIX remote-shell helpers.
3
+ *
4
+ * Pure command construction used by `remote_bash` and `remote_exec`; the
5
+ * native local `bash` tool never reaches this module.
6
+ */
7
+ import { quotePosix } from './posix-quote.js'
8
+
9
+ const MIN_TIMEOUT_MS = 1000
10
+
11
+ /**
12
+ * Wrap a remote command with the remote `timeout` utility.
13
+ *
14
+ * A client-side timeout only closes the SSH channel; a process that ignores
15
+ * SIGHUP (nohup/background) keeps running on the server. Wrapping the command
16
+ * gives the remote side the same deadline: coreutils `timeout` runs the
17
+ * managed command in its own process group and signals the whole group, so
18
+ * when the client gives up the remote work stops too. `kill` adds `-k <n>`
19
+ * (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
20
+ * capability) leaves the command untouched.
21
+ *
22
+ * The wrapper re-enters the remote login shell (`<shell> -c`) so shell
23
+ * semantics do not change. The shell path comes from the capability probe and
24
+ * is referenced as a quoted literal, so this also works on fish/csh (no POSIX
25
+ * `${SHELL:-...}` expansion). The command itself is single-quote escaped and
26
+ * therefore expanded exactly once, inside that shell.
27
+ *
28
+ * @param {string} command
29
+ * @param {number} timeoutMs
30
+ * @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
31
+ * @param {{ killAfterMs?: number }} [options]
32
+ */
33
+ export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
34
+ const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
35
+ const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
36
+ if (mode === 'none') return String(command ?? '')
37
+ const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
38
+ const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
39
+ const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
40
+ const seconds = Math.max(1, Number(timeoutMs) / 1000)
41
+ const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
42
+ return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
43
+ }
44
+
45
+ /**
46
+ * Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
47
+ *
48
+ * The remote shell starts in the login home; absolute paths are used verbatim,
49
+ * `~`/`~/x` and relative paths are anchored at `$HOME`.
50
+ *
51
+ * @returns {{ command: string, cwd: string } | { error: string }}
52
+ */
53
+ export function applyWorkdir(command, workdir) {
54
+ const raw = typeof workdir === 'string' ? workdir.trim() : ''
55
+ const body = String(command ?? '')
56
+ if (!raw) return { command: body, cwd: '~' }
57
+ if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
58
+ if (raw.startsWith('~')) {
59
+ const rest = raw === '~' ? '' : raw.slice(1)
60
+ if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
61
+ const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
62
+ return { command: `cd -- ${target} && ${body}`, cwd: raw }
63
+ }
64
+ if (raw.startsWith('/')) {
65
+ return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
66
+ }
67
+ return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
68
+ }
69
+
70
+ /** Effective timeout for one call, clamped to the configured ceiling. */
71
+ export function resolveTimeoutMs(args, config) {
72
+ const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
73
+ const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
74
+ const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
75
+ const value = requested ?? fallback
76
+ return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
77
+ }
78
+
79
+ /**
80
+ * Whether a runtime result means the command never reached the remote shell.
81
+ * Timeouts and aborts are real command outcomes (timedOut / aborted on the
82
+ * native value), not transport failures.
83
+ */
84
+ export function isTransportFailure(result) {
85
+ if (!result || typeof result !== 'object') return true
86
+ if (result.exitCode !== null && result.exitCode !== undefined) return false
87
+ if (typeof result.signal === 'string' && result.signal.length > 0) return false
88
+ return result.outcome === 'failed'
89
+ }
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
- export function normalizePrefs(raw) {
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
+ }