# dsh-plugin-mesh > DSH Mesh is a plugin for DeepSeek Harness (dsh) that discovers other dsh instances on the local network over UDP multicast or port scan, shows each peer's self-reported capabilities in a sidebar panel, and dispatches tasks between machines in both directions — over the LAN, or over an automatically downloaded userspace Tailscale (tsnet) sidecar when machines are not on the same network. Canonical package: `dsh-plugin-mesh` (npm, MIT). Repository: https://github.com/peng456/dsh-plugin-mesh Showcase: https://peng456.github.io/dsh-plugin-mesh/ Primary documentation language is Chinese; this file is the English, machine-oriented entry point. ## Install ```bash dsh plugin add dsh-plugin-mesh # 'desktop' profile: must run in DSH Desktop's own terminal dsh plugin --profile web add dsh-plugin-mesh # any other profile: a normal terminal is fine ``` From source instead: `git clone https://github.com/peng456/dsh-plugin-mesh && cd dsh-plugin-mesh && ./install.sh` `dsh plugin --profile desktop ...` is rejected from a normal terminal by dsh itself (bin.js:29 — the desktop profile is managed exclusively by the Electron application). Use the app's built-in terminal, or `install.sh`. **A restart of the dsh host is required after install** — the plugin loads at startup. Verify after restart: `GET http://127.0.0.1:45917/mesh/v1/health` returns JSON containing `{"app":"dsh-mesh","machine":"",...}`. ## What it does at runtime - Sends a JSON heartbeat to UDP multicast `239.255.42.99:45892` every `announceIntervalMs` (default 5000 ms) and listens on the same group. - Serves a small HTTP peer API on TCP port 45917 (configurable via `port`). - Optionally starts bundled Go sidecars, each running a userspace Tailscale node (tsnet) for one tailnet channel. Multiple channels can run at once: one machine identity, several reachability paths. No TUN device is created, so the host cannot route to `100.x.y.z`; all tailnet traffic is proxied through the matching sidecar. The binary is downloaded per-platform on first use and verified against checksums pinned in the plugin source. - Auto-creates an agent session on the receiving machine when a task arrives, then routes that task to a new or reused session. - Reports a structured `osInfo` card entry (host) and a `systems[]` array: host first, then environments such as WSL distros on Windows (`kind: "wsl"`, Linux `platform`, distro name/version). Older peers only send `os`, and the UI falls back to parsing it; purely display data, at most 4 systems. - On Windows, probes runtimes inside **running** WSL distros (`wsl.exe -d -e sh -lc …`) and reports them as `capabilities.wsl[]`; stopped distros are never started just to probe. Host and WSL runtimes are displayed separately. - Home-page header exposes two compact text links (no cards): **问题反馈** opens a prefilled GitHub issue, and **检查更新** calls `GET /mesh/api/update`. The update route asks the npm registry (`registry.npmjs.org`, falling back to `registry.npmmirror.com`, 5 s per source, 30 min host-side cache) and compares with the running `VERSION`. A failed check returns `{ok:false,error,failures[]}` and is shown as "检查失败" — never as "已是最新". - One-click install: the inline "新版本 vX" link calls `POST /mesh/api/upgrade` (same-origin loopback only). It downloads the npm tarball for that version, verifies `dist.integrity` (sha512), extracts to a sibling staging dir, runs a real `import()` loadability check, backs up the old directory (`.mesh-upgrade-backup-*`, last 3 kept) and atomically swaps. Any failure keeps the old version and returns the failing stage. The result classifies the change: `restartRequired` only when `lib/` changed (plus version-only bumps), `reloadRequired` when only `client/` changed. - A successful install writes `upgrade.json` under the mesh state dir; until the process restarts, `GET /mesh/api/update` reports `pendingUpgrade` so the UI offers **重启生效** (`POST /mesh/api/upgrade/restart`) or **重载界面** instead of installing again (`POST /mesh/api/upgrade/reload` uses DSH Desktop's built-in `desktopSettingsController.reloadRenderer()`, the same action as Settings → Developer options → Reload interface; other hosts fall back to a page reload). On DSH Desktop the restart goes through the host's own `desktopSettingsController.restart()` (native confirm + ordered relaunch); plain `dsh web`/headless falls back to dshmarket's self-restart (`POST /dsh-market/api/v1/restart`) when that plugin is installed and not under a detected systemd supervisor. When neither entry exists the response is `mode:"manual"` with an explicit reason — it never pretends a restart happened. - The prefilled feedback issue carries the same diagnostics text as the diagnostics section; `buildDiagnostics()` deliberately excludes secrets (pairing code, tsnet auth key). The author's email (`geekstartup@163.com`) is the fallback when GitHub is unreachable. - **First-contact approval (default on).** An inbound task from a machine that is not in `state.json`'s `approvedPeers` is stored with `status: "needs-approval"` and answered with **HTTP 202** `{ok:true,pendingApproval:true}`; no agent session is created. The owner allows or denies it in the panel (`POST /mesh/api/approval` `{id,action:"allow"|"deny"}`); allowing adds the peer to `approvedPeers` and immediately runs the stored task. `POST /mesh/api/approval/revoke` `{machine}` removes trust. Replies (a task whose `thread` matches an outbound dispatch to the same machine) bypass the gate — otherwise the result would never come back. With `requirePairing` on (or the pairing switch enabled) the gate is skipped: pairing is the stronger trust gesture. - `GET /mesh/api/demo` returns a read-only snapshot of this machine (platform, CPU, memory, load, uptime, a few runtimes) used by the panel's single-machine demo. It contains no secrets and changes no state. - `/mesh/api/state` additionally exposes `config.requireApproval`, `approvals[]` (pending first-contact tasks) and `approvedPeers[]` (`{machine,address,at}`). ## Usage patterns (agent to agent) What this is for: one agent hands a task to another agent on a different machine, and the result comes back. The receiving agent decides how to do it; the sender only states the outcome. - **Compile on the PC** — “Pull the latest code, build the Windows target, send me the artifact and any errors.” The peer agent runs the build and returns the artifact path, exit code and error summary. - **Deploy and verify on Android** — “Install the build I just made on the Android device, launch it, send a screenshot and the logs.” The peer installs, launches, screenshots and returns key log lines. - **Plugin adaptation** — “On the Windows box, install the latest build, restart, and confirm `/mesh/v1/health` reports the new version.” The peer upgrades, restarts, probes health and reports the version. - **Chains** — the receiving agent can call `mesh_run` itself to hand the next step to a third machine (compile on B, deploy on C), and the consolidated result is dispatched back to the origin. No central scheduler: each hop is just another `mesh_run`. These are outcomes, not a fixed feature list: the peer agent can run any command it has permission for. ## Tools registered (callable by the agent) | Tool | Parameters | Purpose | |---|---|---| | `mesh_peers` | `includeOffline?: boolean` | List discovered peers: online state, address, OS, user, and the capabilities each peer reports. | | `mesh_scan` | none | Actively scan the local subnet for mesh peers. Use when multicast is blocked. | | `mesh_run` | `peer: string` (required), `task: string` (required), `thread?: string`, `noWake?: boolean`, `newSession?: boolean`, `workspace?: string` | Dispatch a task to another machine. Asynchronous — a successful call means "accepted", not "done". | | `mesh_tasks` | none | Show recently received and sent mesh tasks. | | `mesh_switch` | `scope: "all" \| "lan" \| "internet"` (required), `action?: "off" \| "on"` | One-click kill switch. `off` is the default action. | `peer` accepts a machine name, an instance id, or `ip:port`. **Do not pass `machine/user`** — `from` fields look like `machine/user`, but the peer key is the machine name only. Reuse the same `thread` value for the same piece of work: the receiving side reuses one session per `thread`, which saves tokens and keeps context. Use `noWake: true` for pure acknowledgements — otherwise a task wakes an agent session on the peer. `newSession: true` forces a fresh session while keeping the same conversation key (i.e. it is still the same conversation, with a clean context). ## Kill switch (one-click shutdown) Three scopes, available as the **一键关闭** pills on the home overview card, as the detailed **连接 → 一键关闭** section of the Mesh panel, and as the `mesh_switch` tool (`POST /mesh/api/switch` with `{scope, off}`): | Scope | Effect | |---|---| | `all` | LAN and internet both off: no multicast, no subnet scan, no inbound tasks, all tailnet sidecars stopped | | `lan` | Stops multicast announcements, subnet scan and keepalive pings; the peer HTTP port is rebound from `0.0.0.0` to `127.0.0.1` so nothing on the LAN can connect. Internet channels keep working | | `internet` | Stops every tailnet sidecar. Channel config and auth keys are kept and resume on reopen | - **Immediate, and it survives a restart.** The desired state lives in `/mesh/state.json` under `shutdown` (`{lan, internet, at, by}`, `true` = off), not in the plugin config. `enabled: false` in config is a different thing: it removes the plugin at load time, including the panel, so it cannot be undone from the UI. - The Mesh panel and `/mesh/api/*` stay available while everything is off — otherwise "one-click off" would lock you out of turning it back on. - `/mesh/api/state` reports `switch.lan|internet.{off, active, bind, port, note}`: `off` is the requested state, `active` the real one. A failed rebind shows up as a non-empty `note` instead of silently looking closed. - While closed, outbound dispatch, `mesh_scan`, and the peer HTTP API (including `/mesh/v1/health`) answer with an explicit "closed" error (HTTP 503/409), never with a generic "unreachable" — "no answer" must not be mistaken for "not there". ## Tailnet stalls are reported, not hidden A sidecar that cannot reach the control plane keeps retrying, and `srv.Up()` never returns. Rather than showing "starting" forever, the host marks the channel as **启动受阻** (`node.stalled`, `node.stalledReason`, `node.stalledHint`) once `TSNET_STALL_MS` (75 s) passes with no tailnet address and no auth URL, taking the reason from the sidecar's own `health(warnable=login-state)` / `Received error` diagnostics. The panel shows the reason plus an actionable hint and turns the button into 重试连接; `switch.internet.stalled` and a `note` carry the same fact to the kill-switch section. Most common cause on a machine with a local proxy: the sidecar dials raw TCP/UDP, so an "HTTP clients only" proxy setting does not cover it (`register request: … context deadline exceeded`). ## Remote panel and SSH terminal Two per-peer entry points in the Mesh panel (device page and the device card on the home board, enabled while the peer is online): | Entry | What it does | Requires | |---|---|---| | **访问 Web** | Opens the peer's DSH Web GUI inside the panel | Peer runs a web profile; the URL + token come from `GET /mesh/v1/web-url` on demand | | **SSH** | In-app interactive shell (PTY, ring buffer, resize) | sshd on the peer (default port 22) | - Remote web has two modes: by default it opens the peer's current engine; if that machine also runs a second engine (the desktop app with the history), set `desktopWebPort` there and `:/mesh/web/...` is forwarded to it. Auth is completed server-side, so the launch token never appears in the URL bar. - SSH key auto-deploy: first connection uses username + password; on success the plugin generates an RSA key (PKCS#1 PEM — ssh2 cannot read Node's ed25519 export), appends the public key to the peer's `authorized_keys` idempotently, then proves it with a fresh private-key-only connection. A different username falls back to password. - The private key and credentials live under the profile's mesh directory (mode 0600); they are never broadcast and never included in the peer card. ## Configuration Override by id in the profile's `cordis.patch.yml`: ```yaml - id: mesh config: requirePairing: true ``` **`config:` is replaced wholesale, not merged** (dsh's patch layer does `target[key] = value`). To add one key you must restate the keys you want to keep, or the others fall back to schema defaults. | Key | Type | Default | Notes | |---|---|---|---| | `enabled` | boolean | `true` | | | `machineName` | string | `""` | Human-readable name; empty = ask/derive from hostname | | `port` | natural | `45917` | HTTP peer port | | `announceIntervalMs` | natural | `5000` | Multicast heartbeat period | | `peerTtlMs` | natural | `20000` | Peer marked offline after this silence | | `requestTimeoutMs` | natural | `20000` | Per-request timeout | | `useMulticast` | boolean | `true` | | | `scanOnStart` | boolean | `true` | | | `scanIntervalMs` | natural | `60000` | | | `scanConcurrency` | natural | `64` | | | `scanTimeoutMs` | natural | `800` | | | `pingIntervalMs` | natural | `10000` | LAN health probe period | | `tsnetProbeIntervalMs` | natural | `60000` | Tailnet health probe period | | `requirePairing` | boolean | `false` | **Default off: the peer API accepts unauthenticated task POSTs.** Setting it `true` here pins pairing on; otherwise the Mesh panel's pairing switch (persisted in `state.json`) decides | | `requireApproval` | boolean | `true` | First task from an unknown machine is held as `needs-approval` until the owner clicks Allow. Set `false` to restore fully unattended dispatch. Ignored (no prompt) when pairing is on | | `agentPreset` | string | `"standard"` | Agent preset for auto-created sessions | | `permissionPreset` | string | `"workspace-write"` | Permission preset for auto-created sessions | | `workspaceRoot` | string | `""` | | | `tsnetChannels` | array | `[]` | Multi-tailnet channels: `{id, label, authKey, controlUrl, hostname, enabled}`. A non-empty list pins the channel set (UI read-only) and wins over the legacy single-channel fields. `id` is the stable sidecar directory / tailnet device identity — always give it explicitly when hand-editing; renaming `label` alone derives a new id and therefore registers a new device | | `tsnetEnabled` | boolean | `false` | Legacy single-channel switch; only used when `tsnetChannels` is empty | | `tsnetAuthKey` | string | `""` | Legacy single channel. Tailscale auth key; must be reusable | | `tsnetControlUrl` | string | `""` | Legacy single channel | | `tsnetHostname` | string | `""` | Legacy single channel. The plugin prefixes `dsh-`; do not include it yourself | | `tsnetPermissionPreset` | string | `""` | Empty = same as `permissionPreset` | | `maxWakesPerThread` | natural | `30` | Per-thread wake cap per window | | `threadWindowMs` | natural | `3600000` | Window for the cap above | | `threadCooldownMs` | natural | `5000` | Cooldown between session-creating wakes | | `minTaskChars` | natural | `24` | Bodies shorter than this are not woken (inbox only) | ## Tailnet setup (the only step that needs an external account) Cross-network links require a free Tailscale account and an auth key. Create one at — that URL redirects to sign-up if you are not signed in yet. The key **must be Reusable**; single-use keys fail with `invalid key` on the next start. If device approval is enabled in the tailnet, tick Pre-approved as well. Add a channel in the Mesh panel (name + key, optionally a Headscale control URL), or set `tsnetChannels` in config. Keys pasted in the UI are stored in `~/.dsh/mesh/state.json` (mode 0600) and never need re-entry. The plugin's UI links to the key page directly; do not tell users to hunt for it. One machine can join several tailnets at once. Each channel gets its own sidecar and its own state directory; the mesh device identity stays the same, so peers see one machine with several tailnet endpoints. Endpoint health is tracked per channel: one tailnet going down neither takes the device offline nor affects the other channels. Outbound dispatch tries LAN first, then each tailnet channel in order. ## Network and security facts - Ports: TCP `45917` (peer HTTP), UDP `45892` (multicast group `239.255.42.99`). - Discovery is intentionally public: any host on the same L2 network can see the machine and its self-reported card. - With `requireApproval: true` (the default) a first task from an unknown machine does **not** wake anything: it is held for local approval. Once approved, later tasks from that machine run unattended. Set `requireApproval: false` to get fully unattended dispatch (the pre-0.6 behaviour). - With `requirePairing: false` (the default) there is still no cryptographic authentication of the sender: the trust decision is per-machine, made by the owner, not by a shared secret. On untrusted networks turn on the pairing switch in the Mesh panel (immediate, no restart) or set `requirePairing: true`, and make both machines share one secret. The token travels in a plain HTTP header — it guards against stray devices, not against a network sniffer. - The shared secret is stored in `~/.dsh/mesh/state.json` (mode 0600) alongside `machineName`, the persisted `instanceIds`, and the tsnet auth key. It is never included in the multicast card. - Persistent identity is a UUID per profile; the OS machine fingerprint is used only to reclaim a reinstalled device (same name, or the old record already offline). Two live instances on one physical machine are kept separate. - Sidecar binaries are verified against sha256 checksums pinned in `lib/tsnet/checksums.js` — deliberately not fetched from the same release, so a hostile mirror cannot swap both. ## Requirements - A dsh host that can load cordis plugins with a client half (DSH Desktop, or `dsh web` + a profile). - Node >= 20 for development. Peer `@deepseek-ai/*` packages are provided by the host and are not bundled. - No Go toolchain and no Tailscale client are required by end users. ## Development ```bash npm run check # version consistency + TDZ scan + syntax + loadability npm test # = check + regression suites (~310 assertions, network-free) npm run selftest:caps # capability collection: sanitising, GPU parsing, retries, claim rules npm run selftest:client # client bundle rendering without a browser npm run selftest:prompt # task prompt generation npm run build:sidecar # rebuild the Go tsnet sidecar (cross-platform) ``` `AGENTS.md` in the repository root carries the operating guide for coding agents: hard constraints, known traps, and file map. Read it before changing this project. ## Documentation map - `AGENTS.md` — agent operating guide, constraints, traps (English) - `README.en.md` — short English quickstart (English) - `README.md` — user-facing entry (Chinese): what it gives you, 5-minute start, safety, FAQ - `docs/DESIGN-NOTES.md` — the old long-form README: multicast/scan/firewall details, tsnet, identity, full config table, security model, limits, development - `DESIGN.md` — design rationale, trade-offs and post-mortems (Chinese) - `Placement设计.md` — placement design and implementation record (Chinese) - `设备身份与多通道设计.md` — identity and multi-channel design (Chinese) - `docs/index.html` — user-facing showcase, Chinese (GitHub Pages) - `docs/demo.html` — 60-second animated walkthrough (schematic; connects to nothing) ## Contact geekstartup@163.com · https://github.com/peng456/dsh-plugin-mesh/issues · npm: `dsh-plugin-mesh`