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
package/lib/core/guard.js
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fallback blacklist for remote
|
|
2
|
+
* Fallback blacklist for remote commands.
|
|
3
3
|
*
|
|
4
4
|
* Registered through `ctx.tools.guard()`: synchronous, monotonic, and checked
|
|
5
5
|
* after DSH's own pre-execute policy and approval, so no later listener can
|
|
6
|
-
* turn a denial back into a permission.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* turn a denial back into a permission.
|
|
7
|
+
*
|
|
8
|
+
* There is no session-scoped target state: every remote tool names its own
|
|
9
|
+
* `server`, so the guard resolves that explicit reference. The native local
|
|
10
|
+
* `bash`/`pwsh` tools are never inspected — local work is outside this
|
|
11
|
+
* plugin's remote-command boundary.
|
|
11
12
|
*/
|
|
12
13
|
import { classifyCommand } from './gate.js'
|
|
13
14
|
import { classifyPowerShellCommand } from './gate-pwsh.js'
|
|
15
|
+
import { findServerRef } from './server-ref.js'
|
|
14
16
|
|
|
15
|
-
/** The plugin-owned POSIX shell tool
|
|
16
|
-
|
|
17
|
+
/** The plugin-owned POSIX shell tool. */
|
|
18
|
+
const REMOTE_BASH_TOOL_NAME = 'remote_bash'
|
|
17
19
|
|
|
18
20
|
/** The plugin-owned PowerShell tool (Windows targets). */
|
|
19
|
-
|
|
21
|
+
const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
const
|
|
23
|
+
/** Explicit-target batch tool: every task names its own server. */
|
|
24
|
+
const REMOTE_EXEC_TOOL_NAME = 'remote_exec'
|
|
23
25
|
|
|
24
26
|
export const BLOCK_REASON_PREFIX = 'dsh-ssh-workbench blocked this command: '
|
|
25
27
|
|
|
@@ -31,38 +33,88 @@ export function sessionKeyOf(exec) {
|
|
|
31
33
|
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Pick the gate for one explicit server. A record's `platform` is only a hint:
|
|
38
|
+
* the executor probes the real dialect before running. When facts are not
|
|
39
|
+
* collected yet, run both blacklists so a stale hint cannot hide a
|
|
40
|
+
* Windows-only destructive command (and vice versa).
|
|
41
|
+
*/
|
|
42
|
+
function classifyForServer(server, command) {
|
|
43
|
+
const dialect = server?.facts?.dialect
|
|
44
|
+
if (dialect === 'windows') return classifyPowerShellCommand(command)
|
|
45
|
+
if (dialect === 'posix') return classifyCommand(command)
|
|
46
|
+
const posix = classifyCommand(command)
|
|
47
|
+
if (posix.decision === 'deny') return posix
|
|
48
|
+
return classifyPowerShellCommand(command)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function summaryOf(prefix, command) {
|
|
52
|
+
const text = `${prefix}${command}`.slice(0, 200)
|
|
53
|
+
return text
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
57
|
* @param {object} deps
|
|
36
|
-
* @param {object} deps.targets per-session target store
|
|
37
58
|
* @param {(entry: object) => void} [deps.recordActivity]
|
|
59
|
+
* @param {() => object[]} [deps.loadInventory]
|
|
38
60
|
* @returns {(exec: object) => string|undefined} a `ctx.tools.guard()` handler
|
|
39
61
|
*/
|
|
40
|
-
export function createBlacklistGuard({
|
|
62
|
+
export function createBlacklistGuard({ recordActivity, loadInventory } = {}) {
|
|
63
|
+
function inventory() {
|
|
64
|
+
if (typeof loadInventory !== 'function') return undefined
|
|
65
|
+
try {
|
|
66
|
+
const servers = loadInventory()
|
|
67
|
+
return Array.isArray(servers) ? servers : undefined
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function guardOne(exec, serverRef, command, label = '') {
|
|
74
|
+
const ref = typeof serverRef === 'string' ? serverRef.trim() : ''
|
|
75
|
+
if (!ref || ref.toLowerCase() === 'local') return undefined
|
|
76
|
+
if (!command.trim()) return undefined
|
|
77
|
+
const servers = inventory()
|
|
78
|
+
if (servers === undefined) return undefined
|
|
79
|
+
const server = findServerRef(servers, ref)
|
|
80
|
+
if (server === undefined) return undefined
|
|
81
|
+
const classification = classifyForServer(server, command)
|
|
82
|
+
if (classification.decision !== 'deny') return undefined
|
|
83
|
+
try {
|
|
84
|
+
recordActivity?.({
|
|
85
|
+
kind: 'exec',
|
|
86
|
+
server: server.name,
|
|
87
|
+
sessionId: sessionKeyOf(exec),
|
|
88
|
+
summary: summaryOf(label, command),
|
|
89
|
+
outcome: `blocked: ${classification.ruleId}`,
|
|
90
|
+
})
|
|
91
|
+
} catch {
|
|
92
|
+
// Activity recording is best effort and must never change the decision.
|
|
93
|
+
}
|
|
94
|
+
return `${BLOCK_REASON_PREFIX}${label}${classification.reason}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Check every task independently; one denied task denies the whole batch. */
|
|
98
|
+
function guardBatch(exec) {
|
|
99
|
+
const tasks = exec.arguments?.tasks
|
|
100
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return undefined
|
|
101
|
+
for (let index = 0; index < tasks.length; index += 1) {
|
|
102
|
+
const task = tasks[index]
|
|
103
|
+
if (!task || typeof task !== 'object') continue
|
|
104
|
+
const command = typeof task.command === 'string' ? task.command : ''
|
|
105
|
+
const denied = guardOne(exec, task.server, command, `task ${index + 1}: `)
|
|
106
|
+
if (denied !== undefined) return denied
|
|
107
|
+
}
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
41
111
|
return function blacklistGuard(exec) {
|
|
42
112
|
try {
|
|
43
113
|
if (!exec) return undefined
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!isPosix && !isPowerShell) return undefined
|
|
47
|
-
const key = sessionKeyOf(exec)
|
|
48
|
-
if (key === undefined || !targets) return undefined
|
|
49
|
-
const binding = targets.get(key)
|
|
50
|
-
if (!binding || !binding.active) return undefined
|
|
114
|
+
if (exec.name === REMOTE_EXEC_TOOL_NAME) return guardBatch(exec)
|
|
115
|
+
if (exec.name !== REMOTE_BASH_TOOL_NAME && exec.name !== REMOTE_PWSH_TOOL_NAME) return undefined
|
|
51
116
|
const command = typeof exec.arguments?.command === 'string' ? exec.arguments.command : ''
|
|
52
|
-
|
|
53
|
-
const classification = isPowerShell ? classifyPowerShellCommand(command) : classifyCommand(command)
|
|
54
|
-
if (classification.decision !== 'deny') return undefined
|
|
55
|
-
try {
|
|
56
|
-
recordActivity?.({
|
|
57
|
-
kind: 'exec',
|
|
58
|
-
server: binding.active,
|
|
59
|
-
summary: command.slice(0, 200),
|
|
60
|
-
outcome: `blocked: ${classification.ruleId}`,
|
|
61
|
-
})
|
|
62
|
-
} catch {
|
|
63
|
-
// Activity recording is best effort and must never change the decision.
|
|
64
|
-
}
|
|
65
|
-
return `${BLOCK_REASON_PREFIX}${classification.reason}`
|
|
117
|
+
return guardOne(exec, exec.arguments?.server, command)
|
|
66
118
|
} catch {
|
|
67
119
|
// A guard must never turn its own failure into a denial of unrelated tools.
|
|
68
120
|
return undefined
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight remote executions, for the composer's "current target" indicator.
|
|
3
|
+
*
|
|
4
|
+
* This is a display-only projection. It never selects a target, never reaches
|
|
5
|
+
* a tool, and disappears as soon as the dispatch settles. Every remote tool is
|
|
6
|
+
* explicit-server, so this map is the only place where the UI can learn what a
|
|
7
|
+
* running tool is touching.
|
|
8
|
+
*/
|
|
9
|
+
import { sessionKeyOf } from './remote-shell-tool.js'
|
|
10
|
+
import {
|
|
11
|
+
REMOTE_EDIT_TOOL_NAME,
|
|
12
|
+
REMOTE_GLOB_TOOL_NAME,
|
|
13
|
+
REMOTE_GREP_TOOL_NAME,
|
|
14
|
+
REMOTE_READ_TOOL_NAME,
|
|
15
|
+
REMOTE_WRITE_TOOL_NAME,
|
|
16
|
+
} from './remote-file-tools.js'
|
|
17
|
+
import { REMOTE_EXEC_TOOL_NAME } from './remote-exec-tool.js'
|
|
18
|
+
|
|
19
|
+
const SINGLE_SERVER_TOOLS = new Set([
|
|
20
|
+
'remote_bash',
|
|
21
|
+
'remote_pwsh',
|
|
22
|
+
REMOTE_READ_TOOL_NAME,
|
|
23
|
+
REMOTE_WRITE_TOOL_NAME,
|
|
24
|
+
REMOTE_EDIT_TOOL_NAME,
|
|
25
|
+
REMOTE_GLOB_TOOL_NAME,
|
|
26
|
+
REMOTE_GREP_TOOL_NAME,
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/** Extract the explicit server names a remote tool call will touch. */
|
|
30
|
+
export function serversOf(exec) {
|
|
31
|
+
const name = exec?.name
|
|
32
|
+
const args = exec?.arguments
|
|
33
|
+
if (!name || !args || typeof args !== 'object') return []
|
|
34
|
+
if (name === REMOTE_EXEC_TOOL_NAME) {
|
|
35
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : []
|
|
36
|
+
const names = []
|
|
37
|
+
for (const task of tasks) {
|
|
38
|
+
if (task && typeof task.server === 'string' && task.server.length > 0 && !names.includes(task.server)) {
|
|
39
|
+
names.push(task.server)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return names
|
|
43
|
+
}
|
|
44
|
+
if (!SINGLE_SERVER_TOOLS.has(name)) return []
|
|
45
|
+
return typeof args.server === 'string' && args.server.length > 0 ? [args.server] : []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {{ now?: () => number }} [options] */
|
|
49
|
+
export function createLiveExecutions(options = {}) {
|
|
50
|
+
const now = options.now ?? Date.now
|
|
51
|
+
let nextToken = 0
|
|
52
|
+
/** @type {Map<number, { sessionId: string, tool: string, servers: string[], startedAt: number }>} */
|
|
53
|
+
const active = new Map()
|
|
54
|
+
|
|
55
|
+
/** Register one dispatch; returns undefined when this is not a remote call. */
|
|
56
|
+
function begin(exec) {
|
|
57
|
+
const servers = serversOf(exec)
|
|
58
|
+
if (servers.length === 0) return undefined
|
|
59
|
+
const sessionId = sessionKeyOf(exec)
|
|
60
|
+
if (!sessionId) return undefined
|
|
61
|
+
const token = ++nextToken
|
|
62
|
+
active.set(token, {
|
|
63
|
+
sessionId,
|
|
64
|
+
tool: typeof exec.name === 'string' ? exec.name : '',
|
|
65
|
+
servers,
|
|
66
|
+
startedAt: now(),
|
|
67
|
+
})
|
|
68
|
+
return token
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function end(token) {
|
|
72
|
+
if (token !== undefined) active.delete(token)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns {{ count: number, servers: { name: string, count: number, startedAt: number }[] }} */
|
|
76
|
+
function snapshot(sessionId) {
|
|
77
|
+
const byName = new Map()
|
|
78
|
+
let calls = 0
|
|
79
|
+
for (const entry of active.values()) {
|
|
80
|
+
if (entry.sessionId !== sessionId) continue
|
|
81
|
+
calls += 1
|
|
82
|
+
for (const name of entry.servers) {
|
|
83
|
+
const previous = byName.get(name)
|
|
84
|
+
byName.set(name, {
|
|
85
|
+
name,
|
|
86
|
+
count: (previous?.count ?? 0) + 1,
|
|
87
|
+
startedAt: previous ? Math.min(previous.startedAt, entry.startedAt) : entry.startedAt,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { count: calls, servers: [...byName.values()] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function clear(sessionId) {
|
|
95
|
+
for (const [token, entry] of active) {
|
|
96
|
+
if (sessionId === undefined || entry.sessionId === sessionId) active.delete(token)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { begin, end, snapshot, clear, size: () => active.size }
|
|
101
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
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 by config: remote_write/remote_edit will refuse. Use the workbench SFTP panel for uploads, or ask the user to enable remoteFileWrite.',
|
|
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
|
+
* createUserMessage?: Function,
|
|
153
|
+
* }} [options]
|
|
154
|
+
*/
|
|
155
|
+
export function installManagedContext(ctx, options = {}) {
|
|
156
|
+
const enabled = options.enabled !== false
|
|
157
|
+
const remoteAvailable = options.remoteAvailable !== false
|
|
158
|
+
const loadInventory = options.loadInventory ?? loadServers
|
|
159
|
+
const targets = options.targets ?? createTargetStore({ loadInventory })
|
|
160
|
+
const maxContextServers = Number.isInteger(options.maxContextServers) && options.maxContextServers > 0
|
|
161
|
+
? options.maxContextServers
|
|
162
|
+
: DEFAULT_MAX_CONTEXT_SERVERS
|
|
163
|
+
const warn = (message) => {
|
|
164
|
+
try {
|
|
165
|
+
ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
|
|
166
|
+
} catch {
|
|
167
|
+
// Logging must never fail the plugin.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!ctx || typeof ctx.on !== 'function') return { targets }
|
|
171
|
+
|
|
172
|
+
let factory = options.createUserMessage
|
|
173
|
+
const messageFactory = async () => {
|
|
174
|
+
if (factory !== undefined) return factory
|
|
175
|
+
try {
|
|
176
|
+
const mod = await import('@deepseek-ai/dsh-llm')
|
|
177
|
+
factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
|
|
178
|
+
} catch {
|
|
179
|
+
factory = null
|
|
180
|
+
}
|
|
181
|
+
return factory
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const handler = async (payload, next) => {
|
|
185
|
+
const decision = await next()
|
|
186
|
+
try {
|
|
187
|
+
if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
|
|
188
|
+
if (enabled === false) return decision
|
|
189
|
+
// Never describe a remote world the Host cannot execute in.
|
|
190
|
+
if (!remoteAvailable) return decision
|
|
191
|
+
const agent = payload?.agent
|
|
192
|
+
const session = agent?.session
|
|
193
|
+
const key = session?.id ?? agent?.id
|
|
194
|
+
if (typeof key !== 'string' || key.length === 0) return decision
|
|
195
|
+
let servers
|
|
196
|
+
try {
|
|
197
|
+
servers = loadInventory()
|
|
198
|
+
} catch (error) {
|
|
199
|
+
warn(`inventory unavailable: ${describeError(error)}`)
|
|
200
|
+
return decision
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(servers) || servers.length === 0) return decision
|
|
203
|
+
// Restore resumed-Session mention hints once; deriveMessages() is
|
|
204
|
+
// intentionally skipped on later steps.
|
|
205
|
+
if (targets.get(key)?.seeded !== true) {
|
|
206
|
+
try {
|
|
207
|
+
const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
|
|
208
|
+
targets.ensureSeeded(key, history, servers)
|
|
209
|
+
} catch {
|
|
210
|
+
targets.ensureSeeded(key, [], servers)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const human = lastHumanMessage(decision.messages)
|
|
214
|
+
const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
|
|
215
|
+
const state = targets.bindMentions(key, mentions, human?.id)
|
|
216
|
+
if (!state) return decision
|
|
217
|
+
const changed = state.version !== state.injectedVersion
|
|
218
|
+
const firstStep = payload?.step === 1
|
|
219
|
+
if (!changed) {
|
|
220
|
+
if (!firstStep) return decision
|
|
221
|
+
// One context message per turn is enough while it is still present in
|
|
222
|
+
// the step messages. Re-inject after compaction or a new human turn.
|
|
223
|
+
const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
|
|
224
|
+
if (hasContext) return decision
|
|
225
|
+
}
|
|
226
|
+
let lastExecuted = null
|
|
227
|
+
try {
|
|
228
|
+
lastExecuted = typeof options.loadLastExecuted === 'function' ? options.loadLastExecuted(key) : null
|
|
229
|
+
} catch {
|
|
230
|
+
lastExecuted = null
|
|
231
|
+
}
|
|
232
|
+
const hints = {
|
|
233
|
+
candidates: state.candidates,
|
|
234
|
+
lastExecuted: typeof lastExecuted === 'string' && lastExecuted.length > 0 ? lastExecuted : null,
|
|
235
|
+
hostPlatform: options.hostPlatform,
|
|
236
|
+
maxServers: maxContextServers,
|
|
237
|
+
}
|
|
238
|
+
const message = buildManagedContextMessage(servers, hints, await messageFactory())
|
|
239
|
+
targets.markInjected(key, state.version)
|
|
240
|
+
return { ...decision, messages: [...decision.messages, message] }
|
|
241
|
+
} catch (error) {
|
|
242
|
+
warn(`resource context skipped: ${describeError(error)}`)
|
|
243
|
+
return decision
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
|
|
248
|
+
const offDisposed = ctx.on('agent/disposed', (disposed) => {
|
|
249
|
+
try {
|
|
250
|
+
const session = disposed?.agent?.session
|
|
251
|
+
const key = session?.id ?? disposed?.agent?.id
|
|
252
|
+
if (typeof key === 'string') targets.clear(key)
|
|
253
|
+
} catch {
|
|
254
|
+
// Best effort cleanup.
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
if (typeof ctx.effect === 'function') {
|
|
258
|
+
ctx.effect(
|
|
259
|
+
() => () => {
|
|
260
|
+
try {
|
|
261
|
+
offPreStep?.()
|
|
262
|
+
} catch {
|
|
263
|
+
// Disposal is best effort.
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
offDisposed?.()
|
|
267
|
+
} catch {
|
|
268
|
+
// Disposal is best effort.
|
|
269
|
+
}
|
|
270
|
+
targets.clearAll?.()
|
|
271
|
+
},
|
|
272
|
+
'dsh-ssh-workbench: resource context',
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return { targets, offPreStep, offDisposed }
|
|
276
|
+
}
|