free-coding-models 0.5.81 → 0.5.83
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 +24 -7
- package/bin/free-coding-models.js +20 -0
- package/changelog/v0.5.82.md +23 -0
- package/changelog/v0.5.83.md +22 -0
- package/package.json +1 -1
- package/sources.js +81 -65
- package/src/core/benchmark.js +1 -1
- package/src/core/config.js +1 -0
- package/src/core/endpoint-installer.js +7 -2
- package/src/core/kilo.js +7 -0
- package/src/core/models-dev-index.js +1 -0
- package/src/core/opencode.js +14 -0
- package/src/core/ping.js +14 -4
- package/src/core/provider-key-tester.js +2 -1
- package/src/core/provider-metadata.js +11 -2
- package/src/core/quota-capabilities.js +1 -0
- package/src/core/router-daemon.js +2 -2
- package/src/core/security.js +132 -65
- package/src/core/sync-set.js +1 -1
- package/src/core/tool-launchers.js +15 -5
- package/src/core/utils.js +88 -1
- package/src/tui/app.js +7 -5
- package/src/tui/cli-help.js +1 -0
- package/src/tui/command-palette.js +3 -2
- package/src/tui/key-handler.js +172 -61
- package/src/tui/overlays.js +135 -94
- package/src/tui/render-helpers.js +42 -3
- package/src/tui/render-table.js +74 -4
- package/src/tui/theme.js +2 -0
- package/src/tui/tui-state.js +15 -1
- package/web/dist/assets/{index-Bb5I0K1-.js → index-CSoKc1qF.js} +3 -3
- package/web/dist/index.html +1 -1
package/src/core/kilo.js
CHANGED
|
@@ -110,6 +110,13 @@ export async function startKilo(model, fcmConfig) {
|
|
|
110
110
|
options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '{env:OPENROUTER_API_KEY}' },
|
|
111
111
|
models: {}
|
|
112
112
|
}
|
|
113
|
+
} else if (providerKey === 'orcarouter') {
|
|
114
|
+
config.provider.orcarouter = {
|
|
115
|
+
npm: '@ai-sdk/openai-compatible',
|
|
116
|
+
name: 'OrcaRouter',
|
|
117
|
+
options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
|
|
118
|
+
models: {}
|
|
119
|
+
}
|
|
113
120
|
} else if (providerKey === 'huggingface') {
|
|
114
121
|
config.provider.huggingface = {
|
|
115
122
|
npm: '@ai-sdk/openai-compatible',
|
package/src/core/opencode.js
CHANGED
|
@@ -425,6 +425,13 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
425
425
|
options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '{env:OPENROUTER_API_KEY}' },
|
|
426
426
|
models: {}
|
|
427
427
|
}
|
|
428
|
+
} else if (providerKey === 'orcarouter') {
|
|
429
|
+
config.provider.orcarouter = {
|
|
430
|
+
npm: '@ai-sdk/openai-compatible',
|
|
431
|
+
name: 'OrcaRouter',
|
|
432
|
+
options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
|
|
433
|
+
models: {}
|
|
434
|
+
}
|
|
428
435
|
} else if (providerKey === 'huggingface') {
|
|
429
436
|
config.provider.huggingface = {
|
|
430
437
|
npm: '@ai-sdk/openai-compatible',
|
|
@@ -793,6 +800,13 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
|
|
|
793
800
|
options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '{env:OPENROUTER_API_KEY}' },
|
|
794
801
|
models: {}
|
|
795
802
|
}
|
|
803
|
+
} else if (providerKey === 'orcarouter') {
|
|
804
|
+
config.provider.orcarouter = {
|
|
805
|
+
npm: '@ai-sdk/openai-compatible',
|
|
806
|
+
name: 'OrcaRouter',
|
|
807
|
+
options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
|
|
808
|
+
models: {}
|
|
809
|
+
}
|
|
796
810
|
} else if (providerKey === 'huggingface') {
|
|
797
811
|
config.provider.huggingface = {
|
|
798
812
|
npm: '@ai-sdk/openai-compatible',
|
package/src/core/ping.js
CHANGED
|
@@ -56,12 +56,17 @@ const DISABLED_THINKING_RETRY_STATUSES = new Set([400, 422])
|
|
|
56
56
|
const disabledThinkingUnsupportedProviders = new Set()
|
|
57
57
|
|
|
58
58
|
// 📖 resolveCloudflareUrl: Cloudflare's OpenAI-compatible endpoint is account-scoped.
|
|
59
|
-
// 📖 We resolve
|
|
59
|
+
// 📖 We resolve the placeholder from the CLOUDFLARE_ACCOUNT_ID env var so provider
|
|
60
|
+
// 📖 setup can stay simple in config. Supports both the `{account_id}` placeholder
|
|
61
|
+
// 📖 and the explicit `{$CLOUDFLARE_ACCOUNT_ID}` form used in sources.js.
|
|
60
62
|
export function resolveCloudflareUrl(url) {
|
|
61
63
|
const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
|
|
62
|
-
|
|
63
|
-
if (!
|
|
64
|
-
|
|
64
|
+
const hasPlaceholder = url.includes('{$CLOUDFLARE_ACCOUNT_ID}') || url.includes('{account_id}')
|
|
65
|
+
if (!hasPlaceholder) return url
|
|
66
|
+
const replacement = accountId ? encodeURIComponent(accountId) : 'missing-account-id'
|
|
67
|
+
return url
|
|
68
|
+
.replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, replacement)
|
|
69
|
+
.replace(/\{account_id\}/g, replacement)
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
// 📖 buildChatCompletionPingBody: Use the smallest useful chat-completion probe.
|
|
@@ -130,6 +135,11 @@ export function buildPingRequest(apiKey, modelId, providerKey, url, options = {}
|
|
|
130
135
|
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
131
136
|
headers['X-Title'] = 'free-coding-models'
|
|
132
137
|
}
|
|
138
|
+
if (providerKey === 'orcarouter') {
|
|
139
|
+
// 📖 OrcaRouter uses the same app-identification convention as OpenRouter.
|
|
140
|
+
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
141
|
+
headers['X-Title'] = 'free-coding-models'
|
|
142
|
+
}
|
|
133
143
|
|
|
134
144
|
return {
|
|
135
145
|
url,
|
|
@@ -68,6 +68,7 @@ export const PROVIDER_AUTH_ENDPOINTS = {
|
|
|
68
68
|
siliconflow: { url: 'https://api.siliconflow.cn/v1/models', method: 'GET' },
|
|
69
69
|
pollinations: null,
|
|
70
70
|
requesty: null,
|
|
71
|
+
orcarouter: { url: 'https://api.orcarouter.ai/v1/models', method: 'GET' },
|
|
71
72
|
together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
|
|
72
73
|
perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
|
|
73
74
|
chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
|
|
@@ -103,7 +104,7 @@ export async function testProviderKeyDirect(apiKey, providerKey) {
|
|
|
103
104
|
|
|
104
105
|
const { url, method } = authConfig
|
|
105
106
|
const headers = { Authorization: `Bearer ${apiKey}` }
|
|
106
|
-
if (providerKey === 'openrouter') {
|
|
107
|
+
if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
|
|
107
108
|
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
108
109
|
headers['X-Title'] = 'free-coding-models'
|
|
109
110
|
}
|
|
@@ -65,6 +65,7 @@ export const ENV_VAR_NAMES = {
|
|
|
65
65
|
pollinations: 'POLLINATIONS_API_KEY',
|
|
66
66
|
siliconflow: 'SILICONFLOW_API_KEY',
|
|
67
67
|
requesty: 'REQUESTY_API_KEY',
|
|
68
|
+
orcarouter: 'ORCAROUTER_API_KEY',
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// 📖 OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
|
|
@@ -255,11 +256,11 @@ export const PROVIDER_METADATA = {
|
|
|
255
256
|
rateLimits: 'Depends on provider subscription (e.g., Anthropic, OpenAI)',
|
|
256
257
|
},
|
|
257
258
|
'opencode-zen': {
|
|
258
|
-
label: '
|
|
259
|
+
label: 'OpencodeZen',
|
|
259
260
|
color: chalk.rgb(139, 92, 246), // violet — distinctive from other providers
|
|
260
261
|
signupUrl: 'https://opencode.ai/auth',
|
|
261
262
|
signupHint: 'Login at opencode.ai/auth to get your Zen API key',
|
|
262
|
-
rateLimits: 'Free tier models — requires
|
|
263
|
+
rateLimits: 'Free tier models — requires Zen API key',
|
|
263
264
|
zenOnly: true,
|
|
264
265
|
},
|
|
265
266
|
chutes: {
|
|
@@ -330,6 +331,14 @@ export const PROVIDER_METADATA = {
|
|
|
330
331
|
signupHint: 'API Keys → Create (200 req/day free, no card)',
|
|
331
332
|
rateLimits: 'Free · 200 req/day on free models (20 req/min) · no card',
|
|
332
333
|
},
|
|
334
|
+
orcarouter: {
|
|
335
|
+
label: 'OrcaRouter',
|
|
336
|
+
color: chalk.rgb(255, 138, 64),
|
|
337
|
+
signupUrl: 'https://www.orcarouter.ai',
|
|
338
|
+
signupHint: 'Register (GitHub OAuth, no credit card) → API keys',
|
|
339
|
+
rateLimits: 'Free Hacker tier · zero token markup · 3 API keys',
|
|
340
|
+
detailedLimits: 'Zero-markup AI gateway: token prices are passed through at provider rates.\nFree Hacker plan: 3 API keys, adaptive routing + automatic failover + guardrails included.\nOnly the explicit $-0 models are listed in this catalog; the orcarouter/fusion family is pay-as-you-go.',
|
|
341
|
+
},
|
|
333
342
|
'ollama-cloud': {
|
|
334
343
|
label: 'Ollama Cloud',
|
|
335
344
|
color: chalk.rgb(230, 230, 230),
|
|
@@ -64,6 +64,7 @@ export const PROVIDER_CAPABILITIES = {
|
|
|
64
64
|
pollinations: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
65
65
|
siliconflow: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
66
66
|
requesty: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
67
|
+
orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/** Fallback for unrecognized providers */
|
|
@@ -578,7 +578,7 @@ export function cloneHeadersForUpstream(reqHeaders, apiKey, providerKey) {
|
|
|
578
578
|
}
|
|
579
579
|
headers['Content-Type'] = headers['Content-Type'] || 'application/json'
|
|
580
580
|
headers.Authorization = `Bearer ${apiKey}`
|
|
581
|
-
if (providerKey === 'openrouter') {
|
|
581
|
+
if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
|
|
582
582
|
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
583
583
|
headers['X-Title'] = 'free-coding-models'
|
|
584
584
|
}
|
|
@@ -3619,7 +3619,7 @@ function createDefaultProbeFn(apiKeys) {
|
|
|
3619
3619
|
headers.Prefer = 'wait=4'
|
|
3620
3620
|
} else {
|
|
3621
3621
|
headers.Authorization = `Bearer ${apiKey}`
|
|
3622
|
-
if (provider === 'openrouter') {
|
|
3622
|
+
if (provider === 'openrouter' || provider === 'orcarouter') {
|
|
3623
3623
|
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
3624
3624
|
headers['X-Title'] = 'free-coding-models'
|
|
3625
3625
|
}
|
package/src/core/security.js
CHANGED
|
@@ -8,27 +8,33 @@
|
|
|
8
8
|
* 📖 This module:
|
|
9
9
|
* - Checks config file permissions on startup
|
|
10
10
|
* - Warns user if permissions are too open
|
|
11
|
-
* - Offers auto-fix option with user confirmation
|
|
11
|
+
* - Offers auto-fix option with user confirmation (interactive TTY only)
|
|
12
12
|
* - Fixes permissions securely (chmod 600 = user read/write only)
|
|
13
13
|
*
|
|
14
|
+
* 📖 Issue #173: this check used to run un-awaited inside runApp, so the TUI
|
|
15
|
+
* entered raw mode / the alternate screen while the prompt was still pending.
|
|
16
|
+
* On Windows the warning + prompt were invisible and the app looked frozen.
|
|
17
|
+
* Now checkConfigSecurity() is async, awaited by the bin entry BEFORE the TUI
|
|
18
|
+
* starts, and it never prompts on non-TTY stdin or daemon/web/JSON surfaces.
|
|
19
|
+
*
|
|
14
20
|
* 📖 Secure permissions:
|
|
15
21
|
* - 0o600 (octal 600) = user:rw, group:---, world:---
|
|
16
22
|
* - Only the file owner can read or write
|
|
17
23
|
* - This is the standard for files containing secrets (SSH keys, API keys, etc.)
|
|
18
24
|
*
|
|
19
|
-
* 📖
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
* - Backup tools: Might copy files with permissions intact
|
|
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.
|
|
23
28
|
*
|
|
24
29
|
* @functions
|
|
25
|
-
* → checkConfigSecurity()
|
|
26
|
-
* →
|
|
27
|
-
* →
|
|
28
|
-
* →
|
|
29
|
-
* →
|
|
30
|
+
* → checkConfigSecurity() - Async main security check; awaited before the TUI starts
|
|
31
|
+
* → resolveSecurityAction() - Pure gate deciding auto-fix / prompt / warn-only (in utils.js)
|
|
32
|
+
* → 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)
|
|
35
|
+
* → promptSecurityFix() - Interactive prompt asking user to fix permissions
|
|
30
36
|
*
|
|
31
|
-
* @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions
|
|
37
|
+
* @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions, formatMode, formatModeRwx
|
|
32
38
|
*/
|
|
33
39
|
|
|
34
40
|
import fs from 'node:fs'
|
|
@@ -36,8 +42,9 @@ import path from 'node:path'
|
|
|
36
42
|
import os from 'node:os'
|
|
37
43
|
import readline from 'node:readline'
|
|
38
44
|
import { CONFIG_PATH } from './config.js'
|
|
45
|
+
import { resolveSecurityAction } from './utils.js'
|
|
39
46
|
|
|
40
|
-
// 📖 Config file path
|
|
47
|
+
// 📖 Config file path - matches the path used in config.js (honours the
|
|
41
48
|
// 📖 --config-dir / FCM_CONFIG_DIR override when set).
|
|
42
49
|
function getConfigPath() {
|
|
43
50
|
return CONFIG_PATH
|
|
@@ -47,6 +54,10 @@ function getConfigPath() {
|
|
|
47
54
|
// 📖 This means: owner can read+write, group and others have no permissions
|
|
48
55
|
const SECURE_MODE = 0o600
|
|
49
56
|
|
|
57
|
+
// 📖 True on Windows, where chmod is best-effort (read-only bit only) and the
|
|
58
|
+
// 📖 manual fix hint should point at icacls instead of chmod.
|
|
59
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
60
|
+
|
|
50
61
|
// 📖 Get file stats including permissions for the config file
|
|
51
62
|
// 📖 Returns null if file doesn't exist
|
|
52
63
|
function getConfigPermissions() {
|
|
@@ -80,7 +91,7 @@ export function isConfigSecure() {
|
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
// 📖 Fix config file permissions to secure mode (chmod 600)
|
|
83
|
-
// 📖
|
|
94
|
+
// 📖 Best-effort on Windows (read-only bit); returns true if successful, false otherwise
|
|
84
95
|
export function fixConfigPermissions() {
|
|
85
96
|
const configPath = getConfigPath()
|
|
86
97
|
|
|
@@ -97,23 +108,21 @@ export function fixConfigPermissions() {
|
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
// 📖 Format permission mode in octal (e.g., 0o644 → "644")
|
|
100
|
-
|
|
111
|
+
// 📖 Exported for unit tests
|
|
112
|
+
export function formatMode(mode) {
|
|
101
113
|
return (mode & 0o777).toString(8).padStart(3, '0')
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
// 📖 Format permission mode in human-readable rwx format (e.g., 0o644 → "rw-r--r--")
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
// 📖 Walks bits 8..0 in groups of three: owner rwx, group rwx, others rwx.
|
|
118
|
+
// 📖 Bit 8 = owner read, bit 7 = owner write, bit 6 = owner exec, and so on.
|
|
119
|
+
// 📖 Exported for unit tests
|
|
120
|
+
export function formatModeRwx(mode) {
|
|
107
121
|
const types = ['r', 'w', 'x']
|
|
122
|
+
const perms = []
|
|
108
123
|
|
|
109
|
-
for (let i =
|
|
110
|
-
|
|
111
|
-
if (mode & (1 << (i + j))) {
|
|
112
|
-
perms.push(types[j])
|
|
113
|
-
} else {
|
|
114
|
-
perms.push('-')
|
|
115
|
-
}
|
|
116
|
-
}
|
|
124
|
+
for (let i = 8; i >= 0; i--) {
|
|
125
|
+
perms.push(mode & (1 << i) ? types[(8 - i) % 3] : '-')
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
return [
|
|
@@ -123,10 +132,72 @@ function formatModeRwx(mode) {
|
|
|
123
132
|
].join(' / ')
|
|
124
133
|
}
|
|
125
134
|
|
|
126
|
-
// 📖
|
|
127
|
-
|
|
135
|
+
// 📖 Print the insecure-permissions warning (stderr, so --json stdout stays clean)
|
|
136
|
+
function printSecurityWarning(perms) {
|
|
137
|
+
const currentMode = formatMode(perms.mode)
|
|
138
|
+
const currentRwx = formatModeRwx(perms.mode)
|
|
139
|
+
|
|
140
|
+
console.error('')
|
|
141
|
+
console.error('⚠️ SECURITY WARNING ⚠️')
|
|
142
|
+
console.error('')
|
|
143
|
+
console.error(`Your config file has insecure permissions: ${currentMode} (${currentRwx})`)
|
|
144
|
+
console.error(`File: ${perms.path}`)
|
|
145
|
+
console.error('')
|
|
146
|
+
console.error('This means other users on this system may be able to read your API keys.')
|
|
147
|
+
console.error('')
|
|
148
|
+
console.error('Recommended: Fix permissions to 600 (rw-------) - owner read/write only')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 📖 Print the manual fix hint. On Windows, point at icacls since Node's chmod
|
|
152
|
+
// 📖 only toggles the read-only attribute there.
|
|
153
|
+
function printManualFixHint() {
|
|
154
|
+
console.error('')
|
|
155
|
+
if (IS_WINDOWS) {
|
|
156
|
+
console.error('To fix manually (PowerShell), run:')
|
|
157
|
+
console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "$env:USERNAME:R,W"`)
|
|
158
|
+
} else {
|
|
159
|
+
console.error('To fix manually, run:')
|
|
160
|
+
console.error(` chmod 600 ${getConfigPath()}`)
|
|
161
|
+
}
|
|
162
|
+
console.error('')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 📖 Apply the fix and report the outcome. Shared by the prompt path (user said
|
|
166
|
+
// 📖 yes) and the auto-fix path (--fix-permissions / --yes / -y).
|
|
167
|
+
function applyFixAndReport() {
|
|
168
|
+
const success = fixConfigPermissions()
|
|
169
|
+
|
|
170
|
+
if (success) {
|
|
171
|
+
console.error('')
|
|
172
|
+
console.error('✅ Permissions fixed! Your API keys are now secure.')
|
|
173
|
+
console.error('')
|
|
174
|
+
if (IS_WINDOWS) {
|
|
175
|
+
console.error('Note: on Windows this is best-effort (read-only bit). See docs for NTFS ACLs.')
|
|
176
|
+
console.error('')
|
|
177
|
+
}
|
|
178
|
+
return { wasSecure: false, wasFixed: true }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.error('')
|
|
182
|
+
console.error('❌ Failed to fix permissions automatically.')
|
|
183
|
+
printManualFixHint()
|
|
184
|
+
return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 📖 Check security and handle the fix flow if needed
|
|
188
|
+
// 📖 Await this BEFORE starting any terminal UI (issue #173) so the warning and
|
|
189
|
+
// 📖 the confirmation prompt are visible and fully resolved before raw mode /
|
|
190
|
+
// 📖 the alternate screen take over.
|
|
191
|
+
//
|
|
192
|
+
// 📖 Options:
|
|
193
|
+
// autoFix - true when --fix-permissions / --yes / -y was passed: apply the
|
|
194
|
+
// fix without asking
|
|
195
|
+
// promptAllowed - false on daemon/web/JSON surfaces: never prompt there, at most
|
|
196
|
+
// warn on stderr
|
|
197
|
+
// stdinIsTTY - override the stdin TTY detection (tests); defaults to real detection
|
|
198
|
+
//
|
|
128
199
|
// 📖 Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
|
|
129
|
-
export function checkConfigSecurity() {
|
|
200
|
+
export async function checkConfigSecurity(options = {}) {
|
|
130
201
|
const perms = getConfigPermissions()
|
|
131
202
|
|
|
132
203
|
// 📖 No file yet = nothing to check
|
|
@@ -139,24 +210,38 @@ export function checkConfigSecurity() {
|
|
|
139
210
|
return { wasSecure: true, wasFixed: false }
|
|
140
211
|
}
|
|
141
212
|
|
|
142
|
-
// 📖
|
|
143
|
-
const
|
|
144
|
-
|
|
213
|
+
// 📖 Pure gate (see utils.js): decides auto-fix vs prompt vs warn-only.
|
|
214
|
+
const action = resolveSecurityAction({
|
|
215
|
+
configExists: true,
|
|
216
|
+
isSecure: false,
|
|
217
|
+
autoFixRequested: options.autoFix === true,
|
|
218
|
+
stdinIsTTY: options.stdinIsTTY ?? (process.stdin?.isTTY === true),
|
|
219
|
+
promptAllowed: options.promptAllowed !== false,
|
|
220
|
+
})
|
|
145
221
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
222
|
+
if (action === 'none') {
|
|
223
|
+
return { wasSecure: true, wasFixed: false }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 📖 Security issue detected! Print the warning first so it is on screen
|
|
227
|
+
// 📖 no matter which path follows.
|
|
228
|
+
printSecurityWarning(perms)
|
|
229
|
+
|
|
230
|
+
if (action === 'auto-fix') {
|
|
231
|
+
return applyFixAndReport()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (action === 'warn-only') {
|
|
235
|
+
console.error('Running non-interactively (piped stdin or daemon/web mode), so skipping the prompt.')
|
|
236
|
+
printManualFixHint()
|
|
237
|
+
return { wasSecure: false, wasFixed: false, error: 'non_interactive' }
|
|
238
|
+
}
|
|
155
239
|
|
|
156
240
|
return promptSecurityFix()
|
|
157
241
|
}
|
|
158
242
|
|
|
159
243
|
// 📖 Interactive prompt asking user if they want to auto-fix
|
|
244
|
+
// 📖 Only reached on a real interactive TTY (gated in checkConfigSecurity)
|
|
160
245
|
// 📖 Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
|
|
161
246
|
async function promptSecurityFix() {
|
|
162
247
|
const rl = readline.createInterface({
|
|
@@ -165,37 +250,22 @@ async function promptSecurityFix() {
|
|
|
165
250
|
})
|
|
166
251
|
|
|
167
252
|
try {
|
|
168
|
-
const
|
|
253
|
+
const rawAnswer = await new Promise((resolve) => {
|
|
169
254
|
rl.question('Fix permissions automatically? (Y/n): ', resolve)
|
|
170
255
|
})
|
|
171
256
|
|
|
172
257
|
rl.close()
|
|
173
258
|
|
|
259
|
+
// 📖 Normalise: readline can resolve with undefined when stdin closes mid-prompt
|
|
260
|
+
const answer = String(rawAnswer ?? '').trim().toLowerCase()
|
|
261
|
+
|
|
174
262
|
// 📖 Default to yes if user just presses Enter
|
|
175
|
-
if (answer
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (success) {
|
|
179
|
-
console.error('')
|
|
180
|
-
console.error('✅ Permissions fixed! Your API keys are now secure.')
|
|
181
|
-
console.error('')
|
|
182
|
-
return { wasSecure: false, wasFixed: true }
|
|
183
|
-
} else {
|
|
184
|
-
console.error('')
|
|
185
|
-
console.error('❌ Failed to fix permissions automatically.')
|
|
186
|
-
console.error('')
|
|
187
|
-
console.error('Run this command manually:')
|
|
188
|
-
console.error(` chmod 600 ${getConfigPath()}`)
|
|
189
|
-
console.error('')
|
|
190
|
-
return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
|
|
191
|
-
}
|
|
263
|
+
if (answer === 'y' || answer === '') {
|
|
264
|
+
return applyFixAndReport()
|
|
192
265
|
} else {
|
|
193
266
|
console.error('')
|
|
194
267
|
console.error('⚠️ Permissions not fixed. Your API keys may be at risk.')
|
|
195
|
-
|
|
196
|
-
console.error('To fix later, run:')
|
|
197
|
-
console.error(` chmod 600 ${getConfigPath()}`)
|
|
198
|
-
console.error('')
|
|
268
|
+
printManualFixHint()
|
|
199
269
|
return { wasSecure: false, wasFixed: false, error: 'user_declined' }
|
|
200
270
|
}
|
|
201
271
|
} catch (err) {
|
|
@@ -203,10 +273,7 @@ async function promptSecurityFix() {
|
|
|
203
273
|
// 📖 If we can't prompt (e.g., non-interactive TTY), just warn and continue
|
|
204
274
|
console.error('')
|
|
205
275
|
console.error('⚠️ Unable to prompt for permission fix (non-interactive terminal?)')
|
|
206
|
-
|
|
207
|
-
console.error('To fix manually, run:')
|
|
208
|
-
console.error(` chmod 600 ${getConfigPath()}`)
|
|
209
|
-
console.error('')
|
|
276
|
+
printManualFixHint()
|
|
210
277
|
return { wasSecure: false, wasFixed: false, error: 'no_tty' }
|
|
211
278
|
}
|
|
212
279
|
}
|
package/src/core/sync-set.js
CHANGED
|
@@ -192,7 +192,7 @@ function buildHeaders(providerKey, apiKey) {
|
|
|
192
192
|
'Content-Type': 'application/json',
|
|
193
193
|
Authorization: `Bearer ${apiKey}`,
|
|
194
194
|
}
|
|
195
|
-
if (providerKey === 'openrouter') {
|
|
195
|
+
if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
|
|
196
196
|
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
197
197
|
headers['X-Title'] = 'free-coding-models'
|
|
198
198
|
}
|
|
@@ -50,6 +50,7 @@ import { PROVIDER_METADATA } from './provider-metadata.js'
|
|
|
50
50
|
import { resolveToolBinaryPath } from './tool-bootstrap.js'
|
|
51
51
|
import { ensureDir, readJson, writeJson } from './shared-helpers.js'
|
|
52
52
|
import { parseContextWindow } from './endpoint-installer.js'
|
|
53
|
+
import { resolveCloudflareUrl } from './ping.js'
|
|
53
54
|
|
|
54
55
|
const OPENAI_COMPAT_ENV_KEYS = [
|
|
55
56
|
'OPENAI_API_KEY',
|
|
@@ -108,10 +109,15 @@ function backupIfExists(filePath) {
|
|
|
108
109
|
function getProviderBaseUrl(providerKey) {
|
|
109
110
|
const url = sources[providerKey]?.url
|
|
110
111
|
if (!url) return null
|
|
111
|
-
|
|
112
|
+
let resolvedUrl = url
|
|
112
113
|
.replace(/\/chat\/completions$/i, '')
|
|
113
114
|
.replace(/\/responses$/i, '')
|
|
114
115
|
.replace(/\/predictions$/i, '')
|
|
116
|
+
// 📖 Cloudflare uses account_id in URL - resolve from CLOUDFLARE_ACCOUNT_ID env var
|
|
117
|
+
if (providerKey === 'cloudflare') {
|
|
118
|
+
resolvedUrl = resolveCloudflareUrl(resolvedUrl)
|
|
119
|
+
}
|
|
120
|
+
return resolvedUrl
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
function deleteEnvKeys(env, keys) {
|
|
@@ -579,9 +585,9 @@ export function writeZCodeConfig(model, config, paths = getDefaultToolPaths()) {
|
|
|
579
585
|
|
|
580
586
|
const providerKey = model.providerKey || 'nvidia'
|
|
581
587
|
const apiKey = getApiKey(config, providerKey)
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
588
|
+
// 📖 Use getProviderBaseUrl so Cloudflare's {account_id} placeholder is resolved
|
|
589
|
+
// 📖 (raw sources[].url would leave it literal and ZCode would fail on Cloudflare models).
|
|
590
|
+
const baseUrl = getProviderBaseUrl(providerKey)
|
|
585
591
|
const providerId = `fcm-${providerKey}`
|
|
586
592
|
const providerLabel = `FCM ${sources[providerKey]?.name || providerKey}`
|
|
587
593
|
|
|
@@ -838,7 +844,11 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
|
|
|
838
844
|
}
|
|
839
845
|
|
|
840
846
|
if (mode === 'goose') {
|
|
841
|
-
|
|
847
|
+
// 📖 Always use the resolved provider base URL from buildToolEnv (getProviderBaseUrl),
|
|
848
|
+
// 📖 which substitutes Cloudflare's {account_id} placeholder via resolveCloudflareUrl.
|
|
849
|
+
// 📖 Using the raw sources[].url would leave the literal {account_id} in the Goose
|
|
850
|
+
// 📖 provider config, so Goose would POST to /accounts/{account_id}/... and 400.
|
|
851
|
+
const gooseBaseUrl = baseUrl
|
|
842
852
|
const gooseModelId = resolveLauncherModelId(model)
|
|
843
853
|
const result = writeGooseConfig({ ...model, modelId: gooseModelId }, apiKey, gooseBaseUrl, model.providerKey, paths)
|
|
844
854
|
env.GOOSE_PROVIDER = `fcm-${model.providerKey}`
|
package/src/core/utils.js
CHANGED
|
@@ -409,6 +409,30 @@ export function filterByTier(results, tierLetter) {
|
|
|
409
409
|
return results.filter(r => allowed.includes(r.tier))
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
// 📖 PROBE_FAILED_STATUSES: row health states that count as "failed" for the
|
|
413
|
+
// 📖 Shift+P re-probe action (issue #168). Covers the errors a user actually sees
|
|
414
|
+
// 📖 in the table: dead endpoints (404/410/5xx/ERR → 'down'), rate limits
|
|
415
|
+
// 📖 (429 → 'down'), rejected keys ('auth_error') and network timeouts.
|
|
416
|
+
// 📖 'noauth' is deliberately excluded: those rows have no API key, so an
|
|
417
|
+
// 📖 authenticated probe cannot even run for them.
|
|
418
|
+
export const PROBE_FAILED_STATUSES = new Set(['down', 'timeout', 'auth_error'])
|
|
419
|
+
|
|
420
|
+
// 📖 isProbeFailedRow: True when a single result row is currently showing an
|
|
421
|
+
// 📖 error state worth re-probing. Hidden rows are skipped on purpose: Shift+P
|
|
422
|
+
// 📖 re-probes what the user can SEE failing, not rows the probe already hid.
|
|
423
|
+
export function isProbeFailedRow(row) {
|
|
424
|
+
return !!row && !row.hidden && PROBE_FAILED_STATUSES.has(row.status)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 📖 selectProbeFailedRows: Filter a results array down to the rows that are
|
|
428
|
+
// 📖 currently failing (auth fail / 429 / 404 / timeout). Pure helper used by
|
|
429
|
+
// 📖 the TUI Shift+P "re-probe failed rows only" action (issue #168) so users
|
|
430
|
+
// 📖 can retry 1-2 flaky providers without burning quota on the whole list.
|
|
431
|
+
export function selectProbeFailedRows(results) {
|
|
432
|
+
if (!Array.isArray(results)) return []
|
|
433
|
+
return results.filter(isProbeFailedRow)
|
|
434
|
+
}
|
|
435
|
+
|
|
412
436
|
// 📖 findBestModel: Pick the single best model from a results array.
|
|
413
437
|
// 📖 Used by --fiable mode to output the most reliable model after 10s of analysis.
|
|
414
438
|
//
|
|
@@ -460,6 +484,7 @@ export function findBestModel(results) {
|
|
|
460
484
|
// --daemon, --daemon-bg, --daemon-stop,
|
|
461
485
|
// --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
|
|
462
486
|
// --playground / playground subcommand (open the in-TUI chat playground)
|
|
487
|
+
// --fix-permissions / --yes / -y (auto-fix config permissions without prompting)
|
|
463
488
|
// - Value flag: --tier <letter> (the next non-flag arg is the tier value)
|
|
464
489
|
// - Probe-cache flags (t1):
|
|
465
490
|
// --reprobe / --no-cache (boolean) — force-rebuild the probe cache this run
|
|
@@ -533,7 +558,8 @@ export function parseArgs(argv) {
|
|
|
533
558
|
if (configDirValueIdx !== -1) skipIndices.add(configDirValueIdx)
|
|
534
559
|
|
|
535
560
|
for (const [i, arg] of args.entries()) {
|
|
536
|
-
|
|
561
|
+
// 📖 -y is a boolean flag (security auto-fix), never an API key
|
|
562
|
+
if (arg.startsWith('--') || arg === '-h' || arg === '-y') {
|
|
537
563
|
flags.push(arg.toLowerCase())
|
|
538
564
|
} else if (skipIndices.has(i)) {
|
|
539
565
|
// 📖 Skip — this is a value for --tier, not an API key
|
|
@@ -577,6 +603,11 @@ export function parseArgs(argv) {
|
|
|
577
603
|
const daemonStopMode = flags.includes('--daemon-stop')
|
|
578
604
|
const daemonStatusMode = flags.includes('--daemon-status')
|
|
579
605
|
|
|
606
|
+
// 📖 --fix-permissions / --yes / -y - auto-answer "yes" to the config-permission
|
|
607
|
+
// 📖 security prompt (chmod 600, best-effort on Windows) so scripts, CI and
|
|
608
|
+
// 📖 non-interactive terminals never hang on a hidden prompt. Issue #173.
|
|
609
|
+
const fixPermissionsMode = flags.includes('--fix-permissions') || flags.includes('--yes') || flags.includes('-y')
|
|
610
|
+
|
|
580
611
|
// 📖 --sync-set [name] — auto-discover and populate a router set with best available models
|
|
581
612
|
const syncSetMode = flags.includes('--sync-set')
|
|
582
613
|
const syncSetName = syncSetValueIdx !== -1 ? args[syncSetValueIdx] : null
|
|
@@ -686,9 +717,65 @@ export function parseArgs(argv) {
|
|
|
686
717
|
clearRuntimeMode,
|
|
687
718
|
// 📖 Config location flag — see src/core/config.js getConfigDir()
|
|
688
719
|
configDir: configDirValueIdx !== -1 ? args[configDirValueIdx] : null,
|
|
720
|
+
// 📖 Security auto-fix flag - see src/core/security.js checkConfigSecurity()
|
|
721
|
+
fixPermissionsMode,
|
|
689
722
|
}
|
|
690
723
|
}
|
|
691
724
|
|
|
725
|
+
// ─── Config Security Gating (issue #173) ─────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
// 📖 resolveSecurityAction: pure decision helper that tells the startup security
|
|
728
|
+
// 📖 check what it should do about insecure config file permissions.
|
|
729
|
+
//
|
|
730
|
+
// 📖 Why: the security warning + "Fix permissions automatically?" prompt used to
|
|
731
|
+
// 📖 run un-awaited while the TUI entered raw mode / the alternate screen, so the
|
|
732
|
+
// 📖 prompt was invisible (worst on Windows) and the app looked frozen. This gate
|
|
733
|
+
// 📖 guarantees: never prompt without an interactive surface, never prompt a
|
|
734
|
+
// 📖 daemon/web/JSON surface, and always auto-fix when a yes-flag is passed.
|
|
735
|
+
//
|
|
736
|
+
// 📖 Params:
|
|
737
|
+
// configExists - does the config file exist (no file = nothing to secure)
|
|
738
|
+
// isSecure - are permissions already 0600
|
|
739
|
+
// autoFixRequested - user passed --fix-permissions / --yes / -y
|
|
740
|
+
// stdinIsTTY - is stdin an interactive terminal
|
|
741
|
+
// promptAllowed - is this an interactive surface (TUI)? false for daemon/web/JSON
|
|
742
|
+
//
|
|
743
|
+
// 📖 Returns one of: 'none' | 'auto-fix' | 'warn-only' | 'prompt'
|
|
744
|
+
export function resolveSecurityAction({ configExists, isSecure, autoFixRequested, stdinIsTTY, promptAllowed }) {
|
|
745
|
+
if (!configExists || isSecure) return 'none'
|
|
746
|
+
if (autoFixRequested) return 'auto-fix'
|
|
747
|
+
if (!stdinIsTTY || !promptAllowed) return 'warn-only'
|
|
748
|
+
return 'prompt'
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 📖 detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
|
|
752
|
+
// 📖 WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
|
|
753
|
+
// 📖 terminals) often run 80x24 or smaller with no or limited color support, and
|
|
754
|
+
// 📖 the palette/overlays must degrade instead of overflowing or painting garbage.
|
|
755
|
+
// 📖 Everything is injected by the caller (env, size, TTY) so tests never touch
|
|
756
|
+
// 📖 process.env and the function stays deterministic.
|
|
757
|
+
//
|
|
758
|
+
// 📖 Rules:
|
|
759
|
+
// - FORCE_COLOR wins over NO_COLOR (same precedence chalk uses).
|
|
760
|
+
// - NO_COLOR (non-empty, per no-color.org spec) disables color.
|
|
761
|
+
// - Not a TTY, TERM missing / "dumb" / "unknown" disables color, unless
|
|
762
|
+
// COLORTERM is set (some terminals export only COLORTERM).
|
|
763
|
+
// - compact = size too tight for the roomy overlay layout (cols < 90 or rows < 24,
|
|
764
|
+
// thresholds chosen so a plain 80x24 console gets the space-saving layout).
|
|
765
|
+
//
|
|
766
|
+
// 📖 Returns { colorSupported: boolean, compact: boolean }
|
|
767
|
+
export function detectTerminalCapabilities({ env = {}, cols = 80, rows = 24, isTTY = true } = {}) {
|
|
768
|
+
const term = String(env.TERM ?? '').trim().toLowerCase()
|
|
769
|
+
const colorterm = String(env.COLORTERM ?? '').trim().toLowerCase()
|
|
770
|
+
const force = env.FORCE_COLOR
|
|
771
|
+
const forceOn = force !== undefined && force !== '' && force !== '0' && force !== 'false'
|
|
772
|
+
const noColor = env.NO_COLOR !== undefined && env.NO_COLOR !== ''
|
|
773
|
+
const termUsable = term !== '' && term !== 'dumb' && term !== 'unknown'
|
|
774
|
+
const colorSupported = forceOn || (!noColor && isTTY !== false && (termUsable || colorterm !== ''))
|
|
775
|
+
const compact = Math.floor(cols) < 90 || Math.floor(rows) < 24
|
|
776
|
+
return { colorSupported, compact }
|
|
777
|
+
}
|
|
778
|
+
|
|
692
779
|
// ─── Smart Recommend — Scoring Engine ─────────────────────────────────────────
|
|
693
780
|
|
|
694
781
|
// 📖 Task types for the Smart Recommend questionnaire.
|