@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
@@ -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