dsh-gitbash-shell 0.39.0 → 0.39.2

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.
@@ -26,7 +26,7 @@
26
26
  * 1. `pwsh` present + `bash` absent -> register the plugin's OWN `bash`
27
27
  * tool (src/bash-tool.js) in an agent-owned scope. It executes through a
28
28
  * PRIVATE GitBashSandboxExecutor instance (constructed once per process
29
- * in a private cordis scope, never registered as a service), and mirrors
29
+ * in a shell-isolated cordis scope, never registered as the ROOT shell), and mirrors
30
30
  * the official tool's schema, background jobs, escalation and rendering
31
31
  * call-for-call.
32
32
  * 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge stands
@@ -137,6 +137,43 @@ const HOST_MODULES = [
137
137
  ['llmModule', '@deepseek-ai/dsh-llm'],
138
138
  ]
139
139
 
140
+ /**
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.
145
+ *
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.
148
+ */
149
+ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope, { timeoutMs = 8000 } = {}) {
150
+ const executorScope = createScope(ctx.isolate('shell'))
151
+ let timer
152
+ try {
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()')
167
+ }
168
+ return { executor, executorScope }
169
+ } catch (error) {
170
+ await executorScope.dispose()
171
+ throw error
172
+ } finally {
173
+ clearTimeout(timer)
174
+ }
175
+ }
176
+
140
177
  /**
141
178
  * Mount the per-agent reconcile. `deps`:
142
179
  * - tools(ctx) -> the host tool registry (or undefined)
@@ -154,8 +191,25 @@ const HOST_MODULES = [
154
191
  */
155
192
  export function mountInlineTools(ctx, deps) {
156
193
  const { tools, agents, createScope, pwshEnabled, log } = deps
157
- /** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */
194
+ /** Per-agent mounts, masks and cancellation; `agents` above is the host service. */
158
195
  const tracked = new Map()
196
+ const pending = new Set()
197
+ const controller = new AbortController()
198
+ let closed = false
199
+ const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
200
+ const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() })
201
+ // Loader imports cannot be cancelled, but no continuation may outlive its
202
+ // owner. Join our cancelled continuations without waiting on a stuck loader.
203
+ const untilDisposed = async (promise, signal) => {
204
+ signal.throwIfAborted()
205
+ let abort
206
+ const cancelled = new Promise((_, reject) => {
207
+ abort = () => reject(signal.reason)
208
+ signal.addEventListener('abort', abort, { once: true })
209
+ })
210
+ try { return await Promise.race([promise, cancelled]) }
211
+ finally { signal.removeEventListener('abort', abort) }
212
+ }
159
213
  /** Resolved once per process: { executor, executorScope, module }. */
160
214
  let bashOnce
161
215
  /** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
@@ -177,71 +231,90 @@ export function mountInlineTools(ctx, deps) {
177
231
  * declarations — the §4k schemastery semantics come for free), so it never
178
232
  * touches the root `ctx.shell` service slot the official pwsh executor
179
233
  * owns. The executor scope lives for the plugin's lifetime and is disposed
180
- * by the effect registered at the bottom of this function.
234
+ * by the ownership effect registered before lifecycle wiring.
181
235
  */
182
236
  const bashOncePromise = async () => {
237
+ controller.signal.throwIfAborted()
183
238
  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 }
239
+ const mounting = (async () => {
240
+ let executorScope
241
+ try {
242
+ const { GitBashSandboxExecutor } = await untilDisposed(import('./shell.js'), controller.signal)
243
+ if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
244
+ if (!GitBashSandboxExecutor.Config) {
245
+ throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
246
+ }
247
+ if (typeof createScope !== 'function') throw new Error('no createScope seam')
248
+ controller.signal.throwIfAborted()
249
+ const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
250
+ executorScope = mounted.executorScope
251
+ controller.signal.throwIfAborted()
252
+ const hostModules = {}
253
+ for (const [key, name] of HOST_MODULES) {
254
+ hostModules[key] = await untilDisposed(resolveHostModule(ctx, name), controller.signal)
255
+ }
256
+ const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal)
257
+ const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules })
258
+ return { executor: mounted.executor, executorScope, module }
259
+ } catch (error) {
260
+ if (executorScope) await executorScope.dispose()
261
+ throw error
210
262
  }
