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.
@@ -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 { persistSentinelConfigAfterApply } from '../security/sentinel-config-store.js';
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 Sentinel limits applied and saved to ${savedTo}. Survives Gateway restart; env vars override individual fields when set.`,
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
- * Apply saved limits on Gateway boot. Env-defined fields keep env values.
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(
@@ -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.push_overlay(Screen::SentinelConfig);
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.toggle_view_mode();
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 toggle_view_mode(&mut self) {
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::Templates,
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 Custom · ↑/↓ Template · Enter Apply · R Refresh · ESC Close"
558
+ "Tab: next view · ↑/↓ Template · Enter Apply · R Refresh · ESC Close"
530
559
  }
531
560
  SentinelViewMode::Custom => {
532
- "Tab Templates · ↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · ESC Close"
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("Press Tab → Custom to tune your own limits.").style(Style::default().fg(TEXT_MUTED)),
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.64",
3
+ "version": "2.10.65",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.64: Sentinel limits persist to disk; Custom tab edit with arrow keys and M:SS display.",
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",