@vellumai/assistant 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -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
 
@@ -11,7 +13,9 @@ import { getOrCreateConversation } from '../../memory/conversation-key-store.js'
11
13
  import { formatSseFrame, formatSseHeartbeat } from '../assistant-event.js';
12
14
  import type { AssistantEventSubscription } from '../assistant-event-hub.js';
13
15
  import { AssistantEventHub,assistantEventHub } from '../assistant-event-hub.js';
16
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
14
17
  import { httpError } from '../http-errors.js';
18
+ import { type ServerWithRequestIP, verifyHttpActorTokenWithLocalFallback } from '../middleware/actor-token.js';
15
19
 
16
20
  /** Keep-alive comment sent to idle clients every 30 s by default. */
17
21
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
@@ -29,11 +33,23 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
29
33
  export function handleSubscribeAssistantEvents(
30
34
  req: Request,
31
35
  url: URL,
32
- options?: {
33
- hub?: AssistantEventHub;
34
- heartbeatIntervalMs?: number;
35
- },
36
+ options?:
37
+ | { hub?: AssistantEventHub; heartbeatIntervalMs?: number; skipActorVerification?: false; server: ServerWithRequestIP }
38
+ | { hub?: AssistantEventHub; heartbeatIntervalMs?: number; skipActorVerification: true },
36
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
+
37
53
  const conversationKey = url.searchParams.get('conversationKey');
38
54
  if (!conversationKey) {
39
55
  return httpError('BAD_REQUEST', 'conversationKey is required', 400);
@@ -50,8 +66,6 @@ export function handleSubscribeAssistantEvents(
50
66
  // closures are in place before events can arrive. `controllerRef` is set
51
67
  // synchronously inside ReadableStream's start(), so it is non-null by the
52
68
  // time any event or eviction fires.
53
- // 'self' is the assistantId used by buildAssistantEvent('self', ...) for
54
- // all HTTP and voice session events.
55
69
  let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
56
70
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
57
71
  let sub!: AssistantEventSubscription;
@@ -63,7 +77,7 @@ export function handleSubscribeAssistantEvents(
63
77
 
64
78
  try {
65
79
  sub = hub.subscribe(
66
- { assistantId: 'self', sessionId: mapping.conversationId },
80
+ { assistantId: DAEMON_INTERNAL_ASSISTANT_ID, sessionId: mapping.conversationId },
67
81
  (event) => {
68
82
  const controller = controllerRef;
69
83
  if (!controller) return;
@@ -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
+ }
@@ -3,9 +3,10 @@
3
3
  */
4
4
  import { deleteConversationKey } from '../../memory/conversation-key-store.js';
5
5
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
6
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
6
7
  import { httpError } from '../http-errors.js';
7
8
 
8
- export async function handleDeleteConversation(req: Request, assistantId: string = 'self'): Promise<Response> {
9
+ export async function handleDeleteConversation(req: Request, assistantId: string = DAEMON_INTERNAL_ASSISTANT_ID): Promise<Response> {
9
10
  const body = await req.json() as {
10
11
  sourceChannel?: string;
11
12
  externalChatId?: string;
@@ -26,14 +27,14 @@ export async function handleDeleteConversation(req: Request, assistantId: string
26
27
  const legacyKey = `${sourceChannel}:${externalChatId}`;
27
28
  const scopedKey = `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
28
29
  deleteConversationKey(scopedKey);
29
- if (assistantId === 'self') {
30
+ if (assistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
30
31
  deleteConversationKey(legacyKey);
31
32
  }
32
33
  // external_conversation_bindings is currently assistant-agnostic
33
34
  // (unique by sourceChannel + externalChatId). Restrict mutations to the
34
35
  // canonical self-assistant route so multi-assistant legacy routes do not
35
36
  // clobber each other's bindings.
36
- if (assistantId === 'self') {
37
+ if (assistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
37
38
  externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
38
39
  }
39
40
 
@@ -28,6 +28,7 @@ import { IngressBlockedError } from '../../util/errors.js';
28
28
  import { getLogger } from '../../util/logger.js';
29
29
  import { readHttpToken } from '../../util/platform.js';
30
30
  import { notifyGuardianOfAccessRequest } from '../access-request-helper.js';
31
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
31
32
  import {
32
33
  buildApprovalUIMetadata,
33
34
  getApprovalInfoByConversation,
@@ -46,7 +47,7 @@ import {
46
47
  } from '../channel-guardian-service.js';
47
48
  import { getTransport } from '../channel-invite-transport.js';
48
49
  import { deliverChannelReply } from '../gateway-client.js';
49
- import { resolveGuardianContext } from '../guardian-context-resolver.js';
50
+ import { resolveGuardianContext, resolveRoutingState } from '../guardian-context-resolver.js';
50
51
  import { routeGuardianReply } from '../guardian-reply-router.js';
51
52
  import {
52
53
  composeChannelVerifyReply,
@@ -97,7 +98,7 @@ export async function handleChannelInbound(
97
98
  req: Request,
98
99
  processMessage?: MessageProcessor,
99
100
  bearerToken?: string,
100
- assistantId: string = 'self',
101
+ assistantId: string = DAEMON_INTERNAL_ASSISTANT_ID,
101
102
  gatewayOriginSecret?: string,
102
103
  approvalCopyGenerator?: ApprovalCopyGenerator,
103
104
  approvalConversationGenerator?: ApprovalConversationGenerator,
@@ -414,6 +415,7 @@ export async function handleChannelInbound(
414
415
  senderExternalUserId: canonicalSenderId ?? rawSenderId,
415
416
  senderName: body.senderName,
416
417
  senderUsername: body.senderUsername,
418
+ previousMemberStatus: resolvedMember.status,
417
419
  });
418
420
  guardianNotified = accessResult.notified;
419
421
  } catch (err) {
@@ -580,7 +582,7 @@ export async function handleChannelInbound(
580
582
  // external_conversation_bindings is assistant-agnostic. Restrict writes to
581
583
  // self so assistant-scoped legacy routes do not overwrite each other's
582
584
  // channel binding metadata for the same chat.
583
- if (canonicalAssistantId === 'self') {
585
+ if (canonicalAssistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
584
586
  externalConversationStore.upsertBinding({
585
587
  conversationId: result.conversationId,
586
588
  sourceChannel,
@@ -1346,6 +1348,13 @@ interface BackgroundProcessingParams {
1346
1348
  const TELEGRAM_TYPING_INTERVAL_MS = 4_000;
1347
1349
  const PENDING_APPROVAL_POLL_INTERVAL_MS = 300;
1348
1350
 
1351
+ // Module-level map tracking which approval requestIds have already been
1352
+ // notified to trusted contacts. Maps requestId -> conversationId so that
1353
+ // cleanup can be scoped to the owning conversation's poller, preventing
1354
+ // concurrent pollers from different conversations from evicting each
1355
+ // other's entries.
1356
+ const globalNotifiedApprovalRequestIds = new Map<string, string>();
1357
+
1349
1358
  function delay(ms: number): Promise<void> {
1350
1359
  return new Promise((resolve) => setTimeout(resolve, ms));
1351
1360
  }
@@ -1447,7 +1456,7 @@ function startPendingApprovalPromptWatcher(params: {
1447
1456
  replyCallbackUrl,
1448
1457
  chatId: externalChatId,
1449
1458
  sourceChannel,
1450
- assistantId: assistantId ?? 'self',
1459
+ assistantId: assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
1451
1460
  bearerToken,
1452
1461
  prompt,
1453
1462
  uiMetadata: buildApprovalUIMetadata(prompt, info),
@@ -1477,6 +1486,126 @@ function startPendingApprovalPromptWatcher(params: {
1477
1486
  };
1478
1487
  }
1479
1488
 
1489
+ /**
1490
+ * Resolve a human-readable guardian name from the guardian binding metadata.
1491
+ * Returns the display name, username (prefixed with @), or undefined if
1492
+ * no name is available.
1493
+ */
1494
+ function resolveGuardianDisplayName(
1495
+ assistantId: string,
1496
+ sourceChannel: ChannelId,
1497
+ ): string | undefined {
1498
+ const binding = getGuardianBinding(assistantId, sourceChannel);
1499
+ if (!binding?.metadataJson) return undefined;
1500
+ try {
1501
+ const parsed = JSON.parse(binding.metadataJson) as Record<string, unknown>;
1502
+ if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
1503
+ return parsed.displayName.trim();
1504
+ }
1505
+ if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
1506
+ return `@${parsed.username.trim()}`;
1507
+ }
1508
+ } catch {
1509
+ // ignore malformed metadata
1510
+ }
1511
+ return undefined;
1512
+ }
1513
+
1514
+ /**
1515
+ * Start a poller that sends a one-shot "waiting for guardian approval" message
1516
+ * to the trusted contact when a confirmation_request enters guardian approval
1517
+ * wait. Deduplicates by requestId so each request only produces one message.
1518
+ *
1519
+ * Only activates for trusted-contact actors with a resolvable guardian route.
1520
+ */
1521
+ function startTrustedContactApprovalNotifier(params: {
1522
+ conversationId: string;
1523
+ sourceChannel: ChannelId;
1524
+ externalChatId: string;
1525
+ guardianTrustClass: GuardianContext['trustClass'];
1526
+ guardianExternalUserId?: string;
1527
+ replyCallbackUrl: string;
1528
+ bearerToken?: string;
1529
+ assistantId?: string;
1530
+ }): () => void {
1531
+ const {
1532
+ conversationId,
1533
+ sourceChannel,
1534
+ externalChatId,
1535
+ guardianTrustClass,
1536
+ guardianExternalUserId,
1537
+ replyCallbackUrl,
1538
+ bearerToken,
1539
+ assistantId,
1540
+ } = params;
1541
+
1542
+ // Only notify trusted contacts who have a resolvable guardian route.
1543
+ if (guardianTrustClass !== 'trusted_contact' || !guardianExternalUserId) {
1544
+ return () => {};
1545
+ }
1546
+
1547
+ let active = true;
1548
+
1549
+ const poll = async (): Promise<void> => {
1550
+ while (active) {
1551
+ try {
1552
+ const pending = getApprovalInfoByConversation(conversationId);
1553
+ const info = pending[0];
1554
+
1555
+ // Clean up resolved requests from the module-level dedupe map.
1556
+ // Only remove entries that belong to THIS conversation — other
1557
+ // conversations' pollers own their own entries. Without this
1558
+ // scoping, concurrent pollers would evict each other's request
1559
+ // IDs and cause duplicate notifications.
1560
+ const currentPendingIds = new Set(pending.map(p => p.requestId));
1561
+ for (const [rid, cid] of globalNotifiedApprovalRequestIds) {
1562
+ if (cid === conversationId && !currentPendingIds.has(rid)) {
1563
+ globalNotifiedApprovalRequestIds.delete(rid);
1564
+ }
1565
+ }
1566
+
1567
+ if (info && !globalNotifiedApprovalRequestIds.has(info.requestId)) {
1568
+ globalNotifiedApprovalRequestIds.set(info.requestId, conversationId);
1569
+ const guardianName = resolveGuardianDisplayName(
1570
+ assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
1571
+ sourceChannel,
1572
+ );
1573
+ const waitingText = guardianName
1574
+ ? `Waiting for ${guardianName}'s approval...`
1575
+ : 'Waiting for your guardian\'s approval...';
1576
+ try {
1577
+ await deliverChannelReply(replyCallbackUrl, {
1578
+ chatId: externalChatId,
1579
+ text: waitingText,
1580
+ assistantId: assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
1581
+ }, bearerToken);
1582
+ } catch (err) {
1583
+ log.warn({ err, conversationId }, 'Failed to deliver trusted-contact pending-approval notification');
1584
+ // Remove from notified set so delivery is retried on next poll
1585
+ globalNotifiedApprovalRequestIds.delete(info.requestId);
1586
+ }
1587
+ }
1588
+ } catch (err) {
1589
+ log.warn({ err, conversationId }, 'Trusted-contact approval notifier poll failed');
1590
+ }
1591
+ await delay(PENDING_APPROVAL_POLL_INTERVAL_MS);
1592
+ }
1593
+ };
1594
+
1595
+ void poll();
1596
+ return () => {
1597
+ active = false;
1598
+
1599
+ // Evict all dedupe entries owned by this conversation so the
1600
+ // module-level map doesn't grow unboundedly after the poller stops.
1601
+ for (const [rid, cid] of globalNotifiedApprovalRequestIds) {
1602
+ if (cid === conversationId) {
1603
+ globalNotifiedApprovalRequestIds.delete(rid);
1604
+ }
1605
+ }
1606
+ };
1607
+ }
1608
+
1480
1609
  function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
1481
1610
  const {
1482
1611
  processMessage,
@@ -1519,6 +1648,18 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1519
1648
  approvalCopyGenerator,
1520
1649
  })
1521
1650
  : undefined;
1651
+ const stopTcApprovalNotifier = replyCallbackUrl
1652
+ ? startTrustedContactApprovalNotifier({
1653
+ conversationId,
1654
+ sourceChannel,
1655
+ externalChatId,
1656
+ guardianTrustClass: guardianCtx.trustClass,
1657
+ guardianExternalUserId: guardianCtx.guardianExternalUserId,
1658
+ replyCallbackUrl,
1659
+ bearerToken,
1660
+ assistantId,
1661
+ })
1662
+ : undefined;
1522
1663
 
1523
1664
  try {
1524
1665
  const cmdIntent = commandIntent && typeof commandIntent.type === 'string'
@@ -1536,7 +1677,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1536
1677
  },
1537
1678
  assistantId,
1538
1679
  guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
1539
- isInteractive: guardianCtx.trustClass === 'guardian',
1680
+ isInteractive: resolveRoutingState(guardianCtx).promptWaitingAllowed,
1540
1681
  ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
1541
1682
  },
1542
1683
  sourceChannel,
@@ -1564,6 +1705,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1564
1705
  } finally {
1565
1706
  stopTypingHeartbeat?.();
1566
1707
  stopApprovalWatcher?.();
1708
+ stopTcApprovalNotifier?.();
1567
1709
  }
1568
1710
  })();
1569
1711
  }
@@ -147,6 +147,8 @@ export async function handleCreateInvite(req: Request): Promise<Response> {
147
147
  expiresInMs: body.expiresInMs as number | undefined,
148
148
  expectedExternalUserId: body.expectedExternalUserId as string | undefined,
149
149
  voiceCodeDigits: body.voiceCodeDigits as number | undefined,
150
+ friendName: body.friendName as string | undefined,
151
+ guardianName: body.guardianName as string | undefined,
150
152
  });
151
153
 
152
154
  if (!result.ok) {