ch1bug/dsh-shell-host

dsh-shell-host

DSH bundle: native MSYS2 (UCRT64) bash for the DSH shell seam — fork of @deepseek-ai/dsh-bash-local with configurable bash path

미분류deepseek-harnessshellterminal

dsh-shell-host

DSH bundle: a tools-only plugin (ADR-0012/0013) — persistent PTY sessions (./pty), interactive ssh sessions (./ssh), launcher-preset terminals (shell_open, ./terminal), 远程工作区与文件服务(./remote / ./remote-fs),以及 WSL bridge tools (./wsl, 默认关闭). No shell executor, no ctx.shell provider, no upstream loader-row takeover: every row in cordis.patch.yml is an own-id insert, and the shell seam belongs to the platform shell executors alone.

History: this package was formerly a shell executor (MSYS2-first, VS Code
terminal-profile modeled, routed backends incl. ssh) with host-only forks
of the permission-presets and pwsh-tool rows. That world was removed in two
steps — ADR-0012 (no upstream-row takeover) and ADR-0013 (executor world
deleted) — after the executor registration/composition path kept failing in
practice. 原远程 one-shot 的 route C 分工后来由 ADR-0016 修订;
现行工作区能力由本包承接,单次执行与 dsh-ssh/ssh_exec 正交分工。

The bundle patch

