free-coding-models 0.5.86 → 0.5.87

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 (163) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/package.json +8 -5
  5. package/sources.js +4 -4
  6. package/src/core/cache.js +3 -1
  7. package/src/core/config.js +43 -5
  8. package/src/core/endpoint-installer.js +23 -12
  9. package/src/core/installed-models-manager.js +43 -20
  10. package/src/core/kilo-config.js +51 -5
  11. package/src/core/kilo.js +5 -3
  12. package/src/core/legacy-proxy-cleanup.js +22 -5
  13. package/src/core/model-merger.js +4 -0
  14. package/src/core/models-drift.js +9 -0
  15. package/src/core/opencode-config.js +57 -5
  16. package/src/core/opencode.js +67 -41
  17. package/src/core/playground.js +7 -0
  18. package/src/core/probe-cache.js +35 -4
  19. package/src/core/provider-key-tester.js +10 -2
  20. package/src/core/provider-metadata.js +10 -0
  21. package/src/core/provider-quota-fetchers.js +14 -7
  22. package/src/core/router-daemon.js +208 -26
  23. package/src/core/runtime-telemetry.js +27 -1
  24. package/src/core/shared-helpers.js +12 -1
  25. package/src/core/shell-env.js +3 -1
  26. package/src/core/telemetry.js +18 -5
  27. package/src/core/tool-launchers.js +9 -6
  28. package/src/core/updater.js +28 -2
  29. package/src/core/utils.js +76 -71
  30. package/src/tui/app.js +38 -14
  31. package/src/tui/command-palette.js +22 -2
  32. package/src/tui/key-handler.js +123 -179
  33. package/src/tui/overlays.js +21 -71
  34. package/src/tui/render-helpers.js +62 -4
  35. package/src/tui/render-table.js +34 -3
  36. package/src/tui/theme.js +36 -5
  37. package/src/tui/tui-filters.js +28 -8
  38. package/src/tui/tui-state.js +22 -6
  39. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  40. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  41. package/web/dist/index.html +5 -9
  42. package/web/index.html +3 -7
  43. package/web/server.js +97 -23
  44. package/web/vite.config.js +8 -0
  45. package/web/app.legacy.js +0 -900
  46. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  47. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  48. package/web/public/favicon.ico +0 -0
  49. package/web/public/favicons/apple-touch-icon.png +0 -0
  50. package/web/public/favicons/browserconfig.xml +0 -12
  51. package/web/public/favicons/favicon-16x16.png +0 -0
  52. package/web/public/favicons/favicon-192x192.png +0 -0
  53. package/web/public/favicons/favicon-32x32.png +0 -0
  54. package/web/public/favicons/favicon-48x48.png +0 -0
  55. package/web/public/favicons/favicon-512x512.png +0 -0
  56. package/web/public/favicons/favicon-96x96.png +0 -0
  57. package/web/public/favicons/favicon.ico +0 -0
  58. package/web/public/favicons/mstile-150x150.png +0 -0
  59. package/web/public/favicons/mstile-310x150.png +0 -0
  60. package/web/public/favicons/mstile-310x310.png +0 -0
  61. package/web/public/favicons/mstile-512x512.png +0 -0
  62. package/web/public/favicons/mstile-70x70.png +0 -0
  63. package/web/public/favicons/site.webmanifest +0 -25
  64. package/web/src/App.jsx +0 -521
  65. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  66. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  67. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  68. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  69. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  70. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  71. package/web/src/components/atoms/HealthCell.jsx +0 -143
  72. package/web/src/components/atoms/HealthCell.module.css +0 -60
  73. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  74. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  75. package/web/src/components/atoms/MoodCell.jsx +0 -25
  76. package/web/src/components/atoms/MoodCell.module.css +0 -6
  77. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  78. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  79. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  80. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  81. package/web/src/components/atoms/RankCell.jsx +0 -9
  82. package/web/src/components/atoms/RankCell.module.css +0 -9
  83. package/web/src/components/atoms/Sparkline.jsx +0 -63
  84. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  85. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  86. package/web/src/components/atoms/StatusDot.jsx +0 -13
  87. package/web/src/components/atoms/StatusDot.module.css +0 -29
  88. package/web/src/components/atoms/TPSCell.jsx +0 -36
  89. package/web/src/components/atoms/TPSCell.module.css +0 -44
  90. package/web/src/components/atoms/TierBadge.jsx +0 -10
  91. package/web/src/components/atoms/TierBadge.module.css +0 -29
  92. package/web/src/components/atoms/Toast.jsx +0 -25
  93. package/web/src/components/atoms/Toast.module.css +0 -41
  94. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  95. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  96. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  97. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  98. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  99. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  100. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  101. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  102. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  103. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  104. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  105. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  106. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  107. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  108. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  109. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  110. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  111. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  112. package/web/src/components/help/HelpView.jsx +0 -201
  113. package/web/src/components/help/HelpView.module.css +0 -156
  114. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  115. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  116. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  117. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  118. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  119. package/web/src/components/launch/LaunchButton.jsx +0 -30
  120. package/web/src/components/launch/LaunchButton.module.css +0 -35
  121. package/web/src/components/launch/LaunchModal.module.css +0 -125
  122. package/web/src/components/layout/Footer.jsx +0 -19
  123. package/web/src/components/layout/Footer.module.css +0 -10
  124. package/web/src/components/layout/Header.jsx +0 -265
  125. package/web/src/components/layout/Header.module.css +0 -357
  126. package/web/src/components/palette/CommandPalette.jsx +0 -301
  127. package/web/src/components/palette/CommandPalette.module.css +0 -121
  128. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  129. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  130. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  131. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  132. package/web/src/components/recommend/RecommendView.jsx +0 -121
  133. package/web/src/components/recommend/RecommendView.module.css +0 -57
  134. package/web/src/components/router/RouterView.jsx +0 -1212
  135. package/web/src/components/router/RouterView.module.css +0 -1289
  136. package/web/src/components/settings/SettingsView.jsx +0 -555
  137. package/web/src/components/settings/SettingsView.module.css +0 -550
  138. package/web/src/components/tools/ToolPicker.jsx +0 -86
  139. package/web/src/components/tools/ToolPicker.module.css +0 -97
  140. package/web/src/components/update/UpdateChip.jsx +0 -104
  141. package/web/src/components/update/UpdateChip.module.css +0 -146
  142. package/web/src/global.css +0 -298
  143. package/web/src/hooks/urlState.constants.js +0 -28
  144. package/web/src/hooks/useChangelog.js +0 -51
  145. package/web/src/hooks/useColumnSizing.js +0 -211
  146. package/web/src/hooks/useFavorites.js +0 -150
  147. package/web/src/hooks/useFilter.js +0 -310
  148. package/web/src/hooks/useInstalledModels.js +0 -42
  149. package/web/src/hooks/useRecommend.js +0 -67
  150. package/web/src/hooks/useRouterDashboard.js +0 -133
  151. package/web/src/hooks/useSSE.js +0 -73
  152. package/web/src/hooks/useSocket.js +0 -203
  153. package/web/src/hooks/useTheme.js +0 -83
  154. package/web/src/hooks/useTokenUsage.js +0 -103
  155. package/web/src/hooks/useToolMode.js +0 -77
  156. package/web/src/hooks/useUpdateChecker.js +0 -91
  157. package/web/src/hooks/useUrlState.js +0 -170
  158. package/web/src/main.jsx +0 -15
  159. package/web/src/utils/download.js +0 -15
  160. package/web/src/utils/format.js +0 -42
  161. package/web/src/utils/m3.js +0 -57
  162. package/web/src/utils/ranks.js +0 -37
  163. package/web/styles.legacy.css +0 -963
