multiclaws 0.4.42 → 0.4.43

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.
Files changed (38) hide show
  1. package/README.md +2 -0
  2. package/dist/gateway/handlers.d.ts +4 -4
  3. package/dist/gateway/handlers.js +239 -239
  4. package/dist/index.d.ts +8 -8
  5. package/dist/index.js +710 -710
  6. package/dist/infra/frp.d.ts +55 -55
  7. package/dist/infra/frp.js +398 -398
  8. package/dist/infra/gateway-client.d.ts +27 -27
  9. package/dist/infra/gateway-client.js +136 -136
  10. package/dist/infra/json-store.d.ts +4 -4
  11. package/dist/infra/json-store.js +57 -57
  12. package/dist/infra/logger.d.ts +14 -14
  13. package/dist/infra/logger.js +25 -25
  14. package/dist/infra/rate-limiter.d.ts +19 -19
  15. package/dist/infra/rate-limiter.js +69 -69
  16. package/dist/infra/tailscale.d.ts +19 -19
  17. package/dist/infra/tailscale.js +120 -120
  18. package/dist/infra/telemetry.d.ts +3 -3
  19. package/dist/infra/telemetry.js +17 -17
  20. package/dist/infra/version.d.ts +1 -1
  21. package/dist/infra/version.js +19 -19
  22. package/dist/service/a2a-adapter.d.ts +80 -80
  23. package/dist/service/a2a-adapter.js +505 -505
  24. package/dist/service/agent-profile.d.ts +17 -17
  25. package/dist/service/agent-profile.js +58 -58
  26. package/dist/service/agent-registry.d.ts +29 -29
  27. package/dist/service/agent-registry.js +131 -131
  28. package/dist/service/multiclaws-service.d.ts +150 -150
  29. package/dist/service/multiclaws-service.js +1137 -1137
  30. package/dist/service/session-store.d.ts +46 -46
  31. package/dist/service/session-store.js +143 -143
  32. package/dist/task/tracker.d.ts +46 -46
  33. package/dist/task/tracker.js +191 -191
  34. package/dist/team/team-store.d.ts +42 -42
  35. package/dist/team/team-store.js +195 -195
  36. package/dist/types/openclaw.d.ts +109 -109
  37. package/dist/types/openclaw.js +2 -2
  38. package/package.json +1 -1
