dsh-bash-env
按 workdir 为 dsh 的每条 shell 命令计算环境:PATH 追加、任意环境变量、venv 自动激活、mise shims。带一个 Web UI 设置分区(设置 → 终端环境)。
A dsh profile plugin that replaces the built-in bash executor so every bash -c
gets an environment resolved from its own working directory, plus a dedicated
settings section in the Web UI.
它解决什么
dsh 的每条命令都是非登录、非交互的 bash -c(~/.bashrc、~/.bash_profile 一个都不读),而子进程的 PATH 来自启动 dsh 的那个进程——桌面 App 由 GUI 会话拉起时,那份 PATH 里通常没有 ~/.cargo/bin、mise shims 之类。于是在终端里能跑的命令,在 dsh 里"找不到命令"。
本插件不引入 shell 启动文件,而是用 dsh 官方扩展点替换 ctx.shell,在每次 spawn 前按该命令的 workdir 算出环境覆盖层。
两个配置入口,同一个 user 层
| 入口 | 适用 |
|---|---|
| Web UI:设置 → 终端环境 | 日常调整;写入与 YAML 相同的 user 层(即 profile 的 cordis.patch.yml 里 id: bash-env 那一段) |
~/.dsh/profiles/<profile>/cordis.patch.yml |
批量/脚本化;改完存盘由 HMR 立即重组配置层 |
两者都改完即生效,不需要重启(配置层由 dsh-hmr 监听)。但插件代码改动不会被 HMR 重载(hmr.root 默认不监听模块文件),需要重启 harness。
配置必须扁平
设置表单只寻址单段路径(SettingsFormModel 的 path: [field]),所以一个嵌套块在浏览器里根本编辑不到。因此配置是每个开关一个顶层键:
# ~/.dsh/profiles/<profile>/cordis.patch.yml
- id: bash-env
name: dsh-bash-env
config:
prependPath:
- /home/you/.cargo/bin
envFile: /home/you/.dsh/shell-env.conf
envVars: {}
venvEnabled: true
venvFallback: /home/you/.venv
miseEnabled: true
miseShims: /home/you/.local/share/mise/shims
miseAutoInstall: false
| 字段 | 默认 | 含义 |
|---|---|---|
prependPath |
[] |
放在 PATH 前部的目录。不做存在性剔除(版本管理器首次安装后目录才出现),只在挂载时对不存在的项 warn |
envFile |
'' |
每行一条 KEY=VALUE 的文件;spawn 时读取,改完下一条命令即生效。设了但读不到 → 命令报错(明确可见,不静默) |
envVars |
{} |
内联变量,优先级高于 envFile |
bashEnv |
'' |
一个绝对路径;bash 在每条非交互命令前 source 它。留空即关闭 |
venvEnabled |
true |
是否查找虚拟环境 |
venvNames |
['.venv','venv'] |
每层候选目录名 |
venvMaxDepth |
3 |
从 workdir 向上查找的层数 |
venvStopAt |
$HOME |
查找不进入该目录;家目录 venv 因此不会隐式生效 |
venvFallback |
'' |
查找未命中时使用;不可用时保持不激活 |
miseEnabled |
true |
是否启用 mise 支持 |
miseShims |
~/.local/share/mise/shims |
shim 目录,加入 PATH |
miseAutoInstall |
false |
false 时注入 MISE_AUTO_INSTALL=false / MISE_NOT_FOUND_AUTO_INSTALL=false |
miseHookEnv |
false |
打开后每个目录问一次 mise hook-env,拿到 JAVA_HOME/GOROOT/GOBIN 与项目 [env] 段 |
miseBin |
'' |
mise 可执行文件;留空用 PATH 上的 mise |
cwd / timeoutMs / maxTimeoutMs / maxOutputBytes / maxSpillBytes / graceMs |
— | 继承内置 executor 的预算字段 |
环境合成顺序:
PATH = <venv>/bin : prependPath... : <mise shims> : 继承的 PATH (首次出现优先,自动去重)
VIRTUAL_ENV = <按 workdir 检测到的 venv>
MISE_AUTO_INSTALL / MISE_NOT_FOUND_AUTO_INSTALL = 'false'
envVars 与 envFile 最后应用(可覆盖上面的值)
venv 排在 prependPath 之前是刻意契约:否则版本管理器的 python3 shim 会压过 venv,出现 VIRTUAL_ENV 指向 A、实际解释器是 B 的静默错误。
覆盖层落在 executor 自身覆盖(NO_COLOR / TERM=dumb / PAGER)和管理事实 DSH_* 之上,随后 spec.dshEnv 再压一次——配置永远无法遮蔽托管事实。
环境变量文件格式
# 注释与空行忽略;行首可写 export
CARGO_HOME=$HOME/.cargo
PATH=$PATH:/opt/tools
LITERAL='$HOME 不被展开'
$VAR/${VAR}相对于该命令继承到的环境展开,也可引用同文件中更早的行- 单引号包裹的值不做展开
- 变量名必须合法;
DSH_*前缀被拒绝(harness 自己的命名空间) - 某一行格式不对 → 命令带着
文件:行号的明确报错失败,改好即恢复
mise 的派生变量(JAVA_HOME / GOROOT / GOBIN)
shims 回答的是"运行哪个可执行文件",它回答不了"该用哪个 JAVA_HOME"——那是变量,不是文件。所以 miseHookEnv 走另一条路:每个目录问一次 mise hook-env,拿到完整答案(派生变量 + 项目自己的 [env] 段),并按目录缓存。
为什么值得(实测,10 次平均):
| 成本 | |
|---|---|
mise hook-env(每条命令) |
~20ms,缓存命中后 ≈0 |
| 一次 shim 调用 | ~30ms |
一条命令里调 node -v + npm -v + java -version,用 shims 要付 ~90ms;用 hook-env(缓存后)是 0,而且顺带得到 JAVA_HOME。成功时不再叠加 shims——那会把刚省下的成本加回去。
缓存键:(规范化 workdir, mise 配置指纹)。指纹是沿目录链 stat mise.toml/.mise.toml/mise.local.toml/.tool-versions 加上 ~/.config/mise/config.toml 的 mtime+size,另配 5 分钟 TTL 与 64 条 LRU 上限(否则长时间会话会攒下无数目录)。mise install 改了配置 → 指纹变化 → 自动重问。
失败一律回落 shims,不让命令失败:mise 未安装、探测超时(3s 上限)、退出码非零、或输出里没有可用变量——四种都归到 shims-fallback。走哪条路由可以从托管事实看到:
echo "$DSH_MISE" # hook-env | shims | shims-fallback
两个必须知道的点:
hook-env是 mise 的内部接口(帮助里写着[internal] called by activate hook),版本间可能变。所以解析是"看不懂就忽略"、输出为空即视为失败,整个功能默认关闭。- 项目的
[env]段属于这份答案,也就是说仓库里的一个文件可以为在其中执行的每条命令设置变量。这是它默认关闭的真正原因——要开请自己确认那些仓库可信(mise 自身的trust门也会拦未信任的配置)。
默认关闭。打开方式:设置 → 终端环境 → 「向 mise 询问当前目录解析出什么」;mise 不在 PATH 上时再填 miseBin。
BASH_ENV / shell 启动文件
bashEnv 是唯一的"逃生舱",也是本插件唯一注入代码而非数据的设置。它解决变量表达不了的事:
| 能做 | 不能做 |
|---|---|
| 定义 shell 函数(非交互 shell 里可用 ✅) | 定义 alias(非交互 shell 不展开 alias,实测 type rm → /usr/bin/rm) |
设置 shell 选项、shopt |
期望它输出任何东西——文件里的打印会混进每条命令的输出 |
| 执行工具自带的激活脚本(nvm/conda 等) | 承担重活:它在每条命令上都会执行,嵌套 shell 还会再执行一次 |
语义:bash 只为非交互 shell source BASH_ENV——而 dsh 的每条 bash -c 正是非交互的。(bash --rcfile 是另一回事:它隐含 -i,会引入提示符与回显,所以没有采用。)它在本插件组装好的环境之后运行,因此对 PATH 有最终决定权;想让显式变量赢,把 BASH_ENV 写进 envVars 即可。
校验:bash 对一个读不到文件的 BASH_ENV 是静默忽略的,所以插件自己校验——非绝对路径、文件不存在、或不是普通文件,都会让命令带着原因失败,而不是看起来配了却没生效。
默认关闭。要打开就在设置页填这个字段(或写进 YAML),指向类似 ~/.dsh/shell-startup.sh 的小文件。模板里只放了注释,需要什么自己加。
为什么继承 SandboxBashExecutor
- 它
extends LocalBashExecutor,所以超时、输出上限、spill、后台作业语义全部原样保留; - 它是施加会话文件策略的那个 executor。改为继承
LocalBashExecutor会静默丢掉 workspace-write 沙箱; - 唯一覆写点是
spawnSpec(),前台/后台、受限/完全访问全部经过它。
ctx.shell 每个 context 只允许一个实现,所以 bundle patch 必须先禁用内置行。
诊断走命令错误,不走日志
ctx.logger.warn 在无头组合里没有落盘/落终端(App 捕获的 dsh-web.log 里既没有警告也没有错误),所以它不是一个能被看到的通道。因此配置错误在 spawnSpec 里抛出:
- 键名拼错 → 每条命令失败,消息形如
invalid config:\n - $.venvFalback is not a known option; did you mean "venvFallback"?executor 仍然挂载,改回配置即可恢复,不需要重启。 - 类型写错 → 由 schema 校验在挂载时报错(
ValidationError: $.prependPath expected array but got ...),这一条会让该行不挂载,也就是暂时没有ctx.shell。 - env 文件问题 → 命令失败,消息带文件名与行号。
安装
# 1. 建立宿主包链接(见下)
cd /path/to/dsh-bash-env && node scripts/link-host.mjs
# 2. 链接进 profile 并加入 bundles
dsh plugin --profile <profile> add link:/path/to/dsh-bash-env
# 再把 "dsh-bash-env" 加进 ~/.dsh/profiles/<profile>/package.json 的 dsh.profile.bundles
# 3. 在 profile 的 cordis.patch.yml 里写站点配置(见上),或用 Web UI
本机注意:
dsh plugin add会因缺 pnpm 失败(App 的 runtime 里只有corepack/node/npm/npx),GUI 插件管理器同理。手工等价写入即可:profile 的node_modules/<name>相对符号链接 +package.json依赖与 bundles +pnpm-lock.yaml的 importer 登记。
为什么必须有 link-host
用 link: 安装的插件,其 realpath 在 profile 树之外。dsh 的加载器对 linked 插件是从该 realpath 自身的祖先 node_modules 解析裸导入的(@deepseek-ai/dsh-app-boot 的 routeLinked),而 ~/projects/... 一路上没有 @deepseek-ai/*,于是 ERR_MODULE_NOT_FOUND。
scripts/link-host.mjs 在插件内建立指向 profile 共享层的相对符号链接。它们最终解析到和宿主同一份实体文件,所以服务类保持同一实例——若各自装一份 @deepseek-ai/cordis,ctx.shell 会直接崩。
Web UI 设置分区
注册进 shell 的 settings.section 列表槽——和 通用 / 模型 / 插件 / Agent 预设 同一层级的独立分区(order 12,介于模型 10 与插件 15 之间),而不是通用设置里的一行。
页面结构:
- 顶部说明块:为什么需要它(非登录
bash -c不读任何启动文件)、PATH 的合成顺序、改动何时生效、出错时的行为、作用范围,以及为什么官方的「终端」卡片不见了。 - 四个分组:命令预算 / PATH 与环境变量 / Python 虚拟环境 / mise 工具链。每组带一句组说明(例如其余预算项要去 YAML 配)。
- 每个设置项:一句
hint常驻显示;更长的说明放在壳层的信息按钮弹出区里(SettingsValueField的help)。列表与勾选类控件用原生<details>承担同样的角色,因为壳层的 primitive 只提供单行 input。
绑定的是 profile 里真实组合出的 bash-env 行,且无条件注册:官方设置卡用 configForms.whileServed 门控("没组合这个插件就不出现"),而那个判据读的是宿主回报的命名空间列表——如果某个前端 shell 不回报这份列表(例如局域网 bridge 的页面),入口就会整个消失,看起来和"插件没加载"一模一样。所以本插件改为始终注册,命名空间确实不可用时由设置表单自己显示「该插件当前未加载,暂时无法配置」——有入口并说明状态,比静默消失有用。
保存写入的就是同一个 user 层,与手改 YAML 等价。
由于
bash-sandbox被本插件禁用,官方那个 "Terminal / 终端" 设置卡会随之消失(它whileServed(['bash-sandbox','pwsh-sandbox']))。命令超时与输出上限已并入本分区,功能没有丢失。
配置字段必须是 .volatile()(插件作者必读)
设置服务拒绝写入任何非 volatile 的路径(dsh-settings:Config field "x" is not volatile)。症状极具误导性:字段在设置页正常渲染、能编辑,但每次保存都被拒——重启也一样,且不说明原因。
内置 executor 的 6 个预算字段全都带 .volatile(),本插件自己的 12 个字段也必须带。test/executor.integration.mjs 里有一条守卫会遍历 Config.dict 断言这一点。
注意 .volatile() 会改变取值的形态:它解析出的是访问器而不是普通值(这正是内置代码处处写 this.config.timeoutMs.get() 的原因)。所以读取必须解包,否则数组字段会退化成对象,每条命令都会以 object is not iterable 失败。本插件用 plainConfig() 在每次 spawn 时统一解包。
保存失败时会说明原因
壳层表单把所有拒绝都显示成同一句"本部署没有接受这些值",不显示原因——而两种原因该做的事完全不同:
| 原因 | 含义 | 该怎么办 |
|---|---|---|
settings/conflict |
你打开页面后,这份配置被别处改过(revision 过期) | 重开设置再保存 |
settings/rejected |
宿主拒绝了取值 | 看具体报错改值 |
所以本插件在 configMutate 外面包了一层:拒绝时用当前 revision 重放同一批操作——
- 重放成功 ⇒ 说明只是 revision 过期。改动已经保存,表单按成功处理(清空草稿),页面只多一行说明发生了什么。省掉"重开再点一次"。
- 重放仍被拒 ⇒ 把宿主的
code与message原样显示出来。 - 读不到原因(例如 remote 不可用)⇒ 退回到列出这两类常见原因。
另外 booleanField 的 format 容忍宿主把布尔值回传成字符串 'true'/'false':否则复选框会把已启用的项显示成未勾选,诱使你点一下、产生一次本不需要的写入。
为什么局域网页面上这个入口不能配置
dsh 自身把设置界面限制在本机 loopback,与本插件无关。dsh-client-ui-settings 这样决定持久化方式:
const persistence = ctx.remote.$host.isLoopback ? "host" : "memory";
而 isLoopback 由浏览器地址栏判定(dsh-client-connection:isLoopbackHostname(pageLocation.hostname),即 localhost / ::1 / 127/8)。一旦用局域网 IP 打开(如 http://192.168.10.20:3082/),持久化退化为 memory,而 memory 模式下:
load() { if (this.persistence === "memory") return Promise.resolve(); … } // 不读命名空间
ensure() { if (this.persistence === "memory") return Promise.resolve(); … } // 同上
mutate() { if (this.persistence === "memory" …) return Promise.resolve(false) } // 不写
后果有两个,都会遇到:
- 任何插件的设置页都显示"未加载 / 无法配置"(镜像从未加载 → 命名空间永远不是
ready)。官方的设置卡还会因此整个不出现(它们用whileServed门控)。 - 保存被客户端直接拒绝,报"本部署没有接受这些值"——请求根本没发到宿主。
所以:设置请在本机页面(127.0.0.1:3080,或桌面 App 打开的页面)修改。从远程页面发起的命令同样会用到这些设置,因为环境是在宿主侧按工作目录解析的——只有"编辑"受这个限制。
本插件的卡片改为无条件注册,所以远程页面上至少能看到入口和它的真实状态(而不是入口静默消失)。
为什么页内没有"运行一条命令"的测试器
这是刻意的取舍,不是遗漏。客户端要触达宿主只有两条路:
| 通道 | 插件可用? | 鉴权 |
|---|---|---|
Typert remote(ctx.remote.*) |
❌ | 有,但一个 api 包需要代码生成的 typert.host.js / typert.remote-client.js(各上千行),第三方插件没有这套构建链 |
ctx.webserver.register() |
✅ | ❌ 平台层没有:cookie 校验在 dsh-client-connection 内部且未导出 |
也就是说,页内执行器只能用一条默认无鉴权的本地 HTTP 路由来跑任意命令。虽然本地同用户进程本来就能执行命令,但网页可以通过 CSRF / DNS-rebinding 去打 127.0.0.1——为此在设置页里开一个执行端点,代价与收益不成比例,所以不做。
怎么验证配置生效
在 dsh 的会话里跑(走的就是本插件解析出的环境):
bash -c 'command -v cargo; echo "VE=$VIRTUAL_ENV"; command -v python3; command -v node'
# 期望:cargo 在 ~/.cargo/bin,VE 指向 ~/.venv 或当前项目的 .venv,
# python3 来自该 venv,node 来自 mise shims
bash -c 'printf "%s\n" "$PATH"' | tr ':' '\n' | head -5
# 期望顺序:<venv>/bin → 额外 PATH 目录 → mise shims → 继承项
# 切换工作目录即切换 venv:
# 用 bash 工具的 workdir 参数指向另一个项目,再 echo $VIRTUAL_ENV
SettingsForm 只负责写配置;解析结果永远以真实命令的输出为准——这也是上面那条取舍的另一个理由。
测试
npm test # 51 个纯逻辑单测 + 5 个客户端冒烟测试
npm run test:integration # 13 个集成测试:真挂载 cordis 组合并执行真命令
集成测试覆盖单测到不了的部分:插件确实以 ctx.shell 挂载(无重复注册)、Config 校验通过、解析出的环境真的到达 spawn 的 bash -c,包括嵌套 shell,以及 DSH_* 托管事实不被配置覆盖。
它曾经抓到过一个真 bug:base.env 只是覆盖层、不含 PATH(继承环境由 subprocess seam 合并),只从它算 PATH 会把 PATH 覆盖成仅剩 prepend 项,连 bash 自己都 spawn 不了(ENOENT)。
客户端冒烟测试用桩 window.__ModuleLoader__ / require / ctx 验证:包 id、字典、绑定的是 bash-env、注册进 settings.section(不是 settings.general.item)、以及每个控件的文本↔schema 值转换。它不能验证真实渲染——shell 的 primitives 依赖 React 与 CSS modules,在 Node 里不可加载。
已知边界
- 只影响 bash 工具跑的命令。 dsh 里其他 spawn 路径(
ctx.subprocess直连的 MCP server、终端 PTY、插件自起的进程)不经过 shell executor,拿不到这份环境。 - 不处理 mise 的派生变量。 shims 只提供可执行文件;
JAVA_HOME/GOROOT/GOBIN及 mise 的[env]段需要mise hook-env,那是另一个(带缓存的)模块,本插件不做。 - 不提供
BASH_ENV。 需要 shell 函数/rm包装时才有必要,且 alias 在非交互 shell 里本就不生效。 envVars在 UI 里是单行文本域:每行一条KEY=VALUE,但不做$VAR展开预览——展开发生在 host 侧。- POSIX only,
bash-sandbox的 gating 照抄(Windows 上本行 disabled,本来也没有 bash)。
No comments yet. Be the first to write one.