@vellumai/assistant 0.11.8-staging.1 → 0.11.8

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 (48) hide show
  1. package/docs/guardian-request-flow.md +3 -3
  2. package/openapi.yaml +0 -24
  3. package/package.json +1 -1
  4. package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
  5. package/src/__tests__/anthropic-provider.test.ts +2 -0
  6. package/src/__tests__/channel-availability-routes.test.ts +15 -0
  7. package/src/__tests__/channel-readiness-routes.test.ts +96 -3
  8. package/src/__tests__/channel-readiness-service.test.ts +19 -4
  9. package/src/__tests__/config-loader-backfill.test.ts +2 -2
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  11. package/src/__tests__/email-invite-adapter.test.ts +53 -12
  12. package/src/__tests__/external-binding-chat-name.test.ts +60 -0
  13. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
  14. package/src/__tests__/managed-profile-guard.test.ts +1 -1
  15. package/src/__tests__/model-intents.test.ts +2 -2
  16. package/src/__tests__/pricing.test.ts +13 -0
  17. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
  18. package/src/api/responses/home.ts +0 -6
  19. package/src/config/__tests__/default-profile-catalog.test.ts +1 -1
  20. package/src/config/call-site-defaults.ts +1 -1
  21. package/src/config/default-profile-catalog.ts +12 -12
  22. package/src/config/default-profile-names.ts +2 -2
  23. package/src/email/byo-email-credential.test.ts +39 -0
  24. package/src/email/byo-email-credential.ts +76 -0
  25. package/src/email/registered-inbox.test.ts +272 -0
  26. package/src/email/registered-inbox.ts +169 -0
  27. package/src/messaging/providers/slack/approval-source.test.ts +70 -0
  28. package/src/messaging/providers/slack/approval-source.ts +18 -5
  29. package/src/notifications/__tests__/guardian-feed-projection.test.ts +13 -14
  30. package/src/notifications/approval-card-data.ts +13 -5
  31. package/src/notifications/emit-signal.ts +5 -8
  32. package/src/notifications/guardian-feed-projection.ts +9 -74
  33. package/src/notifications/guardian-question-mode.ts +33 -1
  34. package/src/notifications/home-feed-side-effect.ts +0 -4
  35. package/src/persistence/external-conversation-store.ts +5 -1
  36. package/src/providers/anthropic/client.ts +1 -1
  37. package/src/providers/model-catalog.ts +54 -0
  38. package/src/providers/model-intents.ts +3 -3
  39. package/src/runtime/approval-source-link.ts +6 -0
  40. package/src/runtime/channel-invite-transports/email.ts +10 -9
  41. package/src/runtime/channel-readiness-service.ts +83 -23
  42. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +1 -1
  43. package/src/runtime/routes/channel-availability-routes.ts +18 -31
  44. package/src/runtime/routes/credential-routes.ts +3 -0
  45. package/src/runtime/routes/email-routes.ts +35 -47
  46. package/src/runtime/verification-outbound-actions.ts +8 -12
  47. package/src/tools/credentials/store.ts +3 -0
  48. package/src/util/pricing.ts +2 -1
@@ -2,16 +2,18 @@
2
2
  * Email channel invite adapter.
3
3
  *
4
4
  * Resolves the assistant's email address for use in invite instructions.
5
- * Reads the address from workspace config (`email.address`). Returns
6
- * `undefined` when no address is configured, which causes the invite
5
+ * The address is a managed inbox registration, which lives on the platform
6
+ * (nothing about one lands in workspace config), so it resolves through the
7
+ * shared registered-inbox reader. Returns `undefined` when no address is
8
+ * registered or the platform cannot be asked, which causes the invite
7
9
  * instruction generator to emit generic "on Email" wording.
8
10
  *
9
11
  * Email invites use the universal 6-digit code path for redemption, so
10
- * this adapter only implements `resolveChannelHandleAsync` — no
12
+ * this adapter only implements `resolveChannelHandleAsync`, with no
11
13
  * `buildShareLink` or `extractInboundToken` needed.
12
14
  */
13
15
 
14
- import { getNestedValue, loadRawConfig } from "../../config/loader.js";
16
+ import { resolveRegisteredInbox } from "../../email/registered-inbox.js";
15
17
  import type { ChannelInviteAdapter } from "../channel-invite-types.js";
16
18
 
