@vellumai/assistant 0.4.3 → 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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -25,6 +25,7 @@ import type { Provider } from '../../providers/types.js';
25
25
  import { getLogger } from '../../util/logger.js';
26
26
  import { buildAssistantEvent } from '../assistant-event.js';
27
27
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
28
+ import { bridgeConfirmationRequestToGuardian } from '../confirmation-request-guardian-bridge.js';
28
29
  import { routeGuardianReply } from '../guardian-reply-router.js';
29
30
  import { httpError } from '../http-errors.js';
30
31
  import type {
@@ -35,48 +36,41 @@ import type {
35
36
  RuntimeMessagePayload,
36
37
  SendMessageDeps,
37
38
  } from '../http-types.js';
39
+ import { resolveLocalIpcGuardianContext } from '../local-actor-identity.js';
40
+ import { type ServerWithRequestIP, verifyHttpActorTokenWithLocalFallback } from '../middleware/actor-token.js';
38
41
  import * as pendingInteractions from '../pending-interactions.js';
39
42
 
40
43
  const log = getLogger('conversation-routes');
41
44
 
42
45
  const SUGGESTION_CACHE_MAX = 100;
43
46
 
44
- function collectLivePendingConfirmationRequestIds(
47
+ function collectCanonicalGuardianRequestHintIds(
45
48
  conversationId: string,
46
49
  sourceChannel: string,
47
50
  session: import('../../daemon/session.js').Session,
48
51
  ): string[] {
49
- const pendingInteractionRequestIds = pendingInteractions
50
- .getByConversation(conversationId)
51
- .filter(
52
- (interaction) =>
53
- interaction.kind === 'confirmation'
54
- && interaction.session === session
55
- && session.hasPendingConfirmation(interaction.requestId),
56
- )
57
- .map((interaction) => interaction.requestId);
58
-
59
- // Query both by destination conversation (via deliveries table) and by
60
- // source conversation (direct field). For desktop/HTTP sessions these
61
- // often overlap, but the Set dedup below handles that.
62
- const pendingCanonicalRequestIds = [
52
+ const requests = [
63
53
  ...listPendingCanonicalGuardianRequestsByDestinationConversation(conversationId, sourceChannel)
64
- .filter((request) => request.kind === 'tool_approval')
65
- .map((request) => request.id),
54
+ .map((request) => ({ id: request.id, kind: request.kind })),
66
55
  ...listCanonicalGuardianRequests({
67
56
  status: 'pending',
68
57
  conversationId,
69
- kind: 'tool_approval',
70
- }).map((request) => request.id),
71
- ].filter((requestId) => session.hasPendingConfirmation(requestId));
72
-
73
- return Array.from(new Set([
74
- ...pendingInteractionRequestIds,
75
- ...pendingCanonicalRequestIds,
76
- ]));
58
+ }).map((request) => ({ id: request.id, kind: request.kind })),
59
+ ];
60
+
61
+ const deduped = new Map<string, string>();
62
+ for (const request of requests) {
63
+ if (!deduped.has(request.id)) {
64
+ deduped.set(request.id, request.kind ?? '');
65
+ }
66
+ }
67
+
68
+ return Array.from(deduped.entries())
69
+ .filter(([requestId, kind]) => kind !== 'tool_approval' || session.hasPendingConfirmation(requestId))
70
+ .map(([requestId]) => requestId);
77
71
  }
78
72
 
