dsh-gitbash-shell 0.31.0 → 0.31.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,89 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.31.1 — 2026-09-27
7
+
8
+ **类型**:fix(issue #12:0.29.1 在 DSH 0.1.7-rc.2 上仍解析到 profile 里的 schemastery 3.18.2,每次 shell 调用报
9
+ `TypeError: config.timeoutMs.get is not a function`)
10
+
11
+ > 报告者 **tkhs101** 的分析是准确的(根因、复现步骤、建议方向都对)。本条目按他的复现步骤逐条核对后落地。
12
+
13
+ ### 结论:是「我们解析到哪一份 schemastery」的问题,不是用户的配置问题
14
+
15
+ `@deepseek-ai/schemastery` 在本插件里是 **peer**,由环境提供 —— Node 从**插件文件向上**找 `node_modules`,
16
+ 在 hoisted profile 里会先撞上**被其他依赖提升到 profile 根**的旧副本(报告者机器实测:3.18.2,**没有 `volatile()`**),
17
+ 而宿主 dsh 自己用的是安装目录里与 shell 包放在一起的 3.18.4(有)。
18
+
19
+ **用户什么都没做错**:hoisted 布局是 pnpm 的正常产物,旧副本被提升也是其他依赖的正常行为,不需要改任何配置。
20
+
21
+ ### 影响
22
+
23
+ 插件把 `pwsh-sandbox` 关掉、把 `ctx.shell` 换成 Git Bash 之后,它的 Config 因为探测不到 `volatile()`
24
+ 退化成普通值,基类随即在 `.get()` 上抛错:
25
+
26
+ ```
27
+ TypeError: config.timeoutMs.get is not a function
28
+ ```
29
+
30
+ **每一次 shell 调用都失败**(包括 `echo hi`),等于 agent 完全没有命令行能力;而**宿主日志全绿**
31
+ (插件行加载成功),报错信息也指不到插件 —— 只能靠卸载插件来确认。卸载即恢复。
32
+
33
+ ### 根因链(每层都有代码位置)
34
+
35
+ 1. `src/shell.js:46` 静态 `import z from '@deepseek-ai/schemastery'` —— 自 v0.24.4 起**一字未改**,
36
+ 所以 **0.29.1 与 0.31.0 带的是同一个缺陷**(报告者不是"版本旧",升级插件修不了)。
37
+ 2. Node 解析 → 插件所在 profile 的 `node_modules/@deepseek-ai/schemastery` = **3.18.2**。
38
+ 3. `live(schema)` 的判据是 `typeof schema.volatile === 'function'` → 3.18.2 没有 → 返回普通 schema。
39
+ 4. 宿主 dsh 0.1.7 的 `LocalBashExecutor` 把这六个字段声明为 `.volatile()`
40
+ (`packages/shell/bash-local/src/index.ts:100-107`),并在 `assertServiceableBashConfig`(line 80)
41
+ 与 `resolve`(line 124/132)里调 `.get()`。
42
+ 5. ⇒ 崩。
43
+
44
+ **v0.24.4(issue #6)的探测只在「我们碰巧解析到的那份恰好有 `volatile()`」时有效** —— 报告者指出的正是这个软肋。
45
+
46
+ ### 修复
47
+
48
+ 新增 **`src/schemastery.js`**:不再让解析顺序决定用哪份,而是**问宿主**。
49
+
50
+ - **`hostWantsVolatileRefs(ParentConfig)`** —— 直接调用**宿主自己**的 `SandboxBashExecutor.Config({})`,
51
+ 看 `timeoutMs` 解析成 `{ get() }` 还是普通值。这正是"这个宿主会不会调 `.get()`"的直接读数
52
+ (0.1.7+ → `true`;≤0.1.6 → `false`),读的是**行为**,不碰任何私有 schema 结构。
53
+ - **候选顺序**:shell 包自己的解析域(权威,与执行器同源)→ 插件自身域。
54
+ - **`chooseCopy` 纯函数**(可测)的判定:
55
+ - 宿主要 volatile 且**自己的副本能服务** → 保持自己那份(**能服务就不换**)。
56
+ - 宿主要 volatile 而自己的副本**不能** → **重定向到 shell 包那份**(#12 的修法),并打一行日志说明取自哪里。
57
+ - 宿主读普通值(≤0.1.6)→ 保持自己那份,**绝不**递一个它不会 `.get()` 的 ref(#12 的镜像错误)。
58
+ - 宿主答不上来 → 保持 v0.31.0 行为(逐字段探测),**不替宿主猜**。
59
+ - 宿主明确要而哪儿都没有 → 保留自己那份(**不改变其它行为**)但**大声说明原因**,
60
+ 不再留一个与插件看不出关系的神秘 TypeError。
61
+ - **两个消费点共用同一份缓存解析**(`src/shell.js` 的执行器 Config ↔ `src/index.js` 的行 Config 与旧时代
62
+ settings 命名空间)—— 否则两处可能拿到不同的 schemastery,设置项与执行器就会各说各话。
63
+ - **静态 peer import 全部消除**(它会让一行加载失败静默杀掉整行 —— v0.24.3 的教训),改由
64
+ `src/schemastery.js` 内**动态**加载。
65
+
66
+ ### 验证
67
+
68
+ - `npm test` **123/123**(新增 5 项):宿主行为的三种回答(`true`/`false`/无法判定)、`hasVolatile` 的边界
69
+ (含抛错的 getter)、`chooseCopy` 的完整矩阵(含"能服务就不换"与"顺序不决定结果")、共享加载器的
70
+ 缓存一致性、两个半都不得静态 import peer。
71
+ - **临时拓扑复现 + 修复对照**(插件域真装 `@deepseek-ai/schemastery@3.18.2`,shell 包域真装 `3.18.4`,
72
+ 且 shell 包用真的 `.volatile()` 声明 Config —— 与 issue 描述逐条对应):
73
+
74
+ | 场景 | 修复前 v0.31.0 | 修复后 |
75
+ |---|---|---|
76
+ | 新宿主 + 根目录被提升的旧副本(**#12 原场景**) | `timeoutMs = 120000`、`.get` 不存在 → **DISAGREE**(即报告者的崩溃) | `timeoutMs = {}`、`.get` 是函数 → **AGREE**;日志 `schemastery taken from the shell packages instead of this plugin's own resolution (host-volatile-redirected)` |
77
+ | 旧宿主 ≤0.1.6(读普通值) | — | 两边都是普通值 → **AGREE**(即使 shell 包那份 capable,也**没有被误用**) |
78
+
79
+ **判据是「插件与宿主的解析结果是否一致」,不是「有没有 `.get()`」** —— 后者在旧宿主上会给出反向的错误结论
80
+ (旧宿主拿到 ref 同样是崩)。
81
+ - **第三种组合(宿主要 volatile 而两边都没有)在现实中不可达**:宿主能正常跑,就说明它自己解析到了配套副本,
82
+ 而"shell 包域"正是宿主的域。该分支仍保留 fail-loud 日志作为防御,并由单元测试覆盖。
83
+
84
+ ### 相关
85
+
86
+ - issue #12(报告者 tkhs101,含完整复现步骤与修复建议)
87
+ - issue #6 / v0.24.4:同一崩溃的**另一方向**(当时是完全没有 volatile 探测)
88
+
6
89
  ## v0.31.0 — 2026-09-27
7
90
 
8
91
  **类型**:fix + feat(用户报告:点开对话里的文件链接,原生右侧栏显示「文件不存在,可能已被移动或删除」)
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.31.0",
3
+ "version": "0.31.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。双 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.31.0",
3
+ "version": "0.31.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/index.js CHANGED
@@ -55,6 +55,25 @@ import { dirname, join } from 'node:path'
55
55
  import { fileURLToPath } from 'node:url'
56
56
 
57
57
  import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
58
+ import { loadSchemastery } from './schemastery.js'
59
+
60
+ /**
61
+ * The executor base class's own Config schema, when this deployment has it.
62
+ *
63
+ * Dynamic and optional on purpose: `@deepseek-ai/dsh-bash-sandbox` belongs to
64
+ * another row of THIS plugin, and a deployment that cannot resolve it must
65
+ * still mount every other face (the static-peer-import lesson, v0.24.3). What
66
+ * we need it for is the host's OWN behavioural answer to "do you read your
67
+ * shell limits through `.get()`?" — see src/schemastery.js.
68
+ */
69
+ async function executorConfigSchema() {
70
+ try {
71
+ const mod = await import('@deepseek-ai/dsh-bash-sandbox')
72
+ return (mod.SandboxBashExecutor ?? mod.default)?.Config
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
58
77
 
59
78
  /**
60
79
  * The schemastery module, resolved LAZILY (v0.24.3): `@deepseek-ai/schemastery`
@@ -69,10 +88,15 @@ import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
69
88
  * verified with a fixture plugin whose only difference was the static peer
70
89
  * import. Deferring the import hides the schema where the module is absent
71
90
  * instead of killing the row.
91
+ *
92
+ * WHICH copy is resolved is no longer left to our own resolution order
93
+ * (v0.31.1, issue #12): src/schemastery.js asks the host what it expects and
94
+ * picks a copy that agrees with it. Both call sites share the one cached
95
+ * answer, so the settings namespace and the executor can never disagree.
72
96
  */
73
97
  let Schema = null
74
98
  try {
75
- Schema = (await import('@deepseek-ai/schemastery')).default
99
+ Schema = (await loadSchemastery({ parentConfig: await executorConfigSchema() })).z ?? null
76
100
  } catch (error) {
77
101
  Schema = null
78
102
  console.warn(
@@ -2415,11 +2439,14 @@ export async function apply(ctx, config = {}) {
2415
2439
  })()
2416
2440
  if (legacySettings) try {
2417
2441
  ctx.inject(['settings'], (sctx) => {
2418
- Promise.all([import('@deepseek-ai/dsh-settings'), import('@deepseek-ai/schemastery')])
2419
- .then(([ds, sm]) => {
2442
+ Promise.resolve(import('@deepseek-ai/dsh-settings'))
2443
+ .then((ds) => {
2420
2444
  const settings = sctx && sctx.settings
2421
2445
  if (!settings || typeof settings.register !== 'function') return
2422
- const Schema = sm.default
2446
+ // The SAME copy the executor and the row Config were built from
2447
+ // (issue #12): one resolution per process, so the two faces of this
2448
+ // plugin can never disagree about the schema vocabulary.
2449
+ if (!Schema) return
2423
2450
  // Era probe: newer dsh register() takes a plain string; the older
2424
2451
  // one accepted the branded helper — one call satisfies both.
2425
2452
  const ns = typeof ds.settingsNamespace === 'function'
@@ -0,0 +1,178 @@
1
+ /**
2
+ * dsh-gitbash-shell/schemastery — resolve the ONE schemastery copy whose
3
+ * semantics match the executing host, instead of whichever copy Node finds
4
+ * first.
5
+ *
6
+ * WHY THIS MODULE EXISTS (issue #12). `@deepseek-ai/schemastery` is a peer, so
7
+ * it is resolved by walking UP from this plugin's own file. In a hoisted
8
+ * profile that walk can land on an OLDER copy another dependency hoisted to the
9
+ * profile root — measured on the reporter's machine: 3.18.2, which has no
10
+ * `volatile()` — while the host itself runs the copy installed beside its own
11
+ * shell packages (3.18.4). The executor's base class comes from the HOST's copy
12
+ * of `@deepseek-ai/dsh-bash-sandbox`: on dsh 0.1.7+ it declares its limits with
13
+ * `.volatile()` and reads every one of them through `.get()`
14
+ * (`packages/shell/bash-local/src/index.ts:100-107`, `assertServiceableBashConfig`).
15
+ * A subclass whose Config was built from a `volatile`-less schemastery
16
+ * therefore yields PLAIN values and the first shell call dies with
17
+ * `TypeError: config.timeoutMs.get is not a function`, taking the whole
18
+ * `ctx.shell` down with it. Issue #6 was the same crash from the other
19
+ * direction (an unconditional plain Config); v0.24.4 fixed that with a
20
+ * per-field `schema.volatile` probe, which only helps when the copy we happen
21
+ * to resolve HAS `volatile()`.
22
+ *
23
+ * The choice is therefore driven by the HOST'S BEHAVIOUR, never by our own
24
+ * resolution order. Asking the base class what it resolves `timeoutMs` to is a
25
+ * direct read of "does this host call `.get()`?" — true on 0.1.7+, false on
26
+ * <= 0.1.6 where plain values are the correct answer. Only when the host wants
27
+ * Volatile refs do we insist on a copy that provides them, and only then do we
28
+ * try the parent package's own resolution domain first. An unreadable parent
29
+ * Config keeps the historical behaviour (our own copy, per-field probe), so an
30
+ * unknown host is never made worse.
31
+ */
32
+
33
+ import { createRequire } from 'node:module'
34
+ import { pathToFileURL } from 'node:url'
35
+
36
+ /** The peer whose copy must agree with the host. */
37
+ export const SCHEMASTERY_PEER = '@deepseek-ai/schemastery'
38
+
39
+ /** The package whose resolution domain is authoritative — the executor we extend. */
40
+ export const SCHEMASTERY_PARENT = '@deepseek-ai/dsh-bash-sandbox'
41
+
42
+ const TAG = '[gitbash-shell]'
43
+
44
+ /** Node's own answer for `specifier` as seen from `base`, or `''` when it fails. */
45
+ export function resolveFrom(base, specifier) {
46
+ try {
47
+ return createRequire(base).resolve(specifier)
48
+ } catch {
49
+ return ''
50
+ }
51
+ }
52
+
53
+ /** Whether one loaded module looks like a usable schemastery. */
54
+ export function isSchemastery(z) {
55
+ return !!z && typeof z.object === 'function'
56
+ }
57
+
58
+ /** Whether one loaded schemastery can produce Volatile refs. */
59
+ export function hasVolatile(z) {
60
+ if (!isSchemastery(z)) return false
61
+ try {
62
+ return typeof z.string().volatile === 'function'
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Ask the executing host whether it reads its shell limits through `.get()`.
70
+ *
71
+ * `SandboxBashExecutor.Config({})` is the host's OWN schema, built from the
72
+ * host's own schemastery: it resolves `timeoutMs` to a `Volatile` ref exactly
73
+ * when the host declares volatile fields. Behaviour, not internals — no private
74
+ * schema shape is read.
75
+ *
76
+ * @param ParentConfig - the base class's `static Config`, if it has one.
77
+ * @returns `true`/`false` when the host answered, `undefined` when it could not.
78
+ */
79
+ export function hostWantsVolatileRefs(ParentConfig) {
80
+ if (typeof ParentConfig !== 'function') return undefined
81
+ try {
82
+ const resolved = ParentConfig({})
83
+ if (!resolved || typeof resolved !== 'object') return undefined
84
+ return typeof resolved.timeoutMs?.get === 'function'
85
+ } catch {
86
+ return undefined
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Pick one already-loaded copy. Pure, so the matrix is testable without a
92
+ * filesystem: the caller supplies the candidates and their resolution order.
93
+ *
94
+ * @param options - the host's answer, our own entry path, and the loaded copies in priority order.
95
+ * @returns the chosen copy (or undefined) and why, for the log line.
96
+ */
97
+ export function chooseCopy({ needsVolatile, ownEntry, copies }) {
98
+ const own = copies.find((copy) => copy.entry === ownEntry)
99
+ if (copies.length === 0) return { chosen: undefined, reason: 'unresolved' }
100
+ if (needsVolatile === false) {
101
+ // <= 0.1.6 reads plain values: the historical answer is the correct one.
102
+ return { chosen: own ?? copies[0], reason: 'host-plain' }
103
+ }
104
+ if (needsVolatile === true) {
105
+ // Our own copy is the historical answer: keep it whenever it can serve, and
106
+ // redirect ONLY when it cannot (that redirection is the whole point of #12).
107
+ if (own !== undefined && hasVolatile(own.z)) return { chosen: own, reason: 'host-volatile' }
108
+ const capable = copies.find((copy) => hasVolatile(copy.z))
109
+ return capable
110
+ ? { chosen: capable, reason: 'host-volatile-redirected' }
111
+ : { chosen: own ?? copies[0], reason: 'no-volatile-anywhere' }
112
+ }
113
+ // The host could not answer: keep the pre-#12 behaviour (our own copy) rather
114
+ // than guessing a semantics change on its behalf.
115
+ return { chosen: own ?? copies[0], reason: 'host-unknown' }
116
+ }
117
+
118
+ /** Load one module by absolute entry path. */
119
+ async function loadAt(entry) {
120
+ const mod = await import(pathToFileURL(entry).href)
121
+ return mod?.default ?? mod
122
+ }
123
+
124
+ /** One process-wide answer: the module graph does not change under us. */
125
+ let cached = null
126
+
127
+ /**
128
+ * Resolve, load, and cache the schemastery copy that matches the executing host.
129
+ *
130
+ * @param options - `parentConfig` is the executor base class's `static Config`.
131
+ * @returns the chosen module (or undefined), where it came from, the host's answer, and the reason.
132
+ */
133
+ export async function loadSchemastery({ parentConfig } = {}) {
134
+ if (cached !== null) return cached
135
+ const needsVolatile = hostWantsVolatileRefs(parentConfig)
136
+ const ownEntry = resolveFrom(import.meta.url, SCHEMASTERY_PEER)
137
+
138
+ const wanted = []
139
+ const parentEntry = resolveFrom(import.meta.url, SCHEMASTERY_PARENT)
140
+ if (parentEntry !== '') {
141
+ const beside = resolveFrom(parentEntry, SCHEMASTERY_PEER)
142
+ if (beside !== '' && beside !== ownEntry) wanted.push({ where: 'the shell packages', entry: beside })
143
+ }
144
+ if (ownEntry !== '') wanted.push({ where: 'this plugin', entry: ownEntry })
145
+
146
+ const copies = []
147
+ for (const candidate of wanted) {
148
+ try {
149
+ const z = await loadAt(candidate.entry)
150
+ if (isSchemastery(z)) copies.push({ where: candidate.where, entry: candidate.entry, z })
151
+ } catch { /* try the next domain */ }
152
+ }
153
+
154
+ const { chosen, reason } = chooseCopy({ needsVolatile, ownEntry, copies })
155
+ const result = { z: chosen?.z, source: chosen?.where ?? '', entry: chosen?.entry ?? '', needsVolatile, reason }
156
+ cached = result
157
+
158
+ if (result.z === undefined) {
159
+ console.warn(`${TAG} ${SCHEMASTERY_PEER} is not resolvable from this plugin or from ${SCHEMASTERY_PARENT}; the row Config surface is absent`)
160
+ } else if (reason === 'no-volatile-anywhere') {
161
+ // The one case the plugin cannot paper over: the host calls `.get()` on
162
+ // plain values, so every shell call would fail. Say so where the user will
163
+ // see it, instead of leaving a mystery TypeError.
164
+ console.warn(
165
+ `${TAG} this host reads its shell limits through .get() but NO resolvable ${SCHEMASTERY_PEER} provides volatile()`
166
+ + ` (tried: ${wanted.map((w) => `${w.where} -> ${w.entry}`).join('; ') || 'nothing resolved'}).`
167
+ + ' Shell calls will fail with "config.timeoutMs.get is not a function"; install a dsh whose schemastery matches its own shell packages.',
168
+ )
169
+ } else if (chosen.where === 'the shell packages') {
170
+ console.log(`${TAG} ${SCHEMASTERY_PEER} taken from the shell packages instead of this plugin's own resolution (${reason}): ${chosen.entry}`)
171
+ }
172
+ return result
173
+ }
174
+
175
+ /** Test seam: forget the cached answer. */
176
+ export function resetSchemasteryCache() {
177
+ cached = null
178
+ }
package/src/shell.js CHANGED
@@ -43,7 +43,7 @@ import { DEFAULT_GIT_BASH, bashResolutionReport, effectiveConfiguredBashPath, re
43
43
 
44
44
  /** Re-exported for callers that historically read it here (the value now lives in bash-path.js). */
45
45
  export { DEFAULT_GIT_BASH }
46
- import z from '@deepseek-ai/schemastery'
46
+ import { loadSchemastery } from './schemastery.js'
47
47
 
48
48
 
49
49
  /** Log prefix, matching src/index.js. */
@@ -77,7 +77,10 @@ const SETTINGS_NAMESPACE = 'gitbash-shell'
77
77
  * ones. Published schemastery 3.18.2 has no `volatile()`, which is exactly why
78
78
  * the probe (not an unconditional call) is required.
79
79
  */
80
- function live(schema) {
80
+ function live(schema, needsVolatile) {
81
+ // A <=0.1.6 host reads plain values: never hand it a ref it will not `.get()`
82
+ // (issue #12 — the host's own answer wins over what our copy happens to offer).
83
+ if (needsVolatile === false) return schema
81
84
  return typeof schema.volatile === 'function' ? schema.volatile() : schema
82
85
  }
83
86
 
@@ -86,16 +89,19 @@ function live(schema) {
86
89
  * the smoke test can drive BOTH eras with a recording stand-in instead of
87
90
  * depending on which schemastery happens to be installed here.
88
91
  * @param z schemastery module (real one, or the test's recorder).
92
+ * @param needsVolatile - the host's OWN answer from src/schemastery.js: `true`
93
+ * forces Volatile refs, `false` forces plain values, `undefined` keeps the
94
+ * per-field probe (the historical behaviour, kept for an unreadable host).
89
95
  * @returns the object schema for the `gitbash-executor` row.
90
96
  */
91
- export function gitBashShellConfig(z) {
97
+ export function gitBashShellConfig(z, needsVolatile) {
92
98
  return z.object({
93
- cwd: live(z.string()),
94
- timeoutMs: live(z.number().default(120000)),
95
- maxTimeoutMs: live(z.number().default(600000)),
96
- maxOutputBytes: live(z.number().default(64000)),
97
- maxSpillBytes: live(z.number().default(64 * 1024 * 1024)),
98
- graceMs: live(z.number().default(3000)),
99
+ cwd: live(z.string(), needsVolatile),
100
+ timeoutMs: live(z.number().default(120000), needsVolatile),
101
+ maxTimeoutMs: live(z.number().default(600000), needsVolatile),
102
+ maxOutputBytes: live(z.number().default(64000), needsVolatile),
103
+ maxSpillBytes: live(z.number().default(64 * 1024 * 1024), needsVolatile),
104
+ graceMs: live(z.number().default(3000), needsVolatile),
99
105
  // Our own knob: the base class neither declares nor `.get()`s it, and
100
106
  // `get bashPath()` below reads it as a plain value, so it stays plain.
101
107
  //
@@ -107,8 +113,21 @@ export function gitBashShellConfig(z) {
107
113
  })
108
114
  }
109
115
 
116
+ /**
117
+ * The schemastery copy that matches the executing host (see src/schemastery.js).
118
+ * Resolving our own way can land on an OLDER copy another dependency hoisted to
119
+ * the profile root — one without `volatile()` — and the executor then declares
120
+ * plain limits for a base class that calls `.get()` on them: every shell call
121
+ * dies with `config.timeoutMs.get is not a function` (issue #12).
122
+ *
123
+ * Deliberately ONE line, and the ONLY top-level await in this module: the smoke
124
+ * harness evaluates this body inside `new Function`, where an await cannot
125
+ * appear, so it swaps exactly this expression for a stub.
126
+ */
127
+ const schema = await loadSchemastery({ parentConfig: SandboxBashExecutor.Config })
128
+
110
129
  /** Resolved configuration: the local executor's knobs, plus the Git Bash path. */
111
- export const Config = gitBashShellConfig(z)
130
+ export const Config = schema.z ? gitBashShellConfig(schema.z, schema.needsVolatile) : undefined
112
131
 
113
132
  /** Git Bash executor — mirrors the shipped bash/pwsh sandbox executors. */
114
133
  export class GitBashSandboxExecutor extends SandboxBashExecutor {