@vellumai/assistant 0.3.28 → 0.4.1

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 (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -85,6 +85,12 @@ export interface GuardianReplyResult {
85
85
  requestId?: string;
86
86
  /** Detailed result from the canonical decision primitive (when a decision was attempted). */
87
87
  canonicalResult?: CanonicalDecisionResult;
88
+ /**
89
+ * When true, the caller should skip legacy approval interception for this
90
+ * message. Set by the invite handoff bypass so that "open invite flow"
91
+ * reaches the assistant even when other legacy guardian approvals are pending.
92
+ */
93
+ skipApprovalInterception?: boolean;
88
94
  }
89
95
 
90
96
  // ---------------------------------------------------------------------------
@@ -319,7 +325,30 @@ export async function routeGuardianReply(
319
325
  }
320
326
  }
321
327
 
322
- // ── 2.5. Deterministic plain-text decisions for known pending targets ──
328
+ // ── 2.5. Invite handoff bypass for access requests ──
329
+ // When the guardian sends "open invite flow" and there is at least one
330
+ // pending access_request, return not_consumed so the message falls through
331
+ // to the normal assistant turn and can invoke the Trusted Contacts skill.
332
+ if (messageText.length > 0 && pendingRequests.length > 0) {
333
+ const normalized = messageText.trim().toLowerCase().replace(/[.!?]+$/g, '');
334
+ if (normalized === 'open invite flow') {
335
+ const hasAccessRequest = pendingRequests.some(r => r.kind === 'access_request');
336
+ if (hasAccessRequest) {
337
+ log.info(
338
+ { event: 'router_invite_handoff', pendingCount: pendingRequests.length },
339
+ 'Guardian sent "open invite flow" with pending access_request — passing through to assistant',
340
+ );
341
+ return {
342
+ consumed: false,
343
+ decisionApplied: false,
344
+ type: 'not_consumed' as const,
345
+ skipApprovalInterception: true,
346
+ };
347
+ }
348
+ }
349
+ }
350
+
351
+ // ── 2.6. Deterministic plain-text decisions for known pending targets ──
323
352
  // Desktop sessions intentionally do not enable NL classification; when the
324
353
  // caller has exactly one known pending request and sends an explicit
325
354
  // approve/reject phrase ("approve", "yes", "reject", "no"), apply the
@@ -551,6 +580,7 @@ const EXPLICIT_APPROVE_PHRASES: ReadonlySet<string> = new Set([
551
580
  'yes',
552
581
  'y',
553
582
  'allow',
583
+ 'go for it',
554
584
  'go ahead',
555
585
  'proceed',
556
586
  'do it',
@@ -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
+ }
@@ -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,
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readdirSync, statSync } from 'node:fs';
5
5
  import { join, relative } from 'node:path';
6
6
 
7
+ import { createAssistantMessage, createUserMessage } from '../../agent/message-types.js';
7
8
  import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '../../channels/types.js';
8
9
  import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
9
10
  import type { ServerMessage } from '../../daemon/ipc-protocol.js';
@@ -11,6 +12,8 @@ import * as attachmentsStore from '../../memory/attachments-store.js';
11
12
  import {
12
13
  createCanonicalGuardianRequest,
13
14
  generateCanonicalRequestCode,
15
+ listCanonicalGuardianRequests,
16
+ listPendingCanonicalGuardianRequestsByDestinationConversation,
14
17
  } from '../../memory/canonical-guardian-store.js';
