Passer au contenu principal
Z

dsh-plugin-agent-bridge

zshqzcn/dsh-plugin-agent-bridge

Two-way agent bridge between two DeepSeek Harness hosts: either side can ask the other agent questions and run bounded dialogues over a PSK-authenticated TCP link, with a chat page on both sides and read-only tool posture by default. Both ends need the same shared key and a nodeId configured.

Installer

dsh plugin --profile web add github:zshqzcn/dsh-plugin-agent-bridge

README

dsh-plugin-agent-bridge

English | 中文

A two-way agent-to-agent bridge between two DeepSeek Harness hosts over a private network. An agent on one machine can ask the agent on the other machine a question, receive a streamed answer produced with that machine's own workspace and tools, and hold a multi-turn dialogue — all over one authenticated TCP link.

Not affiliated with DeepSeek. This is a community plugin that runs inside a DSH profile.


English

Features

  • Two-way link. Either side can open a turn; both directions share one socket.
  • Operator page in the GUI. A Bridge entry in the sidebar (below Automation tasks) opens a full-width chat page: connection state, transcript, Markdown, code blocks with copy, and a composer that sends to the peer agent.
  • Agent tools. bridge_ask for one question, bridge_dialog for a bounded multi-turn conversation between the two agents.
  • Authenticated transport. NDJSON over TCP; PSK proved by HMAC-SHA256 challenge-response, every frame carries an HMAC over its sequence number. Replays and reordering are refused.
  • One dedicated session per peer. A bridged agent never reaches the operator's own conversations.
  • Read-only by default. A bridged child may read / search / fetch; writes and command execution are refused by both a name filter and a tool-kind fence.
  • Loop guards. Hop limit (default 8) and a per-minute turn budget (default 30) are enforced on every inbound turn; a bridged child cannot call bridge_ask back. The hop count is cooperative (a peer reports it), so identical text from the same peer inside a short window is refused as well.
  • Reply language. Agent answers come back in the configured language (default zh), while the transcript keeps what was actually typed.
  • Persistent transcript. Every link event and turn is appended to bridge-transcript.jsonl next to the profile, so the panel survives restarts.
  • Self-diagnosis. On load the plugin writes bridge-boot.json (version, services, effective tool posture) — the only way to see a partially failed setup in the desktop build, which keeps no application log.

Requirements

  • Two machines that can reach each other on a private network (Tailscale, WireGuard, LAN + firewall rules, …).
  • DeepSeek Harness desktop or web build with a profile (tested against 0.2.1-rc.2).
  • Node.js ≥ 22.
  • One pre-shared key of at least 16 characters, identical on both sides.

Install

On both machines.

1. Put the package in the profile
# Windows: profile lives at C:\Users\<you>\.dsh\profiles\desktop
$profileDir = "$env:USERPROFILE\.dsh\profiles\desktop"
New-Item -ItemType Directory -Force "$profileDir\node_modules" | Out-Null
Copy-Item -Recurse .\dsh-plugin-agent-bridge "$profileDir\node_modules\dsh-plugin-agent-bridge"
2. Register the plugin row

Append to $profileDir\cordis.patch.yml. The two machines differ only in nodeId and peer.

Machine A — the listener (accepts links):

- insert:
    - id: agent-bridge
      name: dsh-plugin-agent-bridge
      config:
        nodeId: machine-a
        psk: !!js process.env.DSH_BRIDGE_PSK
        listen: true
        port: 19388
        cwd: C:\Users\<you>\.dsh\bridge-workspace
        tools:
          preset: readonly
          deny:
            - ask_user_question

Machine B — the dialer (connects to A, retries with backoff up to 30 s):

- insert:
    - id: agent-bridge
      name: dsh-plugin-agent-bridge
      config:
        nodeId: machine-b
        psk: !!js process.env.DSH_BRIDGE_PSK
        peer:
          host: 100.64.0.2       # A's address on your private network
          port: 19388
        cwd: C:\Users\<you>\.dsh\bridge-workspace
        tools:
          preset: readonly
          deny:
            - ask_user_question

