dsh-gitbash-shell 0.38.5 → 0.39.1
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 +91 -0
- package/README.md +47 -33
- package/README_EN.md +44 -27
- package/cordis.patch.yml +23 -30
- package/dsh.plugin.json +2 -2
- package/package.json +12 -3
- package/src/bash-path.js +7 -4
- package/src/bash-render.js +239 -0
- package/src/bash-tool.js +589 -0
- package/src/client.js +48 -45
- package/src/compositions.js +3 -3
- package/src/fastctx-bridge.js +49 -10
- package/src/index.js +28 -35
- package/src/inline-tools.js +180 -385
- package/src/shell.js +13 -9
package/src/inline-tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-gitbash-shell/inline-tools — runtime shell-tool patching for the OFFICIAL
|
|
3
|
-
* presets on Windows (v0.35.0).
|
|
3
|
+
* presets on Windows (v0.35.0; route B since v0.39.0).
|
|
4
4
|
*
|
|
5
5
|
* WHY THIS EXISTS. The official preset rows compose their tool set at boot
|
|
6
6
|
* (`tool-bash` disabled on win32, `tool-pwsh` enabled), and the patch layer
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* carry `group: true` (vendor/include/src/index.ts buildMap), while a preset
|
|
9
9
|
* declaration is a plain row whose `config.plugins` is opaque data. Rewriting
|
|
10
10
|
* the declaration's whole `config` would freeze the official row list at our
|
|
11
|
-
* restatement instant
|
|
12
|
-
*
|
|
13
|
-
* official contract explicitly invites per-agent variants:
|
|
11
|
+
* restatement instant. So the fix runs at the tool layer, where the official
|
|
12
|
+
* contract explicitly invites per-agent variants:
|
|
14
13
|
*
|
|
15
14
|
* - the tools registry REJECTS same-scope duplicate names but points at this
|
|
16
15
|
* exact path for variants ("for a per-agent variant, register through that
|
|
@@ -18,36 +17,32 @@
|
|
|
18
17
|
* - `agent/created` fires once per agent with the composed tool face
|
|
19
18
|
* reachable through `ctx.get('tools').schemas(agent)`.
|
|
20
19
|
*
|
|
21
|
-
*
|
|
20
|
+
* ROUTE B (v0.39.0, the DSH-Store-compliant architecture). The plugin no
|
|
21
|
+
* longer replaces the host `ctx.shell` executor, so the OFFICIAL tool-bash
|
|
22
|
+
* module — whose every execution goes through `ctx.shell`, i.e. PowerShell
|
|
23
|
+
* here — is OFF the table. Instead one reconcile per agent reads the face and
|
|
24
|
+
* acts on three facts:
|
|
22
25
|
*
|
|
23
|
-
* 1. `pwsh` present + `bash` absent ->
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* 3. `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
26
|
+
* 1. `pwsh` present + `bash` absent -> register the plugin's OWN `bash`
|
|
27
|
+
* tool (src/bash-tool.js) in an agent-owned scope. It executes through a
|
|
28
|
+
* PRIVATE GitBashSandboxExecutor instance (constructed once per process
|
|
29
|
+
* in a shell-isolated cordis scope, never registered as the ROOT shell), and mirrors
|
|
30
|
+
* the official tool's schema, background jobs, escalation and rendering
|
|
31
|
+
* call-for-call.
|
|
32
|
+
* 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge stands
|
|
33
|
+
* down, the pwsh hiding stands down (dsh-ops owns the host-pwsh story),
|
|
34
|
+
* and a bash tool they already publish means ours is not registered.
|
|
35
|
+
* 3. `pwshTool` switch OFF (the default) -> the REAL host `pwsh` tool is
|
|
36
|
+
* hidden per agent (`tools.restrict`) and denied at the call layer. With
|
|
37
|
+
* route B the host pwsh is REAL PowerShell again — hiding it is a pure
|
|
38
|
+
* product choice (bash-first host), not a safety denial, and flipping
|
|
39
|
+
* the switch surfaces it unchanged.
|
|
35
40
|
*
|
|
36
41
|
* Everything here is Windows-only by caller gate; on other hosts the module
|
|
37
42
|
* loads but mounts nothing.
|
|
38
43
|
*/
|
|
39
44
|
|
|
40
|
-
/**
|
|
41
|
-
* The tool-result contract the HOST registry requires from every registered
|
|
42
|
-
* tool (issue #18, root cause 2): `register()` throws
|
|
43
|
-
* `must declare output { schema, render, presentationMeta? }` unless the
|
|
44
|
-
* definition carries an `output` whose `schema` passes
|
|
45
|
-
* `assertSupportedJsonSchema` and whose `render` is a function. Both of this
|
|
46
|
-
* plugin's tool families return `{ content: [{ type: 'text', text }] }` from
|
|
47
|
-
* `execute`, so the schema and the text renderer below are shared verbatim
|
|
48
|
-
* with the FastCtx bridge — the same frozen shape dsh-ops ships for its own
|
|
49
|
-
* MCP-backed tools (proven on real Windows hosts).
|
|
50
|
-
*/
|
|
45
|
+
/** FastCtx-bridge shared output contract (unchanged since v0.35.0). */
|
|
51
46
|
export const TOOL_OUTPUT_SCHEMA = Object.freeze({
|
|
52
47
|
type: 'object',
|
|
53
48
|
properties: {
|
|
@@ -63,8 +58,6 @@ export const TOOL_OUTPUT_SCHEMA = Object.freeze({
|
|
|
63
58
|
* blocks and non-string text fields are skipped; empty content renders as one
|
|
64
59
|
* empty text block rather than an empty array, so the card never loses its
|
|
65
60
|
* placeholder.
|
|
66
|
-
* @param {unknown} value - the execute return value.
|
|
67
|
-
* @returns {Array<{ type: string, text: string }>} content blocks for the host renderer.
|
|
68
61
|
*/
|
|
69
62
|
export function renderTextContent(value) {
|
|
70
63
|
const blocks = Array.isArray(value?.content) ? value.content : []
|
|
@@ -75,18 +68,25 @@ export function renderTextContent(value) {
|
|
|
75
68
|
return [{ type: 'text', text }]
|
|
76
69
|
}
|
|
77
70
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Answer for one observed tool face. Pure.
|
|
73
|
+
* @param {string[]|undefined} names - the agent's composed tool names.
|
|
74
|
+
* @param {{ pwshVisible?: boolean }} [options] - whether the row switch shows
|
|
75
|
+
* the (real) host pwsh tool; defaults to hidden.
|
|
76
|
+
*/
|
|
77
|
+
export function inlineNeeds(names, { pwshVisible } = {}) {
|
|
80
78
|
const set = new Set(Array.isArray(names) ? names : [])
|
|
81
79
|
const has = (name) => set.has(name)
|
|
82
80
|
return {
|
|
83
81
|
// A pwsh tool without a bash tool is the official win32 composition on
|
|
84
|
-
// this host: bash must be patched in
|
|
85
|
-
// Git Bash through ctx.shell (a lying name) — deny it.
|
|
82
|
+
// this host: our own bash tool must be patched in.
|
|
86
83
|
needsBash: has('pwsh') && !has('bash'),
|
|
87
|
-
|
|
84
|
+
// The real pwsh tool is hidden while the row switch is off (the default):
|
|
85
|
+
// the product answer is bash-first. dsh-ops co-install hands the whole
|
|
86
|
+
// host-shell story over to dsh-ops (checked by the caller).
|
|
87
|
+
hidePwsh: has('pwsh') && pwshVisible !== true,
|
|
88
88
|
// dsh-ops publishes file/search tools under the ops_ prefix. When they are
|
|
89
|
-
// here, our FastCtx bridge
|
|
89
|
+
// here, our FastCtx bridge would be a duplicate.
|
|
90
90
|
opsPresent: has('ops_inspect_local_file') || has('ops_grep') || has('ops_bash'),
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -94,14 +94,14 @@ export function inlineNeeds(names) {
|
|
|
94
94
|
/**
|
|
95
95
|
* The routing addendum appended to the `bash` tool's description through the
|
|
96
96
|
* system-prompt assemble waterfall. TWO variants (v0.37.0, user decision):
|
|
97
|
-
* the pwsh tool is
|
|
98
|
-
* the agent cannot see only teaches the model to call a
|
|
99
|
-
* variant names `pwsh` (the
|
|
100
|
-
*
|
|
101
|
-
* of the path dialect a delegate sees.
|
|
97
|
+
* the pwsh tool is hidden by default, and a routing sentence that names a tool
|
|
98
|
+
* the agent cannot see only teaches the model to call a hidden name. The ON
|
|
99
|
+
* variant names `pwsh` (the REAL PowerShell tool, real again since route B —
|
|
100
|
+
* the official executor stays in place); the OFF variant states bash-only
|
|
101
|
+
* routing. Both hold regardless of the path dialect a delegate sees.
|
|
102
102
|
*/
|
|
103
103
|
export const GITBASH_ROUTE_ADDENDUM = [
|
|
104
|
-
'Prefer this bash tool for ALL commands; use the
|
|
104
|
+
'Prefer this bash tool for ALL commands; use the pwsh tool ONLY when a Windows-native capability (cmdlets, registry, services, WMI) is truly required.',
|
|
105
105
|
'Fix failed bash commands in bash — never switch shells mid-task, never embed `pwsh -Command` inside a bash command (variable expansion and output encoding break there).',
|
|
106
106
|
].join('\n')
|
|
107
107
|
|
|
@@ -121,187 +121,56 @@ export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths
|
|
|
121
121
|
/** @deprecated composed form kept for tests: route + MSYS sentences. */
|
|
122
122
|
export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM
|
|
123
123
|
|
|
124
|
-
/** The pwsh-replacement tool's model-facing description. */
|
|
125
|
-
export function gitbashPwshDescription() {
|
|
126
|
-
return 'Run a command in Windows PowerShell 7 (pwsh). Use ONLY when a Windows-native capability '
|
|
127
|
-
+ '(cmdlets, registry, services, WMI, scheduled tasks) is required — for everything else prefer the bash tool. '
|
|
128
|
-
+ 'Each call uses a fresh shell; set workdir explicitly. Paths inside command must be Windows form (C:/... or C:\\\\...). '
|
|
129
|
-
+ 'For scripts longer than about 1 KB, or containing quotes/backslashes/non-ASCII text, write the script to a file first '
|
|
130
|
-
+ 'and pass script_path instead. Output is UTF-8; long output is truncated with the full text saved to a reported file. '
|
|
131
|
-
+ 'Fix failed pwsh commands here; do not switch shells or mix PowerShell syntax into bash commands.'
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Resolve the host's own PowerShell 7 — never a bundled payload, never
|
|
136
|
-
* Windows PowerShell 5.1. Mirrors the probe order the official pwsh resolver
|
|
137
|
-
* documents (well-known install locations, then PATH), WITHOUT its 5.1
|
|
138
|
-
* fallback: if no pwsh 7 exists the caller simply does not publish the tool.
|
|
139
|
-
* Pure I/O via the injected `exists` so the candidate matrix is testable.
|
|
140
|
-
* @returns {string} an absolute pwsh.exe path, or '' when absent.
|
|
141
|
-
*/
|
|
142
|
-
export function resolveHostPwsh7({ env = process.env, exists, platform = process.platform } = {}) {
|
|
143
|
-
if (platform !== 'win32') return ''
|
|
144
|
-
const programFiles = env.ProgramFiles ?? env.PROGRAMFILES ?? 'C:\\Program Files'
|
|
145
|
-
const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
146
|
-
const localAppData = env.LOCALAPPDATA ?? env.LocalAppData ?? ''
|
|
147
|
-
const userProfile = env.USERPROFILE ?? ''
|
|
148
|
-
const candidates = [
|
|
149
|
-
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
|
150
|
-
`${programFiles}\\PowerShell\\7-preview\\pwsh.exe`,
|
|
151
|
-
`${programFilesX86}\\PowerShell\\7\\pwsh.exe`,
|
|
152
|
-
localAppData === '' ? '' : `${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`,
|
|
153
|
-
userProfile === '' ? '' : `${userProfile}\\scoop\\shims\\pwsh.exe`,
|
|
154
|
-
'C:\\ProgramData\\chocolatey\\bin\\pwsh.exe',
|
|
155
|
-
].filter((entry) => entry !== '')
|
|
156
|
-
for (const candidate of candidates) {
|
|
157
|
-
if (exists(candidate)) return candidate
|
|
158
|
-
}
|
|
159
|
-
// PATH last: a PATH hit may be a store alias or shim, so it ranks below the
|
|
160
|
-
// well-known installs but still beats "no tool at all".
|
|
161
|
-
const pathValue = env.PATH ?? env.Path ?? ''
|
|
162
|
-
for (const dir of pathValue.split(';')) {
|
|
163
|
-
const clean = dir.trim().replace(/^"(.*)"$/, '$1')
|
|
164
|
-
if (clean === '') continue
|
|
165
|
-
const candidate = `${clean.replace(/[\\/]+$/, '')}\\pwsh.exe`
|
|
166
|
-
if (exists(candidate)) return candidate
|
|
167
|
-
}
|
|
168
|
-
return ''
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Arguments `pwsh7` accepts — strict: anything else is rejected. */
|
|
172
|
-
export const PWSH_ARGUMENTS = Object.freeze(['command', 'script_path', 'workdir', 'timeoutMs'])
|
|
173
|
-
|
|
174
|
-
const PWSH_DEFAULT_TIMEOUT_MS = 120_000
|
|
175
|
-
const PWSH_MAX_TIMEOUT_MS = 600_000
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Build the argv for one pwsh invocation. `command` and `script_path` are
|
|
179
|
-
* exclusive; a script path keeps the script's bytes OUT of the tool-call JSON
|
|
180
|
-
* entirely (the whole point of the dsh-ops issue #7 lesson: long heredoc-heavy
|
|
181
|
-
* commands inside one JSON string raise the malformed-tool-call rate and burn
|
|
182
|
-
* a whole model turn). Pure.
|
|
183
|
-
*/
|
|
184
|
-
export function pwshArgv(pwsh, args, { msysToWin } = {}) {
|
|
185
|
-
const hasCommand = typeof args.command === 'string' && args.command.trim() !== ''
|
|
186
|
-
const hasScript = typeof args.script_path === 'string' && args.script_path.trim() !== ''
|
|
187
|
-
if (hasCommand === hasScript) return { error: 'provide exactly one of command or script_path' }
|
|
188
|
-
if (hasScript) {
|
|
189
|
-
const raw = /** @type {string} */ (args.script_path)
|
|
190
|
-
// The model speaks MSYS (our prompt dialect); pwsh needs a Windows path.
|
|
191
|
-
const script = msysToWin ? msysToWin(raw) : raw
|
|
192
|
-
return { argv: [pwsh, '-NoLogo', '-NoProfile', '-NonInteractive', '-File', script] }
|
|
193
|
-
}
|
|
194
|
-
return { argv: [pwsh, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', /** @type {string} */ (args.command)] }
|
|
195
|
-
}
|
|
196
|
-
|
|
197
124
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* server occupying the call until timeout). Pure.
|
|
202
|
-
* @returns {string|undefined} an error message, or undefined when valid.
|
|
125
|
+
* The deny reason for a hidden pwsh call (route B wording): the tool is REAL
|
|
126
|
+
* PowerShell on this host, just hidden by the row switch — the sentence says
|
|
127
|
+
* so and points at the switch instead of calling the tool a lie.
|
|
203
128
|
*/
|
|
204
|
-
export function
|
|
205
|
-
|
|
206
|
-
return 'invalid arguments: expected an object'
|
|
207
|
-
}
|
|
208
|
-
const unknown = Object.keys(args).filter((key) => !PWSH_ARGUMENTS.includes(key))
|
|
209
|
-
if (unknown.length > 0) {
|
|
210
|
-
return `invalid arguments: unknown key(s) ${unknown.map((key) => JSON.stringify(key)).join(', ')}; this tool accepts ${PWSH_ARGUMENTS.join(', ')}`
|
|
211
|
-
}
|
|
212
|
-
const hasCommand = typeof args.command === 'string' && args.command.trim() !== ''
|
|
213
|
-
const hasScript = typeof args.script_path === 'string' && args.script_path.trim() !== ''
|
|
214
|
-
if (hasCommand === hasScript) return 'invalid pwsh input: provide exactly one of command or script_path'
|
|
215
|
-
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
216
|
-
return 'invalid timeoutMs: expected a positive number'
|
|
217
|
-
}
|
|
218
|
-
if (args.timeoutMs !== undefined && /** @type {number} */ (args.timeoutMs) > PWSH_MAX_TIMEOUT_MS) {
|
|
219
|
-
return `invalid timeoutMs: must be no greater than ${PWSH_MAX_TIMEOUT_MS}`
|
|
220
|
-
}
|
|
221
|
-
return undefined
|
|
129
|
+
export function hiddenPwshDenyReason() {
|
|
130
|
+
return 'the pwsh tool is hidden on this host by the dsh-gitbash-shell settings (pwsh tool switch, off by default). Use the bash tool for commands. If you need Windows-native work (registry, services, WMI), ask the user to enable the pwsh tool on the dsh-gitbash-shell settings card.'
|
|
222
131
|
}
|
|
223
132
|
|
|
224
|
-
/** The
|
|
225
|
-
const
|
|
133
|
+
/** The host packages the own bash tool consumes, resolved once per process. */
|
|
134
|
+
const HOST_MODULES = [
|
|
135
|
+
['toolsModule', '@deepseek-ai/dsh-tools'],
|
|
136
|
+
['sandboxModule', '@deepseek-ai/dsh-sandbox'],
|
|
137
|
+
['llmModule', '@deepseek-ai/dsh-llm'],
|
|
138
|
+
]
|
|
226
139
|
|
|
227
140
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
141
|
+
* Mount the executor through the host class-plugin path. A plugin fiber owns
|
|
142
|
+
* effects, but does NOT create a service namespace: ShellExecutor's constructor
|
|
143
|
+
* still provides `shell`. Isolate ONLY that service, retaining the host's
|
|
144
|
+
* subprocess/sandbox/policy dependencies and leaving the official shell intact.
|
|
230
145
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* (vendor/loader/src/config/tree.ts:122). It runs the host's resolve
|
|
234
|
-
* hooks, so it reaches packages inside the desktop app.asar that a bare
|
|
235
|
-
* Node resolver cannot see from the plugin's profile layout.
|
|
236
|
-
* ② A plain createRequire from the host base — the v0.35.0 channel, kept
|
|
237
|
-
* for layouts where the internal loader is absent.
|
|
238
|
-
*
|
|
239
|
-
* @returns {Promise<{ load: async () => object }|undefined>} a lazy loader for
|
|
240
|
-
* the module, or undefined when neither channel resolves it.
|
|
146
|
+
* The empty scope does not inject shell, so read its active provider through
|
|
147
|
+
* get(), not the property-access path that requires an inject declaration.
|
|
241
148
|
*/
|
|
242
|
-
export async function
|
|
149
|
+
export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope, { timeoutMs = 8000 } = {}) {
|
|
150
|
+
const executorScope = createScope(ctx.isolate('shell'))
|
|
151
|
+
let timer
|
|
243
152
|
try {
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
153
|
+
const mountTimeout = new Promise((_, reject) => {
|
|
154
|
+
timer = setTimeout(() => reject(new Error('executor scope mount timed out (a required service may be absent in this composition)')), timeoutMs)
|
|
155
|
+
})
|
|
156
|
+
const executor = await Promise.race([
|
|
157
|
+
(async () => {
|
|
158
|
+
if (executorScope.fiber) await executorScope.fiber
|
|
159
|
+
const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
|
|
160
|
+
await fiber
|
|
161
|
+
return executorScope.ctx.get('shell')
|
|
162
|
+
})(),
|
|
163
|
+
mountTimeout,
|
|
164
|
+
])
|
|
165
|
+
if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
|
|
166
|
+
throw new Error('executor instance did not expose resolve()/execute()')
|
|
248
167
|
}
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
const { createRequire } = await import('node:module')
|
|
252
|
-
const { pathToFileURL } = await import('node:url')
|
|
253
|
-
const req = createRequire(ctx?.baseUrl ?? import.meta.url)
|
|
254
|
-
const href = pathToFileURL(req.resolve(TOOL_BASH_PACKAGE)).href
|
|
255
|
-
return { load: async () => import(href) }
|
|
256
|
-
} catch {
|
|
257
|
-
return undefined
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Activate the OFFICIAL tool-bash inside one agent-owned scope: a child scope
|
|
263
|
-
* of the agent's context keeps every registration (tool, prompt section,
|
|
264
|
-
* effect) revertible at agent disposal, and the scoped-layer tool shadows the
|
|
265
|
-
* (absent) global one exactly the way the registry's own error message
|
|
266
|
-
* prescribes for per-agent variants. The official module is loaded ONCE per
|
|
267
|
-
* process; each agent gets a fresh scope and a fresh apply().
|
|
268
|
-
*/
|
|
269
|
-
export async function activateOfficialBash(agent, resolved, log) {
|
|
270
|
-
let mod
|
|
271
|
-
try {
|
|
272
|
-
mod = await resolved.load()
|
|
273
|
-
} catch (error) {
|
|
274
|
-
log(`official tool-bash module import failed: ${error?.message ?? error}`)
|
|
275
|
-
return undefined
|
|
276
|
-
}
|
|
277
|
-
if (!mod || typeof mod.apply !== 'function') {
|
|
278
|
-
log('official tool-bash module has no apply(); activation skipped')
|
|
279
|
-
return undefined
|
|
280
|
-
}
|
|
281
|
-
// Mount through cordis's OWN plugin path. The official module object
|
|
282
|
-
// DECLARES its service needs (`inject: ['tools','shell','systemPrompt',
|
|
283
|
-
// 'shellEnv','workingDirectory']`); a hand-called mod.apply(ctx) has no
|
|
284
|
-
// such declaration, so cordis refuses every service read with
|
|
285
|
-
// `cannot get property "shell" without inject` (v0.38.2 field report).
|
|
286
|
-
// ctx.plugin(mod) registers a REAL fiber with the declaration honored,
|
|
287
|
-
// runs apply itself, and the wrapped fiber is a thenable — awaiting it
|
|
288
|
-
// waits for the activation (and its async fiber start) to finish.
|
|
289
|
-
let fiber
|
|
290
|
-
try {
|
|
291
|
-
fiber = agent.ctx.plugin(mod)
|
|
168
|
+
return { executor, executorScope }
|
|
292
169
|
} catch (error) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
await fiber
|
|
298
|
-
} catch (error) {
|
|
299
|
-
log(`official tool-bash fiber failed: ${error?.message ?? error}`)
|
|
300
|
-
return undefined
|
|
301
|
-
}
|
|
302
|
-
return {
|
|
303
|
-
fiber,
|
|
304
|
-
dispose: async () => { try { await fiber?.dispose?.() } catch { /* already gone */ } },
|
|
170
|
+
await executorScope.dispose()
|
|
171
|
+
throw error
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timer)
|
|
305
174
|
}
|
|
306
175
|
}
|
|
307
176
|
|
|
@@ -310,28 +179,24 @@ export async function activateOfficialBash(agent, resolved, log) {
|
|
|
310
179
|
* - tools(ctx) -> the host tool registry (or undefined)
|
|
311
180
|
* - agents(ctx) -> the host agents service (or undefined)
|
|
312
181
|
* - createScope(fn) -> cordis createScope
|
|
313
|
-
* -
|
|
314
|
-
*
|
|
315
|
-
* - spawn(spec) -> the host subprocess seam, or undefined
|
|
316
|
-
* - pwshPath -> resolved host pwsh 7, or '' (tool not published)
|
|
317
|
-
* - pwshEnabled() -> the row switch for publishing the pwsh tool
|
|
182
|
+
* - pwshEnabled() -> the row switch; TRUE shows the real host pwsh tool,
|
|
183
|
+
* false/undefined (default) hides it
|
|
318
184
|
* - log(message) -> the plugin's tagged logger
|
|
319
185
|
*
|
|
320
186
|
* Registration decisions re-run on EVERY agent creation AND once at mount
|
|
321
|
-
* time for every ALREADY-LIVE agent (the dsh-ops-proven shape
|
|
322
|
-
*
|
|
323
|
-
* a plugin hot-reload, or a host whose sessions resumed before this bundle
|
|
324
|
-
* row finished mounting — and an agent/created-only listener leaves every
|
|
325
|
-
* such agent with the fake pwsh and no bash, which is exactly the Windows
|
|
326
|
-
* field report behind this block. A second, INDEPENDENT layer guards the
|
|
187
|
+
* time for every ALREADY-LIVE agent (the dsh-ops-proven shape): this row can
|
|
188
|
+
* activate long after agents exist. A second, INDEPENDENT layer guards the
|
|
327
189
|
* call itself through `tools/pre-execute`: visibility restriction can lose a
|
|
328
|
-
* race
|
|
190
|
+
* race.
|
|
329
191
|
*/
|
|
330
192
|
export function mountInlineTools(ctx, deps) {
|
|
331
|
-
const { tools, agents, createScope,
|
|
332
|
-
/** agent -> { timer, scope, denyPwsh
|
|
193
|
+
const { tools, agents, createScope, pwshEnabled, log } = deps
|
|
194
|
+
/** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */
|
|
333
195
|
const tracked = new Map()
|
|
334
|
-
|
|
196
|
+
/** Resolved once per process: { executor, executorScope, module }. */
|
|
197
|
+
let bashOnce
|
|
198
|
+
/** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
|
|
199
|
+
let opsSeen = false
|
|
335
200
|
|
|
336
201
|
const faceNames = (agent) => {
|
|
337
202
|
try {
|
|
@@ -343,30 +208,59 @@ export function mountInlineTools(ctx, deps) {
|
|
|
343
208
|
}
|
|
344
209
|
|
|
345
210
|
/**
|
|
346
|
-
*
|
|
347
|
-
* The
|
|
348
|
-
*
|
|
349
|
-
*
|
|
211
|
+
* Construct the private executor and the own bash-tool module ONCE per
|
|
212
|
+
* process. The executor mounts through cordis's own class-plugin path in a
|
|
213
|
+
* PRIVATE scope (`ctx.plugin(class)` honours the static inject/Config
|
|
214
|
+
* declarations — the §4k schemastery semantics come for free), so it never
|
|
215
|
+
* touches the root `ctx.shell` service slot the official pwsh executor
|
|
216
|
+
* owns. The executor scope lives for the plugin's lifetime and is disposed
|
|
217
|
+
* by the effect registered at the bottom of this function.
|
|
350
218
|
*/
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
219
|
+
const bashOncePromise = async () => {
|
|
220
|
+
if (bashOnce) return bashOnce
|
|
221
|
+
bashOnce = (async () => {
|
|
222
|
+
const { GitBashSandboxExecutor } = await import('./shell.js')
|
|
223
|
+
if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
|
|
224
|
+
if (!GitBashSandboxExecutor.Config) {
|
|
225
|
+
throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
|
|
226
|
+
}
|
|
227
|
+
if (typeof createScope !== 'function') throw new Error('no createScope seam')
|
|
228
|
+
const { executor, executorScope } = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
|
|
229
|
+
const hostModules = {}
|
|
230
|
+
for (const [key, name] of HOST_MODULES) {
|
|
231
|
+
try {
|
|
232
|
+
hostModules[key] = await resolveHostModule(ctx, name)
|
|
233
|
+
} catch { hostModules[key] = undefined }
|
|
234
|
+
}
|
|
235
|
+
const { createBashToolModule } = await import('./bash-tool.js')
|
|
236
|
+
const module = createBashToolModule({ executor, log, ...hostModules })
|
|
237
|
+
return { executor, executorScope, module }
|
|
238
|
+
})()
|
|
239
|
+
bashOnce.catch(() => { bashOnce = undefined })
|
|
240
|
+
return bashOnce
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Register the own bash tool inside one agent-owned scope. */
|
|
244
|
+
const provideBash = async (agent, state) => {
|
|
245
|
+
if (state.scope) return
|
|
355
246
|
try {
|
|
247
|
+
const { executor, module } = await bashOncePromise()
|
|
248
|
+
log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
|
|
356
249
|
const scope = createScope(agent.ctx)
|
|
357
|
-
// Same asynchronous-fiber shape
|
|
358
|
-
// apply callback to capture scope.ctx before touching its tools.
|
|
250
|
+
// Same asynchronous-fiber shape the official module needed: wait for
|
|
251
|
+
// the apply callback to capture scope.ctx before touching its tools.
|
|
359
252
|
if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
|
|
360
|
-
const registry = scope.ctx?.tools
|
|
361
|
-
if (!registry || typeof
|
|
253
|
+
const registry = scope.ctx?.get('tools')
|
|
254
|
+
if (!registry || typeof scope.ctx.plugin !== 'function') {
|
|
362
255
|
void scope.dispose().catch(() => {})
|
|
363
256
|
return
|
|
364
257
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
258
|
+
const fiber = scope.ctx.plugin(module)
|
|
259
|
+
await fiber
|
|
260
|
+
state.scope = scope
|
|
261
|
+
log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
|
|
368
262
|
} catch (error) {
|
|
369
|
-
log(`
|
|
263
|
+
log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
|
|
370
264
|
}
|
|
371
265
|
}
|
|
372
266
|
|
|
@@ -374,9 +268,8 @@ export function mountInlineTools(ctx, deps) {
|
|
|
374
268
|
if (!agent?.ctx) return
|
|
375
269
|
const names = faceNames(agent)
|
|
376
270
|
// The composed face may not be wired yet at created-time. Retry bounded:
|
|
377
|
-
// a face with NEITHER tool carries no verdict
|
|
378
|
-
//
|
|
379
|
-
// quietly — a slower composition simply gets no inline patch this boot.
|
|
271
|
+
// a face with NEITHER tool carries no verdict. Five tries at 250ms, then
|
|
272
|
+
// give up quietly — a slower composition simply gets no inline patch.
|
|
380
273
|
if (attempt < 5 && !names.includes('bash') && !names.includes('pwsh')) {
|
|
381
274
|
const timer = setTimeout(() => { try { reconcile(agent, { attempt: attempt + 1 }) } catch { /* never crash on a timer */ } }, 250)
|
|
382
275
|
timer.unref?.()
|
|
@@ -385,56 +278,36 @@ export function mountInlineTools(ctx, deps) {
|
|
|
385
278
|
else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
|
|
386
279
|
return
|
|
387
280
|
}
|
|
388
|
-
const
|
|
281
|
+
const pwshVisible = deps.pwshEnabled?.() === true
|
|
282
|
+
const needs = inlineNeeds(names, { pwshVisible })
|
|
283
|
+
if (needs.opsPresent) opsSeen = true
|
|
389
284
|
let state = tracked.get(agent)
|
|
390
285
|
if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
|
|
391
286
|
if (!state) {
|
|
392
|
-
state = { timer: undefined, scope: undefined, denyPwsh: undefined
|
|
287
|
+
state = { timer: undefined, scope: undefined, denyPwsh: undefined }
|
|
393
288
|
tracked.set(agent, state)
|
|
394
289
|
}
|
|
395
|
-
// The pwsh face
|
|
396
|
-
// is
|
|
397
|
-
// dsh-ops present -> hands off entirely (their host-pwsh story stands
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// The deny MUST go through the AGENT's scoped context
|
|
412
|
-
// (`agent.ctx.tools`, the dsh-ops-proven shape): on the ROOT context
|
|
413
|
-
// the host throws "requires a scoped context" and the deny silently
|
|
414
|
-
// never exists (issue #18, root cause 3).
|
|
415
|
-
try {
|
|
416
|
-
const deny = agent.ctx?.tools?.restrict?.({ deny: ['pwsh'] })
|
|
417
|
-
if (typeof deny === 'function') state.denyPwsh = deny
|
|
418
|
-
} catch (error) {
|
|
419
|
-
log(`fake-pwsh deny failed: ${error?.message ?? error}`)
|
|
420
|
-
}
|
|
290
|
+
// The pwsh face (route B): the host tool is REAL PowerShell now, so the
|
|
291
|
+
// default is a product-level HIDE, not a safety denial.
|
|
292
|
+
// dsh-ops present -> hands off entirely (their host-pwsh story stands).
|
|
293
|
+
// switch OFF -> hide the tool for this agent (tools.restrict).
|
|
294
|
+
// switch ON -> leave the real tool visible, untouched.
|
|
295
|
+
if (needs.hidePwsh && !needs.opsPresent) {
|
|
296
|
+
if (!state.denyPwsh) {
|
|
297
|
+
// Through the AGENT's scoped context (`agent.ctx.tools`, the dsh-ops
|
|
298
|
+
// -proven shape): on the ROOT context the host throws "requires a
|
|
299
|
+
// scoped context" and the restriction silently never exists (issue
|
|
300
|
+
// #18, root cause 3).
|
|
301
|
+
try {
|
|
302
|
+
const deny = agent.ctx?.tools?.restrict?.({ deny: ['pwsh'] })
|
|
303
|
+
if (typeof deny === 'function') state.denyPwsh = deny
|
|
304
|
+
} catch (error) {
|
|
305
|
+
log(`pwsh hide failed: ${error?.message ?? error}`)
|
|
421
306
|
}
|
|
422
|
-
if (deps.pwshEnabled && deps.pwshEnabled() === true) void providePwsh(agent, state)
|
|
423
307
|
}
|
|
424
308
|
}
|
|
425
309
|
if (needs.needsBash && !state.scope) {
|
|
426
|
-
|
|
427
|
-
void toolBashResolvePromise.then(async (resolved) => {
|
|
428
|
-
if (!resolved) {
|
|
429
|
-
log('official @deepseek-ai/dsh-tool-bash is not resolvable (neither the host loader nor a plain resolution found it); bash stays absent for this agent')
|
|
430
|
-
return
|
|
431
|
-
}
|
|
432
|
-
const scope = await activateOfficialBash(agent, resolved, log)
|
|
433
|
-
if (scope && tracked.get(agent) === state) {
|
|
434
|
-
state.scope = scope
|
|
435
|
-
log('official bash tool activated for this agent (Git Bash host; fake pwsh denied)')
|
|
436
|
-
}
|
|
437
|
-
}).catch(() => { /* logged inside activateOfficialBash */ })
|
|
310
|
+
void provideBash(agent, state)
|
|
438
311
|
}
|
|
439
312
|
}
|
|
440
313
|
|
|
@@ -444,30 +317,26 @@ export function mountInlineTools(ctx, deps) {
|
|
|
444
317
|
tracked.delete(agent)
|
|
445
318
|
if (state.timer) clearTimeout(state.timer)
|
|
446
319
|
if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
|
|
447
|
-
if (state.pwshScope) { void state.pwshScope.dispose().catch(() => {}) }
|
|
448
320
|
if (state.scope) { void state.scope.dispose().catch(() => {}) }
|
|
449
321
|
}
|
|
450
322
|
|
|
451
323
|
try {
|
|
452
324
|
ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
|
|
453
325
|
ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
|
|
454
|
-
// MOUNT-TIME SWEEP (v0.35.2
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// finished mounting would otherwise keep the fake pwsh and no bash on
|
|
458
|
-
// every live agent. Same shape the proven dsh-ops denial uses.
|
|
326
|
+
// MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
|
|
327
|
+
// mounts never fire agent/created. Same shape the proven dsh-ops denial
|
|
328
|
+
// uses.
|
|
459
329
|
for (const agent of agents?.(ctx)?.list?.() ?? []) {
|
|
460
330
|
try { reconcile(agent) } catch { /* one broken agent never blocks the rest */ }
|
|
461
331
|
}
|
|
462
|
-
// CALL-LAYER GUARD (v0.35.2, second independent layer;
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// replacement named.
|
|
332
|
+
// CALL-LAYER GUARD (v0.35.2, second independent layer; route B wording):
|
|
333
|
+
// while the row switch hides the real pwsh tool, a `pwsh` call that slips
|
|
334
|
+
// past a visibility race is denied with the switch named. With the switch
|
|
335
|
+
// ON the REAL tool answers for itself.
|
|
467
336
|
ctx.on('tools/pre-execute', (exec, next) => {
|
|
468
337
|
try {
|
|
469
|
-
if (exec?.name === 'pwsh') {
|
|
470
|
-
return { kind: 'deny', reason:
|
|
338
|
+
if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) {
|
|
339
|
+
return { kind: 'deny', reason: hiddenPwshDenyReason() }
|
|
471
340
|
}
|
|
472
341
|
} catch { /* never block the waterfall */ }
|
|
473
342
|
return next()
|
|
@@ -480,91 +349,17 @@ export function mountInlineTools(ctx, deps) {
|
|
|
480
349
|
ctx.effect(() => () => {
|
|
481
350
|
for (const agent of [...tracked.keys()]) teardown(agent)
|
|
482
351
|
tracked.clear()
|
|
352
|
+
if (bashOnce) {
|
|
353
|
+
void Promise.resolve(bashOnce).then(({ executorScope } = {}) => {
|
|
354
|
+
try { executorScope?.dispose?.() } catch { /* already gone */ }
|
|
355
|
+
}).catch(() => {})
|
|
356
|
+
bashOnce = undefined
|
|
357
|
+
}
|
|
483
358
|
}, 'dsh-gitbash-shell: inline tool patching')
|
|
484
359
|
}
|
|
485
360
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
* bash executors consume the same seam). There is deliberately NO file
|
|
492
|
-
* sandbox on this channel — the tool is published only to danger-full-access
|
|
493
|
-
* sessions and says so in the error it raises elsewhere.
|
|
494
|
-
* @param {object} options
|
|
495
|
-
* @param {string} options.pwsh - resolved pwsh.exe path.
|
|
496
|
-
* @param {(spec: object) => { done: Promise<object>, collected?: object }} options.spawn - the host subprocess seam.
|
|
497
|
-
* @param {(session: object|undefined) => boolean} options.allowed - per-session permission verdict.
|
|
498
|
-
* @param {(path: string) => string} [options.msysToWin] - argument dialect translation (MSYS -> Windows), the same mount table the file tools use.
|
|
499
|
-
* @param {(text: string) => string} [options.echo] - result dialect echo (Windows -> MSYS), gated by the caller.
|
|
500
|
-
*/
|
|
501
|
-
export function gitbashPwshDefinition({ pwsh, spawn, allowed, msysToWin, echo }) {
|
|
502
|
-
return {
|
|
503
|
-
// v0.37.0 (user decision): the tool is named `pwsh7` — NOT `pwsh` (that
|
|
504
|
-
// name belongs to the host's fake global tool, whose executor is
|
|
505
|
-
// ctx.shell = Git Bash on this host and stays denied) and NOT
|
|
506
|
-
// `gitbash_pwsh` (which read like a bash/pwsh hybrid). `pwsh7` is purely
|
|
507
|
-
// additive: it never shadows anything, and it exists only on hosts where
|
|
508
|
-
// a real PowerShell 7 is installed.
|
|
509
|
-
name: 'pwsh7',
|
|
510
|
-
description: gitbashPwshDescription(),
|
|
511
|
-
parameters: {
|
|
512
|
-
type: 'object',
|
|
513
|
-
properties: {
|
|
514
|
-
command: { type: 'string', description: 'Short PowerShell command; for long or multi-line scripts use script_path.' },
|
|
515
|
-
script_path: { type: 'string', description: 'Existing script path; use instead of command for long or multi-line scripts.' },
|
|
516
|
-
workdir: { type: 'string', description: 'Working directory; defaults to the session workspace when omitted.' },
|
|
517
|
-
timeoutMs: { type: 'number', description: 'Timeout in milliseconds; the command is killed on expiry. Defaults to 120000.' },
|
|
518
|
-
},
|
|
519
|
-
required: ['command'],
|
|
520
|
-
additionalProperties: false,
|
|
521
|
-
},
|
|
522
|
-
output: {
|
|
523
|
-
schema: TOOL_OUTPUT_SCHEMA,
|
|
524
|
-
render: (_args, value) => renderTextContent(value),
|
|
525
|
-
},
|
|
526
|
-
async execute(args, exec) {
|
|
527
|
-
const invalid = validatePwshArgs(args)
|
|
528
|
-
if (invalid) throw new Error(invalid)
|
|
529
|
-
if (!allowed(exec?.agent?.session)) {
|
|
530
|
-
throw new Error('pwsh7 requires danger-full-access for this session; this channel has no file sandbox.')
|
|
531
|
-
}
|
|
532
|
-
const built = pwshArgv(pwsh, args, { msysToWin })
|
|
533
|
-
if (built.error) throw new Error(built.error)
|
|
534
|
-
const timeoutMs = args.timeoutMs === undefined ? PWSH_DEFAULT_TIMEOUT_MS : args.timeoutMs
|
|
535
|
-
const controller = new AbortController()
|
|
536
|
-
const timer = setTimeout(() => controller.abort(new Error(`command timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
537
|
-
timer.unref?.()
|
|
538
|
-
try {
|
|
539
|
-
const handle = spawn({
|
|
540
|
-
argv: built.argv,
|
|
541
|
-
cwd: typeof args.workdir === 'string' && args.workdir !== '' ? args.workdir : undefined,
|
|
542
|
-
stdio: {
|
|
543
|
-
stdin: 'ignore',
|
|
544
|
-
stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
|
|
545
|
-
stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
|
|
546
|
-
},
|
|
547
|
-
signal: controller.signal,
|
|
548
|
-
})
|
|
549
|
-
const outcome = await handle.done
|
|
550
|
-
const streamText = (reader) => {
|
|
551
|
-
if (reader === undefined || typeof reader.readFrom !== 'function') return ''
|
|
552
|
-
const read = reader.readFrom(0)
|
|
553
|
-
const text = read.text ?? ''
|
|
554
|
-
return echo ? echo(text) : text
|
|
555
|
-
}
|
|
556
|
-
const stdout = streamText(handle.collected?.stdout)
|
|
557
|
-
const stderr = streamText(handle.collected?.stderr)
|
|
558
|
-
let body = stdout
|
|
559
|
-
if (stderr !== '') body += (body !== '' && !body.endsWith('\n') ? '\n' : '') + '[stderr]\n' + stderr
|
|
560
|
-
if (body === '') body = '(no output)'
|
|
561
|
-
const markers = []
|
|
562
|
-
if (outcome?.signal !== undefined && outcome?.signal !== null) markers.push(`[signal: ${outcome.signal}]`)
|
|
563
|
-
if (outcome?.exitCode !== undefined && outcome?.exitCode !== null && outcome.exitCode !== 0) markers.push(`[exit code: ${outcome.exitCode}]`)
|
|
564
|
-
return { content: [{ type: 'text', text: [body, ...markers].join('\n') }] }
|
|
565
|
-
} finally {
|
|
566
|
-
clearTimeout(timer)
|
|
567
|
-
}
|
|
568
|
-
},
|
|
569
|
-
}
|
|
570
|
-
}
|
|
361
|
+
// The host-package resolution channel lives in bash-tool.js; import it for
|
|
362
|
+
// local use AND re-export it so the host half (and the tests) see ONE
|
|
363
|
+
// implementation.
|
|
364
|
+
import { resolveHostModule } from './bash-tool.js'
|
|
365
|
+
export { resolveHostModule }
|