@vellumai/assistant 0.4.1 → 0.4.3

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 (97) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/bun.lock +0 -83
  3. package/docs/trusted-contact-access.md +20 -0
  4. package/package.json +2 -3
  5. package/src/__tests__/access-request-decision.test.ts +0 -1
  6. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  7. package/src/__tests__/call-routes-http.test.ts +0 -25
  8. package/src/__tests__/channel-approval-routes.test.ts +55 -5
  9. package/src/__tests__/channel-guardian.test.ts +6 -5
  10. package/src/__tests__/config-schema.test.ts +2 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +54 -1
  12. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  13. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  14. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
  15. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  16. package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
  17. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
  18. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  19. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  20. package/src/__tests__/non-member-access-request.test.ts +28 -1
  21. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  22. package/src/__tests__/relay-server.test.ts +644 -4
  23. package/src/__tests__/send-endpoint-busy.test.ts +129 -3
  24. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  25. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  26. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  27. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  28. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  29. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  30. package/src/__tests__/twilio-routes.test.ts +4 -3
  31. package/src/__tests__/update-bulletin.test.ts +0 -1
  32. package/src/approvals/guardian-decision-primitive.ts +24 -2
  33. package/src/approvals/guardian-request-resolvers.ts +42 -3
  34. package/src/calls/call-constants.ts +8 -0
  35. package/src/calls/call-controller.ts +2 -1
  36. package/src/calls/call-domain.ts +5 -4
  37. package/src/calls/relay-server.ts +513 -116
  38. package/src/calls/twilio-routes.ts +3 -5
  39. package/src/calls/types.ts +1 -1
  40. package/src/calls/voice-session-bridge.ts +4 -3
  41. package/src/cli/core-commands.ts +7 -4
  42. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  43. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  44. package/src/config/calls-schema.ts +12 -0
  45. package/src/config/feature-flag-registry.json +0 -8
  46. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  47. package/src/daemon/handlers/config-channels.ts +5 -7
  48. package/src/daemon/handlers/config-inbox.ts +2 -0
  49. package/src/daemon/handlers/index.ts +2 -1
  50. package/src/daemon/handlers/publish.ts +11 -46
  51. package/src/daemon/handlers/sessions.ts +136 -13
  52. package/src/daemon/ipc-contract/apps.ts +1 -0
  53. package/src/daemon/ipc-contract/inbox.ts +4 -0
  54. package/src/daemon/ipc-contract/integrations.ts +3 -1
  55. package/src/daemon/server.ts +19 -3
  56. package/src/daemon/session-agent-loop.ts +35 -23
  57. package/src/daemon/session-runtime-assembly.ts +3 -1
  58. package/src/daemon/session-surfaces.ts +29 -1
  59. package/src/memory/app-store.ts +6 -0
  60. package/src/memory/conversation-crud.ts +2 -1
  61. package/src/memory/conversation-title-service.ts +16 -2
  62. package/src/memory/db-init.ts +4 -0
  63. package/src/memory/delivery-crud.ts +2 -1
  64. package/src/memory/embedding-local.ts +25 -13
  65. package/src/memory/embedding-runtime-manager.ts +24 -6
  66. package/src/memory/guardian-action-store.ts +2 -1
  67. package/src/memory/guardian-approvals.ts +3 -2
  68. package/src/memory/ingress-invite-store.ts +12 -2
  69. package/src/memory/ingress-member-store.ts +4 -3
  70. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  71. package/src/memory/migrations/index.ts +1 -0
  72. package/src/memory/schema.ts +10 -5
  73. package/src/notifications/copy-composer.ts +11 -1
  74. package/src/notifications/emit-signal.ts +2 -1
  75. package/src/runtime/access-request-helper.ts +11 -3
  76. package/src/runtime/actor-trust-resolver.ts +2 -2
  77. package/src/runtime/assistant-scope.ts +10 -0
  78. package/src/runtime/guardian-context-resolver.ts +5 -1
  79. package/src/runtime/guardian-outbound-actions.ts +5 -4
  80. package/src/runtime/guardian-reply-router.ts +12 -0
  81. package/src/runtime/http-server.ts +12 -20
  82. package/src/runtime/ingress-service.ts +14 -0
  83. package/src/runtime/invite-redemption-service.ts +2 -1
  84. package/src/runtime/middleware/twilio-validation.ts +2 -4
  85. package/src/runtime/routes/call-routes.ts +2 -1
  86. package/src/runtime/routes/channel-route-shared.ts +3 -3
  87. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  88. package/src/runtime/routes/conversation-routes.ts +33 -11
  89. package/src/runtime/routes/events-routes.ts +2 -3
  90. package/src/runtime/routes/inbound-conversation.ts +4 -3
  91. package/src/runtime/routes/inbound-message-handler.ts +16 -4
  92. package/src/runtime/routes/ingress-routes.ts +2 -0
  93. package/src/tools/apps/executors.ts +15 -0
  94. package/src/tools/calls/call-start.ts +2 -1
  95. package/src/tools/terminal/parser.ts +12 -0
  96. package/src/tools/tool-approval-handler.ts +2 -1
  97. package/src/workspace/git-service.ts +19 -0
