dsh-gitbash-shell 0.39.1 → 0.39.3

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.
@@ -191,8 +191,25 @@ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createSc
191
191
  */
192
192
  export function mountInlineTools(ctx, deps) {
193
193
  const { tools, agents, createScope, pwshEnabled, log } = deps
194
- /** 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. */
195
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
+ }
196
213
  /** Resolved once per process: { executor, executorScope, module }. */
197
214
  let bashOnce
198
215
  /** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
@@ -214,58 +231,101 @@ export function mountInlineTools(ctx, deps) {
214
231
  * declarations — the §4k schemastery semantics come for free), so it never
215
232
  * touches the root `ctx.shell` service slot the official pwsh executor
216
233
  * owns. The executor scope lives for the plugin's lifetime and is disposed
217
- * by the effect registered at the bottom of this function.
234
+ * by the ownership effect registered before lifecycle wiring.
218
235
  */
219
236
  const bashOncePromise = async () => {
237
+ controller.signal.throwIfAborted()
220
238
  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 }
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
234
262
  }
235
- const { createBashToolModule } = await import('./bash-tool.js')
236
- const module = createBashToolModule({ executor, log, ...hostModules })
237
- return { executor, executorScope, module }
238
263
  })()
239
- bashOnce.catch(() => { bashOnce = undefined })
240
- return bashOnce
264
+ bashOnce = mounting
265
+ mounting.catch(() => { if (bashOnce === mounting) bashOnce = undefined })
266
+ return mounting
241
267
  }
242
268
 
243
269
  /** Register the own bash tool inside one agent-owned scope. */
244
270
  const provideBash = async (agent, state) => {
245
- if (state.scope) return
271
+ let scope
272
+ let retained = false
246
273
  try {
247
- const { executor, module } = await bashOncePromise()
274
+ const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal)
275
+ if (!isLive(agent, state)) return
248
276
  log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
249
- const scope = createScope(agent.ctx)
277
+ scope = createScope(agent.ctx)
278
+ state.scope = scope // Own the scope BEFORE either asynchronous mount.
250
279
  // Same asynchronous-fiber shape the official module needed: wait for
251
280
  // the apply callback to capture scope.ctx before touching its tools.
252
- if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
281
+ if (scope.fiber) await untilDisposed(scope.fiber, state.controller.signal)
282
+ if (!isLive(agent, state)) return
253
283
  const registry = scope.ctx?.get('tools')
254
- if (!registry || typeof scope.ctx.plugin !== 'function') {
255
- void scope.dispose().catch(() => {})
256
- return
257
- }
284
+ if (!registry || typeof scope.ctx.plugin !== 'function') return
258
285
  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)')
286
+ await untilDisposed(fiber, state.controller.signal)
287
+ if (!isLive(agent, state)) return
288
+ retained = true
289
+ // An INACTIVE fiber resolves its await() without ever running apply()
290
+ // (issue #27): awaiting the plugin proves nothing, so read the registry
291
+ // back before claiming success. A miss gets an explicit failure line —
292
+ // never a silent no-tool session behind a success-shaped log.
293
+ const registeredNames = scope.ctx.get !== undefined
294
+ ? ((scope.ctx.get('tools')?.schemas?.(agent) ?? []).map((schema) => schema && typeof schema.name === 'string' ? schema.name : ''))
295
+ : []
296
+ if (registeredNames.includes('bash')) {
297
+ log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
298
+ } else {
299
+ log('own bash tool did NOT register for this agent: the module fiber likely went INACTIVE (this host lacks an injected service the module requires) or the tools read-back is unavailable — bash is NOT available in this session; please report this with your dsh version (issue #27 pattern)')
300
+ }
262
301
  } catch (error) {
263
- log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
302
+ if (isLive(agent, state)) log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
303
+ } finally {
304
+ if (scope && !retained) {
305
+ if (state.scope === scope) state.scope = undefined
306
+ await scope.dispose()
307
+ }
264
308
  }
265
309
  }
266
310
 
