4runr-os 2.10.64 → 2.10.66
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 +12 -6
- package/apps/gateway/src/security/sentinel-config-store.ts +74 -16
- package/mk3-tui/src/app.rs +26 -32
- package/mk3-tui/src/ui/sentinel_config.rs +144 -100
- 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
|
|
@@ -171,7 +175,7 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
|
|
|
171
175
|
|
|
172
176
|
try {
|
|
173
177
|
sentinel.updateConfig(config);
|
|
174
|
-
const savedTo = persistSentinelConfigAfterApply(sentinel, config);
|
|
178
|
+
const savedTo = persistSentinelConfigAfterApply(sentinel, config, fastify.log);
|
|
175
179
|
return {
|
|
176
180
|
success: true,
|
|
177
181
|
template: templateName,
|
|
@@ -231,11 +235,11 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
|
|
|
231
235
|
|
|
232
236
|
try {
|
|
233
237
|
sentinel.updateConfig(config);
|
|
234
|
-
const savedTo = persistSentinelConfigAfterApply(sentinel, config);
|
|
238
|
+
const savedTo = persistSentinelConfigAfterApply(sentinel, config, fastify.log);
|
|
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
|
|
|
@@ -124,27 +119,90 @@ export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
|
|
|
124
119
|
|
|
125
120
|
for (const { field } of ENV_FIELD_KEYS) {
|
|
126
121
|
if (envDefinesField(field)) continue;
|
|
127
|
-
(merged as
|
|
122
|
+
(merged as any)[field] = saved[field];
|
|
128
123
|
}
|
|
129
124
|
|
|
130
125
|
try {
|
|
131
126
|
sentinel.updateConfig(merged);
|
|
132
|
-
logger?.info('Sentinel limits loaded from disk', {
|
|
133
|
-
path: getSentinelLimitsFilePath(),
|
|
134
|
-
});
|
|
135
127
|
return true;
|
|
136
|
-
} catch
|
|
137
|
-
|
|
138
|
-
|
|
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 filePath = getSentinelLimitsFilePath();
|
|
141
|
+
const saved = loadSentinelLimitsFromDisk();
|
|
142
|
+
|
|
143
|
+
if (!saved) {
|
|
144
|
+
logger?.info('No Sentinel limits file found on disk (will use env defaults)', {
|
|
145
|
+
expectedPath: filePath,
|
|
139
146
|
});
|
|
140
147
|
return false;
|
|
141
148
|
}
|
|
149
|
+
|
|
150
|
+
// Check which env vars are set and will override the disk values
|
|
151
|
+
const envOverrides: string[] = [];
|
|
152
|
+
const diskApplied: string[] = [];
|
|
153
|
+
|
|
154
|
+
for (const { field, envKeys } of ENV_FIELD_KEYS) {
|
|
155
|
+
if (envDefinesField(field)) {
|
|
156
|
+
const activeEnv = envKeys.find(k => process.env[k] !== undefined && process.env[k]?.trim() !== '');
|
|
157
|
+
if (activeEnv) {
|
|
158
|
+
envOverrides.push(`${field} (via ${activeEnv}=${process.env[activeEnv]})`);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
diskApplied.push(`${field}=${saved[field]}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ok = mergePersistedSentinelConfig(sentinel);
|
|
166
|
+
|
|
167
|
+
if (ok) {
|
|
168
|
+
const finalConfig = sentinel.getConfig();
|
|
169
|
+
logger?.info('✅ Sentinel limits loaded from disk and merged', {
|
|
170
|
+
diskPath: filePath,
|
|
171
|
+
diskValues: saved,
|
|
172
|
+
envOverrides: envOverrides.length > 0 ? envOverrides : 'none',
|
|
173
|
+
diskAppliedFields: diskApplied.length > 0 ? diskApplied : 'none (all env-overridden)',
|
|
174
|
+
finalActiveConfig: finalConfig,
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
logger?.warn('Failed to apply Sentinel limits from disk', {
|
|
178
|
+
diskPath: filePath,
|
|
179
|
+
savedConfig: saved,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return ok;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Call before returning policy config to clients so long-lived Gateway picks up disk saves. */
|
|
187
|
+
export function hydrateSentinelConfigFromDisk(sentinel: Sentinel): void {
|
|
188
|
+
mergePersistedSentinelConfig(sentinel);
|
|
142
189
|
}
|
|
143
190
|
|
|
144
191
|
export function persistSentinelConfigAfterApply(
|
|
145
192
|
sentinel: Sentinel,
|
|
146
|
-
config: SentinelConfig
|
|
193
|
+
config: SentinelConfig,
|
|
194
|
+
logger?: {
|
|
195
|
+
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
196
|
+
}
|
|
147
197
|
): string {
|
|
198
|
+
const filePath = getSentinelLimitsFilePath();
|
|
148
199
|
saveSentinelLimitsToDisk(config);
|
|
149
|
-
|
|
200
|
+
|
|
201
|
+
logger?.info('✅ Sentinel limits saved to disk', {
|
|
202
|
+
path: filePath,
|
|
203
|
+
config: config,
|
|
204
|
+
note: 'Survives Gateway restart. Env vars in docker-compose override individual fields when set.',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return filePath;
|
|
150
208
|
}
|
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 => {
|
|
@@ -2566,21 +2571,10 @@ impl App {
|
|
|
2566
2571
|
self.state.sentinel_config.error =
|
|
2567
2572
|
Some("Connect to Gateway first.".to_string());
|
|
2568
2573
|
} else if let Some(ws) = ws_client {
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
let name = t.name.clone();
|
|
2574
|
-
self.begin_sentinel_apply_request(ws, &key);
|
|
2575
|
-
self.add_log(format!("[SENTINEL] Applying template: {}", name));
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
2578
|
-
SentinelViewMode::Custom => {
|
|
2579
|
-
let draft = self.state.sentinel_config.custom_draft.clone();
|
|
2580
|
-
self.begin_sentinel_apply_custom_request(ws, &draft);
|
|
2581
|
-
self.add_log("[SENTINEL] Applying custom limits".to_string());
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
2574
|
+
// Apply custom configuration
|
|
2575
|
+
let draft = self.state.sentinel_config.custom_draft.clone();
|
|
2576
|
+
self.begin_sentinel_apply_custom_request(ws, &draft);
|
|
2577
|
+
self.add_log("[SENTINEL] Applying custom limits...".to_string());
|
|
2584
2578
|
}
|
|
2585
2579
|
self.request_render("sentinel_apply");
|
|
2586
2580
|
}
|
|
@@ -14,10 +14,27 @@ 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
|
-
Templates,
|
|
18
17
|
Custom,
|
|
19
18
|
}
|
|
20
19
|
|
|
20
|
+
const ABOUT_SENTINEL_LINES: &[&str] = &[
|
|
21
|
+
"Sentinel is 4Runr's runtime safety layer for agent executions on this Gateway.",
|
|
22
|
+
"",
|
|
23
|
+
"What it does:",
|
|
24
|
+
" • Watches each run while it is running (queue processor path)",
|
|
25
|
+
" • Enforces limits: idle time, max duration, token cap, cost, loop detection",
|
|
26
|
+
" • On violation: kills the run (status killed, policy_violation:* in logs)",
|
|
27
|
+
"",
|
|
28
|
+
"Scope:",
|
|
29
|
+
" • One policy for ALL runs on this Gateway (not per-agent yet)",
|
|
30
|
+
" • A run = one agent execution (POST /api/runs → start → agent runs)",
|
|
31
|
+
"",
|
|
32
|
+
"Persistence:",
|
|
33
|
+
" • Enter: saves to disk (%APPDATA%\\4runr\\config\\sentinel-limits.json)",
|
|
34
|
+
" • Survives Gateway restart",
|
|
35
|
+
" • Env vars in docker-compose override individual fields when set",
|
|
36
|
+
];
|
|
37
|
+
|
|
21
38
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
22
39
|
pub enum CustomField {
|
|
23
40
|
Enabled,
|
|
@@ -138,7 +155,7 @@ pub struct SentinelConfigState {
|
|
|
138
155
|
impl Default for SentinelConfigState {
|
|
139
156
|
fn default() -> Self {
|
|
140
157
|
Self {
|
|
141
|
-
view_mode: SentinelViewMode::
|
|
158
|
+
view_mode: SentinelViewMode::Custom,
|
|
142
159
|
templates: Vec::new(),
|
|
143
160
|
selected_index: 0,
|
|
144
161
|
custom_draft: SentinelCurrentConfig::default(),
|
|
@@ -165,14 +182,10 @@ impl SentinelConfigState {
|
|
|
165
182
|
self.templates.get(self.selected_index)
|
|
166
183
|
}
|
|
167
184
|
|
|
168
|
-
pub fn
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
SentinelViewMode::Custom
|
|
173
|
-
}
|
|
174
|
-
SentinelViewMode::Custom => SentinelViewMode::Templates,
|
|
175
|
-
};
|
|
185
|
+
pub fn cycle_view_mode(&mut self) {
|
|
186
|
+
// Only one view now, so cycling does nothing
|
|
187
|
+
self.view_mode = SentinelViewMode::Custom;
|
|
188
|
+
self.sync_custom_draft_from_current();
|
|
176
189
|
self.error = None;
|
|
177
190
|
self.status_message = None;
|
|
178
191
|
}
|
|
@@ -190,40 +203,22 @@ impl SentinelConfigState {
|
|
|
190
203
|
}
|
|
191
204
|
|
|
192
205
|
pub fn select_previous(&mut self) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
SentinelViewMode::Custom => {
|
|
200
|
-
let idx = CustomField::ALL
|
|
201
|
-
.iter()
|
|
202
|
-
.position(|f| *f == self.custom_field)
|
|
203
|
-
.unwrap_or(0);
|
|
204
|
-
if idx > 0 {
|
|
205
|
-
self.custom_field = CustomField::ALL[idx - 1];
|
|
206
|
-
}
|
|
207
|
-
}
|
|
206
|
+
let idx = CustomField::ALL
|
|
207
|
+
.iter()
|
|
208
|
+
.position(|f| *f == self.custom_field)
|
|
209
|
+
.unwrap_or(0);
|
|
210
|
+
if idx > 0 {
|
|
211
|
+
self.custom_field = CustomField::ALL[idx - 1];
|
|
208
212
|
}
|
|
209
213
|
}
|
|
210
214
|
|
|
211
215
|
pub fn select_next(&mut self) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
SentinelViewMode::Custom => {
|
|
219
|
-
let idx = CustomField::ALL
|
|
220
|
-
.iter()
|
|
221
|
-
.position(|f| *f == self.custom_field)
|
|
222
|
-
.unwrap_or(0);
|
|
223
|
-
if idx + 1 < CustomField::ALL.len() {
|
|
224
|
-
self.custom_field = CustomField::ALL[idx + 1];
|
|
225
|
-
}
|
|
226
|
-
}
|
|
216
|
+
let idx = CustomField::ALL
|
|
217
|
+
.iter()
|
|
218
|
+
.position(|f| *f == self.custom_field)
|
|
219
|
+
.unwrap_or(0);
|
|
220
|
+
if idx + 1 < CustomField::ALL.len() {
|
|
221
|
+
self.custom_field = CustomField::ALL[idx + 1];
|
|
227
222
|
}
|
|
228
223
|
}
|
|
229
224
|
|
|
@@ -488,10 +483,6 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
488
483
|
])
|
|
489
484
|
.split(area);
|
|
490
485
|
|
|
491
|
-
let mode_label = match sc.view_mode {
|
|
492
|
-
SentinelViewMode::Templates => "Templates",
|
|
493
|
-
SentinelViewMode::Custom => "Custom",
|
|
494
|
-
};
|
|
495
486
|
let health_txt = if sc.health_status.is_empty() {
|
|
496
487
|
"—".to_string()
|
|
497
488
|
} else {
|
|
@@ -499,10 +490,7 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
499
490
|
};
|
|
500
491
|
|
|
501
492
|
let header = Block::default()
|
|
502
|
-
.title(format!(
|
|
503
|
-
" 🛡️ Sentinel — {} · {} ",
|
|
504
|
-
mode_label, health_txt
|
|
505
|
-
))
|
|
493
|
+
.title(format!(" 🛡️ Sentinel — {} ", health_txt))
|
|
506
494
|
.borders(Borders::ALL)
|
|
507
495
|
.border_style(Style::default().fg(BRAND_PURPLE))
|
|
508
496
|
.style(Style::default().bg(BG_PANEL));
|
|
@@ -513,25 +501,12 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
513
501
|
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
|
|
514
502
|
.split(chunks[1]);
|
|
515
503
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
render_templates_panel(f, body[1], sc);
|
|
520
|
-
}
|
|
521
|
-
SentinelViewMode::Custom => {
|
|
522
|
-
render_custom_editor(f, body[0], sc);
|
|
523
|
-
render_custom_help(f, body[1], sc);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
504
|
+
// Unified view: Custom editor on left, About + Help on right
|
|
505
|
+
render_custom_editor(f, body[0], sc);
|
|
506
|
+
render_custom_help_with_about(f, body[1], sc);
|
|
526
507
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
"Tab Custom · ↑/↓ Template · Enter Apply · R Refresh · ESC Close"
|
|
530
|
-
}
|
|
531
|
-
SentinelViewMode::Custom => {
|
|
532
|
-
"Tab Templates · ↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · ESC Close"
|
|
533
|
-
}
|
|
534
|
-
};
|
|
508
|
+
const DEFAULT_FOOTER: &str =
|
|
509
|
+
"↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · R Refresh · ESC Close";
|
|
535
510
|
const CUSTOM_SHORTCUTS: &str =
|
|
536
511
|
"←/→ or +/- adjust value · wheel · Space ON/OFF · Enter apply · ESC close";
|
|
537
512
|
|
|
@@ -542,13 +517,9 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
542
517
|
} else if let Some(e) = &sc.error {
|
|
543
518
|
e.clone()
|
|
544
519
|
} else if let Some(m) = &sc.status_message {
|
|
545
|
-
|
|
546
|
-
format!("{}\n{}", m, CUSTOM_SHORTCUTS)
|
|
547
|
-
} else {
|
|
548
|
-
m.clone()
|
|
549
|
-
}
|
|
520
|
+
format!("{}\n{}", m, CUSTOM_SHORTCUTS)
|
|
550
521
|
} else {
|
|
551
|
-
|
|
522
|
+
DEFAULT_FOOTER.to_string()
|
|
552
523
|
};
|
|
553
524
|
|
|
554
525
|
let footer = Block::default()
|
|
@@ -573,6 +544,48 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
|
|
573
544
|
);
|
|
574
545
|
}
|
|
575
546
|
|
|
547
|
+
fn render_about_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
548
|
+
let block = Block::default()
|
|
549
|
+
.title(" 📖 What is Sentinel? ")
|
|
550
|
+
.borders(Borders::ALL)
|
|
551
|
+
.border_style(Style::default().fg(CYBER_CYAN))
|
|
552
|
+
.style(Style::default().bg(BG_PANEL));
|
|
553
|
+
let inner = block.inner(area);
|
|
554
|
+
f.render_widget(block, area);
|
|
555
|
+
|
|
556
|
+
let mut lines: Vec<Line> = Vec::new();
|
|
557
|
+
for line in ABOUT_SENTINEL_LINES {
|
|
558
|
+
if line.is_empty() {
|
|
559
|
+
lines.push(Line::from(""));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
let style = if line.starts_with(" •") {
|
|
563
|
+
Style::default().fg(TEXT_PRIMARY)
|
|
564
|
+
} else if line.ends_with(':') && !line.starts_with(' ') {
|
|
565
|
+
Style::default().fg(NEON_GREEN).bold()
|
|
566
|
+
} else {
|
|
567
|
+
Style::default().fg(TEXT_DIM)
|
|
568
|
+
};
|
|
569
|
+
lines.push(Line::from(Span::styled(*line, style)));
|
|
570
|
+
}
|
|
571
|
+
lines.push(Line::from(""));
|
|
572
|
+
lines.push(Line::from(vec![
|
|
573
|
+
Span::styled("Active limits now: ", Style::default().fg(TEXT_DIM)),
|
|
574
|
+
Span::styled(
|
|
575
|
+
if sc.current_enabled { "ON" } else { "OFF" },
|
|
576
|
+
Style::default().fg(NEON_GREEN),
|
|
577
|
+
),
|
|
578
|
+
Span::styled(" · idle ", Style::default().fg(TEXT_DIM)),
|
|
579
|
+
Span::styled(fmt_ms(sc.current_idle_ms), Style::default().fg(TEXT_PRIMARY)),
|
|
580
|
+
Span::styled(" · max ", Style::default().fg(TEXT_DIM)),
|
|
581
|
+
Span::styled(fmt_ms(sc.current_duration_ms), Style::default().fg(TEXT_PRIMARY)),
|
|
582
|
+
Span::styled(" · cost ", Style::default().fg(TEXT_DIM)),
|
|
583
|
+
Span::styled(fmt_cost(sc.current_max_cost), Style::default().fg(AMBER_WARN)),
|
|
584
|
+
]));
|
|
585
|
+
|
|
586
|
+
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
|
587
|
+
}
|
|
588
|
+
|
|
576
589
|
fn render_active_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
577
590
|
let block = Block::default()
|
|
578
591
|
.title(" ⚙️ Active limits (live) ")
|
|
@@ -627,7 +640,7 @@ fn render_active_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
|
627
640
|
),
|
|
628
641
|
]),
|
|
629
642
|
Line::from(""),
|
|
630
|
-
Line::from("
|
|
643
|
+
Line::from("Tab → About / Custom / Templates to switch views.").style(Style::default().fg(TEXT_MUTED)),
|
|
631
644
|
];
|
|
632
645
|
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
|
633
646
|
}
|
|
@@ -715,50 +728,81 @@ fn render_custom_editor(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
|
715
728
|
f.render_widget(List::new(items), inner);
|
|
716
729
|
}
|
|
717
730
|
|
|
718
|
-
fn
|
|
719
|
-
|
|
720
|
-
|
|
731
|
+
fn render_custom_help_with_about(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
|
|
732
|
+
use ratatui::layout::{Constraint, Direction, Layout};
|
|
733
|
+
|
|
734
|
+
// Split into About section (top) and field help (bottom)
|
|
735
|
+
let chunks = Layout::default()
|
|
736
|
+
.direction(Direction::Vertical)
|
|
737
|
+
.constraints([Constraint::Length(15), Constraint::Min(10)])
|
|
738
|
+
.split(area);
|
|
739
|
+
|
|
740
|
+
// Top: About Sentinel
|
|
741
|
+
let about_block = Block::default()
|
|
742
|
+
.title(" 📖 What is Sentinel? ")
|
|
721
743
|
.borders(Borders::ALL)
|
|
722
|
-
.border_style(Style::default().fg(
|
|
744
|
+
.border_style(Style::default().fg(CYBER_CYAN))
|
|
723
745
|
.style(Style::default().bg(BG_PANEL));
|
|
724
|
-
let
|
|
725
|
-
f.render_widget(
|
|
746
|
+
let about_inner = about_block.inner(chunks[0]);
|
|
747
|
+
f.render_widget(about_block, chunks[0]);
|
|
748
|
+
|
|
749
|
+
let mut about_lines: Vec<Line> = Vec::new();
|
|
750
|
+
for line in ABOUT_SENTINEL_LINES {
|
|
751
|
+
if line.is_empty() {
|
|
752
|
+
about_lines.push(Line::from(""));
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
let style = if line.starts_with(" •") {
|
|
756
|
+
Style::default().fg(TEXT_PRIMARY)
|
|
757
|
+
} else if line.ends_with(':') && !line.starts_with(' ') {
|
|
758
|
+
Style::default().fg(NEON_GREEN).bold()
|
|
759
|
+
} else {
|
|
760
|
+
Style::default().fg(TEXT_DIM)
|
|
761
|
+
};
|
|
762
|
+
about_lines.push(Line::from(Span::styled(*line, style)));
|
|
763
|
+
}
|
|
764
|
+
f.render_widget(
|
|
765
|
+
Paragraph::new(about_lines).wrap(Wrap { trim: false }),
|
|
766
|
+
about_inner,
|
|
767
|
+
);
|
|
726
768
|
|
|
769
|
+
// Bottom: Field-specific help
|
|
727
770
|
let field = sc.custom_field;
|
|
728
|
-
let
|
|
771
|
+
let help_block = Block::default()
|
|
772
|
+
.title(format!(" ℹ️ {} ", field.label()))
|
|
773
|
+
.borders(Borders::ALL)
|
|
774
|
+
.border_style(Style::default().fg(BRAND_PURPLE))
|
|
775
|
+
.style(Style::default().bg(BG_PANEL));
|
|
776
|
+
let help_inner = help_block.inner(chunks[1]);
|
|
777
|
+
f.render_widget(help_block, chunks[1]);
|
|
778
|
+
|
|
779
|
+
let mut help_lines = vec![
|
|
729
780
|
Line::from(""),
|
|
730
781
|
Line::from(field.help()).style(Style::default().fg(TEXT_PRIMARY)),
|
|
731
782
|
Line::from(""),
|
|
732
783
|
];
|
|
733
784
|
if let Some(reason) = field.kill_reason() {
|
|
734
|
-
|
|
785
|
+
help_lines.push(Line::from(vec![
|
|
735
786
|
Span::styled("Kill log reason: ", Style::default().fg(TEXT_DIM)),
|
|
736
787
|
Span::styled(reason, Style::default().fg(AMBER_WARN)),
|
|
737
788
|
]));
|
|
738
|
-
|
|
789
|
+
help_lines.push(Line::from(""));
|
|
739
790
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
lines.push(
|
|
744
|
-
Line::from("Enter Apply → Gateway + disk (%APPDATA%\\4runr\\config\\sentinel-limits.json).")
|
|
745
|
-
.style(Style::default().fg(NEON_GREEN)),
|
|
746
|
-
);
|
|
747
|
-
lines.push(
|
|
748
|
-
Line::from("Survives Gateway restart. Env vars in docker-compose override fields when set.")
|
|
749
|
-
.style(Style::default().fg(TEXT_MUTED)),
|
|
750
|
-
);
|
|
751
|
-
lines.push(
|
|
752
|
-
Line::from("Scope: all agent runs on this Gateway (one policy per run execution).")
|
|
753
|
-
.style(Style::default().fg(TEXT_MUTED)),
|
|
791
|
+
help_lines.push(
|
|
792
|
+
Line::from("Persistent via env (Gateway boot):")
|
|
793
|
+
.style(Style::default().fg(NEON_GREEN).bold()),
|
|
754
794
|
);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
795
|
+
help_lines.push(Line::from(field.env_var()).style(Style::default().fg(CYBER_CYAN)));
|
|
796
|
+
help_lines.push(Line::from(""));
|
|
797
|
+
help_lines.push(
|
|
798
|
+
Line::from("Adjust: ←/→ or +/- · mouse wheel · Space toggles Enforcement.")
|
|
758
799
|
.style(Style::default().fg(NEON_GREEN)),
|
|
759
800
|
);
|
|
760
801
|
|
|
761
|
-
f.render_widget(
|
|
802
|
+
f.render_widget(
|
|
803
|
+
Paragraph::new(help_lines).wrap(Wrap { trim: false }),
|
|
804
|
+
help_inner,
|
|
805
|
+
);
|
|
762
806
|
}
|
|
763
807
|
|
|
764
808
|
#[cfg(test)]
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4runr-os",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.66",
|
|
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.66: Sentinel unified Custom+About view (Templates removed); enhanced persistence logging.",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"4runr": "dist/index.js",
|