From 59d29dc13b87fabf8ebafa1cc6d3178d9b887d8b Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Fri, 23 Oct 2026 05:11:16 +0800 Subject: [PATCH 1/2] fix: strict review R01-R10 hardening for path scan, native promotion and fastctx bridge - R01/R02: path literal scanner no-op guard, TEMP root whole-value mapping - R03: keep brace alternatives in absolute glob patterns - R04: native promotion propagates lossy and gapBefore with spill path - R05: winget inventory uses per-export UUID paths and verifies structure - R06: fastctx bridge separates user switch, peer yield and payload states - R07: settings card resets clean draft on scope/ready changes - R08: default directory IO uses Dirent and excludes same-name dirs - R09/R10: background outcome null preservation, typed native path percent signs Tests: review-core-regressions, review-native-regressions, review-native-preview added. --- CHANGELOG.md | 100 +++++++++++++ package.json | 4 +- src/bash-render.js | 2 +- src/bash-tool.js | 2 +- src/client.js | 46 +++--- src/fastctx-bridge.js | 178 ++++++++++++++++------- src/index.js | 43 ++++-- src/native-executor.js | 3 +- src/native-tool.js | 7 +- src/tool-runner.js | 57 +++++--- src/toolchain.js | 26 ++-- tests/bridge-regressions.mjs | 179 ++++++++++++++++++++++- tests/client-regressions.mjs | 175 ++++++++++++++++++++++ tests/review-core-regressions.mjs | 215 ++++++++++++++++++++++++++++ tests/review-native-preview.mjs | 135 +++++++++++++++++ tests/review-native-regressions.mjs | 180 +++++++++++++++++++++++ 16 files changed, 1229 insertions(+), 123 deletions(-) create mode 100644 tests/review-core-regressions.mjs create mode 100644 tests/review-native-preview.mjs create mode 100644 tests/review-native-regressions.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 765daf7..04762d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,106 @@ - **dsh-ops 并存时的 shell 工具剔除(用户决策定案)**:安装了本插件 = bash 用本插件方案;`ops_bash` / `ops_run` 在本插件 bash 工具在场的 agent 上默认 deny(`tools.restrict` + `tools/pre-execute` 双层),不做设置开关;ops 的 file / 后台 / PTC 增强不受影响。 - 验证:`npm test` 263 项全绿(新增 tests/terminal-bundle.mjs 26 项:plan 矩阵 12 + writer 12 + report + detect);守卫测试演进 2 处(compatibility assemble 段参数面补 `terminalDialect`;smoke 侧栏守卫按上述范围收窄)。 +## Unreleased — 2026-10-18 + +### 严格 review 的 R01–R10 已修复(fix / test,未发布) + +- **R01**:[路径字面量扫描](src/index.js) 为换行后的含混 slash 添加整份 no-op 守卫,主扫描与模板插值共用;覆盖 LF / CRLF / Unicode 行分隔符、跨行注释、let/var、带标签 break/continue 与模板内声明,保留同一行除法和明确正则的原行为。仍是保守扫描器,不宣称成为完整 JS parser,也不改变已知的路径状业务字符串启发式边界。 +- **R02 / R03**:[whole-value TEMP 回显与绝对 glob 拆分](src/index.js) 分别改为仅匹配真实 TEMP 根前缀,以及把 brace alternatives 留在 pattern 中。补备份路径/UNC/相对值/大小写/目录段边界与 brace 目录回归;没有改宿主搜索工具的根锚定规则。 +- **R04**:[native promotion](src/native-tool.js) 复用 `renderJobRead`,消费 read 时同时传播 `lossy` 与 `chunks[].gapBefore`,附带可用 spill 路径;没有恢复路径时明确显示 unavailable。合并验收时也修正了 [Bash promotion](src/bash-tool.js) 同一类只检查 lossy 的遗漏,补有缺口/无缺口负控。保留原输出片段、消费次数、闭合 DTO、producer 结局与权限/取消契约,不重跑命令。 +- **R05**:[winget inventory](src/tool-runner.js) 每次导出使用 UUID 独立路径,仅清理本次路径;检查 export 退出码、JSON 和清单结构。状态探测失败直接报错,不伪造 missing;安装/升级任务在无法建立可信 inventory 时结成可见失败,**不启动任何安装/升级**。并发状态探测 + auto 升级回归保留已安装的 GNU 构建,不改装首选 MSVC;合法空清单的正常安装路径仍通过。所有 winget 回归均为受控 IO,无真实软件安装。 +- **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_* 让位不变。 +- **R07**:[设置卡](src/client.js) 在 scope / ready 状态 / 镜像值变化时初始化干净草稿,保留用户 dirty 编辑;用 scope + 不透明 draft owner 绑定保存状态,迟到的保存回包不能误标新 scope、继续编辑或 scope 往返后的新草稿。拒绝保存仍不显示成功,21 语言词典和宿主父层订阅方式不变。 +- **R08**:[默认目录 IO](src/tool-runner.js) 使用 Dirent,[命令扫描](src/toolchain.js) 排除同名目录,并仅对候选符号链接检查文件目标;保留真实无扩展名文件、有效文件链接、扩展名白名单和首个 PATH 命中顺序。字符串式注入 IO 兼容旧确定性夹具,生产 IO 始终提供文件类型。 +- **R09 / R10**:[后台结局](src/bash-render.js) 对 null 保留 null、缺字段显示 unknown,真实 0/非零/kill 原样;[typed native path](src/native-executor.js) 仅拒绝展开前缀,绝对路径内部百分号、美元符号和波浪号保持数据语义,不新增变量展开。 +- **先红后绿**:首批核心回归旧代码 11 fail、native 普通回归 19 fail、设置卡/桥回归 9 fail;随后追加的 scope 迟到保存与 Bash source-gap 回归也在修复前各捕获 1 fail。真实 Core native 新 preview 旧代码 1 pass / 2 fail(大输出缺警示、百分号路径误拒),修复后同组全部通过。没有降低已有断言或禁用检查。 +- **最终验证**: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` 通过。 +- **实际 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 验收。 +- **边界**:未操作生产 GUI、未安装/部署、未改生产 profile、未重启宿主;真实受限 token/ACL/IPC 围栏、每个旧 Core、非 CRT 任意解释器、真实磁盘耗尽与跨任务 quota 仍未覆盖。没有提交、推送、版本号变更或发布。下面保留修复前的 review 快照供追溯。 + +### 全仓严格 review:9 项 P2、1 项 P3(test / docs,修复前快照) + +审查快照为 [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 引入;以下未执行修复。 + +#### R01 · P2:路径扫描器将 ASI 后的正则内容改成 Windows 路径 + +- 定位:[scanCodeLiterals](src/index.js#L1393-L1424)。Windows 方言与 `codePaths` 开启时,合法程序 `let x\n/['/c/foo']/.test('C') && (x=true); return x ?? false` 中的 regexp 起始 `/` 被误当除法,其字符类里的引号又被当作 JS 字符串。 +- 实际改写为 `/['C:/foo']/`;分别用 `new Function` 执行,原程序返回 `false`,改写程序返回 `true`。这不是已知的“路径状业务字符串启发式”边界,而是正则被错误识别为字符串,违反“正则不改/不确定整份 no-op”的约束。 +- 建议:补声明/ASI 与 regexp 的判定;不能确定时整份跳过。回归应覆盖 let/var 声明后的正则与原有除法负控,不以仅锁定这个样例替代词法验证。 + +#### R02 · P2:TEMP 回显会改写非 TEMP 路径的中间子串 + +- 定位:[pathEcho](src/index.js#L756-L760) 将 whole-value 路径交给 [mountTempRoot](src/index.js#L709-L714) 全串替换,仅检查匹配后侧边界,不要求真实根前缀。 +- `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 链接可能指向另一位置。 +- 建议:whole-value 只做从开头开始且有段边界的 TEMP 根映射;散文内嵌诊断另保留有边界的全文逻辑。覆盖备份目录、UNC 与相对结果路径的负控。 + +#### R03 · P2:绝对 glob 的目录 brace alternatives 被当成实体目录 + +- 定位:[GLOB_META 与 splitGlobPrefix](src/index.js#L1506-L1522) 没将 `{` 识别为 glob 元字符。`/c/project/{src,test}/*.js` 被拆为 `path='C:/project/{src,test}'`、`pattern='*.js'`,表达式错误进入文件系统根路径。 +- 唯一临时目录内创建两目录/两文件,同一真实 ripgrep 从 fixture cwd 以 `{foo,bar}/*.js` 搜索返回 2 项;用插件拆分产物执行则 exit 2、根目录不存在。这里确认的是 brace 被移入 path,不把 Core 绝对搜索根与带斜杠 pattern 自身的锚定语义混为同一个问题。 +- 建议:仅提取确定的字面目录前缀,完整识别支持的 glob 目录元字符,并对照真实搜索工具补 brace 目录回归。 + +#### R04 · P2:native 超时提升消费输出后丢掉缺口事实 + +- 定位:[native promotion](src/native-tool.js#L167-L169) 仅保留 `ringDelta(read.chunks)`,既不保留 `read.lossy`,也不检查 `chunks[].gapBefore` 或呈现可用的完整输出路径。[promoted renderer](src/native-tool.js#L96-L99) 也没有对应警示。 +- **真实 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 显式取消并等待结束。 +- 建议:在消费 read 的同一位置综合 ring eviction 与 source gap,并携带可用恢复路径或闭合 DTO 元数据;补真实 source-gap 与 ring-loss 两种提升回归。最终输出事实可能另外说明 loss,不能替代首次提升时的即时警示。 + +#### R05 · P2:并发 winget 探测与安装共用导出文件,可能装错构建 + +- 定位:[状态探测的临时清单](src/tool-runner.js#L111-L119) 与 [安装任务的临时清单](src/tool-runner.js#L198-L204) 均使用固定文件名,读取后各自删除,且导出失败后把清单缺失当作空清单继续动作。 +- 受控异步 IO 调度中,两次 export 都已写完,状态请求先读并删除,安装任务后取结果时读到 ENOENT;原来 `BurntSushi.ripgrep.GNU` 已安装且可升级,`auto` 任务却生成 `install --id BurntSushi.ripgrep.MSVC`,而不是升级已安装 GNU 构建。正常的另一设置页刷新即可与任务重叠;单个 activeJob 锁不覆盖状态请求。 +- 建议:每次导出使用自有唯一临时目录/文件并仅清理自己;检查 export 结局,无法建立可信 inventory 时不应静默把升级意图变成安装。证据为受控 IO 时序与实际 runner 决策,无真实 winget 安装。 + +#### R06 · P2:FastCtx 的 volatile 开关不能双向生效 + +- 定位:[FastCtx reconcile](src/fastctx-bridge.js#L461-L477) 仅在 agent/created 时读取开关;OFF 还将 `withdrawnByOps` 永久设为 true。[调用路径](src/fastctx-bridge.js#L333-L357) 不重验 enabled。 +- 受控 transport 复现:ON 时发布 `gb_replace` → 将权威开关置 OFF,现有工具仍在且旧定义仍成功 dispatch → 下一次创建 agent 才撤下 → 再置 ON 并创建 agent,工具仍不回来。默认 OFF 已被观察过的进程也无法仅靠设置卡重新开启。 +- 建议:把用户 OFF 与 peer 让位分成不同状态;监听开关并重新协调,同时阻止 OFF 后未启动的新调用。不要将此项描述成提供了 FastCtx OS confinement;它只涉及所承诺的开关行为。 + +#### R07 · P2:设置卡首次 loading→ready 竞态可清空有效 bashPath + +- 定位:[bashDraft 初始化 effect](src/client.js#L2077-L2082) 使用空依赖数组;[保存](src/client.js#L2308-L2310) 写入当前草稿,而不是权威配置。 +- 当插件 inventory 先到、own ConfigForm 仍 loading 时卡片首次挂载,初始化跳过;设置镜像随后 ready、宿主父组件正常重渲染也不会重跑 effect。原 Client 函数配合受控 Hook 渲染复现:权威值为 `Q:/Git/bin/bash.exe`,输入框仍空,用户未编辑直接保存会写 `bashPath:''`,意外切回自动探测。 +- 已核 Core 0.2.1-rc.2 的真实父层契约:PluginManagerPage 的 loaded 只等待插件 inventory,PackageDetail 对 configured slot 的挂载不等待 ConfigForm ready,父页订阅 settings namespace 后会正常重渲染。因此不是“自身未订阅导致整个 GUI 永不更新”;后者是已排除的初始误判。本项证据为真实契约 + 原函数受控渲染,未做生产 GUI 延迟注入。 +- 建议:首次 ready 后初始化草稿,并用 dirty 状态防止后续镜像刷新覆盖用户正在编辑的值。 + +#### R08 · P2:PATH 同名目录误报为命令(上一轮已知遗留) + +- 定位:[scanCommands](src/toolchain.js#L236-L263) 与 [默认 readdir IO](src/tool-runner.js#L67-L72)。真实临时 PATH 只有 `make` 目录时仍判定命令存在,winget 可用且清单为空时得到 `external` / `actionable:false`,安装入口被错误关闭。 +- v0.40.4 已明确保留目录类型区分为后续项,本轮不冒充新增。应补类型验证且保留真实无扩展名文件/可执行文件符号链接。 + +#### R09 · P2:未知后台退出码被报告为 0(上一轮已确认) + +- 定位:[processOutcome](src/bash-render.js#L163-L168)。真实 Cordis 挂载自有 bash 模块,以受控已结束句柄 `exitCode:null` 执行后台路径,detail 被 `?? 0` 写成 `exit code: 0`;同一结果的前台仍为 `[exit code: null]`。 +- Core 0.2.1-rc.2 官方 tool-bash 同样含此表达式,属于镜像继承问题,不是本次 UI 更新新引入。应保留未知值,而非将 Job 的 completed 词汇与命令成功混同;本项未用真实 OS 子进程制造缺失退出码结局。 + +#### R10 · P3:typed absolute path 误拒合法百分号文件名 + +- 定位:[nativePath](src/native-executor.js#L24-L34) 的 `%[^%]+%` 无位置限制,在确认原生绝对路径前就拒绝字面百分号片段。 +- 唯一临时目录内 `%fixed%.txt` 为真实文件,普通 Node fs 能读回 sentinel,Windows absolute 判定为真;相同值传 kind:path 仍报“需要 absolute path / 不支持变量展开”,literal argv 则接受。若 executable 或 cwd 目录包含同类合法名字,没有改用 literal 的入口。 +- 建议:拒绝真正依赖展开的相对/前缀变量输入;已经是绝对路径中的 `%` 应保留为数据。发生频率低于上述问题,按 P3 排序;不涉及越权。 + +#### 验证、反证与范围边界 + +- **便携全量回归**:Windows / Node 24.13.0,重新执行 `npm test`,**237/237**,0 fail / skip / cancel。语法与发布清单检查沿用同一产品快照上一轮通过的 `node --check` / `npm pack --dry-run --ignore-scripts`。 +- **完整原版 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 所有权。复制件是当前安装代码的隔离验证,不等于更新了正在运行的产品。 +- **补充审查**:两项独立专项回归分别 62/62 与 49/49,与全量重叠,不累加成额外通过数;96 次真实 dispatch listener 门控矩阵通过。上述确认问题的临时复现由主审逐项复跑;R04 另有真实 Core + 真 producer 补证,其余按各条注明的受控层级解释。 +- **失败尝试保留**:Electron Node 24.21 的原 loader 重入递归属于该启动路线的测试装置问题,不据此报产品失效;临时 ASAR 复制最初遇到目录 cp 不支持与嵌套 yaml 副本,改逐文件复制且保留嵌套布局;首完整预览 85/86 唯一失败是复制件缺少只用于打印 Core 版本的 manifest,补原始 manifest 后完整重跑通过。没有把这些失败尝试计入成功次数。 +- **已排除**:自身 ConfigForm 未 subscribe 并不导致当前宿主设置卡永不刷新(父组件订阅);终端未清理 retry timer 的一次迟到回调,在真实 Cordis dispose 后被 inactive-context 检查拒绝,受控 editor **0 次写入**,不报告“卸载后覆盖配置”。 +- **未确认候选**:percent-encoded 非 UUID sessionId 二次编码的正常可达性;旧 Core minimal compaction 探针区分度;status 路由与外层认证的远程部署组合;`virtualMounts` OFF 的 NUL 设备特例。没有证据前不归入上述 10 项。 +- **明确不覆盖**:生产 GUI 延迟/视觉验收、真实 LLM 自主选路、真实受限 token/ACL/IPC 围栏正负验收、每个旧 Core 版本实机、非 CRT 任意解释器 quoting、真实磁盘耗尽/RSS/跨任务 quota。已有默认 fail-closed、用户显式关闭后的 legacy UNCONFINED、codePaths 路径状业务字符串启发式与动态拼接边界均按既定产品约束评价,未冒充新增漏洞。 +- 本轮只更新审查记录;未改产品源码、未提交/推送、未安装/部署、未改生产 profile、未重启宿主。 + +### 同步 v0.40.5 后的功能审查(test / docs,未修复) + +- 审查基线:[v0.40.5(6c5f1cd)](https://github.com/KannaKuron/dsh-gitbash-shell/commit/6c5f1cda5828ee5cbd5dba1aa704496f69e34412)。相对本地原基线快进 8 个提交,无合并冲突;本条只记录检查,未修改产品源码。 +- **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 记载的目录类型区分遗留项相同,不是本次同步新引入。需后续补文件类型判定,同时保留无扩展名真实可执行文件与可执行文件符号链接。 +- **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 子进程制造无退出码结局。 +- **验证:** 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 安装或真实异常进程。 +- **实际 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` 全套通过。 +- **未覆盖:** 完整 native/path/输出 preview、生产 GUI、模式选择器与 PTY、真实 LLM 自主选路、真实受限 OS 围栏。未安装或部署插件,未改生产配置,未重启宿主。 + ## v0.40.5 — 2026-10-18 ### 工具行字号跟随宿主字体设置;半像素字号归整(fix) diff --git a/package.json b/package.json index 4ba8ddd..a605dfc 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "icon.svg" ], "scripts": { - "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", - "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", + "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", + "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", "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" }, "keywords": [ diff --git a/src/bash-render.js b/src/bash-render.js index bae30f5..00ca2f2 100644 --- a/src/bash-render.js +++ b/src/bash-render.js @@ -163,7 +163,7 @@ function sandboxNotes(sandbox, escalationModes) { export function processOutcome(proc, escalationModes = []) { const base = proc.status === 'killed' ? { status: 'killed', detail: proc.signal !== null && proc.signal !== undefined ? `signal: ${proc.signal}` : 'killed before exit' } - : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + : { status: 'completed', detail: `exit code: ${proc.exitCode === undefined ? 'unknown' : proc.exitCode}` } const notes = sandboxNotes(proc.sandbox, escalationModes) return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` } } diff --git a/src/bash-tool.js b/src/bash-tool.js index e68b0be..e7e1ccb 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -455,7 +455,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm timeoutMs, // Preserve ring/spill notices; sandbox notes belong to the final renderer. output: renderJobRead( - ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], undefined, escalationModes, + ringDelta(read.chunks), read.lossy || read.chunks.some(chunk => chunk.gapBefore), read.job.output.spillPaths ?? [], undefined, escalationModes, ), ...(sandbox === undefined ? {} : { sandbox }), } diff --git a/src/client.js b/src/client.js index a38b410..9af9c6c 100644 --- a/src/client.js +++ b/src/client.js @@ -2068,18 +2068,33 @@ window.__ModuleLoader__.load({ } catch (error_) { /* keep the switch usable */ } }, []); - var bashState = useState(""); - var bashDraft = bashState[0]; - var setBashDraft = bashState[1]; - var bashSavedState = useState(false); - var bashSaved = bashSavedState[0]; - var setBashSaved = bashSavedState[1]; - useEffect(function () { + var scope = props.scope; + var snap = { status: "unavailable" }; try { - var s0 = scope && typeof scope.getSnapshot === "function" ? scope.getSnapshot() : null; - if (s0 && s0.value && typeof s0.value.bashPath === "string") setBashDraft(s0.value.bashPath) - } catch (e) { /* keep empty draft */ } - }, []); + if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot(); + } catch (error_) { /* keep unavailable */ } + + var bashState = useState({ scope: scope, value: "", dirty: false, owner: {} }); + var bashDraft = bashState[0].scope === scope ? bashState[0].value : ""; + var setBashDraft = bashState[1]; + var bashSaved = bashState[0].scope === scope && bashState[0].saved === true; + function setBashSaved(saved) { + setBashDraft(function (previous) { + // A late save only belongs to the exact draft that submitted it. + if (previous.scope !== scope || previous.owner !== bashState[0].owner || previous.saved === saved) return previous; + return Object.assign({}, previous, { saved: saved === true }); + }); + } + var mirroredBashPath = snap.value && typeof snap.value.bashPath === "string" ? snap.value.bashPath : ""; + useEffect(function () { + // Core's parent rerenders on mirror arrival. Hydrate once ready (or + // for a new scope), but never replace this scope's local edits. + setBashDraft(function (previous) { + if (previous.scope !== scope) return { scope: scope, value: snap.status === "ready" ? mirroredBashPath : "", dirty: false, owner: {} }; + if (snap.status !== "ready" || previous.dirty || previous.value === mirroredBashPath) return previous; + return { scope: scope, value: mirroredBashPath, dirty: false, owner: {} }; + }); + }, [scope, snap.status, mirroredBashPath]); /* v0.38.0: the card's sections are FOLDERS. All seven stay collapsed by default — the user scans seven labelled header rows, expands the @@ -2098,13 +2113,6 @@ window.__ModuleLoader__.load({ }); } - var scope = props.scope; - - var snap = { status: "unavailable" }; - try { - if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot(); - } catch (error_) { /* keep unavailable */ } - var pageView = props.view === "page"; // Hooks run for every snapshot state; readiness only gates their work. @@ -2301,7 +2309,7 @@ window.__ModuleLoader__.load({ E("span", { className: "gb-rowLabel" }, t("bash.label") + ":"), ), E("div", { className: "gb-seg" }, - 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); setBashSaved(false); } }), + 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: {} }); } }), E("button", { type: "button", className: "gb-saveBtn" + (bashSaved ? " gb-saved" : ""), diff --git a/src/fastctx-bridge.js b/src/fastctx-bridge.js index 213d3e6..4938f94 100644 --- a/src/fastctx-bridge.js +++ b/src/fastctx-bridge.js @@ -117,6 +117,8 @@ export class McpStdio { /** Spawn the server and run the MCP initialize handshake. */ async start() { const { spawn } = await import('node:child_process') + // close() can run while the dynamic import is pending; never resurrect it. + if (this.dead) throw new Error('the FastCtx bridge was stopped') this.child = spawn(this.file, this.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) this.child.stdout.setEncoding('utf8') this.child.stderr.setEncoding('utf8') @@ -323,43 +325,102 @@ const FASTCTX_SANDBOX_NOTE = 'FastCtx reads the real filesystem with no workspac * - echo(text) -> result dialect echo, or undefined (off) * - log(message) -> the plugin's tagged logger * - * The surface publishes at the FIRST agent creation and re-decides on every - * later one: dsh-ops appearing (ops_* tools in the face) withdraws gb_* for - * the rest of the process — duplicates would only teach the model two names - * for one job. + * The surface publishes at the first agent creation or live switch update. + * User OFF/ON is reversible; dsh-ops appearing (ops_* tools in the face) + * independently withdraws gb_* for the rest of the process. Every call also + * checks the live switch and its publication generation, including old SDKs. */ export function mountFastctx(ctx, deps) { const { platform, enabled, createRequireFrom, tools, msysToWin, echo, log } = deps if (platform !== 'win32') return let disposer = undefined let client = undefined - let decided = false + let unavailable = false let withdrawnByOps = false + let switchOn + let generation = 0 let closed = false let publishing let connecting const starting = new Set() + const pending = new Set() + const agents = new Set() + + const track = (promise) => { + pending.add(promise) + promise.then(() => pending.delete(promise), () => pending.delete(promise)) + return promise + } const unpublish = (reason) => { if (disposer) { - try { disposer() } catch { /* already gone */ } + const dispose = disposer disposer = undefined + try { dispose() } catch { /* already gone */ } } if (reason) log(reason) } - const callWithReconnect = async (rawName, args) => { - if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped') + const stopConnections = () => { + const owned = new Set([...starting, client].filter(Boolean)) + client = undefined + // Calling close immediately rejects pending requests and kills the child; + // track completion as well so disposal owns even superseded generations. + track(Promise.allSettled([...owned].map((connection) => connection.close()))) + } + + const live = (agent) => { + if (closed) return false + const next = !!enabled() + if (switchOn !== next) { + switchOn = next + generation++ + unavailable = false + if (!next) { + unpublish('FastCtx bridge disabled by the fastctxTools switch') + stopConnections() + } + } + if (!withdrawnByOps) { + const registry = tools(ctx) + const faces = new Set(agent ? [agent, ...agents] : agents.size ? agents : [undefined]) + for (const face of faces) { + const names = (registry?.schemas?.(face) ?? []).map((schema) => schema?.name) + // Any ops_* name means dsh-ops owns the file surface. Matching the + // prefix (rather than only the listed markers) covers the 0.4.0 + // progressive residents AND the names the marker list does not carry + // yet (ops_batch / ops_glob / ops_replace / ops_run): a later caller + // can hand the face a name that was never resident in face 1. + if (names.some((name) => typeof name === 'string' + && (name.startsWith('ops_') || OPS_PRESENCE_MARKERS.includes(name)))) { + withdrawnByOps = true + generation++ + unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down') + stopConnections() + break + } + } + } + return switchOn && !withdrawnByOps + } + + const assertLive = (owner, agent) => { + if (!live(agent) || owner !== generation || !disposer) throw new Error('the FastCtx bridge was stopped') + } + + const callWithReconnect = async (rawName, args, owner, agent) => { + assertLive(owner, agent) if (client === undefined || client.isDead) { - if (!connecting) { - connecting = (async () => { + if (!connecting || connecting.owner !== owner) { + const task = { owner } + task.promise = track((async () => { const { file } = resolveFastctx(ctx, createRequireFrom) if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry') const fresh = new McpStdio({ file, log }) starting.add(fresh) try { await fresh.start() - if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped') + assertLive(owner, agent) client = fresh } catch (error) { await fresh.close() @@ -367,23 +428,25 @@ export function mountFastctx(ctx, deps) { } finally { starting.delete(fresh) } - })().finally(() => { connecting = undefined }) + })().finally(() => { if (connecting === task) connecting = undefined })) + connecting = task } - await connecting + await connecting.promise } - if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped') + assertLive(owner, agent) + // Reconnect only BEFORE dispatch. Never replay a failed tools/call: it may + // already have written files even when its response is an ordinary error. return client.callTool(rawName, args) } - const publishOnce = async () => { - if (closed || disposer || decided && withdrawnByOps) return + const publishOnce = async (owner) => { + if (!live() || owner !== generation || disposer || unavailable) return const registry = tools(ctx) if (!registry || typeof registry.register !== 'function') return const { file, source } = resolveFastctx(ctx, createRequireFrom) if (file === '') { log('FastCtx payload not found (probed ' + PAYLOAD_PACKAGES.map((p) => p.pkg).join(', ') + '); gb_* tools stay unpublished') - decided = true - withdrawnByOps = true // nothing to publish either way; stop re-deciding + unavailable = true return } const fresh = new McpStdio({ file, log }) @@ -391,23 +454,30 @@ export function mountFastctx(ctx, deps) { let toolList try { await fresh.start() + if (!live() || owner !== generation) return toolList = await fresh.listTools() } catch (error) { - try { await fresh.close() } catch { /* best effort */ } - log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`) - decided = true - withdrawnByOps = true + if (live() && owner === generation) { + log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`) + unavailable = true + } return } finally { + try { await fresh.close() } catch { /* best effort */ } starting.delete(fresh) } - await fresh.close() - if (closed || withdrawnByOps) return + if (!live() || owner !== generation) return // Discovery never overwrites the independently owned lazy call client. const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name))) const registered = [] const registeredNames = [] + const release = () => { + for (const dispose of registered) { + try { dispose() } catch (error) { log(`FastCtx unregister failed: ${error?.message ?? error}`) } + } + } for (const tool of wanted) { + if (!live() || owner !== generation) break const rawName = String(tool.name) const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object', properties: {} } // Ignore-rule transparency (v0.38.5, the field smoke report): the three @@ -438,6 +508,7 @@ export function mountFastctx(ctx, deps) { render: (_args, value) => renderTextContent(value), }, async execute(args, execution) { + assertLive(owner, execution?.agent) // Preserve v0.39.1 / #21: glob accepts real string arrays, // while stringified arrays and malformed patterns fail loudly. if (args !== null && typeof args === 'object' && 'pattern' in args) { @@ -445,7 +516,7 @@ export function mountFastctx(ctx, deps) { if (invalid) throw new Error(invalid) } const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined) - const result = await callWithReconnect(rawName, translated) + const result = await callWithReconnect(rawName, translated, owner, execution?.agent) const text = renderFastctxResult(rawName, result, echo ? (value) => echo(value, execution) : undefined) // The host treats a returned value as success; MCP failures must // throw, just as its official MCP client does. @@ -461,52 +532,51 @@ export function mountFastctx(ctx, deps) { log(`${gbName(rawName)} registration failed: ${error?.message ?? error}`) } } + if (!live() || owner !== generation) { + release() + return + } if (registered.length === 0) { log('FastCtx published none of the known file tools; nothing registered') - decided = true - withdrawnByOps = true + unavailable = true return } - disposer = () => { for (const dispose of registered) dispose() } + disposer = release log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`) } const publish = () => { - if (!publishing) publishing = publishOnce().finally(() => { publishing = undefined }) - return publishing + if (!publishing || publishing.owner !== generation) { + const task = { owner: generation } + task.promise = track(publishOnce(task.owner).finally(() => { if (publishing === task) publishing = undefined })) + publishing = task + } + return publishing.promise } const reconcile = (agent) => { - if (closed) return - if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return } - if (decided && withdrawnByOps) return - let names = [] try { - names = (tools(ctx)?.schemas?.(agent) ?? []).map((schema) => schema.name).filter((name) => typeof name === 'string') - } catch { /* a broken face reads as empty */ } - if (names.some((name) => OPS_PRESENCE_MARKERS.includes(name))) { - if (!withdrawnByOps) { - withdrawnByOps = true - decided = true - unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down') - } - return + if (!live(agent)) return + void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`)) + } catch (error) { + log(`FastCtx bridge reconcile failed: ${error?.message ?? error}`) } - void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`)) } - try { - ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } }) - } catch (error) { - log(`FastCtx bridge wiring failed: ${error?.message ?? error}`) - return - } + // Install disposal before listeners: partial wiring still owns every child. ctx.effect(() => async () => { closed = true + generation++ + agents.clear() unpublish() - const owned = new Set([...starting, client].filter(Boolean)) - await Promise.allSettled([...owned].map((connection) => connection.close())) - await Promise.allSettled([publishing, connecting].filter(Boolean)) - client = undefined + stopConnections() + await Promise.allSettled([...pending]) }, 'dsh-gitbash-shell: FastCtx bridge') + try { + ctx.on('agent/created', ({ agent } = {}) => { if (!closed && agent) agents.add(agent); reconcile(agent) }) + ctx.on('agent/disposed', ({ agent } = {}) => { agents.delete(agent) }) + ctx.on('loader/volatile-update', () => { reconcile() }) + } catch (error) { + log(`FastCtx bridge wiring failed: ${error?.message ?? error}`) + } } diff --git a/src/index.js b/src/index.js index ebc4a10..d9df314 100644 --- a/src/index.js +++ b/src/index.js @@ -766,7 +766,12 @@ function pathEcho(value, env) { const out = driveToMsys(value) if (typeof out !== 'string' || out.length === 0) return out const tempRoot = env && env.tmpDir ? driveToMsys(env.tmpDir) : '' - return tempRoot ? mountTempRoot(out, tempRoot) : out + // A whole path has exactly one root; an embedded copy in a backup tree is data. + if (tempRoot && out.slice(0, tempRoot.length).toLowerCase() === tempRoot.toLowerCase() + && (out.length === tempRoot.length || out[tempRoot.length] === '/')) { + return '/tmp' + out.slice(tempRoot.length) + } + return out } /** @@ -1251,6 +1256,12 @@ function regexCanStart(previous) { return previous.length === 1 && '(,=:[!&|?{;+-*%<>~^'.includes(previous) } +/** A newline may end a declaration before a statement regexp; do not guess ASI. */ +function ambiguousSlash(code, index, previous, previousEnd) { + return previous === ')' || previous === '}' + || (!regexCanStart(previous) && /[\r\n\u2028\u2029]/.test(code.slice(previousEnd, index))) +} + /** Read an identifier as ONE token: keyword-led regexes are not division. */ function codeWordAt(code, index) { return /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(code.slice(index))?.[0] @@ -1263,7 +1274,7 @@ function skipQuotedLiteral(code, index) { while (i < code.length) { const c = code[i] if (c === '\\') { i += 2; continue } - if (c === '\n') return -1 + if (/[\r\n\u2028\u2029]/.test(c)) return -1 if (c === quote) return i + 1 i += 1 } @@ -1277,7 +1288,7 @@ function skipRegexLiteral(code, index) { while (i < code.length) { const c = code[i] if (c === '\\') { i += 2; continue } - if (c === '\n') return -1 + if (/[\r\n\u2028\u2029]/.test(c)) return -1 if (c === '[') inClass = true else if (c === ']') inClass = false else if (c === '/' && inClass === false) { @@ -1295,11 +1306,13 @@ function skipBracedExpression(code, index) { let depth = 1 let i = index let last = '' + let previousEnd = 0 while (i < code.length) { const c = code[i] const next = code[i + 1] if (c === '/' && next === '/') { - const nl = code.indexOf('\n', i) + const lineEnd = /[\r\n\u2028\u2029]/.exec(code.slice(i)) + const nl = lineEnd ? i + lineEnd.index : -1 if (nl === -1) return -1 i = nl continue @@ -1315,6 +1328,7 @@ function skipBracedExpression(code, index) { if (end === -1) return -1 i = end last = 'x' + previousEnd = i continue } if (c === '`') { @@ -1322,15 +1336,17 @@ function skipBracedExpression(code, index) { if (inner === null) return -1 i = inner.end last = 'x' + previousEnd = i continue } // A slash after a closing group may start a statement regex OR division. // Without a parser that distinction is uncertain: abort the WHOLE scan. - if (c === '/' && (last === ')' || last === '}')) return -1 + if (c === '/' && ambiguousSlash(code, i, last, previousEnd)) return -1 const word = codeWordAt(code, i) if (word) { last = last === '.' ? 'x' : word i += word.length + previousEnd = i continue } if (c === '/' && regexCanStart(last)) { @@ -1338,6 +1354,7 @@ function skipBracedExpression(code, index) { if (end === -1) return -1 i = end last = 'x' + previousEnd = i continue } if (c === '{') depth += 1 @@ -1345,7 +1362,7 @@ function skipBracedExpression(code, index) { depth -= 1 if (depth === 0) return i + 1 } - if (c.trim() !== '') last = c + if (c.trim() !== '') { last = c; previousEnd = i + 1 } i += 1 } return -1 @@ -1383,11 +1400,13 @@ export function scanCodeLiterals(code) { const spans = [] let i = 0 let last = '' + let previousEnd = 0 while (i < code.length) { const c = code[i] const next = code[i + 1] if (c === '/' && next === '/') { - const nl = code.indexOf('\n', i) + const lineEnd = /[\r\n\u2028\u2029]/.exec(code.slice(i)) + const nl = lineEnd ? i + lineEnd.index : -1 // A line comment may legally terminate at EOF (outside interpolation). if (nl === -1) break i = nl @@ -1405,6 +1424,7 @@ export function scanCodeLiterals(code) { spans.push({ start: i + 1, end: end - 1 }) i = end last = 'x' + previousEnd = i continue } if (c === '`') { @@ -1418,15 +1438,17 @@ export function scanCodeLiterals(code) { } i = template.end last = 'x' + previousEnd = i continue } // A slash after a closing group may start a statement regex OR division. // Without a parser that distinction is uncertain: abort the WHOLE scan. - if (c === '/' && (last === ')' || last === '}')) return null + if (c === '/' && ambiguousSlash(code, i, last, previousEnd)) return null const word = codeWordAt(code, i) if (word) { last = last === '.' ? 'x' : word i += word.length + previousEnd = i continue } if (c === '/' && regexCanStart(last)) { @@ -1434,9 +1456,10 @@ export function scanCodeLiterals(code) { if (end === -1) return null i = end last = 'x' + previousEnd = i continue } - if (c.trim() !== '') last = c + if (c.trim() !== '') { last = c; previousEnd = i + 1 } i += 1 } return spans @@ -1512,7 +1535,7 @@ export function rewriteCodePaths(code, env) { // path WITHOUT wildcards splits into directory + basename. Runs only for // the glob tool and only for patterns that start absolute. -const GLOB_META = /[*?\[\]]/ +const GLOB_META = /[*?\[\]{}]/ /** Split an absolute glob pattern into { prefix, rest }; null when relative. */ function splitGlobPrefix(pattern) { diff --git a/src/native-executor.js b/src/native-executor.js index f51fc3f..ef9658b 100644 --- a/src/native-executor.js +++ b/src/native-executor.js @@ -25,7 +25,8 @@ export function nativePath(input, translateMsysPath, platform = process.platform object(input, label, new Set(['kind', 'value'])) if (input.kind !== 'path') throw new TypeError(`${label}: kind must be path`) const value = string(input.value, label) - if (/^[~$]|%[^%]+%/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`) + // Reject expansion prefixes, not literal characters inside absolute paths. + if (/^(?:[~$]|%[^%]+%)/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`) if (platform !== 'win32') throw new Error('gitbash_native currently supports only the Windows local execution world') // A rooted Windows path without a drive is not absolute in this contract. const absolute = v => /^[a-z]:[\\/]/i.test(v) || /^\\\\[^\\]+\\[^\\]+/.test(v) || /^\/\/[^/]+\/[^/]+/.test(v) diff --git a/src/native-tool.js b/src/native-tool.js index a1225d1..3f0efa8 100644 --- a/src/native-tool.js +++ b/src/native-tool.js @@ -1,6 +1,6 @@ import { registerExperimentalTool } from './experimental-tools.js' import { createNativeExecutor } from './native-executor.js' -import { processJob, processSources, ringDelta } from './bash-render.js' +import { processJob, processSources, ringDelta, renderJobRead } from './bash-render.js' const typedPath = () => ({ type: 'object', additionalProperties: false, properties: { kind: { type: 'string', const: 'path', required: true }, value: { type: 'string', required: true }, @@ -165,8 +165,11 @@ export function createNativeToolModule(deps) { throw new Error(`native preparation timed out; job ${attached.id} was cancelled, inspect job_output before retrying`) } const read = jobs.read(attached.id, owner) + // A ring eviction can report gapBefore even when its pull source + // was lossless. This consuming read must preserve both loss facts. + const lostOutput = read.lossy || read.chunks.some(chunk => chunk.gapBefore) return { kind: 'promoted', jobId: attached.id, cwd: request.cwd, timeoutMs: request.timeoutMs, - output: ringDelta(read.chunks), encoding: request.output } + output: renderJobRead(ringDelta(read.chunks), lostOutput, read.job?.output?.spillPaths), encoding: request.output } } jobs.remove(attached.id, owner) if (!attached.process()) throw new Error(view.detail ?? 'native preparation failed before a process handle was available') diff --git a/src/tool-runner.js b/src/tool-runner.js index 09212d0..f505a1f 100644 --- a/src/tool-runner.js +++ b/src/tool-runner.js @@ -26,7 +26,8 @@ */ import { execFile } from 'node:child_process' -import { readdirSync, readFileSync, rmSync } from 'node:fs' +import { readdirSync, readFileSync, rmSync, statSync } from 'node:fs' +import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -68,7 +69,10 @@ export function defaultRunnerIo() { return String(process.env.PATH || '').split(';').filter(Boolean) }, readdir(dir) { - return readdirSync(dir) + return readdirSync(dir, { withFileTypes: true }) + }, + isFile(path) { + try { return statSync(path).isFile() } catch { return false } }, readFile(path) { return readFileSync(path, 'utf8') @@ -82,6 +86,27 @@ export function defaultRunnerIo() { } } +/** Export one authoritative inventory into a per-call owned path. Failure is not absence. */ +async function readWingetInventory(io) { + const exportPath = io.tmpFile(`dsh-gitbash-shell-wg-export-${randomUUID()}.json`) + try { + const result = await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS) + if (result.code !== 0) throw new Error(`export exited with code ${result.code}`) + const text = io.readFile(exportPath) + const json = JSON.parse(text) + if (!json || !Array.isArray(json.Sources) || json.Sources.some(source => + !source || typeof source.SourceDetails?.Name !== 'string' || !Array.isArray(source.Packages) + || source.Packages.some(pkg => !pkg || typeof pkg.PackageIdentifier !== 'string'))) { + throw new Error('invalid export structure') + } + return parseExportJson(text, TOOLCHAIN) + } catch (error) { + throw new Error(`winget inventory unavailable: ${error?.message ?? error}`) + } finally { + io.remove(exportPath) + } +} + /** * The whole catalog's state in four winget/PATH reads: one `--version` probe, * one `export` inventory, one `--upgrade-available` list, one PATH scan. @@ -108,16 +133,7 @@ export async function probeToolchain(io) { } } - const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json') - let owned = new Map() - try { - await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS) - owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN) - } catch { - owned = new Map() - } finally { - io.remove(exportPath) - } + const owned = await readWingetInventory(io) let upgradable = new Set() try { @@ -197,11 +213,20 @@ export async function runToolJob(io, request, onChange) { // the installed package id (GNU vs MSVC ripgrep), never the preferred one. let owned = new Map() if (entries.length > 0) { - const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json') try { - await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS) - owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN) - } catch { owned = new Map() } finally { io.remove(exportPath) } + owned = await readWingetInventory(io) + } catch (error) { + // Do not choose a preferred build or start an installer from an unknown inventory. + job.results = entries.map(tool => ({ + id: tool.id, cmd: tool.cmd, action: mode === 'upgrade' ? 'upgrade' : 'install', + wingetId: tool.ids[0], admin: tool.admin === true, ok: false, kind: 'failed', + detail: `${error?.message ?? error}; no install or upgrade was attempted`, + })) + job.done = entries.length + job.finished = true + if (onChange) onChange(job) + return job + } } for (const tool of entries) { diff --git a/src/toolchain.js b/src/toolchain.js index 33573cc..d8c41f1 100644 --- a/src/toolchain.js +++ b/src/toolchain.js @@ -229,7 +229,7 @@ const EXEC_EXTS = new Set(['.exe', '.cmd', '.bat', '.com']) * Only EXECUTABLE entries resolve (see EXEC_EXTS): a man page (`just.1`) or a * document (`jq.md`) shares the stem without being the command, and issue #31 * measured a man page winning the first-hit race over the `just.exe` beside it. - * @param {{pathDirs: () => string[], readdir: (dir: string) => string[]}} io + * @param {{pathDirs: () => string[], readdir: (dir: string) => Array, isFile?: (path: string) => boolean}} io * @param {Array} tools - catalog entries to look for. * @returns {Map} cmd → the first resolving full path. */ @@ -247,17 +247,23 @@ export function scanCommands(io, tools) { try { entries = io.readdir(dir) } catch { continue } if (!Array.isArray(entries)) continue for (const entry of entries) { - if (typeof entry !== 'string') continue - // The LAST extension decides, exactly as before — but it now has to be - // an executable one, so a man page/doc/directory is skipped instead of - // resolving to a command it merely shares a stem with. - const suffix = /(\.[^./\\]+)$/.exec(entry) + const name = typeof entry === 'string' ? entry : entry?.name + if (typeof name !== 'string') continue + const suffix = /(\.[^./\\]+)$/.exec(name) if (suffix !== null && !EXEC_EXTS.has(suffix[1].toLowerCase())) continue - const base = (suffix === null ? entry : entry.slice(0, suffix.index)).toLowerCase() + const base = (suffix === null ? name : name.slice(0, suffix.index)).toLowerCase() const cmd = wanted.get(base) - if (cmd !== undefined && !found.has(cmd)) { - found.set(cmd, dir.replace(/[\\/]+$/, '') + '\\' + entry) - } + if (cmd === undefined || found.has(cmd)) continue + const fullPath = dir.replace(/[\\/]+$/, '') + '\\' + name + try { + // Production IO supplies Dirents. Follow only symlink candidates to a file; + // directories (even make / jq.exe) and broken links never satisfy a command. + // String-only injected IO remains supported for legacy deterministic fixtures. + const file = typeof entry === 'string' + ? (typeof io.isFile !== 'function' || io.isFile(fullPath)) + : (entry.isFile?.() === true || (entry.isSymbolicLink?.() === true && io.isFile?.(fullPath) === true)) + if (file) found.set(cmd, fullPath) + } catch { /* an unreadable or disappearing candidate is not a command */ } } } return found diff --git a/tests/bridge-regressions.mjs b/tests/bridge-regressions.mjs index f72ff58..7246c0b 100644 --- a/tests/bridge-regressions.mjs +++ b/tests/bridge-regressions.mjs @@ -15,15 +15,25 @@ test('FastCtx: spawn failure rejects initialization and clears the transport', a assert.equal(client.child, undefined) }) +test('FastCtx: closing during the async spawn import prevents a late child', async () => { + const client = new McpStdio({ file: join(tmpdir(), `must-not-spawn-${randomUUID()}`), timeoutMs: 1000 }) + const started = client.start() + const rejected = assert.rejects(started, /bridge was stopped/) + await client.close() + await rejected + assert.equal(client.child, undefined) + assert.equal(client.pending.size, 0) +}) + async function bridgeFixture(t, result, callbacks = {}) { const originals = Object.fromEntries(['start', 'listTools', 'callTool', 'close'].map((key) => [key, McpStdio.prototype[key]])) - const definitions = new Map(), listeners = new Map(), cleanups = [], calls = [], instances = [] - McpStdio.prototype.start = async function () { instances.push(this); this.dead = false; return this } + const definitions = new Map(), listeners = new Map(), cleanups = [], calls = [], instances = [], registrations = [] + McpStdio.prototype.start = async function () { instances.push(this); this.dead = false; return callbacks.start ? callbacks.start(this) : this } McpStdio.prototype.listTools = async function () { if (callbacks.listTools) return callbacks.listTools(this) return [{ name: 'inspect_local_file', inputSchema: { type: 'object', properties: {} } }] } - McpStdio.prototype.callTool = async function (name, args) { calls.push({ name, args }); return result } + McpStdio.prototype.callTool = async function (name, args) { calls.push({ name, args }); return callbacks.callTool ? callbacks.callTool(name, args) : result } McpStdio.prototype.close = async function () { this.dead = true; callbacks.onClose?.(this) } t.after(async () => { for (const cleanup of cleanups.reverse()) await cleanup() @@ -31,7 +41,12 @@ async function bridgeFixture(t, result, callbacks = {}) { }) const registry = { schemas: () => callbacks.faceSchemas ? callbacks.faceSchemas() : [], - register(definition) { definitions.set(definition.name, definition); return () => definitions.delete(definition.name) }, + register(definition) { + assert.ok(!definitions.has(definition.name), 'the real registry rejects duplicate tool names') + registrations.push(definition) + definitions.set(definition.name, definition) + return () => { if (definitions.get(definition.name) === definition) definitions.delete(definition.name) } + }, } const ctx = { on(name, callback) { listeners.set(name, callback) }, effect(callback) { cleanups.push(callback()) } } function createRequireFrom() { @@ -43,13 +58,14 @@ async function bridgeFixture(t, result, callbacks = {}) { platform: 'win32', enabled: () => true, createRequireFrom, tools: () => registry, msysToWin: (value) => value, echo: (value) => value, log: () => {}, ...callbacks, }) - const createAgent = () => listeners.get('agent/created')({ agent: {} }) + const createAgent = (agent = {}) => listeners.get('agent/created')({ agent }) + const volatileUpdate = () => listeners.get('loader/volatile-update')?.([['fastctxTools']]) createAgent() if (callbacks.concurrentCreation) createAgent() if (!callbacks.pendingBootstrap) await new Promise(setImmediate) const definition = definitions.get(`gb_${callbacks.rawName || 'inspect_local_file'}`) - if (!callbacks.pendingBootstrap) assert.ok(definition) - return { definition, definitions, calls, instances, createAgent, dispose: () => Promise.all(cleanups.map((cleanup) => cleanup())) } + if (!callbacks.pendingBootstrap && !callbacks.unpublished) assert.ok(definition) + return { definition, definitions, registrations, calls, instances, createAgent, volatileUpdate, registry, dispose: () => Promise.all(cleanups.map((cleanup) => cleanup())) } } test('FastCtx: an MCP tool error throws rather than returning success content', async (t) => { @@ -95,6 +111,155 @@ test('FastCtx: preserves upstream #21 real-array glob patterns through execution assert.equal(calls.length, 1, 'a stringified array must fail before reaching MCP') }) +test('FastCtx: OFF to ON publishes on volatile update without a new agent', async (t) => { + let enabled = false + const fixture = await bridgeFixture(t, {}, { enabled: () => enabled, unpublished: true }) + assert.equal(fixture.instances.length, 0) + enabled = true + fixture.volatileUpdate() + await new Promise(setImmediate) + assert.equal(fixture.definitions.size, 1) + assert.equal(fixture.instances.length, 1) +}) + +for (const connected of [false, true]) { + test(`FastCtx: ON to OFF revokes old definitions (${connected ? 'connected' : 'lazy'}) and ON restores a fresh owner`, async (t) => { + let enabled = true + const fixture = await bridgeFixture(t, {}, { enabled: () => enabled }) + if (connected) await fixture.definition.execute({}, { agent: {} }) + const before = { starts: fixture.instances.length, calls: fixture.calls.length } + enabled = false + fixture.volatileUpdate() + assert.equal(fixture.definitions.size, 0, 'withdraw immediately, without agent creation') + await assert.rejects(fixture.definition.execute({}, { agent: {} }), /bridge was stopped/) + assert.equal(fixture.instances.length, before.starts, 'OFF must never spawn through a cached SDK definition') + assert.equal(fixture.calls.length, before.calls, 'OFF must never send tools/call') + assert.ok(fixture.instances.every((client) => client.isDead)) + enabled = true + fixture.volatileUpdate() + await new Promise(setImmediate) + assert.equal(fixture.definitions.size, 1) + const current = fixture.definitions.get('gb_inspect_local_file') + assert.notEqual(current, fixture.definition) + await assert.rejects(fixture.definition.execute({}, { agent: {} }), /bridge was stopped/) + await current.execute({}, { agent: {} }) + assert.equal(fixture.calls.length, before.calls + 1) + }) +} + +test('FastCtx: stale definitions recheck the live switch even before its update event', async (t) => { + let enabled = true + const fixture = await bridgeFixture(t, {}, { + enabled: () => enabled, + msysToWin: () => assert.fail('disabled old references must not even enter the dialect resolver'), + }) + enabled = false + await assert.rejects(fixture.definition.execute({ path: '/c/fixture' }, { agent: {} }), /bridge was stopped/) + assert.equal(fixture.instances.length, 1) + assert.equal(fixture.calls.length, 0) +}) + +test('FastCtx: OFF during discovery cancels it; rapid ON and concurrent agents leave only one current publication', async (t) => { + let enabled = true, releaseDiscovery + const pending = new Promise((resolve) => { releaseDiscovery = resolve }) + let discoveries = 0 + const listed = [{ name: 'inspect_local_file', inputSchema: { type: 'object', properties: {} } }] + t.after(() => releaseDiscovery(listed)) + const fixture = await bridgeFixture(t, {}, { + enabled: () => enabled, pendingBootstrap: true, + listTools: () => ++discoveries === 1 ? pending : listed, + }) + await new Promise(setImmediate) + enabled = false + fixture.volatileUpdate() + assert.ok(fixture.instances[0].isDead, 'OFF closes pending discovery') + enabled = true + fixture.volatileUpdate() + fixture.createAgent({ id: 'a' }) + fixture.createAgent({ id: 'b' }) + await new Promise(setImmediate) + releaseDiscovery(listed) + await new Promise(setImmediate) + assert.equal(fixture.definitions.size, 1) + assert.equal(fixture.instances.length, 2, 'one discovery for each enabled generation') + assert.equal(fixture.registrations.length, 1, 'superseded discovery must never attempt late publication') + await fixture.dispose() + assert.equal(fixture.definitions.size, 0) + assert.ok(fixture.instances.every((client) => client.isDead)) +}) + +test('FastCtx: OFF during lazy connect prevents a late call and closes its connection', async (t) => { + let enabled = true, releaseStart + let starts = 0 + const pending = new Promise((resolve) => { releaseStart = resolve }) + const fixture = await bridgeFixture(t, {}, { + enabled: () => enabled, + start: (client) => ++starts === 1 ? client : pending.then(() => client), + }) + const execution = fixture.definition.execute({}, { agent: {} }) + const rejected = assert.rejects(execution, /bridge was stopped/) + enabled = false + fixture.volatileUpdate() + releaseStart() + await rejected + assert.equal(fixture.calls.length, 0) + assert.ok(fixture.instances.every((client) => client.isDead)) +}) + +test('FastCtx: disposal owns a pending lazy connection and rejects its caller', async (t) => { + let starts = 0, releaseStart + const gate = new Promise((resolve) => { releaseStart = resolve }) + const fixture = await bridgeFixture(t, {}, { + start: (client) => ++starts === 1 ? client : gate.then(() => client), + onClose: () => { if (starts > 1) releaseStart() }, + }) + const call = assert.rejects(fixture.definition.execute({}, { agent: {} }), /bridge was stopped/) + await fixture.dispose() + await call + fixture.volatileUpdate() + fixture.createAgent() + await new Promise(setImmediate) + assert.equal(fixture.calls.length, 0) + assert.equal(fixture.instances.length, 2) + assert.equal(fixture.definitions.size, 0) + assert.ok(fixture.instances.every((client) => client.isDead)) +}) + +test('FastCtx: ops appearing in a later caller owns the surface even without a new agent event', async (t) => { + let enabled = true + const fixture = await bridgeFixture(t, {}, { enabled: () => enabled }) + fixture.registry.schemas = (agent) => agent?.ops ? [{ name: 'ops_replace' }] : [] + await assert.rejects(fixture.definition.execute({}, { agent: { ops: true } }), /bridge was stopped/) + assert.equal(fixture.definitions.size, 0) + assert.equal(fixture.calls.length, 0) + assert.equal(fixture.instances.length, 1) + enabled = false; fixture.volatileUpdate() + enabled = true; fixture.volatileUpdate() + fixture.createAgent({ ops: true }) + await new Promise(setImmediate) + assert.equal(fixture.definitions.size, 0, 'user ON cannot undo peer stand-down') +}) + +test('FastCtx: a concurrent ops agent cancels pending discovery before publication', async (t) => { + let releaseDiscovery + const pending = new Promise((resolve) => { releaseDiscovery = resolve }) + const fixture = await bridgeFixture(t, {}, { pendingBootstrap: true, listTools: () => pending }) + await new Promise(setImmediate) + fixture.registry.schemas = (agent) => agent?.ops ? [{ name: 'ops_grep' }] : [] + fixture.createAgent({ ops: true }) + releaseDiscovery([{ name: 'inspect_local_file' }]) + await new Promise(setImmediate) + assert.equal(fixture.definitions.size, 0) + assert.ok(fixture.instances.every((client) => client.isDead)) +}) + +test('FastCtx: ordinary backend failure is never automatically replayed', async (t) => { + const fixture = await bridgeFixture(t, {}, { callTool: () => { throw new Error('fixture replace failed after a possible write') } }) + await assert.rejects(fixture.definition.execute({}, { agent: {} }), /possible write/) + assert.equal(fixture.calls.length, 1) + assert.equal(fixture.instances.length, 2) +}) + test('FastCtx: disposal cancels discovery and prevents late tool registration', async (t) => { let rejectDiscovery const gate = new Promise((_resolve, reject) => { rejectDiscovery = reject }) diff --git a/tests/client-regressions.mjs b/tests/client-regressions.mjs index 0c532eb..51c2feb 100644 --- a/tests/client-regressions.mjs +++ b/tests/client-regressions.mjs @@ -79,6 +79,181 @@ function cardFixture({ expanded = false } = {}) { } } +// Unlike the observation-only card fixture above, this runner preserves hook +// state and compares the real dependency arrays across parent-driven renders. +function draftFixture(initial = { status: 'loading' }) { + let declaration, stateIndex = 0, effectIndex = 0, dirty = false, tree + const states = [], dependencies = [], cleanups = [], effects = [], writes = [] + const React = { + Component: function () {}, + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + useState(initialValue) { + const index = stateIndex++ + if (!(index in states)) states[index] = typeof initialValue === 'function' ? initialValue() : initialValue + return [states[index], (value) => { + const next = typeof value === 'function' ? value(states[index]) : value + if (!Object.is(next, states[index])) { states[index] = next; dirty = true } + }] + }, + useEffect(callback, next) { + const index = effectIndex++, previous = dependencies[index] + if (!previous || previous.length !== next.length || next.some((value, i) => !Object.is(value, previous[i]))) { + dependencies[index] = next + effects.push(() => { cleanups[index]?.(); cleanups[index] = callback() }) + } + }, + } + const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') + assert.ok(source.includes('return module.exports;')) + vm.runInNewContext(source.replace('return module.exports;', 'exports.testCard = GitBashCard; return module.exports;'), { + window: { __ModuleLoader__: { load(value) { declaration = value } } }, console, + fetch: async () => ({ ok: false, status: 503 }), setTimeout, clearTimeout, + }) + const Card = declaration.factory((name) => name === 'react' ? React : {}).testCard + function form(snapshot) { + return { + snapshot, accepted: true, + getSnapshot() { return this.snapshot }, + set(key, value) { writes.push({ scope: this, key, value }); return Promise.resolve(this.accepted) }, + } + } + const own = form(initial) + const props = { scope: own, ctx: { get: () => undefined }, view: 'page', t: (key) => key } + function render() { + for (let turn = 0; turn < 20; turn++) { + stateIndex = effectIndex = 0; effects.length = 0; dirty = false + tree = Card(props) + for (const effect of effects) effect() + if (!dirty) return tree + } + assert.fail('hook fixture did not settle') + } + const nodes = () => walk(render()) + function controls() { + let input = nodes().find((node) => node.type === 'input' && node.props.placeholder === 'C:/Program Files/Git/bin/bash.exe') + if (!input) { + const fold = nodes().find((node) => node.props.className === 'gb-foldHead' && walk(node).some((child) => child.children?.includes('sec.executor'))) + assert.ok(fold) + fold.props.onClick() + input = nodes().find((node) => node.type === 'input' && node.props.placeholder === 'C:/Program Files/Git/bin/bash.exe') + } + return { input, save: nodes().find((node) => node.type === 'button' && node.props.className?.includes('gb-saveBtn')) } + } + return { own, form, writes, render, controls, nodes, setScope: (scope) => { props.scope = scope }, dispose: () => cleanups.forEach((cleanup) => cleanup?.()) } +} + +function walk(node) { + return !node || typeof node !== 'object' ? [] : [node, ...(node.children ?? []).flat(Infinity).flatMap(walk)] +} + +test('client: bash draft hydrates when loading first becomes ready through a parent render', async (t) => { + const f = draftFixture() + t.after(f.dispose) + assert.equal(f.render(), null) + f.own.snapshot = { status: 'ready', value: { bashPath: 'Q:/Git/bin/bash.exe' } } + // Core owns the ConfigForm subscription and rerenders this child on arrival. + f.render() + const { input, save } = f.controls() + assert.equal(input.props.value, 'Q:/Git/bin/bash.exe') + save.props.onClick() + await new Promise(setImmediate) + assert.equal(f.writes.at(-1).value, 'Q:/Git/bin/bash.exe', 'untouched save must not clear the existing path') + assert.ok(f.controls().save.props.className.includes('gb-saved')) +}) + +test('client: bash draft hydrates on a ready initial mount', (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + assert.equal(f.controls().input.props.value, 'R:/Git/bin/bash.exe') +}) + +test('client: pristine bash draft follows a later mirror path update', (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + assert.equal(f.controls().input.props.value, 'R:/Git/bin/bash.exe') + f.own.snapshot = { status: 'ready', value: { bashPath: 'T:/external/Git/bin/bash.exe' } } + assert.equal(f.controls().input.props.value, 'T:/external/Git/bin/bash.exe') +}) + +test('client: dirty bash draft survives later mirror refreshes', (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + f.controls().input.props.onChange({ target: { value: 'S:/edited/Git/bin/bash.exe' } }) + f.own.snapshot = { status: 'ready', value: { bashPath: 'T:/external/Git/bin/bash.exe', posixPaths: true } } + assert.equal(f.controls().input.props.value, 'S:/edited/Git/bin/bash.exe') + assert.ok(!f.controls().save.props.className.includes('gb-saved')) +}) + +test('client: scope change discards the former scope draft and hydrates the new ready snapshot', (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + f.controls().input.props.onChange({ target: { value: 'S:/unsaved/Git/bin/bash.exe' } }) + const other = f.form({ status: 'loading' }) + f.setScope(other) + assert.equal(f.render(), null) + other.snapshot = { status: 'ready', value: { bashPath: 'U:/other/Git/bin/bash.exe' } } + assert.equal(f.controls().input.props.value, 'U:/other/Git/bin/bash.exe') + const third = f.form({ status: 'ready', value: {} }) + f.setScope(third) + assert.equal(f.controls().input.props.value, '') +}) + +for (const newScopeSaved of [false, true]) { + test(`client: a pending former-scope save cannot change the new scope saved state (${newScopeSaved})`, async (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + let acceptOld + f.own.accepted = new Promise((resolve) => { acceptOld = resolve }) + f.controls().save.props.onClick() + const other = f.form({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + f.setScope(other) + assert.ok(!f.controls().save.props.className.includes('gb-saved')) + if (newScopeSaved) { + f.controls().save.props.onClick() + await new Promise(setImmediate) + } + acceptOld(true) + await new Promise(setImmediate) + assert.equal(f.controls().save.props.className.includes('gb-saved'), newScopeSaved) + assert.equal(f.controls().input.props.value, 'R:/Git/bin/bash.exe') + }) +} + +for (const change of ['edit', 'scope roundtrip']) { + test(`client: a pending save cannot mark a replacement draft saved after ${change}`, async (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + let acceptOld + f.own.accepted = new Promise((resolve) => { acceptOld = resolve }) + f.controls().save.props.onClick() + if (change === 'edit') { + f.controls().input.props.onChange({ target: { value: 'S:/edited/Git/bin/bash.exe' } }) + } else { + f.setScope(f.form({ status: 'ready', value: { bashPath: 'T:/other/Git/bin/bash.exe' } })) + f.render() + f.setScope(f.own) + f.render() + } + acceptOld(true) + await new Promise(setImmediate) + assert.ok(!f.controls().save.props.className.includes('gb-saved')) + }) +} + +test('client: refused bash save keeps the dirty draft and never claims success', async (t) => { + const f = draftFixture({ status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } }) + t.after(f.dispose) + f.own.accepted = false + f.controls().input.props.onChange({ target: { value: ' S:/edited/Git/bin/bash.exe ' } }) + f.controls().save.props.onClick() + await new Promise(setImmediate) + assert.equal(f.writes.at(-1).value, 'S:/edited/Git/bin/bash.exe') + f.own.snapshot = { status: 'ready', value: { bashPath: 'R:/Git/bin/bash.exe' } } + assert.equal(f.controls().input.props.value, ' S:/edited/Git/bin/bash.exe ') + assert.ok(!f.controls().save.props.className.includes('gb-saved')) + assert.ok(f.nodes().some((node) => node.children?.some((child) => typeof child === 'string' && child.includes('bash.saveFailed')))) +}) + test('client: pending, ready and unavailable snapshots call the same hooks', () => { const fixture = cardFixture() const pending = fixture.render() diff --git a/tests/review-core-regressions.mjs b/tests/review-core-regressions.mjs new file mode 100644 index 0000000..e64ec96 --- /dev/null +++ b/tests/review-core-regressions.mjs @@ -0,0 +1,215 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { rewriteCodePaths, translateGlobArguments, _internal } from '../src/index.js' +import { scanCommands } from '../src/toolchain.js' +import { defaultRunnerIo, probeToolchain, runToolJob } from '../src/tool-runner.js' +import { Context, Service } from '@deepseek-ai/cordis' +import { createBashToolModule } from '../src/bash-tool.js' + +const latch = () => { + let resolve + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +test('R01: ambiguous ASI slash leaves the whole program unchanged, including prior literals', () => { + for (const declaration of ['let x', 'var x']) { + for (const newline of ['\n', '\r\n', '\u2028', '\u2029', '/*\n*/']) { + const code = `const p='/c/before'; ${declaration}${newline}/['/c/foo']/.test('C') && (x=true); return x ?? false` + const rewritten = rewriteCodePaths(code, null) + assert.equal(new Function(code)(), false) + assert.equal(rewritten, code, `${declaration} followed by ${JSON.stringify(newline)}`) + assert.equal(new Function(rewritten)(), false) + } + } +}) + +test('R01: ordinary literals, explicit regexes and same-line division keep their contracts', () => { + const code = "const p='/c/file'; let n = 12 / 3; const r=/['/c/foo']/; return [p,n,r.test('C')]" + const rewritten = rewriteCodePaths(code, null) + assert.deepEqual(new Function(rewritten)(), ['C:/file', 4, false]) + const ambiguousDivision = "const p='/c/file'; let n = 12\n / 3; return [p,n]" + assert.equal(rewriteCodePaths(ambiguousDivision, null), ambiguousDivision, 'uncertain newline division is a whole-program no-op') +}) + +test('R01: labeled ASI and template interpolation use the same conservative slash guard', () => { + for (const code of [ + "const p='/c/file'; label: { break label\n/['/c/foo']/.test('C'); } return p", + "const p='/c/file'; label: while(false) { continue label\n/['/c/foo']/.test('C'); } return p", + "const p='/c/file'; const text=`${(()=>{let x\n/['/c/foo']/.test('C');return 'done'})()}`; return p", + ]) { + assert.equal(new Function(code)(), '/c/file') + assert.equal(rewriteCodePaths(code, null), code) + } +}) + +test('R02: whole-value TEMP echo only maps the actual root, never an embedded path fragment', () => { + const env = { tmpDir: 'C:/Temp' } + for (const [input, expected] of [ + ['C:/Temp', '/tmp'], ['c:/TEMP/file', '/tmp/file'], ['C:\\Temp\\space dir\\file', '/tmp/space dir/file'], + ['C:/Temperature/file', '/c/Temperature/file'], ['D:/backup/c/Temp/result.txt', '/d/backup/c/Temp/result.txt'], + ['C:/safe/c/Temp/x', '/c/safe/c/Temp/x'], ['backup/c/Temp/x', 'backup/c/Temp/x'], + ['//server/share/c/Temp/x', '//server/share/c/Temp/x'], + ]) { + const value = { path: input, lines: [{ text: 'C:/Temp is file content, not metadata' }] } + const echoed = _internal.rewriteResultPaths('read', value, env) + assert.equal(echoed.path, expected, input) + assert.equal(echoed.lines, value.lines) + } +}) + +test('R03: directory brace expressions stay inside the glob, not the filesystem root', () => { + for (const [pattern, expected] of [ + ['/c/project/{src,test}/*.js', { path: 'C:/project', pattern: '{src,test}/*.js' }], + ['C:/project/{src,test}/**/*.{js,ts}', { path: 'C:/project', pattern: '{src,test}/**/*.{js,ts}' }], + ['/tmp/{one,two}/file.txt', { path: 'D:/Temp', pattern: '{one,two}/file.txt' }], + ['/c/project/*/src/*.js', { path: 'C:/project', pattern: '*/src/*.js' }], + ['/c/project/readme.md', { path: 'C:/project', pattern: 'readme.md' }], + ]) assert.deepEqual(translateGlobArguments({ pattern }, { tmpDir: 'D:/Temp' }), expected) + const relative = { pattern: '{src,test}/*.js', path: '/c/project' } + assert.equal(translateGlobArguments(relative, null), relative) +}) + +for (const gapBefore of [true, false]) test(`R04: Bash promotion also preserves source gap=${gapBefore} independently of lossy`, async t => { + const root = new Context(), definitions = new Map(), cleanups = [], started = latch() + let finish, hooks, reads = 0 + class Tools extends Service { + constructor(ctx) { super(ctx, 'tools') } + register(definition) { return this.ctx.effect(() => { definitions.set(definition.name, definition); return () => definitions.delete(definition.name) }) } + } + const proc = { status: 'running', exitCode: null, signal: null, + done: new Promise(resolve => { finish = () => { proc.status = 'completed'; proc.exitCode = 0; resolve() } }), + kill() { finish() }, result: async () => ({ exitCode: 0 }), + } + const jobs = { + start(spec) { hooks = spec.run(); return 'fixed-bash-job' }, + async wait() { await started.promise; await Promise.resolve(); return { status: 'running' } }, + read() { reads++; return { chunks: [{ channel: 'stdout', text: 'RETAINED_TAIL', gapBefore }], lossy: false, job: { output: { spillPaths: [] } } } }, + } + for (const [name, value] of Object.entries({ jobs, + systemPrompt: { section() {}, getSectionOrder: () => 100 }, shellEnv: { collect: () => ({}) }, + workingDirectory: { ensure: async () => 'C:/fixed' }, + })) cleanups.push(root.provide(name, value)) + t.after(async () => { finish(); if (hooks) await hooks.done; for (const off of cleanups.reverse()) await off() }) + const tools = root.plugin(Tools); await tools; cleanups.push(() => tools.dispose()) + const module = root.plugin(createBashToolModule({ log() {}, executor: { + resolve: request => ({ ...request, workdir: 'C:/fixed', timeoutMs: 100 }), + execute: async () => { started.resolve(); return proc }, + } })) + await module; cleanups.push(() => module.dispose()); await new Promise(resolve => setImmediate(resolve)) + const result = await definitions.get('bash').execute({ description: 'Check retained Bash output facts', command: 'CONTROLLED_NO_OS_SPAWN' }, { agent: { id: 'fixed-owner', session: { header: {} } }, signal: new AbortController().signal }) + assert.equal(result.kind, 'promoted') + assert.equal(reads, 1) + assert.equal(result.output.includes('some output was dropped'), gapBefore) + assert.match(result.output, /^RETAINED_TAIL/) +}) + +const inventory = JSON.stringify({ Sources: [{ SourceDetails: { Name: 'winget' }, Packages: [ + { PackageIdentifier: 'BurntSushi.ripgrep.GNU', Version: '1.0.0' }, +] }] }) + +function runnerFixture({ exportCode = 0, text = inventory } = {}) { + const files = new Map(), calls = [], removed = [], exports = [] + const io = { + pathDirs: () => [], readdir: () => [], tmpFile: name => path.join(tmpdir(), name), + readFile(filename) { if (!files.has(filename)) throw new Error('ENOENT controlled inventory'); return files.get(filename) }, + remove(filename) { removed.push(filename); files.delete(filename) }, + async run(_file, argv) { + calls.push(argv) + if (argv[0] === '--version') return { code: 0, output: 'v1.29.0' } + if (argv[0] === 'export') { + const filename = argv[argv.indexOf('-o') + 1] + exports.push(filename); files.set(filename, text) + return { code: exportCode, output: 'controlled export' } + } + return { code: 0, output: argv[0] === 'list' ? 'BurntSushi.ripgrep.GNU 1.0.0 2.0.0' : '' } + }, + } + return { io, files, calls, removed, exports } +} + +test('R05: overlapping status and auto upgrade own different export files and retain the installed build', async () => { + const f = runnerFixture(), a = latch(), b = latch(), startedA = latch(), startedB = latch() + const run = f.io.run + let exports = 0 + f.io.run = async (...args) => { + const result = await run(...args) + if (args[1][0] === 'export') { + if (++exports === 1) { startedA.resolve(); await a.promise } + else { startedB.resolve(); await b.promise } + } + return result + } + const status = probeToolchain(f.io) + await startedA.promise + const job = runToolJob(f.io, { action: 'auto', ids: ['rg'] }) + await startedB.promise + a.resolve(); assert.equal((await status).tools.find(row => row.id === 'rg').state, 'managed') + b.resolve() + const result = await job + assert.notEqual(f.exports[0], f.exports[1], 'each invocation owns its output path') + assert.equal(result.results[0].action, 'upgrade') + assert.equal(result.results[0].wingetId, 'BurntSushi.ripgrep.GNU') + assert.ok(f.calls.some(argv => argv[0] === 'upgrade' && argv.includes('BurntSushi.ripgrep.GNU'))) + assert.ok(!f.calls.some(argv => argv[0] === 'install')) + assert.deepEqual(new Set(f.removed), new Set(f.exports)) +}) + +for (const fault of [ + { name: 'nonzero export with stale-looking valid JSON', exportCode: 1 }, + { name: 'invalid JSON', text: '{broken' }, + { name: 'missing Sources', text: '{}' }, + { name: 'malformed source', text: '{"Sources":[{}]}' }, +]) { + test(`R05: ${fault.name} never becomes an empty inventory install`, async () => { + const f = runnerFixture(fault) + const job = await runToolJob(f.io, { action: 'auto', ids: ['rg', 'make'] }) + assert.equal(job.finished, true) + assert.equal(job.done, 2) + assert.equal(job.results.length, 2) + assert.ok(job.results.every(result => !result.ok && /inventory/i.test(result.detail))) + assert.ok(!f.calls.some(argv => ['install', 'upgrade'].includes(argv[0]))) + assert.deepEqual(f.removed, f.exports) + await assert.rejects(probeToolchain(f.io), /inventory/i, 'the settings card must not advertise an authoritative missing state') + }) +} + +test('R05: a valid empty inventory still permits the explicitly requested catalog install', async () => { + const f = runnerFixture({ text: '{"Sources":[]}' }) + const job = await runToolJob(f.io, { action: 'auto', ids: ['make'] }) + assert.equal(job.results[0].ok, true) + assert.equal(job.results[0].action, 'install') + assert.ok(f.calls.some(argv => argv[0] === 'install' && argv.includes('ezwinports.make'))) +}) + +test('R08: real directory entries cannot masquerade as extensionless or .exe commands', t => { + const root = mkdtempSync(path.join(tmpdir(), 'gitbash-review-commands-')) + t.after(() => { + assert.equal(path.dirname(root), tmpdir()) + assert.ok(path.basename(root).startsWith('gitbash-review-commands-')) + rmSync(root, { recursive: true, force: true }) + }) + mkdirSync(path.join(root, 'make')); mkdirSync(path.join(root, 'jq.exe')) + writeFileSync(path.join(root, 'fzf'), 'controlled file') + writeFileSync(path.join(root, 'just.1'), 'manual') + writeFileSync(path.join(root, 'just.exe'), 'controlled file') + const io = { ...defaultRunnerIo(), pathDirs: () => [root] } + const hits = scanCommands(io) + assert.equal(hits.has('make'), false) + assert.equal(hits.has('jq'), false) + assert.ok(hits.get('fzf')?.endsWith('\\fzf')) + assert.ok(hits.get('just')?.endsWith('\\just.exe')) +}) + +test('R08: executable symlinks are checked by target type and broken links/directories are skipped', () => { + const link = name => ({ name, isFile: () => false, isSymbolicLink: () => true }) + const io = { + pathDirs: () => ['C:/tools'], readdir: () => [link('make.exe'), link('jq.exe'), link('fzf')], + isFile: filename => filename.endsWith('\\make.exe'), + } + const hits = scanCommands(io) + assert.deepEqual([...hits.keys()], ['make']) +}) diff --git a/tests/review-native-preview.mjs b/tests/review-native-preview.mjs new file mode 100644 index 0000000..720ae8e --- /dev/null +++ b/tests/review-native-preview.mjs @@ -0,0 +1,135 @@ +// Actual selected Core + ToolRuntime + Jobs; ordinary Node producers only. +// Test scratch only: no installation, production profile, network or secrets. +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { Context } from '@deepseek-ai/cordis' +import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' +import * as toolsModule from '@deepseek-ai/dsh-tools' +import * as sandboxModule from '@deepseek-ai/dsh-sandbox' +import * as localBashModule from '@deepseek-ai/dsh-bash-local' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' +import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output' +import { LocalJobRegistry } from '@deepseek-ai/dsh-jobs-local' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import * as shellEnv from '@deepseek-ai/dsh-shell-env' +import { createNativeOutputAdapter } from '../src/native-output.js' +import { createNativeToolModule, NATIVE_OUTPUT_SCHEMA } from '../src/native-tool.js' + +assert.equal(process.versions.electron, undefined, 'Use ordinary Node, never an Electron producer') +const evidence = process.env.DSH_NATIVE_REVIEW_EVIDENCE_DIR +assert.ok(evidence && path.isAbsolute(evidence), 'Explicit caller-owned scratch directory required') +const directory = fs.mkdtempSync(path.join(evidence, 'native-review-')) +const observations = [] +const typedPath = value => ({ kind: 'path', value }) +const literal = value => ({ kind: 'literal', value }) +const request = (code, patch = {}) => ({ description: 'Native review fixed Node sentinel', + executable: typedPath(process.execPath), entrypoint: 'interpreter', argv: [literal('-e'), literal(code)], ...patch }) +const msys = value => value.replaceAll('\\', '/').replace(/^([a-z]):/i, (_, drive) => `/${drive.toLowerCase()}`) +function createScope(owner) { + let ctx + const fiber = owner.plugin(value => { ctx = value }) + return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } +} +async function fixture(t) { + const root = new Context(), disposers = [], reads = [], handles = [] + let agent + const official = { original: true } + for (const [name, value] of Object.entries({ shell: official, + sandboxPolicy: { defaultMode: 'danger-full-access', resolve: () => ({ mode: 'danger-full-access', workspaceRoot: directory }) }, + workingDirectory: { ensure: async () => directory }, agents: { get: id => agent?.id === id ? agent : undefined }, + })) disposers.push(root.provide(name, value)) + const mount = async (module, config) => { + const fiber = root.plugin(module, config); disposers.push(() => fiber.dispose()); await fiber; return fiber + } + t.after(async () => { for (const dispose of disposers.reverse()) await dispose() }) + for (const module of [LocalSubprocessRuntime, SystemPrompt, toolsModule.ToolRuntime, shellEnv]) await mount(module) + const provider = root.get('subprocess') + const rootProviders = ['shell', 'subprocess'].map(name => root.reflect.store[root[Context.isolate][name]]) + provider.internals.spillDir = directory + agent = { id: randomUUID(), session: { header: { id: randomUUID(), cwd: directory } } } + const owner = createAgentScope(root, agent); agent.ctx = owner.ctx; disposers.push(() => owner.dispose()) + await mount(LocalJobRegistry, { pumpPollMs: 5 }) + await mount({ inject: ['jobs'], apply(ctx) { ctx.jobs.attachController('native-review-controller') } }) + const jobs = root.get('jobs'), read = jobs.read.bind(jobs) + jobs.read = (...args) => { + const value = read(...args) + reads.push({ lossy: value.lossy, gaps: value.chunks.some(chunk => chunk.gapBefore), + chars: value.chunks.reduce((total, chunk) => total + chunk.text.length, 0), spillPaths: value.job.output.spillPaths ?? [] }) + return value + } + const module = createNativeToolModule({ toolsModule, sandboxModule, localBashModule, createScope, + translateMsysPath: value => /^\/[a-z]\//i.test(value) ? `${value[1].toUpperCase()}:/${value.slice(3)}` : value, + outputOptions: { OutputCollector, artifactDirectory: directory, drainTimeoutMs: 200 }, + createNativeOutputAdapter(provider, options) { + const adapter = createNativeOutputAdapter(provider, options) + return { spawn(spec) { const handle = adapter.spawn(spec); handles.push(handle); return handle } } + }, + }) + const fiber = agent.ctx.plugin(module); await fiber; disposers.push(() => fiber.dispose()) + const run = args => root.get('tools').execute({ agent, name: 'gitbash_native', callId: randomUUID(), + signal: new AbortController().signal, arguments: args }) + return { run, jobs, agent, reads, handles, preserved() { + for (const [index, name] of ['shell', 'subprocess'].entries()) assert.equal(root.reflect.store[root[Context.isolate][name]], rootProviders[index]) + } } +} + +for (const large of [true, false]) test(`R04 real Core: ${large ? '1MiB gap' : 'lossless'} promotion reports consumed loss correctly`, { timeout: 15000 }, async t => { + const f = await fixture(t) + const code = `${large ? 'process.stdout.write("A".repeat(1024*1024));' : ''}process.stdout.write("PROMOTION_FIXED_TAIL");setTimeout(()=>{},5000)` + const response = await f.run(request(code, { timeoutMs: 1200 })) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.kind, 'promoted') + // Always stop the admitted producer before a later assertion could fail. + f.jobs.kill(response.value.jobId, f.agent.id, 'fixed regression cleanup') + const outcome = await f.jobs.wait(response.value.jobId, 5000, f.agent.id) + assert.equal(outcome.status, 'killed') + assert.equal(f.handles.length, 1, 'metadata rendering must not repeat a producer') + assert.equal(await f.handles[0].waitForExit(AbortSignal.timeout(3000)), true) + const first = f.reads[0] + assert.ok(first) + const text = response.content.map(part => part.text ?? '').join('\n') + observations.push({ name: t.name, read: first, outputChars: response.value.output.length, + warning: /some output was dropped/.test(text), producerCount: f.handles.length, outcome: outcome.status }) + assert.match(response.value.output, /PROMOTION_FIXED_TAIL/) + if (large) { + assert.equal(first.lossy, false, 'actual Core ring gap must be tested separately from source lossy') + assert.equal(first.gaps, true) + assert.ok(first.chars < 1024 * 1024, 'the ring retained only a bounded tail') + assert.ok(/some output was dropped from memory; full output:/.test(response.value.output), 'consumed ring gap must be visible in promotion output') + for (const spillPath of first.spillPaths) assert.ok(response.value.output.includes(spillPath)) + if (!first.spillPaths.length) assert.match(response.value.output, /full output: \(unavailable\)/) + } else { + assert.equal(first.lossy, false); assert.equal(first.gaps, false) + assert.doesNotMatch(text, /some output was dropped/) + } + assert.deepEqual(toolsModule.validateJsonSchemaValue(toolsModule.valueSchemaSpecToJsonSchema(NATIVE_OUTPUT_SCHEMA), response.value), []) + f.preserved() +}) + +test('R10 real Core: percent/dollar/home literals survive native and MSYS argv/cwd paths', { timeout: 15000 }, async t => { + const f = await fixture(t) + const cwd = path.join(directory, '%fixed% $literal ~literal') + fs.mkdirSync(cwd) + const filename = path.join(cwd, '%fixed%.txt') + fs.writeFileSync(filename, 'PERCENT_FIXED_SENTINEL', 'utf8') + for (const convert of [value => value, msys]) { + const args = request('console.log(JSON.stringify({cwd:process.cwd(),text:require("node:fs").readFileSync(process.argv[1],"utf8")}))', { cwd: typedPath(convert(cwd)) }) + args.argv.push(literal('--'), typedPath(convert(filename))) + const response = await f.run(args) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.kind, 'foreground') + assert.equal(response.value.exitCode, 0) + assert.deepEqual(JSON.parse(response.value.stdout.text), { cwd: path.win32.normalize(cwd), text: 'PERCENT_FIXED_SENTINEL' }) + } + assert.equal(f.handles.length, 2) + f.preserved() + observations.push({ name: t.name, nativeAndMsysPaths: true, producerCount: f.handles.length }) +}) + +test.after(() => { + fs.writeFileSync(path.join(directory, 'observations.json'), JSON.stringify({ scope: 'Actual selected Core; ordinary Node producers; no production changes', observations }, null, 2) + '\n') + console.log(`Native review preview evidence: ${directory}`) +}) diff --git a/tests/review-native-regressions.mjs b/tests/review-native-regressions.mjs new file mode 100644 index 0000000..6d79081 --- /dev/null +++ b/tests/review-native-regressions.mjs @@ -0,0 +1,180 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { Context, Service } from '@deepseek-ai/cordis' +import { createNativeToolModule, renderNativeResult, NATIVE_OUTPUT_SCHEMA } from '../src/native-tool.js' +import { nativePath, parseNativeRequest } from '../src/native-executor.js' +import { processOutcome } from '../src/bash-render.js' + +const typedPath = value => ({ kind: 'path', value }) +const translate = value => value.startsWith('/c/') ? `C:/${value.slice(3)}` : value +const options = { platform: 'win32', translateMsysPath: translate, sessionCwd: 'C:/fixed' } +const request = patch => ({ description: 'Fixed native review sentinel', executable: typedPath('C:/fixed/producer.exe'), argv: [], ...patch }) + +// Actual repository Cordis lifecycle; controlled Jobs/executor seam, no OS spawn, +// artifacts, environment reads, profile changes or timers needed for promotion. +async function promotionFixture(t, { lossy = false, gapBefore = false, gapChannel = 'stdout', spillPaths = [], mode = 'danger-full-access', controller = true } = {}) { + const root = new Context() + const definitions = new Map() + const disposers = [] + const counts = { admission: 0, reads: 0, workspace: 0, artifact: 0, spawn: 0 } + let finish, hooks, started + const ready = new Promise(resolve => { started = resolve }) + class Tools extends Service { + constructor(ctx) { super(ctx, 'tools') } + schemas() { return [...definitions.keys()].map(name => ({ name })) } + register(definition) { + return this.ctx.effect(() => { + definitions.set(definition.name, definition) + return () => definitions.delete(definition.name) + }) + } + } + class Local extends Service { + static inject = ['subprocess'] + constructor(ctx) { super(ctx, 'shell') } + resolve(spec) { return spec } + async executeArgv() { + const proc = { status: 'running', exitCode: null, signal: null, + done: new Promise(resolve => { finish = () => { proc.status = 'completed'; proc.exitCode = 7; resolve() } }), + result: async () => ({ exitCode: 7, signal: null, timedOut: false, aborted: false, + stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }), + kill() { finish(); return true }, readOutput() {}, + } + started() + return proc + } + } + function createScope(owner) { + let ctx + const fiber = owner.plugin(value => { ctx = value }) + return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } + } + const chunks = [ + { channel: 'stdout', text: 'RETAINED_TAIL', ...(gapBefore && gapChannel === 'stdout' ? { gapBefore: true } : {}) }, + { channel: 'stderr', text: 'FIXED_STDERR', ...(gapBefore && gapChannel === 'stderr' ? { gapBefore: true } : {}) }, + ] + const job = { output: { spillPaths } } + const jobs = { + start(spec) { + counts.admission++ + if (!controller) throw new Error('no job controller') + hooks = spec.run() + return 'fixed-owned-job' + }, + async wait() { await ready; await Promise.resolve(); return { status: 'running' } }, + read() { counts.reads++; return { chunks: counts.reads === 1 ? chunks : [], lossy, job } }, + kill() { hooks.cancel('fixed cleanup') }, + } + for (const [name, service] of Object.entries({ + sandboxPolicy: { resolve: () => mode === undefined ? undefined : ({ mode }) }, + workingDirectory: { ensure: async () => { counts.workspace++; return 'C:/fixed' } }, + shellEnv: { collect: () => ({}) }, + subprocess: { spawn() { counts.spawn++; throw new Error('NO_OS_SPAWN') } }, jobs, + })) disposers.push(root.provide(name, service)) + const toolsFiber = root.plugin(Tools); await toolsFiber + disposers.push(() => toolsFiber.dispose()) + const native = root.plugin(createNativeToolModule({ platform: 'win32', + toolsModule: { defineTool: value => value }, localBashModule: { LocalBashExecutor: Local }, createScope, + createArtifactDirectory() { counts.artifact++; return 'C:/fixed-no-directory-created' }, + createNativeOutputAdapter: () => ({ spawn() { counts.spawn++; throw new Error('NO_OS_SPAWN') } }), + })) + await native + await new Promise(resolve => setImmediate(resolve)) + disposers.push(() => native.dispose()) + t.after(async () => { + finish?.() + if (hooks) await hooks.done + for (const dispose of disposers.reverse()) await dispose() + }) + return { counts, jobs, + run: () => definitions.get('gitbash_native').execute(request({ timeoutMs: 1000 }), { + agent: { id: 'fixed-owner', ctx: root, session: {} }, signal: new AbortController().signal, + }), + } +} + +for (const [label, metadata] of [ + ['lossy', { lossy: true, spillPaths: ['C:/fixed/raw.bin', 'C:/fixed/decoded.log'] }], + ['gap without lossy', { lossy: false, gapBefore: true, spillPaths: ['C:/fixed/gap.log'] }], + ['stderr gap without lossy', { lossy: false, gapBefore: true, gapChannel: 'stderr' }], + ['loss without spill', { lossy: true }], +]) test(`R04: promotion retains consumed ${label} metadata`, async t => { + const f = await promotionFixture(t, metadata) + const value = await f.run() + assert.equal(value.kind, 'promoted') + assert.match(value.output, /^RETAINED_TAIL\n\[stderr\]\nFIXED_STDERR/) + assert.match(value.output, /some output was dropped from memory; full output:/) + for (const spillPath of metadata.spillPaths ?? []) assert.ok(value.output.includes(spillPath)) + if (!metadata.spillPaths?.length) assert.match(value.output, /full output: \(unavailable\)/) + assert.ok(renderNativeResult(value).includes(value.output)) + assert.equal(f.counts.reads, 1, 'promotion consumes the registry read exactly once') + assert.deepEqual(f.jobs.read().chunks, [], 'later reads cannot recover consumed loss metadata') + assert.equal(f.counts.spawn, 0) + assert.equal(f.counts.admission, 1) + const schema = NATIVE_OUTPUT_SCHEMA.oneOf.find(branch => branch.properties.kind.enum?.includes('promoted')) + assert.equal(schema.additionalProperties, false) + assert.ok(Object.keys(value).every(key => Object.hasOwn(schema.properties, key)), 'do not widen the closed DTO') +}) + +test('R04: lossless promotion does not warn even when spill paths exist', async t => { + const f = await promotionFixture(t, { spillPaths: ['C:/fixed/not-a-loss.log'] }) + const value = await f.run() + assert.equal(value.output, 'RETAINED_TAIL\n[stderr]\nFIXED_STDERR') + assert.doesNotMatch(renderNativeResult(value), /dropped|not-a-loss/) +}) + +for (const mode of ['read-only', 'workspace-write', 'unknown']) test(`R04: ${mode} policy still refuses before admission`, async t => { + const f = await promotionFixture(t, { mode }) + await assert.rejects(f.run(), /refuses/) + assert.deepEqual(f.counts, { admission: 0, reads: 0, workspace: 0, artifact: 0, spawn: 0 }) +}) +test('R04: controller admission refusal cannot start an executor', async t => { + const f = await promotionFixture(t, { controller: false }) + await assert.rejects(f.run(), /no job controller/) + assert.equal(f.counts.admission, 1) + assert.equal(f.counts.artifact, 0) + assert.equal(f.counts.spawn, 0) +}) + +for (const [exitCode, detail] of [[null, 'exit code: null'], [undefined, 'exit code: unknown'], [7, 'exit code: 7'], [0, 'exit code: 0']]) { + test(`R09: completed job preserves exit fact ${String(exitCode)}`, () => { + assert.deepEqual(processOutcome({ status: 'completed', exitCode }), { status: 'completed', detail }) + }) +} +test('R09: killed job keeps interruption terminology even with exit zero', () => { + assert.deepEqual(processOutcome({ status: 'killed', signal: 'SIGTERM', exitCode: 0 }), { status: 'killed', detail: 'signal: SIGTERM' }) + assert.deepEqual(processOutcome({ status: 'killed', signal: null, exitCode: null }), { status: 'killed', detail: 'killed before exit' }) +}) + +for (const prefix of ['C:/fixed/%fixed%', 'C:\\fixed\\%fixed%', '//server/share/%fixed%', '/c/fixed/%fixed%']) { + for (const field of ['executable', 'argv', 'cwd']) test(`R10: absolute percent literal in ${field}: ${prefix}`, () => { + const patch = field === 'executable' ? { executable: typedPath(`${prefix}/producer.exe`) } + : field === 'argv' ? { argv: [typedPath(`${prefix}/input.txt`), { kind: 'literal', value: '%fixed%' }] } + : { cwd: typedPath(prefix) } + const result = parseNativeRequest(request(patch), options) + assert.ok((field === 'argv' ? result.argv[0] : result[field]).includes('%fixed%')) + if (field === 'argv') assert.equal(result.argv[1], '%fixed%') + }) +} +test('R10: absolute internal dollar/home characters are data, including default cwd', () => { + const result = parseNativeRequest(request({ executable: typedPath('C:/$fixed/~/producer.exe'), + argv: [typedPath('/c/$fixed/~/input.txt')] }), { ...options, sessionCwd: 'C:/%fixed%/$literal/~literal' }) + assert.equal(result.executable, 'C:\\$fixed\\~\\producer.exe') + assert.equal(result.argv[0], 'C:\\$fixed\\~\\input.txt') + assert.equal(result.cwd, 'C:\\%fixed%\\$literal\\~literal') +}) +test('R10: percent literals introduced by a resolved MSYS mount stay literal', () => { + assert.equal(nativePath(typedPath('/tmp/input.txt'), value => value === '/tmp/input.txt' ? 'C:/%fixed%/input.txt' : value, 'win32'), 'C:\\%fixed%\\input.txt') +}) +for (const value of ['%TEMP%/input.txt', '$HOME/input.txt', '${TEMP}/input.txt', '~/input.txt', 'relative/%fixed%/input.txt', 'C:relative/%fixed%', '/unknown/%fixed%']) { + test(`R10: unresolved or variable-prefixed paths remain refused: ${value}`, () => { + assert.throws(() => nativePath(typedPath(value), translate, 'win32'), /absolute|expansion/) + }) +} +test('R10: variable prefixes never reach an expanding translator', () => { + let translations = 0 + for (const value of ['%TEMP%/input.txt', '$HOME/input.txt', '~/input.txt']) { + assert.throws(() => nativePath(typedPath(value), () => { translations++; return 'C:/expanded/input.txt' }, 'win32'), /expansion/) + } + assert.equal(translations, 0) +}) From c57d1779c20eaffbfab47c9047263a02d8304e03 Mon Sep 17 00:00:00 2001 From: Kouzaki Umi Date: Fri, 23 Oct 2026 05:31:42 +0800 Subject: [PATCH 2/2] Update bash draft save logic to include dirty state Add 'dirty' state management to bash draft saving logic. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.js b/src/client.js index 9af9c6c..2af335f 100644 --- a/src/client.js +++ b/src/client.js @@ -2082,7 +2082,7 @@ window.__ModuleLoader__.load({ setBashDraft(function (previous) { // A late save only belongs to the exact draft that submitted it. if (previous.scope !== scope || previous.owner !== bashState[0].owner || previous.saved === saved) return previous; - return Object.assign({}, previous, { saved: saved === true }); + return Object.assign({}, previous, { saved: saved === true, dirty: saved === true ? false : previous.dirty }); }); } var mirroredBashPath = snap.value && typeof snap.value.bashPath === "string" ? snap.value.bashPath : "";