@ozaiya/openclaw-channel 0.10.4 → 0.10.6

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,486 @@ 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
+ // Buffer Docker messages until relay WS is open.
485
+ // Docker noVNC sends the VNC handshake immediately on connect (local, fast),
486
+ // but relay WS (over internet) may not be open yet. Without buffering, the
487
+ // handshake is dropped and the VNC server times out waiting for a response.
488
+ const earlyDockerMsgs = [];
489
+ let relayOpen = false;
490
+ relayWs.on("open", () => {
491
+ log?.info(`[gateway] sandbox-screen: relay WS connected channelId=${channelId}`);
492
+ relayOpen = true;
493
+ for (const msg of earlyDockerMsgs) {
494
+ if (relayWs.readyState === 1)
495
+ relayWs.send(msg);
496
+ }
497
+ earlyDockerMsgs.length = 0;
430
498
  });
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}`);
499
+ // Raw binary pipe: Docker noVNC ↔ relay WS (server browser)
500
+ dockerWs.on("message", (data) => {
501
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
502
+ if (relayOpen && relayWs.readyState === 1 /* OPEN */) {
503
+ relayWs.send(buf);
504
+ }
505
+ else {
506
+ earlyDockerMsgs.push(buf);
507
+ }
435
508
  });
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 });
509
+ relayWs.on("message", (data) => {
510
+ if (dockerWs.readyState === 1 /* OPEN */) {
511
+ dockerWs.send(Buffer.isBuffer(data) ? data : Buffer.from(data));
512
+ }
440
513
  });
514
+ dockerWs.on("close", () => cleanupScreen('docker-ws-close'));
515
+ dockerWs.on("error", (err) => cleanupScreen(`docker-ws-error(${err.message})`));
516
+ relayWs.on("close", () => cleanupScreen('relay-ws-close'));
517
+ relayWs.on("error", (err) => cleanupScreen(`relay-ws-error(${err.message})`));
441
518
  ack({ ok: true });
442
519
  }
443
520
  else if (payload.method === 'sandbox-screen-password') {
444
521
  // Return VNC password from Docker container env (no WS connection)
445
522
  let password = '';
523
+ if (desktop.isInContainer) {
524
+ password = process.env.VNC_PASSWORD || process.env.OPENCLAW_BROWSER_NOVNC_PASSWORD || '';
525
+ }
526
+ else
527
+ try {
528
+ const dockerEnvLocal = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
529
+ let containerId = '';
530
+ try {
531
+ containerId = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
532
+ }
533
+ catch { /* ignore */ }
534
+ if (!containerId) {
535
+ try {
536
+ containerId = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
537
+ }
538
+ catch { /* ignore */ }
539
+ }
540
+ if (containerId) {
541
+ const envOutput = execSync(`docker inspect ${containerId} -f '{{range .Config.Env}}{{println .}}{{end}}'`, { timeout: 5000, env: dockerEnvLocal }).toString();
542
+ for (const line of envOutput.split('\n')) {
543
+ if (line.startsWith('OPENCLAW_BROWSER_NOVNC_PASSWORD=')) {
544
+ password = line.slice('OPENCLAW_BROWSER_NOVNC_PASSWORD='.length).trim();
545
+ break;
546
+ }
547
+ }
548
+ }
549
+ }
550
+ catch { /* ignore */ }
551
+ ack({ ok: true, result: { password } });
552
+ // ── Desktop container lifecycle ─────────────────────────────────────
553
+ }
554
+ else if (payload.method === 'desktop-container-create') {
555
+ ack({ ok: true, result: desktop.createContainer(payload.params) });
556
+ }
557
+ else if (payload.method === 'desktop-container-list') {
558
+ ack({ ok: true, result: desktop.listContainers() });
559
+ }
560
+ else if (payload.method === 'desktop-container-start') {
561
+ ack({ ok: true, result: desktop.startContainer(payload.params.id) });
562
+ }
563
+ else if (payload.method === 'desktop-container-stop') {
564
+ ack({ ok: true, result: desktop.stopContainer(payload.params.id) });
565
+ }
566
+ else if (payload.method === 'desktop-container-remove') {
567
+ ack({ ok: true, result: desktop.removeContainer(payload.params.id) });
568
+ }
569
+ else if (payload.method === 'desktop-container-logs') {
570
+ ack({ ok: true, result: { logs: desktop.getContainerLogs(payload.params.id, payload.params.tail) } });
571
+ }
572
+ else if (payload.method === 'desktop-container-launch-app') {
573
+ ack({ ok: true, result: desktop.launchApp(payload.params.id, payload.params.app, payload.params.appArgs) });
574
+ // ── Desktop AT-SPI proxy ────────────────────────────────────────────
575
+ }
576
+ else if (payload.method === 'desktop-atspi-health') {
577
+ ack({ ok: true, result: desktop.atspiHealth(payload.params.agentPort) });
578
+ }
579
+ else if (payload.method === 'desktop-atspi-apps') {
580
+ ack({ ok: true, result: desktop.atspiApps(payload.params.agentPort) });
581
+ }
582
+ else if (payload.method === 'desktop-atspi-tree') {
583
+ ack({ ok: true, result: desktop.atspiTree(payload.params.agentPort, { app: payload.params.app, maxDepth: payload.params.maxDepth }) });
584
+ }
585
+ else if (payload.method === 'desktop-atspi-find') {
586
+ const { agentPort, ...criteria } = payload.params;
587
+ ack({ ok: true, result: desktop.atspiFind(agentPort, criteria) });
588
+ }
589
+ else if (payload.method === 'desktop-atspi-action') {
590
+ ack({ ok: true, result: desktop.atspiAction(payload.params.agentPort, payload.params.path, payload.params.action) });
591
+ }
592
+ else if (payload.method === 'desktop-atspi-type') {
593
+ ack({ ok: true, result: desktop.atspiType(payload.params.agentPort, payload.params.path, payload.params.text) });
594
+ }
595
+ else if (payload.method === 'desktop-atspi-key') {
596
+ ack({ ok: true, result: desktop.atspiKey(payload.params.agentPort, payload.params.key, payload.params.modifiers) });
597
+ }
598
+ else if (payload.method === 'desktop-atspi-click') {
599
+ ack({ ok: true, result: desktop.atspiClick(payload.params.agentPort, payload.params.x, payload.params.y, payload.params.button) });
600
+ }
601
+ else if (payload.method === 'desktop-atspi-screenshot') {
602
+ ack({ ok: true, result: { data: desktop.atspiScreenshot(payload.params.agentPort, { bounds: payload.params.bounds, quality: payload.params.quality }) } });
603
+ // ── Desktop screen VNC relay ────────────────────────────────────────
604
+ }
605
+ else if (payload.method === 'desktop-clipboard-type') {
606
+ ack({ ok: true, result: desktop.clipboardType(payload.params.id, payload.params.text) });
607
+ }
608
+ else if (payload.method === 'desktop-xdotool-key') {
609
+ ack({ ok: true, result: desktop.xdotoolKey(payload.params.id, payload.params.keys) });
610
+ }
611
+ else if (payload.method === 'desktop-screen-open') {
612
+ const { channelId, containerId } = payload.params;
613
+ log?.info(`[gateway] desktop-screen-open channelId=${channelId} containerId=${containerId}`);
614
+ // Find the noVNC port for the desktop container
615
+ const noVncPort = desktop.isInContainer
616
+ ? parseInt(process.env.NOVNC_PORT || "6080", 10)
617
+ : desktop.findContainerPort(containerId, 6080);
618
+ if (!noVncPort) {
619
+ ack({ ok: false, error: "Desktop container noVNC port not found" });
620
+ return;
621
+ }
622
+ const noVncHost = process.env.NOVNC_HOST || 'localhost';
623
+ let dockerWs;
624
+ try {
625
+ const WsModule = await import("ws");
626
+ const WsCtor = WsModule.default ?? WsModule;
627
+ dockerWs = new WsCtor(`ws://${noVncHost}:${noVncPort}/websockify`);
628
+ dockerWs.binaryType = "arraybuffer";
629
+ }
630
+ catch (err) {
631
+ ack({ ok: false, error: `Failed to connect to noVNC: ${String(err)}` });
632
+ return;
633
+ }
634
+ // Open dedicated relay WS back to the server
635
+ const desktopRelayUrl = `${apiBaseUrl.replace(/^http/, 'ws')}/v1/ws-relay/${channelId}/ws?token=${encodeURIComponent(gatewayToken)}`;
636
+ let relayWs;
446
637
  try {
447
- const dockerEnv = { ...process.env, PATH: `${process.env.PATH}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
448
- let containerId = '';
638
+ const WsModule = await import("ws");
639
+ const WsCtor = WsModule.default ?? WsModule;
640
+ relayWs = new WsCtor(desktopRelayUrl);
641
+ }
642
+ catch (err) {
643
+ try {
644
+ dockerWs.close();
645
+ }
646
+ catch { /* ignore */ }
647
+ ack({ ok: false, error: `Failed to connect relay WS: ${String(err)}` });
648
+ return;
649
+ }
650
+ screenConnections.set(channelId, { dockerWs, relayWs });
651
+ const cleanupDesktop = (reason) => {
652
+ const entry = screenConnections.get(channelId);
653
+ if (!entry)
654
+ return;
655
+ screenConnections.delete(channelId);
656
+ try {
657
+ if (entry.dockerWs.readyState !== 3 /* CLOSED */)
658
+ entry.dockerWs.close();
659
+ }
660
+ catch { /* ignore */ }
449
661
  try {
450
- containerId = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnv }).toString().trim();
662
+ if (entry.relayWs.readyState !== 3 /* CLOSED */)
663
+ entry.relayWs.close();
451
664
  }
452
665
  catch { /* ignore */ }
453
- if (!containerId) {
666
+ log?.info(`[gateway] desktop-screen: closed channelId=${channelId} reason=${reason}`);
667
+ };
668
+ dockerWs.on("open", () => {
669
+ log?.info(`[gateway] desktop-screen: connected to noVNC on port ${noVncPort}`);
670
+ });
671
+ // Buffer Docker messages until relay WS is open (same race fix as sandbox).
672
+ const earlyDesktopMsgs = [];
673
+ let desktopRelayOpen = false;
674
+ relayWs.on("open", () => {
675
+ log?.info(`[gateway] desktop-screen: relay WS connected channelId=${channelId}`);
676
+ desktopRelayOpen = true;
677
+ for (const msg of earlyDesktopMsgs) {
678
+ if (relayWs.readyState === 1)
679
+ relayWs.send(msg);
680
+ }
681
+ earlyDesktopMsgs.length = 0;
682
+ });
683
+ // Raw binary pipe: Docker noVNC ↔ relay WS (server ↔ browser)
684
+ dockerWs.on("message", (data) => {
685
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
686
+ if (desktopRelayOpen && relayWs.readyState === 1 /* OPEN */) {
687
+ relayWs.send(buf);
688
+ }
689
+ else {
690
+ earlyDesktopMsgs.push(buf);
691
+ }
692
+ });
693
+ relayWs.on("message", (data) => {
694
+ if (dockerWs.readyState === 1 /* OPEN */) {
695
+ dockerWs.send(Buffer.isBuffer(data) ? data : Buffer.from(data));
696
+ }
697
+ });
698
+ dockerWs.on("close", () => cleanupDesktop('docker-ws-close'));
699
+ dockerWs.on("error", (err) => cleanupDesktop(`docker-ws-error(${err.message})`));
700
+ relayWs.on("close", () => cleanupDesktop('relay-ws-close'));
701
+ relayWs.on("error", (err) => cleanupDesktop(`relay-ws-error(${err.message})`));
702
+ ack({ ok: true });
703
+ }
704
+ else if (payload.method === 'desktop-screen-password') {
705
+ const { containerId } = payload.params;
706
+ ack({ ok: true, result: { password: desktop.getVncPassword(containerId) } });
707
+ // ── Bot terminal PTY (dedicated WS pipe) ─────────────────────────────
708
+ }
709
+ else if (payload.method === 'bot-terminal-open') {
710
+ const { channelId, containerId } = payload.params;
711
+ log?.info(`[gateway] bot-terminal-open channelId=${channelId} containerId=${containerId ?? 'self'}`);
712
+ try {
713
+ const nodePty = await import("node-pty");
714
+ const cols = 80;
715
+ const rows = 24;
716
+ const env = { TERM: 'xterm-256color', ...process.env };
717
+ let ptyProcess;
718
+ if (desktop.isInContainer) {
719
+ ptyProcess = nodePty.spawn('/bin/bash', ['-l'], { cols, rows, cwd: '/', env });
720
+ }
721
+ else {
722
+ const dockerEnvLocal = { ...env, PATH: `${process.env.PATH || ''}:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin` };
723
+ let targetContainer = containerId;
724
+ if (!targetContainer) {
725
+ try {
726
+ targetContainer = execSync("docker ps -q --filter 'label=openclaw.sandboxBrowser=1' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
727
+ }
728
+ catch { /* ignore */ }
729
+ if (!targetContainer) {
730
+ try {
731
+ targetContainer = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnvLocal }).toString().trim();
732
+ }
733
+ catch { /* ignore */ }
734
+ }
735
+ }
736
+ if (!targetContainer) {
737
+ ack({ ok: false, error: 'No Docker container found for terminal' });
738
+ return;
739
+ }
740
+ ptyProcess = nodePty.spawn('docker', ['exec', '-it', targetContainer, '/bin/bash'], {
741
+ cols, rows, cwd: '/', env: dockerEnvLocal,
742
+ });
743
+ }
744
+ // Open dedicated WS back to the server relay endpoint
745
+ const relayUrl = `${apiBaseUrl.replace(/^http/, 'ws')}/v1/ws-relay/${channelId}/ws?token=${encodeURIComponent(gatewayToken)}`;
746
+ const WsModule = await import("ws");
747
+ const WsCtor = WsModule.default ?? WsModule;
748
+ const relayWs = new WsCtor(relayUrl);
749
+ terminalConnections.set(channelId, { pty: ptyProcess, ws: relayWs });
750
+ const cleanupTerminal = (reason) => {
751
+ const entry = terminalConnections.get(channelId);
752
+ if (!entry)
753
+ return;
754
+ terminalConnections.delete(channelId);
454
755
  try {
455
- containerId = execSync("docker ps -q --filter 'name=sandbox-browser' | head -1", { timeout: 5000, env: dockerEnv }).toString().trim();
756
+ entry.pty.kill();
456
757
  }
457
758
  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;
759
+ try {
760
+ if (entry.ws.readyState !== 3 /* CLOSED */)
761
+ entry.ws.close();
762
+ }
763
+ catch { /* ignore */ }
764
+ log?.info(`[gateway] bot-terminal: closed channelId=${channelId} reason=${reason}`);
765
+ };
766
+ relayWs.on("open", () => {
767
+ log?.info(`[gateway] bot-terminal: relay WS connected channelId=${channelId}`);
768
+ });
769
+ // PTY → relay WS (binary)
770
+ ptyProcess.onData((data) => {
771
+ if (relayWs.readyState === 1 /* OPEN */) {
772
+ relayWs.send(Buffer.from(data));
773
+ }
774
+ });
775
+ // Relay WS → PTY (text input or JSON control messages)
776
+ relayWs.on("message", (raw) => {
777
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
778
+ const str = buf.toString();
779
+ // Check for JSON control messages (resize)
780
+ try {
781
+ const msg = JSON.parse(str);
782
+ if (msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
783
+ ptyProcess.resize(msg.cols, msg.rows);
784
+ return;
465
785
  }
466
786
  }
467
- }
787
+ catch {
788
+ // Not JSON — treat as terminal input
789
+ }
790
+ ptyProcess.write(str);
791
+ });
792
+ ptyProcess.onExit(({ exitCode }) => {
793
+ // Send pty_exit control message before closing
794
+ if (relayWs.readyState === 1 /* OPEN */) {
795
+ relayWs.send(JSON.stringify({ type: 'pty_exit', exitCode }));
796
+ }
797
+ cleanupTerminal(`pty-exit(${exitCode})`);
798
+ });
799
+ relayWs.on("close", () => cleanupTerminal('relay-ws-close'));
800
+ relayWs.on("error", (err) => cleanupTerminal(`relay-ws-error(${err.message})`));
801
+ ack({ ok: true });
468
802
  }
469
- catch { /* ignore */ }
470
- ack({ ok: true, result: { password } });
803
+ catch (err) {
804
+ log?.warn(`[gateway] bot-terminal-open failed: ${String(err)}`);
805
+ ack({ ok: false, error: `Failed to open terminal: ${String(err)}` });
806
+ }
807
+ // ── WeChat monitor ──────────────────────────────────────────────────
808
+ }
809
+ else if (payload.method === 'wechat-monitor-start') {
810
+ const { containerId, ...monitorConfig } = payload.params;
811
+ const existing = getMonitor(containerId);
812
+ if (existing) {
813
+ ack({ ok: true, result: existing.getStatus() });
814
+ return;
815
+ }
816
+ const monitor = new WeChatMonitorSession(containerId, {
817
+ ...monitorConfig,
818
+ onNewMessage: (event) => {
819
+ sock.emit("wechat-monitor-event", event);
820
+ },
821
+ onError: (error) => {
822
+ log?.warn(`[wechat-monitor] error: ${error.message}`);
823
+ },
824
+ }, log);
825
+ await monitor.start();
826
+ ack({ ok: true, result: monitor.getStatus() });
827
+ }
828
+ else if (payload.method === 'wechat-monitor-stop') {
829
+ const { containerId } = payload.params;
830
+ const monitor = getMonitor(containerId);
831
+ if (monitor) {
832
+ monitor.stop();
833
+ ack({ ok: true, result: { stopped: true } });
834
+ }
835
+ else {
836
+ ack({ ok: true, result: { stopped: false, reason: "not running" } });
837
+ }
838
+ }
839
+ else if (payload.method === 'wechat-monitor-status') {
840
+ const { containerId } = payload.params;
841
+ const monitor = getMonitor(containerId);
842
+ ack({ ok: true, result: monitor ? monitor.getStatus() : { running: false, containerId } });
471
843
  }
472
844
  else {
473
845
  ack({ ok: false, error: `Unknown RPC method: ${payload.method}` });
@@ -477,15 +849,36 @@ export async function startGatewayMode(options) {
477
849
  ack({ ok: false, error: err instanceof Error ? err.message : 'RPC handler error' });
478
850
  }
479
851
  });
480
- // Clean up all sandbox connections on socket disconnect
852
+ // Clean up all screen connections, terminal connections, and monitors on socket disconnect
481
853
  sock.on("disconnect", () => {
482
- for (const [, ws] of sandboxConnections) {
854
+ for (const [, entry] of screenConnections) {
855
+ try {
856
+ if (entry.dockerWs.readyState !== 3)
857
+ entry.dockerWs.close();
858
+ }
859
+ catch { /* ignore */ }
860
+ try {
861
+ if (entry.relayWs.readyState !== 3)
862
+ entry.relayWs.close();
863
+ }
864
+ catch { /* ignore */ }
865
+ }
866
+ screenConnections.clear();
867
+ for (const [, entry] of terminalConnections) {
483
868
  try {
484
- ws.close();
869
+ entry.pty.kill();
485
870
  }
486
871
  catch { /* ignore */ }
872
+ try {
873
+ if (entry.ws.readyState !== 3)
874
+ entry.ws.close();
875
+ }
876
+ catch { /* ignore */ }
877
+ }
878
+ terminalConnections.clear();
879
+ for (const [, monitor] of getAllMonitors()) {
880
+ monitor.stop();
487
881
  }
488
- sandboxConnections.clear();
489
882
  });
490
883
  }
491
884
  catch (err) {