multiclaws 0.4.8 → 0.4.10
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/README.md +22 -19
- package/README.zh-CN.md +9 -6
- package/dist/index.js +15 -1
- package/dist/infra/frp.d.ts +55 -0
- package/dist/infra/frp.js +384 -0
- package/dist/service/a2a-adapter.js +2 -2
- package/dist/service/multiclaws-service.d.ts +10 -1
- package/dist/service/multiclaws-service.js +26 -85
- package/openclaw.plugin.json +14 -1
- package/package.json +1 -1
- package/skills/multiclaws/SKILL.md +24 -10
package/README.md
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
1
|
# MultiClaws
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Multi-agent collaboration plugin for [OpenClaw](https://openclaw.ai). Connect multiple OpenClaw instances into a team and delegate tasks between them using the [A2A protocol](https://google.github.io/A2A/).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[中文文档](README.zh-CN.md)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Installation
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Just tell your AI:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
> Run `openclaw plugins install multiclaws` and tell me what it can do.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Your AI handles the rest — installation, configuration, and profile setup — no manual steps required.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
## Usage
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
- **「用邀请码 mc:xxxxx 加入团队」** — 加入队友的团队
|
|
19
|
-
- **「让 Bob 总结一下最新报告」** — 把任务委派给队友的 AI
|
|
20
|
-
- **「显示所有智能体」** — 查看团队成员及其能力
|
|
17
|
+
Everything works through natural language:
|
|
21
18
|
|
|
22
|
-
|
|
19
|
+
- **"Create a team called my-team"** — creates a team and generates an invite code
|
|
20
|
+
- **"Join team with invite code mc:xxxxx"** — join a teammate's team
|
|
21
|
+
- **"Ask Bob to summarize the latest report"** — delegate a task to a teammate's AI
|
|
22
|
+
- **"Show all agents"** — list team members and their capabilities
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
## How It Works
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
MultiClaws enables multiple OpenClaw instances to collaborate as a team. Each instance acts as both a client (delegating tasks) and a server (receiving tasks from others). Tasks are executed by the remote AI and results are returned directly.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Works out of the box on the same local network. Cross-network collaboration is also supported.
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
See [SKILL.md](skills/multiclaws/SKILL.md) for full details.
|
|
33
|
+
|
|
34
|
+
## Development
|
|
31
35
|
|
|
32
36
|
```bash
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
pnpm test
|
|
37
|
+
npm install
|
|
38
|
+
npm run build
|
|
36
39
|
```
|
package/README.zh-CN.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# MultiClaws
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[OpenClaw](https://openclaw.ai) 多智能体协作插件。将多个 OpenClaw 实例组成团队,通过 [A2A 协议](https://google.github.io/A2A/) 互相委派任务。
|
|
4
|
+
|
|
5
|
+
[English](README.md)
|
|
4
6
|
|
|
5
7
|
## 安装
|
|
6
8
|
|
|
@@ -19,9 +21,11 @@ AI 会自动完成安装、配置和档案生成,无需手动修改任何文
|
|
|
19
21
|
- **「让 Bob 总结一下最新报告」** — 把任务委派给队友的 AI
|
|
20
22
|
- **「显示所有智能体」** — 查看团队成员及其能力
|
|
21
23
|
|
|
22
|
-
##
|
|
24
|
+
## 工作原理
|
|
25
|
+
|
|
26
|
+
MultiClaws 让多个 OpenClaw 实例作为一个团队协作。每个实例既可以作为客户端(委派任务),也可以作为服务端(接收他人任务)。任务由远端 AI 执行,结果直接返回。
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
同局域网开箱即用,也支持跨网络协作。
|
|
25
29
|
|
|
26
30
|
## 详细文档
|
|
27
31
|
|
|
@@ -30,7 +34,6 @@ AI 会自动完成安装、配置和档案生成,无需手动修改任何文
|
|
|
30
34
|
## 开发
|
|
31
35
|
|
|
32
36
|
```bash
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
pnpm test
|
|
37
|
+
npm install
|
|
38
|
+
npm run build
|
|
36
39
|
```
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,23 @@ const logger_1 = require("./infra/logger");
|
|
|
6
6
|
const telemetry_1 = require("./infra/telemetry");
|
|
7
7
|
function readConfig(api) {
|
|
8
8
|
const raw = (api.pluginConfig ?? {});
|
|
9
|
+
let tunnel;
|
|
10
|
+
const rawTunnel = raw.tunnel;
|
|
11
|
+
if (rawTunnel && rawTunnel.type === "frp") {
|
|
12
|
+
tunnel = {
|
|
13
|
+
type: "frp",
|
|
14
|
+
serverAddr: typeof rawTunnel.serverAddr === "string" ? rawTunnel.serverAddr : "",
|
|
15
|
+
serverPort: typeof rawTunnel.serverPort === "number" ? rawTunnel.serverPort : 7000,
|
|
16
|
+
token: typeof rawTunnel.token === "string" ? rawTunnel.token : "",
|
|
17
|
+
portRangeStart: typeof rawTunnel.portRangeStart === "number" ? rawTunnel.portRangeStart : 7011,
|
|
18
|
+
portRangeEnd: typeof rawTunnel.portRangeEnd === "number" ? rawTunnel.portRangeEnd : 7020,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
9
21
|
return {
|
|
10
22
|
port: typeof raw.port === "number" ? raw.port : undefined,
|
|
11
23
|
displayName: typeof raw.displayName === "string" ? raw.displayName : undefined,
|
|
12
24
|
selfUrl: typeof raw.selfUrl === "string" ? raw.selfUrl : undefined,
|
|
25
|
+
tunnel,
|
|
13
26
|
telemetry: {
|
|
14
27
|
consoleExporter: typeof raw.telemetry?.consoleExporter === "boolean"
|
|
15
28
|
? Boolean(raw.telemetry.consoleExporter)
|
|
@@ -332,6 +345,7 @@ const plugin = {
|
|
|
332
345
|
port: config.port,
|
|
333
346
|
displayName: config.displayName,
|
|
334
347
|
selfUrl: config.selfUrl,
|
|
348
|
+
tunnel: config.tunnel,
|
|
335
349
|
gatewayConfig: gatewayConfig ?? undefined,
|
|
336
350
|
logger: structured.logger,
|
|
337
351
|
});
|
|
@@ -392,7 +406,7 @@ multiclaws 插件刚安装,需要在本次对话中完成以下初始化流程
|
|
|
392
406
|
2. 向用户展示生成的档案,并逐一确认以下三项(名字和 bio 需要用户明确回答):
|
|
393
407
|
- **名字**:展示推断出的名字,询问是否需要修改
|
|
394
408
|
- **Bio**:展示生成的 bio,询问是否需要修改
|
|
395
|
-
-
|
|
409
|
+
- **网络情况**:告知用户「所有实例通过 FRP 隧道通信,需在插件配置中设置 tunnel 字段(包含 frps 服务器地址、端口、token 和可用端口范围),frpc 会自动下载安装」,无需用户回答
|
|
396
410
|
|
|
397
411
|
3. 根据用户对名字和 bio 的回答更新档案内容(如需修改),然后调用 \`multiclaws_profile_set(ownerName="...", bio="...")\` 保存。
|
|
398
412
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type FrpTunnelConfig = {
|
|
2
|
+
serverAddr: string;
|
|
3
|
+
serverPort: number;
|
|
4
|
+
token: string;
|
|
5
|
+
portRangeStart: number;
|
|
6
|
+
portRangeEnd: number;
|
|
7
|
+
};
|
|
8
|
+
export type FrpTunnelStatus = {
|
|
9
|
+
status: "running";
|
|
10
|
+
publicUrl: string;
|
|
11
|
+
remotePort: number;
|
|
12
|
+
} | {
|
|
13
|
+
status: "starting";
|
|
14
|
+
} | {
|
|
15
|
+
status: "stopped";
|
|
16
|
+
} | {
|
|
17
|
+
status: "error";
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
type Logger = {
|
|
21
|
+
info: (message: string) => void;
|
|
22
|
+
warn: (message: string) => void;
|
|
23
|
+
error: (message: string) => void;
|
|
24
|
+
};
|
|
25
|
+
/** Check if frpc binary is available in system PATH */
|
|
26
|
+
export declare function detectFrpc(): boolean;
|
|
27
|
+
export declare class FrpTunnelManager {
|
|
28
|
+
private readonly config;
|
|
29
|
+
private readonly localPort;
|
|
30
|
+
private readonly stateDir;
|
|
31
|
+
private readonly logger;
|
|
32
|
+
private frpcProcess;
|
|
33
|
+
private healthCheckTimer;
|
|
34
|
+
private _status;
|
|
35
|
+
private _publicUrl;
|
|
36
|
+
private configPath;
|
|
37
|
+
private adminPort;
|
|
38
|
+
constructor(opts: {
|
|
39
|
+
config: FrpTunnelConfig;
|
|
40
|
+
localPort: number;
|
|
41
|
+
stateDir: string;
|
|
42
|
+
logger?: Logger;
|
|
43
|
+
});
|
|
44
|
+
get status(): FrpTunnelStatus;
|
|
45
|
+
get publicUrl(): string | null;
|
|
46
|
+
start(): Promise<string>;
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
private tryStartWithPort;
|
|
49
|
+
private waitForProxy;
|
|
50
|
+
private startHealthCheck;
|
|
51
|
+
private killProcess;
|
|
52
|
+
private ensureFrpcBinary;
|
|
53
|
+
private downloadFrpc;
|
|
54
|
+
}
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FrpTunnelManager = void 0;
|
|
7
|
+
exports.detectFrpc = detectFrpc;
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
10
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
/* Constants */
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
const FRP_VERSION = "0.61.1";
|
|
17
|
+
const ADMIN_API_POLL_INTERVAL_MS = 1_000;
|
|
18
|
+
const ADMIN_API_POLL_MAX_RETRIES = 15;
|
|
19
|
+
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
20
|
+
const PROCESS_KILL_TIMEOUT_MS = 3_000;
|
|
21
|
+
/* ------------------------------------------------------------------ */
|
|
22
|
+
/* Helpers */
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
function run(cmd, timeoutMs = 5_000) {
|
|
25
|
+
return (0, node_child_process_1.execSync)(cmd, { timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] })
|
|
26
|
+
.toString()
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
/** Check if frpc binary is available in system PATH */
|
|
30
|
+
function detectFrpc() {
|
|
31
|
+
try {
|
|
32
|
+
const cmd = process.platform === "win32" ? "where frpc" : "which frpc";
|
|
33
|
+
run(cmd);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Find an available port by briefly binding to port 0 */
|
|
41
|
+
async function findFreePort() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const server = node_net_1.default.createServer();
|
|
44
|
+
server.listen(0, "127.0.0.1", () => {
|
|
45
|
+
const addr = server.address();
|
|
46
|
+
if (!addr || typeof addr === "string") {
|
|
47
|
+
server.close(() => reject(new Error("failed to get port")));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const port = addr.port;
|
|
51
|
+
server.close(() => resolve(port));
|
|
52
|
+
});
|
|
53
|
+
server.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Parse remote port from frpc admin API response (format: ":12345" or "[::]:12345") */
|
|
57
|
+
function parseRemotePort(remoteAddr) {
|
|
58
|
+
const colonIdx = remoteAddr.lastIndexOf(":");
|
|
59
|
+
if (colonIdx === -1)
|
|
60
|
+
throw new Error(`unexpected remote_addr format: ${remoteAddr}`);
|
|
61
|
+
const portStr = remoteAddr.slice(colonIdx + 1);
|
|
62
|
+
const port = parseInt(portStr, 10);
|
|
63
|
+
if (isNaN(port) || port <= 0)
|
|
64
|
+
throw new Error(`invalid port in remote_addr: ${remoteAddr}`);
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
/** Get platform identifier for frp release download */
|
|
68
|
+
function getFrpPlatform() {
|
|
69
|
+
const platform = process.platform;
|
|
70
|
+
const arch = process.arch;
|
|
71
|
+
let frpOs;
|
|
72
|
+
if (platform === "linux")
|
|
73
|
+
frpOs = "linux";
|
|
74
|
+
else if (platform === "darwin")
|
|
75
|
+
frpOs = "darwin";
|
|
76
|
+
else if (platform === "win32")
|
|
77
|
+
frpOs = "windows";
|
|
78
|
+
else
|
|
79
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
80
|
+
let frpArch;
|
|
81
|
+
if (arch === "x64")
|
|
82
|
+
frpArch = "amd64";
|
|
83
|
+
else if (arch === "arm64")
|
|
84
|
+
frpArch = "arm64";
|
|
85
|
+
else if (arch === "ia32")
|
|
86
|
+
frpArch = "386";
|
|
87
|
+
else
|
|
88
|
+
throw new Error(`unsupported architecture: ${arch}`);
|
|
89
|
+
const ext = platform === "win32" ? "zip" : "tar.gz";
|
|
90
|
+
return { os: frpOs, arch: frpArch, ext };
|
|
91
|
+
}
|
|
92
|
+
/** Shuffle an array in place (Fisher-Yates) */
|
|
93
|
+
function shuffle(arr) {
|
|
94
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
95
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
96
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
97
|
+
}
|
|
98
|
+
return arr;
|
|
99
|
+
}
|
|
100
|
+
/** Generate a range of numbers [start, end) */
|
|
101
|
+
function range(start, end) {
|
|
102
|
+
const result = [];
|
|
103
|
+
for (let i = start; i < end; i++)
|
|
104
|
+
result.push(i);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
/* FrpTunnelManager */
|
|
109
|
+
/* ------------------------------------------------------------------ */
|
|
110
|
+
class FrpTunnelManager {
|
|
111
|
+
config;
|
|
112
|
+
localPort;
|
|
113
|
+
stateDir;
|
|
114
|
+
logger;
|
|
115
|
+
frpcProcess = null;
|
|
116
|
+
healthCheckTimer = null;
|
|
117
|
+
_status = { status: "stopped" };
|
|
118
|
+
_publicUrl = null;
|
|
119
|
+
configPath = "";
|
|
120
|
+
adminPort = 0;
|
|
121
|
+
constructor(opts) {
|
|
122
|
+
this.config = opts.config;
|
|
123
|
+
this.localPort = opts.localPort;
|
|
124
|
+
this.stateDir = opts.stateDir;
|
|
125
|
+
this.logger = opts.logger ?? {
|
|
126
|
+
info: () => { },
|
|
127
|
+
warn: () => { },
|
|
128
|
+
error: () => { },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
get status() {
|
|
132
|
+
return this._status;
|
|
133
|
+
}
|
|
134
|
+
get publicUrl() {
|
|
135
|
+
return this._publicUrl;
|
|
136
|
+
}
|
|
137
|
+
/* ── Start ─────────────────────────────────────────────────────── */
|
|
138
|
+
async start() {
|
|
139
|
+
this._status = { status: "starting" };
|
|
140
|
+
// 1. Ensure frpc binary exists
|
|
141
|
+
const frpcPath = await this.ensureFrpcBinary();
|
|
142
|
+
this.logger.info(`[frp] using frpc binary: ${frpcPath}`);
|
|
143
|
+
// 2. Find free port for admin API
|
|
144
|
+
this.adminPort = await findFreePort();
|
|
145
|
+
// 3. Try ports in random order from range
|
|
146
|
+
const ports = shuffle(range(this.config.portRangeStart, this.config.portRangeEnd + 1));
|
|
147
|
+
for (const port of ports) {
|
|
148
|
+
try {
|
|
149
|
+
const publicUrl = await this.tryStartWithPort(frpcPath, port);
|
|
150
|
+
this._publicUrl = publicUrl;
|
|
151
|
+
this._status = { status: "running", publicUrl, remotePort: port };
|
|
152
|
+
// Start health monitoring
|
|
153
|
+
this.startHealthCheck();
|
|
154
|
+
return publicUrl;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
this.logger.warn(`[frp] port ${port} unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
// Kill process if started, try next port
|
|
159
|
+
await this.killProcess();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this._status = {
|
|
163
|
+
status: "error",
|
|
164
|
+
reason: `all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} exhausted`,
|
|
165
|
+
};
|
|
166
|
+
throw new Error(`FRP tunnel failed: all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} are unavailable`);
|
|
167
|
+
}
|
|
168
|
+
/* ── Stop ──────────────────────────────────────────────────────── */
|
|
169
|
+
async stop() {
|
|
170
|
+
if (this.healthCheckTimer) {
|
|
171
|
+
clearInterval(this.healthCheckTimer);
|
|
172
|
+
this.healthCheckTimer = null;
|
|
173
|
+
}
|
|
174
|
+
await this.killProcess();
|
|
175
|
+
// Cleanup config file
|
|
176
|
+
if (this.configPath) {
|
|
177
|
+
try {
|
|
178
|
+
await promises_1.default.unlink(this.configPath);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// ignore if already removed
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this._status = { status: "stopped" };
|
|
185
|
+
this._publicUrl = null;
|
|
186
|
+
}
|
|
187
|
+
/* ── Private: try a specific port ──────────────────────────────── */
|
|
188
|
+
async tryStartWithPort(frpcPath, remotePort) {
|
|
189
|
+
const proxyName = `multiclaws-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`;
|
|
190
|
+
this.configPath = node_path_1.default.join(this.stateDir, "frpc.toml");
|
|
191
|
+
const configContent = [
|
|
192
|
+
`serverAddr = "${this.config.serverAddr}"`,
|
|
193
|
+
`serverPort = ${this.config.serverPort}`,
|
|
194
|
+
`auth.token = "${this.config.token}"`,
|
|
195
|
+
``,
|
|
196
|
+
`webServer.addr = "127.0.0.1"`,
|
|
197
|
+
`webServer.port = ${this.adminPort}`,
|
|
198
|
+
``,
|
|
199
|
+
`[[proxies]]`,
|
|
200
|
+
`name = "${proxyName}"`,
|
|
201
|
+
`type = "tcp"`,
|
|
202
|
+
`localIP = "127.0.0.1"`,
|
|
203
|
+
`localPort = ${this.localPort}`,
|
|
204
|
+
`remotePort = ${remotePort}`,
|
|
205
|
+
].join("\n");
|
|
206
|
+
// Ensure stateDir exists
|
|
207
|
+
await promises_1.default.mkdir(this.stateDir, { recursive: true });
|
|
208
|
+
await promises_1.default.writeFile(this.configPath, configContent, "utf8");
|
|
209
|
+
// Spawn frpc
|
|
210
|
+
this.frpcProcess = (0, node_child_process_1.spawn)(frpcPath, ["-c", this.configPath], {
|
|
211
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
212
|
+
});
|
|
213
|
+
// Capture stdout/stderr for logging
|
|
214
|
+
this.frpcProcess.stdout?.on("data", (data) => {
|
|
215
|
+
const line = data.toString().trim();
|
|
216
|
+
if (line)
|
|
217
|
+
this.logger.info(`[frpc] ${line}`);
|
|
218
|
+
});
|
|
219
|
+
this.frpcProcess.stderr?.on("data", (data) => {
|
|
220
|
+
const line = data.toString().trim();
|
|
221
|
+
if (line)
|
|
222
|
+
this.logger.warn(`[frpc:stderr] ${line}`);
|
|
223
|
+
});
|
|
224
|
+
this.frpcProcess.on("exit", (code, signal) => {
|
|
225
|
+
if (this._status.status === "running") {
|
|
226
|
+
this.logger.error(`[frp] frpc process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
227
|
+
this._status = { status: "error", reason: `frpc exited (code=${code})` };
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// Poll admin API to confirm proxy is running
|
|
231
|
+
await this.waitForProxy(proxyName);
|
|
232
|
+
return `http://${this.config.serverAddr}:${remotePort}`;
|
|
233
|
+
}
|
|
234
|
+
/* ── Private: poll admin API ──────────────────────────────────── */
|
|
235
|
+
async waitForProxy(proxyName) {
|
|
236
|
+
const url = `http://127.0.0.1:${this.adminPort}/api/proxy/tcp`;
|
|
237
|
+
for (let attempt = 0; attempt < ADMIN_API_POLL_MAX_RETRIES; attempt++) {
|
|
238
|
+
await new Promise((r) => setTimeout(r, ADMIN_API_POLL_INTERVAL_MS));
|
|
239
|
+
// Check if process has already exited
|
|
240
|
+
if (!this.frpcProcess || this.frpcProcess.exitCode !== null) {
|
|
241
|
+
throw new Error("frpc process exited before proxy became ready");
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3_000) });
|
|
245
|
+
if (!res.ok)
|
|
246
|
+
continue;
|
|
247
|
+
const data = (await res.json());
|
|
248
|
+
const proxy = data.proxies?.find((p) => p.name === proxyName);
|
|
249
|
+
if (!proxy)
|
|
250
|
+
continue;
|
|
251
|
+
if (proxy.status === "running") {
|
|
252
|
+
this.logger.info(`[frp] proxy "${proxyName}" is running (remote_addr: ${proxy.remote_addr})`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (proxy.err) {
|
|
256
|
+
throw new Error(`frp proxy error: ${proxy.err}`);
|
|
257
|
+
}
|
|
258
|
+
// status might be "new" or "wait_start" — keep polling
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (err instanceof Error && err.message.startsWith("frp proxy error:")) {
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
// fetch failed (admin API not yet ready) — keep polling
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
throw new Error("timeout waiting for frpc proxy to become running");
|
|
268
|
+
}
|
|
269
|
+
/* ── Private: health check ─────────────────────────────────────── */
|
|
270
|
+
startHealthCheck() {
|
|
271
|
+
this.healthCheckTimer = setInterval(async () => {
|
|
272
|
+
if (this._status.status !== "running")
|
|
273
|
+
return;
|
|
274
|
+
try {
|
|
275
|
+
const res = await fetch(`http://127.0.0.1:${this.adminPort}/api/proxy/tcp`, { signal: AbortSignal.timeout(5_000) });
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
this.logger.warn("[frp] health check: admin API returned non-OK");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
this.logger.warn("[frp] health check: failed to reach admin API");
|
|
282
|
+
}
|
|
283
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
284
|
+
// Don't prevent Node from exiting
|
|
285
|
+
if (this.healthCheckTimer.unref) {
|
|
286
|
+
this.healthCheckTimer.unref();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/* ── Private: kill process ─────────────────────────────────────── */
|
|
290
|
+
async killProcess() {
|
|
291
|
+
const proc = this.frpcProcess;
|
|
292
|
+
if (!proc)
|
|
293
|
+
return;
|
|
294
|
+
this.frpcProcess = null;
|
|
295
|
+
// Phase 1: graceful kill
|
|
296
|
+
proc.kill();
|
|
297
|
+
const exited = await Promise.race([
|
|
298
|
+
new Promise((resolve) => proc.on("exit", () => resolve(true))),
|
|
299
|
+
new Promise((resolve) => setTimeout(() => resolve(false), PROCESS_KILL_TIMEOUT_MS)),
|
|
300
|
+
]);
|
|
301
|
+
if (!exited) {
|
|
302
|
+
// Phase 2: force kill
|
|
303
|
+
try {
|
|
304
|
+
if (process.platform === "win32" && proc.pid) {
|
|
305
|
+
(0, node_child_process_1.execSync)(`taskkill /pid ${proc.pid} /f /t`, { stdio: "ignore" });
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
proc.kill("SIGKILL");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// process may have already exited
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/* ── Private: ensure frpc binary ───────────────────────────────── */
|
|
317
|
+
async ensureFrpcBinary() {
|
|
318
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
319
|
+
const localBinary = node_path_1.default.join(this.stateDir, `frpc${ext}`);
|
|
320
|
+
// 1. Check stateDir
|
|
321
|
+
try {
|
|
322
|
+
await promises_1.default.access(localBinary);
|
|
323
|
+
return localBinary;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// not found locally
|
|
327
|
+
}
|
|
328
|
+
// 2. Check system PATH
|
|
329
|
+
if (detectFrpc()) {
|
|
330
|
+
return "frpc";
|
|
331
|
+
}
|
|
332
|
+
// 3. Auto-download
|
|
333
|
+
this.logger.info(`[frp] frpc not found, downloading v${FRP_VERSION}...`);
|
|
334
|
+
return await this.downloadFrpc(localBinary);
|
|
335
|
+
}
|
|
336
|
+
async downloadFrpc(targetPath) {
|
|
337
|
+
const { os: frpOs, arch: frpArch, ext } = getFrpPlatform();
|
|
338
|
+
const archiveName = `frp_${FRP_VERSION}_${frpOs}_${frpArch}`;
|
|
339
|
+
const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${archiveName}.${ext}`;
|
|
340
|
+
const downloadDir = node_path_1.default.join(this.stateDir, "frpc-download");
|
|
341
|
+
await promises_1.default.mkdir(downloadDir, { recursive: true });
|
|
342
|
+
const archivePath = node_path_1.default.join(downloadDir, `${archiveName}.${ext}`);
|
|
343
|
+
// Download
|
|
344
|
+
this.logger.info(`[frp] downloading from ${url}`);
|
|
345
|
+
const res = await fetch(url);
|
|
346
|
+
if (!res.ok) {
|
|
347
|
+
throw new Error(`failed to download frpc: HTTP ${res.status} from ${url}`);
|
|
348
|
+
}
|
|
349
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
350
|
+
await promises_1.default.writeFile(archivePath, buffer);
|
|
351
|
+
// Extract
|
|
352
|
+
this.logger.info(`[frp] extracting ${archivePath}`);
|
|
353
|
+
const binaryName = process.platform === "win32" ? "frpc.exe" : "frpc";
|
|
354
|
+
try {
|
|
355
|
+
if (ext === "tar.gz") {
|
|
356
|
+
(0, node_child_process_1.execSync)(`tar -xzf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// Windows: use tar (available since Windows 10 1803)
|
|
360
|
+
(0, node_child_process_1.execSync)(`tar -xf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
|
|
361
|
+
}
|
|
362
|
+
// Move binary to target
|
|
363
|
+
const extractedBinary = node_path_1.default.join(downloadDir, archiveName, binaryName);
|
|
364
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(targetPath), { recursive: true });
|
|
365
|
+
await promises_1.default.copyFile(extractedBinary, targetPath);
|
|
366
|
+
// Make executable on Unix
|
|
367
|
+
if (process.platform !== "win32") {
|
|
368
|
+
await promises_1.default.chmod(targetPath, 0o755);
|
|
369
|
+
}
|
|
370
|
+
this.logger.info(`[frp] frpc installed to ${targetPath}`);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
// Cleanup download directory
|
|
374
|
+
try {
|
|
375
|
+
await promises_1.default.rm(downloadDir, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// ignore cleanup errors
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return targetPath;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
exports.FrpTunnelManager = FrpTunnelManager;
|
|
@@ -110,8 +110,8 @@ class OpenClawAgentExecutor {
|
|
|
110
110
|
const gateway = this.gatewayConfig;
|
|
111
111
|
const startTime = Date.now();
|
|
112
112
|
let attempt = 0;
|
|
113
|
-
//
|
|
114
|
-
const pollDelays = [
|
|
113
|
+
// Start aggressive, max out at 500ms to minimize result latency
|
|
114
|
+
const pollDelays = [100, 200, 300, 500];
|
|
115
115
|
while (Date.now() - startTime < timeoutMs) {
|
|
116
116
|
const delay = pollDelays[Math.min(attempt, pollDelays.length - 1)];
|
|
117
117
|
await sleep(delay);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
+
import { type FrpTunnelConfig } from "../infra/frp";
|
|
2
3
|
import { type AgentRecord } from "./agent-registry";
|
|
3
4
|
import { type AgentProfile } from "./agent-profile";
|
|
4
5
|
import { type TeamRecord, type TeamMember } from "../team/team-store";
|
|
@@ -8,6 +9,9 @@ export type MulticlawsServiceOptions = {
|
|
|
8
9
|
port?: number;
|
|
9
10
|
displayName?: string;
|
|
10
11
|
selfUrl?: string;
|
|
12
|
+
tunnel?: FrpTunnelConfig & {
|
|
13
|
+
type: "frp";
|
|
14
|
+
};
|
|
11
15
|
gatewayConfig?: GatewayConfig;
|
|
12
16
|
logger?: {
|
|
13
17
|
info: (message: string) => void;
|
|
@@ -35,6 +39,7 @@ export declare class MulticlawsService extends EventEmitter {
|
|
|
35
39
|
private agentCard;
|
|
36
40
|
private readonly clientFactory;
|
|
37
41
|
private readonly httpRateLimiter;
|
|
42
|
+
private frpTunnel;
|
|
38
43
|
private selfUrl;
|
|
39
44
|
private profileDescription;
|
|
40
45
|
constructor(options: MulticlawsServiceOptions);
|
|
@@ -89,9 +94,13 @@ export declare class MulticlawsService extends EventEmitter {
|
|
|
89
94
|
private fetchMemberDescriptions;
|
|
90
95
|
private syncTeamToRegistry;
|
|
91
96
|
private createA2AClient;
|
|
97
|
+
/**
|
|
98
|
+
* Send a message using A2A streaming to minimize latency.
|
|
99
|
+
* Instead of a single blocking HTTP call, consume the SSE stream and
|
|
100
|
+
* return the final Task or Message as soon as B signals completion.
|
|
101
|
+
*/
|
|
92
102
|
private processTaskResult;
|
|
93
103
|
private extractArtifactText;
|
|
94
|
-
private notifyTailscaleSetup;
|
|
95
104
|
/** Fetch with up to 2 retries and exponential backoff. */
|
|
96
105
|
private fetchWithRetry;
|
|
97
106
|
private log;
|
|
@@ -9,7 +9,7 @@ const node_os_1 = __importDefault(require("node:os"));
|
|
|
9
9
|
const node_http_1 = __importDefault(require("node:http"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
11
|
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
12
|
-
const
|
|
12
|
+
const frp_1 = require("../infra/frp");
|
|
13
13
|
const json_store_1 = require("../infra/json-store");
|
|
14
14
|
const express_1 = __importDefault(require("express"));
|
|
15
15
|
const server_1 = require("@a2a-js/sdk/server");
|
|
@@ -21,7 +21,6 @@ const agent_profile_1 = require("./agent-profile");
|
|
|
21
21
|
const team_store_1 = require("../team/team-store");
|
|
22
22
|
const tracker_1 = require("../task/tracker");
|
|
23
23
|
const zod_1 = require("zod");
|
|
24
|
-
const gateway_client_1 = require("../infra/gateway-client");
|
|
25
24
|
const rate_limiter_1 = require("../infra/rate-limiter");
|
|
26
25
|
/* ------------------------------------------------------------------ */
|
|
27
26
|
/* Service */
|
|
@@ -39,6 +38,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
39
38
|
agentCard = null;
|
|
40
39
|
clientFactory = new client_1.ClientFactory();
|
|
41
40
|
httpRateLimiter = new rate_limiter_1.RateLimiter({ windowMs: 60_000, maxRequests: 60 });
|
|
41
|
+
frpTunnel = null;
|
|
42
42
|
selfUrl;
|
|
43
43
|
profileDescription = "OpenClaw agent";
|
|
44
44
|
constructor(options) {
|
|
@@ -51,33 +51,28 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
51
51
|
this.taskTracker = new tracker_1.TaskTracker({
|
|
52
52
|
filePath: node_path_1.default.join(multiclawsStateDir, "tasks.json"),
|
|
53
53
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.selfUrl = options.selfUrl ?? `http://${getLocalIp()}:${port}`;
|
|
54
|
+
// selfUrl resolved later in start() after FRP tunnel setup
|
|
55
|
+
this.selfUrl = options.selfUrl ?? "";
|
|
57
56
|
}
|
|
58
57
|
async start() {
|
|
59
58
|
if (this.started)
|
|
60
59
|
return;
|
|
61
|
-
//
|
|
60
|
+
// Resolve selfUrl: explicit config > FRP tunnel
|
|
62
61
|
if (!this.options.selfUrl) {
|
|
63
62
|
const port = this.options.port ?? 3100;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
this.selfUrl = `http://${tsIp}:${port}`;
|
|
68
|
-
this.log("info", `Tailscale IP detected: ${tsIp}`);
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
// Slow path: Tailscale not active — run full detection and notify user
|
|
72
|
-
const tailscale = await (0, tailscale_1.detectTailscale)();
|
|
73
|
-
if (tailscale.status === "ready") {
|
|
74
|
-
this.selfUrl = `http://${tailscale.ip}:${port}`;
|
|
75
|
-
this.log("info", `Tailscale IP detected: ${tailscale.ip}`);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
void this.notifyTailscaleSetup(tailscale);
|
|
79
|
-
}
|
|
63
|
+
if (!this.options.tunnel || this.options.tunnel.type !== "frp") {
|
|
64
|
+
throw new Error("multiclaws requires either 'selfUrl' or 'tunnel' configuration. " +
|
|
65
|
+
"Please configure tunnel in plugin settings.");
|
|
80
66
|
}
|
|
67
|
+
this.frpTunnel = new frp_1.FrpTunnelManager({
|
|
68
|
+
config: this.options.tunnel,
|
|
69
|
+
localPort: port,
|
|
70
|
+
stateDir: node_path_1.default.join(this.options.stateDir, "multiclaws"),
|
|
71
|
+
logger: this.options.logger,
|
|
72
|
+
});
|
|
73
|
+
const publicUrl = await this.frpTunnel.start();
|
|
74
|
+
this.selfUrl = publicUrl;
|
|
75
|
+
this.log("info", `FRP tunnel ready: ${publicUrl}`);
|
|
81
76
|
}
|
|
82
77
|
// Load profile for AgentCard description
|
|
83
78
|
let profile = await this.profileStore.load();
|
|
@@ -149,6 +144,10 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
149
144
|
this.started = false;
|
|
150
145
|
this.taskTracker.destroy();
|
|
151
146
|
this.httpRateLimiter.destroy();
|
|
147
|
+
if (this.frpTunnel) {
|
|
148
|
+
await this.frpTunnel.stop();
|
|
149
|
+
this.frpTunnel = null;
|
|
150
|
+
}
|
|
152
151
|
await new Promise((resolve) => {
|
|
153
152
|
if (!this.httpServer) {
|
|
154
153
|
resolve();
|
|
@@ -618,6 +617,11 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
618
617
|
async createA2AClient(agent) {
|
|
619
618
|
return await this.clientFactory.createFromUrl(agent.url);
|
|
620
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Send a message using A2A streaming to minimize latency.
|
|
622
|
+
* Instead of a single blocking HTTP call, consume the SSE stream and
|
|
623
|
+
* return the final Task or Message as soon as B signals completion.
|
|
624
|
+
*/
|
|
621
625
|
processTaskResult(trackId, result) {
|
|
622
626
|
if ("status" in result && result.status) {
|
|
623
627
|
const task = result;
|
|
@@ -648,49 +652,6 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
648
652
|
.map((p) => p.text)
|
|
649
653
|
.join("\n");
|
|
650
654
|
}
|
|
651
|
-
async notifyTailscaleSetup(tailscale) {
|
|
652
|
-
let message;
|
|
653
|
-
if (tailscale.status === "needs_auth") {
|
|
654
|
-
message = [
|
|
655
|
-
"🔗 **MultiClaws: Tailscale 登录**",
|
|
656
|
-
"",
|
|
657
|
-
"Tailscale 已安装但未登录,跨网络协作需要完成登录。",
|
|
658
|
-
"",
|
|
659
|
-
`👉 **请在浏览器打开:** ${tailscale.authUrl}`,
|
|
660
|
-
"",
|
|
661
|
-
"登录完成后重启 OpenClaw 即可。",
|
|
662
|
-
"_(局域网内协作无需此步骤,现在即可使用)_",
|
|
663
|
-
].join("\n");
|
|
664
|
-
}
|
|
665
|
-
else {
|
|
666
|
-
// not_installed or unavailable
|
|
667
|
-
message = [
|
|
668
|
-
"🌐 **MultiClaws: 跨网络协作提示**",
|
|
669
|
-
"",
|
|
670
|
-
"**局域网内已可直接协作,无需任何配置。**",
|
|
671
|
-
"",
|
|
672
|
-
"如需跨网络(不同局域网间)协作,请安装 Tailscale:",
|
|
673
|
-
"https://tailscale.com/download",
|
|
674
|
-
"",
|
|
675
|
-
"安装并登录后重启 OpenClaw,将自动配置跨网络连接。",
|
|
676
|
-
].join("\n");
|
|
677
|
-
}
|
|
678
|
-
// Send to user via gateway (best-effort, don't throw)
|
|
679
|
-
if (this.options.gatewayConfig) {
|
|
680
|
-
try {
|
|
681
|
-
await (0, gateway_client_1.invokeGatewayTool)({
|
|
682
|
-
gateway: this.options.gatewayConfig,
|
|
683
|
-
tool: "message",
|
|
684
|
-
args: { action: "send", message },
|
|
685
|
-
timeoutMs: 5_000,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
catch {
|
|
689
|
-
// Fallback to log
|
|
690
|
-
this.log("warn", message.replace(/\*\*/g, "").replace(/```[^`]*```/gs, ""));
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
655
|
/** Fetch with up to 2 retries and exponential backoff. */
|
|
695
656
|
async fetchWithRetry(url, init, retries = 2) {
|
|
696
657
|
let lastError = null;
|
|
@@ -715,23 +676,3 @@ class MulticlawsService extends node_events_1.EventEmitter {
|
|
|
715
676
|
}
|
|
716
677
|
}
|
|
717
678
|
exports.MulticlawsService = MulticlawsService;
|
|
718
|
-
function getLocalIp() {
|
|
719
|
-
// Prefer Tailscale IP if available
|
|
720
|
-
const tsIp = (0, tailscale_1.getTailscaleIpFromInterfaces)();
|
|
721
|
-
if (tsIp)
|
|
722
|
-
return tsIp;
|
|
723
|
-
const interfaces = node_os_1.default.networkInterfaces();
|
|
724
|
-
let fallback;
|
|
725
|
-
for (const addrs of Object.values(interfaces)) {
|
|
726
|
-
if (!addrs)
|
|
727
|
-
continue;
|
|
728
|
-
for (const addr of addrs) {
|
|
729
|
-
if (addr.family === "IPv4" && !addr.internal) {
|
|
730
|
-
if (addr.address.startsWith("192.168."))
|
|
731
|
-
return addr.address;
|
|
732
|
-
fallback ??= addr.address;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
return fallback ?? node_os_1.default.hostname();
|
|
737
|
-
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -20,7 +20,20 @@
|
|
|
20
20
|
},
|
|
21
21
|
"selfUrl": {
|
|
22
22
|
"type": "string",
|
|
23
|
-
"description": "Publicly reachable URL for this agent.
|
|
23
|
+
"description": "Publicly reachable URL for this agent. If not set, tunnel configuration is required."
|
|
24
|
+
},
|
|
25
|
+
"tunnel": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"description": "FRP tunnel configuration for cross-network connectivity. Required if selfUrl is not set.",
|
|
28
|
+
"properties": {
|
|
29
|
+
"type": { "type": "string", "enum": ["frp"] },
|
|
30
|
+
"serverAddr": { "type": "string", "description": "FRP server address (IP or domain)" },
|
|
31
|
+
"serverPort": { "type": "integer", "default": 7000, "description": "FRP server bind port" },
|
|
32
|
+
"token": { "type": "string", "description": "FRP authentication token" },
|
|
33
|
+
"portRangeStart": { "type": "integer", "description": "Start of available remote port range" },
|
|
34
|
+
"portRangeEnd": { "type": "integer", "description": "End of available remote port range (inclusive)" }
|
|
35
|
+
},
|
|
36
|
+
"required": ["type", "serverAddr", "serverPort", "token", "portRangeStart", "portRangeEnd"]
|
|
24
37
|
},
|
|
25
38
|
"telemetry": {
|
|
26
39
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ MultiClaws 让多个 OpenClaw 实例通过 [A2A(Agent-to-Agent)](https://goo
|
|
|
23
23
|
2. 向用户展示生成的档案,并逐一确认以下三项:
|
|
24
24
|
- **名字**:展示推断出的名字,询问是否需要修改(需用户明确回答)
|
|
25
25
|
- **Bio**:展示生成的 bio,询问是否需要修改(需用户明确回答)
|
|
26
|
-
-
|
|
26
|
+
- **网络情况**:告知用户「所有实例通过 FRP 隧道通信,需在插件配置中设置 tunnel 字段(frps 地址、端口、token、可用端口范围),frpc 会自动下载安装」,无需用户回答
|
|
27
27
|
|
|
28
28
|
3. 根据用户对名字和 bio 的回答更新内容后,调用 `multiclaws_profile_set(ownerName="...", bio="...")` 保存档案。
|
|
29
29
|
|
|
@@ -153,15 +153,29 @@ multiclaws_profile_show()
|
|
|
153
153
|
|
|
154
154
|
---
|
|
155
155
|
|
|
156
|
-
##
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
## 网络配置
|
|
157
|
+
|
|
158
|
+
所有实例通过 FRP 隧道通信。需要一台运行 frps 的公网服务器。
|
|
159
|
+
frpc 会在首次启动时自动下载安装,无需手动安装。
|
|
160
|
+
|
|
161
|
+
在插件配置中添加 tunnel 字段即可:
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"tunnel": {
|
|
165
|
+
"type": "frp",
|
|
166
|
+
"serverAddr": "你的frps地址",
|
|
167
|
+
"serverPort": 7000,
|
|
168
|
+
"token": "你的认证令牌",
|
|
169
|
+
"portRangeStart": 7011,
|
|
170
|
+
"portRangeEnd": 7020
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
161
174
|
|
|
162
175
|
当加入或委派因网络错误失败时(ECONNREFUSED、ETIMEDOUT、fetch failed),引导用户:
|
|
163
176
|
|
|
164
|
-
>
|
|
165
|
-
> 1.
|
|
166
|
-
> 2.
|
|
167
|
-
> 3.
|
|
177
|
+
> 网络连接失败,请检查:
|
|
178
|
+
> 1. 确认 frpc 已正常启动(查看日志中的 `[frp] FRP tunnel ready` 信息)
|
|
179
|
+
> 2. 检查插件配置中的 `tunnel.serverAddr` / `tunnel.token` 是否正确
|
|
180
|
+
> 3. 确认 frps 服务器可达(`telnet frps地址 端口`)
|
|
181
|
+
> 4. 重启 OpenClaw
|