free-coding-models 0.5.86 → 0.5.88

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 (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. package/web/styles.legacy.css +0 -963
@@ -134,10 +134,11 @@ export function parseSiliconFlowResponse(responseData) {
134
134
  * Create an in-memory cache entry.
135
135
  * @param {number|null} value
136
136
  * @param {number} expiresAt - Date.now() timestamp
137
- * @returns {{ value: number|null, expiresAt: number }}
137
+ * @param {number} [resolvedAt=Date.now()] - when the value was actually fetched
138
+ * @returns {{ value: number|null, expiresAt: number, resolvedAt: number }}
138
139
  */
139
- function makeCacheEntry(value, expiresAt) {
140
- return { value, expiresAt }
140
+ function makeCacheEntry(value, expiresAt, resolvedAt = Date.now()) {
141
+ return { value, expiresAt, resolvedAt }
141
142
  }
142
143
 
143
144
  // ─── Endpoint definitions ─────────────────────────────────────────────────────
@@ -320,7 +321,8 @@ export async function fetchProviderQuota(providerKey, apiKey, options = {}) {
320
321
  const pendingPromise = doFetch()
321
322
  .then((value) => {
322
323
  const finalValue = (typeof value === 'number' && Number.isFinite(value)) ? value : null
323
- _defaultCache.set(cacheKey, makeCacheEntry(finalValue, Date.now() + cacheTtlMs))
324
+ const resolvedAt = Date.now()
325
+ _defaultCache.set(cacheKey, makeCacheEntry(finalValue, resolvedAt + cacheTtlMs, resolvedAt))
324
326
  return finalValue
325
327
  })
326
328
  .catch(() => {
@@ -483,12 +485,17 @@ function getActiveSnapshot(providerKey, now = Date.now()) {
483
485
  if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) continue
484
486
  const value = entry.value
485
487
  if (typeof value !== 'number' || !Number.isFinite(value)) continue
488
+ // 📖 lastUpdated must be when the value was actually fetched, not "now": a
489
+ // 📖 TTL-cached value can be up to 60s old, and stamping it with now made it
490
+ // 📖 always beat real passive header snapshots in getQuota's freshest-wins
491
+ // 📖 pick while fabricating a fresh remaining/limit.
492
+ const resolvedAt = typeof entry.resolvedAt === 'number'
493
+ ? entry.resolvedAt
494
+ : entry.expiresAt - DEFAULT_CACHE_TTL_MS
486
495
  return makeSnapshot(
487
496
  { remaining: value, limit: 100, percent: value, windowType: 'unknown', source: 'active_fetcher' },
488
497
  'endpoint',
489
- // 📖 active fetcher stores wall-clock ms at fetch time; expose it so
490
- // 📖 getQuota's freshest-wins logic works on real timestamps.
491
- now,
498
+ resolvedAt,
492
499
  )
493
500
  }
494
501
  return null
@@ -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
  }