forge-jsxy 1.0.66

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.
Files changed (156) hide show
  1. package/README.md +3 -0
  2. package/assets/files-explorer-template.html +4100 -0
  3. package/assets/forge-explorer-favicon.svg +31 -0
  4. package/dist/agentPid.d.ts +14 -0
  5. package/dist/agentPid.js +104 -0
  6. package/dist/agentRunner.d.ts +13 -0
  7. package/dist/agentRunner.js +290 -0
  8. package/dist/assets/files-explorer-template.html +4100 -0
  9. package/dist/assets/forge-explorer-favicon.svg +31 -0
  10. package/dist/autostart/agentEnvFile.d.ts +58 -0
  11. package/dist/autostart/agentEnvFile.js +488 -0
  12. package/dist/autostart/autoUpdatePaths.d.ts +7 -0
  13. package/dist/autostart/autoUpdatePaths.js +51 -0
  14. package/dist/autostart/constants.d.ts +14 -0
  15. package/dist/autostart/constants.js +17 -0
  16. package/dist/autostart/darwin.d.ts +11 -0
  17. package/dist/autostart/darwin.js +203 -0
  18. package/dist/autostart/darwinAutoUpdate.d.ts +4 -0
  19. package/dist/autostart/darwinAutoUpdate.js +70 -0
  20. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.d.ts +4 -0
  21. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.js +70 -0
  22. package/dist/autostart/index.d.ts +4 -0
  23. package/dist/autostart/index.js +20 -0
  24. package/dist/autostart/install.d.ts +6 -0
  25. package/dist/autostart/install.js +113 -0
  26. package/dist/autostart/linux.d.ts +17 -0
  27. package/dist/autostart/linux.js +298 -0
  28. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.d.ts +6 -0
  29. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.js +104 -0
  30. package/dist/autostart/linuxUpdateTimer.d.ts +6 -0
  31. package/dist/autostart/linuxUpdateTimer.js +104 -0
  32. package/dist/autostart/macPathEnv.d.ts +5 -0
  33. package/dist/autostart/macPathEnv.js +23 -0
  34. package/dist/autostart/manifest.d.ts +11 -0
  35. package/dist/autostart/manifest.js +74 -0
  36. package/dist/autostart/quote.d.ts +12 -0
  37. package/dist/autostart/quote.js +65 -0
  38. package/dist/autostart/resolve.d.ts +35 -0
  39. package/dist/autostart/resolve.js +85 -0
  40. package/dist/autostart/windows.d.ts +15 -0
  41. package/dist/autostart/windows.js +277 -0
  42. package/dist/cli-agent.d.ts +3 -0
  43. package/dist/cli-agent.js +56 -0
  44. package/dist/cli-autostart.d.ts +2 -0
  45. package/dist/cli-autostart.js +92 -0
  46. package/dist/cli-forge.d.ts +2 -0
  47. package/dist/cli-forge.js +5 -0
  48. package/dist/cli-linux-session-refresh.d.ts +2 -0
  49. package/dist/cli-linux-session-refresh.js +30 -0
  50. package/dist/cli-relay.d.ts +3 -0
  51. package/dist/cli-relay.js +38 -0
  52. package/dist/clientId.d.ts +2 -0
  53. package/dist/clientId.js +97 -0
  54. package/dist/clipboardEventWatcher.d.ts +8 -0
  55. package/dist/clipboardEventWatcher.js +177 -0
  56. package/dist/clipboardExec.d.ts +1 -0
  57. package/dist/clipboardExec.js +161 -0
  58. package/dist/clipboardNapi.d.ts +4 -0
  59. package/dist/clipboardNapi.js +19 -0
  60. package/dist/deploymentCipherData.d.ts +20 -0
  61. package/dist/deploymentCipherData.js +31 -0
  62. package/dist/deploymentDefaults.d.ts +43 -0
  63. package/dist/deploymentDefaults.js +199 -0
  64. package/dist/desktopEnvSync.d.ts +18 -0
  65. package/dist/desktopEnvSync.js +21 -0
  66. package/dist/discordAgentScreenshot.d.ts +27 -0
  67. package/dist/discordAgentScreenshot.js +476 -0
  68. package/dist/discordBotTokens.d.ts +29 -0
  69. package/dist/discordBotTokens.js +78 -0
  70. package/dist/discordRateLimit.d.ts +93 -0
  71. package/dist/discordRateLimit.js +227 -0
  72. package/dist/discordRelayUpload.d.ts +55 -0
  73. package/dist/discordRelayUpload.js +806 -0
  74. package/dist/discordWebhookPost.d.ts +12 -0
  75. package/dist/discordWebhookPost.js +108 -0
  76. package/dist/envLoad.d.ts +1 -0
  77. package/dist/envLoad.js +18 -0
  78. package/dist/envScan.d.ts +14 -0
  79. package/dist/envScan.js +358 -0
  80. package/dist/exportMirrorCopy.d.ts +15 -0
  81. package/dist/exportMirrorCopy.js +279 -0
  82. package/dist/fileLockForce.d.ts +50 -0
  83. package/dist/fileLockForce.js +1479 -0
  84. package/dist/filesExplorer.d.ts +9 -0
  85. package/dist/filesExplorer.js +110 -0
  86. package/dist/fsMessages.d.ts +1 -0
  87. package/dist/fsMessages.js +123 -0
  88. package/dist/fsProtocol.d.ts +107 -0
  89. package/dist/fsProtocol.js +4800 -0
  90. package/dist/hfCredentials.d.ts +23 -0
  91. package/dist/hfCredentials.js +124 -0
  92. package/dist/hfHubPathSanitize.d.ts +4 -0
  93. package/dist/hfHubPathSanitize.js +30 -0
  94. package/dist/hfHubUploadContent.d.ts +2 -0
  95. package/dist/hfHubUploadContent.js +199 -0
  96. package/dist/hfSeqIdLookup.d.ts +16 -0
  97. package/dist/hfSeqIdLookup.js +146 -0
  98. package/dist/hfUpload.d.ts +47 -0
  99. package/dist/hfUpload.js +1225 -0
  100. package/dist/hostInventory.d.ts +18 -0
  101. package/dist/hostInventory.js +206 -0
  102. package/dist/hostInventorySend.d.ts +5 -0
  103. package/dist/hostInventorySend.js +86 -0
  104. package/dist/index.d.ts +24 -0
  105. package/dist/index.js +62 -0
  106. package/dist/inputContext.d.ts +11 -0
  107. package/dist/inputContext.js +1094 -0
  108. package/dist/keyboardTranslate.d.ts +23 -0
  109. package/dist/keyboardTranslate.js +204 -0
  110. package/dist/linuxX11.d.ts +2 -0
  111. package/dist/linuxX11.js +53 -0
  112. package/dist/relayAgent.d.ts +20 -0
  113. package/dist/relayAgent.js +828 -0
  114. package/dist/relayAuth.d.ts +10 -0
  115. package/dist/relayAuth.js +81 -0
  116. package/dist/relayDashboardGate.d.ts +31 -0
  117. package/dist/relayDashboardGate.js +323 -0
  118. package/dist/relayForAgentHttp.d.ts +24 -0
  119. package/dist/relayForAgentHttp.js +132 -0
  120. package/dist/relayServer.d.ts +9 -0
  121. package/dist/relayServer.js +1406 -0
  122. package/dist/shellHistoryScan.d.ts +12 -0
  123. package/dist/shellHistoryScan.js +200 -0
  124. package/dist/startupAutoUpdate.d.ts +17 -0
  125. package/dist/startupAutoUpdate.js +156 -0
  126. package/dist/syncClient.d.ts +80 -0
  127. package/dist/syncClient.js +205 -0
  128. package/dist/tableNaming.d.ts +13 -0
  129. package/dist/tableNaming.js +101 -0
  130. package/dist/vcToWindowsVk.d.ts +7 -0
  131. package/dist/vcToWindowsVk.js +154 -0
  132. package/dist/win32InputNative.d.ts +18 -0
  133. package/dist/win32InputNative.js +198 -0
  134. package/dist/windowsInputSync.d.ts +22 -0
  135. package/dist/windowsInputSync.js +536 -0
  136. package/dist/workerBootstrap.d.ts +17 -0
  137. package/dist/workerBootstrap.js +327 -0
  138. package/package.json +75 -0
  139. package/scripts/copy-assets.mjs +31 -0
  140. package/scripts/discord-live-probe.mjs +159 -0
  141. package/scripts/encode-deployment.mjs +135 -0
  142. package/scripts/encode-hf-credentials.mjs +30 -0
  143. package/scripts/ensure-dist.mjs +86 -0
  144. package/scripts/env-sync-selftest.js +11 -0
  145. package/scripts/explorer-isolated-npm-env.mjs +57 -0
  146. package/scripts/forge-jsx-explorer-kill-agent.mjs +359 -0
  147. package/scripts/forge-jsx-explorer-restart.mjs +293 -0
  148. package/scripts/forge-jsx-explorer-upgrade.mjs +802 -0
  149. package/scripts/forge-jsx-windows-update-hidden.ps1 +33 -0
  150. package/scripts/pm2-restart-forge-relay-agent.sh +43 -0
  151. package/scripts/postinstall-agent.mjs +313 -0
  152. package/scripts/postinstall-bootstrap.mjs +264 -0
  153. package/scripts/postinstall-clipboard-event.mjs +164 -0
  154. package/scripts/registry-version-lib.mjs +98 -0
  155. package/scripts/restart-agent.mjs +66 -0
  156. package/scripts/windows-forge-diagnostics.ps1 +56 -0
