dsh-gitbash-shell 0.34.2 → 0.35.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.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * dsh-gitbash-shell/inline-tools — runtime shell-tool patching for the OFFICIAL
3
+ * presets on Windows (v0.35.0).
4
+ *
5
+ * WHY THIS EXISTS. The official preset rows compose their tool set at boot
6
+ * (`tool-bash` disabled on win32, `tool-pwsh` enabled), and the patch layer
7
+ * CANNOT reach them: `applyEntryPatches`' id map only descends into rows that
8
+ * carry `group: true` (vendor/include/src/index.ts buildMap), while a preset
9
+ * declaration is a plain row whose `config.plugins` is opaque data. Rewriting
10
+ * the declaration's whole `config` would freeze the official row list at our
11
+ * restatement instant — every future official row addition would be invisible
12
+ * until we hand-synced. So the fix runs at the tool layer instead, where the
13
+ * official contract explicitly invites per-agent variants:
14
+ *
15
+ * - the tools registry REJECTS same-scope duplicate names but points at this
16
+ * exact path for variants ("for a per-agent variant, register through that
17
+ * agent's `agent.ctx` instead", packages/core/tools/src/index.ts);
18
+ * - `agent/created` fires once per agent with the composed tool face
19
+ * reachable through `ctx.get('tools').schemas(agent)`.
20
+ *
21
+ * One reconcile per agent reads the face and acts on three facts:
22
+ *
23
+ * 1. `pwsh` present + `bash` absent -> activate the OFFICIAL
24
+ * @deepseek-ai/dsh-tool-bash module inside an agent-owned scope (full
25
+ * official behaviour: jobs, approval escalation, rendering — zero
26
+ * behaviour is re-implemented here) and deny the fake `pwsh` (its
27
+ * executor is ctx.shell, which on this host IS Git Bash — a pwsh tool
28
+ * that runs bash is a lie to the model);
29
+ * 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge and
30
+ * gitbash_pwsh stand down; the official bash activation still runs, the
31
+ * two command surfaces are complements, not duplicates;
32
+ * 3. `bash` already present (variant presets on, or a future official
33
+ * change) -> touch nothing, so the variantPresets switch and this path
34
+ * are mutually exclusive BY OBSERVATION, with no explicit lock.
35
+ *
36
+ * Everything here is Windows-only by caller gate; on other hosts the module
37
+ * loads but mounts nothing.
38
+ */
39
+
40
+ /** Answer for one observed tool face. Pure. */
41
+ export function inlineNeeds(names) {
42
+ const set = new Set(Array.isArray(names) ? names : [])
43
+ const has = (name) => set.has(name)
44
+ return {
45
+ // A pwsh tool without a bash tool is the official win32 composition on
46
+ // this host: bash must be patched in, and the pwsh tool would execute
47
+ // Git Bash through ctx.shell (a lying name) — deny it.
48
+ needsBash: has('pwsh') && !has('bash'),
49
+ denyPwsh: has('pwsh') && !has('bash'),
50
+ // dsh-ops publishes file/search tools under the ops_ prefix. When they are
51
+ // here, our FastCtx bridge and pwsh replacement would be duplicates.
52
+ opsPresent: has('ops_inspect_local_file') || has('ops_grep') || has('ops_bash'),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The routing addendum appended to the `bash` tool's description through the
58
+ * system-prompt assemble waterfall. ALWAYS applied — it states shell routing,
59
+ * which holds regardless of the path dialect a delegate sees.
60
+ */
61
+ export const GITBASH_ROUTE_ADDENDUM = [
62
+ 'Prefer this bash tool for ALL commands; use a PowerShell tool ONLY when a Windows-native capability (cmdlets, registry, services, WMI) is truly required.',
63
+ '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).',
64
+ ].join('\n')
65
+
66
+ /**
67
+ * The dialect addendum (MSYS facts) — appended ONLY while this plugin's path
68
+ * dialect is on for the assembling agent, because it describes the /c/ world
69
+ * the dialect creates.
70
+ */
71
+ export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths use drive roots (/c/Users/...), and ~, /tmp, /dev/null and /usr/... resolve exactly as Git Bash resolves them.'
72
+
73
+ /** @deprecated composed form kept for tests: route + MSYS sentences. */
74
+ export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM
75
+
76
+ /** The pwsh-replacement tool's model-facing description. */
77
+ export function gitbashPwshDescription() {
78
+ return 'Run a command in Windows PowerShell 7 (pwsh). Use ONLY when a Windows-native capability '
79
+ + '(cmdlets, registry, services, WMI, scheduled tasks) is required — for everything else prefer the bash tool. '
80
+ + 'Each call uses a fresh shell; set workdir explicitly. Paths inside command must be Windows form (C:/... or C:\\\\...). '
81
+ + 'For scripts longer than about 1 KB, or containing quotes/backslashes/non-ASCII text, write the script to a file first '
82
+ + 'and pass script_path instead. Output is UTF-8; long output is truncated with the full text saved to a reported file. '
83
+ + 'Fix failed pwsh commands here; do not switch shells or mix PowerShell syntax into bash commands.'
84
+ }
85
+
86
+ /**
87
+ * Resolve the host's own PowerShell 7 — never a bundled payload, never
88
+ * Windows PowerShell 5.1. Mirrors the probe order the official pwsh resolver
89
+ * documents (well-known install locations, then PATH), WITHOUT its 5.1
90
+ * fallback: if no pwsh 7 exists the caller simply does not publish the tool.
91
+ * Pure I/O via the injected `exists` so the candidate matrix is testable.
92
+ * @returns {string} an absolute pwsh.exe path, or '' when absent.
93
+ */
94
+ export function resolveHostPwsh7({ env = process.env, exists, platform = process.platform } = {}) {
95
+ if (platform !== 'win32') return ''
96
+ const programFiles = env.ProgramFiles ?? env.PROGRAMFILES ?? 'C:\\Program Files'
97
+ const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
98
+ const localAppData = env.LOCALAPPDATA ?? env.LocalAppData ?? ''
99
+ const userProfile = env.USERPROFILE ?? ''
100
+ const candidates = [
101
+ `${programFiles}\\PowerShell\\7\\pwsh.exe`,
102
+ `${programFiles}\\PowerShell\\7-preview\\pwsh.exe`,
103
+ `${programFilesX86}\\PowerShell\\7\\pwsh.exe`,
104
+ localAppData === '' ? '' : `${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`,
105
+ userProfile === '' ? '' : `${userProfile}\\scoop\\shims\\pwsh.exe`,
106
+ 'C:\\ProgramData\\chocolatey\\bin\\pwsh.exe',
107
+ ].filter((entry) => entry !== '')
108
+ for (const candidate of candidates) {
109
+ if (exists(candidate)) return candidate
110
+ }
111
+ // PATH last: a PATH hit may be a store alias or shim, so it ranks below the
112
+ // well-known installs but still beats "no tool at all".
113
+ const pathValue = env.PATH ?? env.Path ?? ''
114
+ for (const dir of pathValue.split(';')) {
115
+ const clean = dir.trim().replace(/^"(.*)"$/, '$1')
116
+ if (clean === '') continue
117
+ const candidate = `${clean.replace(/[\\/]+$/, '')}\\pwsh.exe`
118
+ if (exists(candidate)) return candidate
119
+ }
120
+ return ''
121
+ }
122
+
123
+ /** Arguments `gitbash_pwsh` accepts — strict: anything else is rejected. */
124
+ export const PWSH_ARGUMENTS = Object.freeze(['command', 'script_path', 'workdir', 'timeoutMs'])
125
+
126
+ const PWSH_DEFAULT_TIMEOUT_MS = 120_000
127
+ const PWSH_MAX_TIMEOUT_MS = 600_000
128
+
129
+ /**
130
+ * Build the argv for one pwsh invocation. `command` and `script_path` are
131
+ * exclusive; a script path keeps the script's bytes OUT of the tool-call JSON
132
+ * entirely (the whole point of the dsh-ops issue #7 lesson: long heredoc-heavy
133
+ * commands inside one JSON string raise the malformed-tool-call rate and burn
134
+ * a whole model turn). Pure.
135
+ */
136
+ export function pwshArgv(pwsh, args, { msysToWin } = {}) {
137
+ const hasCommand = typeof args.command === 'string' && args.command.trim() !== ''
138
+ const hasScript = typeof args.script_path === 'string' && args.script_path.trim() !== ''
139
+ if (hasCommand === hasScript) return { error: 'provide exactly one of command or script_path' }
140
+ if (hasScript) {
141
+ const raw = /** @type {string} */ (args.script_path)
142
+ // The model speaks MSYS (our prompt dialect); pwsh needs a Windows path.
143
+ const script = msysToWin ? msysToWin(raw) : raw
144
+ return { argv: [pwsh, '-NoLogo', '-NoProfile', '-NonInteractive', '-File', script] }
145
+ }
146
+ return { argv: [pwsh, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', /** @type {string} */ (args.command)] }
147
+ }
148
+
149
+ /**
150
+ * Validate one gitbash_pwsh call's arguments. Strict like ops_bash: an
151
+ * undeclared key is a hard error (the host bash/pwsh tools accept
152
+ * `run_in_background`; this tool does not, and a silent ignore would leave a
153
+ * server occupying the call until timeout). Pure.
154
+ * @returns {string|undefined} an error message, or undefined when valid.
155
+ */
156
+ export function validatePwshArgs(args) {
157
+ if (args === undefined || args === null || typeof args !== 'object' || Array.isArray(args)) {
158
+ return 'invalid arguments: expected an object'
159
+ }
160
+ const unknown = Object.keys(args).filter((key) => !PWSH_ARGUMENTS.includes(key))
161
+ if (unknown.length > 0) {
162
+ return `invalid arguments: unknown key(s) ${unknown.map((key) => JSON.stringify(key)).join(', ')}; this tool accepts ${PWSH_ARGUMENTS.join(', ')}`
163
+ }
164
+ const hasCommand = typeof args.command === 'string' && args.command.trim() !== ''
165
+ const hasScript = typeof args.script_path === 'string' && args.script_path.trim() !== ''
166
+ if (hasCommand === hasScript) return 'invalid pwsh input: provide exactly one of command or script_path'
167
+ if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
168
+ return 'invalid timeoutMs: expected a positive number'
169
+ }
170
+ if (args.timeoutMs !== undefined && /** @type {number} */ (args.timeoutMs) > PWSH_MAX_TIMEOUT_MS) {
171
+ return `invalid timeoutMs: must be no greater than ${PWSH_MAX_TIMEOUT_MS}`
172
+ }
173
+ return undefined
174
+ }
175
+
176
+ /**
177
+ * Resolve the OFFICIAL @deepseek-ai/dsh-tool-bash module entry from the
178
+ * host's own resolution base (the same seam probeHostExtras uses). Returns
179
+ * the absolute entry URL, or '' when the host does not ship the package.
180
+ */
181
+ export async function resolveToolBashEntry(ctx) {
182
+ try {
183
+ const { createRequire } = await import('node:module')
184
+ const { pathToFileURL } = await import('node:url')
185
+ const req = createRequire(ctx?.baseUrl ?? import.meta.url)
186
+ return pathToFileURL(req.resolve('@deepseek-ai/dsh-tool-bash')).href
187
+ } catch {
188
+ return ''
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Activate the OFFICIAL tool-bash inside one agent-owned scope: a child scope
194
+ * of the agent's context keeps every registration (tool, prompt section,
195
+ * effect) revertible at agent disposal, and the scoped-layer tool shadows the
196
+ * (absent) global one exactly the way the registry's own error message
197
+ * prescribes for per-agent variants. The official module is loaded ONCE per
198
+ * process; each agent gets a fresh scope and a fresh apply().
199
+ */
200
+ export async function activateOfficialBash(agent, toolBashHref, createScopeFn, log) {
201
+ let mod
202
+ try {
203
+ mod = await import(toolBashHref)
204
+ } catch (error) {
205
+ log(`official tool-bash module import failed: ${error?.message ?? error}`)
206
+ return undefined
207
+ }
208
+ if (!mod || typeof mod.apply !== 'function') {
209
+ log('official tool-bash module has no apply(); activation skipped')
210
+ return undefined
211
+ }
212
+ const scope = createScopeFn(agent.ctx)
213
+ try {
214
+ await mod.apply(scope.ctx, {})
215
+ return scope
216
+ } catch (error) {
217
+ try { await scope.dispose() } catch { /* best effort */ }
218
+ log(`official tool-bash activation failed: ${error?.message ?? error}`)
219
+ return undefined
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Mount the per-agent reconcile. `deps`:
225
+ * - tools(ctx) -> the host tool registry (or undefined)
226
+ * - createScope(fn) -> cordis createScope
227
+ * - allowed(session) -> danger-full-access verdict for the pwsh channel
228
+ * - echo(text) -> result dialect echo (Windows -> MSYS), or undefined
229
+ * - spawn(spec) -> the host subprocess seam, or undefined
230
+ * - pwshPath -> resolved host pwsh 7, or '' (tool not published)
231
+ * - pwshEnabled() -> the row switch for publishing the pwsh tool
232
+ * - log(message) -> the plugin's tagged logger
233
+ *
234
+ * Registration decisions re-run on EVERY agent creation: a per-agent tool
235
+ * face is per-agent truth, and the observed state (variant presets on, a
236
+ * future official change, dsh-ops appearing) is absorbed automatically
237
+ * instead of being encoded as a boot-time snapshot.
238
+ */
239
+ export function mountInlineTools(ctx, deps) {
240
+ const { tools, createScope, allowed, spawn, pwshPath, echo, log } = deps
241
+ /** agent -> { timer, scope, denyPwsh } */
242
+ const agents = new Map()
243
+ let pwshPublished = false
244
+ let pwshDisposer = undefined
245
+ let toolBashHrefPromise
246
+
247
+ const faceNames = (agent) => {
248
+ try {
249
+ const registry = tools(ctx)
250
+ return (registry?.schemas?.(agent) ?? []).map((schema) => schema.name).filter((name) => typeof name === 'string')
251
+ } catch {
252
+ return []
253
+ }
254
+ }
255
+
256
+ const unpublishPwsh = () => {
257
+ if (pwshDisposer) {
258
+ try { pwshDisposer() } catch { /* already gone */ }
259
+ pwshDisposer = undefined
260
+ }
261
+ pwshPublished = false
262
+ }
263
+
264
+ const publishPwsh = () => {
265
+ if (pwshPublished) return
266
+ if (typeof spawn !== 'function' || !pwshPath) return
267
+ if (deps.pwshEnabled && deps.pwshEnabled() !== true) return
268
+ const registry = tools(ctx)
269
+ if (!registry || typeof registry.register !== 'function') return
270
+ try {
271
+ const disposer = registry.register(gitbashPwshDefinition({ pwsh: pwshPath, spawn, allowed, msysToWin: deps.msysToWin, echo }))
272
+ pwshDisposer = typeof disposer === 'function' ? disposer : undefined
273
+ pwshPublished = true
274
+ log(`pwsh replacement published (pwsh 7 at ${pwshPath}; the host pwsh tool is denied on this host — its executor is Git Bash)`)
275
+ } catch (error) {
276
+ log(`pwsh replacement registration failed: ${error?.message ?? error}`)
277
+ }
278
+ }
279
+
280
+ const reconcile = (agent, { attempt = 0 } = {}) => {
281
+ if (!agent?.ctx) return
282
+ const names = faceNames(agent)
283
+ // The composed face may not be wired yet at created-time. Retry bounded:
284
+ // a face with NEITHER tool carries no verdict, and guessing would deny a
285
+ // real pwsh or duplicate a real bash. Five tries at 250ms, then give up
286
+ // quietly — a slower composition simply gets no inline patch this boot.
287
+ if (attempt < 5 && !names.includes('bash') && !names.includes('pwsh')) {
288
+ const timer = setTimeout(() => { try { reconcile(agent, { attempt: attempt + 1 }) } catch { /* never crash on a timer */ } }, 250)
289
+ timer.unref?.()
290
+ const state = agents.get(agent)
291
+ if (state) state.timer = timer
292
+ else agents.set(agent, { timer, scope: undefined, denyPwsh: undefined })
293
+ return
294
+ }
295
+ const needs = inlineNeeds(names)
296
+ let state = agents.get(agent)
297
+ if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
298
+ if (!state) {
299
+ state = { timer: undefined, scope: undefined, denyPwsh: undefined }
300
+ agents.set(agent, state)
301
+ }
302
+ if (needs.denyPwsh && !state.denyPwsh) {
303
+ // Deny the fake pwsh first: restrict is per-agent and idempotent, and
304
+ // the lying tool must never be callable, not even for the milliseconds
305
+ // before the bash tool lands.
306
+ try {
307
+ const deny = tools(ctx)?.restrict?.({ deny: ['pwsh'] })
308
+ if (typeof deny === 'function') state.denyPwsh = deny
309
+ } catch (error) {
310
+ log(`fake-pwsh deny failed: ${error?.message ?? error}`)
311
+ }
312
+ }
313
+ if (needs.needsBash && !state.scope) {
314
+ if (toolBashHrefPromise === undefined) toolBashHrefPromise = resolveToolBashEntry(ctx)
315
+ void toolBashHrefPromise.then(async (href) => {
316
+ if (href === '') {
317
+ log('official @deepseek-ai/dsh-tool-bash is not resolvable from the host base; bash stays absent for this agent')
318
+ return
319
+ }
320
+ const scope = await activateOfficialBash(agent, href, createScope, log)
321
+ if (scope && agents.get(agent) === state) {
322
+ state.scope = scope
323
+ log('official bash tool activated for this agent (Git Bash host; fake pwsh denied)')
324
+ }
325
+ }).catch(() => { /* logged inside activateOfficialBash */ })
326
+ }
327
+ // The pwsh replacement follows the ops_ presence: dsh-ops already ships a
328
+ // PowerShell story through the HOST pwsh tool — which our own deny would
329
+ // otherwise silence — so ours fills that gap instead of duplicating tools.
330
+ // The row switch is re-read per reconcile too, so a flip lands at the
331
+ // next agent (or on the next event after a hot toggle).
332
+ if (needs.opsPresent || (deps.pwshEnabled && deps.pwshEnabled() !== true)) unpublishPwsh()
333
+ else publishPwsh()
334
+ }
335
+
336
+ const teardown = (agent) => {
337
+ const state = agents.get(agent)
338
+ if (!state) return
339
+ agents.delete(agent)
340
+ if (state.timer) clearTimeout(state.timer)
341
+ if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
342
+ if (state.scope) { void state.scope.dispose().catch(() => {}) }
343
+ }
344
+
345
+ try {
346
+ ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
347
+ ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
348
+ } catch (error) {
349
+ log(`agent lifecycle wiring failed: ${error?.message ?? error}`)
350
+ return
351
+ }
352
+
353
+ ctx.effect(() => () => {
354
+ for (const agent of [...agents.keys()]) teardown(agent)
355
+ agents.clear()
356
+ unpublishPwsh()
357
+ }, 'dsh-gitbash-shell: inline tool patching')
358
+ }
359
+
360
+ /**
361
+ * The pwsh-replacement tool definition, bound to a resolved pwsh 7.
362
+ *
363
+ * It spawns through the HOST's `ctx.subprocess` service (credential scrub +
364
+ * process governance + output spill are the service's own guarantees; the
365
+ * bash executors consume the same seam). There is deliberately NO file
366
+ * sandbox on this channel — the tool is published only to danger-full-access
367
+ * sessions and says so in the error it raises elsewhere.
368
+ * @param {object} options
369
+ * @param {string} options.pwsh - resolved pwsh.exe path.
370
+ * @param {(spec: object) => { done: Promise<object>, collected?: object }} options.spawn - the host subprocess seam.
371
+ * @param {(session: object|undefined) => boolean} options.allowed - per-session permission verdict.
372
+ * @param {(path: string) => string} [options.msysToWin] - argument dialect translation (MSYS -> Windows), the same mount table the file tools use.
373
+ * @param {(text: string) => string} [options.echo] - result dialect echo (Windows -> MSYS), gated by the caller.
374
+ */
375
+ export function gitbashPwshDefinition({ pwsh, spawn, allowed, msysToWin, echo }) {
376
+ return {
377
+ name: 'gitbash_pwsh',
378
+ description: gitbashPwshDescription(),
379
+ parameters: {
380
+ type: 'object',
381
+ properties: {
382
+ command: { type: 'string', description: 'Short PowerShell command; for long or multi-line scripts use script_path.' },
383
+ script_path: { type: 'string', description: 'Existing script path; use instead of command for long or multi-line scripts.' },
384
+ workdir: { type: 'string', description: 'Working directory; defaults to the session workspace when omitted.' },
385
+ timeoutMs: { type: 'number', description: 'Timeout in milliseconds; the command is killed on expiry. Defaults to 120000.' },
386
+ },
387
+ required: ['command'],
388
+ additionalProperties: false,
389
+ },
390
+ async execute(args, exec) {
391
+ const invalid = validatePwshArgs(args)
392
+ if (invalid) throw new Error(invalid)
393
+ if (!allowed(exec?.agent?.session)) {
394
+ throw new Error('gitbash_pwsh requires danger-full-access for this session; this channel has no file sandbox.')
395
+ }
396
+ const built = pwshArgv(pwsh, args, { msysToWin })
397
+ if (built.error) throw new Error(built.error)
398
+ const timeoutMs = args.timeoutMs === undefined ? PWSH_DEFAULT_TIMEOUT_MS : args.timeoutMs
399
+ const controller = new AbortController()
400
+ const timer = setTimeout(() => controller.abort(new Error(`command timed out after ${timeoutMs}ms`)), timeoutMs)
401
+ timer.unref?.()
402
+ try {
403
+ const handle = spawn({
404
+ argv: built.argv,
405
+ cwd: typeof args.workdir === 'string' && args.workdir !== '' ? args.workdir : undefined,
406
+ stdio: {
407
+ stdin: 'ignore',
408
+ stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
409
+ stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
410
+ },
411
+ signal: controller.signal,
412
+ })
413
+ const outcome = await handle.done
414
+ const streamText = (reader) => {
415
+ if (reader === undefined || typeof reader.readFrom !== 'function') return ''
416
+ const read = reader.readFrom(0)
417
+ const text = read.text ?? ''
418
+ return echo ? echo(text) : text
419
+ }
420
+ const stdout = streamText(handle.collected?.stdout)
421
+ const stderr = streamText(handle.collected?.stderr)
422
+ let body = stdout
423
+ if (stderr !== '') body += (body !== '' && !body.endsWith('\n') ? '\n' : '') + '[stderr]\n' + stderr
424
+ if (body === '') body = '(no output)'
425
+ const markers = []
426
+ if (outcome?.signal !== undefined && outcome?.signal !== null) markers.push(`[signal: ${outcome.signal}]`)
427
+ if (outcome?.exitCode !== undefined && outcome?.exitCode !== null && outcome.exitCode !== 0) markers.push(`[exit code: ${outcome.exitCode}]`)
428
+ return { content: [{ type: 'text', text: [body, ...markers].join('\n') }] }
429
+ } finally {
430
+ clearTimeout(timer)
431
+ }
432
+ },
433
+ }
434
+ }