dsh-gitbash-shell 0.32.1 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,55 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.33.1 — 2026-10-02
7
+
8
+ **类型**:docs(issue #14:README「保留官方沙箱语义」与 Windows 受限模式的实际行为不一致)
9
+
10
+ > 报告者 **SenkjM** 的五条观察(enforcement 元数据、完整性级别 Low→Medium、shell 越界写成功而文件工具被拒、一次性提示、README 关键词计数)**全部与设计一致**;他指出的是文档缺口——行为在代码层有如实标注(§1 红线「绝不静默假成功」守住的是结果元数据与运行期提示),但 README 层面除了「保留官方沙箱语义」这句误导性概括外零披露。本版补齐,并顺带修正 README 里残留的物化时代描述。
11
+
12
+ - **README.md / README_EN.md 新增「安全语义:Windows 上的受限模式」章节**(置于「它做了什么」之后):POSIX 完整保留 / Windows 受限模式 shell 走 unconfined 的根因(MSYS2 vs restricted-token,OS 层冲突无插件内解)与取舍理由、如何标注(结果元数据 `enforcement: "unconfined"` + 每进程一次性日志)、**文件工具不受影响**(fs 沙箱另一层,边界不一致如实写明)、可观察对照(完整性级别不再压低 Low→Medium、TEMP 不再隔离)、以及「需要 shell 也被硬拦 ⇒ 本插件在 Windows 上不适合你,卸载即回 pwsh-sandbox,没有中间态」的明确选择提示;「它解决什么」的「保留官方沙箱语义」改为指向该章节。
13
+ - **顺带清理 README 的物化时代残留描述**(行为未变,纯文档对齐 v0.32.0+ 现状):「它做了什么」第 3 条改为声明式注册(`ctx.agentPresets.register`,行集为已提交 JS 数据,不再物化目录,旧物化树按 marker 单向清理);删除「dsh 版本双适配」「dsh 0.1.6 适配」两个过时段落(era 机制已随 v0.32.0 删除),替换为「宿主要求与 preset 对齐」(下限 >=0.1.7-rc + smoke 锁对齐 + preset id 不变);「配置」/「ptc 联动」/「去重开关」段的「物化」措辞与旧宿主(≤0.1.6)双时代描述同步更新。
14
+ - docs-only:src/ 零改动,冒烟 99 项不变(README 内容无 smoke 断言)。发 patch 版本而非仅推文档,是因为 README 随 npm 包分发——安全披露应让 `npm view` / 插件管理页看到的 README 同步更新。
15
+
16
+ - 相关:issue #14(<https://github.com/KannaKuron/dsh-gitbash-shell/issues/14>)、CHANGELOG v0.13.2(取舍的完整分析)。
17
+
18
+ ## v0.33.0 — 2026-10-01
19
+
20
+ **类型**:fix(issue #13:Scoop 安装 Git 时 `bashPath` 配置正确但每次启动仍报 could NOT be resolved 并弹引导框)+ feat(解析链新增 `git --exec-path` 与 GitForWindows 注册表两级发现)
21
+
22
+ > 报告者 **ID-izlq-Github** 的定位是准确的:装配期冻结、两个读取器在 apply 时刻读空、自动链对 shim 布局盲区,三条全部成立。本条目按他的分析与建议落地(建议 1/2/3/4 全采纳)。
23
+
24
+ ### 修复一:显式层懒化,不再在装配期冻结(`src/index.js`)
25
+
26
+ - **冻结点**(issue 引用的 `index.js:1813`):apply() 时 `effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))` 算一次就定死。那一刻 `ctx.loader.resolve('gitbash-executor')` 因行未进 store 而 throw → 被吞成 `''`;settings 服务也可能尚未注入(0.1.7+ 只有 describe()/update())。两者皆空 ⇒ 误判「未配置」⇒ 跑自动链。执行器(`shell.js` 的 `get bashPath()` 懒解析)却正常 —— **同一次启动、两条路径、相反结论**,这正是 issue 的内核。
27
+ - **修法**:冻结值换成两个读取器 `readConfiguredBashPath()` / `readBashResolution()`,**全部消费点逐次重读**:① `gitBash` capability 改 getter(bashPath/ok/source/configured/tried;ptc 侧只读静态 `active`,不受影响);② status 路由实时重读(弹窗因此不再误报 —— 行挂载完成后 `configured` 非空,解析成功,`ok:true`,弹窗根本不出现);③ pathmap 路由与 tools/execute / post-execute 的 `buildTranslateEnv`;④ 官方侧栏终端接管每次尝试重读;⑤ better-sidebar 接管改挂 settle 通知。
28
+ - **settings 绕行退役**:`gitbash-shell` 行的 `bashPath` 直接从 apply() 的 `config` 参数读(新 `rowBashPath(config)`:volatile ref / 普通值 / 缺席三态,绝不抛)—— 这就是同一行的值,比绕 `settings.describe()` 更直接,且 volatile 意味着用户改设置下一读即生效。宿主半不再 import `settingsBashPath`(shell.js 侧保留:executor 行运行时读 describe() 实测正常)。
29
+ - **启动日志 settle 化**:boot 打印一次;「失败且显式层为空」(行可能还在挂载)时轮询 250ms×20(仅 win32,5 秒封顶),显式层出现或解析翻转后补打 `(settled)` 段;成功或显式值被拒为最终态,不轮询。terminal 接管通过 `onBashSettled` 通知(better-sidebar 在 boot 失败→settled 成功时也能接管,无需重启)。
30
+
31
+ ### 修复一·续:executor 行读取在 0.2.0-rc.2 上恒断,不只是时序(`src/bash-path.js`)
32
+
33
+ - **真机实测(隔离实例 + 探针插件,报告场景复现)**:dsh 0.2.0-rc.2 上 bundle-patch insert 的行(`gitbash-executor`)挂在**所属插件的 subtree** 里,`loader.resolve('gitbash-executor')` 从 root 树查找**恒 throw**(探针 apply 时刻与 12 秒后各 7 次采样全部 throw)——报告者以为的「启动时序问题」在这台宿主上实际是**永远读不到**。旧代码把 throw 吞成 `''` ⇒ 显式层整个失效:patch 写 `gitbash-executor.config.bashPath` 的用户(issue #11 起推荐、#13 报告者的实际配置方式)在 0.2.0 上 boot/status/翻译层全部看不到该值,而执行器自己读 `this.config.bashPath` 正常 —— 「命令能用但每次启动报错+弹框」的另一半根因。**并连带违反 §4h 契约**:显式值(哪怕被拒)不得被自动链顶替 —— 实测坏值 `Q:/nope/bin/bash.exe` 被自动链的 default 层「顶替成功」,boot 谎报 resolved。
34
+ - **修法**:`executorConfiguredBashPath` 二段式 —— `resolve('gitbash-executor')` 先试(旧宿主行在 root 树,命中即返回,空串也是真实答案=自动链);throw 则 `loader.entries()` 迭代(**含 subtree**)按行自身 `options.id` 匹配(0.2.0 实测稳定可读,`bashPath` 值全程不变)。防回归用例锁死三种 loader 形状(root 命中 / subtree+entries / 行缺失)。
35
+ - **修复后 A/B 真机读数**(隔离实例,patch 写 `gitbash-executor.config.bashPath`):
36
+ - 坏值 `Q:/nope/...`:boot `bashPath is set to "Q:/nope/bin/bash.exe" and that value was rejected; nothing else is substituted`(fail-loud、无轮询、default 层不再顶替);status `ok:false, configured:"Q:/nope/bin/bash.exe", tried[0]={source:'configured',code:'missing'}` —— boot/status/执行器三方一致;
37
+ - 好值:boot `Git Bash resolved from the configured source`;status `ok:true, source:"configured"` —— 弹窗不出现。
38
+
39
+ ### 修复二:解析链补 shim 布局的发现方式(`src/bash-path.js`)
40
+
41
+ - **新 source `exec-path`**(位置:PATH 反推之后、注册表 PATH 之前):对 PATH 上第一个 `git.exe` 跑 `git --exec-path`(Scoop/choco 的 **shim 也能启动真身**,真身报告自己的家),从输出向上最多 5 级找「同时含 `cmd/git.exe` 与 `usr/bin/bash.exe`」的目录,产出 `<root>/bin/bash.exe`。纯函数 `execPathBashCandidates(execPath, exists)` 可测;`ucrt64`/`mingw64`/旧 flat `libexec` 三种布局全覆盖,MSYS2(`usr/libexec/git-core`,根上没有 `cmd/git.exe`)天然不产出候选。
42
+ - **GitForWindows 注册表兜底**:`HKLM\SOFTWARE\GitForWindows`、`HKLM\SOFTWARE\WOW6432Node\GitForWindows`、`HKCU\Software\GitForWindows` 的 `InstallPath`(官方安装器写的;Scoop/便携通常没有,故排在 exec-path 之后),同样走 `exec-path` source。
43
+ - **候选仍过全套判据**(黑名单 → 形状 → `cmd/git.exe`/`usr/bin/bash.exe` → mingw64/msys-2.0.dll → `.windows.` 版本指纹 → `uname -s` 一票否决):§4h 两条硬边界不变 —— 永不回退、只认 Git for Windows 的 bash。冒烟新增「WSL bash 经 exec-path 到达仍被拒」用例。
44
+ - **顺带**:`defaultIo().run` 从 execFileSync 换 spawnSync(piped stdio)—— execFileSync 的同步家族在子进程失败时把 stderr 直接泄漏到宿主日志(reg 的 GBK 错误文本 = 乱码),v0.28.0 起每次失败探测都在漏;spawnSync 捕获进 result 由 catch 丢弃。本机实测乱码消除、探测结果不变(exec-path 层产出 `C:/Program Files/Git/bin/bash.exe`,mingw64 布局反推正确,全链仍 `source=default` 先命中,新层是后备)。
45
+
46
+ ### 冒烟与真机
47
+
48
+ - 新增三组用例:①「exec-path covers Scoop layouts」(纯函数三布局 + MSYS2 不命中 + 全链 Scoop 场景 + WSL 否决);②「explicit tier is LAZY」(冻结模式防回归:断言 `const configuredBashPath = effectiveConfiguredBashPath(` 与 `const bashResolution = resolveGitBashCached(` 不得再现;懒读取器/status 实时重读/settle 通知必须存在;`rowBashPath` 三态);③「executor row lookup survives the subtree layout change」(loader 三形状:root 命中 / subtree+entries / 行缺失,空串=真实答案)。
49
+ - **顺手修掉存量失败**(0.32.1 在 win32 上 latent):「full apply() mount on a stub ctx」的 settings stub 还停在 `settings.get(ns)` 时代,readShell 自 v0.25.1 起只认 `describe()` ⇒ stub 恒 null ⇒ 轮询 12×1500ms 后断言必挂。stub 补 `describe()`,与现役读法对齐。
50
+ - 99 项全绿(96 存量 + 3 新增;toolchain PATH 用例的平台误判 v0.32.1 已修,本次全平台绿)。
51
+ - **真机验证**(隔离 `DSH_HOME`(Temp 目录)+ profile 副本 `cp -rL` 解引用 + 插件目录换本仓源码 + 探针插件采 loader 形状;全程独占 3080 —— 用户实例当时未运行,验证后实例已杀、410M 副本与探针已删净,`~/.dsh` 零触碰):默认场景 boot `resolved from the default source`、status `ok:true`;坏值/好值 A/B 见上文「修复一·续」;官方侧栏终端接管懒读路径正常(`already uses this Git Bash ... (nothing written)` 幂等);ptc 联动正常(cordis-gitbash 去重、对方注册 Git Bash rows —— capability getter 无碍);spawnSync 改造后启动日志**零乱码**。**未覆盖**:settle 轮询分支的翻转真机时序(0.2.0-rc.2 上 entries() 在 apply 时刻即稳定可读,轮询多数情况下不触发;接线由源码断言锁定,行为留待真实慢宿主检验)。
52
+
53
+ - 相关:issue #13(<https://github.com/KannaKuron/dsh-gitbash-shell/issues/13>)、issue #11(解析链的由来)、AGENTS.md §4h(顺序契约更新)。
54
+
6
55
  ## v0.32.1 — 2026-09-28
7
56
 
8
57
  **类型**:chore(清掉 v0.32.0 遗留的 legacy settings 死代码)+ fix(冒烟里 mac 平台误判的 toolchain 路径用例)
package/README.md CHANGED
@@ -6,14 +6,16 @@
6
6
 
7
7
  > 让 DeepSeek Harness (dsh) 在 Windows 上**全部使用 Git Bash** 的官方风格插件
8
8
  > —— 以 Git for Windows 的 `bash.exe` 替换 PowerShell 执行器,并为所有模式
9
- > 物化对应的 Git Bash 版 agent preset。
9
+ > 注册对应的 Git Bash 版 agent preset。
10
10
 
11
11
  ## 它解决什么
12
12
 
13
13
  官方 Windows 组合默认把 `dsh-pwsh-sandbox` 作为 `ctx.shell`(PowerShell 执行器),
14
14
  且各 preset 的 `tool-bash` 行在 win32 上被禁用——因为在 Windows 上裸 `bash`
15
15
  会解析到 `C:\Windows\System32\bash.exe`(WSL 占位),本插件直接指定
16
- `C:/Program Files/Git/bin/bash.exe` 并保留官方沙箱语义。
16
+ `C:/Program Files/Git/bin/bash.exe`。沙箱语义在 POSIX 宿主上完整保留;
17
+ **Windows 宿主上受限模式有一处重要例外——见下方
18
+ [「安全语义:Windows 上的受限模式」](#安全语义windows-上的受限模式)**。
17
19
 
18
20
  安装本插件后:
19
21
 
@@ -28,29 +30,15 @@
28
30
  Git Bash host 下会拿到"暗示 PowerShell 语法的工具",请改用上面的变体;
29
31
  已装 dsh-ptc-cordis-preset 的话,`PTC 创造模式` 用户 preset 不受影响。
30
32
 
31
- ### dsh 版本双适配(0.1.1 与 0.1.2+)
33
+ ### 宿主要求与 preset 对齐
32
34
 
33
- dsh 0.1.2 把内置 `code` preset 改名为 `ptc`(`mode: code` → `mode: ptc`,官方不做
34
- 兼容别名),并给各内置 preset 新增 `command-goal` 等行。本插件为受影响的变体
35
- (standard/code/cordis)**同时携带两个 era 的已提交组合文本**,启动时探测内置
36
- roster 自动选择,并记进 `.plugin-managed.json` 的 `base` 字段;探测翻转(dsh
37
- 升级前后)自动重物化。`minimal-gitbash` 的内置底稿跨版本未变,单文本服务两个
38
- era。**preset id 保持 `code-gitbash` 不变**(会话钉在 id 上,改名会让已固定的
39
- 会话报 preset not found)。无论先升级插件还是先升级 dsh,都会自动收敛;用户改
40
- 过的目录照旧不碰。
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` 的取消信号都按**加载期探测**自适应,新旧宿主行为一致。
35
+ 自 v0.32.0 起宿主下限为 **dsh >= 0.1.7-rc**(经 peer `@deepseek-ai/dsh` 声明,
36
+ prerelease 含入求值;旧宿主不再支持)。四个变体的行集是 `src/compositions.js`
37
+ 里已提交的 JS 数据,镜像官方 0.1.7 的 standard/minimal/ptc/cordis 行切分并叠加
38
+ Git Bash 增量;跟随 dsh 升级的对齐由冒烟测试
39
+ `compositions: full variants mirror the official row split` 锁住。
40
+ **preset id 保持 `code-gitbash` 不变**——会话钉在 id 上,dsh 0.1.2 把内置 `code`
41
+ 改名 `ptc` 时本插件的变体 id 就没有跟随(改名会让已固定的会话报 preset not found)。
54
42
 
55
43
  ## 安装(公开 npm 插件,推荐)
56
44
 
@@ -74,24 +62,59 @@ bundle patch(`cordis.patch.yml`)应用三个改动:
74
62
  2. 插入 `gitbash-executor`(`dsh-gitbash-shell/shell`):继承官方
75
63
  `@deepseek-ai/dsh-bash-sandbox`,仅把内层 argv 换成
76
64
  `<git-bash.exe> -c <command>`。沙箱策略/拒绝分类/后台任务/超时/设置节
77
- 全部沿用官方实现;full-access 分支单独接 Git Bash(父类那里硬编码裸 `bash`);
78
- 3. 插入 `gitbash-presets`(`dsh-gitbash-shell/presets`):启动时把上表 4 个
79
- preset 物化到首个 user-trust preset 根目录,并写
80
- `.plugin-managed.json`(逐文件哈希)——未改动则随版本刷新;被用户改过就
81
- 不再碰;卸载时(且仅当未改动)会清理。
65
+ 沿用官方实现;full-access 分支单独接 Git Bash(父类那里硬编码裸 `bash`);
66
+ **Windows 上受限分支有一处例外,见下一节**;
67
+ 3. 插入 `gitbash-shell`(`dsh-gitbash-shell`):以**声明式注册**
68
+ (`ctx.agentPresets.register`,v0.32.0 起)提供上表 4 个 preset 变体——
69
+ 行集是仓库里已提交、可 diff 的 JS 数据(`src/compositions.js`),**不再物化
70
+ 任何目录**;旧版本遗留在磁盘上的物化树,启动时仅当 marker 判定为
71
+ 「未被用户改过」才单向清理,用户改过的绝不碰。
82
72
 
83
73
  **环境变量**:`bash.exe` 是 host 进程的直接子进程(不经 git-bash 登录壳),完整继承
84
74
  系统环境变量与 `DSH_*` 快照,和原来 pwsh 拿到的完全一致。
85
75
 
76
+ ## 安全语义:Windows 上的受限模式
77
+
78
+ **POSIX 宿主**:官方沙箱语义**完整保留**——受限令牌、工作区围栏、拒绝分类,
79
+ 与 `@deepseek-ai/dsh-bash-sandbox` 完全一致。本节只讲 Windows。
80
+
81
+ **Windows 宿主上的事实**:`read-only` / `workspace-write` 权限预设下的 **shell 命令**,
82
+ 实际以**不受限(unconfined)方式执行**:
83
+
84
+ - **为什么**:MSYS2 运行时无法在 restricted-token 沙箱下启动——`msys-2.0.dll`
85
+ 初始化时创建的 cygheap 映射与信号管道,DACL 只含用户 SID;而 WRITE_RESTRICTED
86
+ 令牌的二次写检查要求 restricting-SID ACE → 初始化即死于 Win32 error 5 /
87
+ 0xC0000142,**任何版本、任何参数都无解**(cmd/pwsh 走匿名管道不受影响)。
88
+ 这是 OS 层冲突,插件内没有真正的修法,只有两个选择:**如实标注的绕过**(本插件,
89
+ v0.13.0 起)或**直接拒绝执行**。选了前者,是因为装这个插件的目的就是用 Git Bash——
90
+ 静默换 pwsh 顶上、或让每条命令都失败,都不如把真相摆在结果里。
91
+ - **如何标注(绝不静默)**:每条此类结果的元数据携带
92
+ `sandbox: { mode: "<请求的模式>", denied: false, enforcement: "unconfined" }`,
93
+ 并在每个进程首次受限调用时打印一行说明日志(`File sandbox enforcement is
94
+ partial on this host.`)。
95
+ - **文件工具不受影响**:read / write / edit / glob / grep 的沙箱是**另一层**
96
+ (fs-tool policy),照常拦截工作区外访问——也就是说 `workspace-write` 下
97
+ shell 能写出工作区、文件工具不能,**两者的边界并不一致**。
98
+ - **可观察的对照**(相对 pwsh-sandbox 时代):会话进程的完整性级别不再被压低
99
+ (Low → Medium);`$env:TEMP` 不再被重定向到 per-session 隔离目录。
100
+
101
+ **如果你需要 shell 也被硬性拦住**(例如不希望模型经 shell 写工作区外的任何路径),
102
+ 这个插件在 Windows 上不适合你——卸载即回到 `pwsh-sandbox` 的受限语义,没有中间态。
103
+ `danger-full-access` 预设本来就不受限,装不装本插件行为一致。
104
+
105
+ > 这一取舍的完整分析(含 0.6.0~0.13.1 的故障史与社区两派对比)见 CHANGELOG
106
+ > v0.13.2 与 `src/shell.js` 头注释。
107
+
86
108
  ## 配置
87
109
 
88
110
  `gitbash-shell` 行(行 id 自 v0.24.0 起与设置命名空间同串;≤ v0.23.0 为 `gitbash-presets`)
89
- 支持 `presets` 数组,只物化你常用的模式(未列出的旧物化目录、且未被用户修改过的,会自动清理):
111
+ 支持 `presets` 数组,只注册你常用的模式(未列出的变体不再进入名录;改动经
112
+ volatile-update 触发重注册):
90
113
 
91
114
  ```yaml
92
115
  - id: gitbash-shell
93
116
  config:
94
- presets: [standard-gitbash, minimal-gitbash] # 默认物化全部 4 个
117
+ presets: [standard-gitbash, minimal-gitbash] # 默认注册全部 4 个
95
118
  suppressPeerCordis: false # 与 dsh-ptc-cordis-preset 去重,默认关
96
119
  ```
97
120
 
@@ -126,7 +149,7 @@ dsh-ptc-cordis-preset 报告它的 `PTC 创造模式` 已经是 Git Bash 版。
126
149
  default: ptc-cordis # 或 standard-gitbash / minimal-gitbash
127
150
  ```
128
151
 
129
- ## bash 从哪里来:解析链与失败引导(v0.28.0,issue #11)
152
+ ## bash 从哪里来:解析链与失败引导(v0.28.0,issue #11;懒化与 exec-path 层 v0.33.0,issue #13)
130
153
 
131
154
  `bashPath` **默认留空 = 自动探测**;解析顺序就是契约:
132
155
 
@@ -136,8 +159,16 @@ dsh-ptc-cordis-preset 报告它的 `PTC 创造模式` 已经是 Git Bash 版。
136
159
  `%LOCALAPPDATA%\Programs\Git\bin\bash.exe`;
137
160
  3. **PATH**(用户 PATH + 系统 PATH,进程内已合并)逐目录找 `bash.exe`;
138
161
  4. **PATH 上的 `git.exe` 反推** `<gitdir>/../bin/bash.exe`;
139
- 5. **注册表 `Path`**(`HKCU\Environment`、`HKLM\...\Session Manager\Environment`)——覆盖"GUI 启动时 PATH 快照过期";
140
- 6. **全落空 ⇒ 报错 + 引导**(启动日志一份完整报告,客户端弹一次引导框)。
162
+ 5. **`git --exec-path` 反推 + GitForWindows 注册表**(v0.33.0):对 PATH 上的 `git.exe`(Scoop / choco 的
163
+ **shim** 也照常启动真身)跑 `--exec-path`,从输出向上找 Git 根 —— 兼容 `mingw64` / `ucrt64` 两种运行时
164
+ 布局,补上「Scoop 安装、PATH 上只有 shims、没有 `bash.exe`」的盲区;随后兜底读注册表
165
+ `HKLM\SOFTWARE\GitForWindows` 的 `InstallPath`;
166
+ 6. **注册表 `Path`**(`HKCU\Environment`、`HKLM\...\Session Manager\Environment`)——覆盖"GUI 启动时 PATH 快照过期";
167
+ 7. **全落空 ⇒ 报错 + 引导**(启动日志一份完整报告,客户端弹一次引导框)。
168
+
169
+ **解析结果是活的,不是启动时判一次**(v0.33.0,issue #13):启动那一刻插件行可能还没全部挂载,显式配置
170
+ 会被短暂读成空 —— 现在启动日志、状态接口、弹窗与侧栏终端接管都在**使用时实时重读**,执行器能用,
171
+ 它们就不会再说「解析失败」;启动日志也会在行挂载稳定后补一份最终结论。
141
172
 
142
173
  > **只认 Git for Windows 的 bash。** WSL(`C:\Windows\System32\bash.exe`,永远在 PATH 且排序靠前)、
143
174
  > WindowsApps 别名、MSYS2、Cygwin 的 bash **一律拒绝**;候选还要通过 Git 布局(`usr/bin` + `cmd\git.exe` +
@@ -211,14 +242,11 @@ preset 会被插件自动清理;宿主 shell 回退为 PowerShell。
211
242
  ## 与 dsh-ptc-cordis-preset 联动
212
243
 
213
244
  本插件在 host 上发布 `gitBash` 能力服务(`{ active, bashPath }`,仅 Windows 为 active)。
214
- [dsh-ptc-cordis-preset](https://github.com/KannaKuron/dsh-ptc-cordis-preset) v0.5.0+ 在物化
215
- `PTC 创造模式` 时会检测该信号:两个插件都安装时,**PTC 创造模式自动物化为 Git Bash 版**
245
+ [dsh-ptc-cordis-preset](https://github.com/KannaKuron/dsh-ptc-cordis-preset) v0.5.0+ 在注册
246
+ `PTC 创造模式` 时会检测该信号:两个插件都安装时,**PTC 创造模式自动注册为 Git Bash 版**
216
247
  (`tool-bash` 启用、`tool-pwsh` 禁用),无需新增模式、无需手工修改 preset;
217
248
  只装本插件时 PTC 创造模式保持原样(由它自己的插件管理)。
218
249
 
219
- > 切换生效后若 `ptc-cordis` 目录已存在且被旧版本物化,删除
220
- > `~/.dsh/.agent-presets/ptc-cordis` 并重启,即由新逻辑重新物化。
221
-
222
250
  ### 去重开关:`suppressPeerCordis`(默认关,v0.25.0)
223
251
 
224
252
  联动生效后,`创造模式 · Git Bash`(本插件的 `cordis-gitbash`)与对方已经 Git Bash 化的
@@ -237,14 +265,11 @@ preset 会被插件自动清理;宿主 shell 回退为 PowerShell。
237
265
 
238
266
  **生效时机**:
239
267
 
240
- - **新宿主(dsh ≥ 0.1.7)是实时的**:开关走行 Config 的 volatile 通道,对方的能力则经
268
+ - **开关是实时的**:走行 Config 的 volatile 通道,对方的能力则经
241
269
  `ctx.inject(['ptcCordisPreset'])` 观察(**与插件行激活顺序无关**),两边任一变化都当场
242
270
  reconcile——摘掉打日志 `preset 'cordis-gitbash' retired (dsh-ptc-cordis-preset covers Creation
243
271
  mode on Git Bash)`,恢复打 `preset 'cordis-gitbash' registered declaratively`。已经挂载的会话
244
272
  钉在自己的组合快照上,不受影响;名录变化从新会话开始可见。
245
- - **旧宿主(dsh ≤ 0.1.6)是启动时判定一次**:旧宿主没有可观察的注册表,能力探测是有界的
246
- (默认 1s,读不到即视为"对方不覆盖"),所以打开开关后,上一轮物化出来的 `cordis-gitbash`
247
- 目录会被清理;把开关关掉后,该变体要到**下一次启动**才重新物化。
248
273
 
249
274
  **两侧设置卡上是同一份状态**:权威值只有本插件这一行 Config 一份;对方的设置卡通过
250
275
  `ctx.configForms.get('gitbash-shell')` **绑定同一行**、写同一个字段(DSH 官方支持编辑另一个
package/README_EN.md CHANGED
@@ -5,25 +5,20 @@
5
5
  [简体中文](README.md) | English
6
6
 
7
7
  > Run **every agent shell command through Git Bash** on Windows with DeepSeek
8
- > Harness (dsh) — replaces the PowerShell executor and materializes Git Bash
8
+ > Harness (dsh) — replaces the PowerShell executor and registers Git Bash
9
9
  > variants of all four agent presets.
10
10
 
11
- ### dsh 0.1.6: the workflow-engine row was renamed
11
+ ### Host requirement & preset alignment
12
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.
13
+ Since v0.32.0 the host floor is **dsh >= 0.1.7-rc** (declared through the
14
+ `@deepseek-ai/dsh` peer, prereleases included; older hosts are no longer
15
+ supported). The four variants are committed JS row sets in
16
+ `src/compositions.js`, mirroring the official 0.1.7 standard/minimal/ptc/cordis
17
+ split plus the Git Bash delta; upgrade alignment is locked by the smoke test
18
+ `compositions: full variants mirror the official row split`. **Preset ids
19
+ never change** (`code-gitbash` kept its historical id when dsh 0.1.2 renamed
20
+ the built-in `code` to `ptc` — sessions are pinned to ids, and a rename would
21
+ orphan them).
27
22
 
28
23
  ## Install (public npm package)
29
24
 
@@ -35,19 +30,6 @@ dsh plugin --profile web add dsh-gitbash-shell
35
30
  `dsh.bundle` declaration, appends the package to `dsh.profile.bundles`.
36
31
  **Restart the profile's host to activate.**
37
32
 
38
- ## Works with both dsh 0.1.1 and 0.1.2+
39
-
40
- dsh 0.1.2 renamed the built-in `code` preset to `ptc` (`mode: code` → `mode: ptc`,
41
- no compatibility aliases) and added new built-in rows (`command-goal`, …). For
42
- the affected variants (standard/code/cordis) this plugin **ships both committed
43
- era texts**, probes the built-in roster at every boot, records the choice in
44
- `.plugin-managed.json` (`base`), and re-materializes automatically when the
45
- detection flips. `minimal-gitbash`'s built-in base did not change across the
46
- rename, so one text serves both eras. **Preset ids never change**
47
- (`code-gitbash` keeps its historical id — sessions are pinned to ids, and a
48
- rename would orphan them). Either upgrade order converges automatically; directories
49
- you modified are still never touched.
50
-
51
33
  ## What it does
52
34
 
53
35
  The bundle patch (`cordis.patch.yml`):
@@ -56,17 +38,59 @@ The bundle patch (`cordis.patch.yml`):
56
38
  2. mounts `dsh-gitbash-shell/shell` — a subclass of the shipped
57
39
  `@deepseek-ai/dsh-bash-sandbox` whose inner argv is
58
40
  `<git-bash.exe> -c <command>` (sandbox policy, denial classification,
59
- background jobs, and settings behavior all inherited);
60
- 3. mounts `dsh-gitbash-shell/presets`, which materializes
61
- `standard-gitbash`, `minimal-gitbash`, `code-gitbash`,
62
- `cordis-gitbash` into the first user-trust preset root, guarded by
63
- per-file `.plugin-managed.json` hashes (user edits are never overwritten;
64
- unmodified trees are cleaned on uninstall).
41
+ background jobs, and settings behavior all inherited; **one Windows
42
+ exception for confined modes — see the next section**);
43
+ 3. mounts `dsh-gitbash-shell`, which registers the four preset variants
44
+ **declaratively** (`ctx.agentPresets.register`, v0.32.0+) — the row sets
45
+ are committed, diffable JS data (`src/compositions.js`) and **no directory
46
+ is ever materialized**; materialized trees left by older versions are
47
+ cleaned one-way at boot only when the marker proves the user never edited
48
+ them.
65
49
 
66
50
  Environment: `bash.exe` is spawned as a direct child of the host, so it
67
51
  inherits the full system environment plus the `DSH_*` snapshot, exactly like
68
52
  the pwsh executor did.
69
53
 
54
+ ## Security semantics: confined modes on Windows
55
+
56
+ **POSIX hosts**: the official sandbox semantics are **fully preserved** —
57
+ restricted tokens, workspace fencing, denial classification, identical to
58
+ `@deepseek-ai/dsh-bash-sandbox`. This section is about Windows only.
59
+
60
+ **On Windows**, shell commands under the `read-only` / `workspace-write`
61
+ permission presets actually run **UNCONFINED**:
62
+
63
+ - **Why**: the MSYS2 runtime cannot start under the restricted-token sandbox —
64
+ `msys-2.0.dll` creates its cygheap mapping and signal pipe with DACLs naming
65
+ only the user SID, while a WRITE_RESTRICTED token's pass-2 write check
66
+ demands a restricting-SID ACE, so init dies with Win32 error 5 / 0xC0000142
67
+ before argv ever runs — **every version, no workaround** (cmd/pwsh use
68
+ anonymous pipes and are unaffected). This is an OS-level conflict with no
69
+ in-plugin cure; the two options are **a labelled bypass** (this plugin,
70
+ since v0.13.0) or **refusing to run at all**. The former was chosen because
71
+ installing this plugin means wanting Git Bash — silently falling back to
72
+ pwsh, or failing every command, is worse than putting the truth in the result.
73
+ - **How it is labelled (never silent)**: every such result carries
74
+ `sandbox: { mode: "<requested mode>", denied: false, enforcement: "unconfined" }`,
75
+ plus a one-time notice per process (`File sandbox enforcement is partial on
76
+ this host.`).
77
+ - **File tools are NOT affected**: the read / write / edit / glob / grep
78
+ sandbox is a **separate layer** (the fs-tool policy) and still fences
79
+ out-of-workspace access. Under `workspace-write` the shell can write outside
80
+ the workspace while file tools cannot — **the two boundaries differ**.
81
+ - **Observable differences** vs the pwsh-sandbox era: the session process's
82
+ integrity level is no longer lowered (Low → Medium), and `$env:TEMP` is no
83
+ longer redirected to a per-session directory.
84
+
85
+ **If you need the shell itself hard-fenced too** (e.g. the model must not
86
+ write anywhere outside the workspace through the shell), this plugin is not
87
+ for you on Windows — uninstalling restores `pwsh-sandbox`'s confined
88
+ semantics; there is no middle ground. The `danger-full-access` preset is
89
+ unconfined by design either way.
90
+
91
+ > The full analysis (the 0.6.0–0.13.1 failure history and the community's two
92
+ > camps) is in the CHANGELOG under v0.13.2 and in the `src/shell.js` header.
93
+
70
94
  ## Config
71
95
 
72
96
  The `gitbash-shell` row (its id has matched the settings namespace since v0.24.0; it was
@@ -91,9 +115,16 @@ Executor config:
91
115
 
92
116
  ```yaml
93
117
  config:
94
- bashPath: "D:/Tools/Git/bin/bash.exe" # default C:/Program Files/Git/bin/bash.exe
118
+ bashPath: "D:/Tools/Git/bin/bash.exe" # default '' (empty) = automatic chain
95
119
  ```
96
120
 
121
+ The automatic chain (empty `bashPath`) probes, in order: explicit settings → default
122
+ install locations → PATH → `git.exe` reverse lookup → `git --exec-path` (covers
123
+ Scoop/choco shim layouts and the `ucrt64` runtime, v0.33.0) + the GitForWindows
124
+ registry `InstallPath` → the registry `Path`. Resolution is re-read live per
125
+ consumer (never frozen at boot, v0.33.0), and only Git for Windows' own bash
126
+ counts — WSL / MSYS2 / Cygwin are refused with guidance, never substituted.
127
+
97
128
  ## Cooperation with dsh-ptc-cordis-preset
98
129
 
99
130
  This plugin publishes a `gitBash` host capability (`{ active, bashPath }`,
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.32.1",
3
+ "version": "0.33.1",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器,声明式注册 standard/minimal/code/cordis 的 Git Bash 变体 preset(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.32.1",
3
+ "version": "0.33.1",
4
4
  "description": "Windows 上让 agent 的每条 shell 命令改经 Git for Windows bash 执行;物化四个内置预设的 Git Bash 变体,统一 MSYS 盘根路径方言,侧栏终端接管可开关。· Run every agent shell command through Git for Windows bash on Windows; materialize Git Bash variants of the built-in presets, unify the MSYS drive-root path dialect, and adopt the sidebar terminal behind a switch.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/bash-path.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * arrives through `io`, so the whole chain is unit-testable off-Windows.
21
21
  */
22
22
 
23
- import { execFileSync } from 'node:child_process'
23
+ import { spawnSync } from 'node:child_process'
24
24
  import { existsSync } from 'node:fs'
25
25
  import { dirname } from 'node:path'
26
26
 
@@ -44,7 +44,7 @@ export const GIT_WINDOWS_VERSION_MARKER = '.windows.'
44
44
  export const GIT_BASH_UNAME = /^MINGW(32|64)_NT-/
45
45
 
46
46
  /** Exit codes/sources reported back to the caller. */
47
- export const BASH_SOURCES = ['configured', 'default', 'path', 'path-git', 'registry']
47
+ export const BASH_SOURCES = ['configured', 'default', 'path', 'path-git', 'exec-path', 'registry']
48
48
 
49
49
  /**
50
50
  * Normalize a Windows path for comparison: forward slashes, lowercase, no
@@ -132,11 +132,46 @@ export function classifyBashCandidate(candidate, io) {
132
132
  return { ok: true, path: candidate, root, gitExe }
133
133
  }
134
134
 
135
+ /**
136
+ * Walk UP from a `git --exec-path` output looking for the Git for Windows
137
+ * root, and return its `<root>/bin/bash.exe` candidate (issue #13).
138
+ *
139
+ * Why this tier exists: package managers that SHIM Git (Scoop, and portable
140
+ * installs generally) put `git.exe` on PATH in a directory (`E:/Scoop/shims`)
141
+ * that is neither `<root>/cmd` nor `<root>/bin`, so the PATH reverse lookup
142
+ * never fires and there is no `bash.exe` on PATH at all. The REAL git still
143
+ * knows where it lives — `git --exec-path` prints
144
+ * `<root>/<mingw64|ucrt64>/libexec/git-core` (or `<root>/libexec/git-core`
145
+ * on old layouts) even when launched through a shim — so climbing ≤5 levels
146
+ * and demanding BOTH `cmd/git.exe` and `usr/bin/bash.exe` finds the root on
147
+ * every layout. That pair is also the discriminator: MSYS2's git reports
148
+ * `<msys64>/usr/libexec/git-core`, whose root has `usr/bin/bash.exe` but NO
149
+ * `cmd/git.exe`, so it never produces a candidate here (and even if it did,
150
+ * classifyBashCandidate's blacklist + fingerprints would refuse it).
151
+ * @param {string|undefined} execPath - raw `git --exec-path` output.
152
+ * @param {(path: string) => boolean} exists - filesystem probe.
153
+ * @returns {string[]} zero or one candidate.
154
+ */
155
+ export function execPathBashCandidates(execPath, exists) {
156
+ if (typeof execPath !== 'string' || execPath.trim() === '') return []
157
+ let dir = execPath.replace(/\\/g, '/').replace(/\/+$/, '')
158
+ for (let i = 0; i < 5; i++) {
159
+ const parent = dirname(dir)
160
+ if (parent === dir || parent === '' || parent === '.') break
161
+ dir = parent
162
+ if (exists(dir + '/cmd/git.exe') && exists(dir + '/usr/bin/bash.exe')) {
163
+ return [dir + '/bin/bash.exe']
164
+ }
165
+ }
166
+ return []
167
+ }
168
+
135
169
  /**
136
170
  * The ordered candidate chain for one resolution, WITHOUT validation. Order is
137
171
  * the contract (issue #11): explicit setting → default install locations →
138
- * PATH → git.exe reverse lookup → registry PATH (the last one only helps when
139
- * the process PATH snapshot predates a user's PATH edit).
172
+ * PATH → git.exe reverse lookup → git --exec-path / GitForWindows registry
173
+ * (the last one only helps when the process PATH snapshot predates a user's
174
+ * PATH edit).
140
175
  * @param {object} input - resolution input.
141
176
  * @param {string} input.configured - explicit path ('' = none).
142
177
  * @param {object} io - injected facts.
@@ -152,6 +187,7 @@ export function bashCandidates({ configured }, io) {
152
187
  for (const path of io.defaults()) out.push({ path, source: 'default' })
153
188
  for (const path of io.pathBashCandidates()) out.push({ path, source: 'path' })
154
189
  for (const path of io.gitReverseCandidates()) out.push({ path, source: 'path-git' })
190
+ for (const path of io.execPathBashCandidates()) out.push({ path, source: 'exec-path' })
155
191
  for (const path of io.registryBashCandidates()) out.push({ path, source: 'registry' })
156
192
  return out
157
193
  }
@@ -262,23 +298,50 @@ export function effectiveConfiguredBashPath(ownRowValue, settingsValue) {
262
298
 
263
299
  /**
264
300
  * The `gitbash-executor` ROW's own `bashPath` — the highest-priority tier.
265
- * Read through the Loader's public `resolve(id)` (the row id is part of this
301
+ * Read through the Loader's public surface (the row id is part of this
266
302
  * plugin's own bundle patch), so the translation layer sees the same explicit
267
303
  * value the executor row carries instead of quietly resolving a different
268
304
  * one. Absent row / absent config / any loader shape change reads ''.
305
+ *
306
+ * TWO access paths, because hosts differ (issue #13, measured on 0.2.0-rc.2):
307
+ * bundle-patched rows live in the OWNING PLUGIN'S SUBTREE there, so a bare
308
+ * `resolve('gitbash-executor')` throws from the root tree FOREVER (not just
309
+ * at boot) — the explicit tier silently read '' while the executor's own lazy
310
+ * `this.config.bashPath` worked, i.e. the two halves of this plugin disagreed
311
+ * about the user's explicit answer. `entries()` iterates subtrees as well, so
312
+ * it finds the row on both layouts; the direct resolve stays as the first try
313
+ * for older hosts where the row sits in the root tree.
269
314
  * @param {object} ctx - a context exposing `loader`.
270
315
  * @returns {string} the row's configured path, '' when unset.
271
316
  */
272
317
  export function executorConfiguredBashPath(ctx) {
273
- try {
274
- const entry = ctx && ctx.loader && typeof ctx.loader.resolve === 'function'
275
- ? ctx.loader.resolve('gitbash-executor')
276
- : undefined
318
+ const read = (entry) => {
277
319
  const value = entry && entry.options ? entry.options.config : undefined
278
320
  return value && typeof value.bashPath === 'string' ? value.bashPath : ''
279
- } catch {
280
- return ''
281
321
  }
322
+ const loader = ctx && ctx.loader
323
+ if (!loader) return ''
324
+ // Subtree walk: finds the row wherever the host nests bundle-patched rows.
325
+ const walk = () => {
326
+ try {
327
+ if (typeof loader.entries !== 'function') return undefined
328
+ for (const entry of loader.entries()) {
329
+ if (entry && entry.options && entry.options.id === 'gitbash-executor') return entry
330
+ }
331
+ return undefined
332
+ } catch {
333
+ return undefined
334
+ }
335
+ }
336
+ try {
337
+ // Direct resolve first: the row sits in the ROOT tree on older hosts, and
338
+ // an EMPTY string it may carry is a real answer (= run the automatic
339
+ // chain) — only a throw (subtree row, newer hosts) falls through.
340
+ if (typeof loader.resolve === 'function') {
341
+ return read(loader.resolve('gitbash-executor'))
342
+ }
343
+ } catch { /* not in the root tree — walk the subtrees below */ }
344
+ return read(walk())
282
345
  }
283
346
 
284
347
  /**
@@ -289,19 +352,29 @@ export function executorConfiguredBashPath(ctx) {
289
352
  * @returns {object} the production io.
290
353
  */
291
354
  export function defaultIo() {
355
+ // spawnSync (not execFileSync): the sync-exec family LEAKS a failing
356
+ // child's stderr straight onto the host log (reg's GBK error text showed
357
+ // up as mojibake on every failed probe), while spawnSync with piped stdio
358
+ // captures it into the result where the catch below can drop it.
292
359
  const run = (file, args) => {
293
360
  try {
294
- return String(execFileSync(file, args, { encoding: 'utf8', timeout: 5000, windowsHide: true })).trim()
361
+ const result = spawnSync(file, args, { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
362
+ if (result.error !== undefined || result.status !== 0) return undefined
363
+ return String(result.stdout ?? '').trim()
295
364
  } catch {
296
365
  return undefined
297
366
  }
298
367
  }
368
+ const registryValue = (hive, key, name) => {
369
+ const out = run('reg', ['query', hive + '\\' + key, '/v', name])
370
+ if (typeof out !== 'string') return undefined
371
+ const match = new RegExp(name + '\\s+REG_(?:EXPAND_)?SZ\\s+(.*)', 'i').exec(out)
372
+ return match === null ? undefined : match[1].trim()
373
+ }
299
374
  const registryPath = (hive, key) => {
300
- const out = run('reg', ['query', hive + '\\' + key, '/v', 'Path'])
301
- if (typeof out !== 'string') return []
302
- const match = /Path\s+REG_(?:EXPAND_)?SZ\s+(.*)/i.exec(out)
303
- if (match === null) return []
304
- return match[1].split(';').map((entry) => entry.trim()).filter((entry) => entry !== '')
375
+ const value = registryValue(hive, key, 'Path')
376
+ if (typeof value !== 'string') return []
377
+ return value.split(';').map((entry) => entry.trim()).filter((entry) => entry !== '')
305
378
  }
306
379
  const dirs = () => {
307
380
  const out = []
@@ -314,6 +387,23 @@ export function defaultIo() {
314
387
  return out
315
388
  }
316
389
  const pathDirs = () => String(process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').map((entry) => entry.trim()).filter((entry) => entry !== '')
390
+ const gitForWindowsInstalls = () => {
391
+ const roots = []
392
+ // The official installer writes these (machine-wide / per-user); the
393
+ // 32-bit-on-64-bit install lives under WOW6432Node. Scoop/portable usually
394
+ // has none of them, which is why this rides behind `git --exec-path`.
395
+ for (const [hive, key] of [
396
+ ['HKLM', 'SOFTWARE\\GitForWindows'],
397
+ ['HKLM', 'SOFTWARE\\WOW6432Node\\GitForWindows'],
398
+ ['HKCU', 'Software\\GitForWindows'],
399
+ ]) {
400
+ const install = registryValue(hive, key, 'InstallPath')
401
+ if (typeof install === 'string' && install.trim() !== '') {
402
+ roots.push(install.replace(/\\/g, '/').replace(/\/+$/, '') + '/bin/bash.exe')
403
+ }
404
+ }
405
+ return roots
406
+ }
317
407
  return {
318
408
  exists: (path) => {
319
409
  try { return existsSync(path) } catch { return false }
@@ -326,6 +416,19 @@ export function defaultIo() {
326
416
  const parent = dirname(dir.replace(/\\/g, '/'))
327
417
  return [parent + '/bin/bash.exe', dirname(parent) + '/bin/bash.exe']
328
418
  }),
419
+ // issue #13: shimmed Git layouts (Scoop, portable) have no bash.exe on PATH
420
+ // and a shim directory that defeats the cmd/bin reverse lookup. The REAL
421
+ // git reports its own home through --exec-path even when launched via a
422
+ // shim, and the official installer's InstallPath backs it up.
423
+ execPathBashCandidates: () => {
424
+ const gitExe = pathDirs()
425
+ .map((dir) => dir.replace(/\\/g, '/') + '/git.exe')
426
+ .find((path) => { try { return existsSync(path) } catch { return false } })
427
+ const execPath = gitExe === undefined ? undefined : run(gitExe, ['--exec-path'])
428
+ return [...execPathBashCandidates(execPath, (path) => {
429
+ try { return existsSync(path) } catch { return false }
430
+ }), ...gitForWindowsInstalls()]
431
+ },
329
432
  registryBashCandidates: () => [
330
433
  ...registryPath('HKCU', 'Environment'),
331
434
  ...registryPath('HKLM', 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'),
package/src/index.js CHANGED
@@ -276,7 +276,7 @@ export function effectivePresetIds(configured, { suppress, peerGitBash } = {}) {
276
276
  */
277
277
  import {
278
278
  DEFAULT_GIT_BASH, GIT_BASH_DOWNLOAD_URL, bashResolutionReport,
279
- effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached, settingsBashPath,
279
+ effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached,
280
280
  } from './bash-path.js'
281
281
  import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
282
282
  import {
@@ -481,6 +481,24 @@ export function valueOf(value) {
481
481
  return value && typeof value.get === 'function' ? value.get() : value
482
482
  }
483
483
 
484
+ /**
485
+ * This row's OWN live `bashPath` (issue #13): apply()'s `config` parameter IS
486
+ * the `gitbash-shell` row's resolved Config, so reading it here replaces the
487
+ * settings-service detour (`settingsBashPath`) that could read '' at apply
488
+ * time on hosts where the service is describe()/update()-only or not injected
489
+ * yet. Volatile refs mean a settings edit lands on the next read, no listener.
490
+ * @param {object|undefined} config - apply()'s resolved Config (may be absent).
491
+ * @returns {string} the row's `bashPath`, '' when unset or unreadable.
492
+ */
493
+ export function rowBashPath(config) {
494
+ try {
495
+ const value = config && typeof config === 'object' ? valueOf(config.bashPath) : undefined
496
+ return typeof value === 'string' ? value.trim() : ''
497
+ } catch {
498
+ return ''
499
+ }
500
+ }
501
+
484
502
  /**
485
503
  * Era-aware live settings reader for everything wired inside apply(): the
486
504
  * NEW era reads this plugin's own Config refs (no listener needed — every
@@ -1805,25 +1823,38 @@ export async function apply(ctx, config = {}) {
1805
1823
  // materialize: service present, 'active: true' on Windows, 'active: false'
1806
1824
  // where the bundle is installed but the platform stack stayed native.
1807
1825
  /* The explicit tier of the resolution chain, in its documented priority:
1808
- the `gitbash-executor` row's own config > the `gitbash-shell` row/settings
1809
- field > empty (= run the automatic chain). Both this layer and the
1810
- executor resolve from this same value, so an explicit answer is never
1826
+ the `gitbash-executor` row's own config > the `gitbash-shell` row's
1827
+ `bashPath` field > empty (= run the automatic chain). Both this layer and
1828
+ the executor resolve from this same value, so an explicit answer is never
1811
1829
  substituted and never silently ignored.
1812
- (issue #11: the row config is where the reporter's workaround landed.) */
1813
- const configuredBashPath = effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))
1814
- const bashResolution = resolveGitBashCached({ configured: configuredBashPath })
1830
+ (issue #11: the row config is where the reporter's workaround landed.)
1831
+ LAZY, never frozen (issue #13): at apply() time the loader store may not
1832
+ hold the executor row yet — `loader.resolve()` throws and reads '' — and
1833
+ the old settings-service detour could equally read '' before the service
1834
+ was injected. A frozen '' then ran the automatic chain (which fails on
1835
+ shimmed Git layouts) while the executor's own per-call lazy resolution
1836
+ worked, i.e. the same boot gave OPPOSITE verdicts. Every consumer below
1837
+ re-reads per use; the own row's value comes from apply()'s `config`
1838
+ parameter (this row's live Config refs — volatile, so a settings edit is
1839
+ visible on the next read without any listener). */
1840
+ const readConfiguredBashPath = () => effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), rowBashPath(config))
1841
+ const readBashResolution = () => resolveGitBashCached({ configured: readConfiguredBashPath() })
1815
1842
  const gitBashCapability = {
1816
1843
  active: process.platform === 'win32',
1817
1844
  // Kept for existing consumers (peer plugins read `bashPath`): the resolved
1818
1845
  // interpreter. When resolution failed we report the explicit value (or the
1819
1846
  // historical default) so the ENOENT a consumer may hit names a real path —
1820
- // never a substitute shell.
1821
- bashPath: bashResolution.ok ? bashResolution.path : (configuredBashPath || DEFAULT_GIT_BASH),
1847
+ // never a substitute shell. Getters keep the capability live (issue #13):
1848
+ // a consumer reading it after the rows settle sees the settled verdict.
1849
+ get bashPath() {
1850
+ const resolution = readBashResolution()
1851
+ return resolution.ok ? resolution.path : (readConfiguredBashPath() || DEFAULT_GIT_BASH)
1852
+ },
1822
1853
  // Additive (v0.28.0): the full verdict, for the guided popup and peers.
1823
- ok: bashResolution.ok,
1824
- source: bashResolution.source,
1825
- configured: configuredBashPath,
1826
- tried: bashResolution.tried,
1854
+ get ok() { return readBashResolution().ok },
1855
+ get source() { return readBashResolution().source },
1856
+ get configured() { return readBashResolution().configured },
1857
+ get tried() { return readBashResolution().tried },
1827
1858
  downloadUrl: GIT_BASH_DOWNLOAD_URL,
1828
1859
  }
1829
1860
  const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
@@ -1979,7 +2010,7 @@ export async function apply(ctx, config = {}) {
1979
2010
  // untouched and the result metadata is not echoed back in MSYS form.
1980
2011
  if (dialect.posixPaths && dialectApplies(dialect, exec && exec.agent)) {
1981
2012
  echo = true
1982
- const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2013
+ const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null
1983
2014
  echoEnv = env
1984
2015
  if (exec && exec.arguments && typeof exec.arguments === 'object') {
1985
2016
  const translated = translateDispatch(exec, dialect, env)
@@ -2034,7 +2065,7 @@ export async function apply(ctx, config = {}) {
2034
2065
  if (!dialectApplies(dialect, exec && exec.agent)) return next()
2035
2066
  if (dialect.posixPaths && dialect.errorDialect && exec && result && typeof result === 'object'
2036
2067
  && result.isError === true && Array.isArray(result.content)) {
2037
- const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2068
+ const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null
2038
2069
  if (exec.name === 'run_code') {
2039
2070
  // paths only — the /dev/null hint is for file tools
2040
2071
  const content = rewriteErrorContent(result.content, { nulHint: false, env })
@@ -2108,14 +2139,59 @@ export async function apply(ctx, config = {}) {
2108
2139
  // Now the boot log names the value, the whole probe chain and where to fix
2109
2140
  // it, and the client half reads the same verdict over HTTP to raise the
2110
2141
  // guided popup. Nothing here substitutes another shell — by design.
2111
- console.log(TAG + ' bash resolution: ' + bashResolutionReport(bashResolution).split('\n').join('\n' + TAG + ' '))
2142
+ //
2143
+ // issue #13: the boot verdict may legitimately be wrong for a few seconds —
2144
+ // at apply() time the loader store may not hold the executor row yet, so
2145
+ // the explicit tier reads '' while the executor's own per-call lazy
2146
+ // resolution succeeds. A FAILURE with an EMPTY configured value is therefore
2147
+ // not final: poll briefly for the explicit tier to appear as the rows
2148
+ // settle, print the settled verdict when it changes, and notify the
2149
+ // adoptions below (official terminal / better-sidebar) through
2150
+ // `onBashSettled`. A failure with a NON-empty configured value IS final (the
2151
+ // user's explicit answer was rejected), and so is any success.
2152
+ let settledBashResolution = null
2153
+ const bashSettleWaiters = []
2154
+ const onBashSettled = (listener) => {
2155
+ if (settledBashResolution !== null) listener(settledBashResolution)
2156
+ else bashSettleWaiters.push(listener)
2157
+ }
2158
+ const settleBashResolution = (resolution) => {
2159
+ settledBashResolution = resolution
2160
+ for (const listener of bashSettleWaiters.splice(0)) {
2161
+ try { listener(resolution) } catch { /* a waiter never blocks settling */ }
2162
+ }
2163
+ }
2164
+ const printBashResolution = (phase, resolution) => {
2165
+ console.log(TAG + ' bash resolution' + (phase === '' ? '' : ' (' + phase + ')') + ': '
2166
+ + bashResolutionReport(resolution).split('\n').join('\n' + TAG + ' '))
2167
+ }
2168
+ const bootBashResolution = readBashResolution()
2169
+ printBashResolution('boot', bootBashResolution)
2170
+ // Non-win32 hosts settle immediately: resolution failure there is the
2171
+ // designed state (nothing consumes the capability), so no poll.
2172
+ if (bootBashResolution.ok || bootBashResolution.configured !== '' || process.platform !== 'win32') {
2173
+ settleBashResolution(bootBashResolution)
2174
+ } else {
2175
+ let ticks = 0
2176
+ const timer = setInterval(() => {
2177
+ ticks += 1
2178
+ if (readConfiguredBashPath() === '' && ticks < 20) return // rows still mounting
2179
+ clearInterval(timer)
2180
+ const settled = readBashResolution()
2181
+ if (settled.ok || settled.configured !== '') printBashResolution('settled', settled)
2182
+ settleBashResolution(settled)
2183
+ }, 250)
2184
+ ctx.effect(() => () => clearInterval(timer), 'dsh-gitbash-shell: bash resolution settle poll')
2185
+ }
2112
2186
  if (process.platform === 'win32') {
2113
2187
  try {
2114
2188
  ctx.inject(['webServer'], (wctx) => {
2115
2189
  try {
2116
2190
  const route = '/dsh-gitbash-shell/api/status'
2117
2191
  const read = () => {
2118
- const resolution = resolveGitBashCached({ configured: configuredBashPath, fresh: true })
2192
+ // LIVE re-read (issue #13): the popup must see what the executor
2193
+ // actually resolves NOW, not a value frozen at apply() time.
2194
+ const resolution = resolveGitBashCached({ configured: readConfiguredBashPath(), fresh: true })
2119
2195
  return {
2120
2196
  ok: resolution.ok,
2121
2197
  platform: process.platform,
@@ -2178,7 +2254,7 @@ export async function apply(ctx, config = {}) {
2178
2254
  send(res, 403, { error: 'forbidden', reason: fence.reason })
2179
2255
  return
2180
2256
  }
2181
- const env = buildTranslateEnv(configuredBashPath)
2257
+ const env = buildTranslateEnv(readConfiguredBashPath())
2182
2258
  // One line per read: the browser half fetches this once per page load,
2183
2259
  // so its presence in the log is how a user can tell the rescue is live.
2184
2260
  console.log(TAG + ' path map read by ' + (req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '?'))
@@ -2304,10 +2380,14 @@ export async function apply(ctx, config = {}) {
2304
2380
  attempts += 1
2305
2381
  let result
2306
2382
  try {
2383
+ // LAZY re-read per attempt (issue #13): the first try may run
2384
+ // before the loader store holds the executor row, when the
2385
+ // resolution legitimately still reads ''.
2386
+ const resolution = readBashResolution()
2307
2387
  result = await adoptTerminalShell(editorCtx.configEditor, {
2308
2388
  platform: process.platform,
2309
2389
  enabled: liveSettings.autoTerminalShell(),
2310
- resolved: bashResolution.ok ? bashResolution.path : '',
2390
+ resolved: resolution.ok ? resolution.path : '',
2311
2391
  })
2312
2392
  } catch (error) {
2313
2393
  // adoptTerminalShell reports expected conditions itself; reaching
@@ -2332,9 +2412,20 @@ export async function apply(ctx, config = {}) {
2332
2412
  // The sidebar resolves its terminal shell through the settings seam per
2333
2413
  // open; adopt it through that seam (see adoptSidebarShell for rationale).
2334
2414
  // Disable with `betterSidebarShell: false` in the plugin row config.
2415
+ // Rides the settle notification (issue #13): a boot-time failure with an
2416
+ // empty explicit tier may still settle into a success once the loader rows
2417
+ // mount, and the adoption then happens without a restart.
2335
2418
  if (config.betterSidebarShell !== false && process.platform === 'win32') {
2336
- if (bashResolution.ok) adoptSidebarShell(ctx, bashResolution.path, () => liveSettings.adoptSidebar())
2337
- else console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2419
+ let sidebarAdopted = false
2420
+ onBashSettled((resolution) => {
2421
+ if (resolution.ok) {
2422
+ if (sidebarAdopted) return
2423
+ sidebarAdopted = true
2424
+ adoptSidebarShell(ctx, resolution.path, () => liveSettings.adoptSidebar())
2425
+ return
2426
+ }
2427
+ console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2428
+ })
2338
2429
  }
2339
2430
 
2340
2431
  // ── era split: declarative registration on dsh >= 0.1.7 ──────────────────