dsh-gitbash-shell 0.26.1 → 0.28.0

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/src/index.js CHANGED
@@ -180,6 +180,51 @@ export function pythonBackendActive(capability) {
180
180
  return intent && reported === 'python'
181
181
  }
182
182
 
183
+ /**
184
+ * Is this agent a DELEGATED one — a subagent, a team member, or a nested child
185
+ * of either? The canonical in-tree signal is the session header pair dsh's own
186
+ * workspace-changes plugin uses
187
+ * (`packages/deliverables/workspace-changes/src/index.ts:59-60`), stamped when
188
+ * the subagent driver creates a child
189
+ * (`packages/subagent/subagent/src/child-agent.ts:139-155`):
190
+ * `origin === 'subagent'` or a non-zero `delegationDepth`.
191
+ *
192
+ * Unknown shapes read as NOT delegated: applying the dialect is the helpful
193
+ * direction, and a diagnostic assembly without an agent must keep it.
194
+ * @param {object|undefined} agent - the assembling/executing agent, when known.
195
+ * @returns {boolean} true only when the agent is provably delegated.
196
+ */
197
+ export function isDelegatedAgent(agent) {
198
+ try {
199
+ const header = agent && agent.session ? agent.session.header : undefined
200
+ if (header === null || typeof header !== 'object') return false
201
+ if (header.origin === 'subagent') return true
202
+ return typeof header.delegationDepth === 'number' && header.delegationDepth > 0
203
+ } catch {
204
+ return false
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Does the Git Bash dialect apply to THIS agent (v0.27.0)? The row switch
210
+ * `subagentDialect` (default ON) decides whether delegated agents — subagents,
211
+ * team members, nested children — participate in the dialect at all; the main
212
+ * agent always does. A closed function so every consumer (prompt assembly,
213
+ * dispatch translation, result echo, failure text, the shell-env fact) answers
214
+ * the same way.
215
+ *
216
+ * Note the honest bound: dsh has exactly ONE shell provider per process, so
217
+ * the executor binary stays Git Bash for every agent; this gate governs the
218
+ * dialect/translation layers that this plugin owns.
219
+ * @param {object} dialect - the live dialect switches.
220
+ * @param {object|undefined} agent - the agent that owns this assembly/execution.
221
+ * @returns {boolean} true when the dialect applies to this agent.
222
+ */
223
+ export function dialectApplies(dialect, agent) {
224
+ if (!dialect || dialect.subagentDialect !== false) return true
225
+ return !isDelegatedAgent(agent)
226
+ }
227
+
183
228
  /**
184
229
  * The roster this plugin serves: the configured list, minus the variant a peer
185
230
  * already covers. Two independent facts must BOTH hold before anything is
@@ -200,8 +245,15 @@ export function effectivePresetIds(configured, { suppress, peerGitBash } = {}) {
200
245
  return list.filter((id) => id !== PEER_COVERED_PRESET_ID)
201
246
  }
202
247
 
203
- /** Git Bash binary default — must match src/shell.js. */
204
- const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
248
+ /**
249
+ * The ONE bash resolver (v0.28.0, issue #11): the executor (src/shell.js) and
250
+ * this translation layer read the same memoized result, so "dialect translated
251
+ * to Q:/Git but the executor still spawns C:/Program Files/Git" cannot happen.
252
+ */
253
+ import {
254
+ DEFAULT_GIT_BASH, GIT_BASH_DOWNLOAD_URL, bashResolutionReport,
255
+ effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached, settingsBashPath,
256
+ } from './bash-path.js'
205
257
 
206
258
  /** The shipped preset whose skills/ dir seeds cordis-gitbash. */
207
259
  const SKILLS_SOURCE_PRESET = 'cordis'
@@ -823,6 +875,15 @@ export const Config = Schema === null ? undefined : Schema.object({
823
875
  globSplit: live(Schema.boolean().default(true)),
824
876
  errorDialect: live(Schema.boolean().default(true)),
825
877
  codePaths: live(Schema.boolean().default(true)),
878
+ /**
879
+ * Whether DELEGATED agents (subagents, team members, nested children) also
880
+ * use the Git Bash dialect (v0.27.0). Default ON — the historical behavior;
881
+ * OFF keeps the dialect to the main agent, so a delegated request carries the
882
+ * official shell semantics (no prompt rewrite, no path-argument translation,
883
+ * no echo rewrite, no DSH_PATH_DIALECT fact). The executor itself is one
884
+ * per-process provider, so Git Bash stays the binary either way.
885
+ */
886
+ subagentDialect: live(Schema.boolean().default(true)),
826
887
  gitAutocrlf: live(Schema.boolean().default(true)),
827
888
  bashPath: live(Schema.string().default('')),
828
889
  adoptSidebar: live(Schema.boolean().default(true)),
@@ -869,6 +930,8 @@ function makeLiveReader(ctx, config) {
869
930
  errorDialect: valueOf(config.errorDialect) === true,
870
931
  codePaths: valueOf(config.codePaths) === true,
871
932
  gitAutocrlf: valueOf(config.gitAutocrlf) === true,
933
+ // Default ON: delegated agents keep the dialect unless the user opts out.
934
+ subagentDialect: valueOf(config.subagentDialect) !== false,
872
935
  bashPath: typeof bash === 'string' ? bash : '',
873
936
  adoptSidebar: valueOf(config.adoptSidebar) !== false,
874
937
  }
@@ -943,7 +1006,7 @@ export function readPosixPaths(ctxLike) {
943
1006
  * first. All default ON (schema defaults); never throws.
944
1007
  */
945
1008
  export function readDialectSettings(ctxLike) {
946
- const fallback = { posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false, codePaths: false, gitAutocrlf: false, bashPath: '' }
1009
+ const fallback = { posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false, codePaths: false, gitAutocrlf: false, bashPath: '', subagentDialect: true }
947
1010
  try {
948
1011
  const settings = ctxLike && typeof ctxLike.get === 'function' ? ctxLike.get('settings') : undefined
949
1012
  const value = settings && typeof settings.get === 'function' ? settings.get(SETTINGS_NAMESPACE) : undefined
@@ -954,6 +1017,8 @@ export function readDialectSettings(ctxLike) {
954
1017
  globSplit: v.globSplit === true,
955
1018
  errorDialect: v.errorDialect === true,
956
1019
  codePaths: v.codePaths === true,
1020
+ // An old host without the key keeps the default (delegated agents covered).
1021
+ subagentDialect: v.subagentDialect !== false,
957
1022
  gitAutocrlf: v.gitAutocrlf === true,
958
1023
  bashPath: typeof v.bashPath === 'string' ? v.bashPath : '',
959
1024
  }
@@ -1390,23 +1455,12 @@ export function buildTranslateEnv(configuredBashPath) {
1390
1455
  if (h && existsSync(h)) env.home = h.replace(/\\/g, '/')
1391
1456
  } catch { /* keep undefined */ }
1392
1457
  try {
1393
- const candidates = []
1394
- if (cacheKey !== '') candidates.push(cacheKey.replace(/\\/g, '/'))
1395
- candidates.push(DEFAULT_GIT_BASH)
1396
- for (const dir of String(process.env.PATH ?? '').split(';')) {
1397
- const clean = dir && dir.trim()
1398
- if (clean) candidates.push(clean.replace(/\\/g, '/') + '/bash.exe')
1399
- }
1400
- for (const candidate of candidates) {
1401
- try {
1402
- if (!existsSync(candidate)) continue
1403
- const root = dirname(dirname(candidate))
1404
- if (existsSync(join(root, 'usr', 'bin'))) {
1405
- env.gitRoot = root.replace(/\\/g, '/')
1406
- break
1407
- }
1408
- } catch { /* try the next candidate */ }
1409
- }
1458
+ // v0.28.0 (issue #11): ONE resolver for the executor and this layer. Its
1459
+ // chain is setting > default install paths > PATH > registry PATH and it
1460
+ // refuses WSL/MSYS2/Cygwin outright; `cacheKey` is the settings bashPath,
1461
+ // i.e. the same explicit tier the executor reads.
1462
+ const resolution = resolveGitBashCached({ configured: cacheKey })
1463
+ if (resolution.ok) env.gitRoot = resolution.root.replace(/\\/g, '/')
1410
1464
  } catch { /* keep undefined */ }
1411
1465
  translateEnvCache.set(cacheKey, env)
1412
1466
  return env
@@ -2293,7 +2347,28 @@ export async function apply(ctx, config = {}) {
2293
2347
  // plugins (e.g. dsh-ptc-cordis-preset) can adopt it in the presets they
2294
2348
  // materialize: service present, 'active: true' on Windows, 'active: false'
2295
2349
  // where the bundle is installed but the platform stack stayed native.
2296
- const gitBashCapability = { active: process.platform === 'win32', bashPath: DEFAULT_GIT_BASH }
2350
+ /* The explicit tier of the resolution chain, in its documented priority:
2351
+ the `gitbash-executor` row's own config > the `gitbash-shell` row/settings
2352
+ field > empty (= run the automatic chain). Both this layer and the
2353
+ executor resolve from this same value, so an explicit answer is never
2354
+ substituted and never silently ignored.
2355
+ (issue #11: the row config is where the reporter's workaround landed.) */
2356
+ const configuredBashPath = effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))
2357
+ const bashResolution = resolveGitBashCached({ configured: configuredBashPath })
2358
+ const gitBashCapability = {
2359
+ active: process.platform === 'win32',
2360
+ // Kept for existing consumers (peer plugins read `bashPath`): the resolved
2361
+ // interpreter. When resolution failed we report the explicit value (or the
2362
+ // historical default) so the ENOENT a consumer may hit names a real path —
2363
+ // never a substitute shell.
2364
+ bashPath: bashResolution.ok ? bashResolution.path : (configuredBashPath || DEFAULT_GIT_BASH),
2365
+ // Additive (v0.28.0): the full verdict, for the guided popup and peers.
2366
+ ok: bashResolution.ok,
2367
+ source: bashResolution.source,
2368
+ configured: configuredBashPath,
2369
+ tried: bashResolution.tried,
2370
+ downloadUrl: GIT_BASH_DOWNLOAD_URL,
2371
+ }
2297
2372
  const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
2298
2373
  ctx.effect(() => disposeGitBash, 'dsh-gitbash-shell: gitBash capability')
2299
2374
 
@@ -2336,6 +2411,7 @@ export async function apply(ctx, config = {}) {
2336
2411
  globSplit: Schema.boolean().default(true),
2337
2412
  errorDialect: Schema.boolean().default(true),
2338
2413
  codePaths: Schema.boolean().default(true),
2414
+ subagentDialect: Schema.boolean().default(true),
2339
2415
  gitAutocrlf: Schema.boolean().default(true),
2340
2416
  bashPath: Schema.string().default(''),
2341
2417
  adoptSidebar: Schema.boolean().default(true),
@@ -2391,8 +2467,17 @@ export async function apply(ctx, config = {}) {
2391
2467
  // git line endings and anything else non-DSH_* ride the executor's
2392
2468
  // own env layer (src/shell.js), not this registry.
2393
2469
  variables: { [PATH_DIALECT_KEY]: { description: PATH_DIALECT_DESCRIPTION } },
2394
- resolve() {
2395
- return liveSettings.posix() ? { [PATH_DIALECT_KEY]: PATH_DIALECT_VALUE } : {}
2470
+ resolve(execution) {
2471
+ try {
2472
+ const dialect = liveSettings.dialect()
2473
+ if (dialect.posixPaths !== true) return {}
2474
+ // The environment fact is per execution, so a delegated agent
2475
+ // the user excluded sees no DSH_PATH_DIALECT at all.
2476
+ if (!dialectApplies(dialect, execution && execution.agent)) return {}
2477
+ return { [PATH_DIALECT_KEY]: PATH_DIALECT_VALUE }
2478
+ } catch {
2479
+ return {}
2480
+ }
2396
2481
  },
2397
2482
  })
2398
2483
  envCtx.effect(() => unregister, 'dsh-gitbash-shell: shellEnv path-dialect fact')
@@ -2418,9 +2503,16 @@ export async function apply(ctx, config = {}) {
2418
2503
  // could corrupt pattern/default fields).
2419
2504
  if (process.platform === 'win32') {
2420
2505
  try {
2421
- ctx.on('system-prompt/assemble', (assembly, _assembleContext, next) => {
2506
+ ctx.on('system-prompt/assemble', (assembly, assembleContext, next) => {
2422
2507
  try {
2423
- if (liveSettings.posix() && assembly && typeof assembly === 'object') {
2508
+ /* v0.27.0: the main agent always participates; a DELEGATED agent
2509
+ (subagent / team member / nested child) only while the
2510
+ `subagentDialect` switch is on. Turning it off leaves the official
2511
+ prompt text untouched for those agents — no source replacement and
2512
+ no run_code hint — so they meet the shell they were composed for. */
2513
+ const dialect = liveSettings.dialect()
2514
+ const applies = dialectApplies(dialect, assembleContext && assembleContext.agent)
2515
+ if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') {
2424
2516
  for (const section of Array.isArray(assembly.sections) ? assembly.sections : []) {
2425
2517
  if (section && typeof section.text === 'string') section.text = windowsToMsys(section.text)
2426
2518
  }
@@ -2491,9 +2583,12 @@ export async function apply(ctx, config = {}) {
2491
2583
  // `bash.workdir` went through raw, and an in-workspace `/c/...` write
2492
2584
  // fell outside the sandbox. One missed call site, five symptoms.
2493
2585
  const dialect = liveSettings.dialect()
2494
- if (dialect.posixPaths) {
2586
+ // v0.27.0: a delegated agent the user excluded keeps the official
2587
+ // semantics for BOTH faces of its calls — arguments go through
2588
+ // untouched and the result metadata is not echoed back in MSYS form.
2589
+ if (dialect.posixPaths && dialectApplies(dialect, exec && exec.agent)) {
2495
2590
  echo = true
2496
- const env = dialect.virtualMounts ? buildTranslateEnv(dialect.bashPath) : null
2591
+ const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2497
2592
  echoEnv = env
2498
2593
  if (exec && exec.arguments && typeof exec.arguments === 'object') {
2499
2594
  const translated = translateDispatch(exec, dialect, env)
@@ -2542,9 +2637,13 @@ export async function apply(ctx, config = {}) {
2542
2637
  let contentPatch
2543
2638
  try {
2544
2639
  const dialect = liveSettings.dialect()
2640
+ // Same agent gate as the dispatch wrapper: one call, one dialect.
2641
+ // RETURN the downstream promise: a waterfall listener that swallows
2642
+ // it would hand the caller `undefined` instead of the decision.
2643
+ if (!dialectApplies(dialect, exec && exec.agent)) return next()
2545
2644
  if (dialect.posixPaths && dialect.errorDialect && exec && result && typeof result === 'object'
2546
2645
  && result.isError === true && Array.isArray(result.content)) {
2547
- const env = dialect.virtualMounts ? buildTranslateEnv(dialect.bashPath) : null
2646
+ const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2548
2647
  if (exec.name === 'run_code') {
2549
2648
  // paths only — the /dev/null hint is for file tools
2550
2649
  const content = rewriteErrorContent(result.content, { nulHint: false, env })
@@ -2588,10 +2687,14 @@ export async function apply(ctx, config = {}) {
2588
2687
  pctx.effect(() => pctx.systemPrompt.context({
2589
2688
  name: 'gitbash-shell:posix-paths',
2590
2689
  order: 126,
2591
- text: () => {
2690
+ text: (assembleContext) => {
2592
2691
  try {
2593
2692
  const dialect = liveSettings.dialect()
2594
2693
  if (dialect.posixPaths !== true) return ''
2694
+ // A delegated agent whose dialect the user turned off meets the
2695
+ // official shell semantics: no directive, the same way no
2696
+ // translation happens for its calls.
2697
+ if (!dialectApplies(dialect, assembleContext && assembleContext.agent)) return ''
2595
2698
  return dialect.virtualMounts === false ? POSIX_DIRECTIVE_TEXT_STRICT : POSIX_DIRECTIVE_TEXT
2596
2699
  } catch {
2597
2700
  return ''
@@ -2608,12 +2711,56 @@ export async function apply(ctx, config = {}) {
2608
2711
  }
2609
2712
  }
2610
2713
 
2714
+ // ── fail-loud bash report + the client-visible status route (v0.28.0) ──
2715
+ // issue #11: the old failure mode was "every command dies with
2716
+ // `spawn C:/Program Files/Git/bin/bash.exe ENOENT` and nothing explains it".
2717
+ // Now the boot log names the value, the whole probe chain and where to fix
2718
+ // it, and the client half reads the same verdict over HTTP to raise the
2719
+ // guided popup. Nothing here substitutes another shell — by design.
2720
+ console.log(TAG + ' bash resolution: ' + bashResolutionReport(bashResolution).split('\n').join('\n' + TAG + ' '))
2721
+ if (process.platform === 'win32') {
2722
+ try {
2723
+ ctx.inject(['webServer'], (wctx) => {
2724
+ try {
2725
+ const route = '/dsh-gitbash-shell/api/status'
2726
+ const read = () => {
2727
+ const resolution = resolveGitBashCached({ configured: configuredBashPath, fresh: true })
2728
+ return {
2729
+ ok: resolution.ok,
2730
+ platform: process.platform,
2731
+ resolved: resolution.path,
2732
+ root: resolution.root,
2733
+ source: resolution.source,
2734
+ configured: resolution.configured,
2735
+ tried: resolution.tried,
2736
+ downloadUrl: GIT_BASH_DOWNLOAD_URL,
2737
+ settingsHint: 'Settings -> Plugins -> dsh-gitbash-shell -> "Git Bash path"',
2738
+ }
2739
+ }
2740
+ const handler = (req, res) => {
2741
+ const text = JSON.stringify(read())
2742
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text) })
2743
+ res.end(text)
2744
+ }
2745
+ const dispose = wctx.webServer.register({ kind: 'prefix', path: route, handler })
2746
+ wctx.effect(() => dispose, 'dsh-gitbash-shell: bash status route')
2747
+ console.log(TAG + ' bash status route active at ' + route + ' (read by the guided popup)')
2748
+ } catch (error) {
2749
+ console.log(TAG + ' bash status route failed: ' + (error?.message ?? error))
2750
+ }
2751
+ })
2752
+ } catch (error) {
2753
+ console.log(TAG + ' bash status route wiring failed: ' + (error?.message ?? error))
2754
+ }
2755
+ }
2756
+
2611
2757
  // ── dsh-better-sidebar terminal adoption (Windows only) ────────────────
2612
2758
  // The sidebar resolves its terminal shell through the settings seam per
2613
2759
  // open; adopt it through that seam (see adoptSidebarShell for rationale).
2614
2760
  // Disable with `betterSidebarShell: false` in the plugin row config.
2615
2761
  if (config.betterSidebarShell !== false && process.platform === 'win32') {
2616
- adoptSidebarShell(ctx, gitBashCapability.bashPath, () => liveSettings.adoptSidebar())
2762
+ if (bashResolution.ok) adoptSidebarShell(ctx, bashResolution.path, () => liveSettings.adoptSidebar())
2763
+ else console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2617
2764
  }
2618
2765
 
2619
2766
  // ── era split: declarative registration on dsh >= 0.1.7 ──────────────────
@@ -2717,4 +2864,4 @@ export async function apply(ctx, config = {}) {
2717
2864
  }
2718
2865
 
2719
2866
  // Test surface: pure helpers, no Cordis context required.
2720
- export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, detectPeerCoverage, readSuppressPeerCordis, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, readPosixPaths, readAdoptSidebar, readDialectSettings, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim, baseForRoster, detectBase, pickComposition, personaEraForText, detectPersonaEra, injectPresentRow, hostHasToolPresent, detectPresentSupport, injectPluginManagerRow, hostHasPluginManagerTools, rowFormOf, rowFormsOf, alignEngineRow, alignRalphRow, ROW_SOURCE }
2867
+ export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, detectPeerCoverage, readSuppressPeerCordis, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, readPosixPaths, readAdoptSidebar, readDialectSettings, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim, baseForRoster, detectBase, pickComposition, personaEraForText, detectPersonaEra, injectPresentRow, hostHasToolPresent, detectPresentSupport, injectPluginManagerRow, hostHasPluginManagerTools, rowFormOf, rowFormsOf, alignEngineRow, alignRalphRow, ROW_SOURCE }
package/src/shell.js CHANGED
@@ -38,10 +38,13 @@
38
38
  */
39
39
 
40
40
  import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
41
+
42
+ import { DEFAULT_GIT_BASH, bashResolutionReport, effectiveConfiguredBashPath, resolveGitBashCached, settingsBashPath } from './bash-path.js'
43
+
44
+ /** Re-exported for callers that historically read it here (the value now lives in bash-path.js). */
45
+ export { DEFAULT_GIT_BASH }
41
46
  import z from '@deepseek-ai/schemastery'
42
47
 
43
- /** Default Git for Windows bash (forward slashes work on Windows too). */
44
- export const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
45
48
 
46
49
  /** Log prefix, matching src/index.js. */
47
50
  const TAG = '[gitbash-shell]'
@@ -95,7 +98,12 @@ export function gitBashShellConfig(z) {
95
98
  graceMs: live(z.number().default(3000)),
96
99
  // Our own knob: the base class neither declares nor `.get()`s it, and
97
100
  // `get bashPath()` below reads it as a plain value, so it stays plain.
98
- bashPath: z.string().default(DEFAULT_GIT_BASH),
101
+ //
102
+ // EMPTY means AUTO (v0.28.0, issue #11): the shared resolver walks
103
+ // setting > default install paths > PATH > registry PATH and refuses WSL /
104
+ // MSYS2 / Cygwin. A hard-coded default here is what made a Git installed
105
+ // outside C:/Program Files look like "dsh is broken".
106
+ bashPath: z.string().default(''),
99
107
  })
100
108
  }
101
109
 
@@ -107,9 +115,49 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
107
115
  static inject = ['subprocess', 'sandbox', 'sandboxPolicy']
108
116
  static Config = Config
109
117
 
110
- /** Effective Git Bash path: composition/config value with the default fallback. */
118
+ /**
119
+ * Effective Git Bash path. The configured value wins when it is non-empty
120
+ * (the user's explicit answer is never substituted); otherwise the SHARED
121
+ * resolver decides — the very same result the path-dialect translation layer
122
+ * uses, so "dialect works, executor cannot find bash" (issue #11) is
123
+ * structurally impossible.
124
+ *
125
+ * A failed resolution returns '' — deliberately NOT a substitute shell. Every
126
+ * argv built from it then fails loudly on spawn; `apply()` has already
127
+ * printed the full report, and the client half shows the guided popup.
128
+ */
111
129
  get bashPath() {
112
- return this.config.bashPath ?? DEFAULT_GIT_BASH
130
+ return this.bashResolution().path
131
+ }
132
+
133
+ /**
134
+ * Resolve the interpreter with the SAME merged explicit tier the main plugin
135
+ * uses (executor row config > `gitbash-shell` settings row > empty = auto),
136
+ * so both halves of the plugin always agree on which bash this process runs.
137
+ * @returns {object} the memoized resolution verdict.
138
+ */
139
+ bashResolution() {
140
+ const configured = effectiveConfiguredBashPath(this.config.bashPath, settingsBashPath(this.ctx))
141
+ return resolveGitBashCached({ configured })
142
+ }
143
+
144
+ /**
145
+ * The resolved Git Bash path, or a THROW that says exactly what is wrong.
146
+ *
147
+ * The throw is the design (AGENTS.md §4h): with no Git Bash there is no
148
+ * command execution at all — we do not substitute pwsh, cmd, WSL or MSYS2,
149
+ * and we do not let a bare `spawn ENOENT` be the only thing the user sees.
150
+ * The boot log carries the same report (src/index.js) and the client half
151
+ * shows the guided popup with the probed list.
152
+ * @returns {string} the resolved interpreter path.
153
+ */
154
+ requireBashPath() {
155
+ const resolution = this.bashResolution()
156
+ if (!resolution.ok) {
157
+ throw new Error('dsh-gitbash-shell: no Git for Windows bash found, so this command cannot run. '
158
+ + bashResolutionReport(resolution).split('\n').slice(1).join(' | '))
159
+ }
160
+ return resolution.path
113
161
  }
114
162
 
115
163
  /**
@@ -129,7 +177,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
129
177
  * @returns the provider's exact argv and settlement-classification facts (a promise on dsh >= 0.1.6).
130
178
  */
131
179
  confine(command, policy, signal) {
132
- return this.ctx.sandbox.confine([this.bashPath, '-c', command], policy, signal)
180
+ return this.ctx.sandbox.confine([this.requireBashPath(), '-c', command], policy, signal)
133
181
  }
134
182
 
135
183
  /**
@@ -219,7 +267,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
219
267
  const routed = this.withParityEnv(spec)
220
268
  const route = this.gitBashRoute(routed)
221
269
  if (route === undefined) return super.execute(routed)
222
- const argv = [this.bashPath, '-c', routed.command]
270
+ const argv = [this.requireBashPath(), '-c', routed.command]
223
271
  if (route.unconfined) this.warnConfinedUnconfined(route.mode)
224
272
  const sandbox = route.unconfined
225
273
  ? { mode: route.mode, denied: false, enforcement: 'unconfined' }
@@ -304,13 +352,13 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
304
352
  const { mode } = policy
305
353
  if (mode === 'danger-full-access') {
306
354
  const { result } = GitBashSandboxExecutor.unwrapRunArgv(
307
- await this.runArgv(spec, [this.bashPath, '-c', spec.command]))
355
+ await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
308
356
  return { ...result, sandbox: { mode, denied: false } }
309
357
  }
310
358
  if (process.platform === 'win32') {
311
359
  this.warnConfinedUnconfined(mode)
312
360
  const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv(
313
- await this.runArgv(spec, [this.bashPath, '-c', spec.command]))
361
+ await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
314
362
  // No spawn means no argv ran, so there is nothing to label as
315
363
  // unconfined; the parent reports the same bare sandbox facts there.
316
364
  return { ...result, sandbox: spawnRequested ? { mode, denied: false, enforcement: 'unconfined' } : { mode, denied: false } }
@@ -326,7 +374,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
326
374
  const fullAccess = mode === 'danger-full-access'
327
375
  if (fullAccess || process.platform === 'win32') {
328
376
  if (!fullAccess) this.warnConfinedUnconfined(mode)
329
- const proc = this.startArgv(spec, [this.bashPath, '-c', spec.command])
377
+ const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command])
330
378
  proc.sandbox = fullAccess ? { mode, denied: false } : { mode, denied: false, enforcement: 'unconfined' }
331
379
  // Match the loaded base contract: the async one (dsh >= 0.1.6) is
332
380
  // awaited by its callers, the synchronous one is consumed directly.