@ozaiya/openclaw-channel 0.10.44 → 0.10.45

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.
@@ -28,7 +28,8 @@ import * as desktop from "./desktopContainer.js";
28
28
  import { createSandboxFileSession, handleSandboxFileMessage } from "./sandboxFileTransfer.js";
29
29
  import { acquireCdpScreencast, releaseCdpScreencast } from "./sandboxScreenCdp.js";
30
30
  import { createSandboxRtcSession } from "./sandboxScreenRtc.js";
31
- import { probeFfmpeg, createH264Transcoder } from "./sandboxScreenH264.js";
31
+ import { probeFfmpeg, createH264Transcoder, createBitrateController } from "./sandboxScreenH264.js";
32
+ import { probeDesktopSource, createDesktopVideo, createDesktopInput } from "./sandboxScreenDesktop.js";
32
33
  import { unsealScreenKey, encryptScreenPayload, decryptScreenPayload, E2E_INPUT_MARKER } from "./sandboxScreenE2E.js";
33
34
  import { readSandboxIdentity } from "./sandboxIdentity.js";
34
35
  import { WeChatMonitorSession, getMonitor, getAllMonitors } from "./wechatMonitor.js";
@@ -583,7 +584,7 @@ export async function startGatewayMode(options) {
583
584
  // DataChannel (P2P, or TURN via coturn) and move frames + input onto it —
584
585
  // skipping the double-proxied relay, which is the dominant latency. The
585
586
  // relay stays connected as signaling pipe + automatic fallback.
586
- const { channelId, botId, supportsRtc, supportsE2E, iceServers, requestVnc } = payload.params;
587
+ const { channelId, botId, supportsRtc, supportsE2E, iceServers, requestVnc, requestDesktop } = payload.params;
587
588
  // Full-desktop noVNC tier (the bot's screenMode='vnc' → server &vnc=1 → requestVnc). Instead
588
589
  // of the CDP screencast, raw-pipe the sandbox-browser's websockify (x11vnc on Xvfb :1)
589
590
  // straight to the relay — exactly like desktop-screen-open. The viewer runs noVNC/RFB and
@@ -695,7 +696,20 @@ export async function startGatewayMode(options) {
695
696
  let userQuality = null;
696
697
  let userBitrateKbps = null;
697
698
  let userMinIntervalMs = null;
698
- log?.info(`[gateway] sandbox-screen-open channelId=${channelId} botId=${botId} (cdp${supportsRtc ? '+rtc' : ''}${expectE2E ? '+e2e' : ''})`);
699
+ // Desktop source (viewer asked with &desktop=1 — screenMode='vnc' bots):
700
+ // the whole X display + real X11 input, served by the browser
701
+ // container's control server. Compose mode only (host-mode sandboxes
702
+ // don't run it). Unavailable ⇒ the page (CDP) source, and the viewer is
703
+ // told so it can fall back to its noVNC pipe.
704
+ const controlHost = process.env.NOVNC_HOST || '';
705
+ const controlPort = Number(process.env.RECORD_CONTROL_PORT || '8091');
706
+ const desktopInfo = requestDesktop && desktop.isInContainer && controlHost
707
+ ? await probeDesktopSource(controlHost, controlPort)
708
+ : null;
709
+ const useDesktop = !!desktopInfo;
710
+ if (requestDesktop && !useDesktop)
711
+ log?.warn(`[gateway] sandbox-screen: desktop source unavailable channelId=${channelId} — page source`);
712
+ log?.info(`[gateway] sandbox-screen-open channelId=${channelId} botId=${botId} (${useDesktop ? `desktop ${desktopInfo.width}x${desktopInfo.height}` : 'cdp'}${supportsRtc ? '+rtc' : ''}${expectE2E ? '+e2e' : ''})`);
699
713
  // Resolve the browser container's CDP host:port (the socat-proxied DevTools
700
714
  // endpoint). In-container: NOVNC_HOST + CDP port. On host: the mapped 9222.
701
715
  let cdpHost = process.env.NOVNC_HOST || 'localhost';
@@ -735,7 +749,8 @@ export async function startGatewayMode(options) {
735
749
  if (!desktop.isInContainer && browserContainerId) {
736
750
  ffmpegCandidates.push(['docker', 'exec', '-i', browserContainerId, 'ffmpeg']);
737
751
  }
738
- const ffmpegCmd = await probeFfmpeg(ffmpegCandidates, dockerEnvLocal);
752
+ // (The desktop source encodes inside the browser container — no local ffmpeg needed.)
753
+ const ffmpegCmd = useDesktop ? null : await probeFfmpeg(ffmpegCandidates, dockerEnvLocal);
739
754
  if (ffmpegCmd)
740
755
  log?.info(`[gateway] sandbox-screen: H.264 encoder available (${ffmpegCmd[0]})`);
741
756
  // Open the relay WS back to the server.
@@ -751,7 +766,7 @@ export async function startGatewayMode(options) {
751
766
  ack({ ok: false, error: `Failed to connect relay WS: ${String(err)}` });
752
767
  return;
753
768
  }
754
- const entry = { relayWs, cdpKey: { host: cdpHost, port: cdpPort } };
769
+ const entry = { relayWs, cdpKey: useDesktop ? undefined : { host: cdpHost, port: cdpPort } };
755
770
  screenConnections.set(channelId, entry);
756
771
  let relayOpen = false;
757
772
  const earlyFrames = [];
@@ -770,20 +785,20 @@ export async function startGatewayMode(options) {
770
785
  if (!pt || pt.length < 1)
771
786
  return;
772
787
  if (pt[0] === 0)
773
- entry.cdp?.handleBinaryInput(pt.subarray(1));
788
+ entry.input?.handleBinaryInput(pt.subarray(1));
774
789
  else {
775
790
  try {
776
791
  const m = JSON.parse(pt.subarray(1).toString());
777
792
  if (m.type === 'input')
778
- entry.cdp?.handleInput(m);
793
+ entry.input?.handleInput(m);
779
794
  else if (m.type === 'viewport-size')
780
- entry.cdp?.resize(Number(m.width), Number(m.height));
795
+ entry.input?.resize(Number(m.width), Number(m.height));
781
796
  }
782
797
  catch { /* ignore */ }
783
798
  }
784
799
  return;
785
800
  }
786
- entry.cdp?.handleBinaryInput(buf);
801
+ entry.input?.handleBinaryInput(buf);
787
802
  return;
788
803
  }
789
804
  try {
@@ -805,9 +820,9 @@ export async function startGatewayMode(options) {
805
820
  return;
806
821
  }
807
822
  if (msg.type === 'input')
808
- entry.cdp?.handleInput(msg);
823
+ entry.input?.handleInput(msg);
809
824
  else if (msg.type === 'viewport-size')
810
- entry.cdp?.resize(Number(msg.width), Number(msg.height));
825
+ entry.input?.resize(Number(msg.width), Number(msg.height));
811
826
  // The viewer pings every second for its latency indicator — answer on
812
827
  // the active transport (silently dropped before, which left the
813
828
  // latency stat permanently empty on the bot view).
@@ -827,13 +842,17 @@ export async function startGatewayMode(options) {
827
842
  if (isFinite(mbps) && mbps > 0) {
828
843
  userBitrateKbps = Math.max(300, Math.min(40000, Math.round(mbps * 1000)));
829
844
  userQuality = Math.max(40, Math.min(95, Math.round(45 + Math.log2(mbps) * 10)));
830
- transcoder?.updateBitrate(userBitrateKbps);
845
+ // The pick is the adaptation ceiling; it jumps there and
846
+ // backs off from it only on loss.
847
+ bitrate.setCeiling(userBitrateKbps);
831
848
  }
832
849
  }
833
850
  else if (msg.setting === 'frameRate') {
834
851
  const f = Number(msg.value);
835
- if (isFinite(f) && f > 0)
852
+ if (isFinite(f) && f > 0) {
836
853
  userMinIntervalMs = Math.round(1000 / Math.max(1, Math.min(30, f)));
854
+ desktopVideo?.setFps(f);
855
+ }
837
856
  }
838
857
  }
839
858
  }
@@ -847,6 +866,17 @@ export async function startGatewayMode(options) {
847
866
  t?.close();
848
867
  }
849
868
  catch { /* ignore */ } };