@@ -40,6 +40,7 @@ import { consumeCallback, consumeCallbackError } from '../security/oauth-callbac
40
40
  import { getLogger } from '../util/logger.js';
41
41
  import { buildAssistantEvent } from './assistant-event.js';
42
42
  import { assistantEventHub } from './assistant-event-hub.js';
43
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
43
44
  import { sweepFailedEvents } from './channel-retry-sweep.js';
44
45
  import { httpError } from './http-errors.js';
45
46
  // Middleware
@@ -97,7 +98,6 @@ import {
97
98
  startCanonicalGuardianExpirySweep,
98
99
  stopCanonicalGuardianExpirySweep,
99
100
  } from './routes/canonical-guardian-expiry-sweep.js';
100
- import { canonicalChannelAssistantId } from './routes/channel-route-shared.js';
101
101
  import {
102
102
  handleChannelDeliveryAck,
103
103
  handleChannelInbound,
@@ -270,7 +270,7 @@ export class RuntimeHttpServer {
270
270
  ipcBroadcast(msg);
271
271
  // Also publish to the event hub so HTTP/SSE clients (e.g. macOS
272
272
  // app with localHttpEnabled) receive pairing approval requests.
273
- void assistantEventHub.publish(buildAssistantEvent('self', msg));
273
+ void assistantEventHub.publish(buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, msg));
274
274
  }
275
275
  : undefined,
276
276
  };
@@ -521,22 +521,13 @@ export class RuntimeHttpServer {
521
521
  }
522
522
  }
523
523
 
524
- // New assistant-less runtime routes: /v1/<endpoint>
525
- const newRouteMatch = path.match(/^\/v1\/(?!assistants\/)(.+)$/);
526
- if (newRouteMatch) {
527
- return this.dispatchEndpoint(newRouteMatch[1], req, url);
524
+ // Runtime routes: /v1/<endpoint>
525
+ const routeMatch = path.match(/^\/v1\/(.+)$/);
526
+ if (routeMatch) {
527
+ return this.dispatchEndpoint(routeMatch[1], req, url);
528
528
  }
529
529
 
530
- // Legacy: /v1/assistants/:assistantId/<endpoint>
531
- const match = path.match(/^\/v1\/assistants\/([^/]+)\/(.+)$/);
532
- if (!match) {
533
- return httpError('NOT_FOUND', 'Not found', 404);
534
- }
535
-
536
- const assistantId = canonicalChannelAssistantId(match[1]);
537
- const endpoint = match[2];
538
- log.warn({ endpoint, assistantId }, '[deprecated] /v1/assistants/:assistantId/... route used; migrate to /v1/...');
539
- return this.dispatchEndpoint(endpoint, req, url, assistantId);
530
+ return httpError('NOT_FOUND', 'Not found', 404);
540
531
  }
541
532
 
