dsh-gitbash-shell 0.38.5 → 0.39.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 +51 -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 +10 -2
- 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/index.js +28 -35
- package/src/inline-tools.js +164 -393
- package/src/shell.js +12 -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 private cordis scope, never registered as a service), 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,217 +121,45 @@ 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
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** The official bash-tool package the inline patch activates. */
|
|
225
|
-
const TOOL_BASH_PACKAGE = '@deepseek-ai/dsh-tool-bash'
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Resolve the OFFICIAL @deepseek-ai/dsh-tool-bash module. TWO channels, in
|
|
229
|
-
* order (v0.38.1, the desktop-host field report):
|
|
230
|
-
*
|
|
231
|
-
* ① THE HOST'S OWN LOADER — `ctx.loader.internal.import(name, baseUrl, {})`,
|
|
232
|
-
* the exact call the row mount itself uses
|
|
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.
|
|
241
|
-
*/
|
|
242
|
-
export async function resolveToolBashEntry(ctx) {
|
|
243
|
-
try {
|
|
244
|
-
const internal = ctx?.loader?.internal
|
|
245
|
-
if (internal && typeof internal.import === 'function') {
|
|
246
|
-
const mod = await internal.import(TOOL_BASH_PACKAGE, ctx?.baseUrl, {})
|
|
247
|
-
if (mod && typeof mod.apply === 'function') return { load: async () => mod }
|
|
248
|
-
}
|
|
249
|
-
} catch { /* fall through to the plain resolution */ }
|
|
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
|
-
}
|
|
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.'
|
|
259
131
|
}
|
|
260
132
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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)
|
|
292
|
-
} catch (error) {
|
|
293
|
-
log(`official tool-bash mount failed: ${error?.message ?? error}`)
|
|
294
|
-
return undefined
|
|
295
|
-
}
|
|
296
|
-
try {
|
|
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 */ } },
|
|
305
|
-
}
|
|
306
|
-
}
|
|
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
|
+
]
|
|
307
139
|
|
|
308
140
|
/**
|
|
309
141
|
* Mount the per-agent reconcile. `deps`:
|
|
310
142
|
* - tools(ctx) -> the host tool registry (or undefined)
|
|
311
143
|
* - agents(ctx) -> the host agents service (or undefined)
|
|
312
144
|
* - 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
|
|
145
|
+
* - pwshEnabled() -> the row switch; TRUE shows the real host pwsh tool,
|
|
146
|
+
* false/undefined (default) hides it
|
|
318
147
|
* - log(message) -> the plugin's tagged logger
|
|
319
148
|
*
|
|
320
149
|
* 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
|
|
150
|
+
* time for every ALREADY-LIVE agent (the dsh-ops-proven shape): this row can
|
|
151
|
+
* activate long after agents exist. A second, INDEPENDENT layer guards the
|
|
327
152
|
* call itself through `tools/pre-execute`: visibility restriction can lose a
|
|
328
|
-
* race
|
|
153
|
+
* race.
|
|
329
154
|
*/
|
|
330
155
|
export function mountInlineTools(ctx, deps) {
|
|
331
|
-
const { tools, agents, createScope,
|
|
332
|
-
/** agent -> { timer, scope, denyPwsh
|
|
156
|
+
const { tools, agents, createScope, pwshEnabled, log } = deps
|
|
157
|
+
/** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */
|
|
333
158
|
const tracked = new Map()
|
|
334
|
-
|
|
159
|
+
/** Resolved once per process: { executor, executorScope, module }. */
|
|
160
|
+
let bashOnce
|
|
161
|
+
/** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
|
|
162
|
+
let opsSeen = false
|
|
335
163
|
|
|
336
164
|
const faceNames = (agent) => {
|
|
337
165
|
try {
|
|
@@ -343,30 +171,72 @@ export function mountInlineTools(ctx, deps) {
|
|
|
343
171
|
}
|
|
344
172
|
|
|
345
173
|
/**
|
|
346
|
-
*
|
|
347
|
-
* The
|
|
348
|
-
*
|
|
349
|
-
*
|
|
174
|
+
* Construct the private executor and the own bash-tool module ONCE per
|
|
175
|
+
* process. The executor mounts through cordis's own class-plugin path in a
|
|
176
|
+
* PRIVATE scope (`ctx.plugin(class)` honours the static inject/Config
|
|
177
|
+
* declarations — the §4k schemastery semantics come for free), so it never
|
|
178
|
+
* touches the root `ctx.shell` service slot the official pwsh executor
|
|
179
|
+
* owns. The executor scope lives for the plugin's lifetime and is disposed
|
|
180
|
+
* by the effect registered at the bottom of this function.
|
|
350
181
|
*/
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
182
|
+
const bashOncePromise = async () => {
|
|
183
|
+
if (bashOnce) return bashOnce
|
|
184
|
+
bashOnce = (async () => {
|
|
185
|
+
const { GitBashSandboxExecutor } = await import('./shell.js')
|
|
186
|
+
if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
|
|
187
|
+
if (!GitBashSandboxExecutor.Config) {
|
|
188
|
+
throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
|
|
189
|
+
}
|
|
190
|
+
if (typeof createScope !== 'function') throw new Error('no createScope seam')
|
|
191
|
+
const executorScope = createScope(ctx)
|
|
192
|
+
if (executorScope.fiber) { try { await executorScope.fiber } catch { /* disposed */ } }
|
|
193
|
+
const mountTimeout = new Promise((_, reject) => {
|
|
194
|
+
const timer = setTimeout(() => reject(new Error('executor scope mount timed out (a required service may be absent in this composition)')), 8000)
|
|
195
|
+
timer.unref?.()
|
|
196
|
+
})
|
|
197
|
+
const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
|
|
198
|
+
const executor = await Promise.race([
|
|
199
|
+
(async () => { await fiber; return executorScope.ctx.shell })(),
|
|
200
|
+
mountTimeout,
|
|
201
|
+
])
|
|
202
|
+
if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
|
|
203
|
+
throw new Error('executor instance did not expose resolve()/execute()')
|
|
204
|
+
}
|
|
205
|
+
const hostModules = {}
|
|
206
|
+
for (const [key, name] of HOST_MODULES) {
|
|
207
|
+
try {
|
|
208
|
+
hostModules[key] = await resolveHostModule(ctx, name)
|
|
209
|
+
} catch { hostModules[key] = undefined }
|
|
210
|
+
}
|
|
211
|
+
const { createBashToolModule } = await import('./bash-tool.js')
|
|
212
|
+
const module = createBashToolModule({ executor, log, ...hostModules })
|
|
213
|
+
return { executor, executorScope, module }
|
|
214
|
+
})()
|
|
215
|
+
bashOnce.catch(() => { bashOnce = undefined })
|
|
216
|
+
return bashOnce
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Register the own bash tool inside one agent-owned scope. */
|
|
220
|
+
const provideBash = async (agent, state) => {
|
|
221
|
+
if (state.scope) return
|
|
355
222
|
try {
|
|
223
|
+
const { executor, module } = await bashOncePromise()
|
|
224
|
+
log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
|
|
356
225
|
const scope = createScope(agent.ctx)
|
|
357
|
-
// Same asynchronous-fiber shape
|
|
358
|
-
// apply callback to capture scope.ctx before touching its tools.
|
|
226
|
+
// Same asynchronous-fiber shape the official module needed: wait for
|
|
227
|
+
// the apply callback to capture scope.ctx before touching its tools.
|
|
359
228
|
if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
|
|
360
229
|
const registry = scope.ctx?.tools
|
|
361
|
-
if (!registry || typeof
|
|
230
|
+
if (!registry || typeof scope.ctx.plugin !== 'function') {
|
|
362
231
|
void scope.dispose().catch(() => {})
|
|
363
232
|
return
|
|
364
233
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
234
|
+
const fiber = scope.ctx.plugin(module)
|
|
235
|
+
await fiber
|
|
236
|
+
state.scope = scope
|
|
237
|
+
log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
|
|
368
238
|
} catch (error) {
|
|
369
|
-
log(`
|
|
239
|
+
log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
|
|
370
240
|
}
|
|
371
241
|
}
|
|
372
242
|
|
|
@@ -374,9 +244,8 @@ export function mountInlineTools(ctx, deps) {
|
|
|
374
244
|
if (!agent?.ctx) return
|
|
375
245
|
const names = faceNames(agent)
|
|
376
246
|
// 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.
|
|
247
|
+
// a face with NEITHER tool carries no verdict. Five tries at 250ms, then
|
|
248
|
+
// give up quietly — a slower composition simply gets no inline patch.
|
|
380
249
|
if (attempt < 5 && !names.includes('bash') && !names.includes('pwsh')) {
|
|
381
250
|
const timer = setTimeout(() => { try { reconcile(agent, { attempt: attempt + 1 }) } catch { /* never crash on a timer */ } }, 250)
|
|
382
251
|
timer.unref?.()
|
|
@@ -385,56 +254,36 @@ export function mountInlineTools(ctx, deps) {
|
|
|
385
254
|
else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
|
|
386
255
|
return
|
|
387
256
|
}
|
|
388
|
-
const
|
|
257
|
+
const pwshVisible = deps.pwshEnabled?.() === true
|
|
258
|
+
const needs = inlineNeeds(names, { pwshVisible })
|
|
259
|
+
if (needs.opsPresent) opsSeen = true
|
|
389
260
|
let state = tracked.get(agent)
|
|
390
261
|
if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
|
|
391
262
|
if (!state) {
|
|
392
|
-
state = { timer: undefined, scope: undefined, denyPwsh: undefined
|
|
263
|
+
state = { timer: undefined, scope: undefined, denyPwsh: undefined }
|
|
393
264
|
tracked.set(agent, state)
|
|
394
265
|
}
|
|
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
|
-
}
|
|
266
|
+
// The pwsh face (route B): the host tool is REAL PowerShell now, so the
|
|
267
|
+
// default is a product-level HIDE, not a safety denial.
|
|
268
|
+
// dsh-ops present -> hands off entirely (their host-pwsh story stands).
|
|
269
|
+
// switch OFF -> hide the tool for this agent (tools.restrict).
|
|
270
|
+
// switch ON -> leave the real tool visible, untouched.
|
|
271
|
+
if (needs.hidePwsh && !needs.opsPresent) {
|
|
272
|
+
if (!state.denyPwsh) {
|
|
273
|
+
// Through the AGENT's scoped context (`agent.ctx.tools`, the dsh-ops
|
|
274
|
+
// -proven shape): on the ROOT context the host throws "requires a
|
|
275
|
+
// scoped context" and the restriction silently never exists (issue
|
|
276
|
+
// #18, root cause 3).
|
|
277
|
+
try {
|
|
278
|
+
const deny = agent.ctx?.tools?.restrict?.({ deny: ['pwsh'] })
|
|
279
|
+
if (typeof deny === 'function') state.denyPwsh = deny
|
|
280
|
+
} catch (error) {
|
|
281
|
+
log(`pwsh hide failed: ${error?.message ?? error}`)
|
|
421
282
|
}
|
|
422
|
-
if (deps.pwshEnabled && deps.pwshEnabled() === true) void providePwsh(agent, state)
|
|
423
283
|
}
|
|
424
284
|
}
|
|
425
285
|
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 */ })
|
|
286
|
+
void provideBash(agent, state)
|
|
438
287
|
}
|
|
439
288
|
}
|
|
440
289
|
|
|
@@ -444,30 +293,26 @@ export function mountInlineTools(ctx, deps) {
|
|
|
444
293
|
tracked.delete(agent)
|
|
445
294
|
if (state.timer) clearTimeout(state.timer)
|
|
446
295
|
if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
|
|
447
|
-
if (state.pwshScope) { void state.pwshScope.dispose().catch(() => {}) }
|
|
448
296
|
if (state.scope) { void state.scope.dispose().catch(() => {}) }
|
|
449
297
|
}
|
|
450
298
|
|
|
451
299
|
try {
|
|
452
300
|
ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
|
|
453
301
|
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.
|
|
302
|
+
// MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
|
|
303
|
+
// mounts never fire agent/created. Same shape the proven dsh-ops denial
|
|
304
|
+
// uses.
|
|
459
305
|
for (const agent of agents?.(ctx)?.list?.() ?? []) {
|
|
460
306
|
try { reconcile(agent) } catch { /* one broken agent never blocks the rest */ }
|
|
461
307
|
}
|
|
462
|
-
// CALL-LAYER GUARD (v0.35.2, second independent layer;
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// replacement named.
|
|
308
|
+
// CALL-LAYER GUARD (v0.35.2, second independent layer; route B wording):
|
|
309
|
+
// while the row switch hides the real pwsh tool, a `pwsh` call that slips
|
|
310
|
+
// past a visibility race is denied with the switch named. With the switch
|
|
311
|
+
// ON the REAL tool answers for itself.
|
|
467
312
|
ctx.on('tools/pre-execute', (exec, next) => {
|
|
468
313
|
try {
|
|
469
|
-
if (exec?.name === 'pwsh') {
|
|
470
|
-
return { kind: 'deny', reason:
|
|
314
|
+
if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) {
|
|
315
|
+
return { kind: 'deny', reason: hiddenPwshDenyReason() }
|
|
471
316
|
}
|
|
472
317
|
} catch { /* never block the waterfall */ }
|
|
473
318
|
return next()
|
|
@@ -480,91 +325,17 @@ export function mountInlineTools(ctx, deps) {
|
|
|
480
325
|
ctx.effect(() => () => {
|
|
481
326
|
for (const agent of [...tracked.keys()]) teardown(agent)
|
|
482
327
|
tracked.clear()
|
|
328
|
+
if (bashOnce) {
|
|
329
|
+
void Promise.resolve(bashOnce).then(({ executorScope } = {}) => {
|
|
330
|
+
try { executorScope?.dispose?.() } catch { /* already gone */ }
|
|
331
|
+
}).catch(() => {})
|
|
332
|
+
bashOnce = undefined
|
|
333
|
+
}
|
|
483
334
|
}, 'dsh-gitbash-shell: inline tool patching')
|
|
484
335
|
}
|
|
485
336
|
|
|
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
|
-
}
|
|
337
|
+
// The host-package resolution channel lives in bash-tool.js; import it for
|
|
338
|
+
// local use AND re-export it so the host half (and the tests) see ONE
|
|
339
|
+
// implementation.
|
|
340
|
+
import { resolveHostModule } from './bash-tool.js'
|
|
341
|
+
export { resolveHostModule }
|