dsh-gitbash-shell 0.30.1 → 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 +178 -0
- package/README.md +8 -1
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +184 -0
- package/src/index.js +87 -4
- package/src/schemastery.js +178 -0
- package/src/shell.js +29 -10
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,184 @@
|
|
|
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
|
+
|
|
89
|
+
## v0.31.0 — 2026-09-27
|
|
90
|
+
|
|
91
|
+
**类型**:fix + feat(用户报告:点开对话里的文件链接,原生右侧栏显示「文件不存在,可能已被移动或删除」)
|
|
92
|
+
|
|
93
|
+
> 用户原话:**"dsh原生的右侧边栏不支持这种/c/ 路径? 让我们的gitbash插件能不能修复一下这个bug?"**
|
|
94
|
+
> 截图里 tab 标题是 `/c/Users/kanna/sandbox/dsh-remote-app/AGENTS.md`,内容区是那句 not-found。
|
|
95
|
+
|
|
96
|
+
### 结论:不是 dsh 的 bug,是本插件引入的方言在 UI 侧没有闭环
|
|
97
|
+
|
|
98
|
+
dsh **设计上只有两种路径拼写**(POSIX 与 Windows 盘符/UNC,见 `dsh-util-workspace-path` 的
|
|
99
|
+
`isAbsoluteWorkspacePath`);MSYS 盘根是本插件教会模型与工具的东西,**官方核心不认识它**。
|
|
100
|
+
修的责任因此在本插件这边 —— 而且这是本插件第一次把方言边界延伸到**浏览器 UI**。
|
|
101
|
+
|
|
102
|
+
### 根因链(逐层定位,每一层都有代码位置)
|
|
103
|
+
|
|
104
|
+
1. 右侧栏是官方包 `ui-sidebar-documentpreview`,它的失败文案按错误码 `workspace-file/not-found`
|
|
105
|
+
映射(locales.ts:27,与截图逐字一致)。
|
|
106
|
+
2. 读文件的宿主实现在 `packages/api/workspace-files/src/index.ts`:所有路径都经
|
|
107
|
+
`this.ctx.fs.resolve(path, { cwd: workspaceRoot })` / `fs.lstat(path, { cwd })`。
|
|
108
|
+
3. `packages/fs/fs-local/src/fsio.ts:148-150` 在 win32 上就是 **`node:path.resolve(cwd, path)`**。
|
|
109
|
+
4. `/c/Users/...` **不是** node 眼中的绝对路径(`isAbsolute` 只认 `C:\`、`\\server`、posix 根),
|
|
110
|
+
于是它被解析成 **`<当前盘符>:\c\Users\...`** —— 一个不存在的路径 ⇒ not-found。
|
|
111
|
+
5. 那个 `/c/...` 是从哪来的:对话里的文件链接文本来自 **shell 输出**,而本插件让 shell 说 MSYS 方言。
|
|
112
|
+
链接打开走 `ui-chat` 的 `openFile` → `fileAddressFor(sessionId, cwd, path)`
|
|
113
|
+
(`packages/util/workspace-path`):它用 `cwd` 前缀判断"工作区内→转相对路径"。本机实测 session 目录名是
|
|
114
|
+
`--C-Users-kanna-sandbox--`,即 **cwd 是 Windows 拼写**(`C:/Users/kanna/sandbox`),而链接文本是
|
|
115
|
+
`/c/Users/kanna/sandbox/...` —— 前缀不匹配 ⇒ 原样保留绝对 MSYS 拼写 ⇒ 第 4 步必然失败。
|
|
116
|
+
|
|
117
|
+
### 改动
|
|
118
|
+
|
|
119
|
+
**宿主半(`src/index.js`)**:新增只读路由 `GET /dsh-gitbash-shell/api/pathmap`,返回
|
|
120
|
+
`{ platform, home, tmpDir, gitRoot, mounts }` —— 就是既有翻译层 `buildTranslateEnv()` 的同一份事实
|
|
121
|
+
(挂载表直接从 `GIT_MOUNTS` 生成,不另抄一份),加 `process.platform` 供客户端判断。与工具链路由同款
|
|
122
|
+
loopback + same-origin fence(`fenceToolRequest`;GET 只需 loopback),因为 web 服务器可能绑在 `0.0.0.0`,
|
|
123
|
+
而这个路由会报出账号自己的目录。
|
|
124
|
+
|
|
125
|
+
**客户端半(`src/client.js`)**:包装 `ctx.sidebarRight` 的 `openResource` 与 `openResourceIn` ——
|
|
126
|
+
官方文档写明**"每一种进入右侧栏的方式都是这两者之一的调用"**(对话文件链接、工具行行号引用、
|
|
127
|
+
文件树的每一行),所以一个包装点覆盖全部入口,**不碰任何 dsh 源码**。
|
|
128
|
+
|
|
129
|
+
- 地址语法按官方 `file-address.ts` 实现:解析 `dsh-resource://file/{session/<id>|<absolute>}/<path>`
|
|
130
|
+
(逐段 `encodeURIComponent`、`:` 保留、UNC 空首段),改写后按同一编码规则重建,`?`/`#` 后缀原样保留。
|
|
131
|
+
- **未改变即原样转发原字符串**(不是"重建后看起来一样"),所以没有翻译过的地址绝不会被 URL 往返改动。
|
|
132
|
+
- 转换语义与宿主 `translateMsysPath` 逐例对齐(盘根 → `X:/`、`~` → home、`/tmp` → 用户 TEMP、
|
|
133
|
+
`/dev/null` → `\\.\NUL`、`/usr` 系 → Git 根);**不实现宿主的 `$VAR` 展开** —— 对话里的路径是文本不是 shell 词。
|
|
134
|
+
- **门是宿主的事实,不是猜测**:`pathRescueActive()` 要求 `/api/pathmap` 已应答且 `platform === 'win32'`。
|
|
135
|
+
macOS/Linux 宿主**永不**翻译(客户端的 user agent 说明不了路径属于谁的文件系统);fact 未到时也不翻译
|
|
136
|
+
(退化为打补丁前的行为,而不是赌一把)。
|
|
137
|
+
- **可选注入**:`ctx.inject(["sidebarRight"], …)` 而非写进 `exports.inject` —— 硬注入一个本插件不拥有的服务
|
|
138
|
+
会让 fiber 在缺该服务的宿主上永久 PENDING(v0.24.0 的 settingsScope 教训)。
|
|
139
|
+
- 包装挂在服务实例上,经 `sctx.effect` 随插件 fiber 卸载并还原原方法。
|
|
140
|
+
|
|
141
|
+
### 验证
|
|
142
|
+
|
|
143
|
+
- `npm test` **118/118 全绿**(新增 6 项):
|
|
144
|
+
- 驱动**真实 bundle**(fake cordis ctx + fake `sidebarRight` 记录收到的地址)覆盖地址语法:
|
|
145
|
+
盘根 / `~` / `/tmp` / `/usr` / `/etc`、absolute scope、`?line=12` 后缀、UNC 原样、非 file 地址原样、
|
|
146
|
+
非法转义原样、边界不误伤(`/tmpfoo`、`/usrx`、`/Tmp`、`/ab/c`)、已是 Windows 拼写原样、相对路径原样。
|
|
147
|
+
- **宿主事实门**:`platform: 'linux'` 与"fetch 未应答"两种情况下地址逐字节原样。
|
|
148
|
+
- **可逆性**:disposer 还原原方法;`exports.inject` 不得含 `sidebarRight`(静态断言)。
|
|
149
|
+
- **浏览器镜像与宿主翻译器逐例比对**:23 个输入跑 `_internal.translateMsysPath` 与客户端实现,结果必须相同
|
|
150
|
+
—— 锁死两边语义漂移。
|
|
151
|
+
- **真实文件系统实测(win32)**:用本仓库 `package.json` 造 `/c/...` 形式,断言
|
|
152
|
+
`path.resolve(cwd, msys)` 指向**不存在**的路径(这就是用户看到的 not-found),而救回后的
|
|
153
|
+
`C:/...` **existsSync 为真** —— bug 与修复都在真文件上量了一遍。
|
|
154
|
+
- **路由实测**:驱动真实 `apply()`,断言路由注册为 `prefix`、loopback GET 返回 200 且
|
|
155
|
+
`platform` 等于 `process.platform`、mounts 键序与客户端遍历序一致、非 loopback 返回 403 `not-loopback`。
|
|
156
|
+
- **真机验证(隔离实例,未碰用户正在运行的实例)**:把用户 web profile 复制到一个独立 `DSH_HOME`
|
|
157
|
+
(Windows 上 `cp -rL` 解引用 pnpm 链接,97M → 366M,5 秒),把插件目录换成**本仓源码**,独立端口起
|
|
158
|
+
`dsh web`,然后:
|
|
159
|
+
- 宿主侧:`GET /dsh-gitbash-shell/api/pathmap` 实测 **HTTP 200**,返回
|
|
160
|
+
`{"platform":"win32","home":"C:/Users/kanna","tmpDir":"C:/Users/kanna/AppData/Local/Temp",
|
|
161
|
+
"gitRoot":"c:/program files/git","mounts":{…7 项,键序与客户端遍历序一致}}`
|
|
162
|
+
—— 启动日志出现 `MSYS path map route active at /dsh-gitbash-shell/api/pathmap`。
|
|
163
|
+
- 客户端侧:首页 `window.__DSH_BOOT__` 里 `dsh-gitbash-shell` 出现 5 次(启动图含本插件);
|
|
164
|
+
`curl` 它实际会取的 combo URL(`/plugins/??dsh-gitbash-shell/client.js&rev=…`)返回 **200**,
|
|
165
|
+
内容含完整新代码段且**语法有效**。
|
|
166
|
+
- **真实浏览器(CDP 驱动 driver 自己的隔离 Chromium,不动用户的浏览器)控制台实测出现**
|
|
167
|
+
`[gitbash-shell/client] right-sidebar path rescue armed (host spelling restored on open)`
|
|
168
|
+
—— 这一行在 `ctx.inject(["sidebarRight"], …)` 的回调内部,所以它同时证明:client 半在真实浏览器
|
|
169
|
+
里 apply、**`sidebarRight` 客户端服务可见、inject 回调被调用、`openResource` 是函数且已被包装**。
|
|
170
|
+
- 宿主日志随后出现 `path map read by <loopback>` —— **浏览器真的拉到了那份宿主事实**,
|
|
171
|
+
即 `pathRescueActive()` 在生产链路里会返回 true。
|
|
172
|
+
- 路由新增一行访问日志(`path map read by <addr>`),既是诊断手段,也是上面这条验证的观测点。
|
|
173
|
+
- **仍未覆盖**:真实 UI 里"点击 `/c/...` 链接 → tab 显示出文件内容"这最后一跳(需要先构造一个带
|
|
174
|
+
MSYS 路径的会话)。这一跳的两端都被验证过 —— 地址重写(单元矩阵)与宿主可读性(真实文件
|
|
175
|
+
`existsSync`);用户在重启客户端后即可实测确认。
|
|
176
|
+
|
|
177
|
+
### 已知边界(如实记录)
|
|
178
|
+
|
|
179
|
+
- 只有"整体就是一条路径"的链接能被救:`/c/Users/x` 成立;而 shell 输出里**被文本截断/拼接**的路径、
|
|
180
|
+
以及 `/c` 之外的 MSYS 虚拟根(若宿主探测不到 Git 根,tmpDir/gitRoot 为 null)保持原样 ——
|
|
181
|
+
退化行为与修复前一致,**不会**产生错误路径。
|
|
182
|
+
- `/dev/null` 这类设备路径即使被点开也没有可读内容(与文件工具一致,EINVAL 如实报错)。
|
|
183
|
+
|
|
6
184
|
## v0.30.1 — 2026-09-26
|
|
7
185
|
|
|
8
186
|
**类型**:fix(用户实测反馈 v0.30.0 的工具列表:点了按钮看不到进度、看不到在装哪一个、看不到报错、还能连点)
|
package/README.md
CHANGED
|
@@ -316,7 +316,14 @@ PTC/创造及用户自建模式一律覆盖。
|
|
|
316
316
|
用户 TEMP 下的路径回显为 `/tmp/...`(与 bash 的 `$TMP` 一致);文件内容与错误结果不动;
|
|
317
317
|
`grep` 的 `matches[].path`)改写回 MSYS 形式;文件内容与错误结果不动;
|
|
318
318
|
- **运行时事实**:向官方 `dsh-shell-env` 注册表贡献 `DSH_PATH_DIALECT=msys`,模型可在执行时
|
|
319
|
-
核验(随开关实时生效)
|
|
319
|
+
核验(随开关实时生效);
|
|
320
|
+
- **右侧栏文件链接(v0.31.0)**:对话里的文件链接带着 `/c/...` 拼写进入 dsh **原生右侧栏**
|
|
321
|
+
(`ui-sidebar-documentpreview`),而宿主文件系统只认 Windows 盘符/UNC 拼写 —— `/c/...` 会被
|
|
322
|
+
解析成 `<当前盘符>:\c\...`,于是显示「文件不存在,可能已被移动或删除」。插件在右侧栏的**唯一
|
|
323
|
+
入口**(`ctx.sidebarRight.openResource`)上把地址还原成宿主拼写,`~` / `/tmp` / `/dev/null` /
|
|
324
|
+
`/usr` 等挂载点按与工具参数翻译**同一张表**处理;宿主事实经只读路由
|
|
325
|
+
`GET /dsh-gitbash-shell/api/pathmap` 提供,**只有宿主自报 `win32` 时才改写**
|
|
326
|
+
(macOS/Linux 宿主永不翻译,事实未到时也不翻译)。**没有改动任何 dsh 源码**,包装随插件卸载还原。
|
|
320
327
|
|
|
321
328
|
关闭后回到 dsh 原生行为:指示文本为空(组装期直接丢弃,零提示噪声),路径参数与结果元数据
|
|
322
329
|
都不再改写,文件工具收 Windows 路径。**bash 始终是 Git Bash,不受此开关影响**——它只决定
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-gitbash-shell",
|
|
3
|
-
"version": "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.
|
|
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/client.js
CHANGED
|
@@ -2452,6 +2452,185 @@ window.__ModuleLoader__.load({
|
|
|
2452
2452
|
);
|
|
2453
2453
|
}
|
|
2454
2454
|
|
|
2455
|
+
// ── native right-sidebar path rescue (v0.31.0) ───────────────────────
|
|
2456
|
+
//
|
|
2457
|
+
// WHY: while the POSIX dialect is on, the conversation shows MSYS drive
|
|
2458
|
+
// roots (/c/Users/...), and dsh's OWN file links carry that spelling into
|
|
2459
|
+
// the right Sidebar. The Host resolves the path with node:path against
|
|
2460
|
+
// the session cwd, so '/c/Users/x' becomes '<current drive>:\c\Users\x'
|
|
2461
|
+
// and the preview answers "file not found". The plugin introduced that
|
|
2462
|
+
// spelling, so the plugin restores its meaning — at the ONE point every
|
|
2463
|
+
// way into the column passes through: `ctx.sidebarRight.openResource`
|
|
2464
|
+
// (conversation file links, tool-row line references, the file tree).
|
|
2465
|
+
//
|
|
2466
|
+
// SAFETY: the rewrite is driven by the HOST's own platform fact, never by
|
|
2467
|
+
// a guess: nothing is rewritten until `/api/pathmap` reports win32, so a
|
|
2468
|
+
// macOS/Linux Host can never see its paths translated. A path already in
|
|
2469
|
+
// the Host's spelling is returned unchanged, so with the dialect off the
|
|
2470
|
+
// wrapper is a pure pass-through. The wrapper lives on the service
|
|
2471
|
+
// instance and is removed with this plugin's own fiber.
|
|
2472
|
+
var FILE_ADDRESS_PREFIX = "dsh-resource://file/";
|
|
2473
|
+
var PATHMAP_URL = "dsh-gitbash-shell/api/pathmap";
|
|
2474
|
+
/** The Host's MSYS facts, or null until /api/pathmap has answered. */
|
|
2475
|
+
var pathMap = null;
|
|
2476
|
+
var pathMapRequested = false;
|
|
2477
|
+
|
|
2478
|
+
/** Read the Host's platform + mount facts once; never blocks a click. */
|
|
2479
|
+
function loadPathMap() {
|
|
2480
|
+
if (pathMapRequested) return;
|
|
2481
|
+
pathMapRequested = true;
|
|
2482
|
+
try {
|
|
2483
|
+
Promise.resolve(fetch(PATHMAP_URL, { headers: { accept: "application/json" } }))
|
|
2484
|
+
.then(function (response) { return response && response.ok ? response.json() : null; })
|
|
2485
|
+
.then(function (body) {
|
|
2486
|
+
if (body && typeof body === "object" && typeof body.platform === "string") pathMap = body;
|
|
2487
|
+
else console.warn(TAG + " path map unavailable: no rewrite until the host answers");
|
|
2488
|
+
})
|
|
2489
|
+
.catch(function (error) {
|
|
2490
|
+
console.warn(TAG + " path map fetch failed:", error && error.message ? error.message : error);
|
|
2491
|
+
});
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
console.warn(TAG + " path map fetch threw:", error && error.message ? error.message : error);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/** Whether the Host is a Windows one whose filesystem needs the rewrite. */
|
|
2498
|
+
function pathRescueActive() {
|
|
2499
|
+
return pathMap !== null && pathMap.platform === "win32";
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/**
|
|
2503
|
+
* '/c/Users/x' -> 'C:/Users/x', plus the Git Bash mounts the Host's own
|
|
2504
|
+
* translation layer resolves (~, /tmp, /dev/null, /usr & friends). Mirrors
|
|
2505
|
+
* src/index.js translateMsysPath WITHOUT its `$VAR` expansion: a path
|
|
2506
|
+
* rendered in the transcript is text, not a shell word.
|
|
2507
|
+
*/
|
|
2508
|
+
function msysToHostPath(value, env) {
|
|
2509
|
+
if (typeof value !== "string" || value === "") return value;
|
|
2510
|
+
var facts = env || {};
|
|
2511
|
+
if (facts.home && (value === "~" || value.slice(0, 2) === "~/")) {
|
|
2512
|
+
return value === "~" ? facts.home : facts.home + "/" + value.slice(2);
|
|
2513
|
+
}
|
|
2514
|
+
var drive = /^\/([a-z])\/(.*)$/i.exec(value);
|
|
2515
|
+
if (drive) return drive[1].toUpperCase() + ":/" + drive[2];
|
|
2516
|
+
var bare = /^\/([a-z])$/i.exec(value);
|
|
2517
|
+
if (bare) return bare[1].toUpperCase() + ":/";
|
|
2518
|
+
if (value === "/dev/null") return "\\\\.\\NUL";
|
|
2519
|
+
if (facts.tmpDir && (value === "/tmp" || value.slice(0, 5) === "/tmp/")) {
|
|
2520
|
+
return value === "/tmp" ? facts.tmpDir : facts.tmpDir + value.slice(4);
|
|
2521
|
+
}
|
|
2522
|
+
if (facts.gitRoot && facts.mounts) {
|
|
2523
|
+
var mounts = facts.mounts;
|
|
2524
|
+
for (var mount in mounts) {
|
|
2525
|
+
if (!Object.prototype.hasOwnProperty.call(mounts, mount)) continue;
|
|
2526
|
+
if (value === mount || value.slice(0, mount.length + 1) === mount + "/") {
|
|
2527
|
+
return facts.gitRoot + "/" + mounts[mount] + (value === mount ? "" : value.slice(mount.length));
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
return value;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/** Component-encode one path segment, keeping ':' literal for drive letters. */
|
|
2535
|
+
function encodeAddressSegment(segment) {
|
|
2536
|
+
return encodeURIComponent(segment).replace(/%3A/gi, ":");
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/** Encode a '/'-separated path the way dsh's own address builder does. */
|
|
2540
|
+
function encodeAddressPath(path) {
|
|
2541
|
+
return path.split("/").map(encodeAddressSegment).join("/");
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
/**
|
|
2545
|
+
* Rewrite one `dsh-resource://file/...` address so its path uses the
|
|
2546
|
+
* Host's spelling. Anything that is not a file address, does not decode,
|
|
2547
|
+
* or does not change comes back untouched — the caller then forwards the
|
|
2548
|
+
* ORIGINAL string, so no URL round trip can alter a path we never
|
|
2549
|
+
* translated.
|
|
2550
|
+
* @param address - the resource address the Sidebar is about to open.
|
|
2551
|
+
* @param env - the Host's mount facts, or null.
|
|
2552
|
+
* @returns the address to open.
|
|
2553
|
+
*/
|
|
2554
|
+
function rescueFileAddress(address, env) {
|
|
2555
|
+
if (typeof address !== "string" || address.slice(0, FILE_ADDRESS_PREFIX.length) !== FILE_ADDRESS_PREFIX) return address;
|
|
2556
|
+
var tail = address.slice(FILE_ADDRESS_PREFIX.length);
|
|
2557
|
+
var cut = tail.search(/[?#]/);
|
|
2558
|
+
var suffix = cut === -1 ? "" : tail.slice(cut);
|
|
2559
|
+
var parts = (cut === -1 ? tail : tail.slice(0, cut)).split("/");
|
|
2560
|
+
var scope = parts.shift();
|
|
2561
|
+
if (scope !== "session" && scope !== "absolute") return address;
|
|
2562
|
+
var sessionId = null;
|
|
2563
|
+
if (scope === "session") {
|
|
2564
|
+
sessionId = parts.shift();
|
|
2565
|
+
if (sessionId === undefined || sessionId === "" || parts.length === 0) return address;
|
|
2566
|
+
}
|
|
2567
|
+
var segments;
|
|
2568
|
+
try {
|
|
2569
|
+
segments = parts.map(decodeURIComponent);
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
return address; // a malformed escape is not ours to repair
|
|
2572
|
+
}
|
|
2573
|
+
// '//server/share' keeps an empty first segment; a UNC path is never an
|
|
2574
|
+
// MSYS drive root, so it is left exactly as the Host spelled it.
|
|
2575
|
+
if (scope === "absolute" && segments[0] === "" && segments.length > 1) return address;
|
|
2576
|
+
var path = scope === "absolute" ? "/" + segments.join("/") : segments.join("/");
|
|
2577
|
+
var translated = msysToHostPath(path, env);
|
|
2578
|
+
if (translated === path) return address;
|
|
2579
|
+
var rebuilt = scope === "session"
|
|
2580
|
+
? FILE_ADDRESS_PREFIX + "session/" + encodeAddressSegment(sessionId) + "/" + encodeAddressPath(translated)
|
|
2581
|
+
: FILE_ADDRESS_PREFIX + "absolute/" + encodeAddressPath(translated.replace(/^\/+/, ""));
|
|
2582
|
+
return rebuilt + suffix;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/**
|
|
2586
|
+
* Wrap the navigation controller's ONE address-taking entry point. Every
|
|
2587
|
+
* way into the right column calls `openResource` (or its per-session twin),
|
|
2588
|
+
* so one wrapper covers the conversation links, the tool rows and the file
|
|
2589
|
+
* tree at once — no dsh source is touched, and the wrapper is removed when
|
|
2590
|
+
* this plugin's fiber is.
|
|
2591
|
+
*/
|
|
2592
|
+
function installPathRescue(ctx) {
|
|
2593
|
+
loadPathMap();
|
|
2594
|
+
try {
|
|
2595
|
+
ctx.inject(["sidebarRight"], function (sctx) {
|
|
2596
|
+
try {
|
|
2597
|
+
var controller = sctx && sctx.sidebarRight;
|
|
2598
|
+
if (!controller || controller.gbPathRescue === true) return;
|
|
2599
|
+
var openResource = controller.openResource;
|
|
2600
|
+
if (typeof openResource !== "function") return;
|
|
2601
|
+
var openResourceIn = controller.openResourceIn;
|
|
2602
|
+
var rescue = function (address) {
|
|
2603
|
+
return pathRescueActive() ? rescueFileAddress(address, pathMap) : address;
|
|
2604
|
+
};
|
|
2605
|
+
controller.openResource = function (address, options) {
|
|
2606
|
+
return openResource.call(this, rescue(address), options);
|
|
2607
|
+
};
|
|
2608
|
+
if (typeof openResourceIn === "function") {
|
|
2609
|
+
controller.openResourceIn = function (sessionId, address, options) {
|
|
2610
|
+
return openResourceIn.call(this, sessionId, rescue(address), options);
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
controller.gbPathRescue = true;
|
|
2614
|
+
var restore = function () {
|
|
2615
|
+
try {
|
|
2616
|
+
controller.openResource = openResource;
|
|
2617
|
+
if (typeof openResourceIn === "function") controller.openResourceIn = openResourceIn;
|
|
2618
|
+
delete controller.gbPathRescue;
|
|
2619
|
+
} catch (error) { /* a frozen instance keeps the wrapper; harmless */ }
|
|
2620
|
+
};
|
|
2621
|
+
if (typeof sctx.effect === "function") {
|
|
2622
|
+
sctx.effect(function () { return restore; }, "dsh-gitbash-shell: sidebar path rescue");
|
|
2623
|
+
}
|
|
2624
|
+
console.log(TAG + " right-sidebar path rescue armed (host spelling restored on open)");
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
console.warn(TAG + " right-sidebar path rescue failed:", error && error.message ? error.message : error);
|
|
2627
|
+
}
|
|
2628
|
+
});
|
|
2629
|
+
} catch (error) {
|
|
2630
|
+
console.warn(TAG + " right-sidebar path rescue wiring failed:", error && error.message ? error.message : error);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2455
2634
|
// ── plugin ────────────────────────────────────────────────────────────
|
|
2456
2635
|
|
|
2457
2636
|
exports.name = "dsh-gitbash-shell/client";
|
|
@@ -2579,6 +2758,11 @@ window.__ModuleLoader__.load({
|
|
|
2579
2758
|
} catch (error) {
|
|
2580
2759
|
console.warn(TAG + " settings card registration failed:", error && error.message ? error.message : error);
|
|
2581
2760
|
}
|
|
2761
|
+
|
|
2762
|
+
// The native right Sidebar consumes the very dialect this card gates,
|
|
2763
|
+
// so the rescue is armed on the same mount — and independently of the
|
|
2764
|
+
// card, which must never be taken down by it.
|
|
2765
|
+
installPathRescue(ctx);
|
|
2582
2766
|
};
|
|
2583
2767
|
|
|
2584
2768
|
return module.exports;
|
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
|
|
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.
|
|
2419
|
-
.then((
|
|
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
|
-
|
|
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'
|
|
@@ -2776,6 +2803,62 @@ export async function apply(ctx, config = {}) {
|
|
|
2776
2803
|
}
|
|
2777
2804
|
}
|
|
2778
2805
|
|
|
2806
|
+
// ── MSYS path facts for the browser half (v0.31.0) ───────────────────────
|
|
2807
|
+
// The native right Sidebar receives the path spelling the CONVERSATION
|
|
2808
|
+
// shows. While this plugin's POSIX dialect is on, that spelling is an MSYS
|
|
2809
|
+
// drive root (/c/Users/...), and the Host filesystem resolves it against the
|
|
2810
|
+
// current drive instead — '<drive>:\c\Users\...' — so every file link in the
|
|
2811
|
+
// transcript opens onto "file not found". The browser half rewrites the
|
|
2812
|
+
// resource address before opening it; this route is the fact bag that
|
|
2813
|
+
// rewrite needs. It reports the SAME env the tool-argument translation
|
|
2814
|
+
// already resolves against, plus the platform, so a non-Windows client never
|
|
2815
|
+
// rewrites anything. Read-only and loopback-fenced (a GET needs no Origin),
|
|
2816
|
+
// because the web server may be bound to 0.0.0.0.
|
|
2817
|
+
try {
|
|
2818
|
+
ctx.inject(['webServer'], (wctx) => {
|
|
2819
|
+
try {
|
|
2820
|
+
const route = '/dsh-gitbash-shell/api/pathmap'
|
|
2821
|
+
const mounts = {}
|
|
2822
|
+
for (const [mount, target] of GIT_MOUNTS) mounts[mount] = target
|
|
2823
|
+
const send = (res, code, payload) => {
|
|
2824
|
+
const text = JSON.stringify(payload)
|
|
2825
|
+
res.writeHead(code, {
|
|
2826
|
+
'content-type': 'application/json; charset=utf-8',
|
|
2827
|
+
'cache-control': 'no-store',
|
|
2828
|
+
'content-length': Buffer.byteLength(text),
|
|
2829
|
+
})
|
|
2830
|
+
res.end(text)
|
|
2831
|
+
}
|
|
2832
|
+
const handler = (req, res) => {
|
|
2833
|
+
const fence = fenceToolRequest(req)
|
|
2834
|
+
if (!fence.ok) {
|
|
2835
|
+
console.log(TAG + ' path map request refused (' + fence.reason + ')')
|
|
2836
|
+
send(res, 403, { error: 'forbidden', reason: fence.reason })
|
|
2837
|
+
return
|
|
2838
|
+
}
|
|
2839
|
+
const env = buildTranslateEnv(configuredBashPath)
|
|
2840
|
+
// One line per read: the browser half fetches this once per page load,
|
|
2841
|
+
// so its presence in the log is how a user can tell the rescue is live.
|
|
2842
|
+
console.log(TAG + ' path map read by ' + (req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '?'))
|
|
2843
|
+
send(res, 200, {
|
|
2844
|
+
platform: process.platform,
|
|
2845
|
+
home: env.home ?? null,
|
|
2846
|
+
tmpDir: env.tmpDir ?? null,
|
|
2847
|
+
gitRoot: env.gitRoot ?? null,
|
|
2848
|
+
mounts,
|
|
2849
|
+
})
|
|
2850
|
+
}
|
|
2851
|
+
const dispose = wctx.webServer.register({ kind: 'prefix', path: route, handler })
|
|
2852
|
+
wctx.effect(() => dispose, 'dsh-gitbash-shell: path map route')
|
|
2853
|
+
console.log(TAG + ' MSYS path map route active at ' + route + ' (read by the browser half)')
|
|
2854
|
+
} catch (error) {
|
|
2855
|
+
console.log(TAG + ' path map route failed: ' + (error?.message ?? error))
|
|
2856
|
+
}
|
|
2857
|
+
})
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
console.log(TAG + ' path map route wiring failed: ' + (error?.message ?? error))
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2779
2862
|
// ── winget toolchain route (v0.30.0) ─────────────────────────────────────
|
|
2780
2863
|
// The settings card's "install / upgrade" buttons. Installing software is a
|
|
2781
2864
|
// real side effect, so src/tool-runner.js fences the route: only a loopback
|
|
@@ -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
|
|
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 {
|