@mearl/cloud-server 2.0.2 → 2.1.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.
Files changed (3) hide show
  1. package/dist/cli.js +42 -24
  2. package/dist/index.js +41 -23
  3. package/package.json +4 -4
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
@@ -75,17 +76,12 @@ var CloudServer = class {
75
76
  return this.options.token;
76
77
  }
77
78
  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;
79
+ return randomBytes(24).toString("base64url");
84
80
  }
85
81
  async start() {
82
+ const token = this.getToken();
86
83
  await this.createListener(this.options.agentPort, "127.0.0.1", true);
87
84
  await this.createListener(this.options.port, "0.0.0.0", false);
88
- const token = this.getToken();
89
85
  const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
90
86
  let publicWsUrl;
91
87
  const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
@@ -104,6 +100,11 @@ var CloudServer = class {
104
100
  const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
105
101
  publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
106
102
  }
103
+ if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) {
104
+ console.warn(
105
+ "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network."
106
+ );
107
+ }
107
108
  const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
108
109
  console.log(
109
110
  `[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 +177,9 @@ var CloudServer = class {
176
177
  ws.close();
177
178
  }
178
179
  this.connections.clear();
180
+ for (const pending of this.pendingRequests.values()) {
181
+ clearTimeout(pending.timer);
182
+ }
179
183
  this.pendingRequests.clear();
180
184
  this.agentWss?.close();
181
185
  this.connectorWss?.close();
@@ -216,7 +220,9 @@ var CloudServer = class {
216
220
  ws,
217
221
  id: connId,
218
222
  lastHeartbeat: Date.now(),
219
- isAgent: false,
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,
220
226
  fromLocal,
221
227
  messageBuffer: ""
222
228
  };
@@ -302,6 +308,10 @@ var CloudServer = class {
302
308
  ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
303
309
  return;
304
310
  }
311
+ if (this.pendingRequests.has(id)) {
312
+ ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n");
313
+ return;
314
+ }
305
315
  const requestedTimeoutSec = Number(message.timeoutSec);
306
316
  const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
307
317
  const timer = setTimeout(
@@ -313,7 +323,7 @@ var CloudServer = class {
313
323
  },
314
324
  timeoutSec * 1e3 + RESPONSE_GRACE_MS
315
325
  );
316
- this.pendingRequests.set(id, { ws, timer });
326
+ this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer });
317
327
  targetWs.send(
318
328
  JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
319
329
  );
@@ -321,18 +331,18 @@ var CloudServer = class {
321
331
  handleResponse(ws, response, _connId) {
322
332
  const conn = this.connections.get(ws);
323
333
  if (!conn) return;
324
- if (conn.isAgent) return;
334
+ if (conn.fromLocal || conn.isAgent) return;
325
335
  const pending = this.pendingRequests.get(response.id);
326
- if (!pending) return;
336
+ if (!pending || pending.connectorWs !== ws) return;
327
337
  this.pendingRequests.delete(response.id);
328
338
  clearTimeout(pending.timer);
329
- if (pending.ws.readyState === WebSocket.OPEN) {
330
- pending.ws.send(JSON.stringify(response) + "\n");
339
+ if (pending.agentWs.readyState === WebSocket.OPEN) {
340
+ pending.agentWs.send(JSON.stringify(response) + "\n");
331
341
  }
332
342
  }
333
343
  findConnector() {
334
344
  for (const [ws, conn] of this.connections) {
335
- if (!conn.isAgent && ws.readyState === WebSocket.OPEN) {
345
+ if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) {
336
346
  return ws;
337
347
  }
338
348
  }
@@ -340,12 +350,13 @@ var CloudServer = class {
340
350
  }
341
351
  rejectPendingForWs(ws) {
342
352
  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);
353
+ if (pending.agentWs !== ws && pending.connectorWs !== ws) return;
354
+ clearTimeout(pending.timer);
355
+ this.pendingRequests.delete(id);
356
+ if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
357
+ pending.agentWs.send(
358
+ JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n"
359
+ );
349
360
  }
350
361
  });
351
362
  }
@@ -401,10 +412,17 @@ var CloudServer = class {
401
412
  writeConfigFile(info) {
402
413
  try {
403
414
  if (!existsSync(CONFIG_DIR)) {
404
- mkdirSync(CONFIG_DIR, { recursive: true });
415
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
405
416
  }
406
417
  const config = { server: info.localUrl, ...info };
407
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
418
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
419
+ encoding: "utf-8",
420
+ mode: 384
421
+ });
422
+ if (process.platform !== "win32") {
423
+ chmodSync(CONFIG_DIR, 448);
424
+ chmodSync(CONFIG_FILE, 384);
425
+ }
408
426
  console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
409
427
  } catch (error) {
410
428
  console.error("[CloudServer] Failed to write config file:", error);
@@ -489,7 +507,7 @@ async function runForeground(options) {
489
507
  }
490
508
 
491
509
  // src/cli.ts
492
- var CLOUD_SERVER_VERSION = true ? "2.0.2" : "unknown";
510
+ var CLOUD_SERVER_VERSION = true ? "2.1.0" : "unknown";
493
511
  var argv = process.argv.slice(2);
494
512
  if (argv.includes("--version") || argv.includes("-v")) {
495
513
  console.log(CLOUD_SERVER_VERSION);
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
@@ -69,17 +70,12 @@ var CloudServer = class {
69
70
  return this.options.token;
70
71
  }
71
72
  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;
73
+ return randomBytes(24).toString("base64url");
78
74
  }
79
75
  async start() {
76
+ const token = this.getToken();
80
77
  await this.createListener(this.options.agentPort, "127.0.0.1", true);
81
78
  await this.createListener(this.options.port, "0.0.0.0", false);
82
- const token = this.getToken();
83
79
  const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
84
80
  let publicWsUrl;
85
81
  const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
@@ -98,6 +94,11 @@ var CloudServer = class {
98
94
  const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
99
95
  publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
100
96
  }
97
+ if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) {
98
+ console.warn(
99
+ "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network."
100
+ );
101
+ }
101
102
  const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
102
103
  console.log(
103
104
  `[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 +171,9 @@ var CloudServer = class {
170
171
  ws.close();
171
172
  }
172
173
  this.connections.clear();
174
+ for (const pending of this.pendingRequests.values()) {
175
+ clearTimeout(pending.timer);
176
+ }
173
177
  this.pendingRequests.clear();
174
178
  this.agentWss?.close();
175
179
  this.connectorWss?.close();
@@ -210,7 +214,9 @@ var CloudServer = class {
210
214
  ws,
211
215
  id: connId,
212
216
  lastHeartbeat: Date.now(),
213
- isAgent: false,
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,
214
220
  fromLocal,
215
221
  messageBuffer: ""
216
222
  };
@@ -296,6 +302,10 @@ var CloudServer = class {
296
302
  ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
297
303
  return;
298
304
  }
305
+ if (this.pendingRequests.has(id)) {
306
+ ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n");
307
+ return;
308
+ }
299
309
  const requestedTimeoutSec = Number(message.timeoutSec);
300
310
  const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
301
311
  const timer = setTimeout(
@@ -307,7 +317,7 @@ var CloudServer = class {
307
317
  },
308
318
  timeoutSec * 1e3 + RESPONSE_GRACE_MS
309
319
  );
310
- this.pendingRequests.set(id, { ws, timer });
320
+ this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer });
311
321
  targetWs.send(
312
322
  JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
313
323
  );
