dsh-ssh-workbench 1.6.4 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +41 -22
- package/SECURITY.md +2 -0
- package/docs/architecture.md +58 -36
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +20 -8
- package/lib/client.js +171 -157
- package/lib/core/activity.js +5 -0
- package/lib/core/broker.js +10 -7
- package/lib/core/dialect.js +1 -1
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +1 -1
- package/lib/core/guard.js +86 -34
- package/lib/core/live-executions.js +101 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +1 -1
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/{remote-bash-tool.js → remote-shell-tool.js} +58 -69
- package/lib/core/secrets.js +0 -1
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +1 -1
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +113 -154
- package/lib/routes.js +28 -2
- package/package.json +7 -6
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-session
|
|
2
|
+
* Per-session resource hints for the composer and the injected context.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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: [],
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
114
|
+
/** Resolve one resource reference against the inventory (name or id). */
|
|
126
115
|
export function findTarget(servers, ref) {
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
version: replayed.
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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,
|
|
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
|
@@ -10,7 +10,7 @@ import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
|
|
|
10
10
|
export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
|
|
11
11
|
|
|
12
12
|
/** Coerce a stored document into the current preference shape. */
|
|
13
|
-
|
|
13
|
+
function normalizePrefs(raw) {
|
|
14
14
|
const prefs = { ...DEFAULT_PREFS }
|
|
15
15
|
if (!raw || typeof raw !== 'object') return prefs
|
|
16
16
|
if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar
|