@vellumai/assistant 0.4.2 → 0.4.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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,256 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ const routeGuardianReplyMock = mock(async () => ({
4
+ consumed: false,
5
+ decisionApplied: false,
6
+ type: 'not_consumed' as const,
7
+ })) as any;
8
+ const listPendingByDestinationMock = mock((_conversationId: string, _sourceChannel?: string) => [] as Array<{ id: string; kind?: string }>);
9
+ const listCanonicalMock = mock((_filters?: Record<string, unknown>) => [] as Array<{ id: string }>);
10
+ const addMessageMock = mock(async (_conversationId: string, role: string, _content?: string, _metadata?: Record<string, unknown>) => ({
11
+ id: role === 'user' ? 'persisted-user-id' : 'persisted-assistant-id',
12
+ }));
13
+
14
+ mock.module('../util/logger.js', () => ({
15
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
16
+ get: () => () => {},
17
+ }),
18
+ }));
19
+
20
+ mock.module('../memory/conversation-key-store.js', () => ({
21
+ getOrCreateConversation: () => ({ conversationId: 'conv-canonical-reply' }),
22
+ getConversationByKey: () => null,
23
+ }));
24
+
25
+ mock.module('../memory/attachments-store.js', () => ({
26
+ getAttachmentsByIds: () => [],
27
+ }));
28
+
29
+ mock.module('../runtime/guardian-reply-router.js', () => ({
30
+ routeGuardianReply: routeGuardianReplyMock,
31
+ }));
32
+
33
+ mock.module('../memory/canonical-guardian-store.js', () => ({
34
+ createCanonicalGuardianRequest: () => ({ id: 'canonical-id', requestCode: 'ABC123' }),
35
+ generateCanonicalRequestCode: () => 'ABC123',
36
+ listPendingCanonicalGuardianRequestsByDestinationConversation: (
37
+ conversationId: string,
38
+ sourceChannel?: string,
39
+ ) => listPendingByDestinationMock(conversationId, sourceChannel),
40
+ listCanonicalGuardianRequests: (filters?: Record<string, unknown>) =>
41
+ listCanonicalMock(filters),
42
+ }));
43
+
44
+ mock.module('../runtime/confirmation-request-guardian-bridge.js', () => ({
45
+ bridgeConfirmationRequestToGuardian: async () => undefined,
46
+ }));
47
+
48
+ mock.module('../memory/conversation-store.js', () => ({
49
+ addMessage: (
50
+ conversationId: string,
51
+ role: string,
52
+ content: string,
53
+ metadata?: Record<string, unknown>,
54
+ ) => addMessageMock(conversationId, role, content, metadata),
55
+ }));
56
+
57
+ mock.module('../runtime/local-actor-identity.js', () => ({
58
+ resolveLocalIpcGuardianContext: () => ({ trustClass: 'guardian', sourceChannel: 'vellum' }),
59
+ }));
60
+
61
+ import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
62
+
63
+ const testServer = {
64
+ requestIP: () => ({ address: '127.0.0.1' }),
65
+ } as unknown as import('../runtime/middleware/actor-token.js').ServerWithRequestIP;
66
+
67
+ describe('handleSendMessage canonical guardian reply interception', () => {
68
+ beforeEach(() => {
69
+ routeGuardianReplyMock.mockClear();
70
+ listPendingByDestinationMock.mockClear();
71
+ listCanonicalMock.mockClear();
72
+ addMessageMock.mockClear();
73
+ });
74
+
75
+ test('consumes access-request code replies on desktop HTTP path without pending confirmations', async () => {
76
+ listPendingByDestinationMock.mockReturnValue([{ id: 'access-req-1' }]);
77
+ listCanonicalMock.mockReturnValue([]);
78
+ routeGuardianReplyMock.mockResolvedValue({
79
+ consumed: true,
80
+ decisionApplied: true,
81
+ type: 'canonical_decision_applied',
82
+ requestId: 'access-req-1',
83
+ replyText: 'Access approved. Verification code: 123456.',
84
+ });
85
+
86
+ const persistUserMessage = mock(async () => 'should-not-be-called');
87
+ const runAgentLoop = mock(async () => undefined);
88
+ const session = {
89
+ setGuardianContext: () => {},
90
+ setStateSignalListener: () => {},
91
+ emitConfirmationStateChanged: () => {},
92
+ emitActivityState: () => {},
93
+ setTurnChannelContext: () => {},
94
+ setTurnInterfaceContext: () => {},
95
+ isProcessing: () => false,
96
+ hasAnyPendingConfirmation: () => false,
97
+ denyAllPendingConfirmations: () => {},
98
+ enqueueMessage: () => ({ queued: true, requestId: 'queued-id' }),
99
+ persistUserMessage,
100
+ runAgentLoop,
101
+ getMessages: () => [] as unknown[],
102
+ assistantId: 'self',
103
+ guardianContext: undefined,
104
+ hasPendingConfirmation: () => false,
105
+ } as unknown as import('../daemon/session.js').Session;
106
+
107
+ const req = new Request('http://localhost/v1/messages', {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({
111
+ conversationKey: 'guardian-thread-key',
112
+ content: '05BECB approve',
113
+ sourceChannel: 'vellum',
114
+ interface: 'macos',
115
+ }),
116
+ });
117
+
118
+ const res = await handleSendMessage(req, {
119
+ sendMessageDeps: {
120
+ getOrCreateSession: async () => session,
121
+ assistantEventHub: { publish: async () => {} } as any,
122
+ resolveAttachments: () => [],
123
+ },
124
+ }, testServer);
125
+
126
+ expect(res.status).toBe(202);
127
+ const body = await res.json() as { accepted: boolean; messageId?: string };
128
+ expect(body.accepted).toBe(true);
129
+ expect(body.messageId).toBe('persisted-user-id');
130
+
131
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
132
+ const routerCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
133
+ expect(routerCall.messageText).toBe('05BECB approve');
134
+ expect(routerCall.pendingRequestIds).toEqual(['access-req-1']);
135
+ expect(addMessageMock).toHaveBeenCalledTimes(2);
136
+ expect(persistUserMessage).toHaveBeenCalledTimes(0);
137
+ expect(runAgentLoop).toHaveBeenCalledTimes(0);
138
+ });
139
+
140
+ test('passes undefined pendingRequestIds when no canonical hints are found', async () => {
141
+ listPendingByDestinationMock.mockReturnValue([]);
142
+ listCanonicalMock.mockReturnValue([]);
143
+ routeGuardianReplyMock.mockResolvedValue({
144
+ consumed: false,
145
+ decisionApplied: false,
146
+ type: 'not_consumed',
147
+ });
148
+
149
+ const persistUserMessage = mock(async () => 'persisted-user-id');
150
+ const runAgentLoop = mock(async () => undefined);
151
+ const session = {
152
+ setGuardianContext: () => {},
153
+ setStateSignalListener: () => {},
154
+ emitConfirmationStateChanged: () => {},
155
+ emitActivityState: () => {},
156
+ setTurnChannelContext: () => {},
157
+ setTurnInterfaceContext: () => {},
158
+ isProcessing: () => false,
159
+ hasAnyPendingConfirmation: () => false,
160
+ denyAllPendingConfirmations: () => {},
161
+ enqueueMessage: () => ({ queued: true, requestId: 'queued-id' }),
162
+ persistUserMessage,
163
+ runAgentLoop,
164
+ getMessages: () => [] as unknown[],
165
+ assistantId: 'self',
166
+ guardianContext: undefined,
167
+ hasPendingConfirmation: () => false,
168
+ } as unknown as import('../daemon/session.js').Session;
169
+
170
+ const req = new Request('http://localhost/v1/messages', {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({
174
+ conversationKey: 'guardian-thread-key',
175
+ content: 'hello there',
176
+ sourceChannel: 'vellum',
177
+ interface: 'macos',
178
+ }),
179
+ });
180
+
181
+ const res = await handleSendMessage(req, {
182
+ sendMessageDeps: {
183
+ getOrCreateSession: async () => session,
184
+ assistantEventHub: { publish: async () => {} } as any,
185
+ resolveAttachments: () => [],
186
+ },
187
+ }, testServer);
188
+
189
+ expect(res.status).toBe(202);
190
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
191
+ const routerCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
192
+ expect(routerCall.pendingRequestIds).toBeUndefined();
193
+ expect(persistUserMessage).toHaveBeenCalledTimes(1);
194
+ expect(runAgentLoop).toHaveBeenCalledTimes(1);
195
+ });
196
+
197
+ test('excludes stale tool_approval hints without a live pending confirmation', async () => {
198
+ listPendingByDestinationMock.mockReturnValue([
199
+ { id: 'tool-approval-live', kind: 'tool_approval' },
200
+ { id: 'tool-approval-stale', kind: 'tool_approval' },
201
+ { id: 'access-req-1', kind: 'access_request' },
202
+ ]);
203
+ listCanonicalMock.mockReturnValue([]);
204
+ routeGuardianReplyMock.mockResolvedValue({
205
+ consumed: false,
206
+ decisionApplied: false,
207
+ type: 'not_consumed',
208
+ });
209
+
210
+ const persistUserMessage = mock(async () => 'persisted-user-id');
211
+ const runAgentLoop = mock(async () => undefined);
212
+ const session = {
213
+ setGuardianContext: () => {},
214
+ setStateSignalListener: () => {},
215
+ emitConfirmationStateChanged: () => {},
216
+ emitActivityState: () => {},
217
+ setTurnChannelContext: () => {},
218
+ setTurnInterfaceContext: () => {},
219
+ isProcessing: () => false,
220
+ hasAnyPendingConfirmation: () => true,
221
+ denyAllPendingConfirmations: () => {},
222
+ enqueueMessage: () => ({ queued: true, requestId: 'queued-id' }),
223
+ persistUserMessage,
224
+ runAgentLoop,
225
+ getMessages: () => [] as unknown[],
226
+ assistantId: 'self',
227
+ guardianContext: undefined,
228
+ hasPendingConfirmation: (requestId: string) => requestId === 'tool-approval-live',
229
+ } as unknown as import('../daemon/session.js').Session;
230
+
231
+ const req = new Request('http://localhost/v1/messages', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({
235
+ conversationKey: 'guardian-thread-key',
236
+ content: 'approve',
237
+ sourceChannel: 'vellum',
238
+ interface: 'macos',
239
+ }),
240
+ });
241
+
242
+ const res = await handleSendMessage(req, {
243
+ sendMessageDeps: {
244
+ getOrCreateSession: async () => session,
245
+ assistantEventHub: { publish: async () => {} } as any,
246
+ resolveAttachments: () => [],
247
+ },
248
+ }, testServer);
249
+
250
+ expect(res.status).toBe(202);
251
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
252
+ const routerCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
253
+ expect(routerCall.pendingRequestIds).toEqual(['tool-approval-live', 'access-req-1']);
254
+ expect((routerCall.pendingRequestIds as string[]).includes('tool-approval-stale')).toBe(false);
255
+ });
256
+ });
@@ -17,8 +17,17 @@ mock.module('../memory/attachments-store.js', () => ({
17
17
  getAttachmentsByIds: () => [],
18
18
  }));
