@mearl/cloud-server 2.1.0 → 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
@@ -20,9 +20,12 @@ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
20
20
 
21
21
  // src/index.ts
22
22
  import {
23
+ isAgentHelloMessage,
24
+ isConnectorHelloMessage,
23
25
  isHeartbeatMessage,
24
26
  isCloudMessage,
25
27
  isCloudResponse,
28
+ resolveCloudConnectorSelector,
26
29
  MAX_BUFFER_SIZE,
27
30
  DEFAULT_HEARTBEAT_TIMEOUT,
28
31
  DEFAULT_REQUEST_TIMEOUT
@@ -46,6 +49,7 @@ var DEFAULT_PATH = "/ws";
46
49
  var DEFAULT_MAX_CONNECTIONS = 100;
47
50
  var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
48
51
  var RESPONSE_GRACE_MS = 5e3;
52
+ var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
49
53
  var CloudServer = class {
50
54
  options;
51
55
  connectorWss = null;
@@ -220,9 +224,8 @@ var CloudServer = class {
220
224
  ws,
221
225
  id: connId,
222
226
  lastHeartbeat: Date.now(),
223
- // The loopback listener is agent-only. Marking it immediately prevents an
224
- // idle local client from being mistaken for a connector during reconnects.
225
- isAgent: fromLocal,
227
+ connectedAt: Date.now(),
228
+ role: fromLocal ? "agent" : "pending",
226
229
  fromLocal,
227
230
  messageBuffer: ""
228
231
  };
@@ -246,7 +249,7 @@ var CloudServer = class {
246
249
  if (!line.trim()) continue;
247
250
  try {
248
251
  const message = JSON.parse(line);
249
- this.handleMessage(ws, message, connId);
252
+ this.handleMessage(ws, message);
250
253
  } catch (error) {
251
254
  console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
252
255
  }
@@ -261,7 +264,7 @@ var CloudServer = class {
261
264
  console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
262
265
  });
263
266
  }
264
- handleMessage(ws, message, connId) {
267
+ handleMessage(ws, message) {
265
268
  const conn = this.connections.get(ws);
266
269
  if (!conn) return;
267
270
  if (isHeartbeatMessage(message)) {
@@ -275,20 +278,61 @@ var CloudServer = class {
275
278
  }
276
279
  return;
277
280
  }
281
+ if (isAgentHelloMessage(message)) {
282
+ this.handleAgentHello(ws);
283
+ return;
284
+ }
285
+ if (isConnectorHelloMessage(message)) {
286
+ this.handleConnectorHello(ws, message);
287
+ return;
288
+ }
278
289
  if (isCloudResponse(message)) {
279
- this.handleResponse(ws, message, connId);
290
+ this.handleResponse(ws, message);
280
291
  return;
281
292
  }
282
293
  if (isCloudMessage(message)) {
283
- this.handleRequest(ws, message, connId);
294
+ this.handleRequest(ws, message);
284
295
  return;
285
296
  }
286
297
  }
287
- handleRequest(ws, message, _connId) {
288
- const { id, action, data } = message;
298
+ handleAgentHello(ws) {
289
299
  const conn = this.connections.get(ws);
290
300
  if (!conn) return;
291
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") {
292
336
  console.warn(
293
337
  `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
294
338
  );
@@ -296,57 +340,124 @@ var CloudServer = class {
296
340
  JSON.stringify({
297
341
  id,
298
342
  success: false,
299
- 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."
300
344
  }) + "\n"
301
345
  );
302
- ws.close(1008, "Agent must be local");
303
346
  return;
304
347
  }
305
- conn.isAgent = true;
306
- const targetWs = this.findConnector();
307
- if (!targetWs) {
308
- 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");
309
355
  return;
310
356
  }
311
- if (this.pendingRequests.has(id)) {
312
- ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n");
357
+ const target = this.resolveConnector(message.connector);
358
+ if ("error" in target) {
359
+ ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
313
360
  return;
314
361
  }
362
+ const routeId = randomBytes(12).toString("base64url");
315
363
  const requestedTimeoutSec = Number(message.timeoutSec);
316
364
  const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
317
365
  const timer = setTimeout(
318
366
  () => {
319
- this.pendingRequests.delete(id);
367
+ this.pendingRequests.delete(routeId);
320
368
  if (ws.readyState === WebSocket.OPEN) {
321
369
  ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
322
370
  }
323
371
  },
324
372
  timeoutSec * 1e3 + RESPONSE_GRACE_MS
325
373
  );
326
- this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer });
327
- targetWs.send(
328
- 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"
329
388
  );
330
389
  }
331
- handleResponse(ws, response, _connId) {
390
+ handleResponse(ws, response) {
332
391
  const conn = this.connections.get(ws);
333
392
  if (!conn) return;
334
- if (conn.fromLocal || conn.isAgent) return;
393
+ if (conn.role !== "connector") return;
335
394
  const pending = this.pendingRequests.get(response.id);
336
395
  if (!pending || pending.connectorWs !== ws) return;
337
396
  this.pendingRequests.delete(response.id);
338
397
  clearTimeout(pending.timer);
339
398
  if (pending.agentWs.readyState === WebSocket.OPEN) {
340
- pending.agentWs.send(JSON.stringify(response) + "\n");
399
+ pending.agentWs.send(
400
+ JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
401
+ );
341
402
  }
342
403
  }
343
- 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 = [];
344
420
  for (const [ws, conn] of this.connections) {
345
- if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) {
346
- return ws;
421
+ if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
422
+ conn.role = "connector";
347
423
  }
424
+ if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
425
+ connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
348
426
  }
349
- 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
+ };
350
461
  }
351
462
  rejectPendingForWs(ws) {
352
463
  this.pendingRequests.forEach((pending, id) => {
@@ -355,7 +466,11 @@ var CloudServer = class {
355
466
  this.pendingRequests.delete(id);
356
467
  if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
357
468
  pending.agentWs.send(
358
- JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n"
469
+ JSON.stringify({
470
+ id: pending.agentRequestId,
471
+ success: false,
472
+ error: "Connector connection closed"
473
+ }) + "\n"
359
474
  );
360
475
  }
361
476
  });
@@ -430,15 +545,13 @@ var CloudServer = class {
430
545
  }
431
546
  getStats() {
432
547
  let agents = 0;
433
- let connectors = 0;
434
548
  for (const conn of this.connections.values()) {
435
- if (conn.isAgent) agents++;
436
- else connectors++;
549
+ if (conn.role === "agent") agents++;
437
550
  }
438
551
  return {
439
552
  connections: this.connections.size,
440
553
  agents,
441
- connectors,
554
+ connectors: this.getConnectorConnections().length,
442
555
  pendingRequests: this.pendingRequests.size
443
556
  };
444
557
  }
@@ -507,7 +620,7 @@ async function runForeground(options) {
507
620
  }
508
621
 
509
622
  // src/cli.ts
510
- var CLOUD_SERVER_VERSION = true ? "2.1.0" : "unknown";
623
+ var CLOUD_SERVER_VERSION = true ? "2.2.0" : "unknown";
511
624
  var argv = process.argv.slice(2);
512
625
  if (argv.includes("--version") || argv.includes("-v")) {
513
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
@@ -14,9 +14,12 @@ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
14
14
 
15
15
  // src/index.ts
16
16
  import {
17
+ isAgentHelloMessage,
18
+ isConnectorHelloMessage,
17
19
  isHeartbeatMessage,
18
20
  isCloudMessage,
19
21
  isCloudResponse,
22
+ resolveCloudConnectorSelector,
20
23
  MAX_BUFFER_SIZE,
21
24
  DEFAULT_HEARTBEAT_TIMEOUT,
22
25
  DEFAULT_REQUEST_TIMEOUT
@@ -40,6 +43,7 @@ var DEFAULT_PATH = "/ws";
40
43
  var DEFAULT_MAX_CONNECTIONS = 100;
41
44
  var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
42
45
  var RESPONSE_GRACE_MS = 5e3;
46
+ var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
43
47
  var CloudServer = class {
44
48
  options;
45
49
  connectorWss = null;
@@ -214,9 +218,8 @@ var CloudServer = class {
214
218
  ws,
215
219
  id: connId,
216
220
  lastHeartbeat: Date.now(),
217
- // The loopback listener is agent-only. Marking it immediately prevents an
218
- // idle local client from being mistaken for a connector during reconnects.
219
- isAgent: fromLocal,
221
+ connectedAt: Date.now(),
222
+ role: fromLocal ? "agent" : "pending",
220
223
  fromLocal,
221
224
  messageBuffer: ""
222
225
  };
@@ -240,7 +243,7 @@ var CloudServer = class {
240
243
  if (!line.trim()) continue;
241
244
  try {
242
245
  const message = JSON.parse(line);
243
- this.handleMessage(ws, message, connId);
246
+ this.handleMessage(ws, message);
244
247
  } catch (error) {
245
248
  console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
246
249
  }
@@ -255,7 +258,7 @@ var CloudServer = class {
255
258
  console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
256
259
  });
257
260
  }
258
- handleMessage(ws, message, connId) {
261
+ handleMessage(ws, message) {
259
262
  const conn = this.connections.get(ws);
260
263
  if (!conn) return;
261
264
  if (isHeartbeatMessage(message)) {
@@ -269,20 +272,61 @@ var CloudServer = class {
269
272
  }
270
273
  return;
271
274
  }
275
+ if (isAgentHelloMessage(message)) {
276
+ this.handleAgentHello(ws);
277
+ return;
278
+ }
279
+ if (isConnectorHelloMessage(message)) {
280
+ this.handleConnectorHello(ws, message);
281
+ return;
282
+ }
272
283
  if (isCloudResponse(message)) {
273
- this.handleResponse(ws, message, connId);
284
+ this.handleResponse(ws, message);
274
285
  return;
275
286
  }
276
287
  if (isCloudMessage(message)) {
277
- this.handleRequest(ws, message, connId);
288
+ this.handleRequest(ws, message);
278
289
  return;
279
290
  }
280
291
  }
281
- handleRequest(ws, message, _connId) {
282
- const { id, action, data } = message;
292
+ handleAgentHello(ws) {
283
293
  const conn = this.connections.get(ws);
284
294
  if (!conn) return;
285
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") {
286
330
  console.warn(
287
331
  `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
288
332
  );
@@ -290,57 +334,124 @@ var CloudServer = class {
290
334
  JSON.stringify({
291
335
  id,
292
336
  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."
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."
294
338
  }) + "\n"
295
339
  );
296
- ws.close(1008, "Agent must be local");
297
340
  return;
298
341
  }
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");
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");
303
349
  return;
304
350
  }
305
- if (this.pendingRequests.has(id)) {
306
- ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n");
351
+ const target = this.resolveConnector(message.connector);
352
+ if ("error" in target) {
353
+ ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
307
354
  return;
308
355
  }
356
+ const routeId = randomBytes(12).toString("base64url");
309
357
  const requestedTimeoutSec = Number(message.timeoutSec);
310
358
  const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
311
359
  const timer = setTimeout(
312
360
  () => {
313
- this.pendingRequests.delete(id);
361
+ this.pendingRequests.delete(routeId);
314
362
  if (ws.readyState === WebSocket.OPEN) {
315
363
  ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
316
364
  }
317
365
  },
318
366
  timeoutSec * 1e3 + RESPONSE_GRACE_MS
319
367
  );
320
- this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer });
321
- targetWs.send(
322
- 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"
323
382
  );
324
383
  }
