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.
- package/CHANGELOG.md +100 -0
- package/README.md +38 -14
- package/README_EN.md +54 -18
- package/dsh.plugin.json +1 -1
- package/package.json +3 -3
- package/src/bash-render.js +21 -10
- package/src/bash-tool.js +79 -26
- package/src/client.js +36 -25
- package/src/compositions.js +11 -7
- package/src/fastctx-bridge.js +78 -31
- package/src/index.js +185 -152
- package/src/inline-tools.js +124 -50
- package/src/shell.js +14 -14
package/src/inline-tools.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
throw new Error('executor
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
261
|
-
|
|
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, {
|
|
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 =
|
|
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
|
-
|
|
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) {
|
|
320
|
-
|
|
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 } = {}) =>
|
|
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
|
|
14
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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:
|
|
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
|
|