dsh-gitbash-shell 0.13.2 → 0.14.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
4
 
5
+ 简体中文 | [English](README_EN.md)
6
+
5
7
  > 让 DeepSeek Harness (dsh) 在 Windows 上**全部使用 Git Bash** 的官方风格插件
6
8
  > —— 以 Git for Windows 的 `bash.exe` 替换 PowerShell 执行器,并为所有模式
7
9
  > 物化对应的 Git Bash 版 agent preset。
@@ -37,6 +39,19 @@ era。**preset id 保持 `code-gitbash` 不变**(会话钉在 id 上,改名会
37
39
  会话报 preset not found)。无论先升级插件还是先升级 dsh,都会自动收敛;用户改
38
40
  过的目录照旧不碰。
39
41
 
42
+ ### dsh 0.1.6 适配(工作流引擎行改名)
43
+
44
+ dsh 0.1.6-alpha.1 把内置预设的工作流引擎行 `workflow-worker-thread` 改名为
45
+ `workflow-ptc`,并**删除**了旧包。组合里一行 import 失败会拒绝**整棵 preset 挂载**,
46
+ 所以把旧名钉死在资产里的 preset 在新版上会直接不可用。本插件两个拼法都不钉:物化时
47
+ **从宿主自己的内置 preset 现场抄**那一行的 id、包名与 `disabled` 状态
48
+ (`rowFormsOf` / `alignEngineRow`),并让 `tool-ralph` 跟随新版默认的 `disabled: true`。
49
+ 改写是纯字符串手术(不解析 YAML,`!!js` 安全)且幂等;探测失败(旧宿主、无 roster)时
50
+ 资产保持逐字节原样——**一份资产通吃两个 era,升级顺序无关**。
51
+
52
+ 同一版还把 `LocalBashExecutor` 的受保护钩子改成了异步:插件的 `runArgv` 结果解包、
53
+ `start` 的返回形态与 `confine` 的取消信号都按**加载期探测**自适应,新旧宿主行为一致。
54
+
40
55
  ## 安装(公开 npm 插件,推荐)
41
56
 
