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
@@ -17,21 +17,34 @@
17
17
  * Now checkConfigSecurity() is async, awaited by the bin entry BEFORE the TUI
18
18
  * starts, and it never prompts on non-TTY stdin or daemon/web/JSON surfaces.
19
19
  *
20
- * 📖 Secure permissions:
21
- * - 0o600 (octal 600) = user:rw, group:---, world:---
22
- * - Only the file owner can read or write
23
- * - This is the standard for files containing secrets (SSH keys, API keys, etc.)
20
+ * 📖 Issue #173 follow-up (rutexd): on Windows the "fix" never persisted and the
21
+ * warning re-fired on every launch. Root cause: Node maps win32 modes to only
22
+ * 0666 (writable) or 0444 (read-only), so 0600 is unreachable and
23
+ * `(mode & 0o777) === 0o600` was always false; chmod 600 on win32 just clears
24
+ * the read-only bit. The real fix:
25
+ * - the Windows verdict now comes from the NTFS ACL via `icacls <file>`
26
+ * - fixing on Windows runs `icacls /inheritance:r /grant:r <user>:F` and is
27
+ * verified by re-reading the ACL before claiming success
28
+ * - when the fix cannot be applied or verified, an ack marker file
29
+ * (<config>.securityack) keeps the warning quiet for 30 days instead of
30
+ * nagging on every launch (--fix-permissions bypasses the marker)
31
+ * POSIX behaviour (chmod 600) is unchanged on macOS/Linux.
24
32
  *
25
- * 📖 Windows note: Node's chmod on win32 is best-effort (it can only toggle the
26
- * read-only attribute, NTFS ACLs still govern real access). We still try,
27
- * and the manual hint points at icacls for a real fix.
33
+ * 📖 Secure permissions:
34
+ * - POSIX: 0o600 (octal 600) = user:rw, group:---, world:---
35
+ * - Windows: NTFS inheritance disabled, grants only for the current user
36
+ * (SYSTEM/Administrators whitelisted) - see parseIcaclsOutput in utils.js
28
37
  *
29
38
  * @functions
30
39
  * → checkConfigSecurity() - Async main security check; awaited before the TUI starts
31
40
  * → resolveSecurityAction() - Pure gate deciding auto-fix / prompt / warn-only (in utils.js)
41
+ * → parseIcaclsOutput() - Pure parser for icacls output (in utils.js)
42
+ * → shouldSkipSecurityWarn() - Pure anti-nag gate for repeat warnings (in utils.js)
32
43
  * → getConfigPermissions() - Returns file mode object for config
33
- * → isConfigSecure() - Boolean check if permissions are correct
34
- * → fixConfigPermissions() - Applies chmod 600 to config file (best-effort on Windows)
44
+ * → isConfigSecure() - Boolean check if permissions are correct (POSIX modes)
45
+ * → fixConfigPermissions() - Applies chmod 600 to config file (POSIX)
46
+ * → getWindowsAclStatus() - Async icacls read + parse → is the ACL secure?
47
+ * → fixWindowsAcl() - Async icacls fix + ACL re-verify
35
48
  * → promptSecurityFix() - Interactive prompt asking user to fix permissions
36
49
  *
37
50
  * @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions, formatMode, formatModeRwx
@@ -41,8 +54,12 @@ import fs from 'node:fs'
41
54
  import path from 'node:path'
42
55
  import os from 'node:os'
43
56
  import readline from 'node:readline'
57
+ import { execFile } from 'node:child_process'
58
+ import { promisify } from 'node:util'
44
59
  import { CONFIG_PATH } from './config.js'
45
- import { resolveSecurityAction } from './utils.js'
60
+ import { resolveSecurityAction, parseIcaclsOutput, shouldSkipSecurityWarn } from './utils.js'
61
+
62
+ const execFileAsync = promisify(execFile)
46
63
 
47
64
  // 📖 Config file path - matches the path used in config.js (honours the
48
65
  // 📖 --config-dir / FCM_CONFIG_DIR override when set).
@@ -91,7 +108,8 @@ export function isConfigSecure() {
91
108
  }
92
109
 
93
110
  // 📖 Fix config file permissions to secure mode (chmod 600)
