4runr-os 2.10.68 → 2.10.69

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.
@@ -2929,9 +2929,9 @@
2929
2929
  "license": "MIT"
2930
2930
  },
2931
2931
  "node_modules/@types/node": {
2932
- "version": "20.19.41",
2933
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
2934
- "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
2932
+ "version": "20.19.42",
2933
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
2934
+ "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
2935
2935
  "license": "MIT",
2936
2936
  "dependencies": {
2937
2937
  "undici-types": "~6.21.0"
@@ -1,81 +1,81 @@
1
- /**
2
- * One-shot: prove sentinel-limits.json round-trip changes Sentinel.getConfig()
3
- * and policy thresholds (simulates Gateway restart without HTTP).
4
- *
5
- * Usage: node scripts/verify-sentinel-persist.mjs
6
- */
7
- import * as fs from 'fs';
8
- import * as path from 'path';
9
- import * as os from 'os';
10
-
11
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '4runr-persist-verify-'));
12
- process.env['APPDATA'] = tmp;
13
-
14
- const { Sentinel, policyManager } = await import('@4runr/sentinel');
15
- const {
16
- saveSentinelLimitsToDisk,
17
- loadSentinelLimitsFromDisk,
18
- applyPersistedSentinelConfig,
19
- getSentinelLimitsFilePath,
20
- } = await import('../dist/security/sentinel-config-store.js');
21
-
22
- const distinctive = {
23
- enabled: true,
24
- runMaxDurationMs: 111_000,
25
- runMaxTokens: 22_000,
26
- runIdleMs: 12_345,
27
- loopWindow: 9,
28
- loopMax: 4,
29
- runMaxCost: 2.75,
30
- };
31
-
32
- saveSentinelLimitsToDisk(distinctive);
33
- const filePath = getSentinelLimitsFilePath();
34
- if (!fs.existsSync(filePath)) {
35
- console.error('FAIL: file not written', filePath);
36
- process.exit(1);
37
- }
38
- const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf8'));
39
- console.log('OK: wrote', filePath);
40
- console.log(' config.runIdleMs on disk:', onDisk.config?.runIdleMs);
41
-
42
- const loaded = loadSentinelLimitsFromDisk();
43
- if (loaded?.runIdleMs !== distinctive.runIdleMs) {
44
- console.error('FAIL: load from disk', loaded);
45
- process.exit(1);
46
- }
47
- console.log('OK: loadSentinelLimitsFromDisk matches');
48
-
49
- Sentinel.resetInstance();
50
- const sentinel = Sentinel.getInstance();
51
- const before = sentinel.getConfig().runIdleMs;
52
- applyPersistedSentinelConfig(sentinel);
53
- const after = sentinel.getConfig().runIdleMs;
54
- if (after !== distinctive.runIdleMs) {
55
- console.error('FAIL: after boot apply', { before, after, expected: distinctive.runIdleMs });
56
- process.exit(1);
57
- }
58
- console.log('OK: fresh singleton after applyPersistedSentinelConfig', { before, after });
59
-
60
- const violations = policyManager.evaluatePolicies(
61
- 'verify-run',
62
- {
63
- runId: 'verify-run',
64
- createdAt: Date.now(),
65
- startedAt: Date.now() - 30_000,
66
- lastTokenAt: Date.now() - 30_000,
67
- tokenCount: 0,
68
- status: 'running',
69
- events: [],
70
- },
71
- sentinel.getConfig()
72
- );
73
- const idle = violations.find((v) => v.policy === 'idle');
74
- if (!idle || idle.threshold !== distinctive.runIdleMs) {
75
- console.error('FAIL: policy threshold', idle);
76
- process.exit(1);
77
- }
78
- console.log('OK: idle policy threshold = persisted runIdleMs', idle.threshold);
79
-
80
- fs.rmSync(tmp, { recursive: true, force: true });
81
- console.log('\nAll persistence checks passed (fundamental, not UI-only).');
1
+ /**
2
+ * One-shot: prove sentinel-limits.json round-trip changes Sentinel.getConfig()
3
+ * and policy thresholds (simulates Gateway restart without HTTP).
4
+ *
5
+ * Usage: node scripts/verify-sentinel-persist.mjs
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as os from 'os';
10
+
11
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '4runr-persist-verify-'));
12
+ process.env['APPDATA'] = tmp;
13
+
14
+ const { Sentinel, policyManager } = await import('@4runr/sentinel');
15
+ const {
16
+ saveSentinelLimitsToDisk,
17
+ loadSentinelLimitsFromDisk,
18
+ applyPersistedSentinelConfig,
19
+ getSentinelLimitsFilePath,
20
+ } = await import('../dist/security/sentinel-config-store.js');
21
+
22
+ const distinctive = {
23
+ enabled: true,
24
+ runMaxDurationMs: 111_000,
25
+ runMaxTokens: 22_000,
26
+ runIdleMs: 12_345,
27
+ loopWindow: 9,
28
+ loopMax: 4,
29
+ runMaxCost: 2.75,
30
+ };
31
+
32
+ saveSentinelLimitsToDisk(distinctive);
33
+ const filePath = getSentinelLimitsFilePath();
34
+ if (!fs.existsSync(filePath)) {
35
+ console.error('FAIL: file not written', filePath);
36
+ process.exit(1);
37
+ }
38
+ const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf8'));
39
+ console.log('OK: wrote', filePath);
40
+ console.log(' config.runIdleMs on disk:', onDisk.config?.runIdleMs);
41
+
42
+ const loaded = loadSentinelLimitsFromDisk();
43
+ if (loaded?.runIdleMs !== distinctive.runIdleMs) {
44
+ console.error('FAIL: load from disk', loaded);
45
+ process.exit(1);
46
+ }
47
+ console.log('OK: loadSentinelLimitsFromDisk matches');
48
+
49
+ Sentinel.resetInstance();
50
+ const sentinel = Sentinel.getInstance();
51
+ const before = sentinel.getConfig().runIdleMs;
52
+ applyPersistedSentinelConfig(sentinel);
53
+ const after = sentinel.getConfig().runIdleMs;
54
+ if (after !== distinctive.runIdleMs) {
55
+ console.error('FAIL: after boot apply', { before, after, expected: distinctive.runIdleMs });
56
+ process.exit(1);
57
+ }
58
+ console.log('OK: fresh singleton after applyPersistedSentinelConfig', { before, after });
59
+
60
+ const violations = policyManager.evaluatePolicies(
61
+ 'verify-run',
62
+ {
63
+ runId: 'verify-run',
64
+ createdAt: Date.now(),
65
+ startedAt: Date.now() - 30_000,
66
+ lastTokenAt: Date.now() - 30_000,
67
+ tokenCount: 0,
68
+ status: 'running',
69
+ events: [],
70
+ },
71
+ sentinel.getConfig()
72
+ );
73
+ const idle = violations.find((v) => v.policy === 'idle');
74
+ if (!idle || idle.threshold !== distinctive.runIdleMs) {
75
+ console.error('FAIL: policy threshold', idle);
76
+ process.exit(1);
77
+ }
78
+ console.log('OK: idle policy threshold = persisted runIdleMs', idle.threshold);
79
+
80
+ fs.rmSync(tmp, { recursive: true, force: true });
81
+ console.log('\nAll persistence checks passed (fundamental, not UI-only).');
@@ -1,93 +1,93 @@
1
- /**
2
- * HTTP apply → disk → simulated restart (proves route wiring, not UI-only).
3
- */
4
-
5
- import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
6
- import Fastify from 'fastify';
7
- import * as fs from 'fs';
8
- import * as path from 'path';
9
- import * as os from 'os';
10
- import { Sentinel } from '@4runr/sentinel';
11
- import {
12
- getSentinelLimitsFilePath,
13
- applyPersistedSentinelConfig,
14
- hydrateSentinelConfigFromDisk,
15
- } from '../security/sentinel-config-store.js';
16
-
17
- jest.mock('../middleware/rateLimit.js', () => ({
18
- writeRateLimit: async () => {},
19
- readRateLimit: async () => {},
20
- }));
21
-
22
- jest.mock('../middleware/auth.js', () => ({
23
- requireAuth: async () => {},
24
- }));
25
-
26
- describe('POST /api/sentinel/policies/custom persistence', () => {
27
- const originalAppData = process.env['APPDATA'];
28
- let tmpDir: string;
29
- let app: ReturnType<typeof Fastify>;
30
-
31
- beforeEach(async () => {
32
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '4runr-sentinel-http-'));
33
- process.env['APPDATA'] = tmpDir;
34
- delete process.env['RUN_IDLE_MS'];
35
- delete process.env['SENTINEL_IDLE_MS'];
36
- Sentinel.resetInstance();
37
-
38
- const { sentinelPolicyRoutes } = await import('../routes/sentinel-policies.js');
39
- app = Fastify();
40
- await sentinelPolicyRoutes(app);
41
- await app.ready();
42
- });
43
-
44
- afterEach(async () => {
45
- await app.close();
46
- Sentinel.resetInstance();
47
- if (originalAppData === undefined) delete process.env['APPDATA'];
48
- else process.env['APPDATA'] = originalAppData;
49
- fs.rmSync(tmpDir, { recursive: true, force: true });
50
- });
51
-
52
- it('writes sentinel-limits.json and reloads after simulated Gateway restart', async () => {
53
- const distinctiveIdle = 77_777;
54
-
55
- const res = await app.inject({
56
- method: 'POST',
57
- url: '/api/sentinel/policies/custom',
58
- payload: {
59
- enabled: true,
60
- runMaxDurationMs: 120_000,
61
- runMaxTokens: 16_000,
62
- runIdleMs: distinctiveIdle,
63
- loopWindow: 15,
64
- loopMax: 5,
65
- runMaxCost: 1.5,
66
- },
67
- });
68
-
69
- expect(res.statusCode).toBe(200);
70
- const body = res.json() as { success?: boolean; config?: { runIdleMs?: number } };
71
- expect(body.success).toBe(true);
72
- expect(body.config?.runIdleMs).toBe(distinctiveIdle);
73
-
74
- const filePath = getSentinelLimitsFilePath();
75
- expect(fs.existsSync(filePath)).toBe(true);
76
- const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf8')) as {
77
- config: { runIdleMs: number };
78
- };
79
- expect(onDisk.config.runIdleMs).toBe(distinctiveIdle);
80
-
81
- Sentinel.resetInstance();
82
- const sentinel = Sentinel.getInstance();
83
- expect(applyPersistedSentinelConfig(sentinel)).toBe(true);
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);
92
- });
93
- });
1
+ /**
2
+ * HTTP apply → disk → simulated restart (proves route wiring, not UI-only).
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
6
+ import Fastify from 'fastify';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as os from 'os';
10
+ import { Sentinel } from '@4runr/sentinel';
11
+ import {
12
+ getSentinelLimitsFilePath,
13
+ applyPersistedSentinelConfig,
14
+ hydrateSentinelConfigFromDisk,
15
+ } from '../security/sentinel-config-store.js';
16
+
17
+ jest.mock('../middleware/rateLimit.js', () => ({
18
+ writeRateLimit: async () => {},
19
+ readRateLimit: async () => {},
20
+ }));
21
+
22
+ jest.mock('../middleware/auth.js', () => ({
23
+ requireAuth: async () => {},
24
+ }));
25
+
26
+ describe('POST /api/sentinel/policies/custom persistence', () => {
27
+ const originalAppData = process.env['APPDATA'];
28
+ let tmpDir: string;
29
+ let app: ReturnType<typeof Fastify>;
30
+
31
+ beforeEach(async () => {
32
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '4runr-sentinel-http-'));
33
+ process.env['APPDATA'] = tmpDir;
34
+ delete process.env['RUN_IDLE_MS'];
35
+ delete process.env['SENTINEL_IDLE_MS'];
36
+ Sentinel.resetInstance();
37
+
38
+ const { sentinelPolicyRoutes } = await import('../routes/sentinel-policies.js');
39
+ app = Fastify();
40
+ await sentinelPolicyRoutes(app);
41
+ await app.ready();
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await app.close();
46
+ Sentinel.resetInstance();
47
+ if (originalAppData === undefined) delete process.env['APPDATA'];
48
+ else process.env['APPDATA'] = originalAppData;
49
+ fs.rmSync(tmpDir, { recursive: true, force: true });
50
+ });
51
+
52
+ it('writes sentinel-limits.json and reloads after simulated Gateway restart', async () => {
53
+ const distinctiveIdle = 77_777;
54
+
55
+ const res = await app.inject({
56
+ method: 'POST',
57
+ url: '/api/sentinel/policies/custom',
58
+ payload: {
59
+ enabled: true,
60
+ runMaxDurationMs: 120_000,
61
+ runMaxTokens: 16_000,
62
+ runIdleMs: distinctiveIdle,
63
+ loopWindow: 15,
64
+ loopMax: 5,
65
+ runMaxCost: 1.5,
66
+ },
67
+ });
68
+
69
+ expect(res.statusCode).toBe(200);
70
+ const body = res.json() as { success?: boolean; config?: { runIdleMs?: number } };
71
+ expect(body.success).toBe(true);
72
+ expect(body.config?.runIdleMs).toBe(distinctiveIdle);
73
+
74
+ const filePath = getSentinelLimitsFilePath();
75
+ expect(fs.existsSync(filePath)).toBe(true);
76
+ const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf8')) as {
77
+ config: { runIdleMs: number };
78
+ };
79
+ expect(onDisk.config.runIdleMs).toBe(distinctiveIdle);
80
+
81
+ Sentinel.resetInstance();
82
+ const sentinel = Sentinel.getInstance();
83
+ expect(applyPersistedSentinelConfig(sentinel)).toBe(true);
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);
92
+ });
93
+ });
@@ -1,133 +1,133 @@
1
- /**
2
- * Sentinel limits disk persistence
3
- */
4
-
5
- import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
6
- import * as fs from 'fs';
7
- import * as path from 'path';
8
- import * as os from 'os';
9
- import {
10
- getSentinelLimitsFilePath,
11
- loadSentinelLimitsFromDisk,
12
- saveSentinelLimitsToDisk,
13
- applyPersistedSentinelConfig,
14
- } from '../security/sentinel-config-store.js';
15
- import { Sentinel, policyManager } from '@4runr/sentinel';
16
- import type { RunState } from '@4runr/sentinel';
17
-
18
- describe('sentinel-config-store', () => {
19
- const originalAppData = process.env['APPDATA'];
20
- let tmpDir: string;
21
-
22
- beforeEach(() => {
23
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '4runr-sentinel-'));
24
- process.env['APPDATA'] = tmpDir;
25
- Sentinel.resetInstance();
26
- });
27
-
28
- afterEach(() => {
29
- Sentinel.resetInstance();
30
- if (originalAppData === undefined) {
31
- delete process.env['APPDATA'];
32
- } else {
33
- process.env['APPDATA'] = originalAppData;
34
- }
35
- fs.rmSync(tmpDir, { recursive: true, force: true });
36
- });
37
-
38
- it('round-trips config to disk', () => {
39
- const config = {
40
- enabled: true,
41
- runMaxDurationMs: 90_000,
42
- runMaxTokens: 8_000,
43
- runIdleMs: 45_000,
44
- loopWindow: 10,
45
- loopMax: 3,
46
- runMaxCost: 1.25,
47
- };
48
- saveSentinelLimitsToDisk(config);
49
- expect(fs.existsSync(getSentinelLimitsFilePath())).toBe(true);
50
- const loaded = loadSentinelLimitsFromDisk();
51
- expect(loaded).toMatchObject(config);
52
- });
53
-
54
- it('applyPersistedSentinelConfig merges when env field unset', () => {
55
- delete process.env['RUN_IDLE_MS'];
56
- delete process.env['SENTINEL_IDLE_MS'];
57
- saveSentinelLimitsToDisk({
58
- enabled: true,
59
- runMaxDurationMs: 60_000,
60
- runMaxTokens: 16_000,
61
- runIdleMs: 99_000,
62
- loopWindow: 15,
63
- loopMax: 5,
64
- runMaxCost: 2.0,
65
- });
66
- const sentinel = Sentinel.getInstance();
67
- applyPersistedSentinelConfig(sentinel);
68
- expect(sentinel.getConfig().runIdleMs).toBe(99_000);
69
- });
70
-
71
- it('simulates Gateway restart: disk limits reload into a fresh Sentinel singleton', () => {
72
- delete process.env['RUN_IDLE_MS'];
73
- delete process.env['SENTINEL_IDLE_MS'];
74
-
75
- const distinctiveIdle = 12_345;
76
- saveSentinelLimitsToDisk({
77
- enabled: true,
78
- runMaxDurationMs: 600_000,
79
- runMaxTokens: 50_000,
80
- runIdleMs: distinctiveIdle,
81
- loopWindow: 15,
82
- loopMax: 5,
83
- runMaxCost: 3.5,
84
- });
85
-
86
- Sentinel.resetInstance();
87
- const sentinel = Sentinel.getInstance();
88
- expect(sentinel.getConfig().runIdleMs).not.toBe(distinctiveIdle);
89
-
90
- expect(applyPersistedSentinelConfig(sentinel)).toBe(true);
91
- expect(sentinel.getConfig().runIdleMs).toBe(distinctiveIdle);
92
- expect(sentinel.getConfig().runMaxCost).toBe(3.5);
93
- });
94
-
95
- it('persisted limits drive real policy evaluation (not display-only)', () => {
96
- delete process.env['RUN_IDLE_MS'];
97
- delete process.env['SENTINEL_IDLE_MS'];
98
-
99
- const idleMs = 8_000;
100
- saveSentinelLimitsToDisk({
101
- enabled: true,
102
- runMaxDurationMs: 600_000,
103
- runMaxTokens: 50_000,
104
- runIdleMs: idleMs,
105
- loopWindow: 15,
106
- loopMax: 5,
107
- runMaxCost: 1.0,
108
- });
109
-
110
- Sentinel.resetInstance();
111
- const sentinel = Sentinel.getInstance();
112
- applyPersistedSentinelConfig(sentinel);
113
-
114
- const runState: RunState = {
115
- runId: 'run-persist-verify',
116
- createdAt: Date.now(),
117
- startedAt: Date.now() - 20_000,
118
- lastTokenAt: Date.now() - 20_000,
119
- tokenCount: 0,
120
- status: 'running',
121
- events: [],
122
- };
123
-
124
- const violations = policyManager.evaluatePolicies(
125
- 'run-persist-verify',
126
- runState,
127
- sentinel.getConfig()
128
- );
129
- const idleViolation = violations.find((v) => v.policy === 'idle');
130
- expect(idleViolation).toBeDefined();
131
- expect(idleViolation!.threshold).toBe(idleMs);
132
- });
133
- });
1
+ /**
2
+ * Sentinel limits disk persistence
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import {
10
+ getSentinelLimitsFilePath,
11
+ loadSentinelLimitsFromDisk,
12
+ saveSentinelLimitsToDisk,
13
+ applyPersistedSentinelConfig,
14
+ } from '../security/sentinel-config-store.js';
15
+ import { Sentinel, policyManager } from '@4runr/sentinel';
16
+ import type { RunState } from '@4runr/sentinel';
17
+
18
+ describe('sentinel-config-store', () => {
19
+ const originalAppData = process.env['APPDATA'];
20
+ let tmpDir: string;
21
+
22
+ beforeEach(() => {
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '4runr-sentinel-'));
24
+ process.env['APPDATA'] = tmpDir;
25
+ Sentinel.resetInstance();
26
+ });
27
+
28
+ afterEach(() => {
29
+ Sentinel.resetInstance();
30
+ if (originalAppData === undefined) {
31
+ delete process.env['APPDATA'];
32
+ } else {
33
+ process.env['APPDATA'] = originalAppData;
34
+ }
35
+ fs.rmSync(tmpDir, { recursive: true, force: true });
36
+ });
37
+
38
+ it('round-trips config to disk', () => {
39
+ const config = {
40
+ enabled: true,
41
+ runMaxDurationMs: 90_000,
42
+ runMaxTokens: 8_000,
43
+ runIdleMs: 45_000,
44
+ loopWindow: 10,
45
+ loopMax: 3,
46
+ runMaxCost: 1.25,
47
+ };
48
+ saveSentinelLimitsToDisk(config);
49
+ expect(fs.existsSync(getSentinelLimitsFilePath())).toBe(true);
50
+ const loaded = loadSentinelLimitsFromDisk();
51
+ expect(loaded).toMatchObject(config);
52
+ });
53
+
54
+ it('applyPersistedSentinelConfig merges when env field unset', () => {
55
+ delete process.env['RUN_IDLE_MS'];
56
+ delete process.env['SENTINEL_IDLE_MS'];
57
+ saveSentinelLimitsToDisk({
58
+ enabled: true,
59
+ runMaxDurationMs: 60_000,
60
+ runMaxTokens: 16_000,
61
+ runIdleMs: 99_000,
62
+ loopWindow: 15,
63
+ loopMax: 5,
64
+ runMaxCost: 2.0,
65
+ });
66
+ const sentinel = Sentinel.getInstance();
67
+ applyPersistedSentinelConfig(sentinel);
68
+ expect(sentinel.getConfig().runIdleMs).toBe(99_000);
69
+ });
70
+
71
+ it('simulates Gateway restart: disk limits reload into a fresh Sentinel singleton', () => {
72
+ delete process.env['RUN_IDLE_MS'];
73
+ delete process.env['SENTINEL_IDLE_MS'];
74
+
75
+ const distinctiveIdle = 12_345;
76
+ saveSentinelLimitsToDisk({
77
+ enabled: true,
78
+ runMaxDurationMs: 600_000,
79
+ runMaxTokens: 50_000,
80
+ runIdleMs: distinctiveIdle,
81
+ loopWindow: 15,
82
+ loopMax: 5,
83
+ runMaxCost: 3.5,
84
+ });
85
+
86
+ Sentinel.resetInstance();
87
+ const sentinel = Sentinel.getInstance();
88
+ expect(sentinel.getConfig().runIdleMs).not.toBe(distinctiveIdle);
89
+
90
+ expect(applyPersistedSentinelConfig(sentinel)).toBe(true);
91
+ expect(sentinel.getConfig().runIdleMs).toBe(distinctiveIdle);
92
+ expect(sentinel.getConfig().runMaxCost).toBe(3.5);
93
+ });
94
+
95
+ it('persisted limits drive real policy evaluation (not display-only)', () => {
96
+ delete process.env['RUN_IDLE_MS'];
97
+ delete process.env['SENTINEL_IDLE_MS'];
98
+
99
+ const idleMs = 8_000;
100
+ saveSentinelLimitsToDisk({
101
+ enabled: true,
102
+ runMaxDurationMs: 600_000,
103
+ runMaxTokens: 50_000,
104
+ runIdleMs: idleMs,
105
+ loopWindow: 15,
106
+ loopMax: 5,
107
+ runMaxCost: 1.0,
108
+ });
109
+
110
+ Sentinel.resetInstance();
111
+ const sentinel = Sentinel.getInstance();
112
+ applyPersistedSentinelConfig(sentinel);
113
+
114
+ const runState: RunState = {
115
+ runId: 'run-persist-verify',
116
+ createdAt: Date.now(),
117
+ startedAt: Date.now() - 20_000,
118
+ lastTokenAt: Date.now() - 20_000,
119
+ tokenCount: 0,
120
+ status: 'running',
121
+ events: [],
122
+ };
123
+
124
+ const violations = policyManager.evaluatePolicies(
125
+ 'run-persist-verify',
126
+ runState,
127
+ sentinel.getConfig()
128
+ );
129
+ const idleViolation = violations.find((v) => v.policy === 'idle');
130
+ expect(idleViolation).toBeDefined();
131
+ expect(idleViolation!.threshold).toBe(idleMs);
132
+ });
133
+ });
@@ -1,208 +1,208 @@
1
- /**
2
- * Persist operator Sentinel limits to disk (survives Gateway restart).
3
- * File: {4Runr data dir}/config/sentinel-limits.json
4
- *
5
- * Boot order: env defaults (packages/sentinel) → overlay saved file when present.
6
- * Explicit env vars for a field are not overwritten by the saved file.
7
- */
8
-
9
- import * as fs from 'fs';
10
- import * as path from 'path';
11
- import { get4RunrConfigDir } from '@4runr/shared';
12
- import { Sentinel, type SentinelConfig } from '@4runr/sentinel';
13
-
14
- const FILE_NAME = 'sentinel-limits.json';
15
-
16
- const ENV_FIELD_KEYS: Array<{
17
- field: keyof SentinelConfig;
18
- envKeys: string[];
19
- }> = [
20
- { field: 'enabled', envKeys: ['SENTINEL_ENABLED'] },
21
- { field: 'runMaxDurationMs', envKeys: ['RUN_MAX_DURATION_MS'] },
22
- { field: 'runMaxTokens', envKeys: ['RUN_MAX_TOKENS'] },
23
- { field: 'runIdleMs', envKeys: ['SENTINEL_IDLE_MS', 'RUN_IDLE_MS'] },
24
- { field: 'loopWindow', envKeys: ['SENTINEL_LOOP_WINDOW'] },
25
- { field: 'loopMax', envKeys: ['SENTINEL_LOOP_MAX'] },
26
- { field: 'runMaxCost', envKeys: ['RUN_MAX_COST', 'SENTINEL_MAX_COST'] },
27
- ];
28
-
29
- export function getSentinelLimitsFilePath(): string {
30
- return path.join(get4RunrConfigDir(), FILE_NAME);
31
- }
32
-
33
- function envDefinesField(field: keyof SentinelConfig): boolean {
34
- const entry = ENV_FIELD_KEYS.find((e) => e.field === field);
35
- if (!entry) return false;
36
- return entry.envKeys.some((k) => {
37
- const v = process.env[k];
38
- return v !== undefined && v.trim() !== '';
39
- });
40
- }
41
-
42
- function parseSavedPayload(raw: unknown): SentinelConfig | null {
43
- if (!raw || typeof raw !== 'object') return null;
44
- const o = raw as Record<string, unknown>;
45
- const num = (key: string): number | undefined => {
46
- const v = o[key];
47
- if (typeof v === 'number' && Number.isFinite(v)) return v;
48
- return undefined;
49
- };
50
- const enabled = o['enabled'];
51
- if (typeof enabled !== 'boolean') return null;
52
- const runMaxDurationMs = num('runMaxDurationMs');
53
- const runMaxTokens = num('runMaxTokens');
54
- const runIdleMs = num('runIdleMs');
55
- const loopWindow = num('loopWindow');
56
- const loopMax = num('loopMax');
57
- if (
58
- runMaxDurationMs === undefined ||
59
- runMaxTokens === undefined ||
60
- runIdleMs === undefined ||
61
- loopWindow === undefined ||
62
- loopMax === undefined
63
- ) {
64
- return null;
65
- }
66
- const runMaxCost = num('runMaxCost');
67
- return {
68
- enabled,
69
- runMaxDurationMs,
70
- runMaxTokens,
71
- runIdleMs,
72
- loopWindow,
73
- loopMax,
74
- ...(runMaxCost !== undefined ? { runMaxCost } : {}),
75
- };
76
- }
77
-
78
- export function loadSentinelLimitsFromDisk(): SentinelConfig | null {
79
- const filePath = getSentinelLimitsFilePath();
80
- try {
81
- if (!fs.existsSync(filePath)) return null;
82
- const text = fs.readFileSync(filePath, 'utf8');
83
- const parsed = JSON.parse(text) as unknown;
84
- const inner =
85
- parsed && typeof parsed === 'object' && 'config' in (parsed as object)
86
- ? (parsed as { config: unknown }).config
87
- : parsed;
88
- return parseSavedPayload(inner);
89
- } catch {
90
- return null;
91
- }
92
- }
93
-
94
- export function saveSentinelLimitsToDisk(config: SentinelConfig): void {
95
- const filePath = getSentinelLimitsFilePath();
96
- const payload = {
97
- version: 1,
98
- updatedAt: new Date().toISOString(),
99
- config: {
100
- enabled: config.enabled,
101
- runMaxDurationMs: config.runMaxDurationMs,
102
- runMaxTokens: config.runMaxTokens,
103
- runIdleMs: config.runIdleMs,
104
- loopWindow: config.loopWindow,
105
- loopMax: config.loopMax,
106
- runMaxCost: config.runMaxCost ?? 1.0,
107
- },
108
- };
109
- fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 });
110
- }
111
-
112
- /** Merge saved limits from disk into the live Sentinel singleton (env wins per field). */
113
- export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
114
- const saved = loadSentinelLimitsFromDisk();
115
- if (!saved) return false;
116
-
117
- const current = sentinel.getConfig();
118
- const merged: SentinelConfig = { ...current };
119
-
120
- for (const { field } of ENV_FIELD_KEYS) {
121
- if (envDefinesField(field)) continue;
122
- (merged as any)[field] = saved[field];
123
- }
124
-
125
- try {
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 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,
146
- });
147
- return false;
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);
189
- }
190
-
191
- export function persistSentinelConfigAfterApply(
192
- sentinel: Sentinel,
193
- config: SentinelConfig,
194
- logger?: {
195
- info: (msg: string, meta?: Record<string, unknown>) => void;
196
- }
197
- ): string {
198
- const filePath = getSentinelLimitsFilePath();
199
- saveSentinelLimitsToDisk(config);
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;
208
- }
1
+ /**
2
+ * Persist operator Sentinel limits to disk (survives Gateway restart).
3
+ * File: {4Runr data dir}/config/sentinel-limits.json
4
+ *
5
+ * Boot order: env defaults (packages/sentinel) → overlay saved file when present.
6
+ * Explicit env vars for a field are not overwritten by the saved file.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { get4RunrConfigDir } from '@4runr/shared';
12
+ import { Sentinel, type SentinelConfig } from '@4runr/sentinel';
13
+
14
+ const FILE_NAME = 'sentinel-limits.json';
15
+
16
+ const ENV_FIELD_KEYS: Array<{
17
+ field: keyof SentinelConfig;
18
+ envKeys: string[];
19
+ }> = [
20
+ { field: 'enabled', envKeys: ['SENTINEL_ENABLED'] },
21
+ { field: 'runMaxDurationMs', envKeys: ['RUN_MAX_DURATION_MS'] },
22
+ { field: 'runMaxTokens', envKeys: ['RUN_MAX_TOKENS'] },
23
+ { field: 'runIdleMs', envKeys: ['SENTINEL_IDLE_MS', 'RUN_IDLE_MS'] },
24
+ { field: 'loopWindow', envKeys: ['SENTINEL_LOOP_WINDOW'] },
25
+ { field: 'loopMax', envKeys: ['SENTINEL_LOOP_MAX'] },
26
+ { field: 'runMaxCost', envKeys: ['RUN_MAX_COST', 'SENTINEL_MAX_COST'] },
27
+ ];
28
+
29
+ export function getSentinelLimitsFilePath(): string {
30
+ return path.join(get4RunrConfigDir(), FILE_NAME);
31
+ }
32
+
33
+ function envDefinesField(field: keyof SentinelConfig): boolean {
34
+ const entry = ENV_FIELD_KEYS.find((e) => e.field === field);
35
+ if (!entry) return false;
36
+ return entry.envKeys.some((k) => {
37
+ const v = process.env[k];
38
+ return v !== undefined && v.trim() !== '';
39
+ });
40
+ }
41
+
42
+ function parseSavedPayload(raw: unknown): SentinelConfig | null {
43
+ if (!raw || typeof raw !== 'object') return null;
44
+ const o = raw as Record<string, unknown>;
45
+ const num = (key: string): number | undefined => {
46
+ const v = o[key];
47
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
48
+ return undefined;
49
+ };
50
+ const enabled = o['enabled'];
51
+ if (typeof enabled !== 'boolean') return null;
52
+ const runMaxDurationMs = num('runMaxDurationMs');
53
+ const runMaxTokens = num('runMaxTokens');
54
+ const runIdleMs = num('runIdleMs');
55
+ const loopWindow = num('loopWindow');
56
+ const loopMax = num('loopMax');
57
+ if (
58
+ runMaxDurationMs === undefined ||
59
+ runMaxTokens === undefined ||
60
+ runIdleMs === undefined ||
61
+ loopWindow === undefined ||
62
+ loopMax === undefined
63
+ ) {
64
+ return null;
65
+ }
66
+ const runMaxCost = num('runMaxCost');
67
+ return {
68
+ enabled,
69
+ runMaxDurationMs,
70
+ runMaxTokens,
71
+ runIdleMs,
72
+ loopWindow,
73
+ loopMax,
74
+ ...(runMaxCost !== undefined ? { runMaxCost } : {}),
75
+ };
76
+ }
77
+
78
+ export function loadSentinelLimitsFromDisk(): SentinelConfig | null {
79
+ const filePath = getSentinelLimitsFilePath();
80
+ try {
81
+ if (!fs.existsSync(filePath)) return null;
82
+ const text = fs.readFileSync(filePath, 'utf8');
83
+ const parsed = JSON.parse(text) as unknown;
84
+ const inner =
85
+ parsed && typeof parsed === 'object' && 'config' in (parsed as object)
86
+ ? (parsed as { config: unknown }).config
87
+ : parsed;
88
+ return parseSavedPayload(inner);
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export function saveSentinelLimitsToDisk(config: SentinelConfig): void {
95
+ const filePath = getSentinelLimitsFilePath();
96
+ const payload = {
97
+ version: 1,
98
+ updatedAt: new Date().toISOString(),
99
+ config: {
100
+ enabled: config.enabled,
101
+ runMaxDurationMs: config.runMaxDurationMs,
102
+ runMaxTokens: config.runMaxTokens,
103
+ runIdleMs: config.runIdleMs,
104
+ loopWindow: config.loopWindow,
105
+ loopMax: config.loopMax,
106
+ runMaxCost: config.runMaxCost ?? 1.0,
107
+ },
108
+ };
109
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 });
110
+ }
111
+
112
+ /** Merge saved limits from disk into the live Sentinel singleton (env wins per field). */
113
+ export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
114
+ const saved = loadSentinelLimitsFromDisk();
115
+ if (!saved) return false;
116
+
117
+ const current = sentinel.getConfig();
118
+ const merged: SentinelConfig = { ...current };
119
+
120
+ for (const { field } of ENV_FIELD_KEYS) {
121
+ if (envDefinesField(field)) continue;
122
+ (merged as any)[field] = saved[field];
123
+ }
124
+
125
+ try {
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 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,
146
+ });
147
+ return false;
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);
189
+ }
190
+
191
+ export function persistSentinelConfigAfterApply(
192
+ sentinel: Sentinel,
193
+ config: SentinelConfig,
194
+ logger?: {
195
+ info: (msg: string, meta?: Record<string, unknown>) => void;
196
+ }
197
+ ): string {
198
+ const filePath = getSentinelLimitsFilePath();
199
+ saveSentinelLimitsToDisk(config);
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;
208
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.10.68",
3
+ "version": "2.10.69",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.68: Fix Windows relaunch after auto-update (opens new 4Runr window).",
5
+ "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.69: Bundle pre-built Windows mk3-tui binary so 4r launches without a local Rust build.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "4runr": "dist/index.js",
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * OS-first smoke for 4Runr Tools (Sentinel + Shield) against a live Gateway.
4
4
  *