npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-plugin-mesh

v0.5.8

Published

DSH Mesh:局域网自动发现同网段的 DSH 实例、显示每台机器上报的能力,并双向派发任务;可用内置的用户态 Tailscale 节点跨互联网组网。Discovers other DSH instances on the LAN, shows the capabilities each one reports, and dispatches tasks between machines in both directions, with an optional userspace Tailscale n

Readme

dsh-plugin-mesh

🌐 想先看看它长什么样、怎么用? 打开展示页:https://peng456.github.io/dsh-plugin-mesh/ (面向使用者、从零讲起;本 README 面向开发与排障。)

🤖 给 agent / 英文读者:llms.txt(机器可读的事实:工具、配置项与默认值、端口) · AGENTS.md(改这个仓库前的硬约束与坑)· README.en.md(英文快速上手)· DESIGN.md(设计原理与复盘)

🛠 想参与开发:CONTRIBUTING.md(提交规范、会被挡下的写法)—— 合并进 main 就会自动测试、发 npm 包和 GitHub Release,你不需要碰版本号。

本 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:一台机器,多条通道

设备身份只有一个(同一个 instanceId、同一个机器名),但它可以同时有多条 通信通道:局域网,以及任意多条 tailnet。每把 tskey 对应一个 tailnet,插件为 每条通道各起一个用户态 Tailscale 节点(sidecar),各自使用独立的节点状态目录。

为什么需要多通道:一个人可能同时有个人 tailnet、公司 tailnet、自建 Headscale; 一台机器可能既要在家里被发现,又要在公司网络里被发现。以前只支持单通道,等于 逼用户在这些网络里二选一。

界面上的操作(侧栏 Mesh → 互联网组网):

  • 「添加通道」填名称(可选)+ 授权码(+ 可选的 Headscale 控制服务器)→ 保存后立即启动。每张通道卡片独立显示状态、tailnet 地址、授权链接。
  • 「断开 / 连接」只影响这一条通道;其它通道和局域网照常工作。
  • 「删除通道」会停掉对应 sidecar;删除最后一条等于关闭跨网络组网(旧版单通道字段也会一并清掉)。
  • 通道 id 由宿主生成(ch-xxxxxxxx),改显示名不会改变设备身份。

发现与派发的行为:

  • 每条通道各扫一遍 sidecar 的节点表,命中 /mesh/v1/health 的设备进入同一个 设备列表;一台设备在 N 个 tailnet 里就是 N 条 tailnet 端点。
  • 端点按通道分别记健康状态:某条 tailnet 断了不会让设备离线,也不会连坐其它通道。
  • 发送时按「局域网 → tailnet 通道」逐个失败切换;两条 tailnet 的 100.x 网段即使 重叠也不会互相覆盖(端点身份是通道,不是地址)。
  • 本机名片会带上每条通道的 tailnet 地址(tailnetIps[]),对端在局域网通着 的时候就能提前记下备选地址,断网后直接切。

配置文件的等价写法见下面的 tsnetChannels;手写配置时务必显式给 id (稳定标识,决定 tailnet 里的设备身份),只写 label 会按 label + 控制服务器推导, 改名等于重新注册一台设备:

tsnetChannels:
  - id: personal
    label: 个人
    authKey: tskey-auth-...
  - id: company
    label: 公司
    authKey: tskey-auth-...
    controlUrl: https://headscale.example.com

配置文件里一旦出现非空的 tsnetChannels,界面就只能看不能改(部署方钉死), 与其它配置项的规则一致。旧的单通道字段(tsnetEnabled / tsnetAuthKey / tsnetControlUrl / tsnetHostname)继续可用:没有通道列表时它们合成一条 id = default 的通道,并沿用升级前的 sidecar 状态目录 —— 升级不会丢设备身份。

安全边界

进 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 看:mesh_run 的 peer 填 winbox,只要在 A 的上游对端里唯一就能用(自己不算)
  • 从第三台机器看:如果它发现有两台都叫 winbox,mesh_run 会拒绝并列出候选, 让你改用 <ip>:<port> 精确定位

地址也接受实例 id 或 ip:port,所以撞名时总有办法精确指定。