42
57
  npm: [dsh-gitbash-shell](https://www.npmjs.com/package/dsh-gitbash-shell),
package/README_EN.md CHANGED
@@ -2,10 +2,29 @@
2
2
 
3
3
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
4
 
5
+ [简体中文](README.md) | English
6
+
5
7
  > Run **every agent shell command through Git Bash** on Windows with DeepSeek
6
8
  > Harness (dsh) — replaces the PowerShell executor and materializes Git Bash
7
9
  > variants of all four agent presets.
8
10
 
11
+ ### dsh 0.1.6: the workflow-engine row was renamed
12
+
13
+ dsh 0.1.6-alpha.1 renamed the built-in presets' workflow-engine row from
14
+ `workflow-worker-thread` to `workflow-ptc` and **deleted** the old package. One row
15
+ that fails to import rejects the **whole preset mount**, so a composition pinning the
16
+ old name simply stops working on the new host. This plugin pins neither spelling: at
17
+ materialization it copies that row — id, package and `disabled` state — straight out
18
+ of the host's own built-in preset (`rowFormsOf` / `alignEngineRow`), and aligns
19
+ `tool-ralph` with the new `disabled: true` default. The rewrite is plain string
20
+ surgery (no YAML round-trip, so `!!js` stays safe) and idempotent; a failed probe (old
21
+ host, no roster) leaves the assets byte-for-byte untouched — **one set of assets serves
22
+ both eras, in either upgrade order**.
23
+
24
+ The same release made `LocalBashExecutor`'s protected hooks asynchronous: the
25
+ `runArgv` result unwrapping, the `start` return shape and the `confine` cancellation
26
+ signal are all probed at load time, so both host generations behave identically.
27
+
9
28
  ## Install (public npm package)
10
29
 
11
30
  ```sh
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器,并物化 standard/minimal/code/cordis 的 Git Bash 变体 preset。双 era 适配 dsh <= 0.1.1(内置 id 'code')与 >= 0.1.2(改名 'ptc'),启动探测自动选择,preset id 永不变。联动:dsh-ptc-cordis-preset(两者同装时 PTC 创造模式自动物化为 Git Bash 版)+ dsh-better-sidebar(终端 UI 标签与模型 terminal_* 工具自动切到 Git Bash)。",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "DSH plugin: run every agent shell command through Git for Windows bash on Windows instead of PowerShell. Replaces the pwsh executor with a Git Bash ctx.shell provider and materializes Git Bash variants of the standard/minimal/code/cordis agent presets into your user preset root at startup. On Windows it also adopts dsh-better-sidebar's terminal shell (UI tabs and the model-facing terminal_* tools) through the sidebar's official settings seam, and pairs with dsh-ptc-cordis-preset so the PTC Creation-mode preset materializes as its Git Bash variant. Ships both composition eras for dsh <= 0.1.1 (built-in preset id 'code') and dsh >= 0.1.2 (renamed id 'ptc') — the era is probed per boot and re-materialized automatically after a dsh upgrade, in either upgrade order; preset ids never change so pinned sessions stay valid.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -231,6 +231,183 @@ async function detectPresentSupport(agentPresets) {
231
231
  }
232
232
  }
233
233
 
234
+ /** Row ids the shipped workflow-engine row has answered to (dsh 0.1.6 renamed it). */
235
+ const ENGINE_ROW_IDS = ['workflow-worker-thread', 'workflow-ptc']
236
+
237
+ /** Which built-in preset each of this plugin's variants mirrors, for row-form lookup. */
238
+ export const ROW_SOURCE = {
239
+ 'standard-gitbash': 'standard',
240
+ 'cordis-gitbash': 'cordis',
241
+ 'code-gitbash': 'ptc',
242
+ }
243
+
244
+ /** Leading whitespace of one line (pure). */
245
+ function indentOf(line) {
246
+ return line.slice(0, line.length - line.trimStart().length)
247
+ }
248
+
249
+ /** The unquoted value of a name line, or undefined when the line is not one (pure). */
250
+ function quotedName(line) {
251
+ const trimmed = line.trim()
252
+ if (!trimmed.startsWith('name: ')) return undefined
253
+ const raw = trimmed.slice(6).trim()
254
+ if (raw.length > 1 && raw.charAt(0) === "'" && raw.charAt(raw.length - 1) === "'") return raw.slice(1, -1)
255
+ return raw
256
+ }
257
+
258
+ /**
259
+ * Read ONE row's spelling out of a composition text (pure): the name value
260
+ * that follows its id line and whether the row's own block turns it off before
261
+ * the next row starts. Returns undefined when the row is absent, and never
262
+ * throws, so a composition that legitimately lacks the row (minimal) is simply
263
+ * left alone.
264
+ * @param text - composition text.
265
+ * @param rowId - exact id value to locate.
266
+ * @returns the row's spelling, or undefined.
267
+ */
268
+ export function rowFormOf(text, rowId) {
269
+ const lines = text.split('\n')
270
+ for (let i = 0; i < lines.length; i += 1) {
271
+ if (lines[i].trim() !== '- id: ' + rowId) continue
272
+ const nameLine = i + 1 < lines.length ? lines[i + 1] : ''
273
+ const name = quotedName(nameLine)
274
+ if (name === undefined) continue
275
+ let disabled = false
276
+ let disabledIndent = indentOf(nameLine)
277
+ for (let j = i + 2; j < lines.length; j += 1) {
278
+ if (lines[j].trim().startsWith('- id: ')) break
279
+ if (lines[j].trim() === 'disabled: true') {
280
+ disabled = true
281
+ disabledIndent = indentOf(lines[j])
282
+ break
283
+ }
284
+ }
285
+ return { id: rowId, name, indent: indentOf(lines[i]), nameIndent: indentOf(nameLine), disabled, disabledIndent }
286
+ }
287
+ return undefined
288
+ }
289
+
290
+ /**
291
+ * Read the two rows this plugin's committed compositions must track out of a
292
+ * SHIPPED composition text (pure). The built-in preset is the authority: it
293
+ * mounts on this host by construction, so whatever it spells is mountable
294
+ * here, on every era, without shipping one asset variant per rename.
295
+ * @param text - a shipped agent.cordis.yml.
296
+ * @returns an object with the engine row and the tool-ralph row; either may be undefined.
297
+ */
298
+ export function rowFormsOf(text) {
299
+ let engine
300
+ for (const id of ENGINE_ROW_IDS) {
301
+ engine = rowFormOf(text, id)
302
+ if (engine !== undefined) break
303
+ }
304
+ return { engine, ralph: rowFormOf(text, 'tool-ralph') }
305
+ }
306
+
307
+ /**
308
+ * Align the workflow-engine row with the host's own spelling (pure,
309
+ * idempotent, plain string surgery — never a YAML round-trip, so !!js literals
310
+ * survive).
311
+ *
312
+ * dsh 0.1.6-alpha.1 renamed this row workflow-worker-thread to workflow-ptc
313
+ * AND deleted the old package. A composition row whose module fails to import
314
+ * rejects the WHOLE preset mount (agent-presets mount.ts), so a committed old
315
+ * spelling breaks every Git Bash preset on the new host while the new spelling
316
+ * breaks them on hosts that still ship only the old package. The materializer
317
+ * therefore copies the row out of the host's own built-in preset instead of
318
+ * pinning either spelling.
319
+ * @param text - composition text.
320
+ * @param form - the host's engine row from rowFormsOf, or undefined to leave the text alone.
321
+ * @param options - engineEnabled forces the row on regardless of the host's own state.
322
+ * @returns the aligned text.
323
+ */
324
+ export function alignEngineRow(text, form, options) {
325
+ if (!form) return text
326
+ const engineEnabled = Boolean(options && options.engineEnabled)
327
+ let current
328
+ for (const id of ENGINE_ROW_IDS) {
329
+ current = rowFormOf(text, id)
330
+ if (current !== undefined) break
331
+ }
332
+ if (current === undefined) return text
333
+ const head = '- id: ' + current.id + '\n' + current.nameIndent + "name: '" + current.name + "'"
334
+ const wanted = '- id: ' + form.id + '\n' + current.nameIndent + "name: '" + form.name + "'"
335
+ const wantOff = engineEnabled ? false : form.disabled
336
+ let out = text.replace(head, wanted)
337
+ if (wantOff && !current.disabled) {
338
+ out = out.replace(wanted + '\n', wanted + '\n' + current.nameIndent + 'disabled: true\n')
339
+ } else if (!wantOff && current.disabled) {
340
+ out = out.replace(wanted + '\n' + current.disabledIndent + 'disabled: true\n', wanted + '\n')
341
+ }
342
+ return out
343
+ }
344
+
345
+ /**
346
+ * Align the tool-ralph row with the host's own default (pure, idempotent).
347
+ * dsh 0.1.6-alpha.1 ships ralph disabled in every built-in preset: its tool
348
+ * description restricts it to runs the human explicitly asked for. The row
349
+ * still imports on older hosts, so this is a semantics alignment rather than a
350
+ * mount fix — but a composition claiming to mirror the built-in preset should
351
+ * not silently re-enable a tool the deployment turned off.
352
+ * @param text - composition text.
353
+ * @param form - the host's tool-ralph row from rowFormsOf, or undefined.
354
+ * @returns the aligned text.
355
+ */
356
+ export function alignRalphRow(text, form) {
357
+ if (!form) return text
358
+ const current = rowFormOf(text, 'tool-ralph')
359
+ if (current === undefined) return text
360
+ const head = '- id: tool-ralph\n' + current.nameIndent + "name: '" + current.name + "'"
361
+ if (form.disabled && !current.disabled) {
362
+ return text.replace(head + '\n', head + '\n' + current.nameIndent + 'disabled: true\n')
363
+ }
364
+ if (!form.disabled && current.disabled) {
365
+ return text.replace(head + '\n' + current.disabledIndent + 'disabled: true\n', head + '\n')
366
+ }
367
+ return text
368
+ }
369
+
370
+ /**
371
+ * Probe every built-in preset this plugin's variants mirror for those row
372
+ * spellings (never throws). The live roster is the authority — package
373
+ * resolution alone would lie on linked development trees and on CLI installs
374
+ * whose first-party packages live outside the profile.
375
+ * @param agentPresets - the roster service.
376
+ * @returns a map of row forms per available built-in id, or undefined.
377
+ */
378
+ async function detectRowForms(agentPresets) {
379
+ try {
380
+ const list = await agentPresets.list()
381
+ const entries = Array.isArray(list) ? list : []
382
+ const out = {}
383
+ for (const id of ['ptc', 'standard', 'cordis']) {
384
+ const entry = entries.find((p) => p && p.id === id && typeof p.path === 'string')
385
+ if (!entry) continue
386
+ const file = /\.yml$/.test(entry.path) ? entry.path : join(entry.path, 'agent.cordis.yml')
387
+ out[id] = rowFormsOf(readFileSync(file, 'utf8'))
388
+ }
389
+ return Object.keys(out).length > 0 ? out : undefined
390
+ } catch (error) {
391
+ console.log(TAG + ' row-form probe failed (' + (error && error.message ? error.message : error) + ') — keeping the committed spellings')
392
+ return undefined
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Marker fingerprint of the row spellings one variant is aligned to, so a host
398
+ * that renames (or re-defaults) them re-materializes on the next boot.
399
+ * @param rows - detectRowForms result.
400
+ * @param presetId - the variant being materialized.
401
+ * @returns a short stable string, '' when nothing was probed.
402
+ */
403
+ function rowFingerprint(rows, presetId) {
404
+ const source = ROW_SOURCE[presetId]
405
+ const form = rows && source ? rows[source] : undefined
406
+ if (!form || !form.engine) return ''
407
+ const ralph = form.ralph === undefined ? 'na' : (form.ralph.disabled ? 'off' : 'on')
408
+ return form.engine.id + ':' + (form.engine.disabled ? 'off' : 'on') + ':' + ralph
409
+ }
410
+
234
411
  /**
235
412
  * Pick the committed composition asset inside one variant directory (pure).
236
413
  * The ptc-era twin is preferred when the roster says `ptc`; the persona-split
@@ -250,11 +427,17 @@ function pickComposition(base, persona, available) {
250
427
  }
251
428
 
252
429
  /** Startup decision for an existing unmodified tree: 'refresh' or 'idle'. */
253
- function syncDecision({ state, marker, version, sourceHashes, base = 'code', persona = 'text', present = false }) {
430
+ function syncDecision({ state, marker, version, sourceHashes, base = 'code', persona = 'text', present = false, rows = '' }) {
254
431
  if (state !== 'unmodified' || !marker) return 'refresh'
255
432
  if (marker.version !== version) return 'refresh'
256
433
  if (marker.base !== base) return 'refresh'
257
434
  if ((marker.persona ?? 'text') !== persona) return 'refresh'
435
+ // Host row-form flip: dsh 0.1.6-alpha.1 renamed the workflow-engine row and
436
+ // deleted the package behind the old spelling. A composition row that cannot
437
+ // be imported rejects the WHOLE preset mount, so a host reporting a
438
+ // different spelling must re-materialize (same marker-dimension pattern as
439
+ // persona and present).
440
+ if ((marker.rows ?? '') !== rows) return 'refresh'
258
441
  // Host-capability flip: @deepseek-ai/dsh-tool-present first shipped with dsh
259
442
  // 0.1.5-alpha.2. A preset composed with the row on a host that lacks the
260
443
  // package REJECTS THE WHOLE MOUNT (mount.ts: a row whose module failed to
@@ -315,7 +498,7 @@ async function hostHasToolPresent() {
315
498
  }
316
499
 
317
500
  /** Write one preset directory from scratch. Returns 'ok' or 'no-skills-source'. */
318
- function materialize({ target, presetId, skillsSource, version, base = 'code', persona = 'text', present = false }) {
501
+ function materialize({ target, presetId, skillsSource, version, base = 'code', persona = 'text', present = false, rows }) {
319
502
  rmSync(target, { recursive: true, force: true })
320
503
  mkdirSync(target, { recursive: true })
321
504
 
@@ -323,6 +506,17 @@ function materialize({ target, presetId, skillsSource, version, base = 'code', p
323
506
  const compositionFile = pickComposition(base, persona, available)
324
507
  let composition = readFileSync(join(pkgDir, 'assets', presetId, compositionFile), 'utf8')
325
508
  if (present && compositionFile.includes('.ptc.')) composition = injectPresentRow(composition)
509
+ // Row-form alignment (v0.14.0): the committed assets pin one spelling of the
510
+ // workflow-engine row, but a host renames it out from under them (dsh
511
+ // 0.1.6-alpha.1) and a row whose module fails to import rejects the whole
512
+ // mount. Copy the host's own spelling rather than shipping a third asset
513
+ // variant per era. Absent probe = leave the frozen history untouched.
514
+ const rowSource = ROW_SOURCE[presetId]
515
+ const rowForm = rows && rowSource ? rows[rowSource] : undefined
516
+ if (rowForm) {
517
+ composition = alignEngineRow(composition, rowForm.engine)
518
+ composition = alignRalphRow(composition, rowForm.ralph)
519
+ }
326
520
  writeFileSync(join(target, 'agent.cordis.yml'), composition)
327
521
  writeFileSync(join(target, 'preset.yml'), readFileSync(join(pkgDir, 'assets', presetId, 'preset.yml')))
328
522
 
@@ -337,7 +531,7 @@ function materialize({ target, presetId, skillsSource, version, base = 'code', p
337
531
  }
338
532
  }
339
533
 
340
- const marker = { managedBy: MANAGED_BY, version, presetId, base, persona, present, files: hashTree(target) }
534
+ const marker = { managedBy: MANAGED_BY, version, presetId, base, persona, present, rows: rowFingerprint(rows, presetId), files: hashTree(target) }
341
535
  writeFileSync(join(target, MARKER_FILE), JSON.stringify(marker, null, 2) + '\n')
342
536
  return skills
343
537
  }
@@ -951,6 +1145,10 @@ export async function apply(ctx, config = {}) {
951
1145
  // resolving from this plugin's own tree (covers hosts where the roster
952
1146
  // probe is unavailable).
953
1147
  const present = (await detectPresentSupport(ctx.agentPresets)) || (await hostHasToolPresent())
1148
+ // Row spellings of the built-in presets these variants mirror: the
1149
+ // workflow-engine row was renamed and its old package deleted in dsh
1150
+ // 0.1.6-alpha.1, so the materializer copies whatever the host itself ships.
1151
+ const rows = await detectRowForms(ctx.agentPresets)
954
1152
  const userRootPath = userRoot.path
955
1153
  purgeOrphans(userRootPath, presetIds)
956
1154
 
@@ -980,12 +1178,13 @@ export async function apply(ctx, config = {}) {
980
1178
 
981
1179
  const sourceHashes = presetId === 'cordis-gitbash' ? skillsHashes(skillsSource) : null
982
1180
  const marker = readMarker(target)
983
- if (state === 'unmodified' && syncDecision({ state, marker, version, sourceHashes, base, persona, present }) === 'idle') {
1181
+ const markerRows = rowFingerprint(rows, presetId)
1182
+ if (state === 'unmodified' && syncDecision({ state, marker, version, sourceHashes, base, persona, present, rows: markerRows }) === 'idle') {
984
1183
  ctx.logger?.('gitbash-shell')?.debug?.( `preset '${presetId}' up to date (v${version}, ${base}-era${persona === 'split' ? ', persona-split' : ''}) — idle`)
985
1184
  continue
986
1185
  }
987
1186
 
988
- const skills = materialize({ target, presetId, skillsSource, version, base, persona, present })
1187
+ const skills = materialize({ target, presetId, skillsSource, version, base, persona, present, rows })
989
1188
  const verb = state === 'absent' ? 'materialized' : 'refreshed'
990
1189
  console.log(
991
1190
  `${TAG} ${verb} preset '${presetId}' into ${userRootPath} (v${version}, ${base}-era composition${persona === 'split' ? ', persona-split' : ''})` +
@@ -995,4 +1194,4 @@ export async function apply(ctx, config = {}) {
995
1194
  }
996
1195
 
997
1196
  // Test surface: pure helpers, no Cordis context required.
998
- export const _internal = { PRESET_IDS, translateMsysPath, translatePathArguments, readPosixPaths, windowsToMsys, rewriteResultPaths, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim, baseForRoster, detectBase, pickComposition, personaEraForText, detectPersonaEra, injectPresentRow, hostHasToolPresent, detectPresentSupport }
1197
+ export const _internal = { PRESET_IDS, translateMsysPath, translatePathArguments, readPosixPaths, windowsToMsys, rewriteResultPaths, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim, baseForRoster, detectBase, pickComposition, personaEraForText, detectPersonaEra, injectPresentRow, hostHasToolPresent, detectPresentSupport, rowFormOf, rowFormsOf, alignEngineRow, alignRalphRow, ROW_SOURCE }
package/src/shell.js CHANGED
@@ -70,12 +70,52 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
70
70
  /**
71
71
  * Wrap one shell command via the ctx.sandbox provider, substituting Git Bash
72
72
  * for the shipped bare `bash` argv.
73
+ *
74
+ * Signature spans both dsh eras: 0.1.6-alpha.1 made the provider's confine
75
+ * async and handed it the caller's abort signal (the base class now calls
76
+ * `this.confine(command, policy, signal)` and awaits it). The predecessor
77
+ * was synchronous and took two arguments. Forwarding the third argument is
78
+ * safe on both — the old provider ignores it and its synchronous return is
79
+ * what the old base class still consumes — while dropping it on the new host
80
+ * would silently detach sandbox preparation from cancellation.
73
81
  * @param command - shell source for the confined inner `bash -c`.
74
82
  * @param policy - resolved confined execution policy.
75
- * @returns the provider's exact argv and settlement-classification facts.
83
+ * @param signal - caller deadline/cancellation, forwarded when the host provides one.
84
+ * @returns the provider's exact argv and settlement-classification facts (a promise on dsh >= 0.1.6).
76
85
  */
77
- confine(command, policy) {
78
- return this.ctx.sandbox.confine([this.bashPath, '-c', command], policy)
86
+ confine(command, policy, signal) {
87
+ return this.ctx.sandbox.confine([this.bashPath, '-c', command], policy, signal)
88
+ }
89
+
90
+ /**
91
+ * Whether the LOADED base class declares an async `start`, probed once
92
+ * (pure). dsh 0.1.6-alpha.1 turned LocalBashExecutor.start into an async
93
+ * method returning Promise<ShellProcess>; before that it returned the live
94
+ * handle directly. This override has to match whichever contract the host
95
+ * actually carries: handing a plain handle to a host that awaits is fine,
96
+ * but handing a Promise to a synchronous host would give it a thenable
97
+ * instead of a process. Shape is probed, never inferred from a version.
98
+ * @returns true when the base class start is an async function.
99
+ */
100
+ static baseStartIsAsync() {
101
+ return SandboxBashExecutor.prototype.start.constructor.name === 'AsyncFunction'
102
+ }
103
+
104
+ /**
105
+ * Unwrap `runArgv`'s settlement across dsh eras (pure). 0.1.6-alpha.1
106
+ * changed the protected hook's return value from the bare ShellRunResult to
107
+ * `{ result, spawnRequested }`, where spawnRequested is false when
108
+ * preparation was cancelled before any spawn. Spreading the wrapper as if it
109
+ * were the result would hand the caller an object carrying no exitCode and
110
+ * no streams at all, so it is unwrapped by shape — never by version.
111
+ * @param raw - whatever the loaded `runArgv` resolved to.
112
+ * @returns the bare result plus whether argv ever reached the provider.
113
+ */
114
+ static unwrapRunArgv(raw) {
115
+ if (raw !== null && typeof raw === 'object' && typeof raw.spawnRequested === 'boolean' && raw.result !== undefined) {
116
+ return { result: raw.result, spawnRequested: raw.spawnRequested }
117
+ }
118
+ return { result: raw, spawnRequested: true }
79
119
  }
80
120
 
81
121
  /**
@@ -93,13 +133,17 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
93
133
  if (policy === undefined) return super.run(spec)
94
134
  const { mode } = policy
95
135
  if (mode === 'danger-full-access') {
96
- const result = await this.runArgv(spec, [this.bashPath, '-c', spec.command])
136
+ const { result } = GitBashSandboxExecutor.unwrapRunArgv(
137
+ await this.runArgv(spec, [this.bashPath, '-c', spec.command]))
97
138
  return { ...result, sandbox: { mode, denied: false } }
98
139
  }
99
140
  if (process.platform === 'win32') {
100
141
  this.warnConfinedUnconfined(mode)
101
- const result = await this.runArgv(spec, [this.bashPath, '-c', spec.command])
102
- return { ...result, sandbox: { mode, denied: false, enforcement: 'unconfined' } }
142
+ const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv(
143
+ await this.runArgv(spec, [this.bashPath, '-c', spec.command]))
144
+ // No spawn means no argv ran, so there is nothing to label as
145
+ // unconfined; the parent reports the same bare sandbox facts there.
146
+ return { ...result, sandbox: spawnRequested ? { mode, denied: false, enforcement: 'unconfined' } : { mode, denied: false } }
103
147
  }
104
148
  return super.run(spec)
105
149
  }
@@ -108,16 +152,14 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
108
152
  const policy = spec.sandboxPolicy
109
153
  if (policy === undefined) return super.start(spec)
110
154
  const { mode } = policy
111
- if (mode === 'danger-full-access') {
112
- const proc = this.startArgv(spec, [this.bashPath, '-c', spec.command])
113
- proc.sandbox = { mode, denied: false }
114
- return proc
115
- }
116
- if (process.platform === 'win32') {
117
- this.warnConfinedUnconfined(mode)
155
+ const fullAccess = mode === 'danger-full-access'
156
+ if (fullAccess || process.platform === 'win32') {
157
+ if (!fullAccess) this.warnConfinedUnconfined(mode)
118
158
  const proc = this.startArgv(spec, [this.bashPath, '-c', spec.command])
119
- proc.sandbox = { mode, denied: false, enforcement: 'unconfined' }
120
- return proc
159
+ proc.sandbox = fullAccess ? { mode, denied: false } : { mode, denied: false, enforcement: 'unconfined' }
160
+ // Match the loaded base contract: the async one (dsh >= 0.1.6) is
161
+ // awaited by its callers, the synchronous one is consumed directly.
162
+ return GitBashSandboxExecutor.baseStartIsAsync() ? Promise.resolve(proc) : proc
121
163
  }
122
164
  return super.start(spec)
123
165
  }