4runr-os 2.10.71 → 2.10.73
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/package-lock.json +113 -113
- package/apps/gateway/scripts/verify-sentinel-persist.mjs +81 -81
- package/apps/gateway/src/__tests__/integration/sentinel.test.ts +79 -0
- package/apps/gateway/src/__tests__/sentinel-config-persist-http.test.ts +93 -93
- package/apps/gateway/src/__tests__/sentinel-config-store.test.ts +133 -133
- package/apps/gateway/src/security/sentinel-config-store.ts +208 -208
- package/dist/boot-orchestrator.d.ts.map +1 -1
- package/dist/boot-orchestrator.js +5 -0
- package/dist/boot-orchestrator.js.map +1 -1
- package/dist/gateway-prisma-bootstrap.d.ts +11 -0
- package/dist/gateway-prisma-bootstrap.d.ts.map +1 -0
- package/dist/gateway-prisma-bootstrap.js +65 -0
- package/dist/gateway-prisma-bootstrap.js.map +1 -0
- package/dist/tui-handlers.js +57 -67
- package/dist/tui-handlers.js.map +1 -1
- package/dist/watchdog.d.ts +5 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +82 -75
- package/dist/watchdog.js.map +1 -1
- package/mk3-tui/src/main.rs +7 -0
- package/package.json +2 -2
- package/scripts/os-tools-smoke.cjs +1 -1
|
@@ -242,6 +242,85 @@ describe('Sentinel Integration', () => {
|
|
|
242
242
|
SENTINEL_SMOKE_TIMEOUT
|
|
243
243
|
);
|
|
244
244
|
|
|
245
|
+
it(
|
|
246
|
+
'custom runIdleMs from POST /policies/custom is enforced (same path as OS UI apply)',
|
|
247
|
+
async () => {
|
|
248
|
+
if (!sentinelHealthy) {
|
|
249
|
+
console.log('Skipping: Sentinel is not healthy');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const currentResponse = await authenticatedRequest('/api/sentinel/policies/current');
|
|
254
|
+
expect(currentResponse.status).toBe(200);
|
|
255
|
+
const currentData = await currentResponse.json();
|
|
256
|
+
const previousConfig = currentData.config as Record<string, unknown>;
|
|
257
|
+
|
|
258
|
+
const customIdleMs = 10_000;
|
|
259
|
+
const sleepMs = 13_000;
|
|
260
|
+
|
|
261
|
+
const customResponse = await authenticatedRequest('/api/sentinel/policies/custom', {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
...previousConfig,
|
|
265
|
+
enabled: true,
|
|
266
|
+
runIdleMs: customIdleMs,
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
expect(customResponse.status).toBe(200);
|
|
270
|
+
const customData = await customResponse.json();
|
|
271
|
+
expect(customData.config.runIdleMs).toBe(customIdleMs);
|
|
272
|
+
|
|
273
|
+
const createResponse = await authenticatedRequest('/api/runs', {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
name: 'Custom Idle Enforcement Smoke',
|
|
277
|
+
input: {
|
|
278
|
+
agent_id: 'test',
|
|
279
|
+
sleep_ms: sleepMs,
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect([201, 429]).toContain(createResponse.status);
|
|
285
|
+
if (createResponse.status === 429) {
|
|
286
|
+
console.log('Rate limited, skipping custom idle enforcement smoke');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const createData = await createResponse.json();
|
|
291
|
+
const runId = createData.run.id;
|
|
292
|
+
|
|
293
|
+
const startResponse = await authenticatedRequest(`/api/runs/${runId}/start`, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: JSON.stringify({ priority: 'high' }),
|
|
296
|
+
});
|
|
297
|
+
expect(startResponse.status).toBe(200);
|
|
298
|
+
|
|
299
|
+
const startedAt = Date.now();
|
|
300
|
+
const finalRun = await pollRunUntilStatus(runId, 'killed', {
|
|
301
|
+
maxWaitMs: 25_000,
|
|
302
|
+
intervalMs: 500,
|
|
303
|
+
});
|
|
304
|
+
const elapsedMs = Date.now() - startedAt;
|
|
305
|
+
|
|
306
|
+
expect(finalRun.status).toBe('killed');
|
|
307
|
+
expect(finalRun.output).toMatchObject({
|
|
308
|
+
source: 'sentinel',
|
|
309
|
+
reason: 'policy_violation:idle',
|
|
310
|
+
policy: 'idle',
|
|
311
|
+
});
|
|
312
|
+
// Killed near custom 10s threshold — not strict (15s) or typical UI 60s idle.
|
|
313
|
+
expect(elapsedMs).toBeGreaterThan(customIdleMs - 3_000);
|
|
314
|
+
expect(elapsedMs).toBeLessThan(14_000);
|
|
315
|
+
|
|
316
|
+
await authenticatedRequest('/api/sentinel/policies/custom', {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
body: JSON.stringify(previousConfig),
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
SENTINEL_SMOKE_TIMEOUT
|
|
322
|
+
);
|
|
323
|
+
|
|
245
324
|
it('GET /sentinel/health and /api/sentinel/policies/current respond for staging checks', async () => {
|
|
246
325
|
const healthResponse = await authenticatedRequest('/sentinel/health');
|
|
247
326
|
expect(healthResponse.status).toBe(200);
|
|
@@ -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
|
+
});
|