dsh-gitbash-shell 0.38.5 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 shell-isolated cordis scope, never registered as the ROOT shell), and mirrors
30
+ * the official tool's schema, background jobs, escalation and rendering
31
+ * call-for-call.
32
+ * 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge stands
33
+ * down, the pwsh hiding stands down (dsh-ops owns the host-pwsh story),
34
+ * and a bash tool they already publish means ours is not registered.
35
+ * 3. `pwshTool` switch OFF (the default) -> the REAL host `pwsh` tool is
36
+ * hidden per agent (`tools.restrict`) and denied at the call layer. With
37
+ * route B the host pwsh is REAL PowerShell again — hiding it is a pure
38
+ * product choice (bash-first host), not a safety denial, and flipping
39
+ * the switch surfaces it unchanged.
35
40
  *
36
41
  * Everything here is Windows-only by caller gate; on other hosts the module
37
42
  * loads but mounts nothing.
38
43
  */
39
44
 
40
- /**
41
- * The tool-result contract the HOST registry requires from every registered
42
- * tool (issue #18, root cause 2): `register()` throws
43
- * `must declare output { schema, render, presentationMeta? }` unless the
44
- * definition carries an `output` whose `schema` passes
45
- * `assertSupportedJsonSchema` and whose `render` is a function. Both of this
46
- * plugin's tool families return `{ content: [{ type: 'text', text }] }` from
47
- * `execute`, so the schema and the text renderer below are shared verbatim
48
- * with the FastCtx bridge — the same frozen shape dsh-ops ships for its own
49
- * MCP-backed tools (proven on real Windows hosts).
50
- */
45
+ /** FastCtx-bridge shared output contract (unchanged since v0.35.0). */
51
46
  export const TOOL_OUTPUT_SCHEMA = Object.freeze({
52
47
  type: 'object',
53
48
  properties: {
@@ -63,8 +58,6 @@ export const TOOL_OUTPUT_SCHEMA = Object.freeze({
63
58
  * blocks and non-string text fields are skipped; empty content renders as one
64
59
  * empty text block rather than an empty array, so the card never loses its
65
60
  * placeholder.
66
- * @param {unknown} value - the execute return value.
67
- * @returns {Array<{ type: string, text: string }>} content blocks for the host renderer.
68
61
  */
69
62
  export function renderTextContent(value) {
70
63
  const blocks = Array.isArray(value?.content) ? value.content : []
@@ -75,18 +68,25 @@ export function renderTextContent(value) {
75
68
  return [{ type: 'text', text }]
76
69
  }
77
70
 
78
- /** 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,187 +121,56 @@ export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths
121
121
  /** @deprecated composed form kept for tests: route + MSYS sentences. */
122
122
  export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM
123
123
 
124
- /** The pwsh-replacement tool's model-facing description. */
125
- export function gitbashPwshDescription() {
126
- return 'Run a command in Windows PowerShell 7 (pwsh). Use ONLY when a Windows-native capability '
127
- + '(cmdlets, registry, services, WMI, scheduled tasks) is required — for everything else prefer the bash tool. '
128
- + 'Each call uses a fresh shell; set workdir explicitly. Paths inside command must be Windows form (C:/... or C:\\\\...). '
129
- + 'For scripts longer than about 1 KB, or containing quotes/backslashes/non-ASCII text, write the script to a file first '
130
- + 'and pass script_path instead. Output is UTF-8; long output is truncated with the full text saved to a reported file. '
131
- + 'Fix failed pwsh commands here; do not switch shells or mix PowerShell syntax into bash commands.'
132
- }
133
-
134
- /**
135
- * Resolve the host's own PowerShell 7 — never a bundled payload, never
136
- * Windows PowerShell 5.1. Mirrors the probe order the official pwsh resolver
137
- * documents (well-known install locations, then PATH), WITHOUT its 5.1
138
- * fallback: if no pwsh 7 exists the caller simply does not publish the tool.
139
- * Pure I/O via the injected `exists` so the candidate matrix is testable.
140
- * @returns {string} an absolute pwsh.exe path, or '' when absent.
141
- */
142
- export function resolveHostPwsh7({ env = process.env, exists, platform = process.platform } = {}) {
143
- if (platform !== 'win32') return ''
144
- const programFiles = env.ProgramFiles ?? env.PROGRAMFILES ?? 'C:\\Program Files'
145
- const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
146
- const localAppData = env.LOCALAPPDATA ?? env.LocalAppData ?? ''
147
- const userProfile = env.USERPROFILE ?? ''
148
- const candidates = [
149
- `${programFiles}\\PowerShell\\7\\pwsh.exe`,
150
- `${programFiles}\\PowerShell\\7-preview\\pwsh.exe`,
151
- `${programFilesX86}\\PowerShell\\7\\pwsh.exe`,
152
- localAppData === '' ? '' : `${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`,
153
- userProfile === '' ? '' : `${userProfile}\\scoop\\shims\\pwsh.exe`,
154
- 'C:\\ProgramData\\chocolatey\\bin\\pwsh.exe',
155
- ].filter((entry) => entry !== '')
156
- for (const candidate of candidates) {
157
- if (exists(candidate)) return candidate
158
- }
159
- // PATH last: a PATH hit may be a store alias or shim, so it ranks below the
160
- // well-known installs but still beats "no tool at all".
161
- const pathValue = env.PATH ?? env.Path ?? ''
162
- for (const dir of pathValue.split(';')) {
163
- const clean = dir.trim().replace(/^"(.*)"$/, '$1')
164
- if (clean === '') continue
165
- const candidate = `${clean.replace(/[\\/]+$/, '')}\\pwsh.exe`
166
- if (exists(candidate)) return candidate
167
- }
168
- return ''
169
- }
170
-
171
- /** Arguments `pwsh7` accepts — strict: anything else is rejected. */
172
- export const PWSH_ARGUMENTS = Object.freeze(['command', 'script_path', 'workdir', 'timeoutMs'])
173
-
174
- const PWSH_DEFAULT_TIMEOUT_MS = 120_000
175
- const PWSH_MAX_TIMEOUT_MS = 600_000
176
-
177
- /**
178
- * Build the argv for one pwsh invocation. `command` and `script_path` are
179
- * exclusive; a script path keeps the script's bytes OUT of the tool-call JSON
180
- * entirely (the whole point of the dsh-ops issue #7 lesson: long heredoc-heavy
181
- * commands inside one JSON string raise the malformed-tool-call rate and burn
182
- * a whole model turn). Pure.
183
- */
184
- export function pwshArgv(pwsh, args, { msysToWin } = {}) {
185
- const hasCommand = typeof args.command === 'string' && args.command.trim() !== ''
186
- const hasScript = typeof args.script_path === 'string' && args.script_path.trim() !== ''
187
- if (hasCommand === hasScript) return { error: 'provide exactly one of command or script_path' }
188
- if (hasScript) {
189
- const raw = /** @type {string} */ (args.script_path)
190
- // The model speaks MSYS (our prompt dialect); pwsh needs a Windows path.
191
- const script = msysToWin ? msysToWin(raw) : raw
192
- return { argv: [pwsh, '-NoLogo', '-NoProfile', '-NonInteractive', '-File', script] }
193
- }
194
- return { argv: [pwsh, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', /** @type {string} */ (args.command)] }
195
- }
196
-
197
124
  /**
198
- * 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
129
+ export function hiddenPwshDenyReason() {
130
+ return 'the pwsh tool is hidden on this host by the dsh-gitbash-shell settings (pwsh tool switch, off by default). Use the bash tool for commands. If you need Windows-native work (registry, services, WMI), ask the user to enable the pwsh tool on the dsh-gitbash-shell settings card.'
222
131
  }
223
132
 
224
- /** The official bash-tool package the inline patch activates. */
225
- const TOOL_BASH_PACKAGE = '@deepseek-ai/dsh-tool-bash'
133
+ /** The host packages the own bash tool consumes, resolved once per process. */
134
+ const HOST_MODULES = [
135
+ ['toolsModule', '@deepseek-ai/dsh-tools'],
136
+ ['sandboxModule', '@deepseek-ai/dsh-sandbox'],
137
+ ['llmModule', '@deepseek-ai/dsh-llm'],
138
+ ]
226
139
 
227
140
  /**
228
- * Resolve the OFFICIAL @deepseek-ai/dsh-tool-bash module. TWO channels, in
229
- * order (v0.38.1, the desktop-host field report):
141
+ * Mount the executor through the host class-plugin path. A plugin fiber owns
142
+ * effects, but does NOT create a service namespace: ShellExecutor's constructor
143
+ * still provides `shell`. Isolate ONLY that service, retaining the host's
144
+ * subprocess/sandbox/policy dependencies and leaving the official shell intact.
230
145
  *
231
- * ① 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.
146
+ * The empty scope does not inject shell, so read its active provider through
147
+ * get(), not the property-access path that requires an inject declaration.
241
148
  */
242
- export async function resolveToolBashEntry(ctx) {
149
+ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope, { timeoutMs = 8000 } = {}) {
150
+ const executorScope = createScope(ctx.isolate('shell'))
151
+ let timer
243
152
  try {
244
- const 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 }
153
+ const mountTimeout = new Promise((_, reject) => {
154
+ timer = setTimeout(() => reject(new Error('executor scope mount timed out (a required service may be absent in this composition)')), timeoutMs)
155
+ })
156
+ const executor = await Promise.race([
157
+ (async () => {
158
+ if (executorScope.fiber) await executorScope.fiber
159
+ const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
160
+ await fiber
161
+ return executorScope.ctx.get('shell')
162
+ })(),
163
+ mountTimeout,
164
+ ])
165
+ if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
166
+ throw new Error('executor instance did not expose resolve()/execute()')
248
167
  }
249
- } 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
- }
259
- }
260
-
261
- /**
262
- * Activate the OFFICIAL tool-bash inside one agent-owned scope: a child scope
263
- * of the agent's context keeps every registration (tool, prompt section,
264
- * effect) revertible at agent disposal, and the scoped-layer tool shadows the
265
- * (absent) global one exactly the way the registry's own error message
266
- * prescribes for per-agent variants. The official module is loaded ONCE per
267
- * process; each agent gets a fresh scope and a fresh apply().
268
- */
269
- export async function activateOfficialBash(agent, resolved, log) {
270
- let mod
271
- try {
272
- mod = await resolved.load()
273
- } catch (error) {
274
- log(`official tool-bash module import failed: ${error?.message ?? error}`)
275
- return undefined
276
- }
277
- if (!mod || typeof mod.apply !== 'function') {
278
- log('official tool-bash module has no apply(); activation skipped')
279
- return undefined
280
- }
281
- // Mount through cordis's OWN plugin path. The official module object
282
- // DECLARES its service needs (`inject: ['tools','shell','systemPrompt',
283
- // 'shellEnv','workingDirectory']`); a hand-called mod.apply(ctx) has no
284
- // such declaration, so cordis refuses every service read with
285
- // `cannot get property "shell" without inject` (v0.38.2 field report).
286
- // ctx.plugin(mod) registers a REAL fiber with the declaration honored,
287
- // runs apply itself, and the wrapped fiber is a thenable — awaiting it
288
- // waits for the activation (and its async fiber start) to finish.
289
- let fiber
290
- try {
291
- fiber = agent.ctx.plugin(mod)
168
+ return { executor, executorScope }
292
169
  } catch (error) {
293
- 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 */ } },
170
+ await executorScope.dispose()
171
+ throw error
172
+ } finally {
173
+ clearTimeout(timer)
305
174
  }
306
175
  }
307
176
 
@@ -310,28 +179,24 @@ export async function activateOfficialBash(agent, resolved, log) {
310
179
  * - tools(ctx) -> the host tool registry (or undefined)
311
180
  * - agents(ctx) -> the host agents service (or undefined)
312
181
  * - createScope(fn) -> cordis createScope
313
- * - 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
182
+ * - pwshEnabled() -> the row switch; TRUE shows the real host pwsh tool,
183
+ * false/undefined (default) hides it
318
184
  * - log(message) -> the plugin's tagged logger
319
185
  *
320
186
  * Registration decisions re-run on EVERY agent creation AND once at mount
321
- * time for every ALREADY-LIVE agent (the dsh-ops-proven shape,
322
- * `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
187
+ * time for every ALREADY-LIVE agent (the dsh-ops-proven shape): this row can
188
+ * activate long after agents exist. A second, INDEPENDENT layer guards the
327
189
  * call itself through `tools/pre-execute`: visibility restriction can lose a
328
- * race, and a lying pwsh that reaches its executor still runs Git Bash.
190
+ * race.
329
191
  */
330
192
  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 */
193
+ const { tools, agents, createScope, pwshEnabled, log } = deps
194
+ /** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */
333
195
  const tracked = new Map()
334
- let toolBashResolvePromise
196
+ /** Resolved once per process: { executor, executorScope, module }. */
197
+ let bashOnce
198
+ /** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
199
+ let opsSeen = false
335
200
 
336
201
  const faceNames = (agent) => {
337
202
  try {
@@ -343,30 +208,59 @@ export function mountInlineTools(ctx, deps) {
343
208
  }
344
209
 
345
210
  /**
346
- * 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.
211
+ * Construct the private executor and the own bash-tool module ONCE per
212
+ * process. The executor mounts through cordis's own class-plugin path in a
213
+ * PRIVATE scope (`ctx.plugin(class)` honours the static inject/Config
214
+ * declarations — the §4k schemastery semantics come for free), so it never
215
+ * touches the root `ctx.shell` service slot the official pwsh executor
216
+ * owns. The executor scope lives for the plugin's lifetime and is disposed
217
+ * by the effect registered at the bottom of this function.
350
218
  */
351
- const providePwsh = async (agent, state) => {
352
- if (state.pwshScope) return
353
- if (typeof spawn !== 'function' || !pwshPath) return
354
- if (!createScope) return
219
+ const bashOncePromise = async () => {
220
+ if (bashOnce) return bashOnce
221
+ bashOnce = (async () => {
222
+ const { GitBashSandboxExecutor } = await import('./shell.js')
223
+ if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
224
+ if (!GitBashSandboxExecutor.Config) {
225
+ throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
226
+ }
227
+ if (typeof createScope !== 'function') throw new Error('no createScope seam')
228
+ const { executor, executorScope } = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
229
+ const hostModules = {}
230
+ for (const [key, name] of HOST_MODULES) {
231
+ try {
232
+ hostModules[key] = await resolveHostModule(ctx, name)
233
+ } catch { hostModules[key] = undefined }
234
+ }
235
+ const { createBashToolModule } = await import('./bash-tool.js')
236
+ const module = createBashToolModule({ executor, log, ...hostModules })
237
+ return { executor, executorScope, module }
238
+ })()
239
+ bashOnce.catch(() => { bashOnce = undefined })
240
+ return bashOnce
241
+ }
242
+
243
+ /** Register the own bash tool inside one agent-owned scope. */
244
+ const provideBash = async (agent, state) => {
245
+ if (state.scope) return
355
246
  try {
247
+ const { executor, module } = await bashOncePromise()
248
+ log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
356
249
  const scope = createScope(agent.ctx)
357
- // Same asynchronous-fiber shape as activateOfficialBash: wait for the
358
- // apply callback to capture scope.ctx before touching its tools.
250
+ // Same asynchronous-fiber shape the official module needed: wait for
251
+ // the apply callback to capture scope.ctx before touching its tools.
359
252
  if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
360
- const registry = scope.ctx?.tools
361
- if (!registry || typeof registry.register !== 'function') {
253
+ const registry = scope.ctx?.get('tools')
254
+ if (!registry || typeof scope.ctx.plugin !== 'function') {
362
255
  void scope.dispose().catch(() => {})
363
256
  return
364
257
  }
365
- 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})`)
258
+ const fiber = scope.ctx.plugin(module)
259
+ await fiber
260
+ state.scope = scope
261
+ log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
368
262
  } catch (error) {
369
- log(`pwsh7 tool registration failed for this agent: ${error?.message ?? error}`)
263
+ log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
370
264
  }
371
265
  }
372
266
 
@@ -374,9 +268,8 @@ export function mountInlineTools(ctx, deps) {
374
268
  if (!agent?.ctx) return
375
269
  const names = faceNames(agent)
376
270
  // The composed face may not be wired yet at created-time. Retry bounded:
377
- // a face with NEITHER tool carries no verdict, 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.
271
+ // a face with NEITHER tool carries no verdict. Five tries at 250ms, then
272
+ // give up quietly — a slower composition simply gets no inline patch.
380
273
  if (attempt < 5 && !names.includes('bash') && !names.includes('pwsh')) {
381
274
  const timer = setTimeout(() => { try { reconcile(agent, { attempt: attempt + 1 }) } catch { /* never crash on a timer */ } }, 250)
382
275
  timer.unref?.()
@@ -385,56 +278,36 @@ export function mountInlineTools(ctx, deps) {
385
278
  else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
386
279
  return
387
280
  }
388
- const needs = inlineNeeds(names)
281
+ const pwshVisible = deps.pwshEnabled?.() === true
282
+ const needs = inlineNeeds(names, { pwshVisible })
283
+ if (needs.opsPresent) opsSeen = true
389
284
  let state = tracked.get(agent)
390
285
  if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
391
286
  if (!state) {
392
- state = { timer: undefined, scope: undefined, denyPwsh: undefined, pwshScope: undefined }
287
+ state = { timer: undefined, scope: undefined, denyPwsh: undefined }
393
288
  tracked.set(agent, state)
394
289
  }
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
- }
290
+ // The pwsh face (route B): the host tool is REAL PowerShell now, so the
291
+ // default is a product-level HIDE, not a safety denial.
292
+ // dsh-ops present -> hands off entirely (their host-pwsh story stands).
293
+ // switch OFF -> hide the tool for this agent (tools.restrict).
294
+ // switch ON -> leave the real tool visible, untouched.
295
+ if (needs.hidePwsh && !needs.opsPresent) {
296
+ if (!state.denyPwsh) {
297
+ // Through the AGENT's scoped context (`agent.ctx.tools`, the dsh-ops
298
+ // -proven shape): on the ROOT context the host throws "requires a
299
+ // scoped context" and the restriction silently never exists (issue
300
+ // #18, root cause 3).
301
+ try {
302
+ const deny = agent.ctx?.tools?.restrict?.({ deny: ['pwsh'] })
303
+ if (typeof deny === 'function') state.denyPwsh = deny
304
+ } catch (error) {
305
+ log(`pwsh hide failed: ${error?.message ?? error}`)
421
306
  }
422
- if (deps.pwshEnabled && deps.pwshEnabled() === true) void providePwsh(agent, state)
423
307
  }
424
308
  }
425
309
  if (needs.needsBash && !state.scope) {
426
- 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 */ })
310
+ void provideBash(agent, state)
438
311
  }
439
312
  }