325
- handleResponse(ws, response, _connId) {
384
+ handleResponse(ws, response) {
326
385
  const conn = this.connections.get(ws);
327
386
  if (!conn) return;
328
- if (conn.fromLocal || conn.isAgent) return;
387
+ if (conn.role !== "connector") return;
329
388
  const pending = this.pendingRequests.get(response.id);
330
389
  if (!pending || pending.connectorWs !== ws) return;
331
390
  this.pendingRequests.delete(response.id);
332
391
  clearTimeout(pending.timer);
333
392
  if (pending.agentWs.readyState === WebSocket.OPEN) {
334
- pending.agentWs.send(JSON.stringify(response) + "\n");
393
+ pending.agentWs.send(
394
+ JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
395
+ );
335
396
  }
336
397
  }
337
- 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 = [];
338
414
  for (const [ws, conn] of this.connections) {
339
- if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) {
340
- return ws;
415
+ if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
416
+ conn.role = "connector";
341
417
  }
418
+ if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
419
+ connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
342
420
  }
343
- 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
+ };
344
455
  }
345
456
  rejectPendingForWs(ws) {
346
457
  this.pendingRequests.forEach((pending, id) => {
@@ -349,7 +460,11 @@ var CloudServer = class {
349
460
  this.pendingRequests.delete(id);
350
461
  if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
351
462
  pending.agentWs.send(
352
- JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n"
463
+ JSON.stringify({
464
+ id: pending.agentRequestId,
465
+ success: false,
466
+ error: "Connector connection closed"
467
+ }) + "\n"
353
468
  );
354
469
  }
355
470
  });
@@ -424,15 +539,13 @@ var CloudServer = class {
424
539
  }
425
540
  getStats() {
426
541
  let agents = 0;
427
- let connectors = 0;
428
542
  for (const conn of this.connections.values()) {
429
- if (conn.isAgent) agents++;
430
- else connectors++;
543
+ if (conn.role === "agent") agents++;
431
544
  }
432
545
  return {
433
546
  connections: this.connections.size,
434
547
  agents,
435
- connectors,
548
+ connectors: this.getConnectorConnections().length,
436
549
  pendingRequests: this.pendingRequests.size
437
550
  };
438
551
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/cloud-server",
3
- "version": "2.1.0",
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.1.0",
33
- "@mearl/cloud-types": "2.1.0",
34
- "@mearl/daemon-core": "2.1.0"
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",