94
- // 📖 Best-effort on Windows (read-only bit); returns true if successful, false otherwise
111
+ // 📖 POSIX path; on Windows this is NOT the real fix (see fixWindowsAcl) and is
112
+ // 📖 kept only as a harmless last-ditch fallback. Returns true if successful.
95
113
  export function fixConfigPermissions() {
96
114
  const configPath = getConfigPath()
97
115
 
@@ -107,6 +125,88 @@ export function fixConfigPermissions() {
107
125
  }
108
126
  }
109
127
 
128
+ // 📖 Current Windows user name, used both for icacls grants and for matching
129
+ // 📖 ACE lines when parsing the ACL (os.userInfo works on win32 too).
130
+ function getWindowsUserName() {
131
+ try {
132
+ return os.userInfo().username
133
+ } catch {
134
+ return process.env.USERNAME || ''
135
+ }
136
+ }
137
+
138
+ // 📖 Read the real NTFS ACL via icacls and decide if it is secure:
139
+ // 📖 inheritance disabled + grants only for the current user (and trusted
140
+ // 📖 SYSTEM/Administrators entries). Returns { checked, secure, ...parsed };
141
+ // 📖 checked=false when icacls is missing or failed (caller falls back to the
142
+ // 📖 ack-gated warning path instead of trusting useless mode bits).
143
+ async function getWindowsAclStatus(configPath) {
144
+ try {
145
+ const { stdout } = await execFileAsync('icacls', [configPath], {
146
+ timeout: 5000,
147
+ windowsHide: true,
148
+ })
149
+ const parsed = parseIcaclsOutput({ output: stdout, userName: getWindowsUserName(), filePath: configPath })
150
+ const secure = !parsed.inheritanceEnabled && !parsed.othersHaveAccess && parsed.ownerHasAccess
151
+ return { checked: true, secure, ...parsed }
152
+ } catch (err) {
153
+ return { checked: false, secure: false, grants: [], otherNames: [] }
154
+ }
155
+ }
156
+
157
+ // 📖 Fix the NTFS ACL for real: drop inherited ACEs, grant only the current
158
+ // 📖 user full control, then VERIFY by re-reading the ACL (never claim success
159
+ // 📖 without proof - that lie was the core of the issue #173 follow-up).
160
+ // 📖 execFile (no shell) keeps the path injection-safe even with spaces.
161
+ async function fixWindowsAcl(configPath) {
162
+ const userName = getWindowsUserName()
163
+ if (!userName) return false
164
+
165
+ try {
166
+ await execFileAsync(
167
+ 'icacls',
168
+ [configPath, '/inheritance:r', '/grant:r', `${userName}:F`],
169
+ { timeout: 5000, windowsHide: true }
170
+ )
171
+ const status = await getWindowsAclStatus(configPath)
172
+ return status.checked && status.secure
173
+ } catch (err) {
174
+ return false
175
+ }
176
+ }
177
+
178
+ // 📖 Anti-nag marker: written when we warned but could not fix/verify, so the
179
+ // 📖 same warning does not re-fire on every launch (see shouldSkipSecurityWarn).
180
+ // 📖 Lives next to the config so --config-dir / FCM_CONFIG_DIR stays coherent.
181
+ function getAckPath() {
182
+ return `${getConfigPath()}.securityack`
183
+ }
184
+
185
+ function readSecurityAck() {
186
+ try {
187
+ return fs.readFileSync(getAckPath(), 'utf8').trim()
188
+ } catch {
189
+ return null
190
+ }
191
+ }
192
+
193
+ function writeSecurityAck() {
194
+ try {
195
+ fs.writeFileSync(getAckPath(), new Date().toISOString(), { mode: 0o600 })
196
+ return true
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ function clearSecurityAck() {
203
+ try {
204
+ fs.unlinkSync(getAckPath())
205
+ } catch {
206
+ // 📖 Nothing to clean up is fine.
207
+ }
208
+ }
209
+
110
210
  // 📖 Format permission mode in octal (e.g., 0o644 → "644")
111
211
  // 📖 Exported for unit tests
112
212
  export function formatMode(mode) {
@@ -133,7 +233,9 @@ export function formatModeRwx(mode) {
133
233
  }
134
234
 
135
235
  // 📖 Print the insecure-permissions warning (stderr, so --json stdout stays clean)
136
- function printSecurityWarning(perms) {
236
+ // 📖 On Windows, mode bits are meaningless (always 0666/0444), so when the ACL
237
+ // 📖 was read we show WHAT actually grants access instead of a fake octal story.
238
+ function printSecurityWarning(perms, aclStatus = { checked: false }) {
137
239
  const currentMode = formatMode(perms.mode)
138
240
  const currentRwx = formatModeRwx(perms.mode)
139
241
 
@@ -145,16 +247,29 @@ function printSecurityWarning(perms) {
145
247
  console.error('')
146
248
  console.error('This means other users on this system may be able to read your API keys.')
147
249
  console.error('')
148
- console.error('Recommended: Fix permissions to 600 (rw-------) - owner read/write only')
250
+
251
+ if (aclStatus.checked) {
252
+ if (aclStatus.inheritanceEnabled) {
253
+ console.error('NTFS: this file inherits access permissions from your user folder.')
254
+ }
255
+ if (aclStatus.othersHaveAccess) {
256
+ const names = (aclStatus.otherNames || []).slice(0, 3).join(', ')
257
+ console.error(`NTFS: access is also granted to: ${names}`)
258
+ }
259
+ console.error('')
260
+ }
261
+
262
+ console.error('Recommended: restrict access to your user account only')
149
263
  }
150
264
 
151
- // 📖 Print the manual fix hint. On Windows, point at icacls since Node's chmod
152
- // 📖 only toggles the read-only attribute there.
265
+ // 📖 Print the manual fix hint. Platform-matched command: icacls on Windows
266
+ // 📖 (NTFS ACLs), chmod elsewhere.
153
267
  function printManualFixHint() {
154
268
  console.error('')
155
269
  if (IS_WINDOWS) {
270
+ const user = getWindowsUserName() || '$env:USERNAME'
156
271
  console.error('To fix manually (PowerShell), run:')
157
- console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "$env:USERNAME:R,W"`)
272
+ console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "${user}:F"`)
158
273
  } else {
159
274
  console.error('To fix manually, run:')
160
275
  console.error(` chmod 600 ${getConfigPath()}`)
@@ -164,24 +279,37 @@ function printManualFixHint() {
164
279
 
165
280
  // 📖 Apply the fix and report the outcome. Shared by the prompt path (user said
166
281
  // 📖 yes) and the auto-fix path (--fix-permissions / --yes / -y).
167
- function applyFixAndReport() {
168
- const success = fixConfigPermissions()
282
+ // 📖 Windows: icacls ACL fix, verified by re-reading the ACL.
283
+ // 📖 POSIX: chmod 600, verified by re-statting the file.
284
+ // 📖 If the fix cannot be verified, write the anti-nag ack so the warning does
285
+ // 📖 not re-fire on every launch, and point at the manual command.
286
+ async function applyFixAndReport() {
287
+ const configPath = getConfigPath()
288
+ let success = false
289
+
290
+ if (IS_WINDOWS) {
291
+ success = await fixWindowsAcl(configPath)
292
+ } else {
293
+ success = fixConfigPermissions() && getConfigPermissions()?.isSecure === true
294
+ }
169
295
 
170
296
  if (success) {
297
+ clearSecurityAck()
171
298
  console.error('')
172
299
  console.error('✅ Permissions fixed! Your API keys are now secure.')
173
300
  console.error('')
174
301
  if (IS_WINDOWS) {
175
- console.error('Note: on Windows this is best-effort (read-only bit). See docs for NTFS ACLs.')
302
+ console.error(`NTFS access is now restricted to "${getWindowsUserName()}".`)
176
303
  console.error('')
177
304
  }
178
305
  return { wasSecure: false, wasFixed: true }
179
306
  }
180
307
 
308
+ writeSecurityAck()
181
309
  console.error('')
182
310
  console.error('❌ Failed to fix permissions automatically.')
183
311
  printManualFixHint()
184
- return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
312
+ return { wasSecure: false, wasFixed: false, error: 'fix_failed' }
185
313
  }
186
314
 
187
315
  // 📖 Check security and handle the fix flow if needed
@@ -189,9 +317,14 @@ function applyFixAndReport() {
189
317
  // 📖 the confirmation prompt are visible and fully resolved before raw mode /
190
318
  // 📖 the alternate screen take over.
191
319
  //
320
+ // 📖 Windows verdict (issue #173 follow-up): mode bits on win32 are only ever
321
+ // 📖 0666 or 0444, so the POSIX 0600 check would nag forever. The real verdict
322
+ // 📖 comes from the NTFS ACL (icacls). If icacls is unavailable, fall through to
323
+ // 📖 the warning path but let the anti-nag ack keep it to once per 30 days.
324
+ //
192
325
  // 📖 Options:
193
326
  // autoFix - true when --fix-permissions / --yes / -y was passed: apply the
194
- // fix without asking
327
+ // fix without asking (also bypasses the anti-nag ack)
195
328
  // promptAllowed - false on daemon/web/JSON surfaces: never prompt there, at most
196
329
  // warn on stderr
197
330
  // stdinIsTTY - override the stdin TTY detection (tests); defaults to real detection
@@ -205,11 +338,23 @@ export async function checkConfigSecurity(options = {}) {
205
338
  return { wasSecure: true, wasFixed: false }
206
339
  }
207
340
 
208
- // 📖 Permissions are already secure
209
- if (perms.isSecure) {
341
+ // 📖 Security verdict, platform-aware.
342
+ let aclStatus = { checked: false, secure: false, grants: [], otherNames: [] }
343
+ if (IS_WINDOWS) {
344
+ aclStatus = await getWindowsAclStatus(perms.path)
345
+ if (aclStatus.checked && aclStatus.secure) {
346
+ return { wasSecure: true, wasFixed: false }
347
+ }
348
+ } else if (perms.isSecure) {
210
349
  return { wasSecure: true, wasFixed: false }
211
350
  }
212
351
 
352
+ // 📖 Anti-nag gate: if we already warned recently and the fix did not stick,
353
+ // 📖 stay quiet instead of nagging on every launch. --fix-permissions bypasses.
354
+ if (options.autoFix !== true && shouldSkipSecurityWarn({ ackedAt: readSecurityAck() })) {
355
+ return { wasSecure: false, wasFixed: false, error: 'warned_recently' }
356
+ }
357
+
213
358
  // 📖 Pure gate (see utils.js): decides auto-fix vs prompt vs warn-only.
214
359
  const action = resolveSecurityAction({
215
360
  configExists: true,
@@ -225,7 +370,7 @@ export async function checkConfigSecurity(options = {}) {
225
370
 
226
371
  // 📖 Security issue detected! Print the warning first so it is on screen
227
372
  // 📖 no matter which path follows.
228
- printSecurityWarning(perms)
373
+ printSecurityWarning(perms, aclStatus)
229
374
 
230
375
  if (action === 'auto-fix') {
231
376
  return applyFixAndReport()
@@ -234,6 +379,7 @@ export async function checkConfigSecurity(options = {}) {
234
379
  if (action === 'warn-only') {
235
380
  console.error('Running non-interactively (piped stdin or daemon/web mode), so skipping the prompt.')
236
381
  printManualFixHint()
382
+ writeSecurityAck()
237
383
  return { wasSecure: false, wasFixed: false, error: 'non_interactive' }
238
384
  }
239
385
 
@@ -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()
@@ -48,7 +48,7 @@ import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
48
48
  import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
49
49
  import { PROVIDER_METADATA } from './provider-metadata.js'
50
50
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
51
- import { ensureDir, readJson, writeJson } from './shared-helpers.js'
51
+ import { ensureDir, readJson, writeJson, shellSingleQuote } from './shared-helpers.js'
52
52
  import { parseContextWindow } from './endpoint-installer.js'
53
53
  import { resolveCloudflareUrl } from './ping.js'
54
54
 
@@ -70,9 +70,11 @@ function parseCtxToTokens(ctx) {
70
70
  if (!match) return null
71
71
  const num = parseFloat(match[1])
72
72
  const suffix = match[2].toLowerCase()
73
- // 📖 LLM token counts use binary (1024), not decimal (1000).
74
- if (suffix === 'k') return Math.round(num * 1024)
75
- if (suffix === 'm') return Math.round(num * 1024 * 1024)
73
+ // 📖 Catalog ctx strings use DECIMAL semantics ("1M" = 1,000,000), matching
74
+ // 📖 parseContextWindow in endpoint-installer.js. The old 1024 multipliers made
75
+ // 📖 the two parsers disagree (1048576 vs 1000000) for the same catalog value.
76
+ if (suffix === 'k') return Math.round(num * 1000)
77
+ if (suffix === 'm') return Math.round(num * 1000 * 1000)
76
78
  return Math.round(num)
77
79
  }
78
80
 
@@ -414,10 +416,11 @@ function writeOpenHandsEnv(model, apiKey, baseUrl, paths = getDefaultToolPaths()
414
416
  const backupPath = backupIfExists(filePath)
415
417
  const lines = [
416
418
  '# 📖 Managed by free-coding-models',
417
- `export OPENAI_API_KEY="${apiKey}"`,
419
+ // 📖 Single-quote the key so quotes/$()/backticks in it stay literal on `source`.
420
+ `export OPENAI_API_KEY=${shellSingleQuote(apiKey)}`,
418
421
  `export OPENAI_BASE_URL="${baseUrl}"`,
419
422
  `export OPENAI_MODEL="${model.modelId}"`,
420
- `export LLM_API_KEY="${apiKey}"`,
423
+ `export LLM_API_KEY=${shellSingleQuote(apiKey)}`,
421
424
  `export LLM_BASE_URL="${baseUrl}"`,
422
425
  `export LLM_MODEL="openai/${model.modelId}"`,
423
426
  ]
@@ -31,6 +31,7 @@
31
31
  * → resolveCurrentNpmInstallTarget() — Detect the npm prefix that owns the active package
32
32
  * → getInstallArgs(pm, version) — Build correct { bin, args } per package manager
33
33
  * → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
34
+ * → isNewerVersion(remote, local) - Semver compare: true only when remote is greater
34
35
  * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
35
36
  * → checkForUpdate() — Startup wrapper, returns version string or null
36
37
  * → isPackageDevMode() — Detect git/dev checkouts that must not self-update
@@ -38,7 +39,7 @@
38
39
  * → runUpdate(latestVersion) — Install new version via detected PM + relaunch
39
40
  * @exports
40
41
  * detectPackageManager, resolveCurrentNpmInstallTarget, getInstallArgs, getManualInstallCmd,
41
- * checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
42
+ * isNewerVersion, checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
42
43
  * enforceMandatoryStartupUpdate, runUpdate, fetchLastReleaseDate
43
44
  *
44
45
  * @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
@@ -232,6 +233,31 @@ export function getManualInstallCmd(pm, version, options = {}) {
232
233
  return [bin, ...args].map(shellQuoteArg).join(' ')
233
234
  }
234
235
 
236
+ /**
237
+ * 📖 isNewerVersion: dependency-free semver comparison of dotted numeric versions.
238
+ * 📖 Update availability used to be a string inequality, so a dist-tag rollback
239
+ * 📖 (remote < local) triggered a pointless "downgrade" install. Only a strictly
240
+ * 📖 GREATER remote version counts as an update now.
241
+ * @param {string} remoteVersion
242
+ * @param {string} localVersion
243
+ * @returns {boolean} true only when remoteVersion > localVersion
244
+ */
245
+ export function isNewerVersion(remoteVersion, localVersion) {
246
+ const parseParts = (v) => String(v ?? '')
247
+ .trim()
248
+ .replace(/^v/i, '')
249
+ .split('.')
250
+ .map(part => parseInt(part, 10) || 0)
251
+ const remote = parseParts(remoteVersion)
252
+ const local = parseParts(localVersion)
253
+ const len = Math.max(remote.length, local.length)
254
+ for (let i = 0; i < len; i++) {
255
+ const diff = (remote[i] || 0) - (local[i] || 0)
256
+ if (diff !== 0) return diff > 0
257
+ }
258
+ return false
259
+ }
260
+
235
261
  /**
236
262
  * 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
237
263
  * 📖 Used by settings manual-check flow to display meaningful status in the UI.
@@ -242,7 +268,7 @@ export async function checkForUpdateDetailed() {
242
268
  const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
243
269
  if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
244
270
  const data = await res.json()
245
- if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
271
+ if (data.version && isNewerVersion(data.version, LOCAL_VERSION)) return { latestVersion: data.version, error: null }
246
272
  return { latestVersion: null, error: null }
247
273
  } catch (error) {
248
274
  const message = error instanceof Error ? error.message : 'Unknown error'