@vellumai/assistant 0.4.1 → 0.4.3

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 (97) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/bun.lock +0 -83
  3. package/docs/trusted-contact-access.md +20 -0
  4. package/package.json +2 -3
  5. package/src/__tests__/access-request-decision.test.ts +0 -1
  6. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  7. package/src/__tests__/call-routes-http.test.ts +0 -25
  8. package/src/__tests__/channel-approval-routes.test.ts +55 -5
  9. package/src/__tests__/channel-guardian.test.ts +6 -5
  10. package/src/__tests__/config-schema.test.ts +2 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +54 -1
  12. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  13. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  14. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
  15. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  16. package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
  17. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
  18. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  19. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  20. package/src/__tests__/non-member-access-request.test.ts +28 -1
  21. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  22. package/src/__tests__/relay-server.test.ts +644 -4
  23. package/src/__tests__/send-endpoint-busy.test.ts +129 -3
  24. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  25. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  26. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  27. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  28. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  29. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  30. package/src/__tests__/twilio-routes.test.ts +4 -3
  31. package/src/__tests__/update-bulletin.test.ts +0 -1
  32. package/src/approvals/guardian-decision-primitive.ts +24 -2
  33. package/src/approvals/guardian-request-resolvers.ts +42 -3
  34. package/src/calls/call-constants.ts +8 -0
  35. package/src/calls/call-controller.ts +2 -1
  36. package/src/calls/call-domain.ts +5 -4
  37. package/src/calls/relay-server.ts +513 -116
  38. package/src/calls/twilio-routes.ts +3 -5
  39. package/src/calls/types.ts +1 -1
  40. package/src/calls/voice-session-bridge.ts +4 -3
  41. package/src/cli/core-commands.ts +7 -4
  42. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  43. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  44. package/src/config/calls-schema.ts +12 -0
  45. package/src/config/feature-flag-registry.json +0 -8
  46. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  47. package/src/daemon/handlers/config-channels.ts +5 -7
  48. package/src/daemon/handlers/config-inbox.ts +2 -0
  49. package/src/daemon/handlers/index.ts +2 -1
  50. package/src/daemon/handlers/publish.ts +11 -46
  51. package/src/daemon/handlers/sessions.ts +136 -13
  52. package/src/daemon/ipc-contract/apps.ts +1 -0
  53. package/src/daemon/ipc-contract/inbox.ts +4 -0
  54. package/src/daemon/ipc-contract/integrations.ts +3 -1
  55. package/src/daemon/server.ts +19 -3
  56. package/src/daemon/session-agent-loop.ts +35 -23
  57. package/src/daemon/session-runtime-assembly.ts +3 -1
  58. package/src/daemon/session-surfaces.ts +29 -1
  59. package/src/memory/app-store.ts +6 -0
  60. package/src/memory/conversation-crud.ts +2 -1
  61. package/src/memory/conversation-title-service.ts +16 -2
  62. package/src/memory/db-init.ts +4 -0
  63. package/src/memory/delivery-crud.ts +2 -1
  64. package/src/memory/embedding-local.ts +25 -13
  65. package/src/memory/embedding-runtime-manager.ts +24 -6
  66. package/src/memory/guardian-action-store.ts +2 -1
  67. package/src/memory/guardian-approvals.ts +3 -2
  68. package/src/memory/ingress-invite-store.ts +12 -2
  69. package/src/memory/ingress-member-store.ts +4 -3
  70. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  71. package/src/memory/migrations/index.ts +1 -0
  72. package/src/memory/schema.ts +10 -5
  73. package/src/notifications/copy-composer.ts +11 -1
  74. package/src/notifications/emit-signal.ts +2 -1
  75. package/src/runtime/access-request-helper.ts +11 -3
  76. package/src/runtime/actor-trust-resolver.ts +2 -2
  77. package/src/runtime/assistant-scope.ts +10 -0
  78. package/src/runtime/guardian-context-resolver.ts +5 -1
  79. package/src/runtime/guardian-outbound-actions.ts +5 -4
  80. package/src/runtime/guardian-reply-router.ts +12 -0
  81. package/src/runtime/http-server.ts +12 -20
  82. package/src/runtime/ingress-service.ts +14 -0
  83. package/src/runtime/invite-redemption-service.ts +2 -1
  84. package/src/runtime/middleware/twilio-validation.ts +2 -4
  85. package/src/runtime/routes/call-routes.ts +2 -1
  86. package/src/runtime/routes/channel-route-shared.ts +3 -3
  87. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  88. package/src/runtime/routes/conversation-routes.ts +33 -11
  89. package/src/runtime/routes/events-routes.ts +2 -3
  90. package/src/runtime/routes/inbound-conversation.ts +4 -3
  91. package/src/runtime/routes/inbound-message-handler.ts +16 -4
  92. package/src/runtime/routes/ingress-routes.ts +2 -0
  93. package/src/tools/apps/executors.ts +15 -0
  94. package/src/tools/calls/call-start.ts +2 -1
  95. package/src/tools/terminal/parser.ts +12 -0
  96. package/src/tools/tool-approval-handler.ts +2 -1
  97. package/src/workspace/git-service.ts +19 -0