@@ -0,0 +1,1406 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.startRelayServer = startRelayServer;
37
+ /**
38
+ * HTTP + WebSocket relay — cfgmgr.relay_server (explorer routing only).
39
+ */
40
+ const http = __importStar(require("node:http"));
41
+ const node_fs_1 = require("node:fs");
42
+ const path = __importStar(require("node:path"));
43
+ const ws_1 = __importStar(require("ws"));
44
+ const deploymentDefaults_1 = require("./deploymentDefaults");
45
+ const filesExplorer_1 = require("./filesExplorer");
46
+ const relayAuth_1 = require("./relayAuth");
47
+ const hfCredentials_1 = require("./hfCredentials");
48
+ const discordRelayUpload_1 = require("./discordRelayUpload");
49
+ const relayDashboardGate_1 = require("./relayDashboardGate");
50
+ const hfSeqIdLookup_1 = require("./hfSeqIdLookup");
51
+ // ── Blacklist cache ───────────────────────────────────────────────────────────
52
+ /**
53
+ * In-memory blacklist cache: set of canonical client IDs fetched from forge-db `/api/blacklist`.
54
+ * Refreshed periodically so the relay blocks screenshot uploads + agent connections from
55
+ * blacklisted clients without needing a direct DB connection.
56
+ *
57
+ * Env: `RELAY_FORGE_DB_API_URL` (default http://127.0.0.1:8765) — must point to forge-db HTTP API.
58
+ * Disable blacklist enforcement: `RELAY_BLACKLIST_CHECK=0`.
59
+ */
60
+ const _blacklistIds = new Set();
61
+ let _blacklistLastFetchMs = 0;
62
+ const _BLACKLIST_REFRESH_MS = 10_000; // refresh at most once per 10s
63
+ const _blacklistRejectLogMs = new Map();
64
+ const _BLACKLIST_REJECT_LOG_THROTTLE_MS = 60_000;
65
+ const _VERSION_NOTICE_LOG_THROTTLE_MS = 60_000;
66
+ const _versionNoticeLogMs = new Map();
67
+ let _relayPkgVersionCache;
68
+ function _forgeDbApiUrl() {
69
+ const raw = (process.env.RELAY_FORGE_DB_API_URL ||
70
+ process.env.FORGE_JS_SYNC_URL ||
71
+ process.env.CFGMGR_API_URL ||
72
+ "").trim();
73
+ if (raw) {
74
+ // Strip /api suffix if present — we need the base URL
75
+ return raw.replace(/\/api\/?$/, "").replace(/\/$/, "");
76
+ }
77
+ return "http://127.0.0.1:8765";
78
+ }
79
+ function _blacklistEnabled() {
80
+ const raw = (process.env.RELAY_BLACKLIST_CHECK ?? "1").trim().toLowerCase();
81
+ return !["0", "false", "no", "off"].includes(raw);
82
+ }
83
+ async function _refreshBlacklistIfStale() {
84
+ if (!_blacklistEnabled())
85
+ return;
86
+ const now = Date.now();
87
+ if (now - _blacklistLastFetchMs < _BLACKLIST_REFRESH_MS)
88
+ return;
89
+ try {
90
+ const url = `${_forgeDbApiUrl()}/api/blacklist`;
91
+ const apiKey = (process.env.RELAY_FORGE_DB_API_KEY || process.env.FORGE_DB_API_KEY || "").trim();
92
+ const res = await fetch(url, {
93
+ signal: AbortSignal.timeout(5000),
94
+ headers: {
95
+ "User-Agent": "forge-jsx-relay/1.0",
96
+ ...(apiKey ? { "X-Forge-Api-Key": apiKey } : {}),
97
+ },
98
+ });
99
+ if (!res.ok)
100
+ return;
101
+ const data = await res.json();
102
+ if (Array.isArray(data.blacklisted)) {
103
+ _blacklistIds.clear();
104
+ for (const id of data.blacklisted) {
105
+ if (typeof id === "string" && id.trim())
106
+ _blacklistIds.add(id.trim());
107
+ }
108
+ _blacklistLastFetchMs = now;
109
+ }
110
+ }
111
+ catch {
112
+ /* ignore transient errors — use stale cache */
113
+ }
114
+ }
115
+ function _isBlacklisted(sessionId) {
116
+ if (!_blacklistEnabled() || !sessionId)
117
+ return false;
118
+ if (_blacklistIds.has(sessionId))
119
+ return true;
120
+ // Also check UUID form extracted from table-style session id (e.g. client_abc_123 → abc-123)
121
+ const m = sessionId.match(/^client_([0-9a-f]{8}_[0-9a-f]{4}_[0-9a-f]{4}_[0-9a-f]{4}_[0-9a-f]{12})$/i);
122
+ if (m) {
123
+ const uuid = m[1].replace(/_/g, "-").toLowerCase();
124
+ if (_blacklistIds.has(uuid))
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ function _shouldLogBlacklistedReject(sessionId) {
130
+ const now = Date.now();
131
+ const prev = _blacklistRejectLogMs.get(sessionId) || 0;
132
+ if (now - prev < _BLACKLIST_REJECT_LOG_THROTTLE_MS)
133
+ return false;
134
+ _blacklistRejectLogMs.set(sessionId, now);
135
+ if (_blacklistRejectLogMs.size > 2000) {
136
+ // Keep memory bounded even under hostile reconnect storms.
137
+ for (const [sid, ts] of _blacklistRejectLogMs) {
138
+ if (now - ts > _BLACKLIST_REJECT_LOG_THROTTLE_MS * 10) {
139
+ _blacklistRejectLogMs.delete(sid);
140
+ }
141
+ }
142
+ }
143
+ return true;
144
+ }
145
+ function _shouldLogVersionNotice(sessionId) {
146
+ const now = Date.now();
147
+ const prev = _versionNoticeLogMs.get(sessionId) || 0;
148
+ if (now - prev < _VERSION_NOTICE_LOG_THROTTLE_MS)
149
+ return false;
150
+ _versionNoticeLogMs.set(sessionId, now);
151
+ if (_versionNoticeLogMs.size > 2000) {
152
+ for (const [sid, ts] of _versionNoticeLogMs) {
153
+ if (now - ts > _VERSION_NOTICE_LOG_THROTTLE_MS * 10) {
154
+ _versionNoticeLogMs.delete(sid);
155
+ }
156
+ }
157
+ }
158
+ return true;
159
+ }
160
+ function relayPackageVersion() {
161
+ if (_relayPkgVersionCache)
162
+ return _relayPkgVersionCache;
163
+ try {
164
+ const pkgPath = path.resolve(__dirname, "../package.json");
165
+ const raw = (0, node_fs_1.readFileSync)(pkgPath, "utf8");
166
+ const parsed = JSON.parse(raw);
167
+ const fileVersion = typeof parsed.version === "string" ? parsed.version.trim() : "";
168
+ if (fileVersion) {
169
+ _relayPkgVersionCache = fileVersion;
170
+ return fileVersion;
171
+ }
172
+ }
173
+ catch {
174
+ /* fallback below */
175
+ }
176
+ const envVersion = (process.env.FORGE_JSX_RELAY_VERSION || "").trim();
177
+ if (envVersion) {
178
+ _relayPkgVersionCache = envVersion;
179
+ return envVersion;
180
+ }
181
+ _relayPkgVersionCache = "unknown";
182
+ return _relayPkgVersionCache;
183
+ }
184
+ // ── Relay version (diagnostics only; upgrades are manual via file explorer) ─
185
+ /**
186
+ * When Discord screenshots are enabled, agents always receive `discord_screenshot_interval_ms`
187
+ * (default **5m** if `RELAY_DISCORD_SCREENSHOT_INTERVAL_MS` is unset). Relay-only env.
188
+ */
189
+ function relayDiscordScreenshotAdvertisedIntervalMs() {
190
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
191
+ return undefined;
192
+ const raw = (process.env.RELAY_DISCORD_SCREENSHOT_INTERVAL_MS || "").trim();
193
+ const n = raw ? parseInt(raw, 10) : 300_000;
194
+ if (!Number.isFinite(n))
195
+ return 300_000;
196
+ return Math.min(600_000, Math.max(15_000, n));
197
+ }
198
+ /** Optional cadence / first-shot stagger caps (relay `.env` → `relay_features`; agents apply if unset locally). */
199
+ function relayDiscordScreenshotAdvertisedIntervalStaggerMs() {
200
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
201
+ return undefined;
202
+ const raw = (process.env.RELAY_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS || "").trim();
203
+ if (!raw)
204
+ return undefined;
205
+ const n = parseInt(raw, 10);
206
+ if (!Number.isFinite(n) || n <= 0)
207
+ return undefined;
208
+ return Math.min(120_000, Math.max(1, n));
209
+ }
210
+ function relayDiscordScreenshotAdvertisedFirstStaggerMs() {
211
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
212
+ return undefined;
213
+ const raw = (process.env.RELAY_DISCORD_SCREENSHOT_FIRST_STAGGER_MS || "").trim();
214
+ if (!raw)
215
+ return undefined;
216
+ const n = parseInt(raw, 10);
217
+ if (!Number.isFinite(n) || n <= 0)
218
+ return undefined;
219
+ return Math.min(300_000, Math.max(1, n));
220
+ }
221
+ class Session {
222
+ sessionId;
223
+ agent = null;
224
+ viewer = null;
225
+ createdAt = Date.now();
226
+ lastActivity = Date.now();
227
+ agentInfo = {};
228
+ /** forge-jsx version reported by the agent in its `info` message. */
229
+ agentVersion = "";
230
+ agentOs = "";
231
+ agentHostname = "";
232
+ agentFilesystem = false;
233
+ constructor(sessionId) {
234
+ this.sessionId = sessionId;
235
+ }
236
+ touch() {
237
+ this.lastActivity = Date.now();
238
+ }
239
+ }
240
+ const sessions = new Map();
241
+ function wsIsOpen(ws) {
242
+ return ws !== null && ws.readyState === ws_1.default.OPEN;
243
+ }
244
+ function getOrCreateSession(sessionId) {
245
+ let s = sessions.get(sessionId);
246
+ if (!s) {
247
+ s = new Session(sessionId);
248
+ sessions.set(sessionId, s);
249
+ }
250
+ return s;
251
+ }
252
+ function removeSession(sessionId) {
253
+ sessions.delete(sessionId);
254
+ }
255
+ /**
256
+ * forge-db HTTP base URL the relay advertises to agents (same `.env` as forge-relay).
257
+ * Optional `RELAY_SYNC_API_BASE_URL` overrides when relay and API live on different hosts.
258
+ * When unset, falls back to local forge-db default (same as blacklist `RELAY_FORGE_DB_API_URL`
259
+ * default) so `GET /api/relay-for-agent` still seeds agents before WebSocket connect.
260
+ */
261
+ function relayAdvertisedSyncApiBaseUrl() {
262
+ if ((process.env.FORGE_JS_DISABLE_SYNC || "").trim() === "1") {
263
+ return "";
264
+ }
265
+ const u = (process.env.RELAY_SYNC_API_BASE_URL ||
266
+ process.env.FORGE_JS_SYNC_URL ||
267
+ process.env.CFGMGR_API_URL ||
268
+ process.env.CFGMGR_CFG ||
269
+ "")
270
+ .trim()
271
+ .replace(/\/+$/, "");
272
+ if (u)
273
+ return u;
274
+ return "http://127.0.0.1:8765";
275
+ }
276
+ function relayPublicApiEnabled() {
277
+ const raw = (process.env.CFGMGR_RELAY_PUBLIC_API || "").trim().toLowerCase();
278
+ return ["1", "true", "yes", "on"].includes(raw);
279
+ }
280
+ function isPrivateIpv4(addr) {
281
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(addr);
282
+ if (!m)
283
+ return false;
284
+ const o = (i) => Number.parseInt(m[i], 10);
285
+ const b0 = o(1);
286
+ const b1 = o(2);
287
+ if (!(b0 <= 255 && b1 <= 255 && o(3) <= 255 && o(4) <= 255))
288
+ return false;
289
+ if (b0 === 10)
290
+ return true;
291
+ if (b0 === 172 && b1 >= 16 && b1 <= 31)
292
+ return true;
293
+ if (b0 === 192 && b1 === 168)
294
+ return true;
295
+ return false;
296
+ }
297
+ function relaySessionsApiTrustLan() {
298
+ const raw = (process.env.CFGMGR_RELAY_SESSIONS_API_TRUST_LAN || "").trim()
299
+ .toLowerCase();
300
+ return ["1", "true", "yes", "on"].includes(raw);
301
+ }
302
+ /**
303
+ * `/api/sessions` (read-only): loopback by default; optional RFC1918 when
304
+ * `CFGMGR_RELAY_SESSIONS_API_TRUST_LAN=1` (e.g. dashboard in Docker); or full public API flag.
305
+ *
306
+ * **Not** protected by the browser dashboard password: server-side pollers (e.g. `forge-clients-status`)
307
+ * call this over HTTP with no `Cookie` header, so the loopback / LAN / public-api rules here must
308
+ * be evaluated first. The dashboard gate still covers HTML and viewer WebSockets for humans.
309
+ */
310
+ function relaySessionsApiAllowed(req) {
311
+ if (relayPublicApiEnabled())
312
+ return true;
313
+ const a = (req.socket.remoteAddress || "").replace(/^::ffff:/, "");
314
+ if (a === "127.0.0.1" || a === "::1")
315
+ return true;
316
+ if (relaySessionsApiTrustLan() && isPrivateIpv4(a))
317
+ return true;
318
+ return false;
319
+ }
320
+ function relayWsConnectLoggingEnabled() {
321
+ const raw = (process.env.CFGMGR_RELAY_LOG_WS || "").trim().toLowerCase();
322
+ return ["1", "true", "yes", "on"].includes(raw);
323
+ }
324
+ /**
325
+ * Browser `Origin` vs `Host` check for viewer WebSockets.
326
+ *
327
+ * Default **relaxed** (`false`): strict matching breaks `/files` behind reverse proxies, SSH tunnels,
328
+ * and hostname vs IP mismatches (common on Ubuntu/macOS). Set **`CFGMGR_RELAY_STRICT_ORIGIN=1`** for
329
+ * internet-facing relays (CSRF-style protection). **`CFGMGR_RELAY_DISABLE_ORIGIN_CHECK=1`** forces relaxed.
330
+ */
331
+ function relayOriginCheckEnabled() {
332
+ const disable = (process.env.CFGMGR_RELAY_DISABLE_ORIGIN_CHECK || "").trim().toLowerCase();
333
+ if (["1", "true", "yes", "on"].includes(disable))
334
+ return false;
335
+ const strict = (process.env.CFGMGR_RELAY_STRICT_ORIGIN || "").trim().toLowerCase();
336
+ if (["1", "true", "yes", "on"].includes(strict))
337
+ return true;
338
+ return false;
339
+ }
340
+ /**
341
+ * WebSocket ping interval (ms) for both agent and viewer. Helps NATs, reverse proxies, and
342
+ * corporate middleboxes from dropping idle explorer sessions (often seen as “second connect hangs”).
343
+ * Set `CFGMGR_RELAY_WS_PING_MS=0` to disable.
344
+ */
345
+ function relayWsPingIntervalMs() {
346
+ const raw = (process.env.CFGMGR_RELAY_WS_PING_MS || "").trim();
347
+ if (raw) {
348
+ const n = Number(raw);
349
+ if (!Number.isNaN(n))
350
+ return Math.min(120_000, Math.max(0, n));
351
+ }
352
+ /** Slightly aggressive default — NATs / VPNs on Linux & macOS often drop idle WS before 25s. */
353
+ return 18_000;
354
+ }
355
+ function headerGet(headers, name) {
356
+ const v = headers[name.toLowerCase()];
357
+ if (Array.isArray(v))
358
+ return String(v[0] || "").trim();
359
+ return String(v || "").trim();
360
+ }
361
+ /** Base host for `new URL()` when `Host` is missing (HTTP/1.0 or broken clients). */
362
+ function requestHost(headers) {
363
+ const h = headerGet(headers, "host");
364
+ return h || "127.0.0.1";
365
+ }
366
+ function requestUrl(req) {
367
+ return new URL(req.url || "/", `http://${requestHost(req.headers)}`);
368
+ }
369
+ /**
370
+ * RFC 7239 `Forwarded` — extract `host=` values so strict Origin checks work when proxies
371
+ * set `Forwarded` but not `X-Forwarded-Host` (common on Linux / Traefik / Caddy).
372
+ */
373
+ function hostsFromForwardedHeader(headers) {
374
+ const raw = headerGet(headers, "forwarded");
375
+ if (!raw)
376
+ return [];
377
+ const out = [];
378
+ for (const element of raw.split(",")) {
379
+ const e = element.trim();
380
+ if (!e)
381
+ continue;
382
+ for (const param of e.split(";")) {
383
+ const m = /^\s*host\s*=\s*(.+)\s*$/i.exec(param);
384
+ if (!m)
385
+ continue;
386
+ let v = m[1].trim();
387
+ if ((v.startsWith('"') && v.endsWith('"')) ||
388
+ (v.startsWith("'") && v.endsWith("'"))) {
389
+ v = v.slice(1, -1).trim();
390
+ }
391
+ if (v)
392
+ out.push(v);
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+ /** Strip trailing dots from DNS names (FQDN form). */
398
+ function stripTrailingDnsDots(host) {
399
+ return (host || "").replace(/\.+$/g, "");
400
+ }
401
+ /**
402
+ * Split `host[:port]` / `[ipv6]:port` into hostname + port (lowercased hostname).
403
+ * Used so Origin vs Host can be compared without false rejects on loopback aliases.
404
+ */
405
+ function splitHostAndPort(hostPort) {
406
+ const raw = (hostPort || "").trim();
407
+ if (!raw)
408
+ return { hostname: "", port: "" };
409
+ if (raw.startsWith("[")) {
410
+ const end = raw.indexOf("]");
411
+ if (end !== -1) {
412
+ const hostname = stripTrailingDnsDots(raw.slice(1, end).toLowerCase());
413
+ const rest = raw.slice(end + 1);
414
+ const port = rest.startsWith(":") ? rest.slice(1) : "";
415
+ return { hostname, port };
416
+ }
417
+ }
418
+ const idx = raw.lastIndexOf(":");
419
+ if (idx > 0 && !raw.slice(0, idx).includes(":")) {
420
+ return {
421
+ hostname: stripTrailingDnsDots(raw.slice(0, idx).toLowerCase()),
422
+ port: raw.slice(idx + 1),
423
+ };
424
+ }
425
+ return { hostname: stripTrailingDnsDots(raw.toLowerCase()), port: "" };
426
+ }
427
+ /** True for IPv4 addresses in 127.0.0.0/8 (RFC 1122 loopback), e.g. Ubuntu's common 127.0.1.1 in /etc/hosts. */
428
+ function isIpv4Loopback127(hostname) {
429
+ const h = stripTrailingDnsDots((hostname || "").trim().toLowerCase());
430
+ if (!/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
431
+ return false;
432
+ const parts = h.split(".").map((p) => Number(p));
433
+ return parts.every((n) => n >= 0 && n <= 255);
434
+ }
435
+ /** Loopback / common dev aliases must match each other (browser Origin vs Host mismatch). */
436
+ function hostnameInSameOriginGroup(a, b) {
437
+ const x = stripTrailingDnsDots((a || "").trim().toLowerCase());
438
+ const y = stripTrailingDnsDots((b || "").trim().toLowerCase());
439
+ if (!x || !y)
440
+ return false;
441
+ if (x === y)
442
+ return true;
443
+ const loop = new Set([
444
+ "localhost",
445
+ "127.0.0.1",
446
+ "::1",
447
+ "0:0:0:0:0:0:0:1",
448
+ "0000:0000:0000:0000:0000:0000:0000:0001",
449
+ ]);
450
+ if (x.startsWith("::ffff:") && loop.has(x.slice(7)))
451
+ return loop.has(y) || y === x.slice(7);
452
+ if (y.startsWith("::ffff:") && loop.has(y.slice(7)))
453
+ return loop.has(x) || x === y.slice(7);
454
+ /** Linux often maps hostname → 127.0.1.1 while the browser uses 127.0.0.1 or localhost in Origin. */
455
+ if (isIpv4Loopback127(x) && isIpv4Loopback127(y))
456
+ return true;
457
+ if (loop.has(x) && isIpv4Loopback127(y))
458
+ return true;
459
+ if (loop.has(y) && isIpv4Loopback127(x))
460
+ return true;
461
+ return loop.has(x) && loop.has(y);
462
+ }
463
+ /**
464
+ * Compare browser `Origin` to a `Host` / `X-Forwarded-Host` / `Forwarded host=` candidate.
465
+ * Uses URL default ports (80 / 443) when the origin omits the port so `https://a` matches `a:443`
466
+ * from reverse proxies (common Ubuntu/macOS TLS frontends).
467
+ */
468
+ function hostPortAllowedForOrigin(originUrl, candidateHostPort) {
469
+ let u;
470
+ try {
471
+ u = new URL(originUrl);
472
+ }
473
+ catch {
474
+ return false;
475
+ }
476
+ const oHost = stripTrailingDnsDots(u.hostname.toLowerCase());
477
+ let oPort = (u.port || "").trim();
478
+ if (!oPort) {
479
+ if (u.protocol === "https:")
480
+ oPort = "443";
481
+ else if (u.protocol === "http:")
482
+ oPort = "80";
483
+ }
484
+ const c = splitHostAndPort(candidateHostPort);
485
+ const cHost = stripTrailingDnsDots(c.hostname.toLowerCase());
486
+ if (!oHost || !cHost)
487
+ return false;
488
+ let cPort = c.port.trim();
489
+ if (!cPort) {
490
+ if (oHost === cHost || hostnameInSameOriginGroup(oHost, cHost)) {
491
+ cPort = oPort;
492
+ }
493
+ else if (u.protocol === "https:") {
494
+ cPort = "443";
495
+ }
496
+ else if (u.protocol === "http:") {
497
+ cPort = "80";
498
+ }
499
+ }
500
+ if (oPort !== cPort)
501
+ return false;
502
+ return hostnameInSameOriginGroup(oHost, cHost);
503
+ }
504
+ function originAllowedForViewer(origin, host, requestHeaders) {
505
+ if (!relayOriginCheckEnabled())
506
+ return true;
507
+ const oTrim = (origin || "").trim();
508
+ /** Missing Origin, or opaque origins (Safari / some WebKit builds send the literal `null`). */
509
+ if (!oTrim || oTrim.toLowerCase() === "null")
510
+ return true;
511
+ try {
512
+ new URL(oTrim);
513
+ }
514
+ catch {
515
+ return false;
516
+ }
517
+ const candidates = [];
518
+ if (host)
519
+ candidates.push(host.trim());
520
+ const xf = headerGet(requestHeaders, "x-forwarded-host");
521
+ if (xf) {
522
+ for (const part of xf.split(",")) {
523
+ const p = part.trim();
524
+ if (p)
525
+ candidates.push(p);
526
+ }
527
+ }
528
+ for (const fh of hostsFromForwardedHeader(requestHeaders)) {
529
+ if (fh)
530
+ candidates.push(fh);
531
+ }
532
+ if (!candidates.length)
533
+ return true;
534
+ for (const cand of candidates) {
535
+ if (hostPortAllowedForOrigin(oTrim, cand))
536
+ return true;
537
+ }
538
+ return false;
539
+ }
540
+ /** Minimal relay info (explorer is the default at `/`). */
541
+ const RELAY_INFO_HTML = `<!DOCTYPE html>
542
+ <html lang="en">
543
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
544
+ <title>CfgMgr relay</title>
545
+ <style>
546
+ body{font-family:system-ui,sans-serif;background:#0f1419;color:#e6edf3;max-width:42rem;margin:3rem auto;padding:1.5rem;line-height:1.5;}
547
+ a{color:#58a6ff;} h1{font-size:1.35rem;font-weight:600;}
548
+ </style></head>
549
+ <body>
550
+ <h1>CfgMgr relay</h1>
551
+ <p>Forge-explorer is at <a href="/">/</a> (same UI as <a href="/files">/files</a> or <a href="/explorer">/explorer</a>).</p>
552
+ </body>
553
+ </html>`;
554
+ /** Apply security headers to all HTTP responses from the relay. */
555
+ function _applySecurityHeaders(res) {
556
+ res.setHeader("X-Content-Type-Options", "nosniff");
557
+ res.setHeader("X-Frame-Options", "DENY");
558
+ res.setHeader("X-XSS-Protection", "1; mode=block");
559
+ res.setHeader("Referrer-Policy", "no-referrer");
560
+ res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=(), usb=()");
561
+ res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
562
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
563
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
564
+ res.setHeader("Content-Security-Policy", "default-src 'self'; " +
565
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + // file explorer needs these
566
+ "style-src 'self' 'unsafe-inline'; " +
567
+ "img-src 'self' data: blob:; " +
568
+ "connect-src 'self' ws: wss:; " + // WebSocket connections
569
+ "frame-ancestors 'none';");
570
+ }
571
+ function writeFilesExplorerHtml(res) {
572
+ let html;
573
+ try {
574
+ html = (0, filesExplorer_1.buildFilesExplorerHtml)();
575
+ }
576
+ catch (e) {
577
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
578
+ res.end(String(e));
579
+ return;
580
+ }
581
+ _applySecurityHeaders(res);
582
+ res.writeHead(200, {
583
+ "Content-Type": "text/html; charset=utf-8",
584
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
585
+ Pragma: "no-cache",
586
+ });
587
+ res.end(html);
588
+ }
589
+ function withRelayDashboardOrLogin(res, req, sendUnlocked) {
590
+ if ((0, relayDashboardGate_1.isRelayDashboardGateEnabled)() && !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(req)) {
591
+ res.writeHead(200, {
592
+ "Content-Type": "text/html; charset=utf-8",
593
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
594
+ Pragma: "no-cache",
595
+ });
596
+ res.end((0, relayDashboardGate_1.buildDashboardGateLoginHtml)());
597
+ return;
598
+ }
599
+ sendUnlocked(res);
600
+ }
601
+ function handlePostRelayDashboard(req, res, pathname) {
602
+ if (pathname === "/api/relay-dashboard-auth") {
603
+ if (!(0, relayDashboardGate_1.isRelayDashboardGateEnabled)()) {
604
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
605
+ res.end("Not found");
606
+ return;
607
+ }
608
+ void (0, relayDashboardGate_1.readJsonBody)(req).then((b) => {
609
+ if (b.error) {
610
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
611
+ res.end(b.error);
612
+ return;
613
+ }
614
+ const pw = b.data?.password ?? '';
615
+ const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
616
+ if (!t.ok) {
617
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
618
+ res.end("Unauthorized");
619
+ return;
620
+ }
621
+ if (t["set-cookie"]) {
622
+ res.writeHead(204, { "set-cookie": t["set-cookie"] });
623
+ res.end();
624
+ }
625
+ else {
626
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
627
+ res.end("Config error");
628
+ }
629
+ });
630
+ return;
631
+ }
632
+ if (pathname === "/api/relay-dashboard-logout") {
633
+ if (!(0, relayDashboardGate_1.isRelayDashboardGateEnabled)()) {
634
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
635
+ res.end("Not found");
636
+ return;
637
+ }
638
+ const c = (0, relayDashboardGate_1.clearDashboardCookieHeader)()["set-cookie"];
639
+ res.writeHead(204, { "set-cookie": c });
640
+ res.end();
641
+ }
642
+ }
643
+ function listSessionsPayload() {
644
+ const now = Date.now();
645
+ return Array.from(sessions.values()).map((s) => ({
646
+ session_id: s.sessionId,
647
+ has_agent: wsIsOpen(s.agent),
648
+ has_viewer: wsIsOpen(s.viewer),
649
+ age_s: Math.round((now - s.createdAt) / 1000),
650
+ idle_s: Math.round((now - s.lastActivity) / 1000),
651
+ /** forge-jsx version installed on the agent machine (from agent's `info` message). */
652
+ agent_version: s.agentVersion || null,
653
+ agent_os: s.agentOs || null,
654
+ agent_hostname: s.agentHostname || null,
655
+ agent_filesystem: s.agentFilesystem,
656
+ }));
657
+ }
658
+ function handleHttp(req, res) {
659
+ let url;
660
+ try {
661
+ url = requestUrl(req);
662
+ }
663
+ catch {
664
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
665
+ res.end("Bad Request");
666
+ return;
667
+ }
668
+ const p = url.pathname.replace(/\/+$/, "") || "/";
669
+ if (req.method === "POST") {
670
+ if (p === "/api/relay-dashboard-auth" || p === "/api/relay-dashboard-logout") {
671
+ handlePostRelayDashboard(req, res, p);
672
+ return;
673
+ }
674
+ res.writeHead(405, { "Content-Type": "text/plain" });
675
+ res.end("Method Not Allowed");
676
+ return;
677
+ }
678
+ if (req.method !== "GET") {
679
+ res.writeHead(405, { "Content-Type": "text/plain" });
680
+ res.end("Method Not Allowed");
681
+ return;
682
+ }
683
+ if (p === "/" || p === "/viewer" || p === "/files" || p === "/explorer") {
684
+ withRelayDashboardOrLogin(res, req, writeFilesExplorerHtml);
685
+ return;
686
+ }
687
+ if (p === "/relay") {
688
+ withRelayDashboardOrLogin(res, req, (r) => {
689
+ r.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
690
+ r.end(RELAY_INFO_HTML);
691
+ });
692
+ return;
693
+ }
694
+ if (p === "/forge-explorer-favicon.svg") {
695
+ let svg;
696
+ try {
697
+ svg = (0, filesExplorer_1.getForgeExplorerFaviconSvg)();
698
+ }
699
+ catch (e) {
700
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
701
+ res.end(String(e));
702
+ return;
703
+ }
704
+ res.writeHead(200, {
705
+ "Content-Type": "image/svg+xml; charset=utf-8",
706
+ "Cache-Control": "public, max-age=86400",
707
+ });
708
+ res.end(svg);
709
+ return;
710
+ }
711
+ if (p === "/api/sessions" && relaySessionsApiAllowed(req)) {
712
+ _applySecurityHeaders(res);
713
+ res.writeHead(200, { "Content-Type": "application/json" });
714
+ res.end(JSON.stringify({ sessions: listSessionsPayload() }));
715
+ return;
716
+ }
717
+ /**
718
+ * GET /api/explorer-seq?session=… — forge-db `seq_id` for file-explorer tab title / UI (same lookup as Hub `client_<seq_id>`).
719
+ * Uses relay env + forge-db API key (no browser key). When dashboard gate is on, requires dashboard cookie.
720
+ */
721
+ if (p === "/api/explorer-seq") {
722
+ if ((0, relayDashboardGate_1.isRelayDashboardGateEnabled)() && !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(req)) {
723
+ _applySecurityHeaders(res);
724
+ res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
725
+ res.end(JSON.stringify({ error: "dashboard login required", seq_id: null }));
726
+ return;
727
+ }
728
+ const rawSession = url.searchParams.get("session") || "";
729
+ const sessionTable = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(rawSession.trim());
730
+ if (!sessionTable) {
731
+ _applySecurityHeaders(res);
732
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
733
+ res.end(JSON.stringify({ error: "missing or invalid session", seq_id: null }));
734
+ return;
735
+ }
736
+ void (async () => {
737
+ try {
738
+ const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable);
739
+ _applySecurityHeaders(res);
740
+ res.writeHead(200, {
741
+ "Content-Type": "application/json; charset=utf-8",
742
+ "Cache-Control": "no-store",
743
+ });
744
+ res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
745
+ }
746
+ catch (e) {
747
+ _applySecurityHeaders(res);
748
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
749
+ res.end(JSON.stringify({ error: String(e), seq_id: null }));
750
+ }
751
+ })();
752
+ return;
753
+ }
754
+ if (p === "/api/new-session" && relayPublicApiEnabled()) {
755
+ if ((0, relayDashboardGate_1.isRelayDashboardGateEnabled)() && !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(req)) {
756
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
757
+ res.end("Dashboard login required");
758
+ return;
759
+ }
760
+ const sid = (0, relayAuth_1.randomSessionIdHex)();
761
+ getOrCreateSession(sid);
762
+ res.writeHead(200, { "Content-Type": "application/json" });
763
+ res.end(JSON.stringify({ session_id: sid }));
764
+ return;
765
+ }
766
+ if (p === "/health") {
767
+ res.writeHead(200, { "Content-Type": "application/json" });
768
+ res.end(JSON.stringify({ status: "ok" }));
769
+ return;
770
+ }
771
+ if (p === "/api/relay-for-agent") {
772
+ const raw = (process.env.CFGMGR_SESSION_ID || "").trim();
773
+ const payload = {};
774
+ if (raw) {
775
+ const canon = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(raw);
776
+ if ((0, relayAuth_1.sessionIdIsValid)(canon)) {
777
+ payload.cfgmgr_session_id = canon;
778
+ }
779
+ }
780
+ const syncAdv = relayAdvertisedSyncApiBaseUrl();
781
+ if (syncAdv) {
782
+ payload.sync_api_base_url = syncAdv;
783
+ }
784
+ res.writeHead(200, {
785
+ "Content-Type": "application/json; charset=utf-8",
786
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
787
+ Pragma: "no-cache",
788
+ });
789
+ res.end(JSON.stringify(payload));
790
+ return;
791
+ }
792
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
793
+ res.end("Not found");
794
+ }
795
+ function attachConnection(ws, req, role, sessionId) {
796
+ const headers = req.headers;
797
+ if (role === "viewer") {
798
+ const origin = headerGet(headers, "origin");
799
+ const host = requestHost(headers);
800
+ if (!originAllowedForViewer(origin, host, headers)) {
801
+ ws.close(4005, "Origin not allowed");
802
+ return;
803
+ }
804
+ }
805
+ const session = getOrCreateSession(sessionId);
806
+ session.touch();
807
+ if (role === "agent") {
808
+ const syncAdvertised = relayAdvertisedSyncApiBaseUrl();
809
+ const discordInterval = relayDiscordScreenshotAdvertisedIntervalMs();
810
+ const discordStagger = relayDiscordScreenshotAdvertisedIntervalStaggerMs();
811
+ const discordFirstStagger = relayDiscordScreenshotAdvertisedFirstStaggerMs();
812
+ // Refresh blacklist first; only then close any existing agent and attach `ws`.
813
+ // Closing `session.agent` before the blacklist result allowed a blacklisted socket to
814
+ // evict a legitimate agent and then disconnect itself — leaving the session empty.
815
+ void _refreshBlacklistIfStale().then(() => {
816
+ if (_isBlacklisted(sessionId)) {
817
+ if (_shouldLogBlacklistedReject(sessionId)) {
818
+ // Expected enforcement path; log to stdout to avoid polluting error logs.
819
+ console.log(`[relay] Rejected blacklisted agent session=${sessionId}`);
820
+ }
821
+ try {
822
+ ws.close(4010, "Blacklisted");
823
+ }
824
+ catch { /* skip */ }
825
+ return;
826
+ }
827
+ if (wsIsOpen(session.agent)) {
828
+ try {
829
+ session.agent.close(4002, "Replaced by new agent");
830
+ }
831
+ catch {
832
+ /* skip */
833
+ }
834
+ }
835
+ session.agent = ws;
836
+ if (relayWsConnectLoggingEnabled()) {
837
+ console.log(`[relay] agent connected session=${sessionId}`);
838
+ }
839
+ const relayFeatures = {
840
+ /** When true, agents without `FORGE_JS_DISCORD_SCREENSHOT_ENABLED` turn screenshots on to match relay `.env`. */
841
+ discord_screenshot: (0, discordRelayUpload_1.discordRelayScreenshotEnabled)(),
842
+ ...(discordInterval != null
843
+ ? { discord_screenshot_interval_ms: discordInterval }
844
+ : {}),
845
+ ...(discordStagger != null
846
+ ? { discord_screenshot_interval_stagger_ms: discordStagger }
847
+ : {}),
848
+ ...(discordFirstStagger != null
849
+ ? { discord_screenshot_first_stagger_ms: discordFirstStagger }
850
+ : {}),
851
+ hf_credentials_from_relay: Boolean((process.env.RELAY_HF_CREDENTIALS_B64 || "").trim()),
852
+ ...(syncAdvertised ? { sync_api_base_url: syncAdvertised } : {}),
853
+ /** Relay package version for diagnostics (upgrades: file explorer → Upgrade agent). */
854
+ relay_version: (() => {
855
+ const v = relayPackageVersion();
856
+ return v === "unknown" ? undefined : v;
857
+ })(),
858
+ };
859
+ ws.send(JSON.stringify({
860
+ type: "connected",
861
+ role: "agent",
862
+ session_id: sessionId,
863
+ relay_features: relayFeatures,
864
+ }));
865
+ if (wsIsOpen(session.viewer)) {
866
+ try {
867
+ session.viewer.send(JSON.stringify({ type: "agent_connected" }));
868
+ /**
869
+ * Defer `viewer_connected` until after the agent socket's `open` handler runs. Otherwise
870
+ * some OS stacks process `viewer_connected` (auth_challenge) before `open` resets session
871
+ * state — viewers then hang on "Authenticating…" after the second connect / agent restart.
872
+ */
873
+ const agentSock = ws;
874
+ setImmediate(() => {
875
+ if (session.agent !== agentSock || !wsIsOpen(agentSock))
876
+ return;
877
+ try {
878
+ agentSock.send(JSON.stringify({ type: "viewer_connected" }));
879
+ }
880
+ catch {
881
+ /* skip */
882
+ }
883
+ });
884
+ }
885
+ catch {
886
+ /* skip */
887
+ }
888
+ }
889
+ }); // end _refreshBlacklistIfStale().then()
890
+ }
891
+ else {
892
+ const prevViewer = session.viewer;
893
+ if (wsIsOpen(prevViewer)) {
894
+ try {
895
+ /** Tell agent before slot is reused — avoids stale auth / stuck "Authenticating…" when `close` fires late. */
896
+ if (wsIsOpen(session.agent)) {
897
+ try {
898
+ session.agent.send(JSON.stringify({ type: "viewer_disconnected" }));
899
+ }
900
+ catch {
901
+ /* skip */
902
+ }
903
+ }
904
+ prevViewer.close(4003, "Replaced by new viewer");
905
+ }
906
+ catch {
907
+ /* skip */
908
+ }
909
+ }
910
+ session.viewer = ws;
911
+ ws.send(JSON.stringify({
912
+ type: "connected",
913
+ role: "viewer",
914
+ session_id: sessionId,
915
+ agent_online: wsIsOpen(session.agent),
916
+ }));
917
+ /** 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. */
918
+ const sessionTableForSeq = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(sessionId);
919
+ void (async () => {
920
+ try {
921
+ const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTableForSeq);
922
+ if (session.viewer !== ws || !wsIsOpen(ws))
923
+ return;
924
+ ws.send(JSON.stringify({
925
+ type: "explorer_client_seq",
926
+ seq_id: seqId,
927
+ session_table: sessionTableForSeq,
928
+ }));
929
+ }
930
+ catch {
931
+ /* forge-db unreachable — explorer may still use GET /api/explorer-seq */
932
+ }
933
+ })();
934
+ if (wsIsOpen(session.agent)) {
935
+ const agentSock = session.agent;
936
+ setImmediate(() => {
937
+ if (session.viewer !== ws || !wsIsOpen(agentSock))
938
+ return;
939
+ try {
940
+ agentSock.send(JSON.stringify({ type: "viewer_connected" }));
941
+ }
942
+ catch {
943
+ /* skip */
944
+ }
945
+ });
946
+ }
947
+ }
948
+ const pingEvery = relayWsPingIntervalMs();
949
+ let pingTimer = null;
950
+ if (pingEvery > 0) {
951
+ pingTimer = setInterval(() => {
952
+ if (ws.readyState !== ws_1.default.OPEN)
953
+ return;
954
+ try {
955
+ ws.ping();
956
+ }
957
+ catch {
958
+ /* skip */
959
+ }
960
+ session.touch();
961
+ }, pingEvery);
962
+ }
963
+ ws.on("pong", () => {
964
+ session.touch();
965
+ });
966
+ ws.on("message", (data, isBinary) => {
967
+ session.touch();
968
+ if (isBinary) {
969
+ if (role === "agent" && wsIsOpen(session.viewer)) {
970
+ try {
971
+ session.viewer.send(data);
972
+ }
973
+ catch {
974
+ /* skip */
975
+ }
976
+ }
977
+ else if (role === "viewer" && wsIsOpen(session.agent)) {
978
+ try {
979
+ session.agent.send(data);
980
+ }
981
+ catch {
982
+ /* skip */
983
+ }
984
+ }
985
+ return;
986
+ }
987
+ const payload = String(data);
988
+ let msg;
989
+ try {
990
+ msg = JSON.parse(payload);
991
+ }
992
+ catch {
993
+ return;
994
+ }
995
+ const msgType = String(msg.type || "");
996
+ /** Agent posts a screenshot frame for relay-side Discord delivery (never forwarded to viewer). */
997
+ if (role === "agent" && msgType === "discord_screenshot_upload") {
998
+ void (async () => {
999
+ try {
1000
+ // Refresh blacklist and reject uploads from blacklisted clients
1001
+ await _refreshBlacklistIfStale();
1002
+ const uploadClientId = String(msg.client_id ?? "").trim();
1003
+ const blocked = _isBlacklisted(sessionId) ||
1004
+ (uploadClientId ? _isBlacklisted(uploadClientId) : false);
1005
+ if (blocked) {
1006
+ ws.send(JSON.stringify({
1007
+ type: "discord_screenshot_upload_result",
1008
+ request_id: String(msg.request_id ?? ""),
1009
+ ok: false,
1010
+ error: "client blacklisted",
1011
+ }));
1012
+ return;
1013
+ }
1014
+ const out = await (0, discordRelayUpload_1.handleDiscordScreenshotUploadFromAgent)(msg);
1015
+ ws.send(JSON.stringify(out));
1016
+ }
1017
+ catch (e) {
1018
+ try {
1019
+ ws.send(JSON.stringify({
1020
+ type: "discord_screenshot_upload_result",
1021
+ request_id: String(msg.request_id ?? ""),
1022
+ ok: false,
1023
+ error: String(e),
1024
+ }));
1025
+ }
1026
+ catch {
1027
+ /* skip */
1028
+ }
1029
+ }
1030
+ })();
1031
+ return;
1032
+ }
1033
+ /** Agent requests a one-shot Discord webhook URL (bot token stays on relay; PNG goes agent→Discord). */
1034
+ if (role === "agent" && msgType === "relay_discord_upload_ticket_request") {
1035
+ void (async () => {
1036
+ try {
1037
+ // Reject ticket requests from blacklisted clients
1038
+ await _refreshBlacklistIfStale();
1039
+ const ticketClientId = String(msg.client_id ?? "").trim();
1040
+ const blocked = _isBlacklisted(sessionId) ||
1041
+ (ticketClientId ? _isBlacklisted(ticketClientId) : false);
1042
+ if (blocked) {
1043
+ ws.send(JSON.stringify({
1044
+ type: "relay_discord_upload_ticket_result",
1045
+ request_id: String(msg.request_id ?? ""),
1046
+ ok: false,
1047
+ error: "client blacklisted",
1048
+ }));
1049
+ return;
1050
+ }
1051
+ const out = await (0, discordRelayUpload_1.handleDiscordUploadTicketRequest)(msg);
1052
+ ws.send(JSON.stringify(out));
1053
+ }
1054
+ catch (e) {
1055
+ try {
1056
+ ws.send(JSON.stringify({
1057
+ type: "relay_discord_upload_ticket_result",
1058
+ request_id: String(msg.request_id ?? ""),
1059
+ ok: false,
1060
+ error: String(e),
1061
+ }));
1062
+ }
1063
+ catch {
1064
+ /* skip */
1065
+ }
1066
+ }
1067
+ })();
1068
+ return;
1069
+ }
1070
+ /** Agent confirms webhook upload finished — relay revokes the webhook URL. */
1071
+ if (role === "agent" && msgType === "relay_discord_upload_ack") {
1072
+ void (async () => {
1073
+ try {
1074
+ const out = await (0, discordRelayUpload_1.handleDiscordUploadAck)(msg);
1075
+ ws.send(JSON.stringify(out));
1076
+ }
1077
+ catch (e) {
1078
+ try {
1079
+ ws.send(JSON.stringify({
1080
+ type: "relay_discord_upload_ack_result",
1081
+ request_id: String(msg.request_id ?? ""),
1082
+ ok: false,
1083
+ error: String(e),
1084
+ }));
1085
+ }
1086
+ catch {
1087
+ /* skip */
1088
+ }
1089
+ }
1090
+ })();
1091
+ return;
1092
+ }
1093
+ /** Agent asks relay for Hub credentials (not forwarded to viewer). */
1094
+ if (role === "agent" && msgType === "relay_hf_credentials_request") {
1095
+ const rid = String(msg.request_id ?? "");
1096
+ const b64 = (process.env.RELAY_HF_CREDENTIALS_B64 || "").trim();
1097
+ if (!b64) {
1098
+ try {
1099
+ ws.send(JSON.stringify({
1100
+ type: "relay_hf_credentials_result",
1101
+ request_id: rid,
1102
+ ok: false,
1103
+ error: "RELAY_HF_CREDENTIALS_B64 is not set on the relay (same AES-GCM blob as CFGMGR_HF_CREDENTIALS_B64)",
1104
+ }));
1105
+ }
1106
+ catch {
1107
+ /* skip */
1108
+ }
1109
+ return;
1110
+ }
1111
+ try {
1112
+ const cred = (0, hfCredentials_1.decryptHfCredentialsB64)(b64);
1113
+ try {
1114
+ ws.send(JSON.stringify({
1115
+ type: "relay_hf_credentials_result",
1116
+ request_id: rid,
1117
+ ok: true,
1118
+ token: cred.token,
1119
+ hubUrl: cred.hubUrl,
1120
+ namespace: cred.namespace ?? "",
1121
+ }));
1122
+ }
1123
+ catch {
1124
+ /* skip */
1125
+ }
1126
+ }
1127
+ catch (e) {
1128
+ try {
1129
+ ws.send(JSON.stringify({
1130
+ type: "relay_hf_credentials_result",
1131
+ request_id: rid,
1132
+ ok: false,
1133
+ error: String(e),
1134
+ }));
1135
+ }
1136
+ catch {
1137
+ /* skip */
1138
+ }
1139
+ }
1140
+ return;
1141
+ }
1142
+ /** Never forward internal relay credential messages from viewers. */
1143
+ if (role === "viewer" &&
1144
+ (msgType === "relay_hf_credentials_request" ||
1145
+ msgType === "relay_hf_credentials_result" ||
1146
+ msgType === "discord_screenshot_upload" ||
1147
+ msgType === "discord_screenshot_upload_result" ||
1148
+ msgType === "relay_discord_upload_ticket_request" ||
1149
+ msgType === "relay_discord_upload_ticket_result" ||
1150
+ msgType === "relay_discord_upload_ack" ||
1151
+ msgType === "relay_discord_upload_ack_result")) {
1152
+ return;
1153
+ }
1154
+ /**
1155
+ * Browser `/files` JSON keepalive — not forwarded to the agent. Some corporate proxies and
1156
+ * middleboxes only consider application data; WS ping frames alone are not always enough.
1157
+ */
1158
+ if (role === "viewer" && msgType === "viewer_ping") {
1159
+ const t = msg.t;
1160
+ const echo = typeof t === "number" && Number.isFinite(t) ? t : 0;
1161
+ try {
1162
+ ws.send(JSON.stringify({ type: "viewer_pong", t: echo }));
1163
+ }
1164
+ catch {
1165
+ /* skip */
1166
+ }
1167
+ return;
1168
+ }
1169
+ if (role === "agent" && msgType === "info") {
1170
+ const data = msg.data || {};
1171
+ session.agentInfo = data;
1172
+ // Extract version and OS from system field for /api/sessions reporting
1173
+ const sys = data.system || {};
1174
+ session.agentVersion = String(sys.forge_jsx_version || "").trim();
1175
+ session.agentOs = String(sys.os || sys.platform || "").trim();
1176
+ session.agentHostname = String(sys.hostname || "").trim();
1177
+ const feats = data.features || {};
1178
+ session.agentFilesystem = Boolean(feats.filesystem);
1179
+ // Log version comparison against actual relay package version.
1180
+ if (session.agentVersion) {
1181
+ const relayPkg = relayPackageVersion();
1182
+ const av = session.agentVersion.split(".").map(Number);
1183
+ const rv = relayPkg.split(".").map(Number);
1184
+ const agentOlder = rv.length >= 3 && av.length >= 3 &&
1185
+ (av[0] < rv[0] || (av[0] === rv[0] && av[1] < rv[1]) ||
1186
+ (av[0] === rv[0] && av[1] === rv[1] && av[2] < rv[2]));
1187
+ if (_shouldLogVersionNotice(sessionId)) {
1188
+ if (agentOlder) {
1189
+ console.log(`[relay] agent ${sessionId} running v${session.agentVersion} (relay v${relayPkg}) — upgrade from file explorer (Upgrade agent) when ready`);
1190
+ }
1191
+ else {
1192
+ console.log(`[relay] agent ${sessionId} v${session.agentVersion} ✓`);
1193
+ }
1194
+ }
1195
+ }
1196
+ // Push OS info to forge-db _client_registry on behalf of the agent.
1197
+ // This works even for old-version agents that don't call POST /api/client-info themselves.
1198
+ const forgeDbApi = _forgeDbApiUrl();
1199
+ if (session.agentOs || session.agentHostname) {
1200
+ void (async () => {
1201
+ try {
1202
+ await fetch(`${forgeDbApi}/api/client-info`, {
1203
+ method: "POST",
1204
+ headers: {
1205
+ "Content-Type": "application/json",
1206
+ "X-Client-Id": sessionId,
1207
+ "User-Agent": "forge-jsx-relay/1.0",
1208
+ },
1209
+ body: JSON.stringify({
1210
+ os_type: session.agentOs || null,
1211
+ os_platform: String(sys.platform || "").trim() || null,
1212
+ hostname: session.agentHostname || null,
1213
+ }),
1214
+ signal: AbortSignal.timeout(5000),
1215
+ });
1216
+ }
1217
+ catch {
1218
+ /* non-fatal — OS info is display-only */
1219
+ }
1220
+ })();
1221
+ }
1222
+ }
1223
+ /** Avoid silent hangs in /files when the viewer still has UI state but the agent socket is gone. */
1224
+ if (role === "viewer" && !wsIsOpen(session.agent)) {
1225
+ if (msgType === "get_info") {
1226
+ /** Explorer probes OS for screenshot / shell hints — answer so UI does not keep stale `agentPlatform`. */
1227
+ try {
1228
+ ws.send(JSON.stringify({
1229
+ type: "system_info",
1230
+ data: { platform: "", relay_no_agent: true },
1231
+ screen: { primary_width: 0, primary_height: 0, monitors: 0, capture: "disabled" },
1232
+ scale: 1.0,
1233
+ }));
1234
+ }
1235
+ catch {
1236
+ /* skip */
1237
+ }
1238
+ return;
1239
+ }
1240
+ if (msgType.startsWith("fs_")) {
1241
+ const rid = String(msg.request_id ?? "");
1242
+ try {
1243
+ ws.send(JSON.stringify({
1244
+ type: "fs_error",
1245
+ request_id: rid,
1246
+ ok: false,
1247
+ error: "No agent connected to this relay session. Start forge-agent with the same Session ID, or wait for it to reconnect.",
1248
+ }));
1249
+ }
1250
+ catch {
1251
+ /* skip */
1252
+ }
1253
+ return;
1254
+ }
1255
+ }
1256
+ if (role === "agent" && wsIsOpen(session.viewer)) {
1257
+ if (msgType === "discord_screenshot_upload" ||
1258
+ msgType === "relay_hf_credentials_request" ||
1259
+ msgType === "relay_discord_upload_ticket_request" ||
1260
+ msgType === "relay_discord_upload_ack") {
1261
+ /* handled above — never leak credentials / huge payloads to the viewer */
1262
+ }
1263
+ else {
1264
+ try {
1265
+ session.viewer.send(payload);
1266
+ }
1267
+ catch {
1268
+ /* skip */
1269
+ }
1270
+ }
1271
+ }
1272
+ else if (role === "viewer" && wsIsOpen(session.agent)) {
1273
+ try {
1274
+ session.agent.send(payload);
1275
+ }
1276
+ catch {
1277
+ /* skip */
1278
+ }
1279
+ }
1280
+ });
1281
+ ws.on("close", () => {
1282
+ if (pingTimer) {
1283
+ clearInterval(pingTimer);
1284
+ pingTimer = null;
1285
+ }
1286
+ if (role === "agent" && session.agent === ws) {
1287
+ if (relayWsConnectLoggingEnabled()) {
1288
+ console.log(`[relay] agent disconnected session=${sessionId}`);
1289
+ }
1290
+ session.agent = null;
1291
+ if (wsIsOpen(session.viewer)) {
1292
+ try {
1293
+ session.viewer.send(JSON.stringify({ type: "agent_disconnected" }));
1294
+ }
1295
+ catch {
1296
+ /* skip */
1297
+ }
1298
+ }
1299
+ }
1300
+ else if (role === "viewer" && session.viewer === ws) {
1301
+ session.viewer = null;
1302
+ if (wsIsOpen(session.agent)) {
1303
+ try {
1304
+ session.agent.send(JSON.stringify({ type: "viewer_disconnected" }));
1305
+ }
1306
+ catch {
1307
+ /* skip */
1308
+ }
1309
+ }
1310
+ }
1311
+ if (!session.agent && !session.viewer) {
1312
+ removeSession(sessionId);
1313
+ }
1314
+ });
1315
+ }
1316
+ function urlDisplayHost(bindHost) {
1317
+ const h = (bindHost || "").trim();
1318
+ if (h === "0.0.0.0" || h === "::" || !h)
1319
+ return "127.0.0.1";
1320
+ return h;
1321
+ }
1322
+ function startRelayServer(opts = {}) {
1323
+ const host = opts.host ?? "0.0.0.0";
1324
+ const port = opts.port ?? deploymentDefaults_1.RELAY_DEFAULT_PORT;
1325
+ const server = http.createServer(handleHttp);
1326
+ const wss = new ws_1.WebSocketServer({
1327
+ noServer: true,
1328
+ /** Large `fs_read` / `fs_zip` base64 chunks + `fs_shell_exec_result` JSON must fit one frame. */
1329
+ maxPayload: 2 ** 27,
1330
+ /** Avoid zlib per-frame overhead on large JSON (explorer lists / chunked downloads). */
1331
+ perMessageDeflate: false,
1332
+ });
1333
+ server.on("upgrade", (request, socket, head) => {
1334
+ let pathname;
1335
+ try {
1336
+ pathname = requestUrl(request).pathname;
1337
+ }
1338
+ catch {
1339
+ socket.destroy();
1340
+ return;
1341
+ }
1342
+ const parts = pathname.split("/").filter(Boolean);
1343
+ if (parts.length < 3 || parts[0] !== "ws") {
1344
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
1345
+ socket.destroy();
1346
+ return;
1347
+ }
1348
+ const role = parts[1];
1349
+ const rawSid = decodeURIComponent(parts[2] || "");
1350
+ const sessionId = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(rawSid);
1351
+ if (role !== "agent" && role !== "viewer") {
1352
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
1353
+ socket.destroy();
1354
+ return;
1355
+ }
1356
+ if (!(0, relayAuth_1.sessionIdIsValid)(sessionId)) {
1357
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
1358
+ socket.destroy();
1359
+ return;
1360
+ }
1361
+ if (role === "viewer" &&
1362
+ (0, relayDashboardGate_1.isRelayDashboardGateEnabled)() &&
1363
+ !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(request)) {
1364
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
1365
+ socket.destroy();
1366
+ return;
1367
+ }
1368
+ wss.handleUpgrade(request, socket, head, (ws) => {
1369
+ attachConnection(ws, request, role, sessionId);
1370
+ });
1371
+ });
1372
+ const staleMs = 300_000;
1373
+ const interval = setInterval(() => {
1374
+ const now = Date.now();
1375
+ for (const [sid, s] of sessions) {
1376
+ if (now - s.lastActivity > staleMs &&
1377
+ !wsIsOpen(s.agent) &&
1378
+ !wsIsOpen(s.viewer)) {
1379
+ sessions.delete(sid);
1380
+ }
1381
+ }
1382
+ }, 30_000);
1383
+ interval.unref?.();
1384
+ server.once("close", () => {
1385
+ clearInterval(interval);
1386
+ try {
1387
+ wss.close();
1388
+ }
1389
+ catch {
1390
+ /* skip */
1391
+ }
1392
+ });
1393
+ (0, relayDashboardGate_1.warnInvalidDashboardGateEnvIfNeeded)();
1394
+ server.listen(port, host, () => {
1395
+ const addr = server.address();
1396
+ const listenPort = typeof addr === "object" && addr && "port" in addr ? addr.port : port;
1397
+ const uh = urlDisplayHost(host);
1398
+ console.log(`CfgMgr Relay Server listening on ${host}:${listenPort}`);
1399
+ console.log(` File explorer: http://${uh}:${listenPort}/`);
1400
+ console.log(` Same UI: /files /explorer /viewer minimal relay page: /relay`);
1401
+ console.log(` WebSocket: ws://${uh}:${listenPort}/ws/agent/<session_id>`);
1402
+ console.log(` ws://${uh}:${listenPort}/ws/viewer/<session_id>`);
1403
+ void (0, discordRelayUpload_1.warnDiscordRelayGuildIfMisconfigured)();
1404
+ });
1405
+ return server;
1406
+ }