安装

两台机器都装。装完必须重启 DSH Desktop 才会加载。

方式 A:从 npm 装(推荐,一条命令)

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

脚本做两件事,且可重复执行(已装就跳过):

  1. 把插件复制到 ~/.dsh/profiles/desktop/node_modules/dsh-plugin-mesh/
  2. 把它加进该 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), 必须用应用自带的那个终端。

升级已安装的插件

scripts/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 | 查看收到/派出的任务记录 | | mesh_switch | 一键关闭 / 打开 mesh(全部 / 局域网 / 互联网) |

所以你可以直接说:

「看看局域网里有哪些机器,然后让 B 把 web 服务重新部署一下,跑完告诉我。」

聊天窗口 @ 设备

在任意会话的输入框里打 @,菜单会列出当前在线的 mesh 设备(和文件、会话 引用在同一个菜单里,按 Mesh 设备 分组)。选中某项只会插入 @机器名 这段文本, 真正的派发发生在你按下回车之后:宿主认出消息开头的 @目标,把正文直接发给那台 机器,回执再回到当前这个会话。

@文超-win 把 web 服务重新部署一下,跑完告诉我
  • 只认开头的 @。 @机器名 出现在正文中间不会自动派发 —— 那多半只是"提到某台 机器",交给本机 agent 自己决定要不要用 mesh_run。
  • 可以一次 @ 多台:@mac @hos 都看一下磁盘 会给每台各发一条,各自一个 thread, 回执不会串线。
  • 回执一定落回当前会话:thread 在 HTTP 之前就绑到当前会话上,对端回执会作为一条新 消息进入这场对话(会话没开也会被恢复)。对端 agent 常把自己的结果也标成「回执」 (noWake)—— 人发起的派发的回执不受 noWake 限制,照样投递,否则你等着的答案 只会躺在收件箱里显示「仅记录」。
  • 失败一定说出来。 离线 / 同名歧义 / 本机 mesh 已关闭 / 对端 500,都会把原因写进 这一轮的消息里,并明确「没有在本机执行」—— 不会静默丢包,也不会让 agent 顺手在 本机把远端任务做一遍。
  • 文件引用不受影响。 开头的 @路径(比如 @AGENTS.md)只有在该名字刚好是某台 机器的名字时才算派发目标;@"带空格路径" 一律让给文件引用。
  • 不想要这个行为就把 atDispatch 设为 false(见「配置项」),@ 会退回普通文本。

实现分两半:浏览器侧 client/client.js 注册一个 @ 源(候选 = 在线设备); 宿主侧 lib/index.js 的 agent/pre-step 调用 lib/at-mention.js(纯逻辑,单测 覆盖)认出开头的 @目标并派发。

一键关闭(kill switch)

三个范围,两处入口:主页概览卡上的一排胶囊(一键关闭 · 全部 / 局域网 / 互联网, 不展开、不跳页,状态直接写在上面),以及 连接 → 一键关闭 的详细区块(带监听地址、 组播状态与偏差说明)。agent 用 mesh_switch 调用:

| 范围 | 关掉之后会发生什么 | |---|---| | 全部 Mesh | 局域网与互联网一起关:不广播、不扫网段、不接受任务、tailnet sidecar 全停 | | 局域网 Mesh | 停发组播、停止扫描与 ping;对端端口从 0.0.0.0 收回 127.0.0.1。互联网通道(如果有)继续可用 | | 互联网 Mesh | 停掉全部 tailnet sidecar;通道与授权码保留,再打开即恢复,不用重新登录 |

