dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
把手机上的 Termux 接成 DSH 的一条执行通道:模型可以在插件注册的 termux_* 工具里 跑 pkg install、访问 ~/storage、用 Termux 完整的 Linux 工具链。
但它不是「必须先连 Termux 才能干活」 —— 插件有两个执行面,默认 auto 自动选:
执行面 · 何时生效 · 命令在哪跑 · 能干什么 termux · 有 agent 连接(或 backend 显式写 termux) · Termux 的来宾环境 · pkg install、~/storage、Termux 的 uid local · 没有 agent 连接(或 backend 显式写 local) · DSH 客人自己的 Debian 13 · bash / gcc / make / node / pnpm / python3 / git / ffmpeg / sqlite3,apt 走 dshbox 的 fakeroot wrapper
所以「不想把 Termux 挂后台」时照样能执行命令、装包、编译;「想用 Termux 的环境」时 在 Termux 里跑一次启动脚本,auto 就会自动切过去。
DSH 跑在 App 的 PRoot 客人里。这个客人:
能力 · 状况 有没有 am / su / 直接调 Android 服务 · 没有(su 文件存在但对 untrusted_app 不可读;cmd activity 被 SecurityException 拒) 和 Termux 有没有共享文件系统 · 没有(不是同一个 /data 视图;Termux 的 rootfs 在客人里根本不存在) 和 Termux 有没有共享网络命名空间 · 有(同一个 loopback,客人内 127.0.0.1:3080 就是宿主 Web GUI) 客人自己有没有可用的 Linux 工具链 · 有(Debian 13 trixie aarch64,见上表 local 列)
前两条决定了「主动把 Termux 进程拉起来」不可能,所以桥接方向只能是 Termux 连回来:
[DSH 客人] 监听 127.0.0.1:8791 ←—— 主动连回来 —— [Termux 里的 bash agent] ↑ ↓ 6 个 termux_* 工具 pkg / apt / ~/storage / git …
第四条决定了 local 执行面:客人本身就是一台能用的 Linux,只是此前插件只认 agent, 没连上就整条命令报 E_NO_AGENT。加上 local 之后,「不需要后台运行 Termux」这个诉求 才真正成立 —— 不是绕过 Termux,而是把一直在那儿、只是没被用起来的客人环境接上。
要连 Termux 时,用户在 Termux 里起一次 agent(可 nohup 常驻,起过一次就不用再管):
bash <(curl -s http://127.0.0.1:8791/agent)
bash install.sh # 自动探测 profile($DSH_HOME/profiles/web 等) bash install.sh /path/to/.dsh/profiles/web # 或显式指定 bash uninstall.sh # 卸载
装完重启 dsh。工具列表里会多出 6 个 termux_* 工具。
1.2.6 及之前,install.sh 是手工把包目录拷进 `node_modules/@local/`、但不在 `package.json` 里声明它。这种"幽灵依赖"会被任何一次 pnpm install / pnpm add / pnpm prune (包括在 GUI 里装别的插件)当垃圾静默剪掉 —— 目录没了、bundles 还记着它,下次重启 dsh 插件就凭空消失,而且报错只说 bundle 解析不到,看不出是 pnpm 干的。
1.2.7 改成有声明的布局:
/local-plugins/@local/dsh-termux-bridge ← 包体(vendor 目录,真实目录) /node_modules/@local/dsh-termux-bridge ← 软链 → ../../local-plugins/@local/dsh-termux-bridge /package.json dependencies: "@local/dsh-termux-bridge": "link:./local-plugins/@local/dsh-termux-bridge"
链接型依赖不被 pnpm 重建,所以 pnpm install / pnpm add / --frozen-lockfile 之后软链都原样存活。 相对路径让它不依赖共享存储是否挂载,也不依赖绝对路径。
install.sh 的事务顺序:
node --check(四个 JS)与 bash -n(agent 脚本)以及文件清单核对;
pnpm install --lockfile-only --offline:链接型依赖不访问registry,所以整个安装过程不需要网络**;
layout: "vendor-link")。任一步失败都完整回滚:包体移回、软链按原样重建(旧版是软链就重建软链、是实体目录就移回备份)、 依赖声明还原、lockfile 覆盖回旧版;首装失败还会把本次登记的 bundle 条目一并摘掉, profile 回到安装前的状态。判据是装之前有没有旧版(HAD_PREV),不是"备份位还在不在"。
这条链路不是原子操作,重装/升级前先停 dsh。
直接跑 bash install.sh 即可,无需先卸载。安装器会检测到 node_modules 里是实体目录 (旧装法),把它挪进 nmbackup. 备份位,再按新布局装;失败会自动移回去。
卸载也认得两种布局:uninstall.sh 会四处一起收 —— bundle 条目 → dependencies 里的 link: 声明 → node_modules 软链(或旧实体目录)→ vendor 包体,最后重算 lockfile,不留悬空声明。
scripts/ensure-installed.sh 仍然可用,但现在它主要是给同机的其它插件 (dsh-mobile-adapt / mobile-pilot / browser-use)做幽灵依赖自检 —— 本插件 1.2.7 起已有正式依赖声明,不再需要它兜底。
bash scripts/ensure-installed.sh --check # 只自检,缺失则退出码 1 bash scripts/ensure-installed.sh # 自检 + 重跑对应安装器补回来
这是实测踩到过的坑,不是理论风险。 本插件 1.2.7 起有正式依赖声明,pnpm install 动不了它; 但同机的 @local/dsh-mobile-adapt、@local/mobile-pilot、@local/browser-use 仍是旧装法 (把包目录直接放进 node_modules/@local/,package.json 里只有 bundle 条目、没有 dependencies 声明)。 pnpm 一跑就把它们当幽灵依赖清掉:
$ pnpm install --frozen-lockfile # 在 profile 目录里 $ ls node_modules/@local/ dsh-termux-bridge # ← 另外三个没了 $ dsh --profile web --dump-config dsh: skipping profile bundle "@local/dsh-mobile-adapt": cannot resolve ...
症状的隐蔽之处:正在运行的 dsh 进程早已把插件加载进内存,所以 phone_* 等工具当场还能用; 只有下一次重启才会集体消失,而且报错只说"解析不到 bundle",看不出是 pnpm 干的。
恢复办法(各自重跑安装器):
bash /root/projects/.dsh/dshbox/dshbox-plugins/mobile-pilot/install.sh bash /root/projects/.dsh/dshbox/dshbox-plugins/mobile-adapt/install.sh bash /root/projects/.dsh/dshbox/dshbox-plugins/browser-use/install.sh /root/projects/.dsh/profiles/web
注意 browser-use 的安装器必须带 profile 目录参数(其它两个不用),少了会报 Browser adapter: profile directory is unavailable。
验收集:
dsh --profile web --dump-config | grep -E '^# == @local/'
应当列出四个:dsh-mobile-adapt / mobile-pilot / browser-use / dsh-termux-bridge。
另外注意 dsh 不在客人 shell 的默认 PATH 里,要么用 /root/projects/.dsh/dshbox/bin/dsh,要么先把该目录加进 PATH —— 否则 dsh ... | grep ... 会静默返回空(command not found 被管道吞掉),很容易被误判成"插件没被加载"。
工具 · 作用 termux_status · 监听状态、端口、已连 agent、累计任务数、最近错误、下一个命令走哪个执行面、客人工具链探测。排障第一步。 termux_bootstrap · 生成 Termux 侧启动命令、URL 与脚本正文(inline:false 可只要命令)。不跑也能执行命令。 termux_exec · 执行一段 bash 脚本正文。支持 cwd / backend / timeoutMs / maxBytes。 termux_run · 以 argv 数组执行命令(不经过 shell 二次解析,最安全)。 termux_push · 把内容写到目标路径(覆盖写,目录不存在自动建)。默认按 UTF-8 写文本,encoding: base64 可写任意二进制。 termux_pull · 读目标文件(默认最多 8 MiB)。文本原样回 UTF-8,二进制按 base64 回并显式标记。
所有工具的输出都是结构化对象,字段固定: ok / code / text / exitCode / elapsedMs / truncated / timedOut(+ 各自扩展字段, exec / run / push / pull 另有 backend 表示实际执行面;pull 另有 encoding / binary / bytes)。 机器判读读字段,不要解析 text。
termux_pull 的 content 有两种编码,靠 encoding 字段区分:
encoding · binary · content 是什么 · bytes utf8 · false · 文件原文(UTF-8) · 真实字节数 base64 · true · 原始字节的 base64 · 真实字节数(不是 content 的长度)
判定规则:不含 NUL 字节、且是合法 UTF-8 才算文本,否则一律按 base64 回传并置 binary=true。 截断正好切在多字节字符中间时,会从尾部最多回退 3 个字节找合法边界,不会把纯文本误判成二进制。
这条判定是 1.2.8 修的 bug:此前 DATA 帧走 decField(),它内部是 Buffer.from(field,"base64").toString("utf8"),非法字节被静默替换成 U+FFFD—— 2048 字节的文件读出来只有 1945 个字符,长度对不上,ok 却依然是 true。 所以 bytes 与 encoding 是两个独立事实:判断大小只看 bytes,解释 content 先看 encoding。 二进制内容不要直接当文本读。
写回去用 termux_push 的 encoding: 'base64',与 pull 的出口正好对齐,可以往返。
值 · 行为 留空或 auto(默认) · 有 agent 连接 → termux;没有 → local。这是日常最省心的用法。 termux · 强制走 Termux。没有 agent 时直接报 E_NO_AGENT,不降级 —— 这时候你要的是 Termux 的 uid / ~/storage / pkg,偷偷换成客人本地跑会得出错误结论。 local · 强制走客人本地 Debian,即使有 agent 连接。
一个容易忽略的细节:两个执行面看到的文件系统不一样。同一个路径 /tmp/x 在 local 里是 客人的 /tmp,在 termux 里是 Termux 的 /tmp;~/ 也是各自的。跨面读文件要用对应 backend 的 push / pull,不要以为写一次两边都能看见。
错误码:E_NO_AGENT(强制 termux 但没连上)、E_AGENT_BUSY(队列满/agent 忙)、 E_AGENT_TIMEOUT(等回包超时,远端可能仍在跑)、E_AGENT_GONE(连接中断,结果未知)、 E_TIMEOUT(命令被 timeoutMs 强杀,timedOut=true)、E_BAD_CWD(工作目录不存在,命令没有执行)、 E_INVALID_ARGUMENT(backend 写错 / encoding 不支持)。
cwd 指向不存在的目录时,命令根本不会执行,直接返回 E_BAD_CWD。 这个错误码是 1.2.8 新加的,此前只有 EXIT_97。
97 这个数字是本插件自己选的约定,不是 shell 的通用约定 —— 所以用户脚本 完全可能自己 exit 97,此时看退出码就分不清「路径写错了」和「命令真的返回了 97」。 1.2.8 的处理是把来源显式标出来:
情况 · code · 说明 cwd 不存在(新 agent) · E_BAD_CWD · agent 在 RESULT 末字段带 hint=cwd,来源确定 cwd 不存在(老 agent) · EXIT_97 · 老 agent 不发 hint 字段,只能退回看退出码 命令自己 exit 97 · EXIT_97 · hint 为空;text 里会点明「97 也可能是命令自己返回的」
所以拿到 EXIT_97 时不要一口咬定是路径问题,先看 text 末尾那句话,或确认 cwd 真的存在。 新老 agent 混装是正常状态(agent 是用户手动起的),两种来源都得容忍。
两个执行面用同一套语义,但机制不同:
timeout --preserve-status 直接跑用户脚本。--preserve-status会原样报出被跑命令的退出码,于是「脚本自己 exit 124」与「被 timeout 杀掉」本来就能从 $? 分开 (实测:自己 exit 124 → 124;被 timeout 杀 → 128+信号 = 143/137)。剩下唯一的歧义是脚本自己 kill -TERM 自己也是 143,所以再加一道判据:`code > 128` 且实际用时已经跑到时限附近 才归一成 exitCode=124 / timedOut=true,保证上层语义稳定。 没有 --preserve-status 的 timeout(BSD / macOS)自动降级到旧路径,只是超时码要额外归一。
然后统一归到 exitCode=124 / timedOut=true。
只看 $? 会把「命令自己 exit 124」和「被超时杀掉」混成一件事,这是本插件刻意绕开的坑。 1.2.9 之前 agent 侧是靠一个 runner.sh 把 $? 写进文件来区分的(三层 fork);现在这层已经去掉, 判据改由 --preserve-status 提供。
一行一帧,空格分列;所有可能含空格的字段一律 base64,空值写 -。 EXECRAW / PUTRAW 是唯一的例外:帧头行之后紧跟裸字节,不是纯行帧(见本节末尾)。
HELLO WELCOME | DENY EXEC EXECRAW PUT PUTRAW GET RESULT DATA PING -> PONG
约束:PROTO_VERSION=1、MAX_LINE_BYTES=16 MiB、MAX_SCRIPT_BYTES=256 KiB、RAW_CHUNK_BYTES=4 MiB。
RESULT 的第 8 字段 hint 是 1.2.8 新加的裸 ASCII 语义标记(不是 base64,普通路径写 -, cwd 不存在写 cwd)。它解决的是「97 是谁给的」这个问题,详见第 3 节的「cwd 不存在」小节。老 agent 不发这个字段, 解析时按空串处理即可 —— 这也是为什么上层必须容忍缺失。
MAX_LINE_BYTES 必须是载荷上限的 1.33 倍以上:base64 会膨胀 4/3。这曾经是个真 bug —— 帧上限和输出上限都设成 1 MiB,于是 maxBytes=1 MiB 时数据帧变成约 1.4 MiB > 1 MiB, 接收端直接 destroy,调用方看到的是莫名其妙的 E_AGENT_GONE。协议层上限必须显式大于载荷上限。
裸字节帧(`PUTRAW` 1.2.8 / `EXECRAW` 1.2.9) 与上面的行帧不是一个形状:帧头行以 \n 收尾, 后面紧跟 nbytes 个原始字节,没有任何分隔符。agent 读完帧头那一行就直接 head -c <&3 (EXECRAW 用 bash 内建 read -N,省掉两次 fork)落盘,不经过 base64。