package/dist/infra/frp.js CHANGED
@@ -1,398 +1,398 @@
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
- // frpc 0.61.x uses /api/status (returns { tcp: [...], udp: [...], ... })
237
- // older versions used /api/proxy/tcp (returns flat array or { proxies: [...] })
238
- const statusUrl = `http://127.0.0.1:${this.adminPort}/api/status`;
239
- for (let attempt = 0; attempt < ADMIN_API_POLL_MAX_RETRIES; attempt++) {
240
- await new Promise((r) => setTimeout(r, ADMIN_API_POLL_INTERVAL_MS));
241
- // Check if process has already exited
242
- if (!this.frpcProcess || this.frpcProcess.exitCode !== null) {
243
- throw new Error("frpc process exited before proxy became ready");
244
- }
245
- try {
246
- const res = await fetch(statusUrl, { signal: AbortSignal.timeout(3_000) });
247
- if (!res.ok)
248
- continue;
249
- const data = (await res.json());
250
- // /api/status groups proxies by type: { tcp: [...], udp: [...], ... }
251
- const tcpProxies = data.tcp ?? [];
252
- const proxy = tcpProxies.find((p) => p.name === proxyName);
253
- if (!proxy)
254
- continue;
255
- if (proxy.status === "running") {
256
- this.logger.info(`[frp] proxy "${proxyName}" is running (remote_addr: ${proxy.remote_addr})`);
257
- return;
258
- }
259
- if (proxy.err) {
260
- throw new Error(`frp proxy error: ${proxy.err}`);
261
- }
262
- // status might be "new" or "wait_start" — keep polling
263
- }
264
- catch (err) {
265
- if (err instanceof Error && err.message.startsWith("frp proxy error:")) {
266
- throw err;
267
- }
268
- // fetch failed (admin API not yet ready) — keep polling
269
- }
270
- }
271
- throw new Error("timeout waiting for frpc proxy to become running");
272
- }
273
- /* ── Private: health check ─────────────────────────────────────── */
274
- startHealthCheck() {
275
- this.healthCheckTimer = setInterval(async () => {
276
- if (this._status.status !== "running")
277
- return;
278
- try {
279
- const res = await fetch(`http://127.0.0.1:${this.adminPort}/api/status`, { signal: AbortSignal.timeout(5_000) });
280
- if (!res.ok) {
281
- this.logger.warn("[frp] health check: admin API returned non-OK");
282
- this._status = { status: "error", reason: "admin API returned non-OK status" };
283
- }
284
- }
285
- catch {
286
- this.logger.warn("[frp] health check: failed to reach admin API");
287
- this._status = { status: "error", reason: "admin API unreachable" };
288
- }
289
- }, HEALTH_CHECK_INTERVAL_MS);
290
- // Don't prevent Node from exiting
291
- if (this.healthCheckTimer.unref) {
292
- this.healthCheckTimer.unref();
293
- }
294
- }
295
- /* ── Private: kill process ─────────────────────────────────────── */
296
- async killProcess() {
297
- const proc = this.frpcProcess;
298
- if (!proc)
299
- return;
300
- this.frpcProcess = null;
301
- // Phase 1: graceful kill
302
- proc.kill();
303
- const exited = await Promise.race([
304
- new Promise((resolve) => proc.on("exit", () => resolve(true))),
305
- new Promise((resolve) => setTimeout(() => resolve(false), PROCESS_KILL_TIMEOUT_MS)),
306
- ]);
307
- if (!exited) {
308
- // Phase 2: force kill
309
- try {
310
- if (process.platform === "win32" && proc.pid) {
311
- (0, node_child_process_1.execSync)(`taskkill /pid ${proc.pid} /f /t`, { stdio: "ignore" });
312
- }
313
- else {
314
- proc.kill("SIGKILL");
315
- }
316
- }
317
- catch {
318
- // process may have already exited
319
- }
320
- }
321
- }
322
- /* ── Private: ensure frpc binary ───────────────────────────────── */
323
- async ensureFrpcBinary() {
324
- const ext = process.platform === "win32" ? ".exe" : "";
325
- const localBinary = node_path_1.default.join(this.stateDir, `frpc${ext}`);
326
- // 1. Check stateDir
327
- try {
328
- await promises_1.default.access(localBinary);
329
- return localBinary;
330
- }
331
- catch {
332
- // not found locally
333
- }
334
- // 2. Check system PATH
335
- if (detectFrpc()) {
336
- return "frpc";
337
- }
338
- // 3. Auto-download
339
- this.logger.info(`[frp] frpc not found, downloading v${FRP_VERSION}...`);
340
- return await this.downloadFrpc(localBinary);
341
- }
342
- async downloadFrpc(targetPath) {
343
- const { os: frpOs, arch: frpArch, ext } = getFrpPlatform();
344
- const archiveName = `frp_${FRP_VERSION}_${frpOs}_${frpArch}`;
345
- const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${archiveName}.${ext}`;
346
- const downloadDir = node_path_1.default.join(this.stateDir, "frpc-download");
347
- await promises_1.default.mkdir(downloadDir, { recursive: true });
348
- const archivePath = node_path_1.default.join(downloadDir, `${archiveName}.${ext}`);
349
- // Download
350
- this.logger.info(`[frp] downloading from ${url}`);
351
- const res = await fetch(url);
352
- if (!res.ok) {
353
- throw new Error(`failed to download frpc: HTTP ${res.status} from ${url}`);
354
- }
355
- const buffer = Buffer.from(await res.arrayBuffer());
356
- await promises_1.default.writeFile(archivePath, buffer);
357
- // Extract
358
- this.logger.info(`[frp] extracting ${archivePath}`);
359
- const binaryName = process.platform === "win32" ? "frpc.exe" : "frpc";
360
- try {
361
- if (ext === "tar.gz") {
362
- (0, node_child_process_1.execSync)(`tar -xzf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
363
- }
364
- else {
365
- // Windows: use spawnSync to bypass cmd.exe quote-mangling that breaks
366
- // the embedded PowerShell double-quotes when execSync(string) is used.
367
- const psCmd = `$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${downloadDir.replace(/'/g, "''")}' -Force`;
368
- const psResult = (0, node_child_process_1.spawnSync)("powershell", ["-NoProfile", "-Command", psCmd], {
369
- stdio: "pipe",
370
- });
371
- if (psResult.status !== 0) {
372
- const stderr = psResult.stderr?.toString().trim() ?? "";
373
- throw new Error(`Expand-Archive failed (exit ${psResult.status}): ${stderr}`);
374
- }
375
- }
376
- // Move binary to target
377
- const extractedBinary = node_path_1.default.join(downloadDir, archiveName, binaryName);
378
- await promises_1.default.mkdir(node_path_1.default.dirname(targetPath), { recursive: true });
379
- await promises_1.default.copyFile(extractedBinary, targetPath);
380
- // Make executable on Unix
381
- if (process.platform !== "win32") {
382
- await promises_1.default.chmod(targetPath, 0o755);
383
- }
384
- this.logger.info(`[frp] frpc installed to ${targetPath}`);
385
- }
386
- finally {
387
- // Cleanup download directory
388
- try {
389
- await promises_1.default.rm(downloadDir, { recursive: true, force: true });
390
- }
391
- catch {
392
- // ignore cleanup errors
393
- }
394
- }
395
- return targetPath;
396
- }
397
- }
398
- exports.FrpTunnelManager = FrpTunnelManager;
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
+ // frpc 0.61.x uses /api/status (returns { tcp: [...], udp: [...], ... })
237
+ // older versions used /api/proxy/tcp (returns flat array or { proxies: [...] })
238
+ const statusUrl = `http://127.0.0.1:${this.adminPort}/api/status`;
239
+ for (let attempt = 0; attempt < ADMIN_API_POLL_MAX_RETRIES; attempt++) {
240
+ await new Promise((r) => setTimeout(r, ADMIN_API_POLL_INTERVAL_MS));
241
+ // Check if process has already exited
242
+ if (!this.frpcProcess || this.frpcProcess.exitCode !== null) {
243
+ throw new Error("frpc process exited before proxy became ready");
244
+ }
245
+ try {
246
+ const res = await fetch(statusUrl, { signal: AbortSignal.timeout(3_000) });
247
+ if (!res.ok)
248
+ continue;
249
+ const data = (await res.json());
250
+ // /api/status groups proxies by type: { tcp: [...], udp: [...], ... }
251
+ const tcpProxies = data.tcp ?? [];
252
+ const proxy = tcpProxies.find((p) => p.name === proxyName);
253
+ if (!proxy)
254
+ continue;
255
+ if (proxy.status === "running") {
256
+ this.logger.info(`[frp] proxy "${proxyName}" is running (remote_addr: ${proxy.remote_addr})`);
257
+ return;
258
+ }
259
+ if (proxy.err) {
260
+ throw new Error(`frp proxy error: ${proxy.err}`);
261
+ }
262
+ // status might be "new" or "wait_start" — keep polling
263
+ }
264
+ catch (err) {
265
+ if (err instanceof Error && err.message.startsWith("frp proxy error:")) {
266
+ throw err;
267
+ }
268
+ // fetch failed (admin API not yet ready) — keep polling
269
+ }
270
+ }
271
+ throw new Error("timeout waiting for frpc proxy to become running");
272
+ }
273
+ /* ── Private: health check ─────────────────────────────────────── */
274
+ startHealthCheck() {
275
+ this.healthCheckTimer = setInterval(async () => {
276
+ if (this._status.status !== "running")
277
+ return;
278
+ try {
279
+ const res = await fetch(`http://127.0.0.1:${this.adminPort}/api/status`, { signal: AbortSignal.timeout(5_000) });
280
+ if (!res.ok) {
281
+ this.logger.warn("[frp] health check: admin API returned non-OK");
282
+ this._status = { status: "error", reason: "admin API returned non-OK status" };
283
+ }
284
+ }
285
+ catch {
286
+ this.logger.warn("[frp] health check: failed to reach admin API");
287
+ this._status = { status: "error", reason: "admin API unreachable" };
288
+ }
289
+ }, HEALTH_CHECK_INTERVAL_MS);
290
+ // Don't prevent Node from exiting
291
+ if (this.healthCheckTimer.unref) {
292
+ this.healthCheckTimer.unref();
293
+ }
294
+ }
295
+ /* ── Private: kill process ─────────────────────────────────────── */
296
+ async killProcess() {
297
+ const proc = this.frpcProcess;
298
+ if (!proc)
299
+ return;
300
+ this.frpcProcess = null;
301
+ // Phase 1: graceful kill
302
+ proc.kill();
303
+ const exited = await Promise.race([
304
+ new Promise((resolve) => proc.on("exit", () => resolve(true))),
305
+ new Promise((resolve) => setTimeout(() => resolve(false), PROCESS_KILL_TIMEOUT_MS)),
306
+ ]);
307
+ if (!exited) {
308
+ // Phase 2: force kill
309
+ try {
310
+ if (process.platform === "win32" && proc.pid) {
311
+ (0, node_child_process_1.execSync)(`taskkill /pid ${proc.pid} /f /t`, { stdio: "ignore" });
312
+ }
313
+ else {
314
+ proc.kill("SIGKILL");
315
+ }
316
+ }
317
+ catch {
318
+ // process may have already exited
319
+ }
320
+ }
321
+ }
322
+ /* ── Private: ensure frpc binary ───────────────────────────────── */
323
+ async ensureFrpcBinary() {
324
+ const ext = process.platform === "win32" ? ".exe" : "";
325
+ const localBinary = node_path_1.default.join(this.stateDir, `frpc${ext}`);
326
+ // 1. Check stateDir
327
+ try {
328
+ await promises_1.default.access(localBinary);
329
+ return localBinary;
330
+ }
331
+ catch {
332
+ // not found locally
333
+ }
334
+ // 2. Check system PATH
335
+ if (detectFrpc()) {
336
+ return "frpc";
337
+ }
338
+ // 3. Auto-download
339
+ this.logger.info(`[frp] frpc not found, downloading v${FRP_VERSION}...`);
340
+ return await this.downloadFrpc(localBinary);
341
+ }
342
+ async downloadFrpc(targetPath) {
343
+ const { os: frpOs, arch: frpArch, ext } = getFrpPlatform();
344
+ const archiveName = `frp_${FRP_VERSION}_${frpOs}_${frpArch}`;
345
+ const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${archiveName}.${ext}`;
346
+ const downloadDir = node_path_1.default.join(this.stateDir, "frpc-download");
347
+ await promises_1.default.mkdir(downloadDir, { recursive: true });
348
+ const archivePath = node_path_1.default.join(downloadDir, `${archiveName}.${ext}`);
349
+ // Download
350
+ this.logger.info(`[frp] downloading from ${url}`);
351
+ const res = await fetch(url);
352
+ if (!res.ok) {
353
+ throw new Error(`failed to download frpc: HTTP ${res.status} from ${url}`);
354
+ }
355
+ const buffer = Buffer.from(await res.arrayBuffer());
356
+ await promises_1.default.writeFile(archivePath, buffer);
357
+ // Extract
358
+ this.logger.info(`[frp] extracting ${archivePath}`);
359
+ const binaryName = process.platform === "win32" ? "frpc.exe" : "frpc";
360
+ try {
361
+ if (ext === "tar.gz") {
362
+ (0, node_child_process_1.execSync)(`tar -xzf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
363
+ }
364
+ else {
365
+ // Windows: use spawnSync to bypass cmd.exe quote-mangling that breaks
366
+ // the embedded PowerShell double-quotes when execSync(string) is used.
367
+ const psCmd = `$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${downloadDir.replace(/'/g, "''")}' -Force`;
368
+ const psResult = (0, node_child_process_1.spawnSync)("powershell", ["-NoProfile", "-Command", psCmd], {
369
+ stdio: "pipe",
370
+ });
371
+ if (psResult.status !== 0) {
372
+ const stderr = psResult.stderr?.toString().trim() ?? "";
373
+ throw new Error(`Expand-Archive failed (exit ${psResult.status}): ${stderr}`);
374
+ }
375
+ }
376
+ // Move binary to target
377
+ const extractedBinary = node_path_1.default.join(downloadDir, archiveName, binaryName);
378
+ await promises_1.default.mkdir(node_path_1.default.dirname(targetPath), { recursive: true });
379
+ await promises_1.default.copyFile(extractedBinary, targetPath);
380
+ // Make executable on Unix
381
+ if (process.platform !== "win32") {
382
+ await promises_1.default.chmod(targetPath, 0o755);
383
+ }
384
+ this.logger.info(`[frp] frpc installed to ${targetPath}`);
385
+ }
386
+ finally {
387
+ // Cleanup download directory
388
+ try {
389
+ await promises_1.default.rm(downloadDir, { recursive: true, force: true });
390
+ }
391
+ catch {
392
+ // ignore cleanup errors
393
+ }
394
+ }
395
+ return targetPath;
396
+ }
397
+ }
398
+ exports.FrpTunnelManager = FrpTunnelManager;