@ozaiya/openclaw-channel 0.10.3 → 0.10.5

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.
@@ -24,6 +24,8 @@ import _sodium from "libsodium-wrappers";
24
24
  import { io as ioConnect } from "socket.io-client";
25
25
  import { decryptBox } from "./crypto.js";
26
26
  import { resolveOzaiyaSttConfig } from "./transcribeAudio.js";
27
+ import * as desktop from "./desktopContainer.js";
28
+ import { WeChatMonitorSession, getMonitor, getAllMonitors } from "./wechatMonitor.js";
27
29
  let sodiumReady = false;
28
30
  async function ensureSodium() {
29
31
  if (!sodiumReady) {
@@ -328,8 +330,10 @@ export async function startGatewayMode(options) {
328
330
  log?.warn(`[gateway] Socket.io connection error: ${String(err)}`);
329
331
  startPolling();
330
332
  });
331
- // Sandbox browser VNC relay state must be declared before update/rpc handlers
332
- const sandboxConnections = new Map();
333
+ // Screen connections (VNC): both dockerWs and relayWs per channel
334
+ const screenConnections = new Map();
335
+ // PTY terminal connections (pty + dedicated relay WS)
336
+ const terminalConnections = new Map();
333
337
  socket.on("update", (payload) => {
334
338
  const body = payload?.body;
335
339
  if (!body?.t)
@@ -347,26 +351,7 @@ export async function startGatewayMode(options) {
347
351
  }
348
352
  onWebhookEvent(body.botId, webhookPayload);
349
353
  }
350
- else if (body.t === "sandbox-screen-data" && body.channelId && body.data) {
351
- // Server → Docker: relay VNC data from browser
352
- const ws = sandboxConnections.get(body.channelId);
353
- if (ws && ws.readyState === 1 /* OPEN */) {
354
- // Handle both Buffer (binary) and string (base64) formats
355
- const buf = Buffer.isBuffer(body.data) ? body.data : Buffer.from(body.data, "base64");
356
- ws.send(buf);
357
- }
358
- }
359
- else if (body.t === "sandbox-screen-close" && body.channelId) {
360
- // Server requests closing a sandbox screen channel
361
- const ws = sandboxConnections.get(body.channelId);
362
- if (ws) {
363
- sandboxConnections.delete(body.channelId);
364
- try {
365
- ws.close();
366
- }
367
- catch { /* ignore */ }
368
- }
369
- }
354
+ // Screen data/close events removed — screen data now flows via dedicated WS pipe
370
355
  });
371
356
  // Initialize file browser sandbox to stateDir
372
357
  const fileBrowser = await import("./fileBrowser.js");