几个刻意的取舍:

  • 关闭立即生效,且跨重启保持。 状态写在本机 state.json 的 shutdown 字段, 不是插件配置 —— 重启 DSH 后不会自己悄悄恢复。改配置文件那个 enabled: false 是另一回事:它在加载期就把整个插件摘掉,连面板都没有了,也就不可能从界面打开。
  • 面板始终可用。 一键关闭只停三条对外路径,/mesh/api/* 照常工作, 否则就成了"一键把自己锁在外面"。
  • 局域网关了但互联网还开着时,对端端口只绑回环。 tailnet 的入站是 sidecar 反代到 127.0.0.1 的,所以这条路径不受影响;而局域网里的机器连端口都连不上。
  • 说得到就要做得到。 如果重绑回环失败(端口被占等),界面会显示一行偏差说明, 不会只显示"已关闭"就完事。

关闭后,"发现 0 台机器"是正常现象,不是故障。顶部横幅会一直提示当前是 哪一条被关了。同类误判在这个项目里出现过:把"没有答"当成"没有"。

互联网一直显示"正在启动"怎么办

不会了。启动超过 75 秒还没拿到 tailnet 地址、也没有授权链接时,状态会变成 启动受阻,并把 sidecar 自己报的登录错误和一句可执行提示一起显示出来, 旁边的按钮变成「重试连接」。最常见的原因是本机代理:sidecar 走的是裸 TCP/UDP,只放行 HTTP 客户端的代理设置对它无效(实测诊断里是 register request: … context deadline exceeded)。


问题反馈与检查更新(主页页头)

两个入口在主页页头右侧,和「专注」「自定义」同一行,只有一行小字,不占卡片:

检查更新(可一键安装)

  • 进主页时静默查一次最新版本;宿主机侧缓存 30 分钟(失败只缓存 1 分钟), 点一下「检查更新」立即强查。版本源是 npm registry:registry.npmjs.org 优先, registry.npmmirror.com 兜底,每源 5 秒超时。接口是 GET /mesh/api/update (?force=1 跳过缓存)。
  • 有新版本时旁边多出「新版本 vX」,点第一下只是确认(6 秒内不点第二下自动 取消),第二下才开始装。整条链路:
    1. 从 npm 取该版本的 dist.tarball 与 dist.integrity(官方源 → 镜像);
    2. 下载后必须通过 sha512 校验,不一致直接丢弃;
    3. 解包到安装目录旁的 staging(同文件系统,后面 rename 才是原子的);
    4. 用真实的 import() 检查新入口可加载 —— 不通过就中止,绝不覆盖;
    5. 备份旧目录 → 原子替换;失败自动回滚。备份名 .mesh-upgrade-backup-*, 只保留最近 3 份。
  • 装完之后按「这次改了什么」给不同的动作:
    • 改了宿主半(lib/)→ 重启生效。DSH Desktop 上点它走宿主自己的 desktopSettingsController.restart()(原生确认框 + 有序 relaunch,和切换 profile 同一条路);普通 dsh web / headless 如果装了 dshmarket,会退到它的 自重启(/dsh-market/api/v1/restart,同源回环);两者都没有就老实说 「手动重启」,不会假装成功;
    • 只改了界面(client/)→ 重载界面 即可,不需要重启宿主。桌面端走宿主内置的 desktopSettingsController.reloadRenderer()(等同「设置 → 开发者选项 → 重新加载 界面」);其它宿主没有这个入口,退到页面自身 location.reload();
    • 只改了版本号 → 建议重启(否则进程里的版本号还是旧的)。
  • 失败一定保留旧版:任何一步出错都会把阶段(下载 / 校验 / 解包 / 可加载 / 替换)和原因写回界面,旁边显示「安装失败」,点一下重试。下载 450KB 的包通常 一两秒,超时 60 秒。
  • 查不到就说查不到。 超时 / HTTP 错误 / registry 返回了无法比较的版本号时, 旁边会多出一个「检查失败」(悬停可看逐条原因),点一下重试 —— 不会显示成 「已是最新」,否则用户会永远升不了级还看不出来。
  • 写接口只接受同源回环请求(POST /mesh/api/upgrade、/mesh/api/upgrade/restart), 不接受转发头,防止局域网页面借浏览器重启宿主。

问题反馈

  • 点「问题反馈」在 GitHub 打开新建 Issue 页,标题与正文已预填:正文含版本、系统、 发现方式、对端数量与本机名 / 地址 / MAC,不含配对码与 tsnet 授权码 (buildDiagnostics() 只挑安全字段)。提交前可以自己删改。
  • GitHub 打不开时,把鼠标停在「问题反馈」上能看到邮件地址 [email protected],直接把现场贴过去即可。

远程面板与 SSH 终端

设备页和主页的设备卡片上各有两个入口(对端在线时可用):

| 入口 | 做什么 | 前提 | |---|---|---| | 访问 Web | 在应用内打开对端的 DSH Web GUI,窄屏上不用来回切浏览器 | 对端是 web profile;地址与 token 由 /mesh/v1/web-url 按需发放 | | SSH 终端 | 应用内的交互式 shell(PTY、滚动缓冲、窗口尺寸跟随) | 对端开着 sshd(默认 22 端口) |

访问 Web 的两种模式:默认打开对端当前引擎的界面;如果那台机器上还跑着 另一个引擎(比如桌面 App,里面有历史会话),在它那边把 desktopWebPort 设成那个 引擎的 Web 端口(0 = 关闭这条前缀转发),之后 <对端>:<port>/mesh/web/... 会把 请求转发过去 —— 认证在对端服务端完成,token 不会出现在地址栏里。

SSH 免密:首次连接填用户名 + 密码;成功后本机会生成一把 RSA 密钥(lib/ssh-host.js 用 PKCS#1 PEM —— ssh2 不认 Node 导出的 ed25519),把公钥幂等写进对端的 authorized_keys,再用"只有私钥"的新连接自测一次;自测通过后界面标"免密"。 用户名与保存的不一致时直接走密码,不会拿别人的公钥硬试。

私钥与凭据存在本机 profile 的 mesh 目录(0600,不进组播、不进对端名片)。

⚠️ 能在这里开 SSH,意味着"对端能给你派任务 + 你信任它"就已经够用来登进对方 shell。 网络不完全可信时,先把「配对码」打开;不想让某台机器被登录就把它从设备列表信任范围里拿掉。

配置项

默认已经可用。要改就在 profile 的 cordis.patch.yml 里按 id 覆盖。

⚠️ patch 是「整体替换 config」,不是按 key 合并。 @deepseek-ai/cordis-plugin-include 对非 insert patch 走的是浅赋值 (target[key] = value,见该包 src/index.ts L121-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: ''
    atDispatch: true           # 聊天窗口 @ 在线设备后,发送即派发(false = @ 只当文本)
    # ── 互联网自组网(Tailscale / tsnet)──
    # 多 tailnet 通道:每项 { id, label, authKey, controlUrl, hostname, enabled }。
    # 非空时优先于下面的旧版单通道字段,且界面只能看不能改(部署方钉死)。
    # 手写时**务必显式给 id**:它决定 sidecar 状态目录与 tailnet 设备身份。
    tsnetChannels: []
    #   - id: personal
    #     label: 个人
    #     authKey: tskey-auth-...
    #   - id: company
    #     label: 公司
    #     authKey: tskey-auth-...
    #     controlUrl: https://headscale.example.com
    # 旧版单通道字段(没有 tsnetChannels 时生效;也可在 Mesh 页面上改,存 state.json)
    tsnetEnabled: false
    tsnetAuthKey: ''           # 建议只在页面填;配置里填则优先且钉死
    tsnetControlUrl: ''        # 留空 = 官方 Tailscale;Headscale 填自己的
    tsnetHostname: ''          # 留空 = 由机器名派生(立朋-mac → dsh-mac)
    tsnetDownloadSources: []   # 自定义下载源前缀,可用 {url} 占位符
    tsnetPermissionPreset: ''  # 留空 = 与 permissionPreset 完全一致;填了就只对 tailnet 生效
    # ── 循环限速 ──
    maxWakesPerThread: 30      # 每窗口内最多自动唤醒几次;0 = 不限制
    threadWindowMs: 3600000    # 窗口长度(毫秒),默认 1 小时
    threadCooldownMs: 5000     # 两次自动唤醒的最小间隔;0 = 不限制
    minTaskChars: 24           # 正文短于此长度只入箱不唤醒(挡 ping/ack)

循环限速(重要)

为什么需要:入站任务会自动新建会话并立刻让 agent 开跑,而每条消息都由 远端决定。两个 agent 互相回执时会形成自持的 ping-pong —— 实测一个互回执线程 在 2.5 小时内自动开了 54 个会话、约 800 万 token,全部是重复探索。

四道闸门,任何一道触发都只入箱、不唤醒(任务仍可在 /mesh/v1/tasks 和 Mesh 页面看到,不会被丢弃):

| 闸门 | 默认 | 作用 | |---|---|---| | minTaskChars | 24 | 太短的内容不开会话 | | 寒暄识别 | 内置 | ping / ack / 收到 / 好的 等整条即寒暄的,不开会话 | | threadCooldownMs | 5s | 同一 thread 不会连续秒回,管突发 | | maxWakesPerThread | 30 | 同一 thread 在 threadWindowMs 内的唤醒上限,管持续速率 | | noWake(发送方标记) | — | 只入箱、不开会话;例外见下 |

⚠️ 人手动派发的任务与它的回执不走前两道闸门。 聊天窗口 @ 设备、Mesh 面板 「派发任务」发出去的任务带 human 标记,对端不再用「太短 / 纯寒暄」拦它 —— 人说一句「看下磁盘」只有 4 个字,被吞成「仅记录」只会让人以为派发坏了 (2026-10-30 真机:routeDecision = 正文仅 6 字符 < minTaskChars(8))。同理, 这条派发的回执即使被对端 agent 标成 noWake,也会投递回会话:那是用户等着的 答案,不是可以静默丢掉的寒暄;对端是旧版本、仍按旧文案把结果标成回执时也一样。 限速(maxWakesPerThread)对人和回执同样生效 —— 防刷靠它,不靠内容闸门。

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 反代。

已知限制

  1. 同 thread 复用会话,但跨 thread 不复用:同一 thread 的后续消息会注入 同一个会话;没带 thread 的任务仍然每次新建。(早于 2026-09 的版本无此复用, 每条消息都新建会话。)
  2. 去重只在进程生命周期内(内存),重启后同 id 会重复入箱。
  3. 没有离线排队:对端离线时 mesh_run 直接失败,不暂存重投。 (要这个能力可以用工作区里的 lanmsg.py。)
  4. 同机多实例:第二个实例端口被占会退回随机端口,主动扫描扫不到它(组播仍能发现)。 真要同机多实例,给它们配不同 port 和对方的 scanPorts。
  5. sidebar.panellist 槽位官方插件没人用过(翻遍 58 个客户端 bundle,只有官方侧栏 自己声明它)。我按源码契约接的,实测注册和渲染都正常,但不如 sidebar.footer.action 那样有第三方先例。
  6. 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 的最小变更是:

  1. Mesh 页面开开关 + 填授权码(→ state.json,无需重启、无需改文件)
  2. 只有需要局域网镜子时,才用 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   # 两个进程同时跑可验证互相发现

参与开发与发版

合并进 main 就是发版:CI 会跑全量测试、按提交信息决定版本号(0.x 阶段 feat: / fix: 都只升 patch,只有 breaking 才升 minor;1.0 之后恢复标准 semver; docs:/chore:/ci: 不发版)、发 npm 包(带 provenance 签名)、打 plugin-v<版本> tag,并建带源码包的 GitHub Release。贡献者不需要手改版本号、打 tag 或跑 npm publish —— 手改反而会和流水线打架(npm run check 会在两处版本号不一致时 直接拦下)。

| 想知道 | 看 | |---|---| | 提交信息怎么写、PR 会被哪些门禁检查 | CONTRIBUTING.md | | 流水线怎么运作、失败了怎么重跑、怎么撤回 | docs/RELEASE.md | | 改代码前必须知道的硬约束与坑 | AGENTS.md | | 版本升级 / 新功能后的回归测试怎么跑 | docs/TEST-CASES.md | | 功能需求、架构逻辑、多版本通信协议、跨 IDE 实现契约 | docs/PLUGIN-SPEC.md |

本地预演"如果现在合并会发生什么"(不联网、不改文件):

npm run release:plan

文件结构

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 展开)
│   ├── at-mention.js      # 聊天窗口 @ 设备的纯逻辑(认目标 / 改写消息,单测覆盖)
│   ├── 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