542
533
  private handleBrowserRelayUpgrade(req: Request, server: ReturnType<typeof Bun.serve>): Response {
@@ -616,8 +607,8 @@ export class RuntimeHttpServer {
616
607
  endpoint: string,
617
608
  req: Request,
618
609
  url: URL,
619
- assistantId: string = 'self',
620
610
  ): Promise<Response> {
611
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
621
612
  return withErrorHandling(endpoint, async () => {
622
613
  if (endpoint === 'health' && req.method === 'GET') return handleHealth();
623
614
  if (endpoint === 'debug' && req.method === 'GET') return handleDebug();
@@ -690,7 +681,7 @@ export class RuntimeHttpServer {
690
681
  try {
691
682
  recordConversationSeenSignal({
692
683
  conversationId,
693
- assistantId: 'self',
684
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
694
685
  sourceChannel: (body.sourceChannel as string) ?? 'vellum',
695
686
  signalType: (body.signalType as string ?? 'macos_conversation_opened') as SignalType,
696
687
  confidence: (body.confidence as string ?? 'explicit') as Confidence,
@@ -714,6 +705,7 @@ export class RuntimeHttpServer {
714
705
  processMessage: this.processMessage,
715
706
  persistAndProcessMessage: this.persistAndProcessMessage,
716
707
  sendMessageDeps: this.sendMessageDeps,
708
+ approvalConversationGenerator: this.approvalConversationGenerator,
717
709
  });
718
710
  }
719
711
 
@@ -812,11 +804,11 @@ export class RuntimeHttpServer {
812
804
 
813
805
  // Internal Twilio forwarding endpoints (gateway -> runtime)
814
806
  if (endpoint === 'internal/twilio/voice-webhook' && req.method === 'POST') {
815
- const json = await req.json() as { params: Record<string, string>; originalUrl?: string; assistantId?: string };
807
+ const json = await req.json() as { params: Record<string, string>; originalUrl?: string };
816
808
  const formBody = new URLSearchParams(json.params).toString();
817
809
  const reconstructedUrl = json.originalUrl ?? req.url;
818
810
  const fakeReq = new Request(reconstructedUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody });
819
- return await handleVoiceWebhook(fakeReq, json.assistantId);
811
+ return await handleVoiceWebhook(fakeReq);
820
812
  }
821
813
 
822
814
  if (endpoint === 'internal/twilio/status' && req.method === 'POST') {
@@ -57,6 +57,8 @@ export interface InviteResponseData {
57
57
  expectedExternalUserId?: string;
58
58
  voiceCode?: string;
59
59
  voiceCodeDigits?: number;
60
+ friendName?: string;
61
+ guardianName?: string;
60
62
  createdAt: number;
61
63
  }
62
64
 
@@ -110,6 +112,8 @@ function inviteToResponse(inv: IngressInvite, opts?: { rawToken?: string; voiceC
110
112
  ...(inv.expectedExternalUserId ? { expectedExternalUserId: inv.expectedExternalUserId } : {}),
111
113
  ...(opts?.voiceCode ? { voiceCode: opts.voiceCode } : {}),
112
114
  ...(inv.voiceCodeDigits != null ? { voiceCodeDigits: inv.voiceCodeDigits } : {}),
115
+ ...(inv.friendName ? { friendName: inv.friendName } : {}),
116
+ ...(inv.guardianName ? { guardianName: inv.guardianName } : {}),
113
117
  createdAt: inv.createdAt,
114
118
  };
115
119
  }
@@ -149,6 +153,8 @@ export function createIngressInvite(params: {
149
153
  // Voice invite parameters
150
154
  expectedExternalUserId?: string;
151
155
  voiceCodeDigits?: number;
156
+ friendName?: string;
157
+ guardianName?: string;
152
158
  }): IngressResult<InviteResponseData> {
153
159
  if (!params.sourceChannel) {
154
160
  return { ok: false, error: 'sourceChannel is required for create' };
@@ -168,6 +174,12 @@ export function createIngressInvite(params: {
168
174
  if (!isValidE164(params.expectedExternalUserId)) {
169
175
  return { ok: false, error: 'expectedExternalUserId must be in E.164 format (e.g., +15551234567)' };
170
176
  }
177
+ if (typeof params.friendName !== 'string' || !params.friendName.trim()) {
178
+ return { ok: false, error: 'friendName is required for voice invites' };
179
+ }
180
+ if (typeof params.guardianName !== 'string' || !params.guardianName.trim()) {
181
+ return { ok: false, error: 'guardianName is required for voice invites' };
182
+ }
171
183
  voiceCode = generateVoiceCode(6);
172
184
  voiceCodeHash = hashVoiceCode(voiceCode);
173
185
  }
@@ -181,6 +193,8 @@ export function createIngressInvite(params: {
181
193
  expectedExternalUserId: params.expectedExternalUserId,
182
194
  voiceCodeHash,
183
195
  voiceCodeDigits: 6,
196
+ friendName: params.friendName,
197
+ guardianName: params.guardianName,
184
198
  } : {}),
185
199
  });
186
200
  // Voice invites must not expose the token — callers must redeem via the
@@ -13,6 +13,7 @@ import { findActiveVoiceInvites,findByTokenHash, hashToken, markInviteExpired, r
13
13
  import { findMember, upsertMember } from '../memory/ingress-member-store.js';
14
14
  import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
15
15
  import { hashVoiceCode } from '../util/voice-code.js';
16
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Outcome type
@@ -223,7 +224,7 @@ export function redeemVoiceInviteCode(params: {
223
224
  sourceChannel: 'voice';
224
225
  code: string;
225
226
  }): VoiceRedemptionOutcome {
226
- const { assistantId = 'self', callerExternalUserId, code } = params;
227
+ const { assistantId = DAEMON_INTERNAL_ASSISTANT_ID, callerExternalUserId, code } = params;
227
228
 
228
229
  if (!callerExternalUserId) {
229
230
  return { ok: false, reason: 'invalid_or_expired' };
@@ -12,12 +12,10 @@ import { httpError } from '../http-errors.js';
12
12
  const log = getLogger('runtime-http');
13
13
 
14
14
  /**
15
- * Regex to extract the Twilio webhook subpath from both top-level and
16
- * assistant-scoped route shapes:
15
+ * Regex to extract the Twilio webhook subpath:
17
16
  * /v1/calls/twilio/<subpath>
18
- * /v1/assistants/<id>/calls/twilio/<subpath>
19
17
  */
20
- export const TWILIO_WEBHOOK_RE = /^\/v1\/(?:assistants\/[^/]+\/)?calls\/twilio\/(.+)$/;
18
+ export const TWILIO_WEBHOOK_RE = /^\/v1\/calls\/twilio\/(.+)$/;
21
19
 
22
20
  /**
23
21
  * Gateway-compatible Twilio webhook paths:
@@ -11,6 +11,7 @@
11
11
  import { answerCall, cancelCall, getCallStatus, relayInstruction,startCall } from '../../calls/call-domain.js';
12
12
  import { getConfig } from '../../config/loader.js';
13
13
  import { VALID_CALLER_IDENTITY_MODES } from '../../config/schema.js';
14
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
14
15
  import { httpError, httpErrorCodeFromStatus } from '../http-errors.js';
15
16
 
16
17
  // ── Idempotency cache ─────────────────────────────────────────────────────────
@@ -41,7 +42,7 @@ function pruneIdempotencyCache(): void {
41
42
  * Optional `idempotencyKey`: if supplied, duplicate requests with the same key
42
43
  * within 5 minutes return the cached 201 response without starting a second call.
43
44
  */
44
- export async function handleStartCall(req: Request, assistantId: string = 'self'): Promise<Response> {
45
+ export async function handleStartCall(req: Request, assistantId: string = DAEMON_INTERNAL_ASSISTANT_ID): Promise<Response> {
45
46
  if (!getConfig().calls.enabled) {
46
47
  return httpError('FORBIDDEN', 'Calls feature is disabled via configuration. Set calls.enabled to true to use this feature.', 403);
47
48
  }
@@ -4,7 +4,7 @@
4
4
  import { timingSafeEqual } from 'node:crypto';
5
5
 
6
6
  import type { ChannelId } from '../../channels/types.js';
7
- import { normalizeAssistantId } from '../../util/platform.js';
7
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
8
8
  import type {
9
9
  ApprovalAction,
10
10
  ApprovalDecisionResult,
@@ -15,8 +15,8 @@ export type { ActorTrustClass, DenialReason, GuardianContext } from '../guardian
15
15
  export { toGuardianRuntimeContext } from '../guardian-context-resolver.js';
16
16
 
17
17
  /** Canonicalize assistantId for channel ingress paths. */
18
- export function canonicalChannelAssistantId(assistantId: string): string {
19
- return normalizeAssistantId(assistantId);
18
+ export function canonicalChannelAssistantId(_assistantId: string): string {
19
+ return DAEMON_INTERNAL_ASSISTANT_ID;
20
20
  }
21
21
 
22
22
  // ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ import {
10
10
  } from '../../memory/conversation-attention-store.js';
11
11
  import * as conversationStore from '../../memory/conversation-store.js';
12
12
  import { truncate } from '../../util/truncate.js';
13
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
13
14
  import { httpError } from '../http-errors.js';
14
15
 
15
16
  export function handleListConversationAttention(url: URL): Response {
@@ -27,7 +28,7 @@ export function handleListConversationAttention(url: URL): Response {
27
28
  }
28
29
 
29
30
  const attentionStates = listConversationAttention({
30
- assistantId: 'self',
31
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
31
32
  state: stateParam as AttentionFilterState,
32
33
  sourceChannel: channel,
33
34
  source: sourceParam !== 'all' ? sourceParam : undefined,
@@ -24,9 +24,11 @@ import { getConfiguredProvider } from '../../providers/provider-send-message.js'
24
24
  import type { Provider } from '../../providers/types.js';
25
25
  import { getLogger } from '../../util/logger.js';
26
26
  import { buildAssistantEvent } from '../assistant-event.js';
27
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
27
28
  import { routeGuardianReply } from '../guardian-reply-router.js';
28
29
  import { httpError } from '../http-errors.js';
29
30
  import type {
31
+ ApprovalConversationGenerator,
30
32
  MessageProcessor,
31
33
  NonBlockingMessageProcessor,
32
34
  RuntimeAttachmentMetadata,
@@ -87,6 +89,7 @@ async function tryConsumeInlineApprovalReply(params: {
87
89
  }>;
88
90
  session: import('../../daemon/session.js').Session;
89
91
  onEvent: (msg: ServerMessage) => void;
92
+ approvalConversationGenerator?: ApprovalConversationGenerator;
90
93
  }): Promise<{ consumed: boolean; messageId?: string }> {
91
94
  const {
92
95
  conversationId,
@@ -96,15 +99,16 @@ async function tryConsumeInlineApprovalReply(params: {
96
99
  attachments,
97
100
  session,
98
101
  onEvent,
102
+ approvalConversationGenerator,
99
103
  } = params;
100
104
  const trimmedContent = content.trim();
101
105
 
102
- // Only consume inline replies when there are no queued turns, matching
103
- // the IPC path guard. With queued messages, "approve"/"no" should be
104
- // processed in queue order rather than treated as a confirmation reply.
106
+ // Try inline approval interception whenever a pending confirmation exists.
107
+ // We intentionally do not block on queue depth: after an auto-deny, users
108
+ // often retry with "approve"/"yes" while the queue is still draining, and
109
+ // requiring an empty queue can create a deny/retry cascade.
105
110
  if (
106
111
  !session.hasAnyPendingConfirmation()
107
- || session.getQueueDepth() > 0
108
112
  || trimmedContent.length === 0
109
113
  ) {
110
114
  return { consumed: false };
@@ -125,6 +129,7 @@ async function tryConsumeInlineApprovalReply(params: {
125
129
  },
126
130
  conversationId,
127
131
  pendingRequestIds,
132
+ approvalConversationGenerator,
128
133
  });
129
134
 
130
135
  if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
@@ -176,6 +181,16 @@ async function tryConsumeInlineApprovalReply(params: {
176
181
  return { consumed: true, messageId };
177
182
  }
178
183
 
184
+ function resolveCanonicalRequestSourceType(sourceChannel: string | undefined): 'desktop' | 'channel' | 'voice' {
185
+ if (sourceChannel === 'voice') {
186
+ return 'voice';
187
+ }
188
+ if (sourceChannel === 'vellum') {
189
+ return 'desktop';
190
+ }
191
+ return 'channel';
192
+ }
193
+
179
194
  function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path: string; mtimeMs: number }> {
180
195
  if (!interfacesDir || !existsSync(interfacesDir)) return [];
181
196
  const results: Array<{ path: string; mtimeMs: number }> = [];
@@ -319,12 +334,17 @@ function makeHubPublisher(
319
334
 
320
335
  // Create a canonical guardian request so IPC/HTTP handlers can find it
321
336
  // via applyCanonicalGuardianDecision.
337
+ const guardianContext = session.guardianContext;
338
+ const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
322
339
  createCanonicalGuardianRequest({
323
340
  id: msg.requestId,
324
341
  kind: 'tool_approval',
325
- sourceType: 'desktop',
326
- sourceChannel: 'vellum',
342
+ sourceType: resolveCanonicalRequestSourceType(sourceChannel),
343
+ sourceChannel,
327
344
  conversationId,
345
+ requesterExternalUserId: guardianContext?.requesterExternalUserId,
346
+ requesterChatId: guardianContext?.requesterChatId,
347
+ guardianExternalUserId: guardianContext?.guardianExternalUserId,
328
348
  toolName: msg.toolName,
329
349
  status: 'pending',
330
350
  requestCode: generateCanonicalRequestCode(),
@@ -344,7 +364,7 @@ function makeHubPublisher(
344
364
  ? (msg as { sessionId: string }).sessionId
345
365
  : undefined;
346
366
  const resolvedSessionId = msgSessionId ?? conversationId;
347
- const event = buildAssistantEvent('self', msg, resolvedSessionId);
367
+ const event = buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, msg, resolvedSessionId);
348
368
  hubChain = (async () => {
349
369
  await hubChain;
350
370
  try {
@@ -362,6 +382,7 @@ export async function handleSendMessage(
362
382
  processMessage?: MessageProcessor;
363
383
  persistAndProcessMessage?: NonBlockingMessageProcessor;
364
384
  sendMessageDeps?: SendMessageDeps;
385
+ approvalConversationGenerator?: ApprovalConversationGenerator;
365
386
  },
366
387
  ): Promise<Response> {
367
388
  const body = await req.json() as {
@@ -440,10 +461,11 @@ export async function handleSendMessage(
440
461
  sourceChannel,
441
462
  sourceInterface,
442
463
  content: content ?? '',
443
- attachments,
444
- session,
445
- onEvent,
446
- });
464
+ attachments,
465
+ session,
466
+ onEvent,
467
+ approvalConversationGenerator: deps.approvalConversationGenerator,
468
+ });
447
469
  if (inlineReplyResult.consumed) {
448
470
  return Response.json(
449
471
  { accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
@@ -11,6 +11,7 @@ import { getOrCreateConversation } from '../../memory/conversation-key-store.js'
11
11
  import { formatSseFrame, formatSseHeartbeat } from '../assistant-event.js';
12
12
  import type { AssistantEventSubscription } from '../assistant-event-hub.js';
13
13
  import { AssistantEventHub,assistantEventHub } from '../assistant-event-hub.js';
14
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
14
15
  import { httpError } from '../http-errors.js';
15
16
 
16
17
  /** Keep-alive comment sent to idle clients every 30 s by default. */
@@ -50,8 +51,6 @@ export function handleSubscribeAssistantEvents(
50
51
  // closures are in place before events can arrive. `controllerRef` is set
51
52
  // synchronously inside ReadableStream's start(), so it is non-null by the
52
53
  // time any event or eviction fires.
53
- // 'self' is the assistantId used by buildAssistantEvent('self', ...) for
54
- // all HTTP and voice session events.
55
54
  let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
56
55
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
57
56
  let sub!: AssistantEventSubscription;
@@ -63,7 +62,7 @@ export function handleSubscribeAssistantEvents(
63
62
 
64
63
  try {
65
64
  sub = hub.subscribe(
66
- { assistantId: 'self', sessionId: mapping.conversationId },
65
+ { assistantId: DAEMON_INTERNAL_ASSISTANT_ID, sessionId: mapping.conversationId },
67
66
  (event) => {
68
67
  const controller = controllerRef;
69
68
  if (!controller) return;
@@ -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,
@@ -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,
@@ -580,7 +581,7 @@ export async function handleChannelInbound(
580
581
  // external_conversation_bindings is assistant-agnostic. Restrict writes to
581
582
  // self so assistant-scoped legacy routes do not overwrite each other's
582
583
  // channel binding metadata for the same chat.
583
- if (canonicalAssistantId === 'self') {
584
+ if (canonicalAssistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
584
585
  externalConversationStore.upsertBinding({
585
586
  conversationId: result.conversationId,
586
587
  sourceChannel,
@@ -1401,6 +1402,8 @@ function startPendingApprovalPromptWatcher(params: {
1401
1402
  sourceChannel: ChannelId;
1402
1403
  externalChatId: string;
1403
1404
  guardianTrustClass: GuardianContext['trustClass'];
1405
+ guardianExternalUserId?: string;
1406
+ requesterExternalUserId?: string;
1404
1407
  replyCallbackUrl: string;
1405
1408
  bearerToken?: string;
1406
1409
  assistantId?: string;
@@ -1411,6 +1414,8 @@ function startPendingApprovalPromptWatcher(params: {
1411
1414
  sourceChannel,
1412
1415
  externalChatId,
1413
1416
  guardianTrustClass,
1417
+ guardianExternalUserId,
1418
+ requesterExternalUserId,
1414
1419
  replyCallbackUrl,
1415
1420
  bearerToken,
1416
1421
  assistantId,
@@ -1419,7 +1424,12 @@ function startPendingApprovalPromptWatcher(params: {
1419
1424
 
1420
1425
  // Approval prompt delivery is guardian-only. Non-guardian and unverified
1421
1426
  // actors must never receive approval prompt broadcasts for the conversation.
1422
- if (guardianTrustClass !== 'guardian') {
1427
+ // We also require an explicit identity match against the bound guardian to
1428
+ // avoid broadcasting prompts when trustClass is stale/mis-scoped.
1429
+ const isBoundGuardianActor = guardianTrustClass === 'guardian'
1430
+ && !!guardianExternalUserId
1431
+ && requesterExternalUserId === guardianExternalUserId;
1432
+ if (!isBoundGuardianActor) {
1423
1433
  return () => {};
1424
1434
  }
1425
1435
 
@@ -1438,7 +1448,7 @@ function startPendingApprovalPromptWatcher(params: {
1438
1448
  replyCallbackUrl,
1439
1449
  chatId: externalChatId,
1440
1450
  sourceChannel,
1441
- assistantId: assistantId ?? 'self',
1451
+ assistantId: assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
1442
1452
  bearerToken,
1443
1453
  prompt,
1444
1454
  uiMetadata: buildApprovalUIMetadata(prompt, info),
@@ -1502,6 +1512,8 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1502
1512
  sourceChannel,
1503
1513
  externalChatId,
1504
1514
  guardianTrustClass: guardianCtx.trustClass,
1515
+ guardianExternalUserId: guardianCtx.guardianExternalUserId,
1516
+ requesterExternalUserId: guardianCtx.requesterExternalUserId,
1505
1517
  replyCallbackUrl,
1506
1518
  bearerToken,
1507
1519
  assistantId,
@@ -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) {
@@ -102,6 +102,21 @@ export async function executeAppCreate(
102
102
  const preview = input.preview;
103
103
  const appType = input.type === 'site' ? 'site' as const : 'app' as const;
104
104
 
105
+ // Validate required fields — LLM input is not type-checked at runtime
106
+ if (typeof name !== 'string' || name.trim() === '') {
107
+ return { content: JSON.stringify({ error: 'name is required and must be a non-empty string' }), isError: true };
108
+ }
109
+ if (typeof htmlDefinition !== 'string') {
110
+ return { content: JSON.stringify({ error: 'html is required and must be a string containing the HTML definition' }), isError: true };
111
+ }
112
+ if (pages) {
113
+ for (const [filename, content] of Object.entries(pages)) {
114
+ if (typeof content !== 'string') {
115
+ return { content: JSON.stringify({ error: `pages["${filename}"] must be a string, got ${typeof content}` }), isError: true };
116
+ }
117
+ }
118
+ }
119
+
105
120
  const app = store.createApp({ name, description, schemaJson, htmlDefinition, pages, appType });
106
121
 
107
122
  if (input.set_as_home_base) {
@@ -1,5 +1,6 @@
1
1
  import { startCall } from '../../calls/call-domain.js';
2
2
  import { getConfig } from '../../config/loader.js';
3
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../runtime/assistant-scope.js';
3
4
  import { findActiveSession } from '../../runtime/channel-guardian-service.js';
4
5
  import { normalizePhoneNumber } from '../../util/phone.js';
5
6
  import type { ToolContext, ToolExecutionResult } from '../types.js';
@@ -16,7 +17,7 @@ export async function executeCallStart(
16
17
  ? normalizePhoneNumber(input.phone_number)
17
18
  : null;
18
19
  if (requestedPhone) {
19
- const assistantId = context.assistantId ?? 'self';
20
+ const assistantId = context.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
20
21
  const activeVoiceVerification = findActiveSession(assistantId, 'voice');
21
22
  const verificationDestination = activeVoiceVerification?.destinationAddress ?? activeVoiceVerification?.expectedPhoneE164;
22
23
  if (verificationDestination === requestedPhone) {
@@ -132,6 +132,18 @@ function findWasmPath(pkg: string, file: string): string {
132
132
  return execDirPath;
133
133
  }
134
134
 
135
+ // Use module resolution to find the package. This handles hoisted
136
+ // dependencies (e.g. global bun installs where web-tree-sitter is at the
137
+ // top-level node_modules rather than nested under @vellumai/assistant).
138
+ try {
139
+ const resolved = require.resolve(`${pkg}/package.json`);
140
+ const pkgDir = dirname(resolved);
141
+ const resolvedPath = join(pkgDir, file);
142
+ if (existsSync(resolvedPath)) return resolvedPath;
143
+ } catch (err) {
144
+ log.warn({ err, pkg, file }, 'require.resolve failed for WASM package, falling back to manual resolution');
145
+ }
146
+
135
147
  const sourcePath = join(dir, '..', '..', '..', 'node_modules', pkg, file);
136
148
 
137
149
  if (existsSync(sourcePath)) return sourcePath;
@@ -1,4 +1,5 @@
1
1
  import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
2
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
2
3
  import { createOrReuseToolGrantRequest } from '../runtime/tool-grant-request-helper.js';
3
4
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
4
5
  import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
@@ -128,7 +129,7 @@ export class ToolApprovalHandler {
128
129
  toolName: name,
129
130
  inputDigest,
130
131
  consumingRequestId: context.requestId ?? `preexec-${context.sessionId}-${Date.now()}`,
131
- assistantId: context.assistantId ?? 'self',
132
+ assistantId: context.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
132
133
  executionChannel: context.executionChannel,
133
134
  conversationId: context.conversationId,
134
135
  callSessionId: context.callSessionId,
@@ -16,6 +16,11 @@ const log = getLogger('workspace-git');
16
16
  * Strips all GIT_* env vars (e.g. GIT_DIR, GIT_WORK_TREE) that CI runners
17
17
  * or parent processes may set, then adds GIT_CEILING_DIRECTORIES to prevent
18
18
  * walking up to a parent repo.
19
+ *
20
+ * On macOS, augments PATH with common binary directories so the real git
21
+ * binary is found even when the daemon is launched from a .app bundle with
22
+ * a minimal PATH. Without this, the macOS /usr/bin/git shim triggers an
23
+ * "Install Command Line Developer Tools" popup on every git invocation.
19
24
  */
20
25
  function cleanGitEnv(workspaceDir: string): Record<string, string> {
21
26
  const env: Record<string, string> = {};
@@ -25,6 +30,20 @@ function cleanGitEnv(workspaceDir: string): Record<string, string> {
25
30
  }
26
31
  }
27
32
  env.GIT_CEILING_DIRECTORIES = workspaceDir;
33
+
34
+ const home = process.env.HOME ?? '';
35
+ const extraDirs = [
36
+ '/opt/homebrew/bin',
37
+ '/usr/local/bin',
38
+ `${home}/.local/bin`,
39
+ ];
40
+ const currentPath = env.PATH ?? '';
41
+ const pathDirs = currentPath.split(':');
42
+ const missing = extraDirs.filter(d => !pathDirs.includes(d));
43
+ if (missing.length > 0) {
44
+ env.PATH = [...missing, currentPath].filter(Boolean).join(':');
45
+ }
46
+
28
47
  return env;
29
48
  }
30
49