@mearl/cloud-server 2.7.1 → 2.7.3

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
@@ -10,6 +10,12 @@
10
10
  npx @mearl/setup --cloud
11
11
  ```
12
12
 
13
+ Qoder Cloud Agent 使用同一套包和 server 命令,只在 setup 时选择配对模式:
14
+
15
+ ```bash
16
+ npx @mearl/setup --qoder-cloud --yes
17
+ ```
18
+
13
19
  在 Docker 或自定义进程托管等只需要 Server 包的场景,可单独安装 `@mearl/cloud-server`。
14
20
 
15
21
  ## 使用
@@ -17,6 +23,8 @@ npx @mearl/setup --cloud
17
23
  ### 命令行方式
18
24
 
19
25
  Server **默认以后台守护进程方式运行**,启动后立即返回,并打印本地机器需要执行的连接命令。
26
+ Qoder 模式保持相同的 `start/status/restart/stop/logs` 交互;`start` 和 `restart` 生成新的
27
+ pair/connector 信息后退出,不监听端口或创建守护进程。
20
28
 
21
29
  ```bash
22
30
  mearl-cloud-server [command] [options]
@@ -66,12 +74,13 @@ mearl-cloud-server --foreground
66
74
  [CloudServer] Logs: ~/.mearl/cloud-server.log
67
75
 
68
76
  Connect from your local machine:
69
- npx @mearl/cloud-connector "ws://10.0.1.100:8080/ws?token=xxx"
77
+ npx @mearl/cloud-connector start "ws://10.0.1.100:8080/ws?token=xxx"
70
78
  ```
71
79
 
72
80
  运行时文件位于 `~/.mearl/`:
73
81
 
74
82
  - `cloud-server.json` - 连接配置(含 server URL、pid、端口等),统一 `@mearl/client` 自动读取
83
+ - `cloud-server-mode.json` - setup 在 Qoder 模式下写入的标识;普通 `--cloud` 会删除它
75
84
  - `cloud-server.log` - 后台进程日志(`logs` 子命令读取,或 `tail -f` 跟踪);超过 5MB 会在下次启动时滚动为 `cloud-server.log.1`
76
85
 
77
86
  > 守护进程的启停、状态、日志滚动等生命周期逻辑由共享包 [`@mearl/daemon-core`](../../daemon-core) 提供(与 cloud-connector 复用同一套实现)。
@@ -111,6 +120,10 @@ console.log(stats);
111
120
  await server.stop();
112
121
  ```
113
122
 
123
+ Qoder 模式只复用 cloud-server CLI 作为建联入口。配对完成后的 action 继续通过 Qoder
124
+ Client-Side `mearl` 工具、Session API 和本地 connector 转发,不会连接一个本地
125
+ cloud-server 端口。
126
+
114
127
  ## 架构
115
128
 
