From 81541f482bca7470e02a5734a39010ada437ae88 Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Tue, 13 Oct 2026 20:58:18 +0800 Subject: [PATCH 1/9] experiment: discuss guarded native argv, runtime paths and agent routing --- AGENTS.md | 23 +- CHANGELOG.md | 46 ++- README.md | 99 ++++-- README_EN.md | 80 +++-- docs/experimental-native-execution.md | 219 +++++++++++++ docs/experimental-native-verification.md | 145 +++++++++ package.json | 5 +- src/bash-tool.js | 4 +- src/index.js | 42 +-- src/inline-tools.js | 91 +++++- src/native-executor.js | 271 ++++++++++++++++ src/native-output.js | 347 ++++++++++++++++++++ src/native-tool.js | 194 +++++++++++ src/path-tool.js | 304 ++++++++++++++++++ src/shell.js | 132 ++++---- tests/compatibility-regressions.mjs | 78 ++++- tests/native-executor.mjs | 132 ++++++++ tests/native-integration-preview.mjs | 192 +++++++++++ tests/native-output-preview.mjs | 189 +++++++++++ tests/native-output.mjs | 237 ++++++++++++++ tests/native-preview.mjs | 392 +++++++++++++++++++++++ tests/path-preview.mjs | 216 +++++++++++++ tests/path-tool.mjs | 287 +++++++++++++++++ tests/preview-env.mjs | 25 ++ tests/runtime-preview.mjs | 128 ++++++-- tests/runtime-regressions.mjs | 32 +- tests/smoke.mjs | 107 +++---- 27 files changed, 3725 insertions(+), 292 deletions(-) create mode 100644 docs/experimental-native-execution.md create mode 100644 docs/experimental-native-verification.md create mode 100644 src/native-executor.js create mode 100644 src/native-output.js create mode 100644 src/native-tool.js create mode 100644 src/path-tool.js create mode 100644 tests/native-executor.mjs create mode 100644 tests/native-integration-preview.mjs create mode 100644 tests/native-output-preview.mjs create mode 100644 tests/native-output.mjs create mode 100644 tests/native-preview.mjs create mode 100644 tests/path-preview.mjs create mode 100644 tests/path-tool.mjs create mode 100644 tests/preview-env.mjs diff --git a/AGENTS.md b/AGENTS.md index 32f5999..e7847aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,17 +40,16 @@ ## 核心不变量(改代码前必读) 0. **声明式唯一路径(v0.32.0 起)**:宿主下限 `>=0.1.7-rc`(兼容门禁 `plugin-compatibility.ts` 读 peer `@deepseek-ai/dsh`,`semver.satisfies` 以 `includePrerelease: true` 求值;peer 必须标 `peerDependenciesMeta.optional`,否则 autoInstallPeers 场景下对只有 prerelease 的 `@deepseek-ai/dsh` 解析失败),**era/物化/present/rows/detectPeerCoverage 已全部删除**——`apply()` 直走声明式注册 `runDeclarativeEra`(v0.35.0 起受 `variantPresets` 开关门控,默认关=desired 为空集,见 §4l),没有「旧宿主分支」可回退。要点:四个变体由 `pluginsFor({ kind, gitBash, skillsDir, pythonActive, hostExtras })` / `minimalPluginsFor({ bashPath })` 生成行集(`src/compositions.js`,双态镜像官方 standard/minimal/ptc/cordis + Git Bash 增量:`hostExtras` 缺省 ↔ 0.1.7 行集,`{ timeContext, toolSchedule }` ↔ 0.2.1 行集,探测式接入见第 8 条;minimal 的 PTY 行 `bashPath` 由 index.js 经 `minimalPtyBashPath(readBashResolution)` 每次注册时懒解析喂入——解析失败兜底可见默认路径,见 §4h 的两条硬边界与 issue #15);cordis 变体的 skills 直接指向 `@deepseek-ai/dsh-agent-preset` 包旁目录(现场解析,不拷贝);**preset id 永不随官方改名**(`code-gitbash` 保持历史 id——会话钉在 id 上,改名即 preset not found);预设清单沿用行配置 `presets`(Config 的普通字段,改动触发重挂载);reconcile 必须**先退役再注册**(宿主 registry 拒绝重复 id,与 dsh-ptc-cordis-preset v0.16.0 的翻转教训同款)。**设置面**:host 半**顶层 await 经 `src/schemastery.js` 解析 schemastery** 并导出 `Config`(开关全 volatile 探测;拿不到 schemastery 时 `Config = undefined`,插件照常挂载;取哪一份由**宿主行为**决定,见 §4k),apply 内 `makeLiveReader(ctx, config)` 供所有翻译层消费点(shellEnv 解析器、prompt 组装、tools/execute、post-execute、posix 指示闭包、adoptSidebar)逐次读取;client 半经 `configForms` 挂详情页设置卡;**挂载行 id `gitbash-presets` → `gitbash-shell`**(与设置命名空间同串)。**绝不用顶层静态 peer import(v0.24.3 加固)**:`@deepseek-ai/schemastery` 是 peer,普通 Node 从本包位置解析不到它;顶层静态 import 一旦失败,dsh Loader 把插件行的导入失败当**非致命跳过**(`vendor/loader/src/config/entry.ts` `_init()`:logger.error + return,永不建 fiber)⇒ 本插件连 preset 变体、执行器接线与 client 半全部消失,而宿主日志全绿(与 dsh-better-workspace issue #9 同一失败类;已用"除静态 peer import 外完全相同"的夹具插件实证)。冒烟有"不得出现静态导入行"断言。**legacy settings 面已整体删除(v0.32.1)**:宿主侧的 namespace 注册块、makeLiveReader/dialectSettings 的 era 探测、四个 settings.get(ns) 读取器与 client 半的 settingsScope 注入 + settings.plugin.item 旧座位全部移除(宿主 settings 服务只余 describe/update;冒烟守卫「源码不得再出现 settings.register/SETTINGS_NAMESPACE」);设置面只剩行 Config + configForms 详情页卡这一条路。 -1. **执行器只替换 argv,不替换行为——除 Windows 受限分支(v0.13.2,issue #1)**:沙箱策略、拒绝分类、 - 后台任务、设置节全部沿用 `@deepseek-ai/dsh-bash-sandbox`;full-access 分支必须单独接 Git Bash - (父类硬编码裸 `bash`,Windows 上会解析到 WSL 占位)。**Windows confined 分支同样必须单独接**: - MSYS2 在 restricted-token(dsh-sandbox-windows-acl)下 DLL 初始化即死(msys-2.0.dll 的 cygheap - 映射与 signal pipe 的 DACL 只含用户 SID,WRITE_RESTRICTED 的 pass-2 写检查要求 restricting-SID - ACE → Win32 error 5 / 0xC0000142;0.6.0 起所有版本;cmd/pwsh 走匿名管道不受影响)——OS 层冲突 - 无插件内解,故 win32 的 read-only/workspace-write 调用改走 runArgv/startArgv(不受限执行),结果 - sandbox 标注 `enforcement: 'unconfined'` + 实例级一次性 console.log 提示;fs 工具沙箱不受影响 - (另一层)。**红线:绝不静默假成功**——要么真受限、要么明示 unconfined、失败如实带错误码。 - 社区同类取舍:绕过(zimzaza4/dsh-bash-win、Jyleaves/dsh-win-bash-fix)vs 拒绝(liceses/ - dsh-gitbash-preset);本插件选绕过+如实标注。本机复现记录见 CHANGELOG v0.13.2。 +1. **执行器替换 full-access argv;Windows 未验证受限能力必须 fail-closed(Unreleased)**: + 沙箱/生命周期基础设施沿用 `@deepseek-ai/dsh-bash-sandbox`;full-access 必须显式 Git for Windows + argv(裸 bash 会命中 WSL 占位)。Windows read-only/workspace-write/缺失策略在工作目录准备、 + Jobs admission 与 spawn 前拒绝;不得恢复旧 unconfined 绕过,不得自动换工具/模式。 + 拒绝使用实际宿主 SandboxUnavailableError,导出缺失仍必须拒绝;官方 root provider 不变。 + **拒绝不是恢复真围栏**。匹配的现代 MSYS signal pipe 有显式 SD 且缺 restricting SID, + cygheap 为 VirtualAlloc;实际 Core 已补 TokenDefaultDacl。不得再用“只补默认DACL/所有未来版本 + 无解/用户-only cygheap 映射”的历史泛化论断。新 IPC/OS 方案必须实际正负权限验收;文件工具 + policy 另属一层,拒绝不得绕过。侧栏交互终端/minimal PTY不因此获得围栏。 + 历史复现见 CHANGELOG v0.13.2;当前实施与独立验证见 CHANGELOG Unreleased。 **0.1.7 执行器契约(v0.24.4 补齐,issue #6 复盘)**:① `Config` 的六个继承字段 (`cwd`/`timeoutMs`/`maxTimeoutMs`/`maxOutputBytes`/`maxSpillBytes`/`graceMs`)**必须经 `live()` 探测加 `.volatile()`** —— 0.1.7-alpha.1 起基类把它们读成 `Volatile` 并一律 `.get()`, @@ -58,7 +57,7 @@ 插件自己的 `bashPath` 保持裸值。② 方法面改了:`run`/`start` 被删除,抽象面是 `resolve` + `execute(spec): Promise`,后台执行 = `onExpiry: 'none'` 的一次 execution,前台/后台由调用方是否 await `result()` 决定 —— 所以**必须覆盖 `execute`** 才能让 - "全权访问走 Git Bash argv""win32 受限改走 unconfined"两条继续成立(`run`/`start` 只留给 + "全权访问走 Git Bash argv""win32 未验证受限能力拒绝启动"两条继续成立(`run`/`start` 只留给 ≤0.1.6,`execute` 在无 `super.execute` 时委派给 `run`);父类的 `decorateResult` 是私有的, 就地记忆化投影由本插件的 `decorateExecution` 复刻。③ `settings` 服务在 0.1.7 上**没有 `get(ns)` 了**(只剩 `describe()`/`update()`):读插件设置一律用 `describe()` 里本行 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6dd10..d317c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,51 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## Unreleased — 2026-10-13 + +**类型**:feat(显式运行时路径/native typed argv/逐流解码) / fix(受限请求 fail-closed、生命周期与输出事实) / test / docs。 + +### Agent 选工具指引补齐(Unreleased) + +- 移除“ALL commands都优先bash”的歧义;明确shell语法/管道/POSIX脚本、精确native argv/已知逐流 + 编码、完整值Node路径转换、真正PowerShell cmdlet/WMI四类场景。只按实际可见工具名称引导, + pwsh同时要求开关允许;缺native/helper不建议虚构名称,不自动换解释器/权限绕拒绝。 +- 路由与方言开关独立,替换幂等文案块随可见性变化;PTC-only run_code得到SDK决策说明, + native/path绑定自身描述在实际Core生成SDK中可见。未知混编码不猜,decoder失败不盲目重跑副作用。 +- 增加5项路由/可见性矩阵/重组/SDK-only/路径说明回归、2项实际Core普通/PTC提示与SDK送达验证。 + 普通全仓 **223/223**、完整实际Corepreview **72/72**,exit0、无skip;不是实际LLM自主选择验收, + 不承诺每次选对。未新增Config/UI或改变工具执行参数、版本、安装、生产配置;未提交/推送。 + +### 获授权工作区实施(Unreleased,未发布/部署) + +- **策略变化**:Windows 自有 bash 与 native 在 read-only/workspace-write/缺失策略时拒绝, + 实际 Core 返回 SANDBOX_UNAVAILABLE;工作目录准备、Jobs admission/promotion与spawn前不走旧 + unconfined绕过,不自动换工具/权限。default full不放宽会话policy;root官方provider不变。 + 历史unconfined渲染保留仅为旧记录。不是恢复真实IPC/OS围栏,不覆盖minimal PTY/交互终端。 +- **完整值路径工具**:新增gitbash_to_native_path,参数msys_path、输出native_path,复用每调用 + fresh共享mount;与自动方言/codePaths独立。未知映射、URL、空值/NUL拒绝,不访问文件或授权限。 + 实际ToolRuntime→NodePtcRuntime→LocalSubprocessRuntime→Node/fs读取自有中文/#输入,route/tag不变。 +- **原生工具**:新增gitbash_native,绝对typed executable/cwd、literal/path argv,逐流utf8/gbk/ + utf16le/raw与replace/strict;只显式path转换,literal /all、/c、空串/引号保真。 + 拒绝bare command、model env、.cmd/.bat/隐式脚本;已知解释器需明确opt-in,非二进制鉴定或任意parser保证。 +- **托管与输出**:每execution私有scope复用真实executeArgv/provider/Jobs,不另造runner或依赖private + spawnSpec;decoder错误保真实exit,不诱导重跑。增量UTF8/BOM兼容、独立reader/cursor、raw/decoded产物 + 与payload预算;scope属agent,后台/promotion卸载工具后仍可结束、仅清自身资源。 +- **独立Astra反证返工**:实证ownerdispose/direct kill曾错报completed/complete、artifact callback + 可无限await、facts schema过宽;均修为operation-owned cause、中止/不完整事实、准备阶段abort/deadline + race与closed schemas。终态enum/callback三参同步;晚产物保留trusted retention,不伪称已清除。 +- **最后全仓结果**:普通回归 **218/218**,实际安装Core 0.2.1-alpha.2完整preview **70/70**,均exit0、 + 无skip/cancel。Astra独立专项复跑unit41/41、preview55/55;它们是全仓结果的重叠证据,不能累加统计。 + 语法、diff检查和npm dry-run源码清单通过;未提交、推送、安装、改生产配置或权限。 +- **测试整合纠正**:首全仓旧description断言还锁旧unconfined措辞,改为更严格拒绝/禁止绕行护栏。 + 首preview缺3项显式证据env;加test-only preview-env,operator目录优先,否则unique host temp。 + 路径夹具原硬编码D盘/安装bootstrap,改动态盘符/宿主自身bootstrap;C盘临时目录真实fs读复验通过。 + 所有失败尝试证据保留,不称失败观察为修复PASS。 +- **边界**:未知同流混编码不可普遍解码;legacy codePaths业务字符串猜测仍在,显式工具不改变其默认。 + 8MiB两流raw+decoded/16MiB自有payload不是RSS或跨任务硬quota,无自动purge;生产GUI/完整插件apply、 + 真受限token/ACL/IPC/VM负向权限、SSH/PTY与非CRT任意quoting尚未覆盖。未增加Config/UI文案字段。 + 公开讨论文档见[实验设计与接口](docs/experimental-native-execution.md)、 + [验证分级与复现](docs/experimental-native-verification.md)。原始本机日志未作为公开附件,测试可随仓库复现。 ## v0.39.4 — 2026-10-14 **类型**:feat(dsh-ops 0.3.0 互通:gb_* 退役 + 统一方言桥) / fix(docs)。 @@ -34,7 +79,6 @@ opt-in)与跨平台(win/mac/linux)支持。本版把并存形态收敛为「各 功能(boot 层侧栏终端 shell 默认)的前提下去除,README 已说明。 - **测试**:新增 dsh-ops 互通矩阵(入参 root/operations/protect、出参 items/matches/root、失败 content/message、 wrapper 门控源码断言、fastctxTools 默认关断言);全量 **172/172**(+1 win32-only skip)。 - ## v0.39.3 — 2026-10-14 **类型**:fix(自有 bash 工具的宿主服务兼容 #27、MSYS 参数/编码提示、源码路径边界与 unconfined 可观测性) / test / docs。 diff --git a/README.md b/README.md index dd4859f..d1588ec 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 简体中文 | [English](README_EN.md) +> **Unreleased/实验性实现讨论**:下述 fail-closed 与两个显式工具尚未发布/部署,不是发布候选;安装公开 npm 包不保证包含这些变更。详见[设计、接口与待讨论决定]()、[验证矩阵与复现]()及 CHANGELOG。 + > 让 DeepSeek Harness (dsh) 在 Windows 上**默认用 Git Bash 跑命令**的官方风格插件 > —— 不替换宿主执行器(官方 PowerShell 执行器原样保留):新增自有 bash 工具 > (私有 Git Bash 执行器实例,不注册为服务),并为所有模式注册对应的 Git Bash 版 @@ -111,40 +113,31 @@ bundle patch(`cordis.patch.yml`)是**纯新增**(v0.39.0 起):只插入本插件 任何目录**;旧版本遗留在磁盘上的物化树,启动时仅当 marker 判定为 「未被用户改过」才单向清理,用户改过的绝不碰(清理不受开关影响)。 -**环境变量**:`bash.exe` 是 host 进程的直接子进程(不经 git-bash 登录壳),完整继承 -系统环境变量与 `DSH_*` 快照。 +**环境变量**:`bash.exe` 经 host 的托管 subprocess provider 直接启动(不经登录壳); +父环境经过宿主凭据清洗,再合入受保护的 `DSH_*` 快照,不提供完整未过滤环境或绕过凭据保护。 ## 安全语义:Windows 上的受限模式 -**POSIX 宿主**:官方沙箱语义**完整保留**——受限令牌、工作区围栏、拒绝分类, -与 `@deepseek-ai/dsh-bash-sandbox` 完全一致。本节只讲 Windows。 - -**Windows 宿主上的事实**:`read-only` / `workspace-write` 权限预设下的 **shell 命令**, -实际以**不受限(unconfined)方式执行**: - -- **为什么**:已验证的 Git Bash/MSYS 组合在现有 restricted-token 后端下初始化失败, - 历史复现为 `msys-2.0.dll` 的 cygheap/signal-pipe 对象权限与 restricting-SID - 二次写检查不兼容(Win32 error 5 / 0xC0000142)。当前没有提供可用的受限 Git Bash - 实现,不声称所有未来运行时或隔离方案都无解。本插件保留**如实标注的绕过**; - 没有静默回退 pwsh/cmd/WSL,本次兼容修复也没有恢复 shell 写入围栏。 -- **如何标注**:结果元数据保留 - `sandbox: { mode: "<请求的模式>", denied: false, enforcement: "unconfined" }`; - 工具描述、前台正文、后台登记及结果摘要、超时转后台反馈都明确警示 shell 写限制未生效。 - 后台登记标明的是计划采用的执行边界,不代表命令已经运行;实例首次调用另有日志。 -- **文件工具是另一层**:官方 fs-tool policy 对 write/edit 保留策略检查: - `read-only` 拒绝修改,`workspace-write` 只允许声明的工作区和平台临时可写根。 - read/glob/grep 等读取并非一律限制在工作区内。shell 进程不受这些工具检查约束, - **两者的边界并不一致**;绝不能用 shell 绕过文件工具已返回的策略拒绝。 -- **可观察的对照**(相对 pwsh-sandbox 时代):会话进程的完整性级别不再被压低 - (Low → Medium);`$env:TEMP` 不再被重定向到 per-session 隔离目录。 - -**如果你需要 shell 也被硬性拦住**(例如不希望模型经 shell 写工作区外的任何路径), -注意本插件的自有 bash 工具在 Windows 受限模式下同样 unconfined(见上)——不用 -它、或卸载本插件让它消失即可;官方 PowerShell 执行器从未被本插件触碰。 -`danger-full-access` 预设本来就不受限,装不装本插件行为一致。 - -> 这一取舍的完整分析(含 0.6.0~0.13.1 的故障史与社区两派对比)见 CHANGELOG -> v0.13.2 与 `src/shell.js` 头注释。 +**POSIX 宿主**:自有执行器的受限路径仍继承官方后端与拒绝分类。本节只讲 Windows。 + +**Unreleased 的 Windows 自有 bash/native 工具采用 fail-closed**:`read-only`、 +`workspace-write` 或缺失/未解析的策略在执行前拒绝;仅真实解析为 +`danger-full-access` 的调用可以执行。受限请求不会登记/启动无围栏进程,也不会自动 +回退 pwsh/cmd/WSL、切模式或绕过文件工具拒绝。此为明确行为变化,不再保留旧版标注后绕过。 + +- **拒绝不是沙箱修复**:当前仍无已验证的受限 Git Bash。历史失败为 Win32 error 5 / + 0xC0000142;匹配本机 MSYS 源码后发现 signal pipe 显式 SD 缺 restricting SID, + 现代 cygheap 使用 VirtualAlloc,Core 已补 TokenDefaultDacl。不能继续宣称 + “只补默认DACL就修好”或“所有未来版本无解”;真正 IPC/OS 方案仍需隔离环境验收。 +- **策略按调用解析**:默认 full 不会放宽另一个只读会话;原生入口的准备、异步边界与 + spawn seam 重验策略。取消、owner dispose 和主动 kill 会标输出不完整,不伪装自然完成。 +- **文件工具是另一层**:官方 write/edit 仍按其 policy 判断;读取不一定只限工作区。 + 路径 helper 只转换字符串,不检查存在性、不授予读写权。不得用任一工具绕过已发生的拒绝。 +- **范围**:此门控覆盖本插件自有 bash/native 工具;侧栏交互终端、minimal 的持久 PTY + 和其他插件执行器不因此获得围栏。官方 root PowerShell 执行器没有被替换。 + +旧 unconfined 记录/渲染兼容仍保留供历史结果使用,不代表新调用可降级。历史见 CHANGELOG +v0.13.2,当前行为及验证见 Unreleased。 ## 配置 @@ -399,6 +392,47 @@ PTC/创造及用户自建模式一律覆盖。 都不再改写,文件工具收 Windows 路径。**bash 始终是 Git Bash,不受此开关影响**——它只决定 跨工具的路径方言。 +## 显式工具(Unreleased) + +两个工具随 Windows 的自有 bash 工具挂入同一 agent-owned scope;不替换官方工具或 root +provider,已有同名工具不覆盖。旧 Core 缺少公开 native executor/OutputCollector 能力时不 +注册 native 工具,bash/path 不受影响;minimal 的持久终端不通过此补位通道。 + +- **`gitbash_to_native_path`**:`{ msys_path: 完整值 } → { native_path }`,复用共享且新鲜的 + 挂载事实;Windows/cwd-relative 值可原样,未知 MSYS 根/缺 mount、URL、空值/NUL 显式拒绝。 + 独立于自动方言与 `codePaths` 开关,不猜程序字符串用途、不授文件权限。 +- **`gitbash_native`**:`description`、绝对 `executable: { kind: 'path', value }`、 + `argv: [{ kind: 'literal'|'path', value }]`;literal 不经 MSYS 转换,path 明确转换并规范化。 + 可选绝对 typed `cwd`;不支持裸 command、model env、隐式脚本、`.cmd/.bat` 或 escalation。 + 已知解释器须显式 `entrypoint: 'interpreter'`;这不是二进制鉴定,也不保证任意 cmd/非CRT parser quoting。 +- 每流 `stdoutEncoding`/`stderrEncoding`:`utf8`(默认)、`gbk`、`utf16le`、`raw`; + `errors: 'replace'`(默认)或显式 `strict`。同流未知混合字节应选 raw,不能自动猜码。 + 解码/存储问题独立于真实 exitCode:命令已执行不等于输出可靠,不应因此重复副作用操作。 +- 现有 Jobs 提供后台与 timeout promotion(须有 controller);缺能力明确拒绝,不静默忽略参数。 + 没有 Jobs 时为前台 kill deadline。准备阶段也有取消/有界预算;owner dispose/kill 标为中止。 +- 产物存放在 host 自有临时目录,不是 model cwd。默认两流 raw+decoded 总磁盘上限8MiB, + 适配器自有载荷预算16MiB;不是OS/RSS或跨任务硬限。完成产物保留便于读取,无自动purge承诺。 + `sourceComplete`、管线 `complete`、tail `lossy`、`artifact.complete` 是不同事实,须联合读取。 + +**Agent 路由指引**:提示组装与工具说明会明确区分:shell语法/管道/重定向/POSIX脚本用 +`bash`;原生.exe精确argv或已知逐流编码用可见的 `gitbash_native`;Node I/O需要完整MSYS +运行时路径时才用可见的 `gitbash_to_native_path`;真正cmdlet/服务/WMI用可见且开关允许的 +`pwsh`。按实际工具面引导,不推荐未注册/隐藏的工具,重复组装随可见性更新、不堆叠。 +权限拒绝必须停止,不能换工具/模式;解码失败不等于命令没执行,不能盲目重跑副作用。 + +PTC 模式只直调 `run_code`,里面使用已提供的 `tools.gitbash_to_native_path` / `tools.gitbash_native`。 +run_code描述给出SDK选路规则,实际绑定各自说明适用场景;不编造未提供的绑定名称。 +已验证实际Core普通/PTC提示与SDK文案送达;未运行真实LLM自主选择验收,不保证每次选择正确。 +假设已取得完整 `inputPath`,直接 Node I/O 可写: + +```typescript +const resolved = await tools.gitbash_to_native_path({ msys_path: inputPath }); +const fs = await import('node:fs/promises'); +return await fs.readFile(resolved.native_path, 'utf8'); +``` + +若程序含路径状路由/搜索数据,选择 `codePaths: false` 避免 legacy 自动误改;工具参数翻译仍在。 + ## 原生 Windows 程序与 run_code 的兼容边界 工具描述始终警示下面的参数、编码和安全限制;这些限制不随路径方言关闭而消失。 @@ -421,8 +455,7 @@ PTC/创造及用户自建模式一律覆盖。 直接调用 Node fs/path 时优先明确的 Windows drive-letter 或 cwd-relative 路径; 工具 path/file_path 字段仍走工具参数翻译。词法扫描不能识别路由、搜索数据或 shell command 的用途;此类字符串不要依赖自动重写,必要时关闭 `codePaths`(提示同步标为 OFF)。 -- **沙箱**:参见上方安全章节。警示可见性得到修复,但 Windows shell 的 unconfined - 取舍未改变,文件工具的策略拒绝绝不授权 shell 重试。 +- **沙箱**:参见上方安全章节。Unreleased 已改为受限请求执行前拒绝,不是恢复了真实围栏;文件工具拒绝不授权 shell/native 重试。 ## 与 dsh-better-sidebar 联动 diff --git a/README_EN.md b/README_EN.md index 44836eb..e287d24 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,6 +4,8 @@ [简体中文](README.md) | English +> **Experimental implementation discussion / Unreleased:** the fail-closed behaviour and two explicit tools below have not been published/deployed and are not a release candidate. Public npm installation does not guarantee these changes. See the [design, interfaces and open decisions](), [verification matrix and reproduction]() and Unreleased CHANGELOG. + > Run **agent commands on Git Bash by default** on Windows with DeepSeek > Harness (dsh) — the plugin does NOT replace the host executor (the official > PowerShell executor stays untouched): it adds its own `bash` tool running @@ -74,8 +76,7 @@ The old "replace the executor" three-step patch became runtime work: only when the marker proves the user never edited them (the cleanup ignores the switch). -Environment: `bash.exe` is spawned as a direct child of the host, so it -inherits the full system environment plus the `DSH_*` snapshot. +Environment: `bash.exe` is a direct child of the host's managed subprocess provider, not a login launcher. Parent environment values pass through credential scrubbing before the protected `DSH_*` overlay; this does not expose an unfiltered host environment. ### Upgrading from 0.34.x @@ -91,46 +92,38 @@ to bring old sessions back as-is, turn `variantPresets` on. ## Security semantics: confined modes on Windows -**POSIX hosts**: the official sandbox semantics are **fully preserved** — -restricted tokens, workspace fencing, denial classification, identical to -`@deepseek-ai/dsh-bash-sandbox`. This section is about Windows only. - -**On Windows**, shell commands under the `read-only` / `workspace-write` -permission presets actually run **UNCONFINED**: - -- **Why**: tested Git Bash/MSYS combinations failed to initialize under the - existing restricted-token backend. Historical reproduction found cygheap/ - signal-pipe object permissions incompatible with the restricting-SID write - check (Win32 error 5 / 0xC0000142). No working confined Git Bash implementation - is provided here; this is not a claim that every future runtime or isolation - design is impossible. The plugin retains its **labelled bypass**, with no - silent fallback to pwsh/cmd/WSL. Compatibility fixes do not restore confinement. -- **How it is labelled**: results retain - `sandbox: { mode: "", denied: false, enforcement: "unconfined" }`. - The tool description, foreground text, initial background registration, - background outcome and timeout-promotion feedback warn that shell write - restrictions are not enforced. Initial registration describes the planned - boundary, not a claim that the command has already run; there is also a - one-time notice per executor instance. -- **Official file tools have a separate policy**: write/edit still reject - mutations under `read-only`; `workspace-write` checks the declared workspace - and platform writable temp roots. Reads such as read/glob/grep are not all - limited to the workspace. Shell processes are not governed by these tool - checks — **the two boundaries differ**. Never bypass a file-tool policy - denial through shell. -- **Observable differences** vs the pwsh-sandbox era: the session process's - integrity level is no longer lowered (Low → Medium), and `$env:TEMP` is no - longer redirected to a per-session directory. - -**If you need the shell itself hard-fenced too** (e.g. the model must not -write anywhere outside the workspace through the shell), note that this -plugin's own bash tool also runs confined commands UNCONFINED on Windows (see -above) — avoid it there, or uninstall the plugin so the tool goes away; the -official PowerShell executor was never touched by this plugin. The -`danger-full-access` preset is unconfined by design either way. - -> The full analysis (the 0.6.0–0.13.1 failure history and the community's two -> camps) is in the CHANGELOG under v0.13.2 and in the `src/shell.js` header. +**POSIX hosts:** the executor's restricted path still inherits the official backend and denial classification. This section concerns Windows. + +**Unreleased Windows bash/native tools fail closed:** read-only, workspace-write or unresolved policy is refused before execution. Only an actually resolved danger-full-access call may run. Restricted requests never admit/start an unconfined process, automatically switch permissions/interpreters, or bypass file-tool denials. This changes the old labelled-bypass behaviour. + +- **Refusal is not restored confinement.** No verified confined Git Bash is provided. Historical failures were Win32 error 5 / 0xC0000142. The matched modern MSYS signal pipe uses an explicit SD without a restricting SID; cygheap uses VirtualAlloc, and Core already supplements TokenDefaultDacl. Neither a default-DACL-only fix nor permanent impossibility is established; IPC/OS solutions still require isolated-environment validation. +- **Per-call authority:** a full-access deployment default cannot widen a read-only session. Native preparation, async boundaries and the spawn seam recheck authority. Cancellation, owner disposal and direct kill mark output incomplete instead of claiming natural success. +- **Separate file policy:** official file tools retain their own checks; reads are not necessarily workspace-only. The path helper only converts strings: no existence check or permission grant. A refusal never authorizes another-tool workaround. +- **Scope:** this gate covers this plugin's own bash/native tools. Sidebar interactive terminals, minimal's persistent PTY and other plugins' executors do not acquire confinement from it. The official root PowerShell executor stays untouched. + +Historical unconfined records/rendering remain readable; they do not permit new calls to downgrade. See CHANGELOG v0.13.2 for history and Unreleased for current behaviour/tests. + +## Explicit tools (Unreleased) + +Both companions join the Windows own-bash agent scope without replacing official tools/root providers. Existing names are not overwritten. Missing public native executor/OutputCollector capability skips native without breaking bash/path; minimal's persistent terminal does not use this inline patch. + +- **gitbash_to_native_path**: `{ msys_path: completeValue } → { native_path }`, using fresh shared mount facts. Native Windows/cwd-relative values may pass through; unknown MSYS roots/missing facts, URLs, empty/NUL values reject. Independent of automatic dialect/codePaths switches, with no filesystem access or permission grant. +- **gitbash_native**: description, absolute `executable: {kind:'path',value}`, `argv: [{kind:'literal'|'path',value}]`, optional absolute typed cwd. Literals avoid MSYS conversion; only declared paths translate/normalize. No bare command, model env, implicit scripts, .cmd/.bat or escalation. Known interpreters require explicit `entrypoint:'interpreter'`; this is not executable attestation or a guarantee about arbitrary cmd/non-CRT parsers. +- Independent stdoutEncoding/stderrEncoding: utf8 (default), gbk, utf16le or raw; errors defaults to replace, strict is opt-in. Unknown mixed bytes require raw rather than guessing. Decoding/storage failures retain actual exit facts; never blindly rerun side effects because display failed. +- Existing Jobs supports background/promotion when its controller is attached. Missing capability rejects rather than ignoring fields. Without Jobs, foreground uses a kill deadline; preparation also has bounded cancellation. Owner disposal/direct kill marks interrupted output. +- Host-owned temporary artifacts, not model cwd: default 8MiB combined raw+decoded disk cap and 16MiB adapter-owned payload budget, not OS/RSS or cross-task hard quotas. Completed files remain readable; no automatic purge is promised. sourceComplete, pipeline complete, tail lossy and artifact.complete are distinct facts. + +**Agent routing:** descriptions distinguish shell syntax/pipelines/redirection/POSIX scripts (bash), exact native .exe argv/known per-stream encoding (offered gitbash_native), complete MSYS runtime values before Node I/O (offered gitbash_to_native_path), and genuine cmdlets/services/WMI (offered, enabled pwsh). Guidance follows actual visibility and replaces its own block on reassembly. Permission denial means stop, not another-tool/mode retry; display failure never authorizes blindly repeating side effects. + +PTC directly calls only run_code; invoke the offered `tools.gitbash_to_native_path`/`tools.gitbash_native` inside it. The run_code description explains SDK selection without inventing absent names, while binding descriptions explain their purpose. Actual Core native/PTC prompt delivery is tested; autonomous LLM selection has not been tested or guaranteed. Given an already obtained complete inputPath: + +```typescript +const resolved = await tools.gitbash_to_native_path({ msys_path: inputPath }); +const fs = await import('node:fs/promises'); +return await fs.readFile(resolved.native_path, 'utf8'); +``` + +For route/search strings that resemble paths, choose codePaths:false to avoid legacy guessing; typed tool argument translation still works. ## Native Windows and run_code compatibility boundaries @@ -163,8 +156,7 @@ output encodings. Lexical scanning cannot distinguish routes/search data/shell command strings from paths. Do not rely on rewriting those values; disable `codePaths` when needed (the model hint now accurately states OFF). -- **Security**: warnings are now visible, but Windows unconfined behavior is - unchanged. A file-tool policy denial never authorizes a shell retry. +- **Security**: Unreleased refuses restricted calls before execution; this is not restored confinement. A file-tool policy denial never authorizes a shell/native retry. ## Config diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md new file mode 100644 index 0000000..4d7565e --- /dev/null +++ b/docs/experimental-native-execution.md @@ -0,0 +1,219 @@ +# 实验性讨论:Windows 安全拒绝、typed native argv 与显式路径转换 + +> **状态:实验性实现讨论 / Draft,Unreleased。** +> 这是可运行的工作区实现,用来验证接口与讨论工程取舍,不是发布建议,也不是完整安全认证。 +> 尚未部署到生产实例;没有改变宿主安装产物、权限、版本号或发布流程。 + +关联:[验证矩阵与复现]()、[变更记录](<../CHANGELOG.md>)。 + +## 1. 要讨论的决定 + +本提案把三个容易混为一谈的问题拆开: + +1. **没有可靠的 Windows Git Bash 围栏时怎么办?** 当前实验选择 fail-closed,不再“标注无围栏后继续”。 +2. **必须精确保留 Windows 原生参数、明确解码原生输出时怎么办?** 新增独立 typed argv 入口,复用 Core 托管执行。 +3. **原生 Node API 如何使用模型得到的 MSYS 路径?** 新增完整值运行时转换,不再把源码字符串猜测当作可靠机制。 + +另补 Agent 路由指引,让上述机制不是仅存在于 README 中的“需要用户手动教模型”的能力。 + +**维护者需要确认的是取舍,不是只看测试绿色:** 是否接受拒绝行为变化、是否需要真正的 opt-in 开关、哪些契约应移入 Core,以及缺少哪些验收才能从实验转为正式功能。 + +## 2. 问题与非目标 + +### 2.1 MSYS 参数转换 + +在 Bash 启动原生程序时,MSYS 可能把 `/all`、`/c` 当作路径。单命令 EXCL、双斜杠等只适用于确定场景;EXCL 按前缀匹配,排除 `/c` 也可能影响真正的 `/c/...` 文件参数。 + +实验不全局设置 `MSYS2_ARG_CONV_EXCL=*`,不重写 Bash command,不猜某个字符串究竟是 option、路径还是程序源码。 + +### 2.2 输出编码 + +UTF-8 捕获不等于原生程序输出 UTF-8。locale 变量不保证改变其代码页;同一调用、甚至同一流可以混合编码。没有替换字符也不能证明转码正确。 + +实验只支持显式已知编码。**未知同流混编码不能普遍可靠解码;损坏后的字符串不能普遍还原原始字节。** + +### 2.3 源码字面量猜测 + +legacy `codePaths` 无法识别 `/c/users` 是路径还是路由数据,也不能可靠处理所有动态表达式。它的默认值和既有兼容机制没有在本提案中移除。 + +显式 helper 是完整值接口,不是“更聪明的全程序重写器”。处理路径状业务数据时建议关闭 `codePaths`;普通工具路径参数翻译不因此关闭。 + +### 2.4 真正 Windows confinement + +本提案**没有修复 OS 围栏**。匹配研究纠正了旧泛化:现代 MSYS cygheap 使用 VirtualAlloc,signal pipe 使用显式 SD;实际 Core 已补 TokenDefaultDacl。不能据此声称“再补默认 DACL 就解决”或“所有未来方案都无解”。 + +IPC/Token/AppContainer/VM 路线仍需隔离环境的真实正负权限验证;本 PR 不修改 ACL、token、registry 或宿主 Core。 + +## 3. 原则与体验变化 + +| 项目 | 旧行为或既有原则 | 当前实验 | +|---|---|---| +| Windows 受限自有 Bash | 无围栏执行并标注 unconfined | RO/WW/缺失策略在执行前拒绝 | +| 默认 Shell | Git for Windows Bash | 保持;原生入口是显式补充,不是隐式 fallback | +| root PowerShell/provider | route B 不替换 | 保持 | +| 原生参数 | 通过 Bash/MSYS,可能转换 | typed native 入口只转换标明的路径 | +| Node 路径 | 字面量尽力重写 | 可显式转换运行时完整值;legacy 默认未改 | +| 编码 | 原有 UTF-8 捕获边界 | 新入口逐流显式编码,默认 utf8/replace | +| 设置/界面 | 现有 Config/UI | 不增字段;没有独立 experimental enable 开关 | + +**重要:Draft 标签不等于运行时 opt-in。** 当前源码被安装后,Windows own-bash scope 会挂载可用 companion;拒绝行为也会生效。这是否应该增加默认关闭的 feature flag,是待讨论项,而不是已经实现的保护。 + +## 4. Agent 应如何选择 + +| 意图 | 选择 | 禁止的理解 | +|---|---|---| +| Shell 脚本、管道、重定向、globbing、POSIX utilities | `bash` | 不能把权限拒绝当作改用另一个执行入口的许可 | +| Windows `.exe` 精确 literal argv,或已知逐流编码 | 可见的 `gitbash_native` | 不是 shell source,也不是通用脚本包装器 | +| 完整 MSYS 路径交给原生 Node fs/path | 可见的 `gitbash_to_native_path` | 不转换路由、搜索数据、command/code;不授文件权限 | +| 真正 PowerShell cmdlet/服务/WMI | 可见且开关允许的 `pwsh` | 不调用隐藏名称、不把 PowerShell 嵌进 Bash 绕行 | +| 权限/访问拒绝 | 停止并说明 | 不自动换模式、解释器、文件工具或 Node I/O | +| 显示/解码失败 | 核对 producer exit 与输出事实 | 不盲目重复有副作用的命令 | + +[路由生成](<../src/inline-tools.js>)在[提示组装](<../src/index.js>)按实际工具面生成,不只看开关意图。自有提示块可替换、幂等;重组后不残留失效建议。方言/子代理方言关闭不关闭安全选路指引。 + +PTC 中直调 schema 可只有 `run_code`:该描述解释如何选择已提供 SDK binding,具体 native/path binding 的描述进入 `tools:sdk`。没有把 SDK 声明误当成可直调工具,也不编造缺失名称。 + +**已验证提示送达,不等于已验证 LLM 必然正确决策。** 当前没有真实模型自主选工具评测。 + +## 5. 架构:不建立第二套进程系统 + +```text +agent-owned tool scope + ├─ bash → private GitBashSandboxExecutor → existing Core lifecycle/provider + ├─ gitbash_to_native_path → fresh shared translator → native_path(无I/O) + └─ gitbash_native + ├─ 每调用解析真实 standing policy / 严格请求校验 + ├─ prepared capability(WeakMap;不是可伪造的 policy DTO) + ├─ bounded preparation / trusted artifact owner + └─ per-operation scope(属于 agent,隔离 shell + subprocess) + └─ actual LocalBashExecutor.executeArgv + └─ private guarded provider facade + └─ actual managed provider + per-stream output adapter +``` + +对应:[执行器](<../src/native-executor.js>)、[工具/Jobs/DTO](<../src/native-tool.js>)、[输出适配](<../src/native-output.js>)。 + +不改 root 服务条目,不另造 child_process runner/Jobs registry,不覆盖 private spawnSpec。新模块不静态导入宿主 peer;由既有 host resolution 通道注入真实模块。 + +## 6. 权限与拒绝 + +### 6.1 自有 Bash + +[策略门](<../src/shell.js>)要求 Windows spec 明确为 `danger-full-access`;其他/缺失策略拒绝。工具层[预检查](<../src/bash-tool.js>)位于工作目录准备、环境收集和 Jobs admission/promotion之前。 + +实际 host SandboxUnavailableError 能解析时保留结构化 SANDBOX_UNAVAILABLE;直接/旧调用者缺导出也必须拒绝,不能因此运行。非 Windows 的受限路径仍继承父后端。 + +### 6.2 Native + +不以部署 defaultMode 代替逐调用策略;准备、执行、目录 callback 前后、最终 spawn seam 重验真实 standing policy。Prepared request 只能由同一个 executor 产生,复制或跨 executor 不构成能力授权。 + +这只是本地 Windows danger-only 入口,**不是 native restricted-token adapter**;不支持模型传入 env、sandbox_permissions 或自动 escalation。 + +### 6.3 边界 + +官方文件工具有独立 policy;helper 转换不扩大权限。minimal 持久 PTY、侧栏交互终端和其他插件执行器不因本门控获得围栏。历史 unconfined 渲染仍兼容旧记录,不授权新调用降级。 + +## 7. 接口 + +### 7.1 `gitbash_to_native_path` + +输入 `{ msys_path: string }`,成功值 `{ native_path: string }`。复用共享 translator 与 fresh mount facts;无 fs I/O、存在性判断或 env seeding。 + +Windows-native/cwd-relative 值声明后可 passthrough;未知 POSIX mount、缺映射、空值、NUL、URL明确拒绝。`msys_path`/`native_path`避开旧参数/结果的自动路径字段翻译,避免转换前被改写或成功值回流。 + +PTC 程序示例,假设 `inputPath` 是已经取得的完整路径值: + +```typescript +const resolved = await tools.gitbash_to_native_path({ msys_path: inputPath }); +const fs = await import('node:fs/promises'); +return await fs.readFile(resolved.native_path, 'utf8'); +``` + +这不是绕过文件拒绝的方式;已发生拒绝必须停止。 + +### 7.2 `gitbash_native` + +| 字段 | 契约 | +|---|---| +| description | 必填、非空、NUL-free | +| executable | 必填 `{kind:'path',value}`,可解析绝对 `.exe`;无 bare command | +| argv | 必填 typed 数组;literal 原值,path 显式转换/规范化;允许空 literal | +| cwd | 可选绝对 typed path;默认 agent workspace | +| entrypoint | native 默认;已知解释器需显式 interpreter | +| stdoutEncoding/stderrEncoding | utf8 默认,或 gbk/utf16le/raw;不支持 auto | +| errors | replace 默认,strict opt-in | +| timeoutMs | 正有限数,默认120000ms,上限600000ms | +| run_in_background | 仅能力允许时声明;与显式 timeoutMs 同时给出会拒绝 | + +Native typed path 不支持相对值、变量/home展开;需要时先通过显式 helper取得可用绝对值。Windows 启动字段/path argv规范化反斜杠;literal `/all`、`/c` 等不经过 MSYS。 + +拒绝未知字段、model env、path-list env、隐式脚本、`.cmd/.bat`。当前限制4096个argv及保守Windows command-line预算。已知解释器名称检查是防误门,不是二进制鉴定;重命名/custom exe可有自己的语言。 + +**Exact argv 指验证过的 Core/CRT/Node 语义,不承诺任意 cmd/非CRT parser 的 quoting。** + +## 8. 编码与输出事实 + +只有已知 collect disposition `{maxBytes,spill?:{maxBytes}}` 转 raw pipe;未知/其他 disposition按原 provider契约委托。每 operation immutable config,stdout/stderr独立 decoder,只在 ingest解码。 + +- UTF8默认宽容替换,保留BOM;strict显式。 +- Decoder能力在spawn前探测;unknown同流mixed选raw。 +- 原字节产物与normalized UTF8产物分开;raw无可靠文本承诺。 +- Reader为独立、非消费normalized UTF8 byte cursor;不是原编码byte游标,也不是Core Jobs全局游标。 +- handle.done保留真实provider outcome/error身份;decode/storage问题只记facts,不伪造命令未执行。 +- OutputCollector只使用公开push/snapshot/seal;不访问其私有spill字段。 + +| 字段 | 解释 | +|---|---| +| sourceComplete | 是否收到自然EOF;不能单独证明程序未被中止 | +| complete | 管线完整性;不承诺tail包含全文 | +| lossy/reliableText | 与尾缓存、解码、存储等损失合读,不是编码自动鉴定 | +| aborted | 取消/主动terminate/owner终止事实 | +| artifact.complete/capReached/error | 对该产物自身完整性、预算、存储错误判断 | +| rawBytes/rawCursor | 原流字节计数/游标 | +| decodedUtf8Bytes/decodedCursor | normalized UTF8计数/游标 | + +Foregound DTO使用closed schemas;去undefined投影不放任unknown字段。无facts/投影失败是显式分支,不通过宽schema吞错。后台最终exit/facts进入现有Jobs文本source,不声称扩展Core Jobs结构化API。 + +## 9. 生命周期与预算 + +准备阶段workspace/scope/plugin mount/artifact callback受同一取消/有界期限控制,剩余预算交Core;后台/onExpiry:none只限制准备,已handoff producer不会被准备timer杀死。 + +operation-owned AbortController统一caller cancel、explicit kill、ownerdispose;先记录cause再terminate。正常settlement后释放scope不能反向污染已完成facts。 + +输出end/error/close/abort/provider failure分别收尾;默认250ms有界drain,不无限等后代持有管道。不保证所有输出在短drain窗口内到达,未完整必须如实标注。 + +默认两流raw+decoded总磁盘8MiB、自有payload16MiB;含保守tail/copy/scratch预留。**不是OS/RSS、provider buffer、caller副本或跨调用硬quota。** + +trusted owner在真实policy/校验后创建自有临时目录,不从model cwd建产物目录。Adapter不创建目录、chmod、删除或授权;文件有限wx创建。完成产物保留供晚reader读取,无自动purge承诺。晚callback结果不续spawn,不武断删除可能仍被引用的目录;归trusted retention owner。 + +## 10. Jobs 与动态装配 + +复用registry.start/wait/read/kill/remove、processJob/processSources和owner/controller admission。Scope属于agent,不属于tool registration fiber;后台/promotion后卸载工具不提前销毁运行进程。 + +没有controller时明确拒绝,不以“admission失败后再运行前台”冒二次副作用风险。没有Jobs时为前台kill deadline;有Jobs时可timeout promotion。该差异需在正式用户体验验收中检查。 + +Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。缺native executor/OutputCollector公开能力不撤bash/path。新工具不加入minimal持久终端通道,不改变root PowerShell、market patch或preset id。 + +## 11. 已发生的反证与修正 + +1. 初版ownerdispose/direct kill虽quiet且无LATE,却错报completed/aborted:false/artifact.complete:true。独立固定sentinel反证后,operation cause与adapter terminate共同修正;正常cleanup反向用例确保不污染成功结果。 +2. trusted artifact callback初版可无限await;abort/deadline时0spawn但不返回。现准备阶段有界race,晚callback不恢复执行,保留资源责任明确。 +3. 初版facts空宽schema现closed DTO;跨模块termination枚举/三参callback曾不同步,已对齐并真实Core负向校验。 +4. 集成发现显式证据env未配置、路径夹具硬编码盘符/安装bootstrap;改test-only目录预载、动态盘符、宿主自身bootstrap,真实Node fs复跑通过。没有放宽产品断言掩盖失败。 + +详情见[验证文档]()。失败观察用例退出0只表示复现成功,不能算修复通过。 + +## 12. 合并讨论 / 未覆盖 + +- [ ] 是否接受受限模式从旧绕过改为拒绝的breaking体验? +- [ ] 是否拆分fail-closed、native/path、路由三个PR?本地基线包含此前兼容修复,需确认目标base/依赖。 +- [ ] 是否增加默认关闭的experimental开关?当前没有此开关。 +- [ ] Native argv、逐流encoding、normalized cursor、artifact facts是否应成为Core公开加法契约? +- [ ] 在哪一层承担跨任务quota、artifact权限/retention/清理? +- [ ] Controller缺失/Jobs promotion如何形成一致的前台体验? +- [ ] 真实Agent选工具评测、生产GUI/full apply、会话replay尚未验收。 +- [ ] 真restricted-token/ACL/IPC/VM正负权限、PTY/SSH、任意非CRT/解释器quoting尚未验收。 +- [ ] 真实disk exhaustion、吞吐/RSS峰值、跨任务DOS/自动purge尚未验收。 +- [ ] 若未来Core提供受限native adapter,应如何探测启用而非偷换mode? + +**当前结论:接口机制与确定性隔离用例可讨论;不能据此宣布普遍乱码解决、Windows沙箱恢复或正式发布就绪。** diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md new file mode 100644 index 0000000..59c069f --- /dev/null +++ b/docs/experimental-native-verification.md @@ -0,0 +1,145 @@ +# 实验性实现:验证矩阵、证据分级与复现 + +> **这是实验性讨论的验证记录,不是发布或安全放行。** +> 关联:[设计/接口/待讨论决定]()、[CHANGELOG](<../CHANGELOG.md>)。 +> 记录日期:2026-10-13(Asia/Hong_Kong);基于当时实际源码与隔离测试,不将本机条件推广为所有宿主保证。 + +## 1. 验证基准与结果 + +- 工作区基线:`4816cd8848a52f73f5f054f761d3ec862cb87ade`,此前MSYS兼容边界修复;实验与Agent路由是在此基线之后的工作树改动。 +- 当时实际安装 **DSH Core 0.2.1-alpha.2**,Node 24.13.0,Windows / Git for Windows。 +- Core版本不是CLI、桌面壳或npm管理器版本。包的既有宿主范围不等于新native能力已在所有版本测试;启用依赖真实公开能力。 +- 最终普通全仓:**223 tests / 223 pass / 0 fail / 0 skip / 0 cancel,exit0**。 +- 最终实际Core完整preview:**72 tests / 72 pass / 0 fail / 0 skip / 0 cancel,exit0**。 +- 语法与`git diff --check`通过;npm dry-run确认新源码包含于发布文件集合。Dry-run不是已安装/发布,也不是新增生产版本。 + +计数是Node test runner报告口径,包括子测试/套件;不同轮次或独立复跑有重叠,**不累加为独立覆盖数量**。223/72是完整脚本结果,不意味着223个单元加72个都是真生产进程场景。 + +## 2. 证据等级 + +| 等级 | 本提案实例 | 可以证明 | 不能证明 | +|---|---|---|---| +| 静态/源码与公开契约 | 配置、SDK/types、实际安装导出、匹配MSYS因果资料 | 实现依赖与约束、提出机制假设 | 新OS沙箱可用或任意版本兼容 | +| 纯单元/受控注入 | 请求parser、预算、流异常、字段schema、路由矩阵 | 确定性输入输出与拒绝/收尾规则 | 真实磁盘故障或任意producer行为 | +| 实际Core + 禁止spawn桩 | ToolRuntime/scoped装配/提示组装 | 真Core结构化拒绝、零admission、注册/卸载/隐藏 | 生产GUI或真实native程序跑通 | +| 实际Core + 受控native程序 | LocalSubprocessRuntime/executeArgv/Jobs、固定bytes/READY/LATE | 本机真实argv、编码、kill/quiet/promotion与owner行为 | 全部原生parser、网络/SSH、真受限token | +| 实际PTC + Node/fs | ToolRuntime→NodePtcRuntime→LocalSubprocessRuntime→Node fs读自有输入 | 完整值helper子分发、动态路径、route/tag保持 | 完整插件apply/生产用户policy/模型自主选择 | +| 实际Core提示/SDK生成 | SystemPrompt.assemble普通/PTC及工具visibility | Agent将收到正确的说明与可见binding | 模型每次一定选对 | +| 未执行 | 生产GUI/full apply/LLM选择、OS权限、吞吐/RSS/retention | 仅列为待验收 | 不能作为通过项 | + +## 3. 可审阅测试清单 + +| 文件 | 重点 | 层次 | +|---|---|---| +| [smoke](<../tests/smoke.mjs>) | legacy与modern executor契约、Windows受限/未解析零argv、full/非Windows行为、既有护栏 | 纯回归与受控基类 | +| [runtime regressions](<../tests/runtime-regressions.mjs>) | 受限前台/后台/promotion admission前拒绝,既有Jobs动态卸载 | Cordis受控回归 | +| [compatibility regressions](<../tests/compatibility-regressions.mjs>) | 源码路径边界、live hint、选路判据、16种可见性组合、幂等、SDK-only/路径说明 | 确定性纯回归 | +| [path unit](<../tests/path-tool.mjs>) | 共享translator/fresh env、empty/NUL/URL/未知映射、schema/注册/卸载 | 纯单元 | +| [path preview](<../tests/path-preview.mjs>) | 真实PTC子分发与Node fs读中文/#自有输入、完整值动态组合、route/tag保持、catchable错误 | 实际引擎/子进程/文件读 | +| [output unit](<../tests/native-output.mjs>) | incremental/BOM、默认replace/strict、budget/cursor、异常drain、terminate完整性 | 单元含受控异常 | +| [output preview](<../tests/native-output-preview.mjs>) | 公开OutputCollector、actual raw pipe、多编码、真实exit与decodeError分离、READY后终止/quiet | 实际Core/固定producer与受控I/O异常 | +| [native unit](<../tests/native-executor.mjs>) | typed argv/path、解释器/字段/编码/env拒绝、预abort、准备/mount有界 | 单元 | +| [native preview](<../tests/native-preview.mjs>) | 实际executeArgv/ToolRuntime/Jobs、argv、多流、strict exit7、credential sentinel、timeout/背景/promotion/controller/owner | 实际Core/真实controlled Node | +| [independent regression](<../tests/native-integration-preview.mjs>) | prepared伪造/跨executor、重复policy gate、callback revocation/abort/deadline、owner/kill、自然完成后晚reader | 实际Core独立反证回归 | +| [runtime preview](<../tests/runtime-preview.mjs>) | actual拒绝、agent装配/卸载/重载、官方root保留;普通/PTC真实prompt/SDK及隐藏工具 | 实际Core,执行部分使用桩 | + +既有client/FastCtx/host/preset/桥接测试仍在完整脚本中;没有只运行新增测试冒充全仓通过。 + +## 4. 特别需要看负向断言 + +### 4.1 Policy不是defaultMode + +- RO/WW/missing拒绝;默认full不能放宽另一会话。 +- Prepared DTO复制、其他executor产生的对象拒绝。 +- 重复resolve阶段与实际executeArgv的spawn seam分别测策略翻转,均零producer spawn。 +- Artifact await期间撤销/取消也拒绝,不恢复已取消准备。 +- 自有bash前台、显式后台及promotion不得先登记job再报告拒绝。 + +这些断言验证入口门禁,**不是实际restricted-token写入正负试验**。 + +### 4.2 Producer outcome与display分离 + +- Strict非法字节不能把真实exit0/exit7改成“命令没有执行”。 +- Raw bytes保留、stdout/stderr单独解码,并发编码不能共享latest状态。 +- `sourceComplete`可与`aborted:true`同时出现,不把自然pipe EOF误作自然producer完成。 +- 实际ownerdispose/directkill保持range quiet且无LATE,同时status killed/aborted true/artifact incomplete。 +- 自然完成后的scope cleanup不得反向污染complete;晚reader产物仍可读。 + +### 4.3 准备阶段取消 + +- 挂起workspace、scope mount、trusted artifact callback受有界等待控制。 +- 取消/期限到达时不等待late callback才返回;scope释放,零spawn。 +- 晚目录结果归trusted owner保留,不冒称自动purge;不武断删除仍可能有引用的资源。 + +### 4.4 输出字段严格性 + +真实ToolRuntime对unknown深层facts、错误类型budget、伪造fallback分支进行负向校验。去undefined只做lossless DTO投影,不放宽closed schema;SDK不支持的minimum关键词没有强行加入,非负计数由adapter运行时校验。 + +### 4.5 Agent选路说明 + +- Shell与native先按任务性质选,不把native当权限拒绝后的fallback。 +- 不再同时承诺“所有命令ALL commands走bash”与“原生入口可用”。 +- `pwsh`必须实际可见且switch允许;native/helper缺失不得推荐其名称。 +- 重复组装和隐藏后更新提示,不堆叠旧块。 +- PTC assembly只见run_code时解释SDK调用,实际SDK含binding自有说明。 +- 委托方言关闭仍有选路/禁止绕行提示。 + +**没有调用真实LLM让它独立选择工具**;此处是prompt delivery,不是行为成功率统计。 + +## 5. 复现方式(开发测试,不是安装教程) + +在工作区已有开发依赖与对应宿主条件下: + +```bash +npm test +``` + +Windows实际Core测试使用操作者指定的宿主目录: + +```bash +DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:preview +``` + +上面的尖括号是占位符,不是可执行路径;目录应来自实际目标宿主,不要从同名CLI推断。 + +脚本使用[host loader](<../tests/host-loader.mjs>)只读解析实际安装导出,再预载[test-only evidence env](<../tests/preview-env.mjs>)。无安装、restart、profile修改、ACL/token/registry修改或真实网络/secret探查。 + +默认创建unique host temp scratch并保留测试证据;不是无副作用只读测试。每流artifact有预算,但测试目录留存仍归调用方。也可以提前建立自有绝对证据目录后提供: + +- DSH_NATIVE_OUTPUT_EVIDENCE_DIR +- DSH_NATIVE_EVIDENCE_DIR +- DSH_NATIVE_REVIEW_EVIDENCE_DIR +- DSH_PATH_EVIDENCE_DIR + +单独运行某个preview时须同时预载host-loader和preview-env,或自行提供它要求的已授权证据目录。不要运行package的version脚本来“验收”:它会改文件并git add,不属于测试。 + +## 6. 失败记录与证据修正 + +| 曾出现的失败 | 纠正 | 为什么记录它 | +|---|---|---| +| Cordis bound-function直接===不同 | 比较真实service registry entry身份 | trace proxy不能被误判为root替换 | +| 夹具缺session.header.id | 补实际shellEnv要求 | 不放宽产品env契约 | +| outputFacts可选undefined | 工具边界lossless投影,closed DTO负例保留 | 不掩盖Core拒绝或producer exit | +| ownerkill只terminate | 加operation cause与adapter interruption,反向cleanup断言 | 原有quiet通过不等于完整性正确 | +| callback无限await | 准备deadline/abort race,late资源责任明确 | 零spawn仍可能无限悬挂 | +| 空宽facts schema | 枚举closed shapes与终态,真实负例 | 不能宽schema补签 | +| 最终termination/callback跨模块不同步 | 精确enum和三参signal对齐 | 防止输出校验抹真实outcome | +| 旧description还锁unconfined宣传 | 改为更强拒绝/不绕行护栏 | 行为变化不是弱化测试 | +| 完整preview缺evidence env/夹具固定盘符 | test-only预载、动态盘符、host bootstrap | 本机可重复不等于可迁移 | + +独立审查旧态观察测试即使exit0,只证明缺陷被复现。最终将这些观察转为修复后的硬回归。独立专项复跑41/55与全仓结果重叠,不另加总数。 + +原始本机日志、失败尝试与artifact保留在操作者本地证据目录;**本文件不是完整日志附件**。公开PR不附个人绝对路径、真实环境、密钥、Cookie或未经审查的raw producer数据。仓库测试提供复现机制;本记录公开的是可审阅断言、计数与限制。 + +## 7. 转正式功能前的验收清单 + +- [ ] 决定experimental opt-in/default与受限breaking体验。 +- [ ] 生产GUI/full plugin apply、Agent真实选择、会话replay。 +- [ ] 真Windows confinement正负权限/IPC/token及可接受隔离方案。 +- [ ] SSH/PTY、代表性非CRT/解释器程序的实际quoting。 +- [ ] 真实disk exhaustion、吞吐/RSS、跨调用配额/retention/清理。 +- [ ] 更多真实Core版本与宿主布局;能力不足不得误advertise。 +- [ ] Jobs/controller缺失和promotion用户体验。 +- [ ] 决定哪些接口应该成为Core公开加法契约。 + +在这些项完成前,保持Draft/实验讨论定位,不称普遍编码修复或新沙箱实现。 diff --git a/package.json b/package.json index 226b6c1..ae5277c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "files": [ "src", + "docs", "cordis.patch.yml", "dsh.plugin.json", "CHANGELOG.md", @@ -52,8 +53,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", - "test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-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", + "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", "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json" }, "keywords": [ diff --git a/src/bash-tool.js b/src/bash-tool.js index 23714a9..0526b1e 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -102,7 +102,7 @@ export function bashDescription() { + 'Git for Windows (MSYS) converts path-looking arguments when invoking native Windows programs: ipconfig /all and cmd /c may break; use ipconfig //all or cmd //c, or a per-command MSYS2_ARG_CONV_EXCL prefix. ' + 'Exclusions are prefixes: excluding /c also excludes /c/... paths, so use native drive-letter paths for those arguments; do not disable conversion globally. Verify the expected effect as well as the exit code. ' + 'Output is decoded as UTF-8; native Windows programs may emit OEM/ANSI bytes (for example CP936/GBK) and display garbled text. Use a program UTF-8 option or explicit known-encoding conversion before capture; locale variables alone do not fix native output, and mixed streams cannot safely be guessed or decoded globally as GBK. ' - + 'On Windows, this Git Bash route does NOT enforce shell file-write restrictions in read-only/workspace-write mode (unconfined). Official file-tool write/edit policy is separate; never use shell to bypass its denial.' + + 'On Windows, read-only/workspace-write or unresolved policy is refused before this Git Bash command starts because verified confinement is unavailable. Only an explicitly resolved danger-full-access policy can execute this route. Do not switch tools or permissions automatically to bypass a refusal; official file-tool write/edit policy remains separate.' } /** The official foreground/present call view (index.ts:108). */ @@ -511,6 +511,8 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode } + // Refuse unsupported policy before directory preparation or job admission. + if (typeof executor.gitBashRoute === 'function') executor.gitBashRoute({ sandboxPolicy: policy }) // Optional read (issue #27): on hosts that provide workingDirectory // this is the same ensure() seam the modern official tool-bash uses; // on hosts without it, fall back to the session cwd exactly the way diff --git a/src/index.js b/src/index.js index fd2d8c1..708f85a 100644 --- a/src/index.js +++ b/src/index.js @@ -46,6 +46,7 @@ import { cpSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, rmSync, @@ -57,7 +58,7 @@ import { fileURLToPath } from 'node:url' import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js' import { loadSchemastery } from './schemastery.js' -import { GITBASH_MSYS_ADDENDUM, GITBASH_ROUTE_ADDENDUM, GITBASH_ROUTE_ADDENDUM_NO_PWSH, mountInlineTools } from './inline-tools.js' +import { applyToolRouting, mountInlineTools } from './inline-tools.js' import { mountFastctx } from './fastctx-bridge.js' /** @@ -2219,25 +2220,15 @@ export async function apply(ctx, config = {}) { no run_code hint — so they meet the shell they were composed for. */ const dialect = liveSettings.dialect() const applies = dialectApplies(dialect, assembleContext && assembleContext.agent) - // v0.35.0: shell-routing addendum on the `bash` tool's description. - // Description is schema-level text, so even the minimal preset's - // complete-persona cannot strip it; the ROUTING sentence applies to - // every agent (routing is dialect-independent), while the MSYS - // sentence only describes the world the dialect switch creates. - // Idempotent by marker: assemble may run more than once per agent. - // The routing sentence follows the pwsh switch (v0.37.0; route B - // v0.39.0): while the pwsh tool is hidden (the default) the sentence - // must NOT name it — a route to a tool the agent cannot see only - // teaches the model to call a hidden name. - if (assembly && typeof assembly === 'object' && Array.isArray(assembly.tools)) { - const routeSentence = liveSettings.pwshTool() === true ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH - for (const tool of assembly.tools) { - if (tool && tool.name === 'bash' && typeof tool.description === 'string' - && !tool.description.includes('Prefer this bash tool')) { - tool.description += '\n' + routeSentence - + (applies && dialect.posixPaths === true ? '\n' + GITBASH_MSYS_ADDENDUM : '') - } - } + // 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(assembly.tools, { + pwshVisible: liveSettings.pwshTool() === true, + msys: applies && dialect.posixPaths === true, + }) } if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') { for (const section of Array.isArray(assembly.sections) ? assembly.sections : []) { @@ -2765,6 +2756,17 @@ export async function apply(ctx, config = {}) { } }, pwshEnabled: () => liveSettings.pwshTool() === 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())), + // Trusted host-owned output storage, invoked only after the native + // executor's actual policy/argument/capability gate. Never model cwd. + // Completed bounded artifacts remain readable like Core spill files; + // this does not claim a cross-task quota or automatic retention purge. + createArtifactDirectory: (_request, _exec, signal) => { + signal?.throwIfAborted() + return mkdtempSync(join(tmpdir(), 'dsh-gitbash-native-')) + }, log: (m) => console.log(TAG + ' ' + m), }) console.log(TAG + ' inline tool patch armed (own bash tool over a private Git Bash executor; official pwsh executor untouched)') diff --git a/src/inline-tools.js b/src/inline-tools.js index e41aaf1..dc15f90 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -101,14 +101,14 @@ export function inlineNeeds(names, { pwshVisible } = {}) { * routing. Both hold regardless of the path dialect a delegate sees. */ export const GITBASH_ROUTE_ADDENDUM = [ - 'Prefer this bash tool for ALL commands; use the pwsh tool ONLY when a Windows-native capability (cmdlets, registry, services, WMI) is truly required.', - 'Fix failed bash commands in bash — never switch shells mid-task, never embed `pwsh -Command` inside a bash command (variable expansion and output encoding break there).', + '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 ALL commands — it is this host\'s shell.', - 'Fix failed commands in bash; there is no PowerShell tool on this host.', + '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') /** @@ -118,6 +118,38 @@ export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [ */ 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 } = {}) { + 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] + 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 } = {}) { + 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 }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '') + : PTC_ROUTING + tool.description = description + ROUTING_START + hint + ROUTING_END + } +} + /** @deprecated composed form kept for tests: route + MSYS sentences. */ export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM @@ -158,7 +190,12 @@ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createSc if (executorScope.fiber) await executorScope.fiber const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor) await fiber - return executorScope.ctx.get('shell') + const executor = executorScope.ctx.get('shell') + const sandboxModule = await resolveHostModule(executorScope.ctx, '@deepseek-ai/dsh-sandbox') + if (typeof sandboxModule?.SandboxUnavailableError === 'function') { + executor.unavailableErrorType = sandboxModule.SandboxUnavailableError + } + return executor })(), mountTimeout, ]) @@ -255,7 +292,33 @@ export function mountInlineTools(ctx, deps) { } const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal) const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules }) - return { executor: mounted.executor, executorScope, module } + const extraModules = [] + if (typeof deps.translatePath === 'function') { + const { createPathToolModule, PATH_TOOL_NAME } = await untilDisposed(import('./path-tool.js'), controller.signal) + extraModules.push({ name: PATH_TOOL_NAME, module: createPathToolModule({ + toolsModule: hostModules.toolsModule, translate: deps.translatePath, log, + }) }) + try { + const localBashModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-bash-local'), controller.signal) + const outputModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-subprocess-local/output'), controller.signal) + if (typeof localBashModule?.LocalBashExecutor !== 'function' + || typeof outputModule?.OutputCollector !== 'function') { + throw new Error('public native executor/OutputCollector capability unavailable') + } + const { createNativeOutputAdapter } = await untilDisposed(import('./native-output.js'), controller.signal) + const { createNativeToolModule } = await untilDisposed(import('./native-tool.js'), controller.signal) + extraModules.push({ name: 'gitbash_native', module: createNativeToolModule({ + toolsModule: hostModules.toolsModule, sandboxModule: hostModules.sandboxModule, + localBashModule, createScope, translateMsysPath: deps.translatePath, + createNativeOutputAdapter, outputOptions: { OutputCollector: outputModule?.OutputCollector }, + createArtifactDirectory: deps.createArtifactDirectory, log, + }) }) + } catch (error) { + if (controller.signal.aborted) throw error + log(`native tool capability unavailable; bash/path tools remain available: ${error?.message ?? error}`) + } + } + return { executor: mounted.executor, executorScope, module, extraModules } } catch (error) { if (executorScope) await executorScope.dispose() throw error @@ -271,7 +334,7 @@ export function mountInlineTools(ctx, deps) { let scope let retained = false try { - const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal) + const { executor, module, extraModules } = await untilDisposed(bashOncePromise(), state.controller.signal) if (!isLive(agent, state)) return log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`) scope = createScope(agent.ctx) @@ -297,6 +360,20 @@ export function mountInlineTools(ctx, deps) { if (registeredNames.includes('bash') === false) { throw new Error('bash tool did not register for this agent (module fiber INACTIVE — this host lacks an injected service the module requires — or the tools read-back is unavailable); bash is NOT available in this session, please report this with your dsh version (issue #27)') } + for (const extra of extraModules ?? []) { + if (faceNames(agent).includes(extra.name)) continue // Never replace another owner. + let extraFiber + try { + extraFiber = scope.ctx.plugin(extra.module) + await untilDisposed(extraFiber, state.controller.signal) + if (!isLive(agent, state)) return + log(`${extra.name} activated in the agent-owned scope`) + } catch (error) { + if (!isLive(agent, state)) return + await extraFiber?.dispose() + log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`) + } + retained = true log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)') } catch (error) { diff --git a/src/native-executor.js b/src/native-executor.js new file mode 100644 index 0000000..af465e4 --- /dev/null +++ b/src/native-executor.js @@ -0,0 +1,271 @@ +import path from 'node:path' + +// No host peer imports here: the owner supplies modules via its host loader. +const KEYS = new Set(['description', 'executable', 'argv', 'cwd', 'timeoutMs', 'run_in_background', 'entrypoint', 'stdoutEncoding', 'stderrEncoding', 'errors']) +const ENCODINGS = new Set(['utf8', 'gbk', 'utf16le', 'raw']) +// A usability guard for known names, NOT executable attestation or a sandbox: +// a renamed/custom .exe may implement its own language. Direct argv is not a +// promise about arbitrary Windows parsers, even after explicit opt-in. +const INTERPRETERS = /^(?:cmd|powershell|pwsh|bash|sh|dash|zsh|fish|wsl|wscript|cscript|mshta|node|nodejs|pythonw?(?:\d+(?:\.\d+)*)?|pypy(?:\d+)?|py|perl|ruby|php|lua|javaw?|jshell|dotnet|csi|tclsh|wish|rscript|busybox|deno|bun)(?:\.exe)?$/i +export const NATIVE_TIMEOUT_MS = 120000 +export const NATIVE_MAX_TIMEOUT_MS = 600000 + +function object(value, label, allowed) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label}: expected an object`) + for (const key of Object.keys(value)) if (!allowed.has(key)) throw new TypeError(`${label}: unsupported field ${key}`) +} +function string(value, label, empty = false) { + if (typeof value !== 'string' || (!empty && value.trim() === '') || value.includes('\0')) throw new TypeError(`${label}: expected ${empty ? 'a' : 'a non-empty'} NUL-free string`) + return value +} + +/** Only explicitly typed paths cross the existing mount translator. No argv guessing. */ +export function nativePath(input, translateMsysPath, platform = process.platform, label = 'path') { + object(input, label, new Set(['kind', 'value'])) + if (input.kind !== 'path') throw new TypeError(`${label}: kind must be path`) + const value = string(input.value, label) + if (/^[~$]|%[^%]+%/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`) + if (platform !== 'win32') throw new Error('gitbash_native currently supports only the Windows local execution world') + // A rooted Windows path without a drive is not absolute in this contract. + const absolute = v => /^[a-z]:[\\/]/i.test(v) || /^\\\\[^\\]+\\[^\\]+/.test(v) || /^\/\/[^/]+\/[^/]+/.test(v) + const translated = absolute(value) ? value : typeof translateMsysPath === 'function' ? translateMsysPath(value) : value + if (typeof translated !== 'string' || translated.includes('\0') || !absolute(translated)) throw new TypeError(`${label}: unresolved or non-absolute typed path`) + return path.win32.normalize(translated) +} + +/** Strict request validation is also applied outside ToolRuntime. */ +export function parseNativeRequest(args, { translateMsysPath, platform = process.platform, sessionCwd } = {}) { + object(args, 'gitbash_native', KEYS) + const description = string(args.description, 'description') + const executable = nativePath(args.executable, translateMsysPath, platform, 'executable') + if (!/\.exe$/i.test(executable)) throw new TypeError('executable: only native .exe files are supported; .cmd/.bat and script files are never wrapped') + const entrypoint = args.entrypoint ?? 'native' + if (!['native', 'interpreter'].includes(entrypoint)) throw new TypeError('entrypoint: expected native or interpreter') + if (INTERPRETERS.test(path.win32.basename(executable)) && entrypoint !== 'interpreter') throw new TypeError('executable: a known interpreter requires explicit entrypoint: interpreter; its language and quoting remain your responsibility') + if (!Array.isArray(args.argv) || args.argv.length > 4096) throw new TypeError('argv: expected an array of at most 4096 typed arguments') + const argv = args.argv.map((arg, index) => { + object(arg, `argv[${index}]`, new Set(['kind', 'value'])) + if (arg.kind === 'literal') return string(arg.value, `argv[${index}].value`, true) + if (arg.kind === 'path') return nativePath(arg, translateMsysPath, platform, `argv[${index}]`) + throw new TypeError(`argv[${index}]: kind must be literal or path`) + }) + // Conservative bound before the provider's Windows command-line serialization. + if ([executable, ...argv].reduce((n, arg) => n + arg.length * 2 + 3, 0) > 30000) throw new TypeError('argv: conservative Windows command-line size limit exceeded') + const cwd = nativePath(args.cwd ?? { kind: 'path', value: sessionCwd }, translateMsysPath, platform, 'cwd') + if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number') + if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') throw new TypeError('run_in_background: expected boolean') + if (args.run_in_background === true && args.timeoutMs !== undefined) throw new TypeError('timeoutMs is not supported with run_in_background; managed jobs have no tool deadline') + const stdoutEncoding = args.stdoutEncoding ?? 'utf8' + const stderrEncoding = args.stderrEncoding ?? 'utf8' + const errors = args.errors ?? 'replace' + if (!ENCODINGS.has(stdoutEncoding) || !ENCODINGS.has(stderrEncoding)) throw new TypeError('encoding: expected utf8, gbk, utf16le or raw; auto is not supported') + if (!['replace', 'strict'].includes(errors)) throw new TypeError('errors: expected replace or strict') + return Object.freeze({ description, executable, argv: Object.freeze(argv), cwd, entrypoint, + timeoutMs: Math.min(args.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS), + run_in_background: args.run_in_background === true, + output: Object.freeze({ stdoutEncoding, stderrEncoding, errors }), + }) +} + +/** Resolve the *call's* standing policy, never the composition default. */ +export function requireNativePolicy(ctx, exec, sandboxModule) { + const service = ctx.get('sandboxPolicy') + const policy = service?.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session }) + if (!policy || policy.mode !== 'danger-full-access') { + const detail = 'gitbash_native has no confined backend adapter. This entry refuses read-only, workspace-write and missing policy before spawn or artifact creation; do not bypass a denied operation.' + if (typeof sandboxModule?.SandboxUnavailableError === 'function') throw new sandboxModule.SandboxUnavailableError(policy?.mode ?? 'unknown', detail) + throw Object.assign(new Error(detail), { code: 'SANDBOX_UNAVAILABLE' }) + } + return policy +} + +function checkedDshEnv(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('shellEnv.collect must return managed environment facts') + const seen = new Set() + const result = {} + for (const [key, entry] of Object.entries(value)) { + if (!/^DSH_/i.test(key) || key.includes('=') || key.includes('\0') || seen.has(key.toUpperCase())) throw new TypeError('shellEnv: invalid or conflicting managed environment key') + result[key] = string(entry, 'shellEnv managed fact', true) + seen.add(key.toUpperCase()) + } + return Object.freeze(result) +} + +function preparationTimeout() { + return Object.assign(new Error('gitbash_native preparation timed out before spawn; no native producer was started'), { code: 'NATIVE_PREPARATION_TIMEOUT' }) +} +function cancellation(parent, timeoutMs) { + const controller = new AbortController() + const forward = () => controller.abort(parent.reason) + parent?.addEventListener('abort', forward, { once: true }) + if (parent?.aborted) forward() + let timer + if (timeoutMs <= 0) controller.abort(preparationTimeout()) + else timer = setTimeout(() => controller.abort(preparationTimeout()), timeoutMs) + return { controller, signal: controller.signal, + stopDeadline() { clearTimeout(timer) }, + dispose() { clearTimeout(timer); parent?.removeEventListener('abort', forward) }, + } +} +async function whileActive(work, signal) { + signal.throwIfAborted() + let abort + const interrupted = new Promise((_, reject) => { + abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + }) + try { return await Promise.race([Promise.resolve().then(() => { signal.throwIfAborted(); return work() }), interrupted]) } + finally { signal.removeEventListener('abort', abort) } +} + +/** + * Per-operation private Core executor. Only this operation's scope and handle are + * owned here. In particular, disposing it never disposes the root subprocess. + * Prepared requests are private capabilities, not caller-supplied policy DTOs. + */ +export function createNativeExecutor(ctx, deps) { + const { localBashModule, sandboxModule, createScope, translateMsysPath, createNativeOutputAdapter, outputOptions = {} } = deps + const LocalBashExecutor = localBashModule?.LocalBashExecutor + if (typeof LocalBashExecutor !== 'function' || typeof createScope !== 'function' || typeof createNativeOutputAdapter !== 'function') throw new Error('gitbash_native: required host executor/output/scope capability is unavailable') + const prepared = new WeakMap() + const remaining = request => request.timeoutMs - (Date.now() - prepared.get(request)) + return { + waitBudget: request => Math.max(1, remaining(request)), + async prepare(args, exec) { + requireNativePolicy(ctx, exec, sandboxModule) + exec.signal?.throwIfAborted() + if (args?.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number') + const startedAt = Date.now() + const timeoutMs = Math.min(args?.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS) + const preparation = cancellation(exec.signal, timeoutMs) + try { + const sessionCwd = exec.agent === undefined ? undefined : await whileActive(() => ctx.workingDirectory.ensure(exec.agent, preparation.signal), preparation.signal) + preparation.signal.throwIfAborted() + const request = parseNativeRequest(args, { translateMsysPath, sessionCwd, platform: deps.platform ?? process.platform }) + const value = Object.freeze({ ...request, dshEnv: checkedDshEnv(ctx.shellEnv.collect(exec)) }) + prepared.set(value, startedAt) + return value + } finally { preparation.dispose() } + }, + async execute(request, exec, { signal = exec.signal, onExpiry = 'kill' } = {}) { + const policy = requireNativePolicy(ctx, exec, sandboxModule) + if (!prepared.has(request)) throw new TypeError('gitbash_native: request must be prepared by this executor') + if (!['kill', 'none'].includes(onExpiry)) throw new TypeError('invalid native expiry mode') + signal?.throwIfAborted() + const provider = ctx.get('subprocess') + if (typeof provider?.spawn !== 'function') throw new Error('gitbash_native: subprocess provider unavailable') + // Jobs registrations survive the tool fiber. Their operation scopes belong + // directly to the agent, not to a disposable tool registration fiber. + const owner = exec.agent?.ctx ?? ctx + const operation = cancellation(signal, remaining(request)) + let scope + let handle + let scopeDisposed = false + let producerSettled = false + let proc + let resultPromise + const cancel = reason => { + if (producerSettled || operation.signal.aborted) return false + operation.controller.abort(Object.assign(new Error(reason), { code: 'NATIVE_OPERATION_CANCELLED' })) + return true + } + // OutputCollector DTOs may contain optional undefined fields. ToolRuntime + // requires lossless JSON, so project the adapter's facts at this boundary. + // Even a facts projection problem must not erase a producer's real exit. + const outputFacts = () => { + try { return JSON.parse(JSON.stringify(handle?.outputFacts?.() ?? { unavailable: true })) } + catch (error) { return { projectionError: String(error?.message ?? error), reliableText: false } } + } + const release = async () => { + if (scopeDisposed) return + scopeDisposed = true + try { await scope?.dispose() } finally { operation.dispose() } + } + try { + operation.signal.throwIfAborted() + scope = createScope(owner.isolate('shell').isolate('subprocess')) + if (scope.fiber) await whileActive(() => scope.fiber, operation.signal) + operation.signal.throwIfAborted() + // Install ownership cancellation before any further preparation await. + scope.ctx.effect(() => async () => { + if (!producerSettled) cancel('native execution owner disposed') + if (!handle) return + handle.terminate() + if (typeof handle.waitForExit === 'function') await handle.waitForExit(AbortSignal.timeout(5000)) + }) + requireNativePolicy(ctx, exec, sandboxModule) + const outputConfig = { ...outputOptions, ...request.output, artifactPolicyValidated: true } + // Capability/encoding validation is pure and precedes directory creation. + createNativeOutputAdapter(provider, { ...outputConfig, artifactDirectory: undefined }) + if (outputConfig.artifactDirectory === undefined) { + if (typeof deps.createArtifactDirectory !== 'function') throw new Error('gitbash_native: no trusted artifact directory owner is configured') + requireNativePolicy(ctx, exec, sandboxModule) + operation.signal.throwIfAborted() + outputConfig.artifactDirectory = await whileActive(async () => { + const directory = await deps.createArtifactDirectory(request, { ...exec, signal: operation.signal }, operation.signal) + if (operation.signal.aborted) { + // The directory owner retains late results under its normal retention + // policy. Never spawn from a late callback or delete a path still + // potentially owned/referenced by that trusted asynchronous producer. + deps.log?.(`gitbash_native: cancelled preparation retained its late artifact directory under owner retention: ${String(directory)}`) + } + return directory + }, operation.signal) + if (typeof outputConfig.artifactDirectory !== 'string') throw new Error('gitbash_native: artifact directory owner did not return a path') + } + requireNativePolicy(ctx, exec, sandboxModule) + operation.signal.throwIfAborted() + const adapter = createNativeOutputAdapter(provider, outputConfig) + const facade = { spawn(spec) { + requireNativePolicy(ctx, exec, sandboxModule) + spec.signal?.throwIfAborted() + handle = adapter.spawn(spec) + return handle + } } + scope.ctx.provide('subprocess', facade) + const mounted = scope.ctx.plugin(LocalBashExecutor) + await whileActive(() => mounted, operation.signal) + operation.signal.throwIfAborted() + const executor = scope.ctx.get('shell') + if (typeof executor?.executeArgv !== 'function') throw new Error('gitbash_native: host executeArgv contract is unavailable') + const budget = remaining(request) + if (budget <= 0) throw preparationTimeout() + const spec = executor.resolve({ command: request.description, workdir: request.cwd, timeoutMs: onExpiry === 'kill' ? budget : request.timeoutMs, + signal: operation.signal, onExpiry, dshEnv: request.dshEnv, sandboxPolicy: policy }) + // Core owns runtime kill deadlines. For Jobs, onExpiry:none keeps running + // after the bounded preparation phase and the caller's promotion deadline. + operation.stopDeadline() + proc = await executor.executeArgv(spec, [request.executable, ...request.argv]) + const originalResult = proc.result.bind(proc) + const originalDone = proc.done + // Keep the original producer exit/status even when decoding/storage fails. + const result = () => resultPromise ??= originalResult().then(value => ({ ...value, status: proc.status, timeoutMs: request.timeoutMs, + cwd: request.cwd, sandbox: { mode: policy.mode, denied: false }, + outputFacts: outputFacts(), + })) + const finished = originalDone.then(async () => { + // Normal settlement must not be relabelled as an owner cancellation + // merely because its now-finished execution scope is being released. + producerSettled = true + try { await result() } catch { /* result() preserves provider rejection */ } + await release() + }, async error => { producerSettled = true; await release(); throw error }) + // Ensure lifecycle cleanup is observed even before a foreground reader attaches. + finished.catch(() => {}) + return { get status() { return proc.status }, get exitCode() { return proc.exitCode }, get signal() { return proc.signal }, + observed: proc.observed, readOutput: () => proc.readOutput(), kill: () => { + if (proc.status !== 'running') return false + cancel('native execution explicitly killed') + return proc.kill() + }, + done: finished, result: async () => { await finished; return result() }, + outputFacts, + sandbox: { mode: policy.mode, denied: false }, + } + } catch (error) { + await release() + throw error + } + }, + } +} diff --git a/src/native-output.js b/src/native-output.js new file mode 100644 index 0000000..aee6b86 --- /dev/null +++ b/src/native-output.js @@ -0,0 +1,347 @@ +// Private, operation-local adapter over the public subprocess raw-pipe seam. +// No peer imports: the host must inject its exported ./output OutputCollector. +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const ENCODINGS = Object.freeze({ utf8: 'utf-8', 'utf-8': 'utf-8', gbk: 'gbk', cp936: 'gbk', utf16le: 'utf-16le', 'utf-16le': 'utf-16le', raw: 'raw' }); +const freeze = value => Object.freeze(value); +const failure = (error, code) => freeze({ code: String(error?.code ?? code), message: String(error?.message ?? error).slice(0, 400) }); +function integer(value, fallback, name, min = 0) { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || result < min) throw new TypeError(`${name} must be a safe integer >= ${min}`); + return result; +} +function encoding(value = 'utf8') { + const label = ENCODINGS[value]; + if (!label) throw new TypeError(`Unsupported native output encoding: ${value}`); + if (label !== 'raw') { + // Probe both replacement and fatal support in the actual host, before spawn. + new TextDecoder(label, { fatal: false, ignoreBOM: true }); + new TextDecoder(label, { fatal: true, ignoreBOM: true }); + } + return label; +} +function isCollect(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Number.isSafeInteger(value.maxBytes) && value.maxBytes >= 0 + && Object.keys(value).every(key => key === 'maxBytes' || key === 'spill') + && (value.spill === undefined || (value.spill !== null && typeof value.spill === 'object' + && Number.isSafeInteger(value.spill.maxBytes) && value.spill.maxBytes >= 0 + && Object.keys(value.spill).every(key => key === 'maxBytes'))); +} +function alignedStart(bytes, start) { + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; + return start; +} +function alignedEnd(bytes, end) { + if (end === bytes.length) return end; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; + return end; +} + +/** + * Construct one immutable operation's provider facade. Create another facade for + * a different encoding/configuration; no ambient per-agent or global settings. + * + * Only known {maxBytes, spill?} stdout/stderr modes opt in. Pipe, inherit and + * future/unknown modes are delegated unchanged. The real provider still owns + * execution, abort, termination, range observation, stdin and control. + * + * artifactDirectory must ALREADY exist and be owned/authorized by the caller. + * artifactPolicyValidated is only a misuse guard, NOT a sandbox grant: the + * native executor must enforce its actual resolved danger-only policy first. + * This adapter never creates directories, chmods, removes files or grants access. + * The caller owns artifact retention/removal after all readers have finished. + * + * maxTotalBytes bounds adapter-owned byte payload reservations + artifact bytes, + * not provider/Node pipe buffers, JS object overhead or copies retained by callers. + * Memory reservation includes tail backing buffers, snapshot/string copies and + * bounded decode scratch (conservative multipliers). No raw in-memory history. + * Artifact quotas are SHARED by both streams and by raw + normalized UTF8 files. + * Readers use normalized UTF8 byte offsets, never raw/source-byte offsets. + */ +export function createNativeOutputAdapter(provider, options = {}) { + if (!provider || typeof provider.spawn !== 'function') throw new TypeError('A real subprocess provider is required'); + if (typeof options.OutputCollector !== 'function') throw new TypeError('Inject the host OutputCollector from its public ./output export'); + const config = freeze({ + OutputCollector: options.OutputCollector, + stdoutEncoding: encoding(options.stdoutEncoding), + stderrEncoding: encoding(options.stderrEncoding), + errors: options.errors ?? 'replace', + maxSpillBytes: integer(options.maxSpillBytes, 8 * 1024 * 1024, 'maxSpillBytes'), + maxTotalBytes: integer(options.maxTotalBytes, 16 * 1024 * 1024, 'maxTotalBytes', 1024), + drainTimeoutMs: integer(options.drainTimeoutMs, 250, 'drainTimeoutMs', 1), + artifactDirectory: options.artifactDirectory, + }); + if (!['replace', 'strict'].includes(config.errors)) throw new TypeError('errors must be replace or strict'); + if (config.drainTimeoutMs > 2_147_483_647) throw new TypeError('drainTimeoutMs exceeds Node timer range'); + if (config.artifactDirectory !== undefined) { + if (options.artifactPolicyValidated !== true) throw new Error('Native output artifacts require caller policy validation'); + if (typeof config.artifactDirectory !== 'string' || !path.isAbsolute(config.artifactDirectory)) throw new TypeError('artifactDirectory must be absolute'); + if (!fs.statSync(config.artifactDirectory).isDirectory()) throw new TypeError('artifactDirectory must already be a directory'); + } + + function spawn(spec) { + const names = ['stdout', 'stderr'].filter(name => isCollect(spec.stdio?.[name])); + if (!names.length) return provider.spawn(spec); + // Capture mutable caller dispositions before invoking the real provider. + const modes = Object.fromEntries(names.map(name => [name, freeze({ maxBytes: spec.stdio[name].maxBytes, spillMax: spec.stdio[name].spill?.maxBytes })])); + const scratchBytes = Math.max(1, Math.min(1024, Math.floor(config.maxTotalBytes / 256))); + const baseReservation = scratchBytes * 64 + 256 * names.length; + const tailShare = Math.max(0, Math.floor((config.maxTotalBytes - baseReservation) / (16 * names.length))); + const caps = Object.fromEntries(names.map(name => [name, Math.min(modes[name].maxBytes, tailShare)])); + const reservedMemoryBytes = baseReservation + names.reduce((sum, name) => sum + caps[name] * 16, 0); + const budget = { artifactBytes: 0, limit: Math.min(config.maxSpillBytes, config.maxTotalBytes - reservedMemoryBytes) }; + const owners = names.map(name => makeOwner(name, modes[name], caps[name], config, budget, scratchBytes)); + const stdio = { ...spec.stdio }; + for (const name of names) stdio[name] = 'pipe'; + let real; + try { real = provider.spawn({ ...spec, stdio }); } + catch (error) { for (const owner of owners) owner.finish('spawn-error'); throw error; } + for (const owner of owners) owner.attach(real[owner.name]); + let timer; + let processSettled = false; + const armDrain = reason => { + if (timer || owners.every(owner => owner.isSettled())) return; + timer = setTimeout(() => { for (const owner of owners) owner.finish(reason); }, config.drainTimeoutMs); + }; + const onAbort = () => { + for (const owner of owners) owner.markAborted(); + armDrain('abort-drain-timeout'); + }; + const signal = spec.signal; + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + const outputSettled = Promise.all(owners.map(owner => owner.settled)).then(() => { + clearTimeout(timer); + }); + // Observe success AND rejection immediately. Decoding/storage NEVER changes + // process outcome or converts a successful side-effecting process into a + // provider failure that might invite an unsafe retry. + const done = Promise.resolve(real.done).then(async outcome => { + processSettled = true; + armDrain('exit-drain-timeout'); + await outputSettled; + signal?.removeEventListener('abort', onAbort); + return outcome; + }, async error => { + processSettled = true; + armDrain('provider-error-drain-timeout'); + await outputSettled; + signal?.removeEventListener('abort', onAbort); + throw error; + }); + const collected = { ...real.collected }; + for (const owner of owners) collected[owner.name] = owner.reader; + const additions = { + done, outputSettled, collected: freeze(collected), + terminate(...args) { + // Direct kill/dispose need not abort spec.signal. Record interruption + // before delegating, but never retroactively spoil a completed result + // merely because an owner invokes idempotent cleanup after settlement. + if (!processSettled || !owners.every(owner => owner.isSettled())) { + for (const owner of owners) owner.markAborted(); + armDrain('terminate-drain-timeout'); + } + return real.terminate(...args); + }, + outputFacts: () => freeze({ + ...Object.fromEntries(owners.map(owner => [owner.name, owner.facts()])), + budget: freeze({ maxTotalBytes: config.maxTotalBytes, reservedMemoryBytes, artifactBytes: budget.artifactBytes, maxArtifactBytes: budget.limit }), + }), + ...Object.fromEntries(names.map(name => [name, undefined])), + }; + return delegate(real, additions); + } + return delegate(provider, { spawn, provider, outputConfig: config }); +} + +// Bind unknown methods to their REAL receiver (including private-field methods). +// Do not mutate provider/handle identity or their service registration. +function delegate(real, additions) { + return new Proxy(Object.create(null), { + get(_target, key) { + if (Object.hasOwn(additions, key)) return additions[key]; + const value = Reflect.get(real, key, real); + return typeof value === 'function' ? value.bind(real) : value; + }, + has: (_target, key) => Object.hasOwn(additions, key) || key in real, + set() { return false; }, + }); +} + +function makeOwner(name, mode, cap, config, budget, scratchBytes) { + const label = config[`${name}Encoding`]; + const collector = new config.OutputCollector(cap, `native-${name}`); // no host-private spill fields + const decoder = label === 'raw' ? undefined : new TextDecoder(label, { fatal: config.errors === 'strict', ignoreBOM: true }); + // A fatal validator distinguishes actual invalid bytes from a legitimate U+FFFD. + let validator = label === 'raw' || config.errors === 'strict' ? undefined : new TextDecoder(label, { fatal: true, ignoreBOM: true }); + let rawBytes = 0, decodedUtf8Bytes = 0, decodeError, storageError, streamError; + let settled = false, naturalEOF = false, aborted = false, termination, stream, stopText = false; + let resolveSettled; + const settledPromise = new Promise(resolve => { resolveSettled = resolve; }); + const artifacts = {}; + function recordStorage(error) { storageError ??= failure(error, 'NATIVE_OUTPUT_STORAGE'); } + function artifact(kind) { + if (!config.artifactDirectory) return undefined; + return artifacts[kind] ??= { kind, encoding: kind === 'raw' ? label : 'utf-8', bytes: 0, capReached: false, error: undefined, path: undefined, fd: undefined, sealed: false }; + } + function writeArtifact(kind, bytes) { + const target = artifact(kind); + if (!target || !bytes.length || target.error || target.capReached) return; + const streamLimit = kind === 'decoded' ? Math.min(mode.spillMax ?? config.maxSpillBytes, config.maxSpillBytes) : config.maxSpillBytes; + let length = Math.min(bytes.length, Math.max(0, budget.limit - budget.artifactBytes), Math.max(0, streamLimit - target.bytes)); + if (kind === 'decoded') length = alignedEnd(bytes, length); + if (length < bytes.length) target.capReached = true; + if (!length) return; + try { + if (target.fd === undefined) { + const filename = path.join(config.artifactDirectory, `native-${randomUUID()}-${name}-${kind}.${kind === 'raw' ? 'bin' : 'utf8.log'}`); + target.fd = fs.openSync(filename, 'wx', 0o600); + target.path = filename; + } + let offset = 0; + while (offset < length) { + const written = fs.writeSync(target.fd, bytes, offset, length - offset); + if (!Number.isSafeInteger(written) || written <= 0) throw new Error('Native artifact write made no progress'); + offset += written; + target.bytes += written; + budget.artifactBytes += written; + } + } catch (error) { target.error = failure(error, 'NATIVE_OUTPUT_SPILL'); recordStorage(error); } + } + function publish(text) { + if (!text) return; + const bytes = Buffer.from(text, 'utf8'); + decodedUtf8Bytes += bytes.length; + writeArtifact('decoded', bytes); + if (stopText) return; + try { + // Bound backing allocations, too: OutputCollector may retain subarray tails. + // Every push owns a small copy, never a view of the provider's giant chunk. + const step = Math.max(1, Math.min(scratchBytes, cap || 1)); + for (let offset = 0; offset < bytes.length; offset += step) { + const slice = bytes.subarray(offset, offset + step); + // Unpooled storage prevents a one-byte retained tail pinning an 8KiB slab. + const owned = Buffer.allocUnsafeSlow(slice.length); + owned.set(slice); + collector.push(owned); + } + } catch (error) { recordStorage(error); stopText = true; } + } + function ingest(chunk) { + if (settled) return; + if (!(chunk instanceof Uint8Array)) { + streamError ??= failure(new Error('Raw pipe emitted non-byte data (setEncoding must not be used)'), 'NATIVE_OUTPUT_NOT_BYTES'); + finish('stream-error'); + return; + } + rawBytes += chunk.byteLength; + // Never decode a giant incoming chunk in one allocation. Borrow raw slices; + // output/string scratch is bounded even after an error or artifact cap. + for (let offset = 0; offset < chunk.byteLength; offset += scratchBytes) { + const bytes = chunk.subarray(offset, offset + scratchBytes); + writeArtifact('raw', bytes); + if (!decoder || (decodeError && config.errors === 'strict')) continue; + if (validator) { + try { validator.decode(bytes, { stream: true }); } + catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); validator = undefined; } + } + try { publish(decoder.decode(bytes, { stream: true })); } + catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); } + } + } + function finish(reason) { + if (settled) return; + settled = true; + termination = reason; + naturalEOF = reason === 'end'; + if (validator) { + try { validator.decode(); } + catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); } + } + if (decoder && !(decodeError && config.errors === 'strict')) { + try { publish(decoder.decode()); } + catch (error) { decodeError ??= failure(error, 'NATIVE_OUTPUT_DECODE'); } + } + try { collector.seal(); } catch (error) { recordStorage(error); } + for (const target of Object.values(artifacts)) { + if (target.fd !== undefined) { + try { fs.closeSync(target.fd); } + catch (error) { target.error ??= failure(error, 'NATIVE_OUTPUT_SPILL_CLOSE'); recordStorage(error); } + target.fd = undefined; + } + target.sealed = true; + } + if (stream) { + stream.removeListener('data', ingest); + stream.removeListener('end', onEnd); + stream.removeListener('close', onClose); + // Keep a harmless error sink until close: destroy can schedule a late + // error, which must not become an uncaught exception after settlement. + if (stream.closed) stream.removeListener('error', onError); + else stream.once('close', () => stream.removeListener('error', onError)); + if (!naturalEOF && !stream.destroyed) stream.destroy(); + } + resolveSettled(); + } + const onEnd = () => finish('end'); + const onClose = () => finish('close-before-end'); + const onError = error => { if (!settled) { streamError ??= failure(error, 'NATIVE_OUTPUT_STREAM'); finish('stream-error'); } }; + function artifactFacts() { + return freeze(Object.fromEntries(Object.entries(artifacts).map(([kind, target]) => [kind, freeze({ + kind, encoding: target.encoding, path: target.path, bytes: target.bytes, + complete: target.sealed && naturalEOF && !aborted && !target.error && !target.capReached && (kind === 'raw' || !decodeError), + capReached: target.capReached, error: target.error, + })]))); + } + const reader = freeze({ readFrom(fromByte) { + if (!Number.isSafeInteger(fromByte) || fromByte < 0 || fromByte > decodedUtf8Bytes) throw new RangeError('Expected a normalized UTF8 cursor between zero and decodedUtf8Bytes'); + let snapshot; + try { snapshot = collector.snapshot(); } + catch (error) { recordStorage(error); return { text: '', nextOffset: decodedUtf8Bytes, lossy: true }; } + const start = snapshot.totalBytes - snapshot.bytes.length; + const requested = Math.max(0, fromByte - start); + const aligned = alignedStart(snapshot.bytes, requested); + const spill = artifactFacts().decoded; + return { + text: snapshot.bytes.subarray(aligned).toString('utf8'), + nextOffset: decodedUtf8Bytes, + lossy: label === 'raw' || !!decodeError || !!storageError || fromByte < start || aligned !== requested, + // Only finalized COMPLETE normalized text can be called a full-output path. + ...(spill?.complete && spill.path ? { spillPath: spill.path } : {}), + }; + } }); + return { + name, reader, settled: settledPromise, finish, isSettled: () => settled, + markAborted() { aborted = true; }, + attach(value) { + stream = value; + if (!stream || typeof stream.on !== 'function') { + stream = undefined; + streamError = failure(new Error('Provider did not expose requested raw pipe'), 'NATIVE_OUTPUT_PIPE_MISSING'); + finish('missing-pipe'); + return; + } + stream.on('error', onError); + stream.once('end', onEnd); + stream.once('close', onClose); + stream.on('data', ingest); + if (stream.readableEnded) finish('end'); + else if (stream.destroyed || stream.closed) finish('close-before-end'); + }, + facts() { + const artifact = artifactFacts(); + const lossy = reader.readFrom(0).lossy; + return freeze({ + encoding: label, errors: config.errors, rawBytes, decodedUtf8Bytes, + rawCursor: rawBytes, decodedCursor: decodedUtf8Bytes, + reliableText: label !== 'raw' && !decodeError && !streamError && !storageError, + lossy, complete: naturalEOF && !aborted && !streamError && !decodeError && !storageError && (label !== 'raw' || artifact.raw?.complete === true), + sourceComplete: naturalEOF, aborted, settled, termination, decodeError, streamError, storageError, artifact, + retainedMaxBytes: cap, + }); + }, + }; +} diff --git a/src/native-tool.js b/src/native-tool.js new file mode 100644 index 0000000..1bda2c7 --- /dev/null +++ b/src/native-tool.js @@ -0,0 +1,194 @@ +import { createNativeExecutor } from './native-executor.js' +import { processJob, processSources, ringDelta } from './bash-render.js' + +const typedPath = () => ({ type: 'object', additionalProperties: false, properties: { + kind: { type: 'string', const: 'path', required: true }, value: { type: 'string', required: true }, +} }) + +/** defineTool's AUTHOR property-map, not a raw object-root JSON schema. */ +export function nativeParameters({ background = false, promote = false } = {}) { + return { + description: { type: 'string', required: true, description: 'Short description of the intended native operation.' }, + executable: { ...typedPath(), required: true, description: 'Absolute native .exe path. MSYS paths translate only in kind:path values. Bare commands, scripts, .cmd and .bat are unsupported.' }, + argv: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: { + kind: { type: 'string', enum: ['literal', 'path'], required: true }, value: { type: 'string', required: true }, + } }, description: 'Each literal is passed unchanged (including /all, /c, quotes and empty strings); each path must be absolute and is explicitly translated to native Windows spelling. No shell expansion.' }, + cwd: { ...typedPath(), description: 'Absolute typed working directory; omitted means the agent workspace.' }, + entrypoint: { type: 'string', enum: ['native', 'interpreter'], description: 'Default native. Known interpreters require explicit interpreter. Their own command language/quoting is not made safe by direct argv; no automatic script wrapper.' }, + timeoutMs: { type: 'number', description: promote ? 'Foreground wait deadline, default 120000ms, capped at 600000ms; expiry promotes the managed process to a job. Not accepted with run_in_background.' : 'Kill deadline, default 120000ms, capped at 600000ms. Not accepted with run_in_background.' }, + ...(background ? { run_in_background: { type: 'boolean', description: 'Use existing managed Jobs; collect with job_output, stop with job_kill. No tool deadline. Requires an attached job controller.' } } : {}), + stdoutEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], description: 'Known producer stdout encoding, default utf8. raw preserves unknown/mixed bytes without claiming reliable text. No auto detection.' }, + stderrEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], description: 'Known producer stderr encoding, independently selected; default utf8.' }, + errors: { type: 'string', enum: ['replace', 'strict'], description: 'Default replace; strict records decoding failures separately from actual command exit. Never rerun a side-effecting command merely because output decoding failed.' }, + } +} + +const streamSchema = { type: 'object', additionalProperties: false, properties: { + text: { type: 'string', required: true }, truncated: { type: 'boolean', required: true }, spillPath: { type: 'string' }, +} } +// Core's author DSL supports integer, not numeric minimum keywords; the +// adapter owns non-negative counters and budget validation before execution. +const byteCount = () => ({ type: 'integer', required: true }) +const factBoolean = () => ({ type: 'boolean', required: true }) +const outputErrorSchema = { type: 'object', additionalProperties: false, properties: { + code: { type: 'string', required: true }, message: { type: 'string', required: true }, +} } +const normalizedEncoding = ['utf-8', 'gbk', 'utf-16le', 'raw'] +const artifactSchema = kind => ({ type: 'object', additionalProperties: false, properties: { + kind: { type: 'string', const: kind, required: true }, + encoding: { type: 'string', ...(kind === 'decoded' ? { const: 'utf-8' } : { enum: normalizedEncoding }), required: true }, + path: { type: 'string' }, bytes: byteCount(), complete: factBoolean(), capReached: factBoolean(), error: outputErrorSchema, +} }) +const streamFactsSchema = { type: 'object', additionalProperties: false, properties: { + encoding: { type: 'string', enum: normalizedEncoding, required: true }, + errors: { type: 'string', enum: ['replace', 'strict'], required: true }, + rawBytes: byteCount(), decodedUtf8Bytes: byteCount(), rawCursor: byteCount(), decodedCursor: byteCount(), + reliableText: factBoolean(), lossy: factBoolean(), complete: factBoolean(), sourceComplete: factBoolean(), + aborted: factBoolean(), settled: factBoolean(), + termination: { type: 'string', enum: ['end', 'close-before-end', 'stream-error', 'missing-pipe', 'spawn-error', 'abort-drain-timeout', 'terminate-drain-timeout', 'exit-drain-timeout', 'provider-error-drain-timeout'] }, + decodeError: outputErrorSchema, streamError: outputErrorSchema, storageError: outputErrorSchema, + artifact: { type: 'object', additionalProperties: false, required: true, properties: { + raw: artifactSchema('raw'), decoded: artifactSchema('decoded'), + } }, + retainedMaxBytes: byteCount(), +} } +// Explicit current adapter DTO, including the two executor-owned degradation +// branches. Optional undefined projection never admits arbitrary output fields. +export const NATIVE_OUTPUT_FACTS_SCHEMA = { oneOf: [ + { type: 'object', additionalProperties: false, properties: { + stdout: { ...streamFactsSchema, required: true }, stderr: { ...streamFactsSchema, required: true }, + budget: { type: 'object', additionalProperties: false, required: true, properties: { + maxTotalBytes: byteCount(), reservedMemoryBytes: byteCount(), artifactBytes: byteCount(), maxArtifactBytes: byteCount(), + } }, + } }, + { type: 'object', additionalProperties: false, properties: { unavailable: { type: 'boolean', const: true, required: true } } }, + { type: 'object', additionalProperties: false, properties: { + projectionError: { type: 'string', required: true }, reliableText: { type: 'boolean', const: false, required: true }, + } }, +] } +const sandboxSchema = { type: 'object', additionalProperties: false, properties: { + mode: { type: 'string', const: 'danger-full-access', required: true }, denied: { type: 'boolean', const: false, required: true }, +} } +const encodingSchema = { type: 'object', additionalProperties: false, properties: { + stdoutEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], required: true }, + stderrEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], required: true }, + errors: { type: 'string', enum: ['replace', 'strict'], required: true }, +} } +export const NATIVE_OUTPUT_SCHEMA = { + oneOf: [ + { type: 'object', additionalProperties: false, properties: { + kind: { type: 'string', const: 'foreground', required: true }, cwd: { type: 'string', required: true }, + status: { type: 'string', enum: ['running', 'completed', 'killed'], required: true }, exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, aborted: { type: 'boolean', required: true }, timeoutMs: { type: 'number', required: true }, + stdout: { ...streamSchema, required: true }, stderr: { ...streamSchema, required: true }, + sandbox: { ...sandboxSchema, required: true }, outputFacts: { ...NATIVE_OUTPUT_FACTS_SCHEMA, required: true }, + } }, + { type: 'object', additionalProperties: false, properties: { + kind: { type: 'string', enum: ['background', 'promoted'], required: true }, cwd: { type: 'string', required: true }, + jobId: { type: 'string', required: true }, timeoutMs: { type: 'number' }, output: { type: 'string' }, + encoding: { ...encodingSchema, required: true }, + } }, + ], +} + +export function renderNativeResult(value) { + if (value.kind !== 'foreground') return `Native command ${value.kind === 'promoted' ? 'continues after the wait deadline' : 'admitted to managed Jobs'}; job id: ${value.jobId}. Collect with job_output, stop with job_kill.\n${value.output ?? ''}\n[requested encoding: ${JSON.stringify(value.encoding)}; final producer/output facts arrive in job output]` + const text = value.stdout.text + (value.stderr.text ? `\n[stderr]\n${value.stderr.text}` : '') + return `${text}\n[exit code: ${value.exitCode ?? 'unknown'}; status: ${value.status}; signal: ${value.signal ?? 'none'}; timedOut: ${value.timedOut}; aborted: ${value.aborted}]\n[native output facts: ${JSON.stringify(value.outputFacts)}]\nOutput decoding/storage problems do not mean the command did not execute; inspect producer exit facts before considering any retry.` +} + +/** One per-agent module. Host packages and the shared path translator are injected. */ +export function createNativeToolModule(deps) { + if (typeof deps.toolsModule?.defineTool !== 'function') throw new Error('gitbash_native: host defineTool capability unavailable') + return { + inject: ['tools', 'subprocess', 'workingDirectory', 'shellEnv'], + apply(ctx, config = {}) { + const executor = createNativeExecutor(ctx, deps) + const enabled = config.enableRunInBackground !== false + const promoteOnTimeout = config.promoteOnTimeout !== false && enabled + const definition = jobs => { + const background = enabled && jobs !== undefined + const promote = background && promoteOnTimeout + const startJob = (request, exec) => { + let proc + let finalFacts = '' + const sources = processSources(() => proc) + sources.push({ channel: 'stderr', read(fromByte) { + const bytes = Buffer.from(finalFacts, 'utf8') + return { text: bytes.subarray(fromByte).toString('utf8'), nextOffset: bytes.length, lossy: false } + } }) + const id = jobs.start({ kind: 'gitbash-native', label: request.description, + ...(exec.agent ? { owner: exec.agent.id } : {}), output: sources, + run: () => processJob(async signal => { + proc = await executor.execute(request, exec, { signal, onExpiry: 'none' }) + return proc + }, async started => { + // done has already sealed decoding and released this operation's scope. + const result = await started.result() + finalFacts = `\n[native producer: exitCode=${result.exitCode}, signal=${result.signal}, status=${result.status}, aborted=${result.aborted}]\n[native output facts: ${JSON.stringify(result.outputFacts)}]\nOutput failure is not command non-execution; do not blindly retry.\n` + return { status: result.status === 'killed' ? 'killed' : 'completed', detail: `exit code: ${result.exitCode ?? 'unknown'}; output facts retained in job output` } + }), + }) + return { id, process: () => proc } + } + const execute = async (args, exec) => { + // prepare resolves policy BEFORE ensure/path/env/artifact work. + const request = await executor.prepare(args, exec) + exec.signal?.throwIfAborted() + if (request.run_in_background && !background) throw new Error('run_in_background is unavailable or disabled; this request was not executed') + if (request.run_in_background) { + const attached = startJob(request, exec) + return { kind: 'background', jobId: attached.id, cwd: request.cwd, encoding: request.output } + } + if (promote) { + // No retry/fallback around admission: a host throwing after starting a + // producer must not cause a second execution of a side-effecting call. + const attached = startJob(request, exec) + const owner = exec.agent?.id + let view + try { view = await jobs.wait(attached.id, executor.waitBudget(request), owner, exec.signal) } + catch (error) { + jobs.kill(attached.id, owner, 'tool call aborted') + await jobs.wait(attached.id, 10000, owner) + throw error + } + if (view.status === 'running' || view.status === 'stopping') { + if (!attached.process()) { + jobs.kill(attached.id, owner, 'timed out during native preparation') + await jobs.wait(attached.id, 10000, owner) + throw new Error(`native preparation timed out; job ${attached.id} was cancelled, inspect job_output before retrying`) + } + const read = jobs.read(attached.id, owner) + return { kind: 'promoted', jobId: attached.id, cwd: request.cwd, timeoutMs: request.timeoutMs, + output: ringDelta(read.chunks), encoding: request.output } + } + jobs.remove(attached.id, owner) + if (!attached.process()) throw new Error(view.detail ?? 'native preparation failed before a process handle was available') + return { kind: 'foreground', ...await attached.process().result() } + } + const proc = await executor.execute(request, exec) + return { kind: 'foreground', ...await proc.result() } + } + return deps.toolsModule.defineTool({ name: 'gitbash_native', + description: 'Choose this tool BEFORE execution for a Windows-native .exe that needs exact literal argv (for example /all or /c without MSYS conversion) or explicitly known independent stdout/stderr encodings. Use an offered shell tool instead for pipelines, redirection, globbing, shell expansion or POSIX scripts; this is direct typed argv through the host managed executor, not shell source. Supply an absolute executable, mark literal arguments unchanged and path arguments explicitly; do not put a whole shell command into argv. Prefer a producer UTF-8 option; declare gbk/utf16le only when known, raw for unknown or mixed bytes. In PTC call this offered SDK binding from inside run_code, not as a direct model tool call. Only actual danger-full-access standing policy is supported; missing/restricted policy is rejected before execution. A policy/access denial is a stop condition, never permission for another-tool retry. Decoding/storage failure preserves producer exit: never blindly repeat side effects. No user env, implicit scripts, .cmd/.bat wrappers, path guessing or encoding auto-detection. Known interpreters require explicit entrypoint:interpreter; common CRT/Node argv is supported, arbitrary interpreter quoting is not guaranteed.', + parameters: nativeParameters({ background, promote }), + output: { schema: NATIVE_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderNativeResult(value) }], presentationMeta: (_args, value) => ({ cwd: value.cwd }) }, + execute, + presentCall: args => ({ card: 'generic', title: args.description, kind: 'execute', content: [{ type: 'text', text: args.executable.value }] }), + }) + } + if (!enabled) { ctx.tools.register(definition(undefined)); return } + let foregroundOnly = ctx.get('jobs') === undefined ? ctx.tools.register(definition(undefined)) : undefined + ctx.inject(['jobs'], jobCtx => { + foregroundOnly?.() + foregroundOnly = undefined + const unregister = ctx.tools.register(definition(jobCtx.jobs)) + jobCtx.effect(() => () => { + unregister() + if (ctx.fiber?.state === 2) foregroundOnly = ctx.tools.register(definition(undefined)) + }) + }) + }, + } +} diff --git a/src/path-tool.js b/src/path-tool.js new file mode 100644 index 0000000..f8627d3 --- /dev/null +++ b/src/path-tool.js @@ -0,0 +1,304 @@ +/** + * dsh-gitbash-shell/path-tool — the EXPLICIT full-value MSYS→native converter + * (`gitbash_to_native_path`, 2026-10-13, compat-implementation task + * IMPL-PATH-HELPER). + * + * WHY. run_code hands a program's path STRINGS to native Node, and the legacy + * codePaths literal rewrite can only ever guess which literals are paths + * (research REPORT.md: route/search data still gets corrupted, dynamic values + * are out of reach by design). The explicit alternative: the CALLER decides a + * complete value is a path and converts it AT RUNTIME through one tool: + * + * const r = await tools.gitbash_to_native_path({ msys_path: p }) + * await fs.readFile(r.native_path, 'utf8') + * + * Contract (mirrors the approved research fixture `fixture_to_native`): + * - the parameter is named `msys_path` ON PURPOSE: the automatic + * tools/execute path-field rewrite only touches the whitelisted + * `file_path`/`path`/`workdir` fields (src/index.js + * TRANSLATABLE_PATH_FIELDS), so the raw MSYS value reaches this tool + * intact and the conversion is the tool's own explicit job; + * - the value goes through the SHARED `translateMsysPath` + a FRESH + * `buildTranslateEnv(...)` per call (injected, never copied here) — drive + * roots, ~, $HOME-style variables, /tmp, /dev/null and the Git install + * mounts resolve exactly like every other translated face of the plugin; + * - NO filesystem I/O, NO existence check, NO permission grant, NO global + * env seeding: the caller's own runtime policy still governs what the + * converted path may touch; + * - already Windows-native (drive-letter/UNC) or cwd-relative values are + * returned unchanged — declared here, in the description; + * - a POSIX-looking value that NO mount maps, or whose needed mount fact is + * unavailable, is REJECTED with an explicit error: returning the POSIX + * spelling as `native_path` would be a silent lie; + * - empty / NUL-byte / URL-shaped values are rejected outright; Unicode, + * `#` and spaces inside a path are preserved byte for byte; + * - the tool is INDEPENDENT of the automatic dialect switches + * (posixPaths/codePaths/virtualMounts): whether it is registered at all + * (and the Windows-only gate) is the main wiring's decision — this module + * carries no Config and reads no dialect state. + * + * Mounting follows the bash-tool pattern: `createPathToolModule(deps)` returns + * a cordis module ({ inject, apply }) that the main wiring mounts inside an + * agent-owned scope; registration reverts through the scope's effect on + * disposal. There are NO top-level static `@deepseek-ai/*` imports — the host + * `defineTool` and the shared translator ride the deps bag (v0.24.3 lesson: a + * failed static peer import silently drops the whole plugin row). + * + * The SUCCESS result is `{ native_path }`, a shape the result-echo layer + * never rewrites (rewriteResultPaths is keyed on the official tools' names + * and path metadata fields, not on this tool), so the native value cannot be + * translated back behind the caller's back. + * + * @module dsh-gitbash-shell/path-tool + */ + +/** The registered tool name. */ +export const PATH_TOOL_NAME = 'gitbash_to_native_path' + +/** Windows drive-letter absolute: `C:/x`, `C:\x` (drive-relative `C:` is NOT). */ +const WINDOWS_ABSOLUTE = /^[A-Za-z]:[\\/]/ + +/** A `scheme:` prefix — `https:`, `file:`, `app:`, … (single-letter `C:` excluded). */ +const URL_SCHEME = /^([A-Za-z][A-Za-z0-9+.\-]*):/ + +const NUL_BYTE = '\0' + +/** + * Whether the value is ALREADY in a native Windows spelling (drive-letter + * absolute or UNC, both slash styles). Pure. + * @param {unknown} value + * @returns {boolean} + */ +export function isWindowsNativeValue(value) { + return typeof value === 'string' + && (WINDOWS_ABSOLUTE.test(value) || value.startsWith('\\\\') || value.startsWith('//')) +} + +/** + * Whether the value LOOKS like it needs MSYS mount resolution (POSIX root, + * home shorthand or a leading variable). A match does NOT promise a mapping + * exists — only that a passthrough would be a guess. Pure. + * @param {unknown} value + * @returns {boolean} + */ +export function looksPosixValue(value) { + return typeof value === 'string' + && (value.startsWith('/') || value.startsWith('~') || value.startsWith('$')) +} + +/** + * One honest line about which mount facts the conversion could see. `null` + * means the wiring turned mounts off; `undefined` means the wiring did not + * expose facts at all. + */ +function factSummary(env) { + if (env === null) return 'mount facts unavailable (virtual mounts are off)' + if (!env || typeof env !== 'object') return 'mount facts not exposed by this wiring' + return `mount facts: home=${env.home ? 'yes' : 'no'} tmpDir=${env.tmpDir ? 'yes' : 'no'} gitRoot=${env.gitRoot ? 'yes' : 'no'}` +} + +/** + * Convert ONE caller-declared full path value. Pure: string surgery only, + * the translator (the shared translateMsysPath composed with a fresh mount + * fact bag) is injected, nothing is probed here. + * + * Rejections are explicit and non-guessing: + * - EMPTY — not a non-empty string; + * - NUL_BYTE — a NUL byte anywhere in the value; + * - URL — a multi-letter scheme prefix (`https:…`, `file:…`); + * - NO_MOUNT_MAPPING — POSIX-looking but the shared mount table returned it + * unchanged: either no mount maps it or a needed fact is missing. The + * POSIX spelling is NEVER returned as `native_path`. + * + * @param {unknown} value - the complete value the caller has decided is a path. + * @param {{ translate?: (value: string, env?: object|null) => unknown, + * env?: object|null }} [options] - the injected shared translator + * and this call's fresh mount-fact bag (for the rejection diagnostics). + * @returns {{ ok: true, native_path: string, + * status: 'converted'|'passthrough', kind: string } + * | { ok: false, code: string, message: string }} + */ +export function convertMsysToNative(value, { translate, env } = {}) { + // The code rides the message: the registry carries only error.message to + // the model/bridge, so the rejection class must survive that one face. + const reject = (code, body) => ({ ok: false, code, message: `${PATH_TOOL_NAME} [${code}]: ${body}` }) + if (typeof value !== 'string' || value.trim().length === 0) { + return reject('EMPTY', 'msys_path must be a non-empty string') + } + if (value.includes(NUL_BYTE)) { + return reject('NUL_BYTE', 'msys_path contains a NUL byte; a single filesystem path is required') + } + const scheme = URL_SCHEME.exec(value) + if (scheme && scheme[1].length >= 2) { + return reject('URL', `msys_path looks like a URL (scheme '${scheme[1]}'); explicit conversion accepts a single filesystem path only`) + } + // Already native or cwd-relative: an honest, DECLARED passthrough — the + // description promises this, so no mount facts are consulted at all. + if (isWindowsNativeValue(value)) { + return { ok: true, native_path: value, status: 'passthrough', kind: 'windows-native' } + } + if (!looksPosixValue(value)) { + return { ok: true, native_path: value, status: 'passthrough', kind: 'cwd-relative' } + } + if (typeof translate !== 'function') { + return reject('NO_TRANSLATOR', `no shared translator is wired; refusing to guess a native form for ${JSON.stringify(value)}`) + } + let translated + try { + translated = translate(value, env ?? null) + } catch (error) { + return reject('TRANSLATE_FAILED', `the shared translator failed for ${JSON.stringify(value)}: ${error?.message ?? error}`) + } + if (typeof translated !== 'string' || translated.length === 0) { + return reject('TRANSLATE_FAILED', `the shared translator returned a non-string for ${JSON.stringify(value)}`) + } + if (translated !== value) { + return { ok: true, native_path: translated, status: 'converted', kind: 'msys' } + } + return reject('NO_MOUNT_MAPPING', `no MSYS mount mapping for ${JSON.stringify(value)} (${factSummary(env)}). Refusing to return the POSIX spelling as a native path — resolve the mount fact or convert the value at its source.`) +} + +/** The model-facing contract (kept in one place for both definition shapes). */ +export function pathToolDescription() { + return 'Explicitly convert one complete caller-declared path value from the MSYS/Git Bash dialect ' + + '(/c/-style drive roots, ~, $HOME-style variables, /tmp, /dev/null, Git installation mounts) ' + + 'to its native Windows form and return { native_path }. Call this ONLY when the full value is ' + + 'already known to be a single filesystem path — the tool never inspects surrounding data and never guesses. ' + + 'Pure string conversion through the plugin\'s shared mount table: no filesystem I/O, no existence check, ' + + 'no permission grant; your runtime policy still governs the converted path. Values that are already ' + + 'Windows-native (drive-letter or UNC) or cwd-relative are returned unchanged, by design. A POSIX-looking ' + + 'value that no mount maps — or whose needed mount fact is unavailable — is REJECTED with an explicit error ' + + 'instead of a misleading passthrough. Independent of the automatic dialect switches; the parameter is named ' + + 'msys_path so the automatic path-field rewrite leaves it untouched. ' + + 'Use it for runtime-built MSYS paths before direct native Node fs/path I/O: ' + + 'await the result and pass result.native_path, never the original MSYS string. ' + + 'Already-native/cwd-relative Node values or ordinary translated tool path fields need no conversion. ' + + 'Never pass routes, search strings, shell commands or program source. ' + + 'In PTC invoke this offered SDK binding from inside run_code. A file policy/access denial is a stop condition, ' + + 'not a reason to convert the spelling and retry through Node or another tool.' +} + +const MSYS_PATH_PARAM_DESCRIPTION = 'The complete value to convert, as one single MSYS/Git Bash path ' + + '(drive root /c/, ~, $HOME-style variable, /tmp, /dev/null or a Git install mount). ' + + 'Pass the FULL value only; the tool performs no I/O and no permission grant.' + +/** + * Build the tool definition in whichever dialect the host supports: the real + * `defineTool` author schema when the host module is available (parameters is + * a property map with `required: true` on the property; the output object + * declares `additionalProperties: false` explicitly and marks every property + * required), or an equivalent plain JSON-schema object otherwise. Both shapes + * share ONE execute body. + * @param {object} deps + * @param {object|undefined} [deps.toolsModule] - `@deepseek-ai/dsh-tools` (defineTool). + * @param {(value: string, env?: object|null) => unknown} [deps.translate] - + * the shared translator, already composed fresh by the wiring. + * @param {(value: string, env: object|null) => unknown} [deps.translateMsysPath] - + * alternative to `translate`: the raw shared helper. + * @param {() => object|null|undefined} [deps.buildEnv] - fresh mount-facts + * reader (buildTranslateEnv composition); called once per tool call. + * @param {(message: string) => void} [deps.log] + * @returns {object} a registry-ready definition. + */ +export function buildPathToolDefinition({ toolsModule, translate, translateMsysPath, buildEnv, log = () => {} } = {}) { + const convertValue = typeof translate === 'function' + ? (value, env) => translate(value, env) + : typeof translateMsysPath === 'function' + ? (value, env) => translateMsysPath(value, env) + : undefined + if (!convertValue) { + throw new Error('path-tool: a shared translator is required — pass translate (fresh composed) or translateMsysPath; the tool never copies the mount table') + } + const execute = (args) => { + // Fresh facts per call: the same lazy discipline as every other + // translation consumer (a settings bashPath change must be visible on + // the next call without a re-mount). + const env = typeof buildEnv === 'function' ? buildEnv() : undefined + const outcome = convertMsysToNative(args?.msys_path, { translate: convertValue, env }) + if (!outcome.ok) throw new Error(outcome.message) + return { native_path: outcome.native_path } + } + if (typeof toolsModule?.defineTool === 'function') { + return toolsModule.defineTool({ + name: PATH_TOOL_NAME, + description: pathToolDescription(), + parameters: { + msys_path: { type: 'string', required: true, description: MSYS_PATH_PARAM_DESCRIPTION }, + }, + output: { + schema: { + type: 'object', + properties: { native_path: { type: 'string', required: true } }, + additionalProperties: false, + }, + render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }], + }, + isConcurrencySafe: true, + execute, + }) + } + log('path-tool: @deepseek-ai/dsh-tools not resolvable; building a plain JSON-schema definition') + return { + name: PATH_TOOL_NAME, + description: pathToolDescription(), + parameters: { + type: 'object', + properties: { msys_path: { type: 'string', description: MSYS_PATH_PARAM_DESCRIPTION } }, + required: ['msys_path'], + additionalProperties: false, + }, + output: { + schema: { + type: 'object', + properties: { native_path: { type: 'string' } }, + required: ['native_path'], + additionalProperties: false, + }, + render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }], + }, + execute, + } +} + +/** + * Build the cordis module the main wiring mounts inside an agent-owned scope. + * The tool registration is owned by the mounting scope: it reverts through + * the scope's effect at disposal, exactly like the bash-tool module's. + * Windows-only availability, agent gating and any dialect/config gate are the + * MAIN WIRING's decisions — this module carries none. + * + * Deps (final interface): + * - toolsModule: `@deepseek-ai/dsh-tools` when resolvable (defineTool); + * absent ⇒ plain JSON-schema definition, same surface. + * - translate: (value) => native, the shared translateMsysPath + * composed fresh per call by the wiring, OR + * - translateMsysPath + buildEnv: the raw shared helper plus a fresh + * mount-facts reader `() => buildTranslateEnv(...)`; + * buildEnv is called once per tool call. + * - log: optional (message) => void. + * + * @param {object} [deps] + * @returns {{ inject: string[], apply: (ctx: object) => void }} + */ +export function createPathToolModule(deps = {}) { + const definition = buildPathToolDefinition(deps) + return { + inject: ['tools'], + apply(ctx) { + if (typeof ctx?.tools?.register !== 'function') { + throw new Error('path-tool: ctx.tools.register is unavailable; cannot own the tool registration') + } + const unregister = ctx.tools.register(definition) + if (typeof unregister === 'function') { + ctx.effect?.(() => () => { + try { + unregister() + } catch { /* the scope is already rolling back */ } + }) + } + ;(typeof deps.log === 'function' ? deps.log : () => {})( + `${PATH_TOOL_NAME} registered (explicit full-value MSYS→native conversion, no I/O)`, + ) + }, + } +} diff --git a/src/shell.js b/src/shell.js index b92abd8..859ead0 100644 --- a/src/shell.js +++ b/src/shell.js @@ -7,31 +7,27 @@ * replacing the shell executor IS overriding one). Instead the plugin * constructs ONE private instance of this class in a PRIVATE cordis scope * (`executorScope.ctx.plugin(GitBashSandboxExecutor)`, src/inline-tools.js) - * and hands it to the plugin's OWN bash tool (src/bash-tool.js). The class - * body is unchanged from its executor-row era: + * and hands it to the plugin's OWN bash tool (src/bash-tool.js). * * - extends @deepseek-ai/dsh-bash-sandbox to inherit policy, denial and - * lifecycle semantics EXCEPT the explicit Windows unconfined route below; + * lifecycle semantics; unsupported Windows policy is refused, not weakened; * - overrides `confine` so the inner argv is [git-bash, -c, command]; * - overrides run/start ONLY for danger-full-access (the parent's * full-access branch calls LocalBashExecutor.run, which hardcodes the * bare `bash` name), routing through runArgv/startArgv with the same * argv as the confined branch; - * - on Windows ALSO overrides the confined branch itself (issue #1): - * historical MSYS2/restricted-token tests failed during msys-2.0.dll - * cygheap/signal-pipe initialization (Win32 error 5 / 0xC0000142; - * restricting-SID object permissions, CHANGELOG v0.13.2). No working - * confined Git Bash implementation is provided here; this is not a claim - * that every future runtime/isolation design is impossible. Windows calls - * retain the explicit UNCONFINED route, labelled on the handle/result and - * in model-facing foreground/background text, plus a one-time notice. - * Official file-tool write/edit policy remains separate; it does not - * constrain shell code and its denials must never be bypassed through shell. + * - on Windows refuses read-only/workspace-write or unresolved policy before + * argv resolution or process start. Historical restricted-token failures + * (Win32 error 5 / 0xC0000142, CHANGELOG v0.13.2) are not proof that every + * future isolation design is impossible. The matched modern MSYS signal + * pipe has an explicit SD; cygheap uses VirtualAlloc, and Core already + * supplements TokenDefaultDacl. No verified confined Git Bash is provided + * here, so the former labelled unconfined bypass is no longer permitted. + * Official file-tool policy is separate and its denials must not be bypassed. * - * Environment inheritance: bash.exe is spawned as a direct child of the host - * process (never through the git-bash.exe login launcher), so it inherits the - * full system environment plus the DSH_* snapshot exactly like the pwsh - * executor did. + * Environment inheritance: bash.exe is a direct child (not a login launcher) + * of the host's managed, credential-scrubbing subprocess provider. The trusted + * DSH_* snapshot does not exempt it from protected environment rules. * * Constructed as a cordis class plugin (static inject/Config honoured) — * inside an explicitly `ctx.isolate('shell')`-isolated scope, so the base @@ -48,9 +44,6 @@ export { DEFAULT_GIT_BASH } import { loadSchemastery } from './schemastery.js' -/** Log prefix, matching src/index.js. */ -const TAG = '[gitbash-shell]' - /** * The plugin row's id, which doubles as the settings namespace on <= 0.1.6 and * as the row-config namespace on 0.1.7+ (same string by design). @@ -212,7 +205,8 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { * @returns true when the base class start is an async function. */ static baseStartIsAsync() { - return SandboxBashExecutor.prototype.start.constructor.name === 'AsyncFunction' + return typeof SandboxBashExecutor.prototype.start === 'function' + && SandboxBashExecutor.prototype.start.constructor.name === 'AsyncFunction' } /** @@ -232,22 +226,31 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { return { result: raw, spawnRequested: true } } + /** Construct the host's structured refusal when its module was resolved. */ + policyUnavailableError(mode) { + const knownMode = mode === 'read-only' || mode === 'workspace-write' ? mode : 'unresolved' + const detail = 'dsh-gitbash-shell does not provide verified Windows Git for Windows confinement. ' + + 'The command was not started; no unconfined fallback is permitted. ' + + 'Do not bypass this refusal with another tool or change permissions automatically.' + if (typeof this.unavailableErrorType === 'function') return new this.unavailableErrorType(knownMode, detail) + // Direct/older callers without the optional host error export still refuse. + const error = new Error('sandbox mode "' + knownMode + '" cannot execute Git Bash on this Windows host. ' + detail) + error.name = 'SandboxUnavailableError' + error.code = 'SANDBOX_UNAVAILABLE' + return error + } + /** - * Decide whether one spec takes the Git Bash argv path, and how to label it - * (pure). Full access always does — the shipped executor hardcodes the bare - * `bash` name there. A Windows confined call does too (issue #1: MSYS2 cannot - * start under the restricted-token runner). Everything else inherits the - * parent's sandbox path, which already runs Git Bash because `confine` below - * substitutes the argv. - * @param spec the shell request. - * @returns `{ mode, unconfined }`, or undefined to inherit the parent path. + * Full access uses explicit Git Bash argv. Windows restricted or unresolved + * policy fails BEFORE argv resolution, job admission or spawning; disclosure + * of an unconfined route is not permission to weaken the requested policy. + * Non-Windows restricted calls retain the parent's actual sandbox path. + * @returns `{ mode, unconfined: false }`, or undefined to inherit. */ gitBashRoute(spec) { - const policy = spec.sandboxPolicy - const mode = policy === undefined ? undefined : policy.mode - if (mode === undefined) return undefined + const mode = spec.sandboxPolicy?.mode if (mode === 'danger-full-access') return { mode, unconfined: false } - if (process.platform === 'win32') return { mode, unconfined: true } + if (process.platform === 'win32') throw this.policyUnavailableError(mode) return undefined } @@ -285,16 +288,13 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { * @param spec resolved execution settings and caller-owned command metadata. * @returns the live execution handle. */ - execute(spec) { + async execute(spec) { if (typeof super.execute !== 'function') return this.run(spec) + const route = this.gitBashRoute(spec) const routed = this.withParityEnv(spec) - const route = this.gitBashRoute(routed) if (route === undefined) return super.execute(routed) const argv = [this.requireBashPath(), '-c', routed.command] - if (route.unconfined) this.warnConfinedUnconfined(route.mode) - const sandbox = route.unconfined - ? { mode: route.mode, denied: false, enforcement: 'unconfined' } - : { mode: route.mode, denied: false } + const sandbox = { mode: route.mode, denied: false } // `executeArgv` is the shipped helper for exactly this: run an explicit // argv with the executor's own lifecycle, output and deadline semantics. return Promise.resolve(this.executeArgv(routed, argv)) @@ -366,48 +366,28 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { * old-era entry point. */ async run(spec) { + const route = this.gitBashRoute(spec) spec = this.withParityEnv(spec) - const policy = spec.sandboxPolicy - if (policy === undefined) return super.run(spec) - const { mode } = policy - if (mode === 'danger-full-access') { - const { result } = GitBashSandboxExecutor.unwrapRunArgv( - await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command])) - return { ...result, sandbox: { mode, denied: false } } - } - if (process.platform === 'win32') { - this.warnConfinedUnconfined(mode) - const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv( - await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command])) - // No spawn means no argv ran, so there is nothing to label as - // unconfined; the parent reports the same bare sandbox facts there. - return { ...result, sandbox: spawnRequested ? { mode, denied: false, enforcement: 'unconfined' } : { mode, denied: false } } - } - return super.run(spec) + if (route === undefined) return super.run(spec) + const { result } = GitBashSandboxExecutor.unwrapRunArgv( + await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command])) + return { ...result, sandbox: { mode: route.mode, denied: false } } } start(spec) { - spec = this.withParityEnv(spec) - const policy = spec.sandboxPolicy - if (policy === undefined) return super.start(spec) - const { mode } = policy - const fullAccess = mode === 'danger-full-access' - if (fullAccess || process.platform === 'win32') { - if (!fullAccess) this.warnConfinedUnconfined(mode) - const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command]) - proc.sandbox = fullAccess ? { mode, denied: false } : { mode, denied: false, enforcement: 'unconfined' } - // Match the loaded base contract: the async one (dsh >= 0.1.6) is - // awaited by its callers, the synchronous one is consumed directly. - return GitBashSandboxExecutor.baseStartIsAsync() ? Promise.resolve(proc) : proc + let route + try { route = this.gitBashRoute(spec) } + catch (error) { + if (GitBashSandboxExecutor.baseStartIsAsync()) return Promise.reject(error) + throw error } - return super.start(spec) - } - - /** One-per-instance notice that confined Windows calls run unconfined. */ - warnConfinedUnconfined(mode) { - if (this._warnedUnconfined) return - this._warnedUnconfined = true - console.log(TAG + ' Windows: confined Git Bash is not provided by this restricted-token backend (issue #1). Requested mode ' + mode + ' will use an UNCONFINED Git Bash route: shell file-write restrictions are NOT enforced. Official file-tool write/edit policy is separate; never bypass its denial through shell.') + spec = this.withParityEnv(spec) + if (route === undefined) return super.start(spec) + const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command]) + const label = (handle) => { handle.sandbox = { mode: route.mode, denied: false }; return handle } + // Preserve the old host's synchronous/async start contract, including its + // refusal shape; modern execute() callers never enter this legacy method. + return GitBashSandboxExecutor.baseStartIsAsync() ? Promise.resolve(proc).then(label) : label(proc) } } diff --git a/tests/compatibility-regressions.mjs b/tests/compatibility-regressions.mjs index 1eb2c4a..aa8633e 100644 --- a/tests/compatibility-regressions.mjs +++ b/tests/compatibility-regressions.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { rewriteCodePaths, scanCodeLiterals, runCodeHintFor, translateDispatch, dialectApplies, windowsToMsys, rewriteResultPaths, rewriteErrorContent, rewriteErrorMessage } from '../src/index.js' import { bashDescription, createBashToolModule } from '../src/bash-tool.js' +import { applyToolRouting, toolRoutingHintFor } from '../src/inline-tools.js' +import { pathToolDescription } from '../src/path-tool.js' import { renderResult, renderBackground, renderJobRead, renderPromoted, processOutcome, parseExitStatus } from '../src/bash-render.js' const env = { home: 'C:/Users/审计', tmpDir: 'C:/Temp', gitRoot: 'C:/Program Files/Git' } @@ -81,8 +83,8 @@ test('compatibility: actual prompt listener reads the live codePaths gate', () = let handler let dialect = { posixPaths: true, codePaths: true, subagentDialect: true } const ctx = { on(name, fn) { assert.equal(name, 'system-prompt/assemble'); handler = fn } } - new Function('ctx', 'liveSettings', 'dialectApplies', 'windowsToMsys', 'runCodeHintFor', 'GITBASH_ROUTE_ADDENDUM', 'GITBASH_ROUTE_ADDENDUM_NO_PWSH', 'GITBASH_MSYS_ADDENDUM', source.slice(start, end))( - ctx, { dialect: () => dialect, pwshTool: () => false }, dialectApplies, windowsToMsys, runCodeHintFor, '', '', '') + new Function('ctx', 'liveSettings', 'dialectApplies', 'windowsToMsys', 'runCodeHintFor', 'applyToolRouting', source.slice(start, end))( + ctx, { dialect: () => dialect, pwshTool: () => false }, dialectApplies, windowsToMsys, runCodeHintFor, applyToolRouting) const assemble = agent => { const assembly = { tools: [{ name: 'run_code' }], contexts: [{ name: 'gitbash-shell:posix-paths', text: 'BASE' }], sections: [] } handler(assembly, { agent }, () => {}) @@ -97,6 +99,72 @@ test('compatibility: actual prompt listener reads the live codePaths gate', () = assert.equal(assemble({ session: { header: { origin: 'subagent' } } }), 'BASE') }) +test('routing: offered native/path/pwsh tools have explicit pre-execution decision criteria', () => { + const tools = ['bash', 'gitbash_native', 'gitbash_to_native_path', 'pwsh'].map(name => ({ name })) + const hint = toolRoutingHintFor(tools, { pwshVisible: true }) + assert.match(hint, /shell syntax, scripts, pipelines, redirection/) + assert.match(hint, /gitbash_native before execution/) + assert.match(hint, /exact literal argv.*\/all or \/c/) + assert.match(hint, /raw for unknown\/mixed bytes/) + assert.match(hint, /gitbash_to_native_path only for a complete caller-declared/) + assert.match(hint, /runtime-built value/) + assert.match(hint, /use the pwsh tool ONLY when PowerShell cmdlets/) + assert.match(hint, /denial is a stop condition/) + assert.match(hint, /never blindly repeat a side-effecting command/) + assert.doesNotMatch(hint, /ALL commands/) +}) + +test('routing: visibility matrix never recommends an absent companion or hidden pwsh', () => { + for (let mask = 0; mask < 8; mask++) for (const pwshVisible of [false, true]) { + const names = ['bash', ...(mask & 1 ? ['gitbash_native'] : []), ...(mask & 2 ? ['gitbash_to_native_path'] : []), ...(mask & 4 ? ['pwsh'] : [])] + const hint = toolRoutingHintFor(names.map(name => ({ name })), { pwshVisible }) + assert.equal(hint.includes('gitbash_native'), !!(mask & 1)) + assert.equal(hint.includes('gitbash_to_native_path'), !!(mask & 2)) + assert.equal(/\bpwsh\b/.test(hint), !!(mask & 4) && pwshVisible) + assert.match(hint, /denial is a stop condition/) + } + assert.equal(toolRoutingHintFor(undefined), '') + assert.equal(toolRoutingHintFor([{ name: 'run_code' }]), '') +}) + +test('routing: replaceable blocks are idempotent and follow live visibility/dialect', () => { + const bash = { name: 'bash', description: 'BASE' } + let tools = [bash, { name: 'gitbash_native' }, { name: 'gitbash_to_native_path' }, { name: 'pwsh' }] + applyToolRouting(tools, { pwshVisible: true, msys: true }) + const first = bash.description + applyToolRouting(tools, { pwshVisible: true, msys: true }) + assert.equal(bash.description, first) + assert.match(first, /\/c\/Users/) + tools = [bash] + applyToolRouting(tools, { pwshVisible: false, msys: false }) + assert.ok(bash.description.startsWith('BASE')) + assert.doesNotMatch(bash.description, /gitbash_native|gitbash_to_native_path|\bpwsh\b|\/c\/Users/) + assert.equal(bash.description.split('[gitbash tool routing]').length, 2) +}) + +test('routing: PTC-only presentation teaches offered SDK selection without guessed hidden names', () => { + const tool = { name: 'run_code', description: 'PTC_BASE' } + applyToolRouting([tool]) + assert.match(tool.description, /await tools\./) + assert.match(tool.description, /typed native-executable binding for exact Windows argv/) + assert.match(tool.description, /complete-path conversion binding/) + assert.match(tool.description, /If a needed binding is absent, report the missing capability/) + assert.match(tool.description, /denial is a stop condition/) + assert.doesNotMatch(tool.description, /gitbash_native|gitbash_to_native_path|\bpwsh\b/) + const once = tool.description + applyToolRouting([tool]) + assert.equal(tool.description, once) +}) + +test('routing: path binding explains Node runtime conversion and explicitly disallows denial retries', () => { + const text = pathToolDescription() + assert.match(text, /runtime-built MSYS paths before direct native Node fs\/path/) + assert.match(text, /result\.native_path/) + assert.match(text, /Never pass routes, search strings, shell commands or program source/) + assert.match(text, /inside run_code/) + assert.match(text, /not a reason to convert the spelling and retry/) +}) + test('compatibility: the registered bash description always warns about native boundaries', () => { const text = bashDescription() assert.match(text, /ipconfig \/all and cmd \/c/) @@ -107,8 +175,10 @@ test('compatibility: the registered bash description always warns about native b assert.match(text, /decoded as UTF-8/) assert.match(text, /CP936\/GBK/) assert.match(text, /mixed streams cannot safely be guessed/) - assert.match(text, /does NOT enforce shell file-write restrictions/) - assert.match(text, /never use shell to bypass its denial/) + assert.match(text, /read-only\/workspace-write or unresolved policy is refused before/) + assert.match(text, /explicitly resolved danger-full-access policy/) + assert.match(text, /Do not switch tools or permissions automatically to bypass a refusal/) + assert.doesNotMatch(text, /will use an UNCONFINED|route does NOT enforce/, 'current behaviour must describe refusal, not the retired bypass') }) test('compatibility: unconfined foreground warning preserves exit parsing and cancellation', () => { diff --git a/tests/native-executor.mjs b/tests/native-executor.mjs new file mode 100644 index 0000000..ce8a09f --- /dev/null +++ b/tests/native-executor.mjs @@ -0,0 +1,132 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { nativePath, parseNativeRequest, requireNativePolicy, createNativeExecutor } from '../src/native-executor.js' +import { nativeParameters, NATIVE_OUTPUT_SCHEMA } from '../src/native-tool.js' + +const translateMsysPath = value => value.startsWith('/c/') ? `C:/${value.slice(3)}` : value === '/tmp' ? 'C:/Native Sentinel Temp' : value +const options = { translateMsysPath, platform: 'win32', sessionCwd: 'C:/Native Sentinel' } +const request = patch => ({ description: 'Inspect fixed sentinel arguments', executable: { kind: 'path', value: 'C:/Native Sentinel/producer.exe' }, argv: [], ...patch }) + +test('native typed argv: slash switches, empty, quotes, Unicode and meta stay literal', () => { + const values = ['/all', '/c', '/c/FILE', '', '"quoted"', 'tail\\', '中文😀', '&|<>^%NAME%!x!', 'https://sentinel.invalid/a'] + const result = parseNativeRequest(request({ argv: values.map(value => ({ kind: 'literal', value })) }), options) + assert.deepEqual(result.argv, values) + assert.ok(Object.isFrozen(result) && Object.isFrozen(result.argv) && Object.isFrozen(result.output)) +}) + +test('native typed path: translation is opt-in and Windows launch fields normalize', () => { + const result = parseNativeRequest(request({ executable: { kind: 'path', value: '/c/Native Sentinel/producer.exe' }, + cwd: { kind: 'path', value: '/tmp' }, argv: [{ kind: 'path', value: '/c/Sentinel/file.txt' }, { kind: 'literal', value: '/c/Sentinel/file.txt' }] }), options) + assert.equal(result.executable, 'C:\\Native Sentinel\\producer.exe') + assert.equal(result.cwd, 'C:\\Native Sentinel Temp') + assert.deepEqual(result.argv, ['C:\\Sentinel\\file.txt', '/c/Sentinel/file.txt']) + assert.equal(nativePath({ kind: 'path', value: '//server/share/f.txt' }, translateMsysPath, 'win32'), '\\\\server\\share\\f.txt') +}) + +test('native input: unresolved paths and unsupported semantics fail closed', () => { + for (const value of ['relative', '/unknown/mount', '~/file', '$HOME/file', '%TEMP%/file', 'C:relative', '\\rooted']) { + assert.throws(() => nativePath({ kind: 'path', value }, translateMsysPath, 'win32'), /absolute|expansion/) + } + for (const key of ['env', 'shell', 'command', 'sandbox_permissions', 'justification', 'windowsVerbatimArguments']) { + assert.throws(() => parseNativeRequest(request({ [key]: {} }), options), /unsupported field/) + } + assert.throws(() => parseNativeRequest(request({ argv: [{ kind: 'literal', value: 'x\0y' }] }), options), /NUL/) + assert.throws(() => parseNativeRequest(request({ argv: [{ kind: 'guess', value: 'x' }] }), options), /kind/) + assert.throws(() => parseNativeRequest(request({ executable: { kind: 'command', value: 'node' } }), options), /kind/) +}) + +test('native input: scripts rejected, interpreter requires explicit declaration', () => { + for (const name of ['producer.cmd', 'producer.bat', 'script.ps1', 'script.js']) assert.throws(() => parseNativeRequest(request({ executable: { kind: 'path', value: `C:/${name}` }, entrypoint: 'interpreter' }), options), /only native/) + for (const name of ['cmd', 'node', 'python3', 'pwsh', 'bash']) { + const args = request({ executable: { kind: 'path', value: `C:/${name}.exe` } }) + assert.throws(() => parseNativeRequest(args, options), /explicit entrypoint/) + assert.equal(parseNativeRequest({ ...args, entrypoint: 'interpreter' }, options).entrypoint, 'interpreter') + } +}) + +test('native input: encoding and timeout are explicit bounded semantics', () => { + for (const timeoutMs of [0, -1, NaN, Infinity]) assert.throws(() => parseNativeRequest(request({ timeoutMs }), options), /positive/) + assert.equal(parseNativeRequest(request({ timeoutMs: 1000000 }), options).timeoutMs, 600000) + assert.throws(() => parseNativeRequest(request({ timeoutMs: 10, run_in_background: true }), options), /not supported/) + assert.throws(() => parseNativeRequest(request({ stdoutEncoding: 'auto' }), options), /encoding/) + assert.throws(() => parseNativeRequest(request({ errors: 'guess' }), options), /errors/) +}) + +test('native policy: missing/RO/WW rejected using actual public error constructor shape', () => { + class SandboxUnavailableError extends Error { constructor(mode, detail) { super(detail); this.mode = mode } } + for (const mode of [undefined, 'read-only', 'workspace-write', 'unknown']) { + let request + const ctx = { get: () => mode ? { defaultMode: 'danger-full-access', resolve(value) { request = value; return { mode } } } : undefined } + assert.throws(() => requireNativePolicy(ctx, { agent: { session: 'session-sentinel' } }, { SandboxUnavailableError }), SandboxUnavailableError) + if (mode) assert.deepEqual(request, { session: 'session-sentinel' }) + } + assert.equal(requireNativePolicy({ get: () => ({ resolve: () => ({ mode: 'danger-full-access' }) }) }, {}).mode, 'danger-full-access') +}) + +test('native policy: all denied paths precede workspace, scope, adapter and spawn', async () => { + for (const mode of [undefined, 'read-only', 'workspace-write']) { + const reached = [] + const ctx = { get: () => mode ? { resolve: () => ({ mode }) } : undefined, + workingDirectory: { ensure() { reached.push('workspace') } }, shellEnv: { collect() { reached.push('env') } } } + const executor = createNativeExecutor(ctx, { localBashModule: { LocalBashExecutor: class {} }, + createScope() { reached.push('scope') }, createNativeOutputAdapter() { reached.push('adapter') }, translateMsysPath }) + await assert.rejects(executor.prepare(request(), { agent: {} }), /refuses/) + await assert.rejects(executor.execute({}, {}), /refuses/) + assert.deepEqual(reached, []) + } +}) + +test('native cancellation: cancellation during workspace preparation cannot hand off a job', async () => { + const abort = new AbortController() + let envCollected = false + const ctx = { get: () => ({ resolve: () => ({ mode: 'danger-full-access' }) }), + workingDirectory: { async ensure() { abort.abort(new Error('fixed preparation cancel')); return 'C:/Native Sentinel' } }, + shellEnv: { collect() { envCollected = true; return {} } } } + const executor = createNativeExecutor(ctx, { localBashModule: { LocalBashExecutor: class {} }, + createScope() { throw new Error('MUST_NOT_CREATE_SCOPE') }, createNativeOutputAdapter() { throw new Error('MUST_NOT_CREATE_ADAPTER') }, translateMsysPath }) + await assert.rejects(executor.prepare(request({ run_in_background: true }), { agent: {}, signal: abort.signal }), /fixed preparation cancel/) + assert.equal(envCollected, false) +}) + +test('native preparation: a non-settling scope mount is bounded and disposed before artifacts', async () => { + let disposed = false + let artifactCreated = false + const owner = { isolate() { return this } } + const ctx = { + get: key => key === 'sandboxPolicy' ? { resolve: () => ({ mode: 'danger-full-access' }) } : { spawn() { throw new Error('MUST_NOT_SPAWN') } }, + workingDirectory: { ensure: async () => 'C:/Native Sentinel' }, shellEnv: { collect: () => ({}) }, + } + const executor = createNativeExecutor(ctx, { platform: 'win32', localBashModule: { LocalBashExecutor: class {} }, + createScope: () => ({ fiber: new Promise(() => {}), dispose: async () => { disposed = true } }), + createNativeOutputAdapter() { artifactCreated = true; throw new Error('MUST_NOT_CREATE_ADAPTER') }, translateMsysPath }) + const exec = { agent: { ctx: owner }, signal: new AbortController().signal } + const prepared = await executor.prepare(request({ timeoutMs: 30 }), exec) + await assert.rejects(executor.execute(prepared, exec), /preparation timed out/) + assert.equal(disposed, true) + assert.equal(artifactCreated, false) +}) + +test('native output schema: every object has explicit closed properties', () => { + const inspect = node => { + if (!node || typeof node !== 'object') return + if (node.type === 'object') { + assert.equal(node.additionalProperties, false) + assert.ok(node.properties && Object.keys(node.properties).length > 0) + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(inspect) + else inspect(value) + } + } + inspect(NATIVE_OUTPUT_SCHEMA) +}) + +test('native schema authoring: implicit property-map, required markers and capability-gated Jobs', () => { + const parameters = nativeParameters() + assert.equal(parameters.type, undefined) + assert.equal(parameters.executable.required, true) + assert.equal(parameters.argv.required, true) + assert.equal(parameters.argv.items.properties.value.required, true) + assert.equal(parameters.run_in_background, undefined) + assert.equal(nativeParameters({ background: true }).run_in_background.type, 'boolean') +}) diff --git a/tests/native-integration-preview.mjs b/tests/native-integration-preview.mjs new file mode 100644 index 0000000..339473a --- /dev/null +++ b/tests/native-integration-preview.mjs @@ -0,0 +1,192 @@ +// Independent installed-Core review. No production profile or OS confinement. +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { Context } from '@deepseek-ai/cordis' +import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' +import * as sandboxModule from '@deepseek-ai/dsh-sandbox' +import * as localBashModule from '@deepseek-ai/dsh-bash-local' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' +import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output' +import { createNativeExecutor } from '../src/native-executor.js' +import { createNativeOutputAdapter } from '../src/native-output.js' + +const evidence = process.env.DSH_NATIVE_REVIEW_EVIDENCE_DIR +assert.ok(evidence && path.isAbsolute(evidence), 'Explicit authorized review evidence directory required') +const directory = path.join(evidence, `independent-${randomUUID()}`) +fs.mkdirSync(directory, { recursive: true }) +const observations = [] +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) +const latch = () => { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } +const request = (code = 'process.stdout.write("FIXED_REVIEW_SENTINEL")', patch = {}) => ({ + description: 'Independent fixed native review sentinel', executable: { kind: 'path', value: process.execPath }, + entrypoint: 'interpreter', argv: ['-e', code].map(value => ({ kind: 'literal', value })), ...patch, +}) +function scope(parent) { + let ctx + const fiber = parent.plugin(value => { ctx = value }) + return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } +} +async function fixture(t, overrides = {}) { + const root = new Context() + let mode = 'danger-full-access', resolves = 0, artifactCalls = 0, denyAt = Infinity + const agent = { id: randomUUID(), session: { header: { id: randomUUID() } } } + const disposers = [] + const scopes = [], handles = [] + for (const [name, service] of Object.entries({ + shell: { fixedOfficial: true }, + sandboxPolicy: { resolve(input) { assert.equal(input.session, agent.session); resolves++; return mode ? { mode: resolves >= denyAt ? 'read-only' : mode, workspaceRoot: directory } : undefined } }, + workingDirectory: { ensure: async () => directory }, shellEnv: { collect: () => ({ DSH_REVIEW_SENTINEL: 'FIXED_NON_SECRET' }) }, + })) disposers.push(root.provide(name, service)) + const providerFiber = root.plugin(LocalSubprocessRuntime); await providerFiber + disposers.push(() => providerFiber.dispose()) + root.get('subprocess').internals.spillDir = directory + const entries = ['shell', 'subprocess'].map(name => root.reflect.store[root[Context.isolate][name]]) + const agentScope = createAgentScope(root, agent); agent.ctx = agentScope.ctx + disposers.push(() => agentScope.dispose()) + const deps = { + localBashModule, sandboxModule, translateMsysPath: value => value, + createScope(parent) { const value = scope(parent); scopes.push(value); return value }, + outputOptions: { OutputCollector, drainTimeoutMs: 50, maxTotalBytes: 4 * 1024 * 1024, maxSpillBytes: 8192 }, + async createArtifactDirectory(...args) { artifactCalls++; return overrides.artifact ? overrides.artifact(...args) : directory }, + createNativeOutputAdapter(provider, options) { + const adapter = createNativeOutputAdapter(provider, options) + return { spawn(spec) { const handle = adapter.spawn(spec); handles.push(handle); return handle } } + }, + } + const executor = createNativeExecutor(agent.ctx, deps) + const abort = new AbortController() + const exec = { agent, signal: abort.signal } + const run = async args => executor.execute(await executor.prepare(args, exec), exec) + t.after(async () => { for (const dispose of disposers.reverse()) await dispose() }) + return { executor, deps, exec, abort, run, agentScope, scopes, handles, setMode(value) { mode = value }, + get artifactCalls() { return artifactCalls }, get resolves() { return resolves }, denyOnResolve(number) { denyAt = number }, + preserved() { for (const [i, name] of ['shell', 'subprocess'].entries()) assert.equal(root.reflect.store[root[Context.isolate][name]], entries[i]) }, + } +} +async function ready(handle) { + for (let i = 0; i < 200; i++) { + if (handle?.collected.stdout.readFrom(0).text.includes('READY')) return + await delay(10) + } + throw new Error('Fixed READY sentinel did not arrive') +} + +test('review: prepared DTO is non-forgeable and executor-local', { timeout: 10000 }, async t => { + const f = await fixture(t) + const prepared = await f.executor.prepare(request(), f.exec) + await assert.rejects(f.executor.execute({ ...prepared }, f.exec), /must be prepared/) + const another = createNativeExecutor(f.exec.agent.ctx, f.deps) + await assert.rejects(another.execute(prepared, f.exec), /must be prepared/) + assert.equal(f.scopes.length, 0); assert.equal(f.handles.length, 0); assert.equal(f.artifactCalls, 0) + f.preserved() +}) + +test('review: all repeated policy gates including the real executeArgv spawn seam fail closed', { timeout: 15000 }, async t => { + for (const gate of [2, 3, 4, 5, 6]) { + await t.test(`policy resolve ${gate}`, async sub => { + const f = await fixture(sub) + const prepared = await f.executor.prepare(request(), f.exec) + f.denyOnResolve(gate) + const attempt = async () => { const proc = await f.executor.execute(prepared, f.exec); await proc.result() } + await assert.rejects(attempt(), /no confined backend/) + assert.equal(f.handles.length, 0) + assert.ok(f.scopes.every(value => value.ctx.fiber.state === 4)) + f.preserved() + }) + } +}) + +test('review: actual policy changes during artifact await cannot spawn', { timeout: 10000 }, async t => { + const entered = latch(), release = latch() + const f = await fixture(t, { artifact: async () => { entered.resolve(); await release.promise; return directory } }) + const running = f.run(request()); const rejected = assert.rejects(running, /no confined backend/) + await entered.promise; f.setMode('read-only'); release.resolve(); await rejected + assert.equal(f.handles.length, 0); assert.ok(f.scopes.every(value => value.ctx.fiber.state === 4)); f.preserved() +}) + +test('review: cancellation during artifact await rejects before native spawn', { timeout: 10000 }, async t => { + const entered = latch(), release = latch() + const f = await fixture(t, { artifact: async () => { entered.resolve(); await release.promise; return directory } }) + const running = f.run(request()); const rejected = assert.rejects(running, /FIXED_CANCEL/) + await entered.promise; f.abort.abort(new Error('FIXED_CANCEL')); release.resolve(); await rejected + assert.equal(f.handles.length, 0); assert.ok(f.scopes.every(value => value.ctx.fiber.state === 4)); f.preserved() +}) + +test('review regression: pending artifact owner cannot delay cancellation settlement', { timeout: 10000 }, async t => { + const entered = latch(), release = latch() + const f = await fixture(t, { artifact: async () => { entered.resolve(); await release.promise; return directory } }) + let settled = false + const running = f.run(request('', { timeoutMs: 20 })).finally(() => { settled = true }) + const rejected = assert.rejects(running, /FIXED_CANCEL/) + await entered.promise; f.abort.abort(new Error('FIXED_CANCEL')); await delay(120) + observations.push({ name: t.name, settledWithin120msOfAbort: settled, scopesDisposed: f.scopes.every(value => value.ctx.fiber.state === 4), spawnCount: f.handles.length }) + const settledBeforeRelease = settled + const disposedBeforeRelease = f.scopes.every(value => value.ctx.fiber.state === 4) + release.resolve(); await rejected; assert.equal(f.handles.length, 0) + assert.equal(settledBeforeRelease, true, 'cancel must settle without waiting for artifact callback') + assert.equal(disposedBeforeRelease, true, 'pending callback must not retain operation scope') +}) + +test('review regression: preparation deadline includes pending artifact callback', { timeout: 10000 }, async t => { + const entered = latch(), release = latch() + const f = await fixture(t, { artifact: async () => { entered.resolve(); await release.promise; return directory } }) + let settled = false + const running = f.run(request('', { timeoutMs: 80 })).finally(() => { settled = true }) + const rejected = assert.rejects(running, /preparation timed out/) + await entered.promise; await delay(200) + const settledBeforeRelease = settled + const disposedBeforeRelease = f.scopes.every(value => value.ctx.fiber.state === 4) + release.resolve(); await rejected + assert.equal(settledBeforeRelease, true); assert.equal(disposedBeforeRelease, true); assert.equal(f.handles.length, 0) +}) + +function interrupted(result) { + assert.equal(result.status, 'killed'); assert.equal(result.aborted, true) + for (const facts of [result.outputFacts.stdout, result.outputFacts.stderr]) { + assert.equal(facts.aborted, true); assert.equal(facts.complete, false) + for (const artifact of Object.values(facts.artifact)) assert.equal(artifact.complete, false) + } + assert.equal(result.stdout.spillPath, undefined) +} + +test('review regression: owner dispose completeness and process settlement', { timeout: 12000 }, async t => { + const f = await fixture(t) + const proc = await f.run(request('process.stdout.write("READY");setTimeout(()=>process.stdout.write("LATE"),5000)')) + await ready(f.handles[0]); await f.agentScope.dispose() + const result = await Promise.race([proc.result(), delay(2000).then(() => { throw new Error('result stalled after owner dispose') })]) + const facts = f.handles[0].outputFacts() + interrupted(result) + assert.doesNotMatch(result.stdout.text, /LATE/); assert.equal(await f.handles[0].waitForExit(AbortSignal.timeout(2000)), true) + observations.push({ name: t.name, status: result.status, exitCode: result.exitCode, aborted: result.aborted, outputFacts: facts }) + f.preserved() +}) + +test('review regression: direct kill completeness and process settlement', { timeout: 12000 }, async t => { + const f = await fixture(t) + const proc = await f.run(request('process.stdout.write("READY");setTimeout(()=>process.stdout.write("LATE"),5000)')) + await ready(f.handles[0]); assert.equal(proc.kill(), true) + const result = await proc.result() + interrupted(result) + assert.doesNotMatch(result.stdout.text, /LATE/) + observations.push({ name: t.name, status: result.status, exitCode: result.exitCode, aborted: result.aborted, outputFacts: result.outputFacts }) + f.preserved() +}) + +test('review regression: normal cleanup preserves complete artifacts for late readers', { timeout: 10000 }, async t => { + const f = await fixture(t) + const proc = await f.run(request()) + const result = await proc.result() + assert.equal(result.exitCode, 0); assert.equal(result.aborted, false); assert.equal(result.outputFacts.stdout.complete, true) + await f.agentScope.dispose() + assert.equal(f.handles[0].outputFacts().stdout.complete, true) + const artifact = result.outputFacts.stdout.artifact.decoded + assert.equal(artifact.complete, true); assert.equal(fs.readFileSync(artifact.path, 'utf8'), 'FIXED_REVIEW_SENTINEL') +}) + +test.after(() => { + fs.writeFileSync(path.join(directory, 'observations.json'), JSON.stringify({ scope: 'Independent actual installed Core; asserted regression observations, not production E2E', observations }, null, 2) + '\n') + console.log(`Independent review evidence: ${directory}`) +}) diff --git a/tests/native-output-preview.mjs b/tests/native-output-preview.mjs new file mode 100644 index 0000000..753b8f6 --- /dev/null +++ b/tests/native-output-preview.mjs @@ -0,0 +1,189 @@ +// Read-only installed-Core imports; write fixtures/artifacts ONLY into the +// explicit caller-owned evidence directory. No profile, Jobs or ToolRuntime E2E. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { Context } from '@deepseek-ai/cordis'; +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'; +import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'; +import { createNativeOutputAdapter } from '../src/native-output.js'; + +const directory = process.env.DSH_NATIVE_OUTPUT_EVIDENCE_DIR; +assert.ok(directory && path.isAbsolute(directory) && fs.statSync(directory).isDirectory(), 'Set DSH_NATIVE_OUTPUT_EVIDENCE_DIR to an existing authorized evidence directory'); +const artifactOptions = { OutputCollector, artifactDirectory: directory, artifactPolicyValidated: true, maxSpillBytes: 64 * 1024, maxTotalBytes: 256 * 1024 }; +const observations = []; +function record(name, facts) { observations.push({ name, ...facts }); console.log(JSON.stringify({ name, ...facts })); } +function fake(options = {}, maxBytes = 4) { + const stdout = new PassThrough(), stderr = new PassThrough(); + let resolve; const outcome = { exitCode: 0, signal: null }; + const real = { stdout, stderr, collected: {}, done: new Promise(yes => { resolve = yes; }), terminate() {}, waitForExit() { return Promise.resolve(true); } }; + const facade = createNativeOutputAdapter({ spawn: () => real }, { ...artifactOptions, ...options }); + const handle = facade.spawn({ stdio: { stdin: 'ignore', stdout: { maxBytes, spill: { maxBytes: 65536 } }, stderr: { maxBytes } }, graceMs: 20 }); + return { stdout, stderr, handle, async finish() { stdout.end(); stderr.end(); resolve(outcome); assert.equal(await handle.done, outcome); return handle.outputFacts(); } }; +} +async function withRuntime(run) { + const root = new Context(); const fiber = root.plugin(LocalSubprocessRuntime); await fiber; + const runtime = root.get('subprocess'); + // Official isolated-instance test hook; prevent default OS temp preparer. + runtime.internals.spillDir = directory; + try { await run(runtime, root); } finally { await fiber.dispose(); } +} +function spawnSpec(code, signal) { + return { argv: [process.execPath, '-e', code], cwd: directory, graceMs: 100, signal, + stdio: { stdin: 'ignore', stdout: { maxBytes: 8, spill: { maxBytes: 65536 } }, stderr: { maxBytes: 1024 } } }; +} + +test('installed collector: normalized UTF8 spill and exact raw artifacts have owner metadata', async () => { + const h = fake({ stdoutEncoding: 'gbk' }); + const bytes = Buffer.from('d6d0cec4b2e2cad40a', 'hex'); + for (const b of bytes) h.stdout.write(Buffer.from([b])); + const facts = await h.finish(); + assert.equal(h.handle.collected.stdout.readFrom(0).text, '试\n'); + assert.equal(facts.stdout.rawBytes, 9); assert.equal(facts.stdout.decodedUtf8Bytes, 13); + assert.equal(facts.stdout.artifact.raw.complete, true); assert.equal(facts.stdout.artifact.decoded.complete, true); + assert.deepEqual(fs.readFileSync(facts.stdout.artifact.raw.path), bytes); + assert.equal(fs.readFileSync(facts.stdout.artifact.decoded.path, 'utf8'), '中文测试\n'); + assert.equal(h.handle.collected.stdout.readFrom(0).spillPath, facts.stdout.artifact.decoded.path); + assert.ok(facts.budget.reservedMemoryBytes + facts.budget.artifactBytes <= facts.budget.maxTotalBytes); + record('actual exported collector + owned artifact storage', facts); +}); + +test('raw mode artifact is bounded and explicitly not reliable text', async () => { + const h = fake({ stdoutEncoding: 'raw', maxSpillBytes: 3 }); h.stdout.write(Buffer.from('d6d0ff00', 'hex')); + const facts = await h.finish(); + assert.equal(facts.stdout.reliableText, false); assert.equal(facts.stdout.complete, false); + assert.equal(facts.stdout.artifact.raw.bytes, 3); assert.equal(facts.stdout.artifact.raw.capReached, true); + assert.deepEqual(fs.readFileSync(facts.stdout.artifact.raw.path), Buffer.from('d6d0ff', 'hex')); + assert.equal(h.handle.collected.stdout.readFrom(0).text, ''); +}); + +test('shared budget includes BOTH streams and raw plus normalized disk bytes', async () => { + const h = fake({ maxTotalBytes: 4096, maxSpillBytes: 512 }); + h.stdout.write(Buffer.from('😀'.repeat(1000))); h.stderr.write(Buffer.alloc(1000, 65)); + const facts = await h.finish(); + assert.ok(facts.budget.artifactBytes <= 512); + assert.ok(facts.budget.reservedMemoryBytes + facts.budget.artifactBytes <= 4096); + assert.equal(facts.stdout.rawBytes, 4000); assert.equal(facts.stderr.rawBytes, 1000); + assert.ok(facts.stdout.artifact.raw.capReached || facts.stdout.artifact.decoded.capReached); + assert.equal(h.handle.collected.stdout.readFrom(0).spillPath, undefined); + for (const stream of [facts.stdout, facts.stderr]) for (const artifact of Object.values(stream.artifact)) { + if (artifact.path && artifact.kind === 'decoded') assert.ok(!fs.readFileSync(artifact.path, 'utf8').includes('\ufffd')); + } + record('bounded combined disk/memory payload budget', facts); +}); + +for (const operation of ['openSync', 'writeSync', 'closeSync']) test(`controlled ${operation} failure preserves exit, seals and reports partial artifact`, async () => { + const original = fs[operation]; + const h = fake(); + let injected = 0; + fs[operation] = function (...args) { + if (operation === 'closeSync') original.apply(this, args); // really close before simulating delayed writeback failure + injected++; + throw Object.assign(new Error(`controlled ${operation} fault`), { code: 'EIO' }); + }; + let facts; + try { h.stdout.write(Buffer.from('controlled spill\n')); facts = await h.finish(); } + finally { fs[operation] = original; } + assert.ok(injected); assert.ok(facts.stdout.storageError); + assert.equal(facts.stdout.complete, false); + assert.equal(h.handle.collected.stdout.readFrom(0).spillPath, undefined); + for (const artifact of Object.values(facts.stdout.artifact)) assert.equal(artifact.complete, false); + record(`injected ${operation} fault (not actual disk exhaustion)`, { stdout: facts.stdout }); +}); + +test('installed rawPipe provider preserves exact fixed bytes without facade mutation', async () => withRuntime(async runtime => { + const spec = spawnSpec("process.stdout.write(Buffer.from('d6d0cec4','hex'));process.stderr.write(Buffer.from('f09f9880','hex'))"); + spec.stdio.stdout = 'pipe'; spec.stdio.stderr = 'pipe'; + const handle = runtime.spawn(spec); const chunks = { stdout: [], stderr: [] }; + for (const name of ['stdout', 'stderr']) handle[name].on('data', bytes => chunks[name].push(bytes)); + const outcome = await handle.done; const quiet = await handle.waitForExit(AbortSignal.timeout(2000)); + assert.equal(outcome.exitCode, 0); assert.equal(quiet, true); + assert.equal(Buffer.concat(chunks.stdout).toString('hex'), 'd6d0cec4'); + assert.equal(Buffer.concat(chunks.stderr).toString('hex'), 'f09f9880'); + record('actual installed Core rawPipe baseline', { outcome, rangeQuiet: quiet, stdoutHex: Buffer.concat(chunks.stdout).toString('hex') }); +})); + +test('installed provider + adapter: independent fixed encodings and unchanged provider registration', async () => withRuntime(async (runtime, root) => { + const originalEntry = root.reflect.store[root[Context.isolate].subprocess]; + const facade = createNativeOutputAdapter(runtime, { ...artifactOptions, stdoutEncoding: 'gbk', stderrEncoding: 'utf16le', errors: 'strict' }); + const handle = facade.spawn(spawnSpec("process.stdout.write(Buffer.from('d6d0cec4','hex'));process.stderr.write(Buffer.from('3dd800de','hex'))")); + const outcome = await handle.done; const quiet = await handle.waitForExit(AbortSignal.timeout(2000)); + assert.equal(outcome.exitCode, 0); assert.equal(quiet, true); + assert.equal(handle.collected.stdout.readFrom(0).text, '中文'); + assert.equal(handle.collected.stderr.readFrom(0).text, '😀'); + // Cordis can bind a fresh method/service proxy on each get: compare the + // registered service entry, not transient bound-function reference identity. + assert.equal(root.reflect.store[root[Context.isolate].subprocess], originalEntry); + assert.equal(facade.provider, runtime); + assert.equal(handle.outputFacts().stdout.complete, true); + const beforeCleanup = handle.outputFacts(); + handle.terminate(); + assert.deepEqual(handle.outputFacts(), beforeCleanup, 'normal post-settlement cleanup must not mark output aborted'); + record('actual installed Core adapter normal outcome', { outcome, rangeQuiet: quiet, facts: handle.outputFacts() }); +})); + +test('installed provider successful side effect sentinel survives strict decoder failure', async () => withRuntime(async runtime => { + const facade = createNativeOutputAdapter(runtime, { ...artifactOptions, errors: 'strict' }); + const handle = facade.spawn(spawnSpec("process.stderr.write('FIXED_SIDE_EFFECT_SENTINEL');process.stdout.write(Buffer.from([255]));process.exitCode=0")); + const outcome = await handle.done; const quiet = await handle.waitForExit(AbortSignal.timeout(2000)); + assert.equal(outcome.exitCode, 0); assert.equal(quiet, true); + assert.equal(handle.collected.stderr.readFrom(0).text, 'FIXED_SIDE_EFFECT_SENTINEL'); + assert.ok(handle.outputFacts().stdout.decodeError); + assert.equal(handle.outputFacts().stdout.artifact.raw.complete, true); + record('actual successful process + independent decodeError (no retry)', { outcome, rangeQuiet: quiet, facts: handle.outputFacts() }); +})); + +test('installed managed cancellation is forwarded; output settles and process range goes quiet', async () => withRuntime(async runtime => { + const abort = new AbortController(); + const facade = createNativeOutputAdapter(runtime, { ...artifactOptions, drainTimeoutMs: 200 }); + const spec = spawnSpec("process.stdout.write('READY');setTimeout(()=>process.stdout.write('LATE'),2000);setInterval(()=>{},1000)", abort.signal); + spec.stdio.stdout.maxBytes = 1024; + const handle = facade.spawn(spec); + let cancelledAfterReady = false; + const poll = setInterval(() => { + if (handle.collected.stdout.readFrom(0).text.includes('READY')) { cancelledAfterReady = true; abort.abort(new Error('controlled caller cancellation')); clearInterval(poll); } + }, 5); + const safety = setTimeout(() => { abort.abort(); handle.terminate(); }, 3000); + try { + const outcome = await handle.done; const quiet = await handle.waitForExit(AbortSignal.timeout(2000)); + assert.equal(cancelledAfterReady, true); assert.equal(quiet, true); + const text = handle.collected.stdout.readFrom(0).text; + assert.ok(text.includes('READY')); assert.ok(!text.includes('LATE')); + assert.equal(handle.outputFacts().stdout.aborted, true); + assert.equal(handle.outputFacts().stdout.complete, false); + record('actual installed Core cancellation (not Jobs/promotion E2E)', { outcome, rangeQuiet: quiet, facts: handle.outputFacts() }); + } finally { clearInterval(poll); clearTimeout(safety); } +})); + +test('installed direct terminate without signal abort marks captured artifacts incomplete', { timeout: 5000 }, async () => withRuntime(async runtime => { + const abort = new AbortController(); + const facade = createNativeOutputAdapter(runtime, { ...artifactOptions, drainTimeoutMs: 200 }); + const spec = spawnSpec("process.stdout.write('READY');setTimeout(()=>process.stdout.write('LATE'),2000);setInterval(()=>{},1000)", abort.signal); + spec.stdio.stdout.maxBytes = 1024; + const handle = facade.spawn(spec); + let terminatedAfterReady = false; + const poll = setInterval(() => { + if (handle.collected.stdout.readFrom(0).text.includes('READY')) { + terminatedAfterReady = true; handle.terminate(); clearInterval(poll); + } + }, 5); + const safety = setTimeout(() => handle.terminate(), 3000); + try { + const outcome = await handle.done; const quiet = await handle.waitForExit(AbortSignal.timeout(1000)); + assert.equal(terminatedAfterReady, true); assert.equal(abort.signal.aborted, false); assert.equal(quiet, true); + const facts = handle.outputFacts(); + assert.equal(facts.stdout.aborted, true); assert.equal(facts.stdout.complete, false); + assert.equal(facts.stdout.artifact.raw.complete, false); assert.equal(facts.stdout.artifact.decoded.complete, false); + assert.equal(handle.collected.stdout.readFrom(0).spillPath, undefined); + const text = handle.collected.stdout.readFrom(0).text; + assert.ok(text.includes('READY')); assert.ok(!text.includes('LATE')); + handle.terminate(); assert.deepEqual(handle.outputFacts(), facts); + record('actual installed Core direct terminate, external signal remains un-aborted', { outcome, rangeQuiet: quiet, facts }); + } finally { clearInterval(poll); clearTimeout(safety); } +})); + +test('write structured preview observations inside authorized evidence directory', () => { + fs.writeFileSync(path.join(directory, 'preview-observations.json'), JSON.stringify({ node: process.version, scope: 'isolated installed Core rawPipe/provider plus exported collector; NOT production Jobs/ToolRuntime/E2E', observations }, null, 2) + '\n'); +}); diff --git a/tests/native-output.mjs b/tests/native-output.mjs new file mode 100644 index 0000000..03303ce --- /dev/null +++ b/tests/native-output.mjs @@ -0,0 +1,237 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PassThrough } from 'node:stream'; +import { createNativeOutputAdapter } from '../src/native-output.js'; + +// Contract fixture only. The preview suite separately injects the installed Core +// OutputCollector and actual managed provider. These tests are NOT Jobs/E2E. +class MemoryCollector { + constructor(cap) { this.cap = cap; this.bytes = Buffer.alloc(0); this.total = 0; } + push(bytes) { this.total += bytes.length; this.bytes = Buffer.concat([this.bytes, bytes]).subarray(-this.cap || Infinity); } + snapshot() { return { bytes: Buffer.from(this.bytes), totalBytes: this.total }; } + seal() {} +} +function harness(options = {}, disposition = { maxBytes: 1024 }) { + let resolve, reject; + const outcome = { exitCode: 17, signal: null }; + const real = { + stdout: new PassThrough(), stderr: new PassThrough(), stdin: {}, control: {}, collected: {}, + done: new Promise((yes, no) => { resolve = yes; reject = no; }), + terminate() { assert.equal(this, real); this.terminated = true; }, + waitForExit(signal) { assert.equal(this, real); return Promise.resolve(signal); }, + }; + const provider = { calls: [], spawn(spec) { assert.equal(this, provider); this.calls.push(spec); return real; } }; + const facade = createNativeOutputAdapter(provider, { OutputCollector: MemoryCollector, drainTimeoutMs: 10, ...options }); + const spec = { argv: ['fixed-producer'], cwd: '.', graceMs: 10, stdio: { stdin: 'ignore', stdout: disposition, stderr: { maxBytes: 1024 } }, signal: options.signal }; + const handle = facade.spawn(spec); + return { provider, facade, real, handle, spec, outcome, resolve: () => resolve(outcome), reject, + async finish() { real.stdout.end(); real.stderr.end(); resolve(outcome); return handle.done; } }; +} + +for (const [label, hex, expected] of [ + ['utf8', 'e4b8adf09f9880', '中😀'], ['gbk', 'd6d0cec4', '中文'], ['utf16le', '2d4e3dd800de', '中😀'], +]) test(`${label}: byte-by-byte ingest and independent normalized cursors`, async () => { + const h = harness({ stdoutEncoding: label }); + const bytes = Buffer.from(hex, 'hex'); + h.real.stdout.write(bytes.subarray(0, 1)); + assert.deepEqual(h.handle.collected.stdout.readFrom(0), { text: '', nextOffset: 0, lossy: false }); + for (const byte of bytes.subarray(1)) h.real.stdout.write(Buffer.from([byte])); + const a = h.handle.collected.stdout.readFrom(0), b = h.handle.collected.stdout.readFrom(0); + assert.deepEqual(a, b); + assert.equal(a.text, expected); + assert.equal(a.nextOffset, Buffer.byteLength(expected)); + assert.equal(h.handle.outputFacts().stdout.rawCursor, bytes.length); + assert.equal(await h.finish(), h.outcome); + assert.equal(h.handle.outputFacts().stdout.complete, true); +}); + +test('default replacement and split BOM match Buffer.toString; genuine U+FFFD is not a decode error', async () => { + for (const hex of ['efbbbfe4b8ad', 'efbfbd', 'e4', 'ff61', 'f09f98', 'eda080']) { + const h = harness(); const bytes = Buffer.from(hex, 'hex'); + for (const byte of bytes) h.real.stdout.write(Buffer.from([byte])); + await h.finish(); + assert.equal(h.handle.collected.stdout.readFrom(0).text, bytes.toString('utf8'), hex); + if (hex === 'efbfbd') assert.equal(h.handle.outputFacts().stdout.decodeError, undefined); + else if (!hex.startsWith('efbbbf')) assert.ok(h.handle.outputFacts().stdout.decodeError); + } +}); + +test('explicit UTF16LE preserves BOM and flushes split surrogate EOF', async () => { + const h = harness({ stdoutEncoding: 'utf16le' }); + for (const b of Buffer.from('fffe3dd800de', 'hex')) h.real.stdout.write(Buffer.from([b])); + await h.finish(); assert.equal(h.handle.collected.stdout.readFrom(0).text, '\ufeff😀'); +}); + +test('strict invalid bytes/EOF keep actual successful or nonzero process outcome', async () => { + for (const hex of ['ff', 'e4']) { + const h = harness({ errors: 'strict' }); + h.real.stdout.write(Buffer.from(hex, 'hex')); + h.real.stdout.write(Buffer.alloc(4096, 0xff)); // drain after decode failure + assert.equal(await h.finish(), h.outcome); + const facts = h.handle.outputFacts().stdout; + assert.ok(facts.decodeError); assert.equal(facts.complete, false); + assert.equal(facts.rawBytes, 4097); assert.equal(facts.reliableText, false); + } + const h = harness({ errors: 'strict' }); h.real.stdout.write(Buffer.from([0xe4])); await h.finish(); + assert.ok(h.handle.outputFacts().stdout.decodeError); +}); + +for (const cap of [0, 1, 3, 4, 5]) test(`tail cap ${cap} discards whole emoji rather than inventing replacement`, async () => { + const h = harness({}, { maxBytes: cap }); h.real.stdout.write(Buffer.from('A中😀')); + await h.finish(); const read = h.handle.collected.stdout.readFrom(0); + assert.equal(read.text, cap >= 4 ? '😀' : ''); + assert.equal(read.nextOffset, 8); assert.equal(read.lossy, true); + assert.ok(!read.text.includes('\ufffd')); +}); + +test('reader aligns arbitrary middle-of-scalar cursor and rejects invalid coordinates', async () => { + const h = harness(); h.real.stdout.write(Buffer.from('中😀Z')); await h.finish(); + assert.deepEqual(h.handle.collected.stdout.readFrom(1), { text: '😀Z', nextOffset: 8, lossy: true }); + for (const cursor of [-1, 0.5, Infinity, 9]) assert.throws(() => h.handle.collected.stdout.readFrom(cursor), RangeError); +}); + +test('facade preserves provider receiver/stdio/control/outcome; configuration is immutable per operation', async () => { + const opts = { stdoutEncoding: 'gbk' }; const h = harness(opts); opts.stdoutEncoding = 'utf8'; + assert.equal(h.facade.provider, h.provider); + assert.equal(h.spec.stdio.stdout.maxBytes, 1024); assert.equal(h.provider.calls[0].stdio.stdout, 'pipe'); + assert.equal(h.handle.stdin, h.real.stdin); assert.equal(h.handle.control, h.real.control); + h.handle.terminate(); assert.equal(h.real.terminated, true); + const signal = new AbortController().signal; assert.equal(await h.handle.waitForExit(signal), signal); + h.real.stdout.write(Buffer.from('d6d0', 'hex')); await h.finish(); + assert.equal(h.handle.collected.stdout.readFrom(0).text, '中'); + const facts = h.handle.outputFacts(); assert.ok(Object.isFrozen(facts.stdout)); + assert.throws(() => { facts.stdout.rawBytes = 0; }, TypeError); +}); + +test('concurrent streams and operations never share decoder state', async () => { + const a = harness({ stdoutEncoding: 'gbk' }), b = harness({ stdoutEncoding: 'utf16le' }); + a.real.stdout.write(Buffer.from('d6', 'hex')); b.real.stdout.write(Buffer.from('2d', 'hex')); + a.real.stderr.write(Buffer.from('😀')); b.real.stderr.write(Buffer.from('中')); + a.real.stdout.write(Buffer.from('d0', 'hex')); b.real.stdout.write(Buffer.from('4e', 'hex')); + await Promise.all([a.finish(), b.finish()]); + for (const h of [a, b]) assert.equal(h.handle.collected.stdout.readFrom(0).text, '中'); + assert.equal(a.handle.collected.stderr.readFrom(0).text, '😀'); + assert.equal(b.handle.collected.stderr.readFrom(0).text, '中'); +}); + +test('unknown/raw/inherit stdio modes are not rewritten or consumed', async () => { + for (const mode of ['pipe', 'inherit', { kind: 'future', maxBytes: 4 }, { maxBytes: 4, future: true }]) { + const h = harness({}, mode); assert.equal(h.provider.calls[0].stdio.stdout, mode); + assert.equal(h.handle.stdout, h.real.stdout); await h.finish(); + } + const provider = { spawn: spec => spec }; const f = createNativeOutputAdapter(provider, { OutputCollector: MemoryCollector }); + const spec = { stdio: { stdout: 'pipe', stderr: 'inherit' } }; assert.equal(f.spawn(spec), spec); +}); + +for (const reason of ['error', 'close', 'exit-timeout', 'abort']) test(`${reason}: bounded output settlement, no lost process exit`, async () => { + const abort = new AbortController(); const h = harness({ signal: abort.signal }); + h.real.stdout.write(Buffer.from([0xe4])); + if (reason === 'error') h.real.stdout.emit('error', new Error('controlled stream fault')); + if (reason === 'close') h.real.stdout.destroy(); + if (reason === 'abort') abort.abort(); + h.real.stderr.end(); h.resolve(); + assert.equal(await h.handle.done, h.outcome); + assert.equal(h.handle.outputFacts().stdout.settled, true); + assert.equal(h.handle.outputFacts().stdout.sourceComplete, false); + assert.ok(h.handle.outputFacts().stdout.decodeError); + assert.equal(h.real.stdout.destroyed, true); +}); + +test('abort seals output without waiting for a never-settling provider, done still belongs to provider', async () => { + const abort = new AbortController(); const h = harness({ signal: abort.signal }); abort.abort(); + await h.handle.outputSettled; assert.equal(h.handle.outputFacts().stderr.termination, 'abort-drain-timeout'); + h.resolve(); assert.equal(await h.handle.done, h.outcome); +}); + +test('provider rejection retains original error identity after bounded drain', async () => { + const h = harness(); const error = new Error('real provider failed'); h.reject(error); + await assert.rejects(h.handle.done, value => value === error); + assert.equal(h.handle.outputFacts().stdout.settled, true); +}); + +test('collector push/read/seal errors are facts, never provider failures', async () => { + for (const method of ['push', 'snapshot', 'seal']) { + class Broken extends MemoryCollector { [method]() { throw new Error(`controlled ${method}`); } } + const h = harness({ OutputCollector: Broken }); h.real.stdout.write(Buffer.from('fixed')); + assert.equal(await h.finish(), h.outcome); + assert.equal(h.handle.collected.stdout.readFrom(0).lossy, true); + assert.ok(h.handle.outputFacts().stdout.storageError); + } +}); + +test('raw mode does not manufacture trustworthy text or decode guesses', async () => { + const h = harness({ stdoutEncoding: 'raw' }); h.real.stdout.write(Buffer.from('d6d0ff', 'hex')); await h.finish(); + assert.deepEqual(h.handle.collected.stdout.readFrom(0), { text: '', nextOffset: 0, lossy: true }); + const f = h.handle.outputFacts().stdout; assert.equal(f.rawBytes, 3); assert.equal(f.reliableText, false); assert.equal(f.complete, false); +}); + +test('budget bounds retained copies even for giant input; zero implicit artifact writes', async () => { + const h = harness({ maxTotalBytes: 1024, maxSpillBytes: 1024 }, { maxBytes: 1_000_000, spill: { maxBytes: 1_000_000 } }); + h.real.stdout.write(Buffer.alloc(100_000, 65)); await h.finish(); const f = h.handle.outputFacts(); + assert.equal(f.stdout.rawBytes, 100_000); assert.ok(f.stdout.retainedMaxBytes <= 8); + assert.ok(f.budget.reservedMemoryBytes + f.budget.artifactBytes <= 1024); + assert.deepEqual(f.stdout.artifact, {}); assert.equal(f.stdout.lossy, true); +}); + +test('late stream error during bounded shutdown is absorbed, EOF flush runs once', async () => { + const h = harness(); h.real.stdout.write(Buffer.from([0xe4])); + h.real.stdout.emit('error', new Error('first error')); + h.real.stdout.emit('error', new Error('late error before close')); + await h.finish(); + assert.equal(h.handle.collected.stdout.readFrom(0).text, '\ufffd'); + assert.equal(h.handle.outputFacts().stdout.streamError.message, 'first error'); + assert.equal(h.real.stdout.listenerCount('data'), 0); +}); + +test('pre-aborted signal and missing provider pipe settle without losing outcome', async () => { + const abort = new AbortController(); abort.abort(); + const h = harness({ signal: abort.signal }); h.resolve(); + assert.equal(await h.handle.done, h.outcome); assert.equal(h.handle.outputFacts().stdout.aborted, true); + const outcome = { exitCode: 0, signal: null }; + const facade = createNativeOutputAdapter({ spawn: () => ({ done: Promise.resolve(outcome), collected: {} }) }, { OutputCollector: MemoryCollector }); + const handle = facade.spawn({ stdio: { stdout: { maxBytes: 4 }, stderr: 'inherit' } }); + assert.equal(await handle.done, outcome); assert.equal(handle.outputFacts().stdout.termination, 'missing-pipe'); +}); + +test('direct terminate marks unfinished output aborted with bounded drain, but completed cleanup is inert', { timeout: 1000 }, async () => { + for (const state of ['pipes-open', 'pipes-ended', 'complete']) { + const h = harness(); h.real.stdout.write(Buffer.from('READY')); + if (state === 'pipes-ended') { h.real.stdout.end(); h.real.stderr.end(); await h.handle.outputSettled; } + if (state === 'complete') await h.finish(); + const before = h.handle.outputFacts(); + h.handle.terminate(); h.handle.terminate(); + assert.equal(h.real.terminated, true); + if (state === 'complete') { + assert.deepEqual(h.handle.outputFacts(), before); + assert.equal(h.handle.outputFacts().stdout.complete, true); + } else { + assert.equal(h.handle.outputFacts().stdout.aborted, true); + assert.equal(h.handle.outputFacts().stdout.complete, false); + await h.handle.outputSettled; // provider is deliberately not resolved yet + if (state === 'pipes-open') assert.equal(h.handle.outputFacts().stdout.termination, 'terminate-drain-timeout'); + h.resolve(); assert.equal(await h.handle.done, h.outcome); + } + } +}); + +test('synchronous spawn failure retains identity; no implicit storage preparation', () => { + const error = new Error('controlled spawn failure'); + const facade = createNativeOutputAdapter({ spawn() { throw error; } }, { OutputCollector: MemoryCollector }); + assert.throws(() => facade.spawn({ stdio: { stdout: { maxBytes: 4 }, stderr: 'inherit' } }), value => value === error); +}); + +test('actual-host encoding capability is checked before any spawn', () => { + const Original = globalThis.TextDecoder; let calls = 0; + globalThis.TextDecoder = class extends Original { constructor(label, options) { if (label === 'gbk') throw new Error('simulated small-ICU unsupported encoding'); super(label, options); } }; + try { assert.throws(() => createNativeOutputAdapter({ spawn() { calls++; } }, { OutputCollector: MemoryCollector, stdoutEncoding: 'gbk' }), /small-ICU/); } + finally { globalThis.TextDecoder = Original; } + assert.equal(calls, 0); +}); + +test('capability/options/policy checks run before provider spawn', () => { + let calls = 0; const p = { spawn() { calls++; } }; + for (const options of [{ stdoutEncoding: 'auto' }, { stdoutEncoding: 'no-such-encoding' }, { errors: 'ignore' }, { maxTotalBytes: 1 }, { drainTimeoutMs: Infinity }, { artifactDirectory: '/not-authorized' }]) { + assert.throws(() => createNativeOutputAdapter(p, { OutputCollector: MemoryCollector, ...options })); + } + assert.equal(calls, 0); +}); diff --git a/tests/native-preview.mjs b/tests/native-preview.mjs new file mode 100644 index 0000000..e455f16 --- /dev/null +++ b/tests/native-preview.mjs @@ -0,0 +1,392 @@ +// 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' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { Context } from '@deepseek-ai/cordis' +import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' +import * as toolsModule from '@deepseek-ai/dsh-tools' +import * as sandboxModule from '@deepseek-ai/dsh-sandbox' +import * as localBashModule from '@deepseek-ai/dsh-bash-local' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' +import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output' +import { LocalJobRegistry } from '@deepseek-ai/dsh-jobs-local' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import * as shellEnv from '@deepseek-ai/dsh-shell-env' +import { createNativeOutputAdapter } from '../src/native-output.js' +import { createNativeExecutor } from '../src/native-executor.js' +import { createNativeToolModule, NATIVE_OUTPUT_FACTS_SCHEMA, NATIVE_OUTPUT_SCHEMA } from '../src/native-tool.js' + +const EVIDENCE = process.env.DSH_NATIVE_EVIDENCE_DIR +if (!EVIDENCE || !path.isAbsolute(EVIDENCE)) throw new Error('Set DSH_NATIVE_EVIDENCE_DIR to an authorized absolute scratch directory') +const DIR = path.join(EVIDENCE, `preview-${randomUUID()}`) +fs.mkdirSync(DIR, { recursive: true }) +const rows = [] +const literal = value => ({ kind: 'literal', value }) +const typedPath = value => ({ kind: 'path', value }) +const request = (code, more = {}) => ({ description: 'Run controlled native Node sentinel', executable: typedPath(process.execPath), entrypoint: 'interpreter', argv: [literal('-e'), literal(code)], ...more }) +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) +function createScope(parent) { + let ctx + const fiber = parent.plugin(value => { ctx = value }) + return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } +} +async function fixture(t, { jobs = false, promote = false, controller = true, artifactFactory, ensure } = {}) { + const root = new Context() + const disposers = [] + const spawned = [] + const scopes = [] + let mode = 'danger-full-access' + let adapterConstructions = 0 + const policy = { defaultMode: 'danger-full-access', resolve: () => mode === undefined ? undefined : { mode, workspaceRoot: DIR } } + const official = { name: 'official-preserved' } + let agent + for (const [name, value] of Object.entries({ shell: official, sandboxPolicy: policy, + workingDirectory: { ensure: ensure ?? (async () => DIR) }, agents: { get: id => agent?.id === id ? agent : undefined } })) disposers.push(root.provide(name, value)) + const mount = async (module, config) => { + const fiber = root.plugin(module, config) + disposers.push(() => fiber.dispose()) + await fiber + return fiber + } + for (const module of [LocalSubprocessRuntime, SystemPrompt, toolsModule.ToolRuntime, 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] + const rootShell = root.reflect.store[root[Context.isolate].shell] + const agentId = `native-preview-${randomUUID()}` + agent = { id: agentId, session: { header: { id: agentId } } } + const agentScope = createAgentScope(root, agent) + agent.ctx = agentScope.ctx + disposers.push(() => agentScope.dispose()) + if (jobs) { + await mount(LocalJobRegistry, { pumpPollMs: 5 }) + if (controller) await mount({ inject: ['jobs'], apply(ctx) { ctx.jobs.attachController('native-preview-controller') } }) + } + const deps = { toolsModule, sandboxModule, localBashModule, + createScope(parent) { const scope = createScope(parent); scopes.push(scope); return scope }, + translateMsysPath: value => /^\/[a-z]\//i.test(value) ? `${value[1].toUpperCase()}:/${value.slice(3)}` : value, + outputOptions: { OutputCollector, ...(artifactFactory ? {} : { artifactDirectory: DIR }), drainTimeoutMs: 200 }, + ...(artifactFactory ? { createArtifactDirectory: artifactFactory } : {}), + createNativeOutputAdapter(provider, options) { + adapterConstructions++ + const adapter = createNativeOutputAdapter(provider, options) + return { spawn(spec) { const handle = adapter.spawn(spec); spawned.push({ spec, handle }); return handle } } + }, + } + const toolFiber = agent.ctx.plugin(createNativeToolModule(deps), { promoteOnTimeout: promote }) + 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 }) + t.after(async () => { + for (const dispose of disposers.reverse()) await dispose() + assert.equal(root.reflect.store[root[Context.isolate].subprocess], undefined) + }) + const directExecutor = createNativeExecutor({ get: key => root.get(key), workingDirectory: root.get('workingDirectory'), shellEnv: root.get('shellEnv') }, deps) + const direct = async args => { + const exec = { agent, signal: new AbortController().signal } + return directExecutor.execute(await directExecutor.prepare(args, exec), exec) + } + return { root, agent, agentScope, toolFiber, spawned, scopes, run, direct, + jobs: root.get('jobs'), setMode(value) { mode = value }, get adapterConstructions() { return adapterConstructions }, + preserved() { assert.equal(root.reflect.store[root[Context.isolate].subprocess], rootProvider); assert.equal(root.reflect.store[root[Context.isolate].shell], rootShell) }, + } +} + +async function waitFor(check, label) { + for (let i = 0; i < 300; i++) { if (check()) return; await delay(10) } + throw new Error(`timed out waiting for ${label}`) +} + +test('installed Core native: closed output schemas reject malformed facts and explicit degradation branches', () => { + const facts = toolsModule.valueSchemaSpecToJsonSchema(NATIVE_OUTPUT_FACTS_SCHEMA) + const validate = value => toolsModule.validateJsonSchemaValue(facts, value) + assert.deepEqual(validate({ unavailable: true }), []) + assert.deepEqual(validate({ projectionError: 'fixed projection sentinel', reliableText: false }), []) + for (const value of [{}, { arbitrary: 'not allowed' }, { unavailable: false }, { unavailable: true, extra: 1 }, + { projectionError: 'fixed', reliableText: true }, { projectionError: 'fixed', reliableText: false, extra: 1 }]) { + assert.ok(validate(value).length > 0, JSON.stringify(value)) + } + const output = toolsModule.valueSchemaSpecToJsonSchema(NATIVE_OUTPUT_SCHEMA) + const background = { kind: 'background', cwd: DIR, jobId: 'fixed-job', encoding: { stdoutEncoding: 'utf8', stderrEncoding: 'gbk', errors: 'strict' } } + assert.deepEqual(toolsModule.validateJsonSchemaValue(output, background), []) + assert.ok(toolsModule.validateJsonSchemaValue(output, { ...background, encoding: { ...background.encoding, extra: true } }).length > 0) + rows.push({ name: 'closed schema validation', status: 'PASS' }) +}) + +test('installed Core native: ToolRuntime exact argv, translated paths, root providers preserved', { timeout: 15000 }, async t => { + const f = await fixture(t) + const values = ['/all', '/c', '/c/SENTINEL', '', '"quoted"', 'tail\\', '中文😀', '&|<>^%FIXED%!fixed!'] + const msys = DIR.replace(/\\/g, '/').replace(/^([A-Z]):/i, (_, drive) => `/${drive.toLowerCase()}`) + '/typed sentinel.txt' + const args = request('console.log(JSON.stringify(process.argv.slice(1)))') + args.argv.push(literal('--'), ...values.map(literal), typedPath(msys)) + const response = await f.run(args) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.exitCode, 0) + assert.deepEqual(JSON.parse(response.value.stdout.text), [...values, path.win32.normalize(`${DIR}/typed sentinel.txt`)]) + assert.equal(f.spawned[0].spec.argv[0], path.win32.normalize(process.execPath)) + assert.equal(f.spawned[0].spec.cwd, path.win32.normalize(DIR)) + assert.equal(response.value.outputFacts.stdout.encoding, 'utf-8') + assert.equal(response.value.status, 'completed') + assert.equal(response.value.aborted, false) + assert.equal(response.value.outputFacts.stdout.aborted, false) + assert.equal(response.value.outputFacts.stdout.complete, true) + const outputSchema = toolsModule.valueSchemaSpecToJsonSchema(NATIVE_OUTPUT_SCHEMA) + for (const locate of [value => value.sandbox, value => value.outputFacts.budget, value => value.outputFacts.stdout, + value => value.outputFacts.stdout.artifact, value => value.outputFacts.stdout.artifact.raw]) { + const malformed = structuredClone(response.value) + locate(malformed).undeclaredFact = 'fixed sentinel' + assert.ok(toolsModule.validateJsonSchemaValue(outputSchema, malformed).length > 0) + } + const malformedBudget = structuredClone(response.value) + malformedBudget.outputFacts.budget.artifactBytes = 'not-a-number' + assert.ok(toolsModule.validateJsonSchemaValue(outputSchema, malformedBudget).length > 0) + assert.ok(f.scopes.every(scope => scope.ctx.fiber.state === 4)) + f.preserved() + rows.push({ name: t.name, status: 'PASS', producerExitCode: response.value.exitCode, outputFacts: response.value.outputFacts }) +}) + +test('installed Core native: typed path actually opens the fixed sentinel file', { timeout: 15000 }, async t => { + const f = await fixture(t) + const filename = path.join(DIR, 'typed path 中文.txt') + fs.writeFileSync(filename, 'FIXED_TYPED_PATH_SENTINEL', 'utf8') + const msys = filename.replace(/\\/g, '/').replace(/^([A-Z]):/i, (_, drive) => `/${drive.toLowerCase()}`) + const args = request('console.log(require("node:fs").readFileSync(process.argv[1],"utf8"))') + args.argv.push(literal('--'), typedPath(msys)) + const response = await f.run(args) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.exitCode, 0) + assert.equal(response.value.stdout.text.trim(), 'FIXED_TYPED_PATH_SENTINEL') + rows.push({ name: t.name, status: 'PASS', producerExitCode: 0 }) +}) + +test('installed Core native: each actual standing policy fails before adapter/scope/spawn', { timeout: 15000 }, async t => { + const f = await fixture(t) + for (const mode of ['read-only', 'workspace-write', undefined]) { + f.setMode(mode) + const response = await f.run(request('console.log("MUST_NOT_RUN")')) + assert.equal(response.isError, true) + assert.match(JSON.stringify(response.error), /SANDBOX_UNAVAILABLE/) + } + assert.equal(f.spawned.length, 0) + assert.equal(f.scopes.length, 0) + assert.equal(f.adapterConstructions, 0) + f.preserved() + rows.push({ name: t.name, status: 'PASS', spawnCount: 0, adapterCount: 0 }) +}) + +test('installed Core native: independent encoding and strict error preserve real producer exit', { timeout: 15000 }, async t => { + const f = await fixture(t) + const response = await f.run(request('process.stdout.write(Buffer.from([0xd6,0xd0,0xce,0xc4]));process.stderr.write("UTF8:中文😀")', { stdoutEncoding: 'gbk', errors: 'strict' })) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.stdout.text, '中文') + assert.equal(response.value.stderr.text, 'UTF8:中文😀') + const invalid = await f.run(request('process.stdout.write(Buffer.from([0xff]));process.exitCode=7', { errors: 'strict' })) + assert.equal(invalid.isError, false, JSON.stringify(invalid)) + assert.equal(invalid.value.exitCode, 7) + assert.ok(invalid.value.outputFacts.stdout.decodeError) + f.preserved() + rows.push({ name: t.name, status: 'PASS', invalidProducerExitCode: 7, outputFacts: invalid.value.outputFacts }) +}) + +test('installed Core native: concurrent encodings do not cross calls and raw preserves bytes', { timeout: 15000 }, async t => { + const f = await fixture(t) + const calls = await Promise.all([ + f.run(request('setTimeout(()=>process.stdout.write(Buffer.from([0xd6,0xd0])),70)', { stdoutEncoding: 'gbk', errors: 'strict' })), + f.run(request('setTimeout(()=>process.stdout.write("UTF8:😀"),20)', { errors: 'strict' })), + ]) + for (const response of calls) assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(calls[0].value.stdout.text, '中') + assert.equal(calls[1].value.stdout.text, 'UTF8:😀') + const raw = await f.run(request('process.stdout.write(Buffer.from([0xff,0x00,0xd6,0xd0]))', { stdoutEncoding: 'raw' })) + assert.equal(raw.isError, false, JSON.stringify(raw)) + assert.equal(raw.value.stdout.text, '') + assert.equal(raw.value.outputFacts.stdout.reliableText, false) + assert.deepEqual(fs.readFileSync(raw.value.outputFacts.stdout.artifact.raw.path), Buffer.from([0xff, 0x00, 0xd6, 0xd0])) + rows.push({ name: t.name, status: 'PASS', rawProducerExitCode: raw.value.exitCode }) +}) + +test('installed Core native: malformed input and pre-cancelled calls cannot spawn', { timeout: 15000 }, async t => { + const f = await fixture(t) + const malformed = [ + request('console.log("MUST_NOT_RUN")', { env: { FIXED: 'SENTINEL' } }), + request('console.log("MUST_NOT_RUN")', { entrypoint: 'native' }), + request('console.log("MUST_NOT_RUN")', { executable: typedPath(path.join(DIR, 'script.cmd')) }), + request('console.log("MUST_NOT_RUN")', { stdoutEncoding: 'auto' }), + request('console.log("MUST_NOT_RUN")', { cwd: typedPath('/unmapped/sentinel') }), + ] + for (const args of malformed) assert.equal((await f.run(args)).isError, true) + const aborted = new AbortController() + aborted.abort(new Error('fixed pre-cancel sentinel')) + assert.equal((await f.run(request('console.log("MUST_NOT_RUN")'), aborted.signal)).isError, true) + assert.equal(f.spawned.length, 0) + assert.equal(f.scopes.length, 0) + assert.equal(f.adapterConstructions, 0) + rows.push({ name: t.name, status: 'PASS', spawnCount: 0 }) +}) + +test('installed Core native: credential scrub with fixed sentinel, no host environment copy', { timeout: 15000 }, async t => { + const f = await fixture(t) + const old = process.env.NATIVE_PREVIEW_SECRET + process.env.NATIVE_PREVIEW_SECRET = 'FIXED_NON_SECRET_SENTINEL' + try { + const response = await f.run(request('console.log(process.env.NATIVE_PREVIEW_SECRET===undefined?"SCRUBBED":"LEAK")')) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.stdout.text.trim(), 'SCRUBBED') + assert.ok(Object.keys(f.spawned[0].spec.env).every(key => /^(DSH_|NO_COLOR$|TERM$|PAGER$|GIT_PAGER$)/.test(key))) + } finally { if (old === undefined) delete process.env.NATIVE_PREVIEW_SECRET; else process.env.NATIVE_PREVIEW_SECRET = old } + rows.push({ name: t.name, status: 'PASS' }) +}) + +test('installed Core native: foreground timeout kills managed producer and releases own scope', { timeout: 15000 }, async t => { + const f = await fixture(t) + const response = await f.run(request('console.log("READY");setTimeout(()=>console.log("LATE"),3000)', { timeoutMs: 400 })) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.timedOut, true) + assert.doesNotMatch(response.value.stdout.text, /LATE/) + assert.equal(await f.spawned[0].handle.waitForExit(AbortSignal.timeout(3000)), true) + assert.ok(f.scopes.every(scope => scope.ctx.fiber.state === 4)) + f.preserved() + rows.push({ name: t.name, status: 'PASS', producerExitCode: response.value.exitCode, timedOut: true }) +}) + +test('installed Core native: caller cancellation terminates range without late sentinel', { timeout: 15000 }, async t => { + const f = await fixture(t) + const abort = new AbortController() + const running = f.run(request('console.log("READY");setTimeout(()=>console.log("LATE"),3000)'), abort.signal) + await waitFor(() => f.spawned[0]?.handle.collected.stdout.readFrom(0).text.includes('READY'), 'READY') + abort.abort(new Error('fixed preview cancellation')) + const response = await running + assert.equal(await f.spawned[0].handle.waitForExit(AbortSignal.timeout(3000)), true) + assert.doesNotMatch(f.spawned[0].handle.collected.stdout.readFrom(0).text, /LATE/) + if (!response.isError) assert.equal(response.value.aborted, true) + f.preserved() + rows.push({ name: t.name, status: 'PASS', toolIsError: response.isError }) +}) + +test('installed Core native: direct kill and owner disposal mark cancellation before producer settlement', { timeout: 15000 }, async t => { + const f = await fixture(t) + const first = await f.direct(request('console.log("READY");setTimeout(()=>console.log("LATE"),3000)')) + await waitFor(() => first.observed.stdout.readFrom(0).text.includes('READY'), 'direct kill READY') + assert.equal(first.kill(), true) + const killed = await first.result() + assert.equal(killed.status, 'killed') + assert.equal(killed.aborted, true) + assert.equal(killed.outputFacts.stdout.aborted, true) + assert.equal(killed.outputFacts.stdout.complete, false) + const second = await f.direct(request('console.log("READY");setTimeout(()=>console.log("LATE"),3000)')) + await waitFor(() => second.observed.stdout.readFrom(0).text.includes('READY'), 'owner dispose READY') + await f.agentScope.dispose() + const disposed = await second.result() + assert.equal(disposed.status, 'killed') + assert.equal(disposed.aborted, true) + assert.equal(disposed.outputFacts.stdout.aborted, true) + assert.equal(disposed.outputFacts.stdout.complete, false) + for (const result of [killed, disposed]) assert.doesNotMatch(result.stdout.text, /LATE/) + for (const { handle } of f.spawned) assert.equal(await handle.waitForExit(AbortSignal.timeout(3000)), true) + rows.push({ name: t.name, status: 'PASS', kill: { status: killed.status, aborted: killed.aborted, complete: killed.outputFacts.stdout.complete }, ownerDispose: { status: disposed.status, aborted: disposed.aborted, complete: disposed.outputFacts.stdout.complete } }) +}) + +test('installed Core native: artifact preparation deadline returns before a late callback and never spawns', { timeout: 15000 }, async t => { + let finish + let preparationSignal + const late = new Promise(resolve => { finish = resolve }) + const f = await fixture(t, { artifactFactory: async (_request, exec) => { preparationSignal = exec.signal; return late } }) + const startedAt = Date.now() + const response = await f.run(request('console.log("MUST_NOT_RUN")', { timeoutMs: 120 })) + assert.equal(response.isError, true) + assert.match(JSON.stringify(response), /preparation timed out/) + assert.ok(Date.now() - startedAt < 1500) + assert.equal(preparationSignal.aborted, true) + assert.equal(f.spawned.length, 0) + assert.ok(f.scopes.every(scope => scope.ctx.fiber.state === 4)) + finish(DIR) + await delay(50) + assert.equal(f.spawned.length, 0, 'late callback cannot resume the cancelled execution') + assert.ok(fs.statSync(DIR).isDirectory(), 'late directory remains with its trusted retention owner') + rows.push({ name: t.name, status: 'PASS', spawnCount: 0 }) +}) + +test('installed Core native: workspace preparation deadline is bounded before execution scope', { timeout: 15000 }, async t => { + const f = await fixture(t, { ensure: () => new Promise(() => {}) }) + const startedAt = Date.now() + const response = await f.run(request('console.log("MUST_NOT_RUN")', { timeoutMs: 60 })) + assert.equal(response.isError, true) + assert.match(JSON.stringify(response), /preparation timed out/) + assert.ok(Date.now() - startedAt < 1500) + assert.equal(f.spawned.length, 0) + assert.equal(f.scopes.length, 0) + rows.push({ name: t.name, status: 'PASS', spawnCount: 0 }) +}) + +test('installed Core native: Jobs background, consuming reads and final encoding facts', { timeout: 15000 }, async t => { + const f = await fixture(t, { jobs: true }) + const response = await f.run(request('console.log("READY");setTimeout(()=>console.log("LATE"),350)', { run_in_background: true })) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.kind, 'background') + const id = response.value.jobId + await waitFor(() => f.spawned.length > 0, 'job spawn') + assert.notEqual(f.scopes[0].ctx.fiber.state, 4, 'background handoff does not dispose execution scope') + const first = f.jobs.read(id, f.agent.id) + const view = await f.jobs.wait(id, 5000, f.agent.id) + assert.equal(view.status, 'completed') + const second = f.jobs.read(id, f.agent.id) + const text = [...first.chunks, ...second.chunks].map(chunk => chunk.text).join('') + assert.match(text, /READY/) + assert.match(text, /LATE/) + assert.match(text, /native output facts/) + assert.equal(f.jobs.read(id, f.agent.id).chunks.length, 0) + assert.throws(() => f.jobs.read(id, 'another-owner'), /another session/) + f.preserved() + rows.push({ name: t.name, status: 'PASS', jobId: id, statusAfterWait: view.status }) +}) + +test('installed Core native: promotion continues; tool fiber reload does not kill handed-off job', { timeout: 15000 }, async t => { + const f = await fixture(t, { jobs: true, promote: true }) + const response = await f.run(request('console.log("READY");setTimeout(()=>console.log("AFTER_PROMOTION"),900)', { timeoutMs: 350 })) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.kind, 'promoted') + await f.toolFiber.dispose() + const view = await f.jobs.wait(response.value.jobId, 5000, f.agent.id) + assert.equal(view.status, 'completed') + assert.match(f.jobs.read(response.value.jobId, f.agent.id).chunks.map(chunk => chunk.text).join(''), /AFTER_PROMOTION/) + f.preserved() + rows.push({ name: t.name, status: 'PASS', jobId: response.value.jobId }) +}) + +test('installed Core native: Jobs controller admission refusal never spawns', { timeout: 15000 }, async t => { + const f = await fixture(t, { jobs: true, controller: false }) + const response = await f.run(request('console.log("MUST_NOT_RUN")', { run_in_background: true })) + assert.equal(response.isError, true) + assert.match(JSON.stringify(response), /no job controller/) + assert.equal(f.spawned.length, 0) + rows.push({ name: t.name, status: 'PASS', spawnCount: 0 }) +}) + +test('installed Core native: job_kill and owner disposal terminate only owned ranges', { timeout: 20000 }, async t => { + const f = await fixture(t, { jobs: true }) + const first = await f.run(request('console.log("READY");setTimeout(()=>console.log("LATE"),5000)', { run_in_background: true })) + assert.equal(first.isError, false, JSON.stringify(first)) + await waitFor(() => f.spawned[0]?.handle.collected.stdout.readFrom(0).text.includes('READY'), 'first READY') + f.jobs.kill(first.value.jobId, f.agent.id, 'fixed preview job_kill') + const killed = await f.jobs.wait(first.value.jobId, 5000, f.agent.id) + assert.equal(killed.status, 'killed') + const second = await f.run(request('console.log("READY");setTimeout(()=>console.log("LATE"),5000)', { run_in_background: true })) + assert.equal(second.isError, false, JSON.stringify(second)) + await waitFor(() => f.spawned[1]?.handle.collected.stdout.readFrom(0).text.includes('READY'), 'second READY') + await f.agentScope.dispose() + for (const { handle } of f.spawned) { + assert.equal(await handle.waitForExit(AbortSignal.timeout(3000)), true) + assert.doesNotMatch(handle.collected.stdout.readFrom(0).text, /LATE/) + } + f.preserved() + rows.push({ name: t.name, status: 'PASS', killStatus: killed.status }) +}) + +process.on('exit', code => { + fs.writeFileSync(path.join(DIR, 'results.json'), JSON.stringify({ exitCode: code, node: process.version, core: 'installed host via --import loader', rows }, null, 2)) + console.log(`Native preview evidence: ${DIR}`) +}) diff --git a/tests/path-preview.mjs b/tests/path-preview.mjs new file mode 100644 index 0000000..54c3690 --- /dev/null +++ b/tests/path-preview.mjs @@ -0,0 +1,216 @@ +// 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 +// child doing real node:fs reads. The new module is REGISTERED through +// createPathToolModule (no mock capture, no inline source slicing), the model +// programs read ONLY the single owned input file created in this run's unique +// evidence directory, and nothing outside that directory is written. +// +// Registry contract (discovered here, locked by these tests): in ToolRuntime +// mode 'ptc' ONLY `run_code` is callable directly — every tool assertion runs +// inside a run_code program through the real PTC sub-dispatch, which is also +// the surface the model actually uses. +// +// Run (working directory = the repo): +// DSH_PREVIEW_HOST_DIR='C:/Program Files/DSH NEXT/resources/app' \ +// node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/path-preview.mjs +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, statSync, writeFileSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import { NodePtcRuntime } from '@deepseek-ai/dsh-ptc-runtime-node' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' + +import * as helpers from '../src/index.js' +import { createPathToolModule, PATH_TOOL_NAME } from '../src/path-tool.js' + +const rootDir = process.env.DSH_PATH_EVIDENCE_DIR +assert.ok(rootDir && isAbsolute(rootDir) && existsSync(rootDir) && statSync(rootDir).isDirectory(), + 'Set DSH_PATH_EVIDENCE_DIR to an existing caller-owned evidence directory, or preload tests/preview-env.mjs') +const dir = mkdtempSync(join(rootDir, 'path-preview-')) +const nativeFile = join(dir, '输入#2.txt').replaceAll('\\', '/') +const msysFile = nativeFile.replace(/^([A-Za-z]):\//, (_, drive) => '/' + drive.toLowerCase() + '/') +const text = 'PATH_PREVIEW_中文_SENTINEL' +writeFileSync(nativeFile, text, { encoding: 'utf8', flag: 'wx' }) + +// Controlled mount facts: the helper is the REAL shared one, only the fact +// bag is a controlled literal (same discipline as the approved research +// fixture — deterministic, no secret-environment reads). The real fresh +// buildTranslateEnv composition gets its own scoped instance below. +const env = { home: dir, tmpDir: join(dir, 'owned-tmp').replaceAll('\\', '/'), gitRoot: 'C:/Program Files/Git' } +const translate = (value) => helpers.translateMsysPath(value, env) + +test('path preview: gitbash_to_native_path on the real engine', async t => { + const cleanup = [] // services + runtime fibers, disposed in finally + const logs = [] + let confinementCalls = 0 + const policy = { mode: 'danger-full-access', workspaceRoot: dir } + const root = new Context() + const mount = async (module, config) => { + const fiber = root.plugin(module, config) + cleanup.push(() => fiber.dispose()) + await fiber + return fiber + } + 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({ + name: 'run_code', callId: 'path-preview-' + name, arguments: original, signal: new AbortController().signal, + }) + // sourceFrozen: the registry must hand the program through untouched. + assert.equal(original.code, code, j(result)) + return result + } + const call = `await tools.${PATH_TOOL_NAME}` + const rows = [] + const pass = (name, detail = {}) => { rows.push({ name, passed: true, ...detail }); console.log('PASS ' + name) } + try { + for (const [name, value] of Object.entries({ + fs: { processPathFromHostPath: value => value }, + sandboxPolicy: { defaultMode: policy.mode, resolve: () => policy }, + sandbox: { async confine() { confinementCalls++; throw new Error('path preview must not change OS confinement') } }, + workingDirectory: { ensure: async () => dir }, + })) cleanup.push(root.provide(name, value)) + + await mount(LocalSubprocessRuntime) + 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' }) + // The module UNDER TEST: real defineTool, real registry, owned scope. + const mainFiber = await mount(createPathToolModule({ + toolsModule: { defineTool }, + translate, + log: (message) => logs.push(message), + })) + + await t.test('converted owned input through the real PTC sub-dispatch', async () => { + const program = `const p=${j(msysFile)}; const r=${call}({msys_path:p}); return { native: r.native_path };` + const result = await runCode('converted-owned-input', program) + assert.ok(!result.isError, j(result)) + assert.equal(result.value.result.native, nativeFile) + pass('converted-owned-input', { native: result.value.result.native }) + }) + + await t.test('unicode / # / spaces preserved through the real schema layer', async () => { + const program = `const r=${call}({msys_path:${j('/c/我的#目录/文件 1.txt')}}); return { native: r.native_path };` + const result = await runCode('unicode-hash-space', program) + assert.ok(!result.isError, j(result)) + assert.equal(result.value.result.native, 'C:/我的#目录/文件 1.txt') + pass('unicode-hash-space-preserved') + }) + + await t.test('already-native and cwd-relative passthrough stay verbatim', async () => { + const program = `return {` + + ` win: (${call}({msys_path:${j('C:\\Users\\x\\y.txt')}})).native_path,` + + ` rel: (${call}({msys_path:${j('docs/file.txt')}})).native_path };` + const result = await runCode('passthrough', program) + assert.ok(!result.isError, j(result)) + assert.deepEqual(result.value.result, { win: 'C:\\Users\\x\\y.txt', rel: 'docs/file.txt' }) + pass('native-and-cwd-relative-passthrough') + }) + + await t.test('registry rejections: empty, NUL, URL, unmapped POSIX mount', async () => { + const cases = ['', 'a\0b', 'https://example.com/x', '/opt/tool'] + const codes = ['EMPTY', 'NUL_BYTE', 'URL', 'NO_MOUNT_MAPPING'] + const program = `const cases=${j(cases)}; const out=[];` + + `for (const value of cases) { try { await ${call}({msys_path:value}); out.push('NO_THROW'); }` + + `catch (e) { out.push(String(e?.message ?? e)) } } return { out };` + const result = await runCode('rejections', program) + assert.ok(!result.isError, j(result)) + result.value.result.out.forEach((message, index) => { + assert.match(message, new RegExp('\\[' + codes[index] + '\\]')) + assert.doesNotMatch(message, /"native_path"/) + }) + pass('registry-rejections') + }) + + await t.test('run_code: explicit full-value helper drives REAL node:fs on the owned input', async () => { + const suffix = msysFile.slice(3) + const program = `const drive=${j(msysFile[1])}; const p='/' + drive + '/' + ${j(suffix)};` + + `const r = ${call}({msys_path:p});` + + `const fs = await import('node:fs/promises');` + + `return { text: await fs.readFile(r.native_path,'utf8'), native: r.native_path, route: '/c/users', tag: String.raw\`/dev/null\` };` + const result = await runCode('explicit-helper-real-fs', program) + assert.ok(!result.isError, j(result)) + assert.equal(result.value.result.text, text) + assert.equal(result.value.result.native, nativeFile) + // Non-path data stays untouched: no global guessing rides along. + assert.equal(result.value.result.route, '/c/users') + assert.equal(result.value.result.tag, '/dev/null') + pass('run-code-explicit-helper-real-fs', { native: result.value.result.native }) + }) + + await t.test('run_code: rejection surfaces as a catchable program error', async () => { + const program = `try { ${call}({msys_path:''}); return 'NO_THROW'; }` + + `catch (e) { return { caught: String(e?.message ?? e) } }` + const result = await runCode('rejection-caught', program) + assert.ok(!result.isError, j(result)) + assert.match(result.value.result.caught, /\[EMPTY\]/) + assert.match(result.value.result.caught, /msys_path must be a non-empty string/) + pass('run-code-rejection-caught') + }) + + await t.test('disposing the owning scope unregisters the tool on the real registry', async () => { + await mainFiber.dispose() + const program = `try { ${call}({msys_path:${j(msysFile)}}); return 'STILL_CALLABLE'; }` + + `catch (e) { return { caught: String(e?.message ?? e) } }` + const result = await runCode('scope-disposal', program) + assert.ok(!result.isError, j(result)) + // Both real unregistration surfaces are accepted: the bridge either + // reports the unknown tool or the dynamic `tools.` binding lost the name. + assert.match(result.value.result.caught, new RegExp('unknown tool "' + PATH_TOOL_NAME + '"|is not a function')) + pass('scope-disposal-unregisters') + }) + + await t.test('second dep shape (raw helper + real fresh buildTranslateEnv) re-mounts cleanly', async () => { + // The name is free again after the scoped disposal above: the raw-helper + // + fresh real buildTranslateEnv composition converts on the real machine. + const fiber = await mount(createPathToolModule({ + toolsModule: { defineTool }, + translateMsysPath: helpers.translateMsysPath, + buildEnv: () => helpers.buildTranslateEnv(''), + log: (message) => logs.push(message), + })) + const program = `const r=${call}({msys_path:${j(msysFile)}}); return { native: r.native_path };` + const result = await runCode('buildenv-dep-shape', program) + assert.ok(!result.isError, j(result)) + assert.equal(result.value.result.native, nativeFile) + await fiber.dispose() + const program2 = `try { ${call}({msys_path:${j(msysFile)}}); return 'STILL_CALLABLE'; }` + + `catch (e) { return { caught: String(e?.message ?? e) } }` + const after = await runCode('buildenv-disposed', program2) + assert.ok(!after.isError, j(after)) + assert.match(after.value.result.caught, /unknown tool|is not a function/) + pass('buildenv-dep-shape-and-scoped-dispose') + }) + + assert.equal(confinementCalls, 0) + assert.ok(logs.length >= 2, 'both registrations logged') + const evidence = { + fixture: 'actual-installed-ToolRuntime-to-NodePtcRuntime-to-LocalSubprocessRuntime; module registered via createPathToolModule; tool asserted through run_code sub-dispatch (mode ptc allows only run_code directly)', + toolName: PATH_TOOL_NAME, + testNode: process.version, + directory: dir, + sourceCaptureOnly: false, + realWorkerAndFsRead: true, + productionGui: false, + fullPluginApply: false, + permissionMode: policy.mode, + sandboxConfineCalls: confinementCalls, + dialectGates: 'none — the tool is independent of posixPaths/codePaths by contract', + rows, + } + writeFileSync(join(dir, 'results.json'), j(evidence) + '\n', { flag: 'wx' }) + assert.equal(rows.length, 8) + console.log(j({ directory: dir, passed: rows.length, evidence: join(dir, 'results.json') })) + } finally { + for (const off of cleanup.reverse()) { + try { await off() } catch { /* a fiber the disposal test already ended */ } + } + } +}) diff --git a/tests/path-tool.mjs b/tests/path-tool.mjs new file mode 100644 index 0000000..f598812 --- /dev/null +++ b/tests/path-tool.mjs @@ -0,0 +1,287 @@ +// Pure unit tests for the explicit path converter (src/path-tool.js) composed +// with the REAL shared helper (src/index.js translateMsysPath). No host Core, +// no registry, no filesystem, no subprocess: the translator here is the +// genuine shared helper, only its mount-fact bag is a controlled literal. +// The real installed-Core engine coverage (ToolRuntime/NodePtcRuntime/ +// LocalSubprocessRuntime) lives in path-preview.mjs (run with host-loader). +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + PATH_TOOL_NAME, + convertMsysToNative, + buildPathToolDefinition, + createPathToolModule, + isWindowsNativeValue, + looksPosixValue, + pathToolDescription, +} from '../src/path-tool.js' +import { translateMsysPath, buildTranslateEnv } from '../src/index.js' + +/** A fully-provisioned controlled mount-fact bag (the shape buildTranslateEnv returns). */ +const FULL_ENV = { + home: 'C:/Users/someone', + tmpDir: 'C:/Users/someone/AppData/Local/Temp', + gitRoot: 'C:/Program Files/Git', +} + +/** The shared helper composed with a fixed env — the wiring's shape, one fact source. */ +const withFullEnv = (value) => translateMsysPath(value, FULL_ENV) + +test('path-tool: composition uses the shared helper, not a copied table', () => { + // Drive roots (no env needed by the shared helper — facts come from IT). + assert.equal(convertMsysToNative('/c/Users/x', { translate: withFullEnv }).native_path, 'C:/Users/x') + assert.equal(convertMsysToNative('/c', { translate: withFullEnv }).native_path, 'C:/') + assert.equal(convertMsysToNative('/D/dir', { translate: withFullEnv }).native_path, 'D:/dir') + // Virtual mounts resolve through the shared helper's own table. + assert.equal( + convertMsysToNative('/tmp/a b/#c.txt', { translate: withFullEnv }).native_path, + FULL_ENV.tmpDir + '/a b/#c.txt', + ) + assert.equal(convertMsysToNative('/dev/null', { translate: withFullEnv }).native_path, '\\\\.\\NUL') + assert.equal( + convertMsysToNative('/usr/bin/bash.exe', { translate: withFullEnv }).native_path, + FULL_ENV.gitRoot + '/usr/bin/bash.exe', + ) + assert.equal( + convertMsysToNative('/bin/sh', { translate: withFullEnv }).native_path, + FULL_ENV.gitRoot + '/usr/bin/sh', + ) + // /home is the GIT mount (git-root/home), NOT the user profile — same fact as bash. + assert.equal( + convertMsysToNative('/home/u', { translate: withFullEnv }).native_path, + FULL_ENV.gitRoot + '/home/u', + ) + // Home shorthand and variables ride the shared helper's expanders. + assert.equal(convertMsysToNative('~', { translate: withFullEnv }).native_path, FULL_ENV.home) + assert.equal(convertMsysToNative('~/notes', { translate: withFullEnv }).native_path, FULL_ENV.home + '/notes') + assert.equal(convertMsysToNative('$HOME/notes', { translate: withFullEnv }).native_path, FULL_ENV.home + '/notes') + assert.equal(convertMsysToNative('$TMP/out', { translate: withFullEnv }).native_path, FULL_ENV.tmpDir + '/out') + assert.equal(convertMsysToNative('${TEMP}/out', { translate: withFullEnv }).native_path, FULL_ENV.tmpDir + '/out') + // Unicode, '#' and spaces pass through byte for byte. + assert.equal( + convertMsysToNative('/c/我的#目录/文件 1.txt', { translate: withFullEnv }).native_path, + 'C:/我的#目录/文件 1.txt', + ) +}) + +test('path-tool: both dep shapes (composed translate / raw helper + env) agree', () => { + const composed = convertMsysToNative('/tmp/x', { translate: withFullEnv }) + const raw = convertMsysToNative('/tmp/x', { translate: translateMsysPath, env: FULL_ENV }) + assert.deepEqual(composed, raw) + assert.equal(composed.status, 'converted') +}) + +test('path-tool: rejections are explicit — empty, NUL byte, URL, non-string', () => { + const run = (value) => convertMsysToNative(value, { translate: withFullEnv }) + for (const value of ['', ' ', '\t']) { + const outcome = run(value) + assert.equal(outcome.ok, false) + assert.equal(outcome.code, 'EMPTY') + assert.match(outcome.message, /msys_path must be a non-empty string/) + } + const nul = run('a\0b') + assert.equal(nul.ok, false) + assert.equal(nul.code, 'NUL_BYTE') + const url = run('https://example.com/x') + assert.equal(url.ok, false) + assert.equal(url.code, 'URL') + assert.match(url.message, /scheme 'https'/) + assert.equal(run('file:///c/x').code, 'URL') + assert.equal(run('ftp://host/p').code, 'URL') + for (const value of [42, null, undefined, {}, []]) { + assert.equal(run(value).code, 'EMPTY') + } + // A single-letter drive is NOT a URL scheme: C:/ and C: (drive-relative) stay accepted. + assert.equal(run('C:/x').ok, true) + assert.equal(run('C:x').ok, true) +}) + +test('path-tool: missing mount facts are rejected, never returned as native', () => { + // Mounts off entirely (env null): /tmp and ~ cannot resolve — loud refusal. + const off = (value) => convertMsysToNative(value, { translate: (v) => translateMsysPath(v, null), env: null }) + const tmpOff = off('/tmp/owned/x') + assert.equal(tmpOff.ok, false) + assert.equal(tmpOff.code, 'NO_MOUNT_MAPPING') + assert.match(tmpOff.message, /mount facts unavailable/) + assert.equal(off('~').code, 'NO_MOUNT_MAPPING') + assert.equal(off('/usr/lib').code, 'NO_MOUNT_MAPPING') + // Drive roots and /dev/null need NO env fact — still convert with mounts off. + assert.equal(off('/c/x').native_path, 'C:/x') + assert.equal(off('/dev/null').native_path, '\\\\.\\NUL') + + // Facts present but the needed one missing (no gitRoot): /usr must refuse. + const noGit = (value) => convertMsysToNative(value, { + translate: (v) => translateMsysPath(v, { home: FULL_ENV.home, tmpDir: FULL_ENV.tmpDir }), + env: { home: FULL_ENV.home, tmpDir: FULL_ENV.tmpDir }, + }) + const usr = noGit('/usr/lib') + assert.equal(usr.ok, false) + assert.equal(usr.code, 'NO_MOUNT_MAPPING') + assert.match(usr.message, /gitRoot=no/) + + // A POSIX root that NO mount maps, even with every fact present. + const unmapped = convertMsysToNative('/opt/does-not-exist', { translate: withFullEnv, env: FULL_ENV }) + assert.equal(unmapped.ok, false) + assert.equal(unmapped.code, 'NO_MOUNT_MAPPING') + assert.match(unmapped.message, /no MSYS mount mapping for "\/opt\/does-not-exist"/) + assert.match(unmapped.message, /home=yes tmpDir=yes gitRoot=yes/) + assert.match(unmapped.message, /Refusing to return the POSIX spelling as a native path/) + // Named home forms (~other) carry no fact: refuse, mirroring the shared helper. + assert.equal(convertMsysToNative('~other/x', { translate: withFullEnv, env: FULL_ENV }).code, 'NO_MOUNT_MAPPING') + + // No translator wired at all: refuse rather than guess. + assert.equal(convertMsysToNative('/c/x', {}).code, 'NO_TRANSLATOR') +}) + +test('path-tool: already-native and cwd-relative values pass through, declared', () => { + const run = (value) => convertMsysToNative(value, { translate: withFullEnv, env: FULL_ENV }) + const windows = ['C:/Users/x', 'C:\\Users\\x', '\\\\srv\\share', '//srv/share'] + for (const value of windows) { + const outcome = run(value) + assert.deepEqual({ ok: outcome.ok, native: outcome.native_path, status: outcome.status, kind: outcome.kind }, + { ok: true, native: value, status: 'passthrough', kind: 'windows-native' }) + } + for (const value of ['docs/file.txt', './x', '..\\y', 'file.txt', 'C:', 'C:tmp']) { + const outcome = run(value) + assert.equal(outcome.ok, true) + assert.equal(outcome.native_path, value) + assert.equal(outcome.status, 'passthrough') + assert.equal(outcome.kind, 'cwd-relative') + } + // The passthrough decision never consults the mount facts. + assert.equal(convertMsysToNative('docs/x', { translate: withFullEnv }).native_path, 'docs/x') +}) + +test('path-tool: classification helpers', () => { + assert.equal(isWindowsNativeValue('C:/x'), true) + assert.equal(isWindowsNativeValue('C:\\x'), true) + assert.equal(isWindowsNativeValue('\\\\srv\\share'), true) + assert.equal(isWindowsNativeValue('//srv/share'), true) + assert.equal(isWindowsNativeValue('C:'), false) + assert.equal(isWindowsNativeValue('relative'), false) + assert.equal(looksPosixValue('/c/x'), true) + assert.equal(looksPosixValue('/tmp'), true) + assert.equal(looksPosixValue('~'), true) + assert.equal(looksPosixValue('$HOME/x'), true) + assert.equal(looksPosixValue('C:/x'), false) + assert.equal(looksPosixValue('relative'), false) +}) + +test('path-tool: plain definition — schema data, execute, fresh buildEnv per call', () => { + let buildCalls = 0 + const definition = buildPathToolDefinition({ + translateMsysPath, + buildEnv: () => { buildCalls++; return FULL_ENV }, + }) + assert.equal(definition.name, PATH_TOOL_NAME) + // Plain JSON-schema shape: property required via the array, object closed. + assert.deepEqual(definition.parameters, { + type: 'object', + properties: { msys_path: { type: 'string', description: definition.parameters.properties.msys_path.description } }, + required: ['msys_path'], + additionalProperties: false, + }) + assert.equal(definition.parameters.properties.msys_path.description.length > 20, true) + assert.deepEqual(definition.output.schema, { + type: 'object', + properties: { native_path: { type: 'string' } }, + required: ['native_path'], + additionalProperties: false, + }) + // Execute converts and consumes FRESH facts per call (never a copied table). + assert.deepEqual(definition.execute({ msys_path: '/c/我的#目录' }), { native_path: 'C:/我的#目录' }) + assert.equal(buildCalls, 1) + assert.deepEqual(definition.execute({ msys_path: '/tmp/a b' }), { native_path: FULL_ENV.tmpDir + '/a b' }) + assert.equal(buildCalls, 2) + // Rejections throw with the explicit code in the message. + assert.throws(() => definition.execute({ msys_path: '' }), /msys_path must be a non-empty string/) + assert.throws(() => definition.execute({ msys_path: 'https://x/y' }), /looks like a URL/) + assert.throws(() => definition.execute({ msys_path: '/opt/nope' }), /no MSYS mount mapping/) + assert.throws(() => definition.execute({}), /msys_path must be a non-empty string/) + // Render: the native value goes out verbatim, nothing re-decoded. + assert.deepEqual( + definition.output.render({ msys_path: '/c/x' }, { native_path: 'D:/我的#目录 1.txt' }), + [{ type: 'text', text: JSON.stringify({ native_path: 'D:/我的#目录 1.txt' }) }], + ) +}) + +test('path-tool: typed author-schema data (captured defineTool)', () => { + let captured + const definition = buildPathToolDefinition({ + toolsModule: { defineTool: (options) => { captured = options; return { name: options.name } } }, + translateMsysPath, + buildEnv: () => FULL_ENV, + }) + assert.equal(definition.name, PATH_TOOL_NAME) + // Author dialect: parameters is a property map, required:true ON the property. + assert.deepEqual(captured.parameters, { + msys_path: { type: 'string', required: true, description: captured.parameters.msys_path.description }, + }) + // Output object: explicit additionalProperties, every property required. + assert.deepEqual(captured.output.schema, { + type: 'object', + properties: { native_path: { type: 'string', required: true } }, + additionalProperties: false, + }) + assert.equal(typeof captured.output.render, 'function') + assert.equal(captured.isConcurrencySafe, true) + assert.equal(captured.description, pathToolDescription()) + assert.match(captured.description, /Call this ONLY when the full value is already known to be a single filesystem path/) + assert.match(captured.description, /REJECTED with an explicit error/) + assert.match(captured.description, /no filesystem I\/O/) +}) + +test('path-tool: the module owns its registration and reverts it on disposal', () => { + const registered = [] + const effects = [] + const ctx = { + tools: { + register(definition) { + registered.push(definition) + return () => { registered.pop() } + }, + }, + effect(callback) { effects.push(callback()) }, + } + const logs = [] + const module = createPathToolModule({ + translateMsysPath, + buildEnv: () => FULL_ENV, + log: (message) => logs.push(message), + }) + assert.deepEqual(module.inject, ['tools']) + module.apply(ctx) + assert.equal(registered.length, 1) + assert.equal(registered[0].name, PATH_TOOL_NAME) + // Two log lines are correct without the host module: the plain-definition + // fallback note at build time, plus the registration line in apply. + assert.equal(logs.length, 2) + assert.match(logs[0], /dsh-tools not resolvable; building a plain JSON-schema definition/) + assert.match(logs[1], /gitbash_to_native_path registered/) + // Scope disposal (the effects cordis runs) removes the tool, no residue. + for (const dispose of effects) dispose() + assert.equal(registered.length, 0) +}) + +test('path-tool: factory fails loud without a translator and on a broken registry', () => { + assert.throws(() => createPathToolModule({}), /a shared translator is required/) + assert.throws(() => createPathToolModule({ buildEnv: () => FULL_ENV }), /a shared translator is required/) + const module = createPathToolModule({ translateMsysPath, buildEnv: () => FULL_ENV }) + assert.throws(() => module.apply({}), /ctx\.tools\.register is unavailable/) +}) + +test('path-tool: the real shared buildTranslateEnv feeds the same helper (composition fact)', () => { + // One real read on this machine: the factory's buildEnv dep is exactly this + // composition in production. Facts must be the plain string bag the helper + // consumes (tmpDir may legitimately be absent in a stripped environment). + const env = buildTranslateEnv('') + assert.equal(typeof env, 'object') + for (const key of ['home', 'tmpDir', 'gitRoot']) { + assert.ok(env[key] === undefined || (typeof env[key] === 'string' && env[key].includes('\\') === false), + `${key} must be absent or a forward-slash string, got ${JSON.stringify(env[key])}`) + } + // Whatever the machine, the drive-root face needs no facts at all. + assert.equal(translateMsysPath('/c/x', env), 'C:/x') + assert.equal(convertMsysToNative('/c/x', { translate: (v) => translateMsysPath(v, env), env }).native_path, 'C:/x') +}) diff --git a/tests/preview-env.mjs b/tests/preview-env.mjs new file mode 100644 index 0000000..c0d509a --- /dev/null +++ b/tests/preview-env.mjs @@ -0,0 +1,25 @@ +// Test-only evidence ownership. No production profile or installed Core writes. +// Operator-provided directories win; otherwise use unique host temporary scratch. +import { existsSync, mkdirSync, mkdtempSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' + +const names = { + DSH_NATIVE_OUTPUT_EVIDENCE_DIR: 'output', + DSH_NATIVE_EVIDENCE_DIR: 'native', + DSH_NATIVE_REVIEW_EVIDENCE_DIR: 'review', + DSH_PATH_EVIDENCE_DIR: 'paths', +} +let scratch +for (const [name, child] of Object.entries(names)) { + let directory = process.env[name] + if (directory === undefined || directory === '') { + scratch ??= mkdtempSync(join(tmpdir(), 'dsh-gitbash-preview-')) + directory = join(scratch, child) + mkdirSync(directory) + process.env[name] = directory + } + if (!isAbsolute(directory) || !existsSync(directory) || !statSync(directory).isDirectory()) { + throw new Error(`${name} must select an existing absolute caller-owned evidence directory`) + } +} diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index 6d66287..66f9685 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -9,7 +9,7 @@ import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools' import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' import * as shellEnv from '@deepseek-ai/dsh-shell-env' -import { mountInlineTools, mountPrivateExecutor } from '../src/inline-tools.js' +import { applyToolRouting, mountInlineTools, mountPrivateExecutor } from '../src/inline-tools.js' import { createBashToolModule } from '../src/bash-tool.js' import { GitBashSandboxExecutor } from '../src/shell.js' @@ -32,7 +32,7 @@ function createScope(parent) { return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } } -async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime } = {}) { +async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime, explicitTools = false, ptc = false } = {}) { const root = new Context() const cleanups = [] const logs = [] @@ -48,6 +48,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu shell: official, subprocess: subprocessRuntime ?? { spawn() { return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: { stdout: { readFrom }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, terminate() {} } } }, sandbox: {}, + ...(ptc ? { ptcRuntime: { language: 'typescript', executionInstructions: 'Prompt-only fixture; never executes programs', resolve: spec => spec, run() { throw new Error('prompt routing fixture must not execute') } } } : {}), sandboxPolicy: { defaultMode: policy.mode, resolve: request => request?.session?.header.policy ?? policy }, workingDirectory: { ensure: async () => process.cwd() }, loader: { internal: { async import(name) { @@ -55,13 +56,13 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu return import(name) } } }, })) cleanups.push(root.provide(name, value)) - const mount = async module => { - const fiber = root.plugin(module) + const mount = async (module, config) => { + const fiber = root.plugin(module, config) cleanups.push(() => fiber.dispose()) await fiber return fiber } - for (const module of [SystemPrompt, ToolRuntime, shellEnv]) await mount(module) + for (const module of [SystemPrompt, ToolRuntime, shellEnv]) await mount(module, module === ToolRuntime ? { mode: ptc ? 'ptc' : 'native' } : undefined) let agentParent await mount({ inject: ['tools'], apply(ctx) { agentParent = ctx @@ -76,6 +77,10 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu const deps = { tools: ctx => ctx.get('tools'), agents: () => ({ list: () => [agent] }), pwshEnabled: () => pwshVisible, + ...(explicitTools ? { + translatePath: value => dialectHelpers.translateMsysPath(value, { home: 'D:/compat-home', tmpDir: 'D:/compat-temp', gitRoot: 'C:/Program Files/Git' }), + createArtifactDirectory() { throw new Error('registration/refusal fixture must not create artifacts') }, + } : {}), log(message) { logs.push(message) if (message.includes('own bash tool activated')) activated.resolve() @@ -263,28 +268,31 @@ test('runtime preview: private execution retains deadlines, background no-expiry assert.equal(f.root.get('shell'), f.official) }) -test('runtime preview: actual ToolRuntime surfaces unconfined foreground and initial background facts', { timeout: 10000 }, async t => { - const f = await fixture(t) - await bounded(f.activated.promise, 'compatibility bash activation') +test('runtime preview: actual ToolRuntime refuses unsafe foreground/background admission', { timeout: 10000 }, async t => { + let spawns = 0 + const f = await fixture(t, { subprocessRuntime: { spawn() { spawns++; throw new Error('restricted fixture must not spawn') } } }) + await bounded(f.activated.promise, 'fail-closed bash activation') assert.match(f.tools.get('bash', f.agent).description, /MSYS2_ARG_CONV_EXCL/) + assert.match(f.tools.get('bash', f.agent).description, /refused before/) for (const mode of ['read-only', 'workspace-write']) { f.agent.session.header.policy = { mode, workspaceRoot: process.cwd() } - const value = await f.execute('bash', { command: 'memory-only command', description: 'Verify unconfined model feedback' }) - assert.equal(value.isError, false, JSON.stringify(value)) - assert.equal(value.value.sandbox.enforcement, 'unconfined') - assert.match(value.content[0].text, /unconfined.*NOT enforced/) + const value = await f.execute('bash', { command: 'memory-only command', description: 'Verify structured safe refusal' }) + assert.equal(value.isError, true, JSON.stringify(value)) + assert.equal(value.error.info.code, 'SANDBOX_UNAVAILABLE') + assert.match(value.content[0].text, /not started/) } const admitted = [] - const off = f.root.provide('jobs', { start(spec) { admitted.push(spec); return 'compatibility-background' } }) + const off = f.root.provide('jobs', { start(spec) { admitted.push(spec); return 'must-not-be-admitted' } }) try { await new Promise(resolve => setImmediate(resolve)) - const value = await f.execute('bash', { command: 'memory-only command', description: 'Verify admission warning', run_in_background: true }) - assert.equal(value.isError, false, JSON.stringify(value)) - assert.equal(value.value.kind, 'background') - assert.equal(value.value.sandbox.enforcement, 'unconfined') - assert.match(value.content[0].text, /started background job compatibility-background[\s\S]*unconfined/) - assert.equal(admitted.length, 1) + for (const run_in_background of [false, true]) { + const value = await f.execute('bash', { command: 'memory-only command', description: 'Reject unsafe job admission', run_in_background }) + assert.equal(value.isError, true, JSON.stringify(value)) + assert.equal(value.error.info.code, 'SANDBOX_UNAVAILABLE') + } + assert.equal(admitted.length, 0) } finally { await off() } + assert.equal(spawns, 0) assert.equal(f.root.get('shell'), f.official) }) @@ -338,18 +346,94 @@ test('runtime preview: actual promoted DTO renders sandbox notes from handle fac } }) -test('runtime preview: actual execution handle carries unconfined facts for live jobs', { timeout: 10000 }, async t => { +test('runtime preview: direct resolved restricted execution refuses and full handle stays memoized', { timeout: 10000 }, async t => { const f = await fixture(t, { existingBash: true }) const { executor, executorScope } = await mountPrivateExecutor(f.root, GitBashSandboxExecutor, createScope) t.after(() => executorScope.dispose()) const spec = executor.resolve({ command: 'memory-only command', workdir: process.cwd(), sandboxPolicy: { mode: 'read-only', workspaceRoot: process.cwd() } }) - const handle = await executor.execute(spec) - assert.equal(handle.sandbox.enforcement, 'unconfined') + await assert.rejects(() => executor.execute(spec), error => error.code === 'SANDBOX_UNAVAILABLE') + await assert.rejects(() => executor.execute({ ...spec, sandboxPolicy: undefined }), error => error.code === 'SANDBOX_UNAVAILABLE') + const full = { ...spec, sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: process.cwd() } } + const handle = await executor.execute(full) + assert.deepEqual(handle.sandbox, { mode: 'danger-full-access', denied: false }) assert.equal(handle.result(), handle.result(), 'projection remains memoized') assert.deepEqual((await handle.result()).sandbox, handle.sandbox) assert.equal(f.root.get('shell'), f.official) }) +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 + 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(sdk, /gitbash_native/) + assert.match(sdk, /Choose this tool BEFORE execution/) + assert.match(sdk, /gitbash_to_native_path/) + assert.match(sdk, /runtime-built MSYS paths before direct native Node/) + assert.doesNotMatch(sdk, /\bpwsh\b/, 'hidden tools must not be offered as SDK bindings') + } else { + const text = first.tools.find(tool => tool.name === 'bash').description + assert.match(text, /gitbash_native before execution/) + assert.match(text, /gitbash_to_native_path only/) + assert.doesNotMatch(text, /\bpwsh\b|ALL commands|\/c\/Users/) + } + 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/) + 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 + 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) +}) + +test('runtime preview: explicit tools activate in the real per-agent wiring and retire together', { timeout: 10000 }, async t => { + let spawns = 0 + const f = await fixture(t, { explicitTools: true, subprocessRuntime: { spawn() { spawns++; throw new Error('this fixture must not spawn') } } }) + await bounded(f.activated.promise, 'explicit tools activation') + assert.deepEqual(f.names().sort(), ['bash', 'gitbash_native', 'gitbash_to_native_path']) + const translated = await f.execute('gitbash_to_native_path', { msys_path: '/d/项目/文件#1.txt' }) + assert.equal(translated.isError, false, JSON.stringify(translated)) + assert.deepEqual(translated.value, { native_path: 'D:/项目/文件#1.txt' }) + for (const mode of ['read-only', 'workspace-write']) { + f.agent.session.header.policy = { mode, workspaceRoot: process.cwd() } + const denied = await f.execute('gitbash_native', { description: 'Refuse unsupported policy', executable: { kind: 'path', value: 'C:/controlled.exe' }, argv: [] }) + assert.equal(denied.isError, true, JSON.stringify(denied)) + assert.equal(denied.error.info.code, 'SANDBOX_UNAVAILABLE') + } + assert.equal(spawns, 0) + assert.equal(f.root.get('shell'), f.official) + await f.patch.dispose() + assert.deepEqual(f.names(), ['pwsh'], 'all own registrations and visibility masks retire together') + const reactivated = deferred() + const previousLog = f.deps.log + f.deps.log = message => { previousLog(message); if (message.includes('own bash tool activated')) reactivated.resolve() } + await f.remount() + await bounded(reactivated.promise, 'explicit tools reload activation') + assert.deepEqual(f.names().sort(), ['bash', 'gitbash_native', 'gitbash_to_native_path'], 'reload must not duplicate registrations') + assert.equal(f.root.get('shell'), f.official) +}) + // The real run_code transport captures rewritten source in a mock PTC runtime; // it does NOT evaluate the program or claim real PTC-engine/filesystem coverage. test('runtime preview: actual run_code transport receives Unicode/EOF fixes and preserves tagged data', async t => { diff --git a/tests/runtime-regressions.mjs b/tests/runtime-regressions.mjs index d11d3ef..295ad53 100644 --- a/tests/runtime-regressions.mjs +++ b/tests/runtime-regressions.mjs @@ -177,12 +177,16 @@ test('runtime: missing approval helper still passes the agent\'s standing mode a await assert.rejects(() => definition.execute({ command: 'echo ok', description: 'Reject unavailable escalation', sandbox_permissions: 'danger-full-access', justification: 'Need broader mode.' }, exec), /no sandbox helper/) }) -test('runtime: background admission exposes its planned unconfined route before any spawn', async t => { +test('runtime: restricted foreground and background calls fail before Jobs admission', async t => { const admitted = [] + const prepared = [] const executor = { sandboxMode: 'danger-full-access', - resolve: request => ({ ...request, timeoutMs: 10 }), - gitBashRoute: spec => ({ mode: spec.sandboxPolicy.mode, unconfined: spec.sandboxPolicy.mode !== 'danger-full-access' }), + resolve(request) { prepared.push(request); return { ...request, timeoutMs: 10 } }, + gitBashRoute(spec) { + if (spec.sandboxPolicy?.mode !== 'danger-full-access') throw Object.assign(new Error('Git Bash policy unavailable; command not started'), { code: 'SANDBOX_UNAVAILABLE' }) + return { mode: 'danger-full-access', unconfined: false } + }, execute() { throw new Error('admission fixture must not spawn') }, } const f = await toolFixture(t, { executor, jobs: { start(spec) { admitted.push(spec); return 'admitted-job' } } }) @@ -190,15 +194,19 @@ test('runtime: background admission exposes its planned unconfined route before assertJsonSchema(definition.output.schema) const schema = definition.output.schema.oneOf[0] assert.deepEqual(schema.properties.sandbox.required, ['mode', 'denied']) - for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { + for (const mode of ['read-only', 'workspace-write']) { const agent = { session: { header: { mode, cwd: 'D:/child-workspace' } } } - const value = await definition.execute({ command: 'memory-only command', description: 'Verify background planned route', run_in_background: true }, { agent, signal: new AbortController().signal }) - assert.equal(value.kind, 'background') - assert.equal(value.sandbox.mode, mode) - const text = definition.output.render({}, value)[0].text - assert.match(text, /started background job admitted-job/) - if (mode === 'danger-full-access') assert.doesNotMatch(text, /unconfined/) - else assert.match(text, /unconfined.*NOT enforced/) + for (const run_in_background of [false, true]) { + await assert.rejects(() => definition.execute({ command: 'memory-only command', description: 'Reject unsafe admission', run_in_background }, { agent, signal: new AbortController().signal }), + error => error.code === 'SANDBOX_UNAVAILABLE') + } } - assert.equal(admitted.length, 3) + assert.deepEqual(prepared, [], 'refusal precedes execution spec preparation') + assert.deepEqual(admitted, [], 'including foreground promotion: no unsafe job is admitted') + const agent = { session: { header: { mode: 'danger-full-access', cwd: 'D:/child-workspace' } } } + const value = await definition.execute({ command: 'memory-only command', description: 'Admit explicit full access', run_in_background: true }, { agent, signal: new AbortController().signal }) + assert.equal(value.kind, 'background') + assert.equal(value.sandbox.mode, 'danger-full-access') + assert.match(definition.output.render({}, value)[0].text, /started background job admitted-job/) + assert.equal(admitted.length, 1) }) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 758b010..233b701 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -597,12 +597,11 @@ test('executor: the base class\'s Config fields stay volatile (issue #6)', () => assert.doesNotMatch(src, /^import .*'@deepseek-ai\/schemastery'/m, 'no static peer import may kill the whole row') }) -test('executor: Windows confined calls run unconfined and say so (issue #1)', async () => { +test('executor: Windows restricted and unresolved policies fail before any argv route', async () => { const src = readFileSync('src/shell.js', 'utf8') const stripped = stripModule(src) const scope = new Function('DEFAULT_GIT_BASH', 'resolveGitBashCached', 'effectiveConfiguredBashPath', 'settingsBashPath', 'bashResolutionReport', 'SandboxBashExecutor', 'z', 'process', stripped + '\nreturn { GitBashSandboxExecutor }') class FakeBase { - constructor() { this.calls = [] } async run(spec) { this.calls.push(['super.run', spec]); return { via: 'super.run' } } start(spec) { this.calls.push(['super.start', spec]); return { via: 'super.start' } } async runArgv(spec, argv) { this.calls.push(['runArgv', argv]); return { exitCode: 0, stdout: {}, stderr: {} } } @@ -616,45 +615,29 @@ test('executor: Windows confined calls run unconfined and say so (issue #1)', as ex.calls = [] return ex } - const spec = { command: 'echo hi', sandboxPolicy: { mode: 'workspace-write' } } - const byKind = (ex, kind) => ex.calls.filter((c) => c[0] === kind) - - const logs = [] - const origLog = console.log - console.log = (...a) => logs.push(a.join(' ')) - try { - // Windows confined: unconfined argv path, honest label, sandbox stack untouched. + const refusal = error => error.code === 'SANDBOX_UNAVAILABLE' && /not started/.test(error.message) + for (const mode of ['read-only', 'workspace-write', undefined, 'invalid-mode']) { + const spec = { command: 'echo hi', ...(mode === undefined ? {} : { sandboxPolicy: { mode } }) } const ex = make('win32') - const r = await ex.run(spec) - assert.equal(byKind(ex, 'runArgv').length, 1) - assert.deepEqual(byKind(ex, 'runArgv')[0][1], ['X:/git/bin/bash.exe', '-c', 'echo hi']) - assert.deepEqual(r.sandbox, { mode: 'workspace-write', denied: false, enforcement: 'unconfined' }) - assert.equal(byKind(ex, 'super.run').length, 0) - const p = ex.start(spec) - assert.deepEqual(p.sandbox, { mode: 'workspace-write', denied: false, enforcement: 'unconfined' }) - assert.equal(byKind(ex, 'super.start').length, 0) - // One-time notice: the second confined call logs nothing more. - await ex.run(spec) - assert.equal(logs.length, 1) - assert.ok(logs[0].includes('UNCONFINED')) - // danger-full-access keeps its exact label (no enforcement key). - const exFull = make('win32') - const rFull = await exFull.run({ command: 'x', sandboxPolicy: { mode: 'danger-full-access' } }) - assert.deepEqual(rFull.sandbox, { mode: 'danger-full-access', denied: false }) - // Non-Windows confined keeps the shipped sandboxed path. - const exLinux = make('linux') - await exLinux.run(spec) - assert.equal(byKind(exLinux, 'super.run').length, 1) - assert.equal(byKind(exLinux, 'runArgv').length, 0) - exLinux.start(spec) - assert.equal(byKind(exLinux, 'super.start').length, 1) - // No policy: fully inherited. - const exNone = make('win32') - await exNone.run({ command: 'x' }) - assert.equal(byKind(exNone, 'super.run').length, 1) - } finally { - console.log = origLog + ex.requireBashPath = () => { throw new Error('refused calls must not even resolve argv') } + await assert.rejects(() => ex.run(spec), refusal) + assert.throws(() => ex.start(spec), refusal) + assert.throws(() => ex.gitBashRoute(spec), refusal) + assert.deepEqual(ex.calls, [], 'no inherited, fallback or explicit argv execution') } + const exFull = make('win32') + const full = { command: 'echo hi', sandboxPolicy: { mode: 'danger-full-access' } } + const result = await exFull.run(full) + assert.deepEqual(exFull.calls[0][1], ['X:/git/bin/bash.exe', '-c', 'echo hi']) + assert.deepEqual(result.sandbox, { mode: 'danger-full-access', denied: false }) + assert.deepEqual(exFull.start(full).sandbox, result.sandbox) + const linux = make('linux') + await linux.run({ command: 'echo hi', sandboxPolicy: { mode: 'read-only' } }) + linux.start({ command: 'echo hi', sandboxPolicy: { mode: 'read-only' } }) + assert.deepEqual(linux.calls.map(call => call[0]), ['super.run', 'super.start']) + const linuxUnresolved = make('linux') + await linuxUnresolved.run({ command: 'echo hi' }) + assert.equal(linuxUnresolved.calls[0][0], 'super.run') }) test('executor: runArgv envelope and start contract span both dsh eras (0.1.6)', async () => { @@ -683,7 +666,7 @@ test('executor: runArgv envelope and start contract span both dsh eras (0.1.6)', async start() { this.calls.push(['super.start']); return { via: 'super.start' } } } - const spec = { command: 'echo hi', sandboxPolicy: { mode: 'workspace-write' } } + const spec = { command: 'echo hi', sandboxPolicy: { mode: 'danger-full-access' } } const logs = [] const origLog = console.log console.log = (...a) => logs.push(a.join(' ')) @@ -691,7 +674,7 @@ test('executor: runArgv envelope and start contract span both dsh eras (0.1.6)', const legacy = build(SyncBase, 'win32') const rLegacy = await legacy.run(spec) assert.equal(rLegacy.stdout.text, 'hi', 'a bare ShellRunResult reaches the caller unchanged') - assert.equal(rLegacy.sandbox.enforcement, 'unconfined') + assert.deepEqual(rLegacy.sandbox, { mode: 'danger-full-access', denied: false }) const modern = build(AsyncBase, 'win32') modern.runArgvReturns = { result: RESULT, spawnRequested: true } @@ -699,18 +682,23 @@ test('executor: runArgv envelope and start contract span both dsh eras (0.1.6)', assert.equal(rModern.stdout.text, 'hi', 'the { result, spawnRequested } envelope is unwrapped') assert.equal(rModern.exitCode, 0) assert.equal(rModern.result, undefined, 'no wrapper leaks into the result the host reads') - assert.equal(rModern.sandbox.enforcement, 'unconfined') + assert.deepEqual(rModern.sandbox, { mode: 'danger-full-access', denied: false }) const cancelled = build(AsyncBase, 'win32') cancelled.runArgvReturns = { result: { ...RESULT, exitCode: null, aborted: true }, spawnRequested: false } const rCancelled = await cancelled.run(spec) - assert.deepEqual(rCancelled.sandbox, { mode: 'workspace-write', denied: false }, 'no spawn means nothing to label as unconfined') + assert.deepEqual(rCancelled.sandbox, { mode: 'danger-full-access', denied: false }, 'cancelled full-access execution keeps its resolved mode') const syncProc = build(SyncBase, 'win32').start(spec) assert.equal(typeof syncProc.then, 'undefined', 'a synchronous host gets the handle itself') const asyncProc = build(AsyncBase, 'win32').start(spec) assert.equal(typeof asyncProc.then, 'function', 'an async host gets a thenable') - assert.equal((await asyncProc).sandbox.enforcement, 'unconfined', 'awaiting yields the labelled handle') + assert.deepEqual((await asyncProc).sandbox, { mode: 'danger-full-access', denied: false }, 'awaiting yields the labelled handle') + const restricted = { ...spec, sandboxPolicy: { mode: 'workspace-write' } } + assert.throws(() => build(SyncBase, 'win32').start(restricted), /not started/) + const refusedAsync = build(AsyncBase, 'win32').start(restricted) + assert.equal(typeof refusedAsync.then, 'function', 'async hosts receive a rejected promise for refusal') + await assert.rejects(() => refusedAsync, /not started/) const forwarded = [] const owner = build(AsyncBase, 'linux') @@ -779,18 +767,16 @@ test('executor: dsh 0.1.7 execute() keeps both Git Bash substitutions (issue #6 assert.deepEqual(fullArgs[0][1], GIT_BASH_ARGV, 'the Git Bash argv replaces the shipped bare `bash`') assert.deepEqual((await fullHandle.result()).sandbox, { mode: 'danger-full-access', denied: false }) - // Windows confined: unconfined argv + the honest label (issue #1), and the - // restricted-token provider is never consulted. - const confinedEx = build(NewBase, 'win32') - confinedEx.ctx = { sandbox: { confine: () => { throw new Error('confine must not run on win32') } } } - const confinedHandle = await confinedEx.execute(confined) - assert.equal(kindOf(confinedEx, 'super.execute').length, 0) - assert.deepEqual(kindOf(confinedEx, 'executeArgv')[0][1], GIT_BASH_ARGV) - assert.deepEqual(confinedHandle.sandbox, { mode: 'workspace-write', denied: false, enforcement: 'unconfined' }, - 'background readers see the boundary before result() is awaited') - assert.deepEqual((await confinedHandle.result()).sandbox, - { mode: 'workspace-write', denied: false, enforcement: 'unconfined' }) - assert.ok(logs.some((line) => line.includes('UNCONFINED')), 'the one-time notice still fires') + // Windows restricted and missing policies are unavailable, not a licence + // to route through the unconfined helper or another interpreter. + for (const mode of ['read-only', 'workspace-write', undefined]) { + const refused = build(NewBase, 'win32') + refused.ctx = { sandbox: { confine: () => { throw new Error('unexpected confine') } } } + await assert.rejects(() => refused.execute({ ...confined, sandboxPolicy: mode === undefined ? undefined : { mode } }), + error => error.code === 'SANDBOX_UNAVAILABLE' && /not started/.test(error.message)) + assert.deepEqual(refused.calls, [], 'no base execute, argv helper or sandbox launch') + } + assert.equal(logs.length, 0, 'no announcement of a route that was not allowed to run') // Non-Windows confined: inherit the parent unchanged (its confine path // already runs Git Bash through the subclass override). @@ -813,9 +799,11 @@ test('executor: dsh 0.1.7 execute() keeps both Git Bash substitutions (issue #6 // Old-era host (no `execute`): our override hands the call to `run`. const oldEx = build(OldBase, 'win32') - const oldResult = await oldEx.execute(confined) + await assert.rejects(() => oldEx.execute(confined), /not started/) + assert.deepEqual(oldEx.calls, []) + const oldResult = await oldEx.execute(full) assert.equal(kindOf(oldEx, 'runArgv')[0][1][0], 'X:/git/bin/bash.exe') - assert.equal(oldResult.sandbox.enforcement, 'unconfined') + assert.deepEqual(oldResult.sandbox, { mode: 'danger-full-access', denied: false }) } finally { console.log = origLog } @@ -2963,6 +2951,9 @@ test('inline tools: index.js wires the patch, the three switches, and the waterf assert.match(hostSource, /GITBASH_ROUTE_ADDENDUM/) assert.match(hostSource, /GITBASH_ROUTE_ADDENDUM_NO_PWSH/) assert.match(hostSource, /liveSettings\.pwshTool\(\) === true \? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH/) + assert.match(hostSource, /applyToolRouting\(assembly\.tools,/) + assert.match(hostSource, /pwshVisible: liveSettings\.pwshTool\(\) === true/) + assert.match(hostSource, /msys: applies && dialect\.posixPaths === true/) // 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/) From 5bf5a27bef3e2c95f5f9a1f7488ec65d6e0d2d33 Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Tue, 13 Oct 2026 21:24:23 +0800 Subject: [PATCH 2/9] docs: remove session-specific workflow from experimental documentation --- CHANGELOG.md | 40 ++++++++++++++++++++---- docs/experimental-native-execution.md | 9 +++--- docs/experimental-native-verification.md | 24 +++++++------- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d317c51..e8804eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,9 @@ native/path绑定自身描述在实际Core生成SDK中可见。未知混编码不猜,decoder失败不盲目重跑副作用。 - 增加5项路由/可见性矩阵/重组/SDK-only/路径说明回归、2项实际Core普通/PTC提示与SDK送达验证。 普通全仓 **223/223**、完整实际Corepreview **72/72**,exit0、无skip;不是实际LLM自主选择验收, - 不承诺每次选对。未新增Config/UI或改变工具执行参数、版本、安装、生产配置;未提交/推送。 + 不承诺每次选对。工具执行参数与Config/UI保持不变。 -### 获授权工作区实施(Unreleased,未发布/部署) +### 实验性实现(Unreleased) - **策略变化**:Windows 自有 bash 与 native 在 read-only/workspace-write/缺失策略时拒绝, 实际 Core 返回 SANDBOX_UNAVAILABLE;工作目录准备、Jobs admission/promotion与spawn前不走旧 @@ -33,12 +33,12 @@ - **托管与输出**:每execution私有scope复用真实executeArgv/provider/Jobs,不另造runner或依赖private spawnSpec;decoder错误保真实exit,不诱导重跑。增量UTF8/BOM兼容、独立reader/cursor、raw/decoded产物 与payload预算;scope属agent,后台/promotion卸载工具后仍可结束、仅清自身资源。 -- **独立Astra反证返工**:实证ownerdispose/direct kill曾错报completed/complete、artifact callback +- **独立验证发现的问题与修正**:实证ownerdispose/direct kill曾错报completed/complete、artifact callback 可无限await、facts schema过宽;均修为operation-owned cause、中止/不完整事实、准备阶段abort/deadline race与closed schemas。终态enum/callback三参同步;晚产物保留trusted retention,不伪称已清除。 - **最后全仓结果**:普通回归 **218/218**,实际安装Core 0.2.1-alpha.2完整preview **70/70**,均exit0、 - 无skip/cancel。Astra独立专项复跑unit41/41、preview55/55;它们是全仓结果的重叠证据,不能累加统计。 - 语法、diff检查和npm dry-run源码清单通过;未提交、推送、安装、改生产配置或权限。 + 无skip/cancel。独立专项复跑unit41/41、preview55/55;与全仓结果重叠,不另加总数。 + 语法、diff检查和npm dry-run源码清单通过。 - **测试整合纠正**:首全仓旧description断言还锁旧unconfined措辞,改为更严格拒绝/禁止绕行护栏。 首preview缺3项显式证据env;加test-only preview-env,operator目录优先,否则unique host temp。 路径夹具原硬编码D盘/安装bootstrap,改动态盘符/宿主自身bootstrap;C盘临时目录真实fs读复验通过。 @@ -47,7 +47,7 @@ 8MiB两流raw+decoded/16MiB自有payload不是RSS或跨任务硬quota,无自动purge;生产GUI/完整插件apply、 真受限token/ACL/IPC/VM负向权限、SSH/PTY与非CRT任意quoting尚未覆盖。未增加Config/UI文案字段。 公开讨论文档见[实验设计与接口](docs/experimental-native-execution.md)、 - [验证分级与复现](docs/experimental-native-verification.md)。原始本机日志未作为公开附件,测试可随仓库复现。 + [验证分级与复现](docs/experimental-native-verification.md)。对应测试可随仓库复现。 ## v0.39.4 — 2026-10-14 **类型**:feat(dsh-ops 0.3.0 互通:gb_* 退役 + 统一方言桥) / fix(docs)。 @@ -140,6 +140,34 @@ opt-in)与跨平台(win/mac/linux)支持。本版把并存形态收敛为「各 ToolRuntime/执行句柄/run_code transport 回归,完整无头 preview **17/17**。旧护栏保留;打包 dry-run、语法和 diff 检查通过。新增 preview 使用实际宿主 API 与内存 subprocess,已有 preview 保留真实 Git Bash/ PowerShell 进程验收;不等同于生产 GUI、真实 PTC 引擎或新 confinement 方案验证。 +- **重启后复测(2026-10-13)**:desktop Profile 的本地安装来源为 + [PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24) 的 + [`4816cd8`](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/4816cd8848a52f73f5f054f761d3ec862cb87ade) + 修复构建(包/插件清单0.39.2,不是额外发版);四个关键已安装源码与工作树逐字节一致、bundle开启。 + 当前实例status路由200/ok=true;真实前台、后台、超时转后台均输出预期sentinel,中文输出正常; + native `cmd //d //c echo`通过,未设置全局EXCL。原始固定GBK字节仍乱码,显式GBK→UTF-8通过, + 说明编码边界仍在,没有把警示宣称为底层修复。再次执行普通回归 **166/166**、宿主preview **17/17**。 + 已安装包helper覆盖中文/#路径、EOF注释和tagged-template保护,描述包含参数/编码/安全提示; + 独立render探针首轮误把stdout/stderr传为字符串,按真实stream DTO修正后通过,未修改产品代码。 + 本次复测未覆盖真实PTC引擎与生产受限会话;unconfined反馈仅由隔离Core/renderer测试核验。 + +### 未修复问题研究(2026-10-13;不是产品实施或发版) + +- **原生参数/编码**:实际安装 Core 0.2.1-alpha.2 的隔离 argv/raw-pipe/私有 facade→executeArgv + 与固定字节机制断言 **21/21**;可复用托管 provider,不需另造 runner。不同流显式解码可行, + 已混在同流的未知编码不可可靠猜测;private spawnSpec 不当稳定接口。cmd 的前斜杠 executable + 是另一个本机失败反例,反斜杠固定 echo 通过不代表任意 cmd/批处理 quoting 已验收。 +- **真实 PTC 补证**:ToolRuntime→真实 NodePtcRuntime→真实 LocalSubprocessRuntime→Node/fs + 隔离实验 **8/8**,独立复跑 exit0。显式完整值 helper 配合 codePaths OFF 能读对动态路径, + 并保留 `/c/users` 路由数据;legacy 字面量猜测仍误改。不是完整插件 apply/生产 GUI/受限 OS 证明。 +- **安全因果纠正**:现场 MSYS 3.6.10-5a1665c8 源码的 cygheap 使用 VirtualAlloc,signal pipe + 显式 sec_user DACL 缺 restricting SID;当前 Core Flags=13 且 TokenDefaultDacl 已补 ACE。 + 因而历史笼统“用户-only cygheap 映射/只补默认 DACL/所有未来版本无解”不再作为当前根因依据。 + 私有 IPC 身份路线仅静态可行,SID 集合模型 **9/9**不是 AccessCheck 或新沙箱实测。 +- **研究提出的方向**:受限模式spawn前fail-closed、显式native typed argv/逐流decoder/path helper; + Core加法契约与隔离Windows VM中的IPC/AppContainer/LPAC/guest边界仍需进一步验收。 + 当前实现与验证见[实验设计](docs/experimental-native-execution.md)、 + [验证矩阵](docs/experimental-native-verification.md);前置兼容修复见[PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24)。 ## v0.39.2 — 2026-10-13 diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md index 4d7565e..b499e16 100644 --- a/docs/experimental-native-execution.md +++ b/docs/experimental-native-execution.md @@ -1,8 +1,7 @@ # 实验性讨论:Windows 安全拒绝、typed native argv 与显式路径转换 > **状态:实验性实现讨论 / Draft,Unreleased。** -> 这是可运行的工作区实现,用来验证接口与讨论工程取舍,不是发布建议,也不是完整安全认证。 -> 尚未部署到生产实例;没有改变宿主安装产物、权限、版本号或发布流程。 +> 本文介绍实验实现的设计、接口和工程取舍。它尚未完成生产验收,不是发布候选或完整安全认证。 关联:[验证矩阵与复现]()、[变更记录](<../CHANGELOG.md>)。 @@ -42,7 +41,7 @@ legacy `codePaths` 无法识别 `/c/users` 是路径还是路由数据,也不 本提案**没有修复 OS 围栏**。匹配研究纠正了旧泛化:现代 MSYS cygheap 使用 VirtualAlloc,signal pipe 使用显式 SD;实际 Core 已补 TokenDefaultDacl。不能据此声称“再补默认 DACL 就解决”或“所有未来方案都无解”。 -IPC/Token/AppContainer/VM 路线仍需隔离环境的真实正负权限验证;本 PR 不修改 ACL、token、registry 或宿主 Core。 +IPC/Token/AppContainer/VM 路线仍需隔离环境的真实正负权限验证;这些 OS 隔离机制不属于本提案的实现范围。 ## 3. 原则与体验变化 @@ -172,7 +171,7 @@ Native typed path 不支持相对值、变量/home展开;需要时先通过显 | rawBytes/rawCursor | 原流字节计数/游标 | | decodedUtf8Bytes/decodedCursor | normalized UTF8计数/游标 | -Foregound DTO使用closed schemas;去undefined投影不放任unknown字段。无facts/投影失败是显式分支,不通过宽schema吞错。后台最终exit/facts进入现有Jobs文本source,不声称扩展Core Jobs结构化API。 +Foreground DTO使用closed schemas;去undefined投影不放任unknown字段。无facts/投影失败是显式分支,不通过宽schema吞错。后台最终exit/facts进入现有Jobs文本source,不声称扩展Core Jobs结构化API。 ## 9. 生命周期与预算 @@ -206,7 +205,7 @@ Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。 ## 12. 合并讨论 / 未覆盖 - [ ] 是否接受受限模式从旧绕过改为拒绝的breaking体验? -- [ ] 是否拆分fail-closed、native/path、路由三个PR?本地基线包含此前兼容修复,需确认目标base/依赖。 +- [ ] 是否拆分fail-closed、native/path、路由三个PR?该实现依赖前置兼容修复,需确认目标base及合并顺序。 - [ ] 是否增加默认关闭的experimental开关?当前没有此开关。 - [ ] Native argv、逐流encoding、normalized cursor、artifact facts是否应成为Core公开加法契约? - [ ] 在哪一层承担跨任务quota、artifact权限/retention/清理? diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md index 59c069f..4bd5def 100644 --- a/docs/experimental-native-verification.md +++ b/docs/experimental-native-verification.md @@ -2,13 +2,13 @@ > **这是实验性讨论的验证记录,不是发布或安全放行。** > 关联:[设计/接口/待讨论决定]()、[CHANGELOG](<../CHANGELOG.md>)。 -> 记录日期:2026-10-13(Asia/Hong_Kong);基于当时实际源码与隔离测试,不将本机条件推广为所有宿主保证。 +> 验证日期:2026-10-13。以下结果适用于所列运行环境,不代表所有宿主版本或生产场景均已通过。 ## 1. 验证基准与结果 -- 工作区基线:`4816cd8848a52f73f5f054f761d3ec862cb87ade`,此前MSYS兼容边界修复;实验与Agent路由是在此基线之后的工作树改动。 -- 当时实际安装 **DSH Core 0.2.1-alpha.2**,Node 24.13.0,Windows / Git for Windows。 -- Core版本不是CLI、桌面壳或npm管理器版本。包的既有宿主范围不等于新native能力已在所有版本测试;启用依赖真实公开能力。 +- 实验增量基线:`4816cd8848a52f73f5f054f761d3ec862cb87ade`(此前MSYS兼容边界修复)。 +- 测试环境:**DSH Core 0.2.1-alpha.2**、Node 24.13.0、Windows / Git for Windows。 +- 新native功能按公开能力探测启用;既有兼容版本范围之外的实际覆盖情况见后文待验收项。 - 最终普通全仓:**223 tests / 223 pass / 0 fail / 0 skip / 0 cancel,exit0**。 - 最终实际Core完整preview:**72 tests / 72 pass / 0 fail / 0 skip / 0 cancel,exit0**。 - 语法与`git diff --check`通过;npm dry-run确认新源码包含于发布文件集合。Dry-run不是已安装/发布,也不是新增生产版本。 @@ -22,7 +22,7 @@ | 静态/源码与公开契约 | 配置、SDK/types、实际安装导出、匹配MSYS因果资料 | 实现依赖与约束、提出机制假设 | 新OS沙箱可用或任意版本兼容 | | 纯单元/受控注入 | 请求parser、预算、流异常、字段schema、路由矩阵 | 确定性输入输出与拒绝/收尾规则 | 真实磁盘故障或任意producer行为 | | 实际Core + 禁止spawn桩 | ToolRuntime/scoped装配/提示组装 | 真Core结构化拒绝、零admission、注册/卸载/隐藏 | 生产GUI或真实native程序跑通 | -| 实际Core + 受控native程序 | LocalSubprocessRuntime/executeArgv/Jobs、固定bytes/READY/LATE | 本机真实argv、编码、kill/quiet/promotion与owner行为 | 全部原生parser、网络/SSH、真受限token | +| 实际Core + 受控native程序 | LocalSubprocessRuntime/executeArgv/Jobs、固定bytes/READY/LATE | 所测环境中的真实argv、编码、kill/quiet/promotion与owner行为 | 全部原生parser、网络/SSH、真受限token | | 实际PTC + Node/fs | ToolRuntime→NodePtcRuntime→LocalSubprocessRuntime→Node fs读自有输入 | 完整值helper子分发、动态路径、route/tag保持 | 完整插件apply/生产用户policy/模型自主选择 | | 实际Core提示/SDK生成 | SystemPrompt.assemble普通/PTC及工具visibility | Agent将收到正确的说明与可见binding | 模型每次一定选对 | | 未执行 | 生产GUI/full apply/LLM选择、OS权限、吞吐/RSS/retention | 仅列为待验收 | 不能作为通过项 | @@ -88,30 +88,30 @@ ## 5. 复现方式(开发测试,不是安装教程) -在工作区已有开发依赖与对应宿主条件下: +安装开发依赖并准备对应宿主后运行: ```bash npm test ``` -Windows实际Core测试使用操作者指定的宿主目录: +Windows实际Core测试通过环境变量指定宿主目录: ```bash DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:preview ``` -上面的尖括号是占位符,不是可执行路径;目录应来自实际目标宿主,不要从同名CLI推断。 +将占位符替换为待测试宿主的 resources/app 绝对路径。 -脚本使用[host loader](<../tests/host-loader.mjs>)只读解析实际安装导出,再预载[test-only evidence env](<../tests/preview-env.mjs>)。无安装、restart、profile修改、ACL/token/registry修改或真实网络/secret探查。 +脚本通过[host loader](<../tests/host-loader.mjs>)解析安装的Core导出,并预载[test-only evidence env](<../tests/preview-env.mjs>)创建测试证据目录。 -默认创建unique host temp scratch并保留测试证据;不是无副作用只读测试。每流artifact有预算,但测试目录留存仍归调用方。也可以提前建立自有绝对证据目录后提供: +测试默认在临时目录下创建独立scratch并保留证据。每流artifact有预算,测试目录不会自动清理;也可提前创建目录并设置以下环境变量: - DSH_NATIVE_OUTPUT_EVIDENCE_DIR - DSH_NATIVE_EVIDENCE_DIR - DSH_NATIVE_REVIEW_EVIDENCE_DIR - DSH_PATH_EVIDENCE_DIR -单独运行某个preview时须同时预载host-loader和preview-env,或自行提供它要求的已授权证据目录。不要运行package的version脚本来“验收”:它会改文件并git add,不属于测试。 +单独运行preview文件时,同时预载host-loader和preview-env,或设置该测试要求的证据目录环境变量。 ## 6. 失败记录与证据修正 @@ -129,7 +129,7 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev 独立审查旧态观察测试即使exit0,只证明缺陷被复现。最终将这些观察转为修复后的硬回归。独立专项复跑41/55与全仓结果重叠,不另加总数。 -原始本机日志、失败尝试与artifact保留在操作者本地证据目录;**本文件不是完整日志附件**。公开PR不附个人绝对路径、真实环境、密钥、Cookie或未经审查的raw producer数据。仓库测试提供复现机制;本记录公开的是可审阅断言、计数与限制。 +本文提供断言、结果计数和适用限制;完整日志与artifact由测试运行生成。以上测试文件可用于复现对应场景。 ## 7. 转正式功能前的验收清单 From 39946f207992bf829fd255d81aa2c5dd0d1fa2db Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Tue, 13 Oct 2026 22:28:18 +0800 Subject: [PATCH 3/9] feat: add live default-on experimental native and fail-closed switch --- AGENTS.md | 4 +- CHANGELOG.md | 18 ++++++++ README.md | 9 ++-- README_EN.md | 8 ++-- docs/experimental-native-execution.md | 22 ++++++---- docs/experimental-native-verification.md | 13 ++++++ src/bash-tool.js | 2 +- src/client.js | 52 ++++++++++++++++++++++++ src/experimental-tools.js | 17 ++++++++ src/index.js | 4 ++ src/inline-tools.js | 8 ++-- src/native-executor.js | 13 +++++- src/native-tool.js | 15 +++---- src/path-tool.js | 14 +++---- src/shell.js | 34 +++++++++++++--- tests/client-regressions.mjs | 36 ++++++++++++++-- tests/native-executor.mjs | 17 ++++++++ tests/native-preview.mjs | 29 ++++++++++++- tests/path-tool.mjs | 13 ++++++ tests/runtime-preview.mjs | 32 ++++++++++++++- tests/smoke.mjs | 12 ++++++ 21 files changed, 321 insertions(+), 51 deletions(-) create mode 100644 src/experimental-tools.js diff --git a/AGENTS.md b/AGENTS.md index e7847aa..f9ef7e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ 1. **执行器替换 full-access argv;Windows 未验证受限能力必须 fail-closed(Unreleased)**: 沙箱/生命周期基础设施沿用 `@deepseek-ai/dsh-bash-sandbox`;full-access 必须显式 Git for Windows argv(裸 bash 会命中 WSL 占位)。Windows read-only/workspace-write/缺失策略在工作目录准备、 - Jobs admission 与 spawn 前拒绝;不得恢复旧 unconfined 绕过,不得自动换工具/模式。 + Jobs admission 与 spawn 前拒绝(`experimentalNative` 默认 true);不得自动换工具/模式。 + 按 PR #26 维护者裁决,用户可实时关闭该 volatile 行 Config 开关,恢复明确标注 unconfined + 的旧 Bash 行为并注销 native/path 两工具;旧 SDK 引用必须拒绝,新 native spawn 必须重验开关。 拒绝使用实际宿主 SandboxUnavailableError,导出缺失仍必须拒绝;官方 root provider 不变。 **拒绝不是恢复真围栏**。匹配的现代 MSYS signal pipe 有显式 SD 且缺 restricting SID, cygheap 为 VirtualAlloc;实际 Core 已补 TokenDefaultDacl。不得再用“只补默认DACL/所有未来版本 diff --git a/CHANGELOG.md b/CHANGELOG.md index e8804eb..88ddb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ ## Unreleased — 2026-10-13 +### PR #26:维护者裁决与运行时开关(fix / feat / docs / test) + +- 行 Config 新增 volatile 布尔 `experimentalNative`,默认 true,旧配置缺键亦开启;设置卡即时编辑。 + 开启时 Windows 受限/未确定 Bash 拒绝;关闭恢复明确标注 `unconfined` 的旧版 Bash 与实例级日志,不提供真实 shell 写入围栏。 +- 两个新工具随开关实时注销/注册,既有会话无需重启;旧 SDK 引用关闭后拒绝。路径工具在翻译前检查; + native 在准备入口、异步准备后、执行入口和最终 spawn 重验。已启动后台 producer 保留生命周期。 +- 拒绝错误与 Bash 描述说明设置卡关闭路径,以及由用户在会话访问模式控件选择 danger-full-access 的路径。 + 21 份词典键齐备,中文/英文先行,其余 19 份暂用英文,合并前补本地化。 +- 实验已 rebase 到 #24 最新 c79332e,保留其 promotion sandbox 警示回归,显式 OFF 验证旧行为。 + #24 仍 OPEN,合并后到 main 的 rebase 尚待;建议两步 review(fail-closed + 开关/UI;native/path/输出/路由),保留 Draft/RFC。 +- 长期跟踪:Core 开放对应 native/encoding/closed DTO API 后优先整体迁移并退役私有实现;artifact 自管,controller 缺失保持拒绝。 + 正式门槛仍为真实 Agent 选路、生产 GUI、维护者 win32 全量验证及非 CRT 矩阵抽测。 +- 本轮纠正:实际 Core 的 tools.restrict 只认全局工具,初版 agent-local mask 失败;改为拥有者动态注销/注册。 + artifact 准备期间关闭的负例推动增加准备后检查,避免把拒绝落成 providerFailure;零 spawn、scope 释放及再次开启通过。 +- 验证:Windows / Node 24.13.0 / Core 0.2.1-alpha.2,普通完整回归 **226/226**、实际 Core 完整 preview **75/75**, + exit0、无 skip/cancel;syntax、diff、npm pack dry-run 通过。不等同于上述四项正式验收。 +- 相关:[PR #26 裁决](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6146363513)、[前置 PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24)。 + **类型**:feat(显式运行时路径/native typed argv/逐流解码) / fix(受限请求 fail-closed、生命周期与输出事实) / test / docs。 ### Agent 选工具指引补齐(Unreleased) diff --git a/README.md b/README.md index d1588ec..8bbcc59 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,10 @@ bundle patch(`cordis.patch.yml`)是**纯新增**(v0.39.0 起):只插入本插件 **POSIX 宿主**:自有执行器的受限路径仍继承官方后端与拒绝分类。本节只讲 Windows。 -**Unreleased 的 Windows 自有 bash/native 工具采用 fail-closed**:`read-only`、 +**Unreleased 的 Windows 自有 bash/native 工具在 experimentalNative 开启时(默认)采用 fail-closed**:`read-only`、 `workspace-write` 或缺失/未解析的策略在执行前拒绝;仅真实解析为 `danger-full-access` 的调用可以执行。受限请求不会登记/启动无围栏进程,也不会自动 -回退 pwsh/cmd/WSL、切模式或绕过文件工具拒绝。此为明确行为变化,不再保留旧版标注后绕过。 +回退 pwsh/cmd/WSL、切模式或绕过文件工具拒绝。用户可在设置中关闭 experimentalNative,恢复明确标注 unconfined 的旧版 Bash 行为。 - **拒绝不是沙箱修复**:当前仍无已验证的受限 Git Bash。历史失败为 Win32 error 5 / 0xC0000142;匹配本机 MSYS 源码后发现 signal pipe 显式 SD 缺 restricting SID, @@ -394,6 +394,9 @@ PTC/创造及用户自建模式一律覆盖。 ## 显式工具(Unreleased) +**运行时开关 `experimentalNative`(默认 true):** 设置 → 插件 → dsh-gitbash-shell →「实验原生执行 / fail-closed」。开启时 Windows 受限/未确定模式拒绝 Bash,提供可用的两个显式工具;关闭立即注销并禁用两个工具,恢复明确标注 `unconfined` 的旧版 Bash(不执行 shell 文件写入限制)。修改作用于现有会话后续调用,无需重启,不终止已启动的任务。拒绝时用户也可在会话访问模式控件选择 `danger-full-access`;Agent 不得自行放宽权限。与路径方言及 codePaths 开关独立。 + + 两个工具随 Windows 的自有 bash 工具挂入同一 agent-owned scope;不替换官方工具或 root provider,已有同名工具不覆盖。旧 Core 缺少公开 native executor/OutputCollector 能力时不 注册 native 工具,bash/path 不受影响;minimal 的持久终端不通过此补位通道。 @@ -455,7 +458,7 @@ return await fs.readFile(resolved.native_path, 'utf8'); 直接调用 Node fs/path 时优先明确的 Windows drive-letter 或 cwd-relative 路径; 工具 path/file_path 字段仍走工具参数翻译。词法扫描不能识别路由、搜索数据或 shell command 的用途;此类字符串不要依赖自动重写,必要时关闭 `codePaths`(提示同步标为 OFF)。 -- **沙箱**:参见上方安全章节。Unreleased 已改为受限请求执行前拒绝,不是恢复了真实围栏;文件工具拒绝不授权 shell/native 重试。 +- **沙箱**:参见上方安全章节。Unreleased 在 experimentalNative 开启时改为受限请求执行前拒绝,不是恢复了真实围栏;文件工具拒绝不授权 shell/native 重试。 ## 与 dsh-better-sidebar 联动 diff --git a/README_EN.md b/README_EN.md index e287d24..96d5084 100644 --- a/README_EN.md +++ b/README_EN.md @@ -94,17 +94,19 @@ to bring old sessions back as-is, turn `variantPresets` on. **POSIX hosts:** the executor's restricted path still inherits the official backend and denial classification. This section concerns Windows. -**Unreleased Windows bash/native tools fail closed:** read-only, workspace-write or unresolved policy is refused before execution. Only an actually resolved danger-full-access call may run. Restricted requests never admit/start an unconfined process, automatically switch permissions/interpreters, or bypass file-tool denials. This changes the old labelled-bypass behaviour. +**Unreleased Windows bash/native tools fail closed while experimentalNative is ON (default):** read-only, workspace-write or unresolved policy is refused before execution. Only an actually resolved danger-full-access call may run. Restricted requests never admit/start an unconfined process, automatically switch permissions/interpreters, or bypass file-tool denials. This changes the old labelled-bypass behaviour. - **Refusal is not restored confinement.** No verified confined Git Bash is provided. Historical failures were Win32 error 5 / 0xC0000142. The matched modern MSYS signal pipe uses an explicit SD without a restricting SID; cygheap uses VirtualAlloc, and Core already supplements TokenDefaultDacl. Neither a default-DACL-only fix nor permanent impossibility is established; IPC/OS solutions still require isolated-environment validation. - **Per-call authority:** a full-access deployment default cannot widen a read-only session. Native preparation, async boundaries and the spawn seam recheck authority. Cancellation, owner disposal and direct kill mark output incomplete instead of claiming natural success. - **Separate file policy:** official file tools retain their own checks; reads are not necessarily workspace-only. The path helper only converts strings: no existence check or permission grant. A refusal never authorizes another-tool workaround. - **Scope:** this gate covers this plugin's own bash/native tools. Sidebar interactive terminals, minimal's persistent PTY and other plugins' executors do not acquire confinement from it. The official root PowerShell executor stays untouched. -Historical unconfined records/rendering remain readable; they do not permit new calls to downgrade. See CHANGELOG v0.13.2 for history and Unreleased for current behaviour/tests. +Historical unconfined records/rendering remain readable; only the user’s explicit experimentalNative opt-out restores the legacy route. See CHANGELOG v0.13.2 for history and Unreleased for current behaviour/tests. ## Explicit tools (Unreleased) +**Runtime switch `experimentalNative` (default true):** Settings > Plugins > dsh-gitbash-shell > Experimental native execution / fail-closed. ON refuses restricted/unresolved Windows Bash and offers available companions. OFF immediately unregisters/disables both tools and restores legacy explicitly `unconfined` Bash, which does not enforce shell file-write restrictions. Changes affect subsequent calls in existing sessions without restart; already running jobs continue. Refusals also guide the user to select `danger-full-access` in the session access-mode control; agents must not relax permissions automatically. This switch is independent of path dialect/codePaths. + Both companions join the Windows own-bash agent scope without replacing official tools/root providers. Existing names are not overwritten. Missing public native executor/OutputCollector capability skips native without breaking bash/path; minimal's persistent terminal does not use this inline patch. - **gitbash_to_native_path**: `{ msys_path: completeValue } → { native_path }`, using fresh shared mount facts. Native Windows/cwd-relative values may pass through; unknown MSYS roots/missing facts, URLs, empty/NUL values reject. Independent of automatic dialect/codePaths switches, with no filesystem access or permission grant. @@ -156,7 +158,7 @@ output encodings. Lexical scanning cannot distinguish routes/search data/shell command strings from paths. Do not rely on rewriting those values; disable `codePaths` when needed (the model hint now accurately states OFF). -- **Security**: Unreleased refuses restricted calls before execution; this is not restored confinement. A file-tool policy denial never authorizes a shell/native retry. +- **Security**: Unreleased refuses restricted calls before execution while experimentalNative is ON; this is not restored confinement. A file-tool policy denial never authorizes a shell/native retry. ## Config diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md index b499e16..8d2d952 100644 --- a/docs/experimental-native-execution.md +++ b/docs/experimental-native-execution.md @@ -15,7 +15,7 @@ 另补 Agent 路由指引,让上述机制不是仅存在于 README 中的“需要用户手动教模型”的能力。 -**维护者需要确认的是取舍,不是只看测试绿色:** 是否接受拒绝行为变化、是否需要真正的 opt-in 开关、哪些契约应移入 Core,以及缺少哪些验收才能从实验转为正式功能。 +维护者已在 [PR #26 裁决](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6146363513)接受 fail-closed 方向,要求默认开启且可实时关闭的行 Config 开关。正式验收门槛仍未完成。 ## 2. 问题与非目标 @@ -53,9 +53,13 @@ IPC/Token/AppContainer/VM 路线仍需隔离环境的真实正负权限验证; | 原生参数 | 通过 Bash/MSYS,可能转换 | typed native 入口只转换标明的路径 | | Node 路径 | 字面量尽力重写 | 可显式转换运行时完整值;legacy 默认未改 | | 编码 | 原有 UTF-8 捕获边界 | 新入口逐流显式编码,默认 utf8/replace | -| 设置/界面 | 现有 Config/UI | 不增字段;没有独立 experimental enable 开关 | +| 设置/界面 | 现有 Config/UI | `experimentalNative` volatile 布尔,默认 true,同卡实时编辑 | -**重要:Draft 标签不等于运行时 opt-in。** 当前源码被安装后,Windows own-bash scope 会挂载可用 companion;拒绝行为也会生效。这是否应该增加默认关闭的 feature flag,是待讨论项,而不是已经实现的保护。 +**运行时开关:** 设置 → 插件 → dsh-gitbash-shell →「实验原生执行 / fail-closed」,权威值是 `gitbash-shell` 行 Config 的 `experimentalNative`,默认 true(包括旧配置缺键)。开启时受限/未确定 Windows Bash 拒绝,两个 companions 在能力可用且不冲突时注册。关闭时实时注销两个工具(旧 SDK 引用也拒绝),Bash 恢复旧版明确标注 `enforcement: 'unconfined'` 的受限执行并一次性日志告知;未确定策略保持旧版宿主委派。关闭不提供真实围栏,shell 文件写入限制不执行;官方文件工具策略始终独立。 + +变更作用于现有会话后续调用,无需重启,不终止已开始的后台 producer。native 在准备入口、异步准备后、执行入口及最终 spawn 重读开关;已关闭则不得开始新进程。重新开启恢复工具注册与拒绝机制。`posixPaths`、`codePaths`、`subagentDialect` 独立,不改变安全开关。拒绝错误说明设置关闭路径及用户在会话访问模式控件选择 `danger-full-access` 的路径,Agent 不得自行调整。 + +21 份词典键已对齐;中文/英文文案先行,其余 19 份暂用明确的英文文案,合并前与维护者补本地化。 ## 4. Agent 应如何选择 @@ -204,12 +208,12 @@ Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。 ## 12. 合并讨论 / 未覆盖 -- [ ] 是否接受受限模式从旧绕过改为拒绝的breaking体验? -- [ ] 是否拆分fail-closed、native/path、路由三个PR?该实现依赖前置兼容修复,需确认目标base及合并顺序。 -- [ ] 是否增加默认关闭的experimental开关?当前没有此开关。 -- [ ] Native argv、逐流encoding、normalized cursor、artifact facts是否应成为Core公开加法契约? -- [ ] 在哪一层承担跨任务quota、artifact权限/retention/清理? -- [ ] Controller缺失/Jobs promotion如何形成一致的前台体验? +- [x] 接受 fail-closed,新增默认 true 的 volatile 开关,关闭恢复旧行为。 +- [x] Controller 缺失保持明确拒绝,不静默改前台重复执行。 +- [x] Artifact ownership/quota/retention 由插件层自管;跨调用 quota/purge 尚未实现,不宣称已验收。 +- [ ] **长期跟踪(不阻塞本 PR)**:Core 将来开放 native typed argv、逐流 encoding、closed DTO 等对应公开 API 时,遵循官方优先纪律整体切换并退役插件私有实现。 +- [ ] **stack / 拆分评估**:#24 尚未合并,当前实验已接在其最新 `c79332e` 后;#24 合并后必须 rebase 到上游 main,保持 RFC。建议两步 review:① Bash fail-closed + 行 Config/UI/拒绝引导及旧行为回退;② native/path、逐流输出与可见性路由。统一开关应在第一步建立,第二步接同一字段。当前 PR 保留完整 RFC 供跨层契约核验,没有另开 PR。 +- [ ] **转正式的四项门槛**:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 Windows 真机全量验证、非 CRT 解释器矩阵抽测。四项齐后才谈 ready for review → release。 - [ ] 真实Agent选工具评测、生产GUI/full apply、会话replay尚未验收。 - [ ] 真restricted-token/ACL/IPC/VM正负权限、PTY/SSH、任意非CRT/解释器quoting尚未验收。 - [ ] 真实disk exhaustion、吞吐/RSS峰值、跨任务DOS/自动purge尚未验收。 diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md index 4bd5def..fd30092 100644 --- a/docs/experimental-native-verification.md +++ b/docs/experimental-native-verification.md @@ -143,3 +143,16 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev - [ ] 决定哪些接口应该成为Core公开加法契约。 在这些项完成前,保持Draft/实验讨论定位,不称普遍编码修复或新沙箱实现。 + +## PR #26 运行时开关裁决后的验证 + +本轮普通完整回归 **226/226**,实际安装 Core 完整 preview **75/75**,均 exit0、无 skip/cancel。环境仍为 Windows、Node 24.13.0、Core 0.2.1-alpha.2。新增覆盖: + +- 卡片缺键默认 ON、两向写同一行 Config 字段、21 字典键对齐(其余 19 语言暂用英文)。 +- 实际 ToolRuntime 中现有会话 ON/OFF 反复切换、初始 OFF 后启用、工具 schema 注销/注册、受限 Bash 拒绝/旧 unconfined 结果;root shell 不变。 +- 旧路径定义关闭后零翻译;native 已准备请求关闭后零 scope/adapter;artifact 异步准备期间关闭后零 spawn、scope 释放,重新开启后真实 Node producer 运行一次。 +- #24 actual promotion DTO 测试以显式 OFF 验证旧 sandbox facts 提示,full-access 无警示。 + +初版 tools.restrict agent-local mask 被实际 Core 拒绝;改为动态拥有者注册。artifact 负例初版检查仅在 spawn facade 内,被 Core 转为失败 handle;增加准备后检查后以明确错误拒绝,未放宽零 spawn 断言。 + +仍待正式四项验收:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 win32 真机全量验证、非 CRT 解释器矩阵抽测。#24 未合并,合并后到上游 main 的 rebase 尚待。 diff --git a/src/bash-tool.js b/src/bash-tool.js index 0526b1e..970a263 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -102,7 +102,7 @@ export function bashDescription() { + 'Git for Windows (MSYS) converts path-looking arguments when invoking native Windows programs: ipconfig /all and cmd /c may break; use ipconfig //all or cmd //c, or a per-command MSYS2_ARG_CONV_EXCL prefix. ' + 'Exclusions are prefixes: excluding /c also excludes /c/... paths, so use native drive-letter paths for those arguments; do not disable conversion globally. Verify the expected effect as well as the exit code. ' + 'Output is decoded as UTF-8; native Windows programs may emit OEM/ANSI bytes (for example CP936/GBK) and display garbled text. Use a program UTF-8 option or explicit known-encoding conversion before capture; locale variables alone do not fix native output, and mixed streams cannot safely be guessed or decoded globally as GBK. ' - + 'On Windows, read-only/workspace-write or unresolved policy is refused before this Git Bash command starts because verified confinement is unavailable. Only an explicitly resolved danger-full-access policy can execute this route. Do not switch tools or permissions automatically to bypass a refusal; official file-tool write/edit policy remains separate.' + + 'On Windows, while Experimental native execution / fail-closed (experimentalNative, default ON) is enabled, read-only/workspace-write or unresolved policy is refused before this Git Bash command starts because verified confinement is unavailable. Only an explicitly resolved danger-full-access policy can execute this route. The user can turn off that switch in Settings > Plugins > dsh-gitbash-shell to restore explicitly UNCONFINED legacy Bash, or select danger-full-access in the session access-mode control. Do not switch tools or permissions automatically to bypass a refusal; official file-tool write/edit policy remains separate.' } /** The official foreground/present call view (index.ts:108). */ diff --git a/src/client.js b/src/client.js index 77d1ac0..c8ee46e 100644 --- a/src/client.js +++ b/src/client.js @@ -138,6 +138,8 @@ window.__ModuleLoader__.load({ "tools.updating": "更新中…", "variant.label": "四个「·Git Bash」变体模式", "variant.hint": "默认关闭:官方四个模式经行内工具补位直接获得 Git Bash,模式列表保持官方原样。开启后注册四个变体(standard/minimal/ptc/cordis · Git Bash)并停用官方模式的工具补位——两者互斥。钉在变体 id 上的旧会话在关闭升级后需重新选择模式;变更对新会话生效。", + "experimental.label": "实验原生执行 / fail-closed", + "experimental.hint": "默认开启:Windows 受限或未确定访问模式拒绝 Git Bash,并提供 gitbash_native / gitbash_to_native_path。修改立即作用于现有会话的后续调用。关闭会隐藏并禁用两个工具,恢复明确标注 UNCONFINED 的旧版 Bash:不执行 shell 文件写入限制。如保留拒绝机制,请由用户在会话访问模式控件中选择 danger-full-access。", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "默认关闭:开启后宿主官方的 pwsh 工具对会话可见——它是真正的 PowerShell(官方解析器自动优先 PowerShell 7),可直接用于 Windows 原生操作(注册表/服务/WMI)。关闭(默认)时该工具被隐藏,主机保持 bash 优先。无需安装任何额外组件;旧 PowerShell 7 旁路工具已随新架构退役。命令仍然首选 bash 工具。", "fctx.label": "FastCtx 文件工具(gb_*)", @@ -216,6 +218,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Updating…", "variant.label": "The four \"· Git Bash\" variant presets", "variant.hint": "Off by default: the four OFFICIAL presets get Git Bash through the inline tool patch and the roster stays official. On: register the four variants (standard/minimal/ptc/cordis · Git Bash) and stop patching the official presets — the two are exclusive. A session pinned to a variant id must re-select a preset after upgrading with the switch off. Applies to new sessions.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh tool (official PowerShell)", "pwsh.hint": "Off by default: when on, the host's official pwsh tool becomes visible to sessions — REAL PowerShell (the official resolver prefers PowerShell 7 automatically) for Windows-native work (registry/services/WMI). Off (the default) hides it and keeps the host bash-first. Nothing extra to install; the retired PowerShell 7 side-channel tool is gone. Commands still prefer the bash tool.", "fctx.label": "FastCtx file tools (gb_*)", @@ -304,6 +308,8 @@ window.__ModuleLoader__.load({ "tools.updating": "جارٍ الترقية…", "variant.label": "الأنماط الأربعة «· Git Bash»", "variant.hint": "معطَّل افتراضيًا: تحصل الأنماط الرسمية الأربعة على Git Bash عبر ترقيع الأدوات داخل العملية وتبقى القائمة رسمية. عند التفعيل تُسجَّل الأنماط الأربعة المتغيّرة وتتوقف ترقيع الأنماط الرسمية — الاثنان حصريان متباددان. الجلسة المثبَّتة على معرّف متغيّر تحتاج إعادة اختيار بعد الترقية مع بقاء المفتاح معطَّلًا.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "أداة pwsh (PowerShell الرسمية)", "pwsh.hint": "مُعطَّلة افتراضيًا: عند التفعيل تصبح أداة pwsh الرسمية للمضيف مرئية للجلسات — وهي PowerShell حقيقية (يفضِّل المُحلِّل الرسمي PowerShell 7 تلقائيًا) لأعمال Windows الأصلية (السجل/الخدمات/WMI). وعند الإيقاف (الافتراضي) تُخفى الأداة ويبقى المضيف يفضِّل bash. لا حاجة لتثبيت أي شيء إضافي؛ أداة PowerShell 7 الجانبية القديمة أُوقفت مع المعمارية الجديدة. مرِّر الأوامر عبر أداة bash كالمعتاد.", "fctx.label": "أدوات ملفات FastCtx (gb_*)", @@ -382,6 +388,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Wird aktualisiert…", "variant.label": "Die vier „· Git Bash“-Varianten", "variant.hint": "Standardmäßig aus: Die vier OFFIZIELLEN Presets bekommen Git Bash durch den Inline-Tool-Patch, die Liste bleibt offiziell. Ein: Registriert die vier Varianten und stoppt das Patchen der offiziellen Presets — beides schließt sich aus. Eine am Varianten-Id festhängende Sitzung muss nach dem Upgrade neu wählen. Gilt für neue Sitzungen.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh-Werkzeug (offizielles PowerShell)", "pwsh.hint": "Standardmäßig aus: Beim Einschalten wird das offizielle pwsh-Werkzeug des Hosts für Sitzungen sichtbar — ECHTES PowerShell (der offizielle Resolver bevorzugt PowerShell 7 automatisch) für Windows-natives Arbeiten (Registry/Dienste/WMI). Im Aus-Zustand (der Standard) bleibt es verborgen und der Host bleibt bash-first. Nichts zusätzlich zu installieren; das alte PowerShell-7-Seitenkanal-Werkzeug ist mit der neuen Architektur entfallen. Befehle laufen weiterhin bevorzugt über das bash-Werkzeug.", "fctx.label": "FastCtx-Dateiwerkzeuge (gb_*)", @@ -460,6 +468,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Mise à jour…", "variant.label": "Les quatre variantes « · Git Bash »", "variant.hint": "Désactivé par défaut : les quatre presets OFFICIELS obtiennent Git Bash via le correctif d’outils en ligne et la liste reste officielle. Activé : enregistre les quatre variantes et cesse de corriger les presets officiels — les deux s’excluent. Une session attachée à un id de variante doit être rechoisie après mise à niveau. S’applique aux nouvelles sessions.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Outil pwsh (PowerShell officiel)", "pwsh.hint": "Désactivé par défaut : activé, l'outil pwsh officiel de l'hôte devient visible pour les sessions — du VRAI PowerShell (le résolveur officiel préfère automatiquement PowerShell 7) pour le travail Windows natif (registre/services/WMI). Désactivé (défaut), il reste masqué et l'hôte garde bash en priorité. Rien à installer ; l'ancien outil PowerShell 7 en parallèle a été retiré avec la nouvelle architecture. Passez toujours par l'outil bash en premier.", "fctx.label": "Outils de fichiers FastCtx (gb_*)", @@ -538,6 +548,8 @@ window.__ModuleLoader__.load({ "tools.updating": "अपडेट हो रहा है…", "variant.label": "चार «· Git Bash» वेरिएंट प्रीसेट", "variant.hint": "डिफ़ॉल्ट रूप से बंद: चार आधिकारिक प्रीसेट को इनलाइन टूल पैच से Git Bash मिलता है और सूची आधिकारिक रहती है। चालू: चार वेरिएंट पंजीकृत होते हैं और आधिकारिक प्रीसेट पैचिंग रुक जाती है — दोनों परस्पर अनन्य हैं। वेरिएंट id पर बंधे सत्र को अपग्रेड के बाद दोबारा चुनना पड़ेगा। नए सत्रों पर लागू।", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh टूल (आधिकारिक PowerShell)", "pwsh.hint": "डिफ़ॉल्ट रूप से बंद: चालू करने पर होस्ट का आधिकारिक pwsh टूल सत्रों के लिए दिखाई देता है — असली PowerShell (आधिकारिक रिज़ॉल्वर स्वतः PowerShell 7 को प्राथमिकता देता है), Windows-नेटिव काम (रजिस्ट्री/सेवाएँ/WMI) के लिए सीधे उपयोगी। बंद (डिफ़ॉल्ट) रहने पर यह छिपा रहता है और होस्ट bash-प्राथमिकता बनाए रखता है। कुछ भी अतिरिक्त इंस्टॉल करने की ज़रूरत नहीं; पुराना PowerShell 7 साइड-चैनल टूल नए आर्किटेक्चर के साथ सेवामुक्त हो गया। कमांड अब भी पहले bash टूल से चलाएँ।", "fctx.label": "FastCtx फ़ाइल उपकरण (gb_*)", @@ -616,6 +628,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Memperbarui…", "variant.label": "Empat preset varian «· Git Bash»", "variant.hint": "Nonaktif secara bawaan: empat preset RESMI mendapat Git Bash melalui patch alat inline dan daftar tetap resmi. Aktif: mendaftarkan empat varian dan berhenti mem-patch preset resmi — keduanya saling eksklusif. Sesi yang terikat pada id varian harus memilih ulang setelah peningkatan. Berlaku untuk sesi baru.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Alat pwsh (PowerShell resmi)", "pwsh.hint": "Nonaktif secara default: saat aktif, alat pwsh resmi milik host menjadi terlihat bagi sesi — PowerShell ASLI (resolver resmi otomatis memilih PowerShell 7) untuk kerja native Windows (registry/layanan/WMI). Nonaktif (default) menyembunyikannya dan host tetap bash-first. Tidak perlu menginstal apa pun; alat samping PowerShell 7 yang lama sudah dipensiunkan bersama arsitektur baru. Jalankan perintah tetap lewat alat bash terlebih dahulu.", "fctx.label": "Alat berkas FastCtx (gb_*)", @@ -694,6 +708,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Aggiornamento…", "variant.label": "Le quattro varianti «· Git Bash»", "variant.hint": "Disattivo per impostazione predefinita: i quattro preset UFFICIALI ottengono Git Bash tramite la patch inline degli strumenti e l’elenco resta ufficiale. Attivo: registra le quattro varianti e smette di correggere i preset ufficiali — i due si escludono. Una sessione legata a un id di variante va riscelta dopo l’aggiornamento. Vale per le nuove sessioni.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Strumento pwsh (PowerShell ufficiale)", "pwsh.hint": "Disattivo per impostazione predefinita: quando attivo, lo strumento pwsh ufficiale dell'host diventa visibile alle sessioni — VERO PowerShell (il resolver ufficiale preferisce automaticamente PowerShell 7) per lavoro Windows nativo (registro/servizi/WMI). Disattivo (predefinito), resta nascosto e l'host resta bash-first. Nulla da installare in più; il vecchio strumento laterale PowerShell 7 è andato in pensione con la nuova architettura. Instrada comunque i comandi prima tramite lo strumento bash.", "fctx.label": "Strumenti file FastCtx (gb_*)", @@ -772,6 +788,8 @@ window.__ModuleLoader__.load({ "tools.updating": "更新中…", "variant.label": "4 つの「· Git Bash」バリアント", "variant.hint": "既定はオフ:公式 4 プリセットはインラインツールパッチで Git Bash を得られ、一覧は公式のまま。オンにすると 4 バリアントを登録し、公式プリセットへのパッチを停止(両者は排他)。バリアント id に固定されたセッションはアップグレード後に再選択が必要。新規セッションに適用。", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh ツール(公式 PowerShell)", "pwsh.hint": "既定はオフ:オンにすると、ホスト公式の pwsh ツールがセッションから見えるようになります — 本物の PowerShell です(公式リゾルバが自動で PowerShell 7 を優先)。レジストリ/サービス/WMI など Windows ネイティブ作業にそのまま使えます。オフ(既定)では非表示のまま、ホストは bash 優先を保ちます。追加インストールは一切不要。旧 PowerShell 7 バイパスツールは新アーキテクチャで退役しました。コマンドは引き続き bash ツールを優先してください。", "fctx.label": "FastCtx ファイルツール(gb_*)", @@ -850,6 +868,8 @@ window.__ModuleLoader__.load({ "tools.updating": "업데이트 중…", "variant.label": "네 개의 «· Git Bash» 변형 프리셋", "variant.hint": "기본 꺼짐: 공식 4개 프리셋이 인라인 도구 패치로 Git Bash를 얻으며 목록은 공식 그대로입니다. 켜면 4개 변형을 등록하고 공식 프리셋 패칭을 중단합니다(상호 배타). 변형 id 에 고정된 세션은 업그레이드 후 다시 선택해야 합니다. 새 세션에 적용.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh 도구(공식 PowerShell)", "pwsh.hint": "기본적으로 꺼짐: 켜면 호스트의 공식 pwsh 도구가 세션에 표시됩니다 — 진짜 PowerShell이며(공식 리졸버가 자동으로 PowerShell 7을 우선), 레지스트리/서비스/WMI 등 Windows 네이티브 작업에 바로 쓸 수 있습니다. 꺼짐(기본값)이면 숨겨진 채로 호스트는 bash 우선을 유지합니다. 추가 설치가 전혀 필요 없습니다. 이전 PowerShell 7 우회 도구는 새 아키텍처와 함께 은퇴했습니다. 명령은 여전히 bash 도구를 먼저 사용하세요.", "fctx.label": "FastCtx 파일 도구(gb_*)", @@ -928,6 +948,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Bijwerken…", "variant.label": "De vier «· Git Bash»-varianten", "variant.hint": "Standaard uit: de vier OFFICIËLE presets krijgen Git Bash via de inline-toolpatch en de lijst blijft officieel. Aan: registreert de vier varianten en stopt het patchen van de officiële presets — sluit elkaar uit. Een sessie die aan een variant-id vastzit moet na een upgrade opnieuw kiezen. Geldt voor nieuwe sessies.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh-hulpmiddel (officiële PowerShell)", "pwsh.hint": "Standaard uit: ingeschakeld wordt het officiële pwsh-hulpmiddel van de host zichtbaar voor sessies — ECHTE PowerShell (de officiële resolver geeft automatisch de voorkeur aan PowerShell 7) voor Windows-natief werk (register/services/WMI). Uit (de standaard) blijft het verborgen en blijft de host bash-first. Niets extra te installeren; het oude PowerShell-7-sidechannel-hulpmiddel is met de nieuwe architectuur met pensioen. Voer opdrachten nog steeds bij voorkeur uit via het bash-hulpmiddel.", "fctx.label": "FastCtx-bestandshulpmiddelen (gb_*)", @@ -1006,6 +1028,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Aktualizowanie…", "variant.label": "Cztery warianty «· Git Bash»", "variant.hint": "Domyślnie wyłączone: cztery OFICJALNE presety dostają Git Bash przez inline-owy patch narzędzi, a lista pozostaje oficjalna. Włączenie rejestruje cztery warianty i zatrzymuje patchowanie oficjalnych presetów — opcje wykluczają się. Sesja przypięta do id wariantu musi zostać wybrana ponownie po aktualizacji. Dotyczy nowych sesji.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Narzędzie pwsh (oficjalny PowerShell)", "pwsh.hint": "Domyślnie wyłączone: po włączeniu oficjalne narzędzie pwsh hosta staje się widoczne dla sesji — PRAWDZIWY PowerShell (oficjalny resolver automatycznie preferuje PowerShell 7) do natywnej pracy w Windows (rejestr/usługi/WMI). Wyłączone (domyślnie) pozostaje ukryte, a host pozostaje bash-first. Niczego nie trzeba dodatkowo instalować; stare narzędzie poboczne PowerShell 7 przeszło na emeryturę wraz z nową architekturą. Polecenia nadal wykonuj najpierw przez narzędzie bash.", "fctx.label": "Narzędzia plików FastCtx (gb_*)", @@ -1084,6 +1108,8 @@ window.__ModuleLoader__.load({ "tools.updating": "A atualizar…", "variant.label": "As quatro variantes «· Git Bash»", "variant.hint": "Desativado por padrão: os quatro presets OFICIAIS recebem Git Bash pelo patch de ferramentas em linha e a lista permanece oficial. Ativado: registra as quatro variantes e deixa de corrigir os presets oficiais — mutuamente exclusivos. Uma sessão presa a um id de variante precisa ser reescolhida após a atualização. Vale para novas sessões.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Ferramenta pwsh (PowerShell oficial)", "pwsh.hint": "Desativado por padrão: ao ativar, a ferramenta pwsh oficial do host fica visível para as sessões — PowerShell REAL (o resolvedor oficial prefere automaticamente o PowerShell 7) para trabalho nativo do Windows (registro/serviços/WMI). Desativado (o padrão), ela permanece oculta e o host continua bash-first. Nada extra para instalar; a antiga ferramenta paralela PowerShell 7 foi aposentada com a nova arquitetura. Encaminhe os comandos ainda pela ferramenta bash primeiro.", "fctx.label": "Ferramentas de arquivos FastCtx (gb_*)", @@ -1162,6 +1188,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Обновление…", "variant.label": "Четыре варианта «· Git Bash»", "variant.hint": "По умолчанию выключено: четыре ОФИЦИАЛЬНЫХ пресета получают Git Bash через встроенный патч инструментов, список остаётся официальным. Включено — регистрирует четыре варианта и прекращает патчить официальные пресеты (взаимоисключающе). Сессия, привязанная к id варианта, после обновления должна выбрать пресет заново. Действует для новых сессий.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Инструмент pwsh (официальный PowerShell)", "pwsh.hint": "По умолчанию выключено: при включении штатный pwsh-инструмент хоста становится видимым для сессий — НАСТОЯЩИЙ PowerShell (официальный резолвер автоматически предпочитает PowerShell 7) для нативной работы Windows (реестр/службы/WMI). В выключенном состоянии (по умолчанию) он скрыт, и хост остаётся bash-приоритетным. Ничего дополнительно устанавливать не нужно; старый обходной инструмент PowerShell 7 ушёл в отставку вместе с новой архитектурой. Команды по-прежнему выполняйте сначала через инструмент bash.", "fctx.label": "Файловые инструменты FastCtx (gb_*)", @@ -1240,6 +1268,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Uppdaterar…", "variant.label": "De fyra «· Git Bash»-varianterna", "variant.hint": "Av som standard: de fyra OFFICIELLA presetten får Git Bash via inline-verktygspatchen och listan förblir officiell. På: registrerar de fyra varianterna och slutar patcha de officiella presetten — ömsesidigt uteslutande. En session fastnåld på ett variant-id måste väljas om efter uppgradering. Gäller nya sessioner.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh-verktyget (officiell PowerShell)", "pwsh.hint": "Av som standard: när den är på blir hostens officiella pwsh-verktyg synligt för sessioner — ÄKTA PowerShell (den officiella upplösaren föredrar automatiskt PowerShell 7) för Windows-inbyggt arbete (register/tjänster/WMI). Av (standarden) hålls den dold och hosten förblir bash-först. Inget extra att installera; det gamla PowerShell 7-sidokanalverktyget har pensionerats med den nya arkitekturen. Kör kommandon fortfarande via bash-verktyget först.", "fctx.label": "FastCtx-filverktyg (gb_*)", @@ -1318,6 +1348,8 @@ window.__ModuleLoader__.load({ "tools.updating": "กำลังอัปเกรด…", "variant.label": "พรีเซ็ตย่อย «· Git Bash» ทั้งสี่", "variant.hint": "ปิดตามค่าเริ่มต้น: พรีเซ็ตทางการทั้งสี่ได้ Git Bash ผ่านแพตช์เครื่องมือในตัว และรายการยังเป็นทางการ เปิด: ลงทะเบียนสี่ตัวย่อยและหยุดแพตช์พรีเซ็ตทางการ — ทั้งสองไม่ใช้ร่วมกันได้ เซสชันที่ตรึงกับ id ย่อยต้องเลือกใหม่หลังอัปเกรด ใช้กับเซสชันใหม่", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "เครื่องมือ pwsh (PowerShell ทางการ)", "pwsh.hint": "ปิดอยู่โดยค่าเริ่มต้น: เมื่อเปิด เครื่องมือ pwsh ทางการของโฮสต์จะปรากฏแก่เซสชัน — เป็น PowerShell แท้ (ตัวแก้ไขทางการเลือก PowerShell 7 ให้โดยอัตโนมัติ) ใช้ได้ทันทีสำหรับงาน Windows แบบเนทีฟ (registry/services/WMI) เมื่อปิด (ค่าเริ่มต้น) เครื่องมือจะถูกซ่อนและโฮสต์ยังให้ bash มาก่อน ไม่ต้องติดตั้งอะไรเพิ่ม; เครื่องมือข้างทาง PowerShell 7 เดิมได้เลิกใช้ไปพร้อมสถาปัตยกรรมใหม่ ส่งคำสั่งผ่านเครื่องมือ bash เป็นอันดับแรกเช่นเดิม", "fctx.label": "เครื่องมือไฟล์ FastCtx (gb_*)", @@ -1396,6 +1428,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Güncelleniyor…", "variant.label": "Dört «· Git Bash» varyantı", "variant.hint": "Varsayılan olarak kapalı: dört RESMİ preset, satır içi araç yamasıyla Git Bash alır; liste resmi kalır. Açık: dört varyantı kaydeder ve resmi presetleri yamalamayı bırakır — ikisi birbirini dışlar. Bir varyant kimliğine bağlı oturum yükseltmeden sonra yeniden seçilmelidir. Yeni oturumlar için geçerlidir.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh aracı (resmî PowerShell)", "pwsh.hint": "Varsayılan olarak kapalı: açıkken ana makinenin resmî pwsh aracı oturumlara görünür olur — GERÇEK PowerShell (resmî çözümleyici otomatik olarak PowerShell 7'yi tercih eder) Windows-yerel işler (kayıt defteri/hizmetler/WMI) için doğrudan kullanılabilir. Kapalı (varsayılan) kaldığında gizli kalır ve ana makine bash-öncelikli kalır. Ekstra bir şey kurmaya gerek yok; eski PowerShell 7 yan kanal aracı yeni mimariyle birlikte emekli edildi. Komutları yine de önce bash aracıyla çalıştırın.", "fctx.label": "FastCtx dosya araçları (gb_*)", @@ -1474,6 +1508,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Đang nâng cấp…", "variant.label": "Bốn biến thể «· Git Bash»", "variant.hint": "Mặc định tắt: bốn preset CHÍNH THỨC nhận Git Bash qua bản vá công cụ nội tuyến và danh sách vẫn chính thức. Bật: đăng ký bốn biến thể và ngừng vá các preset chính thức — hai bên loại trừ nhau. Phiên đang ghim vào id biến thể cần chọn lại sau nâng cấp. Áp dụng cho phiên mới.", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "Công cụ pwsh (PowerShell chính thức)", "pwsh.hint": "Mặc định tắt: khi bật, công cụ pwsh chính thức của host hiển thị cho các phiên — PowerShell THẬT (bộ phân giải chính thức tự ưu tiên PowerShell 7) dùng ngay cho công việc Windows gốc (registry/services/WMI). Tắt (mặc định) thì nó vẫn bị ẩn và host vẫn ưu tiên bash. Không cần cài thêm gì; công cụ rẽ nhánh PowerShell 7 cũ đã nghỉ hưu cùng kiến trúc mới. Vẫn ưu tiên chạy lệnh qua công cụ bash trước.", "fctx.label": "Công cụ tệp FastCtx (gb_*)", @@ -1552,6 +1588,8 @@ window.__ModuleLoader__.load({ "tools.updating": "升級緊…", "variant.label": "四個「·Git Bash」變體模式", "variant.hint": "預設關閉:官方四個模式經行內工具補位直接獲得 Git Bash,模式列表保持官方原樣。開啟後註冊四個變體並停用官方模式的工具補位——兩者互斥。釘在變體 id 的舊會話在關閉升級後需重新選擇模式;變更對新會話生效。", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "預設關閉:開啟後宿主官方的 pwsh 工具對會話可見——它是真正的 PowerShell(官方解析器自動優先 PowerShell 7),可直接用於 Windows 原生操作(登錄檔/服務/WMI)。關閉(預設)時該工具被隱藏,主機保持 bash 優先。無需安裝任何額外元件;舊版 PowerShell 7 旁路工具已隨新架構退役。命令仍然首選 bash 工具。", "fctx.label": "FastCtx 檔案工具(gb_*)", @@ -1630,6 +1668,8 @@ window.__ModuleLoader__.load({ "tools.updating": "升級緊…", "variant.label": "四個「·Git Bash」變體模式", "variant.hint": "預設關閉:官方四個模式經行內工具補位直接獲得 Git Bash,模式列表保持官方原樣。開啟後註冊四個變體並停用官方模式的工具補位——兩者互斥。釘在變體 id 的舊會話在關閉升級後需重新選擇模式;變更對新會話生效。", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "預設關閉:開啟後宿主官方的 pwsh 工具對會話可見——它是真正的 PowerShell(官方解析器自動優先 PowerShell 7),可直接用於 Windows 原生操作(登錄檔/服務/WMI)。關閉(預設)時該工具被隱藏,主機保持 bash 優先。無需安裝任何額外元件;舊版 PowerShell 7 旁路工具已隨新架構退役。命令仍然首選 bash 工具。", "fctx.label": "FastCtx 檔案工具(gb_*)", @@ -1708,6 +1748,8 @@ window.__ModuleLoader__.load({ "tools.updating": "更新中…", "variant.label": "四個「·Git Bash」變體模式", "variant.hint": "預設關閉:官方四個模式經行內工具補位直接獲得 Git Bash,模式列表保持官方原樣。開啟後註冊四個變體並停用官方模式的工具補位——兩者互斥。釘在變體 id 的舊會話在關閉升級後需重新選擇模式;變更對新會話生效。", + "experimental.label": "Experimental native execution / fail-closed", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "預設關閉:開啟後宿主官方的 pwsh 工具對工作階段可見——它是真正的 PowerShell(官方解析器自動優先 PowerShell 7),可直接用於 Windows 原生操作(登錄檔/服務/WMI)。關閉(預設)時該工具被隱藏,主機保持 bash 優先。無需安裝任何額外元件;舊版 PowerShell 7 旁路工具已隨新架構退役。命令仍然首選 bash 工具。", "fctx.label": "FastCtx 檔案工具(gb_*)", @@ -2299,6 +2341,7 @@ window.__ModuleLoader__.load({ writeField exactly like every other row on this card. */ var variantOn = snap.value.variantPresets === true; var pwshOn = snap.value.pwshTool !== false; + var experimentalOn = snap.value.experimentalNative !== false; // Default OFF (v0.39.4): must match the host's `=== true` reader. var fctxOn = snap.value.fastctxTools === true; @@ -2315,6 +2358,15 @@ window.__ModuleLoader__.load({ ); var inlineSection = fold("sec.inline", + E("div", { className: "gb-row" }, + E("span", { className: "gb-rowLabel" }, t("experimental.label") + ":"), + E("span", { className: "gb-rowValue" }, experimentalOn ? t("state.on") : t("state.off")), + ), + E("div", { className: "gb-seg" }, + E("button", { type: "button", className: "gb-segBtn" + (experimentalOn ? " gb-segActive" : ""), onClick: function () { if (!experimentalOn) writeField("experimentalNative", true); } }, t("switch.on")), + E("button", { type: "button", className: "gb-segBtn" + (!experimentalOn ? " gb-segActive" : ""), onClick: function () { if (experimentalOn) writeField("experimentalNative", false); } }, t("switch.off")), + ), + E("p", { className: "gb-hint" }, t("experimental.hint")), E("div", { className: "gb-row" }, E("span", { className: "gb-rowLabel" }, t("pwsh.label") + ":"), E("span", { className: "gb-rowValue" }, pwshOn ? t("state.on") : t("state.off")), diff --git a/src/experimental-tools.js b/src/experimental-tools.js new file mode 100644 index 0000000..d3a6165 --- /dev/null +++ b/src/experimental-tools.js @@ -0,0 +1,17 @@ +/** Live registration for agent-owned companions; restrict() accepts global names only. */ +export function registerExperimentalTool(ctx, definition, enabled) { + let unregister + let closed = false + const refresh = () => { + if (closed) return + unregister?.() + unregister = undefined + if (enabled?.() !== false) unregister = ctx.tools.register(definition()) + } + ctx.effect(() => () => { closed = true; unregister?.(); unregister = undefined }) + ctx.on('loader/volatile-update', paths => { + if (Array.isArray(paths) && paths.some(path => Array.isArray(path) && path[0] === 'experimentalNative')) refresh() + }) + refresh() + return refresh +} diff --git a/src/index.js b/src/index.js index 708f85a..77e5a3a 100644 --- a/src/index.js +++ b/src/index.js @@ -513,6 +513,8 @@ export const Config = Schema === null ? undefined : Schema.object({ * now controls the real thing. */ pwshTool: live(Schema.boolean().default(false)), + // Maintainer PR #26 decision: one live opt-out for refusal and companions. + experimentalNative: live(Schema.boolean().default(true)), /** * Publish the FastCtx file tools (`gb_*`) through the local MCP bridge. * Default ON; Windows-only payload, stands down entirely when dsh-ops is @@ -594,6 +596,7 @@ function makeLiveReader(ctx, config) { autoTerminalShell: () => dialect().autoTerminalShell !== false, variantPresets: () => (hasConfig ? valueOf(config.variantPresets) === true : false), pwshTool: () => (hasConfig ? valueOf(config.pwshTool) !== false : true), + experimentalNative: () => (hasConfig ? valueOf(config.experimentalNative) !== false : true), fastctxTools: () => (hasConfig ? valueOf(config.fastctxTools) === true : false), // Default OFF: only an explicit true asks for deduplication. suppressPeerCordis: () => (hasConfig ? valueOf(config.suppressPeerCordis) === true : false), @@ -2756,6 +2759,7 @@ export async function apply(ctx, config = {}) { } }, pwshEnabled: () => liveSettings.pwshTool() === true, + experimentalEnabled: () => liveSettings.experimentalNative(), // 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 dc15f90..f9dd060 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -235,6 +235,7 @@ export function mountInlineTools(ctx, deps) { let closed = false const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() }) + const experimentalEnabled = () => deps.experimentalEnabled?.() !== false // Loader imports cannot be cancelled, but no continuation may outlive its // owner. Join our cancelled continuations without waiting on a stuck loader. const untilDisposed = async (promise, signal) => { @@ -284,6 +285,7 @@ export function mountInlineTools(ctx, deps) { if (typeof createScope !== 'function') throw new Error('no createScope seam') controller.signal.throwIfAborted() const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope) + mounted.executor.experimentalEnabled = experimentalEnabled executorScope = mounted.executorScope controller.signal.throwIfAborted() const hostModules = {} @@ -296,7 +298,7 @@ export function mountInlineTools(ctx, deps) { if (typeof deps.translatePath === 'function') { const { createPathToolModule, PATH_TOOL_NAME } = await untilDisposed(import('./path-tool.js'), controller.signal) extraModules.push({ name: PATH_TOOL_NAME, module: createPathToolModule({ - toolsModule: hostModules.toolsModule, translate: deps.translatePath, log, + toolsModule: hostModules.toolsModule, translate: deps.translatePath, experimentalEnabled, log, }) }) try { const localBashModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-bash-local'), controller.signal) @@ -311,7 +313,7 @@ export function mountInlineTools(ctx, deps) { toolsModule: hostModules.toolsModule, sandboxModule: hostModules.sandboxModule, localBashModule, createScope, translateMsysPath: deps.translatePath, createNativeOutputAdapter, outputOptions: { OutputCollector: outputModule?.OutputCollector }, - createArtifactDirectory: deps.createArtifactDirectory, log, + createArtifactDirectory: deps.createArtifactDirectory, experimentalEnabled, log, }) }) } catch (error) { if (controller.signal.aborted) throw error @@ -480,7 +482,7 @@ export function mountInlineTools(ctx, deps) { log(`agent bash tool cleanup failed: ${error?.message ?? error}`) })) ctx.on('loader/volatile-update', (paths) => { - if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'pwshTool')) return + if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && ['pwshTool', 'experimentalNative'].includes(path[0]))) return for (const agent of tracked.keys()) { try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) } } diff --git a/src/native-executor.js b/src/native-executor.js index af465e4..12b3744 100644 --- a/src/native-executor.js +++ b/src/native-executor.js @@ -72,7 +72,7 @@ export function requireNativePolicy(ctx, exec, sandboxModule) { const service = ctx.get('sandboxPolicy') const policy = service?.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session }) if (!policy || policy.mode !== 'danger-full-access') { - const detail = 'gitbash_native has no confined backend adapter. This entry refuses read-only, workspace-write and missing policy before spawn or artifact creation; do not bypass a denied operation.' + const detail = 'gitbash_native has no confined backend adapter. This entry refuses read-only, workspace-write and missing policy before spawn or artifact creation; do not bypass a denied operation. The user can select danger-full-access in the session access-mode control, or turn off Experimental native execution / fail-closed (experimentalNative) in Settings > Plugins > dsh-gitbash-shell to disable native/path tools and restore explicitly UNCONFINED legacy Bash.' if (typeof sandboxModule?.SandboxUnavailableError === 'function') throw new sandboxModule.SandboxUnavailableError(policy?.mode ?? 'unknown', detail) throw Object.assign(new Error(detail), { code: 'SANDBOX_UNAVAILABLE' }) } @@ -128,10 +128,14 @@ export function createNativeExecutor(ctx, deps) { const LocalBashExecutor = localBashModule?.LocalBashExecutor if (typeof LocalBashExecutor !== 'function' || typeof createScope !== 'function' || typeof createNativeOutputAdapter !== 'function') throw new Error('gitbash_native: required host executor/output/scope capability is unavailable') const prepared = new WeakMap() + const requireEnabled = () => { + if (deps.experimentalEnabled?.() === false) throw new Error('gitbash_native is disabled by experimentalNative. Enable Experimental native execution / fail-closed in Settings > Plugins > dsh-gitbash-shell.') + } const remaining = request => request.timeoutMs - (Date.now() - prepared.get(request)) return { waitBudget: request => Math.max(1, remaining(request)), async prepare(args, exec) { + requireEnabled() requireNativePolicy(ctx, exec, sandboxModule) exec.signal?.throwIfAborted() if (args?.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number') @@ -140,6 +144,7 @@ export function createNativeExecutor(ctx, deps) { const preparation = cancellation(exec.signal, timeoutMs) try { const sessionCwd = exec.agent === undefined ? undefined : await whileActive(() => ctx.workingDirectory.ensure(exec.agent, preparation.signal), preparation.signal) + requireEnabled() preparation.signal.throwIfAborted() const request = parseNativeRequest(args, { translateMsysPath, sessionCwd, platform: deps.platform ?? process.platform }) const value = Object.freeze({ ...request, dshEnv: checkedDshEnv(ctx.shellEnv.collect(exec)) }) @@ -148,6 +153,7 @@ export function createNativeExecutor(ctx, deps) { } finally { preparation.dispose() } }, async execute(request, exec, { signal = exec.signal, onExpiry = 'kill' } = {}) { + requireEnabled() const policy = requireNativePolicy(ctx, exec, sandboxModule) if (!prepared.has(request)) throw new TypeError('gitbash_native: request must be prepared by this executor') if (!['kill', 'none'].includes(onExpiry)) throw new TypeError('invalid native expiry mode') @@ -193,12 +199,14 @@ export function createNativeExecutor(ctx, deps) { handle.terminate() if (typeof handle.waitForExit === 'function') await handle.waitForExit(AbortSignal.timeout(5000)) }) + requireEnabled() requireNativePolicy(ctx, exec, sandboxModule) const outputConfig = { ...outputOptions, ...request.output, artifactPolicyValidated: true } // Capability/encoding validation is pure and precedes directory creation. createNativeOutputAdapter(provider, { ...outputConfig, artifactDirectory: undefined }) if (outputConfig.artifactDirectory === undefined) { if (typeof deps.createArtifactDirectory !== 'function') throw new Error('gitbash_native: no trusted artifact directory owner is configured') + requireEnabled() requireNativePolicy(ctx, exec, sandboxModule) operation.signal.throwIfAborted() outputConfig.artifactDirectory = await whileActive(async () => { @@ -215,8 +223,10 @@ export function createNativeExecutor(ctx, deps) { } requireNativePolicy(ctx, exec, sandboxModule) operation.signal.throwIfAborted() + requireEnabled() const adapter = createNativeOutputAdapter(provider, outputConfig) const facade = { spawn(spec) { + requireEnabled() requireNativePolicy(ctx, exec, sandboxModule) spec.signal?.throwIfAborted() handle = adapter.spawn(spec) @@ -225,6 +235,7 @@ export function createNativeExecutor(ctx, deps) { scope.ctx.provide('subprocess', facade) const mounted = scope.ctx.plugin(LocalBashExecutor) await whileActive(() => mounted, operation.signal) + requireEnabled() operation.signal.throwIfAborted() const executor = scope.ctx.get('shell') if (typeof executor?.executeArgv !== 'function') throw new Error('gitbash_native: host executeArgv contract is unavailable') diff --git a/src/native-tool.js b/src/native-tool.js index 1bda2c7..48bec5c 100644 --- a/src/native-tool.js +++ b/src/native-tool.js @@ -1,3 +1,4 @@ +import { registerExperimentalTool } from './experimental-tools.js' import { createNativeExecutor } from './native-executor.js' import { processJob, processSources, ringDelta } from './bash-render.js' @@ -178,15 +179,15 @@ export function createNativeToolModule(deps) { presentCall: args => ({ card: 'generic', title: args.description, kind: 'execute', content: [{ type: 'text', text: args.executable.value }] }), }) } - if (!enabled) { ctx.tools.register(definition(undefined)); return } - let foregroundOnly = ctx.get('jobs') === undefined ? ctx.tools.register(definition(undefined)) : undefined + let currentJobs + const refresh = registerExperimentalTool(ctx, () => definition(currentJobs), deps.experimentalEnabled) + if (!enabled) return ctx.inject(['jobs'], jobCtx => { - foregroundOnly?.() - foregroundOnly = undefined - const unregister = ctx.tools.register(definition(jobCtx.jobs)) + currentJobs = jobCtx.jobs + refresh() jobCtx.effect(() => () => { - unregister() - if (ctx.fiber?.state === 2) foregroundOnly = ctx.tools.register(definition(undefined)) + currentJobs = undefined + if (ctx.fiber?.state === 2) refresh() }) }) }, diff --git a/src/path-tool.js b/src/path-tool.js index f8627d3..029d10f 100644 --- a/src/path-tool.js +++ b/src/path-tool.js @@ -1,3 +1,5 @@ +import { registerExperimentalTool } from './experimental-tools.js' + /** * dsh-gitbash-shell/path-tool — the EXPLICIT full-value MSYS→native converter * (`gitbash_to_native_path`, 2026-10-13, compat-implementation task @@ -200,7 +202,7 @@ const MSYS_PATH_PARAM_DESCRIPTION = 'The complete value to convert, as one singl * @param {(message: string) => void} [deps.log] * @returns {object} a registry-ready definition. */ -export function buildPathToolDefinition({ toolsModule, translate, translateMsysPath, buildEnv, log = () => {} } = {}) { +export function buildPathToolDefinition({ toolsModule, translate, translateMsysPath, buildEnv, experimentalEnabled, log = () => {} } = {}) { const convertValue = typeof translate === 'function' ? (value, env) => translate(value, env) : typeof translateMsysPath === 'function' @@ -210,6 +212,7 @@ export function buildPathToolDefinition({ toolsModule, translate, translateMsysP throw new Error('path-tool: a shared translator is required — pass translate (fresh composed) or translateMsysPath; the tool never copies the mount table') } const execute = (args) => { + if (experimentalEnabled?.() === false) throw new Error('gitbash_to_native_path is disabled by experimentalNative. Enable Experimental native execution / fail-closed in Settings > Plugins > dsh-gitbash-shell.') // Fresh facts per call: the same lazy discipline as every other // translation consumer (a settings bashPath change must be visible on // the next call without a re-mount). @@ -288,14 +291,7 @@ export function createPathToolModule(deps = {}) { if (typeof ctx?.tools?.register !== 'function') { throw new Error('path-tool: ctx.tools.register is unavailable; cannot own the tool registration') } - const unregister = ctx.tools.register(definition) - if (typeof unregister === 'function') { - ctx.effect?.(() => () => { - try { - unregister() - } catch { /* the scope is already rolling back */ } - }) - } + registerExperimentalTool(ctx, () => definition, deps.experimentalEnabled) ;(typeof deps.log === 'function' ? deps.log : () => {})( `${PATH_TOOL_NAME} registered (explicit full-value MSYS→native conversion, no I/O)`, ) diff --git a/src/shell.js b/src/shell.js index 859ead0..803fa22 100644 --- a/src/shell.js +++ b/src/shell.js @@ -22,7 +22,8 @@ * future isolation design is impossible. The matched modern MSYS signal * pipe has an explicit SD; cygheap uses VirtualAlloc, and Core already * supplements TokenDefaultDacl. No verified confined Git Bash is provided - * here, so the former labelled unconfined bypass is no longer permitted. + * here. experimentalNative defaults ON (refusal); an explicit user opt-out + * restores the labelled legacy unconfined route, with truthful facts. * Official file-tool policy is separate and its denials must not be bypassed. * * Environment inheritance: bash.exe is a direct child (not a login launcher) @@ -232,6 +233,9 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { const detail = 'dsh-gitbash-shell does not provide verified Windows Git for Windows confinement. ' + 'The command was not started; no unconfined fallback is permitted. ' + 'Do not bypass this refusal with another tool or change permissions automatically.' + + ' The user can open Settings > Plugins > dsh-gitbash-shell and turn off ' + + '"Experimental native execution / fail-closed" (experimentalNative) to restore the legacy ' + + 'explicitly UNCONFINED Bash behavior, or select danger-full-access in the session access-mode control.' if (typeof this.unavailableErrorType === 'function') return new this.unavailableErrorType(knownMode, detail) // Direct/older callers without the optional host error export still refuse. const error = new Error('sandbox mode "' + knownMode + '" cannot execute Git Bash on this Windows host. ' + detail) @@ -250,10 +254,27 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { gitBashRoute(spec) { const mode = spec.sandboxPolicy?.mode if (mode === 'danger-full-access') return { mode, unconfined: false } - if (process.platform === 'win32') throw this.policyUnavailableError(mode) + if (process.platform === 'win32') { + if (this.experimentalEnabled?.() !== false) throw this.policyUnavailableError(mode) + // Exact legacy opt-out: unresolved policy still delegates to the host. + if (mode !== undefined) return { mode, unconfined: true } + } return undefined } + warnConfinedUnconfined(mode) { + if (this._warnedUnconfined) return + this._warnedUnconfined = true + console.log('dsh-gitbash-shell: experimentalNative is OFF. Requested mode ' + mode + + ' uses an UNCONFINED Git Bash route: shell file-write restrictions are NOT enforced. ' + + 'Official file-tool policy is separate; never bypass its denial through shell.') + } + + routeSandbox(route) { + if (route.unconfined) this.warnConfinedUnconfined(route.mode) + return { mode: route.mode, denied: false, ...(route.unconfined ? { enforcement: 'unconfined' } : {}) } + } + /** * Label one execution handle's foreground result without changing its * identity (the parent's `decorateResult` is private to the shipped class, so @@ -294,7 +315,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { const routed = this.withParityEnv(spec) if (route === undefined) return super.execute(routed) const argv = [this.requireBashPath(), '-c', routed.command] - const sandbox = { mode: route.mode, denied: false } + const sandbox = this.routeSandbox(route) // `executeArgv` is the shipped helper for exactly this: run an explicit // argv with the executor's own lifecycle, output and deadline semantics. return Promise.resolve(this.executeArgv(routed, argv)) @@ -369,9 +390,10 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { const route = this.gitBashRoute(spec) spec = this.withParityEnv(spec) if (route === undefined) return super.run(spec) - const { result } = GitBashSandboxExecutor.unwrapRunArgv( + if (route.unconfined) this.warnConfinedUnconfined(route.mode) + const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv( await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command])) - return { ...result, sandbox: { mode: route.mode, denied: false } } + return { ...result, sandbox: this.routeSandbox({ ...route, unconfined: route.unconfined && spawnRequested }) } } start(spec) { @@ -384,7 +406,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { spec = this.withParityEnv(spec) if (route === undefined) return super.start(spec) const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command]) - const label = (handle) => { handle.sandbox = { mode: route.mode, denied: false }; return handle } + const label = (handle) => { handle.sandbox = this.routeSandbox(route); return handle } // Preserve the old host's synchronous/async start contract, including its // refusal shape; modern execute() callers never enter this legacy method. return GitBashSandboxExecutor.baseStartIsAsync() ? Promise.resolve(proc).then(label) : label(proc) diff --git a/tests/client-regressions.mjs b/tests/client-regressions.mjs index 3ad4108..e344c38 100644 --- a/tests/client-regressions.mjs +++ b/tests/client-regressions.mjs @@ -3,17 +3,21 @@ import assert from 'node:assert/strict' import vm from 'node:vm' import { readFileSync } from 'node:fs' -function cardFixture() { +function cardFixture({ expanded = false } = {}) { let declaration, hooks = [], effects = [], setters = [], stateIndex = 0 let status = 'pending', overrides = new Map(), fetchMode = 'none', pollingCalls = 0 const timers = new Map() let timerId = 0 + let values = {} + const writes = [] const React = { Component: function () {}, createElement: (type, props, ...children) => ({ type, props: props || {}, children }), useState(value) { hooks.push('state') const index = stateIndex++ + if (expanded && index === 0) value = true + if (expanded && value && typeof value === 'object' && !Array.isArray(value)) value = { ...value, 'sec.inline': true } return [overrides.has(index) ? overrides.get(index) : (typeof value === 'function' ? value() : value), (next) => setters.push({ index, value: next })] }, useEffect(callback) { hooks.push('effect'); effects.push(callback) }, @@ -36,7 +40,7 @@ function cardFixture() { vm.runInNewContext(readFileSync(new URL('../src/client.js', import.meta.url), 'utf8'), sandbox) const mod = declaration.factory((name) => name === 'react' ? React : {}) const registrations = [] - const form = { getSnapshot: () => ({ status, value: {} }), subscribe: () => () => {}, set: () => Promise.resolve(true) } + const form = { getSnapshot: () => ({ status, value: values }), subscribe: () => () => {}, set: (key, value) => { writes.push({ key, value }); return Promise.resolve(true) } } const forms = { get: () => form } const ctx = { locale: { register: () => () => {} }, configForms: forms, @@ -54,12 +58,14 @@ function cardFixture() { const card = locale.type(locale.props).children[0] return { setStatus: (value) => { status = value }, + setValues: value => { values = value }, + writes, setJob: (job) => { overrides = new Map([[6, job]]) }, setFetchMode: (mode) => { fetchMode = mode }, render() { hooks = []; effects = []; setters = []; stateIndex = 0 - card.type(card.props) - return { hooks: [...hooks], effects: [...effects] } + const tree = card.type(card.props) + return { hooks: [...hooks], effects: [...effects], tree } }, async runTimer() { const [id, callback] = timers.entries().next().value @@ -122,3 +128,25 @@ test('client: cleanup prevents a failed in-flight poll from scheduling a retry', assert.equal(fixture.timers.size, 0) assert.equal(fixture.setters.filter((row) => row.index === 6).length, 0) }) + +test('client: experimental switch defaults ON and writes the authoritative field both ways', async () => { + const f = cardFixture({ expanded: true }); + f.setStatus('ready'); + const walk = node => !node || typeof node !== 'object' ? [] : [node, ...(node.children ?? []).flat(Infinity).flatMap(walk)]; + const buttons = () => walk(f.render().tree).filter(node => node.type === 'button' && String(node.props.onClick).includes('experimentalNative')); + let controls = buttons(); + assert.equal(controls.length, 2); + assert.ok(controls[0].props.className.includes('gb-segActive')); + controls[1].props.onClick(); + await new Promise(setImmediate); + assert.equal(f.writes.at(-1).key, 'experimentalNative'); + assert.equal(f.writes.at(-1).value, false); + f.setValues({ experimentalNative: false }); + controls = buttons(); + assert.ok(controls[1].props.className.includes('gb-segActive')); + controls[0].props.onClick(); + await new Promise(setImmediate); + assert.equal(f.writes.at(-1).value, true); + const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8'); + for (const key of ['experimental.label', 'experimental.hint']) assert.equal(source.split('"' + key + '":').length - 1, 21); +}); diff --git a/tests/native-executor.mjs b/tests/native-executor.mjs index ce8a09f..93bca6e 100644 --- a/tests/native-executor.mjs +++ b/tests/native-executor.mjs @@ -130,3 +130,20 @@ test('native schema authoring: implicit property-map, required markers and capab assert.equal(parameters.run_in_background, undefined) assert.equal(nativeParameters({ background: true }).run_in_background.type, 'boolean') }) + + test('native switch: disabled preparation and stale prepared request have no side effects', async () => { + let enabled = false, ensure = 0; + const ctx = { get: () => ({ resolve: () => ({ mode: 'danger-full-access' }) }), + workingDirectory: { ensure: async () => { ensure++; return 'C:/Native Sentinel'; } }, shellEnv: { collect: () => ({}) } }; + const executor = createNativeExecutor(ctx, { platform: 'win32', experimentalEnabled: () => enabled, + localBashModule: { LocalBashExecutor: class {} }, translateMsysPath, + createScope() { throw new Error('MUST_NOT_CREATE_SCOPE'); }, createNativeOutputAdapter() { throw new Error('MUST_NOT_CREATE_ADAPTER'); } }); + const exec = { agent: {}, signal: new AbortController().signal }; + await assert.rejects(executor.prepare(request(), exec), /disabled by experimentalNative/); + assert.equal(ensure, 0); + enabled = true; + const prepared = await executor.prepare(request(), exec); + enabled = false; + await assert.rejects(executor.execute(prepared, exec), /disabled by experimentalNative/); + assert.equal(ensure, 1); + }); diff --git a/tests/native-preview.mjs b/tests/native-preview.mjs index e455f16..74805b4 100644 --- a/tests/native-preview.mjs +++ b/tests/native-preview.mjs @@ -39,6 +39,7 @@ async function fixture(t, { jobs = false, promote = false, controller = true, ar const spawned = [] const scopes = [] let mode = 'danger-full-access' + let experimental = true let adapterConstructions = 0 const policy = { defaultMode: 'danger-full-access', resolve: () => mode === undefined ? undefined : { mode, workspaceRoot: DIR } } const official = { name: 'official-preserved' } @@ -65,7 +66,7 @@ async function fixture(t, { jobs = false, promote = false, controller = true, ar await mount(LocalJobRegistry, { pumpPollMs: 5 }) if (controller) await mount({ inject: ['jobs'], apply(ctx) { ctx.jobs.attachController('native-preview-controller') } }) } - const deps = { toolsModule, sandboxModule, localBashModule, + const deps = { toolsModule, sandboxModule, localBashModule, experimentalEnabled: () => experimental, createScope(parent) { const scope = createScope(parent); scopes.push(scope); return scope }, translateMsysPath: value => /^\/[a-z]\//i.test(value) ? `${value[1].toUpperCase()}:/${value.slice(3)}` : value, outputOptions: { OutputCollector, ...(artifactFactory ? {} : { artifactDirectory: DIR }), drainTimeoutMs: 200 }, @@ -91,7 +92,9 @@ async function fixture(t, { jobs = false, promote = false, controller = true, ar return directExecutor.execute(await directExecutor.prepare(args, exec), exec) } return { root, agent, agentScope, toolFiber, spawned, scopes, run, direct, - jobs: root.get('jobs'), setMode(value) { mode = value }, get adapterConstructions() { return adapterConstructions }, + jobs: root.get('jobs'), setMode(value) { mode = value }, + setExperimental(value) { experimental = value; root.emit('loader/volatile-update', [['experimentalNative']]) }, + get adapterConstructions() { return adapterConstructions }, preserved() { assert.equal(root.reflect.store[root[Context.isolate].subprocess], rootProvider); assert.equal(root.reflect.store[root[Context.isolate].shell], rootShell) }, } } @@ -149,6 +152,28 @@ test('installed Core native: ToolRuntime exact argv, translated paths, root prov rows.push({ name: t.name, status: 'PASS', producerExitCode: response.value.exitCode, outputFacts: response.value.outputFacts }) }) +test('installed Core native: opt-out during artifact preparation refuses at final spawn, then re-enables', { timeout: 15000 }, async t => { + let signalEntered + const entered = new Promise(resolve => { signalEntered = resolve }) + let finish + const artifact = new Promise(resolve => { finish = resolve }) + const f = await fixture(t, { artifactFactory: () => { signalEntered(); return artifact } }) + const pending = f.direct(request('console.log("MUST_NOT_RUN")')) + await entered + f.setExperimental(false) + assert.ok(!f.root.get('tools').schemas(f.agent).some(tool => tool.name === 'gitbash_native')) + finish(DIR) + await assert.rejects(pending, /disabled by experimentalNative/) + assert.equal(f.spawned.length, 0) + assert.ok(f.scopes.every(scope => scope.ctx.fiber.state === 4)) + f.setExperimental(true) + const response = await f.run(request('console.log("REENABLED")')) + assert.equal(response.isError, false, JSON.stringify(response)) + assert.equal(response.value.stdout.text.trim(), 'REENABLED') + assert.equal(f.spawned.length, 1) + f.preserved() +}) + test('installed Core native: typed path actually opens the fixed sentinel file', { timeout: 15000 }, async t => { const f = await fixture(t) const filename = path.join(DIR, 'typed path 中文.txt') diff --git a/tests/path-tool.mjs b/tests/path-tool.mjs index f598812..b7a2808 100644 --- a/tests/path-tool.mjs +++ b/tests/path-tool.mjs @@ -243,6 +243,7 @@ test('path-tool: the module owns its registration and reverts it on disposal', ( }, }, effect(callback) { effects.push(callback()) }, + on() {}, } const logs = [] const module = createPathToolModule({ @@ -285,3 +286,15 @@ test('path-tool: the real shared buildTranslateEnv feeds the same helper (compos assert.equal(translateMsysPath('/c/x', env), 'C:/x') assert.equal(convertMsysToNative('/c/x', { translate: (v) => translateMsysPath(v, env), env }).native_path, 'C:/x') }) + + test('path switch: stale definitions reject before translation and recover live', () => { + let enabled = false, translated = 0; + const definition = buildPathToolDefinition({ experimentalEnabled: () => enabled, translate: () => { translated++; return 'C:/test'; } }); + assert.throws(() => definition.execute({ msys_path: '/c/test' }), /disabled by experimentalNative/); + assert.equal(translated, 0); + enabled = true; + assert.equal(definition.execute({ msys_path: '/c/test' }).native_path, 'C:/test'); + enabled = false; + assert.throws(() => definition.execute({ msys_path: '/c/test' }), /disabled/); + assert.equal(translated, 1); + }); diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index 66f9685..3bb9b48 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -32,7 +32,7 @@ function createScope(parent) { return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } } -async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime, explicitTools = false, ptc = false } = {}) { +async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime, explicitTools = false, ptc = false, experimental = true } = {}) { const root = new Context() const cleanups = [] const logs = [] @@ -77,6 +77,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu const deps = { tools: ctx => ctx.get('tools'), agents: () => ({ list: () => [agent] }), pwshEnabled: () => pwshVisible, + experimentalEnabled: () => experimental, ...(explicitTools ? { translatePath: value => dialectHelpers.translateMsysPath(value, { home: 'D:/compat-home', tmpDir: 'D:/compat-temp', gitRoot: 'C:/Program Files/Git' }), createArtifactDirectory() { throw new Error('registration/refusal fixture must not create artifacts') }, @@ -111,6 +112,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu root, agent, tools, patch, official, logs, entered, release, activated, failed, agentScopes, deps, names: () => tools.schemas(agent).map(schema => schema.name), 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 }), remount: () => mount(ctx => mountInlineTools(ctx, deps)), @@ -133,6 +135,32 @@ test('runtime preview: live pwsh schema and dispatch follow both switch directio assert.ok(f.names().includes('pwsh')) }) +test('runtime preview: experimental switch changes existing schemas and restricted Bash without restart', { timeout: 15000 }, async t => { + for (const initial of [true, false]) { + const f = await fixture(t, { explicitTools: true, experimental: initial }) + await bounded(f.activated.promise, 'experimental tools activation') + f.agent.session.header.policy = { mode: 'workspace-write', workspaceRoot: process.cwd() } + const args = { command: 'memory-only command', description: 'Verify live opt-out' } + for (const enabled of [initial, !initial, initial, !initial]) { + f.setExperimental(enabled) + for (const name of ['gitbash_native', 'gitbash_to_native_path']) assert.equal(f.names().includes(name), enabled, JSON.stringify({ name, initial, enabled, logs: f.logs })) + const result = await f.execute('bash', args) + assert.equal(result.isError, enabled, JSON.stringify(result)) + if (enabled) { + assert.equal(result.error.info.code, 'SANDBOX_UNAVAILABLE') + assert.match(result.error.message, /Settings.*experimentalNative.*session access-mode/) + } else { + assert.equal(result.value.sandbox.enforcement, 'unconfined') + assert.equal(result.value.sandbox.mode, 'workspace-write') + assert.equal((await f.execute('gitbash_to_native_path', { msys_path: '/c/test' })).isError, true) + } + assert.equal(f.root.get('shell'), f.official) + } + await f.patch.dispose() + assert.deepEqual(f.names(), ['pwsh']) + } +}) + test('runtime preview: plugin disposal cancels a delayed host import and cannot leave a ghost bash', { timeout: 10000 }, async t => { const f = await fixture(t, { pauseModules: true }) await bounded(f.entered.promise, 'host module import entered') @@ -302,7 +330,7 @@ test('runtime preview: actual promoted DTO renders sandbox notes from handle fac const finished = deferred() const raw = 'promotion-raw-output' const readFrom = () => ({ text: raw, nextOffset: raw.length, lossy: false }) - const f = await fixture(t, { subprocessRuntime: { spawn() { + const f = await fixture(t, { experimental: false, subprocessRuntime: { spawn() { spawned.resolve() return { done: finished.promise, collected: { stdout: { readFrom }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, terminate() { finished.resolve({ exitCode: 1, signal: 'SIGTERM' }) } } diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 233b701..f25fea0 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -631,6 +631,18 @@ test('executor: Windows restricted and unresolved policies fail before any argv assert.deepEqual(exFull.calls[0][1], ['X:/git/bin/bash.exe', '-c', 'echo hi']) assert.deepEqual(result.sandbox, { mode: 'danger-full-access', denied: false }) assert.deepEqual(exFull.start(full).sandbox, result.sandbox) + const legacy = make('win32') + legacy.experimentalEnabled = () => false + for (const mode of ['read-only', 'workspace-write']) { + const spec = { command: 'echo hi', sandboxPolicy: { mode } } + const facts = { mode, denied: false, enforcement: 'unconfined' } + assert.deepEqual((await legacy.run(spec)).sandbox, facts) + assert.deepEqual(legacy.start(spec).sandbox, facts) + } + await legacy.run({ command: 'echo hi' }) + assert.equal(legacy.calls.at(-1)[0], 'super.run', 'unresolved opt-out keeps legacy delegation') + legacy.experimentalEnabled = () => true + await assert.rejects(() => legacy.run({ command: 'echo hi', sandboxPolicy: { mode: 'read-only' } }), refusal) const linux = make('linux') await linux.run({ command: 'echo hi', sandboxPolicy: { mode: 'read-only' } }) linux.start({ command: 'echo hi', sandboxPolicy: { mode: 'read-only' } }) From e42844e58664359e9f50e5f156742be3c5a5205f Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Tue, 13 Oct 2026 22:31:22 +0800 Subject: [PATCH 4/9] docs: link commit references with descriptive labels --- CHANGELOG.md | 6 ++++-- docs/experimental-native-execution.md | 2 +- docs/experimental-native-verification.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ddb57..cbf1f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,15 @@ ### PR #26:维护者裁决与运行时开关(fix / feat / docs / test) +- docs:PR 描述和实验文档的提交引用使用可点击链接及说明性标签,避免明文提交 ID。 + - 行 Config 新增 volatile 布尔 `experimentalNative`,默认 true,旧配置缺键亦开启;设置卡即时编辑。 开启时 Windows 受限/未确定 Bash 拒绝;关闭恢复明确标注 `unconfined` 的旧版 Bash 与实例级日志,不提供真实 shell 写入围栏。 - 两个新工具随开关实时注销/注册,既有会话无需重启;旧 SDK 引用关闭后拒绝。路径工具在翻译前检查; native 在准备入口、异步准备后、执行入口和最终 spawn 重验。已启动后台 producer 保留生命周期。 - 拒绝错误与 Bash 描述说明设置卡关闭路径,以及由用户在会话访问模式控件选择 danger-full-access 的路径。 21 份词典键齐备,中文/英文先行,其余 19 份暂用英文,合并前补本地化。 -- 实验已 rebase 到 #24 最新 c79332e,保留其 promotion sandbox 警示回归,显式 OFF 验证旧行为。 +- 实验已 rebase 到 [前置 PR 的最新修复提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/c79332ea2fb6da38042063f7b55046115de8e530),保留其 promotion sandbox 警示回归,显式 OFF 验证旧行为。 #24 仍 OPEN,合并后到 main 的 rebase 尚待;建议两步 review(fail-closed + 开关/UI;native/path/输出/路由),保留 Draft/RFC。 - 长期跟踪:Core 开放对应 native/encoding/closed DTO API 后优先整体迁移并退役私有实现;artifact 自管,controller 缺失保持拒绝。 正式门槛仍为真实 Agent 选路、生产 GUI、维护者 win32 全量验证及非 CRT 矩阵抽测。 @@ -216,7 +218,7 @@ opt-in)与跨平台(win/mac/linux)支持。本版把并存形态收敛为「各 暂态失败后重试,卸载或失效时取消,不因单次请求失败永久卡在忙碌状态。 - **回归入口**:新增可移植客户端/桥/运行时/宿主回归与实际 Core preview 集成用例,接入现有 `npm test`/`npm run test:preview`;不新增依赖、不跳过旧断言或 Node 22 矩阵。对齐上游 v0.39.1 - (`521be01`)后完整测试 **156/156**、实际 Core 无头 preview **14/14**、打包 dry-run 和 diff 检查 + ([私有执行器隔离发布提交](https://github.com/KannaKuron/dsh-gitbash-shell/commit/521be01ee803f289236800ebfb36fab8dd690535))后完整测试 **156/156**、实际 Core 无头 preview **14/14**、打包 dry-run 和 diff 检查 通过。新增执行级回归保留 #21 的 real-array glob 契约与 stringified-array 响亮拒绝;不改已发布 版本号、历史记录或 npm 发布流程。Grok 对修复 diff 的只读反证未发现确定遗漏。 首轮全量检查发现三条旧源码正则仍锁旧实现,改为对应新保护条件并补同值/手选行为测试,没有删掉 diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md index 8d2d952..d1a746c 100644 --- a/docs/experimental-native-execution.md +++ b/docs/experimental-native-execution.md @@ -212,7 +212,7 @@ Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。 - [x] Controller 缺失保持明确拒绝,不静默改前台重复执行。 - [x] Artifact ownership/quota/retention 由插件层自管;跨调用 quota/purge 尚未实现,不宣称已验收。 - [ ] **长期跟踪(不阻塞本 PR)**:Core 将来开放 native typed argv、逐流 encoding、closed DTO 等对应公开 API 时,遵循官方优先纪律整体切换并退役插件私有实现。 -- [ ] **stack / 拆分评估**:#24 尚未合并,当前实验已接在其最新 `c79332e` 后;#24 合并后必须 rebase 到上游 main,保持 RFC。建议两步 review:① Bash fail-closed + 行 Config/UI/拒绝引导及旧行为回退;② native/path、逐流输出与可见性路由。统一开关应在第一步建立,第二步接同一字段。当前 PR 保留完整 RFC 供跨层契约核验,没有另开 PR。 +- [ ] **stack / 拆分评估**:#24 尚未合并,当前实验已接在[前置 PR 的最新修复提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/c79332ea2fb6da38042063f7b55046115de8e530) 后;#24 合并后必须 rebase 到上游 main,保持 RFC。建议两步 review:① Bash fail-closed + 行 Config/UI/拒绝引导及旧行为回退;② native/path、逐流输出与可见性路由。统一开关应在第一步建立,第二步接同一字段。当前 PR 保留完整 RFC 供跨层契约核验,没有另开 PR。 - [ ] **转正式的四项门槛**:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 Windows 真机全量验证、非 CRT 解释器矩阵抽测。四项齐后才谈 ready for review → release。 - [ ] 真实Agent选工具评测、生产GUI/full apply、会话replay尚未验收。 - [ ] 真restricted-token/ACL/IPC/VM正负权限、PTY/SSH、任意非CRT/解释器quoting尚未验收。 diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md index fd30092..66b1749 100644 --- a/docs/experimental-native-verification.md +++ b/docs/experimental-native-verification.md @@ -6,7 +6,7 @@ ## 1. 验证基准与结果 -- 实验增量基线:`4816cd8848a52f73f5f054f761d3ec862cb87ade`(此前MSYS兼容边界修复)。 +- 实验增量基线:[此前 MSYS 兼容边界修复提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/4816cd8848a52f73f5f054f761d3ec862cb87ade)(此前MSYS兼容边界修复)。 - 测试环境:**DSH Core 0.2.1-alpha.2**、Node 24.13.0、Windows / Git for Windows。 - 新native功能按公开能力探测启用;既有兼容版本范围之外的实际覆盖情况见后文待验收项。 - 最终普通全仓:**223 tests / 223 pass / 0 fail / 0 skip / 0 cancel,exit0**。 From d093a76dd793b39f4e1749ba22952ffa5f6eaade Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Wed, 14 Oct 2026 01:34:25 +0800 Subject: [PATCH 5/9] fix: align native RFC with v0.39.3 and complete live-gate compatibility --- AGENTS.md | 3 + CHANGELOG.md | 77 ++++++++++++++---------- README.md | 2 +- README_EN.md | 2 +- docs/experimental-native-execution.md | 7 ++- docs/experimental-native-verification.md | 28 ++++++--- src/bash-tool.js | 8 +-- src/client.js | 76 +++++++++++------------ src/inline-tools.js | 15 ++++- src/native-executor.js | 5 +- src/native-tool.js | 2 +- src/session-workdir.js | 12 ++++ tests/client-regressions.mjs | 4 ++ tests/native-executor.mjs | 18 +++++- tests/native-preview.mjs | 39 ++++++++++-- tests/preview.mjs | 24 +++++++- tests/runtime-preview.mjs | 27 +++++++-- 17 files changed, 242 insertions(+), 107 deletions(-) create mode 100644 src/session-workdir.js diff --git a/AGENTS.md b/AGENTS.md index f9ef7e3..571daff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -606,6 +606,9 @@ 时,inject/服务消费面必须取兼容矩阵内最老宿主的形状或可选化——「宿主新版本有」不等于 「矩阵内都有」;激活/注册类日志必须回读验证,成功日志本身不是证据**(与 §0 静态 import、 §4j 硬注入、v0.38.2 fiber 时序同一失败家族的第无数课)。 + 实验 native 与 Bash 的 cwd 消费共用 `src/session-workdir.js`;native 同样不得硬 inject + workingDirectory。服务存在时 ensure 错误不得吞掉,只有缺服务才走旧接缝。 + companion 激活必须确认 apply 已执行;开关开启时还须工具面回读,关闭时只报告 scope 就绪。 ## 验证清单(改动后) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf1f74..96504ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,18 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 -## Unreleased — 2026-10-13 +## Unreleased — 2026-10-14 + +### PR #26:同步 v0.39.3 与旧宿主兼容(fix / docs / test) + +- 已 rebase 到[上游吸收 PR #28 改进后的 main](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67),前置 PR #24 已合并;正式 v0.39.3 的记录完整保留。 +- Bash/native 共享可选 workingDirectory 读取:有服务保留官方 ensure 及其失败,缺服务使用 policy.workspaceRoot ?? session.header.cwd。native 不再硬 inject 缺失服务。 +- 两个 companion 的成功日志同样要求 apply 已执行及工具面回读;OFF 只报告 scope 就绪且工具关闭,避免 INACTIVE/关闭状态假阳性。 +- 设置开关的 21 份字典均完成本地化,新增文案护栏保留工具名、UNCONFINED 与 danger-full-access 事实;人工译文审阅仍欢迎维护者补充。 +- 新增旧宿主形状下的三工具挂载、双向开关与 cwd;native policy/header/服务失败;真实 cmd 字面 /c 和 PowerShell EncodedCommand + UTF16 输出。真实 Git Bash RO/WW 开关双向切换、未确定策略拒绝亦通过。 +- 当前普通完整回归 **231/231**、实际 Core 完整 preview **79/79**,均 exit0、无 skip/cancel;Windows / Node 24.13.0 / Core 0.2.1-alpha.2。语法、diff 和 npm pack dry-run 通过。 +- 已安装实验构建的[三入口实机报告](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6149607594)作为外部证据引用;报告未记录精确提交,不作为当前 rebase 构建的生产 GUI 验收。真实 LLM 自主选路、维护者 Windows 全量验证与完整非 CRT quoting 矩阵仍待。 + ### PR #26:维护者裁决与运行时开关(fix / feat / docs / test) @@ -16,7 +27,7 @@ - 拒绝错误与 Bash 描述说明设置卡关闭路径,以及由用户在会话访问模式控件选择 danger-full-access 的路径。 21 份词典键齐备,中文/英文先行,其余 19 份暂用英文,合并前补本地化。 - 实验已 rebase 到 [前置 PR 的最新修复提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/c79332ea2fb6da38042063f7b55046115de8e530),保留其 promotion sandbox 警示回归,显式 OFF 验证旧行为。 - #24 仍 OPEN,合并后到 main 的 rebase 尚待;建议两步 review(fail-closed + 开关/UI;native/path/输出/路由),保留 Draft/RFC。 + 当时 #24 仍 OPEN,到 main 的 rebase 尚待(已于本轮完成);建议两步 review(fail-closed + 开关/UI;native/path/输出/路由),保留 Draft/RFC。 - 长期跟踪:Core 开放对应 native/encoding/closed DTO API 后优先整体迁移并退役私有实现;artifact 自管,controller 缺失保持拒绝。 正式门槛仍为真实 Agent 选路、生产 GUI、维护者 win32 全量验证及非 CRT 矩阵抽测。 - 本轮纠正:实际 Core 的 tools.restrict 只认全局工具,初版 agent-local mask 失败;改为拥有者动态注销/注册。 @@ -68,6 +79,38 @@ 真受限token/ACL/IPC/VM负向权限、SSH/PTY与非CRT任意quoting尚未覆盖。未增加Config/UI文案字段。 公开讨论文档见[实验设计与接口](docs/experimental-native-execution.md)、 [验证分级与复现](docs/experimental-native-verification.md)。对应测试可随仓库复现。 + +### 前置修复的历史验证(首次记录时未发布) + +- **重启后复测(2026-10-13)**:desktop Profile 的本地安装来源为 + [PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24) 的 + [`4816cd8`](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/4816cd8848a52f73f5f054f761d3ec862cb87ade) + 修复构建(包/插件清单0.39.2,不是额外发版);四个关键已安装源码与工作树逐字节一致、bundle开启。 + 当前实例status路由200/ok=true;真实前台、后台、超时转后台均输出预期sentinel,中文输出正常; + native `cmd //d //c echo`通过,未设置全局EXCL。原始固定GBK字节仍乱码,显式GBK→UTF-8通过, + 说明编码边界仍在,没有把警示宣称为底层修复。再次执行普通回归 **166/166**、宿主preview **17/17**。 + 已安装包helper覆盖中文/#路径、EOF注释和tagged-template保护,描述包含参数/编码/安全提示; + 独立render探针首轮误把stdout/stderr传为字符串,按真实stream DTO修正后通过,未修改产品代码。 + 本次复测未覆盖真实PTC引擎与生产受限会话;unconfined反馈仅由隔离Core/renderer测试核验。 + +### 未修复问题研究(2026-10-13;不是产品实施或发版) + +- **原生参数/编码**:实际安装 Core 0.2.1-alpha.2 的隔离 argv/raw-pipe/私有 facade→executeArgv + 与固定字节机制断言 **21/21**;可复用托管 provider,不需另造 runner。不同流显式解码可行, + 已混在同流的未知编码不可可靠猜测;private spawnSpec 不当稳定接口。cmd 的前斜杠 executable + 是另一个本机失败反例,反斜杠固定 echo 通过不代表任意 cmd/批处理 quoting 已验收。 +- **真实 PTC 补证**:ToolRuntime→真实 NodePtcRuntime→真实 LocalSubprocessRuntime→Node/fs + 隔离实验 **8/8**,独立复跑 exit0。显式完整值 helper 配合 codePaths OFF 能读对动态路径, + 并保留 `/c/users` 路由数据;legacy 字面量猜测仍误改。不是完整插件 apply/生产 GUI/受限 OS 证明。 +- **安全因果纠正**:现场 MSYS 3.6.10-5a1665c8 源码的 cygheap 使用 VirtualAlloc,signal pipe + 显式 sec_user DACL 缺 restricting SID;当前 Core Flags=13 且 TokenDefaultDacl 已补 ACE。 + 因而历史笼统“用户-only cygheap 映射/只补默认 DACL/所有未来版本无解”不再作为当前根因依据。 + 私有 IPC 身份路线仅静态可行,SID 集合模型 **9/9**不是 AccessCheck 或新沙箱实测。 +- **研究提出的方向**:受限模式spawn前fail-closed、显式native typed argv/逐流decoder/path helper; + Core加法契约与隔离Windows VM中的IPC/AppContainer/LPAC/guest边界仍需进一步验收。 + 当前实现与验证见[实验设计](docs/experimental-native-execution.md)、 + [验证矩阵](docs/experimental-native-verification.md);前置兼容修复见[PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24)。 + ## v0.39.4 — 2026-10-14 **类型**:feat(dsh-ops 0.3.0 互通:gb_* 退役 + 统一方言桥) / fix(docs)。 @@ -160,34 +203,6 @@ opt-in)与跨平台(win/mac/linux)支持。本版把并存形态收敛为「各 ToolRuntime/执行句柄/run_code transport 回归,完整无头 preview **17/17**。旧护栏保留;打包 dry-run、语法和 diff 检查通过。新增 preview 使用实际宿主 API 与内存 subprocess,已有 preview 保留真实 Git Bash/ PowerShell 进程验收;不等同于生产 GUI、真实 PTC 引擎或新 confinement 方案验证。 -- **重启后复测(2026-10-13)**:desktop Profile 的本地安装来源为 - [PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24) 的 - [`4816cd8`](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/4816cd8848a52f73f5f054f761d3ec862cb87ade) - 修复构建(包/插件清单0.39.2,不是额外发版);四个关键已安装源码与工作树逐字节一致、bundle开启。 - 当前实例status路由200/ok=true;真实前台、后台、超时转后台均输出预期sentinel,中文输出正常; - native `cmd //d //c echo`通过,未设置全局EXCL。原始固定GBK字节仍乱码,显式GBK→UTF-8通过, - 说明编码边界仍在,没有把警示宣称为底层修复。再次执行普通回归 **166/166**、宿主preview **17/17**。 - 已安装包helper覆盖中文/#路径、EOF注释和tagged-template保护,描述包含参数/编码/安全提示; - 独立render探针首轮误把stdout/stderr传为字符串,按真实stream DTO修正后通过,未修改产品代码。 - 本次复测未覆盖真实PTC引擎与生产受限会话;unconfined反馈仅由隔离Core/renderer测试核验。 - -### 未修复问题研究(2026-10-13;不是产品实施或发版) - -- **原生参数/编码**:实际安装 Core 0.2.1-alpha.2 的隔离 argv/raw-pipe/私有 facade→executeArgv - 与固定字节机制断言 **21/21**;可复用托管 provider,不需另造 runner。不同流显式解码可行, - 已混在同流的未知编码不可可靠猜测;private spawnSpec 不当稳定接口。cmd 的前斜杠 executable - 是另一个本机失败反例,反斜杠固定 echo 通过不代表任意 cmd/批处理 quoting 已验收。 -- **真实 PTC 补证**:ToolRuntime→真实 NodePtcRuntime→真实 LocalSubprocessRuntime→Node/fs - 隔离实验 **8/8**,独立复跑 exit0。显式完整值 helper 配合 codePaths OFF 能读对动态路径, - 并保留 `/c/users` 路由数据;legacy 字面量猜测仍误改。不是完整插件 apply/生产 GUI/受限 OS 证明。 -- **安全因果纠正**:现场 MSYS 3.6.10-5a1665c8 源码的 cygheap 使用 VirtualAlloc,signal pipe - 显式 sec_user DACL 缺 restricting SID;当前 Core Flags=13 且 TokenDefaultDacl 已补 ACE。 - 因而历史笼统“用户-only cygheap 映射/只补默认 DACL/所有未来版本无解”不再作为当前根因依据。 - 私有 IPC 身份路线仅静态可行,SID 集合模型 **9/9**不是 AccessCheck 或新沙箱实测。 -- **研究提出的方向**:受限模式spawn前fail-closed、显式native typed argv/逐流decoder/path helper; - Core加法契约与隔离Windows VM中的IPC/AppContainer/LPAC/guest边界仍需进一步验收。 - 当前实现与验证见[实验设计](docs/experimental-native-execution.md)、 - [验证矩阵](docs/experimental-native-verification.md);前置兼容修复见[PR #24](https://github.com/KannaKuron/dsh-gitbash-shell/pull/24)。 ## v0.39.2 — 2026-10-13 @@ -218,7 +233,7 @@ opt-in)与跨平台(win/mac/linux)支持。本版把并存形态收敛为「各 暂态失败后重试,卸载或失效时取消,不因单次请求失败永久卡在忙碌状态。 - **回归入口**:新增可移植客户端/桥/运行时/宿主回归与实际 Core preview 集成用例,接入现有 `npm test`/`npm run test:preview`;不新增依赖、不跳过旧断言或 Node 22 矩阵。对齐上游 v0.39.1 - ([私有执行器隔离发布提交](https://github.com/KannaKuron/dsh-gitbash-shell/commit/521be01ee803f289236800ebfb36fab8dd690535))后完整测试 **156/156**、实际 Core 无头 preview **14/14**、打包 dry-run 和 diff 检查 + (`521be01`)后完整测试 **156/156**、实际 Core 无头 preview **14/14**、打包 dry-run 和 diff 检查 通过。新增执行级回归保留 #21 的 real-array glob 契约与 stringified-array 响亮拒绝;不改已发布 版本号、历史记录或 npm 发布流程。Grok 对修复 diff 的只读反证未发现确定遗漏。 首轮全量检查发现三条旧源码正则仍锁旧实现,改为对应新保护条件并补同值/手选行为测试,没有删掉 diff --git a/README.md b/README.md index 8bbcc59..bafec1b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 简体中文 | [English](README_EN.md) -> **Unreleased/实验性实现讨论**:下述 fail-closed 与两个显式工具尚未发布/部署,不是发布候选;安装公开 npm 包不保证包含这些变更。详见[设计、接口与待讨论决定]()、[验证矩阵与复现]()及 CHANGELOG。 +> **Unreleased/实验性实现讨论**:下述 fail-closed 与两个显式工具尚未正式发布,不是发布候选;安装公开 npm 包不保证包含这些变更。详见[设计、接口与待讨论决定]()、[验证矩阵与复现]()及 CHANGELOG。 > 让 DeepSeek Harness (dsh) 在 Windows 上**默认用 Git Bash 跑命令**的官方风格插件 > —— 不替换宿主执行器(官方 PowerShell 执行器原样保留):新增自有 bash 工具 diff --git a/README_EN.md b/README_EN.md index 96d5084..9e173ab 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ [简体中文](README.md) | English -> **Experimental implementation discussion / Unreleased:** the fail-closed behaviour and two explicit tools below have not been published/deployed and are not a release candidate. Public npm installation does not guarantee these changes. See the [design, interfaces and open decisions](), [verification matrix and reproduction]() and Unreleased CHANGELOG. +> **Experimental implementation discussion / Unreleased:** the fail-closed behaviour and two explicit tools below have not been officially released and are not a release candidate. Public npm installation does not guarantee these changes. See the [design, interfaces and open decisions](), [verification matrix and reproduction]() and Unreleased CHANGELOG. > Run **agent commands on Git Bash by default** on Windows with DeepSeek > Harness (dsh) — the plugin does NOT replace the host executor (the official diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md index d1a746c..02e7cff 100644 --- a/docs/experimental-native-execution.md +++ b/docs/experimental-native-execution.md @@ -59,7 +59,7 @@ IPC/Token/AppContainer/VM 路线仍需隔离环境的真实正负权限验证; 变更作用于现有会话后续调用,无需重启,不终止已开始的后台 producer。native 在准备入口、异步准备后、执行入口及最终 spawn 重读开关;已关闭则不得开始新进程。重新开启恢复工具注册与拒绝机制。`posixPaths`、`codePaths`、`subagentDialect` 独立,不改变安全开关。拒绝错误说明设置关闭路径及用户在会话访问模式控件选择 `danger-full-access` 的路径,Agent 不得自行调整。 -21 份词典键已对齐;中文/英文文案先行,其余 19 份暂用明确的英文文案,合并前与维护者补本地化。 +21 份词典均已补齐开关本地化,保留工具名、UNCONFINED 和 danger-full-access 的事实,维护者可继续审阅译文。 ## 4. Agent 应如何选择 @@ -195,7 +195,7 @@ trusted owner在真实policy/校验后创建自有临时目录,不从model cwd 没有controller时明确拒绝,不以“admission失败后再运行前台”冒二次副作用风险。没有Jobs时为前台kill deadline;有Jobs时可timeout promotion。该差异需在正式用户体验验收中检查。 -Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。缺native executor/OutputCollector公开能力不撤bash/path。新工具不加入minimal持久终端通道,不改变root PowerShell、market patch或preset id。 +Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。两工具必须确认 apply 已执行及当前开启状态下工具面可见;关闭状态仅报告 scope 就绪。Bash/native 共用可选目录读取,有服务走官方 ensure(错误不降级),无服务走旧 policy.workspaceRoot ?? session.header.cwd 接缝。缺native executor/OutputCollector公开能力不撤bash/path。新工具不加入minimal持久终端通道,不改变root PowerShell、market patch或preset id。 ## 11. 已发生的反证与修正 @@ -212,7 +212,8 @@ Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。 - [x] Controller 缺失保持明确拒绝,不静默改前台重复执行。 - [x] Artifact ownership/quota/retention 由插件层自管;跨调用 quota/purge 尚未实现,不宣称已验收。 - [ ] **长期跟踪(不阻塞本 PR)**:Core 将来开放 native typed argv、逐流 encoding、closed DTO 等对应公开 API 时,遵循官方优先纪律整体切换并退役插件私有实现。 -- [ ] **stack / 拆分评估**:#24 尚未合并,当前实验已接在[前置 PR 的最新修复提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/c79332ea2fb6da38042063f7b55046115de8e530) 后;#24 合并后必须 rebase 到上游 main,保持 RFC。建议两步 review:① Bash fail-closed + 行 Config/UI/拒绝引导及旧行为回退;② native/path、逐流输出与可见性路由。统一开关应在第一步建立,第二步接同一字段。当前 PR 保留完整 RFC 供跨层契约核验,没有另开 PR。 +- [x] **同步上游**:前置 PR #24 已合并;实验已 rebase 到[上游吸收 PR #28 改进后的 main](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67),继承正式 v0.39.3 的兼容与激活验证。 +- [ ] **拆分评估**:建议两步 review:① Bash fail-closed + 行 Config/UI/拒绝引导/回退;② native/path、逐流输出与可见性路由。唯一开关在第一步建立,第二步复用同一字段。当前保留完整 Draft/RFC,尚未另开子 PR。 - [ ] **转正式的四项门槛**:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 Windows 真机全量验证、非 CRT 解释器矩阵抽测。四项齐后才谈 ready for review → release。 - [ ] 真实Agent选工具评测、生产GUI/full apply、会话replay尚未验收。 - [ ] 真restricted-token/ACL/IPC/VM正负权限、PTY/SSH、任意非CRT/解释器quoting尚未验收。 diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md index 66b1749..c48038b 100644 --- a/docs/experimental-native-verification.md +++ b/docs/experimental-native-verification.md @@ -2,18 +2,18 @@ > **这是实验性讨论的验证记录,不是发布或安全放行。** > 关联:[设计/接口/待讨论决定]()、[CHANGELOG](<../CHANGELOG.md>)。 -> 验证日期:2026-10-13。以下结果适用于所列运行环境,不代表所有宿主版本或生产场景均已通过。 +> 验证日期:2026-10-14(保留此前轮次记录)。以下结果适用于所列运行环境,不代表所有宿主版本或生产场景均已通过。 ## 1. 验证基准与结果 -- 实验增量基线:[此前 MSYS 兼容边界修复提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/4816cd8848a52f73f5f054f761d3ec862cb87ade)(此前MSYS兼容边界修复)。 +- 当前实验基线:[上游 v0.39.3 与 PR #28 后续修复](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67)。前置 PR #24 已合并。 - 测试环境:**DSH Core 0.2.1-alpha.2**、Node 24.13.0、Windows / Git for Windows。 - 新native功能按公开能力探测启用;既有兼容版本范围之外的实际覆盖情况见后文待验收项。 -- 最终普通全仓:**223 tests / 223 pass / 0 fail / 0 skip / 0 cancel,exit0**。 -- 最终实际Core完整preview:**72 tests / 72 pass / 0 fail / 0 skip / 0 cancel,exit0**。 +- 最终普通全仓:**231 tests / 231 pass / 0 fail / 0 skip / 0 cancel,exit0**。 +- 最终实际Core完整preview:**79 tests / 79 pass / 0 fail / 0 skip / 0 cancel,exit0**。 - 语法与`git diff --check`通过;npm dry-run确认新源码包含于发布文件集合。Dry-run不是已安装/发布,也不是新增生产版本。 -计数是Node test runner报告口径,包括子测试/套件;不同轮次或独立复跑有重叠,**不累加为独立覆盖数量**。223/72是完整脚本结果,不意味着223个单元加72个都是真生产进程场景。 +计数是Node test runner报告口径,包括子测试/套件;不同轮次或独立复跑有重叠,**不累加为独立覆盖数量**。231/79 是当前完整脚本结果,不意味着每项都是真生产进程场景。 ## 2. 证据等级 @@ -133,7 +133,7 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev ## 7. 转正式功能前的验收清单 -- [ ] 决定experimental opt-in/default与受限breaking体验。 +- [x] 维护者裁决默认开启的 volatile 开关,关闭恢复旧行为。 - [ ] 生产GUI/full plugin apply、Agent真实选择、会话replay。 - [ ] 真Windows confinement正负权限/IPC/token及可接受隔离方案。 - [ ] SSH/PTY、代表性非CRT/解释器程序的实际quoting。 @@ -144,15 +144,25 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev 在这些项完成前,保持Draft/实验讨论定位,不称普遍编码修复或新沙箱实现。 -## PR #26 运行时开关裁决后的验证 +## PR #26 运行时开关裁决后的验证(2026-10-13 历史轮次) 本轮普通完整回归 **226/226**,实际安装 Core 完整 preview **75/75**,均 exit0、无 skip/cancel。环境仍为 Windows、Node 24.13.0、Core 0.2.1-alpha.2。新增覆盖: -- 卡片缺键默认 ON、两向写同一行 Config 字段、21 字典键对齐(其余 19 语言暂用英文)。 +- 卡片缺键默认 ON、两向写同一行 Config 字段、21 字典键对齐(当时其余 19 语言暂用英文,本轮已本地化)。 - 实际 ToolRuntime 中现有会话 ON/OFF 反复切换、初始 OFF 后启用、工具 schema 注销/注册、受限 Bash 拒绝/旧 unconfined 结果;root shell 不变。 - 旧路径定义关闭后零翻译;native 已准备请求关闭后零 scope/adapter;artifact 异步准备期间关闭后零 spawn、scope 释放,重新开启后真实 Node producer 运行一次。 - #24 actual promotion DTO 测试以显式 OFF 验证旧 sandbox facts 提示,full-access 无警示。 初版 tools.restrict agent-local mask 被实际 Core 拒绝;改为动态拥有者注册。artifact 负例初版检查仅在 spawn facade 内,被 Core 转为失败 handle;增加准备后检查后以明确错误拒绝,未放宽零 spawn 断言。 -仍待正式四项验收:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 win32 真机全量验证、非 CRT 解释器矩阵抽测。#24 未合并,合并后到上游 main 的 rebase 尚待。 +仍待正式四项验收:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 win32 真机全量验证、非 CRT 解释器矩阵抽测。当时 #24 未合并;本轮已完成到上游 main 的 rebase。 + +## 同步 v0.39.3 后的验证(2026-10-14) + +当前完整回归 231/231、实际 Core preview 79/79,exit0、无 skip/cancel。新增缺 workingDirectory 的三工具挂载/cwd/双向开关,真实 native producer 的 policy/header 默认目录;有服务 ensure 错误不降级。两个 companion 也验证真实注册后才报成功。21 份字典已本地化。 + +代表性解释器抽测:真实 cmd.exe 的 literal /c echo sentinel;Windows PowerShell 的显式 EncodedCommand + UTF16 输出中文 sentinel。均通过;这些固定脚本不证明任意引号、元字符或所有非 CRT parser 的可靠性。真实 Git Bash 在 fixture RO/WW 中 OFF 执行并携带 unconfined facts,ON 再次拒绝;未确定策略在 ON 拒绝。所有进程均在隔离 Context 与临时目录中,不改变生产 Profile。 + +[已安装实验构建的实机报告](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6149607594)覆盖 Bash/native/path 入口与 RO/WW、单次批准不粘滞。报告没有记录精确提交,且明确未覆盖开关 OFF、生产 GUI、真实 LLM 自主选路或非 CRT 矩阵;将其作为有限外部证据,不扩展为当前 rebase 构建的完整验收。 + +仍需真实 LLM 自主选路、生产 GUI、维护者 Windows 全量验证、非 CRT 矩阵的正式确认;保留 Draft/RFC。 diff --git a/src/bash-tool.js b/src/bash-tool.js index 970a263..e68b0be 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -40,6 +40,7 @@ * @module dsh-gitbash-shell/bash-tool */ +import { resolveSessionCwd } from './session-workdir.js' import { ESCALATION_TARGETS, parseExitStatus, @@ -517,12 +518,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm // this is the same ensure() seam the modern official tool-bash uses; // on hosts without it, fall back to the session cwd exactly the way // the rc.2-era official resolveWorkdir did (policy root ?? header). - const workingDirectory = ctx.get !== undefined ? ctx.get('workingDirectory') : undefined - const cwd = exec.agent === undefined - ? undefined - : workingDirectory !== undefined - ? await workingDirectory.ensure(exec.agent, exec.signal) - : (standingPolicy?.workspaceRoot ?? exec.agent?.session?.header?.cwd) + const cwd = await resolveSessionCwd(ctx, exec, policy) const workdir = resolveWorkdir(args.workdir, cwd) const dshEnv = ctx.shellEnv.collect(exec) const request = { diff --git a/src/client.js b/src/client.js index c8ee46e..5995683 100644 --- a/src/client.js +++ b/src/client.js @@ -308,8 +308,8 @@ window.__ModuleLoader__.load({ "tools.updating": "جارٍ الترقية…", "variant.label": "الأنماط الأربعة «· Git Bash»", "variant.hint": "معطَّل افتراضيًا: تحصل الأنماط الرسمية الأربعة على Git Bash عبر ترقيع الأدوات داخل العملية وتبقى القائمة رسمية. عند التفعيل تُسجَّل الأنماط الأربعة المتغيّرة وتتوقف ترقيع الأنماط الرسمية — الاثنان حصريان متباددان. الجلسة المثبَّتة على معرّف متغيّر تحتاج إعادة اختيار بعد الترقية مع بقاء المفتاح معطَّلًا.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "التنفيذ الأصلي التجريبي / الرفض الآمن", + "experimental.hint": "مفعّل افتراضيًا: يرفض Git Bash على Windows في أوضاع الوصول المقيّدة أو غير المحدّدة، ويتيح gitbash_native وgitbash_to_native_path. يسري التغيير على الاستدعاءات التالية في الجلسات الحالية. إيقافه يخفي الأداتين ويعطّلهما، ويعيد سلوك Bash القديم المعلن بأنه UNCONFINED: لا تُفرض قيود كتابة الملفات عبر shell. للإبقاء على الرفض الآمن، اختر بنفسك danger-full-access من عنصر التحكم في وضع وصول الجلسة.", "pwsh.label": "أداة pwsh (PowerShell الرسمية)", "pwsh.hint": "مُعطَّلة افتراضيًا: عند التفعيل تصبح أداة pwsh الرسمية للمضيف مرئية للجلسات — وهي PowerShell حقيقية (يفضِّل المُحلِّل الرسمي PowerShell 7 تلقائيًا) لأعمال Windows الأصلية (السجل/الخدمات/WMI). وعند الإيقاف (الافتراضي) تُخفى الأداة ويبقى المضيف يفضِّل bash. لا حاجة لتثبيت أي شيء إضافي؛ أداة PowerShell 7 الجانبية القديمة أُوقفت مع المعمارية الجديدة. مرِّر الأوامر عبر أداة bash كالمعتاد.", "fctx.label": "أدوات ملفات FastCtx (gb_*)", @@ -388,8 +388,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Wird aktualisiert…", "variant.label": "Die vier „· Git Bash“-Varianten", "variant.hint": "Standardmäßig aus: Die vier OFFIZIELLEN Presets bekommen Git Bash durch den Inline-Tool-Patch, die Liste bleibt offiziell. Ein: Registriert die vier Varianten und stoppt das Patchen der offiziellen Presets — beides schließt sich aus. Eine am Varianten-Id festhängende Sitzung muss nach dem Upgrade neu wählen. Gilt für neue Sitzungen.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Experimentelle native Ausführung / sichere Ablehnung", + "experimental.hint": "Standardmäßig EIN: Windows Git Bash wird bei eingeschränktem oder ungeklärtem Zugriffsmodus abgelehnt; gitbash_native und gitbash_to_native_path werden angeboten. Änderungen gelten für nachfolgende Aufrufe bestehender Sitzungen. AUS blendet beide Tools aus und deaktiviert sie; Bash kehrt zum ausdrücklich UNCONFINED gekennzeichneten Verhalten zurück, ohne Schreibbeschränkungen der Shell. Um die sichere Ablehnung beizubehalten, wählen Sie selbst danger-full-access in der Zugriffsmodus-Steuerung der Sitzung.", "pwsh.label": "pwsh-Werkzeug (offizielles PowerShell)", "pwsh.hint": "Standardmäßig aus: Beim Einschalten wird das offizielle pwsh-Werkzeug des Hosts für Sitzungen sichtbar — ECHTES PowerShell (der offizielle Resolver bevorzugt PowerShell 7 automatisch) für Windows-natives Arbeiten (Registry/Dienste/WMI). Im Aus-Zustand (der Standard) bleibt es verborgen und der Host bleibt bash-first. Nichts zusätzlich zu installieren; das alte PowerShell-7-Seitenkanal-Werkzeug ist mit der neuen Architektur entfallen. Befehle laufen weiterhin bevorzugt über das bash-Werkzeug.", "fctx.label": "FastCtx-Dateiwerkzeuge (gb_*)", @@ -468,8 +468,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Mise à jour…", "variant.label": "Les quatre variantes « · Git Bash »", "variant.hint": "Désactivé par défaut : les quatre presets OFFICIELS obtiennent Git Bash via le correctif d’outils en ligne et la liste reste officielle. Activé : enregistre les quatre variantes et cesse de corriger les presets officiels — les deux s’excluent. Une session attachée à un id de variante doit être rechoisie après mise à niveau. S’applique aux nouvelles sessions.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Exécution native expérimentale / refus sécurisé", + "experimental.hint": "Activé par défaut : refuse Git Bash sur Windows en mode d’accès restreint ou indéterminé et propose gitbash_native / gitbash_to_native_path. Les changements concernent les appels suivants des sessions existantes. Désactiver masque et désactive les deux outils et rétablit Bash explicitement UNCONFINED, sans restrictions d’écriture de fichiers par le shell. Pour conserver le refus sécurisé, sélectionnez vous-même danger-full-access dans le contrôle du mode d’accès de la session.", "pwsh.label": "Outil pwsh (PowerShell officiel)", "pwsh.hint": "Désactivé par défaut : activé, l'outil pwsh officiel de l'hôte devient visible pour les sessions — du VRAI PowerShell (le résolveur officiel préfère automatiquement PowerShell 7) pour le travail Windows natif (registre/services/WMI). Désactivé (défaut), il reste masqué et l'hôte garde bash en priorité. Rien à installer ; l'ancien outil PowerShell 7 en parallèle a été retiré avec la nouvelle architecture. Passez toujours par l'outil bash en premier.", "fctx.label": "Outils de fichiers FastCtx (gb_*)", @@ -548,8 +548,8 @@ window.__ModuleLoader__.load({ "tools.updating": "अपडेट हो रहा है…", "variant.label": "चार «· Git Bash» वेरिएंट प्रीसेट", "variant.hint": "डिफ़ॉल्ट रूप से बंद: चार आधिकारिक प्रीसेट को इनलाइन टूल पैच से Git Bash मिलता है और सूची आधिकारिक रहती है। चालू: चार वेरिएंट पंजीकृत होते हैं और आधिकारिक प्रीसेट पैचिंग रुक जाती है — दोनों परस्पर अनन्य हैं। वेरिएंट id पर बंधे सत्र को अपग्रेड के बाद दोबारा चुनना पड़ेगा। नए सत्रों पर लागू।", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "प्रायोगिक नेटिव निष्पादन / सुरक्षित अस्वीकृति", + "experimental.hint": "डिफ़ॉल्ट रूप से चालू: सीमित या अनिर्धारित पहुँच मोड में Windows Git Bash को अस्वीकार करता है और gitbash_native / gitbash_to_native_path उपलब्ध कराता है। बदलाव मौजूदा सत्रों की अगली कॉल पर लागू होते हैं। बंद करने पर दोनों टूल छिप जाते हैं और निष्क्रिय हो जाते हैं; Bash का स्पष्ट रूप से UNCONFINED पुराना व्यवहार लौट आता है, जिसमें shell की फ़ाइल लेखन पाबंदियाँ लागू नहीं होतीं। सुरक्षित अस्वीकृति बनाए रखने के लिए सत्र के पहुँच मोड नियंत्रण में स्वयं danger-full-access चुनें।", "pwsh.label": "pwsh टूल (आधिकारिक PowerShell)", "pwsh.hint": "डिफ़ॉल्ट रूप से बंद: चालू करने पर होस्ट का आधिकारिक pwsh टूल सत्रों के लिए दिखाई देता है — असली PowerShell (आधिकारिक रिज़ॉल्वर स्वतः PowerShell 7 को प्राथमिकता देता है), Windows-नेटिव काम (रजिस्ट्री/सेवाएँ/WMI) के लिए सीधे उपयोगी। बंद (डिफ़ॉल्ट) रहने पर यह छिपा रहता है और होस्ट bash-प्राथमिकता बनाए रखता है। कुछ भी अतिरिक्त इंस्टॉल करने की ज़रूरत नहीं; पुराना PowerShell 7 साइड-चैनल टूल नए आर्किटेक्चर के साथ सेवामुक्त हो गया। कमांड अब भी पहले bash टूल से चलाएँ।", "fctx.label": "FastCtx फ़ाइल उपकरण (gb_*)", @@ -628,8 +628,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Memperbarui…", "variant.label": "Empat preset varian «· Git Bash»", "variant.hint": "Nonaktif secara bawaan: empat preset RESMI mendapat Git Bash melalui patch alat inline dan daftar tetap resmi. Aktif: mendaftarkan empat varian dan berhenti mem-patch preset resmi — keduanya saling eksklusif. Sesi yang terikat pada id varian harus memilih ulang setelah peningkatan. Berlaku untuk sesi baru.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Eksekusi native eksperimental / penolakan aman", + "experimental.hint": "Aktif secara default: menolak Git Bash Windows pada mode akses terbatas atau belum ditentukan, serta menyediakan gitbash_native / gitbash_to_native_path. Perubahan berlaku pada panggilan berikutnya dalam sesi yang sudah ada. Menonaktifkan menyembunyikan dan menonaktifkan kedua alat, lalu mengembalikan Bash lama yang secara jelas UNCONFINED, tanpa pembatasan penulisan file shell. Untuk mempertahankan penolakan aman, pilih sendiri danger-full-access pada kontrol mode akses sesi.", "pwsh.label": "Alat pwsh (PowerShell resmi)", "pwsh.hint": "Nonaktif secara default: saat aktif, alat pwsh resmi milik host menjadi terlihat bagi sesi — PowerShell ASLI (resolver resmi otomatis memilih PowerShell 7) untuk kerja native Windows (registry/layanan/WMI). Nonaktif (default) menyembunyikannya dan host tetap bash-first. Tidak perlu menginstal apa pun; alat samping PowerShell 7 yang lama sudah dipensiunkan bersama arsitektur baru. Jalankan perintah tetap lewat alat bash terlebih dahulu.", "fctx.label": "Alat berkas FastCtx (gb_*)", @@ -708,8 +708,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Aggiornamento…", "variant.label": "Le quattro varianti «· Git Bash»", "variant.hint": "Disattivo per impostazione predefinita: i quattro preset UFFICIALI ottengono Git Bash tramite la patch inline degli strumenti e l’elenco resta ufficiale. Attivo: registra le quattro varianti e smette di correggere i preset ufficiali — i due si escludono. Una sessione legata a un id di variante va riscelta dopo l’aggiornamento. Vale per le nuove sessioni.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Esecuzione nativa sperimentale / rifiuto sicuro", + "experimental.hint": "Attivo per impostazione predefinita: rifiuta Git Bash su Windows con accesso limitato o non determinato e offre gitbash_native / gitbash_to_native_path. Le modifiche valgono per le chiamate successive delle sessioni esistenti. Disattivando si nascondono e disabilitano entrambi gli strumenti e si ripristina Bash esplicitamente UNCONFINED, senza restrizioni di scrittura dei file tramite shell. Per mantenere il rifiuto sicuro, selezionare personalmente danger-full-access nel controllo della modalità di accesso della sessione.", "pwsh.label": "Strumento pwsh (PowerShell ufficiale)", "pwsh.hint": "Disattivo per impostazione predefinita: quando attivo, lo strumento pwsh ufficiale dell'host diventa visibile alle sessioni — VERO PowerShell (il resolver ufficiale preferisce automaticamente PowerShell 7) per lavoro Windows nativo (registro/servizi/WMI). Disattivo (predefinito), resta nascosto e l'host resta bash-first. Nulla da installare in più; il vecchio strumento laterale PowerShell 7 è andato in pensione con la nuova architettura. Instrada comunque i comandi prima tramite lo strumento bash.", "fctx.label": "Strumenti file FastCtx (gb_*)", @@ -788,8 +788,8 @@ window.__ModuleLoader__.load({ "tools.updating": "更新中…", "variant.label": "4 つの「· Git Bash」バリアント", "variant.hint": "既定はオフ:公式 4 プリセットはインラインツールパッチで Git Bash を得られ、一覧は公式のまま。オンにすると 4 バリアントを登録し、公式プリセットへのパッチを停止(両者は排他)。バリアント id に固定されたセッションはアップグレード後に再選択が必要。新規セッションに適用。", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "実験的なネイティブ実行 / 安全な拒否", + "experimental.hint": "既定でオン:Windows の制限付き、または未確定のアクセスモードでは Git Bash を拒否し、gitbash_native / gitbash_to_native_path を提供します。変更は既存セッションの次の呼び出しから適用されます。オフにすると両ツールを非表示・無効化し、UNCONFINED と明示された従来の Bash に戻ります。シェルによるファイル書き込み制限は適用されません。安全な拒否を維持する場合は、ユーザー自身がセッションのアクセスモード設定で danger-full-access を選択してください。", "pwsh.label": "pwsh ツール(公式 PowerShell)", "pwsh.hint": "既定はオフ:オンにすると、ホスト公式の pwsh ツールがセッションから見えるようになります — 本物の PowerShell です(公式リゾルバが自動で PowerShell 7 を優先)。レジストリ/サービス/WMI など Windows ネイティブ作業にそのまま使えます。オフ(既定)では非表示のまま、ホストは bash 優先を保ちます。追加インストールは一切不要。旧 PowerShell 7 バイパスツールは新アーキテクチャで退役しました。コマンドは引き続き bash ツールを優先してください。", "fctx.label": "FastCtx ファイルツール(gb_*)", @@ -868,8 +868,8 @@ window.__ModuleLoader__.load({ "tools.updating": "업데이트 중…", "variant.label": "네 개의 «· Git Bash» 변형 프리셋", "variant.hint": "기본 꺼짐: 공식 4개 프리셋이 인라인 도구 패치로 Git Bash를 얻으며 목록은 공식 그대로입니다. 켜면 4개 변형을 등록하고 공식 프리셋 패칭을 중단합니다(상호 배타). 변형 id 에 고정된 세션은 업그레이드 후 다시 선택해야 합니다. 새 세션에 적용.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "실험적 네이티브 실행 / 안전한 거부", + "experimental.hint": "기본값은 켜짐입니다. 제한되거나 확인되지 않은 접근 모드에서 Windows Git Bash를 거부하고 gitbash_native / gitbash_to_native_path를 제공합니다. 변경은 기존 세션의 다음 호출부터 적용됩니다. 끄면 두 도구를 숨기고 비활성화하며, UNCONFINED로 명시된 기존 Bash 동작을 복원합니다. 이때 셸의 파일 쓰기 제한은 적용되지 않습니다. 안전한 거부를 유지하려면 사용자가 세션 접근 모드 설정에서 직접 danger-full-access를 선택하세요.", "pwsh.label": "pwsh 도구(공식 PowerShell)", "pwsh.hint": "기본적으로 꺼짐: 켜면 호스트의 공식 pwsh 도구가 세션에 표시됩니다 — 진짜 PowerShell이며(공식 리졸버가 자동으로 PowerShell 7을 우선), 레지스트리/서비스/WMI 등 Windows 네이티브 작업에 바로 쓸 수 있습니다. 꺼짐(기본값)이면 숨겨진 채로 호스트는 bash 우선을 유지합니다. 추가 설치가 전혀 필요 없습니다. 이전 PowerShell 7 우회 도구는 새 아키텍처와 함께 은퇴했습니다. 명령은 여전히 bash 도구를 먼저 사용하세요.", "fctx.label": "FastCtx 파일 도구(gb_*)", @@ -948,8 +948,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Bijwerken…", "variant.label": "De vier «· Git Bash»-varianten", "variant.hint": "Standaard uit: de vier OFFICIËLE presets krijgen Git Bash via de inline-toolpatch en de lijst blijft officieel. Aan: registreert de vier varianten en stopt het patchen van de officiële presets — sluit elkaar uit. Een sessie die aan een variant-id vastzit moet na een upgrade opnieuw kiezen. Geldt voor nieuwe sessies.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Experimentele native uitvoering / veilige weigering", + "experimental.hint": "Standaard AAN: weigert Windows Git Bash bij beperkte of onbepaalde toegang en biedt gitbash_native / gitbash_to_native_path aan. Wijzigingen gelden voor volgende aanroepen in bestaande sessies. UIT verbergt en deactiveert beide tools en herstelt het expliciet UNCONFINED gemarkeerde Bash-gedrag, zonder beperkingen op schrijven via de shell. Om veilige weigering te behouden, kiest u zelf danger-full-access in de toegangsmodus van de sessie.", "pwsh.label": "pwsh-hulpmiddel (officiële PowerShell)", "pwsh.hint": "Standaard uit: ingeschakeld wordt het officiële pwsh-hulpmiddel van de host zichtbaar voor sessies — ECHTE PowerShell (de officiële resolver geeft automatisch de voorkeur aan PowerShell 7) voor Windows-natief werk (register/services/WMI). Uit (de standaard) blijft het verborgen en blijft de host bash-first. Niets extra te installeren; het oude PowerShell-7-sidechannel-hulpmiddel is met de nieuwe architectuur met pensioen. Voer opdrachten nog steeds bij voorkeur uit via het bash-hulpmiddel.", "fctx.label": "FastCtx-bestandshulpmiddelen (gb_*)", @@ -1028,8 +1028,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Aktualizowanie…", "variant.label": "Cztery warianty «· Git Bash»", "variant.hint": "Domyślnie wyłączone: cztery OFICJALNE presety dostają Git Bash przez inline-owy patch narzędzi, a lista pozostaje oficjalna. Włączenie rejestruje cztery warianty i zatrzymuje patchowanie oficjalnych presetów — opcje wykluczają się. Sesja przypięta do id wariantu musi zostać wybrana ponownie po aktualizacji. Dotyczy nowych sesji.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Eksperymentalne wykonanie natywne / bezpieczna odmowa", + "experimental.hint": "Domyślnie WŁĄCZONE: odmawia uruchomienia Git Bash w Windows przy ograniczonym lub nieustalonym dostępie i udostępnia gitbash_native / gitbash_to_native_path. Zmiany dotyczą kolejnych wywołań w istniejących sesjach. Wyłączenie ukrywa i dezaktywuje oba narzędzia oraz przywraca Bash jawnie oznaczony jako UNCONFINED, bez ograniczeń zapisu plików przez powłokę. Aby zachować bezpieczną odmowę, wybierz samodzielnie danger-full-access w ustawieniu trybu dostępu sesji.", "pwsh.label": "Narzędzie pwsh (oficjalny PowerShell)", "pwsh.hint": "Domyślnie wyłączone: po włączeniu oficjalne narzędzie pwsh hosta staje się widoczne dla sesji — PRAWDZIWY PowerShell (oficjalny resolver automatycznie preferuje PowerShell 7) do natywnej pracy w Windows (rejestr/usługi/WMI). Wyłączone (domyślnie) pozostaje ukryte, a host pozostaje bash-first. Niczego nie trzeba dodatkowo instalować; stare narzędzie poboczne PowerShell 7 przeszło na emeryturę wraz z nową architekturą. Polecenia nadal wykonuj najpierw przez narzędzie bash.", "fctx.label": "Narzędzia plików FastCtx (gb_*)", @@ -1108,8 +1108,8 @@ window.__ModuleLoader__.load({ "tools.updating": "A atualizar…", "variant.label": "As quatro variantes «· Git Bash»", "variant.hint": "Desativado por padrão: os quatro presets OFICIAIS recebem Git Bash pelo patch de ferramentas em linha e a lista permanece oficial. Ativado: registra as quatro variantes e deixa de corrigir os presets oficiais — mutuamente exclusivos. Uma sessão presa a um id de variante precisa ser reescolhida após a atualização. Vale para novas sessões.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Execução nativa experimental / recusa segura", + "experimental.hint": "Ativado por padrão: recusa Git Bash no Windows em modos de acesso restritos ou indeterminados e oferece gitbash_native / gitbash_to_native_path. As alterações valem para chamadas seguintes nas sessões existentes. Desativar oculta e desativa ambas as ferramentas e restaura o Bash explicitamente UNCONFINED, sem restrições de escrita de arquivos pelo shell. Para manter a recusa segura, selecione você mesmo danger-full-access no controle do modo de acesso da sessão.", "pwsh.label": "Ferramenta pwsh (PowerShell oficial)", "pwsh.hint": "Desativado por padrão: ao ativar, a ferramenta pwsh oficial do host fica visível para as sessões — PowerShell REAL (o resolvedor oficial prefere automaticamente o PowerShell 7) para trabalho nativo do Windows (registro/serviços/WMI). Desativado (o padrão), ela permanece oculta e o host continua bash-first. Nada extra para instalar; a antiga ferramenta paralela PowerShell 7 foi aposentada com a nova arquitetura. Encaminhe os comandos ainda pela ferramenta bash primeiro.", "fctx.label": "Ferramentas de arquivos FastCtx (gb_*)", @@ -1188,8 +1188,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Обновление…", "variant.label": "Четыре варианта «· Git Bash»", "variant.hint": "По умолчанию выключено: четыре ОФИЦИАЛЬНЫХ пресета получают Git Bash через встроенный патч инструментов, список остаётся официальным. Включено — регистрирует четыре варианта и прекращает патчить официальные пресеты (взаимоисключающе). Сессия, привязанная к id варианта, после обновления должна выбрать пресет заново. Действует для новых сессий.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Экспериментальное нативное выполнение / безопасный отказ", + "experimental.hint": "По умолчанию включено: запрещает Git Bash в Windows при ограниченном или неопределённом режиме доступа и предоставляет gitbash_native / gitbash_to_native_path. Изменения действуют для следующих вызовов в текущих сеансах. Выключение скрывает и отключает оба инструмента и возвращает Bash с явной пометкой UNCONFINED: ограничения записи файлов через оболочку не применяются. Чтобы сохранить безопасный отказ, самостоятельно выберите danger-full-access в настройке режима доступа сеанса.", "pwsh.label": "Инструмент pwsh (официальный PowerShell)", "pwsh.hint": "По умолчанию выключено: при включении штатный pwsh-инструмент хоста становится видимым для сессий — НАСТОЯЩИЙ PowerShell (официальный резолвер автоматически предпочитает PowerShell 7) для нативной работы Windows (реестр/службы/WMI). В выключенном состоянии (по умолчанию) он скрыт, и хост остаётся bash-приоритетным. Ничего дополнительно устанавливать не нужно; старый обходной инструмент PowerShell 7 ушёл в отставку вместе с новой архитектурой. Команды по-прежнему выполняйте сначала через инструмент bash.", "fctx.label": "Файловые инструменты FastCtx (gb_*)", @@ -1268,8 +1268,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Uppdaterar…", "variant.label": "De fyra «· Git Bash»-varianterna", "variant.hint": "Av som standard: de fyra OFFICIELLA presetten får Git Bash via inline-verktygspatchen och listan förblir officiell. På: registrerar de fyra varianterna och slutar patcha de officiella presetten — ömsesidigt uteslutande. En session fastnåld på ett variant-id måste väljas om efter uppgradering. Gäller nya sessioner.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Experimentell native-körning / säker avvisning", + "experimental.hint": "PÅ som standard: avvisar Windows Git Bash vid begränsad eller obestämd åtkomst och erbjuder gitbash_native / gitbash_to_native_path. Ändringar gäller nästa anrop i befintliga sessioner. AV döljer och inaktiverar båda verktygen och återställer Bash med den uttryckliga märkningen UNCONFINED, utan begränsningar för filskrivning via skalet. För att behålla säker avvisning väljer du själv danger-full-access i sessionens åtkomstläge.", "pwsh.label": "pwsh-verktyget (officiell PowerShell)", "pwsh.hint": "Av som standard: när den är på blir hostens officiella pwsh-verktyg synligt för sessioner — ÄKTA PowerShell (den officiella upplösaren föredrar automatiskt PowerShell 7) för Windows-inbyggt arbete (register/tjänster/WMI). Av (standarden) hålls den dold och hosten förblir bash-först. Inget extra att installera; det gamla PowerShell 7-sidokanalverktyget har pensionerats med den nya arkitekturen. Kör kommandon fortfarande via bash-verktyget först.", "fctx.label": "FastCtx-filverktyg (gb_*)", @@ -1348,8 +1348,8 @@ window.__ModuleLoader__.load({ "tools.updating": "กำลังอัปเกรด…", "variant.label": "พรีเซ็ตย่อย «· Git Bash» ทั้งสี่", "variant.hint": "ปิดตามค่าเริ่มต้น: พรีเซ็ตทางการทั้งสี่ได้ Git Bash ผ่านแพตช์เครื่องมือในตัว และรายการยังเป็นทางการ เปิด: ลงทะเบียนสี่ตัวย่อยและหยุดแพตช์พรีเซ็ตทางการ — ทั้งสองไม่ใช้ร่วมกันได้ เซสชันที่ตรึงกับ id ย่อยต้องเลือกใหม่หลังอัปเกรด ใช้กับเซสชันใหม่", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "การเรียกใช้แบบเนทีฟเชิงทดลอง / การปฏิเสธอย่างปลอดภัย", + "experimental.hint": "เปิดตามค่าเริ่มต้น: ปฏิเสธ Git Bash บน Windows ในโหมดการเข้าถึงที่จำกัดหรือยังไม่ระบุ และให้ใช้ gitbash_native / gitbash_to_native_path การเปลี่ยนแปลงมีผลกับการเรียกครั้งถัดไปในเซสชันเดิม เมื่อปิดจะซ่อนและปิดใช้งานทั้งสองเครื่องมือ พร้อมคืนพฤติกรรม Bash เดิมที่ระบุชัดว่า UNCONFINED โดยไม่บังคับข้อจำกัดการเขียนไฟล์ผ่านเชลล์ หากต้องการคงการปฏิเสธอย่างปลอดภัย ให้ผู้ใช้เลือก danger-full-access เองในตัวควบคุมโหมดการเข้าถึงของเซสชัน", "pwsh.label": "เครื่องมือ pwsh (PowerShell ทางการ)", "pwsh.hint": "ปิดอยู่โดยค่าเริ่มต้น: เมื่อเปิด เครื่องมือ pwsh ทางการของโฮสต์จะปรากฏแก่เซสชัน — เป็น PowerShell แท้ (ตัวแก้ไขทางการเลือก PowerShell 7 ให้โดยอัตโนมัติ) ใช้ได้ทันทีสำหรับงาน Windows แบบเนทีฟ (registry/services/WMI) เมื่อปิด (ค่าเริ่มต้น) เครื่องมือจะถูกซ่อนและโฮสต์ยังให้ bash มาก่อน ไม่ต้องติดตั้งอะไรเพิ่ม; เครื่องมือข้างทาง PowerShell 7 เดิมได้เลิกใช้ไปพร้อมสถาปัตยกรรมใหม่ ส่งคำสั่งผ่านเครื่องมือ bash เป็นอันดับแรกเช่นเดิม", "fctx.label": "เครื่องมือไฟล์ FastCtx (gb_*)", @@ -1428,8 +1428,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Güncelleniyor…", "variant.label": "Dört «· Git Bash» varyantı", "variant.hint": "Varsayılan olarak kapalı: dört RESMİ preset, satır içi araç yamasıyla Git Bash alır; liste resmi kalır. Açık: dört varyantı kaydeder ve resmi presetleri yamalamayı bırakır — ikisi birbirini dışlar. Bir varyant kimliğine bağlı oturum yükseltmeden sonra yeniden seçilmelidir. Yeni oturumlar için geçerlidir.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Deneysel yerel yürütme / güvenli ret", + "experimental.hint": "Varsayılan olarak AÇIK: kısıtlı veya belirsiz erişim modlarında Windows Git Bash yürütmesini reddeder ve gitbash_native / gitbash_to_native_path araçlarını sunar. Değişiklikler mevcut oturumlardaki sonraki çağrılarda geçerlidir. KAPALI, iki aracı gizler ve devre dışı bırakır; açıkça UNCONFINED olarak işaretlenen eski Bash davranışını geri getirir, kabuk üzerinden dosya yazma kısıtlamaları uygulanmaz. Güvenli reddi korumak için oturumun erişim modu denetiminde danger-full-access seçimini kendiniz yapın.", "pwsh.label": "pwsh aracı (resmî PowerShell)", "pwsh.hint": "Varsayılan olarak kapalı: açıkken ana makinenin resmî pwsh aracı oturumlara görünür olur — GERÇEK PowerShell (resmî çözümleyici otomatik olarak PowerShell 7'yi tercih eder) Windows-yerel işler (kayıt defteri/hizmetler/WMI) için doğrudan kullanılabilir. Kapalı (varsayılan) kaldığında gizli kalır ve ana makine bash-öncelikli kalır. Ekstra bir şey kurmaya gerek yok; eski PowerShell 7 yan kanal aracı yeni mimariyle birlikte emekli edildi. Komutları yine de önce bash aracıyla çalıştırın.", "fctx.label": "FastCtx dosya araçları (gb_*)", @@ -1508,8 +1508,8 @@ window.__ModuleLoader__.load({ "tools.updating": "Đang nâng cấp…", "variant.label": "Bốn biến thể «· Git Bash»", "variant.hint": "Mặc định tắt: bốn preset CHÍNH THỨC nhận Git Bash qua bản vá công cụ nội tuyến và danh sách vẫn chính thức. Bật: đăng ký bốn biến thể và ngừng vá các preset chính thức — hai bên loại trừ nhau. Phiên đang ghim vào id biến thể cần chọn lại sau nâng cấp. Áp dụng cho phiên mới.", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "Thực thi native thử nghiệm / từ chối an toàn", + "experimental.hint": "Mặc định BẬT: từ chối Git Bash trên Windows khi chế độ truy cập bị hạn chế hoặc chưa xác định, đồng thời cung cấp gitbash_native / gitbash_to_native_path. Thay đổi áp dụng cho các lần gọi tiếp theo trong phiên hiện có. TẮT sẽ ẩn và vô hiệu hóa cả hai công cụ, khôi phục Bash cũ được ghi rõ là UNCONFINED, không thực thi giới hạn ghi tệp qua shell. Để giữ cơ chế từ chối an toàn, người dùng tự chọn danger-full-access trong điều khiển chế độ truy cập của phiên.", "pwsh.label": "Công cụ pwsh (PowerShell chính thức)", "pwsh.hint": "Mặc định tắt: khi bật, công cụ pwsh chính thức của host hiển thị cho các phiên — PowerShell THẬT (bộ phân giải chính thức tự ưu tiên PowerShell 7) dùng ngay cho công việc Windows gốc (registry/services/WMI). Tắt (mặc định) thì nó vẫn bị ẩn và host vẫn ưu tiên bash. Không cần cài thêm gì; công cụ rẽ nhánh PowerShell 7 cũ đã nghỉ hưu cùng kiến trúc mới. Vẫn ưu tiên chạy lệnh qua công cụ bash trước.", "fctx.label": "Công cụ tệp FastCtx (gb_*)", @@ -1588,8 +1588,8 @@ window.__ModuleLoader__.load({ "tools.updating": "升級緊…", "variant.label": "四個「·Git Bash」變體模式", "variant.hint": "預設關閉:官方四個模式經行內工具補位直接獲得 Git Bash,模式列表保持官方原樣。開啟後註冊四個變體並停用官方模式的工具補位——兩者互斥。釘在變體 id 的舊會話在關閉升級後需重新選擇模式;變更對新會話生效。", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "實驗原生執行 / fail-closed", + "experimental.hint": "預設開啟:Windows 受限或未確定存取模式下拒絕 Git Bash,並提供 gitbash_native / gitbash_to_native_path。修改即時作用於現有會話的後續呼叫。關閉會隱藏並停用兩個工具,恢復明確標示 UNCONFINED 的舊版 Bash,不執行 shell 檔案寫入限制。如保留安全拒絕,請由用戶在會話存取模式控制項選擇 danger-full-access。", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "預設關閉:開啟後宿主官方的 pwsh 工具對會話可見——它是真正的 PowerShell(官方解析器自動優先 PowerShell 7),可直接用於 Windows 原生操作(登錄檔/服務/WMI)。關閉(預設)時該工具被隱藏,主機保持 bash 優先。無需安裝任何額外元件;舊版 PowerShell 7 旁路工具已隨新架構退役。命令仍然首選 bash 工具。", "fctx.label": "FastCtx 檔案工具(gb_*)", @@ -1668,8 +1668,8 @@ window.__ModuleLoader__.load({ "tools.updating": "升級緊…", "variant.label": "四個「·Git Bash」變體模式", "variant.hint": "預設關閉:官方四個模式經行內工具補位直接獲得 Git Bash,模式列表保持官方原樣。開啟後註冊四個變體並停用官方模式的工具補位——兩者互斥。釘在變體 id 的舊會話在關閉升級後需重新選擇模式;變更對新會話生效。", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "實驗原生執行 / fail-closed", + "experimental.hint": "預設開啟:Windows 受限或未確定存取模式下拒絕 Git Bash,並提供 gitbash_native / gitbash_to_native_path。修改即時作用於現有會話的後續呼叫。關閉會隱藏並停用兩個工具,恢復明確標示 UNCONFINED 的舊版 Bash,不執行 shell 檔案寫入限制。如保留安全拒絕,請由用戶在會話存取模式控制項選擇 danger-full-access。", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "預設關閉:開啟後宿主官方的 pwsh 工具對會話可見——它是真正的 PowerShell(官方解析器自動優先 PowerShell 7),可直接用於 Windows 原生操作(登錄檔/服務/WMI)。關閉(預設)時該工具被隱藏,主機保持 bash 優先。無需安裝任何額外元件;舊版 PowerShell 7 旁路工具已隨新架構退役。命令仍然首選 bash 工具。", "fctx.label": "FastCtx 檔案工具(gb_*)", @@ -1748,8 +1748,8 @@ window.__ModuleLoader__.load({ "tools.updating": "更新中…", "variant.label": "四個「·Git Bash」變體模式", "variant.hint": "預設關閉:官方四個模式經行內工具補位直接獲得 Git Bash,模式列表保持官方原樣。開啟後註冊四個變體並停用官方模式的工具補位——兩者互斥。釘在變體 id 的舊會話在關閉升級後需重新選擇模式;變更對新會話生效。", - "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.label": "實驗原生執行 / fail-closed", + "experimental.hint": "預設開啟:Windows 受限或未確定存取模式下拒絕 Git Bash,並提供 gitbash_native / gitbash_to_native_path。修改立即作用於現有工作階段的後續呼叫。關閉會隱藏並停用兩個工具,恢復明確標示 UNCONFINED 的舊版 Bash,不執行 shell 檔案寫入限制。如保留安全拒絕,請由使用者在工作階段存取模式控制項選擇 danger-full-access。", "pwsh.label": "pwsh 工具(官方 PowerShell)", "pwsh.hint": "預設關閉:開啟後宿主官方的 pwsh 工具對工作階段可見——它是真正的 PowerShell(官方解析器自動優先 PowerShell 7),可直接用於 Windows 原生操作(登錄檔/服務/WMI)。關閉(預設)時該工具被隱藏,主機保持 bash 優先。無需安裝任何額外元件;舊版 PowerShell 7 旁路工具已隨新架構退役。命令仍然首選 bash 工具。", "fctx.label": "FastCtx 檔案工具(gb_*)", diff --git a/src/inline-tools.js b/src/inline-tools.js index f9dd060..024c5ea 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -366,16 +366,25 @@ export function mountInlineTools(ctx, deps) { if (faceNames(agent).includes(extra.name)) continue // Never replace another owner. let extraFiber try { - extraFiber = scope.ctx.plugin(extra.module) + let applied = false + extraFiber = scope.ctx.plugin({ ...extra.module, async apply(extraCtx, config) { + await extra.module.apply(extraCtx, config) + applied = true + } }) await untilDisposed(extraFiber, state.controller.signal) if (!isLive(agent, state)) return - log(`${extra.name} activated in the agent-owned scope`) + if (!applied || (experimentalEnabled() && !faceNames(agent).includes(extra.name))) { + throw new Error(`${extra.name} did not register (module inactive or tool not visible)`) + } + log(experimentalEnabled() + ? `${extra.name} activated in the agent-owned scope` + : `${extra.name} scope ready; tool disabled by experimentalNative`) } catch (error) { if (!isLive(agent, state)) return await extraFiber?.dispose() log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`) } - + } retained = true log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)') } catch (error) { diff --git a/src/native-executor.js b/src/native-executor.js index 12b3744..f51fc3f 100644 --- a/src/native-executor.js +++ b/src/native-executor.js @@ -1,4 +1,5 @@ import path from 'node:path' +import { resolveSessionCwd } from './session-workdir.js' // No host peer imports here: the owner supplies modules via its host loader. const KEYS = new Set(['description', 'executable', 'argv', 'cwd', 'timeoutMs', 'run_in_background', 'entrypoint', 'stdoutEncoding', 'stderrEncoding', 'errors']) @@ -136,14 +137,14 @@ export function createNativeExecutor(ctx, deps) { waitBudget: request => Math.max(1, remaining(request)), async prepare(args, exec) { requireEnabled() - requireNativePolicy(ctx, exec, sandboxModule) + const policy = requireNativePolicy(ctx, exec, sandboxModule) exec.signal?.throwIfAborted() if (args?.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number') const startedAt = Date.now() const timeoutMs = Math.min(args?.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS) const preparation = cancellation(exec.signal, timeoutMs) try { - const sessionCwd = exec.agent === undefined ? undefined : await whileActive(() => ctx.workingDirectory.ensure(exec.agent, preparation.signal), preparation.signal) + const sessionCwd = await whileActive(() => resolveSessionCwd(ctx, { ...exec, signal: preparation.signal }, policy), preparation.signal) requireEnabled() preparation.signal.throwIfAborted() const request = parseNativeRequest(args, { translateMsysPath, sessionCwd, platform: deps.platform ?? process.platform }) diff --git a/src/native-tool.js b/src/native-tool.js index 48bec5c..1d37ff0 100644 --- a/src/native-tool.js +++ b/src/native-tool.js @@ -103,7 +103,7 @@ export function renderNativeResult(value) { export function createNativeToolModule(deps) { if (typeof deps.toolsModule?.defineTool !== 'function') throw new Error('gitbash_native: host defineTool capability unavailable') return { - inject: ['tools', 'subprocess', 'workingDirectory', 'shellEnv'], + inject: ['tools', 'subprocess', 'shellEnv'], apply(ctx, config = {}) { const executor = createNativeExecutor(ctx, deps) const enabled = config.enableRunInBackground !== false diff --git a/src/session-workdir.js b/src/session-workdir.js new file mode 100644 index 0000000..a830787 --- /dev/null +++ b/src/session-workdir.js @@ -0,0 +1,12 @@ +/** Use the host's current directory; retain the old policy/header seam when absent. */ +export async function resolveSessionCwd(ctx, exec, policy) { + if (exec.agent === undefined) return undefined + exec.signal?.throwIfAborted() + const service = typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined + if (service !== undefined) { + if (typeof service?.ensure !== 'function') throw new TypeError('workingDirectory service does not expose ensure()') + // Existing service errors must not become a silent downgrade to the original cwd. + return service.ensure(exec.agent, exec.signal) + } + return policy?.workspaceRoot ?? exec.agent.session?.header?.cwd +} diff --git a/tests/client-regressions.mjs b/tests/client-regressions.mjs index e344c38..0c532eb 100644 --- a/tests/client-regressions.mjs +++ b/tests/client-regressions.mjs @@ -149,4 +149,8 @@ test('client: experimental switch defaults ON and writes the authoritative field assert.equal(f.writes.at(-1).value, true); const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8'); for (const key of ['experimental.label', 'experimental.hint']) assert.equal(source.split('"' + key + '":').length - 1, 21); + const labels = [...source.matchAll(/"experimental.label": ("(?:[^"\\]|\\.)*")/g)].map(match => JSON.parse(match[1])); + assert.equal(labels.filter(label => label === labels[1]).length, 1, 'only English uses the English placeholder'); + const hints = [...source.matchAll(/"experimental.hint": ("(?:[^"\\]|\\.)*")/g)].map(match => JSON.parse(match[1])); + for (const hint of hints) for (const fact of ['gitbash_native', 'gitbash_to_native_path', 'UNCONFINED', 'danger-full-access']) assert.ok(hint.includes(fact)); }); diff --git a/tests/native-executor.mjs b/tests/native-executor.mjs index 93bca6e..fc17c4f 100644 --- a/tests/native-executor.mjs +++ b/tests/native-executor.mjs @@ -79,7 +79,7 @@ test('native policy: all denied paths precede workspace, scope, adapter and spaw test('native cancellation: cancellation during workspace preparation cannot hand off a job', async () => { const abort = new AbortController() let envCollected = false - const ctx = { get: () => ({ resolve: () => ({ mode: 'danger-full-access' }) }), + const ctx = { get: key => key === 'workingDirectory' ? ctx.workingDirectory : { resolve: () => ({ mode: 'danger-full-access' }) }, workingDirectory: { async ensure() { abort.abort(new Error('fixed preparation cancel')); return 'C:/Native Sentinel' } }, shellEnv: { collect() { envCollected = true; return {} } } } const executor = createNativeExecutor(ctx, { localBashModule: { LocalBashExecutor: class {} }, @@ -93,7 +93,7 @@ test('native preparation: a non-settling scope mount is bounded and disposed bef let artifactCreated = false const owner = { isolate() { return this } } const ctx = { - get: key => key === 'sandboxPolicy' ? { resolve: () => ({ mode: 'danger-full-access' }) } : { spawn() { throw new Error('MUST_NOT_SPAWN') } }, + get: key => key === 'workingDirectory' ? ctx.workingDirectory : key === 'sandboxPolicy' ? { resolve: () => ({ mode: 'danger-full-access' }) } : { spawn() { throw new Error('MUST_NOT_SPAWN') } }, workingDirectory: { ensure: async () => 'C:/Native Sentinel' }, shellEnv: { collect: () => ({}) }, } const executor = createNativeExecutor(ctx, { platform: 'win32', localBashModule: { LocalBashExecutor: class {} }, @@ -133,7 +133,7 @@ test('native schema authoring: implicit property-map, required markers and capab test('native switch: disabled preparation and stale prepared request have no side effects', async () => { let enabled = false, ensure = 0; - const ctx = { get: () => ({ resolve: () => ({ mode: 'danger-full-access' }) }), + const ctx = { get: key => key === 'workingDirectory' ? ctx.workingDirectory : { resolve: () => ({ mode: 'danger-full-access' }) }, workingDirectory: { ensure: async () => { ensure++; return 'C:/Native Sentinel'; } }, shellEnv: { collect: () => ({}) } }; const executor = createNativeExecutor(ctx, { platform: 'win32', experimentalEnabled: () => enabled, localBashModule: { LocalBashExecutor: class {} }, translateMsysPath, @@ -147,3 +147,15 @@ test('native schema authoring: implicit property-map, required markers and capab await assert.rejects(executor.execute(prepared, exec), /disabled by experimentalNative/); assert.equal(ensure, 1); }); + + +test('native directory: optional service preserves modern failures and policy/header precedence', async()=>{ + const agent={session:{header:{cwd:'D:/header'}}};let service,root='E:/policy'; + const ctx={get:key=>key==='workingDirectory'?service:key==='sandboxPolicy'?{resolve:()=>({mode:'danger-full-access',...(root?{workspaceRoot:root}:{})})}:undefined,shellEnv:{collect:()=>({})}}; + const executor=createNativeExecutor(ctx,{platform:'win32',localBashModule:{LocalBashExecutor:class{}},translateMsysPath,createScope(){throw new Error('must not mount')},createNativeOutputAdapter(){throw new Error('must not spawn')}}); + const exec={agent,signal:new AbortController().signal}; + assert.equal((await executor.prepare(request(),exec)).cwd,'E:\\policy'); + root=undefined;assert.equal((await executor.prepare(request(),exec)).cwd,'D:\\header'); + const failure=new Error('official ensure failed');service={ensure(){throw failure}}; + await assert.rejects(executor.prepare(request(),exec),e=>e===failure); +}); diff --git a/tests/native-preview.mjs b/tests/native-preview.mjs index 74805b4..8cbbb7b 100644 --- a/tests/native-preview.mjs +++ b/tests/native-preview.mjs @@ -33,7 +33,7 @@ function createScope(parent) { const fiber = parent.plugin(value => { ctx = value }) return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } } -async function fixture(t, { jobs = false, promote = false, controller = true, artifactFactory, ensure } = {}) { +async function fixture(t, { jobs = false, promote = false, controller = true, artifactFactory, ensure, missingDirectoryService = false, policyWorkspaceRoot = DIR } = {}) { const root = new Context() const disposers = [] const spawned = [] @@ -41,11 +41,11 @@ async function fixture(t, { jobs = false, promote = false, controller = true, ar let mode = 'danger-full-access' let experimental = true let adapterConstructions = 0 - const policy = { defaultMode: 'danger-full-access', resolve: () => mode === undefined ? undefined : { mode, workspaceRoot: DIR } } + const policy = { defaultMode: 'danger-full-access', resolve: () => mode === undefined ? undefined : { mode, ...(policyWorkspaceRoot === null ? {} : { workspaceRoot: policyWorkspaceRoot }) } } const official = { name: 'official-preserved' } let agent for (const [name, value] of Object.entries({ shell: official, sandboxPolicy: policy, - workingDirectory: { ensure: ensure ?? (async () => DIR) }, agents: { get: id => agent?.id === id ? agent : undefined } })) disposers.push(root.provide(name, value)) + ...(missingDirectoryService ? {} : { workingDirectory: { ensure: ensure ?? (async () => DIR) } }), agents: { get: id => agent?.id === id ? agent : undefined } })) disposers.push(root.provide(name, value)) const mount = async (module, config) => { const fiber = root.plugin(module, config) disposers.push(() => fiber.dispose()) @@ -58,7 +58,7 @@ async function fixture(t, { jobs = false, promote = false, controller = true, ar const rootProvider = root.reflect.store[root[Context.isolate].subprocess] const rootShell = root.reflect.store[root[Context.isolate].shell] const agentId = `native-preview-${randomUUID()}` - agent = { id: agentId, session: { header: { id: agentId } } } + agent = { id: agentId, session: { header: { id: agentId, cwd: DIR } } } const agentScope = createAgentScope(root, agent) agent.ctx = agentScope.ctx disposers.push(() => agentScope.dispose()) @@ -415,3 +415,34 @@ process.on('exit', code => { fs.writeFileSync(path.join(DIR, 'results.json'), JSON.stringify({ exitCode: code, node: process.version, core: 'installed host via --import loader', rows }, null, 2)) console.log(`Native preview evidence: ${DIR}`) }) + + +test('installed Core native: missing directory service uses policy root then header for a real producer', {timeout:15000}, async t=>{ + for(const policyWorkspaceRoot of [DIR,null]){ + const f=await fixture(t,{missingDirectoryService:true,policyWorkspaceRoot}); + const response=await f.run(request('console.log(process.cwd())')); + assert.equal(response.isError,false,JSON.stringify(response)); + assert.equal(response.value.stdout.text.trim(),path.win32.normalize(DIR)); + assert.equal(f.spawned.length,1);f.preserved(); + } +}); + + +test('installed Core native: cmd literal /c reaches the interpreter without MSYS conversion', {timeout:15000}, async t=>{ + const f=await fixture(t); + const executable=path.join(process.env.SystemRoot??'C:/Windows','System32','cmd.exe'); + const response=await f.run({description:'Fixed cmd interpreter sentinel',executable:typedPath(executable),entrypoint:'interpreter',argv:['/d','/s','/c','echo CMD_FIXED_SENTINEL'].map(literal)}); + assert.equal(response.isError,false,JSON.stringify(response));assert.equal(response.value.exitCode,0); + assert.equal(response.value.stdout.text.trim(),'CMD_FIXED_SENTINEL'); + assert.ok(f.spawned[0].spec.argv.includes('/c'));f.preserved(); +}); + +test('installed Core native: Windows PowerShell encoded command declares UTF16 output explicitly', {timeout:15000}, async t=>{ + const f=await fixture(t); + const executable=path.join(process.env.SystemRoot??'C:/Windows','System32','WindowsPowerShell','v1.0','powershell.exe'); + const code='[Console]::OutputEncoding = [System.Text.Encoding]::Unicode; [Console]::Write("PS_FIXED_中文_SENTINEL")'; + const encoded=Buffer.from(code,'utf16le').toString('base64'); + const response=await f.run({description:'Fixed PowerShell interpreter sentinel',executable:typedPath(executable),entrypoint:'interpreter',argv:['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',encoded].map(literal),stdoutEncoding:'utf16le'}); + assert.equal(response.isError,false,JSON.stringify(response));assert.equal(response.value.exitCode,0); + assert.equal(response.value.stdout.text,'PS_FIXED_中文_SENTINEL');f.preserved(); +}); diff --git a/tests/preview.mjs b/tests/preview.mjs index 481e2d4..cf27502 100644 --- a/tests/preview.mjs +++ b/tests/preview.mjs @@ -30,6 +30,7 @@ test('preview: real host registers and executes Git Bash without replacing Power const root = new Context() const cleanups = [] const logs = [] + let experimental = true const own = (dispose) => { cleanups.push(dispose); return dispose } t.after(async () => { for (const cleanup of cleanups.reverse()) await cleanup() @@ -42,7 +43,7 @@ test('preview: real host registers and executes Git Bash without replacing Power return fiber } const policy = { mode: 'danger-full-access', workspaceRoot: cwd } - own(root.provide('sandboxPolicy', { defaultMode: policy.mode, resolve: () => policy })) + own(root.provide('sandboxPolicy', { defaultMode: policy.mode, resolve: () => policy.mode === undefined ? undefined : policy })) own(root.provide('sandbox', { confine() { throw new Error('preview never requests confinement') } })) own(root.provide('workingDirectory', { ensure: async () => cwd })) own(root.provide('loader', { internal: { import: (name) => import(name) } })) @@ -85,6 +86,7 @@ test('preview: real host registers and executes Git Bash without replacing Power agents: () => ({ list: () => [agent] }), createScope, pwshEnabled: () => pwshVisible, + experimentalEnabled: () => experimental, log(message) { logs.push(message) if (message.includes('activation failed') || message.includes('hide failed')) rejectReady(new Error(message)) @@ -117,6 +119,26 @@ test('preview: real host registers and executes Git Bash without replacing Power assert.match(text, /MINGW(?:32|64)_NT-/) assert.match(text, /GITBASH_PREVIEW_OK/) assert.equal(bash.value.exitCode, 0) + // Real Git Bash, fixture-owned policy only: OFF restores truthful legacy facts. + for (const mode of ['read-only', 'workspace-write']) { + policy.mode = mode + const denied = await run('bash', 'printf "MUST_NOT_START\\n"') + assert.equal(denied.isError, true) + assert.match(denied.error.message, /command was not started/) + experimental = false + root.emit('loader/volatile-update', [['experimentalNative']]) + const legacy = await run('bash', 'printf "LEGACY_FIXED_SENTINEL\\n"') + assert.equal(legacy.isError, false, JSON.stringify(legacy)) + assert.equal(legacy.value.sandbox.mode, mode) + assert.equal(legacy.value.sandbox.enforcement, 'unconfined') + assert.match(legacy.value.stdout.text, /LEGACY_FIXED_SENTINEL/) + experimental = true + root.emit('loader/volatile-update', [['experimentalNative']]) + assert.equal((await run('bash', 'printf "MUST_NOT_START\\n"')).isError, true) + } + policy.mode = undefined + assert.equal((await run('bash', 'printf "MUST_NOT_START\\n"')).isError, true) + policy.mode = 'danger-full-access' const pwsh = await run('pwsh', 'Write-Output "POWERSHELL_PREVIEW_OK"; Get-Process -Id $PID | Select-Object -ExpandProperty ProcessName') assert.equal(pwsh.isError, false, JSON.stringify(pwsh)) assert.match(pwsh.content.map((block) => block.text ?? '').join('\n'), /POWERSHELL_PREVIEW_OK/) diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index 3bb9b48..e559172 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -32,7 +32,7 @@ function createScope(parent) { return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } } -async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime, explicitTools = false, ptc = false, experimental = true } = {}) { +async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime, explicitTools = false, ptc = false, experimental = true, workingDirectory = { ensure: async () => process.cwd() }, sessionCwd, policyWorkspaceRoot = process.cwd() } = {}) { const root = new Context() const cleanups = [] const logs = [] @@ -42,7 +42,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu const failed = deferred() const agentScopes = [] const official = { kind: 'official-pwsh' } - const policy = { mode: 'danger-full-access', workspaceRoot: process.cwd() } + const policy = { mode: 'danger-full-access', ...(policyWorkspaceRoot === null ? {} : { workspaceRoot: policyWorkspaceRoot }) } const readFrom = () => ({ text: 'runtime-preview-ok', nextOffset: 18, lossy: false }) for (const [name, value] of Object.entries({ shell: official, @@ -50,7 +50,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu sandbox: {}, ...(ptc ? { ptcRuntime: { language: 'typescript', executionInstructions: 'Prompt-only fixture; never executes programs', resolve: spec => spec, run() { throw new Error('prompt routing fixture must not execute') } } } : {}), sandboxPolicy: { defaultMode: policy.mode, resolve: request => request?.session?.header.policy ?? policy }, - workingDirectory: { ensure: async () => process.cwd() }, + ...(workingDirectory === null ? {} : { workingDirectory }), loader: { internal: { async import(name) { if (pauseModules && name === '@deepseek-ai/dsh-tools') { entered.resolve(); await release.promise } return import(name) @@ -70,7 +70,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu ctx.tools.register(defineTool({ name, description: name, parameters: {}, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, execute: () => name })) } } }) - const agent = { id: 'runtime-preview-agent', session: { header: {} } } + const agent = { id: 'runtime-preview-agent', session: { header: { ...(sessionCwd === undefined ? {} : { cwd: sessionCwd }) } } } const agentScope = createAgentScope(agentParent, agent) agent.ctx = agentScope.ctx cleanups.push(() => agentScope.dispose()) @@ -502,3 +502,22 @@ test('runtime preview: actual run_code transport receives Unicode/EOF fixes and const uncertain = "const p='/c/工作/a'; return (4+2) / 2" assert.equal(await run(uncertain), dialectHelpers.programPrelude(env) + uncertain) }) + + +test('runtime preview: old hosts without workingDirectory mount all companions and preserve policy-first cwd', { timeout: 10000 }, async t => { + const spawns=[]; + const f=await fixture(t,{explicitTools:true,workingDirectory:null,sessionCwd:'D:/session-project',policyWorkspaceRoot:null, + subprocessRuntime:{spawn(spec){spawns.push(spec);return {done:Promise.resolve({exitCode:0,signal:null}),collected:{stdout:{readFrom:()=>({text:'ok',nextOffset:2,lossy:false})},stderr:{readFrom:()=>({text:'',nextOffset:0,lossy:false})}},terminate(){}}}}}); + await bounded(f.activated.promise,'old-host companions'); + assert.deepEqual(f.names().sort(),['bash','gitbash_native','gitbash_to_native_path']); + for(const [workdir,cwd] of [[undefined,'D:/session-project'],['scripts','D:/session-project/scripts'],['E:/explicit','E:/explicit']]){ + const response=await f.execute('bash',{command:'memory-only',description:'Verify old-host cwd',...(workdir===undefined?{}:{workdir})}); + assert.equal(response.isError,false,JSON.stringify(response));assert.equal(spawns.at(-1).cwd,cwd); + } + f.agent.session.header.policy={mode:'danger-full-access',workspaceRoot:'F:/policy-project'}; + const response=await f.execute('bash',{command:'memory-only',description:'Verify policy precedence',workdir:'scripts'}); + assert.equal(response.isError,false,JSON.stringify(response));assert.equal(spawns.at(-1).cwd,'F:/policy-project/scripts'); + f.setExperimental(false);assert.deepEqual(f.names(),['bash']); + f.setExperimental(true);assert.deepEqual(f.names().sort(),['bash','gitbash_native','gitbash_to_native_path']); + assert.equal(f.root.get('shell'),f.official); +}); From bcd4e9a1965e068d697eab8ef4b1f7c17593dcee Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Wed, 14 Oct 2026 01:55:35 +0800 Subject: [PATCH 6/9] fix: preserve foreign companion tool ownership on refresh --- AGENTS.md | 2 +- CHANGELOG.md | 23 +++++++ docs/experimental-native-execution.md | 2 +- docs/experimental-native-verification.md | 18 +++-- src/experimental-tools.js | 34 +++++++++- src/inline-tools.js | 23 ++++--- src/native-tool.js | 2 +- src/path-tool.js | 5 +- tests/path-tool.mjs | 1 + tests/runtime-preview.mjs | 84 ++++++++++++++++++++++++ tests/runtime-regressions.mjs | 24 +++++++ 11 files changed, 193 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 571daff..60a206e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -608,7 +608,7 @@ §4j 硬注入、v0.38.2 fiber 时序同一失败家族的第无数课)。 实验 native 与 Bash 的 cwd 消费共用 `src/session-workdir.js`;native 同样不得硬 inject workingDirectory。服务存在时 ensure 错误不得吞掉,只有缺服务才走旧接缝。 - companion 激活必须确认 apply 已执行;开关开启时还须工具面回读,关闭时只报告 scope 就绪。 + companion 激活必须确认 apply 已执行;scope 就绪与实际注册成功分别记录。初始挂载、volatile 开关和 Jobs 刷新必须共用按 agent 检查所有权的注册入口:注销自身注册后,外部全局/同 agent 同名工具均让位,knownNames 可用时包含隐藏工具,旧宿主回退 schemas;检查失败或注册异常记录日志,不阻断其他 companion。让位后保留待命 scope,下一次刷新可恢复。 ## 验证清单(改动后) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96504ed..b5db1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ ## Unreleased — 2026-10-14 +### PR #26:工具所有权修复与独立复核(fix / test / docs) + +- 共用注册入口在每次开关或 Jobs 刷新时,先注销自身注册,再检查当前 agent 可见的工具所有权;外部全局或同 agent 的同名工具均让位。实际 Core 的 knownNames 检查也包含隐藏工具,旧宿主回退 schemas 检查。 +- 两个 companion 按 agent 创建注册模块,初始冲突仍保留待命 scope;外部所有者移除后,下次刷新可恢复。所有权检查或注册失败记录明确日志,不向 volatile 事件抛错,不阻断另一个 companion。 +- scope 就绪与工具注册成功分别记录,避免让位或关闭状态被报告为注册成功。Bash 的逐操作 policy 快照契约保持不变。 +- 新增实际 Core 回归覆盖 path/native × 全局/同 agent 的 OFF→ON 冲突、重复开关、所有者移除恢复、native Jobs 刷新以及隐藏的全局同名工具;普通回归覆盖单个注册失败的隔离与恢复。 +- Windows / Node 24.13.0 / Core 0.2.1-alpha.2:普通完整回归 **232/232**、实际 Core 完整 preview **85/85**,均 exit0、无 skip/cancel。隔离子代理独立复核原始所有权复现与新增专项 **9/9** 通过,未发现新的确认缺陷;专项与完整结果有重叠,不累加覆盖数。 +- 相关:[PR #26](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26)。继续保持 Draft/RFC;真实 Agent 自主选路、生产 GUI、维护者 Windows 全量验证和非 CRT 矩阵正式确认仍待。 + +### PR #26:隔离子代理独立审查(review / 修复前复现记录) + +- 由未继承本次对话历史的子代理重新审查[当前实验提交](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/771c618dde5e509a80cb2b77733d1517fc7c89f2) + 相对[上游基线](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67)的完整增量。 + 普通回归 231/231、实际 Core preview 79/79 均 exit0、无 skip/cancel;静态审查覆盖策略/开关、目录、注册/Jobs 生命周期、argv/编码/预算及文档证据。 +- 确认一项 P2:experimental-tools 的 refresh 仅在首次挂载之外直接重注册,不重查同名工具所有权。 + OFF 期间外部注册全局同名 path 工具,ON 后真实 ToolRuntime 调用从 FOREIGN_OWNER 变为本插件 native_path 对象; + 同 agent 层则同步抛 already registered,阻断本次后续 companion 激活。真实 Core/Cordis 与受控 sentinel 工具复现,未启动 native 进程。 +- 建议将冲突判定纳入每次 refresh 的共用注册入口;存在外部所有者时让位并明确日志,不向 volatile 事件抛 duplicate。 + 补 OFF→ON 的全局/同 agent 层及 native Jobs refresh 回归。产品源码尚未修改,未推送或发送 GitHub 评论。 +- 排除最初疑似的 Bash policy 时序问题:实际 sandboxPolicy 契约是逐操作快照、模式变更作用于下一次调用; + fixture 直接修改 header.policy 的反证不足以证明违约,且 base 已有此行为,因此撤回拟议 P1。反证脚本的失败数不等于缺陷数。 +- 无确认 P0/P1;继续保持 Draft/RFC。真实 Agent 自主选路、生产 GUI、维护者 Windows 全量验证和非 CRT 矩阵正式确认仍待,测试绿色不替代这些门槛。 + ### PR #26:同步 v0.39.3 与旧宿主兼容(fix / docs / test) - 已 rebase 到[上游吸收 PR #28 改进后的 main](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67),前置 PR #24 已合并;正式 v0.39.3 的记录完整保留。 diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md index 02e7cff..e098bda 100644 --- a/docs/experimental-native-execution.md +++ b/docs/experimental-native-execution.md @@ -195,7 +195,7 @@ trusted owner在真实policy/校验后创建自有临时目录,不从model cwd 没有controller时明确拒绝,不以“admission失败后再运行前台”冒二次副作用风险。没有Jobs时为前台kill deadline;有Jobs时可timeout promotion。该差异需在正式用户体验验收中检查。 -Windows own-bash agent scope挂两个companions,不覆盖已有同名工具。两工具必须确认 apply 已执行及当前开启状态下工具面可见;关闭状态仅报告 scope 就绪。Bash/native 共用可选目录读取,有服务走官方 ensure(错误不降级),无服务走旧 policy.workspaceRoot ?? session.header.cwd 接缝。缺native executor/OutputCollector公开能力不撤bash/path。新工具不加入minimal持久终端通道,不改变root PowerShell、market patch或preset id。 +Windows own-bash agent scope挂两个companions,各自按 agent 创建注册模块。初始挂载、开关更新和 native Jobs 刷新共用注册入口:先注销自身注册,再检查当前 agent 的工具所有权;已有外部全局或同 agent 同名工具就让位。实际 Core 的 knownNames 包含隐藏工具,旧宿主回退 schemas 检查;检查失败也让位。外部所有者移除后,下次刷新可恢复。注册异常只记录日志,不阻断另一个 companion 的更新。两工具必须确认 apply 已执行;scope 就绪与实际注册成功分别报告,关闭或让位时不会报告注册成功。Bash/native 共用可选目录读取,有服务走官方 ensure(错误不降级),无服务走旧 policy.workspaceRoot ?? session.header.cwd 接缝。缺native executor/OutputCollector公开能力不撤bash/path。新工具不加入minimal持久终端通道,不改变root PowerShell、market patch或preset id。 ## 11. 已发生的反证与修正 diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md index c48038b..f9d0ff5 100644 --- a/docs/experimental-native-verification.md +++ b/docs/experimental-native-verification.md @@ -9,11 +9,11 @@ - 当前实验基线:[上游 v0.39.3 与 PR #28 后续修复](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67)。前置 PR #24 已合并。 - 测试环境:**DSH Core 0.2.1-alpha.2**、Node 24.13.0、Windows / Git for Windows。 - 新native功能按公开能力探测启用;既有兼容版本范围之外的实际覆盖情况见后文待验收项。 -- 最终普通全仓:**231 tests / 231 pass / 0 fail / 0 skip / 0 cancel,exit0**。 -- 最终实际Core完整preview:**79 tests / 79 pass / 0 fail / 0 skip / 0 cancel,exit0**。 +- 最终普通全仓:**232 tests / 232 pass / 0 fail / 0 skip / 0 cancel,exit0**。 +- 最终实际Core完整preview:**85 tests / 85 pass / 0 fail / 0 skip / 0 cancel,exit0**。 - 语法与`git diff --check`通过;npm dry-run确认新源码包含于发布文件集合。Dry-run不是已安装/发布,也不是新增生产版本。 -计数是Node test runner报告口径,包括子测试/套件;不同轮次或独立复跑有重叠,**不累加为独立覆盖数量**。231/79 是当前完整脚本结果,不意味着每项都是真生产进程场景。 +计数是Node test runner报告口径,包括子测试/套件;不同轮次或独立复跑有重叠,**不累加为独立覆盖数量**。232/85 是当前完整脚本结果,不意味着每项都是真生产进程场景。 ## 2. 证据等级 @@ -157,7 +157,7 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev 仍待正式四项验收:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 win32 真机全量验证、非 CRT 解释器矩阵抽测。当时 #24 未合并;本轮已完成到上游 main 的 rebase。 -## 同步 v0.39.3 后的验证(2026-10-14) +## 同步 v0.39.3 后的验证(2026-10-14,所有权修复前) 当前完整回归 231/231、实际 Core preview 79/79,exit0、无 skip/cancel。新增缺 workingDirectory 的三工具挂载/cwd/双向开关,真实 native producer 的 policy/header 默认目录;有服务 ensure 错误不降级。两个 companion 也验证真实注册后才报成功。21 份字典已本地化。 @@ -166,3 +166,13 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev [已安装实验构建的实机报告](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6149607594)覆盖 Bash/native/path 入口与 RO/WW、单次批准不粘滞。报告没有记录精确提交,且明确未覆盖开关 OFF、生产 GUI、真实 LLM 自主选路或非 CRT 矩阵;将其作为有限外部证据,不扩展为当前 rebase 构建的完整验收。 仍需真实 LLM 自主选路、生产 GUI、维护者 Windows 全量验证、非 CRT 矩阵的正式确认;保留 Draft/RFC。 + +## 独立审查后的工具所有权修复(2026-10-14) + +隔离子代理确认一项 P2:开关 OFF 期间外部注册同名工具,恢复 ON 后全局外部工具被遮蔽,同 agent 外部工具则导致 duplicate 异常并中断后续 companion 注册。共用注册入口现对每次初始挂载、开关更新及 native Jobs 刷新重新检查当前 agent 的所有权;外部所有者存在时让位,检查或注册失败记录日志并隔离异常。实际 Core 的 knownNames 包含隐藏工具,旧宿主回退 schemas 检查;旧宿主隐藏工具行为尚未独立验证。 + +`tests/runtime-preview.mjs` 新增六项实际 Core 回归:path/native × 全局/同 agent 四种冲突的重复 OFF→ON、外部所有者移除后的恢复、native Jobs 卸载/重载冲突,以及隐藏全局同名工具。`tests/runtime-regressions.mjs` 新增一项单个注册失败不阻断另一个工具、后续恢复和卸载清理的回归。scope 就绪与实际注册成功分别记录。 + +最终普通全仓 **232/232**、实际 Core 完整 preview **85/85**,均 exit0、无 skip/cancel。隔离子代理独立重跑原始所有权复现两项与新增专项七项,**9/9** 通过:全局与同 agent 外部 sentinel 均保持原响应,另一 companion 正常注册。未发现新的确认缺陷。这些专项与完整结果重叠,不累加。 + +最初疑似 Bash policy 时序问题已撤回:实际宿主契约为逐操作快照,模式变更作用于下一次调用;直接修改 fixture header.policy 不能证明违约。本轮保持该契约。上述四项正式验收仍待,继续保持 Draft/RFC。 diff --git a/src/experimental-tools.js b/src/experimental-tools.js index d3a6165..79ad386 100644 --- a/src/experimental-tools.js +++ b/src/experimental-tools.js @@ -1,12 +1,42 @@ /** Live registration for agent-owned companions; restrict() accepts global names only. */ -export function registerExperimentalTool(ctx, definition, enabled) { +export function registerExperimentalTool(ctx, definition, enabled, { scope, log = () => {} } = {}) { let unregister let closed = false const refresh = () => { if (closed) return unregister?.() unregister = undefined - if (enabled?.() !== false) unregister = ctx.tools.register(definition()) + if (enabled?.() === false) return + const candidate = definition() + // Inspect AFTER removing only our registration. Include restricted names + // when the host exposes them: a hidden foreign tool is still another owner. + let occupied + try { + const view = typeof ctx.tools.view === 'function' ? ctx.tools.view(scope) : undefined + if (typeof view?.knownNames?.has === 'function') { + occupied = view.knownNames.has(candidate.name) + } else if (typeof ctx.tools.schemas === 'function') { + occupied = ctx.tools.schemas(scope).some(tool => tool.name === candidate.name) + } else { + throw new Error('tool ownership inspection unavailable') + } + } catch (error) { + log(`${candidate.name} registration skipped: ${error?.message ?? error}`) + return + } + if (occupied) { + log(`${candidate.name} registration skipped: an existing tool owns this name`) + return + } + try { + unregister = ctx.tools.register(candidate) + } catch (error) { + // A registration conflict must not abort a volatile update or prevent + // another companion's refresh. Other failures remain explicit in logs. + log(`${candidate.name} registration failed: ${error?.message ?? error}`) + return + } + log(`${candidate.name} registered in the agent-owned scope`) } ctx.effect(() => () => { closed = true; unregister?.(); unregister = undefined }) ctx.on('loader/volatile-update', paths => { diff --git a/src/inline-tools.js b/src/inline-tools.js index 024c5ea..453abc8 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -297,8 +297,8 @@ export function mountInlineTools(ctx, deps) { const extraModules = [] if (typeof deps.translatePath === 'function') { const { createPathToolModule, PATH_TOOL_NAME } = await untilDisposed(import('./path-tool.js'), controller.signal) - extraModules.push({ name: PATH_TOOL_NAME, module: createPathToolModule({ - toolsModule: hostModules.toolsModule, translate: deps.translatePath, experimentalEnabled, log, + extraModules.push({ name: PATH_TOOL_NAME, create: scope => createPathToolModule({ + toolsModule: hostModules.toolsModule, translate: deps.translatePath, experimentalEnabled, log, scope, }) }) try { const localBashModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-bash-local'), controller.signal) @@ -309,11 +309,11 @@ export function mountInlineTools(ctx, deps) { } const { createNativeOutputAdapter } = await untilDisposed(import('./native-output.js'), controller.signal) const { createNativeToolModule } = await untilDisposed(import('./native-tool.js'), controller.signal) - extraModules.push({ name: 'gitbash_native', module: createNativeToolModule({ + extraModules.push({ name: 'gitbash_native', create: scope => createNativeToolModule({ toolsModule: hostModules.toolsModule, sandboxModule: hostModules.sandboxModule, localBashModule, createScope, translateMsysPath: deps.translatePath, createNativeOutputAdapter, outputOptions: { OutputCollector: outputModule?.OutputCollector }, - createArtifactDirectory: deps.createArtifactDirectory, experimentalEnabled, log, + createArtifactDirectory: deps.createArtifactDirectory, experimentalEnabled, log, scope, }) }) } catch (error) { if (controller.signal.aborted) throw error @@ -363,22 +363,21 @@ export function mountInlineTools(ctx, deps) { throw new Error('bash tool did not register for this agent (module fiber INACTIVE — this host lacks an injected service the module requires — or the tools read-back is unavailable); bash is NOT available in this session, please report this with your dsh version (issue #27)') } for (const extra of extraModules ?? []) { - if (faceNames(agent).includes(extra.name)) continue // Never replace another owner. let extraFiber try { let applied = false - extraFiber = scope.ctx.plugin({ ...extra.module, async apply(extraCtx, config) { - await extra.module.apply(extraCtx, config) + const module = extra.create(agent) + extraFiber = scope.ctx.plugin({ ...module, async apply(extraCtx, config) { + await module.apply(extraCtx, config) applied = true } }) await untilDisposed(extraFiber, state.controller.signal) if (!isLive(agent, state)) return - if (!applied || (experimentalEnabled() && !faceNames(agent).includes(extra.name))) { - throw new Error(`${extra.name} did not register (module inactive or tool not visible)`) + if (!applied) { + throw new Error(`${extra.name} scope inactive (a required service may be missing)`) } - log(experimentalEnabled() - ? `${extra.name} activated in the agent-owned scope` - : `${extra.name} scope ready; tool disabled by experimentalNative`) + // Registration/stand-down is reported by the shared live registrar. + log(`${extra.name} scope ready; registration follows experimentalNative and existing owners`) } catch (error) { if (!isLive(agent, state)) return await extraFiber?.dispose() diff --git a/src/native-tool.js b/src/native-tool.js index 1d37ff0..7e2dacc 100644 --- a/src/native-tool.js +++ b/src/native-tool.js @@ -180,7 +180,7 @@ export function createNativeToolModule(deps) { }) } let currentJobs - const refresh = registerExperimentalTool(ctx, () => definition(currentJobs), deps.experimentalEnabled) + const refresh = registerExperimentalTool(ctx, () => definition(currentJobs), deps.experimentalEnabled, { scope: deps.scope, log: deps.log }) if (!enabled) return ctx.inject(['jobs'], jobCtx => { currentJobs = jobCtx.jobs diff --git a/src/path-tool.js b/src/path-tool.js index 029d10f..6d52d53 100644 --- a/src/path-tool.js +++ b/src/path-tool.js @@ -291,10 +291,7 @@ export function createPathToolModule(deps = {}) { if (typeof ctx?.tools?.register !== 'function') { throw new Error('path-tool: ctx.tools.register is unavailable; cannot own the tool registration') } - registerExperimentalTool(ctx, () => definition, deps.experimentalEnabled) - ;(typeof deps.log === 'function' ? deps.log : () => {})( - `${PATH_TOOL_NAME} registered (explicit full-value MSYS→native conversion, no I/O)`, - ) + registerExperimentalTool(ctx, () => definition, deps.experimentalEnabled, { scope: deps.scope, log: deps.log }) }, } } diff --git a/tests/path-tool.mjs b/tests/path-tool.mjs index b7a2808..31fe2a5 100644 --- a/tests/path-tool.mjs +++ b/tests/path-tool.mjs @@ -237,6 +237,7 @@ test('path-tool: the module owns its registration and reverts it on disposal', ( const effects = [] const ctx = { tools: { + schemas: () => registered.map(definition => ({ name: definition.name })), register(definition) { registered.push(definition) return () => { registered.pop() } diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index e559172..804a897 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -521,3 +521,87 @@ test('runtime preview: old hosts without workingDirectory mount all companions a f.setExperimental(true);assert.deepEqual(f.names().sort(),['bash','gitbash_native','gitbash_to_native_path']); assert.equal(f.root.get('shell'),f.official); }); + + +function foreignCompanion(name, value) { + return { inject: ['tools'], apply(ctx) { + ctx.tools.register(defineTool({ name, description: 'Foreign companion owner', parameters: { msys_path: { type: 'string', required: true } }, + output: { schema: { type: 'string' }, render: (_args, result) => [{ type: 'text', text: result }] }, execute: () => value })); + } }; +} + +for (const layer of ['global', 'agent']) for (const name of ['gitbash_to_native_path', 'gitbash_native']) { + test('runtime preview: live companion preserves foreign ' + layer + ' owner of ' + name, { timeout: 10000 }, async t => { + const f = await fixture(t, { explicitTools: true, experimental: false }); + await bounded(f.activated.promise, 'companion scopes ready'); + const owner = layer === 'global' ? f.root : f.agent.ctx; + const sentinel = 'FOREIGN_' + layer + '_' + name; + const foreign = owner.plugin(foreignCompanion(name, sentinel)); + await foreign; + t.after(() => foreign.dispose()); + for (const enabled of [true, false, true]) { + assert.doesNotThrow(() => f.setExperimental(enabled)); + 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'); + 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'); + } + assert.ok(f.logs.some(line => line.includes(name + ' registration skipped: an existing tool owns this name'))); + await foreign.dispose(); + f.setExperimental(false); + f.setExperimental(true); + const output = f.tools.sdkSchemas(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(); + assert.deepEqual(f.names(), ['pwsh']); + }); +} + +test('runtime preview: native Jobs reload preserves a foreign owner without blocking its scope', { timeout: 10000 }, async t => { + const f = await fixture(t, { explicitTools: true, experimental: false }); + await bounded(f.activated.promise, 'companion scopes ready'); + const foreign = f.agent.ctx.plugin(foreignCompanion('gitbash_native', 'FOREIGN_JOBS_OWNER')); + await foreign; + t.after(() => foreign.dispose()); + assert.doesNotThrow(() => f.setExperimental(true)); + for (let i = 0; i < 2; i++) { + const removeJobs = f.root.provide('jobs', {}); + await new Promise(setImmediate); + assert.equal((await f.execute('gitbash_native', { msys_path: '/c/sentinel' })).value, 'FOREIGN_JOBS_OWNER'); + await removeJobs(); + assert.equal((await f.execute('gitbash_native', { msys_path: '/c/sentinel' })).value, 'FOREIGN_JOBS_OWNER'); + assert.ok(f.names().includes('gitbash_to_native_path')); + } + await foreign.dispose(); + 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(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); +}); + + +test('runtime preview: an initially hidden foreign tool retains ownership through companion mounting', { timeout: 10000 }, async t => { + const f = await fixture(t, { explicitTools: true, pauseModules: true }); + await bounded(f.entered.promise, 'pause before companion mounting'); + const foreign = f.root.plugin(foreignCompanion('gitbash_to_native_path', 'HIDDEN_FOREIGN_OWNER')); + await foreign; + t.after(() => foreign.dispose()); + const lift = f.agent.ctx.tools.restrict({ deny: ['gitbash_to_native_path'] }); + t.after(lift); + f.release.resolve(); + await bounded(f.activated.promise, 'companion scopes with a hidden foreign owner'); + assert.ok(!f.names().includes('gitbash_to_native_path'), 'own registration must not bypass another owner’s visibility mask'); + assert.ok(f.names().includes('gitbash_native')); + lift(); + assert.equal((await f.execute('gitbash_to_native_path', { msys_path: '/c/sentinel' })).value, 'HIDDEN_FOREIGN_OWNER'); + await foreign.dispose(); + f.setExperimental(false); + f.setExperimental(true); + assert.deepEqual((await f.execute('gitbash_to_native_path', { msys_path: '/c/sentinel' })).value, { native_path: 'C:/sentinel' }); +}); diff --git a/tests/runtime-regressions.mjs b/tests/runtime-regressions.mjs index 295ad53..e4535e5 100644 --- a/tests/runtime-regressions.mjs +++ b/tests/runtime-regressions.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { Context, Service } from '@deepseek-ai/cordis' import { mountInlineTools } from '../src/inline-tools.js' import { createBashToolModule } from '../src/bash-tool.js' +import { registerExperimentalTool } from '../src/experimental-tools.js' function inlineFixture({ visible = false, names = ['bash', 'pwsh'], externalDeny = [] } = {}) { const listeners = new Map() @@ -210,3 +211,26 @@ test('runtime: restricted foreground and background calls fail before Jobs admis assert.match(definition.output.render({}, value)[0].text, /started background job admitted-job/) assert.equal(admitted.length, 1) }) + + +test('runtime: a registration failure is logged without aborting another companion refresh', () => { + const listeners = [], cleanups = [], definitions = new Map(), logs = []; + let enabled = false, fail = true; + const ctx = { tools: { + schemas: () => [...definitions.keys()].map(name => ({ name })), + register(definition) { + if (fail && definition.name === 'path') throw new Error('tool path is already registered in this scope'); + definitions.set(definition.name, definition); + return () => definitions.delete(definition.name); + }, + }, effect: callback => cleanups.push(callback()), on: (_event, callback) => listeners.push(callback) }; + for (const name of ['path', 'native']) registerExperimentalTool(ctx, () => ({ name }), () => enabled, { log: message => logs.push(message) }); + const update = value => { enabled = value; for (const listener of listeners) listener([['experimentalNative']]); }; + assert.doesNotThrow(() => update(true)); + assert.deepEqual([...definitions.keys()], ['native']); + assert.ok(logs.some(line => line.includes('path registration failed'))); + update(false);fail = false;update(true); + assert.deepEqual([...definitions.keys()], ['path', 'native']); + for (const cleanup of cleanups.reverse()) cleanup(); + assert.equal(definitions.size, 0); +}); From 2afb19aca48513291aea98b8c73227996bb84091 Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Thu, 15 Oct 2026 12:37:01 +0800 Subject: [PATCH 7/9] test: reconcile smoke routing assertions and assert fastctxTools-OFF companions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - smoke: drop main-side text assertions for GITBASH_ROUTE_ADDENDUM sentences that moved into applyToolRouting (inline-tools.js) with the native RFC; keep the branch's applyToolRouting/pwshVisible/msys wiring assertions. - runtime-preview: new actual-Core assertion for review item 1 — on a single-install face (no gb_*, no ops_*) with fastctxTools OFF (the v0.39.4 default), companion registration detection still mounts gitbash_native / gitbash_to_native_path (design doc §10 semantics); the disabled bridge never resolves the payload (createRequireFrom / msysToWin tripwires prove the gate). --- tests/runtime-preview.mjs | 29 +++++++++++++++++++++++++++++ tests/smoke.mjs | 6 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index 804a897..0a4df72 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -12,6 +12,7 @@ import * as shellEnv from '@deepseek-ai/dsh-shell-env' import { applyToolRouting, mountInlineTools, mountPrivateExecutor } from '../src/inline-tools.js' import { createBashToolModule } from '../src/bash-tool.js' import { GitBashSandboxExecutor } from '../src/shell.js' +import { mountFastctx } from '../src/fastctx-bridge.js' const deferred = () => { let resolve @@ -462,6 +463,34 @@ test('runtime preview: explicit tools activate in the real per-agent wiring and assert.equal(f.root.get('shell'), f.official) }) +// Review round 1 on PR #26 (v0.39.4 rebase): fastctxTools now defaults to OFF +// and the disabled bridge must neither spawn nor resolve the payload — while +// companion registration detection (knownNames/faceNames) on a face with no +// gb_* and no ops_* still mounts gitbash_native / gitbash_to_native_path, per +// design doc §10 semantics. +test('runtime preview: companions mount on the single-install face with fastctxTools OFF (v0.39.4 default)', { timeout: 10000 }, async t => { + const f = await fixture(t, { explicitTools: true }) + await bounded(f.activated.promise, 'explicit tools activation') + const bridge = f.root.plugin(ctx => mountFastctx(ctx, { + platform: 'win32', + enabled: () => false, // the v0.39.4 default read (valueOf(...) === true) + tools: c => c.get('tools'), + log: message => f.deps.log(message), + createRequireFrom: () => { throw new Error('the disabled bridge must never resolve the payload') }, + msysToWin: () => { throw new Error('the disabled bridge never translates arguments') }, + })) + t.after(() => bridge.dispose()) + await bridge + f.root.emit('agent/created', { agent: f.agent }) + await new Promise(setImmediate) + const names = f.names() + assert.ok(!names.some((name) => name.startsWith('gb_')), 'no gb_* tools on a single-install face') + assert.ok(!names.some((name) => name.startsWith('ops_')), 'no ops_* tools without dsh-ops') + assert.deepEqual([...names].sort(), ['bash', 'gitbash_native', 'gitbash_to_native_path'], + 'companion registration detection is independent of the fastctxTools switch') + assert.ok(f.logs.some(line => line.includes('FastCtx bridge disabled by the fastctxTools switch'))) +}) + // The real run_code transport captures rewritten source in a mock PTC runtime; // it does NOT evaluate the program or claim real PTC-engine/filesystem coverage. test('runtime preview: actual run_code transport receives Unicode/EOF fixes and preserves tagged data', async t => { diff --git a/tests/smoke.mjs b/tests/smoke.mjs index f25fea0..1e1e350 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -2960,9 +2960,9 @@ 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: live\(Schema\.boolean\(\)\.default\(false\)\)/) assert.match(hostSource, /fastctxTools: valueOf\(config\.fastctxTools\) === true/) - assert.match(hostSource, /GITBASH_ROUTE_ADDENDUM/) - assert.match(hostSource, /GITBASH_ROUTE_ADDENDUM_NO_PWSH/) - assert.match(hostSource, /liveSettings\.pwshTool\(\) === true \? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH/) + // 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. assert.match(hostSource, /applyToolRouting\(assembly\.tools,/) assert.match(hostSource, /pwshVisible: liveSettings\.pwshTool\(\) === true/) assert.match(hostSource, /msys: applies && dialect\.posixPaths === true/) From f7c7d815c10178b560f480ecc9df3674525962e7 Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Thu, 15 Oct 2026 12:37:07 +0800 Subject: [PATCH 8/9] docs: record review-round-1 convergence after the v0.39.4 rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design doc §3 experience table: state the combined blind spot — a restricted session with dsh-ops co-installed has zero command tools under both plugins' defaults (our bash is fail-closed, ops_bash publishes full-access only, gb_* defaults off); the escape is turning off experimentalNative or switching the session mode. - design doc §12: mark the upstream sync checkbox at v0.39.4 (52da331) and point at the new fastctxTools-OFF companion assertion. - CHANGELOG: new Unreleased section covering the rebase onto v0.39.4, review items 1-3, and the 235/235 + 86/86 green runs. --- CHANGELOG.md | 8 ++++++++ docs/experimental-native-execution.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5db1e5..e6f42bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ## Unreleased — 2026-10-14 +### PR #26:第一轮设计评审后 rebase 到 v0.39.4(fix / test / docs) + +- 按[维护者第一轮设计评审](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6182031633)将实验分支 rebase 到[上游 v0.39.4 main(52da331)](https://github.com/KannaKuron/dsh-gitbash-shell/commit/52da331);冲突集中在 CHANGELOG、smoke 的路由文本断言、index.js/client.js 的开关读法(`experimentalNative` 默认 true 读 `!== false`,`fastctxTools` 默认 OFF 读 `=== true`)。 +- 新增实际 Core 断言(评审①):`fastctxTools` OFF(v0.39.4 默认值)且工具面无 `gb_*`/`ops_*` 的单装场景,companion 注册检测(knownNames/faceNames)照常挂载 `gitbash_native` 与 `gitbash_to_native_path`(设计文档 §10 语义);禁用的桥不解析载荷、不翻译参数(`createRequireFrom`/`msysToWin` 绊线证明门控)。 +- 设计文档 §3 体验表补组合盲点一句(评审②):同装 dsh-ops 的受限会话在两插件默认值下均无命令工具(本插件 bash fail-closed、ops_bash 只发 full-access、gb_* 默认关),需关 experimentalNative 或切换会话模式。 +- session-workdir 双实现收敛(评审③):保留模块化 `src/session-workdir.js`(`throwIfAborted` 更完整、diff 噪声更小),bash-tool.js 单行调用。 +- 相关:[PR #26](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26)。Windows / Node 24.13.0:普通完整回归 **235/235**、实际 Core(desktop-next host,0.2.0-rc.1)完整 preview **86/86**(较上轮 85 增本轮①断言一例),均 exit0、无 skip/cancel。继续保持 Draft/RFC;真实 Agent 自主选路、生产 GUI、维护者 Windows 全量验证和非 CRT 矩阵正式确认仍待。 + ### PR #26:工具所有权修复与独立复核(fix / test / docs) - 共用注册入口在每次开关或 Jobs 刷新时,先注销自身注册,再检查当前 agent 可见的工具所有权;外部全局或同 agent 的同名工具均让位。实际 Core 的 knownNames 检查也包含隐藏工具,旧宿主回退 schemas 检查。 diff --git a/docs/experimental-native-execution.md b/docs/experimental-native-execution.md index e098bda..49e7629 100644 --- a/docs/experimental-native-execution.md +++ b/docs/experimental-native-execution.md @@ -54,6 +54,7 @@ IPC/Token/AppContainer/VM 路线仍需隔离环境的真实正负权限验证; | Node 路径 | 字面量尽力重写 | 可显式转换运行时完整值;legacy 默认未改 | | 编码 | 原有 UTF-8 捕获边界 | 新入口逐流显式编码,默认 utf8/replace | | 设置/界面 | 现有 Config/UI | `experimentalNative` volatile 布尔,默认 true,同卡实时编辑 | +| 同装 dsh-ops 的受限会话 | 本插件 bash fail-closed;ops_bash 只发 full-access | 两插件默认值下均无命令工具:需关 experimentalNative 或切换会话模式 | **运行时开关:** 设置 → 插件 → dsh-gitbash-shell →「实验原生执行 / fail-closed」,权威值是 `gitbash-shell` 行 Config 的 `experimentalNative`,默认 true(包括旧配置缺键)。开启时受限/未确定 Windows Bash 拒绝,两个 companions 在能力可用且不冲突时注册。关闭时实时注销两个工具(旧 SDK 引用也拒绝),Bash 恢复旧版明确标注 `enforcement: 'unconfined'` 的受限执行并一次性日志告知;未确定策略保持旧版宿主委派。关闭不提供真实围栏,shell 文件写入限制不执行;官方文件工具策略始终独立。 @@ -212,7 +213,7 @@ Windows own-bash agent scope挂两个companions,各自按 agent 创建注册 - [x] Controller 缺失保持明确拒绝,不静默改前台重复执行。 - [x] Artifact ownership/quota/retention 由插件层自管;跨调用 quota/purge 尚未实现,不宣称已验收。 - [ ] **长期跟踪(不阻塞本 PR)**:Core 将来开放 native typed argv、逐流 encoding、closed DTO 等对应公开 API 时,遵循官方优先纪律整体切换并退役插件私有实现。 -- [x] **同步上游**:前置 PR #24 已合并;实验已 rebase 到[上游吸收 PR #28 改进后的 main](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67),继承正式 v0.39.3 的兼容与激活验证。 +- [x] **同步上游**:前置 PR #24 已合并;实验已 rebase 到[上游 v0.39.4 的 main](https://github.com/KannaKuron/dsh-gitbash-shell/commit/52da331)(v0.39.4 起 `fastctxTools` 默认 OFF、新增 ops_* 互操作面),继承正式 v0.39.4 的兼容与激活验证;单装工具面在 `fastctxTools` OFF 下 companion 注册检测不受影响(`tests/runtime-preview.mjs`「companions mount on the single-install face with fastctxTools OFF」锁死设计文档 §10 语义)。 - [ ] **拆分评估**:建议两步 review:① Bash fail-closed + 行 Config/UI/拒绝引导/回退;② native/path、逐流输出与可见性路由。唯一开关在第一步建立,第二步复用同一字段。当前保留完整 Draft/RFC,尚未另开子 PR。 - [ ] **转正式的四项门槛**:真实 Agent 自主选路、生产 GUI(非 preview)、维护者 Windows 真机全量验证、非 CRT 解释器矩阵抽测。四项齐后才谈 ready for review → release。 - [ ] 真实Agent选工具评测、生产GUI/full apply、会话replay尚未验收。 From 8603357ba4b7cc2fdc81d35dd8abfca559c0f195 Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Thu, 15 Oct 2026 12:49:10 +0800 Subject: [PATCH 9/9] docs: close review-round-1 verification findings - verification doc: move the baseline reference to v0.39.4 (52da331) and append the 2026-10-15 round (235+1 guard / 86 green runs, cross-check and mutation-test notes, inherited FastCtx card copy flagged as an upstream issue to report in the PR). - smoke: source assertion that the session-workdir seam stays modular (bash-tool.js imports resolveSessionCwd, no inline workingDirectory fallback returns), guarding review item 3 against silent divergence. - CHANGELOG: accurate disabled-bridge wording (unpublish short-circuit judged by the disabled log assertion), the new guard count, and the PR ready-state per the maintainer's first-round instruction. --- CHANGELOG.md | 4 ++-- docs/experimental-native-verification.md | 8 +++++++- tests/smoke.mjs | 11 +++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6f42bc..6cbcea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,10 @@ ### PR #26:第一轮设计评审后 rebase 到 v0.39.4(fix / test / docs) - 按[维护者第一轮设计评审](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6182031633)将实验分支 rebase 到[上游 v0.39.4 main(52da331)](https://github.com/KannaKuron/dsh-gitbash-shell/commit/52da331);冲突集中在 CHANGELOG、smoke 的路由文本断言、index.js/client.js 的开关读法(`experimentalNative` 默认 true 读 `!== false`,`fastctxTools` 默认 OFF 读 `=== true`)。 -- 新增实际 Core 断言(评审①):`fastctxTools` OFF(v0.39.4 默认值)且工具面无 `gb_*`/`ops_*` 的单装场景,companion 注册检测(knownNames/faceNames)照常挂载 `gitbash_native` 与 `gitbash_to_native_path`(设计文档 §10 语义);禁用的桥不解析载荷、不翻译参数(`createRequireFrom`/`msysToWin` 绊线证明门控)。 +- 新增实际 Core 断言(评审①):`fastctxTools` OFF(v0.39.4 默认值)且工具面无 `gb_*`/`ops_*` 的单装场景,companion 注册检测(knownNames/faceNames)照常挂载 `gitbash_native` 与 `gitbash_to_native_path`(设计文档 §10 语义);禁用的桥走 unpublish 短路、不进入 publish(新增用例断言 disabled 日志;`createRequireFrom`/`msysToWin` 桩在 publish 路径上,门控移除时由该日志断言判负)。 - 设计文档 §3 体验表补组合盲点一句(评审②):同装 dsh-ops 的受限会话在两插件默认值下均无命令工具(本插件 bash fail-closed、ops_bash 只发 full-access、gb_* 默认关),需关 experimentalNative 或切换会话模式。 - session-workdir 双实现收敛(评审③):保留模块化 `src/session-workdir.js`(`throwIfAborted` 更完整、diff 噪声更小),bash-tool.js 单行调用。 -- 相关:[PR #26](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26)。Windows / Node 24.13.0:普通完整回归 **235/235**、实际 Core(desktop-next host,0.2.0-rc.1)完整 preview **86/86**(较上轮 85 增本轮①断言一例),均 exit0、无 skip/cancel。继续保持 Draft/RFC;真实 Agent 自主选路、生产 GUI、维护者 Windows 全量验证和非 CRT 矩阵正式确认仍待。 +- 相关:[PR #26](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26)。Windows / Node 24.13.0:普通完整回归 **236/236**、实际 Core(desktop-next host,0.2.0-rc.1)完整 preview **86/86**(较上轮 85 增本轮①断言一例),均 exit0、无 skip/cancel。PR 已为非 draft,按维护者第一轮评审(rebase + ①断言后声明 ready)视为 ready for review;真实 Agent 自主选路、生产 GUI、维护者 Windows 全量验证和非 CRT 矩阵正式确认仍待。 ### PR #26:工具所有权修复与独立复核(fix / test / docs) diff --git a/docs/experimental-native-verification.md b/docs/experimental-native-verification.md index f9d0ff5..47bd2f9 100644 --- a/docs/experimental-native-verification.md +++ b/docs/experimental-native-verification.md @@ -6,7 +6,7 @@ ## 1. 验证基准与结果 -- 当前实验基线:[上游 v0.39.3 与 PR #28 后续修复](https://github.com/KannaKuron/dsh-gitbash-shell/commit/114dcde434b99755532cc402f13cecbfe86c9a67)。前置 PR #24 已合并。 +- 当前实验基线:[上游 v0.39.4(52da331)](https://github.com/KannaKuron/dsh-gitbash-shell/commit/52da331)。前置 PR #24 已合并;2026-10-15 第一轮设计评审后由 v0.39.3(114dcde)rebase 而来,本节末尾补记本轮验证。 - 测试环境:**DSH Core 0.2.1-alpha.2**、Node 24.13.0、Windows / Git for Windows。 - 新native功能按公开能力探测启用;既有兼容版本范围之外的实际覆盖情况见后文待验收项。 - 最终普通全仓:**232 tests / 232 pass / 0 fail / 0 skip / 0 cancel,exit0**。 @@ -176,3 +176,9 @@ DSH_PREVIEW_HOST_DIR='<实际宿主resources/app绝对路径>' npm run test:prev 最终普通全仓 **232/232**、实际 Core 完整 preview **85/85**,均 exit0、无 skip/cancel。隔离子代理独立重跑原始所有权复现两项与新增专项七项,**9/9** 通过:全局与同 agent 外部 sentinel 均保持原响应,另一 companion 正常注册。未发现新的确认缺陷。这些专项与完整结果重叠,不累加。 最初疑似 Bash policy 时序问题已撤回:实际宿主契约为逐操作快照,模式变更作用于下一次调用;直接修改 fixture header.policy 不能证明违约。本轮保持该契约。上述四项正式验收仍待,继续保持 Draft/RFC。 + +## 同步 v0.39.4 后的验证(2026-10-15,第一轮设计评审收敛) + +按[维护者第一轮设计评审](https://github.com/KannaKuron/dsh-gitbash-shell/pull/26#issuecomment-6182031633)rebase 到 v0.39.4(52da331)并完成三项收敛:① 新增实际 Core 断言——`fastctxTools` OFF(v0.39.4 默认值)且工具面无 `gb_*`/`ops_*` 的单装场景下 companion 注册检测照常挂载(`tests/runtime-preview.mjs`「companions mount on the single-install face with fastctxTools OFF」);② 设计文档 §3 补同装 dsh-ops 组合盲点;③ session-workdir 保留模块化 `src/session-workdir.js`(52da331 未触及 bash-tool.js,收敛在 rebase 中自动完成,smoke 新增源码断言防双实现静默回归)。 + +普通完整回归 **236/236**、实际 Core(desktop-next host,0.2.0-rc.1)完整 preview **86/86**(较上轮 85 增本轮①断言一例),均 exit0、无 skip/cancel。有界只读交叉核验(隔离子代理)独立复跑数字一致,变异测试确认新断言对 fastctx 门控移除有唯一检测力;fastctxTools OFF 下禁用的桥走 unpublish 短路、不进入 publish(disabled 日志断言)。已知非本轮引入的遗留:客户端 FastCtx 卡 21 语言 hint 仍写「默认开启」而实际默认 OFF(继承自 main 52da331),拟作为上游问题在 PR 说明。上述四项正式验收仍待。 diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 1e1e350..f4b755a 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -3130,6 +3130,17 @@ test('bash tool: the schema advertises background + escalation only when the com await assert.rejects(() => def.execute({ command: 'ls', description: 'x', timeoutMs: -1 }, { agent: undefined, signal: { aborted: false } }), /invalid timeoutMs/) }) +// Review round 1 on PR #26 (item 3): the workdir seam stays modular. An inline +// `ctx.get('workingDirectory')` copy must not silently come back the next time +// main touches bash-tool.js. +test('bash tool: the session-workdir seam stays modular, no inline fallback', () => { + const source = readFileSync(new URL('../src/bash-tool.js', import.meta.url), 'utf8') + assert.match(source, /import \{ resolveSessionCwd \} from '\.\/session-workdir\.js'/) + assert.doesNotMatch(source, /ctx\.get\('workingDirectory'\)/, 'no inline workingDirectory fallback; route it through src/session-workdir.js') + const module = readFileSync(new URL('../src/session-workdir.js', import.meta.url), 'utf8') + assert.match(module, /export (async )?function resolveSessionCwd|export const resolveSessionCwd/) +}) + test('bash tool: the defineTool path is taken when the host tools module resolves', async () => { const { createBashToolModule } = await import('../src/bash-tool.js') const registered = []