@mearl/cloud-server 2.0.2 → 2.2.0

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 CHANGED
@@ -21,6 +21,7 @@ mearl-cloud-server [command] [options]
21
21
  ```
22
22
 
23
23
  **子命令:**
24
+
24
25
  - `start` - 后台启动 server(默认,不写也等价于 `start`)
25
26
  - `stop` - 停止后台 server
26
27
  - `restart` - 重启后台 server
@@ -28,6 +29,7 @@ mearl-cloud-server [command] [options]
28
29
  - `logs` - 打印 server 日志末尾
29
30
 
30
31
  **选项:**
32
+
31
33
  - `--foreground` / `-f` - 前台运行(不守护化,适合 systemd / Docker / PM2)
32
34
  - `--port <port>` - 服务器端口(默认 8080)
33
35
  - `--path <path>` - WebSocket 路径(默认 /ws)
@@ -36,6 +38,7 @@ mearl-cloud-server [command] [options]
36
38
  - `--max-connections <n>` - 最大连接数(默认 100)
37
39
 
38
40
  **示例:**
41
+
39
42
  ```bash
40
43
  # 后台启动(自动生成 token),打印连接命令后立即返回
41
44
  mearl-cloud-server
@@ -55,6 +58,7 @@ mearl-cloud-server --foreground
55
58
  ```
56
59
 
57
60
  后台启动后会打印供本地机器使用的连接命令,例如:
61
+
58
62
  ```
59
63
  [CloudServer] Started in background (pid 12345)
60
64
  [CloudServer] Logs: ~/.mearl/cloud-server.log
@@ -64,16 +68,19 @@ mearl-cloud-server --foreground
64
68
  ```
65
69
 
66
70
  运行时文件位于 `~/.mearl/`:
71
+
67
72
  - `cloud-server.json` - 连接配置(含 server URL、pid、端口等),cloud-client 自动读取
68
73
  - `cloud-server.log` - 后台进程日志(`logs` 子命令读取,或 `tail -f` 跟踪);超过 5MB 会在下次启动时滚动为 `cloud-server.log.1`
69
74
 
70
75
  > 守护进程的启停、状态、日志滚动等生命周期逻辑由共享包 [`@mearl/daemon-core`](../../daemon-core) 提供(与 cloud-connector 复用同一套实现)。
71
76
 
72
77
  **关于 token:**
78
+
73
79
  - 未指定 `--token` 时会自动生成。`restart` 会**沿用当前 token**,因此之前打印的连接命令重启后仍然有效。
74
80
  - `stop` 后再 `start` 属于全新启动,会重新生成 token;若希望连接命令长期固定,请显式传入 `--token` 或设置 `MEARL_TOKEN`。
75
81
 
76
82
  **环境变量:**
83
+
77
84
  - `MEARL_TOKEN` - 鉴权密钥(覆盖 --token 选项)
78
85
  - `CLOUD_SERVER_HOST` - 公网主机名(默认自动获取)
79
86
 
@@ -125,21 +132,32 @@ Server 接受两种类型的连接:
125
132
  1. **Agent 连接** - 发起请求的云端客户端
126
133
  2. **Connector 连接** - 转发请求到 native-host 的本地连接器
127
134
 
128
- Server 会自动识别连接类型,并将 Agent 的请求转发到可用的 Connector。
135
+ 两类客户端建连后会分别发送 `agent_hello` 和 `connector_hello`。Connector 会注册
136
+ 稳定 ID、名称和版本;同一 ID 重连时,新连接会替换旧连接。
137
+
138
+ 只有一个 Connector 在线时,Server 自动选择它。多个 Connector 同时在线时,Agent
139
+ 先调用 `connector_list` 获取清单,再在请求中传 `connector`;未指定目标时
140
+ Server 会拒绝请求,避免把浏览器操作发到错误机器。
129
141
 
130
142
  ## 消息协议
131
143
 
132
144
  ### 请求格式(Agent → Server)
145
+
133
146
  ```json
134
147
  {
135
148
  "id": "unique-id",
136
149
  "action": "get_requests",
137
150
  "data": { "count": 5 },
151
+ "connector": "work-mac",
138
152
  "version": "1.30.0"
139
153
  }
