free-coding-models 0.2.5 → 0.2.8
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/CHANGELOG.md +1242 -0
- package/README.md +57 -9
- package/bin/free-coding-models.js +83 -2
- package/package.json +6 -2
- package/sources.js +8 -1
- package/src/analysis.js +5 -1
- package/src/cache.js +165 -0
- package/src/config.js +22 -4
- package/src/constants.js +4 -4
- package/src/key-handler.js +6 -0
- package/src/openclaw.js +6 -1
- package/src/opencode.js +9 -2
- package/src/overlays.js +190 -50
- package/src/provider-metadata.js +8 -7
- package/src/render-helpers.js +6 -4
- package/src/render-table.js +7 -7
- package/src/security.js +210 -0
- package/src/tool-launchers.js +6 -1
- package/src/utils.js +67 -2
package/src/security.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file security.js
|
|
3
|
+
* @description Security checks and auto-fix for config file permissions.
|
|
4
|
+
*
|
|
5
|
+
* 📖 Problem: API keys stored in ~/.free-coding-models.json must be protected.
|
|
6
|
+
* If the file has incorrect permissions (e.g., 644 = world-readable), keys can leak.
|
|
7
|
+
*
|
|
8
|
+
* 📖 This module:
|
|
9
|
+
* - Checks config file permissions on startup
|
|
10
|
+
* - Warns user if permissions are too open
|
|
11
|
+
* - Offers auto-fix option with user confirmation
|
|
12
|
+
* - Fixes permissions securely (chmod 600 = user read/write only)
|
|
13
|
+
*
|
|
14
|
+
* 📖 Secure permissions:
|
|
15
|
+
* - 0o600 (octal 600) = user:rw, group:---, world:---
|
|
16
|
+
* - Only the file owner can read or write
|
|
17
|
+
* - This is the standard for files containing secrets (SSH keys, API keys, etc.)
|
|
18
|
+
*
|
|
19
|
+
* 📖 Why this matters:
|
|
20
|
+
* - Shared systems: Other users could read your API keys
|
|
21
|
+
* - Git accidents: File could be committed with wrong permissions
|
|
22
|
+
* - Backup tools: Might copy files with permissions intact
|
|
23
|
+
*
|
|
24
|
+
* @functions
|
|
25
|
+
* → checkConfigSecurity() — Main security check, prompts for auto-fix if needed
|
|
26
|
+
* → getConfigPermissions() — Returns file mode object for config
|
|
27
|
+
* → isConfigSecure() — Boolean check if permissions are correct
|
|
28
|
+
* → fixConfigPermissions() — Applies chmod 600 to config file
|
|
29
|
+
* → promptSecurityFix() — Interactive prompt asking user to fix permissions
|
|
30
|
+
*
|
|
31
|
+
* @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs'
|
|
35
|
+
import path from 'node:path'
|
|
36
|
+
import os from 'node:os'
|
|
37
|
+
import readline from 'node:readline'
|
|
38
|
+
|
|
39
|
+
// 📖 Config file path — matches the path used in config.js
|
|
40
|
+
function getConfigPath() {
|
|
41
|
+
return path.join(os.homedir(), '.free-coding-models.json')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 📖 Secure file permissions: user read/write only (0o600 = 384 in decimal)
|
|
45
|
+
// 📖 This means: owner can read+write, group and others have no permissions
|
|
46
|
+
const SECURE_MODE = 0o600
|
|
47
|
+
|
|
48
|
+
// 📖 Get file stats including permissions for the config file
|
|
49
|
+
// 📖 Returns null if file doesn't exist
|
|
50
|
+
function getConfigPermissions() {
|
|
51
|
+
const configPath = getConfigPath()
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (!fs.existsSync(configPath)) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stats = fs.statSync(configPath)
|
|
59
|
+
return {
|
|
60
|
+
mode: stats.mode,
|
|
61
|
+
isSecure: (stats.mode & 0o777) === SECURE_MODE,
|
|
62
|
+
path: configPath
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 📖 Check if config file has secure permissions
|
|
70
|
+
// 📖 Returns true if file doesn't exist (nothing to secure) or if permissions are correct
|
|
71
|
+
export function isConfigSecure() {
|
|
72
|
+
const perms = getConfigPermissions()
|
|
73
|
+
|
|
74
|
+
// 📖 No file = nothing to secure
|
|
75
|
+
if (!perms) return true
|
|
76
|
+
|
|
77
|
+
return perms.isSecure
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 📖 Fix config file permissions to secure mode (chmod 600)
|
|
81
|
+
// 📖 Returns true if successful, false otherwise
|
|
82
|
+
export function fixConfigPermissions() {
|
|
83
|
+
const configPath = getConfigPath()
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
if (!fs.existsSync(configPath)) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fs.chmodSync(configPath, SECURE_MODE)
|
|
91
|
+
return true
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 📖 Format permission mode in octal (e.g., 0o644 → "644")
|
|
98
|
+
function formatMode(mode) {
|
|
99
|
+
return (mode & 0o777).toString(8).padStart(3, '0')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 📖 Format permission mode in human-readable rwx format (e.g., 0o644 → "rw-r--r--")
|
|
103
|
+
function formatModeRwx(mode) {
|
|
104
|
+
const perms = []
|
|
105
|
+
const types = ['r', 'w', 'x']
|
|
106
|
+
|
|
107
|
+
for (let i = 6; i >= 0; i -= 3) {
|
|
108
|
+
for (let j = 0; j < 3; j++) {
|
|
109
|
+
if (mode & (1 << (i + j))) {
|
|
110
|
+
perms.push(types[j])
|
|
111
|
+
} else {
|
|
112
|
+
perms.push('-')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
perms.slice(0, 3).join(''), // Owner permissions
|
|
119
|
+
perms.slice(3, 6).join(''), // Group permissions
|
|
120
|
+
perms.slice(6, 9).join('') // Others permissions
|
|
121
|
+
].join(' / ')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 📖 Check security and prompt for auto-fix if needed
|
|
125
|
+
// 📖 Call this on startup before loading config
|
|
126
|
+
// 📖 Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
|
|
127
|
+
export function checkConfigSecurity() {
|
|
128
|
+
const perms = getConfigPermissions()
|
|
129
|
+
|
|
130
|
+
// 📖 No file yet = nothing to check
|
|
131
|
+
if (!perms) {
|
|
132
|
+
return { wasSecure: true, wasFixed: false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 📖 Permissions are already secure
|
|
136
|
+
if (perms.isSecure) {
|
|
137
|
+
return { wasSecure: true, wasFixed: false }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 📖 Security issue detected! Show warning and offer fix.
|
|
141
|
+
const currentMode = formatMode(perms.mode)
|
|
142
|
+
const currentRwx = formatModeRwx(perms.mode)
|
|
143
|
+
|
|
144
|
+
console.error('')
|
|
145
|
+
console.error('⚠️ SECURITY WARNING ⚠️')
|
|
146
|
+
console.error('')
|
|
147
|
+
console.error(`Your config file has insecure permissions: ${currentMode} (${currentRwx})`)
|
|
148
|
+
console.error(`File: ${perms.path}`)
|
|
149
|
+
console.error('')
|
|
150
|
+
console.error('This means other users on this system may be able to read your API keys.')
|
|
151
|
+
console.error('')
|
|
152
|
+
console.error('Recommended: Fix permissions to 600 (rw-------) — owner read/write only')
|
|
153
|
+
|
|
154
|
+
return promptSecurityFix()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 📖 Interactive prompt asking user if they want to auto-fix
|
|
158
|
+
// 📖 Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
|
|
159
|
+
async function promptSecurityFix() {
|
|
160
|
+
const rl = readline.createInterface({
|
|
161
|
+
input: process.stdin,
|
|
162
|
+
output: process.stdout
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const answer = await new Promise((resolve) => {
|
|
167
|
+
rl.question('Fix permissions automatically? (Y/n): ', resolve)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
rl.close()
|
|
171
|
+
|
|
172
|
+
// 📖 Default to yes if user just presses Enter
|
|
173
|
+
if (answer.toLowerCase() === 'y' || answer === '') {
|
|
174
|
+
const success = fixConfigPermissions()
|
|
175
|
+
|
|
176
|
+
if (success) {
|
|
177
|
+
console.error('')
|
|
178
|
+
console.error('✅ Permissions fixed! Your API keys are now secure.')
|
|
179
|
+
console.error('')
|
|
180
|
+
return { wasSecure: false, wasFixed: true }
|
|
181
|
+
} else {
|
|
182
|
+
console.error('')
|
|
183
|
+
console.error('❌ Failed to fix permissions automatically.')
|
|
184
|
+
console.error('')
|
|
185
|
+
console.error('Run this command manually:')
|
|
186
|
+
console.error(` chmod 600 ${getConfigPath()}`)
|
|
187
|
+
console.error('')
|
|
188
|
+
return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
console.error('')
|
|
192
|
+
console.error('⚠️ Permissions not fixed. Your API keys may be at risk.')
|
|
193
|
+
console.error('')
|
|
194
|
+
console.error('To fix later, run:')
|
|
195
|
+
console.error(` chmod 600 ${getConfigPath()}`)
|
|
196
|
+
console.error('')
|
|
197
|
+
return { wasSecure: false, wasFixed: false, error: 'user_declined' }
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
rl.close()
|
|
201
|
+
// 📖 If we can't prompt (e.g., non-interactive TTY), just warn and continue
|
|
202
|
+
console.error('')
|
|
203
|
+
console.error('⚠️ Unable to prompt for permission fix (non-interactive terminal?)')
|
|
204
|
+
console.error('')
|
|
205
|
+
console.error('To fix manually, run:')
|
|
206
|
+
console.error(` chmod 600 ${getConfigPath()}`)
|
|
207
|
+
console.error('')
|
|
208
|
+
return { wasSecure: false, wasFixed: false, error: 'no_tty' }
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/tool-launchers.js
CHANGED
|
@@ -32,6 +32,7 @@ import { homedir } from 'os'
|
|
|
32
32
|
import { dirname, join } from 'path'
|
|
33
33
|
import { spawn } from 'child_process'
|
|
34
34
|
import { sources } from '../sources.js'
|
|
35
|
+
import { PROVIDER_COLOR } from './render-table.js'
|
|
35
36
|
import { getApiKey, getProxySettings } from './config.js'
|
|
36
37
|
import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
|
|
37
38
|
import { getToolMeta } from './tool-metadata.js'
|
|
@@ -262,7 +263,11 @@ export async function startExternalTool(mode, model, config) {
|
|
|
262
263
|
const proxySettings = getProxySettings(config)
|
|
263
264
|
|
|
264
265
|
if (!apiKey && mode !== 'amp') {
|
|
265
|
-
|
|
266
|
+
// 📖 Color provider name the same way as in the main table
|
|
267
|
+
const providerRgb = PROVIDER_COLOR[model.providerKey] ?? [105, 190, 245]
|
|
268
|
+
const providerName = sources[model.providerKey]?.name || model.providerKey
|
|
269
|
+
const coloredProviderName = chalk.bold.rgb(...providerRgb)(providerName)
|
|
270
|
+
console.log(chalk.yellow(` ⚠ No API key configured for ${coloredProviderName}.`))
|
|
266
271
|
console.log(chalk.dim(' Configure the provider first from the Settings screen (P) or via env vars.'))
|
|
267
272
|
console.log()
|
|
268
273
|
return 1
|
package/src/utils.js
CHANGED
|
@@ -389,13 +389,13 @@ export function findBestModel(results) {
|
|
|
389
389
|
// - API key: first positional arg that doesn't start with "--" (e.g., "nvapi-xxx")
|
|
390
390
|
// - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw,
|
|
391
391
|
// --aider, --crush, --goose, --claude-code, --codex, --gemini, --qwen,
|
|
392
|
-
// --openhands, --amp, --pi, --no-telemetry (case-insensitive)
|
|
392
|
+
// --openhands, --amp, --pi, --no-telemetry, --json (case-insensitive)
|
|
393
393
|
// - Value flag: --tier <letter> (the next non-flag arg is the tier value)
|
|
394
394
|
//
|
|
395
395
|
// 📖 Returns:
|
|
396
396
|
// { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode,
|
|
397
397
|
// aiderMode, crushMode, gooseMode, claudeCodeMode, codexMode, geminiMode,
|
|
398
|
-
// qwenMode, openHandsMode, ampMode, piMode, noTelemetry, tierFilter }
|
|
398
|
+
// qwenMode, openHandsMode, ampMode, piMode, noTelemetry, jsonMode, tierFilter }
|
|
399
399
|
//
|
|
400
400
|
// 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
|
|
401
401
|
export function parseArgs(argv) {
|
|
@@ -446,6 +446,7 @@ export function parseArgs(argv) {
|
|
|
446
446
|
const piMode = flags.includes('--pi')
|
|
447
447
|
const noTelemetry = flags.includes('--no-telemetry')
|
|
448
448
|
const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
|
|
449
|
+
const jsonMode = flags.includes('--json')
|
|
449
450
|
|
|
450
451
|
let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
|
|
451
452
|
|
|
@@ -473,6 +474,7 @@ export function parseArgs(argv) {
|
|
|
473
474
|
piMode,
|
|
474
475
|
noTelemetry,
|
|
475
476
|
cleanProxyMode,
|
|
477
|
+
jsonMode,
|
|
476
478
|
tierFilter,
|
|
477
479
|
profileName,
|
|
478
480
|
recommendMode
|
|
@@ -728,3 +730,66 @@ export function getVersionStatusInfo(updateState, latestVersion) {
|
|
|
728
730
|
latestVersion: null,
|
|
729
731
|
}
|
|
730
732
|
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* 📖 formatResultsAsJSON converts model results to clean JSON output for scripting/automation.
|
|
736
|
+
*
|
|
737
|
+
* 📖 This is used by the --json flag to output results in a machine-readable format.
|
|
738
|
+
* 📖 The output is designed to be:
|
|
739
|
+
* - Easy to parse with jq, grep, awk, or any JSON library
|
|
740
|
+
* - Human-readable for debugging
|
|
741
|
+
* - Stable (field names won't change between versions)
|
|
742
|
+
*
|
|
743
|
+
* 📖 Output format:
|
|
744
|
+
* [
|
|
745
|
+
* {
|
|
746
|
+
* "rank": 1,
|
|
747
|
+
* "modelId": "nvidia/deepseek-ai/deepseek-v3.2",
|
|
748
|
+
* "label": "DeepSeek V3.2",
|
|
749
|
+
* "provider": "nvidia",
|
|
750
|
+
* "tier": "S+",
|
|
751
|
+
* "sweScore": "73.1%",
|
|
752
|
+
* "context": "128k",
|
|
753
|
+
* "latestPing": 245,
|
|
754
|
+
* "avgPing": 260,
|
|
755
|
+
* "p95": 312,
|
|
756
|
+
* "jitter": 45,
|
|
757
|
+
* "stability": 87,
|
|
758
|
+
* "uptime": 95.5,
|
|
759
|
+
* "verdict": "Perfect",
|
|
760
|
+
* "status": "up"
|
|
761
|
+
* },
|
|
762
|
+
* ...
|
|
763
|
+
* ]
|
|
764
|
+
*
|
|
765
|
+
* 📖 Note: NaN and Infinity values are converted to null for cleaner JSON.
|
|
766
|
+
*
|
|
767
|
+
* @param {Array} results — Model result objects from the TUI
|
|
768
|
+
* @param {string} sortBy — Current sort column (for rank calculation)
|
|
769
|
+
* @param {number} limit — Maximum number of results to return (0 = all)
|
|
770
|
+
* @returns {string} JSON string of formatted results
|
|
771
|
+
*/
|
|
772
|
+
export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
|
|
773
|
+
const formatted = results
|
|
774
|
+
.map((r, idx) => ({
|
|
775
|
+
rank: r.idx || idx + 1,
|
|
776
|
+
modelId: r.modelId || null,
|
|
777
|
+
label: r.label || null,
|
|
778
|
+
provider: r.providerKey || null,
|
|
779
|
+
tier: r.tier || null,
|
|
780
|
+
sweScore: r.sweScore || null,
|
|
781
|
+
context: r.ctx || null,
|
|
782
|
+
latestPing: (r.pings && r.pings.length > 0) ? r.pings[r.pings.length - 1].ms : null,
|
|
783
|
+
avgPing: (Number.isFinite(r.avg)) ? r.avg : null,
|
|
784
|
+
p95: (Number.isFinite(r.p95)) ? r.p95 : null,
|
|
785
|
+
jitter: (Number.isFinite(r.jitter)) ? r.jitter : null,
|
|
786
|
+
stability: (Number.isFinite(r.stability)) ? r.stability : null,
|
|
787
|
+
uptime: (Number.isFinite(r.uptime)) ? r.uptime : null,
|
|
788
|
+
verdict: r.verdict || null,
|
|
789
|
+
status: r.status || null,
|
|
790
|
+
httpCode: r.httpCode || null
|
|
791
|
+
}))
|
|
792
|
+
.slice(0, limit || undefined)
|
|
793
|
+
|
|
794
|
+
return JSON.stringify(formatted, null, 2)
|
|
795
|
+
}
|