@vellumai/assistant 0.3.7 → 0.3.9

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  11. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  12. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  13. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  14. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  15. package/src/calls/call-domain.ts +21 -21
  16. package/src/calls/call-state.ts +12 -12
  17. package/src/calls/guardian-dispatch.ts +43 -3
  18. package/src/calls/relay-server.ts +34 -39
  19. package/src/calls/twilio-routes.ts +3 -3
  20. package/src/calls/voice-session-bridge.ts +244 -0
  21. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  22. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  23. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/notifications-schema.ts +15 -0
  26. package/src/config/schema.ts +13 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/daemon/daemon-control.ts +13 -12
  29. package/src/daemon/handlers/subagents.ts +10 -3
  30. package/src/daemon/ipc-contract/notifications.ts +9 -0
  31. package/src/daemon/ipc-contract-inventory.json +2 -0
  32. package/src/daemon/ipc-contract.ts +4 -1
  33. package/src/daemon/lifecycle.ts +100 -1
  34. package/src/daemon/server.ts +8 -0
  35. package/src/daemon/session-agent-loop.ts +4 -0
  36. package/src/daemon/session-process.ts +51 -0
  37. package/src/daemon/session-runtime-assembly.ts +32 -0
  38. package/src/daemon/session.ts +5 -0
  39. package/src/memory/db-init.ts +80 -0
  40. package/src/memory/guardian-action-store.ts +2 -2
  41. package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
  42. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  43. package/src/memory/migrations/index.ts +1 -0
  44. package/src/memory/migrations/registry.ts +5 -0
  45. package/src/memory/schema-migration.ts +1 -0
  46. package/src/memory/schema.ts +59 -1
  47. package/src/notifications/README.md +134 -0
  48. package/src/notifications/adapters/macos.ts +55 -0
  49. package/src/notifications/adapters/telegram.ts +65 -0
  50. package/src/notifications/broadcaster.ts +175 -0
  51. package/src/notifications/copy-composer.ts +118 -0
  52. package/src/notifications/decision-engine.ts +391 -0
  53. package/src/notifications/decisions-store.ts +158 -0
  54. package/src/notifications/deliveries-store.ts +130 -0
  55. package/src/notifications/destination-resolver.ts +54 -0
  56. package/src/notifications/deterministic-checks.ts +187 -0
  57. package/src/notifications/emit-signal.ts +191 -0
  58. package/src/notifications/events-store.ts +145 -0
  59. package/src/notifications/preference-extractor.ts +223 -0
  60. package/src/notifications/preference-summary.ts +110 -0
  61. package/src/notifications/preferences-store.ts +142 -0
  62. package/src/notifications/runtime-dispatch.ts +100 -0
  63. package/src/notifications/signal.ts +24 -0
  64. package/src/notifications/types.ts +75 -0
  65. package/src/runtime/http-server.ts +15 -0
  66. package/src/runtime/http-types.ts +22 -0
  67. package/src/runtime/pending-interactions.ts +73 -0
  68. package/src/runtime/routes/approval-routes.ts +179 -0
  69. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  70. package/src/runtime/routes/conversation-routes.ts +107 -1
  71. package/src/runtime/routes/run-routes.ts +1 -1
  72. package/src/runtime/run-orchestrator.ts +157 -2
  73. package/src/subagent/manager.ts +6 -6
  74. package/src/tools/browser/browser-manager.ts +1 -1
  75. package/src/tools/subagent/message.ts +9 -2
  76. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -0,0 +1,704 @@
