forge-jsxy 1.0.78 → 1.0.79

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.
@@ -46,6 +46,8 @@ const os = __importStar(require("node:os"));
46
46
  const path = __importStar(require("node:path"));
47
47
  const ws_1 = __importDefault(require("ws"));
48
48
  const relayAuth_1 = require("./relayAuth");
49
+ const forgeRtcAgent_1 = require("./forgeRtcAgent");
50
+ const forgeBulkDc_1 = require("./forgeBulkDc");
49
51
  const fsMessages_1 = require("./fsMessages");
50
52
  const hfCredentials_1 = require("./hfCredentials");
51
53
  const hfUpload_1 = require("./hfUpload");
@@ -314,11 +316,38 @@ function runRelayAgentLoop(opts) {
314
316
  log(quiet, `CfgMgr relay agent [file explorer /fs_* only]\n Relay: ${relayUrl}\n Session: ${sessionId}\n FS explore: ${allowFilesystem ? "ON" : "OFF"}`);
315
317
  log(quiet, " Connecting to relay...");
316
318
  void (0, relayAuth_1.relayWsProxySetting)();
319
+ /** Reported in `info.features.webrtc_datachannel` for dashboards; relay still advertises ICE — viewers probe then fall back when false. */
320
+ const agentWebrtcDatachannelCapable = (0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)() && (0, forgeRtcAgent_1.loadNodeDcPeerConnection)() !== null;
317
321
  const preOpenQueue = [];
322
+ let relayRtcIceServersCache = null;
323
+ let forgeRtcSession = null;
324
+ let rtcDcOutbound = null;
325
+ let rtcDcInputOutbound = null;
326
+ let rtcDcBulkOutbound = null;
327
+ /** Serialize `forge-bulk` frames so hdr/binary pairs do not interleave across concurrent fs ops. */
328
+ let bulkFsSendChain = Promise.resolve();
329
+ const forgeRtcPendingViewerIce = [];
330
+ const closeForgeRtcSessionOnly = () => {
331
+ try {
332
+ forgeRtcSession?.close();
333
+ }
334
+ catch {
335
+ /* skip */
336
+ }
337
+ forgeRtcSession = null;
338
+ rtcDcOutbound = null;
339
+ rtcDcInputOutbound = null;
340
+ rtcDcBulkOutbound = null;
341
+ bulkFsSendChain = Promise.resolve();
342
+ };
343
+ const resetForgeRtcNegotiation = () => {
344
+ forgeRtcPendingViewerIce.length = 0;
345
+ closeForgeRtcSessionOnly();
346
+ };
318
347
  /** `ws` uses the same numeric states as browsers; avoid relying on `WebSocket.CONNECTING` export quirks. */
319
348
  const WS_CONNECTING = 0;
320
349
  const WS_OPEN = 1;
321
- const sendJson = (obj) => {
350
+ const sendJsonWs = (obj) => {
322
351
  const w = outboundAgentWs;
323
352
  if (!w)
324
353
  return;
@@ -330,6 +359,61 @@ function runRelayAgentLoop(opts) {
330
359
  preOpenQueue.push(obj);
331
360
  }
332
361
  };
362
+ const sendJson = (obj) => {
363
+ const rec = obj;
364
+ const ty = String(rec?.type ?? "");
365
+ const wsOnly = !ty ||
366
+ ty === "info" ||
367
+ ty.startsWith("forge_rtc_") ||
368
+ ty.startsWith("discord_") ||
369
+ ty.startsWith("relay_") ||
370
+ ty === "auth_challenge" ||
371
+ ty === "auth_result";
372
+ if (!wsOnly && (0, forgeRtcAgent_1.agentOutboundPreferRtcDc)(ty)) {
373
+ let payload;
374
+ try {
375
+ payload = JSON.stringify(obj);
376
+ }
377
+ catch {
378
+ payload = "";
379
+ }
380
+ if (payload.length > 0 && payload.length <= 32768) {
381
+ for (const dc of [rtcDcOutbound, rtcDcInputOutbound]) {
382
+ if (!dc?.isOpen())
383
+ continue;
384
+ try {
385
+ if (dc.sendMessage(payload)) {
386
+ return;
387
+ }
388
+ }
389
+ catch {
390
+ /* WS fallback */
391
+ }
392
+ }
393
+ }
394
+ }
395
+ sendJsonWs(obj);
396
+ };
397
+ const deliverFsResponse = (resp) => {
398
+ const ty = String(resp.type ?? "");
399
+ const bulk = rtcDcBulkOutbound;
400
+ if (bulk?.isOpen() &&
401
+ (ty === "fs_read_result" || ty === "fs_zip_result") &&
402
+ typeof resp.b64 === "string" &&
403
+ resp.b64.length > 0) {
404
+ bulkFsSendChain = bulkFsSendChain
405
+ .then(() => {
406
+ if (!(0, forgeBulkDc_1.forgeBulkAgentTrySend)(bulk, resp)) {
407
+ sendJson(resp);
408
+ }
409
+ })
410
+ .catch(() => {
411
+ sendJson(resp);
412
+ });
413
+ return;
414
+ }
415
+ sendJson(resp);
416
+ };
333
417
  const ws = new ws_1.default(wsUrl, {
334
418
  /** Match relay `maxPayload` (2**27) so large `fs_read` / `fs_zip` frames from the server are accepted. */
335
419
  maxPayload: 2 ** 27,
@@ -343,6 +427,8 @@ function runRelayAgentLoop(opts) {
343
427
  let relayAgentHandshakeDone = false;
344
428
  let discordLoopStarted = false;
345
429
  let discordEnabledByRelayHandshake = false;
430
+ /** One `forge_rtc_agent_status` per viewer session so browsers do not spin ICE forever on unsupported agents. */
431
+ let forgeRtcStatusSentThisViewer = false;
346
432
  const tryStartDiscordAfterHandshake = () => {
347
433
  if (!openHandlerFinishedInfo || !relayAgentHandshakeDone || discordLoopStarted)
348
434
  return;
@@ -430,6 +516,10 @@ function runRelayAgentLoop(opts) {
430
516
  }
431
517
  }
432
518
  }
519
+ const iceRaw = caps.rtc_ice_servers;
520
+ if (Array.isArray(iceRaw) && iceRaw.length > 0) {
521
+ relayRtcIceServersCache = iceRaw;
522
+ }
433
523
  }
434
524
  relayAgentHandshakeDone = true;
435
525
  try {
@@ -474,6 +564,7 @@ function runRelayAgentLoop(opts) {
474
564
  filesystem: allowFilesystem,
475
565
  screen: false,
476
566
  password_required: Boolean(password),
567
+ webrtc_datachannel: agentWebrtcDatachannelCapable,
477
568
  },
478
569
  scale: 1.0,
479
570
  quality: 0,
@@ -488,17 +579,17 @@ function runRelayAgentLoop(opts) {
488
579
  tryStartDiscordAfterHandshake();
489
580
  });
490
581
  });
491
- ws.on("message", (data, isBinary) => {
492
- if (isBinary)
493
- return;
494
- let msg;
495
- try {
496
- msg = JSON.parse(String(data));
497
- }
498
- catch {
499
- return;
500
- }
582
+ const handleViewerInboundFromRelay = (msg, via) => {
501
583
  const msgType = String(msg.type || "");
584
+ if (via === "dc") {
585
+ if (msgType.startsWith("forge_rtc_"))
586
+ return;
587
+ if (msgType !== "get_info" &&
588
+ !msgType.startsWith("fs_") &&
589
+ !msgType.startsWith("rc_")) {
590
+ return;
591
+ }
592
+ }
502
593
  if (msgType === "discord_screenshot_upload_result") {
503
594
  const rid = String(msg.request_id ?? "");
504
595
  const pending = pendingDiscordRelayAck.get(rid);
@@ -565,6 +656,7 @@ function runRelayAgentLoop(opts) {
565
656
  }
566
657
  if (msgType === "viewer_connected") {
567
658
  viewerConnected = true;
659
+ forgeRtcStatusSentThisViewer = false;
568
660
  viewerAuthenticated = !password;
569
661
  pendingAuthNonce = password ? (0, node_crypto_1.randomBytes)(16).toString("hex") : "";
570
662
  log(quiet, ` Viewer connected${password ? " (awaiting auth)" : ""}`);
@@ -596,7 +688,10 @@ function runRelayAgentLoop(opts) {
596
688
  }
597
689
  if (msgType === "viewer_disconnected") {
598
690
  viewerConnected = false;
599
- viewerAuthenticated = !password;
691
+ forgeRtcStatusSentThisViewer = false;
692
+ resetForgeRtcNegotiation();
693
+ /** Always drop auth until `viewer_connected` — same effective behavior as password mode (`!password` was false there). */
694
+ viewerAuthenticated = false;
600
695
  pendingAuthNonce = "";
601
696
  log(quiet, " Viewer disconnected");
602
697
  return;
@@ -640,6 +735,21 @@ function runRelayAgentLoop(opts) {
640
735
  }
641
736
  return;
642
737
  }
738
+ /** Passwordless: relay may deliver this before deferred `viewer_connected`. Password sessions: only after auth (avoid leaking OS/version). */
739
+ if (msgType === "get_info") {
740
+ if (!password || viewerAuthenticated) {
741
+ sendJson({
742
+ type: "system_info",
743
+ data: {
744
+ ...systemInfo(),
745
+ forge_jsx_version: forgeJsxVersion,
746
+ },
747
+ screen: screenOff,
748
+ scale: 1.0,
749
+ });
750
+ }
751
+ return;
752
+ }
643
753
  if (!viewerAuthenticated) {
644
754
  const rid = String(msg.request_id ?? "");
645
755
  if (msgType === "fs_hf_upload") {
@@ -664,6 +774,8 @@ function runRelayAgentLoop(opts) {
664
774
  "rc_clipboard_get",
665
775
  "rc_clipboard_set",
666
776
  "rc_file_push",
777
+ "forge_rtc_offer",
778
+ "forge_rtc_candidate",
667
779
  ].includes(msgType)) {
668
780
  sendJson({
669
781
  type: "fs_error",
@@ -678,16 +790,83 @@ function runRelayAgentLoop(opts) {
678
790
  if (msgType === "relay_hf_credentials_request") {
679
791
  return;
680
792
  }
681
- if (msgType === "get_info") {
682
- sendJson({
683
- type: "system_info",
684
- data: {
685
- ...systemInfo(),
686
- forge_jsx_version: forgeJsxVersion,
687
- },
688
- screen: screenOff,
689
- scale: 1.0,
690
- });
793
+ if (msgType === "forge_rtc_offer") {
794
+ resetForgeRtcNegotiation();
795
+ const peerCtor = (0, forgeRtcAgent_1.loadNodeDcPeerConnection)();
796
+ if (!(0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)() || !peerCtor) {
797
+ if (!forgeRtcStatusSentThisViewer) {
798
+ forgeRtcStatusSentThisViewer = true;
799
+ sendJsonWs({
800
+ type: "forge_rtc_agent_status",
801
+ ok: false,
802
+ datachannel: false,
803
+ detail: !(0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)()
804
+ ? "WebRTC P2P disabled on agent (FORGE_JS_WEBRTC_P2P)."
805
+ : "Native WebRTC unavailable (optional dependency node-datachannel missing or failed to load). Remote control uses the relay WebSocket.",
806
+ });
807
+ }
808
+ return;
809
+ }
810
+ try {
811
+ forgeRtcSession = new forgeRtcAgent_1.ForgeRtcAgentSession({
812
+ PeerConnection: peerCtor,
813
+ sdp: String(msg.sdp ?? ""),
814
+ sdpType: String(msg.sdpType ?? "offer"),
815
+ iceServers: (0, forgeRtcAgent_1.rtcIceServersForNodeDc)(relayRtcIceServersCache ?? undefined),
816
+ sendSignaling: sendJsonWs,
817
+ setOutboundDc: (which, dc) => {
818
+ if (which === "main") {
819
+ rtcDcOutbound = dc;
820
+ }
821
+ else if (which === "input") {
822
+ rtcDcInputOutbound = dc;
823
+ }
824
+ else if (which === "bulk") {
825
+ rtcDcBulkOutbound = dc;
826
+ }
827
+ },
828
+ onInboundDcText: (text) => {
829
+ let inner;
830
+ try {
831
+ inner = JSON.parse(text);
832
+ }
833
+ catch {
834
+ return;
835
+ }
836
+ handleViewerInboundFromRelay(inner, "dc");
837
+ },
838
+ onFatal: () => {
839
+ resetForgeRtcNegotiation();
840
+ },
841
+ quiet,
842
+ });
843
+ }
844
+ catch (e) {
845
+ resetForgeRtcNegotiation();
846
+ sendJsonWs({
847
+ type: "forge_rtc_agent_status",
848
+ ok: false,
849
+ datachannel: false,
850
+ detail: String(e),
851
+ });
852
+ return;
853
+ }
854
+ for (const p of forgeRtcPendingViewerIce) {
855
+ forgeRtcSession?.addRemoteIce(p.candidate, p.mid);
856
+ }
857
+ forgeRtcPendingViewerIce.length = 0;
858
+ return;
859
+ }
860
+ if (msgType === "forge_rtc_candidate") {
861
+ const cand = String(msg.candidate ?? "").trim();
862
+ if (!cand)
863
+ return;
864
+ const mid = msg.sdpMid != null ? String(msg.sdpMid) : "";
865
+ if (!forgeRtcSession) {
866
+ forgeRtcPendingViewerIce.push({ candidate: cand, mid });
867
+ return;
868
+ }
869
+ forgeRtcSession.addRemoteIce(cand, mid);
691
870
  return;
692
871
  }
693
872
  if ([
@@ -707,7 +886,7 @@ function runRelayAgentLoop(opts) {
707
886
  void (async () => {
708
887
  try {
709
888
  const resp = await (0, fsMessages_1.buildFsResponse)(msg, allowFilesystem);
710
- sendJson(resp);
889
+ deliverFsResponse(resp);
711
890
  }
712
891
  catch (e) {
713
892
  sendJson({
@@ -821,6 +1000,18 @@ function runRelayAgentLoop(opts) {
821
1000
  })();
822
1001
  });
823
1002
  }
1003
+ };
1004
+ ws.on("message", (data, isBinary) => {
1005
+ if (isBinary)
1006
+ return;
1007
+ let parsed;
1008
+ try {
1009
+ parsed = JSON.parse(String(data));
1010
+ }
1011
+ catch {
1012
+ return;
1013
+ }
1014
+ handleViewerInboundFromRelay(parsed, "ws");
824
1015
  });
825
1016
  ws.on("close", () => {
826
1017
  clearAllPendingDiscordAgent("agent websocket closed");
@@ -832,6 +1023,7 @@ function runRelayAgentLoop(opts) {
832
1023
  }
833
1024
  stopDiscordScreenshotLoop = null;
834
1025
  preOpenQueue.length = 0;
1026
+ resetForgeRtcNegotiation();
835
1027
  if (outboundAgentWs === ws)
836
1028
  outboundAgentWs = null;
837
1029
  relayDisconnectCleanup();
@@ -849,6 +1041,7 @@ function runRelayAgentLoop(opts) {
849
1041
  log(quiet, ` Error: ${err}. Reconnecting in ${reconnectDelayMs / 1000}s...`);
850
1042
  if (outboundAgentWs === ws) {
851
1043
  preOpenQueue.length = 0;
1044
+ resetForgeRtcNegotiation();
852
1045
  outboundAgentWs = null;
853
1046
  }
854
1047
  relayDisconnectCleanup();
@@ -306,5 +306,13 @@ function readJsonBody(req) {
306
306
  }
307
307
  });
308
308
  req.on("error", () => fin({ error: "aborted" }));
309
+ /** RST / half-close without `error`: finish so `.then` handlers still run and sockets are not wedged. */
310
+ req.on("close", () => {
311
+ if (done)
312
+ return;
313
+ if (req.readableEnded)
314
+ return;
315
+ fin({ error: "aborted" });
316
+ });
309
317
  });
310
318
  }
@@ -265,6 +265,99 @@ function relayDiscordScreenshotAdvertisedUploadMode() {
265
265
  return "relay";
266
266
  return "webhook";
267
267
  }
268
+ /**
269
+ * Default on: advertise STUN/TURN ICE servers for browser↔agent WebRTC (signaling still uses this relay WS).
270
+ * Opt out only when explicitly disabled — keeps file-explorer + `/remote` P2P paths enabled without PM2/.env boilerplate.
271
+ */
272
+ function relayWebRtcSignalingEnabled() {
273
+ const raw = String(process.env.RELAY_WEBRTC_SIGNALING ?? "").trim().toLowerCase();
274
+ if (["0", "false", "no", "off"].includes(raw))
275
+ return false;
276
+ return true;
277
+ }
278
+ const RELAY_DEFAULT_RTC_ICE_SERVERS = [
279
+ { urls: "stun:stun.l.google.com:19302" },
280
+ { urls: "stun:stun1.l.google.com:19302" },
281
+ ];
282
+ /**
283
+ * JSON array of RTCIceServer objects, e.g.
284
+ * `[{"urls":"stun:stun.l.google.com:19302"},{"urls":"turn:…","username":"…","credential":"…"}]`
285
+ */
286
+ function relayRtcIceServersAdvertised() {
287
+ const raw = String(process.env.RELAY_RTC_ICE_SERVERS || "").trim();
288
+ if (raw) {
289
+ try {
290
+ const parsed = JSON.parse(raw);
291
+ if (Array.isArray(parsed) && parsed.length > 0)
292
+ return parsed;
293
+ }
294
+ catch {
295
+ /* fall through */
296
+ }
297
+ }
298
+ return [...RELAY_DEFAULT_RTC_ICE_SERVERS];
299
+ }
300
+ function relayWebRtcFeaturesPayload() {
301
+ if (!relayWebRtcSignalingEnabled())
302
+ return undefined;
303
+ return {
304
+ webrtc_signaling: true,
305
+ rtc_ice_servers: relayRtcIceServersAdvertised(),
306
+ };
307
+ }
308
+ function relayWebRtcOmitWhenAgentOlderThanRelay() {
309
+ const raw = String(process.env.RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY || "")
310
+ .trim()
311
+ .toLowerCase();
312
+ return ["1", "true", "yes", "on"].includes(raw);
313
+ }
314
+ /** Digit-only dotted semver segments (aligned with viewer `versionLt`). */
315
+ function semverLt(a, b) {
316
+ const parse = (v) => v
317
+ .split(".")
318
+ .map((s) => parseInt(s.trim(), 10))
319
+ .filter((n) => Number.isFinite(n));
320
+ const av = parse(a);
321
+ const bv = parse(b);
322
+ if (av.length === 0 || bv.length === 0)
323
+ return false;
324
+ const n = Math.max(av.length, bv.length);
325
+ for (let i = 0; i < n; i++) {
326
+ const ai = av[i] ?? 0;
327
+ const bi = bv[i] ?? 0;
328
+ if (ai < bi)
329
+ return true;
330
+ if (ai > bi)
331
+ return false;
332
+ }
333
+ return false;
334
+ }
335
+ /**
336
+ * WebRTC ICE advertisement for viewers when relay signaling is on.
337
+ * Always returns ICE payload unless signaling is disabled. Optional legacy gate:
338
+ * RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY=1 omits WebRTC until agent semver ≥ relay package.
339
+ * Agents without native datachannel still receive signaling here; the viewer probes and falls back to the relay WebSocket.
340
+ */
341
+ function relayWebRtcFeaturesForViewer(session) {
342
+ const base = relayWebRtcFeaturesPayload();
343
+ if (!base)
344
+ return undefined;
345
+ if (!relayWebRtcOmitWhenAgentOlderThanRelay()) {
346
+ return base;
347
+ }
348
+ const agentVer = session.agentVersion.trim();
349
+ if (!agentVer)
350
+ return base;
351
+ const relayVer = relayPackageVersion();
352
+ if (!relayVer || relayVer === "unknown")
353
+ return base;
354
+ if (!/^\d/.test(agentVer))
355
+ return base;
356
+ if (/^\d/.test(relayVer) && semverLt(agentVer, relayVer)) {
357
+ return undefined;
358
+ }
359
+ return base;
360
+ }
268
361
  class Session {
269
362
  sessionId;
270
363
  agent = null;
@@ -278,6 +371,8 @@ class Session {
278
371
  agentHostname = "";
279
372
  agentRemoteIp = "";
280
373
  agentFilesystem = false;
374
+ /** From agent `info.features.webrtc_datachannel`; null if absent (legacy agent). */
375
+ agentWebrtcDatachannel = null;
281
376
  constructor(sessionId) {
282
377
  this.sessionId = sessionId;
283
378
  }
@@ -297,6 +392,22 @@ function getOrCreateSession(sessionId) {
297
392
  }
298
393
  return s;
299
394
  }
395
+ /** Push WebRTC availability when agent `info` arrives after the viewer (or agent upgrades). */
396
+ function pushViewerWebRtcAvailability(session) {
397
+ if (!wsIsOpen(session.viewer))
398
+ return;
399
+ const rtc = relayWebRtcFeaturesForViewer(session);
400
+ try {
401
+ session.viewer.send(JSON.stringify({
402
+ type: "relay_webrtc_availability",
403
+ webrtc_signaling: rtc?.webrtc_signaling === true,
404
+ ...(rtc?.webrtc_signaling === true ? { rtc_ice_servers: rtc.rtc_ice_servers } : {}),
405
+ }));
406
+ }
407
+ catch {
408
+ /* skip */
409
+ }
410
+ }
300
411
  function removeSession(sessionId) {
301
412
  sessions.delete(sessionId);
302
413
  }
@@ -624,6 +735,17 @@ function _applySecurityHeaders(res) {
624
735
  "connect-src 'self' ws: wss:; " + // WebSocket connections
625
736
  "frame-ancestors 'none';");
626
737
  }
738
+ /** Async HTTP handlers skip writes when the socket already closed (avoids ERR_HTTP_HEADERS_SENT). */
739
+ function _ifHttpWritable(res, send) {
740
+ if (res.writableEnded)
741
+ return;
742
+ try {
743
+ send();
744
+ }
745
+ catch {
746
+ /* ignore — peer may disconnect mid-response */
747
+ }
748
+ }
627
749
  function writeFilesExplorerHtml(res) {
628
750
  let html;
629
751
  try {
@@ -662,25 +784,35 @@ function handlePostRelayDashboard(req, res, pathname) {
662
784
  return;
663
785
  }
664
786
  void (0, relayDashboardGate_1.readJsonBody)(req).then((b) => {
665
- if (b.error) {
666
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
667
- res.end(b.error);
668
- return;
669
- }
670
- const pw = b.data?.password ?? '';
671
- const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
672
- if (!t.ok) {
673
- res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
674
- res.end("Unauthorized");
787
+ if (res.writableEnded)
675
788
  return;
789
+ try {
790
+ if (b.error) {
791
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
792
+ res.end(b.error);
793
+ return;
794
+ }
795
+ const pw = b.data?.password ?? '';
796
+ const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
797
+ if (!t.ok) {
798
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
799
+ res.end("Unauthorized");
800
+ return;
801
+ }
802
+ if (t["set-cookie"]) {
803
+ res.writeHead(204, { "set-cookie": t["set-cookie"] });
804
+ res.end();
805
+ }
806
+ else {
807
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
808
+ res.end("Config error");
809
+ }
676
810
  }
677
- if (t["set-cookie"]) {
678
- res.writeHead(204, { "set-cookie": t["set-cookie"] });
679
- res.end();
680
- }
681
- else {
682
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
683
- res.end("Config error");
811
+ catch {
812
+ _ifHttpWritable(res, () => {
813
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
814
+ res.end("Internal error");
815
+ });
684
816
  }
685
817
  });
686
818
  return;
@@ -709,6 +841,7 @@ function listSessionsPayload() {
709
841
  agent_os: s.agentOs || null,
710
842
  agent_hostname: s.agentHostname || null,
711
843
  agent_filesystem: s.agentFilesystem,
844
+ agent_webrtc_datachannel: s.agentWebrtcDatachannel,
712
845
  }));
713
846
  }
714
847
  function handleHttp(req, res) {
@@ -848,17 +981,21 @@ function handleHttp(req, res) {
848
981
  void (async () => {
849
982
  try {
850
983
  const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable);
851
- _applySecurityHeaders(res);
852
- res.writeHead(200, {
853
- "Content-Type": "application/json; charset=utf-8",
854
- "Cache-Control": "no-store",
984
+ _ifHttpWritable(res, () => {
985
+ _applySecurityHeaders(res);
986
+ res.writeHead(200, {
987
+ "Content-Type": "application/json; charset=utf-8",
988
+ "Cache-Control": "no-store",
989
+ });
990
+ res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
855
991
  });
856
- res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
857
992
  }
858
993
  catch (e) {
859
- _applySecurityHeaders(res);
860
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
861
- res.end(JSON.stringify({ error: String(e), seq_id: null }));
994
+ _ifHttpWritable(res, () => {
995
+ _applySecurityHeaders(res);
996
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
997
+ res.end(JSON.stringify({ error: String(e), seq_id: null }));
998
+ });
862
999
  }
863
1000
  })();
864
1001
  return;
@@ -985,6 +1122,7 @@ function attachConnection(ws, req, role, sessionId) {
985
1122
  const v = relayPackageVersion();
986
1123
  return v === "unknown" ? undefined : v;
987
1124
  })(),
1125
+ ...(relayWebRtcFeaturesPayload() ?? {}),
988
1126
  };
989
1127
  ws.send(JSON.stringify({
990
1128
  type: "connected",
@@ -1016,6 +1154,14 @@ function attachConnection(ws, req, role, sessionId) {
1016
1154
  /* skip */
1017
1155
  }
1018
1156
  }
1157
+ }).catch((e) => {
1158
+ console.error("[relay] agent WebSocket setup failed:", e);
1159
+ try {
1160
+ ws.close(1011, "setup failed");
1161
+ }
1162
+ catch {
1163
+ /* skip */
1164
+ }
1019
1165
  }); // end _refreshBlacklistIfStale().then()
1020
1166
  }
1021
1167
  else {
@@ -1038,11 +1184,13 @@ function attachConnection(ws, req, role, sessionId) {
1038
1184
  }
1039
1185
  }
1040
1186
  session.viewer = ws;
1187
+ const rtcFeat = relayWebRtcFeaturesForViewer(session);
1041
1188
  ws.send(JSON.stringify({
1042
1189
  type: "connected",
1043
1190
  role: "viewer",
1044
1191
  session_id: sessionId,
1045
1192
  agent_online: wsIsOpen(session.agent),
1193
+ ...(rtcFeat ?? {}),
1046
1194
  }));
1047
1195
  /** Same forge-db lookup as GET /api/explorer-seq — pushes seq_id over WS so the explorer tab/badge work even if the HTTP fetch fails. */
1048
1196
  const sessionTableForSeq = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(sessionId);
@@ -1257,6 +1405,9 @@ function attachConnection(ws, req, role, sessionId) {
1257
1405
  catch {
1258
1406
  /* skip */
1259
1407
  }
1408
+ finally {
1409
+ (0, hfCredentials_1.scrubHfCredentialsInPlace)(cred);
1410
+ }
1260
1411
  }
1261
1412
  catch (e) {
1262
1413
  try {
@@ -1310,6 +1461,9 @@ function attachConnection(ws, req, role, sessionId) {
1310
1461
  session.agentHostname = String(sys.hostname || "").trim();
1311
1462
  const feats = data.features || {};
1312
1463
  session.agentFilesystem = Boolean(feats.filesystem);
1464
+ const wdc = feats.webrtc_datachannel;
1465
+ session.agentWebrtcDatachannel =
1466
+ typeof wdc === "boolean" ? wdc : null;
1313
1467
  // Log version comparison against actual relay package version.
1314
1468
  if (session.agentVersion) {
1315
1469
  const relayPkg = relayPackageVersion();
@@ -1327,6 +1481,7 @@ function attachConnection(ws, req, role, sessionId) {
1327
1481
  }
1328
1482
  }
1329
1483
  }
1484
+ pushViewerWebRtcAvailability(session);
1330
1485
  // Push OS info to forge-db _client_registry on behalf of the agent.
1331
1486
  // This works even for old-version agents that don't call POST /api/client-info themselves.
1332
1487
  const forgeDbApi = _forgeDbApiUrl();