@@ -375,99 +360,457 @@ export async function startGatewayMode(options) {
375
360
  const sock = socket; // capture non-null for use in callbacks
376
361
  socket.on("gateway-rpc", async (payload, ack) => {
377
362
  try {
378
- if (payload.method === 'list-files') {
363
+ if (payload.method === 'list-screens') {
364
+ const screens = [];
365
+ if (desktop.isInContainer) {
366
+ // In-container: if NOVNC_HOST is set, a separate browser container is reachable
367
+ if (process.env.NOVNC_HOST) {
368
+ screens.push({ id: 'local', type: 'sandbox', name: 'Browser' });
369
+ }
370
+ }
371
+ else {
372
+ // On host: detect sandbox browser via Docker
373
+ try {
374
+ const dockerEnvLocal = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
375
+ let containerId = '';
376
+ try {
377
+ containerId = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
378
+ }
379
+ catch { /* ignore */ }
380
+ if (!containerId) {
381
+ try {
382
+ containerId = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
383
+ }
384
+ catch { /* ignore */ }
385
+ }
386
+ if (containerId) {
387
+ screens.push({ id: 'sandbox', type: 'sandbox', name: 'Browser Sandbox' });
388
+ }
389
+ }
390
+ catch { /* ignore */ }
391
+ // Desktop containers on host
392
+ for (const c of desktop.listContainers()) {
393
+ if (c.status.startsWith('Up') || c.status === 'running') {
394
+ screens.push({ id: c.id, type: 'desktop', name: c.name.replace(/^ozaiya-desktop-/, '') });
395
+ }
396
+ }
397
+ }
398
+ ack({ ok: true, result: { screens } });
399
+ }
400
+ else if (payload.method === 'list-files') {
379
401
  const files = await fileBrowser.listDirectory(payload.params.path ?? '.');
380
402
  ack({ ok: true, result: files });
381
403
  }
382
404
  else if (payload.method === 'sandbox-screen-open') {
383
- // Open sandbox browser VNC relay — connect to Docker noVNC container
405
+ // Open sandbox browser VNC relay — connect to Docker noVNC + relay WS back to server
384
406
  const { channelId, botId } = payload.params;
385
407
  log?.info(`[gateway] sandbox-screen-open channelId=${channelId} botId=${botId}`);
386
408
  // Find the Docker sandbox container's noVNC port
387
409
  let port;
388
- try {
389
- const dockerEnv = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
390
- let output = '';
410
+ if (desktop.isInContainer) {
411
+ // In-container mode: connect to local noVNC
412
+ port = process.env.NOVNC_PORT || '6080';
413
+ }
414
+ else
391
415
  try {
392
- output = execSync("docker port $(docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1) 6080 2>/dev/null", { timeout: 5000, env: dockerEnv }).toString().trim();
416
+ const dockerEnvLocal = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
417
+ let output = '';
418
+ try {
419
+ output = execSync("docker port $(docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1) 6080 2>/dev/null", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
420
+ }
421
+ catch {
422
+ output = execSync("docker port sandbox-browser 6080 2>/dev/null", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
423
+ }
424
+ const lastLine = output.split('\n').pop() || output;
425
+ port = lastLine.split(':').pop() || '6080';
393
426
  }
394
427
  catch {
395
- output = execSync("docker port sandbox-browser 6080 2>/dev/null", { timeout: 5000, env: dockerEnv }).toString().trim();
428
+ log?.warn(`[gateway] sandbox-screen: no Docker sandbox container found`);
429
+ ack({ ok: false, error: 'No Docker sandbox container found' });
430
+ return;
396
431
  }
397
- const lastLine = output.split('\n').pop() || output;
398
- port = lastLine.split(':').pop() || '6080';
399
- }
400
- catch {
401
- log?.warn(`[gateway] sandbox-screen: no Docker sandbox container found`);
402
- ack({ ok: false, error: 'No Docker sandbox container found' });
403
- return;
404
- }
405
432
  // Connect to noVNC websockify in the Docker container
406
- let ws;
433
+ const noVncHost = process.env.NOVNC_HOST || 'localhost';
434
+ let dockerWs;
407
435
  try {
408
436
  const WsModule = await import("ws");
409
437
  const WsCtor = WsModule.default ?? WsModule;
410
- ws = new WsCtor(`ws://localhost:${port}/websockify`);
411
- ws.binaryType = "arraybuffer";
438
+ dockerWs = new WsCtor(`ws://${noVncHost}:${port}/websockify`);
439
+ dockerWs.binaryType = "arraybuffer";
412
440
  }
413
441
  catch (err) {
414
442
  log?.warn(`[gateway] sandbox-screen: failed to create WS: ${String(err)}`);
415
443
  ack({ ok: false, error: `Failed to connect to noVNC: ${String(err)}` });
416
444
  return;
417
445
  }
418
- sandboxConnections.set(channelId, ws);
419
- ws.on("open", () => {
446
+ // Open dedicated relay WS back to the server
447
+ const relayUrl = `${apiBaseUrl.replace(/^http/, 'ws')}/v1/ws-relay/${channelId}/ws?token=${encodeURIComponent(gatewayToken)}`;
448
+ let relayWs;
449
+ try {
450
+ const WsModule = await import("ws");
451
+ const WsCtor = WsModule.default ?? WsModule;
452
+ relayWs = new WsCtor(relayUrl);
453
+ }
454
+ catch (err) {
455
+ log?.warn(`[gateway] sandbox-screen: failed to create relay WS: ${String(err)}`);
456
+ try {
457
+ dockerWs.close();
458
+ }
459
+ catch { /* ignore */ }
460
+ ack({ ok: false, error: `Failed to connect relay WS: ${String(err)}` });
461
+ return;
462
+ }
463
+ screenConnections.set(channelId, { dockerWs, relayWs });
464
+ const cleanupScreen = (reason) => {
465
+ const entry = screenConnections.get(channelId);
466
+ if (!entry)
467
+ return;
468
+ screenConnections.delete(channelId);
469
+ try {
470
+ if (entry.dockerWs.readyState !== 3 /* CLOSED */)
471
+ entry.dockerWs.close();
472
+ }
473
+ catch { /* ignore */ }
474
+ try {
475
+ if (entry.relayWs.readyState !== 3 /* CLOSED */)
476
+ entry.relayWs.close();
477
+ }
478
+ catch { /* ignore */ }
479
+ log?.info(`[gateway] sandbox-screen: closed channelId=${channelId} reason=${reason}`);
480
+ };
481
+ dockerWs.on("open", () => {
420
482
  log?.info(`[gateway] sandbox-screen: connected to Docker noVNC on port ${port}`);
421
483
  });
422
- // Docker → Server: relay VNC data (client→server emit always works)
423
- // Send raw Buffer Socket.io handles binary natively (no base64 overhead)
424
- ws.on("message", (data) => {
425
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
426
- sock.volatile.emit("sandbox-screen-data", {
427
- channelId,
428
- data: buf,
429
- });
484
+ relayWs.on("open", () => {
485
+ log?.info(`[gateway] sandbox-screen: relay WS connected channelId=${channelId}`);
430
486
  });
431
- ws.on("close", () => {
432
- sandboxConnections.delete(channelId);
433
- sock.emit("sandbox-screen-close", { channelId });
434
- log?.info(`[gateway] sandbox-screen: Docker WS closed channelId=${channelId}`);
487
+ // Raw binary pipe: Docker noVNC ↔ relay WS (server browser)
488
+ dockerWs.on("message", (data) => {
489
+ if (relayWs.readyState === 1 /* OPEN */) {
490
+ relayWs.send(Buffer.isBuffer(data) ? data : Buffer.from(data));
491
+ }
435
492
  });
436
- ws.on("error", (err) => {
437
- log?.warn(`[gateway] sandbox-screen: Docker WS error: ${String(err)}`);
438
- sandboxConnections.delete(channelId);
439
- sock.emit("sandbox-screen-close", { channelId });
493
+ relayWs.on("message", (data) => {
494
+ if (dockerWs.readyState === 1 /* OPEN */) {
495
+ dockerWs.send(Buffer.isBuffer(data) ? data : Buffer.from(data));
496
+ }
440
497
  });
498
+ dockerWs.on("close", () => cleanupScreen('docker-ws-close'));
499
+ dockerWs.on("error", (err) => cleanupScreen(`docker-ws-error(${err.message})`));
500
+ relayWs.on("close", () => cleanupScreen('relay-ws-close'));
501
+ relayWs.on("error", (err) => cleanupScreen(`relay-ws-error(${err.message})`));
441
502
  ack({ ok: true });
442
503
  }
443
504
  else if (payload.method === 'sandbox-screen-password') {
444
505
  // Return VNC password from Docker container env (no WS connection)
445
506
  let password = '';
507
+ if (desktop.isInContainer) {
508
+ password = process.env.VNC_PASSWORD || process.env.OPENCLAW_BROWSER_NOVNC_PASSWORD || '';
509
+ }
510
+ else
511
+ try {
512
+ const dockerEnvLocal = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
513
+ let containerId = '';
514
+ try {
515
+ containerId = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
516
+ }
517
+ catch { /* ignore */ }
518
+ if (!containerId) {
519
+ try {
520
+ containerId = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
521
+ }
522
+ catch { /* ignore */ }
523
+ }
524
+ if (containerId) {
525
+ const envOutput = execSync(`docker inspect ${containerId} -f '{{range .Config.Env}}{{println .}}{{end}}'`, { timeout: 5000, env: dockerEnvLocal }).toString();
526
+ for (const line of envOutput.split('\n')) {
527
+ if (line.startsWith('OPENCLAW_BROWSER_NOVNC_PASSWORD=')) {
528
+ password = line.slice('OPENCLAW_BROWSER_NOVNC_PASSWORD='.length).trim();
529
+ break;
530
+ }
531
+ }
532
+ }
533
+ }
534
+ catch { /* ignore */ }
535
+ ack({ ok: true, result: { password } });
536
+ // ── Desktop container lifecycle ─────────────────────────────────────
537
+ }
538
+ else if (payload.method === 'desktop-container-create') {
539
+ ack({ ok: true, result: desktop.createContainer(payload.params) });
540
+ }
541
+ else if (payload.method === 'desktop-container-list') {
542
+ ack({ ok: true, result: desktop.listContainers() });
543
+ }
544
+ else if (payload.method === 'desktop-container-start') {
545
+ ack({ ok: true, result: desktop.startContainer(payload.params.id) });
546
+ }
547
+ else if (payload.method === 'desktop-container-stop') {
548
+ ack({ ok: true, result: desktop.stopContainer(payload.params.id) });
549
+ }
550
+ else if (payload.method === 'desktop-container-remove') {
551
+ ack({ ok: true, result: desktop.removeContainer(payload.params.id) });
552
+ }
553
+ else if (payload.method === 'desktop-container-logs') {
554
+ ack({ ok: true, result: { logs: desktop.getContainerLogs(payload.params.id, payload.params.tail) } });
555
+ }
556
+ else if (payload.method === 'desktop-container-launch-app') {
557
+ ack({ ok: true, result: desktop.launchApp(payload.params.id, payload.params.app, payload.params.appArgs) });
558
+ // ── Desktop AT-SPI proxy ────────────────────────────────────────────
559
+ }
560
+ else if (payload.method === 'desktop-atspi-health') {
561
+ ack({ ok: true, result: desktop.atspiHealth(payload.params.agentPort) });
562
+ }
563
+ else if (payload.method === 'desktop-atspi-apps') {
564
+ ack({ ok: true, result: desktop.atspiApps(payload.params.agentPort) });
565
+ }
566
+ else if (payload.method === 'desktop-atspi-tree') {
567
+ ack({ ok: true, result: desktop.atspiTree(payload.params.agentPort, { app: payload.params.app, maxDepth: payload.params.maxDepth }) });
568
+ }
569
+ else if (payload.method === 'desktop-atspi-find') {
570
+ const { agentPort, ...criteria } = payload.params;
571
+ ack({ ok: true, result: desktop.atspiFind(agentPort, criteria) });
572
+ }
573
+ else if (payload.method === 'desktop-atspi-action') {
574
+ ack({ ok: true, result: desktop.atspiAction(payload.params.agentPort, payload.params.path, payload.params.action) });
575
+ }
576
+ else if (payload.method === 'desktop-atspi-type') {
577
+ ack({ ok: true, result: desktop.atspiType(payload.params.agentPort, payload.params.path, payload.params.text) });
578
+ }
579
+ else if (payload.method === 'desktop-atspi-key') {
580
+ ack({ ok: true, result: desktop.atspiKey(payload.params.agentPort, payload.params.key, payload.params.modifiers) });
581
+ }
582
+ else if (payload.method === 'desktop-atspi-click') {
583
+ ack({ ok: true, result: desktop.atspiClick(payload.params.agentPort, payload.params.x, payload.params.y, payload.params.button) });
584
+ }
585
+ else if (payload.method === 'desktop-atspi-screenshot') {
586
+ ack({ ok: true, result: { data: desktop.atspiScreenshot(payload.params.agentPort, { bounds: payload.params.bounds, quality: payload.params.quality }) } });
587
+ // ── Desktop screen VNC relay ────────────────────────────────────────
588
+ }
589
+ else if (payload.method === 'desktop-clipboard-type') {
590
+ ack({ ok: true, result: desktop.clipboardType(payload.params.id, payload.params.text) });
591
+ }
592
+ else if (payload.method === 'desktop-xdotool-key') {
593
+ ack({ ok: true, result: desktop.xdotoolKey(payload.params.id, payload.params.keys) });
594
+ }
595
+ else if (payload.method === 'desktop-screen-open') {
596
+ const { channelId, containerId } = payload.params;
597
+ log?.info(`[gateway] desktop-screen-open channelId=${channelId} containerId=${containerId}`);
598
+ // Find the noVNC port for the desktop container
599
+ const noVncPort = desktop.isInContainer
600
+ ? parseInt(process.env.NOVNC_PORT || "6080", 10)
601
+ : desktop.findContainerPort(containerId, 6080);
602
+ if (!noVncPort) {
603
+ ack({ ok: false, error: "Desktop container noVNC port not found" });
604
+ return;
605
+ }
606
+ const noVncHost = process.env.NOVNC_HOST || 'localhost';
607
+ let dockerWs;
608
+ try {
609
+ const WsModule = await import("ws");
610
+ const WsCtor = WsModule.default ?? WsModule;
611
+ dockerWs = new WsCtor(`ws://${noVncHost}:${noVncPort}/websockify`);
612
+ dockerWs.binaryType = "arraybuffer";
613
+ }
614
+ catch (err) {
615
+ ack({ ok: false, error: `Failed to connect to noVNC: ${String(err)}` });
616
+ return;
617
+ }
618
+ // Open dedicated relay WS back to the server
619
+ const desktopRelayUrl = `${apiBaseUrl.replace(/^http/, 'ws')}/v1/ws-relay/${channelId}/ws?token=${encodeURIComponent(gatewayToken)}`;
620
+ let relayWs;
446
621
  try {
447
- const dockerEnv = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
448
- let containerId = '';
622
+ const WsModule = await import("ws");
623
+ const WsCtor = WsModule.default ?? WsModule;
624
+ relayWs = new WsCtor(desktopRelayUrl);
625
+ }
626
+ catch (err) {
449
627
  try {
450
- containerId = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnv }).toString().trim();
628
+ dockerWs.close();
451
629
  }
452
630
  catch { /* ignore */ }
453
- if (!containerId) {
631
+ ack({ ok: false, error: `Failed to connect relay WS: ${String(err)}` });
632
+ return;
633
+ }
634
+ screenConnections.set(channelId, { dockerWs, relayWs });
635
+ const cleanupDesktop = (reason) => {
636
+ const entry = screenConnections.get(channelId);
637
+ if (!entry)
638
+ return;
639
+ screenConnections.delete(channelId);
640
+ try {
641
+ if (entry.dockerWs.readyState !== 3 /* CLOSED */)
642
+ entry.dockerWs.close();
643
+ }
644
+ catch { /* ignore */ }
645
+ try {
646
+ if (entry.relayWs.readyState !== 3 /* CLOSED */)
647
+ entry.relayWs.close();
648
+ }
649
+ catch { /* ignore */ }
650
+ log?.info(`[gateway] desktop-screen: closed channelId=${channelId} reason=${reason}`);
651
+ };
652
+ dockerWs.on("open", () => {
653
+ log?.info(`[gateway] desktop-screen: connected to noVNC on port ${noVncPort}`);
654
+ });
655
+ relayWs.on("open", () => {
656
+ log?.info(`[gateway] desktop-screen: relay WS connected channelId=${channelId}`);
657
+ });
658
+ // Raw binary pipe: Docker noVNC ↔ relay WS (server ↔ browser)
659
+ dockerWs.on("message", (data) => {
660
+ if (relayWs.readyState === 1 /* OPEN */) {
661
+ relayWs.send(Buffer.isBuffer(data) ? data : Buffer.from(data));
662
+ }
663
+ });
664
+ relayWs.on("message", (data) => {
665
+ if (dockerWs.readyState === 1 /* OPEN */) {
666
+ dockerWs.send(Buffer.isBuffer(data) ? data : Buffer.from(data));
667
+ }
668
+ });
669
+ dockerWs.on("close", () => cleanupDesktop('docker-ws-close'));
670
+ dockerWs.on("error", (err) => cleanupDesktop(`docker-ws-error(${err.message})`));
671
+ relayWs.on("close", () => cleanupDesktop('relay-ws-close'));
672
+ relayWs.on("error", (err) => cleanupDesktop(`relay-ws-error(${err.message})`));
673
+ ack({ ok: true });
674
+ }
675
+ else if (payload.method === 'desktop-screen-password') {
676
+ const { containerId } = payload.params;
677
+ ack({ ok: true, result: { password: desktop.getVncPassword(containerId) } });
678
+ // ── Bot terminal PTY (dedicated WS pipe) ─────────────────────────────
679
+ }
680
+ else if (payload.method === 'bot-terminal-open') {
681
+ const { channelId, containerId } = payload.params;
682
+ log?.info(`[gateway] bot-terminal-open channelId=${channelId} containerId=${containerId ?? 'self'}`);
683
+ try {
684
+ const nodePty = await import("node-pty");
685
+ const cols = 80;
686
+ const rows = 24;
687
+ const env = { TERM: 'xterm-256color', ...process.env };
688
+ let ptyProcess;
689
+ if (desktop.isInContainer) {
690
+ ptyProcess = nodePty.spawn('/bin/bash', ['-l'], { cols, rows, cwd: '/', env });
691
+ }
692
+ else {
693
+ const dockerEnvLocal = { ...env, PATH: `${process.env.PATH || ''}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
694
+ let targetContainer = containerId;
695
+ if (!targetContainer) {
696
+ try {
697
+ targetContainer = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
698
+ }
699
+ catch { /* ignore */ }
700
+ if (!targetContainer) {
701
+ try {
702
+ targetContainer = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
703
+ }
704
+ catch { /* ignore */ }
705
+ }
706
+ }
707
+ if (!targetContainer) {
708
+ ack({ ok: false, error: 'No Docker container found for terminal' });
709
+ return;
710
+ }
711
+ ptyProcess = nodePty.spawn('docker', ['exec', '-it', targetContainer, '/bin/bash'], {
712
+ cols, rows, cwd: '/', env: dockerEnvLocal,
713
+ });
714
+ }
715
+ // Open dedicated WS back to the server relay endpoint
716
+ const relayUrl = `${apiBaseUrl.replace(/^http/, 'ws')}/v1/ws-relay/${channelId}/ws?token=${encodeURIComponent(gatewayToken)}`;
717
+ const WsModule = await import("ws");
718
+ const WsCtor = WsModule.default ?? WsModule;
719
+ const relayWs = new WsCtor(relayUrl);
720
+ terminalConnections.set(channelId, { pty: ptyProcess, ws: relayWs });
721
+ const cleanupTerminal = (reason) => {
722
+ const entry = terminalConnections.get(channelId);
723
+ if (!entry)
724
+ return;
725
+ terminalConnections.delete(channelId);
454
726
  try {
455
- containerId = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnv }).toString().trim();
727
+ entry.pty.kill();
456
728
  }
457
729
  catch { /* ignore */ }
458
- }
459
- if (containerId) {
460
- const envOutput = execSync(`docker inspect ${containerId} -f '{{range .Config.Env}}{{println .}}{{end}}'`, { timeout: 5000, env: dockerEnv }).toString();
461
- for (const line of envOutput.split('\n')) {
462
- if (line.startsWith('OPENCLAW_BROWSER_NOVNC_PASSWORD=')) {
463
- password = line.slice('OPENCLAW_BROWSER_NOVNC_PASSWORD='.length).trim();
464
- break;
730
+ try {
731
+ if (entry.ws.readyState !== 3 /* CLOSED */)
732
+ entry.ws.close();
733
+ }
734
+ catch { /* ignore */ }
735
+ log?.info(`[gateway] bot-terminal: closed channelId=${channelId} reason=${reason}`);
736
+ };
737
+ relayWs.on("open", () => {
738
+ log?.info(`[gateway] bot-terminal: relay WS connected channelId=${channelId}`);
739
+ });
740
+ // PTY → relay WS (binary)
741
+ ptyProcess.onData((data) => {
742
+ if (relayWs.readyState === 1 /* OPEN */) {
743
+ relayWs.send(Buffer.from(data));
744
+ }
745
+ });
746
+ // Relay WS → PTY (text input or JSON control messages)
747
+ relayWs.on("message", (raw) => {
748
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
749
+ const str = buf.toString();
750
+ // Check for JSON control messages (resize)
751
+ try {
752
+ const msg = JSON.parse(str);
753
+ if (msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
754
+ ptyProcess.resize(msg.cols, msg.rows);
755
+ return;
465
756
  }
466
757
  }
467
- }
758
+ catch {
759
+ // Not JSON — treat as terminal input
760
+ }
761
+ ptyProcess.write(str);
762
+ });
763
+ ptyProcess.onExit(({ exitCode }) => {
764
+ // Send pty_exit control message before closing
765
+ if (relayWs.readyState === 1 /* OPEN */) {
766
+ relayWs.send(JSON.stringify({ type: 'pty_exit', exitCode }));
767
+ }
768
+ cleanupTerminal(`pty-exit(${exitCode})`);
769
+ });
770
+ relayWs.on("close", () => cleanupTerminal('relay-ws-close'));
771
+ relayWs.on("error", (err) => cleanupTerminal(`relay-ws-error(${err.message})`));
772
+ ack({ ok: true });
468
773
  }
469
- catch { /* ignore */ }
470
- ack({ ok: true, result: { password } });
774
+ catch (err) {
775
+ log?.warn(`[gateway] bot-terminal-open failed: ${String(err)}`);
776
+ ack({ ok: false, error: `Failed to open terminal: ${String(err)}` });
777
+ }
778
+ // ── WeChat monitor ──────────────────────────────────────────────────
779
+ }
780
+ else if (payload.method === 'wechat-monitor-start') {
781
+ const { containerId, ...monitorConfig } = payload.params;
782
+ const existing = getMonitor(containerId);
783
+ if (existing) {
784
+ ack({ ok: true, result: existing.getStatus() });
785
+ return;
786
+ }
787
+ const monitor = new WeChatMonitorSession(containerId, {
788
+ ...monitorConfig,
789
+ onNewMessage: (event) => {
790
+ sock.emit("wechat-monitor-event", event);
791
+ },
792
+ onError: (error) => {
793
+ log?.warn(`[wechat-monitor] error: ${error.message}`);
794
+ },
795
+ }, log);
796
+ await monitor.start();
797
+ ack({ ok: true, result: monitor.getStatus() });
798
+ }
799
+ else if (payload.method === 'wechat-monitor-stop') {
800
+ const { containerId } = payload.params;
801
+ const monitor = getMonitor(containerId);
802
+ if (monitor) {
803
+ monitor.stop();
804
+ ack({ ok: true, result: { stopped: true } });
805
+ }
806
+ else {
807
+ ack({ ok: true, result: { stopped: false, reason: "not running" } });
808
+ }
809
+ }
810
+ else if (payload.method === 'wechat-monitor-status') {
811
+ const { containerId } = payload.params;
812
+ const monitor = getMonitor(containerId);
813
+ ack({ ok: true, result: monitor ? monitor.getStatus() : { running: false, containerId } });
471
814
  }
472
815
  else {
473
816
  ack({ ok: false, error: `Unknown RPC method: ${payload.method}` });
@@ -477,15 +820,36 @@ export async function startGatewayMode(options) {
477
820
  ack({ ok: false, error: err instanceof Error ? err.message : 'RPC handler error' });
478
821
  }
479
822
  });
480
- // Clean up all sandbox connections on socket disconnect
823
+ // Clean up all screen connections, terminal connections, and monitors on socket disconnect
481
824
  sock.on("disconnect", () => {
482
- for (const [, ws] of sandboxConnections) {
825
+ for (const [, entry] of screenConnections) {
483
826
  try {
484
- ws.close();
827
+ if (entry.dockerWs.readyState !== 3)
828
+ entry.dockerWs.close();
485
829
  }
486
830
  catch { /* ignore */ }
831
+ try {
832
+ if (entry.relayWs.readyState !== 3)
833
+ entry.relayWs.close();
834
+ }
835
+ catch { /* ignore */ }
836
+ }
837
+ screenConnections.clear();
838
+ for (const [, entry] of terminalConnections) {
839
+ try {
840
+ entry.pty.kill();
841
+ }
842
+ catch { /* ignore */ }
843
+ try {
844
+ if (entry.ws.readyState !== 3)
845
+ entry.ws.close();
846
+ }
847
+ catch { /* ignore */ }
848
+ }
849
+ terminalConnections.clear();
850
+ for (const [, monitor] of getAllMonitors()) {
851
+ monitor.stop();
487
852
  }
488
- sandboxConnections.clear();
489
853
  });
490
854
  }
491
855
  catch (err) {