@chenacc/agentbridge 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -12,6 +12,27 @@ AGENTBRIDGE_PROJECTS=agentbridge=D:\codex_project\手机远程操控agent
12
12
  # Cloudflare quick tunnels set this automatically to https://<random>.trycloudflare.com
13
13
  # AGENTBRIDGE_PUBLIC_ORIGIN=https://agent.example.com
14
14
 
15
+ # --- Cross-network path selection (read by both the CLI and the DSH plugin) ---
16
+ # Which path the QR code uses. auto tries: named tunnel -> Funnel (only if it is
17
+ # already on) -> Cloudflare quick tunnel -> Tailscale -> ZeroTier -> LAN.
18
+ # AGENTBRIDGE_REMOTE=auto
19
+ # AGENTBRIDGE_NO_TUNNEL=1 # same as AGENTBRIDGE_REMOTE=lan
20
+ # AGENTBRIDGE_TUNNEL_URL=https://fixed.example.com # you run the tunnel yourself
21
+
22
+ # Cloudflare named tunnel (needs `cloudflared tunnel create` + a domain on Cloudflare).
23
+ # AGENTBRIDGE_CLOUDFLARE_TUNNEL=agentbridge
24
+ # AGENTBRIDGE_CLOUDFLARE_HOSTNAME=bridge.example.com
25
+
26
+ # Private overlays are detected automatically; these only point at unusual installs.
27
+ # AGENTBRIDGE_TAILSCALE=C:\Program Files\Tailscale\tailscale.exe
28
+ # AGENTBRIDGE_ZEROTIER=10.147.20.15
29
+
30
+ # cloudflared is located on this machine, and downloaded to <data dir>/bin when
31
+ # it is missing.
32
+ # AGENTBRIDGE_CLOUDFLARED=D:\tools\cloudflared.exe # use your own binary; no search, no download
33
+ # AGENTBRIDGE_CLOUDFLARED_URL=https://mirror.example.com/cloudflared-windows-amd64.exe
34
+ # AGENTBRIDGE_NO_DOWNLOAD=1 # never fetch cloudflared automatically
35
+
15
36
  # Optional state location and expiry overrides.
16
37
  # AGENTBRIDGE_DATA_DIR=D:\agentbridge-data
17
38
  # AGENTBRIDGE_PAIRING_TTL_MS=120000
package/README.md CHANGED
@@ -1,26 +1,62 @@
1
1
  # AgentBridge
2
2
 