869
+ // Desktop source halves (useDesktop only): the x11grab stream and the XTest input.
870
+ let desktopVideo = null;
871
+ let desktopInput = null;
872
+ // Media-track bitrate follows the viewer's RTCP loss reports + PLIs,
873
+ // between a floor and the quality pick (or a default ceiling).
874
+ const bitrate = createBitrateController({
875
+ initialKbps: 1200,
876
+ maxKbps: 2500,
877
+ apply: (kbps) => { transcoder?.updateBitrate(kbps); desktopVideo?.updateBitrate(kbps); },
878
+ log: (m) => log?.info(m),
879
+ });
850
880
  if (supportsRtc) {
851
881
  // The container NAT is symmetric on Docker-Desktop-Mac, so a missing
852
882
  // TURN entry here means the direct tier can't ever connect — make
@@ -870,16 +900,25 @@ export async function startGatewayMode(options) {
870
900
  onData: (data) => handleViewerData(data, typeof data !== 'string'),
871
901
  onActive: () => log?.info(`[gateway] sandbox-screen: P2P DataChannel active channelId=${channelId} — frames direct, relay on standby`),
872
902
  onClosed: () => log?.info(`[gateway] sandbox-screen: P2P closed channelId=${channelId} — frames back on relay`),
873
- enableVideo: !!ffmpegCmd,
903
+ enableVideo: !!ffmpegCmd || useDesktop,
874
904
  onMediaStateChange: (ready) => {
905
+ if (ready)
906
+ bitrate.settle();
907
+ if (useDesktop) {
908
+ // Same x11grab stream, re-dialed as H.264 for the media track
909
+ // or JPEG for the DataChannel/relay.
910
+ log?.info(`[gateway] sandbox-screen: desktop ${ready ? 'H.264 media track up' : 'media track down — JPEG'} channelId=${channelId}`);
911
+ desktopVideo?.setMode(ready ? 'h264' : 'jpeg');
912
+ return;
913
+ }
875
914
  if (ready && ffmpegCmd && !transcoder && !transcoderDead) {
876
915
  log?.info(`[gateway] sandbox-screen: H.264 media track up channelId=${channelId} — starting encoder`);
877
916
  transcoder = createH264Transcoder({
878
917
  cmdPrefix: ffmpegCmd,
879
918
  env: dockerEnvLocal,
880
919
  fps: 25,
881
- // Honor a quality pick made before the media track came up.
882
- bitrateKbps: userBitrateKbps ?? 1200,
920
+ // Current adaptive target (includes a quality pick made before the track came up).
921
+ bitrateKbps: bitrate.current(),
883
922
  onAccessUnit: (au) => { rtc?.sendVideoFrame(au); },
884
923
  onExit: () => { transcoder = null; transcoderDead = true; log?.warn(`[gateway] sandbox-screen: encoder gone channelId=${channelId} — JPEG fallback`); },
885
924
  log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
@@ -891,7 +930,8 @@ export async function startGatewayMode(options) {
891
930
  },
892
931
  // Viewer PLI → force the encoder to emit a keyframe now (debounced
893
932
  // inside the transcoder). No transcoder ⇒ no-op (GOP recovers).
894
- onKeyframeRequest: () => transcoder?.forceKeyframe(),
933
+ onKeyframeRequest: () => { bitrate.onPli(); transcoder?.forceKeyframe(); desktopVideo?.forceKeyframe(); },
934
+ onLossReport: (fraction) => bitrate.onLoss(fraction),
895
935
  log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
896
936
  });
897
937
  }
@@ -900,10 +940,22 @@ export async function startGatewayMode(options) {
900
940
  return;
901
941
  screenConnections.delete(channelId);
902
942
  stopTranscoder();
943
+ try {
944
+ desktopVideo?.close();
945
+ }
946
+ catch { /* ignore */ }
947
+ try {
948
+ desktopInput?.close();
949
+ }
950
+ catch { /* ignore */ }
951
+ desktopVideo = null;
952
+ desktopInput = null;
903
953
  // The CDP session is SHARED across viewers of this browser — release
904
954
  // our slot; it closes only when the last viewer leaves.
905
- releaseCdpScreencast(cdpHost, cdpPort, channelId);
955
+ if (!useDesktop)
956
+ releaseCdpScreencast(cdpHost, cdpPort, channelId);
906
957
  entry.cdp = undefined;
958
+ entry.input = undefined;
907
959
  try {
908
960
  rtc?.close();
909
961
  }
@@ -955,88 +1007,118 @@ export async function startGatewayMode(options) {
955
1007
  earlyFrames.push(data);
956
1008
  }
957
1009
  };
958
- // Attach to the (shared, per-browser) CDP screencast — one capture loop
959
- // no matter how many viewers are watching this sandbox.
960
- acquireCdpScreencast({
961
- host: cdpHost,
962
- port: cdpPort,
963
- minIntervalMs: 55,
964
- quality: 40,
965
- log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
966
- }, channelId, {
967
- // Transport-adaptive tuning. Relay tier: ~18fps / q40 — capture is only
968
- // ~38ms, so the bottleneck is per-frame transfer over the (Cloudflare-
969
- // proxied) relay; smaller frames transfer faster = lower latency. Direct
970
- // tier (P2P/TURN DataChannel): the proxy hop is gone, so spend the
971
- // headroom on fidelity + smoothness (~25fps / q60). H.264 tier: the JPEG
972
- // is only encoder input (bandwidth decouples from its size), so feed it
973
- // high quality at the encoder's frame rate. The server drops frames a
974
- // slow viewer can't take (dropFramesOnBackpressure) and the screencast
975
- // self-throttles on the active transport's buffer, so these stay
976
- // ceilings. (Shared session: max quality / min interval across viewers.)
977
- getQuality: () => (userQuality ?? (transcoder && rtc?.isMediaReady() ? 80 : rtc?.isActive() ? 60 : 40)),
978
- getMinIntervalMs: () => (userMinIntervalMs ?? (transcoder && rtc?.isMediaReady() ? 40 : rtc?.isActive() ? 40 : 55)),
979
- onFrame: (jpeg) => {
980
- // H.264 tier: the poll feeds the encoder; the WebRTC media track is
981
- // already DTLS-E2E, so no app-layer crypto there.
982
- if (transcoder && rtc?.isMediaReady()) {
983
- transcoder.feed(jpeg);
1010
+ // A JPEG frame for the DataChannel/relay tiers. With E2E negotiated,
1011
+ // encrypt with the viewer's key; until it arrives, WITHHOLD (never leak a
1012
+ // plaintext frame to the server). Frames are stateless, so the first one
1013
+ // just shows a beat later.
1014
+ const sendJpegFrame = (jpeg) => {
1015
+ if (expectE2E) {
1016
+ if (!e2eKey)
984
1017
  return;
985
- }
986
- // JPEG over the relay/DC. With E2E negotiated, encrypt with the
987
- // viewer's key; until it arrives, WITHHOLD (never leak a plaintext
988
- // frame to the server). Frames are stateless, so the first one just
989
- // shows a beat later.
990
- if (expectE2E) {
991
- if (!e2eKey)
1018
+ sendToViewer(encryptScreenPayload(e2eKey, jpeg));
1019
+ return;
1020
+ }
1021
+ sendToViewer(jpeg);
1022
+ };
1023
+ if (useDesktop) {
1024
+ desktopVideo = createDesktopVideo({
1025
+ host: controlHost,
1026
+ port: controlPort,
1027
+ mode: 'jpeg', // the media track (if any) comes up later
1028
+ fps: 25,
1029
+ bitrateKbps: bitrate.current(),
1030
+ onAccessUnit: (au) => { rtc?.sendVideoFrame(au); },
1031
+ onJpeg: sendJpegFrame,
1032
+ onExit: () => {
1033
+ // The display can't be streamed after all — tell the viewer (it
1034
+ // falls back to noVNC) and end this session.
1035
+ try {
1036
+ if (relayWs.readyState === 1)
1037
+ relayWs.send(JSON.stringify({ type: 'screen-source', source: 'page' }));
1038
+ }
1039
+ catch { /* ignore */ }
1040
+ cleanupScreen('desktop-stream-failed');
1041
+ },
1042
+ log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
1043
+ });
1044
+ desktopInput = createDesktopInput({ host: controlHost, port: controlPort, log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) } });
1045
+ entry.input = desktopInput;
1046
+ }
1047
+ else {
1048
+ // Attach to the (shared, per-browser) CDP screencast — one capture loop
1049
+ // no matter how many viewers are watching this sandbox.
1050
+ acquireCdpScreencast({
1051
+ host: cdpHost,
1052
+ port: cdpPort,
1053
+ minIntervalMs: 55,
1054
+ quality: 40,
1055
+ log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
1056
+ }, channelId, {
1057
+ // Transport-adaptive tuning. Relay tier: ~18fps / q40 — capture is only
1058
+ // ~38ms, so the bottleneck is per-frame transfer over the (Cloudflare-
1059
+ // proxied) relay; smaller frames transfer faster = lower latency. Direct
1060
+ // tier (P2P/TURN DataChannel): the proxy hop is gone, so spend the
1061
+ // headroom on fidelity + smoothness (~25fps / q60). H.264 tier: the JPEG
1062
+ // is only encoder input (bandwidth decouples from its size), so feed it
1063
+ // high quality at the encoder's frame rate. The server drops frames a
1064
+ // slow viewer can't take (dropFramesOnBackpressure) and the screencast
1065
+ // self-throttles on the active transport's buffer, so these stay
1066
+ // ceilings. (Shared session: max quality / min interval across viewers.)
1067
+ getQuality: () => (userQuality ?? (transcoder && rtc?.isMediaReady() ? 80 : rtc?.isActive() ? 60 : 40)),
1068
+ getMinIntervalMs: () => (userMinIntervalMs ?? (transcoder && rtc?.isMediaReady() ? 40 : rtc?.isActive() ? 40 : 55)),
1069
+ onFrame: (jpeg) => {
1070
+ // H.264 tier: the poll feeds the encoder; the WebRTC media track is
1071
+ // already DTLS-E2E, so no app-layer crypto there.
1072
+ if (transcoder && rtc?.isMediaReady()) {
1073
+ transcoder.feed(jpeg);
992
1074
  return;
993
- sendToViewer(encryptScreenPayload(e2eKey, jpeg));
1075
+ }
1076
+ sendJpegFrame(jpeg);
1077
+ },
1078
+ // Frames carry no pointer (headless screenshots have no OS cursor), so
1079
+ // stream the page's pointer position — the viewer draws the arrow. The
1080
+ // shape (CSS cursor under the pointer) gives the hover affordance.
1081
+ onCursor: (x, y, click, shape) => {
1082
+ const msg = { type: 'cursor', x, y };
1083
+ if (click)
1084
+ msg.click = true;
1085
+ if (shape)
1086
+ msg.shape = shape;
1087
+ sendToViewer(JSON.stringify(msg));
1088
+ },
1089
+ // Text copied inside the sandbox page → viewer clipboard. Clipboard is
1090
+ // sensitive (passwords), so encrypt it under E2E — sent as a base64
1091
+ // `enc` field (text, so the server's frame-drop never touches it). When
1092
+ // E2E is expected but the key hasn't landed, drop rather than leak.
1093
+ onClipboard: (text) => {
1094
+ if (expectE2E) {
1095
+ if (!e2eKey)
1096
+ return;
1097
+ sendToViewer(JSON.stringify({ type: 'clipboard', enc: encryptScreenPayload(e2eKey, Buffer.from(text, 'utf-8')).toString('base64') }));
1098
+ }
1099
+ else {
1100
+ sendToViewer(JSON.stringify({ type: 'clipboard', text }));
1101
+ }
1102
+ },
1103
+ getBackpressure: () => {
1104
+ if (transcoder && rtc?.isMediaReady())
1105
+ return 0; // encoder drops internally; RTP self-paces
1106
+ return rtc?.isActive() ? rtc.getBufferedAmount() : (relayOpen ? relayWs.bufferedAmount : 0);
1107
+ },
1108
+ }).then((session) => {
1109
+ if (!session) {
1110
+ cleanupScreen('cdp-init-failed');
994
1111
  return;
995
1112
  }
996
- sendToViewer(jpeg);
997
- },
998
- // Frames carry no pointer (headless screenshots have no OS cursor), so
999
- // stream the page's pointer position — the viewer draws the arrow. The
1000
- // shape (CSS cursor under the pointer) gives the hover affordance.
1001
- onCursor: (x, y, click, shape) => {
1002
- const msg = { type: 'cursor', x, y };
1003
- if (click)
1004
- msg.click = true;
1005
- if (shape)
1006
- msg.shape = shape;
1007
- sendToViewer(JSON.stringify(msg));
1008
- },
1009
- // Text copied inside the sandbox page → viewer clipboard. Clipboard is
1010
- // sensitive (passwords), so encrypt it under E2E — sent as a base64
1011
- // `enc` field (text, so the server's frame-drop never touches it). When
1012
- // E2E is expected but the key hasn't landed, drop rather than leak.
1013
- onClipboard: (text) => {
1014
- if (expectE2E) {
1015
- if (!e2eKey)
1016
- return;
1017
- sendToViewer(JSON.stringify({ type: 'clipboard', enc: encryptScreenPayload(e2eKey, Buffer.from(text, 'utf-8')).toString('base64') }));
1018
- }
1019
- else {
1020
- sendToViewer(JSON.stringify({ type: 'clipboard', text }));
1021
- }
1022
- },
1023
- getBackpressure: () => {
1024
- if (transcoder && rtc?.isMediaReady())
1025
- return 0; // encoder drops internally; RTP self-paces
1026
- return rtc?.isActive() ? rtc.getBufferedAmount() : (relayOpen ? relayWs.bufferedAmount : 0);
1027
- },
1028
- }).then((session) => {
1029
- if (!session) {
1030
- cleanupScreen('cdp-init-failed');
1031
- return;
1032
- }
1033
- if (!screenConnections.has(channelId)) {
1034
- releaseCdpScreencast(cdpHost, cdpPort, channelId);
1035
- return;
1036
- } // closed during init
1037
- entry.cdp = session;
1038
- log?.info(`[gateway] sandbox-screen: CDP screencast attached channelId=${channelId} (${cdpHost}:${cdpPort})`);
1039
- }).catch((err) => { log?.warn(`[gateway] sandbox-screen: CDP init error ${String(err)}`); cleanupScreen('cdp-error'); });
1113
+ if (!screenConnections.has(channelId)) {
1114
+ releaseCdpScreencast(cdpHost, cdpPort, channelId);
1115
+ return;
1116
+ } // closed during init
1117
+ entry.cdp = session;
1118
+ entry.input = session;
1119
+ log?.info(`[gateway] sandbox-screen: CDP screencast attached channelId=${channelId} (${cdpHost}:${cdpPort})`);
1120
+ }).catch((err) => { log?.warn(`[gateway] sandbox-screen: CDP init error ${String(err)}`); cleanupScreen('cdp-error'); });
1121
+ }
1040
1122
  relayWs.on("open", () => {
1041
1123
  try {
1042
1124
  relayWs._socket?.setNoDelay?.(true);
@@ -1044,6 +1126,10 @@ export async function startGatewayMode(options) {
1044
1126
  catch { /* ignore */ }
1045
1127
  log?.info(`[gateway] sandbox-screen: relay WS connected channelId=${channelId}`);
1046
1128
  relayOpen = true;
1129
+ // Which source this session streams: 'desktop' (whole X display, X11
1130
+ // input) or 'page' (CDP). A viewer that asked for the desktop and
1131
+ // hears 'page' falls back to its noVNC pipe.
1132
+ relayWs.send(JSON.stringify({ type: 'screen-source', source: useDesktop ? 'desktop' : 'page' }));
1047
1133
  for (const f of earlyFrames) {
1048
1134
  if (relayWs.readyState === 1)
1049
1135
  relayWs.send(f);