15
18
  import {
16
19
  getConversationByKey,
@@ -21,6 +24,7 @@ import { getConfiguredProvider } from '../../providers/provider-send-message.js'
21
24
  import type { Provider } from '../../providers/types.js';
22
25
  import { getLogger } from '../../util/logger.js';
23
26
  import { buildAssistantEvent } from '../assistant-event.js';
27
+ import { routeGuardianReply } from '../guardian-reply-router.js';
24
28
  import { httpError } from '../http-errors.js';
25
29
  import type {
26
30
  MessageProcessor,
@@ -35,6 +39,143 @@ const log = getLogger('conversation-routes');
35
39
 
36
40
  const SUGGESTION_CACHE_MAX = 100;
37
41
 
42
+ function collectLivePendingConfirmationRequestIds(
43
+ conversationId: string,
44
+ sourceChannel: string,
45
+ session: import('../../daemon/session.js').Session,
46
+ ): string[] {
47
+ const pendingInteractionRequestIds = pendingInteractions
48
+ .getByConversation(conversationId)
49
+ .filter(
50
+ (interaction) =>
51
+ interaction.kind === 'confirmation'
52
+ && interaction.session === session
53
+ && session.hasPendingConfirmation(interaction.requestId),
54
+ )
55
+ .map((interaction) => interaction.requestId);
56
+
57
+ // Query both by destination conversation (via deliveries table) and by
58
+ // source conversation (direct field). For desktop/HTTP sessions these
59
+ // often overlap, but the Set dedup below handles that.
60
+ const pendingCanonicalRequestIds = [
61
+ ...listPendingCanonicalGuardianRequestsByDestinationConversation(conversationId, sourceChannel)
62
+ .filter((request) => request.kind === 'tool_approval')
63
+ .map((request) => request.id),
64
+ ...listCanonicalGuardianRequests({
65
+ status: 'pending',
66
+ conversationId,
67
+ kind: 'tool_approval',
68
+ }).map((request) => request.id),
69
+ ].filter((requestId) => session.hasPendingConfirmation(requestId));
70
+
71
+ return Array.from(new Set([
72
+ ...pendingInteractionRequestIds,
73
+ ...pendingCanonicalRequestIds,
74
+ ]));
75
+ }
76
+
77
+ async function tryConsumeInlineApprovalReply(params: {
78
+ conversationId: string;
79
+ sourceChannel: string;
80
+ sourceInterface: string;
81
+ content: string;
82
+ attachments: Array<{
83
+ id: string;
84
+ filename: string;
85
+ mimeType: string;
86
+ data: string;
87
+ }>;
88
+ session: import('../../daemon/session.js').Session;
89
+ onEvent: (msg: ServerMessage) => void;
90
+ }): Promise<{ consumed: boolean; messageId?: string }> {
91
+ const {
92
+ conversationId,
93
+ sourceChannel,
94
+ sourceInterface,
95
+ content,
96
+ attachments,
97
+ session,
98
+ onEvent,
99
+ } = params;
100
+ const trimmedContent = content.trim();
101
+
102
+ // Only consume inline replies when there are no queued turns, matching
103
+ // the IPC path guard. With queued messages, "approve"/"no" should be
104
+ // processed in queue order rather than treated as a confirmation reply.
105
+ if (
106
+ !session.hasAnyPendingConfirmation()
107
+ || session.getQueueDepth() > 0
108
+ || trimmedContent.length === 0
109
+ ) {
110
+ return { consumed: false };
111
+ }
112
+
113
+ const pendingRequestIds = collectLivePendingConfirmationRequestIds(conversationId, sourceChannel, session);
114
+ if (pendingRequestIds.length === 0) {
115
+ return { consumed: false };
116
+ }
117
+
118
+ const routerResult = await routeGuardianReply({
119
+ messageText: trimmedContent,
120
+ channel: sourceChannel,
121
+ actor: {
122
+ externalUserId: undefined,
123
+ channel: sourceChannel,
124
+ isTrusted: true,
125
+ },
126
+ conversationId,
127
+ pendingRequestIds,
128
+ });
129
+
130
+ if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
131
+ return { consumed: false };
132
+ }
133
+
134
+ // Decision has been applied — transcript persistence is best-effort.
135
+ // If DB writes fail, we still return consumed: true so the approval text
136
+ // is not re-processed as a new user turn.
137
+ let messageId: string | undefined;
138
+ try {
139
+ const channelMeta = {
140
+ userMessageChannel: sourceChannel,
141
+ assistantMessageChannel: sourceChannel,
142
+ userMessageInterface: sourceInterface,
143
+ assistantMessageInterface: sourceInterface,
144
+ provenanceActorRole: 'guardian' as const,
145
+ };
146
+
147
+ const userMessage = createUserMessage(content, attachments);
148
+ const persistedUser = await conversationStore.addMessage(
149
+ conversationId,
150
+ 'user',
151
+ JSON.stringify(userMessage.content),
152
+ channelMeta,
153
+ );
154
+ messageId = persistedUser.id;
155
+
156
+ const replyText = (routerResult.replyText?.trim())
157
+ || (routerResult.decisionApplied ? 'Decision applied.' : 'Request already resolved.');
158
+ const assistantMessage = createAssistantMessage(replyText);
159
+ await conversationStore.addMessage(
160
+ conversationId,
161
+ 'assistant',
162
+ JSON.stringify(assistantMessage.content),
163
+ channelMeta,
164
+ );
165
+
166
+ // Avoid mutating in-memory history / emitting stream deltas while a run is active.
167
+ if (!session.isProcessing()) {
168
+ session.getMessages().push(userMessage, assistantMessage);
169
+ onEvent({ type: 'assistant_text_delta', text: replyText, sessionId: conversationId });
170
+ onEvent({ type: 'message_complete', sessionId: conversationId });
171
+ }
172
+ } catch (err) {
173
+ log.warn({ err, conversationId }, 'Failed to persist inline approval transcript entries');
174
+ }
175
+
176
+ return { consumed: true, messageId };
177
+ }
178
+
38
179
  function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path: string; mtimeMs: number }> {
39
180
  if (!interfacesDir || !existsSync(interfacesDir)) return [];
40
181
  const results: Array<{ path: string; mtimeMs: number }> = [];
@@ -283,13 +424,36 @@ export async function handleSendMessage(
283
424
  const session = await smDeps.getOrCreateSession(mapping.conversationId);
284
425
  // HTTP API is a trusted local ingress (same as IPC) — set guardian context
285
426
  // so that memory extraction is not silently disabled by unverified provenance.
286
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: sourceChannel ?? 'http' });
427
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'http' });
287
428
  const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
