dsh-plugin-mesh
Verifieddsh-plugin-mesh · v0.5.2 · MIT · Web UI
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
Install
dsh plugin add dsh-plugin-mesh Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
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 → 「互联网组网」:
- 打开开关
- 粘贴 Tailscale 授权码(
tskey-auth-...)—— 只需输入一次,存进state.json(权限 0600),重启不用再填- 还没有授权码:先注册 / 登录 Tailscale(免费),再到 https://console.tailscale.com/admin/settings/keys 生成。
- 生成时必须勾 Reusable —— 一次性码只能用一次,重启就会报 invalid key。
- 后台开了设备审批的,还要勾 Pre-approved。
- 插件里的「设置」直接给了这个地址,不用自己去后台翻。
- 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 看:
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
脚本做两件事,且可重复执行(已装就跳过):
- 把插件复制到
~/.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),
必须用应用自带的那个终端。
升级已安装的插件
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 |
查看收到/派出的任务记录 |
所以你可以直接说:
「看看局域网里有哪些机器,然后让 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: 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 内的唤醒上限,管持续速率 |
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 # 两个进程同时跑可验证互相发现
参与开发与发版
合并进 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 展开)
│ ├── 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