@@ -55,6 +55,7 @@ import { getDb, initializeDb, resetDb } from '../memory/db.js';
55
55
  import type { AssistantEvent } from '../runtime/assistant-event.js';
56
56
  import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
57
57
  import { RuntimeHttpServer } from '../runtime/http-server.js';
58
+ import type { ApprovalConversationGenerator } from '../runtime/http-types.js';
58
59
  import * as pendingInteractions from '../runtime/pending-interactions.js';
59
60
 
60
61
  initializeDb();
@@ -135,13 +136,18 @@ function makeHangingSession(): Session {
135
136
  } as unknown as Session;
136
137
  }
137
138
 
138
- function makePendingApprovalSession(requestId: string, processing: boolean): {
139
+ function makePendingApprovalSession(
140
+ requestId: string,
141
+ processing: boolean,
142
+ options?: { queueDepth?: number },
143
+ ): {
139
144
  session: Session;
140
145
  runAgentLoopMock: ReturnType<typeof mock>;
141
146
  enqueueMessageMock: ReturnType<typeof mock>;
142
147
  denyAllPendingConfirmationsMock: ReturnType<typeof mock>;
143
148
  handleConfirmationResponseMock: ReturnType<typeof mock>;
144
149
  } {
150
+ const queueDepth = options?.queueDepth ?? 0;
145
151
  const pending = new Set([requestId]);
146
152
  const messages: unknown[] = [];
147
153
  const runAgentLoopMock = mock(async () => {});
@@ -170,7 +176,7 @@ function makePendingApprovalSession(requestId: string, processing: boolean): {
170
176
  hasAnyPendingConfirmation: () => pending.size > 0,
171
177
  hasPendingConfirmation: (candidateRequestId: string) => pending.has(candidateRequestId),
172
178
  denyAllPendingConfirmations: denyAllPendingConfirmationsMock,
173
- getQueueDepth: () => 0,
179
+ getQueueDepth: () => queueDepth,
174
180
  enqueueMessage: enqueueMessageMock,
175
181
  runAgentLoop: runAgentLoopMock,
176
182
  handleConfirmationResponse: handleConfirmationResponseMock,
@@ -215,11 +221,15 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
215
221
  try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
216
222
  });
217
223
 
218
- async function startServer(sessionFactory: () => Session): Promise<void> {
224
+ async function startServer(
225
+ sessionFactory: () => Session,
226
+ options?: { approvalConversationGenerator?: ApprovalConversationGenerator },
227
+ ): Promise<void> {
219
228
  port = 19000 + Math.floor(Math.random() * 1000);
220
229
  server = new RuntimeHttpServer({
221
230
  port,
222
231
  bearerToken: TEST_TOKEN,
232
+ approvalConversationGenerator: options?.approvalConversationGenerator,
223
233
  sendMessageDeps: {
224
234
  getOrCreateSession: async () => sessionFactory(),
225
235
  assistantEventHub: eventHub,
@@ -349,6 +359,67 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
349
359
  await stopServer();
350
360
  });
351
361
 
362
+ test('consumes natural-language approval text when approval conversation generator is configured', async () => {
363
+ const conversationKey = 'conv-inline-nl';
364
+ const { conversationId } = getOrCreateConversation(conversationKey);
365
+ const requestId = 'req-inline-nl';
366
+ const {
367
+ session,
368
+ runAgentLoopMock,
369
+ enqueueMessageMock,
370
+ denyAllPendingConfirmationsMock,
371
+ handleConfirmationResponseMock,
372
+ } = makePendingApprovalSession(requestId, false);
373
+
374
+ pendingInteractions.register(requestId, {
375
+ session,
376
+ conversationId,
377
+ kind: 'confirmation',
378
+ });
379
+ createCanonicalGuardianRequest({
380
+ id: requestId,
381
+ kind: 'tool_approval',
382
+ sourceType: 'desktop',
383
+ sourceChannel: 'vellum',
384
+ conversationId,
385
+ toolName: 'call_start',
386
+ status: 'pending',
387
+ requestCode: 'C0FFEE',
388
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
389
+ });
390
+
391
+ const approvalConversationGenerator: ApprovalConversationGenerator = async (context) => ({
392
+ disposition: 'approve_once',
393
+ replyText: 'Approved.',
394
+ targetRequestId: context.pendingApprovals[0]?.requestId,
395
+ });
396
+
397
+ await startServer(() => session, { approvalConversationGenerator });
398
+
399
+ const res = await fetch(messagesUrl(), {
400
+ method: 'POST',
401
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
402
+ body: JSON.stringify({
403
+ conversationKey,
404
+ content: "sure let's do that",
405
+ sourceChannel: 'vellum',
406
+ interface: 'macos',
407
+ }),
408
+ });
409
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
410
+
411
+ expect(res.status).toBe(202);
412
+ expect(body.accepted).toBe(true);
413
+ expect(body.messageId).toBeDefined();
414
+ expect(body.queued).toBeUndefined();
415
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
416
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
417
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
418
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
419
+
420
+ await stopServer();
421
+ });
422
+
352
423
  test('consumes explicit approval text while busy instead of auto-denying and queueing', async () => {
353
424
  const conversationKey = 'conv-inline-busy';
354
425
  const { conversationId } = getOrCreateConversation(conversationKey);
@@ -404,6 +475,61 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
404
475
  await stopServer();
405
476
  });
406
477
 
478
+ test('consumes explicit approval text while busy even when queue depth is non-zero', async () => {
479
+ const conversationKey = 'conv-inline-busy-queued';
480
+ const { conversationId } = getOrCreateConversation(conversationKey);
481
+ const requestId = 'req-inline-busy-queued';
482
+ const {
483
+ session,
484
+ runAgentLoopMock,
485
+ enqueueMessageMock,
486
+ denyAllPendingConfirmationsMock,
487
+ handleConfirmationResponseMock,
488
+ } = makePendingApprovalSession(requestId, true, { queueDepth: 2 });
489
+
490
+ pendingInteractions.register(requestId, {
491
+ session,
492
+ conversationId,
493
+ kind: 'confirmation',
494
+ });
495
+ createCanonicalGuardianRequest({
496
+ id: requestId,
497
+ kind: 'tool_approval',
498
+ sourceType: 'desktop',
499
+ sourceChannel: 'vellum',
500
+ conversationId,
501
+ toolName: 'call_start',
502
+ status: 'pending',
503
+ requestCode: 'Q2D456',
504
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
505
+ });
506
+
507
+ await startServer(() => session);
508
+
509
+ const res = await fetch(messagesUrl(), {
510
+ method: 'POST',
511
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
512
+ body: JSON.stringify({
513
+ conversationKey,
514
+ content: 'approve',
515
+ sourceChannel: 'vellum',
516
+ interface: 'macos',
517
+ }),
518
+ });
519
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
520
+
521
+ expect(res.status).toBe(202);
522
+ expect(body.accepted).toBe(true);
523
+ expect(body.messageId).toBeDefined();
524
+ expect(body.queued).toBeUndefined();
525
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
526
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
527
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
528
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
529
+
530
+ await stopServer();
531
+ });
532
+
407
533
  test('consumes explicit rejection text when a single pending confirmation exists (idle)', async () => {
408
534
  const conversationKey = 'conv-inline-reject';
409
535
  const { conversationId } = getOrCreateConversation(conversationKey);
@@ -103,7 +103,6 @@ mock.module('../util/platform.js', () => ({
103
103
  getTCPPort: () => 8765,
104
104
  isIOSPairingEnabled: () => false,
105
105
  isTCPEnabled: () => false,
106
- normalizeAssistantId: (id: string) => id,
107
106
  readHttpToken: () => null,
108
107
  readLockfile: () => null,
109
108
  readPlatformToken: () => null,
@@ -615,7 +615,10 @@ describe('injectInboundActorContext', () => {
615
615
 
616
616
  const result = injectInboundActorContext(baseUserMessage, ctx);
617
617
  const text = (result.content[0] as { type: 'text'; text: string }).text;
618
- expect(text).toContain('non-guardian account');
618
+ expect(text).toContain('trusted contact (non-guardian)');
619
+ expect(text).toContain('attempt to fulfill it normally');
620
+ expect(text).toContain('tool execution layer will automatically deny it and escalate');
621
+ expect(text).toContain('Do not self-approve');
619
622
  expect(text).toContain('Do not explain the verification system');
620
623
  expect(text).toContain('member_status: active');
621
624
  expect(text).toContain('member_policy: default');
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import type {
4
4
  CardSurfaceData,
5
+ DynamicPageSurfaceData,
5
6
  ServerMessage,
6
7
  SurfaceData,
7
8
  SurfaceType,
@@ -96,6 +97,48 @@ describe('task_progress surface compatibility', () => {
96
97
  expect((card.templateData as Record<string, unknown>).status).toBe('in_progress');
97
98
  });
98
99
 
100
+ test('ui_show normalizes top-level dynamic_page fields into data', async () => {
101
+ const sent: ServerMessage[] = [];
102
+ const ctx = makeContext(sent);
103
+
104
+ const result = await surfaceProxyResolver(ctx, 'ui_show', {
105
+ surface_type: 'dynamic_page',
106
+ title: 'My Slides',
107
+ html: '<h1>Hello</h1>',
108
+ preview: { title: 'Slides', subtitle: '3 slides about Apple' },
109
+ });
110
+
111
+ expect(result.isError).toBe(false);
112
+
113
+ const showMessage = sent.find((msg): msg is UiSurfaceShow => msg.type === 'ui_surface_show');
114
+ expect(showMessage).toBeDefined();
115
+ if (!showMessage || showMessage.surfaceType !== 'dynamic_page') return;
116
+
117
+ const page = showMessage.data as DynamicPageSurfaceData;
118
+ expect(page.html).toBe('<h1>Hello</h1>');
119
+ expect(page.preview).toEqual({ title: 'Slides', subtitle: '3 slides about Apple' });
120
+ });
121
+
122
+ test('ui_show dynamic_page uses data.html when properly nested', async () => {
123
+ const sent: ServerMessage[] = [];
124
+ const ctx = makeContext(sent);
125
+
126
+ const result = await surfaceProxyResolver(ctx, 'ui_show', {
127
+ surface_type: 'dynamic_page',
128
+ title: 'My Slides',
129
+ data: { html: '<h1>Nested</h1>' },
130
+ });
131
+
132
+ expect(result.isError).toBe(false);
133
+
134
+ const showMessage = sent.find((msg): msg is UiSurfaceShow => msg.type === 'ui_surface_show');
135
+ expect(showMessage).toBeDefined();
136
+ if (!showMessage || showMessage.surfaceType !== 'dynamic_page') return;
137
+
138
+ const page = showMessage.data as DynamicPageSurfaceData;
139
+ expect(page.html).toBe('<h1>Nested</h1>');
140
+ });
141
+
99
142
  test('ui_update normalizes top-level task_progress fields into templateData', async () => {
100
143
  const sent: ServerMessage[] = [];
101
144
  const ctx = makeContext(sent);
@@ -34,7 +34,6 @@ mock.module('../util/platform.js', () => ({
34
34
  getDbPath: () => join(testDir, 'test.db'),
35
35
  getLogPath: () => join(testDir, 'test.log'),
36
36
  ensureDataDir: () => {},
37
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
38
37
  readHttpToken: () => 'test-bearer-token',
39
38
  }));
40
39
 
@@ -82,6 +81,7 @@ mock.module('../runtime/approval-message-composer.js', () => ({
82
81
  composeApprovalMessageGenerative: async () => 'mock generative message',
83
82
  }));
84
83
 
84
+ import { getResolver } from '../approvals/guardian-request-resolvers.js';
85
85
  import {
86
86
  createApprovalRequest,
87
87
  createBinding,
@@ -489,6 +489,16 @@ describe('trusted contact activated notification signal', () => {
489
489
  expect(activatedSignals.length).toBe(0);
490
490
  });
491
491
 
492
+ test('voice access_request resolver has registered handler for access_request kind', () => {
493
+ // The access_request resolver is registered during module load. When the
494
+ // source channel is 'voice', it should directly activate the member via
495
+ // upsertMember (no verification session). This test validates the resolver
496
+ // is registered and accessible.
497
+ const resolver = getResolver('access_request');
498
+ expect(resolver).toBeDefined();
499
+ expect(resolver!.kind).toBe('access_request');
500
+ });
501
+
492
502
  test('member is persisted BEFORE activated signal is emitted', async () => {
493
503
  // Set up a guardian binding
494
504
  createBinding({
@@ -29,7 +29,6 @@ mock.module('../util/platform.js', () => ({
29
29
  getDbPath: () => join(testDir, 'test.db'),
30
30
  getLogPath: () => join(testDir, 'test.log'),
31
31
  ensureDataDir: () => {},
32
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
33
32
  readHttpToken: () => 'test-bearer-token',
34
33
  }));
35
34
 
@@ -32,7 +32,6 @@ mock.module('../util/platform.js', () => ({
32
32
  getDbPath: () => join(testDir, 'test.db'),
33
33
  getLogPath: () => join(testDir, 'test.log'),
34
34
  ensureDataDir: () => {},
35
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
36
35
  readHttpToken: () => 'test-bearer-token',
37
36
  }));
38
37
 
@@ -704,19 +704,20 @@ describe('twilio webhook routes', () => {
704
704
  expect(res.status).toBe(400);
705
705
  });
706
706
 
707
- test('inbound webhook with forwarded assistantId creates session with correct assistantId', async () => {
707
+ test('inbound webhook creates session with internal scope assistantId', async () => {
708
708
  const req = makeInboundVoiceRequest({
709
709
  CallSid: 'CA_inbound_assist_1',
710
710
  From: '+14155551234',
711
711
  To: '+15550001111',
712
712
  });
713
713
 
714
- const res = await handleVoiceWebhook(req, 'my-assistant-id');
714
+ const res = await handleVoiceWebhook(req);
715
715
 
716
716
  expect(res.status).toBe(200);
717
717
  const session = getCallSessionByCallSid('CA_inbound_assist_1');
718
718
  expect(session).not.toBeNull();
719
- expect(session!.assistantId).toBe('my-assistant-id');
719
+ // Daemon always uses internal scope — external assistant IDs are not leaked into session state.
720
+ expect(session!.assistantId).toBe('self');
720
721
  });
721
722
 
722
723
  test('outbound call flow remains non-regressed with callSessionId present', async () => {
@@ -53,7 +53,6 @@ mock.module('../util/platform.js', () => ({
53
53
  getInterfacesDir: () => '',
54
54
  getClipboardCommand: () => null,
55
55
  readLockfile: () => null,
56
- normalizeAssistantId: (id: string) => id,
57
56
  writeLockfile: () => {},
58
57
  readPlatformToken: () => null,
59
58
  readSessionToken: () => null,
@@ -35,6 +35,7 @@ import {
35
35
  type GuardianApprovalRequest,
36
36
  updateApprovalDecision,
37
37
  } from '../memory/channel-guardian-store.js';
38
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
38
39
  import type {
39
40
  ApprovalAction,
40
41
  ApprovalDecisionResult,
@@ -233,7 +234,7 @@ export function mintCanonicalRequestGrant(params: {
233
234
  }
234
235
 
235
236
  const result = mintGrantFromDecision({
236
- assistantId: 'self',
237
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
237
238
  scopeMode: 'tool_signature',
238
239
  toolName: request.toolName,
239
240
  inputDigest: request.inputDigest,
@@ -349,7 +350,28 @@ export async function applyCanonicalGuardianDecision(
349
350
  }
350
351
 
351
352
  // 2c. Validate identity: actor must match guardian_external_user_id
352
- // unless the actor is trusted (desktop) or the request has no guardian binding.
353
+ // unless the actor is trusted (desktop).
354
+ //
355
+ // Channel tool-approval requests must always be identity-bound. Treat
356
+ // missing guardianExternalUserId as unauthorized (fail-closed) so a
357
+ // non-guardian actor can never approve an unbound request.
358
+ if (
359
+ !actorContext.isTrusted &&
360
+ request.kind === 'tool_approval' &&
361
+ !request.guardianExternalUserId
362
+ ) {
363
+ log.warn(
364
+ {
365
+ event: 'canonical_decision_missing_guardian_binding',
366
+ requestId,
367
+ kind: request.kind,
368
+ sourceType: request.sourceType,
369
+ },
370
+ 'Canonical tool approval missing guardian binding; rejecting decision',
371
+ );
372
+ return { applied: false, reason: 'identity_mismatch', detail: 'missing guardian binding' };
373
+ }
374
+
353
375
  if (
354
376
  request.guardianExternalUserId &&
355
377
  !actorContext.isTrusted &&
@@ -13,8 +13,10 @@
13
13
 
14
14
  import { answerCall } from '../calls/call-domain.js';
15
15
  import type { CanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
16
+ import { upsertMember } from '../memory/ingress-member-store.js';
16
17
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
17
18
  import { addRule } from '../permissions/trust-store.js';
19
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
18
20
  import type { ApprovalAction } from '../runtime/channel-approval-types.js';
19
21
  import { createOutboundSession } from '../runtime/channel-guardian-service.js';
20
22
  import { deliverChannelReply } from '../runtime/gateway-client.js';
@@ -24,6 +26,10 @@ import { getLogger } from '../util/logger.js';
24
26
 
25
27
  const log = getLogger('guardian-request-resolvers');
26
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
27
33
  // ---------------------------------------------------------------------------
28
34
  // Types
29
35
  // ---------------------------------------------------------------------------
@@ -278,7 +284,7 @@ const accessRequestResolver: GuardianRequestResolver = {
278
284
  const requesterExternalUserId = request.requesterExternalUserId ?? '';
279
285
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
280
286
  const decidedByExternalUserId = ctx.actor.externalUserId ?? '';
281
- const assistantId = channelDeliveryContext?.assistantId ?? 'self';
287
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
282
288
 
283
289
  if (decision.action === 'reject') {
284
290
  log.info(
@@ -340,7 +346,40 @@ const accessRequestResolver: GuardianRequestResolver = {
340
346
  return { ok: true, applied: true };
341
347
  }
342
348
 
343
- // On approve: mint an identity-bound verification session so the
349
+ // Voice approvals: directly activate the trusted contact without minting
350
+ // a verification session. The caller is already on the line and the
351
+ // relay server's in-call wait loop will detect the approved status.
352
+ if (channel === 'voice') {
353
+ try {
354
+ upsertMember({
355
+ assistantId,
356
+ sourceChannel: 'voice',
357
+ externalUserId: requesterExternalUserId,
358
+ externalChatId: requesterChatId,
359
+ status: 'active',
360
+ policy: 'allow',
361
+ });
362
+ } catch (err) {
363
+ log.error(
364
+ { err, requesterExternalUserId },
365
+ 'Access request resolver: failed to activate voice caller as trusted contact',
366
+ );
367
+ }
368
+
369
+ log.info(
370
+ {
371
+ event: 'resolver_access_request_voice_approved',
372
+ requestId: request.id,
373
+ channel,
374
+ requesterExternalUserId,
375
+ },
376
+ 'Access request resolver: voice approval — direct trusted-contact activation (no verification session)',
377
+ );
378
+
379
+ return { ok: true, applied: true };
380
+ }
381
+
382
+ // Non-voice approvals: mint an identity-bound verification session so the
344
383
  // requester can verify their identity.
345
384
  const session = createOutboundSession({
346
385
  assistantId,
@@ -461,7 +500,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
461
500
  async resolve(ctx: ResolverContext): Promise<ResolverResult> {
462
501
  const { request, decision, channelDeliveryContext } = ctx;
463
502
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
464
- const assistantId = channelDeliveryContext?.assistantId ?? 'self';
503
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
465
504
 
466
505
  if (decision.action === 'reject') {
467
506
  log.info(
@@ -41,6 +41,14 @@ export function getUserConsultationTimeoutMs(): number {
41
41
  return getConfig().calls.userConsultTimeoutSeconds * 1000;
42
42
  }
43
43
 
44
+ export function getTtsPlaybackDelayMs(): number {
45
+ return getConfig().calls.ttsPlaybackDelayMs;
46
+ }
47
+
48
+ export function getAccessRequestPollIntervalMs(): number {
49
+ return getConfig().calls.accessRequestPollIntervalMs;
50
+ }
51
+
44
52
  export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds
45
53
 
46
54
  // Legacy named exports for backward compatibility (use functions above for config-backed values)
@@ -18,6 +18,7 @@ import {
18
18
  listCanonicalGuardianDeliveries,
19
19
  } from '../memory/canonical-guardian-store.js';
20
20
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
21
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
21
22
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
22
23
  import { getLogger } from '../util/logger.js';
23
24
  import { readHttpToken } from '../util/platform.js';
@@ -245,7 +246,7 @@ export class CallController {
245
246
  this.task = task;
246
247
  this.isInbound = !task;
247
248
  this.broadcast = opts?.broadcast;
248
- this.assistantId = opts?.assistantId ?? 'self';
249
+ this.assistantId = opts?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
249
250
  this.guardianContext = opts?.guardianContext ?? null;
250
251
 
251
252
  // Resolve the conversation ID from the call session
@@ -14,6 +14,7 @@ import { getOrCreateConversation } from '../memory/conversation-key-store.js';
14
14
  import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
15
15
  import { upsertBinding } from '../memory/external-conversation-store.js';
16
16
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
17
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
17
18
  import { isGuardian } from '../runtime/channel-guardian-service.js';
18
19
  import { getSecureKey } from '../security/secure-keys.js';
19
20
  import { getLogger } from '../util/logger.js';
@@ -208,7 +209,7 @@ export type CreateInboundVoiceSessionResult = {
208
209
  export function createInboundVoiceSession(
209
210
  input: CreateInboundVoiceSessionInput,
210
211
  ): CreateInboundVoiceSessionResult {
211
- const { callSid, fromNumber, toNumber, assistantId = 'self' } = input;
212
+ const { callSid, fromNumber, toNumber, assistantId = DAEMON_INTERNAL_ASSISTANT_ID } = input;
212
213
 
213
214
  // Check if a session already exists for this CallSid (replay protection)
214
215
  const existing = getCallSessionByCallSid(callSid);
@@ -219,7 +220,7 @@ export function createInboundVoiceSession(
219
220
 
220
221
  // Create a dedicated voice conversation keyed by CallSid so inbound calls
221
222
  // get their own conversation thread.
222
- const voiceConvKey = assistantId && assistantId !== 'self'
223
+ const voiceConvKey = assistantId && assistantId !== DAEMON_INTERNAL_ASSISTANT_ID
223
224
  ? `asst:${assistantId}:voice:inbound:${callSid}`
224
225
  : `voice:inbound:${callSid}`;
225
226
  const { conversationId: voiceConversationId } = getOrCreateConversation(voiceConvKey);
@@ -272,7 +273,7 @@ export function createInboundVoiceSession(
272
273
  * Initiate a new outbound call.
273
274
  */
274
275
  export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
275
- const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = 'self' } = input;
276
+ const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = DAEMON_INTERNAL_ASSISTANT_ID } = input;
276
277
 
277
278
  if (!phoneNumber || typeof phoneNumber !== 'string') {
278
279
  return { ok: false, error: 'phone_number is required and must be a string', status: 400 };
@@ -644,7 +645,7 @@ export type StartGuardianVerificationCallResult =
644
645
  export async function startGuardianVerificationCall(
645
646
  input: StartGuardianVerificationCallInput,
646
647
  ): Promise<StartGuardianVerificationCallResult> {
647
- const { phoneNumber, guardianVerificationSessionId, assistantId = 'self', originConversationId } = input;
648
+ const { phoneNumber, guardianVerificationSessionId, assistantId = DAEMON_INTERNAL_ASSISTANT_ID, originConversationId } = input;
648
649
 
649
650
  if (!phoneNumber || !E164_REGEX.test(phoneNumber)) {
650
651
  return { ok: false, error: 'phone_number must be in E.164 format', status: 400 };