chore(dsh): 跟上游插件改名 dsh-events,并删掉一条会随机变红的联机验收 (#980)

* rename: 上游插件改名 dsh-jsonl -> dsh-events,同步 vendor 与常量

插件仓库改了名。`jsonl` 说的是输出格式,不是它给你什么——目标用户是想把 dsh 接进
GUI / 编辑器 / CI 的人,他们脑子里的词是「事件」,不是「JSON Lines」。而且别家都不这么
叫(Claude Code 是 --output-format stream-json,Codex 是 --json,OpenCode 是
--format json),有人来找这个能力时搜的是 "dsh json output"。

趁 npm 还没发布、还没对外宣传时改,代价只有改几个文件。旧 URL 有 GitHub 重定向兜底,
但留着迟早会成为断链。

跟着改的:vendor 文件名、同步脚本名、两个常量、环境变量名、模块文档与注释里的链接。
vendor 文件是用改名后的脚本重新生成的,不是手改——review 时加进来的来源与 MIT 许可头
原样保留。

* test(dsh): 删掉一条红绿取决于模型心情的联机验收

`tmp_is_writable_by_design_and_we_cannot_narrow_it` 想把「$TMPDIR 可写且收不掉」这个
dsh 既有行为钉成契约。但验证它必然要让模型去写文件,而 dsh 有**两道松紧不同的围栏**:

  bash(seatbelt 子进程沙箱)      工作目录 + /tmp + $TMPDIR
  write / edit(进程内 fs 围栏)    只有工作目录

prompt 里写了「用 bash 执行」,模型照做就绿,改用 write 就红。实测两种都出现过:一次
文件真落下去(bash),一次模型回 DONE 但文件根本没建(write)。这条测试的红绿反映的是
模型当次的选择,不是被测系统的行为——正是它上面那条注释批评过的毛病,我自己又犯了一遍。

那张「两道围栏」的表挪进模块文档,并写明为什么不做成测试。留下的
`sandbox_blocks_writes_outside_the_workspace` 打的是家目录,两道围栏都挡,不受工具选择
影响,那条才作数。
This commit is contained in:
bigsong authored and GitHub committed 2026-08-22 02:11:25 +08:00
1 parent b7ac91f0e6
commit aa0acf8827
3 files changed
+64 -85

No files matched your search

@@ -13,19 +13,19 @@
//!
//! dsh 是插件栈(cordis)架构,profile 由一层层 patch 叠成,`--patch <file>` 就是官方
//! 留给外部叠自己那层的入口。我们用它挂上 [`TAP_PLUGIN_JS`]——那是
//! [dsh-jsonl](https://github.com/bigsongeth/dsh-jsonl) 的带来源与 MIT 许可头副本:一个订阅
//! [dsh-events](https://github.com/bigsongeth/dsh-events) 的带来源与 MIT 许可头副本:一个订阅
//! `session/event` 的插件,把事件按有版本的公开 schema 打成 NDJSON 到 **stderr**,
//! stdout 的「只打最终文本」契约原样不动。
//!
//! # 上游耦合都收在那个 JS 里
//!
//! dsh 目前是 `0.1.0-rc.6`,事件形状还会变,而上游不收 PR,跟随成本由我们自己背。
//! 所以耦合点被压缩到那一个 JS 文件里:它把 dsh 的内部事件翻译成 dsh-jsonl 的公开 schema
//! 所以耦合点被压缩到那一个 JS 文件里:它把 dsh 的内部事件翻译成 dsh-events 的公开 schema
//! (`text.delta` / `tool.call` / `turn.end` …),Rust 侧只认那个 schema。上游改字段时改
//! JS 即可,Rust 的解析和单测都不用动。
//!
//! 那个 JS 的源头在 dsh-jsonl 仓库,本仓库存的是带版本标记的副本,用
//! `scripts/vendor-dsh-jsonl.sh` 同步——**跨仓库没有自动同步**,见 [`TAP_PLUGIN_JS`]。
//! 那个 JS 的源头在 dsh-events 仓库,本仓库存的是带版本标记的副本,用
//! `scripts/vendor-dsh-events.sh` 同步——**跨仓库没有自动同步**,见 [`TAP_PLUGIN_JS`]。
//!
//! 而且 tap 是**纯增益**:它挂不上或者解析不出来,最终结果照样从 stdout 拿得到,
//! 只是少了逐字流和工具展示。护栏不依赖它(见下)。
@@ -35,14 +35,24 @@
//! dsh 没有逐命令 deny 清单,沙箱经 `DSH_PERMISSION_MODE` 环境变量注入。OpenLess 只开放
//! `read-only` / `workspace-write` 两档;遗留权限值统一 fail-closed 到只读。
//!
//! **要说准它到底挡什么**:`workspace-write` 下的可写根被 dsh 写死成
//! `[工作目录, "/tmp", $TMPDIR]`(见 `@deepseek-ai/dsh-sandbox` 的 `writableRoots`),
//! 且**没有配置项能收掉那两个临时目录**——Codex 那边有 `-c sandbox_workspace_write.
//! exclude_*` 可以收紧,dsh 没有对应物。家目录与系统路径确实挡得住(实测报
//! **要说准它到底挡什么**——dsh 有**两道松紧不同的围栏**(实测 0.1.0-rc.6):
//!
//! | 工具 | 可写范围 |
//! |---|---|
//! | `bash`(seatbelt 子进程沙箱) | 工作目录 + `/tmp` + `$TMPDIR` |
//! | `write` / `edit`(进程内 fs 围栏) | 只有工作目录 |
//!
//! 那三个可写根被 dsh 写死在 `@deepseek-ai/dsh-sandbox` 的 `writableRoots` 里,
//! **没有配置项能收掉那两个临时目录**——Codex 那边有 `-c sandbox_workspace_write.
//! exclude_*` 可以收紧,dsh 没有对应物。家目录与系统路径两道围栏都挡得住(实测报
//! `sandbox: file access denied under workspace-write mode`)。
//!
//! 所以对用户的措辞只能是「撞到限制会如实报错」,不能说成「写入限制在工作目录内」。
//! 见 `live::tmp_is_writable_by_design_and_we_cannot_narrow_it`。
//!
//! 上面那张表**没有做成测试**,是有意的:验证它必然要让模型去写文件,而结果取决于模型
//! 当次挑了 `bash` 还是 `write`——那种测试的红绿反映的是模型的心情,不是被测系统的行为。
//! `live::sandbox_blocks_writes_outside_the_workspace` 打的是家目录,两道围栏都挡,
//! 不受工具选择影响,那条才作数。
//!
//! # prompt 不进 argv
//!
@@ -70,23 +80,23 @@ use super::{CodingAgentError, CodingAgentPermissionMode};
/// 但 `headless-startup` 仍要求 argv 上有个非空任务(空的话它会走 usage error 直接退出)。
pub const ARGV_TASK_PLACEHOLDER: &str = "openless-task";
/// 内嵌的 dsh-jsonl 插件版本。必须与 `vendor/dsh-jsonl.js` 头部的标记一致——
/// 内嵌的 dsh-events 插件版本。必须与 `vendor/dsh-events.js` 头部的标记一致——
/// 下面的单测盯着这一点,防止有人直接改了 vendor 目录却没走同步流程。
pub const VENDORED_DSH_JSONL_VERSION: &str = "0.1.0";
pub const VENDORED_DSH_EVENTS_VERSION: &str = "0.1.0";
/// 我们能解析的 dsh-jsonl schema 大版本。插件报的对不上只记警告,不中止——
/// 我们能解析的 dsh-events schema 大版本。插件报的对不上只记警告,不中止——
/// 最终结果始终从 stdout 拿得到,流式展示只是锦上添花。
pub const SUPPORTED_DSH_JSONL_SCHEMA: u64 = 1;
pub const SUPPORTED_DSH_EVENTS_SCHEMA: u64 = 1;
/// 挂给 dsh 的输出插件,逐字内嵌自 [dsh-jsonl](https://github.com/bigsongeth/dsh-jsonl)。
/// 挂给 dsh 的输出插件,逐字内嵌自 [dsh-events](https://github.com/bigsongeth/dsh-events)。
///
/// **不要在这里改它。** 源头在那个仓库,这里只是一份带版本标记的副本,用
/// `scripts/vendor-dsh-jsonl.sh <checkout>` 同步。之所以内嵌而不是让 App 去依赖 npm 包:
/// `scripts/vendor-dsh-events.sh <checkout>` 同步。之所以内嵌而不是让 App 去依赖 npm 包:
/// 用户装机即用,不需要网络、不需要 `npm i`,也不会碰用户自己的 dsh profile 配置。
///
/// 跨仓库没有任何自动同步。忘了同步的后果是 OpenLess 停在旧版插件(照常工作),不是崩;
/// 真的对不上时 `live::tap_plugin_delivers_streaming_and_tool_events` 会红。
const TAP_PLUGIN_JS: &str = include_str!("vendor/dsh-jsonl.js");
const TAP_PLUGIN_JS: &str = include_str!("vendor/dsh-events.js");
/// 权限模式 → `DSH_PERMISSION_MODE` 取值。
///
@@ -110,9 +120,9 @@ fn configure_dsh_environment(
) {
cmd.env("DSH_PERMISSION_MODE", dsh_permission_mode(permission_mode))
// stdout 必须只保留 dsh 的最终文本;不允许继承用户环境把 JSONL 改到 stdout/文件。
.env("DSH_JSONL_OUT", "stderr")
.env("DSH_EVENTS_OUT", "stderr")
// 原始事件可能包含不必要的内部数据,OpenLess 只消费公开 schema。
.env_remove("DSH_JSONL_RAW");
.env_remove("DSH_EVENTS_RAW");
}
/// 构造 `dsh` 的命令行参数(不含可执行文件本身,也不含 prompt——prompt 走 patch 文件)。
@@ -141,7 +151,7 @@ pub fn build_dsh_patch_yaml(plugin_js_path: &Path, prompt: &str) -> Result<Strin
Ok(format!(
r#"# Generated by OpenLess. Applied as the last patch layer for one headless run.
- insert:
- id: dsh-jsonl
- id: dsh-events
name: {quoted_path}
- id: headless-runner
config:
@@ -161,7 +171,7 @@ fn dsh_task_for_request(req: &CodingAgentRequest) -> String {
req.prompt.clone()
}
/// 解析一行 dsh-jsonl 输出的 NDJSON(schema v1,见插件仓库的 SCHEMA.md)。
/// 解析一行 dsh-events 输出的 NDJSON(schema v1,见插件仓库的 SCHEMA.md)。
///
/// 只认我们用得上的几种;**其余一律忽略**——这是 schema 的兼容性约定:消费者必须忽略
/// 自己不认识的 type 和字段,上游加新事件才不会把我们打挂。
@@ -299,7 +309,7 @@ impl TapWorkspace {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
let js_path = dir.join("dsh-jsonl.mjs");
let js_path = dir.join("dsh-events.mjs");
std::fs::write(&js_path, TAP_PLUGIN_JS).map_err(|e| format!("写 tap 插件失败: {e}"))?;
let patch_path = dir.join("openless.patch.yml");
std::fs::write(&patch_path, build_dsh_patch_yaml(&js_path, prompt)?)
@@ -407,11 +417,11 @@ pub async fn run_dsh_agent(
let _ = sink.send(protocol_state.observe(ev));
} else if let Some(schema) = parse_dsh_schema_version(&l) {
saw_tap = true;
if schema != SUPPORTED_DSH_JSONL_SCHEMA {
if schema != SUPPORTED_DSH_EVENTS_SCHEMA {
// 插件 schema 大版本变了 = 破坏性变更。任务照跑(结果仍从
// stdout 拿),但流式/工具展示可能不完整,留个声明性日志。
log::warn!(
"[dsh] 插件 schema v{schema} 与本版支持的 v{SUPPORTED_DSH_JSONL_SCHEMA} 不一致,本轮流式展示可能不完整"
"[dsh] 插件 schema v{schema} 与本版支持的 v{SUPPORTED_DSH_EVENTS_SCHEMA} 不一致,本轮流式展示可能不完整"
);
}
} else if l.trim_start().starts_with("{\"v\":") {
@@ -536,12 +546,12 @@ mod tests {
// 逐字比对而不是 contains:YAML 全靠缩进,缩进丢了 dsh 直接 `failed to parse`,
// 而 contains 完全看不出来(这就是当初漏掉的那个 bug)。
let yaml =
build_dsh_patch_yaml(Path::new("/tmp/x/dsh-jsonl.mjs"), "读一下 a.txt").unwrap();
build_dsh_patch_yaml(Path::new("/tmp/x/dsh-events.mjs"), "读一下 a.txt").unwrap();
let expected = concat!(
"# Generated by OpenLess. Applied as the last patch layer for one headless run.\n",
"- insert:\n",
" - id: dsh-jsonl\n",
" name: \"/tmp/x/dsh-jsonl.mjs\"\n",
" - id: dsh-events\n",
" name: \"/tmp/x/dsh-events.mjs\"\n",
"- id: headless-runner\n",
" config:\n",
" task: \"读一下 a.txt\"\n",
@@ -602,19 +612,19 @@ mod tests {
"DSH_PERMISSION_MODE".into(),
Some("workspace-write".into())
)));
assert!(envs.contains(&("DSH_JSONL_OUT".into(), Some("stderr".into()))));
assert!(envs.contains(&("DSH_JSONL_RAW".into(), None)));
assert!(envs.contains(&("DSH_EVENTS_OUT".into(), Some("stderr".into()))));
assert!(envs.contains(&("DSH_EVENTS_RAW".into(), None)));
}
#[test]
fn vendored_plugin_version_matches_the_constant() {
// 防的是「有人直接改了 vendor/dsh-jsonl.js 却没走 scripts/vendor-dsh-jsonl.sh」。
// 防的是「有人直接改了 vendor/dsh-events.js 却没走 scripts/vendor-dsh-events.sh」。
// 跨仓库没有自动同步,这条是唯一能当场发现副本被手改的地方。
let marker = format!("dsh-jsonl v{VENDORED_DSH_JSONL_VERSION}");
let marker = format!("dsh-events v{VENDORED_DSH_EVENTS_VERSION}");
assert!(
TAP_PLUGIN_JS.lines().next().unwrap_or("").contains(&marker),
"vendor/dsh-jsonl.js 首行的版本标记与 VENDORED_DSH_JSONL_VERSION 对不上;\
跑 scripts/vendor-dsh-jsonl.sh 重新同步,并把常量改成一致"
"vendor/dsh-events.js 首行的版本标记与 VENDORED_DSH_EVENTS_VERSION 对不上;\
跑 scripts/vendor-dsh-events.sh 重新同步,并把常量改成一致"
);
assert!(
TAP_PLUGIN_JS.contains("do not edit here"),
@@ -650,7 +660,7 @@ mod tests {
#[test]
fn parses_delta_and_tool_and_guard() {
// 形状取自 dsh-jsonl schema v1(真实抓取的样本)。
// 形状取自 dsh-events schema v1(真实抓取的样本)。
assert_eq!(
parse_dsh_tap_line(
"s1",
@@ -681,7 +691,7 @@ mod tests {
parse_dsh_schema_version(
r#"{"v":1,"seq":0,"ts":null,"type":"session.start","sessionId":"s","cwd":"/tmp","schema":1}"#
),
Some(SUPPORTED_DSH_JSONL_SCHEMA)
Some(SUPPORTED_DSH_EVENTS_SCHEMA)
);
}
@@ -769,7 +779,7 @@ mod tests {
let ws = TapWorkspace::create("跑个测试").unwrap();
assert!(ws.patch_path.exists(), "patch 文件应已落盘");
assert!(
ws.dir.join("dsh-jsonl.mjs").exists(),
ws.dir.join("dsh-events.mjs").exists(),
"tap 插件应已落盘"
);
let yaml = std::fs::read_to_string(&ws.patch_path).unwrap();
@@ -882,37 +892,6 @@ mod live {
assert!(!escaped, "沙箱失效:家目录下的文件被创建了 {}", victim.display());
}
#[tokio::test]
#[ignore = "打真实 dsh CLI:要花钱、要网络"]
async fn tmp_is_writable_by_design_and_we_cannot_narrow_it() {
// 这条不是在测我们的代码,是把 **dsh 的既有行为钉成契约**:workspace-write 下
// `/tmp` 与 `$TMPDIR` 可写,而且写死在 dsh 里、没有配置项能收掉(Codex 那边有
// `-c sandbox_workspace_write.exclude_*`,dsh 没有对应物)。
//
// 所以我们对用户的说法只能是「撞到限制会如实报错」,不能说成「写入限制在工作目录内」。
// 哪天 dsh 收紧了这条会红——那是好消息,届时删掉本测试并更新文档。
let dir = fixture_dir();
let target =
std::env::temp_dir().join(format!("openless-dsh-tmp-{}.txt", uuid::Uuid::new_v4()));
let c = run(
&format!(
"用 bash 执行:echo ok > {} 。做完只回答 DONE 或 BLOCKED。",
target.display()
),
&dir,
CodingAgentPermissionMode::AcceptEdits,
)
.await;
println!("[dsh] 写 $TMPDIR 结果 completed={:?}", c.completed);
let wrote = target.exists();
let _ = std::fs::remove_file(&target);
let _ = std::fs::remove_dir_all(&dir);
assert!(
wrote,
"dsh 似乎已把 $TMPDIR 移出可写根——好事,请更新 dsh.rs 的护栏说明并删掉本测试"
);
}
#[tokio::test]
#[ignore = "打真实 dsh CLI:要花钱、要网络"]
async fn prompt_starting_with_a_dash_is_not_parsed_as_a_flag() {
@@ -1,6 +1,6 @@
// Vendored from github.com/bigsongeth/dsh-jsonl v0.1.0 — do not edit here.
// Run scripts/vendor-dsh-jsonl.sh <path-to-checkout> to update, then bump
// VENDORED_DSH_JSONL_VERSION in dsh.rs to match.
// Vendored from github.com/bigsongeth/dsh-events v0.1.0 — do not edit here.
// Run scripts/vendor-dsh-events.sh <path-to-checkout> to update, then bump
// VENDORED_DSH_EVENTS_VERSION in dsh.rs to match.
//
// MIT License
//
@@ -24,7 +24,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// dsh-jsonl — structured event output for `dsh --profile headless`.
// dsh-events — structured event output for `dsh --profile headless`.
//
// `dsh --profile headless "<task>"` prints one line: the final assistant message.
// Everything that happened on the way — file reads, commands, streaming text,
@@ -43,18 +43,18 @@ import { appendFileSync } from 'node:fs'
export const SCHEMA_VERSION = 1
/** Stable Cordis plugin name. */
export const name = 'dsh-jsonl'
export const name = 'dsh-events'
/**
* Where the NDJSON goes.
*
* Default is stderr, not stdout: stdout carries dsh's own final-message line,
* which is its existing contract with every current consumer. Taking it over
* would silently break them. Set DSH_JSONL_OUT=<file> to write to a file
* instead, or DSH_JSONL_OUT=stdout if you genuinely want them interleaved.
* would silently break them. Set DSH_EVENTS_OUT=<file> to write to a file
* instead, or DSH_EVENTS_OUT=stdout if you genuinely want them interleaved.
*/
function makeWriter() {
const target = process.env.DSH_JSONL_OUT
const target = process.env.DSH_EVENTS_OUT
if (!target || target === 'stderr') return line => process.stderr.write(line + '\n')
if (target === 'stdout') return line => process.stdout.write(line + '\n')
return line => appendFileSync(target, line + '\n')
@@ -104,7 +104,7 @@ export function apply(ctx) {
if (Object.keys(guard).length > 0) emit('guard', { seq: guardSeq }, guard)
}
const raw = process.env.DSH_JSONL_RAW === '1'
const raw = process.env.DSH_EVENTS_RAW === '1'
// `session.start` is emitted lazily on the first event, not at mount time:
// the session identity does not exist yet when the plugin is applied.
@@ -1,33 +1,33 @@
#!/usr/bin/env bash
# Copy the dsh-jsonl plugin into OpenLess.
# Copy the dsh-events plugin into OpenLess.
#
# The plugin's source of truth is github.com/bigsongeth/dsh-jsonl. OpenLess
# The plugin's source of truth is github.com/bigsongeth/dsh-events. OpenLess
# embeds the source with its provenance and MIT license (via include_str!) so
# the app needs no npm install and no network at runtime. Nothing syncs the two automatically —
# run this after the upstream plugin changes, then run the dsh live acceptance
# (see coding_agent::dsh::live) to confirm the Rust parser still agrees with it.
#
# scripts/vendor-dsh-jsonl.sh ~/Personal/dsh-jsonl
# scripts/vendor-dsh-events.sh ~/Personal/dsh-events
set -euo pipefail
SRC="${1:-}"
if [[ -z "$SRC" || ! -f "$SRC/index.js" || ! -f "$SRC/package.json" || ! -f "$SRC/LICENSE" ]]; then
echo "usage: $0 <path-to-dsh-jsonl-checkout>" >&2
echo "usage: $0 <path-to-dsh-events-checkout>" >&2
exit 2
fi
VERSION=$(node -p "require('$SRC/package.json').version")
DEST="$(cd "$(dirname "$0")/.." && pwd)/app/src-tauri/src/coding_agent/vendor/dsh-jsonl.js"
DEST="$(cd "$(dirname "$0")/.." && pwd)/app/src-tauri/src/coding_agent/vendor/dsh-events.js"
{
echo "// Vendored from github.com/bigsongeth/dsh-jsonl v${VERSION} — do not edit here."
echo "// Run scripts/vendor-dsh-jsonl.sh <path-to-checkout> to update, then bump"
echo "// VENDORED_DSH_JSONL_VERSION in dsh.rs to match."
echo "// Vendored from github.com/bigsongeth/dsh-events v${VERSION} — do not edit here."
echo "// Run scripts/vendor-dsh-events.sh <path-to-checkout> to update, then bump"
echo "// VENDORED_DSH_EVENTS_VERSION in dsh.rs to match."
echo "//"
awk '{ print (length($0) ? "// " $0 : "//") }' "$SRC/LICENSE"
echo "//"
cat "$SRC/index.js"
} > "$DEST"
echo "vendored dsh-jsonl v${VERSION} -> ${DEST}"
echo "next: set VENDORED_DSH_JSONL_VERSION = \"${VERSION}\" in coding_agent/dsh.rs"
echo "vendored dsh-events v${VERSION} -> ${DEST}"
echo "next: set VENDORED_DSH_EVENTS_VERSION = \"${VERSION}\" in coding_agent/dsh.rs"