140
154
  ```
141
155
 
156
+ `connector` 接受 connector ID 或可唯一匹配的名称。`connector_list` 是
157
+ Server 内置命令,不会转发到浏览器侧。
158
+
142
159
  ### 响应格式(Server → Agent)
160
+
143
161
  ```json
144
162
  {
145
163
  "id": "unique-id",
@@ -150,6 +168,7 @@ Server 会自动识别连接类型,并将 Agent 的请求转发到可用的 Co
150
168
  ```
151
169
 
152
170
  ### 心跳格式
171
+
153
172
  ```json
154
173
  { "type": "ping", "timestamp": 1234567890 }
155
174
  { "type": "pong", "timestamp": 1234567890 }
@@ -211,4 +230,4 @@ pm2 start mearl-cloud-server --name mtop-cloud-server -- --foreground --port 808
211
230
  - 设置合理的 `maxConnections` 限制资源占用
212
231
  - 启用 `token` 鉴权保护服务安全
213
232
  - 在沙箱环境中启动时,会自动检测并使用平台分配的公网地址
214
- - 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址
233
+ - 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址
package/dist/cli.js CHANGED
@@ -7,7 +7,8 @@ import { createDaemon, readRecord, removeRecord } from "@mearl/daemon-core";
7
7
  // src/index.ts
8
8
  import WebSocket, { WebSocketServer } from "ws";
9
9
  import { createServer as createHttpServer } from "node:http";
10
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
10
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
11
+ import { randomBytes } from "node:crypto";
11
12
  import { networkInterfaces } from "node:os";
12
13
 
13
14
  // src/paths.ts
@@ -19,9 +20,12 @@ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
19
20
 
20
21
  // src/index.ts
21
22
  import {
23
+ isAgentHelloMessage,
24
+ isConnectorHelloMessage,
22
25
  isHeartbeatMessage,
23
26
  isCloudMessage,
24
27
  isCloudResponse,
28
+ resolveCloudConnectorSelector,
25
29
  MAX_BUFFER_SIZE,
26
30
  DEFAULT_HEARTBEAT_TIMEOUT,
27
31
  DEFAULT_REQUEST_TIMEOUT
@@ -45,6 +49,7 @@ var DEFAULT_PATH = "/ws";
45
49
  var DEFAULT_MAX_CONNECTIONS = 100;
46
50
  var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
47
51
  var RESPONSE_GRACE_MS = 5e3;
52
+ var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
48
53
  var CloudServer = class {
49
54
  options;
50
55
  connectorWss = null;
@@ -75,17 +80,12 @@ var CloudServer = class {
75
80
  return this.options.token;
76
81
  }
77
82
  generateRandomToken() {
78
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
79
- let token = "";
80
- for (let i = 0; i < 32; i++) {
81
- token += chars.charAt(Math.floor(Math.random() * chars.length));
82
- }
83
- return token;
83
+ return randomBytes(24).toString("base64url");
84
84
  }
85
85
  async start() {
86
+ const token = this.getToken();
86
87
  await this.createListener(this.options.agentPort, "127.0.0.1", true);
87
88
  await this.createListener(this.options.port, "0.0.0.0", false);
88
- const token = this.getToken();
89
89
  const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
90
90
  let publicWsUrl;
91
91
  const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
@@ -104,6 +104,11 @@ var CloudServer = class {
104
104
  const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
105
105
  publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
106
106
  }
107
+ if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) {
108
+ console.warn(
109
+ "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network."
110
+ );
111
+ }
107
112
  const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
108
113
  console.log(
109
114
  `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)`
@@ -176,6 +181,9 @@ var CloudServer = class {
176
181
  ws.close();
177
182
  }
178
183
  this.connections.clear();
184
+ for (const pending of this.pendingRequests.values()) {
185
+ clearTimeout(pending.timer);
186
+ }
179
187
  this.pendingRequests.clear();
180
188
  this.agentWss?.close();
181
189
  this.connectorWss?.close();
@@ -216,7 +224,8 @@ var CloudServer = class {
216
224
  ws,
217
225
  id: connId,
218
226
  lastHeartbeat: Date.now(),
219
- isAgent: false,
227
+ connectedAt: Date.now(),
228
+ role: fromLocal ? "agent" : "pending",
220
229
  fromLocal,
221
230
  messageBuffer: ""
222
231
  };
@@ -240,7 +249,7 @@ var CloudServer = class {
240
249
  if (!line.trim()) continue;
241
250
  try {
242
251
  const message = JSON.parse(line);
243
- this.handleMessage(ws, message, connId);
252
+ this.handleMessage(ws, message);
244
253
  } catch (error) {
245
254
  console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
246
255
  }
@@ -255,7 +264,7 @@ var CloudServer = class {
255
264
  console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
256
265
  });
257
266
  }
258
- handleMessage(ws, message, connId) {
267
+ handleMessage(ws, message) {
259
268
  const conn = this.connections.get(ws);
260
269
  if (!conn) return;
261
270
  if (isHeartbeatMessage(message)) {
@@ -269,20 +278,61 @@ var CloudServer = class {
269
278
  }
270
279
  return;
271
280
  }
281
+ if (isAgentHelloMessage(message)) {
282
+ this.handleAgentHello(ws);
283
+ return;
284
+ }
285
+ if (isConnectorHelloMessage(message)) {
286
+ this.handleConnectorHello(ws, message);
287
+ return;
288
+ }
272
289
  if (isCloudResponse(message)) {
273
- this.handleResponse(ws, message, connId);
290
+ this.handleResponse(ws, message);
274
291
  return;
275
292
  }
276
293
  if (isCloudMessage(message)) {
277
- this.handleRequest(ws, message, connId);
294
+ this.handleRequest(ws, message);
278
295
  return;
279
296
  }
280
297
  }
281
- handleRequest(ws, message, _connId) {
282
- const { id, action, data } = message;
298
+ handleAgentHello(ws) {
283
299
  const conn = this.connections.get(ws);
284
300
  if (!conn) return;
285
301
  if (!conn.fromLocal && !this.options.allowRemoteAgent) {
302
+ ws.close(1008, "Agent must be local");
303
+ return;
304
+ }
305
+ conn.role = "agent";
306
+ delete conn.connector;
307
+ }
308
+ handleConnectorHello(ws, message) {
309
+ const conn = this.connections.get(ws);
310
+ if (!conn || conn.fromLocal) {
311
+ ws.close(1008, "Connector must use the connector listener");
312
+ return;
313
+ }
314
+ for (const [otherWs, other] of this.connections) {
315
+ if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
316
+ this.connections.delete(otherWs);
317
+ this.rejectPendingForWs(otherWs);
318
+ otherWs.close(1e3, "Connector replaced by a newer connection");
319
+ }
320
+ }
321
+ conn.role = "connector";
322
+ conn.connector = {
323
+ connectorId: message.connectorId,
324
+ name: message.name,
325
+ ...message.version ? { version: message.version } : {}
326
+ };
327
+ console.log(
328
+ `[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
329
+ );
330
+ }
331
+ handleRequest(ws, message) {
332
+ const { id, action, data } = message;
333
+ const conn = this.connections.get(ws);
334
+ if (!conn) return;
335
+ if (conn.role !== "agent") {
286
336
  console.warn(
287
337
  `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
288
338
  );
@@ -290,62 +340,138 @@ var CloudServer = class {
290
340
  JSON.stringify({
291
341
  id,
292
342
  success: false,
293
- error: "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
343
+ error: this.options.allowRemoteAgent && !conn.fromLocal ? "Remote agents must identify themselves with an agent_hello message before issuing commands." : "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
294
344
  }) + "\n"
295
345
  );
296
- ws.close(1008, "Agent must be local");
297
346
  return;
298
347
  }
299
- conn.isAgent = true;
300
- const targetWs = this.findConnector();
301
- if (!targetWs) {
302
- ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
348
+ if (action === "connector_list") {
349
+ const response = {
350
+ id,
351
+ success: true,
352
+ data: this.getConnectorList()
353
+ };
354
+ ws.send(JSON.stringify(response) + "\n");
303
355
  return;
304
356
  }
357
+ const target = this.resolveConnector(message.connector);
358
+ if ("error" in target) {
359
+ ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
360
+ return;
361
+ }
362
+ const routeId = randomBytes(12).toString("base64url");
305
363
  const requestedTimeoutSec = Number(message.timeoutSec);
306
364
  const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
307
365
  const timer = setTimeout(
308
366
  () => {
309
- this.pendingRequests.delete(id);
367
+ this.pendingRequests.delete(routeId);
310
368
  if (ws.readyState === WebSocket.OPEN) {
311
369
  ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
312
370
  }
313
371
  },
314
372
  timeoutSec * 1e3 + RESPONSE_GRACE_MS
315
373
  );
316
- this.pendingRequests.set(id, { ws, timer });
317
- targetWs.send(
318
- JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
374
+ this.pendingRequests.set(routeId, {
375
+ agentRequestId: id,
376
+ agentWs: ws,
377
+ connectorWs: target.ws,
378
+ timer
379
+ });
380
+ target.ws.send(
381
+ JSON.stringify({
382
+ id: routeId,
383
+ action,
384
+ data,
385
+ browser: message.browser,
386
+ timeoutSec
387
+ }) + "\n"
319
388
  );
320
389
  }
321
- handleResponse(ws, response, _connId) {
390
+ handleResponse(ws, response) {
322
391
  const conn = this.connections.get(ws);
323
392
  if (!conn) return;
324
- if (conn.isAgent) return;
393
+ if (conn.role !== "connector") return;
325
394
  const pending = this.pendingRequests.get(response.id);
326
- if (!pending) return;
395
+ if (!pending || pending.connectorWs !== ws) return;
327
396
  this.pendingRequests.delete(response.id);
328
397
  clearTimeout(pending.timer);
329
- if (pending.ws.readyState === WebSocket.OPEN) {
330
- pending.ws.send(JSON.stringify(response) + "\n");
398
+ if (pending.agentWs.readyState === WebSocket.OPEN) {
399
+ pending.agentWs.send(
400
+ JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
401
+ );
331
402
  }
332
403
  }
333
- findConnector() {
404
+ connectorInfo(ws, conn) {
405
+ const pendingRequests = Array.from(this.pendingRequests.values()).filter(
406
+ (pending) => pending.connectorWs === ws
407
+ ).length;
408
+ return {
409
+ connectorId: conn.connector?.connectorId ?? conn.id,
410
+ name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
411
+ ...conn.connector?.version ? { version: conn.connector.version } : {},
412
+ connectedAt: new Date(conn.connectedAt).toISOString(),
413
+ lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
414
+ pendingRequests,
415
+ ...!conn.connector ? { legacy: true } : {}
416
+ };
417
+ }
418
+ getConnectorConnections() {
419
+ const connectors = [];
334
420
  for (const [ws, conn] of this.connections) {
335
- if (!conn.isAgent && ws.readyState === WebSocket.OPEN) {
336
- return ws;
421
+ if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
422
+ conn.role = "connector";
337
423
  }
424
+ if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
425
+ connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
338
426
  }
339
- return null;
427
+ return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
428
+ }
429
+ getConnectorList() {
430
+ const connectors = this.getConnectorConnections().map(({ info }) => info);
431
+ return { count: connectors.length, connectors };
432
+ }
433
+ resolveConnector(selector) {
434
+ const connectors = this.getConnectorConnections();
435
+ if (connectors.length === 0) return { error: "No local connector available" };
436
+ if (!selector) {
437
+ if (connectors.length === 1) {
438
+ return { ws: connectors[0].ws, info: connectors[0].info };
439
+ }
440
+ return {
441
+ error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
442
+ };
443
+ }
444
+ const selection = resolveCloudConnectorSelector(
445
+ connectors.map(({ info }) => info),
446
+ selector
447
+ );
448
+ if (selection.status === "matched") {
449
+ const matched = connectors.find(({ info }) => info === selection.connector);
450
+ return { ws: matched.ws, info: matched.info };
451
+ }
452
+ const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
453
+ if (selection.status === "ambiguous") {
454
+ return {
455
+ error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
456
+ };
457
+ }
458
+ return {
459
+ error: `No cloud connector matches "${selector}". Available: ${available}`
460
+ };
340
461
  }
341
462
  rejectPendingForWs(ws) {
342
463
  this.pendingRequests.forEach((pending, id) => {
343
- if (pending.ws === ws) {
344
- clearTimeout(pending.timer);
345
- if (ws.readyState === WebSocket.OPEN) {
346
- ws.send(JSON.stringify({ id, success: false, error: "Connection closed" }) + "\n");
347
- }
348
- this.pendingRequests.delete(id);
464
+ if (pending.agentWs !== ws && pending.connectorWs !== ws) return;
465
+ clearTimeout(pending.timer);
466
+ this.pendingRequests.delete(id);
467
+ if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
468
+ pending.agentWs.send(
469
+ JSON.stringify({
470
+ id: pending.agentRequestId,
471
+ success: false,
472
+ error: "Connector connection closed"
473
+ }) + "\n"
474
+ );
349
475
  }
350
476
  });
351
477
  }
@@ -401,10 +527,17 @@ var CloudServer = class {
401
527
  writeConfigFile(info) {
402
528
  try {
403
529
  if (!existsSync(CONFIG_DIR)) {
404
- mkdirSync(CONFIG_DIR, { recursive: true });
530
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
405
531
  }
406
532
  const config = { server: info.localUrl, ...info };
407
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
533
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
534
+ encoding: "utf-8",
535
+ mode: 384
536
+ });
537
+ if (process.platform !== "win32") {
538
+ chmodSync(CONFIG_DIR, 448);
539
+ chmodSync(CONFIG_FILE, 384);
540
+ }
408
541
  console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
409
542
  } catch (error) {
410
543
  console.error("[CloudServer] Failed to write config file:", error);
@@ -412,15 +545,13 @@ var CloudServer = class {
412
545
  }
413
546
  getStats() {
414
547
  let agents = 0;
415
- let connectors = 0;
416
548
  for (const conn of this.connections.values()) {
417
- if (conn.isAgent) agents++;
418
- else connectors++;
549
+ if (conn.role === "agent") agents++;
419
550
  }
420
551
  return {
421
552
  connections: this.connections.size,
422
553
  agents,
423
- connectors,
554
+ connectors: this.getConnectorConnections().length,
424
555
  pendingRequests: this.pendingRequests.size
425
556
  };
426
557
  }
@@ -489,7 +620,7 @@ async function runForeground(options) {
489
620
  }
490
621
 
491
622
  // src/cli.ts
492
- var CLOUD_SERVER_VERSION = true ? "2.0.2" : "unknown";
623
+ var CLOUD_SERVER_VERSION = true ? "2.2.0" : "unknown";
493
624
  var argv = process.argv.slice(2);
494
625
  if (argv.includes("--version") || argv.includes("-v")) {
495
626
  console.log(CLOUD_SERVER_VERSION);
package/dist/index.d.ts CHANGED
@@ -48,9 +48,14 @@ export declare class CloudServer {
48
48
  stop(): Promise<void>;
49
49
  private handleConnection;
50
50
  private handleMessage;
51
+ private handleAgentHello;
52
+ private handleConnectorHello;
51
53
  private handleRequest;
52
54
  private handleResponse;
53
- private findConnector;
55
+ private connectorInfo;
56
+ private getConnectorConnections;
57
+ private getConnectorList;
58
+ private resolveConnector;
54
59
  private rejectPendingForWs;
55
60
  private startHeartbeatCheck;
56
61
  private getPublicIP;
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/index.ts
2
2
  import WebSocket, { WebSocketServer } from "ws";
3
3
  import { createServer as createHttpServer } from "node:http";
4
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { randomBytes } from "node:crypto";
5
6
  import { networkInterfaces } from "node:os";
6
7
 
7
8
  // src/paths.ts
@@ -13,9 +14,12 @@ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
13
14
 
14
15
  // src/index.ts
15
16
  import {
17
+ isAgentHelloMessage,
18
+ isConnectorHelloMessage,
16
19
  isHeartbeatMessage,
17
20
  isCloudMessage,
18
21
  isCloudResponse,
22
+ resolveCloudConnectorSelector,
19
23
  MAX_BUFFER_SIZE,
20
24
  DEFAULT_HEARTBEAT_TIMEOUT,
21
25
  DEFAULT_REQUEST_TIMEOUT
@@ -39,6 +43,7 @@ var DEFAULT_PATH = "/ws";
39
43
  var DEFAULT_MAX_CONNECTIONS = 100;
40
44
  var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
41
45
  var RESPONSE_GRACE_MS = 5e3;
46
+ var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
42
47
  var CloudServer = class {
43
48
  options;
44
49
  connectorWss = null;
@@ -69,17 +74,12 @@ var CloudServer = class {
69
74
  return this.options.token;
70
75
  }
71
76
  generateRandomToken() {
72
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
73
- let token = "";
74
- for (let i = 0; i < 32; i++) {
75
- token += chars.charAt(Math.floor(Math.random() * chars.length));
76
- }
77
- return token;
77
+ return randomBytes(24).toString("base64url");
78
78
  }
79
79
  async start() {
80
+ const token = this.getToken();
80
81
  await this.createListener(this.options.agentPort, "127.0.0.1", true);
81
82
  await this.createListener(this.options.port, "0.0.0.0", false);
82
- const token = this.getToken();
83
83
  const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
84
84
  let publicWsUrl;
85
85
  const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
@@ -98,6 +98,11 @@ var CloudServer = class {
98
98
  const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
99
99
  publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
100
100
  }
101
+ if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) {
102
+ console.warn(
103
+ "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network."
104
+ );
105
+ }
101
106
  const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
102
107
  console.log(
103
108
  `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)`
@@ -170,6 +175,9 @@ var CloudServer = class {
170
175
  ws.close();
171
176
  }
172
177
  this.connections.clear();
178
+ for (const pending of this.pendingRequests.values()) {
179
+ clearTimeout(pending.timer);
180
+ }
173
181
  this.pendingRequests.clear();
174
182
  this.agentWss?.close();
175
183
  this.connectorWss?.close();
@@ -210,7 +218,8 @@ var CloudServer = class {
210
218
  ws,
211
219
  id: connId,
212
220
  lastHeartbeat: Date.now(),
213
- isAgent: false,
221
+ connectedAt: Date.now(),
222
+ role: fromLocal ? "agent" : "pending",
214
223
  fromLocal,
215
224
  messageBuffer: ""
216
225
  };
@@ -234,7 +243,7 @@ var CloudServer = class {
234
243
  if (!line.trim()) continue;
235
244
  try {
236
245
  const message = JSON.parse(line);
237
- this.handleMessage(ws, message, connId);
246
+ this.handleMessage(ws, message);
238
247
  } catch (error) {
239
248
  console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
240
249
  }
@@ -249,7 +258,7 @@ var CloudServer = class {
249
258
  console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
250
259
  });
251
260
  }
252
- handleMessage(ws, message, connId) {
261
+ handleMessage(ws, message) {
253
262
  const conn = this.connections.get(ws);
254
263
  if (!conn) return;
255
264
  if (isHeartbeatMessage(message)) {
@@ -263,20 +272,61 @@ var CloudServer = class {
263
272
  }
264
273
  return;
265
274
  }
275
+ if (isAgentHelloMessage(message)) {
276
+ this.handleAgentHello(ws);
277
+ return;
278
+ }
279
+ if (isConnectorHelloMessage(message)) {
280
+ this.handleConnectorHello(ws, message);
281
+ return;
282
+ }
266
283
  if (isCloudResponse(message)) {
267
- this.handleResponse(ws, message, connId);
284
+ this.handleResponse(ws, message);
268
285
  return;
269
286
  }
270
287
  if (isCloudMessage(message)) {
271
- this.handleRequest(ws, message, connId);
288
+ this.handleRequest(ws, message);
272
289
  return;
273
290
  }
274
291
  }
275
- handleRequest(ws, message, _connId) {
276
- const { id, action, data } = message;
292
+ handleAgentHello(ws) {
277
293
  const conn = this.connections.get(ws);
278
294
  if (!conn) return;
279
295
  if (!conn.fromLocal && !this.options.allowRemoteAgent) {
296
+ ws.close(1008, "Agent must be local");
297
+ return;
298
+ }
299
+ conn.role = "agent";
300
+ delete conn.connector;
301
+ }
302
+ handleConnectorHello(ws, message) {
303
+ const conn = this.connections.get(ws);
304
+ if (!conn || conn.fromLocal) {
305
+ ws.close(1008, "Connector must use the connector listener");
306
+ return;
307
+ }
308
+ for (const [otherWs, other] of this.connections) {
309
+ if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
310
+ this.connections.delete(otherWs);
311
+ this.rejectPendingForWs(otherWs);
312
+ otherWs.close(1e3, "Connector replaced by a newer connection");
313
+ }
314
+ }
315
+ conn.role = "connector";
316
+ conn.connector = {
317
+ connectorId: message.connectorId,
318
+ name: message.name,
319
+ ...message.version ? { version: message.version } : {}
320
+ };
321
+ console.log(
322
+ `[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
323
+ );
324
+ }
325
+ handleRequest(ws, message) {
326
+ const { id, action, data } = message;
327
+ const conn = this.connections.get(ws);
328
+ if (!conn) return;
329
+ if (conn.role !== "agent") {
280
330
  console.warn(
281
331
  `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
282
332
  );
@@ -284,62 +334,138 @@ var CloudServer = class {
284
334
  JSON.stringify({
285
335
  id,
286
336
  success: false,
287
- error: "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
337
+ error: this.options.allowRemoteAgent && !conn.fromLocal ? "Remote agents must identify themselves with an agent_hello message before issuing commands." : "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
288
338
  }) + "\n"
289
339
  );
290
- ws.close(1008, "Agent must be local");
291
340
  return;
292
341
  }
293
- conn.isAgent = true;
294
- const targetWs = this.findConnector();
295
- if (!targetWs) {
296
- ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
342
+ if (action === "connector_list") {
343
+ const response = {
344
+ id,
345
+ success: true,
346
+ data: this.getConnectorList()
347
+ };
348
+ ws.send(JSON.stringify(response) + "\n");
297
349
  return;
298
350
  }
351
+ const target = this.resolveConnector(message.connector);
352
+ if ("error" in target) {
353
+ ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
354
+ return;
355
+ }
356
+ const routeId = randomBytes(12).toString("base64url");
299
357
  const requestedTimeoutSec = Number(message.timeoutSec);
300
358
  const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
301
359
  const timer = setTimeout(
302
360
  () => {
303
- this.pendingRequests.delete(id);
361
+ this.pendingRequests.delete(routeId);
304
362
  if (ws.readyState === WebSocket.OPEN) {
305
363
  ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
306
364
  }
307
365
  },
308
366
  timeoutSec * 1e3 + RESPONSE_GRACE_MS
309
367
  );
310
- this.pendingRequests.set(id, { ws, timer });
311
- targetWs.send(
312
- JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
368
+ this.pendingRequests.set(routeId, {
369
+ agentRequestId: id,
370
+ agentWs: ws,
371
+ connectorWs: target.ws,
372
+ timer
373
+ });
374
+ target.ws.send(
375
+ JSON.stringify({
376
+ id: routeId,
377
+ action,
378
+ data,
379
+ browser: message.browser,
380
+ timeoutSec
381
+ }) + "\n"
313
382
  );
314
383
  }
315
- handleResponse(ws, response, _connId) {
384
+ handleResponse(ws, response) {
316
385
  const conn = this.connections.get(ws);
317
386
  if (!conn) return;
318
- if (conn.isAgent) return;
387
+ if (conn.role !== "connector") return;
319
388
  const pending = this.pendingRequests.get(response.id);
320
- if (!pending) return;
389
+ if (!pending || pending.connectorWs !== ws) return;
321
390
  this.pendingRequests.delete(response.id);
322
391
  clearTimeout(pending.timer);
323
- if (pending.ws.readyState === WebSocket.OPEN) {
324
- pending.ws.send(JSON.stringify(response) + "\n");
392
+ if (pending.agentWs.readyState === WebSocket.OPEN) {
393
+ pending.agentWs.send(
394
+ JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
395
+ );
325
396
  }
326
397
  }
327
- findConnector() {
398
+ connectorInfo(ws, conn) {
399
+ const pendingRequests = Array.from(this.pendingRequests.values()).filter(
400
+ (pending) => pending.connectorWs === ws
401
+ ).length;
402
+ return {
403
+ connectorId: conn.connector?.connectorId ?? conn.id,
404
+ name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
405
+ ...conn.connector?.version ? { version: conn.connector.version } : {},
406
+ connectedAt: new Date(conn.connectedAt).toISOString(),
407
+ lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
408
+ pendingRequests,
409
+ ...!conn.connector ? { legacy: true } : {}
410
+ };
411
+ }
412
+ getConnectorConnections() {
413
+ const connectors = [];
328
414
  for (const [ws, conn] of this.connections) {
329
- if (!conn.isAgent && ws.readyState === WebSocket.OPEN) {
330
- return ws;
415
+ if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
416
+ conn.role = "connector";
331
417
  }
418
+ if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
419
+ connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
332
420
  }
333
- return null;
421
+ return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
422
+ }
423
+ getConnectorList() {
424
+ const connectors = this.getConnectorConnections().map(({ info }) => info);
425
+ return { count: connectors.length, connectors };
426
+ }
427
+ resolveConnector(selector) {
428
+ const connectors = this.getConnectorConnections();
429
+ if (connectors.length === 0) return { error: "No local connector available" };
430
+ if (!selector) {
431
+ if (connectors.length === 1) {
432
+ return { ws: connectors[0].ws, info: connectors[0].info };
433
+ }
434
+ return {
435
+ error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
436
+ };
437
+ }
438
+ const selection = resolveCloudConnectorSelector(
439
+ connectors.map(({ info }) => info),
440
+ selector
441
+ );
442
+ if (selection.status === "matched") {
443
+ const matched = connectors.find(({ info }) => info === selection.connector);
444
+ return { ws: matched.ws, info: matched.info };
445
+ }
446
+ const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
447
+ if (selection.status === "ambiguous") {
448
+ return {
449
+ error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
450
+ };
451
+ }
452
+ return {
453
+ error: `No cloud connector matches "${selector}". Available: ${available}`
454
+ };
334
455
  }
335
456
  rejectPendingForWs(ws) {
336
457
  this.pendingRequests.forEach((pending, id) => {
337
- if (pending.ws === ws) {
338
- clearTimeout(pending.timer);
339
- if (ws.readyState === WebSocket.OPEN) {
340
- ws.send(JSON.stringify({ id, success: false, error: "Connection closed" }) + "\n");
341
- }
342
- this.pendingRequests.delete(id);
458
+ if (pending.agentWs !== ws && pending.connectorWs !== ws) return;
459
+ clearTimeout(pending.timer);
460
+ this.pendingRequests.delete(id);
461
+ if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
462
+ pending.agentWs.send(
463
+ JSON.stringify({
464
+ id: pending.agentRequestId,
465
+ success: false,
466
+ error: "Connector connection closed"
467
+ }) + "\n"
468
+ );
343
469
  }
344
470
  });
345
471
  }
@@ -395,10 +521,17 @@ var CloudServer = class {
395
521
  writeConfigFile(info) {
396
522
  try {
397
523
  if (!existsSync(CONFIG_DIR)) {
398
- mkdirSync(CONFIG_DIR, { recursive: true });
524
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
399
525
  }
400
526
  const config = { server: info.localUrl, ...info };
401
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
527
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
528
+ encoding: "utf-8",
529
+ mode: 384
530
+ });
531
+ if (process.platform !== "win32") {
532
+ chmodSync(CONFIG_DIR, 448);
533
+ chmodSync(CONFIG_FILE, 384);
534
+ }
402
535
  console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
403
536
  } catch (error) {
404
537
  console.error("[CloudServer] Failed to write config file:", error);
@@ -406,15 +539,13 @@ var CloudServer = class {
406
539
  }
407
540
  getStats() {
408
541
  let agents = 0;
409
- let connectors = 0;
410
542
  for (const conn of this.connections.values()) {
411
- if (conn.isAgent) agents++;
412
- else connectors++;
543
+ if (conn.role === "agent") agents++;
413
544
  }
414
545
  return {
415
546
  connections: this.connections.size,
416
547
  agents,
417
- connectors,
548
+ connectors: this.getConnectorConnections().length,
418
549
  pendingRequests: this.pendingRequests.size
419
550
  };
420
551
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/cloud-server",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "description": "Cloud WebSocket server for Mearl — bridges cloud agents to local connectors",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,9 +29,9 @@
29
29
  "dependencies": {
30
30
  "node-fetch": "^3.3.2",
31
31
  "ws": "^8.18.0",
32
- "@mearl/client": "2.0.2",
33
- "@mearl/cloud-types": "2.0.2",
34
- "@mearl/daemon-core": "2.0.2"
32
+ "@mearl/cloud-types": "2.2.0",
33
+ "@mearl/client": "2.2.0",
34
+ "@mearl/daemon-core": "2.2.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^24.9.1",