4runr-os 2.10.64 → 2.10.65
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/apps/gateway/src/__tests__/sentinel-config-persist-http.test.ts +8 -0
- package/apps/gateway/src/routes/sentinel-policies.ts +10 -4
- package/apps/gateway/src/security/sentinel-config-store.ts +23 -13
- package/mk3-tui/src/app.rs +26 -17
- package/mk3-tui/src/ui/sentinel_config.rs +76 -5
- package/package.json +2 -2
|
@@ -11,6 +11,7 @@ import { Sentinel } from '@4runr/sentinel';
|
|
|
11
11
|
import {
|
|
12
12
|
getSentinelLimitsFilePath,
|
|
13
13
|
applyPersistedSentinelConfig,
|
|
14
|
+
hydrateSentinelConfigFromDisk,
|
|
14
15
|
} from '../security/sentinel-config-store.js';
|
|
15
16
|
|
|
16
17
|
jest.mock('../middleware/rateLimit.js', () => ({
|
|
@@ -81,5 +82,12 @@ describe('POST /api/sentinel/policies/custom persistence', () => {
|
|
|
81
82
|
const sentinel = Sentinel.getInstance();
|
|
82
83
|
expect(applyPersistedSentinelConfig(sentinel)).toBe(true);
|
|
83
84
|
expect(sentinel.getConfig().runIdleMs).toBe(distinctiveIdle);
|
|
85
|
+
|
|
86
|
+
Sentinel.resetInstance();
|
|
87
|
+
const stale = Sentinel.getInstance();
|
|
88
|
+
expect(stale.getConfig().runIdleMs).not.toBe(distinctiveIdle);
|
|
89
|
+
|
|
90
|
+
hydrateSentinelConfigFromDisk(stale);
|
|
91
|
+
expect(stale.getConfig().runIdleMs).toBe(distinctiveIdle);
|
|
84
92
|
});
|
|
85
93
|
});
|
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
} from '@4runr/sentinel';
|
|
13
13
|
import { requireAuth } from '../middleware/auth.js';
|
|
14
14
|
import { readRateLimit, writeRateLimit } from '../middleware/rateLimit.js';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
persistSentinelConfigAfterApply,
|
|
17
|
+
hydrateSentinelConfigFromDisk,
|
|
18
|
+
getSentinelLimitsFilePath,
|
|
19
|
+
} from '../security/sentinel-config-store.js';
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Register Sentinel policy management routes
|
|
@@ -235,7 +239,7 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
|
|
|
235
239
|
return {
|
|
236
240
|
success: true,
|
|
237
241
|
config,
|
|
238
|
-
message: `Custom
|
|
242
|
+
message: `Custom limits saved (${savedTo}). Reopen Sentinel or restart Gateway to reload; env vars override fields when set.`,
|
|
239
243
|
};
|
|
240
244
|
} catch (error: unknown) {
|
|
241
245
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -270,10 +274,12 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
|
|
|
270
274
|
try {
|
|
271
275
|
const { Sentinel } = await import('@4runr/sentinel');
|
|
272
276
|
const sentinel = Sentinel.getInstance();
|
|
273
|
-
|
|
277
|
+
hydrateSentinelConfigFromDisk(sentinel);
|
|
278
|
+
|
|
274
279
|
return {
|
|
275
280
|
success: true,
|
|
276
|
-
config: sentinel.getConfig()
|
|
281
|
+
config: sentinel.getConfig(),
|
|
282
|
+
persistedPath: getSentinelLimitsFilePath(),
|
|
277
283
|
};
|
|
278
284
|
} catch (error: any) {
|
|
279
285
|
return reply.status(500).send({
|
|
@@ -109,13 +109,8 @@ export function saveSentinelLimitsToDisk(config: SentinelConfig): void {
|
|
|
109
109
|
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
*/
|
|
115
|
-
export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
|
|
116
|
-
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
117
|
-
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
118
|
-
}): boolean {
|
|
112
|
+
/** Merge saved limits from disk into the live Sentinel singleton (env wins per field). */
|
|
113
|
+
export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
|
|
119
114
|
const saved = loadSentinelLimitsFromDisk();
|
|
120
115
|
if (!saved) return false;
|
|
121
116
|
|
|
@@ -129,16 +124,31 @@ export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
|
|
|
129
124
|
|
|
130
125
|
try {
|
|
131
126
|
sentinel.updateConfig(merged);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Apply saved limits on Gateway boot. Env-defined fields keep env values.
|
|
135
|
+
*/
|
|
136
|
+
export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
|
|
137
|
+
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
138
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
139
|
+
}): boolean {
|
|
140
|
+
const ok = mergePersistedSentinelConfig(sentinel);
|
|
141
|
+
if (ok) {
|
|
132
142
|
logger?.info('Sentinel limits loaded from disk', {
|
|
133
143
|
path: getSentinelLimitsFilePath(),
|
|
134
144
|
});
|
|
135
|
-
return true;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
logger?.warn('Failed to apply saved Sentinel limits', {
|
|
138
|
-
error: err instanceof Error ? err.message : String(err),
|
|
139
|
-
});
|
|
140
|
-
return false;
|
|
141
145
|
}
|
|
146
|
+
return ok;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Call before returning policy config to clients so long-lived Gateway picks up disk saves. */
|
|
150
|
+
export function hydrateSentinelConfigFromDisk(sentinel: Sentinel): void {
|
|
151
|
+
mergePersistedSentinelConfig(sentinel);
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
export function persistSentinelConfigAfterApply(
|
package/mk3-tui/src/app.rs
CHANGED
|
@@ -828,13 +828,30 @@ impl App {
|
|
|
828
828
|
}
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
pub fn open_sentinel_config(&mut self, ws: Option<&WebSocketClient>) {
|
|
832
|
+
self.state.pending_sentinel_load_id = None;
|
|
833
|
+
self.state.pending_sentinel_apply_id = None;
|
|
834
|
+
self.push_overlay(Screen::SentinelConfig);
|
|
835
|
+
self.state
|
|
836
|
+
.logs
|
|
837
|
+
.push_back("[NAV] Opening Sentinel Configuration...".into());
|
|
838
|
+
if self.state.operation_mode == OperationMode::Connected {
|
|
839
|
+
if let Some(ws) = ws {
|
|
840
|
+
self.begin_sentinel_load_request(ws);
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
self.state.sentinel_config.error = Some(
|
|
844
|
+
"Connect to Gateway first (connect portal).".to_string(),
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
self.request_immediate_render("open_sentinel_config");
|
|
848
|
+
}
|
|
849
|
+
|
|
831
850
|
pub fn begin_sentinel_load_request(&mut self, ws: &WebSocketClient) {
|
|
832
851
|
if self.state.operation_mode != OperationMode::Connected {
|
|
833
852
|
return;
|
|
834
853
|
}
|
|
835
|
-
if self.state.pending_sentinel_load_id.is_some()
|
|
836
|
-
|| self.state.pending_sentinel_apply_id.is_some()
|
|
837
|
-
{
|
|
854
|
+
if self.state.pending_sentinel_load_id.is_some() {
|
|
838
855
|
return;
|
|
839
856
|
}
|
|
840
857
|
self.state.sentinel_config.loading = true;
|
|
@@ -1551,19 +1568,7 @@ impl App {
|
|
|
1551
1568
|
}
|
|
1552
1569
|
}
|
|
1553
1570
|
"sentinel" | "sentinel config" | "sentinel policies" => {
|
|
1554
|
-
self.
|
|
1555
|
-
self.state
|
|
1556
|
-
.logs
|
|
1557
|
-
.push_back("[NAV] Opening Sentinel Configuration...".into());
|
|
1558
|
-
if self.state.operation_mode == OperationMode::Connected {
|
|
1559
|
-
if let Some(ws) = ws_client {
|
|
1560
|
-
self.begin_sentinel_load_request(ws);
|
|
1561
|
-
}
|
|
1562
|
-
} else {
|
|
1563
|
-
self.state.sentinel_config.error = Some(
|
|
1564
|
-
"Connect to Gateway first (connect portal).".to_string(),
|
|
1565
|
-
);
|
|
1566
|
-
}
|
|
1571
|
+
self.open_sentinel_config(ws_client);
|
|
1567
1572
|
}
|
|
1568
1573
|
"config" | "settings" => {
|
|
1569
1574
|
self.push_overlay(Screen::Settings);
|
|
@@ -2514,7 +2519,7 @@ impl App {
|
|
|
2514
2519
|
|
|
2515
2520
|
match key.code {
|
|
2516
2521
|
KeyCode::Tab => {
|
|
2517
|
-
self.state.sentinel_config.
|
|
2522
|
+
self.state.sentinel_config.cycle_view_mode();
|
|
2518
2523
|
self.request_immediate_render("sentinel_tab");
|
|
2519
2524
|
}
|
|
2520
2525
|
KeyCode::Up => {
|
|
@@ -2567,6 +2572,10 @@ impl App {
|
|
|
2567
2572
|
Some("Connect to Gateway first.".to_string());
|
|
2568
2573
|
} else if let Some(ws) = ws_client {
|
|
2569
2574
|
match self.state.sentinel_config.view_mode {
|
|
2575
|
+
SentinelViewMode::About => {
|
|
2576
|
+
self.begin_sentinel_load_request(ws);
|
|
2577
|
+
self.request_render("sentinel_about_refresh");
|
|
2578
|
+
}
|
|
2570
2579
|
SentinelViewMode::Templates => {
|
|
2571
2580
|
if let Some(t) = self.state.sentinel_config.selected_template() {
|
|
2572
2581
|
let key = t.key.clone();
|
|
@@ -14,10 +14,29 @@ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
|
|
|
14
14
|
|
|
15
15
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
16
16
|
pub enum SentinelViewMode {
|
|
17
|
+
About,
|
|
17
18
|
Templates,
|
|
18
19
|
Custom,
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
const ABOUT_SENTINEL_LINES: &[&str] = &[
|
|
23
|
+
"Sentinel is 4Runr's runtime safety layer for agent executions on this Gateway.",
|
|
24
|
+
"",
|
|
25
|
+
"What it does:",
|
|
26
|
+
" • Watches each run while it is running (queue processor path)",
|
|
27
|
+
" • Enforces limits: idle time, max duration, token cap, cost, loop detection",
|
|
28
|
+
" • On violation: kills the run (status killed, policy_violation:* in logs)",
|
|
29
|
+
"",
|
|
30
|
+
"Scope today:",
|
|
31
|
+
" • One policy for ALL runs on this Gateway (not per-agent yet)",
|
|
32
|
+
" • A run = one agent execution (POST /api/runs → start → agent runs)",
|
|
33
|
+
"",
|
|
34
|
+
"How to use this screen:",
|
|
35
|
+
" • Templates: apply a preset · Custom: tune fields · Enter: save to disk",
|
|
36
|
+
" • Saved file survives Gateway restart (see path in Custom help)",
|
|
37
|
+
" • Env vars in docker-compose override individual fields when set",
|
|
38
|
+
];
|
|
39
|
+
|
|
21
40
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
22
41
|
pub enum CustomField {
|
|
23
42
|
Enabled,
|
|
@@ -165,13 +184,14 @@ impl SentinelConfigState {
|
|
|
165
184
|
self.templates.get(self.selected_index)
|
|
166
185
|
}
|
|
167
186
|
|
|
168
|
-
pub fn
|
|
187
|
+
pub fn cycle_view_mode(&mut self) {
|
|
169
188
|
self.view_mode = match self.view_mode {
|
|
189
|
+
SentinelViewMode::About => SentinelViewMode::Templates,
|
|
170
190
|
SentinelViewMode::Templates => {
|
|
171
191
|
self.sync_custom_draft_from_current();
|
|
172
192
|
SentinelViewMode::Custom
|
|
173
193
|
}
|
|
174
|
-
SentinelViewMode::Custom => SentinelViewMode::
|
|
194
|
+
SentinelViewMode::Custom => SentinelViewMode::About,
|
|
175
195
|
};
|
|
176
196
|
self.error = None;
|
|
177
197
|
self.status_message = None;
|
|
@@ -191,6 +211,7 @@ impl SentinelConfigState {
|
|
|
191
211
|
|
|
192
212
|
pub fn select_previous(&mut self) {
|
|
193
213
|
match self.view_mode {
|
|
214
|
+
SentinelViewMode::About => {}
|
|
194
215
|
SentinelViewMode::Templates => {
|
|
195
216
|
if self.selected_index > 0 {
|
|
196
217
|
self.selected_index -= 1;
|
|
@@ -210,6 +231,7 @@ impl SentinelConfigState {
|
|
|
210
231
|
|
|
211
232
|
pub fn select_next(&mut self) {
|
|
212
233
|
match self.view_mode {
|
|
234
|
+
SentinelViewMode::About => {}
|
|
213
235
|
SentinelViewMode::Templates => {
|
|
214
236
|
if self.selected_index + 1 < self.templates.len() {
|
|
215
237
|
self.selected_index += 1;
|
|
@@ -489,6 +511,7 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
489
511
|
.split(area);
|
|
490
512
|
|
|
491
513
|
let mode_label = match sc.view_mode {
|
|
514
|
+
SentinelViewMode::About => "About",
|
|
492
515
|
SentinelViewMode::Templates => "Templates",
|
|
493
516
|
SentinelViewMode::Custom => "Custom",
|
|
494
517
|
};
|
|
@@ -514,6 +537,9 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
514
537
|
.split(chunks[1]);
|
|
515
538
|
|
|
516
539
|
match sc.view_mode {
|
|
540
|
+
SentinelViewMode::About => {
|
|
541
|
+
render_about_panel(f, chunks[1], sc);
|
|
542
|
+
}
|
|
517
543
|
SentinelViewMode::Templates => {
|
|
518
544
|
render_active_panel(f, body[0], sc);
|
|
519
545
|
render_templates_panel(f, body[1], sc);
|
|
@@ -525,11 +551,14 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
525
551
|
}
|
|
526
552
|
|
|
527
553
|
let default_footer = match sc.view_mode {
|
|
554
|
+
SentinelViewMode::About => {
|
|
555
|
+
"Tab: Templates · R: reload from Gateway · ESC: Close"
|
|
556
|
+
}
|
|
528
557
|
SentinelViewMode::Templates => {
|
|
529
|
-
"Tab
|
|
558
|
+
"Tab: next view · ↑/↓ Template · Enter Apply · R Refresh · ESC Close"
|
|
530
559
|
}
|
|
531
560
|
SentinelViewMode::Custom => {
|
|
532
|
-
"Tab
|
|
561
|
+
"Tab: next view · ↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · ESC Close"
|
|
533
562
|
}
|
|
534
563
|
};
|
|
535
564
|
const CUSTOM_SHORTCUTS: &str =
|
|
@@ -573,6 +602,48 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
573
602
|
);
|
|
574
603
|
}
|
|
575
604
|
|
|
605
|
+
fn render_about_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
606
|
+
let block = Block::default()
|
|
607
|
+
.title(" 📖 What is Sentinel? ")
|
|
608
|
+
.borders(Borders::ALL)
|
|
609
|
+
.border_style(Style::default().fg(CYBER_CYAN))
|
|
610
|
+
.style(Style::default().bg(BG_PANEL));
|
|
611
|
+
let inner = block.inner(area);
|
|
612
|
+
f.render_widget(block, area);
|
|
613
|
+
|
|
614
|
+
let mut lines: Vec<Line> = Vec::new();
|
|
615
|
+
for line in ABOUT_SENTINEL_LINES {
|
|
616
|
+
if line.is_empty() {
|
|
617
|
+
lines.push(Line::from(""));
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
let style = if line.starts_with(" •") {
|
|
621
|
+
Style::default().fg(TEXT_PRIMARY)
|
|
622
|
+
} else if line.ends_with(':') && !line.starts_with(' ') {
|
|
623
|
+
Style::default().fg(NEON_GREEN).bold()
|
|
624
|
+
} else {
|
|
625
|
+
Style::default().fg(TEXT_DIM)
|
|
626
|
+
};
|
|
627
|
+
lines.push(Line::from(Span::styled(*line, style)));
|
|
628
|
+
}
|
|
629
|
+
lines.push(Line::from(""));
|
|
630
|
+
lines.push(Line::from(vec![
|
|
631
|
+
Span::styled("Active limits now: ", Style::default().fg(TEXT_DIM)),
|
|
632
|
+
Span::styled(
|
|
633
|
+
if sc.current_enabled { "ON" } else { "OFF" },
|
|
634
|
+
Style::default().fg(NEON_GREEN),
|
|
635
|
+
),
|
|
636
|
+
Span::styled(" · idle ", Style::default().fg(TEXT_DIM)),
|
|
637
|
+
Span::styled(fmt_ms(sc.current_idle_ms), Style::default().fg(TEXT_PRIMARY)),
|
|
638
|
+
Span::styled(" · max ", Style::default().fg(TEXT_DIM)),
|
|
639
|
+
Span::styled(fmt_ms(sc.current_duration_ms), Style::default().fg(TEXT_PRIMARY)),
|
|
640
|
+
Span::styled(" · cost ", Style::default().fg(TEXT_DIM)),
|
|
641
|
+
Span::styled(fmt_cost(sc.current_max_cost), Style::default().fg(AMBER_WARN)),
|
|
642
|
+
]));
|
|
643
|
+
|
|
644
|
+
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
|
645
|
+
}
|
|
646
|
+
|
|
576
647
|
fn render_active_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
577
648
|
let block = Block::default()
|
|
578
649
|
.title(" ⚙️ Active limits (live) ")
|
|
@@ -627,7 +698,7 @@ fn render_active_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
|
627
698
|
),
|
|
628
699
|
]),
|
|
629
700
|
Line::from(""),
|
|
630
|
-
Line::from("
|
|
701
|
+
Line::from("Tab → About / Custom / Templates to switch views.").style(Style::default().fg(TEXT_MUTED)),
|
|
631
702
|
];
|
|
632
703
|
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
|
633
704
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4runr-os",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.65",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.
|
|
5
|
+
"description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.65: Sentinel limits reload from disk on open; About tab explains purpose.",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"4runr": "dist/index.js",
|