311
+ const startBash = (agent, state) => {
312
+ if (state.scope || state.pending || !isLive(agent, state)) return
313
+ const task = provideBash(agent, state)
314
+ state.pending = task
315
+ pending.add(task)
316
+ void task.finally(() => {
317
+ pending.delete(task)
318
+ if (state.pending === task) state.pending = undefined
319
+ }).catch((error) => log(`own bash tool cleanup failed: ${error?.message ?? error}`))
320
+ }
321
+
267
322
  const reconcile = (agent, { attempt = 0 } = {}) => {
268
- if (!agent?.ctx) return
323
+ if (closed || !agent?.ctx) return
324
+ // Undo ONLY our mask before observing the face. schemas() filters a
325
+ // restricted-away pwsh, so it cannot tell us what an ON flip must restore.
326
+ const previous = tracked.get(agent)
327
+ if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined }
328
+ if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined }
269
329
  const names = faceNames(agent)
270
330
  // The composed face may not be wired yet at created-time. Retry bounded:
271
331
  // a face with NEITHER tool carries no verdict. Five tries at 250ms, then
@@ -275,7 +335,7 @@ export function mountInlineTools(ctx, deps) {
275
335
  timer.unref?.()
276
336
  const state = tracked.get(agent)
277
337
  if (state) state.timer = timer
278
- else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
338
+ else tracked.set(agent, { ...newState(), timer })
279
339
  return
280
340
  }
281
341
  const pwshVisible = deps.pwshEnabled?.() === true
@@ -284,7 +344,7 @@ export function mountInlineTools(ctx, deps) {
284
344
  let state = tracked.get(agent)
285
345
  if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
286
346
  if (!state) {
287
- state = { timer: undefined, scope: undefined, denyPwsh: undefined }
347
+ state = newState()
288
348
  tracked.set(agent, state)
289
349
  }
290
350
  // The pwsh face (route B): the host tool is REAL PowerShell now, so the
@@ -307,22 +367,46 @@ export function mountInlineTools(ctx, deps) {
307
367
  }
308
368
  }
309
369
  if (needs.needsBash && !state.scope) {
310
- void provideBash(agent, state)
370
+ startBash(agent, state)
311
371
  }
312
372
  }
313
373
 
314
- const teardown = (agent) => {
374
+ const teardown = async (agent) => {
315
375
  const state = tracked.get(agent)
316
376
  if (!state) return
317
377
  tracked.delete(agent)
378
+ state.controller.abort(new Error('inline bash agent disposed'))
318
379
  if (state.timer) clearTimeout(state.timer)
319
- if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
320
- if (state.scope) { void state.scope.dispose().catch(() => {}) }
380
+ if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined }
381
+ await Promise.all([state.scope?.dispose(), state.pending])
321
382
  }
322
383
 
384
+ // Register ownership before the sweep starts asynchronous work. A cancelled
385
+ // loader continuation and every per-agent mount must settle before disposal.
386
+ ctx.effect(() => async () => {
387
+ closed = true
388
+ const mounting = bashOnce
389
+ controller.abort(new Error('inline bash plugin disposed'))
390
+ await Promise.all([
391
+ ...[...tracked.keys()].map(teardown),
392
+ ...pending,
393
+ mounting?.then(({ executorScope }) => executorScope.dispose(), () => {}),
394
+ ])
395
+ tracked.clear()
396
+ bashOnce = undefined
397
+ }, 'dsh-gitbash-shell: inline tool patching')
398
+
323
399
  try {
324
400
  ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
325
- ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
401
+ ctx.on('agent/disposed', ({ agent } = {}) => teardown(agent).catch((error) => {
402
+ log(`agent bash tool cleanup failed: ${error?.message ?? error}`)
403
+ }))
404
+ ctx.on('loader/volatile-update', (paths) => {
405
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'pwshTool')) return
406
+ for (const agent of tracked.keys()) {
407
+ try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) }
408
+ }
409
+ })
326
410
  // MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
327
411
  // mounts never fire agent/created. Same shape the proven dsh-ops denial
328
412
  // uses.
@@ -346,16 +430,6 @@ export function mountInlineTools(ctx, deps) {
346
430
  return
347
431
  }
348
432
 
349
- ctx.effect(() => () => {
350
- for (const agent of [...tracked.keys()]) teardown(agent)
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
- }
358
- }, 'dsh-gitbash-shell: inline tool patching')
359
433
  }