19
19
 
20
+ mock.module('../runtime/local-actor-identity.js', () => ({
21
+ resolveLocalIpcGuardianContext: (sourceChannel: string) => ({ trustClass: 'guardian', sourceChannel }),
22
+ }));
23
+
24
+ import type { ServerWithRequestIP } from '../runtime/middleware/actor-token.js';
20
25
  import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
21
26
 
27
+ const mockLoopbackServer: ServerWithRequestIP = {
28
+ requestIP: () => ({ address: '127.0.0.1', family: 'IPv4', port: 0 }),
29
+ };
30
+
22
31
  describe('handleSendMessage', () => {
23
32
  test('legacy fallback passes guardian context to processor', async () => {
24
33
  let capturedOptions: RuntimeMessageSessionOptions | undefined;
@@ -41,16 +50,16 @@ describe('handleSendMessage', () => {
41
50
  capturedSourceChannel = sourceChannel;
42
51
  return { messageId: 'msg-legacy-fallback' };
43
52
  },
44
- });
53
+ }, mockLoopbackServer);
45
54
 
46
55
  const body = await res.json() as { accepted: boolean; messageId: string };
47
56
  expect(res.status).toBe(202);
48
57
  expect(body.accepted).toBe(true);
49
58
  expect(body.messageId).toBe('msg-legacy-fallback');
50
59
  expect(capturedSourceChannel).toBe('telegram');
51
- expect(capturedOptions?.guardianContext).toEqual({
60
+ expect(capturedOptions?.guardianContext).toEqual(expect.objectContaining({
52
61
  trustClass: 'guardian',
53
62
  sourceChannel: 'telegram',
54
- });
63
+ }));
55
64
  });