@@ -315,18 +325,18 @@ var CloudServer = class {
315
325
  handleResponse(ws, response, _connId) {
316
326
  const conn = this.connections.get(ws);
317
327
  if (!conn) return;
318
- if (conn.isAgent) return;
328
+ if (conn.fromLocal || conn.isAgent) return;
319
329
  const pending = this.pendingRequests.get(response.id);
320
- if (!pending) return;
330
+ if (!pending || pending.connectorWs !== ws) return;
321
331
  this.pendingRequests.delete(response.id);
322
332
  clearTimeout(pending.timer);
323
- if (pending.ws.readyState === WebSocket.OPEN) {
324
- pending.ws.send(JSON.stringify(response) + "\n");
333
+ if (pending.agentWs.readyState === WebSocket.OPEN) {
334
+ pending.agentWs.send(JSON.stringify(response) + "\n");
325
335
  }
326
336
  }
327
337
  findConnector() {
328
338
  for (const [ws, conn] of this.connections) {
329
- if (!conn.isAgent && ws.readyState === WebSocket.OPEN) {
339
+ if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) {
330
340
  return ws;
331
341
  }
332
342
  }
@@ -334,12 +344,13 @@ var CloudServer = class {
334
344
  }
335
345
  rejectPendingForWs(ws) {
336
346
  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);
347
+ if (pending.agentWs !== ws && pending.connectorWs !== ws) return;
348
+ clearTimeout(pending.timer);
349
+ this.pendingRequests.delete(id);
350
+ if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
351
+ pending.agentWs.send(
352
+ JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n"
353
+ );
343
354
  }
344
355
  });
345
356
  }
@@ -395,10 +406,17 @@ var CloudServer = class {
395
406
  writeConfigFile(info) {
396
407
  try {
397
408
  if (!existsSync(CONFIG_DIR)) {
398
- mkdirSync(CONFIG_DIR, { recursive: true });
409
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
399
410
  }
400
411
  const config = { server: info.localUrl, ...info };
401
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
412
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
413
+ encoding: "utf-8",
414
+ mode: 384
415
+ });
416
+ if (process.platform !== "win32") {
417
+ chmodSync(CONFIG_DIR, 448);
418
+ chmodSync(CONFIG_FILE, 384);
419
+ }
402
420
  console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
403
421
  } catch (error) {
404
422
  console.error("[CloudServer] Failed to write config file:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/cloud-server",
3
- "version": "2.0.2",
3
+ "version": "2.1.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/client": "2.1.0",
33
+ "@mearl/cloud-types": "2.1.0",
34
+ "@mearl/daemon-core": "2.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^24.9.1",