@animalabs/connectome-host 0.7.2 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
@@ -291,3 +291,155 @@ describe('system pins (pin_channel_idle_limit)', () => {
291
291
  expect(bad2.success).toBe(false);
292
292
  });
293
293
  });
294
+
295
+ describe('close provenance + explicit-open protection (issue #5)', () => {
296
+ test('closes carry source subscription-gc; default budget certifies no lease', async () => {
297
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
298
+ const { ctx, toolCalls } = mockCtx();
299
+ await m.start(ctx);
300
+
301
+ await m.onProcess(ambient('c1', 'abcdefghijkl'), PS); // 12 > 10 → close
302
+ expect(toolCalls.length).toBe(1);
303
+ expect(toolCalls[0].input.source).toBe('subscription-gc');
304
+ expect(toolCalls[0].input.overrideExplicitOpen).toBe(false);
305
+
306
+ await m.stop();
307
+ });
308
+
309
+ test('a configured numeric budget is an explicit lease: overrideExplicitOpen true', async () => {
310
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
311
+ const { ctx, toolCalls } = mockCtx();
312
+ await m.start(ctx);
313
+
314
+ await m.handleToolCall({
315
+ id: 't1',
316
+ name: 'set_channel_idle_limit',
317
+ input: { channelId: 'discord:g1:c1', limit: 8 },
318
+ });
319
+ await m.onProcess(ambient('c1', 'abcdefghij'), PS); // 10 > 8 → close under lease
320
+ expect(toolCalls.length).toBe(1);
321
+ expect(toolCalls[0].input.overrideExplicitOpen).toBe(true);
322
+
323
+ await m.stop();
324
+ });
325
+
326
+ test('a structural explicit-open refusal stands down without a retry loop', async () => {
327
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
328
+ const { ctx, toolCalls, getState } = mockCtx();
329
+ (ctx as unknown as { callTool: unknown }).callTool = async (call: { name: string; input: Record<string, unknown> }) => {
330
+ toolCalls.push(call);
331
+ return {
332
+ success: false,
333
+ isError: false,
334
+ error: 'explicitly opened',
335
+ data: { refusal: 'explicit-open' },
336
+ };
337
+ };
338
+ await m.start(ctx);
339
+
340
+ const r = await m.onProcess(ambient('c1', 'abcdefghijkl'), PS); // 12 > 10
341
+ expect(toolCalls.length).toBe(1);
342
+ // No context spam, no counter restore: the janitor stands down and the
343
+ // next attempt is at least a full budget away.
344
+ expect(r.addMessages).toBeUndefined();
345
+ const counters = (getState() as { counters: Record<string, number> }).counters;
346
+ expect(counters['discord:g1:c1']).toBeUndefined();
347
+
348
+ // Next ambient message counts from zero rather than instantly re-firing.
349
+ await m.onProcess(ambient('c1', 'ab'), PS);
350
+ expect(toolCalls.length).toBe(1);
351
+
352
+ await m.stop();
353
+ });
354
+
355
+ test('a successful close emits a privacy-minimal ops receipt when notifyOps exists', async () => {
356
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
357
+ const { ctx } = mockCtx();
358
+ const receipts: Array<{ kind: string; agent: string; message: string; data?: Record<string, unknown> }> = [];
359
+ // The mock is deliberately `this`-sensitive, like the real
360
+ // ModuleContextImpl.notifyOps (which reads this.registry): a detached
361
+ // `const f = ctx.notifyOps; f(...)` throws here instead of passing —
362
+ // the exact bug class Sol caught in the first revision.
363
+ Object.assign(ctx as object, {
364
+ getAgents: () => [{ name: 'mythos' }],
365
+ _receipts: receipts,
366
+ notifyOps(
367
+ this: { _receipts: typeof receipts },
368
+ kind: string,
369
+ agent: string,
370
+ message: string,
371
+ data?: Record<string, unknown>,
372
+ ) {
373
+ this._receipts.push({ kind, agent, message, data });
374
+ },
375
+ });
376
+ await m.start(ctx);
377
+
378
+ await m.onProcess(ambient('c1', 'abcdefghijkl'), PS);
379
+ expect(receipts.length).toBe(1);
380
+ expect(receipts[0].kind).toBe('subscription-gc-close');
381
+ expect(receipts[0].agent).toBe('mythos');
382
+ expect(receipts[0].data).toMatchObject({
383
+ channelId: 'discord:g1:c1',
384
+ limitChars: 10,
385
+ decisionSource: 'subscription-gc',
386
+ lease: 'default',
387
+ });
388
+ // Privacy-minimal: the receipt names ids and thresholds, never content.
389
+ expect(receipts[0].message).not.toContain('abcdef');
390
+
391
+ await m.stop();
392
+ });
393
+
394
+ test('a configured-budget close reports lease configured-budget, claiming no actor', async () => {
395
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
396
+ const { ctx } = mockCtx();
397
+ const receipts: Array<{ message: string; data?: Record<string, unknown> }> = [];
398
+ Object.assign(ctx as object, {
399
+ getAgents: () => [{ name: 'mythos' }],
400
+ _receipts: receipts,
401
+ notifyOps(
402
+ this: { _receipts: typeof receipts },
403
+ _kind: string,
404
+ _agent: string,
405
+ message: string,
406
+ data?: Record<string, unknown>,
407
+ ) {
408
+ this._receipts.push({ message, data });
409
+ },
410
+ });
411
+ await m.start(ctx);
412
+
413
+ await m.handleToolCall({
414
+ id: 't1',
415
+ name: 'set_channel_idle_limit',
416
+ input: { channelId: 'discord:g1:c1', limit: 8 },
417
+ });
418
+ await m.onProcess(ambient('c1', 'abcdefghij'), PS); // 10 > 8
419
+ expect(receipts.length).toBe(1);
420
+ // The override state records no actor (agent, operator, or imported are
421
+ // all possible) — the receipt must not claim 'agent-set'.
422
+ expect(receipts[0].data?.lease).toBe('configured-budget');
423
+ expect(receipts[0].message).toContain('configured per-channel budget');
424
+ expect(receipts[0].message).not.toContain('agent-set');
425
+
426
+ await m.stop();
427
+ });
428
+
429
+ test('an ordinary close failure still restores the counter for retry', async () => {
430
+ const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
431
+ const { ctx, toolCalls, getState } = mockCtx();
432
+ (ctx as unknown as { callTool: unknown }).callTool = async (call: { name: string; input: Record<string, unknown> }) => {
433
+ toolCalls.push(call);
434
+ return { success: false, error: 'server unreachable', isError: true };
435
+ };
436
+ await m.start(ctx);
437
+
438
+ await m.onProcess(ambient('c1', 'abcdefghijkl'), PS);
439
+ expect(toolCalls.length).toBe(1);
440
+ const counters = (getState() as { counters: Record<string, number> }).counters;
441
+ expect(counters['discord:g1:c1']).toBe(12);
442
+
443
+ await m.stop();
444
+ });
445
+ });
@@ -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/modules/web-ui-module.js';
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
- const mod = new WebUiModule({
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 mod.start({} as ModuleContext);
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 mod.stop();
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: health allowed, debug denied (not in scopes).
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
- // healthz returns 503 (app not bound in this harness) auth passed.
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(401);
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