17
19
  // ---------------------------------------------------------------------------
@@ -23,13 +25,12 @@ export const emailInviteAdapter: ChannelInviteAdapter = {
23
25
 
24
26
  async resolveChannelHandleAsync(): Promise<string | undefined> {
25
27
  try {
26
- const raw = loadRawConfig();
27
- const address = getNestedValue(raw, "email.address");
28
- if (typeof address === "string" && address.length > 0) {
29
- return address;
28
+ const inbox = await resolveRegisteredInbox();
29
+ if (inbox.status === "registered") {
30
+ return inbox.address;
30
31
  }
31
32
  } catch {
32
- // Config unavailable
33
+ // Platform unavailable; fall through to generic wording
33
34
  }
34
35
  return undefined;
35
36
  },
@@ -283,32 +283,92 @@ const emailProbe: ChannelProbe = {
283
283
  "Email invite code redemption is enabled",
284
284
  "Email invite code redemption is disabled",
285
285
  ),
286
- await checkIngress(),
286
+ // Managed callbacks allowed: inbound email arrives through the platform
287
+ // callback route the gateway registers (`registerEmailCallbackRoute`,
288
+ // the same pattern as Telegram's webhook route), so a platform-connected
289
+ // deployment with no public ingress URL still receives email.
290
+ await checkIngress(true),
287
291
  ];
288
292
  },
293
+ /**
294
+ * Ask the platform whether an inbox address is registered, falling back to
295
+ * a "your own" provider credential. The platform's email-addresses API is
296
+ * the only writer of managed inbox registrations (nothing about one lands
297
+ * in workspace config), and a BYO deployment's configuration claim is its
298
+ * stored provider API key, so those are the only sources this check may
299
+ * read.
300
+ *
301
+ * Three outcomes, matching the Telegram probe: an answer passes or fails
302
+ * the check outright, and an unreachable platform (with no BYO credential
303
+ * to fall back to) is indeterminate, which is not evidence of a fault and
304
+ * must not report the channel broken.
305
+ */
289
306
  async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
290
- try {
291
- const raw = loadRawConfig();
292
- const address = getNestedValue(raw, "email.address");
293
- const hasInbox = typeof address === "string" && address.length > 0;
294
- return [
295
- {
296
- name: "inbox_configured",
297
- passed: hasInbox,
298
- message: hasInbox
299
- ? `Inbox address is configured (${address})`
300
- : "No inbox address configured — register one with: assistant email register <username>",
301
- },
302
- ];
303
- } catch (err) {
304
- const message = err instanceof Error ? err.message : String(err);
305
- return [
306
- {
307
- name: "inbox_configured",
308
- passed: false,
309
- message: `Failed to check inbox configuration: ${message}`,
310
- },
311
- ];
307
+ // Imported here rather than at module scope, matching the other probes:
308
+ // the platform client pulls in a module graph that unrelated consumers of
309
+ // this service should not have to mock.
310
+ const { resolveRegisteredInbox } =
311
+ await import("../email/registered-inbox.js");
312
+ // Fresh, because this service already caches remote checks for
313
+ // REMOTE_TTL_MS; layering the resolver's own cache under that would make
314
+ // an explicit readiness refresh serve a stale answer anyway.
315
+ const inbox = await resolveRegisteredInbox({ fresh: true });
316
+
317
+ // Published identifier (see the Telegram probe's `webhook_delivery` note):
318
+ // external clients search readiness responses for this name.
319
+ const name = "inbox_configured";
320
+
321
+ if (inbox.status !== "registered") {
322
+ const { resolveConfiguredByoEmailService } =
323
+ await import("../email/byo-email-credential.js");
324
+ const byoService = await resolveConfiguredByoEmailService();
325
+ if (byoService) {
326
+ return [
327
+ {
328
+ name,
329
+ passed: true,
330
+ message: `Email is configured through your own provider (${byoService} API key is stored)`,
331
+ },
332
+ ];
333
+ }
334
+ }
335
+
336
+ switch (inbox.status) {
337
+ case "registered":
338
+ return [
339
+ {
340
+ name,
341
+ passed: true,
342
+ message: `Inbox address is registered (${inbox.address})`,
343
+ },
344
+ ];
345
+ case "none":
346
+ return [
347
+ {
348
+ name,
349
+ passed: false,
350
+ message:
351
+ "No inbox address registered. Register one with: assistant email register <username>, or configure your own provider (Resend or Mailgun)",
352
+ },
353
+ ];
354
+ case "no_platform":
355
+ return [
356
+ {
357
+ name,
358
+ passed: false,
359
+ message:
360
+ "Email is not configured. Connect the platform for a managed inbox (assistant platform connect), or configure your own provider (Resend or Mailgun)",
361
+ },
362
+ ];
363
+ case "unavailable":
364
+ return [
365
+ {
366
+ name,
367
+ passed: true,
368
+ indeterminate: true,
369
+ message: `Could not reach the platform to check inbox registration (${inbox.detail})`,
370
+ },
371
+ ];
312
372
  }
313
373
  },