211
- const { createBashToolModule } = await import('./bash-tool.js')
212
- const module = createBashToolModule({ executor, log, ...hostModules })
213
- return { executor, executorScope, module }
214
263
  })()
215
- bashOnce.catch(() => { bashOnce = undefined })
216
- return bashOnce
264
+ bashOnce = mounting
265
+ mounting.catch(() => { if (bashOnce === mounting) bashOnce = undefined })
266
+ return mounting
217
267
  }
218
268
 
219
269
  /** Register the own bash tool inside one agent-owned scope. */
220
270
  const provideBash = async (agent, state) => {
221
- if (state.scope) return
271
+ let scope
272
+ let retained = false
222
273
  try {
223
- const { executor, module } = await bashOncePromise()
274
+ const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal)
275
+ if (!isLive(agent, state)) return
224
276
  log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
225
- const scope = createScope(agent.ctx)
277
+ scope = createScope(agent.ctx)
278
+ state.scope = scope // Own the scope BEFORE either asynchronous mount.
226
279
  // Same asynchronous-fiber shape the official module needed: wait for
227
280
  // the apply callback to capture scope.ctx before touching its tools.
228
- if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
229
- const registry = scope.ctx?.tools
230
- if (!registry || typeof scope.ctx.plugin !== 'function') {
231
- void scope.dispose().catch(() => {})
232
- return
233
- }
281
+ if (scope.fiber) await untilDisposed(scope.fiber, state.controller.signal)
282
+ if (!isLive(agent, state)) return
283
+ const registry = scope.ctx?.get('tools')
284
+ if (!registry || typeof scope.ctx.plugin !== 'function') return
234
285
  const fiber = scope.ctx.plugin(module)
235
- await fiber
236
- state.scope = scope
286
+ await untilDisposed(fiber, state.controller.signal)
287
+ if (!isLive(agent, state)) return
288
+ retained = true
237
289
  log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
238
290
  } catch (error) {
239
- log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
291
+ if (isLive(agent, state)) log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
292
+ } finally {
293
+ if (scope && !retained) {
294
+ if (state.scope === scope) state.scope = undefined
295
+ await scope.dispose()
296
+ }
240
297
  }
241
298
  }
242
299
 
300
+ const startBash = (agent, state) => {
301
+ if (state.scope || state.pending || !isLive(agent, state)) return
302
+ const task = provideBash(agent, state)
303
+ state.pending = task
304
+ pending.add(task)
305
+ void task.finally(() => {
306
+ pending.delete(task)
307
+ if (state.pending === task) state.pending = undefined
308
+ }).catch((error) => log(`own bash tool cleanup failed: ${error?.message ?? error}`))
309
+ }
310
+
243
311
  const reconcile = (agent, { attempt = 0 } = {}) => {
244
- if (!agent?.ctx) return
312
+ if (closed || !agent?.ctx) return
313
+ // Undo ONLY our mask before observing the face. schemas() filters a
314
+ // restricted-away pwsh, so it cannot tell us what an ON flip must restore.
315
+ const previous = tracked.get(agent)
316
+ if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined }
317
+ if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined }
245
318
  const names = faceNames(agent)
246
319
  // The composed face may not be wired yet at created-time. Retry bounded:
247
320
  // a face with NEITHER tool carries no verdict. Five tries at 250ms, then
@@ -251,7 +324,7 @@ export function mountInlineTools(ctx, deps) {
251
324
  timer.unref?.()
252
325
  const state = tracked.get(agent)
253
326
  if (state) state.timer = timer
254
- else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
327
+ else tracked.set(agent, { ...newState(), timer })
255
328
  return
256
329
  }
257
330
  const pwshVisible = deps.pwshEnabled?.() === true