@@ -32,8 +32,8 @@
32
32
  import { createServer } from 'node:http'
33
33
  import { existsSync, readFileSync } from 'node:fs'
34
34
  import { dirname, join, resolve as resolvePath, sep as pathSep } from 'node:path'
35
- import { fork } from 'node:child_process'
36
- import { randomUUID } from 'node:crypto'
35
+ import { fork, execFileSync } from 'node:child_process'
36
+ import { randomUUID, timingSafeEqual } from 'node:crypto'
37
37
  import { appendFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
38
38
  import { homedir } from 'node:os'
39
39
  import { fileURLToPath } from 'node:url'
@@ -147,6 +147,7 @@ function normalizeToolCallsResponse(data) {
147
147
 
148
148
  const MAX_REQUEST_LOG = 200
149
149
  const MAX_SSE_CLIENTS = 10
150
+ const MAX_SSE_CLIENTS_PER_ORIGIN = 5
150
151
  const MAX_CONCURRENT_REQUESTS = 50
151
152
  const MAX_PROBE_WINDOW = 20
152
153
  const TOKEN_FLUSH_INTERVAL_MS = 60000
@@ -212,6 +213,10 @@ function headerEntries(headers) {
212
213
  headers.forEach((value, key) => {
213
214
  const lower = key.toLowerCase()
214
215
  if (['connection', 'content-encoding', 'content-length', 'transfer-encoding'].includes(lower)) return
216
+ // 📖 Never relay upstream Set-Cookie / CORS headers to browser clients:
217
+ // 📖 the router owns its own CORS posture and must not leak upstream
218
+ // 📖 cookie state into the dashboard.
219
+ if (lower === 'set-cookie' || lower.startsWith('access-control-')) return
215
220
  entries[lower] = value
216
221
  })
217
222
  return entries
@@ -269,10 +274,11 @@ function isLoopbackHostname(hostname) {
269
274
  return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1' || h.endsWith('.localhost')
270
275
  }
271
276
 
272
- // 📖 Private-network hostname check. Allows RFC 1918 IPs (10.x, 172.16-31.x,
273
- // 📖 192.168.x) and hostnames that end in `.local` or `.internal`. This
274
- // 📖 enables Docker and LAN setups where the browser hits the FCM web UI
275
- // 📖 from a different machine but still on a trusted network.
277
+ // 📖 Private-network hostname check for Host-header validation. Matches
278
+ // 📖 RFC 1918 IPs (10.x, 172.16-31.x, 192.168.x) and hostnames that end in
279
+ // 📖 `.local` or `.internal`. Used when the daemon is explicitly bound to a
280
+ // 📖 wildcard address (FCM_HOST=0.0.0.0) so LAN clients keep working; it
281
+ // 📖 never grants origin trust on its own.
276
282
  function isPrivateNetworkHostname(hostname) {
277
283
  if (!hostname) return false
278
284
  const h = hostname.toLowerCase()
@@ -301,20 +307,31 @@ function getAllowedOrigins() {
301
307
  function isSameOriginOrLocal(req) {
302
308
  const origin = req.headers.origin
303
309
  const referer = req.headers.referer || req.headers.referrer
310
+ const hasOrigin = typeof origin === 'string' && origin.length > 0
304
311
  const candidates = []
305
- if (typeof origin === 'string' && origin && origin !== 'null') candidates.push(origin)
306
- else if (typeof referer === 'string' && referer) candidates.push(referer)
312
+ if (hasOrigin && origin !== 'null') {
313
+ candidates.push(origin)
314
+ } else if (hasOrigin && origin === 'null') {
315
+ // 📖 Origin: null (sandboxed iframe / opaque origin) is attacker-friendly:
316
+ // 📖 never trust it on its own - only a loopback Referer alongside it counts.
317
+ if (typeof referer !== 'string' || !referer) return false
318
+ candidates.push(referer)
319
+ } else if (typeof referer === 'string' && referer) {
320
+ candidates.push(referer)
321
+ }
307
322
 
308
323
  // 📖 No Origin/Referer → non-browser caller (curl, native app). Allow.
309
324
  if (candidates.length === 0) return true
310
325
 
326
+ let hostHeader = typeof req.headers.host === 'string' ? req.headers.host.toLowerCase() : ''
311
327
  for (const c of candidates) {
312
328
  try {
313
329
  const parsed = new URL(c)
314
330
  if (isLoopbackHostname(parsed.hostname)) return true
315
- // 📖 Allow Docker / LAN access from private networks
316
- if (isPrivateNetworkHostname(parsed.hostname)) return true
317
- // 📖 Allow user-specified origins via env var
331
+ // 📖 Genuine same-origin request: the browser's Origin matches the Host
332
+ // 📖 it is already talking to (LAN/Docker setups hitting FCM_HOST).
333
+ if (hostHeader && parsed.host.toLowerCase() === hostHeader) return true
334
+ // 📖 Allow user-specified origins via env var (e.g. "http://mybox:19280")
318
335
  if (getAllowedOrigins().includes(c)) return true
319
336
  } catch {
320
337
  return false
@@ -323,6 +340,52 @@ function isSameOriginOrLocal(req) {
323
340
  return false
324
341
  }
325
342
 
343
+ // 📖 Host-header allowlist applied to every request before routing. Blocks
344
+ // 📖 DNS-rebinding, where a public attacker domain resolves to 127.0.0.1 and
345
+ // 📖 the browser sends Host: evil.com, bypassing the Origin/Referer guard.
346
+ // 📖 Loopback Host values are always accepted; when the daemon is bound to a
347
+ // 📖 non-loopback FCM_HOST (LAN / Docker), that hostname plus private-network
348
+ // 📖 hosts are accepted too. Public domains stay rejected.
349
+ function isAllowedHostHeader(hostHeader, port, boundHost) {
350
+ if (typeof hostHeader !== 'string' || !hostHeader) return false
351
+ const value = hostHeader.toLowerCase()
352
+ const loopback = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, '127.0.0.1', 'localhost', '[::1]']
353
+ if (loopback.includes(value)) return true
354
+ if (!boundHost || isLoopbackHostname(boundHost)) return false
355
+ const host = boundHost.toLowerCase()
356
+ if (value === host || value === `${host}:${port}`) return true
357
+ // 📖 Wildcard binds (0.0.0.0 / ::) listen on every interface, so
358
+ // 📖 private-network Host values are legitimate; public domains are not.
359
+ if (host === '0.0.0.0' || host === '::') {
360
+ return isPrivateNetworkHostname(value.replace(/:\d+$/, ''))
361
+ }
362
+ return false
363
+ }
364
+
365
+ // 📖 Optional shared-token auth for the OpenAI-compatible /v1 proxy.
366
+ // 📖 Set FCM_ROUTER_TOKEN to require `Authorization: Bearer <token>` (or
367
+ // 📖 `x-api-key: <token>`) on every /v1/* request; leave it unset for the
368
+ // 📖 default no-auth local behavior.
369
+ function getRouterToken() {
370
+ return (process.env.FCM_ROUTER_TOKEN || '').trim()
371
+ }
372
+
373
+ function safeTokenCompare(candidate, token) {
374
+ const bufA = Buffer.from(String(candidate || ''))
375
+ const bufB = Buffer.from(token)
376
+ if (bufA.length !== bufB.length) return false
377
+ return timingSafeEqual(bufA, bufB)
378
+ }
379
+
380
+ function isAuthorizedForV1(req) {
381
+ const token = getRouterToken()
382
+ if (!token) return true
383
+ const auth = req.headers.authorization || ''
384
+ if (typeof auth === 'string' && auth.startsWith('Bearer ') && safeTokenCompare(auth.slice(7).trim(), token)) return true
385
+ const apiKeyHeader = req.headers['x-api-key']
386
+ return typeof apiKeyHeader === 'string' && safeTokenCompare(apiKeyHeader.trim(), token)
387
+ }
388
+
326
389
  const MIME_TYPES = {
327
390
  '.html': 'text/html; charset=utf-8',
328
391
  '.css': 'text/css; charset=utf-8',
@@ -569,7 +632,9 @@ export function cloneHeadersForUpstream(reqHeaders, apiKey, providerKey) {
569
632
  const headers = {}
570
633
  for (const [key, value] of Object.entries(reqHeaders || {})) {
571
634
  const lower = key.toLowerCase()
572
- if (['host', 'connection', 'content-length', 'authorization'].includes(lower)) continue
635
+ // 📖 Drop the client's cookies: they belong to the local browser session
636
+ // 📖 and must never be forwarded to the upstream provider.
637
+ if (['host', 'connection', 'content-length', 'authorization', 'cookie'].includes(lower)) continue
573
638
  if (typeof value !== 'string') continue
574
639
  if (lower === 'content-type') {
575
640
  headers['Content-Type'] = value
@@ -664,6 +729,14 @@ function readRequestBody(req, limit = MAX_BODY_BYTES) {
664
729
 
665
730
  function readJsonBody(req) {
666
731
  return readRequestBody(req).then((raw) => {
732
+ // 📖 Refuse explicit non-JSON bodies (e.g. text/plain form posts) so a
733
+ // 📖 cross-site form cannot smuggle data into JSON endpoints. Missing or
734
+ // 📖 empty content-type stays allowed for CLI clients that send bare
735
+ // 📖 bodies, and anything with an empty body is fine regardless.
736
+ const contentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'].toLowerCase() : ''
737
+ if (raw.trim() && contentType && !contentType.includes('json')) {
738
+ throw Object.assign(new Error(`Unsupported Content-Type: ${contentType}`), { code: 'UNSUPPORTED_MEDIA_TYPE' })
739
+ }
667
740
  if (!raw.trim()) return {}
668
741
  const parsed = safeJsonParse(raw)
669
742
  if (parsed === null) {
@@ -2334,7 +2407,9 @@ class RouterRuntime {
2334
2407
  // 📖 Provide failover fallback for non-retryable errors from the provider (like 400 Bad Request)
2335
2408
  // when they are caused by format idiosyncrasies (e.g. empty tools array that another model might accept)
2336
2409
  if (response.status >= 400 && response.status < 500) {
2337
- this.recordRouterError(`http_${response.status}`, requestId, { model: key, status: response.status, body: text })
2410
+ // 📖 Telemetry keeps structural fields only: upstream response bodies
2411
+ // 📖 must never leave the machine (they can embed user code/prompts).
2412
+ this.recordRouterError(`http_${response.status}`, requestId, { provider: candidate.provider, model: key, status: response.status })
2338
2413
  this.markFailure(key, `HTTP ${response.status}`)
2339
2414
  this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}` })
2340
2415
  this.recordRuntimeCall({
@@ -2440,8 +2515,9 @@ class RouterRuntime {
2440
2515
  // 📖 Provide failover fallback for non-retryable errors from the provider (like 400 Bad Request)
2441
2516
  // when they are caused by format idiosyncrasies (e.g. empty tools array that another model might accept)
2442
2517
  if (response.status >= 400 && response.status < 500) {
2443
- const rawErr = await response.text()
2444
- this.recordRouterError(`http_${response.status}`, requestId, { model: key, status: response.status, body: rawErr, stream: true })
2518
+ // 📖 Telemetry keeps structural fields only: upstream response bodies
2519
+ // 📖 must never leave the machine (they can embed user code/prompts).
2520
+ this.recordRouterError(`http_${response.status}`, requestId, { provider: candidate.provider, model: key, status: response.status, stream: true })
2445
2521
  this.markFailure(key, `HTTP ${response.status}`)
2446
2522
  this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}`, stream: true })
2447
2523
  return { done: false, failoverToNext: true, reason: `http_${response.status}` }
@@ -2586,8 +2662,13 @@ class RouterRuntime {
2586
2662
  readStreamChunkWithTimeout(reader) {
2587
2663
  const timeoutMs = this.routerConfig().failover.streamStallTimeoutMs
2588
2664
  let timeout = null
2665
+ const read = reader.read()
2666
+ // 📖 When the stall timer below wins the race, this read promise is
2667
+ // 📖 abandoned; without a catch its eventual rejection would surface as
2668
+ // 📖 an unhandledRejection and feed the daemon crash counter.
2669
+ read.catch(() => {})
2589
2670
  return Promise.race([
2590
- reader.read().finally(() => {
2671
+ read.finally(() => {
2591
2672
  if (timeout) clearTimeout(timeout)
2592
2673
  }),
2593
2674
  new Promise((_, reject) => {
@@ -2597,6 +2678,12 @@ class RouterRuntime {
2597
2678
  }
2598
2679
 
2599
2680
  async handleSetsRequest(req, res, url, requestId) {
2681
+ // 📖 Hoisted same-origin guard: covers both the canonical /sets routes and
2682
+ // 📖 the /api/router/sets alias so no set-mutating path skips the check.
2683
+ if (!isSameOriginOrLocal(req)) {
2684
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2685
+ return
2686
+ }
2600
2687
  const router = this.routerConfig()
2601
2688
  const setNameMatch = url.pathname.match(/^\/sets\/([^/]+)$/)
2602
2689
  const activateMatch = url.pathname.match(/^\/sets\/([^/]+)\/activate$/)
@@ -2833,6 +2920,12 @@ class RouterRuntime {
2833
2920
  * 📖 sample of probe results so the UI can show "what changed".
2834
2921
  */
2835
2922
  async handleSyncSetRequest(req, res, requestId, routeUrl = null) {
2923
+ // 📖 Same-origin guard hoisted here so the canonical /sets/:name/sync and
2924
+ // 📖 the /api/router/sets alias are both covered.
2925
+ if (!isSameOriginOrLocal(req)) {
2926
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2927
+ return
2928
+ }
2836
2929
  const url = routeUrl || (req.url ? new URL(req.url, 'http://localhost') : null)
2837
2930
  const pathname = url ? url.pathname : ''
2838
2931
  const setSyncMatch = pathname.match(/^\/sets\/([^/]+)\/sync$/)
@@ -2900,8 +2993,41 @@ class RouterRuntime {
2900
2993
  }, { 'x-request-id': requestId })
2901
2994
  }
2902
2995
 
2996
+ // 📖 Shared admission control for the SSE dashboard streams: same-origin
2997
+ // 📖 browsers only, max 10 concurrent streams overall (MAX_SSE_CLIENTS)
2998
+ // 📖 and max 5 per single Origin so one tab or origin cannot hog every slot.
2999
+ tryOpenSseConnection(req, res, requestId) {
3000
+ if (!isSameOriginOrLocal(req)) {
3001
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
3002
+ return false
3003
+ }
3004
+ if (this.sseClients.size >= MAX_SSE_CLIENTS) {
3005
+ sendError(res, 503, 'Too many dashboard clients', 'service_unavailable', 'too_many_sse_clients', requestId)
3006
+ return false
3007
+ }
3008
+ const originKey = (typeof req.headers.origin === 'string' && req.headers.origin) || 'no-origin'
3009
+ const perOrigin = [...this.sseClients].filter((client) => (client.fcmSseOrigin || 'no-origin') === originKey).length
3010
+ if (perOrigin >= MAX_SSE_CLIENTS_PER_ORIGIN) {
3011
+ sendError(res, 503, 'Too many streams for this origin', 'service_unavailable', 'too_many_sse_clients', requestId)
3012
+ return false
3013
+ }
3014
+ res.fcmSseOrigin = originKey
3015
+ return true
3016
+ }
3017
+
2903
3018
  async handleHttp(req, res) {
2904
- const requestId = req.headers['x-request-id'] || `req-${randomUUID()}`
3019
+ // 📖 Clamp client-supplied request ids so a giant header can't bloat
3020
+ // 📖 logs, telemetry, and response headers.
3021
+ const rawRequestId = req.headers['x-request-id']
3022
+ const requestId = typeof rawRequestId === 'string' && rawRequestId.trim()
3023
+ ? rawRequestId.trim().slice(0, 64)
3024
+ : `req-${randomUUID()}`
3025
+ // 📖 DNS-rebinding guard: reject requests whose Host header is not the
3026
+ // 📖 loopback (or the configured FCM_HOST) before any routing happens.
3027
+ if (!isAllowedHostHeader(req.headers.host, this.port, this.boundHost)) {
3028
+ sendError(res, 403, 'Forbidden host header', 'invalid_request_error', 'forbidden_host', requestId)
3029
+ return
3030
+ }
2905
3031
  const url = new URL(req.url, `http://localhost:${this.port}`)
2906
3032
  try {
2907
3033
  if (req.method === 'GET' && url.pathname === '/health') {
@@ -2934,6 +3060,12 @@ class RouterRuntime {
2934
3060
  return
2935
3061
  }
2936
3062
  if (req.method === 'GET' && url.pathname === '/v1/models') {
3063
+ // 📖 Optional shared-token auth (FCM_ROUTER_TOKEN) protects every
3064
+ // 📖 /v1/* route; with no token configured this is a no-op.
3065
+ if (!isAuthorizedForV1(req)) {
3066
+ sendError(res, 401, 'Missing or invalid router token', 'invalid_request_error', 'invalid_api_key', requestId)
3067
+ return
3068
+ }
2937
3069
  const router = this.routerConfig()
2938
3070
  sendJson(res, 200, {
2939
3071
  object: 'list',
@@ -2945,10 +3077,7 @@ class RouterRuntime {
2945
3077
  return
2946
3078
  }
2947
3079
  if (req.method === 'GET' && url.pathname === '/stream/events') {
2948
- if (this.sseClients.size >= MAX_SSE_CLIENTS) {
2949
- sendError(res, 503, 'Too many dashboard clients', 'service_unavailable', 'too_many_sse_clients', requestId)
2950
- return
2951
- }
3080
+ if (!this.tryOpenSseConnection(req, res, requestId)) return
2952
3081
  res.writeHead(200, {
2953
3082
  'Content-Type': 'text/event-stream',
2954
3083
  'Cache-Control': 'no-cache',
@@ -2963,11 +3092,19 @@ class RouterRuntime {
2963
3092
  return
2964
3093
  }
2965
3094
  if (url.pathname === '/daemon/shutdown' && req.method === 'POST') {
3095
+ if (!isSameOriginOrLocal(req)) {
3096
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
3097
+ return
3098
+ }
2966
3099
  sendJson(res, 200, { ok: true, message: 'Daemon shutting down' }, { 'x-request-id': requestId })
2967
3100
  setTimeout(() => this.shutdown(0), 50)
2968
3101
  return
2969
3102
  }
2970
3103
  if (url.pathname === '/daemon/probe-mode' && req.method === 'POST') {
3104
+ if (!isSameOriginOrLocal(req)) {
3105
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
3106
+ return
3107
+ }
2971
3108
  await this.handleProbeModeRequest(req, res, requestId)
2972
3109
  return
2973
3110
  }
@@ -3163,10 +3300,7 @@ class RouterRuntime {
3163
3300
  return
3164
3301
  }
3165
3302
  if (req.method === 'GET' && url.pathname === '/api/events') {
3166
- if (this.sseClients.size >= MAX_SSE_CLIENTS) {
3167
- sendError(res, 503, 'Too many dashboard clients', 'service_unavailable', 'too_many_sse_clients', requestId)
3168
- return
3169
- }
3303
+ if (!this.tryOpenSseConnection(req, res, requestId)) return
3170
3304
  res.writeHead(200, {
3171
3305
  'Content-Type': 'text/event-stream',
3172
3306
  'Cache-Control': 'no-cache',
@@ -3185,6 +3319,10 @@ class RouterRuntime {
3185
3319
  return
3186
3320
  }
3187
3321
  if (req.method === 'POST' && url.pathname === '/api/benchmark') {
3322
+ if (!isSameOriginOrLocal(req)) {
3323
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
3324
+ return
3325
+ }
3188
3326
  const body = await readJsonBody(req)
3189
3327
  const providerKey = typeof body.providerKey === 'string' ? body.providerKey : ''
3190
3328
  const modelId = typeof body.modelId === 'string' ? body.modelId : ''
@@ -3201,6 +3339,10 @@ class RouterRuntime {
3201
3339
  return
3202
3340
  }
3203
3341
  if (url.pathname === '/api/global-benchmark') {
3342
+ if (!isSameOriginOrLocal(req)) {
3343
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
3344
+ return
3345
+ }
3204
3346
  if (req.method === 'GET') {
3205
3347
  sendJson(res, 200, {
3206
3348
  running: this.webGlobalBenchmarkRunning,
@@ -3262,8 +3404,10 @@ class RouterRuntime {
3262
3404
  sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
3263
3405
  return
3264
3406
  }
3407
+ // 📖 Never return the raw key over HTTP: only the masked form the
3408
+ // 📖 dashboard already displays, plus a hasKey flag for UI state.
3265
3409
  const rawKey = this.getApiKeyForProvider(providerKey)
3266
- sendJson(res, 200, { key: rawKey || null }, { 'x-request-id': requestId })
3410
+ sendJson(res, 200, { key: rawKey ? maskApiKey(rawKey) : null, hasKey: Boolean(rawKey) }, { 'x-request-id': requestId })
3267
3411
  return
3268
3412
  }
3269
3413
  }
@@ -3339,6 +3483,12 @@ class RouterRuntime {
3339
3483
  sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['POST'] })
3340
3484
  return
3341
3485
  }
3486
+ // 📖 Optional shared-token auth (FCM_ROUTER_TOKEN) protects every
3487
+ // 📖 /v1/* route; with no token configured this is a no-op.
3488
+ if (!isAuthorizedForV1(req)) {
3489
+ sendError(res, 401, 'Missing or invalid router token', 'invalid_request_error', 'invalid_api_key', requestId)
3490
+ return
3491
+ }
3342
3492
  const setMatch = url.pathname.match(/^\/v1\/sets\/([^/]+)\/chat\/completions$/)
3343
3493
  const body = await readJsonBody(req)
3344
3494
  await this.routeRequest({ req, res, body, setName: setMatch ? decodeURIComponent(setMatch[1]) : null, requestId })
@@ -3354,6 +3504,10 @@ class RouterRuntime {
3354
3504
  sendError(res, 400, 'Invalid JSON', 'invalid_request_error', 'invalid_json', requestId, { detail: error.message })
3355
3505
  return
3356
3506
  }
3507
+ if (error.code === 'UNSUPPORTED_MEDIA_TYPE') {
3508
+ sendError(res, 415, 'Unsupported Media Type: send application/json', 'invalid_request_error', 'unsupported_media_type', requestId)
3509
+ return
3510
+ }
3357
3511
  this.logger.error('Internal router error', { request_id: requestId, error: error?.stack || error?.message || String(error) })
3358
3512
  this.recordRouterError('internal_router_error', requestId, { message: error?.message || String(error) })
3359
3513
  if (!res.writableEnded) {
@@ -3853,11 +4007,20 @@ export async function runRouterDaemon() {
3853
4007
  const host = process.env.FCM_HOST || '127.0.0.1'
3854
4008
  const port = await listenWithFallback(server, router.port, logger, host)
3855
4009
  runtime.port = port
4010
+ // 📖 Remember the bind host so the Host-header guard knows which non-loopback
4011
+ // 📖 hostname (if any) is legitimately allowed to talk to the daemon.
4012
+ runtime.boundHost = host
3856
4013
  runtime.config.router.port = port
3857
4014
  saveConfig(runtime.config)
3858
4015
  try { writeFileSync(ROUTER_PID_PATH, String(process.pid), { mode: 0o600 }) } catch (error) { logger.warn('PID file write failed', { error: error.message }) }
3859
4016
  try { writeFileSync(ROUTER_PORT_PATH, String(port), { mode: 0o600 }) } catch (error) { logger.warn('Port file write failed', { error: error.message }) }
3860
4017
  logger.info('Router daemon started', { pid: process.pid, port, host, activeSet: runtime.routerConfig().activeSet })
4018
+ // 📖 One-time LAN warning: non-loopback binds are only useful when the
4019
+ // 📖 origin guard knows which origins to trust, so point the user at
4020
+ // 📖 FCM_ALLOWED_ORIGINS (comma-separated, e.g. "http://mybox:19280").
4021
+ if (!isLoopbackHostname(host) && !(process.env.FCM_ALLOWED_ORIGINS || '').trim()) {
4022
+ logger.warn('FCM_HOST is bound to a non-loopback address. Cross-machine browsers will be blocked unless you set FCM_ALLOWED_ORIGINS to your dashboard origin (e.g. "http://192.168.1.10:19280"). Loopback and same-origin clients keep working.')
4023
+ }
3861
4024
  void sendUsageTelemetry(runtime.config, {}, {
3862
4025
  event: 'app_daemon_start',
3863
4026
  mode: 'daemon',
@@ -3959,6 +4122,17 @@ export async function startRouterDaemonBackground() {
3959
4122
  return { ok: false, running: false, pid: child.pid, error: 'Daemon did not become healthy before timeout' }
3960
4123
  }
3961
4124
 
4125
+ // 📖 Best-effort process command lookup used to verify a PID file before
4126
+ // 📖 signalling. Returns null when `ps` is unavailable (e.g. Windows) so the
4127
+ // 📖 caller can keep the previous behavior instead of failing hard.
4128
+ function getProcessCommand(pid) {
4129
+ try {
4130
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim()
4131
+ } catch {
4132
+ return null
4133
+ }
4134
+ }
4135
+
3962
4136
  export async function stopRouterDaemon() {
3963
4137
  const pidPath = getRouterPidPath()
3964
4138
  let pid = readNumberFile(pidPath)
@@ -3975,6 +4149,14 @@ export async function stopRouterDaemon() {
3975
4149
  try { unlinkSync(pidPath) } catch {}
3976
4150
  return { ok: true, stopped: false, stalePid: pid }
3977
4151
  }
4152
+ // 📖 Safety: only SIGTERM processes whose command line names this CLI. A
4153
+ // 📖 stale or corrupted PID file could otherwise kill an unrelated process.
4154
+ // 📖 When the platform has no `ps`, skip verification rather than breaking.
4155
+ const command = getProcessCommand(pid)
4156
+ if (command !== null && !command.includes('free-coding-models')) {
4157
+ console.warn(`⚠️ PID ${pid} does not look like a free-coding-models process (${command || 'unknown command'}); not signalling`)
4158
+ return { ok: false, stopped: false, pid, error: `PID ${pid} is not a free-coding-models process` }
4159
+ }
3978
4160
  process.kill(pid, 'SIGTERM')
3979
4161
  for (let i = 0; i < 60; i += 1) {
3980
4162
  await sleep(250)
@@ -79,6 +79,13 @@ const STATE_DIRNAME = 'free-coding-models'
79
79
  let _cache = null
80
80
  let _cacheLoadedFrom = null
81
81
 
82
+ /**
83
+ * 📖 Model keys (`provider::model`) pruned this session. The flush read-merge-write
84
+ * 📖 re-reads the disk file, so without this set the merge would resurrect exactly
85
+ * 📖 the entries pruneStaleEntries() just deleted.
86
+ */
87
+ const _sessionPrunedKeys = new Set()
88
+
82
89
  // ─── Path resolution ──────────────────────────────────────────────────────────
83
90
 
84
91
  /**
@@ -189,7 +196,8 @@ export function flushRuntimeTelemetry({ path: telemetryPath, cache } = {}) {
189
196
  onDisk = null
190
197
  }
191
198
 
192
- // 📖 Merge: per-model, our deltas win on key collision.
199
+ // 📖 Merge: per-model, our deltas win on key collision. Keys pruned this
200
+ // 📖 session stay deleted instead of coming back from the stale disk snapshot.
193
201
  const merged = onDisk && typeof onDisk === 'object' ? onDisk : { version: 1, models: {}, lastUpdated: 0 }
194
202
  if (!merged.models || typeof merged.models !== 'object') merged.models = {}
195
203
  for (const [key, entry] of Object.entries(localData.models)) {
@@ -206,12 +214,20 @@ export function flushRuntimeTelemetry({ path: telemetryPath, cache } = {}) {
206
214
  merged.models[key] = entry
207
215
  }
208
216
  }
217
+ if (_sessionPrunedKeys.size > 0) {
218
+ for (const key of Object.keys(merged.models)) {
219
+ if (_sessionPrunedKeys.has(key)) delete merged.models[key]
220
+ }
221
+ }
209
222
  merged.lastUpdated = Date.now()
210
223
 
211
224
  try {
212
225
  atomicWriteJson(target, merged, 0o600)
213
226
  _cacheLoadedFrom = target
214
227
  _cache = merged
228
+ // 📖 Deletions are persisted now; stop excluding so future external writes
229
+ // 📖 for the same keys still merge in.
230
+ _sessionPrunedKeys.clear()
215
231
  return true
216
232
  } catch {
217
233
  return false
@@ -229,6 +245,8 @@ export function clearRuntimeTelemetry({ path: telemetryPath } = {}) {
229
245
  const target = telemetryPath ?? getRuntimeTelemetryPath()
230
246
  _cache = null
231
247
  _cacheLoadedFrom = null
248
+ // 📖 The file is gone, so tracked deletions have nothing left to override.
249
+ _sessionPrunedKeys.clear()
232
250
  try {
233
251
  fs.unlinkSync(target)
234
252
  return true
@@ -342,6 +360,9 @@ export function recordModelCall(providerKey, modelId, callResult, opts = {}) {
342
360
 
343
361
  if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
344
362
  _cache = cache
363
+ // 📖 A live re-record of a pruned model means it is routing again: stop
364
+ // 📖 excluding it from disk merges.
365
+ _sessionPrunedKeys.delete(key)
345
366
  }
346
367
 
347
368
  return { written: true }
@@ -531,6 +552,11 @@ export function pruneStaleEntries(maxAgeMs, opts = {}) {
531
552
  if (!entry) continue
532
553
  if (!entry.lastUpdated || now - entry.lastUpdated > maxAgeMs) {
533
554
  delete cache.models[key]
555
+ // 📖 Remember the deletion so flushRuntimeTelemetry's read-merge-write does
556
+ // 📖 not resurrect this entry from the still-stale disk file.
557
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
558
+ _sessionPrunedKeys.add(key)
559
+ }
534
560
  pruned++
535
561
  }
536
562
  }
@@ -16,7 +16,7 @@
16
16
  * → safeJsonParse — JSON.parse with fallback
17
17
  * → maskApiKey — Mask API key for display (show last 4 chars)
18
18
  *
19
- * @exports sleep, ensureDir, readJson, writeJson, atomicWriteJson, safeJsonParse, maskApiKey
19
+ * @exports sleep, ensureDir, readJson, writeJson, atomicWriteJson, safeJsonParse, shellSingleQuote, maskApiKey
20
20
  */
21
21
 
22
22
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
@@ -94,6 +94,17 @@ export function safeJsonParse(raw, fallback = null) {
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * 📖 Single-quote shell escaping for values interpolated into sourced env files.
99
+ * 📖 Wraps the value in single quotes and escapes embedded single quotes the
100
+ * 📖 POSIX way (`'` -> `'\''`) so quotes, `$`, and backticks stay literal.
101
+ * @param {string} value
102
+ * @returns {string}
103
+ */
104
+ export function shellSingleQuote(value) {
105
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
106
+ }
107
+
97
108
  /**
98
109
  * 📖 Mask an API key for display. Shows last 4 chars, rest as bullets.
99
110
  * @param {string} key
@@ -35,7 +35,7 @@
35
35
  * @see src/provider-metadata.js — ENV_VAR_NAMES maps provider → env var name
36
36
  */
37
37
 
38
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'
38
+ import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'
39
39
  import { homedir } from 'node:os'
40
40
  import { join } from 'node:path'
41
41
  import * as readline from 'node:readline'
@@ -191,6 +191,8 @@ export function syncShellEnv(config) {
191
191
  try {
192
192
  const content = buildEnvContent(config, shell)
193
193
  writeFileSync(envPath, content, { mode: 0o600 })
194
+ // 📖 chmod also tightens pre-existing files created before the mode option was added.
195
+ chmodSync(envPath, 0o600)
194
196
  return { success: true, envPath }
195
197
  } catch (err) {
196
198
  return { success: false, envPath, error: err.message }
@@ -10,7 +10,7 @@
10
10
  * - PostHog is used for product analytics (`app_start`, `app_use`, and lightweight
11
11
  * `app_action` events covering launches and key product actions).
12
12
  * - Discord webhooks carry anonymous feature requests (J key) and bug reports (I key).
13
- * - `isTelemetryEnabled()` checks: CLI flag → env var → default (enabled).
13
+ * - `isTelemetryEnabled()` checks: CLI flag → env var → config opt-out → default (enabled).
14
14
  * - `telemetryDebug()` writes to stderr only when FREE_CODING_MODELS_TELEMETRY_DEBUG=1.
15
15
  * - `sendUsageTelemetry()` has a hard 1.2 s timeout so it never blocks startup.
16
16
  *
@@ -20,6 +20,8 @@
20
20
  * - FREE_CODING_MODELS_POSTHOG_KEY — override the PostHog project key
21
21
  * - FREE_CODING_MODELS_POSTHOG_HOST — override the PostHog host
22
22
  * - POSTHOG_PROJECT_API_KEY / POSTHOG_HOST — standard PostHog env vars (fallback)
23
+ * - FCM_DISCORD_FEATURE_WEBHOOK - feature request webhook URL
24
+ * - FCM_DISCORD_BUG_WEBHOOK - bug report webhook URL
23
25
  *
24
26
  * @functions
25
27
  * → parseTelemetryEnv(value) — Convert env string to boolean or null
@@ -63,12 +65,15 @@ const POSTHOG_PROJECT_KEY_DEFAULT = 'phc_5P1n8HaLof6nHM0tKJYt4bV5pj2XPb272fLVigw
63
65
  const POSTHOG_HOST_DEFAULT = 'https://eu.i.posthog.com'
64
66
 
65
67
  // 📖 Discord feature request webhook configuration (anonymous feedback system).
66
- const DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476709155992764427/hmnHNtpducvi5LClhv8DynENjUmmg9q8HI1Bx1lNix56UHqrqZf55rW95LGvNJ2W4j7D'
68
+ // 📖 FCM_DISCORD_FEATURE_WEBHOOK: env var holding the feature-request webhook URL.
69
+ // 📖 When unset, the feature request action reports "not configured" instead of posting.
70
+ const DISCORD_WEBHOOK_URL = (process.env.FCM_DISCORD_FEATURE_WEBHOOK || '').trim()
67
71
  const DISCORD_BOT_NAME = 'TUI - Feature Requests'
68
72
  const DISCORD_EMBED_COLOR = 0x39FF14 // Vert fluo (RGB: 57, 255, 20)
69
73
 
70
74
  // 📖 Discord bug report webhook configuration (anonymous bug reports).
71
- const DISCORD_BUG_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476715954409963743/5cOLf7U_891f1jwxRBLIp2RIP9xYhr4rWtOhipzKKwVdFVl1Bj89X_fB6I_uGXZiGT9E'
75
+ // 📖 FCM_DISCORD_BUG_WEBHOOK: env var holding the bug report webhook URL.
76
+ const DISCORD_BUG_WEBHOOK_URL = (process.env.FCM_DISCORD_BUG_WEBHOOK || '').trim()
72
77
  const DISCORD_BUG_BOT_NAME = 'TUI Bug Report'
73
78
  const DISCORD_BUG_EMBED_COLOR = 0xFF5733 // Rouge (RGB: 255, 87, 51)
74
79
 
@@ -194,7 +199,7 @@ export function getTelemetryTerminal() {
194
199
 
195
200
  /**
196
201
  * 📖 Resolve telemetry effective state with clear precedence:
197
- * 📖 CLI flag > env var > enabled by default (forced for all users).
202
+ * 📖 CLI flag > env var > config opt-out > enabled by default.
198
203
  * @param {Record<string, unknown>} config
199
204
  * @param {{ noTelemetry?: boolean }} cliArgs
200
205
  * @returns {boolean}
@@ -204,7 +209,9 @@ export function isTelemetryEnabled(config, cliArgs) {
204
209
  const envTelemetry = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
205
210
  if (envTelemetry !== null) return envTelemetry
206
211
  ensureTelemetryConfig(config)
207
- return true
212
+ // 📖 Honor a persisted opt-out (config telemetry.enabled=false) set from the UI;
213
+ // 📖 CLI flags and FREE_CODING_MODELS_TELEMETRY still override it above.
214
+ return config.telemetry.enabled !== false
208
215
  }
209
216
 
210
217
  /**
@@ -314,6 +321,9 @@ export async function sendUsageTelemetry(config, cliArgs, payload) {
314
321
  * @returns {Promise<{ success: boolean, error: string|null }>}
315
322
  */
316
323
  export async function sendFeatureRequest(message) {
324
+ if (!DISCORD_WEBHOOK_URL) {
325
+ return { success: false, error: 'feedback webhook not configured (set FCM_DISCORD_FEATURE_WEBHOOK)' }
326
+ }
317
327
  try {
318
328
  // 📖 Collect anonymous telemetry for context (no personal data)
319
329
  const system = getTelemetrySystem()
@@ -361,6 +371,9 @@ export async function sendFeatureRequest(message) {
361
371
  * @returns {Promise<{ success: boolean, error: string|null }>}
362
372
  */
363
373
  export async function sendBugReport(message) {
374
+ if (!DISCORD_BUG_WEBHOOK_URL) {
375
+ return { success: false, error: 'feedback webhook not configured (set FCM_DISCORD_BUG_WEBHOOK)' }
376
+ }
364
377
  try {
365
378
  // 📖 Collect anonymous telemetry for context (no personal data)
366
379
  const system = getTelemetrySystem()