dsh-gitbash-shell 0.42.1 → 0.42.3
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 +162 -0
- package/dsh.plugin.json +1 -1
- package/package.json +3 -3
- package/src/bash-render.js +1 -1
- package/src/bash-tool.js +1 -1
- package/src/client.js +27 -19
- package/src/fastctx-bridge.js +142 -54
- package/src/index.js +33 -10
- package/src/native-executor.js +2 -1
- package/src/native-tool.js +5 -2
- package/src/tool-runner.js +41 -16
- package/src/toolchain.js +16 -10
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,68 @@
|
|
|
3
3
|
> 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
|
|
4
4
|
> 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
|
|
5
5
|
|
|
6
|
+
## [0.42.3] — 2026-10-23
|
|
7
|
+
|
|
8
|
+
### fix:全仓严格 review 的十项修复(PR #34,@KouzakiUmi 贡献;16 文件 +1229/−123)
|
|
9
|
+
|
|
10
|
+
- **R01(P2,路径字面量扫描器)**:ASI 后的合法语句正则(`/.../`)曾被误判成字符串并把其中的
|
|
11
|
+
路径改写成 Windows 形态——**静默改变程序语义**。现在闭组符之后、与下一个 `/` 之间隔着换行且
|
|
12
|
+
无法确定是除法还是正则时,整份 no-op(铁律:任何不确定 = 整份 no-op);行终止符判定从 `\n`
|
|
13
|
+
扩到 `\r\n\u2028\u2029` 全集。主扫描与模板插值共用同一守卫。
|
|
14
|
+
- **R02(P2,pathEcho)**:whole-value 分支不再做无前缀要求的 TEMP 根映射——`D:/backup/c/Temp/result.txt`
|
|
15
|
+
这类备份树内嵌副本是数据,不再被改写;只有从开头开始且有段边界的真实 TEMP 根才映射到 `/tmp`。
|
|
16
|
+
- **R03(P2,绝对 glob 拆分)**:`{}` 加入 `GLOB_META`,brace alternatives 整体留在 pattern 中,
|
|
17
|
+
不再被目录/basename 拆分散架。
|
|
18
|
+
- **R04(P2,promotion 缺口传播)**:后台输出只检查 `lossy`、丢弃 `chunks[].gapBefore`,模型不知道
|
|
19
|
+
ring 驱逐留下缺口。native promotion 复用 `renderJobRead`,同时传播 `lossy`/`gapBefore` 与可用
|
|
20
|
+
spill 路径;`bash-tool.js` 镜像的同款遗漏一并修正。
|
|
21
|
+
- **R05(P2,winget 清单可信化)**:`winget export` 原用固定共享临时路径且静默吞异常——无法建立
|
|
22
|
+
可信清单时仍可能启动安装。现在每次导出用 UUID 独立路径、只清理本次;检查退出码/JSON/清单结构;
|
|
23
|
+
清单不可信时可见失败,**绝不启动任何安装/升级**。状态路由对 probe 失败返回 500+详情(fail-loud
|
|
24
|
+
优于假「全没装」)。
|
|
25
|
+
- **R06(P2,FastCtx 桥生命周期)**:①用户开关 OFF/ON **可逆**(旧实现首次 OFF 即永久判死);
|
|
26
|
+
②dsh-ops 让位独立且不可逆;③payload 不可用独立记录,开关翻转可重试;④publication/discovery/
|
|
27
|
+
lazy connect 全程代际所有权约束(generation owner),旧代际的挂起任务不覆盖新状态;⑤`close()`
|
|
28
|
+
后动态 import 晚到的 spawn 被 `this.dead` 守卫拒绝(不再复活已停桥);⑥**`tools/call` 失败不
|
|
29
|
+
自动重放**——调用可能已写文件;⑦dispose 先于 listeners 安装,部分 wiring 也拥有子进程;⑧ops
|
|
30
|
+
检测扫描全体存活 agent 的 face(不再只看当前),渐进加载不再漏检。
|
|
31
|
+
- **R07(P2,设置卡草稿所有权)**:scope/ready 变化时初始化干净草稿但保留用户 dirty 编辑;保存回包
|
|
32
|
+
用 scope + 不透明 draft owner 绑定,迟到的保存不再误标新 scope 的状态;镜像 hydration 不覆盖
|
|
33
|
+
本地编辑。
|
|
34
|
+
- **R08(P2,命令扫描)**:readdir 改用 Dirent(`withFileTypes`),同名目录(含 `make/`、`jq.exe` 同目录
|
|
35
|
+
骗局)不再被当成命令;仅对符号链接候选检查文件目标(`issue #31` 同源补遗:符号链接与无扩展名场景)。
|
|
36
|
+
- **R09(P2,诚实退出码)**:`proc.exitCode ?? 0` 把合法 `null` 伪造成「exit code: 0」。现在缺字段
|
|
37
|
+
显示 `unknown`,真实 0/非零/kill 原样。官方 `tool-bash` 存在同款(已另行报告官方),官方修复后
|
|
38
|
+
我们镜像跟进。
|
|
39
|
+
- **R10(P3,typed native path)**:展开拒绝正则整体锚定 `^`——只拒绝 `~`/`$`/`%VAR%` **展开前缀**,
|
|
40
|
+
绝对路径内部的 `%`/`$`(如 `C:/data/100%.txt`)保持数据语义,不再误拒。
|
|
41
|
+
- **R06 合并时的实质判断(贡献者)**:`OPS_PRESENCE_MARKERS` 六名不全(dsh-ops 实际 10 个 `ops_*`
|
|
42
|
+
工具,`ops_batch`/`ops_glob`/`ops_replace`/`ops_run` 未收录),改为**前缀匹配 + 常量兜底**——
|
|
43
|
+
评审接受:对任何 `ops_*` 名字让位只会少发布一个重复文件面,方向保守,无误伤性后果。
|
|
44
|
+
- **测试**:新增 `tests/review-core-regressions.mjs` / `review-native-regressions.mjs` /
|
|
45
|
+
`review-native-preview.mjs` 并扩充 client/bridge 回归,全部纳入 test 脚本;先红后绿(旧代码
|
|
46
|
+
39 处 fail),修复后 323 项全绿(贡献者 Windows Node 24.13.0;维护者 macOS 322 pass + 1 平台
|
|
47
|
+
门控 skip)。修复前 review 快照保留在下方「全仓严格 review」一节。
|
|
48
|
+
|
|
49
|
+
## [0.42.2] — 2026-10-20
|
|
50
|
+
|
|
51
|
+
### fix:gb_* 对 dsh-ops 0.4.0 渐进加载的退避检测(每会话首个 agent 会多挂一轮 gb_*)
|
|
52
|
+
|
|
53
|
+
- **上游变化**:dsh-ops 0.4.0 把 PTC 工具面改成渐进加载(T-Auto/dsh-ops #261)——agent 的第一个
|
|
54
|
+
face 只常驻 `ops_read_files` / `ops_result` / `ops_load_sdk` 三件,`ops_inspect_local_file` /
|
|
55
|
+
`ops_grep` / `ops_bash` 等高级 API 要到「owning session 的 committed step 结束、下一次 provider
|
|
56
|
+
schema 捕获前」才发布。
|
|
57
|
+
- **症状**:`src/fastctx-bridge.js` 的退避检测在 `agent/created` 时只认三个旧名字;0.4.0 下 face 1
|
|
58
|
+
三个都不在 ⇒ 检测落空 ⇒ gb_* 照发;下一步 ops 高级 API 到场后,gb_* 三件与 ops_* 文件工具冗余
|
|
59
|
+
并存(名字不冲突,但白占 token)。只有每会话第一个 agent 受影响(后续 agent 创建时旧名字已到场)。
|
|
60
|
+
- **修法**:退避名单提取为导出常量 `OPS_PRESENCE_MARKERS`(六个名字:三个经典标记 + 0.4.0 的三个
|
|
61
|
+
渐进常驻),`reconcile` 改为对名单求 `some/includes`;任何一个在场即整体撤下,语义与 §4l 的
|
|
62
|
+
「见 ops_* 即让位」不变量一致。
|
|
63
|
+
- **测试**:tests/bridge-regressions.mjs 新增两例——face schemas 只返回 0.4.0 渐进三件 ⇒
|
|
64
|
+
definitions 与 FastCtx payload 实例数都必须为 0(payload 不许启动);守卫锁死六个标记名。
|
|
65
|
+
- **ptc-preset 侧无需跟进**:`detectOpsPresence` 只认 dsh-ops-shell / dsh-ops-file 行 id,与渐进
|
|
66
|
+
加载无关。
|
|
67
|
+
|
|
6
68
|
## [0.42.1] — 2026-10-19
|
|
7
69
|
|
|
8
70
|
### Guarded
|
|
@@ -42,6 +104,106 @@
|
|
|
42
104
|
- **dsh-ops 并存时的 shell 工具剔除(用户决策定案)**:安装了本插件 = bash 用本插件方案;`ops_bash` / `ops_run` 在本插件 bash 工具在场的 agent 上默认 deny(`tools.restrict` + `tools/pre-execute` 双层),不做设置开关;ops 的 file / 后台 / PTC 增强不受影响。
|
|
43
105
|
- 验证:`npm test` 263 项全绿(新增 tests/terminal-bundle.mjs 26 项:plan 矩阵 12 + writer 12 + report + detect);守卫测试演进 2 处(compatibility assemble 段参数面补 `terminalDialect`;smoke 侧栏守卫按上述范围收窄)。
|
|
44
106
|
|
|
107
|
+
## Unreleased — 2026-10-18
|
|
108
|
+
|
|
109
|
+
### 严格 review 的 R01–R10 已修复(fix / test,未发布)
|
|
110
|
+
|
|
111
|
+
- **R01**:[路径字面量扫描](src/index.js) 为换行后的含混 slash 添加整份 no-op 守卫,主扫描与模板插值共用;覆盖 LF / CRLF / Unicode 行分隔符、跨行注释、let/var、带标签 break/continue 与模板内声明,保留同一行除法和明确正则的原行为。仍是保守扫描器,不宣称成为完整 JS parser,也不改变已知的路径状业务字符串启发式边界。
|
|
112
|
+
- **R02 / R03**:[whole-value TEMP 回显与绝对 glob 拆分](src/index.js) 分别改为仅匹配真实 TEMP 根前缀,以及把 brace alternatives 留在 pattern 中。补备份路径/UNC/相对值/大小写/目录段边界与 brace 目录回归;没有改宿主搜索工具的根锚定规则。
|
|
113
|
+
- **R04**:[native promotion](src/native-tool.js) 复用 `renderJobRead`,消费 read 时同时传播 `lossy` 与 `chunks[].gapBefore`,附带可用 spill 路径;没有恢复路径时明确显示 unavailable。合并验收时也修正了 [Bash promotion](src/bash-tool.js) 同一类只检查 lossy 的遗漏,补有缺口/无缺口负控。保留原输出片段、消费次数、闭合 DTO、producer 结局与权限/取消契约,不重跑命令。
|
|
114
|
+
- **R05**:[winget inventory](src/tool-runner.js) 每次导出使用 UUID 独立路径,仅清理本次路径;检查 export 退出码、JSON 和清单结构。状态探测失败直接报错,不伪造 missing;安装/升级任务在无法建立可信 inventory 时结成可见失败,**不启动任何安装/升级**。并发状态探测 + auto 升级回归保留已安装的 GNU 构建,不改装首选 MSVC;合法空清单的正常安装路径仍通过。所有 winget 回归均为受控 IO,无真实软件安装。
|
|
115
|
+
- **R06**:[FastCtx bridge](src/fastctx-bridge.js) 分离用户开关、peer 让位与载荷不可用状态,监听 volatile 更新双向协调;publication / discovery / lazy connect 用代际所有权约束,OFF 撤名、关闭连接并拒绝旧 SDK。异步 spawn import 后也重验 stopped,避免 dispose 后晚 spawn;后端已发出的 tools/call 不自动重试。默认 OFF、弃用定位、无 OS confinement 声明与 ops_* 让位不变。
|
|
116
|
+
- **R07**:[设置卡](src/client.js) 在 scope / ready 状态 / 镜像值变化时初始化干净草稿,保留用户 dirty 编辑;用 scope + 不透明 draft owner 绑定保存状态,迟到的保存回包不能误标新 scope、继续编辑或 scope 往返后的新草稿。拒绝保存仍不显示成功,21 语言词典和宿主父层订阅方式不变。
|
|
117
|
+
- **R08**:[默认目录 IO](src/tool-runner.js) 使用 Dirent,[命令扫描](src/toolchain.js) 排除同名目录,并仅对候选符号链接检查文件目标;保留真实无扩展名文件、有效文件链接、扩展名白名单和首个 PATH 命中顺序。字符串式注入 IO 兼容旧确定性夹具,生产 IO 始终提供文件类型。
|
|
118
|
+
- **R09 / R10**:[后台结局](src/bash-render.js) 对 null 保留 null、缺字段显示 unknown,真实 0/非零/kill 原样;[typed native path](src/native-executor.js) 仅拒绝展开前缀,绝对路径内部百分号、美元符号和波浪号保持数据语义,不新增变量展开。
|
|
119
|
+
- **先红后绿**:首批核心回归旧代码 11 fail、native 普通回归 19 fail、设置卡/桥回归 9 fail;随后追加的 scope 迟到保存与 Bash source-gap 回归也在修复前各捕获 1 fail。真实 Core native 新 preview 旧代码 1 pass / 2 fail(大输出缺警示、百分号路径误拒),修复后同组全部通过。没有降低已有断言或禁用检查。
|
|
120
|
+
- **最终验证**:Windows / Node 24.13.0,`npm test` **309/309**,0 fail / skip / cancel;原版 `npm run test:preview` 在隔离的实际 **Core 0.2.1-rc.2** 依赖快照上 **89/89**,0 fail / skip / cancel。新增 [核心回归](tests/review-core-regressions.mjs)、[native 回归](tests/review-native-regressions.mjs)、[真实 Core native preview](tests/review-native-preview.mjs),扩充 [设置卡回归](tests/client-regressions.mjs) 与 [桥回归](tests/bridge-regressions.mjs),均接入 [测试脚本](package.json)。所有产品 JS / 新增测试语法检查、`npm pack --dry-run --ignore-scripts` 与 `git diff --check` 通过。
|
|
121
|
+
- **实际 Core 重点补验**:1 MiB producer 的首次 read 仍为 `lossy=false / gapBefore=true`、64000 字符尾部,修复后另带丢失警示且只有 1 个 producer;无缺口负控不误警示。真实 Node/fs 在 native 与 MSYS argv/cwd 两种路径中读回含 `%fixed% / $literal / ~literal` 的自有 sentinel;根 shell/subprocess provider 未被替换。未知退出码、UNC 解析、FastCtx 开关/取消、winget 并发与 GUI 草稿使用各自受控夹具验证,不冒充真实软件安装或生产 GUI 验收。
|
|
122
|
+
- **边界**:未操作生产 GUI、未安装/部署、未改生产 profile、未重启宿主;真实受限 token/ACL/IPC 围栏、每个旧 Core、非 CRT 任意解释器、真实磁盘耗尽与跨任务 quota 仍未覆盖。没有提交、推送、版本号变更或发布。下面保留修复前的 review 快照供追溯。
|
|
123
|
+
|
|
124
|
+
### 全仓严格 review:9 项 P2、1 项 P3(test / docs,修复前快照)
|
|
125
|
+
|
|
126
|
+
审查快照为 [v0.40.5 / 6c5f1cd](https://github.com/KannaKuron/dsh-gitbash-shell/commit/6c5f1cda5828ee5cbd5dba1aa704496f69e34412)。范围是当前全仓,不只本次同步增量;覆盖全部 19 个产品 JS 模块的行为实现(客户端词典按键集/结构检查,未逐条人工审译),以及组合、patch、包清单、发布/CI 脚本与相关测试。结论为 **10 项确认问题:9 项 P2、1 项 P3;未确认 P0/P1**。其中 R08/R09 是上一轮已记录问题,其余为本轮补充,不表示均由 v0.40.5 引入;以下未执行修复。
|
|
127
|
+
|
|
128
|
+
#### R01 · P2:路径扫描器将 ASI 后的正则内容改成 Windows 路径
|
|
129
|
+
|
|
130
|
+
- 定位:[scanCodeLiterals](src/index.js#L1393-L1424)。Windows 方言与 `codePaths` 开启时,合法程序 `let x\n/['/c/foo']/.test('C') && (x=true); return x ?? false` 中的 regexp 起始 `/` 被误当除法,其字符类里的引号又被当作 JS 字符串。
|
|
131
|
+
- 实际改写为 `/['C:/foo']/`;分别用 `new Function` 执行,原程序返回 `false`,改写程序返回 `true`。这不是已知的“路径状业务字符串启发式”边界,而是正则被错误识别为字符串,违反“正则不改/不确定整份 no-op”的约束。
|
|
132
|
+
- 建议:补声明/ASI 与 regexp 的判定;不能确定时整份跳过。回归应覆盖 let/var 声明后的正则与原有除法负控,不以仅锁定这个样例替代词法验证。
|
|
133
|
+
|
|
134
|
+
#### R02 · P2:TEMP 回显会改写非 TEMP 路径的中间子串
|
|
135
|
+
|
|
136
|
+
- 定位:[pathEcho](src/index.js#L756-L760) 将 whole-value 路径交给 [mountTempRoot](src/index.js#L709-L714) 全串替换,仅检查匹配后侧边界,不要求真实根前缀。
|
|
137
|
+
- `rewriteResultPaths('read', {path:'D:/backup/c/Temp/result.txt'}, {tmpDir:'C:/Temp'})` 实际返回 `/d/backup/tmp/result.txt`,正确值应为 `/d/backup/c/Temp/result.txt`。备份树复刻真实用户 TEMP 路径时同样可触发;后续文件操作与 UI 链接可能指向另一位置。
|
|
138
|
+
- 建议:whole-value 只做从开头开始且有段边界的 TEMP 根映射;散文内嵌诊断另保留有边界的全文逻辑。覆盖备份目录、UNC 与相对结果路径的负控。
|
|
139
|
+
|
|
140
|
+
#### R03 · P2:绝对 glob 的目录 brace alternatives 被当成实体目录
|
|
141
|
+
|
|
142
|
+
- 定位:[GLOB_META 与 splitGlobPrefix](src/index.js#L1506-L1522) 没将 `{` 识别为 glob 元字符。`/c/project/{src,test}/*.js` 被拆为 `path='C:/project/{src,test}'`、`pattern='*.js'`,表达式错误进入文件系统根路径。
|
|
143
|
+
- 唯一临时目录内创建两目录/两文件,同一真实 ripgrep 从 fixture cwd 以 `{foo,bar}/*.js` 搜索返回 2 项;用插件拆分产物执行则 exit 2、根目录不存在。这里确认的是 brace 被移入 path,不把 Core 绝对搜索根与带斜杠 pattern 自身的锚定语义混为同一个问题。
|
|
144
|
+
- 建议:仅提取确定的字面目录前缀,完整识别支持的 glob 目录元字符,并对照真实搜索工具补 brace 目录回归。
|
|
145
|
+
|
|
146
|
+
#### R04 · P2:native 超时提升消费输出后丢掉缺口事实
|
|
147
|
+
|
|
148
|
+
- 定位:[native promotion](src/native-tool.js#L167-L169) 仅保留 `ringDelta(read.chunks)`,既不保留 `read.lossy`,也不检查 `chunks[].gapBefore` 或呈现可用的完整输出路径。[promoted renderer](src/native-tool.js#L96-L99) 也没有对应警示。
|
|
149
|
+
- **真实 Core 补证**:Core 0.2.1-rc.2 的真实 ToolRuntime / LocalJobRegistry / LocalSubprocessRuntime 下,运行一个仅输出 1 MiB 固定字符及 tail sentinel、随后等待的 Node producer,700ms 前台等待后提升。实际消费 read 为 `lossy=false`、`gapBefore=true`,DTO 仅留 64000 字符且带 tail sentinel,模型文本没有 dropped/truncated/gap/full-output 提示。只看 `read.lossy` 同样修不全;该次 chunk 缺口已随消费游标前移,不能指望下一次 read 重现同一告警。进程由自有 Jobs 显式取消并等待结束。
|
|
150
|
+
- 建议:在消费 read 的同一位置综合 ring eviction 与 source gap,并携带可用恢复路径或闭合 DTO 元数据;补真实 source-gap 与 ring-loss 两种提升回归。最终输出事实可能另外说明 loss,不能替代首次提升时的即时警示。
|
|
151
|
+
|
|
152
|
+
#### R05 · P2:并发 winget 探测与安装共用导出文件,可能装错构建
|
|
153
|
+
|
|
154
|
+
- 定位:[状态探测的临时清单](src/tool-runner.js#L111-L119) 与 [安装任务的临时清单](src/tool-runner.js#L198-L204) 均使用固定文件名,读取后各自删除,且导出失败后把清单缺失当作空清单继续动作。
|
|
155
|
+
- 受控异步 IO 调度中,两次 export 都已写完,状态请求先读并删除,安装任务后取结果时读到 ENOENT;原来 `BurntSushi.ripgrep.GNU` 已安装且可升级,`auto` 任务却生成 `install --id BurntSushi.ripgrep.MSVC`,而不是升级已安装 GNU 构建。正常的另一设置页刷新即可与任务重叠;单个 activeJob 锁不覆盖状态请求。
|
|
156
|
+
- 建议:每次导出使用自有唯一临时目录/文件并仅清理自己;检查 export 结局,无法建立可信 inventory 时不应静默把升级意图变成安装。证据为受控 IO 时序与实际 runner 决策,无真实 winget 安装。
|
|
157
|
+
|
|
158
|
+
#### R06 · P2:FastCtx 的 volatile 开关不能双向生效
|
|
159
|
+
|
|
160
|
+
- 定位:[FastCtx reconcile](src/fastctx-bridge.js#L461-L477) 仅在 agent/created 时读取开关;OFF 还将 `withdrawnByOps` 永久设为 true。[调用路径](src/fastctx-bridge.js#L333-L357) 不重验 enabled。
|
|
161
|
+
- 受控 transport 复现:ON 时发布 `gb_replace` → 将权威开关置 OFF,现有工具仍在且旧定义仍成功 dispatch → 下一次创建 agent 才撤下 → 再置 ON 并创建 agent,工具仍不回来。默认 OFF 已被观察过的进程也无法仅靠设置卡重新开启。
|
|
162
|
+
- 建议:把用户 OFF 与 peer 让位分成不同状态;监听开关并重新协调,同时阻止 OFF 后未启动的新调用。不要将此项描述成提供了 FastCtx OS confinement;它只涉及所承诺的开关行为。
|
|
163
|
+
|
|
164
|
+
#### R07 · P2:设置卡首次 loading→ready 竞态可清空有效 bashPath
|
|
165
|
+
|
|
166
|
+
- 定位:[bashDraft 初始化 effect](src/client.js#L2077-L2082) 使用空依赖数组;[保存](src/client.js#L2308-L2310) 写入当前草稿,而不是权威配置。
|
|
167
|
+
- 当插件 inventory 先到、own ConfigForm 仍 loading 时卡片首次挂载,初始化跳过;设置镜像随后 ready、宿主父组件正常重渲染也不会重跑 effect。原 Client 函数配合受控 Hook 渲染复现:权威值为 `Q:/Git/bin/bash.exe`,输入框仍空,用户未编辑直接保存会写 `bashPath:''`,意外切回自动探测。
|
|
168
|
+
- 已核 Core 0.2.1-rc.2 的真实父层契约:PluginManagerPage 的 loaded 只等待插件 inventory,PackageDetail 对 configured slot 的挂载不等待 ConfigForm ready,父页订阅 settings namespace 后会正常重渲染。因此不是“自身未订阅导致整个 GUI 永不更新”;后者是已排除的初始误判。本项证据为真实契约 + 原函数受控渲染,未做生产 GUI 延迟注入。
|
|
169
|
+
- 建议:首次 ready 后初始化草稿,并用 dirty 状态防止后续镜像刷新覆盖用户正在编辑的值。
|
|
170
|
+
|
|
171
|
+
#### R08 · P2:PATH 同名目录误报为命令(上一轮已知遗留)
|
|
172
|
+
|
|
173
|
+
- 定位:[scanCommands](src/toolchain.js#L236-L263) 与 [默认 readdir IO](src/tool-runner.js#L67-L72)。真实临时 PATH 只有 `make` 目录时仍判定命令存在,winget 可用且清单为空时得到 `external` / `actionable:false`,安装入口被错误关闭。
|
|
174
|
+
- v0.40.4 已明确保留目录类型区分为后续项,本轮不冒充新增。应补类型验证且保留真实无扩展名文件/可执行文件符号链接。
|
|
175
|
+
|
|
176
|
+
#### R09 · P2:未知后台退出码被报告为 0(上一轮已确认)
|
|
177
|
+
|
|
178
|
+
- 定位:[processOutcome](src/bash-render.js#L163-L168)。真实 Cordis 挂载自有 bash 模块,以受控已结束句柄 `exitCode:null` 执行后台路径,detail 被 `?? 0` 写成 `exit code: 0`;同一结果的前台仍为 `[exit code: null]`。
|
|
179
|
+
- Core 0.2.1-rc.2 官方 tool-bash 同样含此表达式,属于镜像继承问题,不是本次 UI 更新新引入。应保留未知值,而非将 Job 的 completed 词汇与命令成功混同;本项未用真实 OS 子进程制造缺失退出码结局。
|
|
180
|
+
|
|
181
|
+
#### R10 · P3:typed absolute path 误拒合法百分号文件名
|
|
182
|
+
|
|
183
|
+
- 定位:[nativePath](src/native-executor.js#L24-L34) 的 `%[^%]+%` 无位置限制,在确认原生绝对路径前就拒绝字面百分号片段。
|
|
184
|
+
- 唯一临时目录内 `%fixed%.txt` 为真实文件,普通 Node fs 能读回 sentinel,Windows absolute 判定为真;相同值传 kind:path 仍报“需要 absolute path / 不支持变量展开”,literal argv 则接受。若 executable 或 cwd 目录包含同类合法名字,没有改用 literal 的入口。
|
|
185
|
+
- 建议:拒绝真正依赖展开的相对/前缀变量输入;已经是绝对路径中的 `%` 应保留为数据。发生频率低于上述问题,按 P3 排序;不涉及越权。
|
|
186
|
+
|
|
187
|
+
#### 验证、反证与范围边界
|
|
188
|
+
|
|
189
|
+
- **便携全量回归**:Windows / Node 24.13.0,重新执行 `npm test`,**237/237**,0 fail / skip / cancel。语法与发布清单检查沿用同一产品快照上一轮通过的 `node --check` / `npm pack --dry-run --ignore-scripts`。
|
|
190
|
+
- **完整原版 preview**:从现有桌面 ASAR 只读复制所需依赖到唯一临时目录,保持原有嵌套依赖布局,记录包版本/来源/manifest SHA256;不安装、不改宿主。普通 Node 24.13.0 + Core 0.2.1-rc.2 下,**原版 `npm run test:preview` 86/86**,0 fail / skip / cancel。包含真实 native argv、独立编码/原始字节、PTC→Node/fs、后台/超时/取消/卸载、policy负控和 provider 所有权。复制件是当前安装代码的隔离验证,不等于更新了正在运行的产品。
|
|
191
|
+
- **补充审查**:两项独立专项回归分别 62/62 与 49/49,与全量重叠,不累加成额外通过数;96 次真实 dispatch listener 门控矩阵通过。上述确认问题的临时复现由主审逐项复跑;R04 另有真实 Core + 真 producer 补证,其余按各条注明的受控层级解释。
|
|
192
|
+
- **失败尝试保留**:Electron Node 24.21 的原 loader 重入递归属于该启动路线的测试装置问题,不据此报产品失效;临时 ASAR 复制最初遇到目录 cp 不支持与嵌套 yaml 副本,改逐文件复制且保留嵌套布局;首完整预览 85/86 唯一失败是复制件缺少只用于打印 Core 版本的 manifest,补原始 manifest 后完整重跑通过。没有把这些失败尝试计入成功次数。
|
|
193
|
+
- **已排除**:自身 ConfigForm 未 subscribe 并不导致当前宿主设置卡永不刷新(父组件订阅);终端未清理 retry timer 的一次迟到回调,在真实 Cordis dispose 后被 inactive-context 检查拒绝,受控 editor **0 次写入**,不报告“卸载后覆盖配置”。
|
|
194
|
+
- **未确认候选**:percent-encoded 非 UUID sessionId 二次编码的正常可达性;旧 Core minimal compaction 探针区分度;status 路由与外层认证的远程部署组合;`virtualMounts` OFF 的 NUL 设备特例。没有证据前不归入上述 10 项。
|
|
195
|
+
- **明确不覆盖**:生产 GUI 延迟/视觉验收、真实 LLM 自主选路、真实受限 token/ACL/IPC 围栏正负验收、每个旧 Core 版本实机、非 CRT 任意解释器 quoting、真实磁盘耗尽/RSS/跨任务 quota。已有默认 fail-closed、用户显式关闭后的 legacy UNCONFINED、codePaths 路径状业务字符串启发式与动态拼接边界均按既定产品约束评价,未冒充新增漏洞。
|
|
196
|
+
- 本轮只更新审查记录;未改产品源码、未提交/推送、未安装/部署、未改生产 profile、未重启宿主。
|
|
197
|
+
|
|
198
|
+
### 同步 v0.40.5 后的功能审查(test / docs,未修复)
|
|
199
|
+
|
|
200
|
+
- 审查基线:[v0.40.5(6c5f1cd)](https://github.com/KannaKuron/dsh-gitbash-shell/commit/6c5f1cda5828ee5cbd5dba1aa704496f69e34412)。相对本地原基线快进 8 个提交,无合并冲突;本条只记录检查,未修改产品源码。
|
|
201
|
+
- **P2,已知遗留复现:PATH 同名目录仍会阻止安装。** [scanCommands](src/toolchain.js#L236-L263) 只收到字符串文件名,扩展名白名单不能区分目录与文件。[默认 IO](src/tool-runner.js#L67-L72) 的真实 `readdirSync` 读取一个仅含 `make` 目录的临时 PATH 目录时仍返回命中;在 winget 可用、清单为空的受控装置中,[probeToolchain](src/tool-runner.js#L91-L162) 将该行判为 `external` / `actionable: false`,实际没有命令却不提供安装操作。与 v0.40.4 记载的目录类型区分遗留项相同,不是本次同步新引入。需后续补文件类型判定,同时保留无扩展名真实可执行文件与可执行文件符号链接。
|
|
202
|
+
- **P2,后台退出事实丢失:未知退出码被写成 0。** [processOutcome](src/bash-render.js#L163-L168) 的 `proc.exitCode ?? 0` 把合法的 `null` 改成成功退出码。真实 Cordis 挂载自有 bash 模块、以受控已结束进程句柄执行 `run_in_background: true` 后,[Jobs 接线](src/bash-tool.js#L389-L414) 产出的终态 detail 为 `exit code: 0`;同一结果的前台渲染为 `[exit code: null]`。这不是「completed 应等于 failed」的问题,而是具体退出码被捏造;应保留未知值。当前桌面 Core 0.2.1-rc.2 的官方 tool-bash 同样含此表达式,属于镜像继承问题;未用真实 OS 子进程制造无退出码结局。
|
|
203
|
+
- **验证:** Windows / Node 24.13.0,`npm test` **237/237**,0 fail / skip / cancel;全部产品 JS 的 `node --check` 与 `npm pack --dry-run --ignore-scripts` 通过。两个针对性复现均 exit 0:目录案例使用真实文件系统与受控 winget 清单,后台案例使用真实 Cordis 与受控进程/Jobs,不执行 winget 安装或真实异常进程。
|
|
204
|
+
- **实际 Core 的有界补验:** 桌面壳 2.0.19-next / Core 0.2.1-rc.2 / Electron Node 24.21.0,从现有 ASAR 只读解析依赖;[基础预览](tests/preview.mjs)、[桥预览](tests/bridge-preview.mjs)、[运行时预览](tests/runtime-preview.mjs)、[宿主预览](tests/host-preview.mjs) 合计 **30/30**,0 fail / skip / cancel。覆盖真实 Git Bash / PowerShell、根执行器保留、可见性开关、拒绝时序与卸载。原版 [host-loader](tests/host-loader.mjs#L10-L17) 在该 Electron Node 环境内 `hostRequire.resolve` 重入自身解析钩子,首跑 4 个测试文件均因 `RangeError: Maximum call stack size exceeded` 未进入用例;改用临时目录内的防重入解析器后通过,仓库加载器未修改。不能将补验称为原版 `test:preview` 全套通过。
|
|
205
|
+
- **未覆盖:** 完整 native/path/输出 preview、生产 GUI、模式选择器与 PTY、真实 LLM 自主选路、真实受限 OS 围栏。未安装或部署插件,未改生产配置,未重启宿主。
|
|
206
|
+
|
|
45
207
|
## v0.40.5 — 2026-10-18
|
|
46
208
|
|
|
47
209
|
### 工具行字号跟随宿主字体设置;半像素字号归整(fix)
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-gitbash-shell",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.3",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "Windows 全模式 Git Bash:自带 bash 工具(私有 Git Bash 执行器实例,官方 PowerShell 执行器原样保留),官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0 起;v0.39.0 起不再替换宿主执行器),四个 *·Git Bash 变体 preset 可开关;官方 pwsh 工具默认隐藏、开关可见(真 PowerShell);附带 gb_* FastCtx 文件工具,检测到 dsh-ops 自动让位。联动:dsh-ptc-cordis-preset(PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端标签自动切 Git Bash)。",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gitbash-shell",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.3",
|
|
4
4
|
"description": "Windows 上给 agent 一个真正的 Git Bash 命令工具:私有 Git Bash 执行器实例(官方 PowerShell 执行器原样保留),统一 MSYS 盘根路径方言,官方 pwsh 工具默认隐藏可开关,物化四个内置预设的 Git Bash 变体,侧栏终端接管可开关。· Give agents a real Git Bash tool on Windows: a private Git Bash executor instance (the official PowerShell executor stays untouched), a unified MSYS drive-root path dialect, an opt-in official pwsh tool, and four Git Bash preset variants.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"icon.svg"
|
|
58
58
|
],
|
|
59
59
|
"scripts": {
|
|
60
|
-
"test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs",
|
|
61
|
-
"test:preview": "node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs tests/path-preview.mjs tests/native-output-preview.mjs tests/native-preview.mjs tests/native-integration-preview.mjs",
|
|
60
|
+
"test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs tests/review-core-regressions.mjs tests/review-native-regressions.mjs",
|
|
61
|
+
"test:preview": "node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs tests/path-preview.mjs tests/native-output-preview.mjs tests/native-preview.mjs tests/native-integration-preview.mjs tests/review-native-preview.mjs",
|
|
62
62
|
"version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
|
|
63
63
|
},
|
|
64
64
|
"keywords": [
|
package/src/bash-render.js
CHANGED
|
@@ -163,7 +163,7 @@ function sandboxNotes(sandbox, escalationModes) {
|
|
|
163
163
|
export function processOutcome(proc, escalationModes = []) {
|
|
164
164
|
const base = proc.status === 'killed'
|
|
165
165
|
? { status: 'killed', detail: proc.signal !== null && proc.signal !== undefined ? `signal: ${proc.signal}` : 'killed before exit' }
|
|
166
|
-
: { status: 'completed', detail: `exit code: ${proc.exitCode
|
|
166
|
+
: { status: 'completed', detail: `exit code: ${proc.exitCode === undefined ? 'unknown' : proc.exitCode}` }
|
|
167
167
|
const notes = sandboxNotes(proc.sandbox, escalationModes)
|
|
168
168
|
return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` }
|
|
169
169
|
}
|
package/src/bash-tool.js
CHANGED
|
@@ -455,7 +455,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm
|
|
|
455
455
|
timeoutMs,
|
|
456
456
|
// Preserve ring/spill notices; sandbox notes belong to the final renderer.
|
|
457
457
|
output: renderJobRead(
|
|
458
|
-
ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], undefined, escalationModes,
|
|
458
|
+
ringDelta(read.chunks), read.lossy || read.chunks.some(chunk => chunk.gapBefore), read.job.output.spillPaths ?? [], undefined, escalationModes,
|
|
459
459
|
),
|
|
460
460
|
...(sandbox === undefined ? {} : { sandbox }),
|
|
461
461
|
}
|
package/src/client.js
CHANGED
|
@@ -2068,18 +2068,33 @@ window.__ModuleLoader__.load({
|
|
|
2068
2068
|
} catch (error_) { /* keep the switch usable */ }
|
|
2069
2069
|
}, []);
|
|
2070
2070
|
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
var setBashDraft = bashState[1];
|
|
2074
|
-
var bashSavedState = useState(false);
|
|
2075
|
-
var bashSaved = bashSavedState[0];
|
|
2076
|
-
var setBashSaved = bashSavedState[1];
|
|
2077
|
-
useEffect(function () {
|
|
2071
|
+
var scope = props.scope;
|
|
2072
|
+
var snap = { status: "unavailable" };
|
|
2078
2073
|
try {
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2074
|
+
if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot();
|
|
2075
|
+
} catch (error_) { /* keep unavailable */ }
|
|
2076
|
+
|
|
2077
|
+
var bashState = useState({ scope: scope, value: "", dirty: false, owner: {} });
|
|
2078
|
+
var bashDraft = bashState[0].scope === scope ? bashState[0].value : "";
|
|
2079
|
+
var setBashDraft = bashState[1];
|
|
2080
|
+
var bashSaved = bashState[0].scope === scope && bashState[0].saved === true;
|
|
2081
|
+
function setBashSaved(saved) {
|
|
2082
|
+
setBashDraft(function (previous) {
|
|
2083
|
+
// A late save only belongs to the exact draft that submitted it.
|
|
2084
|
+
if (previous.scope !== scope || previous.owner !== bashState[0].owner || previous.saved === saved) return previous;
|
|
2085
|
+
return Object.assign({}, previous, { saved: saved === true, dirty: saved === true ? false : previous.dirty });
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
var mirroredBashPath = snap.value && typeof snap.value.bashPath === "string" ? snap.value.bashPath : "";
|
|
2089
|
+
useEffect(function () {
|
|
2090
|
+
// Core's parent rerenders on mirror arrival. Hydrate once ready (or
|
|
2091
|
+
// for a new scope), but never replace this scope's local edits.
|
|
2092
|
+
setBashDraft(function (previous) {
|
|
2093
|
+
if (previous.scope !== scope) return { scope: scope, value: snap.status === "ready" ? mirroredBashPath : "", dirty: false, owner: {} };
|
|
2094
|
+
if (snap.status !== "ready" || previous.dirty || previous.value === mirroredBashPath) return previous;
|
|
2095
|
+
return { scope: scope, value: mirroredBashPath, dirty: false, owner: {} };
|
|
2096
|
+
});
|
|
2097
|
+
}, [scope, snap.status, mirroredBashPath]);
|
|
2083
2098
|
|
|
2084
2099
|
/* v0.38.0: the card's sections are FOLDERS. All seven stay collapsed
|
|
2085
2100
|
by default — the user scans seven labelled header rows, expands the
|
|
@@ -2098,13 +2113,6 @@ window.__ModuleLoader__.load({
|
|
|
2098
2113
|
});
|
|
2099
2114
|
}
|
|
2100
2115
|
|
|
2101
|
-
var scope = props.scope;
|
|
2102
|
-
|
|
2103
|
-
var snap = { status: "unavailable" };
|
|
2104
|
-
try {
|
|
2105
|
-
if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot();
|
|
2106
|
-
} catch (error_) { /* keep unavailable */ }
|
|
2107
|
-
|
|
2108
2116
|
var pageView = props.view === "page";
|
|
2109
2117
|
|
|
2110
2118
|
// Hooks run for every snapshot state; readiness only gates their work.
|
|
@@ -2301,7 +2309,7 @@ window.__ModuleLoader__.load({
|
|
|
2301
2309
|
E("span", { className: "gb-rowLabel" }, t("bash.label") + ":"),
|
|
2302
2310
|
),
|
|
2303
2311
|
E("div", { className: "gb-seg" },
|
|
2304
|
-
E("input", { className: "gb-input", type: "text", spellCheck: false, placeholder: "C:/Program Files/Git/bin/bash.exe", value: bashDraft, onChange: function (e) { setBashDraft(e.target.value
|
|
2312
|
+
E("input", { className: "gb-input", type: "text", spellCheck: false, placeholder: "C:/Program Files/Git/bin/bash.exe", value: bashDraft, onChange: function (e) { setBashDraft({ scope: scope, value: e.target.value, dirty: true, owner: {} }); } }),
|
|
2305
2313
|
E("button", {
|
|
2306
2314
|
type: "button",
|
|
2307
2315
|
className: "gb-saveBtn" + (bashSaved ? " gb-saved" : ""),
|
package/src/fastctx-bridge.js
CHANGED
|
@@ -42,6 +42,24 @@ export const FASTCTX_FILE_TOOLS = Object.freeze(['inspect_local_file', 'grep', '
|
|
|
42
42
|
export const GB_PREFIX = 'gb_'
|
|
43
43
|
export const gbName = (raw) => GB_PREFIX + raw
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Any one of these names in the visible face means dsh-ops owns the file
|
|
47
|
+
* surface, so the whole gb_* bridge stands down. The first three are the
|
|
48
|
+
* classic markers; the 0.4.0 progressive PTC rollout (T-Auto/dsh-ops #261)
|
|
49
|
+
* keeps only `ops_read_files`/`ops_result`/`ops_load_sdk` resident in an
|
|
50
|
+
* agent's first face — checking the classic names alone let gb_* mount face 1
|
|
51
|
+
* and linger as redundant duplicates once the advanced APIs arrived a step
|
|
52
|
+
* later. Any single marker present is enough to stand down.
|
|
53
|
+
*/
|
|
54
|
+
export const OPS_PRESENCE_MARKERS = Object.freeze([
|
|
55
|
+
'ops_inspect_local_file',
|
|
56
|
+
'ops_grep',
|
|
57
|
+
'ops_bash',
|
|
58
|
+
'ops_read_files',
|
|
59
|
+
'ops_result',
|
|
60
|
+
'ops_load_sdk',
|
|
61
|
+
])
|
|
62
|
+
|
|
45
63
|
// The HOST registry rejects any tool without an `output` contract (issue #18,
|
|
46
64
|
// root cause 2) — the frozen schema + text renderer the pwsh7
|
|
47
65
|
// definition ships, reused so both families speak one tool-result contract.
|
|
@@ -99,6 +117,8 @@ export class McpStdio {
|
|
|
99
117
|
/** Spawn the server and run the MCP initialize handshake. */
|
|
100
118
|
async start() {
|
|
101
119
|
const { spawn } = await import('node:child_process')
|
|
120
|
+
// close() can run while the dynamic import is pending; never resurrect it.
|
|
121
|
+
if (this.dead) throw new Error('the FastCtx bridge was stopped')
|
|
102
122
|
this.child = spawn(this.file, this.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
103
123
|
this.child.stdout.setEncoding('utf8')
|
|
104
124
|
this.child.stderr.setEncoding('utf8')
|
|
@@ -305,43 +325,102 @@ const FASTCTX_SANDBOX_NOTE = 'FastCtx reads the real filesystem with no workspac
|
|
|
305
325
|
* - echo(text) -> result dialect echo, or undefined (off)
|
|
306
326
|
* - log(message) -> the plugin's tagged logger
|
|
307
327
|
*
|
|
308
|
-
* The surface publishes at the
|
|
309
|
-
*
|
|
310
|
-
* the rest of the process
|
|
311
|
-
*
|
|
328
|
+
* The surface publishes at the first agent creation or live switch update.
|
|
329
|
+
* User OFF/ON is reversible; dsh-ops appearing (ops_* tools in the face)
|
|
330
|
+
* independently withdraws gb_* for the rest of the process. Every call also
|
|
331
|
+
* checks the live switch and its publication generation, including old SDKs.
|
|
312
332
|
*/
|
|
313
333
|
export function mountFastctx(ctx, deps) {
|
|
314
334
|
const { platform, enabled, createRequireFrom, tools, msysToWin, echo, log } = deps
|
|
315
335
|
if (platform !== 'win32') return
|
|
316
336
|
let disposer = undefined
|
|
317
337
|
let client = undefined
|
|
318
|
-
let
|
|
338
|
+
let unavailable = false
|
|
319
339
|
let withdrawnByOps = false
|
|
340
|
+
let switchOn
|
|
341
|
+
let generation = 0
|
|
320
342
|
let closed = false
|
|
321
343
|
let publishing
|
|
322
344
|
let connecting
|
|
323
345
|
const starting = new Set()
|
|
346
|
+
const pending = new Set()
|
|
347
|
+
const agents = new Set()
|
|
348
|
+
|
|
349
|
+
const track = (promise) => {
|
|
350
|
+
pending.add(promise)
|
|
351
|
+
promise.then(() => pending.delete(promise), () => pending.delete(promise))
|
|
352
|
+
return promise
|
|
353
|
+
}
|
|
324
354
|
|
|
325
355
|
const unpublish = (reason) => {
|
|
326
356
|
if (disposer) {
|
|
327
|
-
|
|
357
|
+
const dispose = disposer
|
|
328
358
|
disposer = undefined
|
|
359
|
+
try { dispose() } catch { /* already gone */ }
|
|
329
360
|
}
|
|
330
361
|
if (reason) log(reason)
|
|
331
362
|
}
|
|
332
363
|
|
|
333
|
-
const
|
|
334
|
-
|
|
364
|
+
const stopConnections = () => {
|
|
365
|
+
const owned = new Set([...starting, client].filter(Boolean))
|
|
366
|
+
client = undefined
|
|
367
|
+
// Calling close immediately rejects pending requests and kills the child;
|
|
368
|
+
// track completion as well so disposal owns even superseded generations.
|
|
369
|
+
track(Promise.allSettled([...owned].map((connection) => connection.close())))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const live = (agent) => {
|
|
373
|
+
if (closed) return false
|
|
374
|
+
const next = !!enabled()
|
|
375
|
+
if (switchOn !== next) {
|
|
376
|
+
switchOn = next
|
|
377
|
+
generation++
|
|
378
|
+
unavailable = false
|
|
379
|
+
if (!next) {
|
|
380
|
+
unpublish('FastCtx bridge disabled by the fastctxTools switch')
|
|
381
|
+
stopConnections()
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!withdrawnByOps) {
|
|
385
|
+
const registry = tools(ctx)
|
|
386
|
+
const faces = new Set(agent ? [agent, ...agents] : agents.size ? agents : [undefined])
|
|
387
|
+
for (const face of faces) {
|
|
388
|
+
const names = (registry?.schemas?.(face) ?? []).map((schema) => schema?.name)
|
|
389
|
+
// Any ops_* name means dsh-ops owns the file surface. Matching the
|
|
390
|
+
// prefix (rather than only the listed markers) covers the 0.4.0
|
|
391
|
+
// progressive residents AND the names the marker list does not carry
|
|
392
|
+
// yet (ops_batch / ops_glob / ops_replace / ops_run): a later caller
|
|
393
|
+
// can hand the face a name that was never resident in face 1.
|
|
394
|
+
if (names.some((name) => typeof name === 'string'
|
|
395
|
+
&& (name.startsWith('ops_') || OPS_PRESENCE_MARKERS.includes(name)))) {
|
|
396
|
+
withdrawnByOps = true
|
|
397
|
+
generation++
|
|
398
|
+
unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
|
|
399
|
+
stopConnections()
|
|
400
|
+
break
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return switchOn && !withdrawnByOps
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const assertLive = (owner, agent) => {
|
|
408
|
+
if (!live(agent) || owner !== generation || !disposer) throw new Error('the FastCtx bridge was stopped')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const callWithReconnect = async (rawName, args, owner, agent) => {
|
|
412
|
+
assertLive(owner, agent)
|
|
335
413
|
if (client === undefined || client.isDead) {
|
|
336
|
-
if (!connecting) {
|
|
337
|
-
|
|
414
|
+
if (!connecting || connecting.owner !== owner) {
|
|
415
|
+
const task = { owner }
|
|
416
|
+
task.promise = track((async () => {
|
|
338
417
|
const { file } = resolveFastctx(ctx, createRequireFrom)
|
|
339
418
|
if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
|
|
340
419
|
const fresh = new McpStdio({ file, log })
|
|
341
420
|
starting.add(fresh)
|
|
342
421
|
try {
|
|
343
422
|
await fresh.start()
|
|
344
|
-
|
|
423
|
+
assertLive(owner, agent)
|
|
345
424
|
client = fresh
|
|
346
425
|
} catch (error) {
|
|
347
426
|
await fresh.close()
|
|
@@ -349,23 +428,25 @@ export function mountFastctx(ctx, deps) {
|
|
|
349
428
|
} finally {
|
|
350
429
|
starting.delete(fresh)
|
|
351
430
|
}
|
|
352
|
-
})().finally(() => { connecting = undefined })
|
|
431
|
+
})().finally(() => { if (connecting === task) connecting = undefined }))
|
|
432
|
+
connecting = task
|
|
353
433
|
}
|
|
354
|
-
await connecting
|
|
434
|
+
await connecting.promise
|
|
355
435
|
}
|
|
356
|
-
|
|
436
|
+
assertLive(owner, agent)
|
|
437
|
+
// Reconnect only BEFORE dispatch. Never replay a failed tools/call: it may
|
|
438
|
+
// already have written files even when its response is an ordinary error.
|
|
357
439
|
return client.callTool(rawName, args)
|
|
358
440
|
}
|
|
359
441
|
|
|
360
|
-
const publishOnce = async () => {
|
|
361
|
-
if (
|
|
442
|
+
const publishOnce = async (owner) => {
|
|
443
|
+
if (!live() || owner !== generation || disposer || unavailable) return
|
|
362
444
|
const registry = tools(ctx)
|
|
363
445
|
if (!registry || typeof registry.register !== 'function') return
|
|
364
446
|
const { file, source } = resolveFastctx(ctx, createRequireFrom)
|
|
365
447
|
if (file === '') {
|
|
366
448
|
log('FastCtx payload not found (probed ' + PAYLOAD_PACKAGES.map((p) => p.pkg).join(', ') + '); gb_* tools stay unpublished')
|
|
367
|
-
|
|
368
|
-
withdrawnByOps = true // nothing to publish either way; stop re-deciding
|
|
449
|
+
unavailable = true
|
|
369
450
|
return
|
|
370
451
|
}
|
|
371
452
|
const fresh = new McpStdio({ file, log })
|
|
@@ -373,23 +454,30 @@ export function mountFastctx(ctx, deps) {
|
|
|
373
454
|
let toolList
|
|
374
455
|
try {
|
|
375
456
|
await fresh.start()
|
|
457
|
+
if (!live() || owner !== generation) return
|
|
376
458
|
toolList = await fresh.listTools()
|
|
377
459
|
} catch (error) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
460
|
+
if (live() && owner === generation) {
|
|
461
|
+
log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`)
|
|
462
|
+
unavailable = true
|
|
463
|
+
}
|
|
382
464
|
return
|
|
383
465
|
} finally {
|
|
466
|
+
try { await fresh.close() } catch { /* best effort */ }
|
|
384
467
|
starting.delete(fresh)
|
|
385
468
|
}
|
|
386
|
-
|
|
387
|
-
if (closed || withdrawnByOps) return
|
|
469
|
+
if (!live() || owner !== generation) return
|
|
388
470
|
// Discovery never overwrites the independently owned lazy call client.
|
|
389
471
|
const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name)))
|
|
390
472
|
const registered = []
|
|
391
473
|
const registeredNames = []
|
|
474
|
+
const release = () => {
|
|
475
|
+
for (const dispose of registered) {
|
|
476
|
+
try { dispose() } catch (error) { log(`FastCtx unregister failed: ${error?.message ?? error}`) }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
392
479
|
for (const tool of wanted) {
|
|
480
|
+
if (!live() || owner !== generation) break
|
|
393
481
|
const rawName = String(tool.name)
|
|
394
482
|
const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object', properties: {} }
|
|
395
483
|
// Ignore-rule transparency (v0.38.5, the field smoke report): the three
|
|
@@ -420,6 +508,7 @@ export function mountFastctx(ctx, deps) {
|
|
|
420
508
|
render: (_args, value) => renderTextContent(value),
|
|
421
509
|
},
|
|
422
510
|
async execute(args, execution) {
|
|
511
|
+
assertLive(owner, execution?.agent)
|
|
423
512
|
// Preserve v0.39.1 / #21: glob accepts real string arrays,
|
|
424
513
|
// while stringified arrays and malformed patterns fail loudly.
|
|
425
514
|
if (args !== null && typeof args === 'object' && 'pattern' in args) {
|
|
@@ -427,7 +516,7 @@ export function mountFastctx(ctx, deps) {
|
|
|
427
516
|
if (invalid) throw new Error(invalid)
|
|
428
517
|
}
|
|
429
518
|
const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined)
|
|
430
|
-
const result = await callWithReconnect(rawName, translated)
|
|
519
|
+
const result = await callWithReconnect(rawName, translated, owner, execution?.agent)
|
|
431
520
|
const text = renderFastctxResult(rawName, result, echo ? (value) => echo(value, execution) : undefined)
|
|
432
521
|
// The host treats a returned value as success; MCP failures must
|
|
433
522
|
// throw, just as its official MCP client does.
|
|
@@ -443,52 +532,51 @@ export function mountFastctx(ctx, deps) {
|
|
|
443
532
|
log(`${gbName(rawName)} registration failed: ${error?.message ?? error}`)
|
|
444
533
|
}
|
|
445
534
|
}
|
|
535
|
+
if (!live() || owner !== generation) {
|
|
536
|
+
release()
|
|
537
|
+
return
|
|
538
|
+
}
|
|
446
539
|
if (registered.length === 0) {
|
|
447
540
|
log('FastCtx published none of the known file tools; nothing registered')
|
|
448
|
-
|
|
449
|
-
withdrawnByOps = true
|
|
541
|
+
unavailable = true
|
|
450
542
|
return
|
|
451
543
|
}
|
|
452
|
-
disposer =
|
|
544
|
+
disposer = release
|
|
453
545
|
log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`)
|
|
454
546
|
}
|
|
455
547
|
|
|
456
548
|
const publish = () => {
|
|
457
|
-
if (!publishing
|
|
458
|
-
|
|
549
|
+
if (!publishing || publishing.owner !== generation) {
|
|
550
|
+
const task = { owner: generation }
|
|
551
|
+
task.promise = track(publishOnce(task.owner).finally(() => { if (publishing === task) publishing = undefined }))
|
|
552
|
+
publishing = task
|
|
553
|
+
}
|
|
554
|
+
return publishing.promise
|
|
459
555
|
}
|
|
460
556
|
|
|
461
557
|
const reconcile = (agent) => {
|
|
462
|
-
if (closed) return
|
|
463
|
-
if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return }
|
|
464
|
-
if (decided && withdrawnByOps) return
|
|
465
|
-
let names = []
|
|
466
558
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
withdrawnByOps = true
|
|
472
|
-
decided = true
|
|
473
|
-
unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
|
|
474
|
-
}
|
|
475
|
-
return
|
|
559
|
+
if (!live(agent)) return
|
|
560
|
+
void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
|
|
561
|
+
} catch (error) {
|
|
562
|
+
log(`FastCtx bridge reconcile failed: ${error?.message ?? error}`)
|
|
476
563
|
}
|
|
477
|
-
void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
|
|
478
564
|
}
|
|
479
565
|
|
|
480
|
-
|
|
481
|
-
ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
|
|
482
|
-
} catch (error) {
|
|
483
|
-
log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
|
|
484
|
-
return
|
|
485
|
-
}
|
|
566
|
+
// Install disposal before listeners: partial wiring still owns every child.
|
|
486
567
|
ctx.effect(() => async () => {
|
|
487
568
|
closed = true
|
|
569
|
+
generation++
|
|
570
|
+
agents.clear()
|
|
488
571
|
unpublish()
|
|
489
|
-
|
|
490
|
-
await Promise.allSettled([...
|
|
491
|
-
await Promise.allSettled([publishing, connecting].filter(Boolean))
|
|
492
|
-
client = undefined
|
|
572
|
+
stopConnections()
|
|
573
|
+
await Promise.allSettled([...pending])
|
|
493
574
|
}, 'dsh-gitbash-shell: FastCtx bridge')
|
|
575
|
+
try {
|
|
576
|
+
ctx.on('agent/created', ({ agent } = {}) => { if (!closed && agent) agents.add(agent); reconcile(agent) })
|
|
577
|
+
ctx.on('agent/disposed', ({ agent } = {}) => { agents.delete(agent) })
|
|
578
|
+
ctx.on('loader/volatile-update', () => { reconcile() })
|
|
579
|
+
} catch (error) {
|
|
580
|
+
log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
|
|
581
|
+
}
|
|
494
582
|
}
|
package/src/index.js
CHANGED
|
@@ -766,7 +766,12 @@ function pathEcho(value, env) {
|
|
|
766
766
|
const out = driveToMsys(value)
|
|
767
767
|
if (typeof out !== 'string' || out.length === 0) return out
|
|
768
768
|
const tempRoot = env && env.tmpDir ? driveToMsys(env.tmpDir) : ''
|
|
769
|
-
|
|
769
|
+
// A whole path has exactly one root; an embedded copy in a backup tree is data.
|
|
770
|
+
if (tempRoot && out.slice(0, tempRoot.length).toLowerCase() === tempRoot.toLowerCase()
|
|
771
|
+
&& (out.length === tempRoot.length || out[tempRoot.length] === '/')) {
|
|
772
|
+
return '/tmp' + out.slice(tempRoot.length)
|
|
773
|
+
}
|
|
774
|
+
return out
|
|
770
775
|
}
|
|
771
776
|
|
|
772
777
|
/**
|
|
@@ -1251,6 +1256,12 @@ function regexCanStart(previous) {
|
|
|
1251
1256
|
return previous.length === 1 && '(,=:[!&|?{;+-*%<>~^'.includes(previous)
|
|
1252
1257
|
}
|
|
1253
1258
|
|
|
1259
|
+
/** A newline may end a declaration before a statement regexp; do not guess ASI. */
|
|
1260
|
+
function ambiguousSlash(code, index, previous, previousEnd) {
|
|
1261
|
+
return previous === ')' || previous === '}'
|
|
1262
|
+
|| (!regexCanStart(previous) && /[\r\n\u2028\u2029]/.test(code.slice(previousEnd, index)))
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1254
1265
|
/** Read an identifier as ONE token: keyword-led regexes are not division. */
|
|
1255
1266
|
function codeWordAt(code, index) {
|
|
1256
1267
|
return /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(code.slice(index))?.[0]
|
|
@@ -1263,7 +1274,7 @@ function skipQuotedLiteral(code, index) {
|
|
|
1263
1274
|
while (i < code.length) {
|
|
1264
1275
|
const c = code[i]
|
|
1265
1276
|
if (c === '\\') { i += 2; continue }
|
|
1266
|
-
if (
|
|
1277
|
+
if (/[\r\n\u2028\u2029]/.test(c)) return -1
|
|
1267
1278
|
if (c === quote) return i + 1
|
|
1268
1279
|
i += 1
|
|
1269
1280
|
}
|
|
@@ -1277,7 +1288,7 @@ function skipRegexLiteral(code, index) {
|
|
|
1277
1288
|
while (i < code.length) {
|
|
1278
1289
|
const c = code[i]
|
|
1279
1290
|
if (c === '\\') { i += 2; continue }
|
|
1280
|
-
if (
|
|
1291
|
+
if (/[\r\n\u2028\u2029]/.test(c)) return -1
|
|
1281
1292
|
if (c === '[') inClass = true
|
|
1282
1293
|
else if (c === ']') inClass = false
|
|
1283
1294
|
else if (c === '/' && inClass === false) {
|
|
@@ -1295,11 +1306,13 @@ function skipBracedExpression(code, index) {
|
|
|
1295
1306
|
let depth = 1
|
|
1296
1307
|
let i = index
|
|
1297
1308
|
let last = ''
|
|
1309
|
+
let previousEnd = 0
|
|
1298
1310
|
while (i < code.length) {
|
|
1299
1311
|
const c = code[i]
|
|
1300
1312
|
const next = code[i + 1]
|
|
1301
1313
|
if (c === '/' && next === '/') {
|
|
1302
|
-
const
|
|
1314
|
+
const lineEnd = /[\r\n\u2028\u2029]/.exec(code.slice(i))
|
|
1315
|
+
const nl = lineEnd ? i + lineEnd.index : -1
|
|
1303
1316
|
if (nl === -1) return -1
|
|
1304
1317
|
i = nl
|
|
1305
1318
|
continue
|
|
@@ -1315,6 +1328,7 @@ function skipBracedExpression(code, index) {
|
|
|
1315
1328
|
if (end === -1) return -1
|
|
1316
1329
|
i = end
|
|
1317
1330
|
last = 'x'
|
|
1331
|
+
previousEnd = i
|
|
1318
1332
|
continue
|
|
1319
1333
|
}
|
|
1320
1334
|
if (c === '`') {
|
|
@@ -1322,15 +1336,17 @@ function skipBracedExpression(code, index) {
|
|
|
1322
1336
|
if (inner === null) return -1
|
|
1323
1337
|
i = inner.end
|
|
1324
1338
|
last = 'x'
|
|
1339
|
+
previousEnd = i
|
|
1325
1340
|
continue
|
|
1326
1341
|
}
|
|
1327
1342
|
// A slash after a closing group may start a statement regex OR division.
|
|
1328
1343
|
// Without a parser that distinction is uncertain: abort the WHOLE scan.
|
|
1329
|
-
if (c === '/' && (
|
|
1344
|
+
if (c === '/' && ambiguousSlash(code, i, last, previousEnd)) return -1
|
|
1330
1345
|
const word = codeWordAt(code, i)
|
|
1331
1346
|
if (word) {
|
|
1332
1347
|
last = last === '.' ? 'x' : word
|
|
1333
1348
|
i += word.length
|
|
1349
|
+
previousEnd = i
|
|
1334
1350
|
continue
|
|
1335
1351
|
}
|
|
1336
1352
|
if (c === '/' && regexCanStart(last)) {
|
|
@@ -1338,6 +1354,7 @@ function skipBracedExpression(code, index) {
|
|
|
1338
1354
|
if (end === -1) return -1
|
|
1339
1355
|
i = end
|
|
1340
1356
|
last = 'x'
|
|
1357
|
+
previousEnd = i
|
|
1341
1358
|
continue
|
|
1342
1359
|
}
|
|
1343
1360
|
if (c === '{') depth += 1
|
|
@@ -1345,7 +1362,7 @@ function skipBracedExpression(code, index) {
|
|
|
1345
1362
|
depth -= 1
|
|
1346
1363
|
if (depth === 0) return i + 1
|
|
1347
1364
|
}
|
|
1348
|
-
if (c.trim() !== '') last = c
|
|
1365
|
+
if (c.trim() !== '') { last = c; previousEnd = i + 1 }
|
|
1349
1366
|
i += 1
|
|
1350
1367
|
}
|
|
1351
1368
|
return -1
|
|
@@ -1383,11 +1400,13 @@ export function scanCodeLiterals(code) {
|
|
|
1383
1400
|
const spans = []
|
|
1384
1401
|
let i = 0
|
|
1385
1402
|
let last = ''
|
|
1403
|
+
let previousEnd = 0
|
|
1386
1404
|
while (i < code.length) {
|
|
1387
1405
|
const c = code[i]
|
|
1388
1406
|
const next = code[i + 1]
|
|
1389
1407
|
if (c === '/' && next === '/') {
|
|
1390
|
-
const
|
|
1408
|
+
const lineEnd = /[\r\n\u2028\u2029]/.exec(code.slice(i))
|
|
1409
|
+
const nl = lineEnd ? i + lineEnd.index : -1
|
|
1391
1410
|
// A line comment may legally terminate at EOF (outside interpolation).
|
|
1392
1411
|
if (nl === -1) break
|
|
1393
1412
|
i = nl
|
|
@@ -1405,6 +1424,7 @@ export function scanCodeLiterals(code) {
|
|
|
1405
1424
|
spans.push({ start: i + 1, end: end - 1 })
|
|
1406
1425
|
i = end
|
|
1407
1426
|
last = 'x'
|
|
1427
|
+
previousEnd = i
|
|
1408
1428
|
continue
|
|
1409
1429
|
}
|
|
1410
1430
|
if (c === '`') {
|
|
@@ -1418,15 +1438,17 @@ export function scanCodeLiterals(code) {
|
|
|
1418
1438
|
}
|
|
1419
1439
|
i = template.end
|
|
1420
1440
|
last = 'x'
|
|
1441
|
+
previousEnd = i
|
|
1421
1442
|
continue
|
|
1422
1443
|
}
|
|
1423
1444
|
// A slash after a closing group may start a statement regex OR division.
|
|
1424
1445
|
// Without a parser that distinction is uncertain: abort the WHOLE scan.
|
|
1425
|
-
if (c === '/' && (
|
|
1446
|
+
if (c === '/' && ambiguousSlash(code, i, last, previousEnd)) return null
|
|
1426
1447
|
const word = codeWordAt(code, i)
|
|
1427
1448
|
if (word) {
|
|
1428
1449
|
last = last === '.' ? 'x' : word
|
|
1429
1450
|
i += word.length
|
|
1451
|
+
previousEnd = i
|
|
1430
1452
|
continue
|
|
1431
1453
|
}
|
|
1432
1454
|
if (c === '/' && regexCanStart(last)) {
|
|
@@ -1434,9 +1456,10 @@ export function scanCodeLiterals(code) {
|
|
|
1434
1456
|
if (end === -1) return null
|
|
1435
1457
|
i = end
|
|
1436
1458
|
last = 'x'
|
|
1459
|
+
previousEnd = i
|
|
1437
1460
|
continue
|
|
1438
1461
|
}
|
|
1439
|
-
if (c.trim() !== '') last = c
|
|
1462
|
+
if (c.trim() !== '') { last = c; previousEnd = i + 1 }
|
|
1440
1463
|
i += 1
|
|
1441
1464
|
}
|
|
1442
1465
|
return spans
|
|
@@ -1512,7 +1535,7 @@ export function rewriteCodePaths(code, env) {
|
|
|
1512
1535
|
// path WITHOUT wildcards splits into directory + basename. Runs only for
|
|
1513
1536
|
// the glob tool and only for patterns that start absolute.
|
|
1514
1537
|
|
|
1515
|
-
const GLOB_META = /[*?\[\]]/
|
|
1538
|
+
const GLOB_META = /[*?\[\]{}]/
|
|
1516
1539
|
|
|
1517
1540
|
/** Split an absolute glob pattern into { prefix, rest }; null when relative. */
|
|
1518
1541
|
function splitGlobPrefix(pattern) {
|
package/src/native-executor.js
CHANGED
|
@@ -25,7 +25,8 @@ export function nativePath(input, translateMsysPath, platform = process.platform
|
|
|
25
25
|
object(input, label, new Set(['kind', 'value']))
|
|
26
26
|
if (input.kind !== 'path') throw new TypeError(`${label}: kind must be path`)
|
|
27
27
|
const value = string(input.value, label)
|
|
28
|
-
|
|
28
|
+
// Reject expansion prefixes, not literal characters inside absolute paths.
|
|
29
|
+
if (/^(?:[~$]|%[^%]+%)/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`)
|
|
29
30
|
if (platform !== 'win32') throw new Error('gitbash_native currently supports only the Windows local execution world')
|
|
30
31
|
// A rooted Windows path without a drive is not absolute in this contract.
|
|
31
32
|
const absolute = v => /^[a-z]:[\\/]/i.test(v) || /^\\\\[^\\]+\\[^\\]+/.test(v) || /^\/\/[^/]+\/[^/]+/.test(v)
|
package/src/native-tool.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registerExperimentalTool } from './experimental-tools.js'
|
|
2
2
|
import { createNativeExecutor } from './native-executor.js'
|
|
3
|
-
import { processJob, processSources, ringDelta } from './bash-render.js'
|
|
3
|
+
import { processJob, processSources, ringDelta, renderJobRead } from './bash-render.js'
|
|
4
4
|
|
|
5
5
|
const typedPath = () => ({ type: 'object', additionalProperties: false, properties: {
|
|
6
6
|
kind: { type: 'string', const: 'path', required: true }, value: { type: 'string', required: true },
|
|
@@ -165,8 +165,11 @@ export function createNativeToolModule(deps) {
|
|
|
165
165
|
throw new Error(`native preparation timed out; job ${attached.id} was cancelled, inspect job_output before retrying`)
|
|
166
166
|
}
|
|
167
167
|
const read = jobs.read(attached.id, owner)
|
|
168
|
+
// A ring eviction can report gapBefore even when its pull source
|
|
169
|
+
// was lossless. This consuming read must preserve both loss facts.
|
|
170
|
+
const lostOutput = read.lossy || read.chunks.some(chunk => chunk.gapBefore)
|
|
168
171
|
return { kind: 'promoted', jobId: attached.id, cwd: request.cwd, timeoutMs: request.timeoutMs,
|
|
169
|
-
output: ringDelta(read.chunks), encoding: request.output }
|
|
172
|
+
output: renderJobRead(ringDelta(read.chunks), lostOutput, read.job?.output?.spillPaths), encoding: request.output }
|
|
170
173
|
}
|
|
171
174
|
jobs.remove(attached.id, owner)
|
|
172
175
|
if (!attached.process()) throw new Error(view.detail ?? 'native preparation failed before a process handle was available')
|
package/src/tool-runner.js
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { execFile } from 'node:child_process'
|
|
29
|
-
import { readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
29
|
+
import { readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
30
|
+
import { randomUUID } from 'node:crypto'
|
|
30
31
|
import { tmpdir } from 'node:os'
|
|
31
32
|
import { join } from 'node:path'
|
|
32
33
|
|
|
@@ -68,7 +69,10 @@ export function defaultRunnerIo() {
|
|
|
68
69
|
return String(process.env.PATH || '').split(';').filter(Boolean)
|
|
69
70
|
},
|
|
70
71
|
readdir(dir) {
|
|
71
|
-
return readdirSync(dir)
|
|
72
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
73
|
+
},
|
|
74
|
+
isFile(path) {
|
|
75
|
+
try { return statSync(path).isFile() } catch { return false }
|
|
72
76
|
},
|
|
73
77
|
readFile(path) {
|
|
74
78
|
return readFileSync(path, 'utf8')
|
|
@@ -82,6 +86,27 @@ export function defaultRunnerIo() {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/** Export one authoritative inventory into a per-call owned path. Failure is not absence. */
|
|
90
|
+
async function readWingetInventory(io) {
|
|
91
|
+
const exportPath = io.tmpFile(`dsh-gitbash-shell-wg-export-${randomUUID()}.json`)
|
|
92
|
+
try {
|
|
93
|
+
const result = await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
|
|
94
|
+
if (result.code !== 0) throw new Error(`export exited with code ${result.code}`)
|
|
95
|
+
const text = io.readFile(exportPath)
|
|
96
|
+
const json = JSON.parse(text)
|
|
97
|
+
if (!json || !Array.isArray(json.Sources) || json.Sources.some(source =>
|
|
98
|
+
!source || typeof source.SourceDetails?.Name !== 'string' || !Array.isArray(source.Packages)
|
|
99
|
+
|| source.Packages.some(pkg => !pkg || typeof pkg.PackageIdentifier !== 'string'))) {
|
|
100
|
+
throw new Error('invalid export structure')
|
|
101
|
+
}
|
|
102
|
+
return parseExportJson(text, TOOLCHAIN)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`winget inventory unavailable: ${error?.message ?? error}`)
|
|
105
|
+
} finally {
|
|
106
|
+
io.remove(exportPath)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
/**
|
|
86
111
|
* The whole catalog's state in four winget/PATH reads: one `--version` probe,
|
|
87
112
|
* one `export` inventory, one `--upgrade-available` list, one PATH scan.
|
|
@@ -108,16 +133,7 @@ export async function probeToolchain(io) {
|
|
|
108
133
|
}
|
|
109
134
|
}
|
|
110
135
|
|
|
111
|
-
const
|
|
112
|
-
let owned = new Map()
|
|
113
|
-
try {
|
|
114
|
-
await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
|
|
115
|
-
owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN)
|
|
116
|
-
} catch {
|
|
117
|
-
owned = new Map()
|
|
118
|
-
} finally {
|
|
119
|
-
io.remove(exportPath)
|
|
120
|
-
}
|
|
136
|
+
const owned = await readWingetInventory(io)
|
|
121
137
|
|
|
122
138
|
let upgradable = new Set()
|
|
123
139
|
try {
|
|
@@ -197,11 +213,20 @@ export async function runToolJob(io, request, onChange) {
|
|
|
197
213
|
// the installed package id (GNU vs MSVC ripgrep), never the preferred one.
|
|
198
214
|
let owned = new Map()
|
|
199
215
|
if (entries.length > 0) {
|
|
200
|
-
const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json')
|
|
201
216
|
try {
|
|
202
|
-
await io
|
|
203
|
-
|
|
204
|
-
|
|
217
|
+
owned = await readWingetInventory(io)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// Do not choose a preferred build or start an installer from an unknown inventory.
|
|
220
|
+
job.results = entries.map(tool => ({
|
|
221
|
+
id: tool.id, cmd: tool.cmd, action: mode === 'upgrade' ? 'upgrade' : 'install',
|
|
222
|
+
wingetId: tool.ids[0], admin: tool.admin === true, ok: false, kind: 'failed',
|
|
223
|
+
detail: `${error?.message ?? error}; no install or upgrade was attempted`,
|
|
224
|
+
}))
|
|
225
|
+
job.done = entries.length
|
|
226
|
+
job.finished = true
|
|
227
|
+
if (onChange) onChange(job)
|
|
228
|
+
return job
|
|
229
|
+
}
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
for (const tool of entries) {
|
package/src/toolchain.js
CHANGED
|
@@ -229,7 +229,7 @@ const EXEC_EXTS = new Set(['.exe', '.cmd', '.bat', '.com'])
|
|
|
229
229
|
* Only EXECUTABLE entries resolve (see EXEC_EXTS): a man page (`just.1`) or a
|
|
230
230
|
* document (`jq.md`) shares the stem without being the command, and issue #31
|
|
231
231
|
* measured a man page winning the first-hit race over the `just.exe` beside it.
|
|
232
|
-
* @param {{pathDirs: () => string[], readdir: (dir: string) => string
|
|
232
|
+
* @param {{pathDirs: () => string[], readdir: (dir: string) => Array, isFile?: (path: string) => boolean}} io
|
|
233
233
|
* @param {Array} tools - catalog entries to look for.
|
|
234
234
|
* @returns {Map<string, string>} cmd → the first resolving full path.
|
|
235
235
|
*/
|
|
@@ -247,17 +247,23 @@ export function scanCommands(io, tools) {
|
|
|
247
247
|
try { entries = io.readdir(dir) } catch { continue }
|
|
248
248
|
if (!Array.isArray(entries)) continue
|
|
249
249
|
for (const entry of entries) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// resolving to a command it merely shares a stem with.
|
|
254
|
-
const suffix = /(\.[^./\\]+)$/.exec(entry)
|
|
250
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
251
|
+
if (typeof name !== 'string') continue
|
|
252
|
+
const suffix = /(\.[^./\\]+)$/.exec(name)
|
|
255
253
|
if (suffix !== null && !EXEC_EXTS.has(suffix[1].toLowerCase())) continue
|
|
256
|
-
const base = (suffix === null ?
|
|
254
|
+
const base = (suffix === null ? name : name.slice(0, suffix.index)).toLowerCase()
|
|
257
255
|
const cmd = wanted.get(base)
|
|
258
|
-
if (cmd
|
|
259
|
-
|
|
260
|
-
|
|
256
|
+
if (cmd === undefined || found.has(cmd)) continue
|
|
257
|
+
const fullPath = dir.replace(/[\\/]+$/, '') + '\\' + name
|
|
258
|
+
try {
|
|
259
|
+
// Production IO supplies Dirents. Follow only symlink candidates to a file;
|
|
260
|
+
// directories (even make / jq.exe) and broken links never satisfy a command.
|
|
261
|
+
// String-only injected IO remains supported for legacy deterministic fixtures.
|
|
262
|
+
const file = typeof entry === 'string'
|
|
263
|
+
? (typeof io.isFile !== 'function' || io.isFile(fullPath))
|
|
264
|
+
: (entry.isFile?.() === true || (entry.isSymbolicLink?.() === true && io.isFile?.(fullPath) === true))
|
|
265
|
+
if (file) found.set(cmd, fullPath)
|
|
266
|
+
} catch { /* an unreadable or disappearing candidate is not a command */ }
|
|
261
267
|
}
|
|
262
268
|
}
|
|
263
269
|
return found
|