free-coding-models 0.5.87 → 0.5.89
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 +7 -9
- package/bin/free-coding-models.js +33 -2
- package/changelog/v0.5.88.md +16 -0
- package/changelog/v0.5.89.md +20 -0
- package/package.json +7 -2
- package/src/core/router-daemon.js +1129 -500
- package/src/core/router-v2/anthropic-compat.js +473 -0
- package/src/core/router-v2/bench.js +171 -0
- package/src/core/router-v2/breaker-store.js +265 -0
- package/src/core/router-v2/constants.js +108 -0
- package/src/core/router-v2/decision-trace.js +134 -0
- package/src/core/router-v2/failure-classifier.js +231 -0
- package/src/core/router-v2/request-history.js +137 -0
- package/src/core/router-v2/response-gate.js +175 -0
- package/src/core/router-v2/tui-dashboard.js +632 -0
- package/src/core/schema-normalizer.js +23 -6
- package/src/core/security.js +170 -24
- package/src/core/utils.js +109 -0
- package/src/tui/app.js +7 -2
- package/src/tui/cli-help.js +4 -0
- package/src/tui/key-handler.js +117 -2
- package/src/tui/overlays.js +10 -0
- package/src/tui/tui-state.js +22 -0
- package/web/dist/assets/index-C5hgQLYN.js +48 -0
- package/web/dist/assets/index-CCaxIOti.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +82 -0
- package/web/dist/assets/index-BpTpPS0g.js +0 -44
- package/web/dist/assets/index-CAzFIt8P.css +0 -1
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
|
|
package/src/core/utils.js
CHANGED
|
@@ -608,6 +608,13 @@ export function parseArgs(argv) {
|
|
|
608
608
|
const daemonBackgroundMode = flags.includes('--daemon-bg')
|
|
609
609
|
const daemonStopMode = flags.includes('--daemon-stop')
|
|
610
610
|
const daemonStatusMode = flags.includes('--daemon-status')
|
|
611
|
+
// 📖 Router v2 (beta) lifecycle flags - run v2 alongside v1 on its own
|
|
612
|
+
// port (19380) with persisted breakers + decision traces. See
|
|
613
|
+
// src/core/router-v2/daemon.js and docs/router-v2.md.
|
|
614
|
+
const routerV2Mode = flags.includes('--router-v2')
|
|
615
|
+
const routerV2BackgroundMode = flags.includes('--router-v2-bg')
|
|
616
|
+
const routerV2StopMode = flags.includes('--router-v2-stop')
|
|
617
|
+
const routerV2StatusMode = flags.includes('--router-v2-status')
|
|
611
618
|
|
|
612
619
|
// 📖 --fix-permissions / --yes / -y - auto-answer "yes" to the config-permission
|
|
613
620
|
// 📖 security prompt (chmod 600, best-effort on Windows) so scripts, CI and
|
|
@@ -706,6 +713,11 @@ export function parseArgs(argv) {
|
|
|
706
713
|
checkDriftMode,
|
|
707
714
|
driftThreshold,
|
|
708
715
|
daemonStatusMode,
|
|
716
|
+
// 📖 Router v2 (beta) lifecycle flags - see src/core/router-v2/daemon.js
|
|
717
|
+
routerV2Mode,
|
|
718
|
+
routerV2BackgroundMode,
|
|
719
|
+
routerV2StopMode,
|
|
720
|
+
routerV2StatusMode,
|
|
709
721
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
710
722
|
recommendMode,
|
|
711
723
|
devMode,
|
|
@@ -750,6 +762,103 @@ export function resolveSecurityAction({ configExists, isSecure, autoFixRequested
|
|
|
750
762
|
return 'prompt'
|
|
751
763
|
}
|
|
752
764
|
|
|
765
|
+
// 📖 parseIcaclsOutput: PURE parser for Windows `icacls <file>` output, used by
|
|
766
|
+
// 📖 the config security check to get a REAL answer about NTFS access.
|
|
767
|
+
// 📖 WHY: on win32 Node reports POSIX-style modes as 0666 (writable) or 0444
|
|
768
|
+
// 📖 (read-only) only - 0600 is unreachable - so the old `(mode & 0o777) === 0o600`
|
|
769
|
+
// 📖 test was always false and the warning re-fired on every launch (issue #173
|
|
770
|
+
// 📖 follow-up from rutexd). Only the ACL tells the truth on NTFS.
|
|
771
|
+
// 📖
|
|
772
|
+
// 📖 Sample output we must handle (ACEs, one per line, path on the first line):
|
|
773
|
+
// C:\Users\rutex\.free-coding-models.json NT AUTHORITY\SYSTEM:(I)(F)
|
|
774
|
+
// BUILTIN\Administrators:(I)(F)
|
|
775
|
+
// DESKTOP-XYZ\rutex:(F)
|
|
776
|
+
// Successfully processed 1 files; Failed processing 0 files
|
|
777
|
+
// 📖 `(I)` marks an inherited ACE, so "inheritance disabled" = no `(I)` anywhere.
|
|
778
|
+
// 📖 Locale-proof whitelists: names are machine-localized, so we whitelist by
|
|
779
|
+
// 📖 substring (SYSTEM, Administra* covers Administrators/Administrateurs) and
|
|
780
|
+
// 📖 by well-known SID (S-1-5-18 = SYSTEM, S-1-5-32-544 = Administrators), plus
|
|
781
|
+
// 📖 the current user (case-insensitive, domain-qualified or bare).
|
|
782
|
+
// 📖
|
|
783
|
+
// 📖 Params:
|
|
784
|
+
// output - raw stdout of `icacls <path>` (string)
|
|
785
|
+
// userName - current user name (string), e.g. "rutex"
|
|
786
|
+
// filePath - the exact path passed to icacls (optional but STRONGLY
|
|
787
|
+
// recommended: icacls echoes it on the first line, glued to the
|
|
788
|
+
// first ACE, and paths/account names can both contain spaces)
|
|
789
|
+
// 📖 Returns { inheritanceEnabled, othersHaveAccess, ownerHasAccess, grants }
|
|
790
|
+
// grants = [{ name, flags }] for every ACE line; empty when output unparseable.
|
|
791
|
+
export function parseIcaclsOutput({ output, userName, filePath = '' }) {
|
|
792
|
+
const text = String(output ?? '')
|
|
793
|
+
const user = String(userName ?? '').trim().toLowerCase()
|
|
794
|
+
const knownPath = String(filePath ?? '')
|
|
795
|
+
const lines = text.split(/\r?\n/)
|
|
796
|
+
|
|
797
|
+
// 📖 The rights/flags groups always end the line: "(I)(F)", "(F)", "(R,W)"...
|
|
798
|
+
const aceTail = /((?:\([^)]*\)\s*)+)$/
|
|
799
|
+
const grants = []
|
|
800
|
+
for (const rawLine of lines) {
|
|
801
|
+
const tail = aceTail.exec(rawLine)
|
|
802
|
+
if (!tail) continue
|
|
803
|
+
|
|
804
|
+
let before = rawLine.slice(0, tail.index).trim()
|
|
805
|
+
if (before === '') continue
|
|
806
|
+
// 📖 Drop the colon that separates the account name from its flags.
|
|
807
|
+
before = before.replace(/:$/, '').trim()
|
|
808
|
+
// 📖 First ACE shares its line with the echoed path: strip the path we
|
|
809
|
+
// 📖 passed to icacls. Without a known path, keep only the segment after
|
|
810
|
+
// 📖 the last whitespace (exact for simple names; callers should pass
|
|
811
|
+
// 📖 filePath when the path or account name contains spaces).
|
|
812
|
+
if (knownPath && before.startsWith(knownPath)) {
|
|
813
|
+
before = before.slice(knownPath.length).trim()
|
|
814
|
+
} else if (knownPath === '' && /\s/.test(before)) {
|
|
815
|
+
before = before.slice(before.lastIndexOf(' ') + 1).trim()
|
|
816
|
+
}
|
|
817
|
+
if (before === '') continue
|
|
818
|
+
grants.push({ name: before, flags: tail[1].replace(/\s+/g, '') })
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const isTrusted = (name) => {
|
|
822
|
+
const n = name.toLowerCase()
|
|
823
|
+
if (user !== '' && (n === user || n.endsWith('\\' + user))) return true
|
|
824
|
+
if (n.includes('system') || n.includes('s-1-5-18')) return true
|
|
825
|
+
if (n.includes('administra') || n.includes('s-1-5-32-544')) return true
|
|
826
|
+
return false
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const inheritanceEnabled = grants.some((g) => g.flags.includes('I'))
|
|
830
|
+
const others = grants.filter((g) => !isTrusted(g.name)).map((g) => g.name)
|
|
831
|
+
const ownerHasAccess = grants.some((g) => {
|
|
832
|
+
const n = g.name.toLowerCase()
|
|
833
|
+
return user !== '' && (n === user || n.endsWith('\\' + user))
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
inheritanceEnabled,
|
|
838
|
+
othersHaveAccess: others.length > 0,
|
|
839
|
+
ownerHasAccess,
|
|
840
|
+
grants,
|
|
841
|
+
otherNames: others,
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// 📖 shouldSkipSecurityWarn: PURE anti-nag gate for the config security warning.
|
|
846
|
+
// 📖 WHY: when the permission fix cannot be applied or verified (rare: icacls
|
|
847
|
+
// 📖 missing on Windows, chmod failing on POSIX), the insecure state persists and
|
|
848
|
+
// 📖 the warning used to re-fire on every single launch. Once we warned and the
|
|
849
|
+
// 📖 fix did not stick, stay quiet for `intervalDays` (default 30) instead of
|
|
850
|
+
// 📖 nagging daily. `--fix-permissions` bypasses the gate in the caller.
|
|
851
|
+
// 📖 Params: ackedAt - last warning timestamp (ms epoch or ISO string or null)
|
|
852
|
+
// now - current time in ms epoch; intervalDays - quiet window length
|
|
853
|
+
// 📖 Returns true only when ackedAt parses AND is less than intervalDays old.
|
|
854
|
+
export function shouldSkipSecurityWarn({ ackedAt, now = Date.now(), intervalDays = 30 }) {
|
|
855
|
+
if (ackedAt === null || ackedAt === undefined || ackedAt === '') return false
|
|
856
|
+
const then = typeof ackedAt === 'number' ? ackedAt : Date.parse(String(ackedAt))
|
|
857
|
+
if (!Number.isFinite(then)) return false
|
|
858
|
+
const intervalMs = Math.max(0, intervalDays) * 24 * 60 * 60 * 1000
|
|
859
|
+
return now - then < intervalMs && now >= then
|
|
860
|
+
}
|
|
861
|
+
|
|
753
862
|
// 📖 detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
|
|
754
863
|
// 📖 WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
|
|
755
864
|
// 📖 terminals) often run 80x24 or smaller with no or limited color support, and
|
package/src/tui/app.js
CHANGED
|
@@ -123,6 +123,7 @@ import { createOverlayRenderers } from './overlays.js'
|
|
|
123
123
|
import { createKeyHandler, createMouseEventHandler } from './key-handler.js'
|
|
124
124
|
import { createMouseHandler, containsMouseSequence } from './mouse.js'
|
|
125
125
|
import { stopRouterDashboardClient } from '../core/router-dashboard.js'
|
|
126
|
+
import { stopRouterV2DashboardClient } from '../core/router-v2/tui-dashboard.js'
|
|
126
127
|
import { getToolModeOrder, getToolMeta } from '../core/tool-metadata.js'
|
|
127
128
|
import { startExternalTool } from '../core/tool-launchers.js'
|
|
128
129
|
import { getToolInstallPlan, installToolWithPlan, isToolInstalled } from '../core/tool-bootstrap.js'
|
|
@@ -758,6 +759,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
758
759
|
clearTimeout(state.pingIntervalObj)
|
|
759
760
|
clearInterval(state.versionRecheckTimer)
|
|
760
761
|
stopRouterDashboardClient(state)
|
|
762
|
+
stopRouterV2DashboardClient(state)
|
|
761
763
|
process.stdout.write(ALT_LEAVE)
|
|
762
764
|
if (process.stdout.isTTY) {
|
|
763
765
|
process.stdout.flush && process.stdout.flush()
|
|
@@ -782,6 +784,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
782
784
|
clearTimeout(state.pingIntervalObj)
|
|
783
785
|
clearInterval(state.versionRecheckTimer)
|
|
784
786
|
stopRouterDashboardClient(state)
|
|
787
|
+
stopRouterV2DashboardClient(state)
|
|
785
788
|
// 📖 Remove the actual registered wrappers (see keypressWrapper/mouseDataWrapper).
|
|
786
789
|
if (keypressWrapper) process.stdin.removeListener('keypress', keypressWrapper)
|
|
787
790
|
if (mouseDataWrapper) process.stdin.removeListener('data', mouseDataWrapper)
|
|
@@ -1118,6 +1121,8 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
1118
1121
|
? overlays.renderInstalledModels()
|
|
1119
1122
|
: state.routerDashboardOpen
|
|
1120
1123
|
? overlays.renderRouterDashboard()
|
|
1124
|
+
: state.routerV2DashboardOpen
|
|
1125
|
+
? overlays.renderRouterV2Dashboard()
|
|
1121
1126
|
: state.playgroundOpen
|
|
1122
1127
|
? overlays.renderPlayground()
|
|
1123
1128
|
: state.tokenUsageOpen
|
|
@@ -1261,9 +1266,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
1261
1266
|
}
|
|
1262
1267
|
|
|
1263
1268
|
state.results.forEach(r => {
|
|
1264
|
-
// 📖 When router dashboard is open, ONLY ping favorites every second
|
|
1269
|
+
// 📖 When a router dashboard is open, ONLY ping favorites every second
|
|
1265
1270
|
// 📖 to prevent massive rate limiting across the entire 90+ model catalog.
|
|
1266
|
-
if (state.routerDashboardOpen) {
|
|
1271
|
+
if (state.routerDashboardOpen || state.routerV2DashboardOpen) {
|
|
1267
1272
|
const favKey = `${r.providerKey}/${r.modelId}`
|
|
1268
1273
|
if (!state.config.favorites.includes(favKey)) return
|
|
1269
1274
|
}
|
package/src/tui/cli-help.js
CHANGED
|
@@ -47,6 +47,10 @@ const CONFIG_FLAGS = [
|
|
|
47
47
|
{ flag: '--daemon-bg', description: 'Start the FCM Router daemon in the background' },
|
|
48
48
|
{ flag: '--daemon-status', description: 'Print FCM Router daemon status JSON' },
|
|
49
49
|
{ flag: '--daemon-stop', description: 'Gracefully stop the FCM Router daemon' },
|
|
50
|
+
{ flag: '--router-v2', description: 'Start the Router v2 daemon (BETA) on port 19380: content-validated failover + Anthropic /v1/messages' },
|
|
51
|
+
{ flag: '--router-v2-bg', description: 'Start Router v2 (BETA) in the background' },
|
|
52
|
+
{ flag: '--router-v2-status', description: 'Print Router v2 (BETA) daemon status JSON' },
|
|
53
|
+
{ flag: '--router-v2-stop', description: 'Gracefully stop the Router v2 (BETA) daemon' },
|
|
50
54
|
{ flag: '--sync-set [name]', description: 'Auto-discover and live-probe models into a router set' },
|
|
51
55
|
{ flag: '--config-dir <dir>', description: 'Store config.json + backups/ in <dir> (e.g. --config-dir ~/.config/free-coding-models for the XDG layout)' },
|
|
52
56
|
{ flag: '--fix-permissions, --yes, -y', description: 'Auto-fix insecure config file permissions (chmod 600) without prompting; safe for scripts and CI' },
|
package/src/tui/key-handler.js
CHANGED
|
@@ -46,6 +46,15 @@ import {
|
|
|
46
46
|
setDashboardNotice,
|
|
47
47
|
toggleRouterDashboardProbePause,
|
|
48
48
|
} from '../core/router-dashboard.js'
|
|
49
|
+
import {
|
|
50
|
+
closeRouterV2DashboardOverlay,
|
|
51
|
+
cycleRouterV2ProbeMode,
|
|
52
|
+
openRouterV2DashboardOverlay,
|
|
53
|
+
renderRouterV2Dashboard,
|
|
54
|
+
setRouterV2Notice,
|
|
55
|
+
testAllVisibleViaRouterV2,
|
|
56
|
+
testModelViaRouterV2,
|
|
57
|
+
} from '../core/router-v2/tui-dashboard.js'
|
|
49
58
|
import {
|
|
50
59
|
closePlaygroundOverlay,
|
|
51
60
|
openPlaygroundOverlay,
|
|
@@ -82,8 +91,8 @@ function spawnDaemonCommand(state, args) {
|
|
|
82
91
|
})
|
|
83
92
|
child.unref()
|
|
84
93
|
|
|
85
|
-
// 📖 Only capture stdout for start commands
|
|
86
|
-
if (!args.includes('--daemon-bg')) return
|
|
94
|
+
// 📖 Only capture stdout for start commands - stop doesn't need error surfacing.
|
|
95
|
+
if (!args.includes('--daemon-bg') && !args.includes('--router-v2-bg')) return
|
|
87
96
|
|
|
88
97
|
let stdout = ''
|
|
89
98
|
let stderr = ''
|
|
@@ -1502,6 +1511,7 @@ export function createKeyHandler(ctx) {
|
|
|
1502
1511
|
|
|
1503
1512
|
case 'open-recommend': return openRecommendOverlay()
|
|
1504
1513
|
case 'open-router-dashboard': return openRouterDashboardOverlay(state)
|
|
1514
|
+
case 'open-router-v2-dashboard': return openRouterV2DashboardOverlay(state)
|
|
1505
1515
|
case 'open-playground': return openPlaygroundOverlay(state)
|
|
1506
1516
|
case 'open-token-usage': return openTokenUsageOverlay()
|
|
1507
1517
|
case 'open-runtime-report': return openRuntimeReportOverlay()
|
|
@@ -1774,6 +1784,82 @@ export function createKeyHandler(ctx) {
|
|
|
1774
1784
|
return
|
|
1775
1785
|
}
|
|
1776
1786
|
|
|
1787
|
+
// 📖 Router v2 (BETA) dashboard: ↑↓ navigate the fallback chain,
|
|
1788
|
+
// T tests the model under the cursor through the router (pinned request
|
|
1789
|
+
// through the FULL chain), S toggles the daemon, I cycles probe speed,
|
|
1790
|
+
// C clears the persisted history, Esc goes back.
|
|
1791
|
+
if (state.routerV2DashboardOpen) {
|
|
1792
|
+
const routingOrder = Array.isArray(state.routerV2Stats?.routingOrder) ? state.routerV2Stats.routingOrder : []
|
|
1793
|
+
const maxCursor = Math.max(0, routingOrder.length)
|
|
1794
|
+
const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
|
|
1795
|
+
|
|
1796
|
+
if (key.name === 'escape') {
|
|
1797
|
+
closeRouterV2DashboardOverlay(state)
|
|
1798
|
+
return
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (key.name === 's') {
|
|
1802
|
+
const isRunning = state.routerV2Status === 'ready' || state.routerV2Status === 'partial'
|
|
1803
|
+
state.routerV2Status = 'loading'
|
|
1804
|
+
spawnDaemonCommand(state, [isRunning ? '--daemon-stop' : '--daemon-bg'])
|
|
1805
|
+
return
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
1809
|
+
if ((state.routerV2CursorIndex ?? 0) === maxCursor) {
|
|
1810
|
+
const isRunning = state.routerV2Status === 'ready' || state.routerV2Status === 'partial'
|
|
1811
|
+
state.routerV2Status = 'loading'
|
|
1812
|
+
spawnDaemonCommand(state, [isRunning ? '--daemon-stop' : '--daemon-bg'])
|
|
1813
|
+
}
|
|
1814
|
+
return
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
if (key.name === 't') {
|
|
1818
|
+
const entry = routingOrder[state.routerV2CursorIndex ?? 0]
|
|
1819
|
+
if (entry?.key) void testModelViaRouterV2(state, entry.key)
|
|
1820
|
+
return
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
1824
|
+
state.routerV2CursorIndex = Math.max(0, (state.routerV2CursorIndex ?? 0) - 1)
|
|
1825
|
+
return
|
|
1826
|
+
}
|
|
1827
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
1828
|
+
state.routerV2CursorIndex = Math.min(maxCursor, (state.routerV2CursorIndex ?? 0) + 1)
|
|
1829
|
+
return
|
|
1830
|
+
}
|
|
1831
|
+
if (key.name === 'pageup') {
|
|
1832
|
+
state.routerV2ScrollOffset = Math.max(0, (state.routerV2ScrollOffset || 0) - pageStep)
|
|
1833
|
+
return
|
|
1834
|
+
}
|
|
1835
|
+
if (key.name === 'pagedown') {
|
|
1836
|
+
state.routerV2ScrollOffset = (state.routerV2ScrollOffset || 0) + pageStep
|
|
1837
|
+
return
|
|
1838
|
+
}
|
|
1839
|
+
if (key.name === 'home') {
|
|
1840
|
+
state.routerV2CursorIndex = 0
|
|
1841
|
+
state.routerV2ScrollOffset = 0
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
if (key.name === 'i') {
|
|
1845
|
+
try { await cycleRouterV2ProbeMode(state) } catch {}
|
|
1846
|
+
return
|
|
1847
|
+
}
|
|
1848
|
+
if (key.name === 'c') {
|
|
1849
|
+
void (async () => {
|
|
1850
|
+
try {
|
|
1851
|
+
if (state.routerV2BaseUrl) {
|
|
1852
|
+
await fetch(`${state.routerV2BaseUrl}/api/router-v2/history`, { method: 'DELETE' })
|
|
1853
|
+
state.routerV2History = null
|
|
1854
|
+
setRouterV2Notice(state, 'success', 'Request history cleared.')
|
|
1855
|
+
}
|
|
1856
|
+
} catch {}
|
|
1857
|
+
})()
|
|
1858
|
+
return
|
|
1859
|
+
}
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1777
1863
|
// 📖 Playground overlay: handles Enter, Esc, Backspace, Ctrl+S, Ctrl+L,
|
|
1778
1864
|
// 📖 PageUp/PageDown, and printable input itself. The dedicated handler
|
|
1779
1865
|
// 📖 in core/playground.js owns the draft and submission state.
|
|
@@ -2926,6 +3012,16 @@ export function createKeyHandler(ctx) {
|
|
|
2926
3012
|
return
|
|
2927
3013
|
}
|
|
2928
3014
|
|
|
3015
|
+
// 📖 Shift+V: Open / close the Router v2 (BETA) dashboard.
|
|
3016
|
+
if (key.name === 'v' && key.shift && !key.ctrl && !key.meta) {
|
|
3017
|
+
if (state.routerV2DashboardOpen) {
|
|
3018
|
+
closeRouterV2DashboardOverlay(state)
|
|
3019
|
+
return
|
|
3020
|
+
}
|
|
3021
|
+
openRouterV2DashboardOverlay(state)
|
|
3022
|
+
return
|
|
3023
|
+
}
|
|
3024
|
+
|
|
2929
3025
|
// 📖 Shift+T: open the Token Usage screen.
|
|
2930
3026
|
if (key.name === 't' && key.shift && !key.ctrl && !key.meta) {
|
|
2931
3027
|
openTokenUsageOverlay()
|
|
@@ -3070,6 +3166,25 @@ export function createKeyHandler(ctx) {
|
|
|
3070
3166
|
return
|
|
3071
3167
|
}
|
|
3072
3168
|
|
|
3169
|
+
// 📖 Ctrl+T: test the selected model THROUGH the Router v2 daemon (beta).
|
|
3170
|
+
// 📖 Unlike Ctrl+A (direct provider call), this exercises the full routing
|
|
3171
|
+
// chain: schema normalization, pre-prompt, content gate and the breaker
|
|
3172
|
+
// update. The pinned-model request runs with failover disabled.
|
|
3173
|
+
if (key.ctrl && key.name === 't' && !key.shift) {
|
|
3174
|
+
const selected = state.visibleSorted?.[state.cursor]
|
|
3175
|
+
if (selected?.providerKey && selected?.modelId) {
|
|
3176
|
+
void testModelViaRouterV2(state, `${selected.providerKey}/${selected.modelId}`)
|
|
3177
|
+
}
|
|
3178
|
+
return
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
// 📖 Ctrl+Shift+T: test every visible configured model through Router v2,
|
|
3182
|
+
// results streaming into the Shift+V overlay as they land.
|
|
3183
|
+
if (key.ctrl && key.shift && key.name === 't') {
|
|
3184
|
+
void testAllVisibleViaRouterV2(state)
|
|
3185
|
+
return
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3073
3188
|
if (key.shift && key.name === 'up') {
|
|
3074
3189
|
const selected = state.visibleSorted?.[state.cursor]
|
|
3075
3190
|
if (selected?.isFavorite) {
|