440
313
 
@@ -444,30 +317,26 @@ export function mountInlineTools(ctx, deps) {
444
317
  tracked.delete(agent)
445
318
  if (state.timer) clearTimeout(state.timer)
446
319
  if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
447
- if (state.pwshScope) { void state.pwshScope.dispose().catch(() => {}) }
448
320
  if (state.scope) { void state.scope.dispose().catch(() => {}) }
449
321
  }
450
322
 
451
323
  try {
452
324
  ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
453
325
  ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
454
- // MOUNT-TIME SWEEP (v0.35.2, 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.
326
+ // MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
327
+ // mounts never fire agent/created. Same shape the proven dsh-ops denial
328
+ // uses.
459
329
  for (const agent of agents?.(ctx)?.list?.() ?? []) {
460
330
  try { reconcile(agent) } catch { /* one broken agent never blocks the rest */ }
461
331
  }
462
- // CALL-LAYER GUARD (v0.35.2, second independent layer; 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.
332
+ // CALL-LAYER GUARD (v0.35.2, second independent layer; route B wording):
333
+ // while the row switch hides the real pwsh tool, a `pwsh` call that slips
334
+ // past a visibility race is denied with the switch named. With the switch
335
+ // ON the REAL tool answers for itself.
467
336
  ctx.on('tools/pre-execute', (exec, next) => {
468
337
  try {
469
- if (exec?.name === 'pwsh') {
470
- return { kind: 'deny', reason: '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.' }
338
+ if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) {
339
+ return { kind: 'deny', reason: hiddenPwshDenyReason() }
471
340
  }
472
341
  } catch { /* never block the waterfall */ }
473
342
  return next()
@@ -480,91 +349,17 @@ export function mountInlineTools(ctx, deps) {
480
349
  ctx.effect(() => () => {
481
350
  for (const agent of [...tracked.keys()]) teardown(agent)
482
351
  tracked.clear()
352
+ if (bashOnce) {
353
+ void Promise.resolve(bashOnce).then(({ executorScope } = {}) => {
354
+ try { executorScope?.dispose?.() } catch { /* already gone */ }
355
+ }).catch(() => {})
356
+ bashOnce = undefined
357
+ }
483
358
  }, 'dsh-gitbash-shell: inline tool patching')
484
359
  }
485
360
 
486
- /**
487
- * 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
- }
361
+ // The host-package resolution channel lives in bash-tool.js; import it for
362
+ // local use AND re-export it so the host half (and the tests) see ONE
363
+ // implementation.
364
+ import { resolveHostModule } from './bash-tool.js'
365
+ export { resolveHostModule }