@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
@@ -47,7 +47,7 @@ import {
47
47
  } from '../channel-guardian-service.js';
48
48
  import { getTransport } from '../channel-invite-transport.js';
49
49
  import { deliverChannelReply } from '../gateway-client.js';
50
- import { resolveGuardianContext } from '../guardian-context-resolver.js';
50
+ import { resolveGuardianContext, resolveRoutingState } from '../guardian-context-resolver.js';
51
51
  import { routeGuardianReply } from '../guardian-reply-router.js';
52
52
  import {
53
53
  composeChannelVerifyReply,
@@ -415,6 +415,7 @@ export async function handleChannelInbound(
415
415
  senderExternalUserId: canonicalSenderId ?? rawSenderId,
416
416
  senderName: body.senderName,
417
417
  senderUsername: body.senderUsername,
418
+ previousMemberStatus: resolvedMember.status,
418
419
  });
419
420
  guardianNotified = accessResult.notified;
420
421
  } catch (err) {
@@ -1347,6 +1348,13 @@ interface BackgroundProcessingParams {
1347
1348
  const TELEGRAM_TYPING_INTERVAL_MS = 4_000;
1348
1349
  const PENDING_APPROVAL_POLL_INTERVAL_MS = 300;
1349
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
+
1350
1358
  function delay(ms: number): Promise<void> {
1351
1359
  return new Promise((resolve) => setTimeout(resolve, ms));
1352
1360
  }
@@ -1478,6 +1486,126 @@ function startPendingApprovalPromptWatcher(params: {
1478
1486
  };
1479
1487
  }
1480
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
+
1481
1609
  function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
1482
1610
  const {
1483
1611
  processMessage,
@@ -1520,6 +1648,18 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1520
1648
  approvalCopyGenerator,
1521
1649
  })
1522
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;
1523
1663
 
1524
1664
  try {
1525
1665
  const cmdIntent = commandIntent && typeof commandIntent.type === 'string'
@@ -1537,7 +1677,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1537
1677
  },
1538
1678
  assistantId,
1539
1679
  guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
1540
- isInteractive: guardianCtx.trustClass === 'guardian',
1680
+ isInteractive: resolveRoutingState(guardianCtx).promptWaitingAllowed,
1541
1681
  ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
1542
1682
  },
1543
1683
  sourceChannel,
@@ -1565,6 +1705,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1565
1705
  } finally {
1566
1706
  stopTypingHeartbeat?.();
1567
1707
  stopApprovalWatcher?.();
1708
+ stopTcApprovalNotifier?.();
1568
1709
  }
1569
1710
  })();
1570
1711
  }