cordis.patch.yml 挂载六条自有 id 行,不接管上游行(#143 起 wsl 随 bundle 挂载但默认关闭):

  1. pty (dsh-shell-host/pty) — the persistent session core.
  2. ssh (dsh-shell-host/ssh) — the interactive ssh instance (ssh_start /

ssh_tail / ssh_send / ssh_close), over the pty core (#136).

  1. terminal (dsh-shell-host/terminal) — the shell_open launcher surface.
  2. remote (dsh-shell-host/remote) — 远程工作区登记、预热与长驻连接(#137)。
  3. remote-fs (dsh-shell-host/remote-fs) — 加性 ctx.remoteFs 与消费它的 remote_*

文件工具(remote_ls / remote_read / remote_write / remote_edit,#137–#139), 依赖前一行(行序即依赖)。

  1. wsl (dsh-shell-host/wsl, disabled: true) — Windows 互操作工具(win_* 七个,

#143)。行在挂载面上、默认关闭 —— 开启方式见 *The ./wsl entry*。

怎么装、怎么加行(#142)

装 bundle = 把上面六条 - insert 行并进你 profile 的 patch ($DSH_HOME/profiles//cordis.patch.yml;宿主在启动时组合一次树, 机制见 docs/agents/profile-install.md)。只想加某一项能力,就抄对应的行 —— cordis.patch.yml 本身就是六行的完整可抄来源(wsl 行记得连同 disabled: true 一起抄,除非你就是要开启它,见 *The ./wsl entry*); 下文 ./ssh(迁移节)/ ./remote / ./wsl 各节另有可粘贴的 insert 片段, pty / terminal / remote-fs 三行照本节清单或 cordis.patch.yml 抄即可。

老 profile 迁移(一句话):只挂过 pty + terminal 的,补一行 ssh (#136 起 ssh_* 四工具搬进了 ./ssh,不补就消失 —— 面收窄细节见 *The ./ssh entry* 的迁移节);要远端文件面再补 remote + remote-fs 两行。

两套文件词汇,别混用(Q14 纠正,放显眼处):本包不替换上游 `ctx.fs`, 上游 read / write / edit / glob / grep 行为完全不变 (ADR-0016 决策 1/2/9:本包做的是加性 ctx.remoteFs,上游一字不动)。 进入远程工作区后,文件操作一律用 **remote_*:工作区目录只是一个 本地锚点 —— 只含 `.dsh-remote-workspace.json` 标记文件、不镜像任何远端文件, 对本地上游文件工具就是空目录**;本地 read / write / edit 看到的是锚点,不是远端文件。

`./ssh` 与 `./wsl` 的区别(一句话:方向):./ssh 是远端通道 —— 从本机连到远端 POSIX 主机(交互会话 ssh_start,远程工作区的文件通道也走它); ./wsl 是 Windows 互操作 —— 给跑在 WSL 里的 agent 访问 Windows 侧 (win_* 七个工具),不是远程访问,且随 bundle 挂载但默认关闭 (disabled: true,opt-in 开启,见 *The ./wsl entry*)。

The platform executors (pwsh-sandbox / bash-sandbox) keep their DSH-native posture untouched; the one-shot bash/pwsh tool faces are the platform's own (including upstream's pwsh-fallback behavior when PowerShell 7 is absent).

Host MSYS2 prerequisites(宿主环境最低工具集,2026-10-02 实测补齐)

launcher 预设与 ssh 会话都假定宿主 MSYS2 工具链可用——本 bundle 不负责装齐 MSYS2 工具链。裸 MSYS2 缺常用工具会让 agent 的验证手段悄悄降级(实测: diff/cmp 缺失时所有 diff 类验证被迫绕 git diff --no-index)。安装后请补齐:

pacman -S --needed diffutils patch jq unzip rsync

判定方法:agent 会话里 command -v diff 落空即缺。新增工具需求时在此处追加清单。

权限模型(#102):每次执行 resolve 宿主策略,升级走官方审批

本包的三条执行面(pty_open / shell_open / ssh_start)都起进程,win_* 走宿主 shell seam 执行命令。#102 之前它们既不读 ctx.sandboxPolicy 也不走 ctx.approval —— read-only 会话或 approval/policy = ask 下照样能起进程、写字节(一条绕过官方审批界面的任意执行面)。 现在这三条面接入宿主权限模型,实现集中在 src/permission-gate.ts(单一来源):

面 · 行为 pty_open / shell_open / ssh_start · 每次执行前 sandboxPolicy.resolve({ session }):read-only 拒绝起进程;workspace-write 只允许在 workspaceRoot 内起动(省略 cwd 时默认落在 workspaceRoot,相对 cwd 相对它解析);danger-full-access 不限制 pty_send / ssh_send · 每次写入都重解析:写入基线 = 会话被授权时的模式(升级打开时就是那个一次性授予的模式)。当前模式比它窄就拒绝(如策略降到 read-only,或该会话是 read-only 下经升级打开的 ⇒ 授权不外溢)。升宽不算降级。读(pty_tail)与收尾(pty_close / ssh_close)不设门 —— 它们不放大权限 win_* · 解析后的策略随每次 shell 请求交给执行器(执行器才是真正的强制面);策略拒绝时结果带官方 [sandbox: file access denied under mode] 标记 + 同轮升级提示

升级(one-shot retry):拒绝后可用 sandbox_permissions(workspace-write / danger-full-access)+ justification(一句话理由,与前者成对)重试同一次调用。请求经 ctx.approval.request 交给官方审批界面(本包不自造弹窗),只有 `allowed-once` 放行; rejected / cancelled / unavailable 一律拒绝并把 outcome 回显给模型(fail closed,不静默重试)。 approval/policy = never 时官方服务直接判 rejected(不弹窗)—— 本包按同一条拒绝路径处置。

文案来源(如实标注,不夸大「逐字」):升级请求的 reason / displayReason 文本、allowed-once 的授予语义与非 grant 的处置直接来自 @deepseek-ai/dsh-sandbox 的 approveEscalation;拒绝标记 / 同轮提示 / sandbox_permissions 的参数说明来自同包的 sandboxDenialMarker / escalationHintMarker / sandboxPermissionsDescription(与上游 bash / pwsh / fs 家族同一套词表)。模式阶梯的前置比较 是本包自己做的(用官方 WIDER_MODES 表,把「参数错误」与「策略拒绝」分开,因此错误文案比上游多一句 允许目标清单)—— 这一处是有意的分叉,写在这里而不是声称逐字。

缺服务 = fail closed:ctx.get('sandboxPolicy') / ctx.get('approval') 任一为空时拒绝执行, 错误点名缺的服务并给出挂载指引(不静默放行);四个 entry 也把这两个服务写进 inject,所以缺服务的 部署里这些行不会激活。⇒ 没挂 dsh-sandbox-policy + dsh-user-approval 的部署里 pty_* / shell_open / ssh_start / win_* 直接不可用 —— 这是有意的取舍(宁响亮失败, 不绕过)。官方 base bundle(web / headless 档)默认挂这两行(模式 DSH_PERMISSION_MODE ?? 'workspace-write');`sdk-minimal` 不是(它是 danger-full-access 且不挂 approval)⇒ 该档下本包 的工具面按上述口径不可用,要改用 base 系 profile 或自行补 approval 行。

诚实的边界(不假装做到了没做的):ctx.subprocess 的 spawn spec 没有 sandboxPolicy 字段,本包拿不到进程级隔离。这里能承诺的只有策略判定(模式)+ 起动工作区边界 + 审批门 —— 进程一旦起来,OS 层的读写仍由平台执行器/沙箱方言负责,本包不冒充它。

没有自定义命令白名单(与上游一致):pty_open 收自由命令行,shell_open 是更窄的通道 (可执行只能来自 launcher 预设表 + 探测点)。真正的命令白名单要改公开面(allowedCommands 配置 + resolveExecutable 同一执行世界判定),属另一张票,本包不预先造一套与上游分叉的语义。

pty_open / shell_open / ssh_start 的结果带 sandboxMode(本次跑在哪个模式;带升级时是授予模式)。

The ./pty entry (issue #28, ADR-0007; seam-free since #61, ADR-0010)

src/pty/ is the absorbed dsh-pty-session module (pure move from the dsh-pty-session repo, anchored at dsh-v0.2.1-alpha.1-r1 per ADR-0005): a Layer 0, protocol-agnostic PTY session core — SEAM-FREE since #61 (ADR-0010): sessions are SELF-MANAGED on node-pty (Windows ConPTY) via src/pty/session-core.ts — an owner-scoped registry (sessionId → live session, owner binding, awaited dispose cleanup), per-session line buffers with incremental read cursors, the #56 soft cap (maxSessions, default 8, counted from the registry so an entry remount cannot reset it — the #62 root cause is structurally gone) and the opt-in per-session idleTimeoutMs (auto-close on no new output). Six tools — pty_open / pty_send / pty_tail / pty_close / pty_list / pty_signal — plus a programmatic facade (ctx.provide("pty")) for consumer plugins. Persistent/ interactive session ownership (ADR-0004 decision 5) is unchanged, and the entry keeps its own settings namespace (dsh-pty-session plugin name, maxSessions + tailLines + defaultShellCommand config) — ADR-0007: sharing the package does not merge config surfaces.

import * as pty from 'dsh-shell-host/pty'   // { name, inject, Config, apply }

Mount it via the bundle layer with name: 'dsh-shell-host/pty' — no dependency on any platform seam (the bundle patch mounts the pty + ssh + terminal rows unconditionally).

会话默认 shell(#149):打开时声明一次,之后裸命令直通

pty_open 新增 shellCommand:一串裸命令(可执行 + 它自己的交互 argv,如 /usr/bin/bash.exe --login -i)。声明之后 pty_send 的文本直接由它解释 —— cd 跨调用生效、管道与 && 可用,不必每条命令自己套 bash -lc "…"。

  • 与 `command` 互斥:两个都填 = 响亮报错(INVALID_ARGS)。一个都不给时才轮到 settings 的

defaultShellCommand;Config 也没配 ⇒ 响亮报错,文案点名三个选项。 优先级链 = 显式 `shellCommand` > 显式 `command` > `defaultShellCommand` > 报错 —— 显式 command 是普通进程,永不吃全局默认(否则全局默认会改写所有既有调用)。

  • 零词汇:不新增预设 id、不加 shell: true 布尔、shellCommand 不经 launcher 预设解析

—— 取值即使长得像预设 id(msys2-ucrt)也按裸命令 spawn。预设面仍是 shell_open (分工见 ./terminal 一节)。

  • shell 目标:这样的会话是 shell 目标 ⇒ pty_signal 拒绝 SIGKILL(SIGNAL_FORBIDDEN,

与 shell_open 同源文案),结束时走 pty_close。反例不受影响:pty_open({ command: "bash" }) 不是 shell 目标 —— 核心不从命令行文本猜。

  • 平台差异(如实声明):win32 = parseCommandLine 分词后直接 spawn(无 shell 解释);

POSIX = 仍 /bin/sh -c ,即多一层 `sh` 父进程(pid 报的是 sh)。 不新增 POSIX 分词 / 引号面(那正是本票拒绝的复杂度)。

  • 代价(选显式命令的代价,不掩饰):预设环境(MSYSTEM / CHERE_INVOKING / PATH 前缀)

不由本票代注入,由 caller 自担;要预设环境就用 shell_open。env/PATH 只在 spawn 时 注入 ⇒ 运行中的会话换不了环境。

  • 临时切换(零工具面):在会话里发一行另一解释器的启动命令即进入,exit 回默认 ——

状态天然在 PTY 进程树里,无状态机、无新参数。一条命令形式的一次性调用由 caller 自写方言 (bash -lc / pwsh -Command / cmd /d /c),工具不代拼引号(每方言引号规则不同)。

  • 环境侦测上下文:本 entry 注册一条 systemPrompt.context(shell-host:environments,

order 130,interpolate: false),内容 = 「预设 id → 解析出的可执行」,让 agent 知道该把哪个 路径写进 shellCommand,不必猜。它是环境事实、不是指令,不主动起轮; 首次装配才懒算并缓存(win32 的侦测要起 wsl.exe --list --quiet,绝不在 apply 里同步跑); 没有可用环境时该行整条不注入(不写「无」)。注册走子 fiber (ctx.inject(['systemPrompt'], …),cordis 的 child-fiber 入口):./pty 的工具面从不等待 提示词面 —— 没有 prompt registry 的 tools-only 组合照旧拿到六个工具;而后挂的 registry 仍然会拿到这条上下文(机会式 ctx.get 读取会漏掉这种挂载顺序)。

// 声明一次,之后裸命令直通
const s = await pty_open({ shellCommand: '<msys2>/usr/bin/bash.exe --login -i' })
await pty_send({ id: s.sessionId, data: 'cd /c/work && ls | head -3' })   // 无包装、无包装回显

噪音读数(#149 AC2,真实 ConPTY / win32,N=3;载体 `pty-shell-machine.spec.ts`): 同一 payload ——「每条命令一次 pty_open」= 3 个会话 / 9 个初始化转义序列 / 126 字节; 「一个常驻 shell 会话」= 1 个会话 / 20 个序列 / 566 字节;调用方每命令要写的字节 55(包装串)→ 22(裸命令)。如实说:常驻交互 shell 的字节读数更大 —— 提示符与命令回显是终端语义,不是本票要消的噪音;本票消掉的是「每命令一次会话 + 一次 PTY 初始化

  • 一次 shell 启动」以及回显里再没有包装串。

initialOutput 可能为空(#154):ConPTY 首静默窗与正确的取数姿势

事实:真实 ConPTY 下 `pty_open` 返回的 `initialOutput` 经常是空串,这不是缺陷。 成因在 core 的初始 settle(src/pty/session-core.ts):打开会话后先等一个输出静默窗 (默认 DEFAULT_SETTLE_QUIET_MS = 150,一旦连续 150ms 无新字节即返回;另有 2s 硬上界防挂), 而 ConPTY 的初始化转义序列与 shell 横幅往往在首个静默窗之后才到达 ⇒ 它们不落进 initialOutput,而是落进下一次 pty_tail 的页里(tail 游标从打开时已存在的行起算, 之后到的字节都能读到)。autoClose: true 的一次性形态不受此影响 —— 它等真退出后再走一次 静默窗,缓冲输出会落进 initialOutput(见上一节 #146)。

实测读数(采集环境:文件沙箱 = danger-full-access、无审批提示,真实 ConPTY / win32):

  • #149:shell initialOutput bytes = 0,同一份日志里紧接的 delta 才带着 ESC[?9001h…

与 bash 横幅(.scratch/149-probe.log)。

  • #147 读数 A:pty_open { command: 'node -e "setInterval(() => {}, 1000)"' } 返回

{"initialOutput":""}(转录 .scratch/t147-pty-crosscall.md)。

正确取数姿势:要量「启动输出」(横幅 / 初始化转义 / 提示符),不要读 `initialOutput`, 用后续 tool call 里的 `pty_tail` 轮询。可复跑步骤(最少两次 tool call;采集环境一并记):

  1. pty_open { command: 'node -e "setInterval(() => {}, 1000)"' }(或任一会产出横幅的会话)

→ 记下返回的 session id;此时 `initialOutput` 为 `""` 属预期,别拿它断言「没输出」。

  1. 另一次 tool call 里 pty_tail { id } → 横幅与初始化转义(ESC[?9001h…)在这一页里;

若还没有,隔一拍再 pty_tail,直到读到你要的 needle 为止。 (不产出任何输出的子进程读到空增量也算正常返回 —— 与 docs/dev-environment.md §4.6 的跨轮存活读数是同一套措辞。)

  1. 用完 pty_close { id } 收尾。

本票不改语义:不加「首个输出到达再算 settle」的开关、不改 settle 默认值 —— 要那样请另立票(会牵动默认行为 / 超时上界 / shellCommand 交互与变异验证)。

pty_list / pty_signal(#119)

补齐官方 terminal_list / terminal_signal 的工具面覆盖。两者都只作用于调用方 agent 自己持有的会话(数据源就是核心的 owner 注册表,与 pty_close 的 SESSION_OWNED 同一姿态):pty_list 只列自己的(别人的会话根本不出现在结果里); pty_signal 拿别人的 id 一律 SESSION_OWNED、未知 id NO_SESSION,归属判据先于信号名 校验(别人的 id 配一个非法信号名也只会得到 SESSION_OWNED)。

  • `pty_list`:每行 { id, status, pid? },顺序稳定 = 创建顺序(按 id 的数字

后缀排;字典序会在第 10 个会话起错位,pty-10 会跑到 pty-2 前面)。已退出但仍被 持有的会话照旧列出 —— 持久会话的契约是 pty_close 才回收。

  • `pty_signal`:白名单与官方一致(SIGINT / SIGTERM / SIGKILL / SIGTSTP /

SIGHUP)。平台语义与代码共用同一份常量(PTY_SIGNAL_SEMANTICS, src/pty/session-core.ts 导出;工具描述逐字引用它,本节的取舍与它同源):

  • POSIX:process.kill(-pid, signal) 投递到会话的进程组(forkpty 的子进程是