@bubbolink/cli
v2026.6.4
Published
Relay-first CLI for pairing and bridging OpenClaw gateway traffic
Downloads
1,660
Readme
@bubbolink/cli
把本地 AI runtime(OpenClaw 或 Hermes)接入 Bubbolink relay 服务的独立 CLI。
手机端 App 通过 relay 跟本地 runtime 对话,CLI 负责:
- 配对:把手机 App 账号和本地 runtime 绑定到同一个 relay gateway
- 桥接:长时间运行一个中继 worker,把 relay 队列里的请求拉下来、喂给本地 runtime、再把事件发回 relay
- 守护:用系统服务(launchd / systemd / Task Scheduler)让桥接在后台常驻
深入文档:
- docs/PAIR_FLOW.md —
bubbolink pair完整流程:每一步做了什么、改了哪些文件、--runtime all模式遇到 runtime 缺失怎么处理、对 OpenClaw 配置改动的安全性分析、故障排查 checklist - docs/EXTERNAL_AGENTS.md — claude / codex 等外部 agent 接入指南
- docs/LOGGING.md — 日志格式与排查
- docs/PLATFORM.md — 跨平台细节(launchd / systemd / Windows)
- bubbolink-server/TOOL_EVENTS.md — 工具调用链事件 schema(OpenClaw plugin / Hermes / Codex / Claude → server
session.tool字段映射 + 真实样例)
1. 整体架构
┌────────────────┐ HTTPS / SSE ┌───────────────────────┐
│ Mobile App │◀─────────────────▶│ Relay Server (Go) │
│ (nexus_flutter)│ │ go-relay-test.cece.com │
└────────────────┘ │ │
│ MySQL + Redis │
└───────────┬───────────┘
│
long-poll + POST events
│
┌───────────▼───────────┐
│ bubbolink CLI (Node) │
│ bin/bubbolink │
│ │
│ ┌─────────────────┐ │
│ │ Bridge worker │ │
│ └────────┬────────┘ │
└───────────┼───────────┘
│
┌───────────────┬────────────────┼────────────────┬──────────────┐
│ │ │ │ │
(runtime=openclaw) (runtime=hermes) (runtime=claude) (runtime=codex) │
│ │ │ │ │
HTTP /v1/responses bubbolink- bubbolink- bubbolink- (其他 runtime
│ agent hermes agent claude agent codex 走相同 shim)
▼ ▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ OpenClaw GW │ │ Hermes JS │ │ Claude CLI │ │ Codex MCP │
│ (外部进程) │ │ HTTP/cmd │ │ stream-json│ │ stdio │
│ 127.0.0.1:<p> │ │ backend │ │ │ │ │
└────────────────┘ └────────────┘ └────────────┘ └────────────┘核心架构(重构后):4 种 runtime 走完全统一的 long-poll 模式,CLI 端不做协议翻译。
启动 (任意 runtime bridge)
│
1. POST /v1/pair-codes/<code>/consume
body { client_user_id, runtime }
resp { gw_id, bridge_token, runtime }
│
2. 持久化 ~/.bubbolink/ext-<runtime>-last.json
│
3. long-poll loop(永不退出):
┌─ GET /v1/gateways/<gw>/requests/next?waitSeconds=25
│ Bearer <bridge_token>
│ ↓ 拿到 user request
│
├─ 调本地 runtime(LLM HTTP / Claude CLI / Codex MCP / OpenClaw HTTP)
│
├─ 流式 POST /v1/gateways/<gw>/requests/<rid>/events
│ body 是该 runtime 的 native 事件(stream-json / OpenAI SSE chunk / codex/event 等)
│ ↑↑↑ 服务端 internal/adapter/<runtime>/ 翻译为统一信封
│
└─ 心跳 POST /v1/gateways/<gw>/meta(每 10s)关键特征(重构后):
- CLI bridge 不知道统一 Agent SSE 协议(
/v1/agents/threads/*端点)的存在 - CLI bridge 直接发 runtime 自己的原生事件,服务端
adapter/<runtime>/做 native → unified envelope 翻译 - 之前 CLI 端的
src-ext/core/unified_relay_shim.mjs翻译层 +src-shared/agent-client/HTTP 封装已整体删除 - 4 个 bridge(OpenClaw / Hermes / Claude / Codex)共用同一套
relayWorkerlong-poll 主循环
三个关键角色:
| 角色 | 位置 | 职责 |
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Relay Server | bubbolink-server/(Go) | 握住 App ↔ runtime 之间的所有流量;internal/adapter/<runtime>/ 把 native 事件翻成统一信封 |
| CLI | bin/bubbolink + bin/bubbolink-agent(Node ≥22)| 配对 + 桥接;4 种 runtime 共用 relayWorker long-poll 主循环;不做协议翻译 |
| Runtime | OpenClaw gateway / Hermes(JS) / Claude CLI / Codex CLI | 实际执行 AI turn 的进程或本地 LLM 调用 |
4 种 runtime bridge 对比
| Runtime | 启动命令 | 入口文件 | native 协议(POST 给服务端的事件格式) | 服务端 adapter |
| -------- | ------------------------- | --------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------- |
| OpenClaw | bubbolink bridge | src/tunnel.mjs + src/tunnel_proxy.mjs + src/tunnel_state.mjs | OpenClaw /v1/responses SSE chunk | internal/adapter/openclaw |
| Hermes | bubbolink-agent hermes | src-ext/runtime/hermes/index.mjs | OpenAI-compatible SSE / NDJSON | internal/adapter/hermes(共享 openclaw 逻辑) |
| Claude | bubbolink-agent claude | src-ext/runtime/claude/index.mjs | Claude CLI stream-json | internal/adapter/claude |
| Codex | bubbolink-agent codex | src-ext/runtime/codex/index.mjs | Codex MCP codex/event notification | internal/adapter/codex |
4 个 bridge 入口文件大小都 ≤ 800 行硬限,最大约 700 行。
2. 代码结构
bubbolink-cli/
├── bin/
│ ├── bubbolink # 用户入口(setup / pair / bridge / service / tunnel)
│ ├── bubbolink-agent # bridge 子进程入口(claude / codex / hermes)
│ └── bubbolink-mcp-stdio # codex 反向 MCP stdio bridge
├── src/ # OpenClaw bridge + tunnel + 共享工具
│ ├── core.mjs # URL / code 归一化 / 路径解析等
│ ├── tunnel.mjs # OpenClaw bridge 主循环(277 行)
│ ├── tunnel_proxy.mjs # OpenClaw HTTP / SSE 转发(476 行)
│ ├── tunnel_state.mjs # 文件锁 / 状态机(301 行)
│ └── tunnel_service.mjs # OpenClaw 桥接的 service install/uninstall
├── src-shared/ # 纯基础设施(无业务依赖)
│ ├── logger.mjs # 结构化 JSON 日志
│ ├── correlation.mjs # cid 生成 / 解析
│ └── relay-defaults.json # 默认 relay URL
├── src-ext/ # External Agents(Claude / Codex / Hermes)
│ ├── bin.mjs # bubbolink-agent 入口分发
│ ├── commands/ # pair / service / agent / reload / help 子命令
│ ├── core/
│ │ ├── pairCodeClient.mjs # POST /v1/pair-codes/<code>/consume + 状态持久化
│ │ ├── relayWorker.mjs # 4 个 bridge 共用的 long-poll 主循环
│ │ ├── relayWorkerHttp.mjs # bridge_token Bearer header / fetchJson 工具
│ │ ├── sessionSlot.mjs # 单 session 槽状态机
│ │ ├── stateMachine.mjs # 通用 FSM
│ │ ├── approvalGateway.mjs # HITL 工具审批
│ │ ├── mcpStdioClient.mjs # MCP stdio 客户端(codex 用)
│ │ ├── bubbolinkToolsRegistry.mjs / bubbolinkToolsServer.mjs # 反向工具注入
│ │ ├── accountResolver.mjs # accountId/clientUserId 解析
│ │ └── usageReporter.mjs # token 用量上报
│ ├── runtime/
│ │ ├── claude/index.mjs # Claude bridge(spawn `claude` CLI + stream-json,223 行)
│ │ ├── codex/index.mjs # Codex bridge(spawn `codex` MCP stdio,240 行)
│ │ └── hermes/index.mjs # Hermes bridge(OpenAI-compatible HTTP / subprocess,256 行)
│ ├── service/
│ │ └── serviceManager.mjs # launchd / systemd / Task Scheduler 统一接入
│ └── openclaw-plugin/ # bubbolink-hooks OpenClaw 插件源码(详见 docs/PLUGIN_ACTIVE_RUNS.md)
└── test/ # node --test 测试套件(72 个 test)Hermes 已 100% JS 化 —— 旧 runtime/hermes_bridge/ Python 目录已删除。bin/bubbolink 中所有 Python 探测 / spawn 逻辑也已清除。
已删除的代码(重构清理):
src-ext/core/unified_relay_shim.mjs—— 整个文件删除。之前 CLI 在这里把 legacy{kind: ...}事件翻译成统一信封 + POST 到/v1/agents/threads/<tid>/events,现在改由服务端 adapter 翻译src-shared/agent-client/—— 整个目录删除。CLI bridge 不再调统一协议端点,因此不需要/v1/agents/*HTTP + SSE 客户端- 各 runtime 子目录里的 unified 翻译辅助文件(如
usageReporter的 envelope 包装等)
bin/bubbolink 本身只 spawn bubbolink-agent 子进程;openclaw CLI 是外部可执行文件,通过 PATH / OPENCLAW_HOME 定位。
3. 4 种 runtime 模式
CLI 通过 runtimeKind 分流。所有 runtime 在线协议层完全一致 —— 4 个 bridge 共用 src-ext/core/relayWorker.mjs long-poll 主循环,通过相同的 3 个端点跟 relay 通讯:
POST /v1/pair-codes/<code>/consume(启动配对)GET /v1/gateways/<gw>/requests/next?waitSeconds=25(长轮询拉用户消息)POST /v1/gateways/<gw>/requests/<rid>/events(流式回 native 事件)POST /v1/gateways/<gw>/meta(10s 心跳)
唯一的差异是 bridge 内部对接的本地 runtime 进程,以及推回 relay 的 native 事件格式。CLI 不做协议翻译,服务端 internal/adapter/<runtime>/ 把 native 事件统一翻译为 unified envelope 落库 agent_events 表。
3.1 OpenClaw 模式(默认)
App ──► Relay ──► CLI bridge (Node) ──► HTTP POST /v1/responses ──► OpenClaw Gateway
│
events ◄──────────────── SSE / JSON ┘
(publish back to relay)- 入口:
bubbolink bridge(src/tunnel.mjs主循环 +tunnel_proxy.mjsHTTP / SSE 转发 +tunnel_state.mjs状态机) - OpenClaw 是独立的外部进程(
openclaw gateway run),监听127.0.0.1:<port> - CLI 把 relay long-poll 拉下来的请求转成
POST /v1/responses,再把流式响应原样切片发回 relay - 网关鉴权:CLI 从
~/.openclaw/config.json读gateway.auth.token - 服务端
internal/adapter/openclaw把 OpenClaw 的 SSE chunk 翻译为统一信封事件
3.2 Hermes 模式(pure JS,已替代旧 Python bridge)
App ──► Relay ──► bubbolink-agent hermes ──► OpenAI-compatible HTTP
long-poll (Node 22+ stdlib fetch) (or external command)- 入口:
bubbolink-agent hermes(src-ext/runtime/hermes/index.mjs,256 行) - 走
relayWorkerlong-poll:拿到 user request → 调本地 LLM → 流式回 OpenAI-compatible SSE chunk 给 relay - 后端 2 选 1:
- HTTP:
HERMES_BRIDGE_API_BASE_URL+HERMES_BRIDGE_API_KEY(OpenAI / DeepSeek / 自建 vLLM 等) - subprocess:
HERMES_BRIDGE_COMMAND="<path-to-binary>"(newline-delimited JSON 协议)
- HTTP:
- 本地 transcript 存在
~/.bubbolink/hermes/sessions/<sessionId>.json - 零 Python 依赖
3.3 Claude 模式
App ──► Relay ──► bubbolink-agent claude ──► spawn `claude` CLI (stream-json)- 入口:
bubbolink-agent claude(src-ext/runtime/claude/index.mjs,223 行) - spawn 外部
claudeCLI(Anthropic 官方),从 stdout 读 stream-json,直接作为事件 POST 到/v1/gateways/<gw>/requests/<rid>/events - 服务端
internal/adapter/claude解析 stream-json,把assistant_message_delta/tool_use_block/parent_tool_use_id等翻译成统一信封事件(content_block.delta/tool_use/ subagent run_id 等) - 工具审批走
approvalGateway:PreToolUse hook → 推 interrupt block → 等 App 端 resume
3.4 Codex 模式
App ──► Relay ──► bubbolink-agent codex ──► spawn `codex` MCP stdio server- 入口:
bubbolink-agent codex(src-ext/runtime/codex/index.mjs,240 行) - 通过 MCP stdio 协议跟
codexCLI 通信,反向注入bubbolink-mcp-stdio作为工具服务器 codex/eventnotification 直接序列化为事件 POST 给 relay- 服务端
internal/adapter/codex把codex/event翻译为统一信封
4 种 runtime 使用独立的 relay 凭证和独立的 session 模型(见 AppRuntimeKind 枚举),配对流程里由 App 端决定写哪一组。
4. Relay 协议
CLI 跟 relay 之间只走 HTTP(长轮询 + POST),没有 WebSocket。关键端点:
4.1 配对阶段
| 端点 | 方向 | 用途 |
| ------------------------------------------ | ----------- | -------------------------------------------------------------------- |
| POST /v1/pair-sessions | App → Relay | App 创建 4 位 pair code(含 accountId / runtimeKind) |
| GET /v1/pair-sessions/{code} | CLI → Relay | (openclaw 模式)查配对 session 是否存在、读出 App 设定的 runtimeKind |
| POST /v1/pair-codes/{code}/consume | CLI → Relay | 统一入口:消费 4 位码,body {client_user_id, runtime} → 颁发 {gw_id, bridge_token, runtime} |
兼容性:服务端仍保留 POST /api/ext/pair-codes/<code>/consume?runtime=<rt>(legacy 老 CLI 用),新 CLI 默认调 /v1/pair-codes/...,404 / 410 时回退老端点(升级期间双端兼容)。
4.2 桥接阶段
| 端点 | 方向 | 用途 |
| ----------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------- |
| GET /v1/gateways/{gwId}/requests/next?waitSeconds=N | CLI/bridge → Relay | long-poll 下一个 request(204 表示超时) |
| POST /v1/gateways/{gwId}/requests/{reqId}/events | CLI/bridge → Relay | 推 native 事件(start / chunk / end / error / response),服务端 adapter 翻译为 unified envelope |
| POST /v1/gateways/{gwId}/chat-events | CLI → Relay | OpenClaw 模式下发送结构化聊天事件(供 App 做 structured history) |
| POST /v1/gateways/{gwId}/meta | CLI/bridge → Relay | presence 心跳(online / connected / state),默认 10s 一次 |
| GET/DELETE /v1/gateways/{gwId}/binding | CLI → Relay | 查询 / 解绑当前 gateway |
| POST /v1/gateways/{gwId}/reset | CLI → Relay | 硬重置(bubbolink reset) |
鉴权:桥接阶段所有端点用 Authorization: Bearer <bridge_token>(pair-codes consume 颁发,单向 producer-only)。
bridge_token vs client_token
CLI 持有的是
bridge_token(producer 凭证):能 long-poll 拉 user request、能 POST events 推回。 App 侧持有的是client_token(consumer 凭证):能 GET/v1/agents/threads/<tid>/stream订阅、能查询 thread 状态。 两个 token 互不可换用,混用返回 403。
4.3 事件形状
桥接 worker 发回 relay 的事件统一是:
{
"event": "start|chunk|end|error|response",
"sessionKey": "agent:<id>:relay-gw:<gwId>::<sessionKey>",
"runId": "...",
"statusCode": 200,
"contentType": "text/event-stream; charset=utf-8",
"data": "...",
"createdAtMs": 1713312000000
}sessionKey 格式前后端不一致:App 侧带装饰前缀(agent: / relay-gw:),relay 事件来的是裸 key。两边比较必须归一化(Go 端 normalizeSessionKeyForMatch,Flutter 端 _sessionKeyMatchesForPreview)。
5. 典型流程
5.1 配对(bubbolink pair <code>,统一 long-poll 模式)
4 种 runtime 走完全一致的配对流程(OpenClaw 多一步本地 daemon 重启,详见 docs/PAIR_FLOW.md):
App Relay CLI
│ │ │
│ 1. POST /v1/pair-sessions │ │
│ {runtime, accountId, │ │
│ clientUserId} │ │
├────────────────────────────▶│ │
│ │ │
│ 2. App 屏幕显示 4 位 code │ │
│ │ │
│ │ 3. 用户在 CLI 输入 code │
│ │ │
│ │ 4. POST /v1/pair-codes/ │
│ │ <code>/consume │
│ │ {client_user_id, │
│ │ runtime} │
│ │◀────────────────────────────┤
│ │ │
│ │ 5. {gw_id, bridge_token, │
│ │ runtime} │
│ ├────────────────────────────▶│
│ │ │
│ │ 6. 持久化到 │
│ │ ~/.bubbolink/ │
│ │ ext-<runtime>-last.json│
│ │ │
│ │ 7. 进入 long-poll loop: │
│ │ GET .../requests/next │
│ │◀────────────────────────────┤
│ │ │
│ 8. App 端订阅 thread │ │
│ GET /v1/agents/threads/ │ │
│ <tid>/stream │ │
├────────────────────────────▶│ │配对完成判定 = /pair-codes/<code>/consume 返回 gw_id + bridge_token。CLI 立刻进入 long-poll 主循环,不需要再等 App 端"确认"。
持久化位置(按 runtime 分文件,互不干扰):
~/.bubbolink/ext-openclaw-last.json
~/.bubbolink/ext-hermes-last.json
~/.bubbolink/ext-claude-last.json
~/.bubbolink/ext-codex-last.json文件内容:{ gw_id, runtime, relay_url, bridge_token, saved_at }。session_id 字段保留作老 CLI 兼容(值与 gw_id 相同)。
5.2 桥接(bubbolink bridge,OpenClaw 模式)
┌──────────┐ ┌──────────┐ ┌────────────┐
│ CLI │ long-poll 90s │ Relay │ │ OpenClaw │
│ bridge │───────────────────▶│ │ │ Gateway │
│ │◀───────────────────│ │ │ /v1/resp.. │
│ │ request │ │ │ │
│ │ │ │ │ │
│ │─── POST /v1/resp ─────────────────────────▶ │ │
│ │◀── SSE stream ────────────────────────────── │ │
│ │ │ │ │ │
│ │── POST events ────▶│ │ │ │
│ │ (start/chunk/ │ │ │ │
│ │ end) │ │ │ │
│ │ │ │ │ │
│ │── POST meta ──────▶│ │ │ │
│ │ (heartbeat 10s) │ │ │ │
└──────────┘ └──────────┘ └────────────┘- Bridge 使用
acquireBridgeLock做文件锁,防止一台机器上两个 bridge 抢同一个 gateway - SIGINT/SIGTERM 时发一次
state: "stopped"presence,让 App 立刻看到离线(而不是等 25s TTL)
5.3 桥接(Hermes / Claude / Codex 模式 — 统一 long-poll)
3 个 External Agent runtime 共用同一套主循环,只是本地 backend 不同:
bubbolink-agent <hermes|claude|codex>
│
├── relayWorker.run(handler):
│ loop:
│ req = GET /v1/gateways/<gw>/requests/next?waitSeconds=25
│ if 204 → continue // long-poll 超时
│ handler(req, ctx) →
│
├── handler 实现(按 runtime 分流):
│ hermes: fetch(POST OpenAI /chat/completions, stream:true)
│ ↓ 每个 SSE chunk: ctx.publishEvent({kind:"chunk", data:...})
│ claude: spawn `claude` CLI(stream-json mode)
│ ↓ 每个 stream-json 事件: ctx.publishEvent({kind:"chunk", data:...})
│ codex: MCP stdio 双向通讯 + 反向工具注入
│ ↓ 每个 codex/event: ctx.publishEvent({kind:"chunk", data:...})
│
├── ctx.publishEvent → POST /v1/gateways/<gw>/requests/<rid>/events
│ body 是 native 事件,**不在 CLI 翻译**
│
└── 心跳 POST /v1/gateways/<gw>/meta(每 10s)服务端 internal/adapter/<runtime>/ 接到 native 事件后翻译为统一信封写入 agent_events 表。
Hermes 会话历史持久化在 ~/.bubbolink/hermes/sessions/<sessionId>.json。无 Python,无 venv,无 pip install。
6. 安装 & 快速开始
npm i -g @bubbolink/cliOpenClaw 模式(默认):
# 1) 准备 OpenClaw 配置 + relay 凭证
bubbolink setup \
--relay http://<relay-host>:8090 \
--public-base http://<gateway-public-host>:<port>
# 2) 启动本地 OpenClaw gateway
openclaw gateway run --bind loopback --port <port> --force
# 3) 用 App 里生成的 code 完成配对
bubbolink pair 123456
# 4) 把桥接装成后台服务
bubbolink service installHermes 模式(pure JS,无 Python 依赖):
# 配置后端:HTTP(OpenAI-compatible)
export HERMES_BRIDGE_API_BASE_URL=https://api.openai.com/v1
export HERMES_BRIDGE_API_KEY=sk-xxx
export HERMES_BRIDGE_MODEL=gpt-4o-mini
# 或者用 subprocess 后端(外部 binary,NDJSON 协议)
# export HERMES_BRIDGE_COMMAND=/path/to/my-llm-script
# 配对(CLI 自动检测后端 env,未配置会报错)
bubbolink pair 123456 --runtime hermes
# 前台跑(调试)
bubbolink bridge --runtime hermesClaude / Codex External Agents:
# Claude Code / Codex CLI 需要先在本机安装并登录
# 配对后会自动安装后台服务
bubbolink pair 1234 -r claude
bubbolink pair 1234 -r codex
# 前台调试运行(当前终端内运行,Ctrl+C 可停止)
bubbolink bridge -r claude
bubbolink bridge -r codex重装 App 后重新配对(典型场景):
# 如果之前是 hermes 模式,配对报 "这台主机已经绑定过设备"
bubbolink reset --runtime hermes # 只清 hermes 凭证,不动 openclaw
bubbolink pair <new-code> --runtime hermes
# openclaw 模式不传 --runtime 即可
bubbolink reset
bubbolink pair <new-code>7. 命令参考
bubbolink setup [--relay URL] [--public-base URL] # 写配置 + 装权限
bubbolink pair <code> [--runtime openclaw|hermes|claude|codex] # 配对;claude/codex 默认会自动装后台服务
bubbolink bridge [--runtime openclaw|hermes|claude|codex] # 前台运行桥接;claude/codex 用 Ctrl+C 停止
bubbolink service install|stop|uninstall|restart|status [--runtime ...] # 后台服务;claude/codex 中 stop 是 uninstall 别名
bubbolink uninstall --runtime claude|codex # claude/codex 完整卸载:停服务 + 清本地配对状态
bubbolink reset [--runtime openclaw|hermes] [--gateway GWID] # 硬重置绑定
bubbolink pair-url [--account ID] [--code CODE] # 生成手动配对 URL
bubbolink tunnel on <port> [--relay URL] # 暴露本地 HTTP 端口
bubbolink tunnel off <port> [--relay URL] # 关闭端口转发
bubbolink tunnel ls # 查看 tunnel 状态
bubbolink status [--runtime openclaw|hermes] # 查当前 runtime / 服务 / 绑定Tunnel 快速测试
# 启一个本地测试服务(任何 HTTP 服务器都行;这里用 Node 内置)
mkdir -p /tmp/bubbolink-tunnel-demo
printf 'hello tunnel\n' >/tmp/bubbolink-tunnel-demo/index.html
node -e 'import("node:http").then(({createServer})=>{import("node:fs").then(({readFileSync})=>{createServer((_,res)=>res.end(readFileSync("/tmp/bubbolink-tunnel-demo/index.html"))).listen(8080,"127.0.0.1");console.log("listening on 127.0.0.1:8080");})})'另一个终端:
bubbolink tunnel on 8080示例输出:
Tunnel started
Local: http://127.0.0.1:8080
Public: https://go-relay-test.cece.com/t/p8080-03ce6ff5
Logs: ~/.bubbolink/logs/tunnel-8080.log
Mode: systemd-user说明:
- tunnel agent 会每 10 秒向 relay 发 heartbeat
- relay 超过约 35 秒没收到 heartbeat,会把 tunnel 视为 offline,并对公网请求直接返回
503 tunnel offline bubbolink tunnel on <port>会直接打印公网访问地址,示例里的Public: https://...就是可分享的访问 URL- 如果不传
--relay,默认使用 CLI 当前内置或环境变量指定的 relay 地址 - 后台守护优先走当前平台的服务管理:
- Linux:
systemd --user - macOS:
launchd - Windows:
Task Scheduler
- Linux:
- 如果当前环境没有可用的服务管理器,会自动回退到 detached 进程,并在
bubbolink tunnel on输出里提示
Tunnel 技术实现
这套 tunnel 是标准的反向内网穿透,不要求目标机器有公网 IP,也不要求目标机器开放入站端口。
核心思路:
- 外部用户访问的是 relay server 暴露出来的公网 URL,例如
https://go-relay-test.cece.com/t/<slug> - 内网机器上的
bubbolink tunnel agent主动连接 relay,通过长轮询拉取待处理请求 - agent 在本机访问
127.0.0.1:<port>,再把响应回传给 relay - relay 把响应返回给浏览器或调用方
涉及的 3 个角色:
Client / Browser- 访问公网 tunnel 地址
Relay Server- 负责创建 tunnel、分配
slug、接收公网请求、转发给 agent、返回响应
- 负责创建 tunnel、分配
Tunnel Agent- 运行在用户机器上,负责 heartbeat、拉取请求、访问本地服务、发布响应
关键接口:
POST /v1/tunnels- 创建 tunnel,返回
id、agentToken、publicUrl
- 创建 tunnel,返回
GET /v1/tunnels/:id/requests/next- agent 长轮询拉取待处理请求
POST /v1/tunnels/:id/heartbeat- agent 上报存活状态
POST /v1/tunnels/:id/responses/:reqId/publish- agent 发布本地服务响应
GET /t/:slug/*path- 公网入口,外部流量从这里进入 tunnel
工作流程:
sequenceDiagram
participant U as "User / Browser"
participant R as "Bubbolink Relay Server"
participant A as "Bubbolink Tunnel Agent"
participant L as "Local Service (127.0.0.1:8080)"
A->>R: "POST /v1/tunnels"
R-->>A: "id + agentToken + publicUrl"
A->>R: "POST /v1/tunnels/:id/heartbeat"
A->>R: "GET /v1/tunnels/:id/requests/next"
U->>R: "GET /t/:slug/api/echo?x=1"
R-->>A: "返回待处理 request"
A->>L: "GET http://127.0.0.1:8080/api/echo?x=1"
L-->>A: "200 + headers + body"
A->>R: "POST /v1/tunnels/:id/responses/:reqId/publish"
R-->>U: "200 + headers + body"在线状态与恢复机制:
- agent 每
10s发送一次 heartbeat - server 超过约
35s没收到 heartbeat,会把 tunnel 判定为offline offline状态下,公网请求会直接返回503 tunnel offline- 后台运行优先使用系统服务托管:
- Linux:
systemd --user,root 环境额外支持systemd system service - macOS:
launchd - Windows:
Task Scheduler
- Linux:
- 如果 agent 进程异常退出,服务管理器会自动重启;如果没有服务管理器,则退回 detached 进程模式
实现边界:
- 当前 tunnel 第一版只支持 HTTP 请求/响应转发
- 支持 path、query、JSON/body、常见响应头透传
- 暂不包含 WebSocket、任意 TCP、UDP 转发
- 公网入口当前使用路径路由
/t/<slug>,没有使用独立子域名
复杂路由跳转的适配说明:
- 大多数复杂 Web 系统都可以正常工作,包括:
- SPA 前端路由,例如
react-router、vue-router - 后端页面路由,例如
/admin/users/list - 带 query 的跳转,例如
/orders?page=2&status=paid - 常见的
301/302/307/308重定向
- SPA 前端路由,例如
- 这类场景通常没问题,因为 tunnel 会保留:
- 请求 path
- 原始 query string
- 常规请求头与响应头
- HTTP 状态码
x-forwarded-host
- 真正容易出问题的通常不是“路由复杂”,而是“应用把绝对地址写死”:
- 例如后端返回
Location: http://127.0.0.1:8080/login - 或前端代码里写死
http://内网IP:端口/... - 或登录回调地址、Cookie domain、绝对资源地址强依赖固定 host
- 例如后端返回
- 推荐应用侧尽量使用相对路径,或者正确读取反向代理后的 host/proto 信息生成跳转地址
- 如果系统强依赖 WebSocket、HMR dev server、长连接 upgrade,当前版本需要单独验证,不能默认保证完全可用
固定的 curl 用例:
# 1) GET 根路径
curl -i https://go-relay-test.cece.com/t/p8080-03ce6ff5
# 2) GET 带 path + query
curl -i 'https://go-relay-test.cece.com/t/p8080-03ce6ff5/api/echo?name=bubbolink&mode=query'
# 3) POST JSON 到带 path + query 的入口
curl -i \
-X POST \
'https://go-relay-test.cece.com/t/p8080-03ce6ff5/api/echo?source=curl&lang=zh' \
-H 'content-type: application/json' \
-d '{"hello":"world","from":"bubbolink"}'看 agent 日志:
tail -f ~/.bubbolink/logs/tunnel-8080.log关闭:
bubbolink tunnel off 8080bubbolink reset 的行为:
| 命令 | 作用对象 |
| -------------------------------- | --------------------------------------------------- |
| bubbolink reset | 重置 openclaw 凭证(并额外清空 hermes 凭证,兜底) |
| bubbolink reset --runtime hermes| 只清 hermes 凭证,openclaw 绑定不动 |
reset 做的事:
- 调 relay
DELETE /v1/gateways/{id}/binding清服务端绑定(配对码 / 会话 / 联系人) - 卸载目标 runtime 的后台服务(launchd / systemd / Task Scheduler)
- 停掉目标 runtime 正在跑的桥接进程
- 清
~/.bubbolink/config.json本地凭证:- openclaw → 重新生成
relayGatewayId+ 轮换 token,并连带清空 hermes 凭证 - hermes → 只删
hermesRelayGatewayId/hermesRelayClientToken/hermesRelayGatewayToken
- openclaw → 重新生成
reset 完成后即可对同一 runtime 发起新的 bubbolink pair。
code 接受 1234、123456 或 XXXX-XXXX(参考 normalizeProvidedCode)。
8. 服务管理
| 平台 | 机制 | 单元文件 |
| ------------------ | ----------------- | ----------------------------------------------------------- |
| macOS | launchd | ~/Library/LaunchAgents/com.bubbolink.bridge[-hermes].plist |
| Linux (systemd) | systemd user unit | ~/.config/systemd/user/bubbolink-bridge[-hermes].service |
| Linux (无 systemd) | — | 只能手动 bubbolink bridge,service install 会报友好错误 |
| Windows | Task Scheduler | task name Bubbolink Bridge |
OpenClaw 和 Hermes 两种 runtime 的服务名不同(参考 serviceNames(runtimeKind)),可以同时装一台机上跑两种:
bubbolink service status # 默认看 openclaw
bubbolink service status --runtime hermes # 看 hermes 服务
bubbolink service install # 装 openclaw bridge(默认)
bubbolink service install --runtime hermes # 装 hermes bridge
bubbolink service uninstall [--runtime hermes] # 停 + 删
bubbolink service restart [--runtime hermes] # 重启日志:
# OpenClaw 桥接日志
tail -f ~/.bubbolink/logs/bridge.stdout.log
tail -f ~/.bubbolink/logs/bridge.stderr.log
# Hermes 桥接日志
tail -f ~/.bubbolink/logs/bridge-hermes.stdout.log
tail -f ~/.bubbolink/logs/bridge-hermes.stderr.logClaude / Codex(External Agents)
Claude / Codex 走的是独立的 bubbolink-agent service 管理实现,命令入口仍然是顶层 bubbolink:
| 平台 | 机制 | 单元文件 |
| ------------------ | ----------------- | ----------------------------------------------------------- |
| macOS | launchd | ~/Library/LaunchAgents/com.bubbolink.agent.<runtime>.plist |
| Linux (systemd) | systemd user unit | ~/.config/systemd/user/bubbolink-agent-<runtime>.service |
| Linux (无 systemd) | — | 只能手动 bubbolink bridge -r <runtime> |
| Windows | Task Scheduler | Bubbolink\\Agent-<runtime> |
常用命令:
# 配对后自动装后台服务
bubbolink pair 1234 -r claude
bubbolink pair 1234 -r codex
# 查看后台状态
bubbolink service status -r claude
bubbolink service status -r codex
# 重装后台服务
bubbolink service restart -r claude
bubbolink service restart -r codex
# 只停掉后台服务,但保留本地配对状态
bubbolink service stop -r claude
bubbolink service stop -r codex
# `service uninstall` 对 claude/codex 与 `service stop` 等价
bubbolink service uninstall -r claude
bubbolink service uninstall -r codex
# 完整卸载:停服务并清掉本地配对状态(~/.bubbolink/ext-<runtime>-last.json)
bubbolink uninstall -r claude
bubbolink uninstall -r codex
# 前台调试运行;停止方式就是 Ctrl+C
bubbolink bridge -r claude
bubbolink bridge -r codex说明:
bubbolink pair <code> -r claude|codex成功后,会自动执行后台服务安装。- 对
claude/codex来说,bubbolink service stop -r <runtime>和bubbolink service uninstall -r <runtime>在当前实现里效果相同,都会卸载对应 service,但不会删除本地配对状态。 - 如果你想彻底解绑本机上的 Claude / Codex,请使用
bubbolink uninstall -r <runtime>,它会在停服务之外再清掉本地状态文件。
9. 状态与持久化
| 路径 | 内容 |
| -------------------------------------------- | ------------------------------------------------------------- |
| ~/.openclaw/openclaw.json | OpenClaw gateway 配置(模型、端口、profile、relay 配置片段) |
| ~/.bubbolink/config.json | CLI 级别状态(runtimeKind、4 套 relay 凭证) |
| ~/.bubbolink/hermes/sessions/<sid>.json | Hermes JS bridge 的会话历史持久化 |
| ~/.bubbolink/bridge-launch.sh | OpenClaw 桥接的 launchd/systemd 启动脚本 |
| ~/.bubbolink/bridge-launch-hermes.sh | Hermes 桥接的 launchd/systemd 启动脚本 |
| ~/.bubbolink/logs/ | 桥接 stdout / stderr 日志 |
| ~/.openclaw/bubbolink/runtime/bridge-*.lock | 桥接文件锁(按 relayUrl + gatewayId + gatewayBase 取 hash) |
~/.bubbolink/config.json 里两套凭证并存:
- OpenClaw:
relayGatewayId/relayClientToken/relayGatewayToken - Hermes:
hermesRelayGatewayId/hermesRelayClientToken/hermesRelayGatewayToken
runtimeKind 字段记录最近一次配对的 runtime。敏感字段(gateway.auth.token / channels.*.appSecret)在 debug 输出里会被脱敏成 ***。
10. 环境变量
| 变量 | 作用 |
| ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
| OPENCLAW_HOME | 覆盖 OpenClaw 主目录(默认 ~/.openclaw) |
| BUBBOLINK_RELAY_URL | 覆盖 relay 基址(默认 https://go-relay-test.cece.com) |
| BUBBOLINK_RELAY_TOKEN | Bearer token(配对/绑定端点用) |
| OPENCLAW_BUBBOLINK_GATEWAY_BASE_URL | 覆盖本地 gateway 基址 |
| OPENCLAW_GATEWAY_PORT / OPENCLAW_GATEWAY_HOST | 显式指定 host/port |
| BUBBOLINK_RELAY_GATEWAY_ID / _CLIENT_TOKEN / _GATEWAY_TOKEN | 覆盖 openclaw 的 relay 凭证 |
| BUBBOLINK_HERMES_RELAY_GATEWAY_ID / _CLIENT_TOKEN / _GATEWAY_TOKEN | 覆盖 hermes 的 relay 凭证 |
| HERMES_BRIDGE_API_BASE_URL | Hermes JS bridge HTTP 后端:OpenAI-compatible base URL |
| HERMES_BRIDGE_API_KEY | Hermes HTTP 后端 Bearer token |
| HERMES_BRIDGE_MODEL | Hermes 默认模型名(gpt-4o-mini 等) |
| HERMES_BRIDGE_COMMAND | Hermes 子进程后端:外部 binary 路径(NDJSON 协议) |
11. Hermes JS bridge 多 provider 与流式
Hermes JS bridge 通过 OpenAI-compatible HTTP 协议跑,任何兼容
POST /v1/chat/completions { stream: true } 的服务都能直接接:OpenAI、Azure
OpenAI、DeepSeek、自建 vLLM、LiteLLM、Ollama 兼容层。要切 provider 就改
HERMES_BRIDGE_API_BASE_URL。
流式默认按 OpenAI SSE 帧解析(data: 前缀 + [DONE] 终止符),逐 chunk 透传
为 unified content_block.delta,最终一帧合并为 content_block.completed。
不需要外部多 provider 配置文件 — 后端切换通过 env 完成:
# OpenAI 官方
export HERMES_BRIDGE_API_BASE_URL=https://api.openai.com/v1
export HERMES_BRIDGE_MODEL=gpt-4o-mini
# DeepSeek
export HERMES_BRIDGE_API_BASE_URL=https://api.deepseek.com/v1
export HERMES_BRIDGE_MODEL=deepseek-chat
# 本地 vLLM / Ollama 兼容层
export HERMES_BRIDGE_API_BASE_URL=http://127.0.0.1:8000/v1
export HERMES_BRIDGE_MODEL=qwen2.5需要走非 OpenAI 协议(如 Anthropic 原生 /messages)?用 subprocess 后端:写
一个外部 binary 实现 stdin JSON → stdout NDJSON,然后
export HERMES_BRIDGE_COMMAND=/path/to/my-binary。bridge 会 spawn 它跑每个
turn。
模型选择用 <provider>/<model-id>(例 minimax/MiniMax-M2.7)。桥接先匹配配置的 provider,没匹配到才回退到默认 Hermes API server。
12. 已知陷阱
- relay_client_token 可能在多设备共享 — 删除绑定前必须走
canUseClientTokenDeleteFallback检查,避免误删别人的 binding - Session key 格式前后不一致 — App 侧装饰(
agent:xxx:relay-gw:GW-A::...),relay 事件是裸 key。Flutter 用_sessionKeyMatchesForPreview宽松匹配 - Windows 的 PowerShell 编码 — 服务注册时通过 Base64 传命令行,避免 CJK 路径在 cmd.exe 下被 mojibake
- Hermes / OpenClaw 凭证是两套 —
AppRuntimeKind决定写哪一组,不能互相借用;bubbolink reset不传--runtime默认只重置 openclaw - Hermes cron 任务与配对无关 —
~/.hermes/cron/jobs.json是本地文件。一次性任务(30m/2h/ 具体时间戳)执行后会自动删除,重新配对看不到是正常现象
13. 测试
需要 Node.js ≥ 22。
npm test
node --test test/cli.test.mjs73 个测试(13 个测试文件),覆盖:
- CLI basics / 平台路径 / OpenClaw home 解析 / XML & systemd 转义 / PID 检测 / 文件锁 / PowerShell Base64 / 服务状态 / Node 版本保护
pairCodeClient——/v1/pair-codes/<code>/consume协议契约 + legacy 端点回退relayWorker—— long-poll 拉取 + publishEvent + 心跳 + 退避重试claudeParser/codexEventMapper—— native 事件解析(CLI 端只解析不翻译)approvalGateway/elicitationHandler—— HITL 工具审批mcpStdioClient/mcpStdioBridge/bubbolinkTools—— Codex 反向 MCP 工具注入usageReporter—— token 用量上报
之前 161 测试中有近一半是
unified_relay_shim的 native → unified envelope 翻译契约测试。重构把翻译层挪到服务端 adapter 后,CLI 端只剩 73 测试覆盖 long-poll + pair-code consume + native 事件解析这些核心路径。
CI:
- GitLab CI (
.gitlab-ci.yml):Node 22 on Alpine - GitHub Actions (
.github/workflows/test.yml):Ubuntu / macOS / Windows × Node 22
| 平台 | 状态 |
| ----------------------------------- | ---------------------------- |
| macOS (launchd) | 完全支持 |
| Linux + systemd | 完全支持 |
| Linux 无 systemd(Alpine / OpenRC) | 只支持手动 bubbolink bridge |
| Windows (Task Scheduler) | 支持,CI 覆盖 |
