@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -93,6 +93,10 @@ import {
93
93
  handleInstructionCall,
94
94
  handleStartCall,
95
95
  } from './routes/call-routes.js';
96
+ import {
97
+ startCanonicalGuardianExpirySweep,
98
+ stopCanonicalGuardianExpirySweep,
99
+ } from './routes/canonical-guardian-expiry-sweep.js';
96
100
  import { canonicalChannelAssistantId } from './routes/channel-route-shared.js';
97
101
  import {
98
102
  handleChannelDeliveryAck,
@@ -341,6 +345,9 @@ export class RuntimeHttpServer {
341
345
  startGuardianActionSweep(getGatewayInternalBaseUrl(), this.bearerToken, this.guardianActionCopyGenerator);
342
346
  log.info('Guardian action expiry sweep started');
343
347
 
348
+ startCanonicalGuardianExpirySweep();
349
+ log.info('Canonical guardian request expiry sweep started');
350
+
344
351
  log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
345
352
  if (!isLoopbackHost(this.hostname)) {
346
353
  log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
@@ -361,6 +368,7 @@ export class RuntimeHttpServer {
361
368
  this.pairingStore.stop();
362
369
  stopGuardianExpirySweep();
363
370
  stopGuardianActionSweep();
371
+ stopCanonicalGuardianExpirySweep();
364
372
  if (this.retrySweepTimer) {
365
373
  clearInterval(this.retrySweepTimer);
366
374
  this.retrySweepTimer = null;
@@ -5,6 +5,7 @@
5
5
  * both the HTTP routes and the IPC handlers call the same logic.
6
6
  */
7
7
 
8
+ import { isChannelId } from '../channels/types.js';
8
9
  import {
9
10
  createInvite,
10
11
  type IngressInvite,
@@ -22,11 +23,18 @@ import {
22
23
  revokeMember,
23
24
  upsertMember,
24
25
  } from '../memory/ingress-member-store.js';
26
+ import { isValidE164 } from '../util/phone.js';
27
+ import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
28
+ import { getTransport } from './channel-invite-transport.js';
25
29
  import {
26
30
  type InviteRedemptionOutcome,
27
31
  redeemInvite as redeemInviteTyped,
32
+ redeemVoiceInviteCode as redeemVoiceInviteCodeTyped,
33
+ type VoiceRedemptionOutcome,
28
34
  } from './invite-redemption-service.js';
29
35
 
36
+ import './channel-invite-transports/telegram.js';
37
+
30
38
  // ---------------------------------------------------------------------------
31
39
  // Response shapes — used by both HTTP routes and IPC handlers
32
40
  // ---------------------------------------------------------------------------
@@ -35,12 +43,20 @@ export interface InviteResponseData {
35
43
  id: string;
36
44
  sourceChannel: string;
37
45
  token?: string;
46
+ share?: {
47
+ url: string;
48
+ displayText: string;
49
+ };
38
50
  tokenHash: string;
39
51
  maxUses: number;
40
52
  useCount: number;
41
53
  expiresAt: number | null;
42
54
  status: string;
43
55
  note?: string;
56
+ // Voice invite fields (present only for voice invites)
57
+ expectedExternalUserId?: string;
58
+ voiceCode?: string;
59
+ voiceCodeDigits?: number;
44
60
  createdAt: number;
45
61
  }
46
62
 
@@ -61,17 +77,39 @@ export interface MemberResponseData {
61
77
  // Mappers
62
78
  // ---------------------------------------------------------------------------
63
79
 
64
- function inviteToResponse(inv: IngressInvite, rawToken?: string): InviteResponseData {
80
+ function buildSharePayload(sourceChannel: string, rawToken?: string): InviteResponseData['share'] | undefined {
81
+ if (!rawToken || !isChannelId(sourceChannel)) return undefined;
82
+ const transport = getTransport(sourceChannel);
83
+ if (!transport?.buildShareableInvite) return undefined;
84
+
85
+ try {
86
+ return transport.buildShareableInvite({
87
+ rawToken,
88
+ sourceChannel,
89
+ });
90
+ } catch {
91
+ // Missing channel-specific config (e.g. Telegram bot username) should
92
+ // not fail invite creation — callers can still use the raw token.
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ function inviteToResponse(inv: IngressInvite, opts?: { rawToken?: string; voiceCode?: string }): InviteResponseData {
98
+ const share = buildSharePayload(inv.sourceChannel, opts?.rawToken);
65
99
  return {
66
100
  id: inv.id,
67
101
  sourceChannel: inv.sourceChannel,
68
- ...(rawToken ? { token: rawToken } : {}),
102
+ ...(opts?.rawToken ? { token: opts.rawToken } : {}),
103
+ ...(share ? { share } : {}),
69
104
  tokenHash: inv.tokenHash,
70
105
  maxUses: inv.maxUses,
71
106
  useCount: inv.useCount,
72
107
  expiresAt: inv.expiresAt,
73
108
  status: inv.status,
74
109
  note: inv.note ?? undefined,
110
+ ...(inv.expectedExternalUserId ? { expectedExternalUserId: inv.expectedExternalUserId } : {}),
111
+ ...(opts?.voiceCode ? { voiceCode: opts.voiceCode } : {}),
112
+ ...(inv.voiceCodeDigits != null ? { voiceCodeDigits: inv.voiceCodeDigits } : {}),
75
113
  createdAt: inv.createdAt,
76
114
  };
77
115
  }
@@ -108,17 +146,46 @@ export function createIngressInvite(params: {
108
146
  note?: string;
109
147
  maxUses?: number;
110
148
  expiresInMs?: number;
149
+ // Voice invite parameters
150
+ expectedExternalUserId?: string;
151
+ voiceCodeDigits?: number;
111
152
  }): IngressResult<InviteResponseData> {
112
153
  if (!params.sourceChannel) {
113
154
  return { ok: false, error: 'sourceChannel is required for create' };
114
155
  }
156
+
157
+ // For voice invites: generate a one-time numeric code, hash it, and pass
158
+ // the hash to the store. The plaintext code is included in the response
159
+ // exactly once and never stored.
160
+ let voiceCode: string | undefined;
161
+ let voiceCodeHash: string | undefined;
162
+ const isVoice = params.sourceChannel === 'voice';
163
+
164
+ if (isVoice) {
165
+ if (!params.expectedExternalUserId) {
166
+ return { ok: false, error: 'expectedExternalUserId is required for voice invites' };
167
+ }
168
+ if (!isValidE164(params.expectedExternalUserId)) {
169
+ return { ok: false, error: 'expectedExternalUserId must be in E.164 format (e.g., +15551234567)' };
170
+ }
171
+ voiceCode = generateVoiceCode(6);
172
+ voiceCodeHash = hashVoiceCode(voiceCode);
173
+ }
174
+
115
175
  const { invite, rawToken } = createInvite({
116
176
  sourceChannel: params.sourceChannel,
117
177
  note: params.note,
118
178
  maxUses: params.maxUses,
119
179
  expiresInMs: params.expiresInMs,
180
+ ...(isVoice ? {
181
+ expectedExternalUserId: params.expectedExternalUserId,
182
+ voiceCodeHash,
183
+ voiceCodeDigits: 6,
184
+ } : {}),
120
185
  });
121
- return { ok: true, data: inviteToResponse(invite, rawToken) };
186
+ // Voice invites must not expose the token — callers must redeem via the
187
+ // identity-bound voice code flow, not the generic token redemption path.
188
+ return { ok: true, data: inviteToResponse(invite, { rawToken: isVoice ? undefined : rawToken, voiceCode }) };
122
189
  }
123
190
 
124
191
  export function listIngressInvites(params: {
@@ -172,6 +239,7 @@ export function redeemIngressInvite(params: {
172
239
  // ---------------------------------------------------------------------------
173
240
 
174
241
  export { type InviteRedemptionOutcome } from './invite-redemption-service.js';
242
+ export { type VoiceRedemptionOutcome } from './invite-redemption-service.js';
175
243
 
176
244
  export function redeemIngressInviteTyped(params: {
177
245
  rawToken: string;
@@ -185,6 +253,15 @@ export function redeemIngressInviteTyped(params: {
185
253
  return redeemInviteTyped(params);
186
254
  }
187
255
 
256
+ export function redeemVoiceInviteCode(params: {
257
+ assistantId?: string;
258
+ callerExternalUserId: string;
259
+ sourceChannel: 'voice';
260
+ code: string;
261
+ }): VoiceRedemptionOutcome {
262
+ return redeemVoiceInviteCodeTyped(params);
263
+ }
264
+
188
265
  // ---------------------------------------------------------------------------
189
266
  // Member operations
190
267
  // ---------------------------------------------------------------------------
@@ -7,9 +7,12 @@
7
7
  * persisted, or returned in the outcome.
8
8
  */
9
9
 
10
+ import type { ChannelId } from '../channels/types.js';
10
11
  import { getSqlite } from '../memory/db.js';
11
- import { findByTokenHash, hashToken, markInviteExpired, recordInviteUse,redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
12
+ import { findActiveVoiceInvites,findByTokenHash, hashToken, markInviteExpired, recordInviteUse, redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
12
13
  import { findMember, upsertMember } from '../memory/ingress-member-store.js';
14
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
15
+ import { hashVoiceCode } from '../util/voice-code.js';
13
16
 
14
17
  // ---------------------------------------------------------------------------
15
18
  // Outcome type
@@ -20,6 +23,13 @@ export type InviteRedemptionOutcome =
20
23
  | { ok: true; type: 'already_member'; memberId: string }
21
24
  | { ok: false; reason: 'invalid_token' | 'expired' | 'revoked' | 'max_uses_reached' | 'channel_mismatch' | 'missing_identity' };
22
25
 
26
+ // Generic failure reasons for voice redemption — intentionally vague to avoid
27
+ // leaking information about which invites exist or which identity is bound.
28
+ export type VoiceRedemptionOutcome =
29
+ | { ok: true; type: 'redeemed'; memberId: string; inviteId: string }
30
+ | { ok: true; type: 'already_member'; memberId: string }
31
+ | { ok: false; reason: 'invalid_or_expired' };
32
+
23
33
  // ---------------------------------------------------------------------------
24
34
  // Error-string to typed-reason mapping
25
35
  // ---------------------------------------------------------------------------
@@ -111,6 +121,12 @@ export function redeemInvite(params: {
111
121
  // Sentinel error used to trigger a transaction rollback when the invite
112
122
  // was concurrently revoked/expired between pre-validation and write time.
113
123
  const STALE_INVITE = Symbol('stale_invite');
124
+ const canonicalMemberId = existingMember.externalUserId ? canonicalizeInboundIdentity(sourceChannel as ChannelId, existingMember.externalUserId) : null;
125
+ const canonicalCallerId = externalUserId ? canonicalizeInboundIdentity(sourceChannel as ChannelId, externalUserId) : null;
126
+ const memberMatchesSender = !!(canonicalMemberId && canonicalCallerId && canonicalMemberId === canonicalCallerId);
127
+ const preservedDisplayName = memberMatchesSender && existingMember.displayName?.trim().length
128
+ ? existingMember.displayName
129
+ : displayName;
114
130
 
115
131
  let reactivated: ReturnType<typeof upsertMember> | undefined;
116
132
  try {
@@ -120,7 +136,8 @@ export function redeemInvite(params: {
120
136
  sourceChannel,
121
137
  externalUserId,
122
138
  externalChatId,
123
- displayName,
139
+ // Reactivation should not overwrite a guardian-managed nickname.
140
+ displayName: preservedDisplayName,
124
141
  username,
125
142
  status: 'active',
126
143
  policy: 'allow',
@@ -179,3 +196,125 @@ export function redeemInvite(params: {
179
196
  inviteId: result.invite.id,
180
197
  };
181
198
  }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // redeemVoiceInviteCode
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Redeem a voice invite code for a caller identified by their E.164 phone number.
206
+ *
207
+ * Unlike token-based redemption, voice redemption:
208
+ * 1. Filters only active voice invites bound to the caller's identity
209
+ * (expectedExternalUserId must match callerExternalUserId).
210
+ * 2. Validates the short numeric code by hashing it and comparing to the
211
+ * stored voiceCodeHash.
212
+ * 3. Enforces expiry and use limits.
213
+ * 4. On success: upserts/reactivates a member with status 'active', policy 'allow'.
214
+ * 5. Consumes one invite use atomically (increment useCount).
215
+ *
216
+ * Failure responses are intentionally generic ("invalid_or_expired") to prevent
217
+ * oracle attacks that could reveal which invites exist or which phone numbers
218
+ * are bound.
219
+ */
220
+ export function redeemVoiceInviteCode(params: {
221
+ assistantId?: string;
222
+ callerExternalUserId: string;
223
+ sourceChannel: 'voice';
224
+ code: string;
225
+ }): VoiceRedemptionOutcome {
226
+ const { assistantId = 'self', callerExternalUserId, code } = params;
227
+
228
+ if (!callerExternalUserId) {
229
+ return { ok: false, reason: 'invalid_or_expired' };
230
+ }
231
+
232
+ // Find all active voice invites bound to the caller's phone number
233
+ const candidates = findActiveVoiceInvites({
234
+ assistantId,
235
+ expectedExternalUserId: callerExternalUserId,
236
+ });
237
+
238
+ if (candidates.length === 0) {
239
+ return { ok: false, reason: 'invalid_or_expired' };
240
+ }
241
+
242
+ const codeHash = hashVoiceCode(code);
243
+ const now = Date.now();
244
+
245
+ // Search for a matching invite: code hash match, not expired, uses remaining
246
+ const invite = candidates.find((inv) => {
247
+ if (inv.voiceCodeHash !== codeHash) return false;
248
+ if (inv.expiresAt <= now) return false;
249
+ if (inv.useCount >= inv.maxUses) return false;
250
+ return true;
251
+ });
252
+
253
+ if (!invite) {
254
+ // Mark any expired candidates while we're here
255
+ for (const inv of candidates) {
256
+ if (inv.expiresAt <= now && inv.status === 'active') {
257
+ markInviteExpired(inv.id);
258
+ }
259
+ }
260
+ return { ok: false, reason: 'invalid_or_expired' };
261
+ }
262
+
263
+ // Channel enforcement: voice invites can only be redeemed on the voice channel
264
+ if (invite.sourceChannel !== 'voice') {
265
+ return { ok: false, reason: 'invalid_or_expired' };
266
+ }
267
+
268
+ // Check for existing membership
269
+ const existingMember = findMember({
270
+ assistantId: invite.assistantId,
271
+ sourceChannel: 'voice',
272
+ externalUserId: callerExternalUserId,
273
+ });
274
+
275
+ if (existingMember && existingMember.status === 'active') {
276
+ return { ok: true, type: 'already_member', memberId: existingMember.id };
277
+ }
278
+
279
+ // Blocked members cannot bypass the guardian's explicit block
280
+ if (existingMember && existingMember.status === 'blocked') {
281
+ return { ok: false, reason: 'invalid_or_expired' };
282
+ }
283
+
284
+ // Atomic redemption: upsert member + consume invite use in a transaction
285
+ const STALE_INVITE = Symbol('stale_invite');
286
+ let memberId: string | undefined;
287
+
288
+ try {
289
+ getSqlite().transaction(() => {
290
+ const member = upsertMember({
291
+ assistantId: invite.assistantId,
292
+ sourceChannel: 'voice',
293
+ externalUserId: callerExternalUserId,
294
+ status: 'active',
295
+ policy: 'allow',
296
+ inviteId: invite.id,
297
+ });
298
+ memberId = member.id;
299
+
300
+ const recorded = recordInviteUse({
301
+ inviteId: invite.id,
302
+ externalUserId: callerExternalUserId,
303
+ });
304
+
305
+ if (!recorded) throw STALE_INVITE;
306
+ }).immediate();
307
+ } catch (err) {
308
+ if (err === STALE_INVITE) {
309
+ return { ok: false, reason: 'invalid_or_expired' };
310
+ }
311
+ throw err;
312
+ }
313
+
314
+ return {
315
+ ok: true,
316
+ type: 'redeemed',
317
+ memberId: memberId!,
318
+ inviteId: invite.id,
319
+ };
320
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Canonical guardian request expiry sweep.
3
+ *
4
+ * Periodically scans the `canonical_guardian_requests` table for pending
5
+ * requests whose `expiresAt` timestamp has passed and transitions them to
6
+ * the `expired` status. This ensures that stale requests are cleaned up
7
+ * even when no follow-up traffic arrives from either the guardian or the
8
+ * requester.
9
+ *
10
+ * Complements the existing sweeps:
11
+ * - `calls/guardian-action-sweep.ts` — voice call guardian action expiry
12
+ * - `runtime/routes/guardian-expiry-sweep.ts` — channel guardian approval expiry
13
+ *
14
+ * Unlike those sweeps, this one operates on the unified canonical domain
15
+ * (`canonical_guardian_requests`) and does not need to auto-deny pending
16
+ * interactions or deliver channel notices — the canonical request status
17
+ * transition is the single source of truth, and consumers (resolvers,
18
+ * clients polling prompts) observe the expired status directly.
19
+ */
20
+
21
+ import {
22
+ listCanonicalGuardianRequests,
23
+ resolveCanonicalGuardianRequest,
24
+ } from '../../memory/canonical-guardian-store.js';
25
+ import { getLogger } from '../../util/logger.js';
26
+
27
+ const log = getLogger('canonical-guardian-expiry-sweep');
28
+
29
+ /** Interval at which the expiry sweep runs (60 seconds). */
30
+ const SWEEP_INTERVAL_MS = 60_000;
31
+
32
+ /** Timer handle for the sweep so it can be stopped in tests and shutdown. */
33
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
34
+
35
+ /** Guard against overlapping sweeps. */
36
+ let sweepInProgress = false;
37
+
38
+ /**
39
+ * Sweep all pending canonical guardian requests that have expired.
40
+ *
41
+ * Uses CAS resolution (`resolveCanonicalGuardianRequest`) so that a
42
+ * concurrent decision that wins the race is never overwritten by the
43
+ * sweep. Returns the count of requests transitioned to expired.
44
+ */
45
+ export function sweepExpiredCanonicalGuardianRequests(): number {
46
+ const pending = listCanonicalGuardianRequests({ status: 'pending' });
47
+ const now = Date.now();
48
+ let expiredCount = 0;
49
+
50
+ for (const request of pending) {
51
+ if (!request.expiresAt) continue;
52
+
53
+ const expiresAtMs = new Date(request.expiresAt).getTime();
54
+ if (expiresAtMs >= now) continue;
55
+
56
+ // CAS resolve: only transition from 'pending' to 'expired'.
57
+ // If someone resolved it between our read and this write, the CAS
58
+ // fails harmlessly (returns null) and we skip the request.
59
+ const resolved = resolveCanonicalGuardianRequest(request.id, 'pending', {
60
+ status: 'expired',
61
+ });
62
+
63
+ if (resolved) {
64
+ expiredCount++;
65
+ log.info(
66
+ {
67
+ event: 'canonical_request_expired',
68
+ requestId: request.id,
69
+ kind: request.kind,
70
+ expiresAt: request.expiresAt,
71
+ },
72
+ 'Expired canonical guardian request via sweep',
73
+ );
74
+ }
75
+ }
76
+
77
+ if (expiredCount > 0) {
78
+ log.info(
79
+ { event: 'canonical_expiry_sweep_complete', expiredCount },
80
+ `Canonical guardian expiry sweep: expired ${expiredCount} request(s)`,
81
+ );
82
+ }
83
+
84
+ return expiredCount;
85
+ }
86
+
87
+ /**
88
+ * Start the periodic canonical guardian expiry sweep. Idempotent — calling
89
+ * it multiple times reuses the same timer.
90
+ */
91
+ export function startCanonicalGuardianExpirySweep(): void {
92
+ if (sweepTimer) return;
93
+ sweepTimer = setInterval(() => {
94
+ if (sweepInProgress) return;
95
+ sweepInProgress = true;
96
+ try {
97
+ sweepExpiredCanonicalGuardianRequests();
98
+ } catch (err) {
99
+ log.error({ err }, 'Canonical guardian expiry sweep failed');
100
+ } finally {
101
+ sweepInProgress = false;
102
+ }
103
+ }, SWEEP_INTERVAL_MS);
104
+ }
105
+
106
+ /**
107
+ * Stop the periodic canonical guardian expiry sweep. Used in tests and
108
+ * shutdown.
109
+ */
110
+ export function stopCanonicalGuardianExpirySweep(): void {
111
+ if (sweepTimer) {
112
+ clearInterval(sweepTimer);
113
+ sweepTimer = null;
114
+ }
115
+ sweepInProgress = false;
116
+ }
@@ -11,7 +11,7 @@ import type {
11
11
  ApprovalUIMetadata,
12
12
  } from '../channel-approval-types.js';
13
13
  import type { DenialReason } from '../guardian-context-resolver.js';
14
- export type { ActorRole, DenialReason, GuardianContext } from '../guardian-context-resolver.js';
14
+ export type { ActorTrustClass, DenialReason, GuardianContext } from '../guardian-context-resolver.js';
15
15
  export { toGuardianRuntimeContext } from '../guardian-context-resolver.js';
16
16
 
17
17
  /** Canonicalize assistantId for channel ingress paths. */
@@ -23,7 +23,7 @@ export {
23
23
  } from './channel-inbound-routes.js';
24
24
  export {
25
25
  _setTestPollMaxWait,
26
- type ActorRole,
26
+ type ActorTrustClass,
27
27
  type DenialReason,
28
28
  GATEWAY_ORIGIN_HEADER,
29
29
  type GuardianContext,
@@ -8,6 +8,10 @@ import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '..
8
8
  import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
9
9
  import type { ServerMessage } from '../../daemon/ipc-protocol.js';
10
10
  import * as attachmentsStore from '../../memory/attachments-store.js';
11
+ import {
12
+ createCanonicalGuardianRequest,
13
+ generateCanonicalRequestCode,
14
+ } from '../../memory/canonical-guardian-store.js';
11
15
  import {
12
16
  getConversationByKey,
13
17
  getOrCreateConversation,
@@ -171,6 +175,20 @@ function makeHubPublisher(
171
175
  persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
172
176
  },
173
177
  });
178
+
179
+ // Create a canonical guardian request so IPC/HTTP handlers can find it
180
+ // via applyCanonicalGuardianDecision.
181
+ createCanonicalGuardianRequest({
182
+ id: msg.requestId,
183
+ kind: 'tool_approval',
184
+ sourceType: 'desktop',
185
+ sourceChannel: 'vellum',
186
+ conversationId,
187
+ toolName: msg.toolName,
188
+ status: 'pending',
189
+ requestCode: generateCanonicalRequestCode(),
190
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
191
+ });
174
192
  } else if (msg.type === 'secret_request') {
175
193
  pendingInteractions.register(msg.requestId, {
176
194
  session,
@@ -265,7 +283,7 @@ export async function handleSendMessage(
265
283
  const session = await smDeps.getOrCreateSession(mapping.conversationId);
266
284
  // HTTP API is a trusted local ingress (same as IPC) — set guardian context
267
285
  // so that memory extraction is not silently disabled by unverified provenance.
268
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: sourceChannel ?? 'http' });
286
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'http' });
269
287
  const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
270
288
 
271
289
  const attachments = hasAttachments
@@ -335,7 +353,7 @@ export async function handleSendMessage(
335
353
  mapping.conversationId,
336
354
  content ?? '',
337
355
  hasAttachments ? attachmentIds : undefined,
338
- { guardianContext: { actorRole: 'guardian', sourceChannel } },
356
+ { guardianContext: { trustClass: 'guardian', sourceChannel } },
339
357
  sourceChannel,
340
358
  sourceInterface,
341
359
  );