288
429
 
289
430
  const attachments = hasAttachments
290
431
  ? smDeps.resolveAttachments(attachmentIds)
291
432
  : [];
292
433
 
434
+ // Try to consume the message as an inline approval/rejection reply.
435
+ // On failure, degrade to the existing queue/auto-deny path rather than
436
+ // surfacing a 500 — mirrors the IPC handler's catch-and-fallback.
437
+ try {
438
+ const inlineReplyResult = await tryConsumeInlineApprovalReply({
439
+ conversationId: mapping.conversationId,
440
+ sourceChannel,
441
+ sourceInterface,
442
+ content: content ?? '',
443
+ attachments,
444
+ session,
445
+ onEvent,
446
+ });
447
+ if (inlineReplyResult.consumed) {
448
+ return Response.json(
449
+ { accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
450
+ { status: 202 },
451
+ );
452
+ }
453
+ } catch (err) {
454
+ log.warn({ err, conversationId: mapping.conversationId }, 'Inline approval consumption failed, falling through to normal send path');
455
+ }
456
+
293
457
  if (session.isProcessing()) {
294
458
  // If a tool confirmation is pending, auto-deny it so the agent
295
459
  // can finish the current turn and process this queued message.
@@ -353,7 +517,7 @@ export async function handleSendMessage(
353
517
  mapping.conversationId,
354
518
  content ?? '',
355
519
  hasAttachments ? attachmentIds : undefined,
356
- { guardianContext: { actorRole: 'guardian', sourceChannel } },
520
+ { guardianContext: { trustClass: 'guardian', sourceChannel } },
357
521
  sourceChannel,
358
522
  sourceInterface,
359
523
  );
@@ -100,7 +100,7 @@ export async function handleApprovalInterception(
100
100
  // request targeting this chat, the message might be a decision on behalf
101
101
  // of a non-guardian requester.
102
102
  if (
103
- guardianCtx.actorRole === 'guardian' &&
103
+ guardianCtx.trustClass === 'guardian' &&
104
104
  senderExternalUserId
105
105
  ) {
106
106
  // Callback/button path: deterministic and takes priority.
@@ -161,7 +161,7 @@ export async function handleApprovalInterception(
161
161
  if (guardianApproval) {
162
162
  // Validate that the sender is the specific guardian who was assigned
163
163
  // this approval request. This is a defense-in-depth check — the
164
- // actorRole check above already verifies the sender is a guardian,
164
+ // trustClass check above already verifies the sender is a guardian,
165
165
  // but this catches edge cases like binding rotation between request
166
166
  // creation and decision.
167
167
  if (senderExternalUserId !== guardianApproval.guardianExternalUserId) {
@@ -548,9 +548,15 @@ export async function handleApprovalInterception(
548
548
  const pendingPrompt = getChannelApprovalPrompt(conversationId);
549
549
  if (!pendingPrompt) return { handled: false };
550
550
 
551
- // When the sender is from an unverified channel, auto-deny any pending
552
- // confirmation and block self-approval.
553
- if (guardianCtx.actorRole === 'unverified_channel') {
551
+ // Legacy unverified-channel equivalent:
552
+ // unknown trust + explicit denial reason (`no_identity` / `no_binding`).
553
+ // Unknown without a denial reason means identity-known, non-member sender
554
+ // in a shared channel; that case must not force-reject someone else's request.
555
+ const isLegacyUnverifiedSender = guardianCtx.trustClass === 'unknown' && !!guardianCtx.denialReason;
556
+
557
+ // When the sender is from a legacy-unverified channel actor, auto-deny any
558
+ // pending confirmation and block self-approval.
559
+ if (isLegacyUnverifiedSender) {
554
560
  const pending = getApprovalInfoByConversation(conversationId);
555
561
  if (pending.length > 0) {
556
562
  handleChannelDecision(
@@ -569,7 +575,12 @@ export async function handleApprovalInterception(
569
575
  // When the sender is a non-guardian and there's a pending guardian approval
570
576
  // for this conversation's request, block self-approval. The non-guardian must
571
577
  // wait for the guardian to decide.
572
- if (guardianCtx.actorRole === 'non-guardian') {
578
+ //
579
+ // Include identity-known, non-member senders (`unknown` without denialReason)
580
+ // so shared-channel participants can't approve/deny someone else's pending request.
581
+ const isIdentityKnownNonGuardian = guardianCtx.trustClass === 'trusted_contact'
582
+ || (guardianCtx.trustClass === 'unknown' && !guardianCtx.denialReason);
583
+ if (isIdentityKnownNonGuardian) {
573
584
  const pending = getApprovalInfoByConversation(conversationId);
574
585
  if (pending.length > 0) {
575
586
  const guardianApprovalForRequest = getPendingApprovalForRequest(pending[0].requestId);