3
- > 用微信扫一扫打开手机控制台,安全地查看、继续或停止这台电脑上正在运行的编程 Agent——不用把电脑暴露到公网,凭据始终留在本机。
3
+ [![npm version](https://img.shields.io/npm/v/@chenacc/agentbridge?label=%40chenacc%2Fagentbridge&color=0e5a68)](https://www.npmjs.com/package/@chenacc/agentbridge)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@chenacc/agentbridge?color=0e5a68)](https://www.npmjs.com/package/@chenacc/agentbridge)
5
+ [![DSH plugin](https://img.shields.io/npm/v/@chenacc/dsh-agentbridge?label=DSH%20plugin&color=2f6f7e)](https://www.npmjs.com/package/@chenacc/dsh-agentbridge)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
7
+ [![stars](https://img.shields.io/github/stars/chenacc1/agentbridge?style=social)](https://github.com/chenacc1/agentbridge/stargazers)
4
8
 
5
- ![AgentBridge 架构:电脑上的本地 Bridge 连接已配对手机,并调用获准的编程 Agent 适配器。](./docs/assets/architecture.svg)
9
+ **一条命令,把手机变成这台电脑上 Agent 的遥控器。** 微信扫码即用,不用装 App,也不用和电脑连同一个 Wi-Fi。
10
+
11
+ | 桌面端在干活 | 手机扫一扫就接管 | 手机端同一份对话 |
12
+ |---|---|---|
13
+ | <img src="./docs/assets/screen-desktop-console.jpg" alt="桌面端控制台" width="330" /> | <img src="./docs/assets/screen-mobile-1.png" alt="手机端控制台 1" width="180" /> | <img src="./docs/assets/screen-mobile-2.png" alt="手机端控制台 2" width="180" /> |
14
+
15
+ ```powershell
16
+ npx @chenacc/agentbridge # 起桥并直接打印二维码
17
+ ```
6
18
 
7
- 手机与桌面共享同一份可追加事件流,因此消息、工具活动、审批和任务状态始终同步。电脑是唯一的执行端:手机只下发命令,不运行 Agent,也拿不到任何供应商凭据。
19
+ 然后在 Claude Code / Codex / DSH Desktop 里输入 `/phone`,扫二维码即可。凭据和文件访问始终留在本机,手机只下发命令。
20
+
21
+ > 觉得有用就给个 star:[github.com/chenacc1/agentbridge](https://github.com/chenacc1/agentbridge) —— 不点也不影响任何功能,只影响我有没有动力继续写。
22
+
23
+ 手机与桌面共享同一份可追加事件流,因此消息、工具活动、审批和任务状态始终同步。电脑是唯一的执行端:手机只运行界面,不运行 Agent,也拿不到任何供应商凭据。
24
+
25
+ 默认走 Cloudflare 隧道:手机用流量、在外地都能打开;隧道对外开放,因此自动带一道访问密钥门禁。
26
+
27
+ **当前版本:** 桥 `@chenacc/agentbridge` **0.2.0** · DSH 插件 `@chenacc/dsh-agentbridge` **0.4.0**(两个包各自发布,见文末「版本与发布」)。
8
28
 
9
29
  ## 它是怎么工作的
10
30
 
31
+ ![AgentBridge 架构:电脑上的本地 Bridge 连接已配对手机,并调用获准的编程 Agent 适配器。](./docs/assets/architecture.svg)
32
+
11
33
  ![三端结构:手机控制面、本地 Bridge、电脑执行端](./docs/assets/keyframe-02-three-tier.jpg)
12
34
 
13
35
  1. **手机** 只负责界面与指令:扫码进入 H5 控制台,选工作区、选会话,发出消息、审批与停止命令。
14
36
  2. **本地 Bridge** 是唯一枢纽:校验设备、维持会话与输入租约,把命令写进可追加事件流,再经 SSE 推回手机。
15
37
  3. **电脑上的 Agent 子进程** 真正执行任务,凭据与文件访问始终留在本机,手机侧拿不到。
16
38
 
39
+ 远程时中间多一跳,但入口仍然是桥:
40
+
41
+ ```
42
+ 手机 ──公网 HTTPS──> Cloudflare 边缘 ──隧道──> cloudflared(你电脑上的进程)
43
+ │
44
+ ▼
45
+ AgentBridge 桥(127.0.0.1:8787)
46
+ │
47
+ ▼
48
+ Claude Code / Codex / DeepSeek Harness / DSH Desktop 里的那条会话
49
+ ```
50
+
17
51
  因为上行与下行共用同一条事件流,桌面端和手机看到的是同一份状态,而不是两套各自猜测的界面。
18
52
 
19
53
  > 本文中标注为「分镜关键帧」的图片是讲解视频的概念示意图,用于说明结构关系;真实界面见下文「界面」一节。
20
54
 
55
+
21
56
  ## 主要能力
22
57
 
23
58
  - **扫码配对**:一次性、5 分钟有效的二维码;配对后收藏固定回访地址,之后免扫码进入。
59
+ - **跨网络直接可用**:默认自动开 Cloudflare 隧道,二维码就是公网 https 地址,手机无需安装任何 App。
24
60
  - **工作区 → 会话导航**:一个抽屉管理「Agent → 项目 → 会话」,Bridge 重启后历史与会话不丢失。
25
61
  - **完整对话控制**:流式输出、停止、接管输入、翻看历史,表格 / 公式 / 代码块正确渲染。
26
62
  - **远程协作**:审批卡片、提问卡片、限时自动放行(5/10/30 分钟)、消息排队、会话内搜索。
@@ -40,6 +76,7 @@
40
76
  | Node.js | **22 或更高**,`node -v` 确认 |
41
77
  | 操作系统 | Windows(一键脚本齐全);Linux / macOS 直接用 `npm start` |
42
78
  | Agent 工具 | 可选,按需安装:Claude Code / Codex / DeepSeek Harness / DSH Desktop |
79
+ | cloudflared | **不需要手动准备**:缺了会自动下载(见下一节) |
43
80
 
44
81
  AgentBridge 不替你安装或登录任何 Agent,只检测本机**已安装且已登录**的那些;没装的显示「未安装」,不会假装可用。
45
82
 
@@ -68,7 +105,7 @@ $env:AGENTBRIDGE_PROJECTS = "agentbridge=D:\projects\agentbridge" # 可选:
68
105
  npm start
69
106
  ```
70
107
 
71
- 打开 [http://127.0.0.1:8787](http://127.0.0.1:8787),桌面页会显示一次性二维码;手机连同一可信 Wi-Fi,用微信「扫一扫」扫描即可。
108
+ 打开 [http://127.0.0.1:8787](http://127.0.0.1:8787),桌面页会显示一次性二维码,用微信「扫一扫」即可。
72
109
 
73
110
  > 二维码只用于首次配对,会过期且**只能使用一次**。配对成功后请收藏手机页显示的固定回访地址,之后从收藏进入;不要收藏 `/pair?code=...` 临时链接。
74
111
  > `AGENTBRIDGE_PROJECTS` 可以不写:不设时默认把**启动目录**作为唯一可用项目,别名 `workspace`。别名只允许字母、数字、`_`、`-`(≤40 字符)。
@@ -81,61 +118,121 @@ npx @chenacc/agentbridge phone --agent echo
81
118
  # 从仓库跑的话:node .\bin\agentbridge.js phone --agent echo
82
119
  ```
83
120
 
84
- Echo 适配器不接触任何 AI 工具,只验证「CLI → 本机 Bridge → 生成配对二维码」整条链路。能打印二维码就说明安装正确,扫码后可看到手机与桌面同步。
121
+ Echo 适配器不接触任何 AI 工具,只验证「CLI → 本机 Bridge → 生成配对二维码」整条链路。能打印二维码就说明安装正确,扫码后可看到手机与桌面同步。终端里除了二维码,还会打出两行关键信息:
85
122
 
86
- ## 一键启动与停止(Windows)
123
+ ```
124
+ 网络: 公网地址,手机用流量也能打开
125
+ 微信扫码地址: https://xxx.trycloudflare.com/pair?code=…&key=…
126
+ ```
87
127
 
88
- | 入口 | 用途 |
89
- |---|---|
90
- | `启动 AgentBridge.cmd` | 局域网模式:检查服务健康、避免重复占用 8787、后台启动并打开控制台 |
91
- | `启动 AgentBridge 远程模式.cmd` | Tailscale 模式,跨网络使用 |
92
- | `启动 AgentBridge ZeroTier 远程模式.cmd` | ZeroTier 模式,Tailscale 不稳定时使用 |
93
- | `启动 AgentBridge Cloudflare 远程模式.cmd` | Cloudflare Tunnel,手机不需装任何 App |
94
- | `安全停止 AgentBridge.cmd` | 安全停止:不误杀其它进程、有任务在跑时拒绝停止、绝不强制 kill |
128
+ 第一次运行会自动下载 `cloudflared`(约 55 MB),所以首次可能要等几秒到几十秒;之后再运行就是秒级。
129
+
130
+ ## 网络与远程接入
95
131
 
96
- 日志在 `.agentbridge/logs`;桌面快捷方式用 `.\scripts\install-agentbridge-shortcut.ps1` 创建或刷新。
132
+ ### 二维码里的地址是怎么定的
97
133
 
98
- ## 远程接入方式
134
+ CLI 和插件共用同一套逻辑(`src/remote.js`),按下面的顺序决定二维码写什么:
99
135
 
100
- | 场景 | 模式 | 手机要求 |
136
+ | 优先级 | 条件 | 二维码地址 |
101
137
  |---|---|---|
102
- | 什么都不装、也不连同一个 Wi-Fi(DSH 插件默认) | Cloudflare Tunnel(自动) | 只需浏览器 |
103
- | 同一可信 Wi-Fi | 局域网 | 浏览器 / 微信 |
104
- | 跨网络,但属同一私有网络 | Tailscale | 同一 tailnet |
105
- | Tailscale 不稳定 | ZeroTier | 同一私有网络 |
106
- | 手机装不了 VPN 类 App | Cloudflare Tunnel | 只需浏览器 |
138
+ | 1 | 设了 `AGENTBRIDGE_PUBLIC_ORIGIN` | 你给的那个(Tailscale、ZeroTier、自己的域名都走这条) |
139
+ | 2 | 已有桥正在用公网地址运行 | 复用它,不再开新通道 |
140
+ | 3 | 设了 `AGENTBRIDGE_NO_TUNNEL=1` 或 `AGENTBRIDGE_REMOTE=lan` | 局域网 |
141
+ | 4 | `AGENTBRIDGE_REMOTE` 指定了某一条(`funnel` / `named` / `tailscale` / `zerotier` / `cloudflare`) | 只试那一条 |
142
+ | 5 | 默认 `auto` | 按下面「五种远程路径」的顺序试,成功即停 |
143
+ | 6 | 全都不成立 | 局域网,日志里逐条说明每条为什么失败 |
144
+
145
+ ### 五种远程路径
146
+
147
+ `auto`(默认)的尝试顺序:**命名隧道 → Funnel → Cloudflare quick → Tailscale → ZeroTier → 局域网**。下表的“自动”指装好就能用、不需要你手动跑脚本。
148
+
149
+ | 路径 | 自动? | 手机要求 | 代价 |
150
+ |---|---|---|---|
151
+ | Cloudflare quick tunnel | ✅ 缺 `cloudflared` 会自己下 | 只需浏览器 | 域名每次重建会变 |
152
+ | 局域网 | ✅ 兜底 | 浏览器 / 微信 | 出门就不可用 |
153
+ | Tailscale(私有地址) | ✅ 已登录且在线才用 | 同一 tailnet(装 App) | 手机要常驻 VPN 类 App |
154
+ | ZeroTier(私有地址) | ✅ 检测到 ZeroTier 网卡才用 | 同一私有网络(装 App) | 同上 |
155
+ | Tailscale Funnel | 🟡 需 `AGENTBRIDGE_REMOTE=funnel` 显式开启;`auto` 只复用已开启的 | 只需浏览器 | 要在 Tailscale 管理台开启 Funnel |
156
+ | Cloudflare 命名隧道 | 🟡 需配隧道名 + 域名 | 只需浏览器 | 域名要托管在 Cloudflare |
157
+
158
+ Funnel 单独放在“要显式开启”这一档,是因为开启它会改动你 tailnet 的对外暴露配置——这种事不该由一次扫码替你决定。反过来,**已经开着的** Funnel 会被 `auto` 直接复用。
159
+
160
+ 只想用某一条路径:
161
+
162
+ ```powershell
163
+ $env:AGENTBRIDGE_REMOTE = "funnel" # 或 named | cloudflare | tailscale | zerotier | lan
164
+ agentbridge phone --agent claude
165
+ ```
166
+
167
+ 命名隧道需要两个变量:
168
+
169
+ ```powershell
170
+ $env:AGENTBRIDGE_CLOUDFLARE_TUNNEL = "agentbridge" # cloudflared 里已经建好的隧道名
171
+ $env:AGENTBRIDGE_CLOUDFLARE_HOSTNAME = "bridge.example.com"
172
+ ```
173
+
174
+ 当前路径会同时出现在三个地方:CLI 输出的「网络:」一行、桥启动日志的 `Remote path:` 一行、`/api/health` 的 `remoteProvider` 字段——二维码打不开时先看这三处。
107
175
 
108
176
  ![分镜关键帧:四条网络路径分别接入同一台 Windows Host,其中 Cloudflare 路径还要经过一道访问密钥门禁](./docs/assets/keyframe-09-network-paths.jpg)
109
177
 
110
- 任何模式都不要把 TCP 8787 直接映射到公网。Cloudflare 模式额外启用访问密钥闸门(密钥存于 `.agentbridge/access-key`),光有二维码不够。
178
+ 任何模式都**不要**把 TCP 8787 直接映射到公网。Cloudflare 模式会额外启用访问密钥闸门,光有二维码不够。
179
+
180
+ ### cloudflared 是自动的
181
+
182
+ 插件和 CLI 都会先在本机找 `cloudflared`(PATH、两处 Program Files、winget 目录、`~/.agentbridge/bin`),都找不到时才从 Cloudflare 官方的 GitHub Releases 下载一份到 `%USERPROFILE%\.agentbridge\bin\cloudflared.exe`:不要管理员权限、不写系统目录、删掉那个文件夹就等于卸载。下载完会先跑一次 `cloudflared --version`,验不过的文件不会留下。
183
+
184
+ 想自己装也行(`winget install --id Cloudflare.cloudflared`),已装的那份优先。断网或代理挡住下载时,一切照常,只是退回局域网。
111
185
 
112
186
  ### 访问密钥:会不会变、怎么换
113
187
 
114
- 密钥就是一句口令,不是账号。门禁只在**用 Cloudflare 隧道启动时**打开——其余模式(局域网 / Tailscale / ZeroTier)默认没有门禁,也就不会有人问你密钥。门禁打开时,所有非本机(loopback)请求都必须带上它:二维码里已内嵌 `?key=…`,手机用它换一个 90 天有效的 cookie(`agentbridge_key`)。
188
+ 密钥就是一句口令,不是账号。它只在**对公网开放时**生效(quick tunnel / 命名隧道);局域网、Tailscale、ZeroTier 默认没有门禁,也就不会有人问你密钥。门禁打开时,所有非本机(loopback)请求都必须带上它:二维码里已内嵌 `?key=…`,手机用它换一个 90 天有效的 cookie(`agentbridge_key`)。
189
+
190
+ 密钥文件有两个位置,取决于谁在开隧道:
115
191
 
116
- **默认是固定的。** `scripts/start-agentbridge.ps1` 里的 `Get-OrCreateAccessKey` 先读 `.agentbridge/access-key`:只要文件存在、内容非空且不短于 16 个字符,就原样复用。所以重启、换端口、换隧道地址都不会改它。会变的情况只有三种:
192
+ | 谁来开隧道 | 密钥文件 |
193
+ |---|---|
194
+ | CLI / DSH 插件(默认路径) | `%USERPROFILE%\.agentbridge\access-key` |
195
+ | 仓库里的一键脚本 `.cmd` | `<仓库>\.agentbridge\access-key` |
117
196
 
118
- 1. 删掉(或清空、或写短于 16 个字符)`.agentbridge/access-key`;
119
- 2. 换了一份代码目录(文件跟着目录走,不同 `$bridgeRoot` 各有一把);
120
- 3. 该目录第一次以 Cloudflare 模式启动。
197
+ 两者都是**先读后写**:文件存在、内容非空且不短于 16 个字符就原样复用,所以重启、换端口、换隧道地址都不会改它。会变的只有三种情况:删掉(或清空、或写短于 16 个字符)该文件;换了一份代码目录;该目录第一次以 Cloudflare 模式启动。
121
198
 
122
199
  **换法**(三步,不需要改代码):
123
200
 
124
201
  ```powershell
125
- scripts\stop-agentbridge.ps1 # 1. 停桥
126
- Remove-Item .agentbridge\access-key # 2a. 让下次启动生成新串
202
+ # 1. 停掉桥(安全停止脚本,或结束占用 8787 的进程)
203
+ scripts\stop-agentbridge.ps1
204
+
205
+ # 2a. 让下次启动生成新串
206
+ Remove-Item "$env:USERPROFILE\.agentbridge\access-key"
127
207
  # 或者直接写自己的口令(≥16 字符,别加换行):
128
- # "my-own-long-passphrase" | Set-Content .agentbridge\access-key -NoNewline -Encoding ascii
129
- scripts\start-agentbridge.ps1 -CloudflareTunnel # 3. 启动,窗口会打印新串
208
+ # "my-own-long-passphrase" | Set-Content "$env:USERPROFILE\.agentbridge\access-key" -NoNewline -Encoding ascii
209
+
210
+ # 3. 重启(重启 DSH Desktop,或再跑一次 agentbridge phone)
130
211
  ```
131
212
 
132
- 换完所有手机都要重新扫码或用新串(旧 cookie 不再匹配)。注意 Cloudflare 模式下脚本会**无条件**用文件里的值覆盖 `AGENTBRIDGE_ACCESS_KEY` 环境变量,所以“只设环境变量”在那里不管用——想自定义口令就写进文件;非隧道模式下才是靠环境变量。
213
+ 换完所有手机都要重新扫码(旧 cookie 不再匹配)。`.agentbridge/` 已在 `.gitignore` 中,密钥不会进版本库;但也别把带 `?key=` 的完整链接贴到公开场合。推送通知的跳转链接默认**不带**密钥(`notifyLink` 需要显式开启),所以换钥匙不影响通知。
214
+
215
+ ### 局域网地址是怎么挑的
216
+
217
+ 自动选地址时会跳过 `169.254.x.x`(网卡没拿到租约时的临时地址,手机根本路由不到)和 Hyper-V / WSL / 蓝牙 / Wi-Fi Direct 这类虚拟网卡,优先真实的 Wi-Fi 或以太网地址。扫码后如果微信弹出「该地址为 IP 地址,请使用域名访问网站」,点「继续访问」即可。
218
+
219
+ ## 一键启动与停止(Windows)
220
+
221
+ | 入口 | 用途 |
222
+ |---|---|
223
+ | `启动 AgentBridge.cmd` | 局域网模式:检查服务健康、避免重复占用 8787、后台启动并打开控制台 |
224
+ | `启动 AgentBridge 远程模式.cmd` | Tailscale 模式,跨网络使用 |
225
+ | `启动 AgentBridge ZeroTier 远程模式.cmd` | ZeroTier 模式,Tailscale 不稳定时使用 |
226
+ | `启动 AgentBridge Cloudflare 远程模式.cmd` | Cloudflare Tunnel,手机不需装任何 App(不做自动下载,缺 `cloudflared` 会直接报错并提示安装) |
227
+ | `安全停止 AgentBridge.cmd` | 安全停止:不误杀其它进程、有任务在跑时拒绝停止、绝不强制 kill |
228
+
229
+ 这些脚本自己设置 `AGENTBRIDGE_PUBLIC_ORIGIN` 和访问密钥,和 CLI / 插件的自动模式**不要同时用**:同一端口只能有一个桥在跑,后启动的那个会连上已有实例而不是另起一个。
133
230
 
134
- `.agentbridge/` 已在 `.gitignore` 中,密钥不会进版本库;但也别把带 `?key=` 的完整链接贴到公开场合。推送通知的跳转链接默认**不带**密钥(`notifyLink` 需要显式开启),所以换钥匙不会影响通知。
231
+ 日志在 `<仓库>\.agentbridge\logs`;桌面快捷方式用 `.\scripts\install-agentbridge-shortcut.ps1` 创建或刷新。
135
232
 
136
233
  ## 让 Agent 支持 `/phone`
137
234
 
138
- `/phone` 不是 Agent 自带的功能,而是 AgentBridge 往对应客户端里装的一个**技能**(一个 `SKILL.md` 文件)。它只做一件事:在**当前项目目录**里调用 `agentbridge phone --agent <id>`,由 CLI 确保 Bridge 在运行、创建或附加会话、打印一次性二维码。
235
+ `/phone` 不是 Agent 自带的功能,而是 AgentBridge 往对应客户端里装的一个**技能**(一个 `SKILL.md` 文件)。它只做一件事:在**当前项目目录**里调用 `agentbridge phone --agent <id>`,由 CLI 确保 Bridge 在运行、准备公网地址、创建或附加会话、打印一次性二维码。
139
236
 
140
237
  三个前提,每个 Agent 都一样:
141
238
 
@@ -145,16 +242,18 @@ scripts\start-agentbridge.ps1 -CloudflareTunnel # 3. 启动,窗口会打
145
242
 
146
243
  技能是纯文本文件,删掉对应的 `SKILL.md` 就等于卸载。
147
244
 
148
- 二维码默认是**公网地址**(走 Cloudflare 隧道,手机用流量也能扫)。隧道要用的 `cloudflared` 缺了就自动下载到 `~/.agentbridge/bin`,和 DSH 插件共用同一套逻辑、同一个隧道和同一把访问密钥——DSH 那边已经开着隧道的话,CLI 直接复用,不会再起一条。只想在局域网用就设 `AGENTBRIDGE_NO_TUNNEL=1`;已经有自己的公网入口(命名隧道、Tailscale、ZeroTier)就设 `AGENTBRIDGE_PUBLIC_ORIGIN`。
245
+ 二维码默认是**公网地址**,三条路径共用同一套逻辑、同一条隧道、同一把访问密钥:`cloudflared` 缺了就自动下载;DSH 插件已经开着隧道时,CLI 直接复用它,不会再起一条。只想在局域网用就设 `AGENTBRIDGE_NO_TUNNEL=1`;已有自己的公网入口就设 `AGENTBRIDGE_PUBLIC_ORIGIN`。
246
+
247
+ > 一个例外:如果本机已经有一个**以局域网地址**运行的桥(比如很久以前启动的旧进程),CLI 不会擅自重启它——那会打断正在跑的会话——只会提示本次沿用那个地址。停掉它或重启 DSH Desktop 即可换成公网地址。
149
248
 
150
249
  ### Claude Code
151
250
 
152
251
  ```powershell
153
252
  # 用户级(默认):%USERPROFILE%\.claude\skills\phone\SKILL.md
154
- node .\bin\agentbridge.js install-claude-skill --user
253
+ agentbridge install-claude-skill --user
155
254
 
156
255
  # 只装给某个项目:<项目>\.claude\skills\phone\SKILL.md
157
- node .\bin\agentbridge.js install-claude-skill --project --cwd D:\projects\agentbridge
256
+ agentbridge install-claude-skill --project --cwd D:\projects\agentbridge
158
257
  ```
159
258
 
160
259
  重启 Claude Code,在项目目录输入 `/phone`。技能用官方 `${CLAUDE_SESSION_ID}` 绑定**当前这段对话**,不读私有 transcript、不猜路径;只预授权 `agentbridge phone --agent claude …` 这一条命令,不授予通用 Bash 权限。
@@ -165,24 +264,20 @@ node .\bin\agentbridge.js install-claude-skill --project --cwd D:\projects\agent
165
264
 
166
265
  ```powershell
167
266
  # 用户级(默认):%USERPROFILE%\.agents\skills\phone\SKILL.md
168
- node .\bin\agentbridge.js install-codex-skill --user
267
+ agentbridge install-codex-skill --user
169
268
  ```
170
269
 
171
270
  重启 Codex 桌面项目,在本地项目会话输入 `/phone`。Codex 没对外提供「当前线程 ID」,所以不带参数时它会在同一项目目录**新建**一个会话并明确标注;只有你已知 thread ID 时才精确附加:
172
271
 
173
272
  ```powershell
174
- node .\bin\agentbridge.js phone --agent codex --session <thread-id>
273
+ agentbridge phone --agent codex --session <thread-id>
175
274
  ```
176
275
 
177
276
  ### DSH Desktop
178
277
 
179
278
  前提:DSH Desktop 已安装,且存在桌面 profile `%USERPROFILE%\.dsh\profiles\desktop\package.json`(或由 `DSH_HOME` 指定)。
180
279
 
181
- **第 0 步:桥。** 插件是遥控器,桥是基站。从 0.2.0 起桥随插件一起安装,**DSH Desktop 启动时插件会自己在后台把它拉起来**,正常情况你什么都不用敲。
182
-
183
- 从 0.3.0 起,插件默认再开一条 **Cloudflare 隧道**,把桥映射成公网 https 域名,于是手机不必和电脑在同一个 Wi-Fi——扫码、出差、用流量都能开。隧道要用的 `cloudflared`(Cloudflare 官方客户端)同样是自动的:先找本机已有的那份,找不到就自己从官方 GitHub Releases 下到 `~/.agentbridge/bin`(约 55 MB,只下一次,不要管理员权限,下载后跑一次 `cloudflared --version` 验证才留下)。
184
-
185
- 想自己装也行:`winget install --id Cloudflare.cloudflared`。下不动(断网、公司代理)照样能用,只是退回"手机与电脑同一 Wi-Fi"。想一直走局域网,设 `AGENTBRIDGE_NO_TUNNEL=1`;想用固定域名,去 Cloudflare 建命名隧道并把 `AGENTBRIDGE_PUBLIC_ORIGIN` 指向它。
280
+ **第 0 步:桥。** 插件是遥控器,桥是基站。从 0.2.0 起桥随插件一起安装,**DSH Desktop 启动时插件会自己在后台把它拉起来**;从 0.3.0 起还会再自动开 Cloudflare 隧道。所以正常情况你什么都不用敲,重启桌面等几秒后直接 `/phone`。
186
281
 
187
282
  只有自动启动失败(沙箱限制、端口被占)才需要手动来一条:
188
283
 
@@ -190,21 +285,15 @@ node .\bin\agentbridge.js phone --agent codex --session <thread-id>
190
285
  npx @chenacc/agentbridge
191
286
  ```
192
287
 
193
- 想长期使用、或要改配置(项目白名单、远程通道、通知推送),装成全局命令:
194
-
195
- ```powershell
196
- npm install -g @chenacc/agentbridge # 之后随时 agentbridge phone --agent claude
197
- ```
198
-
199
- 也可以从仓库跑:`git clone https://github.com/chenacc1/agentbridge.git` → `cd agentbridge` → `npm install` → `npm start`(或双击「启动 AgentBridge.cmd」)。
200
-
201
- **第 1 步:装插件(普通用户走市场)**:设置 → 插件市场 → 发现,搜 `AgentBridge` 或 `@chenacc/dsh-agentbridge` → 安装 → 重启 DSH Desktop。等价的命令行安装:
288
+ **第 1 步:装插件(普通用户走市场)**:设置 → 插件市场 → 发现,搜 `AgentBridge` 或 `@chenacc/dsh-agentbridge` → 安装 → 重启 DSH Desktop。等价的手动安装:
202
289
 
203
290
  ```powershell
204
291
  cd "$env:USERPROFILE\.dsh\profiles\desktop"; pnpm add @chenacc/dsh-agentbridge@latest
205
292
  # 装完还需把 "@chenacc/dsh-agentbridge" 加进该 package.json 的 dsh.profile.bundles(市场安装会自动做)
206
293
  ```
207
294
 
295
+ > 市场卡片上显示的版本号来自插件目录快照,每天刷新一次;实际安装拉的是 npm 的 `latest`,所以卡片版本可能比装到的版本旧一天。
296
+
208
297
  **从本地源码装(开发者)**:
209
298
 
210
299
  ```powershell
@@ -228,26 +317,25 @@ npm run uninstall:dsh-phone
228
317
  前提:官方 `dsh` CLI 已安装并配置好模型与凭据,`dsh --profile acp --help` 能打印帮助(而不是弹出浏览器界面)。这个适配器没有斜杠技能,直接用 CLI:
229
318
 
230
319
  ```powershell
231
- node .\bin\agentbridge.js phone --agent deepseek-harness
320
+ agentbridge phone --agent deepseek-harness
232
321
  ```
233
322
 
234
323
  ### 一张表汇总
235
324
 
236
- | Agent | 前提 | 要执行的安装命令 | 重启客户端 | 入口 |
237
- |---|---|---|---|---|
238
- | Claude Code | 已安装并登录 | `install-claude-skill --user` | 需要 | `/phone` |
239
- | Codex Desktop | 已安装并登录 | `install-codex-skill --user` | 需要 | `/phone` |
240
- | DSH Desktop | 存在桌面 profile | `npm run install:dsh-phone` | 需要 | `/phone` |
241
- | DeepSeek Harness | `dsh --profile acp --help` 可用 | 不需要 | — | `phone --agent deepseek-harness` |
242
- | Echo | 无 | 不需要 | — | `phone --agent echo` |
243
- | ZCode | 仅官方 Remote Control / Bot Channel | 不需要 | — | AgentBridge 不代管 |
325
+ | Agent | 前提 | 要执行的安装命令 | 重启客户端 | 入口 | 二维码默认地址 |
326
+ |---|---|---|---|---|---|
327
+ | Claude Code | 已安装并登录 | `agentbridge install-claude-skill --user` | 需要 | `/phone` | 公网 |
328
+ | Codex Desktop | 已安装并登录 | `agentbridge install-codex-skill --user` | 需要 | `/phone` | 公网 |
329
+ | DSH Desktop | 存在桌面 profile | 插件市场安装(或 `pnpm add`) | 需要 | `/phone` | 公网 |
330
+ | DeepSeek Harness | `dsh --profile acp --help` 可用 | 不需要 | — | `phone --agent deepseek-harness` | 公网 |
331
+ | Echo | 无 | 不需要 | — | `phone --agent echo` | 公网 |
332
+ | ZCode | 仅官方 Remote Control / Bot Channel | 不需要 | — | AgentBridge 不代管 | — |
244
333
 
245
334
  ### 通用说明
246
335
 
247
336
  - `/phone` 只接受来自本机 loopback 的绝对路径,手机会话无法提交任意目录。
248
337
  - 想让 `agentbridge` 成为全局命令,在仓库里跑一次 `npm link`。
249
- - 仓库目前尚未创建 `v0.1.0` 标签;打上固定标签后,别人可用一行装好 CLI 与 Codex 技能:
250
- `npm install --global github:chenacc1/agentbridge#v0.1.0; agentbridge setup codex`
338
+ - 想从固定版本装:`npm install --global github:chenacc1/agentbridge#bridge-v0.1.4`,然后 `agentbridge setup codex` 装技能。
251
339
 
252
340
  ## 支持的 Agent
253
341
 
@@ -278,19 +366,45 @@ node .\bin\agentbridge.js phone --agent deepseek-harness
278
366
 
279
367
  完整变量见 [`.env.example`](./.env.example)。
280
368
 
369
+ **桥本身:**
370
+
281
371
  | 变量 | 默认值 | 含义 |
282
372
  |---|---|---|
283
373
  | `AGENTBRIDGE_PORT` | `8787` | HTTP 监听端口 |
374
+ | `AGENTBRIDGE_HOST` | `0.0.0.0` | 监听地址 |
284
375
  | `AGENTBRIDGE_PROJECTS` | 当前目录 | 项目白名单,分号分隔 `别名=绝对路径` |
285
- | `AGENTBRIDGE_PUBLIC_ORIGIN` | 第一个 LAN 地址 | 写入二维码的访问地址 |
376
+ | `AGENTBRIDGE_DATA_DIR` | `~/.agentbridge`(CLI / 插件)/ 启动目录(`npm start`) | 会话、事件、密钥、隧道状态的目录 |
377
+ | `AGENTBRIDGE_PUBLIC_ORIGIN` | 自动(公网隧道或局域网地址) | 写进二维码的访问地址;设了它就不再自动开隧道 |
378
+ | `AGENTBRIDGE_ACCESS_KEY` | 未设置 | 访问密钥;对公网开放时自动生成并写入 `access-key` 文件 |
286
379
  | `AGENTBRIDGE_PAIRING_TTL_MS` | `300000` | 配对码有效期(5 分钟) |
287
380
  | `AGENTBRIDGE_SESSION_TTL_MS` | `259200000` | 设备会话有效期(72 小时) |
288
381
  | `AGENTBRIDGE_CLAUDE_MODEL` | 供应商默认 | 可选的 Claude / Kimi 模型 ID |
289
382
  | `AGENTBRIDGE_NOTIFY_WEBHOOK` | 未设置 | 通知 Webhook;未设置时该功能静默 |
290
383
 
291
- 若自动选择的局域网地址手机访问不到,显式指定 `AGENTBRIDGE_PUBLIC_ORIGIN`;Windows 防火墙提示时只放行「专用网络」。
384
+ **跨网络(CLI 与插件都认):**
385
+
386
+ | 变量 | 默认值 | 含义 |
387
+ |---|---|---|
388
+ | `AGENTBRIDGE_REMOTE` | `auto` | 走哪条路径:`auto` / `cloudflare` / `named` / `funnel` / `tailscale` / `zerotier` / `lan` |
389
+ | `AGENTBRIDGE_NO_TUNNEL` | 未设置 | 设为 `1` 等价于 `AGENTBRIDGE_REMOTE=lan` |
390
+ | `AGENTBRIDGE_TUNNEL_URL` | 未设置 | 直接指定隧道域名(给"隧道自己管"的人) |
391
+ | `AGENTBRIDGE_CLOUDFLARE_TUNNEL` | 未设置 | Cloudflare **命名隧道**的名字;设了它 `auto` 会优先用它 |
392
+ | `AGENTBRIDGE_CLOUDFLARE_HOSTNAME` | 未设置 | 命名隧道对应的公网域名 |
393
+ | `AGENTBRIDGE_CLOUDFLARED` | 自动查找 | 用你自己那份 `cloudflared`;设了它既不搜索也不下载 |
394
+ | `AGENTBRIDGE_CLOUDFLARED_URL` | 官方 Releases | 换下载源(内网镜像),下载后照样过 `--version` 校验 |
395
+ | `AGENTBRIDGE_NO_DOWNLOAD` | 未设置 | 设为 `1` 禁止自动下载 `cloudflared` |
396
+ | `AGENTBRIDGE_TAILSCALE` | 自动查找 | 指定 `tailscale` 可执行文件(装在非常规位置时用) |
397
+ | `AGENTBRIDGE_ZEROTIER` | 自动检测网卡 | 直接指定 ZeroTier 分配到的 IPv4 |
398
+
399
+ **插件 / CLI 专用:**
400
+
401
+ | 变量 | 默认值 | 含义 |
402
+ |---|---|---|
403
+ | `AGENTBRIDGE_ORIGIN` | `http://127.0.0.1:8787` | 插件连接的本地桥地址 |
404
+ | `AGENTBRIDGE_LOCAL_ORIGIN` | `http://127.0.0.1:8787` | CLI 连接的本地桥地址 |
405
+ | `AGENTBRIDGE_NO_AUTOSTART` | 未设置 | 设为 `1` 关掉"插件加载时自动起桥" |
292
406
 
293
- 自动选地址时会跳过 `169.254.x.x`(网卡没拿到租约时的临时地址,手机根本路由不到)和 Hyper-V / WSL / 蓝牙这类虚拟网卡,优先真实的 Wi-Fi 或以太网地址。扫码后如果微信弹出「该地址为 IP 地址,请使用域名访问网站」,点「继续访问」即可;手机上不了网、或者只有手机流量没连同一个 Wi-Fi 时,二维码里的地址是打不开的。
407
+ 变量要设在**真正启动 DSH Desktop / 终端的环境里**,不是设在跑桥的另一个窗口里。
294
408
 
295
409
  ## 安全边界
296
410
 
@@ -298,21 +412,54 @@ node .\bin\agentbridge.js phone --agent deepseek-harness
298
412
 
299
413
  - 二维码里是一次性配对码,不是 Agent 凭据;磁盘只保存设备 token 的 SHA-256 摘要。
300
414
  - 设备 cookie 为 `HttpOnly` + `SameSite=Strict`;修改型请求校验同源,命令 ID 防重放,同一会话同时只有一台设备持有输入租约。
415
+ - 对公网开放时访问密钥强制开启,非 loopback 请求一律要求带密钥;密钥不写进命令行、不进日志。
416
+ - 桥的本地管理接口(配对、注册、停止、`cloudflared` 状态)只接受 loopback;即便是同一台机器上的"要求桥重启"这类动作,也要带上插件在启动时生成的一次性 token。
301
417
  - Agent 凭据与子进程始终留在电脑上,手机永远不是执行环境。
302
418
  - 当前不提供多用户授权、静态加密事件日志、重启后活动子进程恢复或云端 Relay。
303
419
 
420
+ > 默认公网可用意味着**二维码等同于钥匙**:别截图外传,也别把带 `?key=` 的链接贴到公开场合。只想在家里用时设 `AGENTBRIDGE_NO_TUNNEL=1`。
421
+
304
422
  ## 验证
305
423
 
306
424
  ```powershell
307
- npm run check # 语法检查
308
- npm test # 单元与集成测试(当前 187 项全部通过)
425
+ npm run check # 语法检查(38 个文件)
426
+ npm test # 单元与集成测试(当前 240 项全部通过)
427
+ npm run verify:release # 插件发布验收(17 项:registry / tarball / 可导入性)
428
+ npm run verify:remote # 端到端验证 Cloudflare 远程链路,需要 cloudflared 与网络
309
429
  npm run verify:mobile # 可选:真实浏览器端到端,需要 Chrome 或 Edge
310
430
  ```
311
431
 
312
- 其余 `verify:*` 脚本(`navigation`、`models`、`scrim`、`notify`、`attach`、`features`、`blocks`、`questions`、`quicknav`)各自启动真实 Bridge 与无头浏览器做界面验证。
432
+ 其余 `verify:*` 脚本(`navigation`、`models`、`scrim`、`notify`、`attach`、`features`、`blocks`、`questions`、`quicknav`、`responsive`、`guide`)各自启动真实 Bridge 与无头浏览器做界面验证。
433
+
434
+ ## 版本与发布
435
+
436
+ 两个 npm 包,各自独立发版,都由 GitHub Actions 用 tag 触发(OIDC 可信发布,带 SLSA provenance):
437
+
438
+ | 包 | 当前版本 | tag 前缀 | 装到哪 |
439
+ |---|---|---|---|
440
+ | `@chenacc/agentbridge` | `0.2.0` | `bridge-v*` | 全局命令 / `npx` / 插件依赖 |
441
+ | `@chenacc/dsh-agentbridge` | `0.4.0` | `dsh-v*` | DSH Desktop 的 profile |
442
+
443
+ 最近的阶段性变化:
444
+
445
+ - **0.2.0(桥)**:路径选择抽成 provider(`src/remote.js`)——Cloudflare quick / 命名隧道 / Tailscale / Tailscale Funnel / ZeroTier 共用一套顺序与回退,`AGENTBRIDGE_REMOTE` 可指定单条;`/api/health` 与启动日志会报出当前走的是哪条。
446
+ - **0.1.1–0.1.4(桥)**:二维码地址改按"能不能被手机路由到"排序(跳过 `169.254` 与虚拟网卡);`/api/health` 汇报自己广播的地址;新增只有本机 token 能调的自动重启入口;隧道能力下沉到本包,CLI 的 `/phone` 因此也默认公网。
447
+ - **0.4.0(插件)**:改用桥包里的同一套路径选择,本地行为不变但多了 Funnel / Tailscale / ZeroTier 三条可选路径。
448
+ - **0.3.0–0.3.3(插件)**:加载时自动起桥、自动开 Cloudflare 隧道、`cloudflared` 缺失时自动下载。
449
+
450
+ 升级方式:
451
+
452
+ ```powershell
453
+ npm install -g @chenacc/agentbridge@latest # CLI / 桥
454
+ cd "$env:USERPROFILE\.dsh\profiles\desktop"
455
+ pnpm add @chenacc/dsh-agentbridge@latest # DSH 插件(或直接在插件市场点安装)
456
+ ```
313
457
 
314
458
  ## 更多文档
315
459
 
316
460
  - 架构说明:[`docs/architecture/wechat-multi-agent-remote-control.md`](./docs/architecture/wechat-multi-agent-remote-control.md)
317
461
  - 功能与前端执行逻辑:[`docs/软件功能与前端执行逻辑.md`](./docs/软件功能与前端执行逻辑.md)
318
- - 发布流程:[`docs/RELEASING.md`](./docs/RELEASING.md)
462
+ - 发布流程:[`docs/PUBLISHING.md`](./docs/PUBLISHING.md)
463
+ - 安装验收清单:[`docs/verify-plugin-install.md`](./docs/verify-plugin-install.md)
464
+ - 市场收录记录:[`docs/market-submission.md`](./docs/market-submission.md)
465
+ - 版本与更新记录:[`docs/RELEASING.md`](./docs/RELEASING.md)
package/docs/RELEASING.md CHANGED
@@ -1,21 +1,31 @@
1
1
  # Releasing AgentBridge
2
2
 
3
- This repository can be installed directly from a tagged public GitHub release. It does not require npm publication for the first public release.
3
+ This repository can be installed directly from a tagged public GitHub release. Both npm packages are now published as well, and those tags are what the release workflow watches.
4
+
5
+ ## Current state
6
+
7
+ | Package | Version | Tag prefix | Published by |
8
+ |---|---|---|---|
9
+ | `@chenacc/agentbridge` (bridge + CLI) | 0.1.4 | `bridge-v*` | GitHub Actions on tag push, OIDC trusted publishing, SLSA provenance |
10
+ | `@chenacc/dsh-agentbridge` (DSH plugin) | 0.3.3 | `dsh-v*` | same workflow |
11
+
12
+ Pushing `bridge-v0.1.5` publishes the bridge; pushing `dsh-v0.3.4` publishes the plugin. See [`PUBLISHING.md`](./PUBLISHING.md) for the full procedure and the market side of a release.
4
13
 
5
14
  ## GitHub release checklist
6
15
 
7
16
  1. Choose and add an open-source license. Do not publish before this choice is explicit.
8
- 2. Create a Git repository and confirm no runtime data is staged. `.env`, `.agentbridge/`, logs, and `node_modules/` must remain excluded.
17
+ 2. Create a Git repository and confirm no runtime data is staged. `.env`, `.agentbridge/`, `~/.agentbridge/`, logs, and `node_modules/` must remain excluded.
9
18
  3. Run the release checks from the repository root:
10
19
 
11
20
  ```powershell
12
21
  npm ci
13
22
  npm run check
14
23
  npm test
24
+ npm run verify:release # plugin packaging acceptance, 17 checks
15
25
  npm pack --dry-run
16
26
  ```
17
27
 
18
- 4. Create a versioned commit and annotated tag such as `v0.1.0`, then push both the branch and tag to the public GitHub repository.
28
+ 4. Create a versioned commit and annotated tag — `bridge-v0.1.5` for the bridge, `dsh-v0.3.4` for the plugin — then push both the branch and tag to the public GitHub repository.
19
29
  5. On a clean machine with Node.js 22+ and Codex installed, verify the installation command below, restart Codex, and invoke `/phone` from a local-project conversation.
20
30
 
21
31
  ## User installation
@@ -23,13 +33,13 @@ This repository can be installed directly from a tagged public GitHub release. I
23
33
  Windows PowerShell:
24
34
 
25
35
  ```powershell
26
- npm install --global github:<github-owner>/<repository>#v0.1.0; agentbridge setup codex
36
+ npm install --global github:<github-owner>/<repository>#bridge-v0.1.4; agentbridge setup codex
27
37
  ```
28
38
 
29
39
  macOS/Linux:
30
40
 
31
41
  ```bash
32
- npm install --global github:<github-owner>/<repository>#v0.1.0 && agentbridge setup codex
42
+ npm install --global github:<github-owner>/<repository>#bridge-v0.1.4 && agentbridge setup codex
33
43
  ```
34
44
 
35
45
  `agentbridge setup codex` writes the user-level Codex skill. The package's CLI is installed globally and the generated skill records the installed CLI path, so do not replace this workflow with a temporary `npx` execution.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenacc/agentbridge",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Control local coding agents from a phone through a secure WeChat QR web console.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,8 @@
37
37
  ".": "./src/server.js",
38
38
  "./server": "./src/server.js",
39
39
  "./tunnel": "./src/tunnel.js",
40
- "./package.json": "./package.json"
40
+ "./package.json": "./package.json",
41
+ "./remote": "./src/remote.js"
41
42
  },
42
43
  "engines": {
43
44
  "node": ">=22"
@@ -64,7 +65,7 @@
64
65
  "verify:responsive": "node scripts/verify-responsive.mjs",
65
66
  "verify:guide": "node scripts/verify-product-guide.mjs",
66
67
  "verify:release": "node scripts/verify-plugin-release.mjs",
67
- "check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs",
68
+ "check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/remote.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs",
68
69
  "verify:remote": "node scripts/verify-remote-tunnel.mjs"
69
70
  },
70
71
  "dependencies": {
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import QRCode from "qrcode";
8
- import { ensureAccessKey, ensureCloudflared, findCloudflared, startQuickTunnel } from "./tunnel.js";
8
+ import { parseMode, resolveRemote } from "./remote.js";
9
9
 
10
10
  const sourceDir = path.dirname(fileURLToPath(import.meta.url));
11
11
  const bridgeRoot = path.resolve(sourceDir, "..");
@@ -137,57 +137,28 @@ function bridgeDataDir() {
137
137
  }
138
138
 
139
139
  /**
140
- * The public address the QR code should carry, if this run is going to have one.
140
+ * The address the QR code should carry, if this run is going to have one.
141
141
  *
142
- * Returns null when the machine should stay on the LAN: the user opted out, the
143
- * bridge is already running under someone else's address, or no tunnel could be
144
- * brought up. Every one of those is a normal outcome — same-Wi-Fi pairing still
145
- * works — so the reason is printed instead of thrown.
142
+ * The path itself is chosen by `src/remote.js`, which the DSH plugin shares — so
143
+ * "which network am I on" has one answer in both entry points. Returns null when
144
+ * the machine should stay on the LAN: the user opted out, the bridge is already
145
+ * running under someone else's address, or no path could be brought up. Every one
146
+ * of those is a normal outcome — same-Wi-Fi pairing still works — so the reason is
147
+ * logged instead of thrown.
146
148
  */
147
149
  export async function resolvePublicOrigin({ health, log = () => {}, env = process.env, dataDir = bridgeDataDir(), timeoutMs = 40_000 } = {}) {
148
- const explicit = String(env.AGENTBRIDGE_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
149
- if (explicit) return { origin: explicit, accessKey: null, how: "AGENTBRIDGE_PUBLIC_ORIGIN" };
150
-
151
- const supplied = String(env.AGENTBRIDGE_TUNNEL_URL ?? "").trim().replace(/\/$/, "");
152
- if (supplied) return { origin: supplied, accessKey: ensureAccessKey(dataDir), how: "AGENTBRIDGE_TUNNEL_URL" };
153
-
154
- if (env.AGENTBRIDGE_NO_TUNNEL === "1") {
155
- log("AGENTBRIDGE_NO_TUNNEL=1: the QR code will only work on this Wi-Fi");
156
- return null;
157
- }
158
150
  // An already-running bridge owns its address; asking it to move would mean
159
151
  // restarting a service the user may be using right now.
160
- if (health) {
152
+ if (health && !env.AGENTBRIDGE_PUBLIC_ORIGIN && !env.AGENTBRIDGE_TUNNEL_URL && parseMode(env) !== "lan") {
161
153
  const advertised = String(health.advertisedOrigin ?? "");
162
- if (describeOrigin(advertised) === "public") return { origin: advertised, accessKey: null, how: "already running" };
154
+ if (describeOrigin(advertised) === "public") return { origin: advertised, accessKey: null, provider: "already-running", label: "已运行的桥", how: "already running" };
163
155
  log(`AgentBridge 已经在以局域网地址运行(${advertised || "unknown"}),本次二维码沿用这个地址。想换公网地址:先停掉它再重跑。`);
164
156
  return null;
165
157
  }
166
158
 
167
- let command = findCloudflared({ env, home: os.homedir() });
168
- if (!command) {
169
- log("没有找到 cloudflared,正在从 Cloudflare 官方 Releases 下载(只下这一次)…");
170
- command = await ensureCloudflared({ dataDir, env, log });
171
- }
172
- if (!command) {
173
- log("仍然没有 cloudflared,二维码只能用在这台电脑所在 Wi-Fi 里。");
174
- return null;
175
- }
176
-
177
- const port = new URL(`http://127.0.0.1:${env.AGENTBRIDGE_PORT ?? "8787"}`).port;
178
- try {
179
- const tunnel = await startQuickTunnel({
180
- command,
181
- origin: `http://127.0.0.1:${port}`,
182
- dataDir,
183
- log,
184
- timeoutMs,
185
- });
186
- return { origin: tunnel.url, accessKey: ensureAccessKey(dataDir), how: tunnel.reused ? "cloudflare(复用已有隧道)" : "cloudflare" };
187
- } catch (error) {
188
- log(`Cloudflare 隧道没起来,二维码只能用在这台电脑所在 Wi-Fi 里:${error instanceof Error ? error.message : String(error)}`);
189
- return null;
190
- }
159
+ const decided = await resolveRemote({ env, dataDir, log, port: String(env.AGENTBRIDGE_PORT ?? "8787") });
160
+ if (!decided) return null;
161
+ return { ...decided, how: decided.detail ?? decided.provider };
191
162
  }
192
163
 
193
164
  async function startBridge(origin, env = {}) {
@@ -211,12 +182,16 @@ async function ensureBridge(origin, autoStart, log = () => {}) {
211
182
  if (health) return health;
212
183
  if (!autoStart) throw new Error(`Bridge is not running at ${origin}`);
213
184
 
214
- // The tunnel has to exist before the bridge starts, because the address is
215
- // baked into the pairing URL at startup — the exact opposite of the DSH
216
- // plugin, which keeps a local bridge up and restarts it once a tunnel exists.
185
+ // The path has to be chosen before the bridge starts, because the address is
186
+ // baked into the pairing URL at startup — the exact opposite of the DSH plugin,
187
+ // which keeps a local bridge up and restarts it once the address changes.
217
188
  const remote = await resolvePublicOrigin({ log });
218
- const env = remote?.accessKey
219
- ? { AGENTBRIDGE_PUBLIC_ORIGIN: remote.origin, AGENTBRIDGE_ACCESS_KEY: remote.accessKey }
189
+ const env = remote
190
+ ? {
191
+ AGENTBRIDGE_PUBLIC_ORIGIN: remote.origin,
192
+ AGENTBRIDGE_REMOTE_PROVIDER: remote.provider ?? "remote",
193
+ ...(remote.accessKey ? { AGENTBRIDGE_ACCESS_KEY: remote.accessKey } : {}),
194
+ }
220
195
  : {};
221
196
  if (await startBridge(origin, env)) {
222
197
  if (remote) log(`公网地址就绪:${remote.origin}(${remote.how})`);
@@ -255,8 +230,12 @@ async function requestPhone(options) {
255
230
  return result;
256
231
  }
257
232
  const qr = await QRCode.toString(result.pairing.url, { type: "terminal", small: true, errorCorrectionLevel: "M" });
233
+ // Naming the path matters: "it worked yesterday" usually means a different
234
+ // provider won this time, or none did. The bridge reports which one it was
235
+ // started with, so this stays right even when the tunnel is reused.
236
+ const providerLabel = health?.remoteProvider ?? null;
258
237
  const network = describeOrigin(result.pairing.url) === "public"
259
- ? "公网地址,手机用流量也能打开"
238
+ ? `公网地址,手机用流量也能打开${providerLabel ? `(${providerLabel})` : ""}`
260
239
  : "局域网地址,手机需与电脑连同一个 Wi-Fi";
261
240
  console.log(`\nAgentBridge 已绑定当前对话`);
262
241
  console.log(`Agent: ${adapterId}`);
@@ -270,7 +249,11 @@ async function requestPhone(options) {
270
249
  console.log(`二维码图片 Markdown: ![微信扫码二维码](${result.qrImageUrl})`);
271
250
  }
272
251
  console.log(`微信扫码地址: ${result.pairing.url}`);
273
- console.log(`电脑端会话: ${origin}/?session=${encodeURIComponent(result.session.id)}\n`);
252
+ console.log(`电脑端会话: ${origin}/?session=${encodeURIComponent(result.session.id)}`);
253
+ // Consent-based and once, at the very end: no network call, no token, nothing
254
+ // the user did not ask for. Star counts are how the plugin market ranks entries,
255
+ // so asking is fair — clicking on their behalf would not be.
256
+ console.log("觉得有用?给项目点个星:https://github.com/chenacc1/agentbridge\n");
274
257
  return result;
275
258
  }
276
259
 
package/src/config.js CHANGED
@@ -174,6 +174,10 @@ export function loadConfig(env = process.env, workspace = process.cwd()) {
174
174
  // itself, so the plugin may ask for a restart to change the advertised address
175
175
  // without handing that power to anything else that can reach loopback.
176
176
  autostartToken: (env.AGENTBRIDGE_AUTOSTART_TOKEN ?? "").trim() || null,
177
+ // Which network path produced advertisedOrigin. Informational only: the
178
+ // launcher sets it so /api/health and the startup log can name the path
179
+ // instead of leaving the user to guess why the address changed.
180
+ remoteProvider: (env.AGENTBRIDGE_REMOTE_PROVIDER ?? "").trim() || null,
177
181
  // Outbound notifications: unset webhook means the feature is simply off.
178
182
  notifyWebhook: pick(env, "AGENTBRIDGE_NOTIFY_WEBHOOK", notifyFile.webhook),
179
183
  notifyFormat: pick(env, "AGENTBRIDGE_NOTIFY_FORMAT", notifyFile.format ?? "json").toLowerCase(),
package/src/remote.js ADDED
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Which network path a QR code should point at.
3
+ *
4
+ * There is more than one way to put a local bridge in front of a phone, and they
5
+ * differ in what they cost the user: a Cloudflare quick tunnel needs nothing on
6
+ * the phone but hands out a random hostname; Tailscale Funnel is a fixed hostname
7
+ * and still needs nothing on the phone; Tailscale and ZeroTier private addresses
8
+ * are the safest but require an app on the phone and a login.
9
+ *
10
+ * So the choice is data, not `if` statements scattered across the CLI and the DSH
11
+ * plugin: each path is a provider with the same shape, and one resolver walks them
12
+ * in a documented order. Everything that touches the machine (running `tailscale`,
13
+ * reading adapters, downloading cloudflared) is injectable, because this module
14
+ * decides whether a public address exists at all.
15
+ */
16
+ import os from "node:os";
17
+ import { spawn } from "node:child_process";
18
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import {
21
+ ensureAccessKey,
22
+ ensureCloudflared,
23
+ findCloudflared,
24
+ readTunnelState,
25
+ startQuickTunnel,
26
+ tunnelStatePath,
27
+ } from "./tunnel.js";
28
+
29
+ /** Run one command and collect its output instead of streaming it. */
30
+ export function runCommand(command, args, { timeoutMs = 15_000, env = process.env } = {}) {
31
+ return new Promise((resolve) => {
32
+ let child;
33
+ try {
34
+ child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env });
35
+ } catch (error) {
36
+ return resolve({ code: null, stdout: "", stderr: String(error?.message ?? error), missing: true });
37
+ }
38
+ let stdout = "";
39
+ let stderr = "";
40
+ let settled = false;
41
+ const finish = (result) => {
42
+ if (settled) return;
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ resolve(result);
46
+ };
47
+ const timer = setTimeout(() => {
48
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
49
+ finish({ code: null, stdout, stderr, timedOut: true });
50
+ }, timeoutMs);
51
+ timer.unref?.();
52
+ child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
53
+ child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
54
+ child.once?.("error", (error) => finish({ code: null, stdout, stderr, stderr: String(error?.message ?? error), missing: true }));
55
+ child.once?.("exit", (code) => finish({ code, stdout, stderr }));
56
+ });
57
+ }
58
+
59
+ function parseJson(text) {
60
+ try {
61
+ return JSON.parse(text);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Tailscale: shared detection for the private-address and Funnel providers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function tailscaleCandidates(env = process.env) {
72
+ const candidates = [];
73
+ if (env.AGENTBRIDGE_TAILSCALE) candidates.push(env.AGENTBRIDGE_TAILSCALE);
74
+ if (env.ProgramFiles) candidates.push(path.join(env.ProgramFiles, "Tailscale", "tailscale.exe"));
75
+ if (env["ProgramFiles(x86)"]) candidates.push(path.join(env["ProgramFiles(x86)"], "Tailscale", "tailscale.exe"));
76
+ candidates.push("/usr/bin/tailscale", "/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale");
77
+ candidates.push("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
78
+ return candidates;
79
+ }
80
+
81
+ export function findTailscale({ env = process.env, exists = existsSync } = {}) {
82
+ if (env.AGENTBRIDGE_TAILSCALE) return exists(env.AGENTBRIDGE_TAILSCALE) ? env.AGENTBRIDGE_TAILSCALE : null;
83
+ for (const candidate of tailscaleCandidates(env)) {
84
+ // A bare command name is resolved by the shell, not the filesystem.
85
+ if (!path.isAbsolute(candidate)) continue;
86
+ if (exists(candidate)) return candidate;
87
+ }
88
+ return "tailscale";
89
+ }
90
+
91
+ /** The 100.64/10 address Tailscale assigned this node, if it is up. */
92
+ export function tailscaleIpv4(status) {
93
+ const addresses = Array.isArray(status?.Self?.TailscaleIPs) ? status.Self.TailscaleIPs : [];
94
+ return addresses.find((address) => /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(String(address))) ?? null;
95
+ }
96
+
97
+ export function tailscaleDnsName(status) {
98
+ const name = String(status?.Self?.DNSName ?? "").replace(/\.$/, "");
99
+ return name || null;
100
+ }
101
+
102
+ /**
103
+ * Ask Tailscale what it is doing.
104
+ *
105
+ * `BackendState` is the interesting field: "Running" means the node is up and on
106
+ * a tailnet. "NeedsLogin" and "Stopped" are the two states users actually hit,
107
+ * and they deserve different advice, so they are reported separately rather than
108
+ * collapsing into "not available".
109
+ */
110
+ export async function detectTailscale({ env = process.env, run = runCommand, exists = existsSync } = {}) {
111
+ const bin = findTailscale({ env, exists });
112
+ if (!bin) return { available: false, reason: "Tailscale is not installed", bin: null };
113
+ const result = await run(bin, ["status", "--json"]);
114
+ if (result.missing) return { available: false, reason: "Tailscale is not installed", bin };
115
+ if (result.code !== 0) {
116
+ const detail = `${result.stderr || result.stdout}`.trim();
117
+ const needsOperator = /access denied|permission|operator/i.test(detail);
118
+ return {
119
+ available: false,
120
+ bin,
121
+ reason: needsOperator
122
+ ? "this Windows user cannot read Tailscale status; run `tailscale set --operator=%USERNAME%` once, or start DSH as administrator"
123
+ : `tailscale status failed: ${detail || `exit ${result.code}`}`,
124
+ };
125
+ }
126
+ const status = parseJson(result.stdout);
127
+ if (!status) return { available: false, bin, reason: "Tailscale returned unreadable status" };
128
+ const state = String(status.BackendState ?? "");
129
+ if (state !== "Running") {
130
+ return {
131
+ available: false,
132
+ bin,
133
+ status,
134
+ reason: state === "NeedsLogin"
135
+ ? "Tailscale is installed but not signed in; open its tray app and log in"
136
+ : `Tailscale is not running (${state || "unknown"})`,
137
+ };
138
+ }
139
+ return { available: true, bin, status, ipv4: tailscaleIpv4(status), dnsName: tailscaleDnsName(status) };
140
+ }
141
+
142
+ function tailscaleProvider({ id, label, isPublic }) {
143
+ return {
144
+ id,
145
+ label,
146
+ isPublic,
147
+ async probe(ctx) {
148
+ const found = await detectTailscale({ env: ctx.env, run: ctx.run, exists: ctx.exists });
149
+ return found.available ? { ready: true, detail: found } : { ready: false, reason: found.reason, detail: found };
150
+ },
151
+ async ensure(ctx, probed) {
152
+ const found = probed.detail;
153
+ if (!found?.ipv4) {
154
+ throw new Error("Tailscale is running but has no 100.64/10 address yet");
155
+ }
156
+ return { origin: `http://${found.ipv4}:${ctx.port}`, detail: `Tailscale ${found.ipv4}` };
157
+ },
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Tailscale Funnel: fixed public hostname, nothing to install on the phone
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export const funnelProvider = {
166
+ id: "funnel",
167
+ label: "Tailscale Funnel(固定域名)",
168
+ isPublic: true,
169
+ async probe(ctx) {
170
+ const found = await detectTailscale({ env: ctx.env, run: ctx.run, exists: ctx.exists });
171
+ if (!found.available) return { ready: false, reason: found.reason };
172
+ if (!found.dnsName) return { ready: false, reason: "Tailscale has not assigned this node a DNS name yet" };
173
+ const status = await ctx.run(found.bin, ["funnel", "status"]);
174
+ const text = `${status.stdout}${status.stderr}`;
175
+ // `funnel status` prints "No funnel configuration" when nothing is exposed.
176
+ const enabled = status.code === 0 && !/no funnel|not enabled|no configuration/i.test(text);
177
+ if (enabled) return { ready: true, detail: found };
178
+ if (!ctx.allowEnable) {
179
+ return { ready: false, reason: "Funnel is not enabled for this node (set AGENTBRIDGE_REMOTE=funnel to turn it on)" };
180
+ }
181
+ return { ready: true, needsEnable: true, detail: found };
182
+ },
183
+ async ensure(ctx, probed) {
184
+ const found = probed.detail;
185
+ if (probed.needsEnable) {
186
+ const start = await ctx.run(found.bin, ["funnel", "--bg", "--https=443", `http://127.0.0.1:${ctx.port}`]);
187
+ if (start.code !== 0) {
188
+ const text = `${start.stderr}${start.stdout}`.trim();
189
+ const hint = /not enabled|Funnel is not|enable/i.test(text)
190
+ ? " — enable Funnel for this tailnet in the Tailscale admin console (Access controls → Funnel)"
191
+ : "";
192
+ throw new Error(`tailscale funnel failed: ${text || `exit ${start.code}`}${hint}`);
193
+ }
194
+ }
195
+ return { origin: `https://${found.dnsName}`, detail: `Tailscale Funnel ${found.dnsName}` };
196
+ },
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // ZeroTier: private address, app required on the phone
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export function zerotierAddress(interfaces = os.networkInterfaces()) {
204
+ for (const [name, entries] of Object.entries(interfaces)) {
205
+ if (!/zerotier/i.test(name)) continue;
206
+ for (const entry of entries ?? []) {
207
+ if ((entry.family !== "IPv4" && entry.family !== 4) || entry.internal) continue;
208
+ if (!/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(entry.address)) continue;
209
+ return { address: entry.address, name };
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ export const zerotierProvider = {
216
+ id: "zerotier",
217
+ label: "ZeroTier(私有网络)",
218
+ isPublic: false,
219
+ async probe(ctx) {
220
+ const override = String(ctx.env.AGENTBRIDGE_ZEROTIER ?? "").trim();
221
+ if (override) {
222
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(override)) {
223
+ return { ready: false, reason: `AGENTBRIDGE_ZEROTIER is not an IPv4 address: ${override}` };
224
+ }
225
+ return { ready: true, detail: { address: override, name: "AGENTBRIDGE_ZEROTIER" } };
226
+ }
227
+ const found = zerotierAddress(ctx.interfaces ?? os.networkInterfaces());
228
+ if (!found) {
229
+ return { ready: false, reason: "no ZeroTier adapter with a managed IPv4 address (join a network and authorize this member)" };
230
+ }
231
+ return { ready: true, detail: found };
232
+ },
233
+ async ensure(ctx, probed) {
234
+ return { origin: `http://${probed.detail.address}:${ctx.port}`, detail: `ZeroTier ${probed.detail.address}` };
235
+ },
236
+ };
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Cloudflare: quick tunnel (random hostname) and named tunnel (your domain)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ export const cloudflareProvider = {
243
+ id: "cloudflare",
244
+ label: "Cloudflare quick tunnel(自动)",
245
+ isPublic: true,
246
+ async probe(ctx) {
247
+ const command = findCloudflared({ env: ctx.env, home: ctx.home, exists: ctx.exists });
248
+ if (command) return { ready: true, detail: command };
249
+ if (ctx.env.AGENTBRIDGE_NO_DOWNLOAD === "1") {
250
+ return { ready: false, reason: "cloudflared is missing and AGENTBRIDGE_NO_DOWNLOAD=1 forbids fetching it" };
251
+ }
252
+ const downloaded = await ensureCloudflared({ dataDir: ctx.dataDir, env: ctx.env, log: ctx.log });
253
+ if (!downloaded) return { ready: false, reason: "cloudflared is missing and could not be downloaded" };
254
+ return { ready: true, detail: downloaded };
255
+ },
256
+ async ensure(ctx, probed) {
257
+ const tunnel = await startQuickTunnel({
258
+ command: probed.detail,
259
+ origin: `http://127.0.0.1:${ctx.port}`,
260
+ dataDir: ctx.dataDir,
261
+ log: ctx.log,
262
+ });
263
+ return { origin: tunnel.url, detail: tunnel.reused ? "Cloudflare quick tunnel (reused)" : "Cloudflare quick tunnel" };
264
+ },
265
+ };
266
+
267
+ function namedTunnelStatePath(dataDir) {
268
+ return path.join(dataDir, "cloudflare-named-tunnel.json");
269
+ }
270
+
271
+ export const namedTunnelProvider = {
272
+ id: "named",
273
+ label: "Cloudflare 命名隧道(固定域名)",
274
+ isPublic: true,
275
+ async probe(ctx) {
276
+ const name = String(ctx.env.AGENTBRIDGE_CLOUDFLARE_TUNNEL ?? "").trim();
277
+ const hostname = String(ctx.env.AGENTBRIDGE_CLOUDFLARE_HOSTNAME ?? "").trim();
278
+ if (!name) return { ready: false, reason: "AGENTBRIDGE_CLOUDFLARE_TUNNEL is not set" };
279
+ if (!hostname) return { ready: false, reason: "AGENTBRIDGE_CLOUDFLARE_TUNNEL needs AGENTBRIDGE_CLOUDFLARE_HOSTNAME" };
280
+ const command = findCloudflared({ env: ctx.env, home: ctx.home, exists: ctx.exists });
281
+ if (!command) return { ready: false, reason: "cloudflared is missing" };
282
+ return { ready: true, detail: { command, name, origin: `https://${hostname.replace(/^https?:\/\//, "").replace(/\/$/, "")}` } };
283
+ },
284
+ async ensure(ctx, probed) {
285
+ const { command, name, origin } = probed.detail;
286
+ const statePath = namedTunnelStatePath(ctx.dataDir);
287
+ let previous = null;
288
+ try {
289
+ previous = JSON.parse(readFileSync(statePath, "utf8"));
290
+ } catch {
291
+ previous = null;
292
+ }
293
+ const alive = previous && Number.isInteger(previous.pid) && (() => {
294
+ try {
295
+ process.kill(previous.pid, 0);
296
+ return true;
297
+ } catch (error) {
298
+ return error?.code === "EPERM";
299
+ }
300
+ })();
301
+ if (alive && previous.tunnel === name) return { origin, detail: `Cloudflare named tunnel ${name} (reused)` };
302
+
303
+ const child = spawn(command, ["tunnel", "--no-autoupdate", "run", name], {
304
+ detached: true,
305
+ windowsHide: true,
306
+ stdio: "ignore",
307
+ });
308
+ child.unref();
309
+ if (child.pid) {
310
+ try {
311
+ writeFileSync(statePath, JSON.stringify({ pid: child.pid, tunnel: name, origin, startedAt: new Date().toISOString() }, null, 2), "utf8");
312
+ } catch {
313
+ // The record only saves a respawn; the tunnel works either way.
314
+ }
315
+ }
316
+ // cloudflared needs a moment before the edge can route the hostname, and the
317
+ // bridge is about to be started with this origin baked in.
318
+ for (let attempt = 0; attempt < 20; attempt += 1) {
319
+ await new Promise((resolve) => setTimeout(resolve, 500));
320
+ try {
321
+ await ctx.fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(5_000) });
322
+ return { origin, detail: `Cloudflare named tunnel ${name}` };
323
+ } catch {
324
+ // Edge not routing yet, or the bridge is not up — both are expected now.
325
+ }
326
+ }
327
+ return { origin, detail: `Cloudflare named tunnel ${name}` };
328
+ },
329
+ };
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // The resolver
333
+ // ---------------------------------------------------------------------------
334
+
335
+ export const PROVIDER_IDS = ["named", "funnel", "cloudflare", "tailscale", "zerotier"];
336
+
337
+ const PROVIDERS = {
338
+ named: namedTunnelProvider,
339
+ funnel: funnelProvider,
340
+ cloudflare: cloudflareProvider,
341
+ tailscale: tailscaleProvider({ id: "tailscale", label: "Tailscale(私有网络)", isPublic: false }),
342
+ zerotier: zerotierProvider,
343
+ };
344
+
345
+ /**
346
+ * `auto` is deliberately not "try the fanciest thing first".
347
+ *
348
+ * A named tunnel is only in the list when the user configured one, and Funnel is
349
+ * only *reused* in auto mode — turning it on changes the user's tailnet, so that
350
+ * has to be an explicit choice (`AGENTBRIDGE_REMOTE=funnel`). The quick tunnel
351
+ * comes next because it needs nothing from anyone, and the private overlays come
352
+ * last: they are safer, but they require an app on the phone, which is exactly
353
+ * what most people are trying to avoid.
354
+ */
355
+ export const AUTO_ORDER = ["named", "funnel", "cloudflare", "tailscale", "zerotier"];
356
+
357
+ export function parseMode(env = process.env) {
358
+ const raw = String(env.AGENTBRIDGE_REMOTE ?? "").trim().toLowerCase();
359
+ if (!raw || raw === "auto") return "auto";
360
+ if (raw === "lan" || raw === "local" || raw === "off" || raw === "none") return "lan";
361
+ return PROVIDER_IDS.includes(raw) ? raw : "auto";
362
+ }
363
+
364
+ /**
365
+ * The public or overlay address the QR code should carry, or null for the LAN.
366
+ *
367
+ * Returns the provider that won, because "which path am I on" is the first thing
368
+ * anyone asks when the QR code does not open.
369
+ */
370
+ export async function resolveRemote({
371
+ env = process.env,
372
+ dataDir,
373
+ port = String(env.AGENTBRIDGE_PORT ?? "8787"),
374
+ log = () => {},
375
+ mode = parseMode(env),
376
+ providers = PROVIDERS,
377
+ run = runCommand,
378
+ fetchImpl = globalThis.fetch,
379
+ exists = existsSync,
380
+ home = os.homedir(),
381
+ interfaces,
382
+ } = {}) {
383
+ const explicit = String(env.AGENTBRIDGE_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
384
+ if (explicit) {
385
+ return { provider: "explicit", label: "AGENTBRIDGE_PUBLIC_ORIGIN", origin: explicit, accessKey: null, isPublic: !/^http:\/\/(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(explicit) };
386
+ }
387
+ const supplied = String(env.AGENTBRIDGE_TUNNEL_URL ?? "").trim().replace(/\/$/, "");
388
+ if (supplied) {
389
+ return { provider: "tunnel-url", label: "AGENTBRIDGE_TUNNEL_URL", origin: supplied, accessKey: ensureAccessKey(dataDir), isPublic: true };
390
+ }
391
+ if (mode === "lan" || env.AGENTBRIDGE_NO_TUNNEL === "1") {
392
+ const why = env.AGENTBRIDGE_NO_TUNNEL === "1" ? "AGENTBRIDGE_NO_TUNNEL=1" : `AGENTBRIDGE_REMOTE=${mode}`;
393
+ log(`${why}: the QR code will work on this Wi-Fi and nowhere else`);
394
+ return null;
395
+ }
396
+
397
+ const order = mode === "auto" ? AUTO_ORDER : [mode];
398
+ const reasons = [];
399
+ for (const id of order) {
400
+ const provider = providers[id];
401
+ if (!provider) continue;
402
+ const ctx = { env, dataDir, port, log, run, fetch: fetchImpl, exists, home, interfaces, allowEnable: mode === id };
403
+ let probed;
404
+ try {
405
+ probed = await provider.probe(ctx);
406
+ } catch (error) {
407
+ reasons.push(`${id}: ${error?.message ?? error}`);
408
+ continue;
409
+ }
410
+ if (!probed?.ready) {
411
+ reasons.push(`${id}: ${probed?.reason ?? "not available"}`);
412
+ continue;
413
+ }
414
+ try {
415
+ const result = await provider.ensure(ctx, probed);
416
+ log(`${provider.label} → ${result.origin}`);
417
+ return {
418
+ provider: id,
419
+ label: provider.label,
420
+ origin: result.origin,
421
+ detail: result.detail,
422
+ // Anything reachable from the internet gets the key gate, no exceptions.
423
+ accessKey: provider.isPublic ? ensureAccessKey(dataDir) : null,
424
+ isPublic: provider.isPublic,
425
+ };
426
+ } catch (error) {
427
+ reasons.push(`${id}: ${error?.message ?? error}`);
428
+ continue;
429
+ }
430
+ }
431
+
432
+ // Every path was tried and refused. Say why, once, instead of silently
433
+ // pretending the LAN is what the user asked for.
434
+ if (mode !== "auto" || reasons.length) {
435
+ log(`no remote path could be brought up, staying on this Wi-Fi only: ${reasons.join("; ")}`);
436
+ }
437
+ return null;
438
+ }
439
+
440
+ /** Forget the named-tunnel record so the next run starts cloudflared again. */
441
+ export function forgetNamedTunnel(dataDir) {
442
+ try {
443
+ rmSync(namedTunnelStatePath(dataDir), { force: true });
444
+ } catch {
445
+ // Nothing to forget.
446
+ }
447
+ }
448
+
449
+ export { tunnelStatePath };
package/src/server.js CHANGED
@@ -419,6 +419,7 @@ export async function createBridge(options = {}) {
419
419
  advertisedOrigin: config.advertisedOrigin,
420
420
  accessKeyRequired: Boolean(config.accessKey),
421
421
  autostart: config.autostart === true,
422
+ remoteProvider: config.remoteProvider,
422
423
  });
423
424
  }
424
425
 
@@ -989,6 +990,9 @@ export async function startBridge() {
989
990
  bridge.server.listen(bridge.config.port, bridge.config.host, resolve);
990
991
  });
991
992
  console.log(`AgentBridge running at http://127.0.0.1:${bridge.config.port}`);
993
+ if (bridge.config.remoteProvider) {
994
+ console.log(`Remote path: ${bridge.config.remoteProvider} → ${bridge.config.advertisedOrigin}`);
995
+ }
992
996
  console.log(`WeChat scan target: ${bridge.getCurrentPairing().url}`);
993
997
  // Silence here used to mean a QR encoding an address the phone could never reach.
994
998
  if (new URL(bridge.config.advertisedOrigin).hostname === "127.0.0.1") {