116
129
  ```
@@ -0,0 +1,546 @@
1
+ // src/index.ts
2
+ import WebSocket, { WebSocketServer } from "ws";
3
+ import { createServer as createHttpServer } from "node:http";
4
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { randomBytes } from "node:crypto";
6
+ import { networkInterfaces } from "node:os";
7
+
8
+ // src/paths.ts
9
+ import { join } from "node:path";
10
+ import { DAEMON_HOME } from "@mearl/daemon-core";
11
+ var CONFIG_DIR = DAEMON_HOME;
12
+ var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json");
13
+ var MODE_FILE = join(CONFIG_DIR, "cloud-server-mode.json");
14
+ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
15
+
16
+ // src/index.ts
17
+ import {
18
+ isAgentHelloMessage,
19
+ isConnectorHelloMessage,
20
+ isHeartbeatMessage,
21
+ isCloudMessage,
22
+ isCloudResponse,
23
+ resolveCloudConnectorSelector,
24
+ MAX_BUFFER_SIZE,
25
+ DEFAULT_HEARTBEAT_TIMEOUT,
26
+ DEFAULT_REQUEST_TIMEOUT
27
+ } from "@mearl/cloud-types";
28
+ import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts";
29
+ var DEFAULT_PORT = 8080;
30
+ var DEFAULT_PATH = "/ws";
31
+ var DEFAULT_MAX_CONNECTIONS = 100;
32
+ var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
33
+ var RESPONSE_GRACE_MS = 5e3;
34
+ var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
35
+ var CloudServer = class {
36
+ options;
37
+ connectorWss = null;
38
+ agentWss = null;
39
+ connectorHttpServer = null;
40
+ agentHttpServer = null;
41
+ connections = /* @__PURE__ */ new Map();
42
+ pendingRequests = /* @__PURE__ */ new Map();
43
+ heartbeatTimer = null;
44
+ constructor(options = {}) {
45
+ const port = options.port ?? DEFAULT_PORT;
46
+ this.options = {
47
+ port,
48
+ agentPort: options.agentPort ?? port + 1,
49
+ path: options.path ?? DEFAULT_PATH,
50
+ token: options.token,
51
+ heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT,
52
+ maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
53
+ maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE,
54
+ requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT,
55
+ allowRemoteAgent: options.allowRemoteAgent ?? false
56
+ };
57
+ }
58
+ getToken() {
59
+ if (!this.options.token) {
60
+ this.options.token = this.generateRandomToken();
61
+ }
62
+ return this.options.token;
63
+ }
64
+ generateRandomToken() {
65
+ return randomBytes(24).toString("base64url");
66
+ }
67
+ async start() {
68
+ const token = this.getToken();
69
+ await this.createListener(this.options.agentPort, "127.0.0.1", true);
70
+ await this.createListener(this.options.port, "0.0.0.0", false);
71
+ const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
72
+ let publicWsUrl;
73
+ const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
74
+ if (sandboxUrl) {
75
+ try {
76
+ const parsedUrl = new URL(sandboxUrl);
77
+ parsedUrl.protocol = "wss:";
78
+ parsedUrl.pathname = this.options.path;
79
+ parsedUrl.search = `?token=${token}`;
80
+ publicWsUrl = parsedUrl.toString();
81
+ } catch {
82
+ const httpsUrl = sandboxUrl.replace("https://", "wss://");
83
+ publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`;
84
+ }
85
+ } else {
86
+ const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
87
+ publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
88
+ }
89
+ if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) {
90
+ console.warn(
91
+ "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network."
92
+ );
93
+ }
94
+ const connectorCommand = `npx @mearl/cloud-connector start "${publicWsUrl}"`;
95
+ console.log(
96
+ `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)`
97
+ );
98
+ console.log(`[CloudServer] Connect from local machine:`);
99
+ console.log(` ${connectorCommand}`);
100
+ this.startHeartbeatCheck();
101
+ const info = {
102
+ pid: process.pid,
103
+ port: this.options.port,
104
+ path: this.options.path,
105
+ token,
106
+ localUrl: localWsUrl,
107
+ publicUrl: publicWsUrl,
108
+ connectorCommand,
109
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
110
+ };
111
+ this.writeConfigFile(info);
112
+ return info;
113
+ }
114
+ /**
115
+ * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection
116
+ * accepted here so handleRequest can pin command-issuing authority to the
117
+ * loopback listener.
118
+ */
119
+ createListener(port, host, fromLocal) {
120
+ return new Promise((resolve, reject) => {
121
+ const httpServer = createHttpServer((req, res) => {
122
+ console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`);
123
+ res.writeHead(404, { "Content-Type": "text/plain" });
124
+ res.end("Not Found - This is a WebSocket server");
125
+ });
126
+ const wss = new WebSocketServer({
127
+ server: httpServer,
128
+ path: this.options.path,
129
+ maxPayload: this.options.maxMessageSize
130
+ });
131
+ wss.on("connection", (ws, req) => {
132
+ console.log(
133
+ `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}`
134
+ );
135
+ this.handleConnection(ws, req, fromLocal);
136
+ });
137
+ wss.on("error", (error) => {
138
+ console.error("[CloudServer] WebSocket server error:", error.message);
139
+ });
140
+ httpServer.on("error", (error) => {
141
+ console.error("[CloudServer] HTTP server error:", error.message);
142
+ reject(error);
143
+ });
144
+ if (fromLocal) {
145
+ this.agentWss = wss;
146
+ this.agentHttpServer = httpServer;
147
+ } else {
148
+ this.connectorWss = wss;
149
+ this.connectorHttpServer = httpServer;
150
+ }
151
+ httpServer.listen(port, host, () => {
152
+ resolve();
153
+ });
154
+ });
155
+ }
156
+ stop() {
157
+ return new Promise((resolve) => {
158
+ if (this.heartbeatTimer) {
159
+ clearInterval(this.heartbeatTimer);
160
+ this.heartbeatTimer = null;
161
+ }
162
+ for (const [ws] of this.connections) {
163
+ ws.close();
164
+ }
165
+ this.connections.clear();
166
+ for (const pending of this.pendingRequests.values()) {
167
+ clearTimeout(pending.timer);
168
+ }
169
+ this.pendingRequests.clear();
170
+ this.agentWss?.close();
171
+ this.connectorWss?.close();
172
+ this.agentWss = null;
173
+ this.connectorWss = null;
174
+ const servers = [this.agentHttpServer, this.connectorHttpServer].filter(
175
+ (s) => s !== null
176
+ );
177
+ this.agentHttpServer = null;
178
+ this.connectorHttpServer = null;
179
+ if (servers.length === 0) {
180
+ resolve();
181
+ return;
182
+ }
183
+ let remaining = servers.length;
184
+ for (const s of servers) {
185
+ s.close(() => {
186
+ if (--remaining <= 0) resolve();
187
+ });
188
+ }
189
+ });
190
+ }
191
+ handleConnection(ws, req, fromLocal) {
192
+ if (this.connections.size >= this.options.maxConnections) {
193
+ ws.close(1008, "Server is full");
194
+ return;
195
+ }
196
+ if (this.options.token) {
197
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
198
+ const token = url.searchParams.get("token");
199
+ if (token !== this.options.token) {
200
+ ws.close(1008, "Unauthorized");
201
+ return;
202
+ }
203
+ }
204
+ const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
205
+ const connection = {
206
+ ws,
207
+ id: connId,
208
+ lastHeartbeat: Date.now(),
209
+ connectedAt: Date.now(),
210
+ role: fromLocal ? "agent" : "pending",
211
+ fromLocal,
212
+ messageBuffer: ""
213
+ };
214
+ this.connections.set(ws, connection);
215
+ console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`);
216
+ ws.on("message", (data) => {
217
+ const conn = this.connections.get(ws);
218
+ if (!conn) return;
219
+ conn.messageBuffer += data.toString("utf-8");
220
+ if (conn.messageBuffer.length > MAX_BUFFER_SIZE) {
221
+ console.error(
222
+ `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection`
223
+ );
224
+ ws.close(1008, "Message too large");
225
+ return;
226
+ }
227
+ let newlineIndex;
228
+ while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) {
229
+ const line = conn.messageBuffer.slice(0, newlineIndex);
230
+ conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1);
231
+ if (!line.trim()) continue;
232
+ try {
233
+ const message = JSON.parse(line);
234
+ this.handleMessage(ws, message);
235
+ } catch (error) {
236
+ console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
237
+ }
238
+ }
239
+ });
240
+ ws.on("close", () => {
241
+ this.connections.delete(ws);
242
+ this.rejectPendingForWs(ws);
243
+ console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`);
244
+ });
245
+ ws.on("error", (error) => {
246
+ console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
247
+ });
248
+ }
249
+ handleMessage(ws, message) {
250
+ const conn = this.connections.get(ws);
251
+ if (!conn) return;
252
+ if (isHeartbeatMessage(message)) {
253
+ if (message.type === "ping") {
254
+ ws.send(
255
+ JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n"
256
+ );
257
+ conn.lastHeartbeat = Date.now();
258
+ } else if (message.type === "pong") {
259
+ conn.lastHeartbeat = Date.now();
260
+ }
261
+ return;
262
+ }
263
+ if (isAgentHelloMessage(message)) {
264
+ this.handleAgentHello(ws);
265
+ return;
266
+ }
267
+ if (isConnectorHelloMessage(message)) {
268
+ this.handleConnectorHello(ws, message);
269
+ return;
270
+ }
271
+ if (isCloudResponse(message)) {
272
+ this.handleResponse(ws, message);
273
+ return;
274
+ }
275
+ if (isCloudMessage(message)) {
276
+ this.handleRequest(ws, message);
277
+ return;
278
+ }
279
+ }
280
+ handleAgentHello(ws) {
281
+ const conn = this.connections.get(ws);
282
+ if (!conn) return;
283
+ if (!conn.fromLocal && !this.options.allowRemoteAgent) {
284
+ ws.close(1008, "Agent must be local");
285
+ return;
286
+ }
287
+ conn.role = "agent";
288
+ delete conn.connector;
289
+ }
290
+ handleConnectorHello(ws, message) {
291
+ const conn = this.connections.get(ws);
292
+ if (!conn || conn.fromLocal) {
293
+ ws.close(1008, "Connector must use the connector listener");
294
+ return;
295
+ }
296
+ for (const [otherWs, other] of this.connections) {
297
+ if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
298
+ this.connections.delete(otherWs);
299
+ this.rejectPendingForWs(otherWs);
300
+ otherWs.close(1e3, "Connector replaced by a newer connection");
301
+ }
302
+ }
303
+ conn.role = "connector";
304
+ conn.connector = {
305
+ connectorId: message.connectorId,
306
+ name: message.name,
307
+ ...message.version ? { version: message.version } : {}
308
+ };
309
+ console.log(
310
+ `[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
311
+ );
312
+ }
313
+ handleRequest(ws, message) {
314
+ const { id, action, data } = message;
315
+ const conn = this.connections.get(ws);
316
+ if (!conn) return;
317
+ if (conn.role !== "agent") {
318
+ console.warn(
319
+ `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
320
+ );
321
+ ws.send(
322
+ JSON.stringify({
323
+ id,
324
+ success: false,
325
+ 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 @mearl/client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
326
+ }) + "\n"
327
+ );
328
+ return;
329
+ }
330
+ if (action === "connector_list") {
331
+ const response = {
332
+ id,
333
+ success: true,
334
+ data: this.getConnectorList()
335
+ };
336
+ ws.send(JSON.stringify(response) + "\n");
337
+ return;
338
+ }
339
+ const target = this.resolveConnector(message.connector);
340
+ if ("error" in target) {
341
+ ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
342
+ return;
343
+ }
344
+ const routeId = randomBytes(12).toString("base64url");
345
+ const requestedTimeoutSec = Number(message.timeoutSec);
346
+ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
347
+ const timer = setTimeout(
348
+ () => {
349
+ this.pendingRequests.delete(routeId);
350
+ if (ws.readyState === WebSocket.OPEN) {
351
+ ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
352
+ }
353
+ },
354
+ timeoutSec * 1e3 + RESPONSE_GRACE_MS
355
+ );
356
+ this.pendingRequests.set(routeId, {
357
+ agentRequestId: id,
358
+ agentWs: ws,
359
+ connectorWs: target.ws,
360
+ timer
361
+ });
362
+ target.ws.send(
363
+ JSON.stringify({
364
+ id: routeId,
365
+ action,
366
+ data,
367
+ browser: message.browser,
368
+ timeoutSec
369
+ }) + "\n"
370
+ );
371
+ }
372
+ handleResponse(ws, response) {
373
+ const conn = this.connections.get(ws);
374
+ if (!conn) return;
375
+ if (conn.role !== "connector") return;
376
+ const pending = this.pendingRequests.get(response.id);
377
+ if (!pending || pending.connectorWs !== ws) return;
378
+ this.pendingRequests.delete(response.id);
379
+ clearTimeout(pending.timer);
380
+ if (pending.agentWs.readyState === WebSocket.OPEN) {
381
+ pending.agentWs.send(
382
+ JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
383
+ );
384
+ }
385
+ }
386
+ connectorInfo(ws, conn) {
387
+ const pendingRequests = Array.from(this.pendingRequests.values()).filter(
388
+ (pending) => pending.connectorWs === ws
389
+ ).length;
390
+ return {
391
+ connectorId: conn.connector?.connectorId ?? conn.id,
392
+ name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
393
+ ...conn.connector?.version ? { version: conn.connector.version } : {},
394
+ connectedAt: new Date(conn.connectedAt).toISOString(),
395
+ lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
396
+ pendingRequests,
397
+ ...!conn.connector ? { legacy: true } : {}
398
+ };
399
+ }
400
+ getConnectorConnections() {
401
+ const connectors = [];
402
+ for (const [ws, conn] of this.connections) {
403
+ if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
404
+ conn.role = "connector";
405
+ }
406
+ if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
407
+ connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
408
+ }
409
+ return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
410
+ }
411
+ getConnectorList() {
412
+ const connectors = this.getConnectorConnections().map(({ info }) => info);
413
+ return { count: connectors.length, connectors };
414
+ }
415
+ resolveConnector(selector) {
416
+ const connectors = this.getConnectorConnections();
417
+ if (connectors.length === 0) return { error: "No local connector available" };
418
+ const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
419
+ if (!selector) {
420
+ if (connectors.length === 1) {
421
+ return { ws: connectors[0].ws, info: connectors[0].info };
422
+ }
423
+ return {
424
+ error: `Multiple cloud connectors are connected (${connectors.length}). Pass --connector <id|name>. Available: ${available}`
425
+ };
426
+ }
427
+ const selection = resolveCloudConnectorSelector(
428
+ connectors.map(({ info }) => info),
429
+ selector
430
+ );
431
+ if (selection.status === "matched") {
432
+ const matched = connectors.find(({ info }) => info === selection.connector);
433
+ return { ws: matched.ws, info: matched.info };
434
+ }
435
+ if (selection.status === "ambiguous") {
436
+ return {
437
+ error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
438
+ };
439
+ }
440
+ return {
441
+ error: `No cloud connector matches "${selector}". Available: ${available}`
442
+ };
443
+ }
444
+ rejectPendingForWs(ws) {
445
+ this.pendingRequests.forEach((pending, id) => {
446
+ if (pending.agentWs !== ws && pending.connectorWs !== ws) return;
447
+ clearTimeout(pending.timer);
448
+ this.pendingRequests.delete(id);
449
+ if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
450
+ pending.agentWs.send(
451
+ JSON.stringify({
452
+ id: pending.agentRequestId,
453
+ success: false,
454
+ error: "Connector connection closed"
455
+ }) + "\n"
456
+ );
457
+ }
458
+ });
459
+ }
460
+ startHeartbeatCheck() {
461
+ if (this.heartbeatTimer) return;
462
+ this.heartbeatTimer = setInterval(() => {
463
+ const now = Date.now();
464
+ const timeoutMs = this.options.heartbeatTimeout * 1e3;
465
+ for (const [ws, conn] of this.connections) {
466
+ if (now - conn.lastHeartbeat > timeoutMs) {
467
+ console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`);
468
+ ws.close(1e3, "Heartbeat timeout");
469
+ }
470
+ }
471
+ }, 3e4);
472
+ }
473
+ getPublicIP() {
474
+ const interfaces = networkInterfaces();
475
+ for (const name of Object.keys(interfaces)) {
476
+ const iface = interfaces[name];
477
+ if (!iface) continue;
478
+ for (const addr of iface) {
479
+ if (addr.family === "IPv4" && !addr.internal) {
480
+ return addr.address;
481
+ }
482
+ }
483
+ }
484
+ return "localhost";
485
+ }
486
+ async getSandboxPublicUrl(port) {
487
+ if (!process.env.AONE_SANDBOX_ID) {
488
+ return null;
489
+ }
490
+ const mappingApiUrl = "http://localhost:58596/api/port-mapping";
491
+ try {
492
+ const response = await fetch(`${mappingApiUrl}?port=${port}`);
493
+ if (!response.ok) {
494
+ return null;
495
+ }
496
+ const data = await response.json();
497
+ if (data.success && data.url) {
498
+ return data.url;
499
+ }
500
+ } catch (error) {
501
+ console.error(
502
+ `[CloudServer] Failed to query sandbox port mapping:`,
503
+ error instanceof Error ? error.message : String(error)
504
+ );
505
+ }
506
+ return null;
507
+ }
508
+ writeConfigFile(info) {
509
+ try {
510
+ if (!existsSync(CONFIG_DIR)) {
511
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
512
+ }
513
+ const config = { server: info.localUrl, ...info };
514
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
515
+ encoding: "utf-8",
516
+ mode: 384
517
+ });
518
+ if (process.platform !== "win32") {
519
+ chmodSync(CONFIG_DIR, 448);
520
+ chmodSync(CONFIG_FILE, 384);
521
+ }
522
+ console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
523
+ } catch (error) {
524
+ console.error("[CloudServer] Failed to write config file:", error);
525
+ }
526
+ }
527
+ getStats() {
528
+ let agents = 0;
529
+ for (const conn of this.connections.values()) {
530
+ if (conn.role === "agent") agents++;
531
+ }
532
+ return {
533
+ connections: this.connections.size,
534
+ agents,
535
+ connectors: this.getConnectorConnections().length,
536
+ pendingRequests: this.pendingRequests.size
537
+ };
538
+ }
539
+ };
540
+
541
+ export {
542
+ CONFIG_FILE,
543
+ MODE_FILE,
544
+ LOG_FILE,
545
+ CloudServer
546
+ };