56
65
  });
@@ -196,8 +196,8 @@ describe('Invariant 2: no generic plaintext secret read API', () => {
196
196
  'daemon/handlers.ts', // Vercel API token + integration OAuth
197
197
  'daemon/handlers/config-integrations.ts', // Vercel API token + Twitter integration OAuth
198
198
  'daemon/handlers/config-telegram.ts', // Telegram bot token management
199
- 'daemon/handlers/config-twilio.ts', // Twilio credential management
200
199
  'daemon/handlers/config-ingress.ts', // Ingress config (reads Twilio credentials for webhook sync)
200
+ 'runtime/routes/twilio-routes.ts', // Twilio credential management (HTTP control-plane)
201
201
  'security/token-manager.ts', // OAuth token refresh flow
202
202
  'email/providers/index.ts', // email provider API key lookup
203
203
  'tools/network/script-proxy/session-manager.ts', // proxy credential injection at runtime
@@ -299,6 +299,10 @@ mock.module('../memory/conversation-store.js', () => ({
299
299
  getDisplayMetaForConversations: () => new Map(),
300
300
  }));
301
301
 
302
+ mock.module('../runtime/confirmation-request-guardian-bridge.js', () => ({
303
+ bridgeConfirmationRequestToGuardian: () => ({ skipped: true, reason: 'not_trusted_contact' }),
304
+ }));
305
+
302
306
  mock.module('../daemon/session.js', () => ({
303
307
  Session: MockSession,
304
308
  DEFAULT_MEMORY_POLICY: MOCK_DEFAULT_MEMORY_POLICY,
@@ -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
 
@@ -58,12 +58,17 @@ import {
58
58
  import { initializeDb, resetDb } from '../memory/db.js';
59
59
  import { getDb } from '../memory/db.js';
60
60
  import { conversations } from '../memory/schema.js';
61
+ import type { ServerWithRequestIP } from '../runtime/middleware/actor-token.js';
61
62
  import {
62
63
  handleGuardianActionDecision,
63
64
  handleGuardianActionsPending,
64
65
  listGuardianDecisionPrompts,
65
66
  } from '../runtime/routes/guardian-action-routes.js';
66
67
 
68
+ const mockLoopbackServer: ServerWithRequestIP = {
69
+ requestIP: () => ({ address: '127.0.0.1', family: 'IPv4', port: 0 }),
70
+ };
71
+
67
72
  initializeDb();
68
73
 
69
74
  function ensureConversation(id: string): void {
@@ -145,7 +150,7 @@ describe('HTTP handleGuardianActionDecision', () => {
145
150
  method: 'POST',
146
151
  body: JSON.stringify({ action: 'approve_once' }),
147
152
  });
148
- const res = await handleGuardianActionDecision(req);
153
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
149
154
  expect(res.status).toBe(400);
150
155
  const body = await res.json();
151
156
  expect(body.error.message).toContain('requestId');
@@ -156,7 +161,7 @@ describe('HTTP handleGuardianActionDecision', () => {
156
161
  method: 'POST',
157
162
  body: JSON.stringify({ requestId: 'req-1' }),
158
163
  });
159
- const res = await handleGuardianActionDecision(req);
164
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
160
165
  expect(res.status).toBe(400);
161
166
  const body = await res.json();
162
167
  expect(body.error.message).toContain('action');
@@ -167,7 +172,7 @@ describe('HTTP handleGuardianActionDecision', () => {
167
172
  method: 'POST',
168
173
  body: JSON.stringify({ requestId: 'req-1', action: 'nuke_from_orbit' }),
169
174
  });
170
- const res = await handleGuardianActionDecision(req);
175
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
171
176
  expect(res.status).toBe(400);
172
177
  const body = await res.json();
173
178
  expect(body.error.message).toContain('Invalid action');
@@ -180,7 +185,7 @@ describe('HTTP handleGuardianActionDecision', () => {
180
185
  method: 'POST',
181
186
  body: JSON.stringify({ requestId: 'nonexistent', action: 'approve_once' }),
182
187
  });
183
- const res = await handleGuardianActionDecision(req);
188
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
184
189
  expect(res.status).toBe(404);
185
190
  });
186
191
 
@@ -192,7 +197,7 @@ describe('HTTP handleGuardianActionDecision', () => {
192
197
  method: 'POST',
193
198
  body: JSON.stringify({ requestId: 'req-gd-1', action: 'approve_once' }),
194
199
  });
195
- const res = await handleGuardianActionDecision(req);
200
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
196
201
  expect(res.status).toBe(200);
197
202
  const body = await res.json();
198
203
  expect(body.applied).toBe(true);
@@ -207,7 +212,7 @@ describe('HTTP handleGuardianActionDecision', () => {
207
212
  method: 'POST',
208
213
  body: JSON.stringify({ requestId: 'req-scope-1', action: 'approve_once', conversationId: 'conv-wrong' }),
209
214
  });
210
- const res = await handleGuardianActionDecision(req);
215
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
211
216
  expect(res.status).toBe(404);
212
217
  const body = await res.json();
213
218
  expect(body.error.message).toContain('No pending guardian action');
@@ -222,7 +227,7 @@ describe('HTTP handleGuardianActionDecision', () => {
222
227
  method: 'POST',
223
228
  body: JSON.stringify({ requestId: 'req-scope-2', action: 'reject', conversationId: 'conv-match' }),
224
229
  });
225
- const res = await handleGuardianActionDecision(req);
230
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
226
231
  expect(res.status).toBe(200);
227
232
  const body = await res.json();
228
233
  expect(body.applied).toBe(true);
@@ -236,7 +241,7 @@ describe('HTTP handleGuardianActionDecision', () => {
236
241
  method: 'POST',
237
242
  body: JSON.stringify({ requestId: 'req-scope-3', action: 'approve_once' }),
238
243
  });
239
- const res = await handleGuardianActionDecision(req);
244
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
240
245
  expect(res.status).toBe(200);
241
246
  });
242
247
 
@@ -254,7 +259,7 @@ describe('HTTP handleGuardianActionDecision', () => {
254
259
  method: 'POST',
255
260
  body: JSON.stringify({ requestId: 'req-access-1', action: 'approve_once' }),
256
261
  });
257
- const res = await handleGuardianActionDecision(req);
262
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
258
263
  expect(res.status).toBe(200);
259
264
  const body = await res.json();
260
265
  expect(body.applied).toBe(true);
@@ -262,6 +267,27 @@ describe('HTTP handleGuardianActionDecision', () => {
262
267
  expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
263
268
  });
264
269
 
270
+ test('applies decision for voice access_request kind through canonical primitive', async () => {
271
+ createTestCanonicalRequest({
272
+ conversationId: 'conv-voice-access',
273
+ requestId: 'req-voice-access-1',
274
+ kind: 'access_request',
275
+ toolName: 'ingress_access_request',
276
+ guardianExternalUserId: 'guardian-voice-42',
277
+ });
278
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-voice-access-1', grantMinted: false });
279
+
280
+ const req = new Request('http://localhost/v1/guardian-actions/decision', {
281
+ method: 'POST',
282
+ body: JSON.stringify({ requestId: 'req-voice-access-1', action: 'approve_once' }),
283
+ });
284
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
285
+ expect(res.status).toBe(200);
286
+ const body = await res.json();
287
+ expect(body.applied).toBe(true);
288
+ expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
289
+ });
290
+
265
291
  test('returns stale reason from canonical decision primitive', async () => {
266
292
  createTestCanonicalRequest({ conversationId: 'conv-stale', requestId: 'req-stale-1' });
267
293
  mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'already_resolved' });
@@ -270,7 +296,7 @@ describe('HTTP handleGuardianActionDecision', () => {
270
296
  method: 'POST',
271
297
  body: JSON.stringify({ requestId: 'req-stale-1', action: 'approve_once' }),
272
298
  });
273
- const res = await handleGuardianActionDecision(req);
299
+ const res = await handleGuardianActionDecision(req, mockLoopbackServer);
274
300
  const body = await res.json();
275
301
  expect(body.applied).toBe(false);
276
302
  expect(body.reason).toBe('already_resolved');
@@ -286,7 +312,7 @@ describe('HTTP handleGuardianActionDecision', () => {
286
312
  method: 'POST',
287
313
  body: JSON.stringify({ requestId: 'req-actor-1', action: 'approve_once' }),
288
314
  });
289
- await handleGuardianActionDecision(req);
315
+ await handleGuardianActionDecision(req, mockLoopbackServer);
290
316
  const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
291
317
  const actorContext = call.actorContext as Record<string, unknown>;
292
318
  expect(actorContext.externalUserId).toBeUndefined();
@@ -304,7 +330,7 @@ describe('HTTP handleGuardianActionsPending', () => {
304
330
 
305
331
  test('returns 400 when conversationId is missing', () => {
306
332
  const req = new Request('http://localhost/v1/guardian-actions/pending');
307
- const res = handleGuardianActionsPending(req);
333
+ const res = handleGuardianActionsPending(req, mockLoopbackServer);
308
334
  expect(res.status).toBe(400);
309
335
  });
310
336
 
@@ -316,7 +342,7 @@ describe('HTTP handleGuardianActionsPending', () => {
316
342
  });
317
343
 
318
344
  const req = new Request('http://localhost/v1/guardian-actions/pending?conversationId=conv-list');
319
- const res = handleGuardianActionsPending(req);
345
+ const res = handleGuardianActionsPending(req, mockLoopbackServer);
320
346
  expect(res.status).toBe(200);
321
347
 
322
348
  // Verify the prompts directly via the shared helper
@@ -367,6 +367,11 @@ describe('guardian-dispatch', () => {
367
367
  expect(request).toBeDefined();
368
368
  expect(request!.tool_name).toBe('send_email');
369
369
  expect(request!.input_digest).toBe('abc123def456');
370
+
371
+ const signalParams = emitCalls[0] as Record<string, unknown>;
372
+ const payload = signalParams.contextPayload as Record<string, unknown>;
373
+ expect(payload.requestKind).toBe('pending_question');
374
+ expect(payload.toolName).toBe('send_email');
370
375
  });
371
376
 
372
377
  test('omitting toolName and inputDigest stores null for informational ASK_GUARDIAN dispatches', async () => {
@@ -422,6 +427,9 @@ describe('guardian-dispatch', () => {
422
427
  // The request was just created so there is 1 pending request for this session
423
428
  expect(payload.activeGuardianRequestCount).toBe(1);
424
429
  expect(payload.callSessionId).toBe(session.id);
430
+ expect(payload.requestKind).toBe('pending_question');
431
+ expect(payload.toolName).toBeUndefined();
432
+ expect(payload.pendingQuestionId).toBeUndefined();
425
433
  });
426
434
 
427
435
  test('repeated guardian questions in the same call each create per-request delivery rows even when sharing a conversation', async () => {
@@ -35,7 +35,6 @@ mock.module('../util/platform.js', () => ({
35
35
  getDbPath: () => join(testDir, 'test.db'),
36
36
  getLogPath: () => join(testDir, 'test.log'),
37
37
  ensureDataDir: () => {},
38
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
39
38
  readHttpToken: () => 'test-bearer-token',
40
39
  }));
41
40
 
@@ -269,7 +268,7 @@ describe('startOutbound', () => {
269
268
 
270
269
  describe('resendOutbound', () => {
271
270
  test('returns no_active_session when no session exists', () => {
272
- const result = resendOutbound({ channel: 'sms', assistantId: 'no-such-assistant' });
271
+ const result = resendOutbound({ channel: 'sms' });
273
272
  expect(result.success).toBe(false);
274
273
  expect(result.error).toBe('no_active_session');
275
274
  });
@@ -340,7 +339,7 @@ describe('resendOutbound', () => {
340
339
 
341
340
  describe('cancelOutbound', () => {
342
341
  test('returns no_active_session when no session exists', () => {
343
- const result = cancelOutbound({ channel: 'sms', assistantId: 'no-such-assistant-cancel' });
342
+ const result = cancelOutbound({ channel: 'sms' });
344
343
  expect(result.success).toBe(false);
345
344
  expect(result.error).toBe('no_active_session');
346
345
  });
@@ -398,7 +397,7 @@ describe('HTTP route: handleResendOutbound', () => {
398
397
  });
399
398
 
400
399
  test('returns 400 for no_active_session', async () => {
401
- const req = jsonRequest({ channel: 'sms', assistantId: 'resend-no-session' });
400
+ const req = jsonRequest({ channel: 'sms' });
402
401
  const resp = await handleResendOutbound(req);
403
402
  expect(resp.status).toBe(400);
404
403
  const body = await resp.json() as { error?: string };
@@ -440,7 +439,7 @@ describe('HTTP route: handleCancelOutbound', () => {
440
439
  });
441
440
 
442
441
  test('returns 400 for no_active_session', async () => {
443
- const req = jsonRequest({ channel: 'sms', assistantId: 'cancel-no-session' });
442
+ const req = jsonRequest({ channel: 'sms' });
444
443
  const resp = await handleCancelOutbound(req);
445
444
  expect(resp.status).toBe(400);
446
445
  const body = await resp.json() as { error?: string };