360
434
 
361
435
  // The host-package resolution channel lives in bash-tool.js; import it for
package/src/shell.js CHANGED
@@ -10,25 +10,23 @@
10
10
  * and hands it to the plugin's OWN bash tool (src/bash-tool.js). The class
11
11
  * body is unchanged from its executor-row era:
12
12
  *
13
- * - extends @deepseek-ai/dsh-bash-sandbox so every confined-mode
14
- * (read-only / workspace-write) call keeps the exact sandbox policy,
15
- * denial, and runner-failure semantics of the shipped stack;
13
+ * - extends @deepseek-ai/dsh-bash-sandbox to inherit policy, denial and
14
+ * lifecycle semantics EXCEPT the explicit Windows unconfined route below;
16
15
  * - overrides `confine` so the inner argv is [git-bash, -c, command];
17
16
  * - overrides run/start ONLY for danger-full-access (the parent's
18
17
  * full-access branch calls LocalBashExecutor.run, which hardcodes the
19
18
  * bare `bash` name), routing through runArgv/startArgv with the same
20
19
  * argv as the confined branch;
21
20
  * - on Windows ALSO overrides the confined branch itself (issue #1):
22
- * MSYS2 cannot start under the restricted-token sandbox — msys-2.0.dll
23
- * init creates its cygheap mapping and signal pipe with DACLs naming
24
- * only the user SID, a WRITE_RESTRICTED token's pass-2 write check
25
- * demands a restricting-SID ACE, so init dies with Win32 error 5 /
26
- * 0xC0000142 before argv ever runs (silent fake-success or a hard
27
- * crash, every version since 0.6.0; cmd/pwsh are unaffected —
28
- * anonymous pipes). OS-level conflict with no in-plugin cure, so
29
- * confined Windows calls run Git Bash UNCONFINED, labelled as such in
30
- * the result (sandbox.enforcement === 'unconfined') plus a one-time
31
- * notice. The fs-tool sandbox policy still applies to file tools.
21
+ * historical MSYS2/restricted-token tests failed during msys-2.0.dll
22
+ * cygheap/signal-pipe initialization (Win32 error 5 / 0xC0000142;
23
+ * restricting-SID object permissions, CHANGELOG v0.13.2). No working
24
+ * confined Git Bash implementation is provided here; this is not a claim
25
+ * that every future runtime/isolation design is impossible. Windows calls
26
+ * retain the explicit UNCONFINED route, labelled on the handle/result and
27
+ * in model-facing foreground/background text, plus a one-time notice.
28
+ * Official file-tool write/edit policy remains separate; it does not
29
+ * constrain shell code and its denials must never be bypassed through shell.
32
30
  *
33
31
  * Environment inheritance: bash.exe is spawned as a direct child of the host
34
32
  * process (never through the git-bash.exe login launcher), so it inherits the
@@ -262,6 +260,8 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
262
260
  * @returns the same handle, with `result()` mapped.
263
261
  */
264
262
  static decorateExecution(ex, sandbox) {
263
+ // Background reads/outcomes inspect the handle before result() settles.
264
+ ex.sandbox = sandbox
265
265
  const base = ex.result.bind(ex)
266
266
  let decorated
267
267
  ex.result = () => {
@@ -407,7 +407,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
407
407
  warnConfinedUnconfined(mode) {
408
408
  if (this._warnedUnconfined) return
409
409
  this._warnedUnconfined = true
410
- console.log(TAG + ' Windows: MSYS2 cannot start under the restricted-token sandbox (msys-2.0.dll init: cygheap/signal-pipe DACLs carry no restricting-SID ACE -> Win32 error 5 / 0xC0000142; issue #1). Confined call (' + mode + ') ran Git Bash UNCONFINED; the fs-tool sandbox policy still applies.')
410
+ console.log(TAG + ' Windows: confined Git Bash is not provided by this restricted-token backend (issue #1). Requested mode ' + mode + ' will use an UNCONFINED Git Bash route: shell file-write restrictions are NOT enforced. Official file-tool write/edit policy is separate; never bypass its denial through shell.')
411
411
  }
412
412
  }
413
413