Repository navigation
Expand file tree
/
Copy pathinline-tools.js
More file actions
799 lines (769 loc) · 41.6 KB
/
Copy pathinline-tools.js
File metadata and controls
799 lines (769 loc) · 41.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
/**
* dsh-gitbash-shell/inline-tools — runtime shell-tool patching for the OFFICIAL
* presets on Windows (v0.35.0; route B since v0.39.0).
*
* WHY THIS EXISTS. The official preset rows compose their tool set at boot
* (`tool-bash` disabled on win32, `tool-pwsh` enabled), and the patch layer
* CANNOT reach them: `applyEntryPatches`' id map only descends into rows that
* carry `group: true` (vendor/include/src/index.ts buildMap), while a preset
* declaration is a plain row whose `config.plugins` is opaque data. Rewriting
* the declaration's whole `config` would freeze the official row list at our
* restatement instant. So the fix runs at the tool layer, where the official
* contract explicitly invites per-agent variants:
*
* - the tools registry REJECTS same-scope duplicate names but points at this
* exact path for variants ("for a per-agent variant, register through that
* agent's `agent.ctx` instead", packages/core/tools/src/index.ts);
* - `agent/created` fires once per agent with the composed tool face
* reachable through `ctx.get('tools').schemas(agent)`.
*
* ROUTE B (v0.39.0, the DSH-Store-compliant architecture). The plugin no
* longer replaces the host `ctx.shell` executor, so the OFFICIAL tool-bash
* module — whose every execution goes through `ctx.shell`, i.e. PowerShell
* here — is OFF the table. Instead one reconcile per agent reads the face and
* acts on three facts:
*
* 1. `pwsh` present + `bash` absent -> register the plugin's OWN `bash`
* tool (src/bash-tool.js) in an agent-owned scope. It executes through a
* PRIVATE GitBashSandboxExecutor instance (constructed once per process
* in a shell-isolated cordis scope, never registered as the ROOT shell), and mirrors
* the official tool's schema, background jobs, escalation and rendering
* call-for-call.
* 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge stands
* down, the pwsh hiding stands down (dsh-ops owns the host-pwsh story),
* and a bash tool they already publish means ours is not registered.
* 3. `pwshTool` switch OFF (the default) -> the REAL host `pwsh` tool is
* hidden per agent (`tools.restrict`) and denied at the call layer. With
* route B the host pwsh is REAL PowerShell again — hiding it is a pure
* product choice (bash-first host), not a safety denial, and flipping
* the switch surfaces it unchanged.
*
* Everything here is Windows-only by caller gate; on other hosts the module
* loads but mounts nothing.
*/
/** FastCtx-bridge shared output contract (unchanged since v0.35.0). */
export const TOOL_OUTPUT_SCHEMA = Object.freeze({
type: 'object',
properties: {
content: { type: 'array', items: {} },
structuredContent: {},
},
required: ['content'],
additionalProperties: false,
})
/**
* Render the `{ content: [...] }` execute value into text blocks. Non-text
* blocks and non-string text fields are skipped; empty content renders as one
* empty text block rather than an empty array, so the card never loses its
* placeholder.
*/
export function renderTextContent(value) {
const blocks = Array.isArray(value?.content) ? value.content : []
const text = blocks
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
.filter((line) => line !== '')
.join('\n')
return [{ type: 'text', text }]
}
/**
* Answer for one observed tool face. Pure.
* @param {string[]|undefined} names - the agent's composed tool names.
* @param {{ pwshVisible?: boolean }} [options] - whether the row switch shows
* the (real) host pwsh tool; defaults to hidden.
*/
export function inlineNeeds(names, { pwshVisible } = {}) {
const set = new Set(Array.isArray(names) ? names : [])
const has = (name) => set.has(name)
return {
// A pwsh tool without a bash tool is the official win32 composition on
// this host: our own bash tool must be patched in.
needsBash: has('pwsh') && !has('bash'),
// The real pwsh tool is hidden while the row switch is off (the default):
// the product answer is bash-first. dsh-ops co-install hands the whole
// host-shell story over to dsh-ops (checked by the caller).
hidePwsh: has('pwsh') && pwshVisible !== true,
// dsh-ops publishes file/search tools under the ops_ prefix. When they are
// here, our FastCtx bridge would be a duplicate.
opsPresent: has('ops_inspect_local_file') || has('ops_grep') || has('ops_bash'),
}
}
/**
* The dsh-ops shell pair (v0.41.0, owner decision): while this plugin's own
* bash owns a session, those two names are DENIED for the agent. The list is
* dsh-ops's own published surface - lib/policy.js builds every public name as
* TOOL_PREFIX + raw with TOOL_PREFIX = "ops_", and the shell-class raw names
* are "run" (one-shot shell) and "bash" (in-process shell). Their file/search
* (ops_inspect_local_file, ops_grep, ops_glob, ops_replace), background-job
* (ops_run_background, ops_job_*) and batch surfaces stay untouched - the
* owner's call: installing dsh-ops buys its enhancements, not a second bash.
*/
export const OPS_SHELL_TOOLS = Object.freeze(['ops_bash', 'ops_run'])
/**
* The deny reason for an ops shell call on an agent our bash owns (route B
* wording): the tool exists, the host just routes shells elsewhere - the
* sentence names the owner and keeps the agent inside the offered face.
*/
export function deniedOpsShellReason() {
return 'the dsh-ops shell tools are denied on this host: dsh-gitbash-shell owns the shell (its own Git Bash bash tool). Use the bash tool for commands; the other ops_* tools (files, search, background jobs) are unaffected.'
}
/**
* The routing addendum appended to the `bash` tool's description through the
* system-prompt assemble waterfall. TWO variants (v0.37.0, user decision):
* the pwsh tool is hidden by default, and a routing sentence that names a tool
* the agent cannot see only teaches the model to call a hidden name. The ON
* variant names `pwsh` (the REAL PowerShell tool, real again since route B —
* the official executor stays in place); the OFF variant states bash-only
* routing. Both hold regardless of the path dialect a delegate sees.
*/
export const GITBASH_ROUTE_ADDENDUM = [
'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities; use the pwsh tool ONLY when PowerShell cmdlets, registry, services or WMI are truly required.',
'Fix failed bash syntax in bash; never embed `pwsh -Command` inside a bash command as a workaround (variable expansion and output encoding break there). Choose a separately offered native route for its documented purpose before execution, never as a policy-denial retry.',
].join('\n')
/** The bash-only routing variant, used while the pwsh switch is off (the default). */
export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [
'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities — it is this host\'s shell.',
'Fix failed bash syntax in bash; no PowerShell tool is offered here. Do not invent a hidden tool or embed another interpreter as a workaround.',
].join('\n')
/**
* The dialect addendum (MSYS facts) — appended ONLY while this plugin's path
* dialect is on for the assembling agent, because it describes the /c/ world
* the dialect creates.
*/
export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths use drive roots (/c/Users/...), and ~, /tmp, /dev/null and /usr/... resolve exactly as Git Bash resolves them.'
const ROUTING_START = '\n[gitbash tool routing]\n'
const ROUTING_END = '\n[/gitbash tool routing]'
const ROUTING_SAFETY = 'Choose the execution route before running. A policy/access denial is a stop condition: never switch tools, shells or permissions to bypass it. A decoding/display failure does not mean the command failed to run; preserve its exit facts and never blindly repeat a side-effecting command. Do not guess unknown or mixed encodings.'
const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.<name>(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY
/** Model guidance names only tools in this assembly, not a switch's intent. */
export function toolRoutingHintFor(tools, { pwshVisible = false, terminalGitBash = false } = {}) {
const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name))
if (!names.has('bash')) return ''
const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH]
// v0.41.0: the persistent-terminal dialect fact. Said ONLY when the host
// actually carries the terminal tools AND our terminal-bundle adoption has
// settled the rows to Git Bash (the caller passes the settled flag) - a
// bundle left at its official defaults runs pwsh there, and a sentence
// must never describe a shell the button will not start.
if (terminalGitBash === true && names.has('terminal_open')) lines.push('The persistent terminal tools (terminal_open/read/send/...) run Git Bash (MSYS) on this host: the same drive-root paths (/c/...) and MINGW64 semantics as this bash tool; they are NOT PowerShell.')
if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.')
if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.')
lines.push(ROUTING_SAFETY)
return lines.join('\n')
}
/** Add a replaceable, idempotent block; a reused assembly follows visibility. */
export function applyToolRouting(tools, { pwshVisible = false, msys = false, terminalGitBash = false } = {}) {
if (!Array.isArray(tools)) return
for (const tool of tools) {
if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue
let description = tool.description
const start = description.indexOf(ROUTING_START)
const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start)
if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length)
const hint = tool.name === 'bash'
? toolRoutingHintFor(tools, { pwshVisible, terminalGitBash }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
: PTC_ROUTING
tool.description = description + ROUTING_START + hint + ROUTING_END
}
}
/** @deprecated composed form kept for tests: route + MSYS sentences. */
export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM
/**
* The deny reason for a hidden pwsh call (route B wording): the tool is REAL
* PowerShell on this host, just hidden by the row switch — the sentence says
* so and points at the switch instead of calling the tool a lie.
*/
export function hiddenPwshDenyReason() {
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.'
}
/** The host packages the own bash tool consumes, resolved once per process. */
const HOST_MODULES = [
['toolsModule', '@deepseek-ai/dsh-tools'],
['sandboxModule', '@deepseek-ai/dsh-sandbox'],
['llmModule', '@deepseek-ai/dsh-llm'],
]
/**
* Mount the executor through the host class-plugin path. A plugin fiber owns
* effects, but does NOT create a service namespace: ShellExecutor's constructor
* still provides `shell`. Isolate ONLY that service, retaining the host's
* subprocess/sandbox/policy dependencies and leaving the official shell intact.
*
* The empty scope does not inject shell, so read its active provider through
* get(), not the property-access path that requires an inject declaration.
*/
export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope, { timeoutMs = 8000 } = {}) {
const executorScope = createScope(ctx.isolate('shell'))
let timer
try {
const mountTimeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('executor scope mount timed out (a required service may be absent in this composition)')), timeoutMs)
})
const executor = await Promise.race([
(async () => {
if (executorScope.fiber) await executorScope.fiber
const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
await fiber
const executor = executorScope.ctx.get('shell')
const sandboxModule = await resolveHostModule(executorScope.ctx, '@deepseek-ai/dsh-sandbox')
if (typeof sandboxModule?.SandboxUnavailableError === 'function') {
executor.unavailableErrorType = sandboxModule.SandboxUnavailableError
}
return executor
})(),
mountTimeout,
])
if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
throw new Error('executor instance did not expose resolve()/execute()')
}
return { executor, executorScope }
} catch (error) {
await executorScope.dispose()
throw error
} finally {
clearTimeout(timer)
}
}
/**
* Mount the per-agent reconcile. `deps`:
* - tools(ctx) -> the host tool registry (or undefined)
* - agents(ctx) -> the host agents service (or undefined)
* - createScope(fn) -> cordis createScope
* - pwshEnabled() -> the row switch; TRUE shows the real host pwsh tool,
* false/undefined (default) hides it
* - log(message) -> the plugin's tagged logger
*
* Registration decisions re-run on EVERY agent creation AND once at mount
* time for every ALREADY-LIVE agent (the dsh-ops-proven shape): this row can
* activate long after agents exist. A second, INDEPENDENT layer guards the
* call itself through `tools/pre-execute`: visibility restriction can lose a
* race.
*/
export function mountInlineTools(ctx, deps) {
const { tools, agents, createScope, pwshEnabled, log } = deps
/** Per-agent mounts, masks and cancellation; `agents` above is the host service. */
const tracked = new Map()
const pending = new Set()
const controller = new AbortController()
let closed = false
const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, denyOpsShell: undefined, pending: undefined, native: undefined, nativeFailed: false, controller: new AbortController(), toolFace: undefined })
// toolFace freezes the switch values this agent was created under, so a live
// settings edit cannot reshape a running face.
const experimentalEnabled = () => deps.experimentalEnabled?.() !== false
// Loader imports cannot be cancelled, but no continuation may outlive its
// owner. Join our cancelled continuations without waiting on a stuck loader.
const untilDisposed = async (promise, signal) => {
signal.throwIfAborted()
let abort
const cancelled = new Promise((_, reject) => {
abort = () => reject(signal.reason)
signal.addEventListener('abort', abort, { once: true })
})
try { return await Promise.race([promise, cancelled]) }
finally { signal.removeEventListener('abort', abort) }
}
/** Resolved once per process: { executor, executorScope, module }. */
let bashOnce
/** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
let opsSeen = false
const faceNames = (agent) => {
try {
const registry = tools(ctx)
return (registry?.schemas?.(agent) ?? []).map((schema) => schema.name).filter((name) => typeof name === 'string')
} catch {
return []
}
}
/**
* Construct the private executor and the own bash-tool module ONCE per
* process. The executor mounts through cordis's own class-plugin path in a
* PRIVATE scope (`ctx.plugin(class)` honours the static inject/Config
* declarations — the §4k schemastery semantics come for free), so it never
* touches the root `ctx.shell` service slot the official pwsh executor
* owns. The executor scope lives for the plugin's lifetime and is disposed
* by the ownership effect registered before lifecycle wiring.
*/
const bashOncePromise = async () => {
controller.signal.throwIfAborted()
if (bashOnce) return bashOnce
const mounting = (async () => {
let executorScope
try {
const { GitBashSandboxExecutor } = await untilDisposed(import('./shell.js'), controller.signal)
if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
if (!GitBashSandboxExecutor.Config) {
throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
}
if (typeof createScope !== 'function') throw new Error('no createScope seam')
controller.signal.throwIfAborted()
const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
mounted.executor.experimentalEnabled = experimentalEnabled
executorScope = mounted.executorScope
controller.signal.throwIfAborted()
const hostModules = {}
for (const [key, name] of HOST_MODULES) {
hostModules[key] = await untilDisposed(resolveHostModule(ctx, name), controller.signal)
}
const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal)
const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules })
const extraModules = []
if (typeof deps.translatePath === 'function') {
const { createPathToolModule, PATH_TOOL_NAME } = await untilDisposed(import('./path-tool.js'), controller.signal)
extraModules.push({ name: PATH_TOOL_NAME, create: scope => createPathToolModule({
toolsModule: hostModules.toolsModule, translate: deps.translatePath, experimentalEnabled, log, scope,
}) })
try {
const localBashModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-bash-local'), controller.signal)
const outputModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-subprocess-local/output'), controller.signal)
if (typeof localBashModule?.LocalBashExecutor !== 'function'
|| typeof outputModule?.OutputCollector !== 'function') {
throw new Error('public native executor/OutputCollector capability unavailable')
}
const { createNativeOutputAdapter } = await untilDisposed(import('./native-output.js'), controller.signal)
const { createNativeToolModule } = await untilDisposed(import('./native-tool.js'), controller.signal)
extraModules.push({ name: 'gitbash_native', create: scope => createNativeToolModule({
toolsModule: hostModules.toolsModule, sandboxModule: hostModules.sandboxModule,
localBashModule, createScope, translateMsysPath: deps.translatePath,
createNativeOutputAdapter, outputOptions: { OutputCollector: outputModule?.OutputCollector },
createArtifactDirectory: deps.createArtifactDirectory, experimentalEnabled, log, scope,
slot: tracked.get(scope)?.native,
}) })
} catch (error) {
if (controller.signal.aborted) throw error
log(`native tool capability unavailable; bash/path tools remain available: ${error?.message ?? error}`)
}
}
return { executor: mounted.executor, executorScope, module, extraModules }
} catch (error) {
if (executorScope) await executorScope.dispose()
throw error
}
})()
bashOnce = mounting
mounting.catch(() => { if (bashOnce === mounting) bashOnce = undefined })
return mounting
}
/** Register the own bash tool inside one agent-owned scope. */
const provideBash = async (agent, state) => {
let scope
let retained = false
try {
const { executor, module, extraModules } = await untilDisposed(bashOncePromise(), state.controller.signal)
if (!isLive(agent, state)) return
log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
scope = createScope(agent.ctx)
state.scope = scope // Own the scope BEFORE either asynchronous mount.
// Same asynchronous-fiber shape the official module needed: wait for
// the apply callback to capture scope.ctx before touching its tools.
if (scope.fiber) await untilDisposed(scope.fiber, state.controller.signal)
if (!isLive(agent, state)) return
const registry = scope.ctx?.get('tools')
if (!registry || typeof scope.ctx.plugin !== 'function') return
const fiber = scope.ctx.plugin(module)
await untilDisposed(fiber, state.controller.signal)
if (!isLive(agent, state)) return
// An INACTIVE fiber resolves its await() without ever running apply()
// (issue #27): awaiting the plugin proves nothing, so read the registry
// back before claiming success. A miss THROWS — the catch below logs the
// explicit activation failure and the finally disposes this half-mounted
// scope instead of leaking it for the agent's lifetime (shape credit:
// KouzakiUmi's PR #28).
const registeredNames = scope.ctx.get !== undefined
? ((scope.ctx.get('tools')?.schemas?.(agent) ?? []).map((schema) => schema && typeof schema.name === 'string' ? schema.name : ''))
: []
if (registeredNames.includes('bash') === false) {
throw new Error('bash tool did not register for this agent (module fiber 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)')
}
for (const extra of extraModules ?? []) {
let extraFiber
try {
let applied = false
const module = extra.create(agent)
extraFiber = scope.ctx.plugin({ ...module, async apply(extraCtx, config) {
await module.apply(extraCtx, config)
applied = true
} })
await untilDisposed(extraFiber, state.controller.signal)
if (!isLive(agent, state)) return
if (!applied) {
throw new Error(`${extra.name} scope inactive (a required service may be missing)`)
}
// Registration/stand-down is reported by the shared live registrar.
log(`${extra.name} scope ready; registration follows experimentalNative and existing owners`)
} catch (error) {
if (!isLive(agent, state)) return
await extraFiber?.dispose()
log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`)
// A failed native mount must not leave the eager slot behind: the
// tool would be visible but unusable for the agent's whole life.
if (extra.name === 'gitbash_native') failNativeSlot(state, error)
}
}
// v0.41.0 (owner decision): while OUR bash owns the session, the ops
// shell pair is denied by name - installing dsh-ops buys its file,
// search and background enhancements, not a second bash. Through the
// AGENT's scoped context, the exact shape the pwsh hide uses (issue
// #18: the root context throws "requires a scoped context"). Only the
// names actually in the face are listed, so a future dsh-ops rename
// degrades to "no denial", never to a wrong deny list. On failure the
// ops tools stay - our bash remains the routing answer either way.
const opsShell = faceNames(agent).filter((name) => OPS_SHELL_TOOLS.includes(name))
if (opsShell.length > 0) {
try {
const deny = agent.ctx?.tools?.restrict?.({ deny: [...opsShell] })
if (typeof deny === 'function') {
state.denyOpsShell = deny
log('ops shell tools denied for this agent (' + opsShell.join(', ') + '): this host\'s bash is ours; their file/search/background tools stay')
}
} catch (error) {
log('ops shell denial failed (their shell tools stay available): ' + (error?.message ?? error))
}
}
retained = true
log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
} catch (error) {
if (isLive(agent, state)) {
log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
// The eager slot advertised the native tool before this mount. If the
// mount itself failed, withdraw it instead of keeping a ghost.
failNativeSlot(state, error)
}
} finally {
if (scope && !retained) {
if (state.scope === scope) state.scope = undefined
await scope.dispose()
}
}
}
/**
* EAGER STABLE SLOT (issue #38). The native tool used to be registered only
* after an async module mount, so step 1 of every turn saw one tool fewer
* than step 2 and the provider prompt prefix was invalidated. This registers
* the real host definition synchronously at agent/created; the real native
* module later installs its executor UNDER THE SAME REGISTRATION. The name,
* schema and description are produced by the same nativeToolDefinition()
* builder in both places, so the model-facing face never changes.
*
* Only created when the host already exposes the jobs service: the
* background surface must be fixed before step 1, and re-registering later
* is exactly the churn this fixes. If native capability is missing we
* advertise nothing.
*/
const NATIVE_SLOT_WAIT_MS = 15000
const waitForNativeSlot = (slot, signal) => new Promise((resolve, reject) => {
if (slot.impl) return resolve()
if (slot.error) return reject(slot.error)
if (signal?.aborted) return reject(signal.reason ?? new Error('tool call aborted'))
let settled = false
let timer
const onAbort = () => fail(signal.reason ?? new Error('tool call aborted'))
const cleanup = () => {
clearTimeout(timer)
signal?.removeEventListener?.('abort', onAbort)
}
const fail = (error) => {
if (settled) return
settled = true
cleanup()
reject(error)
}
timer = setTimeout(() => fail(new Error('gitbash_native is still initializing; retry after this step')), NATIVE_SLOT_WAIT_MS)
timer.unref?.()
signal?.addEventListener?.('abort', onAbort, { once: true })
slot.ready.then(() => {
if (settled) return
settled = true
cleanup()
resolve()
}, fail)
})
const ensureNativeSlot = (agent, state) => {
if (state.native || state.nativeFailed || !deps.nativeContract || state.toolFace?.experimental === false) return
let jobs
try { jobs = agent.ctx?.get?.('jobs') } catch { jobs = undefined }
if (!jobs) return
// A foreign or earlier owner of the name wins: never shadow a tool we did
// not create.
if (faceNames(agent).includes('gitbash_native')) return
let resolveReady
let rejectReady
const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject })
ready.catch(() => { /* a slot nobody calls must not become an unhandled rejection */ })
let slot
const execute = async (args, exec) => {
if (slot.error) throw slot.error
if (!slot.impl) await waitForNativeSlot(slot, exec?.signal)
if (slot.error) throw slot.error
if (!slot.impl) throw new Error('gitbash_native is not ready in this session')
return slot.impl(args, exec)
}
let definition
try {
definition = deps.nativeContract({ background: true, execute })
} catch (error) {
state.nativeFailed = true
log('gitbash_native eager slot creation failed; the legacy late path stays in use: ' + (error?.message ?? error))
return
}
let disposer
try {
disposer = agent.ctx.tools.register(definition)
if (typeof disposer !== 'function') throw new Error('tools.register returned no disposer')
} catch (error) {
state.nativeFailed = true
log('gitbash_native eager slot registration failed; the legacy late path stays in use: ' + (error?.message ?? error))
return
}
slot = {
background: true, impl: null, error: null, installed: false, disposed: false,
ready,
install(realDefinition) {
if (slot.disposed || slot.installed) return
const realExecute = realDefinition?.execute
if (typeof realExecute !== 'function') {
slot.fail(new Error('gitbash_native real executor definition is invalid'))
return
}
slot.impl = realExecute
slot.installed = true
resolveReady()
},
fail(error) {
if (slot.disposed || slot.error) return
slot.error = error instanceof Error ? error : new Error(String(error))
rejectReady(slot.error)
},
dispose() {
if (slot.disposed) return
slot.disposed = true
try { disposer() } catch { /* registration already gone */ }
rejectReady(new Error('gitbash_native was withdrawn from this session'))
},
}
state.native = slot
log('gitbash_native eager slot registered for this agent (stable from step 1; the real executor installs behind it)')
}
const disposeNativeSlot = (state) => {
const slot = state.native
if (!slot) return
state.native = undefined
slot.dispose()
}
/** A broken native mount must never leave a ghost tool in the face. */
const failNativeSlot = (state, reason) => {
const slot = state.native
if (!slot) return
state.native = undefined
state.nativeFailed = true
const error = reason instanceof Error ? reason : new Error(String(reason))
slot.fail(error)
slot.dispose()
log('gitbash_native eager slot withdrawn: ' + error.message)
}
const startBash = (agent, state) => {
if (state.scope || state.pending || !isLive(agent, state)) return
const task = provideBash(agent, state)
state.pending = task
pending.add(task)
void task.finally(() => {
pending.delete(task)
if (state.pending === task) state.pending = undefined
}).catch((error) => log(`own bash tool cleanup failed: ${error?.message ?? error}`))
}
const reconcile = (agent, { attempt = 0 } = {}) => {
if (closed || !agent?.ctx) return
// Undo ONLY our mask before observing the face. schemas() filters a
// restricted-away pwsh, so it cannot tell us what an ON flip must restore.
const previous = tracked.get(agent)
if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined }
if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined }
if (previous?.denyOpsShell) { previous.denyOpsShell(); previous.denyOpsShell = undefined }
const names = faceNames(agent)
// The composed face may not be wired yet at created-time. Retry bounded:
// a face with NEITHER tool carries no verdict. Five tries at 250ms, then
// give up quietly — a slower composition simply gets no inline patch.
if (attempt < 5 && !names.includes('bash') && !names.includes('pwsh')) {
const timer = setTimeout(() => { try { reconcile(agent, { attempt: attempt + 1 }) } catch { /* never crash on a timer */ } }, 250)
timer.unref?.()
const state = tracked.get(agent)
if (state) state.timer = timer
else tracked.set(agent, { ...newState(), timer })
return
}
let state = tracked.get(agent)
if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
if (!state) {
state = newState()
tracked.set(agent, state)
}
// Freeze the face-shaping switches at this agent's first observation. They
// are volatile row fields, so the host hands an edit to the LIVE row;
// re-reading them here reshaped faces that were already in use (turning the
// pwsh switch off withdrew the own bash tool). The frozen value lasts for this
// agent's whole life; the settings card asks for a restart.
if (state.toolFace === undefined) state.toolFace = {
pwshVisible: deps.pwshEnabled?.() === true,
experimental: deps.experimentalEnabled?.() !== false,
}
const pwshVisible = state.toolFace.pwshVisible === true
const needs = inlineNeeds(names, { pwshVisible })
if (needs.opsPresent) opsSeen = true
// The pwsh face (route B): the host tool is REAL PowerShell now, so the
// default is a product-level HIDE, not a safety denial.
// dsh-ops present -> hands off entirely (their host-pwsh story stands).
// switch OFF -> hide the tool for this agent (tools.restrict).
// switch ON -> leave the real tool visible, untouched.
if (needs.hidePwsh && !needs.opsPresent) {
if (!state.denyPwsh) {
// Through the AGENT's scoped context (`agent.ctx.tools`, the dsh-ops
// -proven shape): on the ROOT context the host throws "requires a
// scoped context" and the restriction silently never exists (issue
// #18, root cause 3).
try {
const deny = agent.ctx?.tools?.restrict?.({ deny: ['pwsh'] })
if (typeof deny === 'function') state.denyPwsh = deny
} catch (error) {
log(`pwsh hide failed: ${error?.message ?? error}`)
}
}
}
// A face WITHOUT pwsh is not the official win32 composition: something
// masked the host tool away, and the only such mask in the wild is a
// preset gate (`tools.restrict({ allow })`, e.g. the pure-chat preset).
// Our bash lives in an AGENT-OWNED scope, and scoped registrations stay
// visible through masks by contract — so a registration made while the
// face was still settling would leak a shell into that preset for the
// rest of the agent's life. `names` above is the PRE-MASK observation
// (our own deny was lifted first), so it is authoritative: stand down.
if (names.length > 0 && !names.includes('pwsh') && state.scope) {
const withdrawn = state.scope
state.scope = undefined
if (state.denyOpsShell) { state.denyOpsShell(); state.denyOpsShell = undefined }
void Promise.resolve(withdrawn.dispose()).catch((error) => log(`own bash withdrawal failed: ${error?.message ?? error}`))
log('own bash tool withdrawn for this agent (the tool face carries no pwsh: a preset mask owns the tool set)')
}
// Eager native slot: create it while the agent is still settling, so the
// tool exists before step 1 assembles its face. Once created it is kept
// stable for the agent's lifetime; a face that no longer needs our bash
// withdraws it.
const nativeWanted = state.toolFace?.experimental !== false && (needs.needsBash || state.scope !== undefined || state.pending !== undefined)
if (!nativeWanted) disposeNativeSlot(state)
else if (!state.native && !state.nativeFailed) ensureNativeSlot(agent, state)
if (needs.needsBash && !state.scope) {
startBash(agent, state)
}
// v0.41.0: keep the ops shell denial attached while our bash owns the
// agent. The mask was undone above for observation; an agent that is
// already owned (state.scope, including one still mounting - provideBash
// claims the scope before its asynchronous stages) gets the denial
// re-attached here, so a reconcile fired by a switch flip or a re-show
// never leaves the pair visible.
if (state.scope && !state.denyOpsShell) {
const opsShell = names.filter((name) => OPS_SHELL_TOOLS.includes(name))
if (opsShell.length > 0) {
try {
const deny = agent.ctx?.tools?.restrict?.({ deny: [...opsShell] })
if (typeof deny === 'function') {
state.denyOpsShell = deny
log('ops shell tools denied for this agent (' + opsShell.join(', ') + ')')
}
} catch (error) {
log('ops shell denial failed (their shell tools stay available): ' + (error?.message ?? error))
}
}
}
}
const teardown = async (agent) => {
const state = tracked.get(agent)
if (!state) return
tracked.delete(agent)
state.controller.abort(new Error('inline bash agent disposed'))
disposeNativeSlot(state)
if (state.timer) clearTimeout(state.timer)
if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined }
if (state.denyOpsShell) { state.denyOpsShell(); state.denyOpsShell = undefined }
await Promise.all([state.scope?.dispose(), state.pending])
}
// Register ownership before the sweep starts asynchronous work. A cancelled
// loader continuation and every per-agent mount must settle before disposal.
ctx.effect(() => async () => {
closed = true
const mounting = bashOnce
controller.abort(new Error('inline bash plugin disposed'))
await Promise.all([
...[...tracked.keys()].map(teardown),
...pending,
mounting?.then(({ executorScope }) => executorScope.dispose(), () => {}),
])
tracked.clear()
bashOnce = undefined
}, 'dsh-gitbash-shell: inline tool patching')
try {
ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
ctx.on('agent/disposed', ({ agent } = {}) => teardown(agent).catch((error) => {
log(`agent bash tool cleanup failed: ${error?.message ?? error}`)
}))
// A tool-face switch edit arrives as a volatile update on the LIVE row.
// Re-reconciling every tracked agent from here reshaped faces that were
// already in use (it could withdraw the own bash tool). Keep the face an
// agent was created with and say what applies a new one.
ctx.on('loader/volatile-update', (paths) => {
if (!Array.isArray(paths)) return
const flipped = paths
.filter((path) => Array.isArray(path) && ['pwshTool', 'experimentalNative'].includes(path[0]))
.map((path) => path[0])
if (flipped.length === 0) return
log(`tool-face switch changed (${flipped.join(', ')}): running agents keep their tool face; a new session or a DSH restart applies the new value`)
})
// MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
// mounts never fire agent/created. Same shape the proven dsh-ops denial
// uses.
for (const agent of agents?.(ctx)?.list?.() ?? []) {
try { reconcile(agent) } catch { /* one broken agent never blocks the rest */ }
}
// CALL-LAYER GUARD (v0.35.2, second independent layer; route B wording):
// while the row switch hides the real pwsh tool, a `pwsh` call that slips
// past a visibility race is denied with the switch named. With the switch
// ON the REAL tool answers for itself.
ctx.on('tools/pre-execute', (exec, next) => {
try {
// THE FACE CAN SETTLE AFTER `agent/created` (Unreleased). Preset rows
// and plugin tools keep registering while the Loader walks the tree,
// so on a slow boot the bounded retry above can burn through its five
// 250ms attempts on an empty face and be FINAL: the agent then never
// gets its bash and keeps a visible (but denied) pwsh. The first tool
// call is the first moment the face is guaranteed to be settled, so
// re-run the decision there while this agent still owns no bash scope.
// Idempotent: once the scope exists this branch never fires again.
const lazyAgent = exec?.agent
const lazyState = lazyAgent ? tracked.get(lazyAgent) : undefined
if (lazyState && !lazyState.scope && !lazyState.pending) {
try { reconcile(lazyAgent) } catch (error) { log(`lazy re-reconcile failed: ${error?.message ?? error}`) }
}
// Judge the call by the face this agent was created with, never by the
// live switch.
const livePwshOff = deps.pwshEnabled?.() !== true
const frozenFace = exec?.agent ? tracked.get(exec.agent)?.toolFace : undefined
const pwshOff = frozenFace === undefined ? livePwshOff : frozenFace.pwshVisible !== true
if (exec?.name === 'pwsh' && !opsSeen && pwshOff) {
return { kind: 'deny', reason: hiddenPwshDenyReason() }
}
// v0.41.0, second independent layer for the ops shell pair: while our
// bash owns this agent, a call that slips past the visibility
// restriction is denied by name - same reasoning as the pwsh guard
// above.
if (OPS_SHELL_TOOLS.includes(exec?.name) && tracked.get(exec?.agent)?.scope) {
return { kind: 'deny', reason: deniedOpsShellReason() }
}
} catch { /* never block the waterfall */ }
return next()
})
} catch (error) {
log(`agent lifecycle wiring failed: ${error?.message ?? error}`)
return
}
}
// The host-package resolution channel lives in bash-tool.js; import it for
// local use AND re-export it so the host half (and the tests) see ONE
// implementation.
import { resolveHostModule } from './bash-tool.js'
export { resolveHostModule }