1
+ /**
2
+ * HTTP-layer integration tests for the standalone approval endpoints.
3
+ *
4
+ * Tests POST /v1/confirm, POST /v1/secret, and POST /v1/trust-rules
5
+ * through RuntimeHttpServer with pending-interactions tracking.
6
+ */
7
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
8
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
12
+ import type { Session } from '../daemon/session.js';
13
+
14
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'approval-routes-http-test-')));
15
+
16
+ mock.module('../util/platform.js', () => ({
17
+ getRootDir: () => testDir,
18
+ getDataDir: () => testDir,
19
+ isMacOS: () => process.platform === 'darwin',
20
+ isLinux: () => process.platform === 'linux',
21
+ isWindows: () => process.platform === 'win32',
22
+ getSocketPath: () => join(testDir, 'test.sock'),
23
+ getPidPath: () => join(testDir, 'test.pid'),
24
+ getDbPath: () => join(testDir, 'test.db'),
25
+ getLogPath: () => join(testDir, 'test.log'),
26
+ ensureDataDir: () => {},
27
+ }));
28
+
29
+ mock.module('../util/logger.js', () => ({
30
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
31
+ get: () => () => {},
32
+ }),
33
+ }));
34
+
35
+ mock.module('../config/loader.js', () => ({
36
+ getConfig: () => ({
37
+ model: 'test',
38
+ provider: 'test',
39
+ apiKeys: {},
40
+ memory: { enabled: false },
41
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
42
+ secretDetection: { enabled: false },
43
+ sandbox: { enabled: false },
44
+ }),
45
+ }));
46
+
47
+ // Mock the trust store so addRule doesn't touch disk or require full config
48
+ mock.module('../permissions/trust-store.js', () => ({
49
+ addRule: () => ({ id: 'test-rule', tool: 'test', pattern: '*', scope: 'everywhere', decision: 'allow', priority: 100 }),
50
+ getRules: () => [],
51
+ }));
52
+
53
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
54
+ import { RuntimeHttpServer } from '../runtime/http-server.js';
55
+ import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
56
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
57
+
58
+ initializeDb();
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Session helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function makeIdleSession(opts?: {
65
+ onConfirmation?: (requestId: string, decision: string) => void;
66
+ onSecret?: (requestId: string, value?: string, delivery?: string) => void;
67
+ }): Session {
68
+ let processing = false;
69
+ return {
70
+ isProcessing: () => processing,
71
+ persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
72
+ processing = true;
73
+ return requestId ?? 'msg-1';
74
+ },
75
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
76
+ setChannelCapabilities: () => {},
77
+ setAssistantId: () => {},
78
+ setGuardianContext: () => {},
79
+ setCommandIntent: () => {},
80
+ updateClient: () => {},
81
+ enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
82
+ runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
83
+ onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
84
+ onEvent({ type: 'message_complete', sessionId: 'test-session' });
85
+ processing = false;
86
+ },
87
+ handleConfirmationResponse: (requestId: string, decision: string) => {
88
+ opts?.onConfirmation?.(requestId, decision);
89
+ },
90
+ handleSecretResponse: (requestId: string, value?: string, delivery?: string) => {
91
+ opts?.onSecret?.(requestId, value, delivery);
92
+ },
93
+ } as unknown as Session;
94
+ }
95
+
96
+ /**
97
+ * Session whose agent loop emits a confirmation_request, so the hub
98
+ * publisher registers a pending interaction automatically.
99
+ */
100
+ function makeConfirmationEmittingSession(opts?: {
101
+ onConfirmation?: (requestId: string, decision: string) => void;
102
+ confirmRequestId?: string;
103
+ toolName?: string;
104
+ }): Session {
105
+ let processing = false;
106
+ const reqId = opts?.confirmRequestId ?? 'confirm-req-1';
107
+ const tool = opts?.toolName ?? 'shell_command';
108
+ return {
109
+ isProcessing: () => processing,
110
+ persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
111
+ processing = true;
112
+ return requestId ?? 'msg-1';
113
+ },
114
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
115
+ setChannelCapabilities: () => {},
116
+ setAssistantId: () => {},
117
+ setGuardianContext: () => {},
118
+ setCommandIntent: () => {},
119
+ updateClient: () => {},
120
+ enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
121
+ runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
122
+ // Emit confirmation_request — this triggers the hub publisher to register
123
+ // the pending interaction
124
+ onEvent({
125
+ type: 'confirmation_request',
126
+ requestId: reqId,
127
+ toolName: tool,
128
+ input: { command: 'ls' },
129
+ riskLevel: 'medium',
130
+ allowlistOptions: [
131
+ { label: 'Allow ls', description: 'Allow ls command', pattern: 'ls' },
132
+ ],
133
+ scopeOptions: [
134
+ { label: 'This session', scope: 'session' },
135
+ ],
136
+ persistentDecisionsAllowed: true,
137
+ });
138
+ // Hang to simulate waiting for decision
139
+ await new Promise<void>(() => {});
140
+ },
141
+ handleConfirmationResponse: (requestId: string, decision: string) => {
142
+ opts?.onConfirmation?.(requestId, decision);
143
+ },
144
+ handleSecretResponse: () => {},
145
+ } as unknown as Session;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Tests
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const TEST_TOKEN = 'test-bearer-token-approvals';
153
+ const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
154
+
155
+ describe('standalone approval endpoints — HTTP layer', () => {
156
+ let server: RuntimeHttpServer;
157
+ let port: number;
158
+ let eventHub: AssistantEventHub;
159
+
160
+ beforeEach(() => {
161
+ const db = getDb();
162
+ db.run('DELETE FROM messages');
163
+ db.run('DELETE FROM conversations');
164
+ db.run('DELETE FROM conversation_keys');
165
+ pendingInteractions.clear();
166
+ eventHub = new AssistantEventHub();
167
+ });
168
+
169
+ afterAll(() => {
170
+ resetDb();
171
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
172
+ });
173
+
174
+ async function startServer(sessionFactory: () => Session): Promise<void> {
175
+ port = 20000 + Math.floor(Math.random() * 1000);
176
+ server = new RuntimeHttpServer({
177
+ port,
178
+ bearerToken: TEST_TOKEN,
179
+ sendMessageDeps: {
180
+ getOrCreateSession: async () => sessionFactory(),
181
+ assistantEventHub: eventHub,
182
+ resolveAttachments: () => [],
183
+ },
184
+ });
185
+ await server.start();
186
+ }
187
+
188
+ async function stopServer(): Promise<void> {
189
+ await server?.stop();
190
+ }
191
+
192
+ function url(path: string): string {
193
+ return `http://127.0.0.1:${port}/v1/${path}`;
194
+ }
195
+
196
+ // ── POST /v1/confirm ─────────────────────────────────────────────────
197
+
198
+ describe('POST /v1/confirm', () => {
199
+ test('resolves a pending confirmation by requestId', async () => {
200
+ let confirmedRequestId: string | undefined;
201
+ let confirmedDecision: string | undefined;
202
+
203
+ const session = makeIdleSession({
204
+ onConfirmation: (reqId, dec) => {
205
+ confirmedRequestId = reqId;
206
+ confirmedDecision = dec;
207
+ },
208
+ });
209
+
210
+ await startServer(() => session);
211
+
212
+ // Manually register a pending interaction
213
+ pendingInteractions.register('req-abc', {
214
+ session,
215
+ conversationId: 'conv-1',
216
+ kind: 'confirmation',
217
+ confirmationDetails: {
218
+ toolName: 'shell_command',
219
+ input: { command: 'ls' },
220
+ riskLevel: 'medium',
221
+ allowlistOptions: [],
222
+ scopeOptions: [],
223
+ },
224
+ });
225
+
226
+ const res = await fetch(url('confirm'), {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
229
+ body: JSON.stringify({ requestId: 'req-abc', decision: 'allow' }),
230
+ });
231
+ const body = await res.json() as { accepted: boolean };
232
+
233
+ expect(res.status).toBe(200);
234
+ expect(body.accepted).toBe(true);
235
+ expect(confirmedRequestId).toBe('req-abc');
236
+ expect(confirmedDecision).toBe('allow');
237
+
238
+ // Interaction should be removed after resolution
239
+ expect(pendingInteractions.get('req-abc')).toBeUndefined();
240
+
241
+ await stopServer();
242
+ });
243
+
244
+ test('returns 404 for unknown requestId', async () => {
245
+ await startServer(() => makeIdleSession());
246
+
247
+ const res = await fetch(url('confirm'), {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
250
+ body: JSON.stringify({ requestId: 'nonexistent', decision: 'allow' }),
251
+ });
252
+
253
+ expect(res.status).toBe(404);
254
+
255
+ await stopServer();
256
+ });
257
+
258
+ test('returns 404 for already-resolved requestId', async () => {
259
+ const session = makeIdleSession();
260
+ await startServer(() => session);
261
+
262
+ pendingInteractions.register('req-once', {
263
+ session,
264
+ conversationId: 'conv-1',
265
+ kind: 'confirmation',
266
+ });
267
+
268
+ // First resolution succeeds
269
+ const res1 = await fetch(url('confirm'), {
270
+ method: 'POST',
271
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
272
+ body: JSON.stringify({ requestId: 'req-once', decision: 'allow' }),
273
+ });
274
+ expect(res1.status).toBe(200);
275
+
276
+ // Second resolution fails (already consumed)
277
+ const res2 = await fetch(url('confirm'), {
278
+ method: 'POST',
279
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
280
+ body: JSON.stringify({ requestId: 'req-once', decision: 'deny' }),
281
+ });
282
+ expect(res2.status).toBe(404);
283
+
284
+ await stopServer();
285
+ });
286
+
287
+ test('returns 400 for missing requestId', async () => {
288
+ await startServer(() => makeIdleSession());
289
+
290
+ const res = await fetch(url('confirm'), {
291
+ method: 'POST',
292
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
293
+ body: JSON.stringify({ decision: 'allow' }),
294
+ });
295
+
296
+ expect(res.status).toBe(400);
297
+
298
+ await stopServer();
299
+ });
300
+
301
+ test('returns 400 for invalid decision', async () => {
302
+ await startServer(() => makeIdleSession());
303
+
304
+ const res = await fetch(url('confirm'), {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
307
+ body: JSON.stringify({ requestId: 'req-1', decision: 'maybe' }),
308
+ });
309
+
310
+ expect(res.status).toBe(400);
311
+
312
+ await stopServer();
313
+ });
314
+ });
315
+
316
+ // ── POST /v1/secret ──────────────────────────────────────────────────
317
+
318
+ describe('POST /v1/secret', () => {
319
+ test('resolves a pending secret request by requestId', async () => {
320
+ let secretRequestId: string | undefined;
321
+ let secretValue: string | undefined;
322
+ let secretDelivery: string | undefined;
323
+
324
+ const session = makeIdleSession({
325
+ onSecret: (reqId, val, del) => {
326
+ secretRequestId = reqId;
327
+ secretValue = val;
328
+ secretDelivery = del;
329
+ },
330
+ });
331
+
332
+ await startServer(() => session);
333
+
334
+ pendingInteractions.register('secret-req-1', {
335
+ session,
336
+ conversationId: 'conv-1',
337
+ kind: 'secret',
338
+ });
339
+
340
+ const res = await fetch(url('secret'), {
341
+ method: 'POST',
342
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
343
+ body: JSON.stringify({ requestId: 'secret-req-1', value: 'my-secret-key', delivery: 'store' }),
344
+ });
345
+ const body = await res.json() as { accepted: boolean };
346
+
347
+ expect(res.status).toBe(200);
348
+ expect(body.accepted).toBe(true);
349
+ expect(secretRequestId).toBe('secret-req-1');
350
+ expect(secretValue).toBe('my-secret-key');
351
+ expect(secretDelivery).toBe('store');
352
+
353
+ // Interaction should be removed after resolution
354
+ expect(pendingInteractions.get('secret-req-1')).toBeUndefined();
355
+
356
+ await stopServer();
357
+ });
358
+
359
+ test('returns 404 for unknown requestId', async () => {
360
+ await startServer(() => makeIdleSession());
361
+
362
+ const res = await fetch(url('secret'), {
363
+ method: 'POST',
364
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
365
+ body: JSON.stringify({ requestId: 'nonexistent', value: 'test' }),
366
+ });
367
+
368
+ expect(res.status).toBe(404);
369
+
370
+ await stopServer();
371
+ });
372
+
373
+ test('returns 400 for missing requestId', async () => {
374
+ await startServer(() => makeIdleSession());
375
+
376
+ const res = await fetch(url('secret'), {
377
+ method: 'POST',
378
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
379
+ body: JSON.stringify({ value: 'test' }),
380
+ });
381
+
382
+ expect(res.status).toBe(400);
383
+
384
+ await stopServer();
385
+ });
386
+
387
+ test('returns 400 for invalid delivery', async () => {
388
+ await startServer(() => makeIdleSession());
389
+
390
+ const res = await fetch(url('secret'), {
391
+ method: 'POST',
392
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
393
+ body: JSON.stringify({ requestId: 'req-1', value: 'test', delivery: 'invalid' }),
394
+ });
395
+
396
+ expect(res.status).toBe(400);
397
+
398
+ await stopServer();
399
+ });
400
+ });
401
+
402
+ // ── POST /v1/trust-rules ─────────────────────────────────────────────
403
+
404
+ describe('POST /v1/trust-rules', () => {
405
+ test('returns 404 for unknown requestId', async () => {
406
+ await startServer(() => makeIdleSession());
407
+
408
+ const res = await fetch(url('trust-rules'), {
409
+ method: 'POST',
410
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
411
+ body: JSON.stringify({ requestId: 'nonexistent', pattern: 'ls', scope: 'session', decision: 'allow' }),
412
+ });
413
+
414
+ expect(res.status).toBe(404);
415
+
416
+ await stopServer();
417
+ });
418
+
419
+ test('returns 400 for missing requestId', async () => {
420
+ await startServer(() => makeIdleSession());
421
+
422
+ const res = await fetch(url('trust-rules'), {
423
+ method: 'POST',
424
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
425
+ body: JSON.stringify({ pattern: 'ls', scope: 'session', decision: 'allow' }),
426
+ });
427
+
428
+ expect(res.status).toBe(400);
429
+
430
+ await stopServer();
431
+ });
432
+
433
+ test('returns 400 for missing pattern', async () => {
434
+ await startServer(() => makeIdleSession());
435
+
436
+ const res = await fetch(url('trust-rules'), {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
439
+ body: JSON.stringify({ requestId: 'req-1', scope: 'session', decision: 'allow' }),
440
+ });
441
+
442
+ expect(res.status).toBe(400);
443
+
444
+ await stopServer();
445
+ });
446
+
447
+ test('returns 400 for missing scope', async () => {
448
+ await startServer(() => makeIdleSession());
449
+
450
+ const res = await fetch(url('trust-rules'), {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
453
+ body: JSON.stringify({ requestId: 'req-1', pattern: 'ls', decision: 'allow' }),
454
+ });
455
+
456
+ expect(res.status).toBe(400);
457
+
458
+ await stopServer();
459
+ });
460
+
461
+ test('returns 400 for invalid decision', async () => {
462
+ await startServer(() => makeIdleSession());
463
+
464
+ const res = await fetch(url('trust-rules'), {
465
+ method: 'POST',
466
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
467
+ body: JSON.stringify({ requestId: 'req-1', pattern: 'ls', scope: 'session', decision: 'maybe' }),
468
+ });
469
+
470
+ expect(res.status).toBe(400);
471
+
472
+ await stopServer();
473
+ });
474
+
475
+ test('returns 409 when no confirmation details available', async () => {
476
+ const session = makeIdleSession();
477
+ await startServer(() => session);
478
+
479
+ // Register without confirmationDetails
480
+ pendingInteractions.register('req-no-details', {
481
+ session,
482
+ conversationId: 'conv-1',
483
+ kind: 'secret',
484
+ });
485
+
486
+ const res = await fetch(url('trust-rules'), {
487
+ method: 'POST',
488
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
489
+ body: JSON.stringify({ requestId: 'req-no-details', pattern: 'ls', scope: 'session', decision: 'allow' }),
490
+ });
491
+
492
+ expect(res.status).toBe(409);
493
+
494
+ await stopServer();
495
+ });
496
+
497
+ test('returns 403 when persistent decisions are not allowed', async () => {
498
+ const session = makeIdleSession();
499
+ await startServer(() => session);
500
+
501
+ pendingInteractions.register('req-no-persist', {
502
+ session,
503
+ conversationId: 'conv-1',
504
+ kind: 'confirmation',
505
+ confirmationDetails: {
506
+ toolName: 'shell_command',
507
+ input: { command: 'rm -rf' },
508
+ riskLevel: 'high',
509
+ allowlistOptions: [{ label: 'Allow', description: 'test', pattern: 'rm' }],
510
+ scopeOptions: [{ label: 'Session', scope: 'session' }],
511
+ persistentDecisionsAllowed: false,
512
+ },
513
+ });
514
+
515
+ const res = await fetch(url('trust-rules'), {
516
+ method: 'POST',
517
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
518
+ body: JSON.stringify({ requestId: 'req-no-persist', pattern: 'rm', scope: 'session', decision: 'allow' }),
519
+ });
520
+
521
+ expect(res.status).toBe(403);
522
+
523
+ await stopServer();
524
+ });
525
+
526
+ test('returns 403 when pattern does not match allowlist', async () => {
527
+ const session = makeIdleSession();
528
+ await startServer(() => session);
529
+
530
+ pendingInteractions.register('req-bad-pattern', {
531
+ session,
532
+ conversationId: 'conv-1',
533
+ kind: 'confirmation',
534
+ confirmationDetails: {
535
+ toolName: 'shell_command',
536
+ input: { command: 'ls' },
537
+ riskLevel: 'medium',
538
+ allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }],
539
+ scopeOptions: [{ label: 'Session', scope: 'session' }],
540
+ },
541
+ });
542
+
543
+ const res = await fetch(url('trust-rules'), {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
546
+ body: JSON.stringify({ requestId: 'req-bad-pattern', pattern: 'rm', scope: 'session', decision: 'allow' }),
547
+ });
548
+
549
+ expect(res.status).toBe(403);
550
+ const body = await res.json() as { error: string };
551
+ expect(body.error).toContain('pattern');
552
+
553
+ await stopServer();
554
+ });
555
+
556
+ test('returns 403 when scope does not match scope options', async () => {
557
+ const session = makeIdleSession();
558
+ await startServer(() => session);
559
+
560
+ pendingInteractions.register('req-bad-scope', {
561
+ session,
562
+ conversationId: 'conv-1',
563
+ kind: 'confirmation',
564
+ confirmationDetails: {
565
+ toolName: 'shell_command',
566
+ input: { command: 'ls' },
567
+ riskLevel: 'medium',
568
+ allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }],
569
+ scopeOptions: [{ label: 'Session', scope: 'session' }],
570
+ },
571
+ });
572
+
573
+ const res = await fetch(url('trust-rules'), {
574
+ method: 'POST',
575
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
576
+ body: JSON.stringify({ requestId: 'req-bad-scope', pattern: 'ls', scope: 'global', decision: 'allow' }),
577
+ });
578
+
579
+ expect(res.status).toBe(403);
580
+ const body = await res.json() as { error: string };
581
+ expect(body.error).toContain('scope');
582
+
583
+ await stopServer();
584
+ });
585
+
586
+ test('does not remove the pending interaction after adding trust rule', async () => {
587
+ const session = makeIdleSession();
588
+ await startServer(() => session);
589
+
590
+ pendingInteractions.register('req-keep', {
591
+ session,
592
+ conversationId: 'conv-1',
593
+ kind: 'confirmation',
594
+ confirmationDetails: {
595
+ toolName: 'shell_command',
596
+ input: { command: 'ls' },
597
+ riskLevel: 'medium',
598
+ allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }],
599
+ scopeOptions: [{ label: 'Session', scope: 'session' }],
600
+ },
601
+ });
602
+
603
+ const res = await fetch(url('trust-rules'), {
604
+ method: 'POST',
605
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
606
+ body: JSON.stringify({ requestId: 'req-keep', pattern: 'ls', scope: 'session', decision: 'allow' }),
607
+ });
608
+
609
+ expect(res.status).toBe(200);
610
+
611
+ // Interaction should still be present (not consumed)
612
+ expect(pendingInteractions.get('req-keep')).toBeDefined();
613
+
614
+ await stopServer();
615
+ });
616
+ });
617
+
618
+ // ── Hub publisher integration ────────────────────────────────────────
619
+
620
+ describe('hub publisher registers pending interactions', () => {
621
+ test('confirmation_request events register pending interactions', async () => {
622
+ const confirmReceived: Array<{ requestId: string; decision: string }> = [];
623
+
624
+ const session = makeConfirmationEmittingSession({
625
+ confirmRequestId: 'auto-req-1',
626
+ toolName: 'shell_command',
627
+ onConfirmation: (reqId, dec) => {
628
+ confirmReceived.push({ requestId: reqId, decision: dec });
629
+ },
630
+ });
631
+
632
+ await startServer(() => session);
633
+
634
+ // Send a message that triggers a confirmation_request
635
+ const res = await fetch(url('messages'), {
636
+ method: 'POST',
637
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
638
+ body: JSON.stringify({ conversationKey: 'conv-auto', content: 'Run ls', sourceChannel: 'macos' }),
639
+ });
640
+ expect(res.status).toBe(202);
641
+
642
+ // Wait for the agent loop to emit the confirmation_request
643
+ await new Promise((r) => setTimeout(r, 100));
644
+
645
+ // The pending interaction should have been auto-registered
646
+ const interaction = pendingInteractions.get('auto-req-1');
647
+ expect(interaction).toBeDefined();
648
+ expect(interaction!.kind).toBe('confirmation');
649
+ expect(interaction!.confirmationDetails?.toolName).toBe('shell_command');
650
+
651
+ // Now resolve it via the confirm endpoint
652
+ const confirmRes = await fetch(url('confirm'), {
653
+ method: 'POST',
654
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
655
+ body: JSON.stringify({ requestId: 'auto-req-1', decision: 'allow' }),
656
+ });
657
+ expect(confirmRes.status).toBe(200);
658
+
659
+ expect(confirmReceived).toHaveLength(1);
660
+ expect(confirmReceived[0].requestId).toBe('auto-req-1');
661
+ expect(confirmReceived[0].decision).toBe('allow');
662
+
663
+ await stopServer();
664
+ });
665
+ });
666
+
667
+ // ── getByConversation ────────────────────────────────────────────────
668
+
669
+ describe('getByConversation', () => {
670
+ test('returns all pending interactions for a conversation', async () => {
671
+ const session = makeIdleSession();
672
+ await startServer(() => session);
673
+
674
+ pendingInteractions.register('req-a', {
675
+ session,
676
+ conversationId: 'conv-x',
677
+ kind: 'confirmation',
678
+ });
679
+ pendingInteractions.register('req-b', {
680
+ session,
681
+ conversationId: 'conv-x',
682
+ kind: 'secret',
683
+ });
684
+ pendingInteractions.register('req-c', {
685
+ session,
686
+ conversationId: 'conv-y',
687
+ kind: 'confirmation',
688
+ });
689
+
690
+ const results = pendingInteractions.getByConversation('conv-x');
691
+ expect(results).toHaveLength(2);
692
+ expect(results.map((r) => r.requestId).sort()).toEqual(['req-a', 'req-b']);
693
+
694
+ const resultsY = pendingInteractions.getByConversation('conv-y');
695
+ expect(resultsY).toHaveLength(1);
696
+ expect(resultsY[0].requestId).toBe('req-c');
697
+
698
+ const resultsZ = pendingInteractions.getByConversation('conv-z');
699
+ expect(resultsZ).toHaveLength(0);
700
+
701
+ await stopServer();
702
+ });
703
+ });
704
+ });