dsh-plugin-mesh
peng456/dsh-plugin-mesh
通过 UDP 组播或端口扫描发现局域网内的其他 DSH 实例,在侧栏面板中列出它们以及各自上报的能力,并可经局域网或自动下载的用户态 Tailscale 节点在两台机器之间双向派发任务。
安装
dsh plugin --profile web add github:peng456/dsh-plugin-meshREADME
dsh-plugin-mesh
🌐 想先看看它长什么样、怎么用? 打开展示页:https://peng456.github.io/dsh-plugin-mesh/ (面向使用者、从零讲起;本 README 面向开发与排障。)
🤖 给 agent / 英文读者:
llms.txt(机器可读的事实:工具、配置项与默认值、端口) ·AGENTS.md(改这个仓库前的硬约束与坑)·README.en.md(英文快速上手)·DESIGN.md(设计原理与复盘)本 README 是完整中文文档,含设计取舍与踩坑记录,比较长。
DSH 的 Mesh 插件:启动后自动发现同一网段的其他 DSH 实例,在侧栏提供一个 操作页面显示各机器的在线状态,并支持把任务派给对端的 agent 执行。 不在同一网段也行 —— 内置用户态 Tailscale 节点,可跨互联网自组网,功能同级。
用起来就是你要的那个效果:在 A 机器上开发,把「部署到 B」这件事丢给 agent, 你只下大命令。
零配置:固定端口 + 自动扫描 + ping,装上就能发现彼此,不需要配对码。 首次打开 Mesh 页面会让你给这台机器起个名字(默认填主机名),别的机器就靠这个名字找你。 跨互联网只要在界面上填一次 Tailscale 授权码,不需要装 Go、不需要装 Tailscale 客户端。
┌──────── A 机器 (DSH) ────────┐ ┌──────── B 机器 (DSH) ────────┐
│ mesh 插件 :45917 │ │ mesh 插件 :45917 │
│ ├ UDP 组播心跳 ◀───────────┼──────────┼──▶ 被动发现 │
│ ├ 主动扫本网段 ────────────┼──────────┼──▶ 兜底发现 │
│ ├ ping 保活 ────────────┼──────────┼──▶ 在线状态 │
│ └ 侧栏 Mesh 页面 │ │ │
│ │───任务──▶│ 唤醒本机 agent 执行 │
│ │◀──回派───┤ │
└───────────────────────────────┘ └──────────────────────────────┘
进程版本 ≠ 磁盘版本 → [WARN] 新代码没被加载,检查 plugins 挂载
(不依赖 `node`:优先 `python3` 解 JSON,退回到正则抠 `version` 字段。)
##### ⚠️ 只许比 `version`;两条轴**互补,缺一不可**
**① 绝不能把 `capabilities` 拉进这个比对"求更严"。**
能力块(`gpu` / `runtimes` / `capabilitiesAt`)是**热刷新**的,与重启完全解耦:
实测同一个 pid 在不重启的情况下 `capabilitiesAt` 一路前进,而 `version` 纹丝不动。
把能力字段纳入比对会造成**反向误诊**。对端给过的反例:它进程是 0.3.27 旧码,
但"磁盘代码采出的 `gpu`"与"进程报的 `gpu`"**都是 `null`**,两者相等 → 检查会
误判成"新码已加载"。**能力相等什么都证明不了。**
**② 两条轴管的是不同的失败**:
| 轴 | 时机 | 兜住的失败 | 工具 |
| --- | --- | --- | --- |
| ① 可加载性 | 重启**前** | **半加载**:`index.js`/`package.json` 已是新版,但新 import 的文件没拷全 → 重启即崩 | `check-loadable.mjs`(真 import 一次入口) |
| ② 进程自述版本 | 重启**后** | **加载了但没重启**:磁盘换了、进程还是旧码 | `/mesh/v1/health` 的 `.version` |
`version` 检查**覆盖不到半加载**:那种情况下它读到的是**新**版本号(常量来自
`index.js`,而 `index.js` 已经换过了),于是**正确地**报新版本、检查**放行**,
可进程 import 时直接 `ERR_MODULE_NOT_FOUND` 崩。
所以只做 ② 会漏半加载,只做 ① 会漏"加载了但没重启"。
### 组播的作用范围:比"局域网"更窄
`239.255.42.99:45892` 是我在私有组播段里挑的(`239.0.0.0/8` 是 RFC 2365 定义的
管理范围,相当于组播里的"私有地址"),**TTL 设为 1**,所以:
- ✅ 同一网段(同一个二层广播域)内有效
- ❌ **路由器不转发** —— 跨子网/VLAN 到不了,那种情况要配 `scanRanges`
- ❌ **AP isolation 会连它一起挡**(那是把客户端之间的所有流量都隔离,不只是组播)
- ⚠️ 部分交换机的 IGMP snooping 在没有 querier 时会丢掉组播
- ⚠️ Windows 防火墙默认拦入站组播,要放行 UDP 45892
正因为组播有这么多坑,才加了扫描那条路:**只要 TCP 通,扫描就能发现**。
---
## 互联网自组网(Tailscale / tsnet)
局域网那套跨互联网时不成立:没有组播,也没有可扫的网段。所以插件内置了一个
**用户态 Tailscale 节点**,把发现与派发延伸到互联网,**功能与局域网完全同级** ——
tailnet 上找到的机器和局域网找到的机器出现在同一个列表里,`mesh_run` 用法一模一样。
### 终端用户零依赖
不需要装 Go,也不需要装 Tailscale 客户端。插件首次启用时按平台下载一个预编译的
sidecar 二进制(约 9MB gzip),缓存在 `~/.dsh/mesh/bin/`。
为什么必须自己编译:`tsnet` 是 Go 库(`tailscale.com/tsnet`),npm 上没有可用的绑定
(实测 `tsnet` 包是个无描述无仓库的空壳,`tailscale` 包是占位符)。所以由
`scripts/build-sidecar.sh` 交叉编译各平台产物,随 GitHub Release 发布。
**已发布的平台:**
| 平台 | 适用 | ELF 形态 |
|---|---|---|
| `darwin/amd64` | Intel Mac | Mach-O |
| `darwin/arm64` | Apple Silicon Mac | Mach-O |
| `windows/amd64` | Windows | PE |
| `linux/amd64` | Linux x86_64、WSL | 全静态 ET_EXEC |
| `linux/arm64` | ARM64 Linux、树莓派 | 全静态 ET_EXEC |
| `android/amd64` | x86_64 Android / 模拟器上的 Termux | **PIE(ET_DYN) + `/system/bin/linker64`** |
| `android/arm64` | aarch64 Android / 手机上的 Termux | **PIE(ET_DYN) + `/system/bin/linker64`** |
> **Android 是独立平台,不是 linux 的别名**(真机实测,2026-10)。
>
> 第一层坑是平台识别:Node 在 Termux 下 `process.platform` 返回的是 **`android`**,
> 不是 `linux`。早期 `detectTarget()` 只认 darwin/win32/linux,于是抛
> 「不支持的系统:android」。这个抛发生在 `binaryInfo()→binaryPath()` 里,而
> `binaryInfo()` 又被 `TsnetNode.status() → /mesh/api/state` 一路调用,于是**整个
> Mesh 页面变成 500**,表现为「本机 undefined · undefined:undefined」且互联网组网
> 卡片消失。这也是为什么 `binaryInfo()` 被改成绝不抛异常。
### ⚠️ Android 端必须手动加白名单,否则会被系统杀掉
**实测结论,不是猜测。** 一台 Android 设备:`up 7 days`、`MemAvailable 2.6 GB`、
`load average 0.00` —— 设备和内存都好好的,但它的 sidecar 进程 `etime` 只有
**2 分钟**。DSH 进程在反复被重建。
**不是 OOM,是 Android 的省电策略在主动冻结/杀掉后台 App。** 对 mesh 的后果:
| 机制 | 触发条件 | 后果 |
|---|---|---|
| **Doze(打盹)** | 屏幕关闭一段时间、未充电、未移动 | 暂停后台**网络访问**与定时任务 → 心跳发不出、ping 收不到,对端看它就像掉线 |
| **App Standby** | App 长期未被使用 | 同上,限制更严 |
| **厂商后台清理** | 小米 / 华为 / OPPO / vivo 自有策略 | **直接杀进程**,比原生激进得多 → 会话被中途打断 |
> 会话被打断**不算故障**:manager 会走"原会话已结束 → 新建会话并重绑同一会话键"
> 这条路径(见 `设备身份与多通道设计.md`)。只是调试噪声变多 —— 同一个会话键
> 可能先后绑定过几个 session。
**必须手动加白名单**,路径因厂商而异:
| 厂商 | 设置路径 |
|---|---|
| 原生 Android | 设置 → 应用 → DSH → 电池 → **不受限制** |
| 小米 / Redmi | 设置 → 应用设置 → 应用管理 → DSH → 省电策略 → **无限制**;再到「自启动」打开;最近任务里长按卡片**加锁**(防一键清理) |
| 华为 / 荣耀 | 设置 → 电池 → 启动管理 → DSH → 关掉「自动管理」→ 手动勾选 **允许自启动 / 关联启动 / 后台活动** |
| OPPO / vivo / 一加 | 设置里找「允许后台运行」「自启动」「电池优化白名单」 |
代价是多耗一点电;长期插电或常驻在线的设备几乎无感。
> 注意:**持久化 instanceId 已经让"重启"不再产生重名**(见「身份与命名」),
> 所以白名单要解决的是**会话连续性**,不是重名。
>
> 第二层坑更隐蔽:当时把 `android` 映射到 `linux` 资产就以为完事了,但 Android 从
> 5.0 起**只允许 PIE 可执行文件**,而给桌面准备的是全静态 `ET_EXEC`,真机 exec
> 阶段会被 bionic 直接拒掉,原话就一句(下载、解压、sha256 全部正常,一跑就退):
>
> ```
> error: "/data/data/com.termux/files/home/.dsh/mesh/bin/sidecar-v1/dsh-mesh-tsnet" has unexpected e_type: 2
> ```
>
> 与 glibc/bionic、`CGO_ENABLED=0` 与否、权限、防火墙都无关,**就是 ELF 类型不对**。
> 所以 `android/*` 单独出一份产物:`-buildmode=pie` 拿到 `ET_DYN`,再用
> `-ldflags=-I /system/bin/linker64` 把解释器指到 bionic。
>
> 只加 `-buildmode=pie` **不够**:Go(实测 go1.25.5)内部链接器产的 linux/arm64 PIE
> 带 `PT_INTERP=/lib/ld-linux-aarch64.so.1`,这个路径在 Android 上不存在,内核会以
> ENOENT 收场。必须显式改解释器路径。好在纯 Go 的 PIE 里 `DT_NEEDED` 为空、动态
> 符号表无未定义符号、重定位全是 `R_AARCH64_RELATIVE`(实测 113089 条),bionic
> 加载时只需刷一遍相对重定位,不依赖任何 libc —— 正是 Android 自家 PIE 的形态。
>
> 桌面 `linux/*` 则**刻意保持全静态 ET_EXEC**:给它加 PIE 会把解释器钉成
> `/lib64/ld-linux-x86-64.so.2`,Alpine 这类 musl 发行版没有该路径,等于用一点
> 安全加固换掉一整类系统。构建脚本里对 android 产物有硬校验(ET_DYN + 解释器 +
> TLS 对齐,任一不满足就直接拒绝发布),免得这些坑再溜出去。
>
> 第三层坑是 **TLS 段对齐**。Go 的**内部链接器**产 PIE 时把 `PT_TLS` 的 `p_align`
> 硬编码成寄存器宽度(`cmd/link/internal/ld/elf.go`: `ph.Align = ctxt.Arch.RegSize`,
> arm64 上是 8),而 ARM64 bionic 要求 ≥64:
>
> ```
> error: "…/dsh-mesh-tsnet": executable's TLS segment is underaligned:
> alignment is 8, needs to be at least 64 for ARM64 Bionic
> ```
>
> 外部链接器(NDK 的 lld)会按符号对齐算这个值,用内部链接器就得自己补,所以
> `scripts/fix-android-tls-align.py` 在编译后把 `p_align` 抬到 64(x86_64 抬到 16)。
> 这是安全的:TLS 块按 variant II 布局、**块尾贴着线程指针**,`p_align` 只影响块起点
> 留多少填充,符号相对块尾的偏移不变,Go 运行时算出来的地址一样。
>
> 第四层坑是 **argv 错位**。Android/Termux 上 exec 经常经 `/system/bin/linker64`
> 转发(`linker64 <程序> -hostname …`,这就是为什么一个**没有** `PT_INTERP` 的静态
> `ET_EXEC` 也会收到 bionic 的报错 —— 内核根本不会去调 bionic)。bionic 只在
> `__libc_init` 里把 `linker64 <程序>` 这两段摘掉,而**纯 Go 程序从不调用
> `__libc_init`**:它直接按内核初始栈读 argc/argv,于是 `os.Args[1]` 是程序路径。
> `flag.Parse()` 一遇到非选项就停止解析,**后面所有开关被静默丢掉**,看起来像
> "收不到任何参数"(`-version` 和故意写错的 `-zzz` 得到同一句报错;对照组 node/bash
> 正常,因为它们是 C 程序、走 `__libc_init`)。所以 `sidecar/main.go` 的
> `normalizeArgs()` 在 `flag.Parse()` 前把第一个以 `-` 开头的元素之前的东西丢掉。
>
> 第四层坑是 **Android 不给 netlink**。修好前两层后 sidecar 能启动了,但死在 tsnet
> 自己的启动阶段:
>
> ```
> {"event":"error","message":"tsnet: route ip+net: netlinkrib: permission denied","where":"start"}
> ```
>
> Android 对 app UID(untrusted_app)关掉了 `NETLINK_ROUTE` 与 `/proc/net`(同一身份下
> `/proc/net/route`、`/proc/net/if_inet6` 都是 EACCES,而 bionic 的 `getifaddrs` 正常),
> 而 Go 标准库的 `net.Interfaces()` 在 linux(android 也算 linux)上一律走 netlink,
> 于是 `netmon.New()` 直接失败。Tailscale 源码里对这件事有原话(`net/netmon/state.go`):
> "It exists because Android SDK 30 no longer permits Go's net.Interfaces to work"。
> 官方 Android app 靠 Java 注册接口表,**裸二进制没有 Java 可依赖**,所以官方提供了
> `tailscale.com/feature/androidbin`(包注释第一句就是"makes Tailscale binaries work
> when run as raw (non-GUI app) executables on Android, such as under Termux"):
> 注册 netmon 的接口回退(合成一个 `android` 接口,地址靠向内核要一条出站 UDP 路由拿
> 源地址,不发包)、拉进 `androiddns`、并把 CA 根证书指到
> `/system/etc/security/cacerts`。最后这条是我们还没走到但一定会踩的坑:**GOOS=linux
> 的二进制在 Android 上系统证书池是空的,控制面 TLS 会全挂**。
> tsnet 默认不引入它("tsnet apps and other programs opt in with a blank import"),
> 所以 sidecar 加了 `sidecar/androidbin_linux.go`(必须带 `linux` 构建约束,否则
> mac/win 构建会报 "build constraints exclude all Go files")。它在普通 Linux 上是
> 空操作(`onAndroid()` 为假)。
>
> 顺带说明为什么**不**改成 `GOOS=android`:`androidbin` 官方就支持 `GOOS=linux` 形态
> ("static linux binaries are commonly run under Android kernels"),而我们的
> `GOOS=linux` 产物已在真机上实测能加载、能解析参数;换 GOOS 会同时动掉运行时这一层,
> 收益不明。
>
> 第五层坑是 **DNS**。前四层修完后 sidecar 能起来、控制 API 可用,但节点一直停在
> `NeedsLogin`、`authUrl` 为空。原因:Android 没有 `/etc/resolv.conf`,Go 的纯 Go
> 解析器读不到配置就回退到 `127.0.0.1:53`(没人在听),于是每个查询都失败;而
> `curl` 走的是 bionic 的 `getaddrinfo`(经 netd),所以"网络明明是通的"。
> tailscale 的 `dnsfallback`(控制面解析兜底)走 DoH 打 `dns.google`/quad9,
> 在国内多半也不通,最后就表现为"控制面怎么都连不上"。
>
> tailscale 为此提供了 `feature/androiddns`(androidbin 会自动拉进来):它**不读系统
> 属性**,而是直接跟 netd 的 `/dev/socket/dnsproxyd` 说协议 —— 和 bionic 的
> `getaddrinfo` 同一条路。它更好(顺带拿到 per-network 配置与 Private DNS),
> 所以它接管了就不动。但它的前提是 `dnsproxyd` 连得上,连不上时它**静默什么都不做**。
>
> 于是 `sidecar/androidnet_linux.go` 补三件事:① 自检 —— 先解析
> `controlplane.tailscale.com`(建链最需要的那个名字),结果写进 `/status` 的 `diag`;
> ② 兜底 —— 解析失败时**扫系统属性**找 DNS(刻意不只看 `net.dns1..4`:实测那台华为
> ROM 上它们全空,只有 `net.rmnet0.dns1` 有值,所以按"键以 `dnsN` 结尾"匹配);
> ③ 再兜底 —— 属性里的都不通就用公共解析器(223.5.5.5 / 119.29.29.29 / …)。
>
> **候选集要逐个自检着换,不能在 `Dial` 里轮询**:UDP "连接"一台不可达的服务器不会
> 失败(只会让查询超时),`Dial` 永远返回第一个候选、后面的根本轮不到。自检本身跑在
> "控制 API 已监听之后、`srv.Up()` 之前",所以不会拖慢握手。
>
> 注意:`androiddns` **不读系统属性**,它走 `/dev/socket/dnsproxyd`(与 bionic 的
> `getaddrinfo` 同一条路)。二进制里 `net.dns*` 那些串是 Go 运行时的符号名
> (`net.dnsReadConfig`、`net.dnsStreamRoundTrip`…),跟 Android 属性无关。
>
> **真正的坑在解析器对象上**(真机日志定位):tailscale 的控制面解析**不走
> `net.DefaultResolver`** —— controlclient 用的是 `net/dnscache` 的 `Forward`,而
> `dnscache` 在**包初始化时自己 new 了一个** `&net.Resolver{PreferGo: …}`
> (`dnscache.go` 的 `single`),和 `net.DefaultResolver` 是两个对象。于是无论
> `androiddns` 还是我们,只要改的是 `net.DefaultResolver`,控制面就一点没沾上:
> 自检(走 `net.DefaultResolver`)通过,`TryLogin`(走 `dnscache.Forward`)却报
> `failed to resolve "controlplane.tailscale.com"`,随后退到 DERP bootstrap
> (裸 IP,被出口代理挡掉)→ 节点永远 `NeedsLogin`。
>
> 所以 `applyResolver()` 把 dial **原地**装到两个解析器上(`net.DefaultResolver`
> 与 `dnscache.Get().Forward`,后者是导出字段),而自检也改成打在
> `controlResolver()`(即控制面真正用的那个)上 —— 否则就是自欺欺人。
> 另外**只能原地改字段、不能替换 `net.DefaultResolver` 指针**:有代码在 init 时
> 就把那个指针存下来了,替换指针它们看不到。
>
> **安装时机同样致命**:`ensureDNS()` 必须早于任何可能启动 tsnet 的代码。真机
> diag 的先后顺序是 `[52] StartLoginInteractiveAs` → `[61] trying bootstrapDNS`
> → `[62] dns: …`,即第一次 `TryLogin` 已经失败,我们才装好解析器。原因是
> `watchStatus` 每秒调 `currentStatus()`,而它调 `srv.LocalClient()` ——
> tsnet 的 `LocalClient()` 内部就调 `s.Start()`,整套后端(含第一次登录)会在
> 一秒内起来。等装好时早已退到 DERP 裸 IP 的 bootstrap,在那台机器"按域名放行"
> 的出口代理下必然全灭。现在 `ensureDNS()` 在 `sc` 建好之后立刻执行,位于
> `go sc.watchStatus(ctx)` 之前;`srv.Up()` 之前还会再打一行 `dnsUpCheck()` 复查
> 控制面解析器的 `Dial` 状态与自检结果,下次一眼能看出有没有抢跑。
>
> 顺带把 tsnet 的日志口径放宽了:原来只放 `tsnet: ` 前缀且含 error/fail/warning 的行,
> 结果 DNS 卡住时 stdout 上一行线索都没有、只能靠 `/status` 猜。现在**全部**进 `diag`
> (容量 40→200 行),其中建链关键路径(control/dns/login/auth/derp/magicsock)实时推
> 到 stdout。
>
> 另外两件诊断工具(0.3.9 起):`dnscache.SetDebugLoggingEnabled(true)` 会把
> tailscale 自己的 dnscache 决策日志("using fallback resolver due to error/no returned
> IPs"、"not using bootstrap DNS: DNS was trustworthy")经 tsnet 的 Logf 流进 `diag`;
> `probeControlPlane()` 异步复刻"解析(`ip`/`ip4`) → 逐地址直连 :443 → TLS(SNI=域名)
> → GET /key?v=148"整条链,每步单独记一行。这样下次就能一锤定音地分清是解析失败、
> 连接失败还是 TLS 失败 —— 前几轮只能看到"退到 DERP 裸 IP",分不清这三者。
>
> 0.3.10 起再加一道**真正的兜底**:`feature/dnsresolvecache`。这是 tailscale 为
> "开机时 DNS 坏掉"设计的机制(dnscache 在**主解析失败或返回空**时会去读
> `<dir>/dns-<host>.json`),但官方明说 tsnet 默认不链接它、"tsnet apps must both
> blank-import this package and configure the cache directory themselves"。所以
> sidecar 现在 blank import 它、用 `dnscache.HookSetCacheDir` 把目录指到
> `<stateDir>/dnscache`,并在自检通过后把我们自己解析出来的
> `controlplane.tailscale.com` 写进 `dns-controlplane.tailscale.com.json`。
> 这样即使控制面的主解析再出问题,它也能从落盘缓存拿到 IP,不必退回 DERP bootstrap。
> 自己写这份文件是安全的:里面只是本进程刚解析并自检通过的 IP,连接时的证书校验
> 照旧(最坏情况与今天一样连不上)。格式已用 tailscale 自己的读入口
> (`dnscache.HookLookupDiskCache`)在本机验证过。
>
> **最后一跳的根因(0.3.11)**:`tailscale/net/netns` 的 linux 与 android 实现是
> 两个文件 —— `netns_linux.go` 的约束是 `linux && !android`,`netns_android.go`
> 是 `android`。GOOS=linux 编出来的产物走 linux 版 `controlC`:它对每个 socket 调
> `netmon.DefaultRouteInterface()` 拿默认路由接口名,而 Android 上 `/proc/net/route`
> 与 netlink 都是 EACCES,于是退化去绑 `"lo"`(或设 SO_MARK),**拨号因此出不去**。
> 真机判据很干净:sidecar 内部用裸 `net.Dialer` 直连 `192.200.0.x:443` 成功、完整
> HTTPS 拿到 200,而 tailscale 自己的拨号路径全灭 —— 两者唯一的差别就是这个
> `Control` 钩子。`netns_android.go` 的 `controlC` 在没有 Java 回调时**什么都不做**,
> 所以 android 资产改成 `GOOS=android` 编译(`CGO_ENABLED=0` 即可,不需要 NDK):
> 顺带把 netmon 换成 android 的 polling 实现、x509 走 `goos.IsAndroid` 分支读
> `/system/etc/security/cacerts`,而且该构建不发 `PT_TLS` 段,TLS 对齐那个坑也消失。
>
> 只堵平台映射还不够:`#spawnOwn()` 的第一行 `binaryInfo()/looksExecutable()` 在任何
> `try` 之外,而 `start()` 是**先把 phase 置成 `"starting"` 才调它** —— 异常 reject 到
> 调用方只留一条 warning,phase 就永远停在 `"starting"`,页面显示「正在组网中」永不结束
> (真机实测:`POST /mesh/api/tsnet/start` 返回 `{"ok":false,...,"node":{"phase":"starting"}}`)。
> 所以 `start()` 与 `#spawnOwn()` 各自再兜一层:任何早期失败都先落 `phase:"error"` 再抛。
> 自动重启路径(`#onExit` 的退避重试)直接调 `#spawnOwn()`、不经过 `start()`,这层兜底是它唯一的保障。
>
> 另外 `#spawnOwnInner()` 现在**无条件**调 `ensureBinary()`:只有它知道
> `PINNED_CHECKSUMS` 变没变(比对解压时旁挂的 `source-sha256` 标记)。原先外面套了
> `!installed || !looksExecutable()`,于是 sidecar 重新发布时那一步永远走不到,
> 老装置会一直复用旧二进制,**只有手动删缓存才会下载**(真机实测踩到)。
补新平台只要跑一次 `scripts/build-sidecar.sh --targets ... --publish`。
构建戳取的是 **sidecar 源码的 git 提交哈希**,而不是时间戳。因为 Go 构建是可复现的
(实测同输入连编两次逐字节相同),所以未改动的平台重编后 sha256 不变,**补新平台
不会让已装的机器白重下 8.5MB**;用时间戳则每次发版全平台哈希都变。
### 用法
侧栏 Mesh → 「互联网组网」:
1. 打开开关
2. 粘贴 Tailscale 授权码(`tskey-auth-...`)—— **只需输入一次**,存进 `state.json`
(权限 0600),重启不用再填
- 还没有授权码:先注册 / 登录 Tailscale(免费),再到
<https://console.tailscale.com/admin/settings/keys> 生成。
- **生成时必须勾 Reusable** —— 一次性码只能用一次,重启就会报 invalid key。
- 后台开了设备审批的,还要勾 Pre-approved。
- 插件里的「设置」直接给了这个地址,不用自己去后台翻。
3. Headscale 用户另填「控制服务器地址」,官方用户留空
节点名自动取机器名(`立朋-mac` → `dsh-mac`,`文超-win` → `dsh-win`),不用你填。
### 安全边界
进 tailnet 要授权码,进局域网要物理接入 —— 两者信任模型不同。所以留了
`tsnetPermissionPreset`:**留空 = 与局域网完全一致**(默认),想只收紧互联网那一侧
就填 `workspace-write` 之类。因为一旦进网,对方就能对你所有机器下 `mesh_run`。
### 网络要求(实测本机)
| 目标 | 结果 |
|---|---|
| `controlplane.tailscale.com` | ✅ 0.65s |
| `login.tailscale.com` | ✅ 0.63s |
| `derp1/2/3.tailscale.com` | ✅ ~0.8s |
| `github.com` 直连 | ❌ HTTP 000 |
所以 sidecar 二进制**默认走镜像下载**(`ghfast.top` → `gh-proxy.com` → … → 直连兜底),
实测 1.24MB/s,9MB 约 8 秒。完整性校验用的是**钉在插件源码里的 sha256**
(`lib/tsnet/checksums.js`),而不是从同一个 release 拉 `checksums.txt` ——
否则恶意镜像可以同时替换两者,校验就成了摆设。
---
## 身份与命名
每台机器有**三个**标识,分工不同:
| 标识 | 是什么 | 稳定性 | 用途 |
|---|---|---|---|
| **instanceId** | 持久 UUID(存在 `state.json`) | 同一 profile 内不变 | 内部用:去重、识别"这条是我自己发的" |
| **machineName** | 人可读的名字,如 `客厅的Mac` | 持久保存 | 你和 agent 用它指代这台机器 |
| **fingerprint** | OS 机器 ID 的 sha256 截断(见 `lib/identity.js`) | 按**物理机**不变 | 只用来**认领**:state.json 丢了/插件重装后,认出"这还是同一台机器" |
**为什么身份用 UUID 而不是 MAC**:一台笔记本同时有 Wi-Fi / 有线 / Thunderbolt 网桥 /
VPN,换网 MAC 就变;iOS / Android / macOS 现在默认按 SSID 使用随机 MAC;Android 上
也拿不到稳定 ID。用 MAC 当身份会让"切个网"变成"多出一台新设备"。指纹经过 sha256
截断后才上网,不可逆推硬件 ID。
**名字解析优先级**:插件配置 `machineName` > 用户首次启动时填的 > 系统主机名。
首次启动时(还没命名过、落在主机名上)Mesh 页面会主动问你一次:输入框预填主机名,
点「就这个」直接用,或改成你想要的名字。名字存在 `$DSH_HOME/mesh/state.json`
(和配对码同一个文件,权限 0600),**改完立刻重新广播**,对端不用重启就能看到。
### 指纹认领的边界(同一台机器上跑多个 profile)
指纹是**按物理机**算的,所以同一台 Mac 上的每个 DSH profile 指纹都相同。认领
因此有个必须的条件,否则两个都活着的实例会**互相删掉对方**(连自己都被删):
| 情况 | 是否认领 |
|---|---|
| 指纹相同 + **机器名相同** | ✅ 认领(重装/丢 state.json,名字一般不变) |
| 指纹相同 + 机器名不同 + **旧记录已离线** | ✅ 认领(重启残留,正是认领要解决的场景) |
| 指纹相同 + 机器名不同 + **两台都在线** | ❌ **不认领**(同机两个 profile,各自合法) |
| 指纹不同 | ❌ 绝不认领(不同物理机,重名交给重名告警) |
### 重名怎么办
名字是人手填的,可能撞。页面会检测并告警,派发时也不会随便挑一台:
- **从 A 看**:`--peer winbox` 只要在 A 的上游对端里唯一就能用(自己不算)
- **从第三台机器看**:如果它发现有两台都叫 winbox,`mesh_run` 会**拒绝**并列出候选,
让你改用 `<ip>:<port>` 精确定位
地址也接受实例 id 或 `ip:port`,所以撞名时总有办法精确指定。
---
## 安装
两台机器都装。**装完必须重启 DSH Desktop 才会加载。**
### 方式 A:从 npm 装(推荐,一条命令)
```bash
dsh plugin add dsh-plugin-mesh # desktop profile ← 要应用自带的终端
dsh plugin --profile web add dsh-plugin-mesh # 其它 profile ← 普通终端即可
为什么推荐这条:它不碰 git clone —— 而 github.com 的 git over https 在国内
会被 Connection reset(首次安装最常见的失败原因)。npm 走的是 registry,国内镜像
(npmmirror)有同步。实测在全新 profile 上 4.8 秒装完,48 个文件。
⚠️ 只有
desktop这一个 profile 例外:dsh plugin --profile desktop ...在 普通终端里会被拒绝(bin.js:29:profile "desktop" is managed exclusively by the Electron application),必须用应用自带的那一个(托盘/菜单 → 终端)。 其它 profile 用普通终端就行(上面第二条已实测)。
方式 B:一键脚本(从源码装)
git clone https://github.com/peng456/dsh-plugin-mesh.git
cd dsh-plugin-mesh
./install.sh
脚本做两件事,且可重复执行(已装就跳过):
- 把插件复制到
~/.dsh/profiles/desktop/node_modules/dsh-plugin-mesh/ - 把它加进该 profile
package.json的dsh.profile.bundles(改前自动备份为package.json.bak)
常用选项:
./install.sh --dry-run # 只打印会做什么,不落盘
./install.sh --profile web # 装到别的 profile
./install.sh --home /path/to/.dsh # 指定 DSH_HOME
./install.sh --uninstall # 卸载(删目录 + 从 bundles 移除)
方式 C:手动装
# macOS
cp -R dsh-plugin-mesh ~/.dsh/profiles/desktop/node_modules/dsh-plugin-mesh
# Windows (PowerShell)
Copy-Item -Recurse dsh-plugin-mesh "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-plugin-mesh"
然后在 ~/.dsh/profiles/desktop/package.json 的 bundles 里加上 "dsh-plugin-mesh":
{
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-plugin-mesh"],
"patchReload": "live"
}
}
}
⚠️ 必须是真实目录,不能是软链。 DSH 的 profile 包解析器会拒绝 realpath 落在
<profile>/node_modules之外的包(module-resolution-*.js里那条判断, 目的是防止陈旧的外部状态覆盖已封装安装)。把插件软链到你的工作目录会在启动时 报ERR_MODULE_NOT_FOUND。install.sh已经处理好了这一点。
方式 D:官方命令 + 本地路径
托盘/菜单 → 终端:
dsh plugin add /path/to/dsh-plugin-mesh
注意 dsh plugin --profile desktop 在普通终端里会被拒绝
(bin.js:29:profile "desktop" is managed exclusively by the Electron application),
必须用应用自带的那个终端。
升级已安装的插件
release-plugin.sh --publish 发完版会自动同步到局域网镜像并打印一段可直接
粘贴到对端的命令。对端(尤其是 Android,见下面的 /tmp 坑)执行后必须重启 DSH
才会加载新版本。
手工步骤就是:下载 → 解压 → 备份 → 覆盖 → 重启。
# 对端可写临时目录(Android 用 $TMPDIR,见下)
T="${TMPDIR:-$HOME/tmp}"; mkdir -p "$T"
curl -fL -o "$T/mesh-0.3.26.tar.gz" http://192.168.1.6:8791/dsh-plugin-mesh-0.3.26.tar.gz
mkdir -p "$T/mesh-new" && tar xzf "$T/mesh-0.3.26.tar.gz" -C "$T/mesh-new"
grep -m1 '"version"' "$T/mesh-new/package.json" # 应显示目标版本
PLUGIN="$HOME/.dsh/profiles/<profile>/node_modules/dsh-plugin-mesh"
cp -R "$PLUGIN" "$PLUGIN.bak"
rm -rf "$PLUGIN/lib" "$PLUGIN/client" "$PLUGIN/scripts" "$PLUGIN/sidecar"
cp -R "$T/mesh-new/lib" "$T/mesh-new/client" "$T/mesh-new/scripts" "$T/mesh-new/sidecar" "$PLUGIN/"
cp "$T/mesh-new/package.json" "$T/mesh-new/cordis.patch.yml" "$PLUGIN/"
# 然后重启 DSH
先 rm -rf 那几个子目录再拷,是为了不让上一版删掉的文件残留下来。
⚠️ 重启前必须先验「可加载」,不能只验「文件对得上」
升级是先落盘、后重启两段式的,中间有一段半态窗口:磁盘已是新版本,
进程还是旧版本(VERSION 是启动时读入的常量,不跟着磁盘变)。这个窗口里如果
新代码新增了一个 import 而落盘漏了那个文件,重启会在 DSH 加载插件时当场崩。
两种常规自检都兜不住这个失败模式:
| 自检 | 为什么兜不住 |
|---|---|
find + 逐字节 cmp | 被漏掉的文件根本不在磁盘上,没有"不一致"可比对,只会报全部一致 |
node --check <file> | 只做单文件语法解析,不解析 import 图,缺文件照样通过 |
唯一能精确复现"重启即崩"的,是从安装路径实际 import 一次入口:
PLUGIN="$HOME/.dsh/profiles/<profile>/node_modules/dsh-plugin-mesh"
# ① 一条命令版(不依赖安装路径里有脚本,任何机器都能跑)
node -e "(async()=>{const m=await import('file://$PLUGIN/lib/index.js');console.log('OK',Object.keys(m))})()"
# ② 仓库里的封装命令(打印版本/exports,退出码可直接当门禁)
npm run check:deploy -- "$PLUGIN" # 严格模式:面向"落盘后的安装路径"
npm run check:loadable # 仓库自检:忽略"缺运行时依赖"这一类误报
退出码 0 = 可加载,非 0 = 别重启,先回滚。check:loadable 已挂在
npm run check 末尾,发版时就会跑到。
⚠️
check-loadable.mjs不能cd "$PLUGIN"再跑 —— 升级只覆盖lib/client/sidecar/scripts,但你手上这份安装目录未必带scripts/(我的就没有), 于是node scripts/check-loadable.mjs会报Cannot find module。那是"脚本不在 那儿",不是半态。所以校验脚本要放在仓库里、把安装路径当参数传;或者用 上面 ① 那行node -e,它不需要安装目录里有任何脚本。⚠️ 校验仓库副本时缺
@deepseek-ai/dsh-brand是正常的(那是 DSH 运行时提供的 依赖,只有安装路径才解析得到),别误判成半态 —— 这就是check:loadable要加--allow-missing-runtime-dep的原因。
这一步是拿真实事故换来的:0.3.28 新增了
lib/prompt.js并由index.js顶层 import 它,磁盘一度处于"新index.js+ 缺prompt.js"的半态 ——cmp全绿、--check全绿,差点带着它重启。由对端 hos 复现并给出这条做法,实测有效。
回滚就是 cp -R "$PLUGIN.bak" "$PLUGIN",再重跑一次上面的校验。
⚠️ Android 上没有
/tmp(根文件系统只读,mkdir /tmp报Read-only file system)。给 Android 的命令必须用$TMPDIR,否则你会看到curl: (23) client returned ERROR on write—— 一个看起来像网络故障、 实际是"盘不存在"的错误。
Windows 防火墙
New-NetFirewallRule -DisplayName "DSH Mesh" -Direction Inbound -Protocol TCP -LocalPort 45917 -Action Allow
New-NetFirewallRule -DisplayName "DSH Mesh (multicast)" -Direction Inbound -Protocol UDP -LocalPort 45892 -Action Allow
只放行 TCP 也行 —— 那时靠主动扫描发现,不依赖组播。
使用
侧栏会出现一个 Mesh 图标(三个节点连成的网状),点开是主页面:
- 本机信息:机器名、地址、端口、当前发现方式与配对要求
- 发现的机器:每台一行,在线绿点/离线灰点,显示地址、系统、用户、最后心跳; 每行「探测」和「派发任务」
- 扫描本网段 / 重新广播 按钮
- 派发任务:选目标机器 → 填任务 → 派发
- 任务记录:收到 / 派出的任务
- 配对码(折叠,默认不需要)
agent 多出四个工具:
| 工具 | 作用 |
|---|---|
mesh_peers | 列出发现的机器 |
mesh_scan | 主动扫本网段,找还没发现的机器 |
mesh_run | 把任务派给某台机器的 agent(异步) |
mesh_tasks | 查看收到/派出的任务记录 |
所以你可以直接说:
「看看局域网里有哪些机器,然后让 B 把 web 服务重新部署一下,跑完告诉我。」
配置项
默认已经可用。要改就在 profile 的 cordis.patch.yml 里按 id 覆盖。
⚠️ patch 是「整体替换 config」,不是按 key 合并。
@deepseek-ai/cordis-plugin-include对非 insert patch 走的是浅赋值 (target[key] = value,见该包src/index.tsL121-124)。config只是 一个 key,所以只写enabled:/tsnetEnabled:这几行,会把原本写在 这一块里的permissionPreset/workspaceRoot/port/ 限速参数 全部抹掉,回落 schema 默认值。实测过的后果(对端 文超-win 2026-10-16 拦下):Windows 上
permissionPreset回落workspace-write(L139)+workspaceRoot回落''→ 主目录(L141),ACL 沙箱直接 fail-closed (Windows ACL temp root must be outside the workspace), 该机所有入站 mesh 任务的命令执行全挂 —— 包括刚从 tailnet 进来的, 因为 tsnet 读的是同一个值(tsnetPermissionPreset || permissionPreset,L692/L714)。所以:目标机器上已经有
- id: mesh时,永远不要整块替换,只合并要改的键。 用scripts/mesh-tsnet-merge.py(文本级键合并,保注释/缩进/行尾/BOM,幂等):python3 scripts/mesh-tsnet-merge.py --file <cordis.patch.yml> \ --download-source http://192.168.1.6:8791/ # 预览,不写盘 python3 scripts/mesh-tsnet-merge.py --file <cordis.patch.yml> \ --download-source http://192.168.1.6:8791/ --apply # 落地,自动留 .bak
参考全量键列表(照抄前请先确认目标机上没有既有的 - id: mesh 自定义):
- id: mesh
config:
machineName: '' # 对端列表里的名字,留空用主机名
port: 45917 # 固定端口
useMulticast: true # 组播被动发现
scanOnStart: true # 启动扫描(带退避重试)
scanIntervalMs: 60000 # 周期扫描;0 = 关闭
scanRanges: [] # 额外网段,如 ["192.168.1.0/24"];空则扫本机 /24
scanPorts: [] # 除本机端口外额外要扫的端口
scanConcurrency: 64
scanTimeoutMs: 800
pingIntervalMs: 10000 # ping 保活;0 = 关闭
announceIntervalMs: 5000
peerTtlMs: 20000 # 多久没消息算离线
requirePairing: false # 见下(也可以用页面上的开关,不必写配置)
agentPreset: standard
permissionPreset: workspace-write
workspaceRoot: ''
# ── 互联网自组网(Tailscale / tsnet)──
tsnetEnabled: false # 也可以在 Mesh 页面上的开关里改(存 state.json)
tsnetAuthKey: '' # 建议只在页面填;配置里填则优先且钉死
tsnetControlUrl: '' # 留空 = 官方 Tailscale;Headscale 填自己的
tsnetHostname: '' # 留空 = 由机器名派生(立朋-mac → dsh-mac)
tsnetDownloadSources: [] # 自定义下载源前缀,可用 {url} 占位符
tsnetPermissionPreset: '' # 留空 = 与 permissionPreset 完全一致;填了就只对 tailnet 生效
# ── 循环限速 ──
maxWakesPerThread: 30 # 每窗口内最多自动唤醒几次;0 = 不限制
threadWindowMs: 3600000 # 窗口长度(毫秒),默认 1 小时
threadCooldownMs: 20000 # 两次自动唤醒的最小间隔;0 = 不限制
minTaskChars: 24 # 正文短于此长度只入箱不唤醒(挡 ping/ack)
循环限速(重要)
为什么需要:入站任务会自动新建会话并立刻让 agent 开跑,而每条消息都由 远端决定。两个 agent 互相回执时会形成自持的 ping-pong —— 实测一个互回执线程 在 2.5 小时内自动开了 54 个会话、约 800 万 token,全部是重复探索。
四道闸门,任何一道触发都只入箱、不唤醒(任务仍可在 /mesh/v1/tasks 和
Mesh 页面看到,不会被丢弃):
| 闸门 | 默认 | 作用 |
|---|---|---|
minTaskChars | 24 | 太短的内容不开会话 |
| 寒暄识别 | 内置 | ping / ack / 收到 / 好的 等整条即寒暄的,不开会话 |
threadCooldownMs | 20s | 同一 thread 不会连续秒回,管突发 |
maxWakesPerThread | 30 | 同一 thread 在 threadWindowMs 内的唤醒上限,管持续速率 |
maxWakesPerThread 是滑动窗口限速,不是终身配额:只统计最近
threadWindowMs(默认 1 小时)内的唤醒。窗口内的唤醒点会随时间自然过期,
该 thread 自动恢复,不需要重启。
⚠ 限速只会让循环变慢,不会让它停。 如果循环的自然节奏低于上限, 窗口永远填不满,限速就永不触发 —— 实测事故节奏约 21 轮/小时,即使把上限 压到 10/小时,24 小时也仍会放行 276 轮。要真正止住请把上限设得低于 循环节奏,或配合会话复用把单价降下来。
撞上限时交给用户决定
限速触发时不静默丢弃,而是在 Mesh 页面出现一张提示卡:
⚠ 有 thread 撞到唤醒上限,已暂停自动唤醒
diet-reconcile 上限 30 次 / 60 分钟 · 已挡下 12 条
[提高上限并放行最新一条] [忽略]
- 提高上限 → 把该 thread 的上限翻倍(至少 +10),并立刻放行最新那一条 (对话的最新一环)。其余被挡的仍留在收件箱 —— 一次性全放会造成新突发, 反而失去限速的意义。覆盖值落盘,重启后仍有效。
- 忽略 → 只清提示,上限不变,任务仍留在收件箱。
对应的 API:POST /mesh/api/throttle/raise {thread, cap?}、
POST /mesh/api/throttle/dismiss {thread};被挡的 thread 列表在
GET /mesh/api/state 的 throttled 字段里。
账本与覆盖值落盘在 ~/.dsh/mesh/threads.json,重启不清零 —— 否则对端
只要等到你重启就能重新灌满窗口。
另外两点配套改动:
- 同 thread 复用会话:同一
thread的后续消息注入同一个会话,不再每条 从零重新探索。这是省 token 的最大一项,也让 agent 记得前几轮结论。 noWake: true:发送方可以显式声明"这条只回执,别开跑"。mesh_run工具已带这个参数 —— 纯回执/确认请用它,否则会凭空开一个 agent 会话。
已知绕过:限速只认 thread 名字,而名字由发送方自由填写。对端若每次都
换 thread 名或不带 thread,就不受限速约束(此时只有 threadCooldownMs 的
突发冷却在起作用)。它是刹车,不是安全边界。
安全
默认不需要配对 —— 同一内网里任何发现到本机的 DSH 都能派发任务。
对本机 agent 而言,这些任务会被当成一条用户消息(默认权限 workspace-write:
能读写工作区、能执行命令)。
这在自己家的可信内网里是合理取舍,也正是你要的"零配置"。但如果你在 不完全可信的网络里(合租、公司、宿舍、有访客设备、IoT 设备多),请打开:
- id: mesh
config:
requirePairing: true
然后在两台机器的 Mesh 页面里互相对一下配对码(首次启动自动生成,页面上可直接复制)。
打开后所有控制接口都要过配对码,用 timingSafeEqual 比较。
其他:
- 发现是公开的:同一网段任何人都能看见机器名、地址、系统、用户名。这是有意的取舍。
- 明文 HTTP:局域网内可接受;跨不可信网络请套 SSH 隧道或 TLS 反代。
已知限制
- 同 thread 复用会话,但跨 thread 不复用:同一
thread的后续消息会注入 同一个会话;没带thread的任务仍然每次新建。(早于 2026-09 的版本无此复用, 每条消息都新建会话。) - 去重只在进程生命周期内(内存),重启后同 id 会重复入箱。
- 没有离线排队:对端离线时
mesh_run直接失败,不暂存重投。 (要这个能力可以用工作区里的lanmsg.py。) - 同机多实例:第二个实例端口被占会退回随机端口,主动扫描扫不到它(组播仍能发现)。
真要同机多实例,给它们配不同
port和对方的scanPorts。 sidebar.panellist槽位官方插件没人用过(翻遍 58 个客户端 bundle,只有官方侧栏 自己声明它)。我按源码契约接的,实测注册和渲染都正常,但不如sidebar.footer.action那样有第三方先例。- Android 上 sidecar 需要专用资产,且下载源仍是拦路虎:
detectTarget()现在把android映射到android资产(PIE +/system/bin/linker64,见上文 Termux 的坑)。 原先那份全静态linux/arm64在真机上 exec 会被 bionic 拒掉(has unexpected e_type: 2), 所以老版本插件 + 新 sidecar 或新插件 + 老 sidecar 都不行,两边要一起升。 剩下的真正拦路虎是下载源:实测那台 Android 上 ghfast.top / gh-proxy.com / ghproxy.net / github.com 全部不通,只能靠同网段的镜子(见下一节)。装好之后 sidecar 本身可以正常跑。 平台不支持不该拖垮页面:binaryInfo()只返回{installed:false, error}从不抛异常,TsnetNode.status()外再兜一层 try/catch,/mesh/api/state在 Android 上仍为ok:true。
逃生通道:用同网段的机器当 sidecar 镜子
当一台机器的所有下载源都不通(被墙、DNS 污染、无外网)时,把已经拿到资产的机器
当镜子即可 —— 校验和钉在 checksums.js 里,明文 http 改不了内容:
# 在能拿到资产的机器上(仓库内 dist/sidecar/ 已有构建产物时)
python3 ../lan-sidecar-mirror.py --host 0.0.0.0 --port 8791 # 见仓库外的 lan-sidecar-mirror.py
# 先验证这台机器「按对方的平台」真的装得上(不碰本机真实缓存)
node scripts/verify-lan-mirror.mjs --url http://192.168.1.6:8791/ --platform android --arch arm64
对端(被墙的那台)在插件配置里加上:
"tsnetDownloadSources": ["http://192.168.1.6:8791/"]
💡 只有
tsnetDownloadSources是「非写进 config 不可」的键。tsnetEnabled/tsnetAuthKey/tsnetControlUrl/tsnetHostname都有 state.json 兜底(effectiveTsnetConfig,L328-339):在 Mesh 页面填一次就行, 存state.json(0600),重启不用再填,也不碰 patch 文件 —— 自然绕开了 「整块替换」这个坑。tsnetDownloadSources没有兜底(manager.js只读this.#config),所以它必须合并进 config。于是新机器接入 tailnet 的最小变更是:
- Mesh 页面开开关 + 填授权码(→ state.json,无需重启、无需改文件)
- 只有需要局域网镜子时,才用
scripts/mesh-tsnet-merge.py合并tsnetDownloadSources这一个键历史教训:曾给对端发过一份只含 5 个 tsnet 键的「贴进去就行」片段, 对端识别出它会抹掉自己 22 个键里的
permissionPreset: danger-full-access而改成手工合并,避免了一次入站任务全挂。别再发片段式模板。
源按顺序试:用户配置的源 → 内置镜像 → 直连 GitHub;http:// 与 https://
都支持(只 import node:https 的话,http 源会直接 ERR_INVALID_PROTOCOL,
而被迫用局域网源的机器恰恰最没法自己排查)。镜像按「完整 GitHub URL 拼在前缀后」
的形状请求,所以镜子只要忽略路径、按文件名给文件就行。
开发与测试
# 搭隔离环境(把插件真实复制进 profile,不碰你的 desktop profile)
MACHINE=macbox PORT=45917 MULTICAST=false SCAN_PORTS="[45918]" \
./dev-setup.sh meshtest /tmp/mesh-dsh
# 启动(dev-boot.mjs 复现真实主进程的模块解析行为)
DSH_HOME=/tmp/mesh-dsh ELECTRON_RUN_AS_NODE=1 \
"/Applications/DSH Desktop 2.app/Contents/MacOS/DSH Desktop" \
dev-boot.mjs --profile meshtest --port 8877
dev-setup.sh 支持的环境变量:MACHINE PORT MULTICAST SCAN_ON_START
SCAN_PORTS PING_INTERVAL_MS REQUIRE_PAIRING。
为什么需要 dev-boot.mjs:desktop-cli.js:78 只在 app.asar 内才安装 profile
包解析器,而这个安装是未打包的 Resources/app。真实 Electron 主进程
(main.js:4478)是无条件安装的,所以真实应用没这个问题,只有 CLI 路径有。
发现模块可以脱离 DSH 单独测:
node lib/_selftest-discovery.mjs alpha 45900 9000 # 两个进程同时跑可验证互相发现
文件结构
dsh-plugin-mesh/
├── package.json # dsh.bundle.patch + dsh.client 声明
├── cordis.patch.yml # 只插入本插件(含默认配置)
├── lib/
│ ├── index.js # 主机侧:发现编排 + 对端 HTTP + agent 工具 + UI 接口 + 入站唤醒
│ ├── discovery.js # UDP 组播被动发现 + 对端注册表 + 过期清理 + 指纹认领
│ ├── identity.js # 持久 UUID 身份 + OS 机器指纹(只用于认领)
│ ├── conversation.js # 会话键 + 回信地址(两端各自算,不协商)
│ ├── capabilities.js # 能力采集/净化/摘要(Placement 第 1 步)
│ ├── scan.js # 主动扫描(固定端口 + CIDR 展开)
│ ├── tsnet/ # 用户态 tailscale 节点(sidecar 下载/管理/桥接)
│ └── _selftest-*.mjs # 回归脚本(默认不碰网络)
├── sidecar/ # Go 源码:tsnet 用户态节点
├── client/
│ └── client.js # 浏览器侧:手写 bundle,无需打包工具
├── dev-setup.sh # 隔离测试环境搭建
├── dev-boot.mjs # 带模块解析器的测试启动器
├── DESIGN.md # 设计原理与复盘(为什么这么设计、踩过哪些坑)
├── Placement设计.md # 步骤 ③ 设计 + 实施记录
├── 设备身份与多通道设计.md # 步骤 ① 设计
└── README.md