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.
- package/README.md +110 -881
- package/bin/free-coding-models.js +4 -1
- package/changelog/v0.5.87.md +48 -0
- package/package.json +8 -5
- package/sources.js +4 -4
- package/src/core/cache.js +3 -1
- package/src/core/config.js +43 -5
- package/src/core/endpoint-installer.js +23 -12
- package/src/core/installed-models-manager.js +43 -20
- package/src/core/kilo-config.js +51 -5
- package/src/core/kilo.js +5 -3
- package/src/core/legacy-proxy-cleanup.js +22 -5
- package/src/core/model-merger.js +4 -0
- package/src/core/models-drift.js +9 -0
- package/src/core/opencode-config.js +57 -5
- package/src/core/opencode.js +67 -41
- package/src/core/playground.js +7 -0
- package/src/core/probe-cache.js +35 -4
- package/src/core/provider-key-tester.js +10 -2
- package/src/core/provider-metadata.js +10 -0
- package/src/core/provider-quota-fetchers.js +14 -7
- package/src/core/router-daemon.js +208 -26
- package/src/core/runtime-telemetry.js +27 -1
- package/src/core/shared-helpers.js +12 -1
- package/src/core/shell-env.js +3 -1
- package/src/core/telemetry.js +18 -5
- package/src/core/tool-launchers.js +9 -6
- package/src/core/updater.js +28 -2
- package/src/core/utils.js +76 -71
- package/src/tui/app.js +38 -14
- package/src/tui/command-palette.js +22 -2
- package/src/tui/key-handler.js +123 -179
- package/src/tui/overlays.js +21 -71
- package/src/tui/render-helpers.js +62 -4
- package/src/tui/render-table.js +34 -3
- package/src/tui/theme.js +36 -5
- package/src/tui/tui-filters.js +28 -8
- package/src/tui/tui-state.js +22 -6
- package/web/dist/assets/index-BpTpPS0g.js +44 -0
- package/web/dist/assets/index-CAzFIt8P.css +1 -0
- package/web/dist/index.html +5 -9
- package/web/index.html +3 -7
- package/web/server.js +97 -23
- package/web/vite.config.js +8 -0
- package/web/app.legacy.js +0 -900
- package/web/dist/assets/index-C5-Ywvv3.css +0 -1
- package/web/dist/assets/index-Dm-Xfby9.js +0 -40
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +0 -12
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +0 -25
- package/web/src/App.jsx +0 -521
- package/web/src/components/analytics/AnalyticsView.jsx +0 -147
- package/web/src/components/analytics/AnalyticsView.module.css +0 -190
- package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
- package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
- package/web/src/components/atoms/AILatencyCell.jsx +0 -38
- package/web/src/components/atoms/AILatencyCell.module.css +0 -51
- package/web/src/components/atoms/HealthCell.jsx +0 -143
- package/web/src/components/atoms/HealthCell.module.css +0 -60
- package/web/src/components/atoms/LastPingCell.jsx +0 -35
- package/web/src/components/atoms/LastPingCell.module.css +0 -45
- package/web/src/components/atoms/MoodCell.jsx +0 -25
- package/web/src/components/atoms/MoodCell.module.css +0 -6
- package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
- package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
- package/web/src/components/atoms/ProviderLogo.jsx +0 -187
- package/web/src/components/atoms/ProviderLogo.module.css +0 -103
- package/web/src/components/atoms/RankCell.jsx +0 -9
- package/web/src/components/atoms/RankCell.module.css +0 -9
- package/web/src/components/atoms/Sparkline.jsx +0 -63
- package/web/src/components/atoms/StabilityCell.jsx +0 -18
- package/web/src/components/atoms/StabilityCell.module.css +0 -13
- package/web/src/components/atoms/StatusDot.jsx +0 -13
- package/web/src/components/atoms/StatusDot.module.css +0 -29
- package/web/src/components/atoms/TPSCell.jsx +0 -36
- package/web/src/components/atoms/TPSCell.module.css +0 -44
- package/web/src/components/atoms/TierBadge.jsx +0 -10
- package/web/src/components/atoms/TierBadge.module.css +0 -29
- package/web/src/components/atoms/Toast.jsx +0 -25
- package/web/src/components/atoms/Toast.module.css +0 -41
- package/web/src/components/atoms/ToastContainer.jsx +0 -16
- package/web/src/components/atoms/ToastContainer.module.css +0 -10
- package/web/src/components/atoms/VerdictBadge.jsx +0 -36
- package/web/src/components/atoms/VerdictBadge.module.css +0 -40
- package/web/src/components/changelog/ChangelogView.jsx +0 -135
- package/web/src/components/changelog/ChangelogView.module.css +0 -159
- package/web/src/components/dashboard/DetailPanel.jsx +0 -217
- package/web/src/components/dashboard/DetailPanel.module.css +0 -260
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
- package/web/src/components/dashboard/ExportModal.jsx +0 -84
- package/web/src/components/dashboard/ExportModal.module.css +0 -99
- package/web/src/components/dashboard/FilterBar.jsx +0 -338
- package/web/src/components/dashboard/FilterBar.module.css +0 -342
- package/web/src/components/dashboard/ModelTable.jsx +0 -672
- package/web/src/components/dashboard/ModelTable.module.css +0 -446
- package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
- package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
- package/web/src/components/help/HelpView.jsx +0 -201
- package/web/src/components/help/HelpView.module.css +0 -156
- package/web/src/components/install/InstallEndpointsView.jsx +0 -309
- package/web/src/components/install/InstallEndpointsView.module.css +0 -427
- package/web/src/components/installed/InstalledModelsView.jsx +0 -89
- package/web/src/components/installed/InstalledModelsView.module.css +0 -200
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
- package/web/src/components/launch/LaunchButton.jsx +0 -30
- package/web/src/components/launch/LaunchButton.module.css +0 -35
- package/web/src/components/launch/LaunchModal.module.css +0 -125
- package/web/src/components/layout/Footer.jsx +0 -19
- package/web/src/components/layout/Footer.module.css +0 -10
- package/web/src/components/layout/Header.jsx +0 -265
- package/web/src/components/layout/Header.module.css +0 -357
- package/web/src/components/palette/CommandPalette.jsx +0 -301
- package/web/src/components/palette/CommandPalette.module.css +0 -121
- package/web/src/components/playground/PlaygroundChat.jsx +0 -527
- package/web/src/components/playground/PlaygroundChat.module.css +0 -382
- package/web/src/components/playground/PlaygroundView.jsx +0 -371
- package/web/src/components/playground/PlaygroundView.module.css +0 -504
- package/web/src/components/recommend/RecommendView.jsx +0 -121
- package/web/src/components/recommend/RecommendView.module.css +0 -57
- package/web/src/components/router/RouterView.jsx +0 -1212
- package/web/src/components/router/RouterView.module.css +0 -1289
- package/web/src/components/settings/SettingsView.jsx +0 -555
- package/web/src/components/settings/SettingsView.module.css +0 -550
- package/web/src/components/tools/ToolPicker.jsx +0 -86
- package/web/src/components/tools/ToolPicker.module.css +0 -97
- package/web/src/components/update/UpdateChip.jsx +0 -104
- package/web/src/components/update/UpdateChip.module.css +0 -146
- package/web/src/global.css +0 -298
- package/web/src/hooks/urlState.constants.js +0 -28
- package/web/src/hooks/useChangelog.js +0 -51
- package/web/src/hooks/useColumnSizing.js +0 -211
- package/web/src/hooks/useFavorites.js +0 -150
- package/web/src/hooks/useFilter.js +0 -310
- package/web/src/hooks/useInstalledModels.js +0 -42
- package/web/src/hooks/useRecommend.js +0 -67
- package/web/src/hooks/useRouterDashboard.js +0 -133
- package/web/src/hooks/useSSE.js +0 -73
- package/web/src/hooks/useSocket.js +0 -203
- package/web/src/hooks/useTheme.js +0 -83
- package/web/src/hooks/useTokenUsage.js +0 -103
- package/web/src/hooks/useToolMode.js +0 -77
- package/web/src/hooks/useUpdateChecker.js +0 -91
- package/web/src/hooks/useUrlState.js +0 -170
- package/web/src/main.jsx +0 -15
- package/web/src/utils/download.js +0 -15
- package/web/src/utils/format.js +0 -42
- package/web/src/utils/m3.js +0 -57
- package/web/src/utils/ranks.js +0 -37
- 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
|
|
273
|
-
// 📖 192.168.x) and hostnames that end in
|
|
274
|
-
// 📖
|
|
275
|
-
// 📖
|
|
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 (
|
|
306
|
-
|
|
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
|
-
// 📖
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
package/src/core/shell-env.js
CHANGED
|
@@ -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 }
|
package/src/core/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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()
|