@animalabs/connectome-host 0.7.3 → 0.7.4
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/CHANGELOG.md +156 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +12 -1
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +2 -2
- package/scripts/audit-module-optins.ts +288 -0
- package/src/framework-strategy.ts +13 -4
- package/src/headless.ts +14 -0
- package/src/index.ts +12 -9
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/mcpl-admin-module.ts +33 -4
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +55 -4
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1187 -0
- package/src/web/protocol.ts +75 -10
- package/test/audit-module-optins.test.ts +167 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +22 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -0
- package/test/mcpl-admin-module.test.ts +23 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +159 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- package/web/vite.config.ts +8 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { ctxSeverity, fmtElapsed, pickTopAlert, sliceViewport } from '../src/tui.js';
|
|
3
|
+
|
|
4
|
+
describe('fmtElapsed', () => {
|
|
5
|
+
test('seconds under a minute stay bare', () => {
|
|
6
|
+
expect(fmtElapsed(0)).toBe('0s');
|
|
7
|
+
expect(fmtElapsed(48)).toBe('48s');
|
|
8
|
+
expect(fmtElapsed(59)).toBe('59s');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('minutes pad seconds to two digits', () => {
|
|
12
|
+
expect(fmtElapsed(60)).toBe('1m00s');
|
|
13
|
+
expect(fmtElapsed(348)).toBe('5m48s');
|
|
14
|
+
expect(fmtElapsed(305)).toBe('5m05s');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('hours pad minutes and drop seconds', () => {
|
|
18
|
+
expect(fmtElapsed(3600)).toBe('1h00m');
|
|
19
|
+
expect(fmtElapsed(3720)).toBe('1h02m');
|
|
20
|
+
expect(fmtElapsed(7500)).toBe('2h05m');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('ctxSeverity', () => {
|
|
25
|
+
test('no budget → always ok', () => {
|
|
26
|
+
expect(ctxSeverity(150_000)).toBe('ok');
|
|
27
|
+
expect(ctxSeverity(150_000, 0)).toBe('ok');
|
|
28
|
+
expect(ctxSeverity(150_000, undefined)).toBe('ok');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('escalates at 75% and 90% of budget', () => {
|
|
32
|
+
expect(ctxSeverity(100_000, 200_000)).toBe('ok');
|
|
33
|
+
expect(ctxSeverity(149_999, 200_000)).toBe('ok');
|
|
34
|
+
expect(ctxSeverity(150_000, 200_000)).toBe('warn');
|
|
35
|
+
expect(ctxSeverity(179_999, 200_000)).toBe('warn');
|
|
36
|
+
expect(ctxSeverity(180_000, 200_000)).toBe('high');
|
|
37
|
+
expect(ctxSeverity(250_000, 200_000)).toBe('high');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('zero context is ok regardless of budget', () => {
|
|
41
|
+
expect(ctxSeverity(0, 100)).toBe('ok');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('pickTopAlert', () => {
|
|
46
|
+
test('empty → null', () => {
|
|
47
|
+
expect(pickTopAlert([])).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('priority kinds beat recency', () => {
|
|
51
|
+
expect(pickTopAlert(['refusal-streak', 'compression-quarantine'])).toBe('compression-quarantine');
|
|
52
|
+
expect(pickTopAlert(['inference-exhausted', 'refusal-streak'])).toBe('inference-exhausted');
|
|
53
|
+
// quarantine outranks hard-down
|
|
54
|
+
expect(pickTopAlert(['inference-exhausted', 'compression-quarantine'])).toBe('compression-quarantine');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('no priority kind → most recent (last) wins', () => {
|
|
58
|
+
expect(pickTopAlert(['a-alert', 'b-alert'])).toBe('b-alert');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('sliceViewport', () => {
|
|
63
|
+
test('everything fits → whole range', () => {
|
|
64
|
+
expect(sliceViewport(10, 3, 20)).toEqual({ start: 0, end: 10 });
|
|
65
|
+
expect(sliceViewport(20, 0, 20)).toEqual({ start: 0, end: 20 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('cursor at top: no top marker row, one extra body line', () => {
|
|
69
|
+
const { start, end } = sliceViewport(100, 0, 20);
|
|
70
|
+
expect(start).toBe(0);
|
|
71
|
+
expect(end).toBe(19); // 19 body rows + 1 bottom-marker row = 20
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('cursor at bottom: no bottom marker row', () => {
|
|
75
|
+
const { start, end } = sliceViewport(100, 99, 20);
|
|
76
|
+
expect(end).toBe(100);
|
|
77
|
+
expect(start).toBe(81); // 1 top-marker row + 19 body rows = 20
|
|
78
|
+
expect(99).toBeGreaterThanOrEqual(start);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('cursor mid-list: centered window with both markers budgeted', () => {
|
|
82
|
+
const { start, end } = sliceViewport(100, 50, 20);
|
|
83
|
+
expect(end - start).toBe(18); // 18 body + 2 marker rows = 20
|
|
84
|
+
expect(50).toBeGreaterThanOrEqual(start);
|
|
85
|
+
expect(50).toBeLessThan(end);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('cursor always lands inside the window', () => {
|
|
89
|
+
for (let cursor = 0; cursor < 60; cursor++) {
|
|
90
|
+
const { start, end } = sliceViewport(60, cursor, 12);
|
|
91
|
+
expect(cursor).toBeGreaterThanOrEqual(start);
|
|
92
|
+
expect(cursor).toBeLessThan(end);
|
|
93
|
+
// rendered rows (body + markers actually shown) never exceed avail
|
|
94
|
+
const rows = (end - start) + (start > 0 ? 1 : 0) + (end < 60 ? 1 : 0);
|
|
95
|
+
expect(rows).toBeLessThanOrEqual(12);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('degenerate tiny viewport still contains the cursor', () => {
|
|
100
|
+
for (const avail of [3, 4, 5]) {
|
|
101
|
+
const { start, end } = sliceViewport(50, 25, avail);
|
|
102
|
+
expect(25).toBeGreaterThanOrEqual(start);
|
|
103
|
+
expect(25).toBeLessThan(end);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { buildContextCoverageSnapshot } from '../src/
|
|
2
|
+
import { buildContextCoverageSnapshot } from '../src/web/panel-data.js';
|
|
3
3
|
|
|
4
4
|
function contextManager(strategy: Record<string, unknown>) {
|
|
5
5
|
return {
|
|
@@ -38,6 +38,7 @@ const BASIC_USER = 'admin';
|
|
|
38
38
|
const BASIC_PASS = 'open-sesame';
|
|
39
39
|
|
|
40
40
|
let handle: ServerHandle;
|
|
41
|
+
let webUiModule: WebUiModule;
|
|
41
42
|
|
|
42
43
|
function basicAuthHeader(user: string, pass: string): string {
|
|
43
44
|
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
|
@@ -59,13 +60,13 @@ beforeAll(async () => {
|
|
|
59
60
|
|
|
60
61
|
// Bun.serve supports port=0 → OS picks a free port; we read it back from
|
|
61
62
|
// the server. Avoids collisions when the test harness retries.
|
|
62
|
-
|
|
63
|
+
webUiModule = new WebUiModule({
|
|
63
64
|
port: 0,
|
|
64
65
|
host: '127.0.0.1',
|
|
65
66
|
basicAuth: { username: BASIC_USER, password: BASIC_PASS },
|
|
66
67
|
staticDir: staticRoot,
|
|
67
68
|
});
|
|
68
|
-
await
|
|
69
|
+
await webUiModule.start({} as ModuleContext);
|
|
69
70
|
|
|
70
71
|
const port = __getSharedServerPortForTests();
|
|
71
72
|
if (!port) throw new Error('webui server not bound; did start() succeed?');
|
|
@@ -74,7 +75,7 @@ beforeAll(async () => {
|
|
|
74
75
|
port,
|
|
75
76
|
staticRoot,
|
|
76
77
|
cleanup: async () => {
|
|
77
|
-
await
|
|
78
|
+
await webUiModule.stop();
|
|
78
79
|
await __resetSharedServerForTests();
|
|
79
80
|
rmSync(tmp, { recursive: true, force: true });
|
|
80
81
|
rmSync(sibling, { recursive: true, force: true });
|
|
@@ -125,6 +126,191 @@ describe('WebUiModule HTTP', () => {
|
|
|
125
126
|
expect(body).toContain('console.log');
|
|
126
127
|
});
|
|
127
128
|
|
|
129
|
+
test('retrieval trace routes require auth and expose the in-memory viewer', async () => {
|
|
130
|
+
for (const path of ['/debug/retrieval', '/debug/retrieval/view']) {
|
|
131
|
+
const unauthenticated = await fetch(`http://127.0.0.1:${handle.port}${path}`);
|
|
132
|
+
expect(unauthenticated.status).toBe(401);
|
|
133
|
+
expect(unauthenticated.headers.get('cache-control')).toBe('no-store');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const headers = { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) };
|
|
137
|
+
const unbound = await fetch(`http://127.0.0.1:${handle.port}/debug/retrieval`, { headers });
|
|
138
|
+
expect(unbound.status).toBe(503);
|
|
139
|
+
expect(unbound.headers.get('cache-control')).toBe('no-store');
|
|
140
|
+
expect(await unbound.json()).toEqual({ error: 'app not bound yet' });
|
|
141
|
+
|
|
142
|
+
const viewer = await fetch(`http://127.0.0.1:${handle.port}/debug/retrieval/view`, { headers });
|
|
143
|
+
expect(viewer.status).toBe(200);
|
|
144
|
+
expect(viewer.headers.get('cache-control')).toBe('no-store');
|
|
145
|
+
const viewerHtml = await viewer.text();
|
|
146
|
+
expect(viewerHtml).toContain('Retrieval traces');
|
|
147
|
+
expect(viewerHtml).toContain('Selected lessons');
|
|
148
|
+
expect(viewerHtml).toContain('Candidate lessons');
|
|
149
|
+
expect(viewerHtml).toContain('Raw JSON (diagnostic)');
|
|
150
|
+
expect(viewerHtml).toContain("'agent: ' + (trace.agentName || 'unknown')");
|
|
151
|
+
expect(viewerHtml).toContain("reasoning ? reasoning.effort : 'default'");
|
|
152
|
+
expect(viewerHtml).not.toContain('reasoning.context');
|
|
153
|
+
expect(viewerHtml).toContain('node.textContent = String(text)');
|
|
154
|
+
expect(viewerHtml).not.toContain('.innerHTML');
|
|
155
|
+
expect(viewerHtml).not.toContain('\\u201C');
|
|
156
|
+
expect(viewerHtml).not.toContain('\\u201D');
|
|
157
|
+
|
|
158
|
+
const signIn = await fetch(`http://127.0.0.1:${handle.port}/auth/basic`, {
|
|
159
|
+
headers,
|
|
160
|
+
redirect: 'manual',
|
|
161
|
+
});
|
|
162
|
+
const cookie = (signIn.headers.get('set-cookie') ?? '').split(';', 1)[0];
|
|
163
|
+
expect(signIn.status).toBe(302);
|
|
164
|
+
expect(cookie.startsWith('fkm_obs=')).toBe(true);
|
|
165
|
+
|
|
166
|
+
const sessionJson = await fetch(`http://127.0.0.1:${handle.port}/debug/retrieval`, {
|
|
167
|
+
headers: { cookie },
|
|
168
|
+
});
|
|
169
|
+
expect(sessionJson.status).toBe(503);
|
|
170
|
+
expect(sessionJson.headers.get('cache-control')).toBe('no-store');
|
|
171
|
+
const sessionViewer = await fetch(
|
|
172
|
+
`http://127.0.0.1:${handle.port}/debug/retrieval/view`,
|
|
173
|
+
{ headers: { cookie } },
|
|
174
|
+
);
|
|
175
|
+
expect(sessionViewer.status).toBe(200);
|
|
176
|
+
expect(sessionViewer.headers.get('cache-control')).toBe('no-store');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('bound retrieval JSON is no-store, redacted by default, and inputs require exact 1', async () => {
|
|
180
|
+
let observedOptions: { limit?: number; includeInputs?: boolean } | undefined;
|
|
181
|
+
const malicious = '</script><img src=x onerror="globalThis.pwned=true">';
|
|
182
|
+
const retrieval = {
|
|
183
|
+
name: 'retrieval',
|
|
184
|
+
getRetrievalTraces: (options: { limit?: number; includeInputs?: boolean } = {}) => {
|
|
185
|
+
observedOptions = options;
|
|
186
|
+
return [{
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
id: 1,
|
|
189
|
+
startedAt: '2026-07-31T00:00:00.000Z',
|
|
190
|
+
agentName: 'test-agent',
|
|
191
|
+
config: {
|
|
192
|
+
model: 'test-retrieval-model',
|
|
193
|
+
requestedReasoning: { effort: 'high' },
|
|
194
|
+
minConfidence: 0.3,
|
|
195
|
+
maxCandidates: 20,
|
|
196
|
+
maxInjectedLessons: 5,
|
|
197
|
+
},
|
|
198
|
+
context: {
|
|
199
|
+
hash: 'abc', messageCount: 1, messageIds: ['m1'],
|
|
200
|
+
...(options.includeInputs ? { input: 'private recent conversation' } : {}),
|
|
201
|
+
},
|
|
202
|
+
cache: { hit: false },
|
|
203
|
+
candidates: [{
|
|
204
|
+
id: 'malicious-lesson', content: malicious, confidence: 0.9,
|
|
205
|
+
tags: [malicious], evidence: [], created: 1, updated: 1,
|
|
206
|
+
deprecated: false, matches: [],
|
|
207
|
+
}],
|
|
208
|
+
relevantLessonIds: [],
|
|
209
|
+
injected: { lessonIds: [], lessons: [] },
|
|
210
|
+
outcome: 'no-concepts',
|
|
211
|
+
}];
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
const framework = {
|
|
215
|
+
getAllAgents: () => [],
|
|
216
|
+
getAllModules: () => [retrieval],
|
|
217
|
+
getSessionUsage: () => { throw new Error('no usage in HTTP harness'); },
|
|
218
|
+
onTrace: () => {},
|
|
219
|
+
};
|
|
220
|
+
webUiModule.setApp({ framework } as never);
|
|
221
|
+
|
|
222
|
+
const headers = { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) };
|
|
223
|
+
try {
|
|
224
|
+
const defaultRes = await fetch(
|
|
225
|
+
`http://127.0.0.1:${handle.port}/debug/retrieval?limit=7`, { headers },
|
|
226
|
+
);
|
|
227
|
+
expect(defaultRes.status).toBe(200);
|
|
228
|
+
expect(defaultRes.headers.get('cache-control')).toBe('no-store');
|
|
229
|
+
const defaultBody = await defaultRes.json() as Record<string, unknown>;
|
|
230
|
+
expect(defaultBody.enabled).toBe(true);
|
|
231
|
+
expect(defaultBody.includeInputs).toBe(false);
|
|
232
|
+
expect(JSON.stringify(defaultBody)).not.toContain('private recent conversation');
|
|
233
|
+
const returnedTraces = defaultBody.traces as Array<{
|
|
234
|
+
candidates: Array<{ content: string }>;
|
|
235
|
+
}>;
|
|
236
|
+
expect(returnedTraces[0].candidates[0].content).toBe(malicious);
|
|
237
|
+
expect(observedOptions).toEqual({ limit: 7, includeInputs: false });
|
|
238
|
+
|
|
239
|
+
const viewerRes = await fetch(
|
|
240
|
+
`http://127.0.0.1:${handle.port}/debug/retrieval/view`, { headers },
|
|
241
|
+
);
|
|
242
|
+
const viewerHtml = await viewerRes.text();
|
|
243
|
+
// Static viewer markup does not inline trace data; dynamic values use text sinks.
|
|
244
|
+
expect(viewerHtml).not.toContain(malicious);
|
|
245
|
+
expect(viewerHtml).toContain('textContent');
|
|
246
|
+
expect(viewerHtml).not.toContain('.innerHTML');
|
|
247
|
+
|
|
248
|
+
for (const alias of ['true', 'yes', '01']) {
|
|
249
|
+
const res = await fetch(
|
|
250
|
+
`http://127.0.0.1:${handle.port}/debug/retrieval?includeInputs=${alias}`, { headers },
|
|
251
|
+
);
|
|
252
|
+
const body = await res.json() as Record<string, unknown>;
|
|
253
|
+
expect(body.includeInputs).toBe(false);
|
|
254
|
+
expect(JSON.stringify(body)).not.toContain('private recent conversation');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const exactRes = await fetch(
|
|
258
|
+
`http://127.0.0.1:${handle.port}/debug/retrieval?includeInputs=1`, { headers },
|
|
259
|
+
);
|
|
260
|
+
const exactBody = await exactRes.json() as Record<string, unknown>;
|
|
261
|
+
expect(exactBody.includeInputs).toBe(true);
|
|
262
|
+
expect(JSON.stringify(exactBody)).toContain('private recent conversation');
|
|
263
|
+
expect(observedOptions).toEqual({ limit: 20, includeInputs: true });
|
|
264
|
+
} finally {
|
|
265
|
+
await webUiModule.stop();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('bound app without retrieval reports tracing disabled', async () => {
|
|
270
|
+
const framework = {
|
|
271
|
+
getAllAgents: () => [],
|
|
272
|
+
getAllModules: () => [],
|
|
273
|
+
getSessionUsage: () => { throw new Error('no usage in HTTP harness'); },
|
|
274
|
+
onTrace: () => {},
|
|
275
|
+
};
|
|
276
|
+
webUiModule.setApp({ framework } as never);
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/debug/retrieval`, {
|
|
279
|
+
headers: { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) },
|
|
280
|
+
});
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
expect(res.headers.get('cache-control')).toBe('no-store');
|
|
283
|
+
expect(await res.json()).toEqual({
|
|
284
|
+
schemaVersion: 1, enabled: false, includeInputs: false, traces: [],
|
|
285
|
+
});
|
|
286
|
+
} finally {
|
|
287
|
+
await webUiModule.stop();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('retrieval JSON errors are also no-store', async () => {
|
|
292
|
+
const framework = {
|
|
293
|
+
getAllAgents: () => [],
|
|
294
|
+
getAllModules: () => [{
|
|
295
|
+
name: 'retrieval',
|
|
296
|
+
getRetrievalTraces: () => { throw new Error('trace listing failed'); },
|
|
297
|
+
}],
|
|
298
|
+
getSessionUsage: () => { throw new Error('no usage in HTTP harness'); },
|
|
299
|
+
onTrace: () => {},
|
|
300
|
+
};
|
|
301
|
+
webUiModule.setApp({ framework } as never);
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/debug/retrieval`, {
|
|
304
|
+
headers: { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) },
|
|
305
|
+
});
|
|
306
|
+
expect(res.status).toBe(500);
|
|
307
|
+
expect(res.headers.get('cache-control')).toBe('no-store');
|
|
308
|
+
expect(await res.json()).toEqual({ error: 'trace listing failed' });
|
|
309
|
+
} finally {
|
|
310
|
+
await webUiModule.stop();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
128
314
|
// Path containment: a request for /../<sibling-of-staticRoot>/secret.txt
|
|
129
315
|
// would, pre-fix, slip past the `startsWith(root)` check because the
|
|
130
316
|
// sibling directory's path begins with `<staticRoot>-evil`. The new check
|
|
@@ -287,7 +287,7 @@ describe('WebUiModule observer flow (e2e)', () => {
|
|
|
287
287
|
|
|
288
288
|
test('grant appears (hot-reload): static public, observer WS flow end-to-end', async () => {
|
|
289
289
|
saveObserversFile(observersPath, {
|
|
290
|
-
observers: [{ key: kp.id, label: 'e2e-device', scopes: ['health', 'ops'] }],
|
|
290
|
+
observers: [{ key: kp.id, label: 'e2e-device', scopes: ['health', 'ops', 'debug'] }],
|
|
291
291
|
});
|
|
292
292
|
await new Promise((r) => setTimeout(r, 3600)); // registry poll is 3s
|
|
293
293
|
|
|
@@ -319,7 +319,7 @@ describe('WebUiModule observer flow (e2e)', () => {
|
|
|
319
319
|
ws.send(JSON.stringify({ type: 'observer-hello', identity: helloFor(kp, host) }));
|
|
320
320
|
const ack = await got('observer-ack');
|
|
321
321
|
expect(ack.label).toBe('e2e-device');
|
|
322
|
-
expect((ack.scopes as string[]).sort()).toEqual(['health', 'ops']);
|
|
322
|
+
expect((ack.scopes as string[]).sort()).toEqual(['debug', 'health', 'ops']);
|
|
323
323
|
|
|
324
324
|
// Read-only: mutating messages are refused.
|
|
325
325
|
ws.send(JSON.stringify({ type: 'user-message', content: 'hi' }));
|
|
@@ -340,11 +340,14 @@ describe('WebUiModule observer flow (e2e)', () => {
|
|
|
340
340
|
});
|
|
341
341
|
expect(String(branchErr.message)).toContain('forbidden');
|
|
342
342
|
|
|
343
|
-
// Session cookie:
|
|
343
|
+
// Session cookie: ordinary debug is allowed by scope, but retrieval traces
|
|
344
|
+
// remain operator-only because they can expose lessons and opt-in inputs.
|
|
344
345
|
const cookie = `fkm_obs=${ack.sessionToken}`;
|
|
345
|
-
//
|
|
346
|
+
// 503 means authorization passed but this harness has no bound app.
|
|
346
347
|
expect((await fetch(`${base()}/healthz`, { headers: { cookie } })).status).toBe(503);
|
|
347
|
-
expect((await fetch(`${base()}/debug/context`, { headers: { cookie } })).status).toBe(
|
|
348
|
+
expect((await fetch(`${base()}/debug/context`, { headers: { cookie } })).status).toBe(503);
|
|
349
|
+
expect((await fetch(`${base()}/debug/retrieval`, { headers: { cookie } })).status).toBe(401);
|
|
350
|
+
expect((await fetch(`${base()}/debug/retrieval/view`, { headers: { cookie } })).status).toBe(401);
|
|
348
351
|
// Basic auth still works for everything.
|
|
349
352
|
expect((await fetch(`${base()}/healthz`, { headers: { authorization: BASIC } })).status).toBe(503);
|
|
350
353
|
|
|
Binary file
|