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.
@@ -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 — 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:
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
- * One reconcile per agent reads the face and acts on three facts:
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 -> 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
- * pwsh7 stands 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.
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
- /** Answer for one observed tool face. Pure. */
79
- export function inlineNeeds(names) {
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, and the pwsh tool would execute
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
- denyPwsh: has('pwsh') && !has('bash'),
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 and pwsh replacement would be duplicates.
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 OFF by default, and a routing sentence that names a tool
98
- * the agent cannot see only teaches the model to call a denied name. The ON
99
- * variant names `pwsh` (the real PowerShell 7 tool that shadows the fake
100
- * global one); the OFF variant states bash-only routing. Both hold regardless
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 pwsh7 tool ONLY when a Windows-native capability (cmdlets, registry, services, WMI) is truly required.',
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
- * Validate one pwsh7 call's arguments. Strict like ops_bash: an
199
- * undeclared key is a hard error (the host bash/pwsh tools accept
200
- * `run_in_background`; this tool does not, and a silent ignore would leave a
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 validatePwshArgs(args) {
205
- if (args === undefined || args === null || typeof args !== 'object' || Array.isArray(args)) {
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
- * 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)
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
- * - allowed(session) -> danger-full-access verdict for the pwsh channel
314
- * - echo(text) -> result dialect echo (Windows -> MSYS), or undefined
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
- * `installHostShellDenial`): this row can activate long after agents exist —
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, and a lying pwsh that reaches its executor still runs Git Bash.
153
+ * race.
329
154
  */
330
155
  export function mountInlineTools(ctx, deps) {
331
- const { tools, agents, createScope, allowed, spawn, pwshPath, echo, log } = deps
332
- /** agent -> { timer, scope, denyPwsh, pwshScope }; named `tracked` because `agents` above is the host service */
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
- let toolBashResolvePromise
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
- * Provide the additive `pwsh7` tool inside one agent-owned scope (v0.37.0).
347
- * The name never collides with the host's fake global `pwsh`, so this is a
348
- * pure ADDITION — it shadows nothing and requires no deny juggling. The
349
- * scope keeps the registration revertible at agent disposal.
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 providePwsh = async (agent, state) => {
352
- if (state.pwshScope) return
353
- if (typeof spawn !== 'function' || !pwshPath) return
354
- if (!createScope) return
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 as activateOfficialBash: wait for the
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 registry.register !== 'function') {
230
+ if (!registry || typeof scope.ctx.plugin !== 'function') {
362
231
  void scope.dispose().catch(() => {})
363
232
  return
364
233
  }
365
- registry.register(gitbashPwshDefinition({ pwsh: pwshPath, spawn, allowed, msysToWin: deps.msysToWin, echo }))
366
- state.pwshScope = scope
367
- log(`pwsh7 tool provided for this agent (real PowerShell 7 at ${pwshPath})`)
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(`pwsh7 tool registration failed for this agent: ${error?.message ?? error}`)
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, and guessing would deny a
378
- // real pwsh or duplicate a real bash. Five tries at 250ms, then give up
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 needs = inlineNeeds(names)
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, pwshScope: undefined }
263
+ state = { timer: undefined, scope: undefined, denyPwsh: undefined }
393
264
  tracked.set(agent, state)
394
265
  }
395
- // The pwsh face has three facts (v0.37.0, user decision — the pwsh7 tool
396
- // is OFF by default, and its switch lives ONLY on this plugin's row):
397
- // dsh-ops present -> hands off entirely (their host-pwsh story stands;
398
- // our pwsh7 and our deny would both be duplicates).
399
- // the fake global `pwsh` (executor = ctx.shell = Git Bash) is DENIED
400
- // per agent in EVERY case — the lying name is never
401
- // callable, and `pwsh7` cannot be caught by it.
402
- // switch ON -> the REAL PowerShell 7 tool is ADDED as `pwsh7` in
403
- // an agent-owned scope (pure addition, no shadowing).
404
- // The row switch is re-read per reconcile, so a flip lands at the next
405
- // agent (or on the next event after a hot toggle).
406
- if (needs.denyPwsh) {
407
- if (needs.opsPresent) {
408
- // hands off
409
- } else {
410
- if (!state.denyPwsh) {
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
- if (toolBashResolvePromise === undefined) toolBashResolvePromise = resolveToolBashEntry(ctx)
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, the Windows field report): agents that
455
- // already exist when this row mounts never fire agent/created — a plugin
456
- // hot-reload or a host that resumed sessions before this bundle row
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; v0.37.0 wording):
463
- // a `pwsh` call ALWAYS reaches the fake global tool (executor = ctx.shell
464
- // = Git Bash on this host) — our real tool is `pwsh7`, a different name —
465
- // so the fake name is denied at dispatch unconditionally, with the
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: 'the host pwsh tool would run Git Bash on this host (its executor was replaced). Use the bash tool for commands. If you need Windows-native work (registry, services, WMI), the real PowerShell 7 tool is pwsh7 — enable it on the dsh-gitbash-shell settings card if it is not present.' }
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
- * The pwsh-replacement tool definition, bound to a resolved pwsh 7.
488
- *
489
- * It spawns through the HOST's `ctx.subprocess` service (credential scrub +
490
- * process governance + output spill are the service's own guarantees; the
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 }