@@ -144,16 +144,15 @@ export function handleClearSlackChannelConfig(): Response {
144
144
  /**
145
145
  * POST /v1/integrations/guardian/challenge
146
146
  *
147
- * Body: { channel?: ChannelId; assistantId?: string; rebind?: boolean; sessionId?: string }
147
+ * Body: { channel?: ChannelId; rebind?: boolean; sessionId?: string }
148
148
  */
149
149
  export async function handleCreateGuardianChallenge(req: Request): Promise<Response> {
150
150
  const body = (await req.json()) as {
151
151
  channel?: ChannelId;
152
- assistantId?: string;
153
152
  rebind?: boolean;
154
153
  sessionId?: string;
155
154
  };
156
- const result = createGuardianChallenge(body.channel, body.assistantId, body.rebind, body.sessionId);
155
+ const result = createGuardianChallenge(body.channel, body.rebind, body.sessionId);
157
156
  const status = result.success ? 200 : 400;
158
157
  return Response.json(result, { status });
159
158
  }
@@ -161,12 +160,11 @@ export async function handleCreateGuardianChallenge(req: Request): Promise<Respo
161
160
  /**
162
161
  * GET /v1/integrations/guardian/status
163
162
  *
164
- * Query params: channel?, assistantId?
163
+ * Query params: channel?
165
164
  */
166
165
  export function handleGetGuardianStatus(url: URL): Response {
167
166
  const channel = (url.searchParams.get('channel') as ChannelId | null) ?? undefined;
168
- const assistantId = url.searchParams.get('assistantId') ?? undefined;
169
- const result = getGuardianStatus(channel, assistantId);
167
+ const result = getGuardianStatus(channel);
170
168
  return Response.json(result);
171
169
  }
172
170
 
@@ -177,13 +175,12 @@ export function handleGetGuardianStatus(url: URL): Response {
177
175
  /**
178
176
  * POST /v1/integrations/guardian/outbound/start
179
177
  *
180
- * Body: { channel: ChannelId; destination?: string; assistantId?: string; rebind?: boolean; originConversationId?: string }
178
+ * Body: { channel: ChannelId; destination?: string; rebind?: boolean; originConversationId?: string }
181
179
  */
182
180
  export async function handleStartOutbound(req: Request): Promise<Response> {
183
181
  const body = (await req.json()) as {
184
182
  channel?: ChannelId;
185
183
  destination?: string;
186
- assistantId?: string;
187
184
  rebind?: boolean;
188
185
  originConversationId?: string;
189
186
  };
@@ -209,7 +206,6 @@ export async function handleStartOutbound(req: Request): Promise<Response> {
209
206
  const result = startOutbound({
210
207
  channel: body.channel,
211
208
  destination: body.destination,
212
- assistantId: body.assistantId,
213
209
  rebind: body.rebind,
214
210
  originConversationId: body.originConversationId,
215
211
  });
@@ -225,12 +221,11 @@ export async function handleStartOutbound(req: Request): Promise<Response> {
225
221
  /**
226
222
  * POST /v1/integrations/guardian/outbound/resend
227
223
  *
228
- * Body: { channel: ChannelId; assistantId?: string; originConversationId?: string }
224
+ * Body: { channel: ChannelId; originConversationId?: string }
229
225
  */
230
226
  export async function handleResendOutbound(req: Request): Promise<Response> {
231
227
  const body = (await req.json()) as {
232
228
  channel?: ChannelId;
233
- assistantId?: string;
234
229
  originConversationId?: string;
235
230
  };
236
231
  if (!body.channel) {
@@ -238,7 +233,6 @@ export async function handleResendOutbound(req: Request): Promise<Response> {
238
233
  }
239
234
  const result = resendOutbound({
240
235
  channel: body.channel,
241
- assistantId: body.assistantId,
242
236
  originConversationId: body.originConversationId,
243
237
  });
244
238
  const status = result.success ? 200 : (result.error === 'rate_limited' ? 429 : 400);
@@ -248,19 +242,17 @@ export async function handleResendOutbound(req: Request): Promise<Response> {
248
242
  /**
249
243
  * POST /v1/integrations/guardian/outbound/cancel
250
244
  *
251
- * Body: { channel: ChannelId; assistantId?: string }
245
+ * Body: { channel: ChannelId }
252
246
  */
253
247
  export async function handleCancelOutbound(req: Request): Promise<Response> {
254
248
  const body = (await req.json()) as {
255
249
  channel?: ChannelId;
256
- assistantId?: string;
257
250
  };
258
251
  if (!body.channel) {
259
252
  return httpError('BAD_REQUEST', 'The "channel" field is required.', 400);
260
253
  }
261
254
  const result = cancelOutbound({
262
255
  channel: body.channel,
263
- assistantId: body.assistantId,
264
256
  });
265
257
  const status = result.success ? 200 : 400;
266
258
  return Response.json(result, { status });
@@ -10,10 +10,127 @@ import {
10
10
  import type { ServerMessage } from '../../daemon/ipc-contract.js';
11
11
  import { PairingStore } from '../../daemon/pairing-store.js';
12
12
  import { getLogger } from '../../util/logger.js';
13
+ import { mintActorToken } from '../actor-token-service.js';
14
+ import {
15
+ createActorTokenRecord,
16
+ revokeByDeviceBinding,
17
+ } from '../actor-token-store.js';
18
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
19
+ import { ensureVellumGuardianBinding } from '../guardian-vellum-migration.js';
13
20
  import { httpError } from '../http-errors.js';
14
21
 
15
22
  const log = getLogger('runtime-http');
16
23
 
24
+ /**
25
+ * Mint an actor token for a paired device if a vellum guardian principal exists.
26
+ * Returns the raw actor token string, or null if no vellum binding exists.
27
+ *
28
+ * NOTE: This function MUST remain synchronous — the mintingInFlight guard depends on it.
29
+ */
30
+ function mintPairingActorToken(deviceId: string, platform: string): string | null {
31
+ try {
32
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
33
+ // Pairing can run before a local client has touched the actor-token
34
+ // bootstrap path. Ensure the vellum guardian principal exists so iOS
35
+ // pairings always have a mint target.
36
+ const guardianPrincipalId = ensureVellumGuardianBinding(assistantId);
37
+ const hashedDeviceId = hashDeviceId(deviceId);
38
+
39
+ // Revoke previous tokens for this device
40
+ revokeByDeviceBinding(assistantId, guardianPrincipalId, hashedDeviceId);
41
+
42
+ const { token, tokenHash, claims } = mintActorToken({
43
+ assistantId,
44
+ platform,
45
+ deviceId,
46
+ guardianPrincipalId,
47
+ });
48
+
49
+ createActorTokenRecord({
50
+ tokenHash,
51
+ assistantId,
52
+ guardianPrincipalId,
53
+ hashedDeviceId,
54
+ platform,
55
+ issuedAt: claims.iat,
56
+ expiresAt: claims.exp,
57
+ });
58
+
59
+ log.info({ assistantId, platform }, 'Minted actor token during pairing');
60
+ return token;
61
+ } catch (err) {
62
+ log.warn({ err }, 'Failed to mint actor token during pairing — continuing without it');
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Transient in-memory map of pairingRequestId -> { deviceId, createdAt }.
69
+ * Stored when a pairing request is initiated (we have the raw deviceId)
70
+ * so the token can be minted later when the pairing is actually approved.
71
+ * Entries include a timestamp so stale entries can be swept if the
72
+ * corresponding pairing expires without an explicit deny.
73
+ */
74
+ const PENDING_DEVICE_ID_TTL_MS = 10 * 60 * 1000; // 10 minutes
75
+ const pendingDeviceIds = new Map<string, { deviceId: string; createdAt: number }>();
76
+
77
+ /**
78
+ * Transient in-memory map of pairingRequestId -> { actorToken, approvedAt }.
79
+ * Populated when a pairing is approved and the actor token is minted.
80
+ * Entries are kept for TOKEN_RETRIEVAL_TTL_MS after approval so that
81
+ * subsequent polls can still retrieve the token if the first response
82
+ * was dropped or timed out.
83
+ */
84
+ const TOKEN_RETRIEVAL_TTL_MS = 5 * 60 * 1000; // 5 minutes
85
+ const approvedActorTokens = new Map<string, { actorToken: string; approvedAt: number }>();
86
+
87
+ /**
88
+ * Sweep stale entries from the approved actor tokens map.
89
+ * Called lazily on each status poll.
90
+ */
91
+ function sweepApprovedTokens(): void {
92
+ const now = Date.now();
93
+ for (const [id, entry] of approvedActorTokens) {
94
+ if (now - entry.approvedAt > TOKEN_RETRIEVAL_TTL_MS) {
95
+ approvedActorTokens.delete(id);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Sweep stale entries from the pending device IDs map.
102
+ * Entries older than PENDING_DEVICE_ID_TTL_MS are removed to prevent
103
+ * unbounded accumulation of raw device identifiers when pairings expire
104
+ * without an explicit deny.
105
+ */
106
+ function sweepPendingDeviceIds(): void {
107
+ const now = Date.now();
108
+ for (const [id, entry] of pendingDeviceIds) {
109
+ if (now - entry.createdAt > PENDING_DEVICE_ID_TTL_MS) {
110
+ pendingDeviceIds.delete(id);
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * In-flight mint guard — prevents overlapping status polls from triggering
117
+ * concurrent token mints for the same pairing request. The second mint
118
+ * would revoke the first token, leaving the client with an invalid token.
119
+ *
120
+ * MUST remain synchronous — async would break this concurrency guard.
121
+ */
122
+ const mintingInFlight = new Set<string>();
123
+
124
+ /**
125
+ * Clean up all transient pairing state for a given request.
126
+ * Called when pairing is denied or otherwise finalized.
127
+ */
128
+ export function cleanupPairingState(pairingRequestId: string): void {
129
+ pendingDeviceIds.delete(pairingRequestId);
130
+ approvedActorTokens.delete(pairingRequestId);
131
+ mintingInFlight.delete(pairingRequestId);
132
+ }
133
+
17
134
  export interface PairingHandlerContext {
18
135
  pairingStore: PairingStore;
19
136
  bearerToken: string | undefined;
@@ -90,15 +207,22 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
90
207
  refreshDevice(hashedDeviceId, deviceName);
91
208
  ctx.pairingStore.approve(pairingRequestId, ctx.bearerToken);
92
209
  log.info({ pairingRequestId, hashedDeviceId }, 'Auto-approved allowlisted device');
210
+ const actorToken = mintPairingActorToken(deviceId, 'ios');
93
211
  return Response.json({
94
212
  status: 'approved',
95
213
  bearerToken: ctx.bearerToken,
96
214
  gatewayUrl: entry.gatewayUrl,
97
215
  localLanUrl: entry.localLanUrl,
98
216
  ...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
217
+ ...(actorToken ? { actorToken } : {}),
99
218
  });
100
219
  }
101
220
 
221
+ // Store the raw deviceId transiently so we can mint the actor token
222
+ // later when the pairing is actually approved (avoids revoking existing
223
+ // tokens and creating DB records for unapproved devices).
224
+ pendingDeviceIds.set(pairingRequestId, { deviceId, createdAt: Date.now() });
225
+
102
226
  // Send IPC to macOS to show approval prompt
103
227
  if (ctx.pairingBroadcast) {
104
228
  ctx.pairingBroadcast({
@@ -124,6 +248,7 @@ export function handlePairingStatus(url: URL, ctx: PairingHandlerContext): Respo
124
248
  const id = url.searchParams.get('id') ?? '';
125
249
  // Note: secret is redacted from logs
126
250
  const secret = url.searchParams.get('secret') ?? '';
251
+ const deviceId = (url.searchParams.get('deviceId') ?? '').trim();
127
252
 
128
253
  if (!id || !secret) {
129
254
  return httpError('BAD_REQUEST', 'Missing required params: id, secret', 400);
@@ -133,18 +258,56 @@ export function handlePairingStatus(url: URL, ctx: PairingHandlerContext): Respo
133
258
  return httpError('FORBIDDEN', 'Forbidden', 403);
134
259
  }
135
260
 
261
+ // Sweep stale transient entries on every poll — not just approved ones —
262
+ // so abandoned pairing attempts don't accumulate indefinitely.
263
+ sweepApprovedTokens();
264
+ sweepPendingDeviceIds();
265
+
136
266
  const entry = ctx.pairingStore.get(id);
137
267
  if (!entry) {
268
+ // Pairing expired or was swept — clean up any lingering pending device ID
269
+ pendingDeviceIds.delete(id);
138
270
  return httpError('NOT_FOUND', 'Not found', 404);
139
271
  }
140
272
 
141
273
  if (entry.status === 'approved') {
274
+ // Mint the actor token on first approved poll if we still have the
275
+ // raw deviceId from the pairing request. Once minted, the token is
276
+ // cached in approvedActorTokens with a TTL so subsequent polls can
277
+ // still retrieve it if the first response was dropped.
278
+ // The pending deviceId is only removed after a successful mint so
279
+ // transient failures allow retries on subsequent polls.
280
+ let tokenEntry = approvedActorTokens.get(id);
281
+ if (!tokenEntry && !mintingInFlight.has(id)) {
282
+ const pending = pendingDeviceIds.get(id);
283
+ const deviceIdMatchesEntry = Boolean(
284
+ deviceId
285
+ && entry.hashedDeviceId
286
+ && hashDeviceId(deviceId) === entry.hashedDeviceId,
287
+ );
288
+ const mintDeviceId = pending?.deviceId ?? (deviceIdMatchesEntry ? deviceId : undefined);
289
+ if (mintDeviceId) {
290
+ mintingInFlight.add(id);
291
+ try {
292
+ const actorToken = mintPairingActorToken(mintDeviceId, 'ios');
293
+ if (actorToken) {
294
+ pendingDeviceIds.delete(id);
295
+ tokenEntry = { actorToken, approvedAt: Date.now() };
296
+ approvedActorTokens.set(id, tokenEntry);
297
+ }
298
+ } finally {
299
+ mintingInFlight.delete(id);
300
+ }
301
+ }
302
+ }
303
+
142
304
  return Response.json({
143
305
  status: 'approved',
144
306
  bearerToken: entry.bearerToken,
145
307
  gatewayUrl: entry.gatewayUrl,
146
308
  localLanUrl: entry.localLanUrl,
147
309
  ...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
310
+ ...(tokenEntry ? { actorToken: tokenEntry.actorToken } : {}),
148
311
  });
149
312
  }
150
313