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.
- package/README.md +109 -888
- package/bin/free-coding-models.js +4 -1
- package/changelog/v0.5.87.md +48 -0
- package/changelog/v0.5.88.md +16 -0
- package/package.json +13 -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/security.js +170 -24
- 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 +173 -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-CAzFIt8P.css +1 -0
- package/web/dist/assets/index-DFg1h0Nd.js +44 -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
package/src/core/security.js
CHANGED
|
@@ -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
|
-
* 📖
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
* 📖
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 (
|
|
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
|
-
// 📖
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
152
|
-
// 📖
|
|
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 "$
|
|
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
|
-
|
|
168
|
-
|
|
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(
|
|
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: '
|
|
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
|
-
// 📖
|
|
209
|
-
|
|
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
|
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()
|
|
@@ -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
|
-
// 📖
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
`
|
|
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
|
|
423
|
+
`export LLM_API_KEY=${shellSingleQuote(apiKey)}`,
|
|
421
424
|
`export LLM_BASE_URL="${baseUrl}"`,
|
|
422
425
|
`export LLM_MODEL="openai/${model.modelId}"`,
|
|
423
426
|
]
|
package/src/core/updater.js
CHANGED
|
@@ -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
|
|
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'
|