dsh-gitbash-shell 0.34.3 → 0.35.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 +21 -0
- package/README.md +38 -15
- package/README_EN.md +29 -8
- package/dsh.plugin.json +2 -2
- package/package.json +4 -1
- package/src/client.js +170 -0
- package/src/fastctx-bridge.js +378 -0
- package/src/index.js +229 -8
- package/src/inline-tools.js +434 -0
|
@@ -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
|
+
}
|