@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 +21 -0
- package/README.md +218 -71
- package/docs/RELEASING.md +15 -5
- package/package.json +4 -3
- package/src/cli.js +32 -49
- package/src/config.js +4 -0
- package/src/remote.js +449 -0
- package/src/server.js +4 -0
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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@chenacc/agentbridge)
|
|
4
|
+
[](https://www.npmjs.com/package/@chenacc/agentbridge)
|
|
5
|
+
[](https://www.npmjs.com/package/@chenacc/dsh-agentbridge)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://github.com/chenacc1/agentbridge/stargazers)
|
|
4
8
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+

|
|
32
|
+
|
|
11
33
|

|
|
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)
|
|
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
|
-
|
|
123
|
+
```
|
|
124
|
+
网络: 公网地址,手机用流量也能打开
|
|
125
|
+
微信扫码地址: https://xxx.trycloudflare.com/pair?code=…&key=…
|
|
126
|
+
```
|
|
87
127
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
132
|
+
### 二维码里的地址是怎么定的
|
|
97
133
|
|
|
98
|
-
|
|
134
|
+
CLI 和插件共用同一套逻辑(`src/remote.js`),按下面的顺序决定二维码写什么:
|
|
99
135
|
|
|
100
|
-
|
|
|
136
|
+
| 优先级 | 条件 | 二维码地址 |
|
|
101
137
|
|---|---|---|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
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
|

|
|
109
177
|
|
|
110
|
-
|
|
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
|
-
|
|
188
|
+
密钥就是一句口令,不是账号。它只在**对公网开放时**生效(quick tunnel / 命名隧道);局域网、Tailscale、ZeroTier 默认没有门禁,也就不会有人问你密钥。门禁打开时,所有非本机(loopback)请求都必须带上它:二维码里已内嵌 `?key=…`,手机用它换一个 90 天有效的 cookie(`agentbridge_key`)。
|
|
189
|
+
|
|
190
|
+
密钥文件有两个位置,取决于谁在开隧道:
|
|
115
191
|
|
|
116
|
-
|
|
192
|
+
| 谁来开隧道 | 密钥文件 |
|
|
193
|
+
|---|---|
|
|
194
|
+
| CLI / DSH 插件(默认路径) | `%USERPROFILE%\.agentbridge\access-key` |
|
|
195
|
+
| 仓库里的一键脚本 `.cmd` | `<仓库>\.agentbridge\access-key` |
|
|
117
196
|
|
|
118
|
-
|
|
119
|
-
2. 换了一份代码目录(文件跟着目录走,不同 `$bridgeRoot` 各有一把);
|
|
120
|
-
3. 该目录第一次以 Cloudflare 模式启动。
|
|
197
|
+
两者都是**先读后写**:文件存在、内容非空且不短于 16 个字符就原样复用,所以重启、换端口、换隧道地址都不会改它。会变的只有三种情况:删掉(或清空、或写短于 16 个字符)该文件;换了一份代码目录;该目录第一次以 Cloudflare 模式启动。
|
|
121
198
|
|
|
122
199
|
**换法**(三步,不需要改代码):
|
|
123
200
|
|
|
124
201
|
```powershell
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
+
agentbridge install-claude-skill --user
|
|
155
254
|
|
|
156
255
|
# 只装给某个项目:<项目>\.claude\skills\phone\SKILL.md
|
|
157
|
-
|
|
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
|
-
|
|
267
|
+
agentbridge install-codex-skill --user
|
|
169
268
|
```
|
|
170
269
|
|
|
171
270
|
重启 Codex 桌面项目,在本地项目会话输入 `/phone`。Codex 没对外提供「当前线程 ID」,所以不带参数时它会在同一项目目录**新建**一个会话并明确标注;只有你已知 thread ID 时才精确附加:
|
|
172
271
|
|
|
173
272
|
```powershell
|
|
174
|
-
|
|
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
|
-
|
|
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 | `
|
|
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
|
-
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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 # 单元与集成测试(当前
|
|
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/
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
|
140
|
+
* The address the QR code should carry, if this run is going to have one.
|
|
141
141
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
168
|
-
if (!
|
|
169
|
-
|
|
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
|
|
215
|
-
// baked into the pairing URL at startup — the exact opposite of the DSH
|
|
216
|
-
//
|
|
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
|
|
219
|
-
? {
|
|
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: `);
|
|
271
250
|
}
|
|
272
251
|
console.log(`微信扫码地址: ${result.pairing.url}`);
|
|
273
|
-
console.log(`电脑端会话: ${origin}/?session=${encodeURIComponent(result.session.id)}
|
|
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") {
|