From ad86e4c76d5914841a64c306d7947b27fb9da41c Mon Sep 17 00:00:00 2001 From: youyv <12569024+youyv@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:54:43 +0800 Subject: [PATCH] fix: preserve scoped runtime, tool ownership and user configuration --- .github/workflows/ci.yml | 4 +- AGENTS.md | 14 ++- CHANGELOG.md | 13 +++ README.md | 13 ++- README_EN.md | 16 +++ package-lock.json | 49 ++++---- package.json | 8 +- src/bash-tool.js | 25 ++++- src/experimental-tools.js | 11 +- src/index.js | 106 ++++++++---------- src/inline-tools.js | 76 ++----------- src/ops-adopt.js | 168 ++++++++++++++-------------- src/ops-ownership.js | 48 ++++++++ src/path-dialect.js | 32 +++--- src/terminal-bundle.js | 8 +- src/tool-ownership.js | 23 ++++ src/tool-routing.js | 64 +++++++++++ tests/README.md | 30 ++++- tests/audit-preview.mjs | 125 +++++++++++++++++++++ tests/audit-regressions.mjs | 63 +++++++++++ tests/bridge-preview.mjs | 5 +- tests/compatibility-regressions.mjs | 16 +-- tests/host-loader.mjs | 9 +- tests/host-preview.mjs | 6 +- tests/host-runtime.mjs | 13 +++ tests/native-preview.mjs | 5 +- tests/ops-adopt.mjs | 89 +++++++-------- tests/path-preview.mjs | 6 +- tests/preview.mjs | 4 +- tests/review-native-preview.mjs | 5 +- tests/runtime-preview.mjs | 71 ++++++------ tests/smoke.mjs | 25 ++--- tests/terminal-bundle.mjs | 20 +++- 33 files changed, 772 insertions(+), 398 deletions(-) create mode 100644 src/ops-ownership.js create mode 100644 src/tool-ownership.js create mode 100644 src/tool-routing.js create mode 100644 tests/audit-preview.mjs create mode 100644 tests/audit-regressions.mjs create mode 100644 tests/host-runtime.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d183ac..476d725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci on: push: - branches: [main, 'fix/**'] + branches: [main, 'fix/**', 'alpha适配'] pull_request: workflow_dispatch: @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - node: [22, 24] + node: ['24.9.0', '24'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 diff --git a/AGENTS.md b/AGENTS.md index cc7525e..34d938f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,8 @@ 反引号语境下出现插值起点),绝不半改——半改的程序比不改更糟; ② **只翻「整体是路径」的字面量**:注释里的路径、带 scheme 的串、`$VAR`(JS 字面量是数据,不做 shell 展开)、已是 Windows 形式、相对路径一律不动; - ③ **开关独立**:设置卡 `codePaths`(默认开,21 语言词典同齐),关掉只影响程序字面量,不动工具参数 + ③ **运行时语言门控**:只对调用 agent 的实际 `ptcRuntime.language === "typescript"` 添加 JavaScript prelude 和改写源码;Python/未知语言不改源码,不能用全局 peer 意图推断。 + ④ **开关独立**:设置卡 `codePaths`(默认开,21 语言词典同齐),关掉只影响程序字面量,不动工具参数 方言;`posixPaths` 仍是总开关。 仍**不在覆盖内**:动态拼出来的路径(`'/c/' + name`)与插值模板——只有「整条路径是一个字面量」才 可判定,其余靠指令表述说清(模型侧纪律:程序里的路径写成实路径)。同源官方事实:run_code 的 @@ -628,7 +629,7 @@ §4j 硬注入、v0.38.2 fiber 时序同一失败家族的第无数课)。 实验 native 与 Bash 的 cwd 消费共用 `src/session-workdir.js`;native 同样不得硬 inject workingDirectory。服务存在时 ensure 错误不得吞掉,只有缺服务才走旧接缝。 - companion 激活必须确认 apply 已执行;scope 就绪与实际注册成功分别记录。初始挂载、volatile 开关和 Jobs 刷新必须共用按 agent 检查所有权的注册入口:注销自身注册后,外部全局/同 agent 同名工具均让位,knownNames 可用时包含隐藏工具,旧宿主回退 schemas;检查失败或注册异常记录日志,不阻断其他 companion。让位后保留待命 scope,下一次刷新可恢复。 + companion 激活必须确认 apply 已执行;scope 就绪与实际注册成功分别记录。初始挂载、volatile 开关和 Jobs 刷新必须共用按 agent 检查所有权的注册入口:注销自身注册后,外部全局/同 agent 同名工具均让位,通过当前宿主公开 `scopeChainOf` 逐层检查 global/祖先/当前 scope 的 schemas,包含在子 scope 隐藏的工具;检查失败或注册异常记录日志,不阻断其他 companion。让位后保留待命 scope,下一次刷新可恢复。 4m. **native 工具首步稳定(v0.47.1,issue #38)**:任何模型可见工具都不允许在 `agent/created` 之后才异步注册——step 1 少一个、step 2 补上会重写 provider 前缀,把整段会话缓存打掉 @@ -642,12 +643,19 @@ 状态重新推导)。槽位与真实 executor 必须共用 `src/native-tool.js` 的 `nativeToolDefinition()`,name/description/parameters/output/presentCall 逐字节一致。 - 真实模块只允许 `slot.install(definition)` 换实现,绝不二次 register;槽位在 - `experimentalNative` 关闭、agent dispose、face 不再需要 bash 时 `dispose`。调用早于 + agent dispose、face 不再需要 bash 时 `dispose`。运行中 agent 的 eager schema 按 toolFace 快照保持稳定,`experimentalNative` 关闭仍立即拒绝其执行;新会话应用新工具面。调用早于 真实 executor 时等待就绪(15s 上限 + 可取消),不把未初始化调用当成功。 - 回归:smoke 的 #38 源码断言 + native-executor 的定义一致性;真实 ToolRuntime 预览 `tests/runtime-preview.mjs` 的 eager-slot 用例需要已安装宿主(host-loader),不在 `npm test` 内。本机无 Windows 时不得声称真机通过,CHANGELOG 必须写明该边界。 +## 当前宿主测试与配置所有权 + +- 宿主预览使用真实 Native/Ptc executor,`tools.execute({calls,...})` 返回 `results`;不得用旧单调用签名或给 registry 打兼容补丁。 +- 路由文案由自有 Bash definition 与 PTC prompt section 提供;`system-prompt/assemble` 不拥有工具声明,修改 `schemas()` 的副本不会进入模型请求。 +- 持久终端补全只改 `config.shellPath`,保留其它配置/注释;整个读改写使用与 configEditor 相同的 profile 锁。 +- dsh-ops 只恢复 `.gitbash-ops-ownership.json` 记录的实际修改字段;原有 false、不明来源的历史 false 和后续用户选择不得认领。重试计时器、正在进行的接管与恢复都由注入 scope 管理。 + ## 上游追踪纪律:dsh 终端面与 dsh-ops(2026-10-19 用户要求) 本插件的三个接管面都锚在**别人的包结构**上,上游任何一侧发新版都必须主动核对并做出适配决断, diff --git a/CHANGELOG.md b/CHANGELOG.md index f3f8720..472e1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## Unreleased — 2026-11-06 + +**类型**: fix / test(Windows 本机审计修复,宿主基线仍为 0.2.2-rc.1) + +- **Python 程序保留**:从调用 agent 的实际 PTC runtime 读取语言;只对 TypeScript 执行程序字面量转换及 JS TEMP prelude。Python/未知运行时源码不变,工具路径字段仍独立转换。 +- **终端配置保留**:只填 `config.shellPath`,保留其它参数与 YAML 注释;读/改/写/回读全程使用宿主 configEditor 同款 profile 文件锁。 +- **隐藏工具所有权**:经公开 scopeChainOf 逐层检查全局、祖先和当前 agent 的 schema,动态 companion 和 eager native slot 共用判据;不读取私有 tools.view,也不假设跨 scope 注册会冲突。 +- **dsh-ops 精确恢复**:profile 旁原子保存原值收据,先记账后编辑;只恢复实际认领且仍为插件写入值的字段。原有 false、后续 true/删除、并发编辑均保留;拒绝写入撤销新增认领,恢复失败保留记录以便重试。卸载先清理重试定时器、等待正在进行的接管,再恢复。 +- **模型路由提示**:Bash 自有定义动态提供描述,由宿主 native/PTC provider 投影;PTC 指引使用真实 prompt section。run_code 提示从选中 executor 的声明判断,并按实际语言门控,删除无效的 schema 副本修改。 +- **测试设施**:host-loader 防同步 require.resolve 重入;preview 统一挂载当前 Native/Ptc executor 并使用 calls/results 批次接口;修正过期的 live face、SDK 与 registry 夹具。终端 YAML 测试纳入 npm test,移除个人路径探测,增加锁定 yaml dev dependency;测试 Cordis 对齐 4.1.0,CI 覆盖 Node 24.9 下限与 24。 +- **独立回归**:完整插件入口的最终 native/PTC 提示、complete persona、真实 Python 解释器、隐藏祖先工具;跨进程 dsh-ops 恢复、原有 false、并发/失败编辑;终端其它配置及注释保留。 +- **验证**:Windows 本机 Desktop/Core 0.2.2-rc.1(Cordis 4.1.0,内置 Node 24.21.0)只读提取后进行隔离预览;npm test 372/372 通过;内置 Node 执行完整 test:preview 93/93 通过,均无跳过;npm pack dry-run 与 diff --check 通过。未修改生产 profile 或安装目录,未调用模型、未发 npm 版本;不声称完成 GUI、真实 dsh-ops 插件或 Python PTC 插件的完整会话验证。 + ## v0.50.0 — 2026-11-06 **类型**:breaking(feat:适配 dsh-ops 0.5.0 单行配置,放弃 0.4.x 旧行集) diff --git a/README.md b/README.md index a23fa1f..334629c 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ POSIX 宿主上完整保留; 0.5.0 已移除旧 `dsh-ops-shell`/`dsh-ops-file`/`dsh-ops-mode-*` 行,本版也删掉对应接管逻辑: 旧 dsh-ops 的 profile 不再被本插件去重,请先升级 dsh-ops 0.5.0(或留在本插件 v0.49.0)。 **停用或卸载本插件的瞬间自动还原**(dispose 钩子只撤回自己写的 `bash:false`,同 section 的其它设置原样保留), - 重新启用又会自动接管。三个边界:①关机状态下卸载的残余覆盖,可用 dsh-ops 设置卡恢复默认,或重新安装/停用本插件 + 重新启用又会自动接管。接管前会在 profile 目录的 `.gitbash-ops-ownership.json` 中原子记录实际修改字段的原值, + 重启后仍可精确恢复;用户原先设置的 `bash:false` 不会被认领或删除。升级前留下且没有记录的 `false` 保持原样, + 不猜测它属于用户还是旧插件。三个边界:①关机状态下卸载的残余覆盖,可用 dsh-ops 设置卡恢复默认,或重新安装/停用本插件 触发还原;②停用时若覆盖值已被你手动改回 `true`,视为你的选择、不动它;③**0.5.0 只对四个内置模式生效**: dsh-ops 不再全局接入未列出的自定义 preset,本插件的 `ptc-cordis` 与「· Git Bash」变体 id 不在四个内置模式中, 那些会话里 dsh-ops 不会发布 `ops_bash`;dsh-ptc-cordis-preset v0.26.0 自己挂 `dsh-ops/integration` 取文件工具, @@ -433,7 +435,7 @@ PTC/创造及用户自建模式一律覆盖。 ## 显式工具(Unreleased) -**运行时开关 `experimentalNative`(默认 true):** 设置 → 插件 → dsh-gitbash-shell →「实验原生执行 / fail-closed」。开启时 Windows 受限/未确定模式拒绝 Bash,提供可用的两个显式工具;关闭立即注销并禁用两个工具,恢复明确标注 `unconfined` 的兼容 Bash(不执行 shell 文件写入限制)。修改作用于现有会话后续调用,无需重启,不终止已启动的任务。拒绝时用户也可在会话访问模式控件选择 `danger-full-access`;Agent 不得自行放宽权限。与路径方言及 codePaths 开关独立。 +**运行时开关 `experimentalNative`(默认 true):** 设置 → 插件 → dsh-gitbash-shell →「实验原生执行 / fail-closed」。开启时 Windows 受限/未确定模式拒绝 Bash,提供可用的两个显式工具;关闭立即禁用两个工具的执行;已有 eager native 槽位保留当前会话的 schema,下一会话应用新工具面,并恢复明确标注 `unconfined` 的兼容 Bash(不执行 shell 文件写入限制)。修改作用于现有会话后续调用,无需重启,不终止已启动的任务。拒绝时用户也可在会话访问模式控件选择 `danger-full-access`;Agent 不得自行放宽权限。与路径方言及 codePaths 开关独立。 两个工具随 Windows 的自有 bash 工具挂入同一 agent-owned scope;不替换官方工具或 root @@ -518,3 +520,10 @@ return await fs.readFile(resolved.native_path, 'utf8'); ## 许可 MIT © KannaKuron。参考与致敬:[dsh-ptc-cordis-preset](https://github.com/KannaKuron/dsh-ptc-cordis-preset)。 + +### 运行时兼容性与配置保留 + +- `run_code` 的程序字面量转换和 JavaScript TEMP 前置代码只适用于当前会话实际使用的 TypeScript 运行时;Python 或未知运行时的源码保持原样,普通工具路径参数仍独立转换。 +- 自动补全持久终端的 `shellPath` 保留已有 `shellArgs`、超时、回滚行数等设置及注释,并与宿主配置编辑器使用同一个 profile 锁。 +- 同名工具的全局、祖先和当前 agent 所有权均被检查;隐藏的外部工具也不会被插件重新注册覆盖。 +- Bash 路由提示通过插件自有工具定义进入普通模式及 PTC SDK;PTC 程序指引通过独立提示段提供,不再修改宿主返回的 schema 副本。 diff --git a/README_EN.md b/README_EN.md index c9e5c8b..4ded977 100644 --- a/README_EN.md +++ b/README_EN.md @@ -373,3 +373,19 @@ When [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) (v0.1 MIT © KannaKuron. Inspired by [dsh-ptc-cordis-preset](https://github.com/KannaKuron/dsh-ptc-cordis-preset). + +### Runtime compatibility and configuration preservation + +- Program-literal rewriting and the JavaScript TEMP prelude run only for the + invoking session's actual TypeScript runtime. Python and unknown runtime source + stays unchanged; ordinary tool path parameters are translated independently. +- Persistent-terminal shellPath adoption preserves other options and comments, + using the same profile lock as Core's configuration editor. +- dsh-ops adoption atomically records original values in the profile-local + `.gitbash-ops-ownership.json` before editing. Restoration survives a restart, + changes only owned fields, and preserves pre-existing false and later user + choices. Unjournalled false values left by older versions are deliberately + left alone because their ownership cannot be determined. +- Companion ownership checks include hidden global and ancestor tools. Bash + routing is emitted by its owned definition for native schemas and PTC SDKs; + PTC program guidance uses a separate prompt section rather than schema copies. diff --git a/package-lock.json b/package-lock.json index 0ff713a..cbdba6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.50.0", "license": "MIT", "devDependencies": { - "@deepseek-ai/schemastery": "^3.19.0" + "@deepseek-ai/cordis": "^4.1.0", + "@deepseek-ai/schemastery": "^3.19.0", + "yaml": "^2.8.1" }, "engines": { "dsh": ">=0.2.2-rc.1", @@ -34,21 +36,21 @@ } }, "node_modules/@deepseek-ai/cordis": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/@deepseek-ai/cordis/-/cordis-4.0.4.tgz", - "integrity": "sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==", + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@deepseek-ai/cordis/-/cordis-4.1.0.tgz", + "integrity": "sha512-CJrjNyBkKn1SfvBQUcgFYKFnLjS+uKrhdMsbHU60xabvi12d+xQ+IQSNlME5Z+B+4Zi6n4BDknKY94tEPeo47w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@deepseek-ai/cosmokit": "~1.8.5", + "@deepseek-ai/cosmokit": "~1.9.0", "@standard-schema/spec": "^1.1.0" }, "bin": { "cordis": "bin.js" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "~1.0.9", - "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + "@deepseek-ai/cordis-plugin-include": "~1.1.0", + "@deepseek-ai/cordis-plugin-loader": "~1.1.0" }, "peerDependenciesMeta": { "@deepseek-ai/cordis-plugin-include": { @@ -60,11 +62,11 @@ } }, "node_modules/@deepseek-ai/cosmokit": { - "version": "1.8.5", - "resolved": "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", - "integrity": "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==", - "license": "MIT", - "peer": true + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.9.0.tgz", + "integrity": "sha512-5O1S53mZiJyZYcvxxD0Qv1EY7qFPSxbGyD8CbVK6gw7W9FvcXZj2YIX1ZkOqyQQ3Xt2rFkZDHJv0+V3Zmmp8CA==", + "dev": true, + "license": "MIT" }, "node_modules/@deepseek-ai/schemastery": { "version": "3.19.0", @@ -77,13 +79,6 @@ "@standard-schema/spec": "^1.1.0" } }, - "node_modules/@deepseek-ai/schemastery/node_modules/@deepseek-ai/cosmokit": { - "version": "1.9.0", - "resolved": "https://registry.npmmirror.com/@deepseek-ai/cosmokit/-/cosmokit-1.9.0.tgz", - "integrity": "sha512-5O1S53mZiJyZYcvxxD0Qv1EY7qFPSxbGyD8CbVK6gw7W9FvcXZj2YIX1ZkOqyQQ3Xt2rFkZDHJv0+V3Zmmp8CA==", - "dev": true, - "license": "MIT" - }, "node_modules/@dsh-ops/fastctx-win32-x64": { "version": "0.2.1", "resolved": "https://registry.npmmirror.com/@dsh-ops/fastctx-win32-x64/-/fastctx-win32-x64-0.2.1.tgz", @@ -101,7 +96,21 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } } } } diff --git a/package.json b/package.json index 9282e73..ade5c19 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,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 tests/terminal-cancel.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", + "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/terminal-cancel.mjs tests/review-core-regressions.mjs tests/review-native-regressions.mjs tests/terminal-bundle.mjs tests/audit-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 tests/audit-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": [ @@ -90,6 +90,8 @@ }, "icon": "./icon.svg", "devDependencies": { - "@deepseek-ai/schemastery": "^3.19.0" + "@deepseek-ai/cordis": "^4.1.0", + "@deepseek-ai/schemastery": "^3.19.0", + "yaml": "^2.8.1" } } diff --git a/src/bash-tool.js b/src/bash-tool.js index d7b0858..79419df 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -1,3 +1,4 @@ +import { applyToolRouting, PTC_ROUTING } from './tool-routing.js' /** * dsh-gitbash-shell/bash-tool — the plugin's OWN bash tool (v0.39.0, route B). * @@ -317,6 +318,16 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm // optionally at the use site (below) instead of injecting it. inject: ['tools', 'systemPrompt', 'shellEnv'], apply(ctx, config = {}) { + const routing = config.routing + if (routing) ctx.systemPrompt.section({ + name: 'gitbash-shell:program-routing', + order: ctx.systemPrompt.getSectionOrder('TOOL_BASH'), + text: ({ scope }) => { + const owner = scope ?? routing.scope + const offered = ctx.tools.selectExecutor(owner).describe(ctx.tools.definitions(owner)).schemas + return offered.some(tool => tool.name === 'run_code') ? PTC_ROUTING : '' + }, + }) const backgroundEnabled = config?.enableRunInBackground ?? true const promoteOnTimeout = (config?.promoteOnTimeout ?? true) && backgroundEnabled // The capability fact rides the executor's constructor read of @@ -540,7 +551,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm if (typeof defineTool !== 'function') { throw new Error('bash tool: @deepseek-ai/dsh-tools defineTool is unavailable') } - return defineTool({ + const definition = defineTool({ name: 'bash', description: bashDescription(), parameters: parametersSpec(), @@ -560,6 +571,18 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm presentCall: presentBashCall, presentResult: presentBashResult, }) + if (routing) Object.defineProperty(definition, 'description', { enumerable: true, get() { + // Definitions contain getters too; reading only names avoids recursion + // through schemas() -> this description. Do not recommend conditional + // foreign tools without an admitted step. Core projects this for native/PTC. + const names = ctx.tools.definitions(routing.scope) + .filter(tool => ctx.tools.executable(tool.name, routing.scope) !== undefined) + .map(tool => ({ name: tool.name })) + const own = { name: 'bash', description: bashDescription() } + applyToolRouting([own, ...names.filter(tool => tool.name !== 'bash')], routing.options()) + return own.description + } }) + return definition } // The background surface follows the registry (index.ts:536), verbatim. diff --git a/src/experimental-tools.js b/src/experimental-tools.js index e66682e..436f2fc 100644 --- a/src/experimental-tools.js +++ b/src/experimental-tools.js @@ -1,3 +1,4 @@ +import { toolNameOccupied } from './tool-ownership.js' /** Live registration for agent-owned companions; restrict() accepts global names only. */ export function registerExperimentalTool(ctx, definition, enabled, { scope, log = () => {} } = {}) { let unregister @@ -8,16 +9,10 @@ export function registerExperimentalTool(ctx, definition, enabled, { scope, log unregister = undefined if (enabled?.() === false) return const candidate = definition() - // Inspect AFTER removing only our registration. dsh made ctx.tools.view - // private, so read the public `schemas()` listing. It omits names - // restricted away from this scope; a hidden foreign owner then surfaces - // through register()'s own conflict error below, which is why the - // inspection is a fast path rather than the ownership authority. + // Global and ancestor registrations remain owned even when hidden here. let occupied try { - const listed = ctx.tools.schemas(scope) - if (listed === undefined) throw new Error('tool ownership inspection unavailable') - occupied = listed.some(tool => tool.name === candidate.name) + occupied = toolNameOccupied(ctx.tools, candidate.name, scope) } catch (error) { log(`${candidate.name} registration skipped: ${error?.message ?? error}`) return diff --git a/src/index.js b/src/index.js index 2698d8e..0e568c5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import { prepareToolOwnership } from './tool-ownership.js' /** * dsh-gitbash-shell — host half (route B: the own bash tool + preset variants). * @@ -28,16 +29,14 @@ import { mkdtempSync, readFileSync, readdirSync, - renameSync, rmSync, - writeFileSync, } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js' import { loadSchemastery } from './schemastery.js' -import { applyToolRouting, mountInlineTools } from './inline-tools.js' +import { mountInlineTools } from './inline-tools.js' import { adoptTerminalBundlePatch, detectTerminalBundle, terminalBundleReport, terminalDialect } from './terminal-bundle.js' import { resolveHostModule } from './bash-tool.js' import { mountFastctx } from './fastctx-bridge.js' @@ -60,6 +59,7 @@ import { rewriteFailureMessage, rewriteResultPaths, runCodeHintFor, + programLanguageFor, scanCodeLiterals, translateDispatch, translateGlobArguments, @@ -1242,22 +1242,15 @@ export async function apply(ctx, config = {}) { // conditional tools are judged exactly as execution will judge them. const offeredTools = (() => { try { - return ctx.get('tools')?.schemas?.(assembleContext && assembleContext.agent, assembleContext && assembleContext.toolSet) ?? [] + const registry = ctx.get('tools') + const scope = assembleContext?.scope ?? assembleContext?.agent + return registry?.selectExecutor(scope).describe(registry.definitions(scope, assembleContext?.toolSet)).schemas ?? [] } catch { return [] } })() - // Routing is dialect-independent and follows actual offered tools. - // PTC may expose only run_code here: its generic SDK decision rule - // relies on the registered bindings' own descriptions, not guessed - // hidden names. Replace our block on reassembly/visibility changes. - if (assembly && typeof assembly === 'object') { - applyToolRouting(offeredTools, { - pwshVisible: liveSettings.pwshTool() === true, - msys: applies && dialect.posixPaths === true, - terminalGitBash: terminalDialect.gitBash === true, - }) - } + // Routing is emitted by the owned Bash definition and prompt section; + // this hook transforms only prompt text and the language-specific hint. if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') { for (const section of Array.isArray(assembly.sections) ? assembly.sections : []) { if (section && typeof section.text === 'string') section.text = windowsToMsys(section.text) @@ -1275,7 +1268,7 @@ export async function apply(ctx, config = {}) { // v0.20.1: the run_code sentence rides the SAME context entry, but // only when this assembly actually offers the tool (see // runCodeHintFor) — every other mode stays quiet. - const hint = runCodeHintFor(offeredTools, dialect.codePaths === true) + const hint = runCodeHintFor(offeredTools, dialect.codePaths === true, programLanguageFor(ctx, assembleContext?.agent)) if (hint !== '') { for (const assembled of Array.isArray(assembly.contexts) ? assembly.contexts : []) { if (assembled && assembled.name === 'gitbash-shell:posix-paths' && typeof assembled.text === 'string') { @@ -1336,7 +1329,7 @@ export async function apply(ctx, config = {}) { const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null echoEnv = env if (exec && exec.arguments && typeof exec.arguments === 'object') { - const translated = translateDispatch(exec, dialect, env) + const translated = translateDispatch(exec, dialect, env, programLanguageFor(ctx, exec?.agent)) if (translated !== exec.arguments) exec.arguments = translated } } @@ -1756,39 +1749,32 @@ export async function apply(ctx, config = {}) { if (process.platform === 'win32') { try { ctx.inject(['configEditor'], (opsEditorCtx) => { - // The dsh-ops rows may activate after this plugin; retry a few - // times (bounded) instead of silently giving up, like the terminal row. let attempts = 0 - let watched = false - const run = async () => { - attempts += 1 - let result - try { - result = await adoptOpsBashRemoval(opsEditorCtx.configEditor) - } catch (error) { - console.log(`${TAG} dsh-ops bash removal crashed: ${error?.message ?? error}`) - return - } - if (result.status === 'skip-row-absent' && attempts < 6) { - setTimeout(() => { run().catch((error) => console.log(`${TAG} dsh-ops bash removal retry failed: ${error?.message ?? error}`)) }, 3000) - return - } - console.log(`${TAG} ${opsAdoptReport(result)}`) - // Restore wiring: only once the removal is actually in force. - // The hook is idempotent (a second run finds nothing to lift), - // so an inject-callback re-run cannot double-lift. - if (!watched && (result.status === 'adopted' || result.status === 'present')) { - watched = true - ctx.effect(() => async () => { - try { - console.log(`${TAG} ${opsRestoreReport(await restoreOpsBash(opsEditorCtx.configEditor))}`) - } catch (error) { - console.log(`${TAG} dsh-ops bash restore crashed: ${error?.message ?? error}`) - } - }, 'gitbash: dsh-ops bash removal restore') - } + let closed = false + let retry + let pending = Promise.resolve() + // Own retries and the in-flight edit before starting either. Disposal + // waits for adoption, then restores only journalled fields. + opsEditorCtx.effect(() => async () => { + closed = true + clearTimeout(retry) + await pending + try { console.log(`${TAG} ${opsRestoreReport(await restoreOpsBash(opsEditorCtx.configEditor))}`) } + catch (error) { console.log(`${TAG} dsh-ops bash restore crashed: ${error?.message ?? error}`) } + }, 'gitbash: dsh-ops bash removal restore') + const run = () => { + if (closed) return + pending = (async () => { + attempts++ + const result = await adoptOpsBashRemoval(opsEditorCtx.configEditor) + if (!closed && result.status === 'skip-row-absent' && attempts < 6) { + retry = setTimeout(run, 3000) + retry.unref?.() + } + console.log(`${TAG} ${opsAdoptReport(result)}`) + })().catch(error => console.log(`${TAG} dsh-ops bash removal failed: ${error?.message ?? error}`)) } - run().catch((error) => console.log(`${TAG} dsh-ops bash removal failed: ${error?.message ?? error}`)) + run() }) } catch (error) { console.log(`${TAG} dsh-ops bash removal wiring failed: ${error?.message ?? error}`) @@ -1822,20 +1808,14 @@ export async function apply(ctx, config = {}) { return } const resolution = readBashResolution() - const result = await adoptTerminalBundlePatch( + const atomic = await resolveHostModule(ctx, '@deepseek-ai/dsh-atomic-write') + if (typeof atomic?.withFileLock !== 'function' || typeof atomic?.writeFileAtomic !== 'function') { + throw new Error('current host atomic profile-edit capability unavailable') + } + const update = () => adoptTerminalBundlePatch( { readFile: (file) => readFileSync(file, 'utf8'), - writeFileAtomic: async (file, data) => { - // The host's atomic write when resolvable; a same-directory - // tmp+rename otherwise — never a torn patch file. - try { - const atomic = await resolveHostModule(ctx, '@deepseek-ai/dsh-atomic-write') - if (typeof atomic?.writeFileAtomic === 'function') return atomic.writeFileAtomic(file, data, { mode: 0o600 }) - } catch { /* fallback below */ } - const tmp = `${file}.gitbash-${Date.now()}.tmp` - writeFileSync(tmp, data, { mode: 0o600 }) - renameSync(tmp, file) - }, + writeFileAtomic: (file, data) => atomic.writeFileAtomic(file, data, { mode: 0o600 }), yaml: () => resolveHostModule(ctx, 'yaml'), }, { @@ -1846,6 +1826,7 @@ export async function apply(ctx, config = {}) { patchPath, }, ) + const result = await atomic.withFileLock(join(dirname(patchPath), 'package.json'), update) if (result.status === 'adopted' || result.status === 'updated' || result.status === 'unchanged') { terminalDialect.gitBash = true } @@ -1894,6 +1875,7 @@ export async function apply(ctx, config = {}) { // stable gitbash_native definition synchronously. This is deliberately a // mount-time preflight: if the public native packages are unavailable we // advertise no slot at all instead of leaving a ghost tool behind. + await prepareToolOwnership(ctx) let nativeContract try { const [toolsModule, localBashModule, outputModule, nativeModule] = await Promise.all([ @@ -1933,6 +1915,10 @@ export async function apply(ctx, config = {}) { pwshEnabled: () => liveSettings.pwshTool() === true, experimentalEnabled: () => liveSettings.experimentalNative(), nativeContract, + routingOptions: (agent) => ({ + msys: liveSettings.posix() && dialectApplies(liveSettings.dialect(), agent), + terminalGitBash: terminalDialect.gitBash === true, + }), // Explicit conversion does not rewrite source or change auto-dialect // settings. The same lazy, verified mount facts are used each call. translatePath: (value) => translateMsysPath(value, buildTranslateEnv(readConfiguredBashPath())), diff --git a/src/inline-tools.js b/src/inline-tools.js index 5937a05..d42d46b 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -1,3 +1,4 @@ +import { toolNameOccupied } from './tool-ownership.js' /** * dsh-gitbash-shell/inline-tools — runtime shell-tool patching for the OFFICIAL * presets on Windows (v0.35.0; route B since v0.39.0). @@ -112,70 +113,7 @@ export function deniedOpsShellReason() { return 'the dsh-ops shell tools are denied on this host: dsh-gitbash-shell owns the shell (its own Git Bash bash tool). Use the bash tool for commands; the other ops_* tools (files, search, background jobs) are unaffected.' } -/** - * The routing addendum appended to the `bash` tool's description through the - * system-prompt assemble waterfall. TWO variants (v0.37.0, user decision): - * the pwsh tool is hidden by default, and a routing sentence that names a tool - * the agent cannot see only teaches the model to call a hidden name. The ON - * variant names `pwsh` (the REAL PowerShell tool, real again since route B — - * the official executor stays in place); the OFF variant states bash-only - * routing. Both hold regardless of the path dialect a delegate sees. - */ -export const GITBASH_ROUTE_ADDENDUM = [ - 'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities; use the pwsh tool ONLY when PowerShell cmdlets, registry, services or WMI are truly required.', - 'Fix failed bash syntax in bash; never embed `pwsh -Command` inside a bash command as a workaround (variable expansion and output encoding break there). Choose a separately offered native route for its documented purpose before execution, never as a policy-denial retry.', -].join('\n') - -/** The bash-only routing variant, used while the pwsh switch is off (the default). */ -export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [ - 'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities — it is this host\'s shell.', - 'Fix failed bash syntax in bash; no PowerShell tool is offered here. Do not invent a hidden tool or embed another interpreter as a workaround.', -].join('\n') - -/** - * The dialect addendum (MSYS facts) — appended ONLY while this plugin's path - * dialect is on for the assembling agent, because it describes the /c/ world - * the dialect creates. - */ -export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths use drive roots (/c/Users/...), and ~, /tmp, /dev/null and /usr/... resolve exactly as Git Bash resolves them.' - -const ROUTING_START = '\n[gitbash tool routing]\n' -const ROUTING_END = '\n[/gitbash tool routing]' -const ROUTING_SAFETY = 'Choose the execution route before running. A policy/access denial is a stop condition: never switch tools, shells or permissions to bypass it. A decoding/display failure does not mean the command failed to run; preserve its exit facts and never blindly repeat a side-effecting command. Do not guess unknown or mixed encodings.' -const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY - -/** Model guidance names only tools in this assembly, not a switch's intent. */ -export function toolRoutingHintFor(tools, { pwshVisible = false, terminalGitBash = false } = {}) { - const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name)) - if (!names.has('bash')) return '' - const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH] - // v0.41.0: the persistent-terminal dialect fact. Said ONLY when the host - // actually carries the terminal tools AND our terminal-bundle adoption has - // settled the rows to Git Bash (the caller passes the settled flag) - a - // bundle left at its official defaults runs pwsh there, and a sentence - // must never describe a shell the button will not start. - if (terminalGitBash === true && names.has('terminal_open')) lines.push('The persistent terminal tools (terminal_open/read/send/...) run Git Bash (MSYS) on this host: the same drive-root paths (/c/...) and MINGW64 semantics as this bash tool; they are NOT PowerShell.') - if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.') - if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.') - lines.push(ROUTING_SAFETY) - return lines.join('\n') -} - -/** Add a replaceable, idempotent block; a reused assembly follows visibility. */ -export function applyToolRouting(tools, { pwshVisible = false, msys = false, terminalGitBash = false } = {}) { - if (!Array.isArray(tools)) return - for (const tool of tools) { - if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue - let description = tool.description - const start = description.indexOf(ROUTING_START) - const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start) - if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length) - const hint = tool.name === 'bash' - ? toolRoutingHintFor(tools, { pwshVisible, terminalGitBash }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '') - : PTC_ROUTING - tool.description = description + ROUTING_START + hint + ROUTING_END - } -} +export { GITBASH_ROUTE_ADDENDUM, GITBASH_ROUTE_ADDENDUM_NO_PWSH, GITBASH_MSYS_ADDENDUM, toolRoutingHintFor, applyToolRouting } from './tool-routing.js' /** * The deny reason for a hidden pwsh call (route B wording): the tool is REAL @@ -374,7 +312,12 @@ export function mountInlineTools(ctx, deps) { if (!isLive(agent, state)) return const registry = scope.ctx?.get('tools') if (!registry || typeof scope.ctx.plugin !== 'function') return - const fiber = scope.ctx.plugin(module) + const fiber = scope.ctx.plugin({ ...module, apply(toolCtx, config) { + return module.apply(toolCtx, { ...config, routing: { + scope: agent, + options: () => ({ pwshVisible: state.toolFace.pwshVisible, ...deps.routingOptions?.(agent) }), + } }) + } }) await untilDisposed(fiber, state.controller.signal) if (!isLive(agent, state)) return // An INACTIVE fiber resolves its await() without ever running apply() @@ -500,7 +443,8 @@ export function mountInlineTools(ctx, deps) { if (!jobs) return // A foreign or earlier owner of the name wins: never shadow a tool we did // not create. - if (faceNames(agent).includes('gitbash_native')) return + try { if (toolNameOccupied(tools(ctx), 'gitbash_native', agent)) return } + catch (error) { log(`gitbash_native eager slot skipped: ${error?.message ?? error}`); return } let resolveReady let rejectReady const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject }) diff --git a/src/ops-adopt.js b/src/ops-adopt.js index c50e768..c43d5b6 100644 --- a/src/ops-adopt.js +++ b/src/ops-adopt.js @@ -1,3 +1,4 @@ +import { opsOwnership } from './ops-ownership.js' /** * dsh-gitbash-shell — dsh-ops bash removal from the agent face. * @@ -42,7 +43,9 @@ * every unrelated dsh-ops key (panel/tui/discovery/...) intact. * * OWNERSHIP AND RESTORE (the owner requirement: uninstalling this plugin must - * give dsh-ops its bash back): + * restore only our changes): + * - a profile-local ownership receipt records original values before edits; + * existing false values are never claimed, including after a restart; * - plugin disabled / uninstalled while dsh runs => our fiber stops => the * dispose hook below runs and lifts the override through the same editor; * - plugin re-enabled => boot re-adopts (idempotent: the plan is a no-op @@ -64,7 +67,7 @@ * 1. NEVER write when the row is absent — no dsh-ops, nothing to remove. * 2. NEVER hand-edit the profile patch file — the official editor only. * 3. NEVER claim success without reading the value back. - * 4. Restore returns the INHERITED value for OUR key only: unrelated + * 4. Restore returns the recorded original override (or inherited value) for OUR key only: unrelated * overrides in the same row (the user's own dsh-ops settings) survive. * 5. NEVER write to a disabled row or mode section (it publishes nothing); * restore still lifts our value from one the user disabled afterwards. @@ -262,9 +265,12 @@ function resultRowsOf(row, plan) { * never throws for an expected condition. */ export async function adoptOpsBashRemoval(editor) { - if (editor === null || editor === undefined || typeof editor.configuration !== 'function') { - return { status: 'skip-row-absent', rows: [], detail: 'the config editor is unavailable, so the dsh-ops row cannot be configured' } - } + if (!editor || typeof editor.configuration !== 'function') return { status: 'skip-row-absent', rows: [], detail: 'the config editor is unavailable' } + const store = opsOwnership(editor) + return store.run(() => adoptOwned(editor, store)) +} + +async function adoptOwned(editor, store) { const rows = opsRowsOf(editor) if (rows.length === 0) { return { status: 'skip-row-absent', rows: [], detail: 'no dsh-ops row is mounted in this profile' } @@ -277,21 +283,48 @@ export async function adoptOpsBashRemoval(editor) { if (targets.length > 0 && typeof editor.edit !== 'function') { return { status: 'write-failed', rows: results, detail: 'the config editor exposes no edit()' } } + const receipt = await store.read() + const originalReceipt = structuredClone(receipt) + for (const { row, mode } of targets) { + const value = boolOf(row, 'override', mode, 'bash') + // Never claim a pre-existing false. It is not among the adopt targets. + receipt.fields[mode] = { hadOverride: value !== undefined, ...(value === undefined ? {} : { value }) } + } + // Persist before editing: a crash/reload after configEditor commits must not + // lose the original value. Failed writes remain retryable and are compared on restore. + if (targets.length) await store.write(receipt) for (const { row } of plans) { const modes = targets.filter((target) => target.row === row).map((target) => target.mode) if (modes.length === 0) continue const id = rowIdOf(row) + const conflicted = new Set() try { await editor.edit(row.entry, (current) => { const next = isRecord(current) ? { ...current } : {} for (const mode of modes) { const section = isRecord(next[mode]) ? { ...next[mode] } : {} + const expected = boolOf(row, 'override', mode, 'bash') ?? boolOf(row, 'inherited', mode, 'bash') + // configEditor serializes against other settings writers. Recheck its + // fresh current value rather than claiming a concurrent user edit. + if (section.bash !== expected) { conflicted.add(mode); continue } section.bash = false next[mode] = section } return next }) + if (conflicted.size) { + for (const mode of conflicted) delete receipt.fields[mode] + await store.write(receipt) + return { status: 'write-failed', rows: results, detail: 'dsh-ops settings changed during adoption; concurrent choices were preserved' } + } } catch (error) { + // Core edit() rolls back a rejected reconciliation. Do not let a failed + // attempt acquire ownership over a future user-written false value. + for (const mode of modes) { + if (Object.hasOwn(originalReceipt.fields, mode)) receipt.fields[mode] = originalReceipt.fields[mode] + else delete receipt.fields[mode] + } + await store.write(receipt) return { status: 'write-failed', rows: results, @@ -336,89 +369,56 @@ export async function adoptOpsBashRemoval(editor) { * @returns {Promise<{status: string, rows: object[], detail?: string}>} the outcome. */ export async function restoreOpsBash(editor) { - if (editor === null || editor === undefined || typeof editor.configuration !== 'function') { - return { status: 'restore-failed', rows: [], detail: 'the config editor is unavailable, so the override cannot be lifted' } - } - const rows = opsRowsOf(editor) - if (rows.length === 0) { - return { status: 'skip-row-absent', rows: [], detail: 'no dsh-ops row is mounted — nothing to restore' } - } - // Only modes where OUR value is in force are touched. A mode the user flipped - // back ON afterwards is their own later choice — leave it exactly where it is. - const results = [] - const targets = [] - for (const row of rows) { - const id = rowIdOf(row) - for (const mode of OPS_MODES) { - const overrideBash = boolOf(row, 'override', mode, 'bash') - if (overrideBash === undefined) { - results.push({ id, mode, action: 'unchanged', reason: 'no bash override to lift in ' + mode }) - continue - } - if (overrideBash !== false) { - results.push({ id, mode, action: 'unchanged', reason: 'the ' + mode + ' override reads bash=true — a later choice of the user, left alone' }) - continue - } - results.push({ id, mode, action: 'restore' }) - targets.push({ row, mode }) - } - } - if (targets.length === 0) { - return { status: 'unchanged', rows: results, detail: results.map((row) => row.mode + ': ' + row.reason).join('; ') } - } - if (typeof editor.edit !== 'function') { - return { status: 'restore-failed', rows: results, detail: 'the config editor exposes no edit()' } - } - for (const row of rows) { - const modes = targets.filter((target) => target.row === row).map((target) => target.mode) - if (modes.length === 0) continue - const id = rowIdOf(row) - try { - await editor.edit(row.entry, (current, inherited) => { - const next = isRecord(current) ? { ...current } : {} - for (const mode of modes) { - const section = isRecord(next[mode]) ? { ...next[mode] } : {} - const inheritedSection = isRecord(inherited) && isRecord(inherited[mode]) ? inherited[mode] : undefined - if (inheritedSection !== undefined && Object.hasOwn(inheritedSection, 'bash')) { - section.bash = inheritedSection.bash - } else { - delete section.bash + if (!editor || typeof editor.configuration !== 'function') return { status: 'restore-failed', rows: [], detail: 'the config editor is unavailable' } + const store = opsOwnership(editor) + return store.run(async () => { + const receipt = await store.read() + const rows = opsRowsOf(editor) + if (!rows.length) return { status: 'skip-row-absent', rows: [], detail: 'no dsh-ops row is mounted' } + const results = [] + for (const row of rows) { + const modes = OPS_MODES.filter(mode => Object.hasOwn(receipt.fields, mode)) + if (!modes.length) continue + if (typeof editor.edit !== 'function') return { status: 'restore-failed', rows: results, detail: 'config editor exposes no edit()' } + try { + const restored = new Map(modes.filter(mode => boolOf(row, 'override', mode, 'bash') === false).map(mode => { + const original = receipt.fields[mode] + return [mode, original.hadOverride ? original.value : row.inherited?.[mode]?.bash] + })) + await editor.edit(row.entry, (current, inherited) => { + const next = isRecord(current) ? { ...current } : {} + for (const mode of modes) { + // A later user choice (including removal of the override) wins. + if (boolOf(row, 'override', mode, 'bash') !== false || next[mode]?.bash !== false) { restored.delete(mode); continue } + const original = receipt.fields[mode] + const section = { ...next[mode] } + const value = original.hadOverride ? original.value : inherited?.[mode]?.bash + if (typeof value === 'boolean') section.bash = value + else delete section.bash + if (Object.keys(section).length) next[mode] = section + else delete next[mode] + restored.set(mode, value) } - if (Object.keys(section).length === 0) delete next[mode] - else next[mode] = section + return next + }) + const after = freshRowOf(editor, rowIdOf(row)) + if (!after) throw new Error('row vanished during restore') + for (const [mode, expected] of restored) { + const actual = boolOf(after, 'override', mode, 'bash') ?? boolOf(after, 'inherited', mode, 'bash') + if (actual !== expected) throw new Error(`restore did not stick for ${mode}`) } - return next - }) - } catch (error) { - return { - status: 'restore-failed', - rows: results, - detail: 'the official config editor refused the restore for ' + id + ': ' + (error && error.message ? error.message : String(error)), - } - } - } - for (const { row, mode } of targets) { - const id = rowIdOf(row) - let after - try { - after = freshRowOf(editor, id) - } catch (error) { - return { - status: 'restore-failed', - rows: results, - detail: 'the restore succeeded but reading ' + id + ' back failed: ' + (error && error.message ? error.message : String(error)), + for (const mode of modes) { + results.push({ id: rowIdOf(row), mode, action: restored.has(mode) ? 'restore' : 'unchanged' }) + delete receipt.fields[mode] + } + await store.write(receipt) + } catch (error) { + return { status: 'restore-failed', rows: results, detail: `owned override restore failed: ${error?.message ?? error}` } } } - const left = boolOf(after, 'override', mode, 'bash') - const inheritedBash = boolOf(after, 'inherited', mode, 'bash') - // A false that equals the inherited value is the restored state, not a - // leftover override; only a false that disagrees with the source is a - // failed lift. - if (left === false && inheritedBash !== false) { - return { status: 'restore-failed', rows: results, detail: 'the restore did not stick on ' + id + ' (' + mode + '): it still reads bash=false' } - } - } - return { status: 'restored', rows: results } + return { status: results.some(row => row.action === 'restore') ? 'restored' : 'unchanged', rows: results, + detail: results.length ? undefined : 'no owned bash overrides to restore' } + }) } /** diff --git a/src/ops-ownership.js b/src/ops-ownership.js new file mode 100644 index 0000000..08142b3 --- /dev/null +++ b/src/ops-ownership.js @@ -0,0 +1,48 @@ +import { readFile, writeFile, rename, unlink } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { randomUUID } from 'node:crypto' + +const profiles = new Map() +const transient = new WeakMap() +const MODES = new Set(['minimal', 'standard', 'ptc', 'cordis']) +function validate(value) { + if (value?.version !== 1 || !value.fields || typeof value.fields !== 'object' || Array.isArray(value.fields)) throw new Error('invalid dsh-ops ownership journal; refusing to guess ownership') + for (const [mode, field] of Object.entries(value.fields)) { + if (!MODES.has(mode) || !field || typeof field.hadOverride !== 'boolean' + || (field.hadOverride && typeof field.value !== 'boolean')) throw new Error('invalid dsh-ops ownership entry') + } + return value +} +/** A separate receipt, never a hand-edit of the profile configuration. */ +export function opsOwnership(editor) { + const documentPath = editor.documentPath + const filename = typeof documentPath === 'string' && documentPath ? join(dirname(documentPath), '.gitbash-ops-ownership.json') : undefined + const cache = filename ? profiles : transient + const key = filename ?? editor + if (cache.has(key)) return cache.get(key) + let memory = { version: 1, fields: {} } + let serial = Promise.resolve() + const store = { + run(task) { + const result = serial.then(task) + serial = result.catch(() => {}) + return result + }, + async read() { + if (!filename) return structuredClone(memory) + try { return validate(JSON.parse(await readFile(filename, 'utf8'))) } + catch (error) { if (error.code === 'ENOENT') return { version: 1, fields: {} }; throw error } + }, + async write(value) { + validate(value) + if (!filename) { memory = structuredClone(value); return } + const temp = `${filename}.${randomUUID()}.tmp` + try { + await writeFile(temp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + await rename(temp, filename) + } finally { await unlink(temp).catch(error => { if (error.code !== 'ENOENT') throw error }) } + }, + } + cache.set(key, store) + return store +} diff --git a/src/path-dialect.js b/src/path-dialect.js index ade676a..921ef5b 100644 --- a/src/path-dialect.js +++ b/src/path-dialect.js @@ -35,11 +35,11 @@ const RUN_CODE_DIRECTIVE_TEXT = ' In a run_code program, compatible plain MSYS p const RUN_CODE_DIRECTIVE_OFF_TEXT = ' In a run_code program, automatic path-literal rewriting is OFF. Direct native Node fs/path APIs require Windows drive-letter or cwd-relative paths, not MSYS /c/... strings. Tool path arguments are still translated separately; program strings do not perform shell expansion.' /** The run_code sentence for this assembly, or '' when the tool is not offered. */ -export function runCodeHintFor(tools, codePaths = true) { +export function runCodeHintFor(tools, codePaths = true, programLanguage) { if (!Array.isArray(tools)) return '' for (const tool of tools) { const name = tool && typeof tool.name === 'string' ? tool.name : '' - if (name === 'run_code') return codePaths === true ? RUN_CODE_DIRECTIVE_TEXT : RUN_CODE_DIRECTIVE_OFF_TEXT + if (name === 'run_code' && programLanguage === 'typescript') return codePaths === true ? RUN_CODE_DIRECTIVE_TEXT : RUN_CODE_DIRECTIVE_OFF_TEXT } return '' } @@ -404,15 +404,12 @@ export const GIT_MOUNTS = new Map([ const translateEnvCache = new Map() -/** - * The runtime facts the virtual mounts resolve against (all optional — a - * missing fact simply leaves its paths untranslated). Probed once per - * distinct bashPath setting (v0.19.0: a settings-configured bash.exe joins - * the candidates FIRST, fixing /usr translation for custom installs); - * tmpdir/homedir are cheap; the Git root walks the candidate bash.exe list - * (the default install plus every PATH entry) and accepts a root only when - * /usr/bin exists, which excludes the WSL bash shim in WindowsApps. - */ +/** Read the invoking scope, never infer its language from a global peer setting. */ +export function programLanguageFor(ctx, agent) { + try { return (agent?.ctx ?? ctx)?.get?.('ptcRuntime')?.language } + catch { return undefined } +} + /** * One dispatch's path-bearing arguments in the host dialect (pure). The * tools/execute wrapper's decision surface, exported so a test can drive the @@ -423,7 +420,7 @@ const translateEnvCache = new Map() * @param {object|null} env - mount table (buildTranslateEnv), null when mounts are off. * @returns {object|undefined} the translated arguments (same reference when untouched). */ -export function translateDispatch(exec, dialect, env) { +export function translateDispatch(exec, dialect, env, programLanguage) { if (!exec || !exec.arguments || typeof exec.arguments !== 'object') return exec?.arguments let translated = translatePathArguments(exec.arguments, env) if (exec.name === 'glob' && dialect.globSplit) { @@ -435,7 +432,7 @@ export function translateDispatch(exec, dialect, env) { // current drive as ':\c\...'. The same mount table covers the program // path literals (see rewriteCodePaths; anything unclear leaves the code // untouched). - if (exec.name === 'run_code' && dialect.codePaths && typeof translated.code === 'string') { + if (exec.name === 'run_code' && dialect.codePaths && programLanguage === 'typescript' && typeof translated.code === 'string') { const rewritten = rewriteCodePaths(translated.code, env) const nextCode = programPrelude(env) + rewritten if (nextCode !== translated.code) translated = { ...translated, code: nextCode } @@ -443,6 +440,15 @@ export function translateDispatch(exec, dialect, env) { return translated } +/** + * The runtime facts the virtual mounts resolve against (all optional — a + * missing fact simply leaves its paths untranslated). Probed once per + * distinct bashPath setting (v0.19.0: a settings-configured bash.exe joins + * the candidates FIRST, fixing /usr translation for custom installs); + * tmpdir/homedir are cheap; the Git root walks the candidate bash.exe list + * (the default install plus every PATH entry) and accepts a root only when + * /usr/bin exists, which excludes the WSL bash shim in WindowsApps. + */ export function buildTranslateEnv(configuredBashPath) { const cacheKey = typeof configuredBashPath === 'string' && configuredBashPath !== '' ? configuredBashPath : '' const cached = translateEnvCache.get(cacheKey) diff --git a/src/terminal-bundle.js b/src/terminal-bundle.js index 1f8acd3..6954f6e 100644 --- a/src/terminal-bundle.js +++ b/src/terminal-bundle.js @@ -254,11 +254,7 @@ export async function adoptTerminalBundlePatch(io, input) { node.type = 'QUOTE_DOUBLE' return node } - const newMap = (...pairs) => { - const map = document.createNode({}) - for (const [key, value] of pairs) map.set(key, value) - return map - } + // An empty patch file parses as a FLOW sequence ([]); once rows are added // it must print in block style or every new row comes out one-line flow. @@ -277,7 +273,7 @@ export async function adoptTerminalBundlePatch(io, input) { const bashNode = plan.bash if (bashIndex >= 0) { document.setIn([bashIndex, 'disabled'], bashNode.disabled) - document.setIn([bashIndex, 'config'], newMap(['shellPath', quotedScalar(bashNode.shellPath)])) + document.setIn([bashIndex, 'config', 'shellPath'], quotedScalar(bashNode.shellPath)) } else { const row = rowFromFragment(`- id: ${TERMINAL_BUNDLE_BASH_ROW}\n disabled: ${bashNode.disabled}\n config:\n shellPath: ${JSON.stringify(bashNode.shellPath)}\n`) if (row === undefined) { diff --git a/src/tool-ownership.js b/src/tool-ownership.js new file mode 100644 index 0000000..12c8660 --- /dev/null +++ b/src/tool-ownership.js @@ -0,0 +1,23 @@ +import { resolveHostModule } from './bash-tool.js' +let scopeModule = await resolveHostModule(undefined, '@deepseek-ai/dsh-scope') + +export async function prepareToolOwnership(ctx) { + const host = await resolveHostModule(ctx, '@deepseek-ai/dsh-scope') + if (typeof host?.scopeChainOf === 'function') scopeModule = host +} + +/** Inspect each owner's own view: a child's mask must not erase ownership. */ +export function toolNameOccupied(registry, name, scope) { + if (typeof registry?.schemas !== 'function') throw new Error('tool ownership inspection unavailable') + const global = registry.schemas() + if (!Array.isArray(global)) throw new Error('tool ownership inspection unavailable') + if (global.some(tool => tool.name === name)) return true + if (scope === undefined) return false + if (typeof scopeModule?.scopeChainOf !== 'function') throw new Error('host scope ownership inspection unavailable') + for (const owner of scopeModule.scopeChainOf(scope)) { + const listed = registry.schemas(owner) + if (!Array.isArray(listed)) throw new Error('tool ownership inspection unavailable') + if (listed.some(tool => tool.name === name)) return true + } + return false +} diff --git a/src/tool-routing.js b/src/tool-routing.js new file mode 100644 index 0000000..2a34bf7 --- /dev/null +++ b/src/tool-routing.js @@ -0,0 +1,64 @@ +/** + * The routing addendum appended to the `bash` tool's description in the owned tool definition. TWO variants (v0.37.0, user decision): + * the pwsh tool is hidden by default, and a routing sentence that names a tool + * the agent cannot see only teaches the model to call a hidden name. The ON + * variant names `pwsh` (the REAL PowerShell tool, real again since route B — + * the official executor stays in place); the OFF variant states bash-only + * routing. Both hold regardless of the path dialect a delegate sees. + */ +export const GITBASH_ROUTE_ADDENDUM = [ + 'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities; use the pwsh tool ONLY when PowerShell cmdlets, registry, services or WMI are truly required.', + 'Fix failed bash syntax in bash; never embed `pwsh -Command` inside a bash command as a workaround (variable expansion and output encoding break there). Choose a separately offered native route for its documented purpose before execution, never as a policy-denial retry.', +].join('\n') + +/** The bash-only routing variant, used while the pwsh switch is off (the default). */ +export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [ + 'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities — it is this host\'s shell.', + 'Fix failed bash syntax in bash; no PowerShell tool is offered here. Do not invent a hidden tool or embed another interpreter as a workaround.', +].join('\n') + +/** + * The dialect addendum (MSYS facts) — appended ONLY while this plugin's path + * dialect is on for the assembling agent, because it describes the /c/ world + * the dialect creates. + */ +export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths use drive roots (/c/Users/...), and ~, /tmp, /dev/null and /usr/... resolve exactly as Git Bash resolves them.' + +const ROUTING_START = '\n[gitbash tool routing]\n' +const ROUTING_END = '\n[/gitbash tool routing]' +const ROUTING_SAFETY = 'Choose the execution route before running. A policy/access denial is a stop condition: never switch tools, shells or permissions to bypass it. A decoding/display failure does not mean the command failed to run; preserve its exit facts and never blindly repeat a side-effecting command. Do not guess unknown or mixed encodings.' +export const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY + +/** Model guidance names only tools in this assembly, not a switch's intent. */ +export function toolRoutingHintFor(tools, { pwshVisible = false, terminalGitBash = false } = {}) { + const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name)) + if (!names.has('bash')) return '' + const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH] + // v0.41.0: the persistent-terminal dialect fact. Said ONLY when the host + // actually carries the terminal tools AND our terminal-bundle adoption has + // settled the rows to Git Bash (the caller passes the settled flag) - a + // bundle left at its official defaults runs pwsh there, and a sentence + // must never describe a shell the button will not start. + if (terminalGitBash === true && names.has('terminal_open')) lines.push('The persistent terminal tools (terminal_open/read/send/...) run Git Bash (MSYS) on this host: the same drive-root paths (/c/...) and MINGW64 semantics as this bash tool; they are NOT PowerShell.') + if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.') + if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.') + lines.push(ROUTING_SAFETY) + return lines.join('\n') +} + +/** Add a replaceable, idempotent block; a reused assembly follows visibility. */ +export function applyToolRouting(tools, { pwshVisible = false, msys = false, terminalGitBash = false } = {}) { + if (!Array.isArray(tools)) return + for (const tool of tools) { + if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue + let description = tool.description + const start = description.indexOf(ROUTING_START) + const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start) + if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length) + const hint = tool.name === 'bash' + ? toolRoutingHintFor(tools, { pwshVisible, terminalGitBash }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '') + : PTC_ROUTING + tool.description = description + ROUTING_START + hint + ROUTING_END + } +} + diff --git a/tests/README.md b/tests/README.md index 80fc50c..9d8e423 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,8 +8,8 @@ npm test npm pack --dry-run --ignore-scripts ``` -The `ci` workflow runs all portable suites on Windows/Linux with Node 22/24. -The lockfile supplies Cordis 4.0.4: executor/lifecycle cases exercise its real +The `ci` workflow runs all portable suites on Windows/Linux with Node 24.9/24. +The lockfile supplies Cordis 4.1.0: executor/lifecycle cases exercise its real `Context`, `Service`, `plugin`, `isolate`, inject, Config and disposal contracts. Client, MCP and host-helper cases also use deterministic fixtures; those are not production end-to-end tests. Actual ToolRuntime integration is checked separately @@ -17,13 +17,13 @@ by the installed-host preview. Optional FastCtx payloads and all npm lifecycle scripts are deliberately excluded from the test dependency install. Additional portable regressions cover snapshot-dependent Hook ordering, retry -and cancellation of client polling, MCP spawn failure/error propagation, dynamic -pwsh visibility, pending tool/preset disposal, jobs withdrawal, fallback schemas, +and cancellation of client polling, MCP spawn failure/error propagation, stable running-agent +pwsh visibility, pending tool/preset disposal, jobs withdrawal, required defineTool schemas, standing session policy, deployment-default migration, regex data preservation, sidebar switch updates and the shared dialect gates. No live winget installation or FastCtx payload is needed. -## Installed-host preview (Windows, Node >= 22.15) +## Installed-host preview (Windows, Node >= 24.9) Select the **actual host directory** that contains its `package.json` and `node_modules`; do not substitute a global CLI installation for Desktop's Core. @@ -43,7 +43,7 @@ npm run test:preview This is a **headless runtime preview**, not a browser/UI deployment. The resolver loads `@deepseek-ai/*` from the selected installed host without changing that installation. The preview creates a fresh Cordis context and temporary cwd, -loads the real SystemPrompt, ToolRuntime, subprocess runtime, shell-env, +loads the real SystemPrompt, ToolRuntime, Native/Ptc tool executors, subprocess runtime, shell-env, SandboxPwshExecutor and official pwsh tool, and activates the actual inline Git Bash patch. Policy and session/working-directory wiring are test fixtures; only danger-full-access commands are exercised. @@ -62,3 +62,21 @@ Assertions cover: The preview makes no model calls, starts no web server, installs no plugin into a profile, does not edit production config, and does not restart Desktop. It is not a full end-to-end GUI/preset-picker/PTY/background-job/confinement test. + +The preview dispatcher uses Core's current `calls` / `results` batch contract. +The synchronous resolution hook guards reentry into `require.resolve`. Ordinary +Node cannot load modules from an Electron ASAR: select a read-only extracted copy +of Desktop's `dsh` directory (including its unpacked files) in that case. Do not +point this variable at `app.asar.unpacked/dsh` alone; it is not the complete Core. + +`tests/audit-preview.mjs` also loads the complete plugin entry and checks actual +native/PTC prompt assembly, complete-persona behavior, hidden ancestor ownership, +and Python source reaching a real interpreter unchanged. Set +`DSH_PREVIEW_PYTHON` to the Python executable to verify (otherwise `python` on PATH). +The PTC runtime adapter is a fixture calling that interpreter; this does not claim +a full deployment of the optional Python-runtime plugin. + +Portable tests now include the persistent-terminal YAML writer, preservation of +user options/comments, TypeScript-only program rewriting, a separate-process +ownership-journal restore, failed edits, and concurrent config changes. YAML is a +locked development dependency rather than a probe of another user's home folder. diff --git a/tests/audit-preview.mjs b/tests/audit-preview.mjs new file mode 100644 index 0000000..d17c089 --- /dev/null +++ b/tests/audit-preview.mjs @@ -0,0 +1,125 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { Context } from '@deepseek-ai/cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '@deepseek-ai/dsh-pwsh-sandbox' +import * as shellEnv from '@deepseek-ai/dsh-shell-env' +import * as toolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as plugin from '../src/index.js' +import { registerExperimentalTool } from '../src/experimental-tools.js' +import { executeTool, nativeExecutor, ptcExecutor } from './host-runtime.mjs' + +async function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'gitbash audit 中文 ')) + const previousHome = process.env.DSH_HOME + process.env.DSH_HOME = directory + const root = new Context(), disposers = [] + const mount = async (mod, config, owner = root) => { + const fiber = owner.plugin(mod, config); disposers.push(() => fiber.dispose()); await fiber; return fiber + } + t.after(async () => { + for (const dispose of disposers.reverse()) await dispose() + if (previousHome === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previousHome + rmSync(directory, { recursive: true, force: true }) + }) + const policy = { mode: 'danger-full-access', workspaceRoot: directory } + for (const [name, value] of Object.entries({ + sandboxPolicy: { defaultMode: policy.mode, resolve: () => policy }, + sandbox: { confine() { throw Error('unexpected confinement') } }, + workingDirectory: { ensure: async () => directory }, + loader: { entries: () => [], internal: { import: name => import(name) } }, + agentPresets: { list: () => [] }, + })) disposers.push(root.provide(name, value)) + for (const mod of [SystemPrompt, ToolRuntime, nativeExecutor, LocalSubprocessRuntime, SandboxPwshExecutor, shellEnv, toolPwsh]) await mount(mod) + const tools = root.get('tools') + const originalShell = root.reflect.store[root[Context.isolate].shell] + const agent = { id: 'audit-agent', session: { header: {} } } + const scope = createScope(root, agent); agent.ctx = scope.ctx; disposers.push(() => scope.dispose()) + let signalReady + const ready = new Promise(resolve => { signalReady = resolve }) + await mount(ctx => { + ctx.on('tools/change', () => { + if (tools.schemas(agent).some(tool => tool.name === 'gitbash_native')) signalReady() + }) + }) + await mount(plugin, { variantPresets: false, adoptSidebar: false, autoTerminalShell: false, fastctxTools: false }) + root.emit('agent/created', { agent }) + let timer + try { await Promise.race([ready, new Promise((_, reject) => { timer = setTimeout(() => reject(Error('companion activation timeout')), 8000) })]) } + finally { clearTimeout(timer) } + const run = (name, args, signal = new AbortController().signal) => executeTool(tools, { agent, name, callId: name, arguments: args, signal }) + return { root, tools, agent, mount, run, directory, originalShell, policy } +} + +test('audit host: final native/PTC/complete-persona prompts carry owned routing and hidden tools stay hidden', { timeout: 15000 }, async t => { + const f = await fixture(t) + const assemble = () => f.root.get('systemPrompt').assemble({ agent: f.agent, scope: f.agent }) + let prompt = await assemble() + let description = prompt.tools.find(tool => tool.name === 'bash').description + assert.match(description, /gitbash_native before execution/) + assert.match(description, /gitbash_to_native_path only/) + assert.doesNotMatch(description, /use the pwsh tool ONLY/) + await f.mount({ inject: ['systemPrompt'], apply(ctx) { + ctx.systemPrompt.section({ name: 'audit:complete', order: 0, complete: true, text: 'COMPLETE PERSONA' }) + } }, undefined, f.agent.ctx) + prompt = await assemble() + assert.equal(prompt.sections.length, 1) + assert.match(prompt.tools.find(tool => tool.name === 'bash').description, /gitbash_native before execution/) + const off = f.agent.ctx.provide('ptcRuntime', { language: 'typescript', executionInstructions: 'test', resolve: s => s, run: async () => ({ logs: [], value: null }) }) + t.after(off) + await f.mount(ptcExecutor, undefined, f.agent.ctx) + prompt = await assemble() + assert.deepEqual(prompt.tools.map(tool => tool.name), ['run_code']) + // A complete persona intentionally suppresses ordinary sections, while the + // tool definitions remain routed and PTC has its own official transport guidance. + assert.match(f.tools.get('bash', f.agent).description, /gitbash_native before execution/) +}) + +test('audit host: full plugin preserves Python source before real interpreter dispatch', { timeout: 15000 }, async t => { + const f = await fixture(t) + const captured = [] + const off = f.agent.ctx.provide('ptcRuntime', { + language: 'python', executionInstructions: 'Print program results.', resolve: s => s, + async run(spec) { + captured.push(spec.program) + const child = spawnSync(process.env.DSH_PREVIEW_PYTHON ?? 'python', ['-c', spec.program], { cwd: f.directory, encoding: 'utf8', windowsHide: true }) + if (child.status !== 0) throw new Error(child.stderr || String(child.error)) + return { logs: [child.stdout.trim()], value: child.stdout.trim() } + }, + }) + t.after(off) + await f.mount(ptcExecutor, undefined, f.agent.ctx) + const code = 'print("PYTHON_AUDIT_OK /c/data.txt")' + const result = await f.run('run_code', { description: 'Check Python program preservation', code }) + assert.equal(result.isError, false, JSON.stringify(result)) + assert.deepEqual(captured, [code]) + assert.match(JSON.stringify(result), /PYTHON_AUDIT_OK/) +}) + +test('audit host: hidden ancestor ownership is preserved, with restoration after foreign disposal', async t => { + const root = new Context(), cleanups = [] + t.after(async () => { for (const off of cleanups.reverse()) await off() }) + for (const mod of [SystemPrompt, ToolRuntime, nativeExecutor]) { const fiber = root.plugin(mod); await fiber; cleanups.push(() => fiber.dispose()) } + let toolsContext + const access = root.plugin({ inject: ['tools'], apply(ctx) { toolsContext = ctx } }); await access; cleanups.push(() => access.dispose()) + const parent = {}, child = {} + const parentScope = createScope(toolsContext, parent); cleanups.push(() => parentScope.dispose()) + const childScope = createScope(parentScope.ctx, child, { parent }); cleanups.push(() => childScope.dispose()) + const definition = value => defineTool({ name: 'gitbash_to_native_path', description: value, parameters: {}, output: { schema: { type: 'string' }, render: () => [] }, execute: () => value }) + const foreign = parentScope.ctx.tools.register(definition('FOREIGN')) + cleanups.push(foreign) + cleanups.push(childScope.ctx.tools.restrict({ deny: ['gitbash_to_native_path'] })) + let refresh + const owner = childScope.ctx.plugin(ctx => { refresh = registerExperimentalTool(ctx, () => definition('OWN'), () => true, { scope: child }) }) + await owner; cleanups.push(() => owner.dispose()) + assert.equal(root.get('tools').schemas(child).some(tool => tool.name === 'gitbash_to_native_path'), false) + foreign(); refresh() + assert.equal(root.get('tools').get('gitbash_to_native_path', child).description, 'OWN') +}) diff --git a/tests/audit-regressions.mjs b/tests/audit-regressions.mjs new file mode 100644 index 0000000..756f7a7 --- /dev/null +++ b/tests/audit-regressions.mjs @@ -0,0 +1,63 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { translateDispatch, programLanguageFor } from '../src/path-dialect.js' +import { adoptOpsBashRemoval, restoreOpsBash } from '../src/ops-adopt.js' + +const env = { tmpDir: 'C:/Temp', home: 'C:/Users/test', gitRoot: 'C:/Git' } +test('audit: only confirmed TypeScript programs receive JS translation; Python and unknown preserve source', () => { + const code = 'print("/c/test.txt")' + for (const language of ['python', undefined, 'unknown']) { + const args = { code } + assert.equal(translateDispatch({ name: 'run_code', arguments: args }, { codePaths: true }, env, language), args) + } + const out = translateDispatch({ name: 'run_code', arguments: { code: 'return "/c/test.txt"' } }, { codePaths: true }, env, 'typescript') + assert.match(out.code, /^try\{const e=process.env/) + assert.match(out.code, /C:\/test.txt/) + const parent = { get: () => ({ language: 'typescript' }) } + assert.equal(programLanguageFor(parent, { ctx: { get: () => ({ language: 'python' }) } }), 'python') + assert.equal(programLanguageFor(parent, { ctx: { get: () => undefined } }), undefined) + assert.equal(programLanguageFor(parent), 'typescript') + assert.equal(programLanguageFor({ get() { throw Error('unavailable') } }), undefined) +}) + +test('audit: ops ownership survives a separate process and never claims original false', async t => { + const dir = mkdtempSync(join(tmpdir(), 'gitbash-ops-receipt-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const documentPath = join(dir, 'cordis.patch.yml') + const moduleUrl = new URL('../src/ops-adopt.js', import.meta.url).href + const source = `import { adoptOpsBashRemoval } from ${JSON.stringify(moduleUrl)}; + const row={entry:{options:{id:'dsh-ops'}},inherited:{},override:{standard:{bash:false,keep:123},cordis:{bash:true}}}; + const editor={documentPath:${JSON.stringify(documentPath)},configuration:()=>[row],edit:async(e,fn)=>{row.override=fn(row.override,row.inherited)}}; + await adoptOpsBashRemoval(editor); console.log(JSON.stringify(row));` + const child = spawnSync(process.execPath, ['--input-type=module', '-e', source], { encoding: 'utf8', windowsHide: true }) + assert.equal(child.status, 0, child.stderr) + const row = JSON.parse(child.stdout) + const receipt = JSON.parse(readFileSync(join(dir, '.gitbash-ops-ownership.json'), 'utf8')) + assert.equal(receipt.fields.standard, undefined) + assert.deepEqual(receipt.fields.cordis, { hadOverride: true, value: true }) + const editor = { documentPath, configuration: () => [row], edit: async (_entry, fn) => { row.override = fn(row.override, row.inherited) } } + assert.equal((await adoptOpsBashRemoval(editor)).status, 'present') + assert.equal((await restoreOpsBash(editor)).status, 'restored') + assert.deepEqual(row.override, { standard: { bash: false, keep: 123 }, cordis: { bash: true } }) + assert.deepEqual(JSON.parse(readFileSync(join(dir, '.gitbash-ops-ownership.json'), 'utf8')).fields, {}) +}) + +test('audit: rejected adoption never owns a future user false; concurrent edits are preserved', async () => { + for (const scenario of ['rejected', 'concurrent']) { + const row = { entry: { options: { id: 'dsh-ops' } }, inherited: {}, override: {} } + const editor = { configuration: () => [structuredClone(row)], async edit(_entry, change) { + if (scenario === 'rejected') throw Error('fixed write failure') + row.override.standard = { bash: false } + row.override = change(row.override, row.inherited) + } } + assert.equal((await adoptOpsBashRemoval(editor)).status, 'write-failed') + row.override.standard = { bash: false } + editor.edit = async (_entry, change) => { row.override = change(row.override, row.inherited) } + await restoreOpsBash(editor) + assert.equal(row.override.standard.bash, false, scenario) + } +}) diff --git a/tests/bridge-preview.mjs b/tests/bridge-preview.mjs index 6ec10c6..122dd07 100644 --- a/tests/bridge-preview.mjs +++ b/tests/bridge-preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, nativeExecutor } from './host-runtime.mjs' import test from 'node:test' import assert from 'node:assert/strict' import * as path from 'node:path' @@ -14,7 +15,7 @@ test('preview: MCP failures retain isError through the actual host ToolRuntime', for (const fiber of fibers.reverse()) await fiber.dispose() Object.assign(McpStdio.prototype, originals) }) - for (const module of [SystemPrompt, ToolRuntime]) { + for (const module of [SystemPrompt, ToolRuntime, nativeExecutor]) { const fiber = root.plugin(module) fibers.push(fiber) await fiber @@ -41,7 +42,7 @@ test('preview: MCP failures retain isError through the actual host ToolRuntime', await new Promise(setImmediate) const tools = root.get('tools') assert.ok(tools.schemas(agent).some((schema) => schema.name === 'gb_inspect_local_file')) - const result = await tools.execute({ agent, name: 'gb_inspect_local_file', callId: 'preview-mcp-error', arguments: {}, signal: new AbortController().signal }) + const result = await executeTool(tools, { agent, name: 'gb_inspect_local_file', callId: 'preview-mcp-error', arguments: {}, signal: new AbortController().signal }) assert.equal(result.isError, true) assert.match(result.error.message, /gb_inspect_local_file failed: fixture file not found/) assert.match(result.content[0].text, /fixture file not found/) diff --git a/tests/compatibility-regressions.mjs b/tests/compatibility-regressions.mjs index f960458..0b40f97 100644 --- a/tests/compatibility-regressions.mjs +++ b/tests/compatibility-regressions.mjs @@ -62,10 +62,10 @@ test('compatibility: interpolation, escape sequences and ambiguous slash stay co test('compatibility: run_code hints truthfully distinguish codePaths ON and OFF', () => { const tools = [{ name: 'run_code' }] - assert.match(runCodeHintFor(tools, true), /best-effort/) - assert.match(runCodeHintFor(tools, true), /tagged templates/) - assert.match(runCodeHintFor(tools, true), /routes or search data/) - const off = runCodeHintFor(tools, false) + assert.match(runCodeHintFor(tools, true, 'typescript'), /best-effort/) + assert.match(runCodeHintFor(tools, true, 'typescript'), /tagged templates/) + assert.match(runCodeHintFor(tools, true, 'typescript'), /routes or search data/) + const off = runCodeHintFor(tools, false, 'typescript') assert.match(off, /rewriting is OFF/) assert.doesNotMatch(off, /are translated.*before the program/) assert.equal(runCodeHintFor([{ name: 'bash' }], false), '') @@ -85,10 +85,10 @@ test('compatibility: actual prompt listener reads the live codePaths gate', () = let dialect = { posixPaths: true, codePaths: true, subagentDialect: true } const ctx = { on(name, fn) { assert.equal(name, 'system-prompt/assemble'); handler = fn }, - get(name) { assert.equal(name, 'tools'); return { schemas: (_agent, toolSet) => toolSet === undefined ? [] : [{ name: 'run_code' }] } }, + get(name) { if (name === 'ptcRuntime') return { language: 'typescript' }; assert.equal(name, 'tools'); return { definitions: () => [], selectExecutor: () => ({ describe: () => ({ schemas: [{ name: 'run_code' }] }) }) } }, } - new Function('ctx', 'liveSettings', 'dialectApplies', 'windowsToMsys', 'runCodeHintFor', 'applyToolRouting', 'terminalDialect', source.slice(start, end))( - ctx, { dialect: () => dialect, pwshTool: () => false }, dialectApplies, windowsToMsys, runCodeHintFor, applyToolRouting, terminalDialect) + new Function('ctx', 'liveSettings', 'dialectApplies', 'windowsToMsys', 'runCodeHintFor', 'applyToolRouting', 'terminalDialect', 'programLanguageFor', source.slice(start, end))( + ctx, { dialect: () => dialect, pwshTool: () => false }, dialectApplies, windowsToMsys, runCodeHintFor, applyToolRouting, terminalDialect, () => 'typescript') const assemble = agent => { const assembly = { contexts: [{ name: 'gitbash-shell:posix-paths', text: 'BASE' }], sections: [] } handler(assembly, { agent, toolSet: {} }, () => {}) @@ -314,7 +314,7 @@ test('interop: ops_* arguments translate — root, operations[].path/to, protect // The posixPaths gate lives in the tools/execute WRAPPER (dialect.posixPaths // && dialectApplies), not inside translateDispatch — lock that wiring: const hostSource = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') - assert.match(hostSource, /if \(dialect\.posixPaths && dialectApplies\(dialect, exec && exec\.agent\)\) \{[\s\S]*?translateDispatch\(exec, dialect, env\)/, 'the wrapper gates the whole argument face on the live posixPaths switch') + assert.match(hostSource, /if \(dialect\.posixPaths && dialectApplies\(dialect, exec && exec\.agent\)\) \{[\s\S]*?translateDispatch\(exec, dialect, env, programLanguageFor/, 'the wrapper gates the whole argument face on the live posixPaths switch') }) test('interop: ops_* results echo back in /c/ — items[].path, matches[].path, root', () => { diff --git a/tests/host-loader.mjs b/tests/host-loader.mjs index e022c68..6edd61c 100644 --- a/tests/host-loader.mjs +++ b/tests/host-loader.mjs @@ -7,10 +7,15 @@ import { pathToFileURL } from 'node:url' const hostDir = process.env.DSH_PREVIEW_HOST_DIR if (!hostDir) throw new Error('Set DSH_PREVIEW_HOST_DIR to the installed DSH host directory containing package.json and node_modules') const hostRequire = createRequire(pathToFileURL(join(resolve(hostDir), 'package.json'))) +// Synchronous require.resolve participates in registerHooks too. Delegate its +// nested resolution to Node instead of recursively redirecting the same request. +let resolvingHost = false registerHooks({ resolve(specifier, context, nextResolve) { - if (specifier.startsWith('@deepseek-ai/')) { - return { url: pathToFileURL(hostRequire.resolve(specifier)).href, shortCircuit: true } + if (!resolvingHost && specifier.startsWith('@deepseek-ai/')) { + resolvingHost = true + try { return { url: pathToFileURL(hostRequire.resolve(specifier)).href, shortCircuit: true } } + finally { resolvingHost = false } } return nextResolve(specifier, context) }, diff --git a/tests/host-preview.mjs b/tests/host-preview.mjs index 4d2324b..62371d1 100644 --- a/tests/host-preview.mjs +++ b/tests/host-preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, nativeExecutor } from './host-runtime.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' @@ -42,7 +43,7 @@ for (const kind of ['standard', 'ptc', 'cordis']) { workingDirectory: { ensure: async () => cwd }, loader: { internal: { import: (name) => import(name) } }, })) own(root.provide(key, value)) - for (const module of [SystemPrompt, LocalSubprocessRuntime, SandboxPwshExecutor, ToolRuntime, shellEnv]) await mount(module) + for (const module of [SystemPrompt, LocalSubprocessRuntime, SandboxPwshExecutor, ToolRuntime, nativeExecutor, shellEnv]) await mount(module) const official = root.reflect.store[root[Context.isolate].shell] const rows = pluginsFor({ kind, gitBash: true, hostExtras: { timeContext: true, toolSchedule: true, pluginMarket: true, compaction: true } }) for (const row of rows.filter((row) => ['tool-bash', 'tool-pwsh'].includes(row.id) && row.disabled !== true)) { @@ -68,7 +69,7 @@ for (const kind of ['standard', 'ptc', 'cordis']) { }, })) await settled; clearTimeout(timer) - const result = await tools.execute({ + const result = await executeTool(tools, { agent, name: 'bash', callId: `variant-${kind}`, signal: new AbortController().signal, arguments: { description: 'Verify private variant executor', command: 'uname -s; printf "VARIANT_GITBASH_OK\\n"', workdir: cwd }, }) @@ -90,6 +91,7 @@ test('host preview: real Core registry waits for activation then retires a regis t.after(async () => { for (const off of cleanups.reverse()) await off() }) cleanups.push(root.provide('loader', { await: async () => {} })) cleanups.push(root.provide('sessionProjections', { register: () => () => {} })) + cleanups.push(root.provide('agents', { list: () => [] })) const registryFiber = root.plugin(AgentPresetRegistry, { default: 'standard' }) cleanups.push(() => registryFiber.dispose()); await registryFiber const registry = root.get('agentPresets') diff --git a/tests/host-runtime.mjs b/tests/host-runtime.mjs new file mode 100644 index 0000000..310148c --- /dev/null +++ b/tests/host-runtime.mjs @@ -0,0 +1,13 @@ +// Current Core contract: register a provider and dispatch raw call batches. +// These helpers call the real host; they never replace ToolRuntime methods. +export * as nativeExecutor from '@deepseek-ai/dsh-tool-executor-native' +export * as ptcExecutor from '@deepseek-ai/dsh-tool-executor-ptc' +export async function executeTool(tools, { name, callId, arguments: args, ...input }) { + const batch = await tools.execute({ ...input, calls: [{ + type: 'tool_use', id: callId, name, arguments: JSON.stringify(args), + }] }) + return batch.results[0].result +} +export function sdkSchemas(tools, scope) { + return tools.definitions(scope).map(tool => ({ name: tool.name, output: tool.output.schema })) +} diff --git a/tests/native-preview.mjs b/tests/native-preview.mjs index 17b5f78..dc9b040 100644 --- a/tests/native-preview.mjs +++ b/tests/native-preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, nativeExecutor } from './host-runtime.mjs' // Isolated actual installed Core, ToolRuntime, Jobs and controlled native Node. // No profile, installation, real network/secret inspection or second runner. import test from 'node:test' @@ -52,7 +53,7 @@ async function fixture(t, { jobs = false, controller = true, artifactFactory, en await fiber return fiber } - for (const module of [LocalSubprocessRuntime, SystemPrompt, toolsModule.ToolRuntime, shellEnv]) await mount(module) + for (const module of [LocalSubprocessRuntime, SystemPrompt, toolsModule.ToolRuntime, nativeExecutor, shellEnv]) await mount(module) const provider = root.get('subprocess') provider.internals.spillDir = DIR // documented test hook, isolated provider only const rootProvider = root.reflect.store[root[Context.isolate].subprocess] @@ -81,7 +82,7 @@ async function fixture(t, { jobs = false, controller = true, artifactFactory, en await toolFiber disposers.push(() => toolFiber.dispose()) const tools = root.get('tools') - const run = (args, signal = new AbortController().signal) => tools.execute({ agent, name: 'gitbash_native', callId: randomUUID(), arguments: args, signal }) + const run = (args, signal = new AbortController().signal) => executeTool(tools, { agent, name: 'gitbash_native', callId: randomUUID(), arguments: args, signal }) t.after(async () => { for (const dispose of disposers.reverse()) await dispose() assert.equal(root.reflect.store[root[Context.isolate].subprocess], undefined) diff --git a/tests/ops-adopt.mjs b/tests/ops-adopt.mjs index 75f09c8..86bf96f 100644 --- a/tests/ops-adopt.mjs +++ b/tests/ops-adopt.mjs @@ -32,8 +32,8 @@ const modeActions = (result) => Object.fromEntries((result.modes ?? result.rows) /** * configEditor stand-in with the parts the plugin module touches: rows(), * configuration() and an edit() that replaces the override wholesale (the - * official change(current, inherited) => next contract; this fake passes the - * override as current, which is enough for the nested-key assertions). + * official change(current, inherited) => next contract; current includes the + * effective inherited settings, matching the installed Core editor). */ function fakeOpsEditor({ rows = [], failEdit = null, ghostEdit = false, extraRows = false } = {}) { const state = { edits: [], rows: structuredClone(rows) } @@ -49,7 +49,9 @@ function fakeOpsEditor({ rows = [], failEdit = null, ghostEdit = false, extraRow if (ghostEdit) return const row = state.rows.find((candidate) => candidate.entry.options.id === entry.options.id) if (!row) throw new Error('fake: row vanished') - row.override = change(row.override ?? {}, row.inherited ?? {}) + const current = { ...row.inherited, ...row.override } + for (const mode of OPS_MODES) if (row.inherited?.[mode] || row.override?.[mode]) current[mode] = { ...row.inherited?.[mode], ...row.override?.[mode] } + row.override = change(current, row.inherited ?? {}) }, } } @@ -138,58 +140,48 @@ test('ops-adopt: a refused or non-sticking write is reported, never claimed', as assert.match(hollow.detail, /did not stick/) }) -test('ops-adopt: restore lifts our nested false back to the inherited value and nothing else', async () => { - const override = { - minimal: { bash: false, promptPolicy: true }, - standard: { bash: false }, - ptc: {}, - cordis: { bash: true }, - } - const inherited = { - minimal: { bash: true, promptPolicy: false }, - ptc: { bash: false }, - cordis: { bash: true }, - } - const editor = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, override, inherited)] }) +test('ops-adopt: restore preserves existing false and restores only adopted fields', async () => { + const editor = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, + { minimal: { bash: false, promptPolicy: true }, cordis: { bash: true } }, + { standard: { bash: true }, ptc: { bash: false } })] }) + await adoptOpsBashRemoval(editor) const restored = await restoreOpsBash(editor) assert.equal(restored.status, 'restored') - assert.equal(opsModeList(restored, ['restore']), 'minimal/standard') const after = editor.state.rows[0].override - assert.equal(after.minimal.bash, true, 'restored to the inherited value') - assert.equal(after.minimal.promptPolicy, true, "the user's own override keys are untouched") - assert.equal('standard' in after, false, 'the section was only our key, so it goes away') - assert.deepEqual(after.ptc, {}, 'a mode with no bash override is left alone') - assert.equal(after.cordis.bash, true, 'a later user ON choice is left alone') + assert.deepEqual(after.minimal, { bash: false, promptPolicy: true }) + assert.equal(after.standard.bash, true) + assert.equal(after.cordis.bash, true) + assert.deepEqual(after.ptc, { bash: false }, 'inherited unrelated mode remains effective') + assert.equal((await restoreOpsBash(editor)).status, 'unchanged') }) test('ops-adopt: restore leaves later user choices and absent rows alone', async () => { - const userTrue = rowOf(OPS_ROW_ID, { minimal: { bash: true } }, { minimal: { bash: true } }) - const editorA = fakeOpsEditor({ rows: [userTrue] }) - const kept = await restoreOpsBash(editorA) - assert.equal(kept.status, 'unchanged') - assert.match(kept.detail, /left alone/) - assert.deepEqual(editorA.state.edits, []) - - const bare = rowOf(OPS_ROW_ID, {}, {}) - const editorB = fakeOpsEditor({ rows: [bare] }) - assert.equal((await restoreOpsBash(editorB)).status, 'unchanged') - assert.deepEqual(editorB.state.edits, []) - - const gone = fakeOpsEditor({ rows: [] }) - assert.equal((await restoreOpsBash(gone)).status, 'skip-row-absent') + const editor = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, {}, {})] }) + await adoptOpsBashRemoval(editor) + editor.state.rows[0].override.minimal.bash = true + delete editor.state.rows[0].override.standard + await restoreOpsBash(editor) + assert.equal(editor.state.rows[0].override.minimal.bash, true) + assert.equal(editor.state.rows[0].override.standard, undefined) + const unowned = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, { minimal: { bash: false } }, {})] }) + assert.equal((await restoreOpsBash(unowned)).status, 'unchanged') + assert.deepEqual(unowned.state.edits, []) + assert.equal((await restoreOpsBash(fakeOpsEditor({ rows: [] }))).status, 'skip-row-absent') assert.equal((await restoreOpsBash(null)).status, 'restore-failed') }) -test('ops-adopt: a failed restore is reported with the manual safety net', async () => { - const failing = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, { minimal: { bash: false } }, {})], failEdit: new Error('file locked') }) - const refused = await restoreOpsBash(failing) - assert.equal(refused.status, 'restore-failed') - assert.match(refused.detail, /refused the restore/) - - const ghost = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, { minimal: { bash: false } }, {})], ghostEdit: true }) - const hollow = await restoreOpsBash(ghost) - assert.equal(hollow.status, 'restore-failed') - assert.match(hollow.detail, /did not stick/) +test('ops-adopt: a failed restore retains ownership for a retry', async () => { + const editor = fakeOpsEditor({ rows: [rowOf(OPS_ROW_ID, {}, {})] }) + await adoptOpsBashRemoval(editor) + const edit = editor.edit + editor.edit = async () => { throw new Error('file locked') } + const failed = await restoreOpsBash(editor) + assert.equal(failed.status, 'restore-failed') + assert.match(failed.detail, /file locked/) + editor.edit = async () => {} + assert.match((await restoreOpsBash(editor)).detail, /did not stick/) + editor.edit = edit + assert.equal((await restoreOpsBash(editor)).status, 'restored') }) test('ops-adopt: the reports carry the owner-facing wording', () => { @@ -223,9 +215,10 @@ test('ops-adopt: index.js wires the disposal restore and keeps it inside the win assert.ok(marker > 0, 'the wiring block exists') const block = source.slice(marker, source.indexOf('OFFICIAL persistent-terminal bundle adoption', marker)) assert.match(block, /process\.platform === 'win32'/, 'the removal is a Windows-host concern only') - assert.match(block, /ctx\.effect\(\(\) => async \(\) => \{/) + assert.match(block, /opsEditorCtx\.effect\(\(\) => async \(\) => \{/) assert.match(block, /restoreOpsBash\(opsEditorCtx\.configEditor\)/) - assert.match(block, /result\.status === 'adopted' \|\| result\.status === 'present'/, 'the restore hook only arms once the removal is actually in force') + assert.match(block, /await pending/, 'restore joins in-flight adoption') + assert.match(block, /clearTimeout\(retry\)/, 'disposal owns pending retries') }) test('ops-adopt: the removal list never grows into dsh-ops background or file tools (2026-10-19 user decision)', () => { diff --git a/tests/path-preview.mjs b/tests/path-preview.mjs index 54c3690..d9f55e6 100644 --- a/tests/path-preview.mjs +++ b/tests/path-preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, ptcExecutor } from './host-runtime.mjs' // Real installed-Core coverage for the explicit path converter. Actual host // exports resolved through tests/host-loader.mjs: ToolRuntime → the retained // run_code transport → NodePtcRuntime → LocalSubprocessRuntime → a real Node @@ -58,7 +59,7 @@ test('path preview: gitbash_to_native_path on the real engine', async t => { const j = JSON.stringify const runCode = async (name, code, extra = {}) => { const original = { description: 'Path preview: exercise gitbash_to_native_path on the real engine', code, ...extra } - const result = await root.get('tools').execute({ + const result = await executeTool(root.get('tools'), { name: 'run_code', callId: 'path-preview-' + name, arguments: original, signal: new AbortController().signal, }) // sourceFrozen: the registry must hand the program through untouched. @@ -80,7 +81,8 @@ test('path preview: gitbash_to_native_path on the real engine', async t => { await mount(NodePtcRuntime, { timeoutMs: 4000, maxTimeoutMs: 10000, maxOutputBytes: 1048576, maxMessageBytes: 2097152, maxPendingCalls: 16, maxOldGenerationSizeMb: 128, graceMs: 1000, launch: { kind: 'node-script', executable: process.execPath } }) await mount(SystemPrompt) - await mount(ToolRuntime, { mode: 'ptc' }) + await mount(ToolRuntime) + await mount(ptcExecutor) // The module UNDER TEST: real defineTool, real registry, owned scope. const mainFiber = await mount(createPathToolModule({ toolsModule: { defineTool }, diff --git a/tests/preview.mjs b/tests/preview.mjs index cf27502..efd8f3e 100644 --- a/tests/preview.mjs +++ b/tests/preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, nativeExecutor } from './host-runtime.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync, rmSync, readFileSync } from 'node:fs' @@ -51,6 +52,7 @@ test('preview: real host registers and executes Git Bash without replacing Power await mount(LocalSubprocessRuntime) await mount(SandboxPwshExecutor) await mount(ToolRuntime) + await mount(nativeExecutor) await mount(shellEnv) await mount(toolPwsh) const official = root.reflect.store[root[Context.isolate].shell] @@ -107,7 +109,7 @@ test('preview: real host registers and executes Git Bash without replacing Power assert.equal(root.reflect.store[root[Context.isolate].shell], official) assert.ok(!tools.schemas().some((schema) => schema.name === 'bash'), 'bash is per-agent, not global') - const run = (name, command) => tools.execute({ + const run = (name, command) => executeTool(tools, { agent, name, callId: `preview-${name}-${Date.now()}`, arguments: { description: `Verify isolated ${name} execution`, command, workdir: cwd }, signal: new AbortController().signal, diff --git a/tests/review-native-preview.mjs b/tests/review-native-preview.mjs index 720ae8e..12701db 100644 --- a/tests/review-native-preview.mjs +++ b/tests/review-native-preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, nativeExecutor } from './host-runtime.mjs' // Actual selected Core + ToolRuntime + Jobs; ordinary Node producers only. // Test scratch only: no installation, production profile, network or secrets. import test from 'node:test' @@ -45,7 +46,7 @@ async function fixture(t) { 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) + for (const module of [LocalSubprocessRuntime, SystemPrompt, toolsModule.ToolRuntime, nativeExecutor, 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 @@ -69,7 +70,7 @@ async function fixture(t) { }, }) 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(), + const run = args => executeTool(root.get('tools'), { 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]) diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index 09d91ae..264dc56 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -1,3 +1,4 @@ +import { executeTool, nativeExecutor, ptcExecutor, sdkSchemas } from './host-runtime.mjs' // Actual installed Core/Cordis contracts, isolated Contexts, and memory-only // subprocesses. No production profile, filesystem writes, or model requests. import test from 'node:test' @@ -63,7 +64,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu await fiber return fiber } - for (const module of [SystemPrompt, ToolRuntime, shellEnv]) await mount(module, undefined) + for (const module of [SystemPrompt, ToolRuntime, ptc ? ptcExecutor : nativeExecutor, shellEnv]) await mount(module, undefined) let agentParent await mount({ inject: ['tools'], apply(ctx) { agentParent = ctx @@ -116,25 +117,28 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu set: value => { pwshVisible = value; root.emit('loader/volatile-update', [['pwshTool']]) }, setExperimental: value => { experimental = value; root.emit('loader/volatile-update', [['experimentalNative']]) }, allowAgentMount: () => { failAgentMount = false }, - execute: (name, args = {}) => tools.execute({ agent, name, callId: `${name}-runtime-preview`, arguments: args, signal: new AbortController().signal }), + execute: (name, args = {}) => executeTool(tools, { agent, name, callId: `${name}-runtime-preview`, arguments: args, signal: new AbortController().signal }), remount: () => mount(ctx => mountInlineTools(ctx, deps)), } } -test('runtime preview: live pwsh schema and dispatch follow both switch directions', { timeout: 10000 }, async t => { +test('runtime preview: pwsh visibility stays stable until the agent patch remounts', { timeout: 10000 }, async t => { const f = await fixture(t, { existingBash: true }) assert.deepEqual(f.names(), ['bash']) f.set(true) - assert.ok(f.names().includes('pwsh')) - assert.equal((await f.execute('pwsh')).value, 'pwsh') - f.set(false) assert.ok(!f.names().includes('pwsh')) assert.equal((await f.execute('pwsh')).isError, true) - f.set(true) - assert.equal((await f.execute('pwsh')).isError, false) - assert.equal(f.root.get('shell'), f.official) await f.patch.dispose() + const shown = await f.remount() + assert.ok(f.names().includes('pwsh')) + assert.equal((await f.execute('pwsh')).value, 'pwsh') + f.set(false) assert.ok(f.names().includes('pwsh')) + assert.equal((await f.execute('pwsh')).isError, false) + await shown.dispose() + const hidden = await f.remount() + assert.ok(!f.names().includes('pwsh')) + await hidden.dispose() }) test('runtime preview: experimental switch changes existing schemas and restricted Bash without restart', { timeout: 15000 }, async t => { @@ -214,13 +218,13 @@ test('runtime preview: a failed per-agent plugin mount releases its scope and ca assert.equal(f.agentScopes[0].ctx.fiber.state, 4) assert.ok(!f.names().includes('bash')) f.allowAgentMount() - f.set(true) + f.root.emit('agent/created', { agent: f.agent }) await bounded(f.activated.promise, 'retried agent mount') assert.equal(f.agentScopes.length, 2) assert.ok(f.names().includes('bash')) }) -test('runtime preview: actual ToolRuntime accepts and executes the recursive plain fallback', { timeout: 10000 }, async t => { +test('runtime preview: actual ToolRuntime accepts and executes the required defineTool contract', { timeout: 10000 }, async t => { const f = await fixture(t, { existingBash: true, pwshVisible: true }) await f.patch.dispose() const child = createScope(f.agent.ctx) @@ -234,7 +238,7 @@ test('runtime preview: actual ToolRuntime accepts and executes the recursive pla stdout: { text: 'plain-fallback-ok', truncated: false }, stderr: { text: '', truncated: false }, sandbox: { mode: spec.sandboxPolicy.mode, denied: false }, }) }), } - const module = createBashToolModule({ executor, log() {} }) + const module = createBashToolModule({ executor, toolsModule: { defineTool }, log() {} }) await child.ctx.plugin(module) f.agent.session.header.policy = { mode: 'read-only', workspaceRoot: 'D:/child-workspace' } const result = await f.execute('bash', { command: 'memory-only command', description: 'Verify plain fallback execution' }) @@ -394,24 +398,15 @@ test('runtime preview: direct resolved restricted execution refuses and full han for (const ptc of [false, true]) test(`runtime preview: routing reaches the actual ${ptc ? 'PTC SDK' : 'native tool'} prompt and respects visibility`, { timeout: 10000 }, async t => { const f = await fixture(t, { explicitTools: true, ptc }) await bounded(f.activated.promise, 'routing tools activation') - const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') - const start = source.indexOf("ctx.on('system-prompt/assemble',") - const end = source.indexOf(" console.log(TAG + ' prompt-assembly path dialect active", start) - assert.ok(start >= 0 && end > start) let dialect = { posixPaths: false, codePaths: false, subagentDialect: true } - const hook = f.root.plugin(ctx => { - new Function('ctx', 'liveSettings', 'dialectApplies', 'windowsToMsys', 'runCodeHintFor', 'applyToolRouting', source.slice(start, end))( - ctx, { dialect: () => dialect, pwshTool: () => f.deps.pwshEnabled() }, dialectHelpers.dialectApplies, dialectHelpers.windowsToMsys, dialectHelpers.runCodeHintFor, applyToolRouting) - }) - t.after(() => hook.dispose()) - await hook + f.deps.routingOptions = agent => ({ msys: dialect.posixPaths && dialectHelpers.dialectApplies(dialect, agent) }) const assemble = () => f.root.get('systemPrompt').assemble({ scope: f.agent, agent: f.agent }) const first = await assemble() const sdk = first.sections.find(section => section.name === 'tools:sdk')?.text ?? '' if (ptc) { assert.deepEqual(first.tools.map(tool => tool.name), ['run_code']) - assert.match(first.tools[0].description, /call only bindings actually offered/) - assert.match(first.tools[0].description, /denial is a stop condition/) + assert.match(first.sections.find(section => section.name === 'gitbash-shell:program-routing').text, /call only bindings actually offered/) + assert.match(first.sections.find(section => section.name === 'gitbash-shell:program-routing').text, /denial is a stop condition/) assert.match(sdk, /gitbash_native/) assert.match(sdk, /Choose this tool BEFORE execution/) assert.match(sdk, /gitbash_to_native_path/) @@ -425,13 +420,13 @@ for (const ptc of [false, true]) test(`runtime preview: routing reaches the actu } f.set(true) const shown = await assemble() - if (ptc) assert.match(shown.sections.find(section => section.name === 'tools:sdk').text, /\bpwsh\b/) - else assert.match(shown.tools.find(tool => tool.name === 'bash').description, /use the pwsh tool ONLY when/) + if (ptc) assert.doesNotMatch(shown.sections.find(section => section.name === 'tools:sdk').text, /\bpwsh\b/) + else assert.doesNotMatch(shown.tools.find(tool => tool.name === 'bash').description, /use the pwsh tool ONLY when/) f.set(false) dialect = { ...dialect, posixPaths: true, subagentDialect: false } f.agent.session.header.origin = 'subagent' const delegated = await assemble() - const text = delegated.tools.find(tool => tool.name === (ptc ? 'run_code' : 'bash')).description + const text = ptc ? delegated.sections.find(section => section.name === 'gitbash-shell:program-routing').text : delegated.tools.find(tool => tool.name === 'bash').description assert.match(text, /denial is a stop condition/, 'routing is independent of delegated dialect switches') assert.doesNotMatch(text, /\/c\/Users/) assert.equal(f.root.get('shell'), f.official) @@ -508,18 +503,19 @@ test('runtime preview: actual run_code transport receives Unicode/EOF fixes and const mount = async (module, config) => { const fiber = root.plugin(module, config); cleanup.push(() => fiber.dispose()); await fiber } t.after(async () => { for (const off of cleanup.reverse()) await off() }) await mount(SystemPrompt) - await mount(ToolRuntime, { mode: 'ptc' }) + await mount(ToolRuntime) + await mount(ptcExecutor) const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') const start = source.indexOf("ctx.on('tools/execute',") const end = source.indexOf(" console.log(TAG + ' MSYS path translation active", start) assert.ok(start >= 0 && end > start) await mount(ctx => { - new Function('ctx', 'liveSettings', 'dialectApplies', 'buildTranslateEnv', 'readConfiguredBashPath', 'translateDispatch', 'rewriteResultPaths', source.slice(start, end))( - ctx, { dialect: () => dialect }, dialectHelpers.dialectApplies, () => env, () => '', dialectHelpers.translateDispatch, dialectHelpers.rewriteResultPaths) + new Function('ctx', 'liveSettings', 'dialectApplies', 'buildTranslateEnv', 'readConfiguredBashPath', 'translateDispatch', 'programLanguageFor', 'rewriteResultPaths', source.slice(start, end))( + ctx, { dialect: () => dialect }, dialectHelpers.dialectApplies, () => env, () => '', dialectHelpers.translateDispatch, dialectHelpers.programLanguageFor, dialectHelpers.rewriteResultPaths) }) const run = async code => { const args = { description: 'Capture compatible program without executing it', code } - const value = await root.get('tools').execute({ name: 'run_code', callId: 'compat-code-' + captured.length, arguments: args, signal: new AbortController().signal }) + const value = await executeTool(root.get('tools'), { name: 'run_code', callId: 'compat-code-' + captured.length, arguments: args, signal: new AbortController().signal }) assert.equal(value.isError, false, JSON.stringify(value)) assert.equal(args.code, code, 'caller source stays unchanged') return captured.at(-1) @@ -574,7 +570,7 @@ for (const layer of ['global', 'agent']) for (const name of ['gitbash_to_native_ const response = await f.execute(name, { msys_path: '/c/sentinel' }); assert.equal(response.isError, false, JSON.stringify(response)); assert.equal(response.value, sentinel); - assert.equal(f.tools.sdkSchemas(f.agent).find(tool => tool.name === name).output.type, 'string', 'SDK retains the foreign return contract'); + assert.equal(sdkSchemas(f.tools, f.agent).find(tool => tool.name === name).output.type, 'string', 'SDK retains the foreign return contract'); const other = name === 'gitbash_native' ? 'gitbash_to_native_path' : 'gitbash_native'; assert.equal(f.names().includes(other), enabled, 'one collision cannot block the other companion'); } @@ -582,7 +578,7 @@ for (const layer of ['global', 'agent']) for (const name of ['gitbash_to_native_ await foreign.dispose(); f.setExperimental(false); f.setExperimental(true); - const output = f.tools.sdkSchemas(f.agent).find(tool => tool.name === name).output; + const output = sdkSchemas(f.tools, f.agent).find(tool => tool.name === name).output; if (name === 'gitbash_to_native_path') assert.equal(output.type, 'object'); else assert.ok(output.oneOf.every(branch => branch.type === 'object'), 'native union contract recovers after the owner leaves'); await f.patch.dispose(); @@ -622,11 +618,14 @@ test('runtime preview: the eager native slot fixes the tool face from agent crea const nativeFace = () => face().filter(tool => tool.name === 'gitbash_native') assert.equal(nativeFace().length, 1) assert.deepEqual(nativeFace(), initial.filter(tool => tool.name === 'gitbash_native'), 'the native entry must not be removed/re-added when the real executor arrives: ' + JSON.stringify(f.logs)) - // Withdrawing the capability must remove the slot rather than leave a ghost. + // The eager schema stays stable on a live agent; its execution gate changes immediately. f.deps.nativeContract = undefined f.setExperimental(false) - assert.ok(!f.names().includes('gitbash_native')) + assert.ok(f.names().includes('gitbash_native'), 'running agent retains its stable eager schema') + const denied = await f.execute('gitbash_native', {}) + assert.equal(denied.isError, true) await f.patch.dispose() + assert.ok(!f.names().includes('gitbash_native')) }) test('runtime preview: native Jobs reload preserves a foreign owner without blocking its scope', { timeout: 10000 }, async t => { @@ -648,7 +647,7 @@ test('runtime preview: native Jobs reload preserves a foreign owner without bloc const removeJobs = f.root.provide('jobs', {}); await new Promise(setImmediate); try { - assert.ok(f.tools.sdkSchemas(f.agent).find(tool => tool.name === 'gitbash_native').output.oneOf.every(branch => branch.type === 'object')); + assert.ok(sdkSchemas(f.tools, f.agent).find(tool => tool.name === 'gitbash_native').output.oneOf.every(branch => branch.type === 'object')); assert.ok(f.tools.get('gitbash_native', f.agent).parameters.properties.run_in_background); } finally { await removeJobs(); } assert.equal(f.tools.get('gitbash_native', f.agent).parameters.properties.run_in_background, undefined); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 09b166f..ef1efb6 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -847,11 +847,11 @@ test('the run_code literal rewrite has its own switch (codePaths)', async () => assert.ok(strict !== null, 'strict directive text is gone') assert.doesNotMatch(strict[1], /run_code/, 'the strict directive must not talk about run_code either') assert.match(pathText, /const RUN_CODE_DIRECTIVE_TEXT = '/, 'the conditional run_code sentence is gone') - assert.match(text, /runCodeHintFor\(offeredTools, dialect\.codePaths === true\)/, 'the assembly hook must gate on the tool list and live codePaths state') + assert.match(text, /runCodeHintFor\(offeredTools, dialect\.codePaths === true, programLanguageFor/, 'the assembly hook must gate on the tool list and live codePaths state') // The event does not carry tools — read the step's admitted toolSet back // from the tools service, so conditional tools are judged exactly as // execution judges them. - assert.match(text, /ctx\.get\('tools'\)\?\.schemas\?\.\(assembleContext && assembleContext\.agent, assembleContext && assembleContext\.toolSet\)/, 'the offered tools must come from the step-admitted set') + assert.match(text, /registry\.definitions\(scope, assembleContext\?\.toolSet\)/, 'the offered tools must come from the step-admitted set') }) test('the run_code sentence is injected only where the tool exists (v0.20.1)', async () => { @@ -859,10 +859,10 @@ test('the run_code sentence is injected only where the tool exists (v0.20.1)', a assert.equal(runCodeHintFor(undefined), '', 'no tool list → nothing to say') assert.equal(runCodeHintFor([]), '') assert.equal(runCodeHintFor([{ name: 'bash' }, { name: 'read' }, { name: 'present' }]), '', 'a mode without run_code stays quiet') - const hint = runCodeHintFor([{ name: 'bash' }, { name: 'run_code' }]) + const hint = runCodeHintFor([{ name: 'bash' }, { name: 'run_code' }], true, 'typescript') assert.match(hint, /run_code program/, 'a mode WITH run_code gets the sentence') assert.match(hint, /translated/, 'and it says what the layer does') - assert.equal(runCodeHintFor([{ name: 'run_code' }, { name: 'run_code' }]), hint, 'idempotent, one sentence') + assert.equal(runCodeHintFor([{ name: 'run_code' }, { name: 'run_code' }], true, 'typescript'), hint, 'idempotent, one sentence') }) test('run_code gets TEMP/TMP only — never a fake HOME or PATH (v0.21.0)', async () => { @@ -1426,7 +1426,7 @@ test('translateDispatch covers every argument face issue #8 reported', () => { const { translateDispatch } = _internal const env = { tmpDir: 'C:/Users/x/AppData/Local/Temp', home: 'C:/Users/x', gitRoot: 'C:/Program Files/Git' } const dialect = { posixPaths: true, virtualMounts: true, globSplit: true, codePaths: true } - const t = (name, args) => translateDispatch({ name, arguments: args }, dialect, env) + const t = (name, args) => translateDispatch({ name, arguments: args }, dialect, env, 'typescript') // ① MSYS drive root: the double root `/c/c/...` came from NOT translating here assert.equal(t('read', { file_path: '/c/Users/x/.dsh/.anonymous-user-id' }).file_path, 'C:/Users/x/.dsh/.anonymous-user-id') assert.equal(t('write', { file_path: '/c/Users/x/.dsh/p.txt', content: 'hi' }).file_path, 'C:/Users/x/.dsh/p.txt') @@ -2970,7 +2970,7 @@ test('inline tools: the routing sentence is present and dialect-split', async () assert.match(mod.GITBASH_MSYS_ADDENDUM, /\/c\/Users/) }) -test('inline tools: index.js wires the patch, the three switches, and the waterfall addendum', () => { +test('inline tools: index.js wires the patch, the three switches, and the owned routing options', () => { const hostSource = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') assert.match(hostSource, /mountInlineTools\(ctx, \{/) assert.match(hostSource, /mountFastctx\(ctx, \{/) @@ -2982,14 +2982,11 @@ test('inline tools: index.js wires the patch, the three switches, and the waterf // file tools — the bridge retires to an explicit opt-in. assert.match(hostSource, /fastctxTools: Schema\.boolean\(\)\.default\(false\)\.volatile\(\)/) assert.match(hostSource, /fastctxTools: valueOf\(config\.fastctxTools\) === true/) - // Routing rides applyToolRouting (inline-tools.js) since the native RFC: - // the sentence is picked from the actual offered face, and the pwsh-visible - // / msys facts still flow from the live settings read here. The list comes - // from the tools service (`ctx.tools.schemas`); the event no longer carries - // declarations. - assert.match(hostSource, /applyToolRouting\(offeredTools,/) - assert.match(hostSource, /pwshVisible: liveSettings\.pwshTool\(\) === true/) - assert.match(hostSource, /msys: applies && dialect\.posixPaths === true/) + // Routing options flow into the per-agent owned Bash definition; the + // assembly waterfall no longer attempts to edit a detached schema list. + assert.match(hostSource, /routingOptions: \(agent\)/) + assert.match(hostSource, /pwshEnabled: \(\) => liveSettings\.pwshTool\(\) === true/) + assert.match(hostSource, /msys: liveSettings\.posix\(\) && dialectApplies/) // The desired set collapses to [] while the switch is off — the cleanup // still runs, but nothing registers. assert.match(hostSource, /readVariants && readVariants\(\) === true\s*\?\s*effectivePresetIds/) diff --git a/tests/terminal-bundle.mjs b/tests/terminal-bundle.mjs index d310e52..fed0216 100644 --- a/tests/terminal-bundle.mjs +++ b/tests/terminal-bundle.mjs @@ -35,11 +35,8 @@ const GOOD = { platform: 'win32', enabled: true, mounted: true, resolved: BASH_P async function realYaml() { const candidates = [] try { candidates.push(createRequire(import.meta.url).resolve('yaml')) } catch { /* not linked here */ } - for (const host of [ - 'C:/Users/kanna/.dsh/profiles/desktop/node_modules/yaml/package.json', - 'D:/APP/DeepSeek Harness/resources/app.asar/dsh/node_modules/yaml/package.json', - ]) { - try { candidates.push(createRequire(pathToFileURL(host)).resolve('yaml')) } catch { /* not installed there */ } + if (process.env.DSH_PREVIEW_HOST_DIR) { + candidates.unshift(createRequire(pathToFileURL(join(process.env.DSH_PREVIEW_HOST_DIR, 'package.json'))).resolve('yaml')) } for (const candidate of candidates) { try { return await import(pathToFileURL(candidate).href) } catch { /* next */ } @@ -332,3 +329,16 @@ test('detectTerminalBundle reads the tool row through the loader service', async } assert.equal(await detectTerminalBundle(disabledCtx), false) }) + +test('writer: filling shellPath preserves every other terminal option and comments', async () => { + const yaml = await realYaml() + assert.ok(yaml, 'portable test dependency or selected installed host supplies yaml') + const h = harness(yaml, { initial: '- id: optional-terminal-bash\n disabled: false\n config:\n # user tuning\n timeoutMs: 98765\n scrollbackLines: 4321\n shellArgs: [--noprofile, --norc, -i]\n' }) + try { + const result = await adoptTerminalBundlePatch(h.io, { ...GOOD, patchPath: h.patchPath }) + assert.equal(result.status, 'updated') + const config = yaml.parse(h.last())[0].config + assert.deepEqual(config, { timeoutMs: 98765, scrollbackLines: 4321, shellArgs: ['--noprofile', '--norc', '-i'], shellPath: BASH_PATH }) + assert.match(h.last(), /# user tuning/) + } finally { rmSync(h.tmp, { recursive: true, force: true }) } +})