@@ -260,7 +333,7 @@ export function mountInlineTools(ctx, deps) {
260
333
  let state = tracked.get(agent)
261
334
  if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
262
335
  if (!state) {
263
- state = { timer: undefined, scope: undefined, denyPwsh: undefined }
336
+ state = newState()
264
337
  tracked.set(agent, state)
265
338
  }
266
339
  // The pwsh face (route B): the host tool is REAL PowerShell now, so the
@@ -283,22 +356,46 @@ export function mountInlineTools(ctx, deps) {
283
356
  }
284
357
  }
285
358
  if (needs.needsBash && !state.scope) {
286
- void provideBash(agent, state)
359
+ startBash(agent, state)
287
360
  }
288
361
  }
289
362
 
290
- const teardown = (agent) => {
363
+ const teardown = async (agent) => {
291
364
  const state = tracked.get(agent)
292
365
  if (!state) return
293
366
  tracked.delete(agent)
367
+ state.controller.abort(new Error('inline bash agent disposed'))
294
368
  if (state.timer) clearTimeout(state.timer)
295
- if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
296
- if (state.scope) { void state.scope.dispose().catch(() => {}) }
369
+ if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined }
370
+ await Promise.all([state.scope?.dispose(), state.pending])
297
371
  }
298
372
 
373
+ // Register ownership before the sweep starts asynchronous work. A cancelled
374
+ // loader continuation and every per-agent mount must settle before disposal.
375
+ ctx.effect(() => async () => {
376
+ closed = true
377
+ const mounting = bashOnce
378
+ controller.abort(new Error('inline bash plugin disposed'))
379
+ await Promise.all([
380
+ ...[...tracked.keys()].map(teardown),
381
+ ...pending,
382
+ mounting?.then(({ executorScope }) => executorScope.dispose(), () => {}),
383
+ ])
384
+ tracked.clear()
385
+ bashOnce = undefined
386
+ }, 'dsh-gitbash-shell: inline tool patching')
387
+
299
388
  try {
300
389
  ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
301
- ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
390
+ ctx.on('agent/disposed', ({ agent } = {}) => teardown(agent).catch((error) => {
391
+ log(`agent bash tool cleanup failed: ${error?.message ?? error}`)
392
+ }))
393
+ ctx.on('loader/volatile-update', (paths) => {
394
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'pwshTool')) return
395
+ for (const agent of tracked.keys()) {
396
+ try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) }
397
+ }
398
+ })
302
399
  // MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
303
400
  // mounts never fire agent/created. Same shape the proven dsh-ops denial
304
401
  // uses.
@@ -322,16 +419,6 @@ export function mountInlineTools(ctx, deps) {
322
419
  return
323
420
  }
324
421
 
325
- ctx.effect(() => () => {
326
- for (const agent of [...tracked.keys()]) teardown(agent)
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
- }
334
- }, 'dsh-gitbash-shell: inline tool patching')
335
422
  }
336
423
 
337
424
  // The host-package resolution channel lives in bash-tool.js; import it for
package/src/shell.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-gitbash-shell/shell — the Git Bash executor INSTANCE (route B, v0.39.0).
3
3
  *
4
- * Since route B this class is NOT mounted as the host `ctx.shell` service:
4
+ * Since route B this class is NOT mounted as the ROOT `ctx.shell` service:
5
5
  * the official PowerShell executor keeps that slot untouched (the DSH-Store
6
6
  * catalog gate forbids bundle patches that disable official components, and
7
7
  * replacing the shell executor IS overriding one). Instead the plugin
@@ -36,8 +36,9 @@
36
36
  * executor did.
37
37
  *
38
38
  * Constructed as a cordis class plugin (static inject/Config honoured) —
39
- * inside the PRIVATE scope only, so the base class's service registration
40
- * lands there and never touches the root `ctx.shell` slot.
39
+ * inside an explicitly `ctx.isolate('shell')`-isolated scope, so the base
40
+ * class's service registration never touches the root `ctx.shell` slot.
41
+ * A plain child plugin fiber is NOT a service isolation boundary.
41
42
  */
42
43
 
43
44
  import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'