314
374
  };
@@ -1771,7 +1771,7 @@ describe("config invariant flag enrichment", () => {
1771
1771
  expect(profiles.custom!).not.toHaveProperty("invariant");
1772
1772
  });
1773
1773
 
1774
- test("every managed default is invariant on the wire, Speed included", async () => {
1774
+ test("every managed default is invariant on the wire, Fast included", async () => {
1775
1775
  // The clients drive their read-only lock off this flag, so a default that
1776
1776
  // resolves from the catalog with no workspace stub must still carry it.
1777
1777
  const body = await configGetRoute.handler({});
@@ -26,7 +26,8 @@ import {
26
26
  type ChannelInfo,
27
27
  } from "../../channels/types.js";
28
28
  import { getConfig } from "../../config/loader.js";
29
- import { VellumPlatformClient } from "../../platform/client.js";
29
+ import { resolveConfiguredByoEmailService } from "../../email/byo-email-credential.js";
30
+ import { resolveRegisteredInbox } from "../../email/registered-inbox.js";
30
31
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
31
32
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
32
33
 
@@ -39,45 +40,31 @@ const BASE_AVAILABLE_CHANNELS: readonly ChannelId[] = [
39
40
  "phone",
40
41
  ] as const;
41
42
 
42
- interface EmailAddressListResponse {
43
- count?: number;
44
- results?: Array<{ id: string; address: string }>;
45
- }
46
-
47
43
  /**
48
- * Best-effort check that an inbox address is registered for this
49
- * assistant. A platform fetch failure is treated as "no inbox" — we
50
- * prefer to under-report than block the entire Contacts page when the
51
- * platform is briefly unreachable.
44
+ * Best-effort check that email is configured for this assistant: a managed
45
+ * inbox registered on the platform, or a "your own" provider credential
46
+ * (whose gateway webhook routes carry inbound just as the managed pipeline
47
+ * does). An unavailable platform is treated as "no inbox": we prefer to
48
+ * under-report than block the entire Contacts page when the platform is
49
+ * briefly unreachable.
50
+ *
51
+ * Fresh, preserving this route's long-standing live-read-per-request
52
+ * behavior: availability is fetched on surface loads, not on a poll, so it
53
+ * does not need the resolver's cache and should not inherit its staleness.
52
54
  */
53
- async function hasRegisteredInbox(): Promise<boolean> {
54
- const client = await VellumPlatformClient.create();
55
- if (!client?.platformAssistantId) {
56
- return false;
57
- }
58
-
59
- try {
60
- const response = await client.fetch(
61
- `/v1/assistants/${client.platformAssistantId}/email-addresses/`,
62
- );
63
- if (!response.ok) {
64
- return false;
65
- }
66
- const data = (await response.json()) as EmailAddressListResponse;
67
- if (typeof data.count === "number") {
68
- return data.count > 0;
69
- }
70
- return Array.isArray(data.results) && data.results.length > 0;
71
- } catch {
72
- return false;
55
+ async function hasConfiguredEmail(): Promise<boolean> {
56
+ const inbox = await resolveRegisteredInbox({ fresh: true });
57
+ if (inbox.status === "registered") {
58
+ return true;
73
59
  }
60
+ return (await resolveConfiguredByoEmailService()) !== undefined;
74
61
  }
75
62
 
76
63
  async function handleGetChannelAvailability(
77
64
  _args: RouteHandlerArgs,
78
65
  ): Promise<{ channels: AvailableChannel[] }> {
79
66
  const ids: ChannelId[] = [...BASE_AVAILABLE_CHANNELS];
80
- if (await hasRegisteredInbox()) {
67
+ if (await hasConfiguredEmail()) {
81
68
  ids.push("email");
82
69
  }
83
70
  if (isA2AEnabled(getConfig())) {
@@ -23,6 +23,7 @@ import {
23
23
  type ManagedCredentialDescriptor,
24
24
  } from "../../credential-execution/managed-catalog.js";
25
25
  import { buildForChatSentinel } from "../../daemon/chat-credential-redaction.js";
26
+ import { invalidateEmailReadinessForByoCredential } from "../../email/byo-email-credential.js";
26
27
  import {
27
28
  disconnectOAuthProvider,
28
29
  getConnectionByProvider,
@@ -551,6 +552,8 @@ async function handleCredentialsDelete({ body }: RouteHandlerArgs) {
551
552
 
552
553
  invalidateConnectionsAfterCredentialDelete(affectedConnections);
553
554
 
555
+ await invalidateEmailReadinessForByoCredential(service);
556
+
554
557
  return { service, field, affectedConnections };
555
558
  }
556
559
 
@@ -7,7 +7,13 @@
7
7
 
8
8
  import { z } from "zod";
9
9
 
10
+ import { getReadinessService } from "../../daemon/handlers/config-channels.js";
10
11
  import { markdownToEmailHtml } from "../../email/html-renderer.js";
12
+ import {
13
+ invalidateRegisteredInboxCache,
14
+ listEmailAddresses,
15
+ type RegisteredEmailAddress,
16
+ } from "../../email/registered-inbox.js";
11
17
  import { VellumPlatformClient } from "../../platform/client.js";
12
18
  import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
13
19
  import {
@@ -70,29 +76,42 @@ async function handleEmailRegister({ body = {} }: RouteHandlerArgs) {
70
76
  address: string;
71
77
  created_at: string;
72
78
  };
79
+ invalidateEmailReadinessState();
73
80
  return data;
74
81
  }
75
82
 
76
- async function handleEmailUnregister(_args: RouteHandlerArgs) {
77
- const client = await requireClient();
78
-
79
- const listResponse = await client.fetch(
80
- `/v1/assistants/${client.platformAssistantId}/email-addresses/`,
81
- );
83
+ /**
84
+ * A registration change must be visible on the next readiness read: both the
85
+ * registered-inbox cache and the readiness service's cached email snapshot
86
+ * would otherwise keep answering with the pre-change state for their TTL.
87
+ */
88
+ function invalidateEmailReadinessState(): void {
89
+ invalidateRegisteredInboxCache();
90
+ getReadinessService().invalidateChannel("email");
91
+ }
82
92
 
83
- if (!listResponse.ok) {
93
+ /**
94
+ * The registered addresses, or the route-appropriate error: a listing
95
+ * failure maps to LIST_FAILED with the platform's status when it sent one.
96
+ */
97
+ async function requireEmailAddressList(
98
+ client: VellumPlatformClient,
99
+ ): Promise<RegisteredEmailAddress[]> {
100
+ const list = await listEmailAddresses(client);
101
+ if (!list.ok) {
84
102
  throw new RouteError(
85
- `Failed to list email addresses: HTTP ${listResponse.status}`,
103
+ `Failed to list email addresses: ${list.detail}`,
86
104
  "LIST_FAILED",
87
- listResponse.status,
105
+ list.status ?? 502,
88
106
  );
89
107
  }
108
+ return list.addresses;
109
+ }
90
110
 
91
- const listData = (await listResponse.json()) as {
92
- results: { id: string; address: string }[];
93
- };
111
+ async function handleEmailUnregister(_args: RouteHandlerArgs) {
112
+ const client = await requireClient();
94
113
 
95
- const addresses = listData.results ?? [];
114
+ const addresses = await requireEmailAddressList(client);
96
115
  if (addresses.length === 0) {
97
116
  throw new NotFoundError("No email address registered for this assistant.");
98
117
  }
@@ -117,29 +136,14 @@ async function handleEmailUnregister(_args: RouteHandlerArgs) {
117
136
  );
118
137
  }
119
138
 
139
+ invalidateEmailReadinessState();
120
140
  return { unregistered: target.address };
121
141
  }
122
142
 
123
143
  async function handleEmailStatus(_args: RouteHandlerArgs) {
124
144
  const client = await requireClient();
125
145
 
126
- const listResponse = await client.fetch(
127
- `/v1/assistants/${client.platformAssistantId}/email-addresses/`,
128
- );
129
-
130
- if (!listResponse.ok) {
131
- throw new RouteError(
132
- `Failed to list email addresses: HTTP ${listResponse.status}`,
133
- "LIST_FAILED",
134
- listResponse.status,
135
- );
136
- }
137
-
138
- const listData = (await listResponse.json()) as {
139
- results: { id: string; address: string }[];
140
- };
141
-
142
- const addresses = listData.results ?? [];
146
+ const addresses = await requireEmailAddressList(client);
143
147
  if (addresses.length === 0) {
144
148
  throw new NotFoundError(
145
149
  "No email address registered for this assistant. Run: assistant email register <username>",
@@ -258,23 +262,7 @@ async function handleEmailSend({ body = {} }: RouteHandlerArgs) {
258
262
  const client = await requireClient();
259
263
 
260
264
  // Resolve "from" address
261
- const listResponse = await client.fetch(
262
- `/v1/assistants/${client.platformAssistantId}/email-addresses/`,
263
- );
264
-
265
- if (!listResponse.ok) {
266
- throw new RouteError(
267
- `Failed to list email addresses: HTTP ${listResponse.status}`,
268
- "LIST_FAILED",
269
- listResponse.status,
270
- );
271
- }
272
-
273
- const listData = (await listResponse.json()) as {
274
- results: { id: string; address: string }[];
275
- };
276
-
277
- const addresses = listData.results ?? [];
265
+ const addresses = await requireEmailAddressList(client);
278
266
  if (addresses.length === 0) {
279
267
  throw new NotFoundError(
280
268
  "No email address registered for this assistant. Run: assistant email register <username>",
@@ -575,27 +575,23 @@ export function deliverVerificationEmail(
575
575
  return;
576
576
  }
577
577
 
578
- const listResponse = await client.fetch(
579
- `/v1/assistants/${client.platformAssistantId}/email-addresses/`,
580
- );
581
- if (!listResponse.ok) {
578
+ const { listEmailAddresses } =
579
+ await import("../email/registered-inbox.js");
580
+ const list = await listEmailAddresses(client);
581
+ if (!list.ok) {
582
582
  log.error(
583
- { status: listResponse.status },
583
+ { status: list.status, detail: list.detail },
584
584
  "Failed to list email addresses for verification",
585
585
  );
586
586
  return;
587
587
  }
588
- const listData = (await listResponse.json()) as {
589
- results: { address: string }[];
590
- };
591
- const addresses = listData.results ?? [];
592
- if (addresses.length === 0) {
588
+ const fromAddress = list.addresses[0]?.address;
589
+ if (!fromAddress) {
593
590
  log.error(
594
- "No email address registered — cannot deliver verification email",
591
+ "No email address registered; cannot deliver verification email",
595
592
  );
596
593
  return;
597
594
  }
598
- const fromAddress = addresses[0].address;
599
595
 
600
596
  const { markdownToEmailHtml } = await import("../email/html-renderer.js");
601
597
  const html = markdownToEmailHtml(text);
@@ -28,6 +28,7 @@ import {
28
28
  ACP_SERVICE,
29
29
  assertAcpCredentialFormat,
30
30
  } from "../../acp/acp-credentials.js";
31
+ import { invalidateEmailReadinessForByoCredential } from "../../email/byo-email-credential.js";
31
32
  import { credentialKey } from "../../security/credential-key.js";
32
33
  import { normalizeSecretValue } from "../../security/secret-normalize.js";
33
34
  import {
@@ -176,5 +177,7 @@ export async function storeCredentialValue(
176
177
  );
177
178
  }
178
179
 
180
+ await invalidateEmailReadinessForByoCredential(service);
181
+
179
182
  return { credentialId: metadata.credentialId, service, field };
180
183
  }
@@ -380,7 +380,8 @@ function calculateUsageCost(
380
380
  directInputCost +
381
381
  outputCost +
382
382
  calculateTokenCost(
383
- effectivePricing.inputPer1M * ANTHROPIC_PROMPT_CACHE_MULTIPLIERS.read,
383
+ effectivePricing.cacheReadPer1M ??
384
+ effectivePricing.inputPer1M * ANTHROPIC_PROMPT_CACHE_MULTIPLIERS.read,
384
385
  usage.cacheReadInputTokens,
385
386
  ) +
386
387
  calculateTokenCost(