psk may also be written literally, but the environment variable is safer: the profile file is easy to commit by accident.

3. Provide the pre-shared key

Set the same DSH_BRIDGE_PSK for the user that starts the app:

[Environment]::SetEnvironmentVariable('DSH_BRIDGE_PSK', '<at-least-16-characters>', 'User')

Then restart DeepSeek Harness. The plugin is host-side code; a page reload is not enough.

4. Check that it came up
  • Sidebar shows Bridge below Automation tasks; opening it shows machine-a ⇄ machine-b.
  • bridge-boot.json in the profile directory reports "phase": "complete" and the installed version.
  • On the dialer, peers becomes non-empty within ~30 s of the listener being reachable.

Configuration

KeyDefaultMeaning
nodeIdrequiredThis machine's name; peers address each other by it.
pskrequiredPre-shared key, ≥16 chars; falls back to DSH_BRIDGE_PSK.
bridgeIddsh-agent-bridgeScopes the key; two independent bridges may share a PSK.
listentrue when peer.host is unsetBind the listener.
host0.0.0.0Listener bind address.
port19388Listener port (not DSH's own web port).
peer.host / peer.port— / 19388Set peer.host to dial that address.
tools.presetreadonlynone, readonly, or full.
tools.allow / tools.denypreset / ['ask_user_question']Name-level allow list and a deny list that always subtracts last.
tools.allowKindsread-only kindsCoarser fence: read, search, fetch, other.
exposeTooltrueRegister bridge_ask / bridge_dialog.
chattrueServe the operator routes (/bridge-chat, /state, /send).
replyLanguagezhLanguage a peer's answer should come back in; '' disables.
maxHops8Recursion limit on relayed messages.
maxTurnsPerMinute30Turn budget per peer, sliding 60 s window.
maxDialogRounds / dialogMaxMs50 / 600000Bounds for one bridge_dialog run.
sessionIdPrefixbridge-Bridged session ids are <prefix><peerId>.
agentOptionshost default model{ provider, model, reasoningEffort, maxTokens }.
cwdhost working directoryWorking directory for bridged sessions. Set this explicitly.
reconnectBaseMs / reconnectMaxMs1000 / 30000Dialer backoff (jittered).
transcriptPath<profile>/bridge-transcript.jsonlWhere the visible transcript is persisted.
transcriptMaxEntryBytes / transcriptMaxFileBytes65536 / 8388608Per-entry and per-file transcript ceilings; oversized text is truncated and the file compacts by bytes.
trustedHosts[]Extra hostnames (besides loopback) allowed to use the chat routes. Needed only when the GUI is opened through a LAN hostname.
maxPeerSessions32Ceiling on live per-peer conversation entries; the least-recently-used peer is evicted.
maxConnections / maxConcurrentPerIp64 / 4Listener admission limits; rejected sources are logged by address.
duplicateTurnWindowMs10000Identical text from the same peer inside this window is refused as a loop.

Using it

  • Operator page. Sidebar → Bridge. Type and press Enter (Shift+Enter for a newline). Local-side turns are boxed; the peer's answers are body copy. Every message has a copy control on hover, and fenced code blocks have one in their header.
  • From an agent. The bridge_ask tool asks a peer one question; bridge_dialog runs a bounded dialogue. Both are refused inside a bridged child, so two agents cannot ask each other forever.
  • From host code. ctx.bridge.prompt(peer?, text, options) and ctx.bridge.converse(...).

Security model — read before exposing it

  1. The private network is transport, not identity. A VPN encrypts the link and fixes addresses; it does not decide who may connect. Anyone on that network holding the PSK can drive the agent on this machine.
  2. The PSK is the whole authentication. It never crosses the wire; both sides prove possession through an HMAC challenge-response, and every later frame is MAC'd and sequence-checked.
  3. The listener binds 0.0.0.0 by default. Put it behind a private network or firewall rules; do not expose port 19388 to the internet.
  4. Read-only is the default posture. readonly blocks writes and command execution through a live name guard on the call AND a tool-kind fence. A bridged child cannot be widened by the peer.
  5. The bridged agent runs with the host process's permissions. Tool policy is the only boundary; treat a peer as code running on your machine.
  6. ask_user_question is denied by default, so a peer cannot raise prompts on your screen.
  7. Chat routes are Host-gated. The page, the transcript (/state) and /send are served only for loopback or a configured trustedHosts hostname, and an Origin, when present, must match that Host. This closes the DNS-rebinding shape where Origin and Host both name the attacker.
  8. Peer identity is the handshake identity. Inbound turns are keyed by the authenticated hello name, never by a self-reported field, and a second live link cannot claim a name that is already in use. The local-dialog session key is reserved.
  9. Admission and records are bounded. The listener caps total and per-source concurrent handshakes and logs rejected addresses; each transcript entry is clamped to 64 KB and the file compacts by bytes as well as by line count.

Testing

npm test

217 tests: transport handshake/framing, reconnect, transcript persistence, tool policy and kind fence, recursion guards, chat routes and same-origin checks, reply language, and the client bundle's slot registration and rendering.

Troubleshooting

SymptomLikely cause
authentication failed / bridgeId mismatchPSK or bridgeId differs between the machines.
peer "machine-b" is not connectedDialer has not connected yet, or the listener is down.
Sidebar has no Bridge rowClient bundle not loaded; restart the app, then check bridge-boot.json (phase, version).
Bridge page appears but sends nothingNo connected peer: the composer disables itself and the header dot stays grey.
Agent answers in the wrong languageCheck replyLanguage; it is appended to the prompt on the wire, so both sides must send it.
Page renders as plain text / cannot scrollAn old stylesheet survived a hot reload; reload the window, or restart.

Version

0.10.9 — see CHANGELOG.md.

License

MIT.


中文

English | 中文

在私有网络上的两台 DeepSeek Harness 主机之间,建立双向 agent 到 agent 的桥。一台机器上的 agent 可以向另一台机器的 agent 提问,得到由对方用自己的工作区和工具产生的流式回答,还能进行多轮对话——全程走同一条已认证的 TCP 链路。

本项目与 DeepSeek 官方无关,是运行在 DSH profile 里的社区插件。

功能

  • 双向链路:任一侧都能发起一轮,两个方向共用一条 socket。
  • GUI 里的操作者页面:侧栏「自动化任务」下方有 Bridge 入口,点开是全宽对话页:连接状态、转录、Markdown、带复制的代码块,以及直接发往对端 agent 的输入框。
  • Agent 工具:bridge_ask 问一句;bridge_dialog 让两个 agent 做一次有界多轮对话。
  • 认证传输:TCP 上的 NDJSON;PSK 通过 HMAC-SHA256 挑战应答证明,之后每一帧都带序号 HMAC,重放与乱序会被拒绝。
  • 每对端独立会话:被桥接的 agent 永远碰不到操作者自己的会话。
  • 默认只读:被桥接的 child 只能 read / search / fetch;写文件和执行命令会被「名称白名单 + 工具种类围栏」双重拒绝。
  • 防环:每个入站回合都有跳数上限(默认 8)和每分钟预算(默认 30);被桥接的 child 不能再调 bridge_ask 回问。跳数是协作式的(由对端上报),因此同一对端在短窗口内重复同一段文字也会被拒绝。
  • 回复语言:对端回答默认用配置的语言(默认 zh),而转录保留你实际输入的原文。
  • 转录持久化:每条链路事件与对话都追加到 profile 旁的 bridge-transcript.jsonl,重启后面板不会清空。
  • 自诊断:插件加载时写 bridge-boot.json(版本、各服务可用性、实际生效的工具姿态)——桌面版不落盘应用日志,这是从进程外看「装了一半」的唯一手段。

环境要求

  • 两台能通过私有网络互通的机器(Tailscale / WireGuard / 局域网 + 防火墙规则均可)。
  • 带 profile 的 DeepSeek Harness 桌面版或 Web 版(在 0.2.1-rc.2 上验证)。
  • Node.js ≥ 22。
  • 一条至少 16 字符的预共享密钥,两台机器必须一致。

安装

两台机器都要做。

1. 把包放进 profile
# Windows:profile 在 C:\Users\<you>\.dsh\profiles\desktop
$profileDir = "$env:USERPROFILE\.dsh\profiles\desktop"
New-Item -ItemType Directory -Force "$profileDir\node_modules" | Out-Null
Copy-Item -Recurse .\dsh-plugin-agent-bridge "$profileDir\node_modules\dsh-plugin-agent-bridge"
2. 注册插件行

追加到 $profileDir\cordis.patch.yml。两台机器只有 nodeId 和 peer 不同。

机器 A —— 监听方(接受连接):

- insert:
    - id: agent-bridge
      name: dsh-plugin-agent-bridge
      config:
        nodeId: machine-a
        psk: !!js process.env.DSH_BRIDGE_PSK
        listen: true
        port: 19388
        cwd: C:\Users\<you>\.dsh\bridge-workspace
        tools:
          preset: readonly
          deny:
            - ask_user_question

机器 B —— 拨号方(连 A;断线后按退避重试,上限 30 秒):

- insert:
    - id: agent-bridge
      name: dsh-plugin-agent-bridge
      config:
        nodeId: machine-b
        psk: !!js process.env.DSH_BRIDGE_PSK
        peer:
          host: 100.64.0.2       # A 在私有网络上的地址
          port: 19388
        cwd: C:\Users\<you>\.dsh\bridge-workspace
        tools:
          preset: readonly
          deny:
            - ask_user_question

psk 也可以直接写明文,但用环境变量更安全:profile 文件很容易被误提交。

3. 设置预共享密钥

给启动应用的同一个用户设置相同的 DSH_BRIDGE_PSK:

[Environment]::SetEnvironmentVariable('DSH_BRIDGE_PSK', '<至少 16 个字符>', 'User')

然后重启 DeepSeek Harness。这是宿主侧代码,刷新页面不够。

4. 确认起来了
  • 侧栏「自动化任务」下方出现 Bridge;点开显示 machine-a ⇄ machine-b。
  • profile 目录里的 bridge-boot.json 显示 "phase": "complete" 与已安装版本。
  • 拨号方在 A 可达后约 30 秒内 peers 非空。

配置项

键默认值含义
nodeId必填本机名字;对端用它寻址。
psk必填预共享密钥,≥16 字符;缺省时读 DSH_BRIDGE_PSK。
bridgeIddsh-agent-bridge密钥作用域;两套独立桥可以共用一条 PSK。
listen未设 peer.host 时为 true是否监听。
host0.0.0.0监听地址。
port19388监听端口(不是 DSH 自己的 Web 端口)。
peer.host / peer.port— / 19388设置 peer.host 即成为拨号方。
tools.presetreadonlynone / readonly / full。
tools.allow / tools.denypreset / ['ask_user_question']名称白名单与「最后生效」的黑名单。
tools.allowKinds只读种类更粗的围栏:read、search、fetch、other。
exposeTooltrue注册 bridge_ask / bridge_dialog。
chattrue提供操作者路由(/bridge-chat、/state、/send)。
replyLanguagezh对端回答使用的语言;'' 关闭。
maxHops8中继消息的递归上限。
maxTurnsPerMinute30每对端每分钟回合预算(60 秒滑动窗口)。
maxDialogRounds / dialogMaxMs50 / 600000一次 bridge_dialog 的轮数与时长上限。
sessionIdPrefixbridge-桥接会话 id 为 <prefix><peerId>。
agentOptions宿主默认模型{ provider, model, reasoningEffort, maxTokens }。
cwd宿主工作目录桥接会话的工作目录。建议显式指定。
reconnectBaseMs / reconnectMaxMs1000 / 30000拨号方退避(带抖动)。
transcriptPath<profile>/bridge-transcript.jsonl可见转录的落盘位置。
transcriptMaxEntryBytes / transcriptMaxFileBytes65536 / 8388608转录单条与整文件的字节上限;超长文本截断,文件按字节压缩。
trustedHosts[]除回环外允许访问聊天路由的主机名;只有通过局域网主机名打开 GUI 时才需要。
maxPeerSessions32每个对端活跃会话条目上限;超出时淘汰最久未用的对端。
maxConnections / maxConcurrentPerIp64 / 4监听准入上限;被拒绝的来源会记录地址。
duplicateTurnWindowMs10000同一对端在此窗口内重复同一段文字会被当作环路拒绝。

使用

  • 操作者页面:侧栏 → Bridge。Enter 发送,Shift+Enter 换行。本地侧的消息带气泡,对端回答是正文排版;悬停消息可复制,代码块头栏也有复制按钮。
  • 从 agent 调用:bridge_ask 问一句;bridge_dialog 做一次有界多轮对话。两者在被桥接的 child 里都会被拒绝,避免两个 agent 无限互问。
  • 从宿主代码调用:ctx.bridge.prompt(peer?, text, options) 与 ctx.bridge.converse(...)。

安全模型——暴露到网络前必读

  1. 私有网络是传输,不是身份。VPN 只负责加密和地址;谁持有 PSK,谁就能驱动本机 agent。
  2. PSK 就是全部认证。它从不过线;双方用挑战应答证明持有,之后每帧都带 MAC 与序号校验。
  3. 监听默认绑 0.0.0.0。请放在私有网络或防火墙规则之后,不要把 19388 暴露到公网。
  4. 默认只读。readonly 同时用「调用时的名称守卫」和「工具种类围栏」挡住写与执行;对端无法把围栏改宽。
  5. 被桥接的 agent 以宿主进程权限运行。工具策略是唯一边界,请把对端当作在本机运行的代码。
  6. 默认 deny ask_user_question,对端不能在你屏幕上弹问题框。
  7. 聊天路由按 Host 校验。页面、转录(/state)与 /send 只对回环或配置的 trustedHosts 主机名提供服务,且带 Origin 时必须与该 Host 一致——这挡住了「Origin 与 Host 都指向攻击者域名」的 DNS rebinding 形状。
  8. 对端身份以握手为准。入站回合用认证后的 hello 名字做会话键,不用自报字段;同名的新链路会被拒绝;local-dialog 是保留键。
  9. 准入与记录都有界。监听端限制总连接数与每来源并发握手并记录被拒地址;转录单条钳制 64 KB,文件按字节和行数双重压缩。

测试

npm test

217 个用例:握手与分帧、重连、转录持久化、工具策略与种类围栏、递归保护、聊天路由与同源校验、回复语言,以及客户端 bundle 的槽位注册与渲染。

常见问题

现象可能原因
authentication failed / bridgeId mismatch两台的 PSK 或 bridgeId 不一致。
peer "machine-b" is not connected拨号方还没连上,或监听方没起来。
侧栏没有 Bridge客户端 bundle 没加载;重启应用后看 bridge-boot.json(phase、version)。
页面能开但发不出去当前没有已连接的对端:输入框会禁用,头部圆点保持灰色。
回复语言不对检查 replyLanguage;它由发送方追加到线上 prompt,两侧都要配。
页面像纯文本、不能滚动旧的 <style> 在热更新后残留;刷新窗口或重启应用。

版本

0.10.9 —— 见 CHANGELOG.md。

许可证

MIT。

Plugins associés