79
- async function tryConsumeInlineApprovalReply(params: {
73
+ async function tryConsumeCanonicalGuardianReply(params: {
80
74
  conversationId: string;
81
75
  sourceChannel: string;
82
76
  sourceInterface: string;
@@ -90,6 +84,8 @@ async function tryConsumeInlineApprovalReply(params: {
90
84
  session: import('../../daemon/session.js').Session;
91
85
  onEvent: (msg: ServerMessage) => void;
92
86
  approvalConversationGenerator?: ApprovalConversationGenerator;
87
+ /** Verified actor identity from actor-token middleware. */
88
+ verifiedActorExternalUserId?: string;
93
89
  }): Promise<{ consumed: boolean; messageId?: string }> {
94
90
  const {
95
91
  conversationId,
@@ -100,42 +96,57 @@ async function tryConsumeInlineApprovalReply(params: {
100
96
  session,
101
97
  onEvent,
102
98
  approvalConversationGenerator,
99
+ verifiedActorExternalUserId,
103
100
  } = params;
104
101
  const trimmedContent = content.trim();
105
102
 
106
- // Try inline approval interception whenever a pending confirmation exists.
107
- // We intentionally do not block on queue depth: after an auto-deny, users
108
- // often retry with "approve"/"yes" while the queue is still draining, and
109
- // requiring an empty queue can create a deny/retry cascade.
110
- if (
111
- !session.hasAnyPendingConfirmation()
112
- || trimmedContent.length === 0
113
- ) {
103
+ if (trimmedContent.length === 0) {
114
104
  return { consumed: false };
115
105
  }
116
106
 
117
- const pendingRequestIds = collectLivePendingConfirmationRequestIds(conversationId, sourceChannel, session);
118
- if (pendingRequestIds.length === 0) {
119
- return { consumed: false };
120
- }
107
+ const pendingRequestHintIds = collectCanonicalGuardianRequestHintIds(conversationId, sourceChannel, session);
108
+ const pendingRequestIds = pendingRequestHintIds.length > 0 ? pendingRequestHintIds : undefined;
121
109
 
122
110
  const routerResult = await routeGuardianReply({
123
111
  messageText: trimmedContent,
124
112
  channel: sourceChannel,
125
113
  actor: {
126
- externalUserId: undefined,
114
+ externalUserId: verifiedActorExternalUserId,
127
115
  channel: sourceChannel,
128
- isTrusted: true,
116
+ // When a verified identity is available, disable the trusted bypass so
117
+ // that the identity-match checks in applyCanonicalGuardianDecision
118
+ // actually run. Only fall back to isTrusted when no verified identity
119
+ // was resolved (defensive — shouldn't happen for vellum since
120
+ // verification runs upstream).
121
+ isTrusted: !verifiedActorExternalUserId,
129
122
  },
130
123
  conversationId,
131
124
  pendingRequestIds,
132
125
  approvalConversationGenerator,
126
+ emissionContext: {
127
+ source: 'inline_nl',
128
+ decisionText: trimmedContent,
129
+ },
133
130
  });
134
131
 
135
132
  if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
136
133
  return { consumed: false };
137
134
  }
138
135
 
136
+ // Success-path emissions (approved/denied) are handled centrally
137
+ // by handleConfirmationResponse (called via the resolver chain).
138
+ // However, stale/failed paths never reach handleConfirmationResponse,
139
+ // so we emit resolved_stale here for those cases.
140
+ if (routerResult.requestId && !routerResult.decisionApplied) {
141
+ session.emitConfirmationStateChanged({
142
+ sessionId: conversationId,
143
+ requestId: routerResult.requestId,
144
+ state: 'resolved_stale',
145
+ source: 'inline_nl',
146
+ decisionText: trimmedContent,
147
+ });
148
+ }
149
+
139
150
  // Decision has been applied — transcript persistence is best-effort.
140
151
  // If DB writes fail, we still return consumed: true so the approval text
141
152
  // is not re-processed as a new user turn.
@@ -336,7 +347,7 @@ function makeHubPublisher(
336
347
  // via applyCanonicalGuardianDecision.
337
348
  const guardianContext = session.guardianContext;
338
349
  const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
339
- createCanonicalGuardianRequest({
350
+ const canonicalRequest = createCanonicalGuardianRequest({
340
351
  id: msg.requestId,
341
352
  kind: 'tool_approval',
342
353
  sourceType: resolveCanonicalRequestSourceType(sourceChannel),
@@ -350,6 +361,18 @@ function makeHubPublisher(
350
361
  requestCode: generateCanonicalRequestCode(),
351
362
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
352
363
  });
364
+
365
+ // For trusted-contact sessions, bridge to guardian.question so the
366
+ // guardian gets notified and can approve via callback/request-code.
367
+ if (guardianContext) {
368
+ bridgeConfirmationRequestToGuardian({
369
+ canonicalRequest,
370
+ guardianContext,
371
+ conversationId,
372
+ toolName: msg.toolName,
373
+ assistantId: session.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
374
+ });
375
+ }
353
376
  } else if (msg.type === 'secret_request') {
354
377
  pendingInteractions.register(msg.requestId, {
355
378
  session,
@@ -384,6 +407,7 @@ export async function handleSendMessage(
384
407
  sendMessageDeps?: SendMessageDeps;
385
408
  approvalConversationGenerator?: ApprovalConversationGenerator;
386
409
  },
410
+ server: ServerWithRequestIP,
387
411
  ): Promise<Response> {
388
412
  const body = await req.json() as {
389
413
  conversationKey?: string;
@@ -441,31 +465,68 @@ export async function handleSendMessage(
441
465
 
442
466
  // ── Queue-if-busy path (preferred when sendMessageDeps is wired) ────
443
467
  if (deps.sendMessageDeps) {
468
+ // Vellum HTTP requests prefer actor-token identity. When absent (e.g. CLI
469
+ // bearer-auth only), fall back to local IPC identity resolution so
470
+ // bearer-authenticated local clients are not rejected.
471
+ const actorVerification = sourceChannel === 'vellum' ? verifyHttpActorTokenWithLocalFallback(req, server) : null;
472
+ if (actorVerification && !actorVerification.ok) {
473
+ return httpError(
474
+ actorVerification.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
475
+ actorVerification.message,
476
+ actorVerification.status,
477
+ );
478
+ }
479
+
444
480
  const smDeps = deps.sendMessageDeps;
445
481
  const session = await smDeps.getOrCreateSession(mapping.conversationId);
446
- // HTTP API is a trusted local ingress (same as IPC) — set guardian context
447
- // so that memory extraction is not silently disabled by unverified provenance.
448
- session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'http' });
482
+ // Resolve actor identity from the verified actor token. The token's
483
+ // guardianPrincipalId is matched against the vellum guardian binding
484
+ // through the standard trust pipeline.
485
+ if (actorVerification?.ok) {
486
+ session.setGuardianContext(actorVerification.guardianContext);
487
+ } else {
488
+ // Non-vellum channels through the HTTP API are still local
489
+ // authenticated requests. Resolve guardian context via the local
490
+ // identity pathway (vellum binding lookup) to preserve guardian
491
+ // trust. Falls back to a minimal guardian context if no binding
492
+ // exists (pre-bootstrap).
493
+ session.setGuardianContext(
494
+ resolveLocalIpcGuardianContext(sourceChannel) ?? { trustClass: 'guardian', sourceChannel },
495
+ );
496
+ }
449
497
  const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
498
+ // Route server-authoritative state signals (confirmation_state_changed,
499
+ // assistant_activity_state) to the SSE hub. Without this, these signals
500
+ // only travel through session.sendToClient, which is a no-op for
501
+ // socketless HTTP sessions.
502
+ session.setStateSignalListener(onEvent);
450
503
 
451
504
  const attachments = hasAttachments
452
505
  ? smDeps.resolveAttachments(attachmentIds)
453
506
  : [];
454
507
 
455
- // Try to consume the message as an inline approval/rejection reply.
508
+ // Resolve the verified actor's external user ID for inline approval
509
+ // routing. Uses the guardianExternalUserId from the verified context
510
+ // (actor-token or local-fallback) rather than hardcoding undefined.
511
+ const verifiedActorExternalUserId = actorVerification?.ok
512
+ ? actorVerification.guardianContext.guardianExternalUserId
513
+ : undefined;
514
+
515
+ // Try to consume the message as a canonical guardian approval/rejection reply.
456
516
  // On failure, degrade to the existing queue/auto-deny path rather than
457
517
  // surfacing a 500 — mirrors the IPC handler's catch-and-fallback.
458
518
  try {
459
- const inlineReplyResult = await tryConsumeInlineApprovalReply({
519
+ const inlineReplyResult = await tryConsumeCanonicalGuardianReply({
460
520
  conversationId: mapping.conversationId,
461
521
  sourceChannel,
462
522
  sourceInterface,
463
523
  content: content ?? '',
464
- attachments,
465
- session,
466
- onEvent,
467
- approvalConversationGenerator: deps.approvalConversationGenerator,
468
- });
524
+ attachments,
525
+ session,
526
+ onEvent,
527
+ approvalConversationGenerator: deps.approvalConversationGenerator,
528
+ verifiedActorExternalUserId,
529
+ });
469
530
  if (inlineReplyResult.consumed) {
470
531
  return Response.json(
471
532
  { accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
@@ -480,6 +541,18 @@ export async function handleSendMessage(
480
541
  // If a tool confirmation is pending, auto-deny it so the agent
481
542
  // can finish the current turn and process this queued message.
482
543
  if (session.hasAnyPendingConfirmation()) {
544
+ // Emit authoritative denial state for each pending request.
545
+ // The onStateSignal listener routes these to the SSE hub automatically.
546
+ for (const interaction of pendingInteractions.getByConversation(mapping.conversationId)) {
547
+ if (interaction.session === session && interaction.kind === 'confirmation') {
548
+ session.emitConfirmationStateChanged({
549
+ sessionId: mapping.conversationId,
550
+ requestId: interaction.requestId,
551
+ state: 'denied' as const,
552
+ source: 'auto_deny' as const,
553
+ });
554
+ }
555
+ }
483
556
  session.denyAllPendingConfirmations();
484
557
  pendingInteractions.removeBySession(session);
485
558
  }
@@ -534,12 +607,27 @@ export async function handleSendMessage(
534
607
  return httpError('SERVICE_UNAVAILABLE', 'Message processing not configured', 503);
535
608
  }
536
609
 
610
+ // Require actor token for vellum channel requests on the legacy path too,
611
+ // with local IPC fallback for bearer-authenticated CLI clients.
612
+ const legacyActorVerification = sourceChannel === 'vellum' ? verifyHttpActorTokenWithLocalFallback(req, server) : null;
613
+ if (legacyActorVerification && !legacyActorVerification.ok) {
614
+ return httpError(
615
+ legacyActorVerification.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
616
+ legacyActorVerification.message,
617
+ legacyActorVerification.status,
618
+ );
619
+ }
620
+
621
+ const guardianContext = legacyActorVerification?.ok
622
+ ? legacyActorVerification.guardianContext
623
+ : resolveLocalIpcGuardianContext(sourceChannel) ?? { trustClass: 'guardian' as const, sourceChannel };
624
+
537
625
  try {
538
626
  const result = await processor(
539
627
  mapping.conversationId,
540
628
  content ?? '',
541
629
  hasAttachments ? attachmentIds : undefined,
542
- { guardianContext: { trustClass: 'guardian', sourceChannel } },
630
+ { guardianContext },
543
631
  sourceChannel,
544
632
  sourceInterface,
545
633
  );
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * GET /v1/events?conversationKey=...
5
5
  *
6
- * Auth is enforced by RuntimeHttpServer before this handler is called.
6
+ * Bearer auth is enforced by RuntimeHttpServer before this handler is called.
7
+ * Actor-token identity verification (with local CLI fallback) is performed
8
+ * within this handler to bind the SSE stream to a verified actor identity.
7
9
  * Subscribers receive all assistant events scoped to the given conversation.
8
10
  */
9
11
 
@@ -13,6 +15,7 @@ import type { AssistantEventSubscription } from '../assistant-event-hub.js';
13
15
  import { AssistantEventHub,assistantEventHub } from '../assistant-event-hub.js';
14
16
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
15
17
  import { httpError } from '../http-errors.js';
18
+ import { type ServerWithRequestIP, verifyHttpActorTokenWithLocalFallback } from '../middleware/actor-token.js';
16
19
 
17
20
  /** Keep-alive comment sent to idle clients every 30 s by default. */
18
21
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
@@ -30,11 +33,23 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
30
33
  export function handleSubscribeAssistantEvents(
31
34
  req: Request,
32
35
  url: URL,
33
- options?: {
34
- hub?: AssistantEventHub;
35
- heartbeatIntervalMs?: number;
36
- },
36
+ options?:
37
+ | { hub?: AssistantEventHub; heartbeatIntervalMs?: number; skipActorVerification?: false; server: ServerWithRequestIP }
38
+ | { hub?: AssistantEventHub; heartbeatIntervalMs?: number; skipActorVerification: true },
37
39
  ): Response {
40
+ // Verify actor-token identity for vellum channel requests, with local
41
+ // CLI fallback for bearer-authenticated clients without X-Actor-Token.
42
+ if (options && !options.skipActorVerification) {
43
+ const actorVerification = verifyHttpActorTokenWithLocalFallback(req, options.server);
44
+ if (!actorVerification.ok) {
45
+ return httpError(
46
+ actorVerification.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
47
+ actorVerification.message,
48
+ actorVerification.status,
49
+ );
50
+ }
51
+ }
52
+
38
53
  const conversationKey = url.searchParams.get('conversationKey');
39
54
  if (!conversationKey) {
40
55
  return httpError('BAD_REQUEST', 'conversationKey is required', 400);
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * These endpoints let desktop clients fetch pending guardian prompts and
5
5
  * submit button decisions without relying on text parsing.
6
+ *
7
+ * All guardian action endpoints require a valid actor token via the
8
+ * X-Actor-Token header (with local CLI fallback). Guardian decisions
9
+ * additionally verify the actor is the bound guardian.
6
10
  */
7
11
  import {
8
12
  applyCanonicalGuardianDecision,
@@ -16,6 +20,12 @@ import type { ApprovalAction } from '../channel-approval-types.js';
16
20
  import type { GuardianDecisionPrompt } from '../guardian-decision-types.js';
17
21
  import { buildDecisionActions } from '../guardian-decision-types.js';
18
22
  import { httpError } from '../http-errors.js';
23
+ import {
24
+ isActorBoundGuardian,
25
+ isLocalFallbackBoundGuardian,
26
+ type ServerWithRequestIP,
27
+ verifyHttpActorTokenWithLocalFallback,
28
+ } from '../middleware/actor-token.js';
19
29
 
20
30
  // ---------------------------------------------------------------------------
21
31
  // GET /v1/guardian-actions/pending?conversationId=...
@@ -23,12 +33,22 @@ import { httpError } from '../http-errors.js';
23
33
 
24
34
  /**
25
35
  * List pending guardian decision prompts for a conversation.
36
+ * Requires a valid actor token.
26
37
  *
27
38
  * Returns guardian approval requests (from the channel guardian store) that
28
39
  * are still pending, mapped to the GuardianDecisionPrompt shape so clients
29
40
  * can render structured button UIs.
30
41
  */
31
- export function handleGuardianActionsPending(req: Request): Response {
42
+ export function handleGuardianActionsPending(req: Request, server: ServerWithRequestIP): Response {
43
+ const tokenResult = verifyHttpActorTokenWithLocalFallback(req, server);
44
+ if (!tokenResult.ok) {
45
+ return httpError(
46
+ tokenResult.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
47
+ tokenResult.message,
48
+ tokenResult.status,
49
+ );
50
+ }
51
+
32
52
  const url = new URL(req.url);
33
53
  const conversationId = url.searchParams.get('conversationId');
34
54
 
@@ -46,12 +66,28 @@ export function handleGuardianActionsPending(req: Request): Response {
46
66
 
47
67
  /**
48
68
  * Submit a guardian action decision.
69
+ * Requires a valid actor token for a bound guardian.
49
70
  *
50
71
  * Routes all decisions through the unified canonical guardian decision
51
72
  * primitive which handles CAS resolution, resolver dispatch, and grant
52
73
  * minting.
53
74
  */
54
- export async function handleGuardianActionDecision(req: Request): Promise<Response> {
75
+ export async function handleGuardianActionDecision(req: Request, server: ServerWithRequestIP): Promise<Response> {
76
+ const tokenResult = verifyHttpActorTokenWithLocalFallback(req, server);
77
+ if (!tokenResult.ok) {
78
+ return httpError(
79
+ tokenResult.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
80
+ tokenResult.message,
81
+ tokenResult.status,
82
+ );
83
+ }
84
+ const isBoundGuardian = tokenResult.claims
85
+ ? isActorBoundGuardian(tokenResult.claims)
86
+ : isLocalFallbackBoundGuardian();
87
+ if (!isBoundGuardian) {
88
+ return httpError('FORBIDDEN', 'Actor is not the bound guardian for this channel', 403);
89
+ }
90
+
55
91
  const body = await req.json() as {
56
92
  requestId?: string;
57
93
  action?: string;
@@ -82,11 +118,17 @@ export async function handleGuardianActionDecision(req: Request): Promise<Respon
82
118
  }
83
119
  }
84
120
 
121
+ // Resolve the actor's external user ID: from the token claims if present,
122
+ // otherwise from the vellum guardian binding (local fallback).
123
+ const actorExternalUserId = tokenResult.claims
124
+ ? tokenResult.claims.guardianPrincipalId
125
+ : tokenResult.guardianContext.guardianExternalUserId;
126
+
85
127
  const canonicalResult = await applyCanonicalGuardianDecision({
86
128
  requestId,
87
129
  action: action as ApprovalAction,
88
130
  actorContext: {
89
- externalUserId: undefined,
131
+ externalUserId: actorExternalUserId,
90
132
  channel: 'vellum',
91
133
  isTrusted: true,
92
134
  },
@@ -741,6 +741,35 @@ export async function handleApprovalInterception(
741
741
  }
742
742
  return { handled: true, type: 'decision_applied' };
743
743
  }
744
+
745
+ // Guard: non-guardian actors with a guardian binding must not self-approve
746
+ // even when no guardian approval row exists yet. The guardian approval
747
+ // row is created asynchronously when the approval prompt is delivered
748
+ // to the guardian. In the window between the pending confirmation being
749
+ // created (isInteractive=true) and the guardian approval row being
750
+ // persisted, any non-guardian actor could otherwise fall through to the
751
+ // standard conversational engine / legacy parser and resolve their own
752
+ // pending request via handleChannelDecision.
753
+ if (guardianCtx.trustClass !== 'guardian' && guardianCtx.guardianExternalUserId) {
754
+ log.info(
755
+ { conversationId, externalChatId, guardianExternalUserId: guardianCtx.guardianExternalUserId },
756
+ 'Blocking non-guardian self-approval: pending confirmation exists but guardian approval row not yet created',
757
+ );
758
+ try {
759
+ const pendingText = await composeApprovalMessageGenerative({
760
+ scenario: 'request_pending_guardian',
761
+ channel: sourceChannel,
762
+ }, {}, approvalCopyGenerator);
763
+ await deliverChannelReply(replyCallbackUrl, {
764
+ chatId: externalChatId,
765
+ text: pendingText,
766
+ assistantId,
767
+ }, bearerToken);
768
+ } catch (err) {
769
+ log.error({ err, conversationId }, 'Failed to deliver guardian-pending notice to non-guardian actor (pre-row guard)');
770
+ }
771
+ return { handled: true, type: 'assistant_turn' };
772
+ }
744
773
  }
745
774
  }
746
775
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * POST /v1/integrations/guardian/vellum/bootstrap
3
+ *
4
+ * Idempotent bootstrap endpoint for the vellum guardian channel.
5
+ * Creates or confirms a guardianPrincipalId and channel='vellum'
6
+ * guardian binding, then mints and returns an actor token bound
7
+ * to (assistantId, guardianPrincipalId, deviceId).
8
+ *
9
+ * Only the hashed token is persisted.
10
+ */
11
+
12
+ import { createHash } from 'node:crypto';
13
+
14
+ import { v4 as uuid } from 'uuid';
15
+
16
+ import {
17
+ createBinding,
18
+ getActiveBinding,
19
+ } from '../../memory/guardian-bindings.js';
20
+ import { getLogger } from '../../util/logger.js';
21
+ import { mintActorToken } from '../actor-token-service.js';
22
+ import {
23
+ createActorTokenRecord,
24
+ revokeByDeviceBinding,
25
+ } from '../actor-token-store.js';
26
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
27
+ import { httpError } from '../http-errors.js';
28
+ import type { ServerWithRequestIP } from '../middleware/actor-token.js';
29
+
30
+ const log = getLogger('guardian-bootstrap');
31
+
32
+ /** Hash a device ID for storage (same pattern as approved-devices-store). */
33
+ function hashDeviceId(deviceId: string): string {
34
+ return createHash('sha256').update(deviceId).digest('hex');
35
+ }
36
+
37
+ /**
38
+ * Ensure a guardianPrincipalId exists for the vellum channel.
39
+ * If a binding already exists, returns the existing guardianExternalUserId
40
+ * as the principal. Otherwise creates a new binding with a fresh principal.
41
+ */
42
+ function ensureGuardianPrincipal(assistantId: string): {
43
+ guardianPrincipalId: string;
44
+ isNew: boolean;
45
+ } {
46
+ const existing = getActiveBinding(assistantId, 'vellum');
47
+ if (existing) {
48
+ return { guardianPrincipalId: existing.guardianExternalUserId, isNew: false };
49
+ }
50
+
51
+ // Mint a new principal ID for the vellum channel
52
+ const guardianPrincipalId = `vellum-principal-${uuid()}`;
53
+
54
+ createBinding({
55
+ assistantId,
56
+ channel: 'vellum',
57
+ guardianExternalUserId: guardianPrincipalId,
58
+ guardianDeliveryChatId: 'local',
59
+ verifiedVia: 'bootstrap',
60
+ metadataJson: JSON.stringify({ bootstrappedAt: Date.now() }),
61
+ });
62
+
63
+ log.info({ assistantId, guardianPrincipalId }, 'Created vellum guardian principal via bootstrap');
64
+ return { guardianPrincipalId, isNew: true };
65
+ }
66
+
67
+ /** Loopback addresses — used to gate the bootstrap endpoint to local-only. */
68
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
69
+
70
+ /**
71
+ * Handle POST /v1/integrations/guardian/vellum/bootstrap
72
+ *
73
+ * Body: { platform: 'macos', deviceId: string }
74
+ * Returns: { guardianPrincipalId, actorToken, isNew }
75
+ *
76
+ * This endpoint is loopback-only (macOS local use only). iOS devices
77
+ * obtain actor tokens exclusively through the QR pairing flow.
78
+ */
79
+ export async function handleGuardianBootstrap(req: Request, server: ServerWithRequestIP): Promise<Response> {
80
+ // Reject proxied requests — bootstrap is local-only
81
+ if (req.headers.get('x-forwarded-for')) {
82
+ return httpError('FORBIDDEN', 'Bootstrap endpoint is local-only', 403);
83
+ }
84
+
85
+ // Reject non-loopback peers
86
+ const peerIp = server.requestIP(req)?.address;
87
+ if (!peerIp || !LOOPBACK_ADDRESSES.has(peerIp)) {
88
+ return httpError('FORBIDDEN', 'Bootstrap endpoint is local-only', 403);
89
+ }
90
+
91
+ try {
92
+ const body = await req.json() as Record<string, unknown>;
93
+ const platform = typeof body.platform === 'string' ? body.platform.trim() : '';
94
+ const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : '';
95
+
96
+ if (!platform || !deviceId) {
97
+ return httpError('BAD_REQUEST', 'Missing required fields: platform, deviceId', 400);
98
+ }
99
+
100
+ if (platform !== 'macos') {
101
+ return httpError('BAD_REQUEST', 'Invalid platform. Bootstrap is macOS-only; iOS uses QR pairing.', 400);
102
+ }
103
+
104
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
105
+ const { guardianPrincipalId, isNew } = ensureGuardianPrincipal(assistantId);
106
+ const hashedDeviceId = hashDeviceId(deviceId);
107
+
108
+ // Revoke any existing active tokens for this device binding
109
+ // so we maintain one-active-token-per-device
110
+ revokeByDeviceBinding(assistantId, guardianPrincipalId, hashedDeviceId);
111
+
112
+ // Mint a new actor token
113
+ const { token, tokenHash, claims } = mintActorToken({
114
+ assistantId,
115
+ platform,
116
+ deviceId,
117
+ guardianPrincipalId,
118
+ });
119
+
120
+ // Store only the hash
121
+ createActorTokenRecord({
122
+ tokenHash,
123
+ assistantId,
124
+ guardianPrincipalId,
125
+ hashedDeviceId,
126
+ platform,
127
+ issuedAt: claims.iat,
128
+ expiresAt: claims.exp,
129
+ });
130
+
131
+ log.info(
132
+ { assistantId, platform, guardianPrincipalId, isNew },
133
+ 'Guardian bootstrap completed',
134
+ );
135
+
136
+ return Response.json({
137
+ guardianPrincipalId,
138
+ actorToken: token,
139
+ isNew,
140
+ });
141
+ } catch (err) {
142
+ log.error({ err }, 'Guardian bootstrap failed');
143
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
144
+ }
145
+ }