@vellumai/vellum-gateway 0.6.2 → 0.6.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.
@@ -60,7 +60,7 @@ function fakeCredentialCache(
60
60
 
61
61
  function defaultCredentials(): Record<string, string> {
62
62
  return {
63
- "credential/vellum/platform_base_url": "https://assistant.vellum.ai",
63
+ "credential/vellum/platform_base_url": "https://platform.vellum.ai",
64
64
  "credential/vellum/platform_assistant_id": "asst-123",
65
65
  "credential/vellum/assistant_api_key": "test-api-key",
66
66
  };
@@ -550,6 +550,70 @@ describe("RemoteFeatureFlagSync", () => {
550
550
  sync.stop();
551
551
  });
552
552
 
553
+ test("syncNow during in-flight poll does not create duplicate poll chains", async () => {
554
+ // Simulate a slow fetch that takes 200ms to resolve.
555
+ let fetchCallCount = 0;
556
+ fetchMock = mock(async () => {
557
+ fetchCallCount++;
558
+ await new Promise((r) => setTimeout(r, 200));
559
+ return Response.json({ flags: { ok: true } });
560
+ });
561
+
562
+ const sync = new RemoteFeatureFlagSync({
563
+ credentials: fakeCredentialCache(defaultCredentials()),
564
+ initialPollIntervalMs: 50,
565
+ });
566
+ await sync.start();
567
+ // start() awaits its own fetchAndCache, so fetchCallCount is 1 now.
568
+ expect(fetchCallCount).toBe(1);
569
+
570
+ // Wait for the first poll timer to fire (50ms would be initial, but
571
+ // start succeeded so it snapped to steady-state). Instead, we'll
572
+ // call syncNow() directly — the interesting case is when poll() is
573
+ // already in-flight. To trigger that, we use a short interval.
574
+ sync.stop();
575
+
576
+ // Reset with short interval to create the race:
577
+ fetchCallCount = 0;
578
+ fetchMock = mock(async () => {
579
+ fetchCallCount++;
580
+ // Slow fetch — 150ms
581
+ await new Promise((r) => setTimeout(r, 150));
582
+ return Response.json({ flags: { ok: true } });
583
+ });
584
+
585
+ const sync2 = new RemoteFeatureFlagSync({
586
+ credentials: fakeCredentialCache(defaultCredentials()),
587
+ initialPollIntervalMs: 30,
588
+ });
589
+ await sync2.start(); // 1 fetch (slow, 150ms)
590
+ expect(fetchCallCount).toBe(1);
591
+
592
+ // Wait for poll timer to fire and start its fetch (30ms after start)
593
+ await new Promise((r) => setTimeout(r, 50));
594
+ // poll() has fired and its fetchAndCache() is now in-flight
595
+
596
+ // Call syncNow() while poll's fetch is in-flight
597
+ const syncNowPromise = sync2.syncNow();
598
+
599
+ // Wait for everything to settle
600
+ await syncNowPromise;
601
+ await new Promise((r) => setTimeout(r, 300));
602
+
603
+ // Count how many fetches happened after the race window
604
+ const fetchesDuringRace = fetchCallCount;
605
+
606
+ // Now wait a bit more — if duplicate poll chains exist, we'd see
607
+ // extra fetches firing at the short interval
608
+ await new Promise((r) => setTimeout(r, 200));
609
+
610
+ // Should NOT have extra fetches from a leaked poll chain
611
+ // At most: 1 (start) + 1 (poll) + 1 (syncNow) + 1 (next scheduled poll)
612
+ expect(fetchCallCount).toBeLessThanOrEqual(fetchesDuringRace + 1);
613
+
614
+ sync2.stop();
615
+ });
616
+
553
617
  test("doubles poll interval on consecutive failures", async () => {
554
618
  // Always fail — missing creds
555
619
  const creds = defaultCredentials();
@@ -20,7 +20,6 @@ mock.module("../auth/token-exchange.js", () => ({
20
20
  mintIngressToken: () => "mock-ingress-token",
21
21
  mintServiceToken: () => "mock-service-token",
22
22
  mintExchangeToken: () => "mock-exchange-token",
23
- mintBrowserRelayToken: () => "mock-browser-relay-token",
24
23
  validateEdgeToken: () => ({ ok: true }),
25
24
  }));
26
25
 
@@ -22,9 +22,6 @@ const log = getLogger("token-exchange");
22
22
  /** TTL for exchange tokens — short-lived, minted per-request. */
23
23
  const EXCHANGE_TOKEN_TTL_SECONDS = 60;
24
24
 
25
- /** TTL for browser relay tokens — longer-lived for extension use. */
26
- const BROWSER_RELAY_TOKEN_TTL_SECONDS = 3600;
27
-
28
25
  // ---------------------------------------------------------------------------
29
26
  // Edge token validation
30
27
  // ---------------------------------------------------------------------------
@@ -131,20 +128,3 @@ export function mintServiceToken(): string {
131
128
  ttlSeconds: EXCHANGE_TOKEN_TTL_SECONDS,
132
129
  });
133
130
  }
134
-
135
- /**
136
- * Mint a long-lived token for the Chrome extension to connect to the
137
- * browser relay WebSocket. Uses gateway audience so it passes
138
- * validateEdgeToken() on the WS upgrade path.
139
- *
140
- * sub=svc:browser-relay:self, scope_profile=gateway_service_v1, TTL=1h
141
- */
142
- export function mintBrowserRelayToken(): string {
143
- return mintToken({
144
- aud: "vellum-gateway",
145
- sub: "svc:browser-relay:self",
146
- scope_profile: "gateway_service_v1",
147
- policy_epoch: CURRENT_POLICY_EPOCH,
148
- ttlSeconds: BROWSER_RELAY_TOKEN_TTL_SECONDS,
149
- });
150
- }
@@ -42,14 +42,47 @@ export const EMAIL_CHANNEL_TRANSPORT_HINTS = [
42
42
  ] as const;
43
43
 
44
44
  export const EMAIL_CHANNEL_TRANSPORT_UX_BRIEF =
45
- "Email is an asynchronous medium. Responses can be longer and more detailed than chat. Use proper formatting. The user may not see the response immediately.";
45
+ "Email is an asynchronous medium. Responses can be longer and more detailed than chat. Use proper formatting. The user may not see the response immediately. To reply, you should almost always use the `assistant email send` CLI command (run `assistant email send --help` for usage). Use your judgment — there may be rare cases where a different medium is more appropriate or no reply is needed.";
46
46
 
47
- export function buildEmailTransportMetadata(): {
47
+ /**
48
+ * Context from the inbound email that the assistant needs to construct a
49
+ * reply via the `assistant email send` CLI command.
50
+ */
51
+ export interface EmailReplyContext {
52
+ /** The sender's email address (who the reply should go to). */
53
+ senderAddress: string;
54
+ /** The assistant's own email address (the "from" for the reply). */
55
+ recipientAddress: string;
56
+ /** Original email subject line, if present. */
57
+ subject?: string;
58
+ /** Message-ID of the inbound email for In-Reply-To threading. */
59
+ inReplyTo?: string;
60
+ }
61
+
62
+ export function buildEmailTransportMetadata(replyContext?: EmailReplyContext): {
48
63
  hints: string[];
49
64
  uxBrief: string;
50
65
  } {
66
+ const hints: string[] = [...EMAIL_CHANNEL_TRANSPORT_HINTS];
67
+
68
+ if (replyContext) {
69
+ hints.push(
70
+ `email-sender: ${replyContext.senderAddress}`,
71
+ `email-recipient: ${replyContext.recipientAddress}`,
72
+ );
73
+ if (replyContext.subject) {
74
+ hints.push(`email-subject: ${replyContext.subject}`);
75
+ }
76
+ if (replyContext.inReplyTo) {
77
+ hints.push(`email-in-reply-to: ${replyContext.inReplyTo}`);
78
+ }
79
+ hints.push(
80
+ "email-reply-help: Run `assistant email send --help` for send usage.",
81
+ );
82
+ }
83
+
51
84
  return {
52
- hints: [...EMAIL_CHANNEL_TRANSPORT_HINTS],
85
+ hints,
53
86
  uxBrief: EMAIL_CHANNEL_TRANSPORT_UX_BRIEF,
54
87
  };
55
88
  }
@@ -364,9 +364,15 @@ export const SLACK_CHANNEL_CREDENTIAL_SPEC: ServiceCredentialSpec = {
364
364
  requiredFields: ["bot_token", "app_token"],
365
365
  } as const;
366
366
 
367
+ export const VELLUM_CREDENTIAL_SPEC: ServiceCredentialSpec = {
368
+ service: "vellum",
369
+ requiredFields: ["platform_base_url", "assistant_api_key", "platform_assistant_id", "webhook_secret"],
370
+ } as const;
371
+
367
372
  export const ALL_CREDENTIAL_SPECS: readonly ServiceCredentialSpec[] = [
368
373
  TELEGRAM_CREDENTIAL_SPEC,
369
374
  TWILIO_CREDENTIAL_SPEC,
370
375
  WHATSAPP_CREDENTIAL_SPEC,
371
376
  SLACK_CHANNEL_CREDENTIAL_SPEC,
377
+ VELLUM_CREDENTIAL_SPEC,
372
378
  ];
@@ -0,0 +1,253 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import type { ConfigFileCache } from "../config-file-cache.js";
3
+ import type { CredentialCache } from "../credential-cache.js";
4
+ import { credentialKey } from "../credential-key.js";
5
+ import {
6
+ mockFetch,
7
+ getMockFetchCalls,
8
+ resetMockFetch,
9
+ } from "../__tests__/mock-fetch.js";
10
+ import {
11
+ registerEmailCallbackRoute,
12
+ EMAIL_CALLBACK_PATH,
13
+ } from "./register-callback.js";
14
+
15
+ afterEach(() => {
16
+ resetMockFetch();
17
+ delete process.env.VELLUM_PLATFORM_URL;
18
+ delete process.env.PLATFORM_INTERNAL_API_KEY;
19
+ delete process.env.PLATFORM_ASSISTANT_ID;
20
+ });
21
+
22
+ function makeConfigFile(
23
+ values: Record<string, Record<string, string>> = {},
24
+ ): ConfigFileCache {
25
+ return {
26
+ getString: (section: string, key: string) =>
27
+ values[section]?.[key] ?? undefined,
28
+ invalidate: () => {},
29
+ } as unknown as ConfigFileCache;
30
+ }
31
+
32
+ function makeCaches(opts: {
33
+ platformBaseUrl?: string;
34
+ assistantApiKey?: string;
35
+ platformAssistantId?: string;
36
+ ingressUrl?: string;
37
+ }): { credentials: CredentialCache; configFile?: ConfigFileCache } {
38
+ const store = new Map<string, string>();
39
+ if (opts.platformBaseUrl)
40
+ store.set(
41
+ credentialKey("vellum", "platform_base_url"),
42
+ opts.platformBaseUrl,
43
+ );
44
+ if (opts.assistantApiKey)
45
+ store.set(
46
+ credentialKey("vellum", "assistant_api_key"),
47
+ opts.assistantApiKey,
48
+ );
49
+ if (opts.platformAssistantId)
50
+ store.set(
51
+ credentialKey("vellum", "platform_assistant_id"),
52
+ opts.platformAssistantId,
53
+ );
54
+
55
+ const result: {
56
+ credentials: CredentialCache;
57
+ configFile?: ConfigFileCache;
58
+ } = {
59
+ credentials: {
60
+ get: async (key: string) => store.get(key),
61
+ invalidate: () => {},
62
+ } as CredentialCache,
63
+ };
64
+
65
+ if (opts.ingressUrl) {
66
+ result.configFile = makeConfigFile({
67
+ ingress: { publicBaseUrl: opts.ingressUrl },
68
+ });
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ describe("registerEmailCallbackRoute", () => {
75
+ test("returns undefined when credentials are missing", async () => {
76
+ const result = await registerEmailCallbackRoute();
77
+ expect(result).toBeUndefined();
78
+ });
79
+
80
+ test("returns undefined when credential cache has no platform values", async () => {
81
+ const caches = makeCaches({});
82
+ const result = await registerEmailCallbackRoute(caches);
83
+ expect(result).toBeUndefined();
84
+ });
85
+
86
+ test("registers callback route with platform via credential cache", async () => {
87
+ const caches = makeCaches({
88
+ platformBaseUrl: "https://platform.example.com",
89
+ assistantApiKey: "test-api-key",
90
+ platformAssistantId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
91
+ });
92
+
93
+ const callbackUrl =
94
+ "https://platform.example.com/v1/gateway/callbacks/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/webhooks/email/";
95
+
96
+ mockFetch(
97
+ "callback-routes/register",
98
+ { method: "POST" },
99
+ {
100
+ body: { callback_url: callbackUrl },
101
+ status: 201,
102
+ },
103
+ );
104
+
105
+ const result = await registerEmailCallbackRoute(caches);
106
+
107
+ expect(result).toBe(callbackUrl);
108
+
109
+ const calls = getMockFetchCalls();
110
+ expect(calls).toHaveLength(1);
111
+ expect(calls[0].path).toContain(
112
+ "/v1/internal/gateway/callback-routes/register/",
113
+ );
114
+ const body = JSON.parse(calls[0].init.body as string);
115
+ expect(body).toEqual({
116
+ assistant_id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
117
+ callback_path: EMAIL_CALLBACK_PATH,
118
+ type: "email",
119
+ });
120
+ });
121
+
122
+ test("falls back to env vars when credential cache is empty", async () => {
123
+ process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
124
+ process.env.PLATFORM_ASSISTANT_ID = "11111111-2222-3333-4444-555555555555";
125
+ process.env.PLATFORM_INTERNAL_API_KEY = "internal-key";
126
+
127
+ const callbackUrl =
128
+ "https://env-platform.example.com/v1/gateway/callbacks/11111111-2222-3333-4444-555555555555/webhooks/email/";
129
+
130
+ mockFetch(
131
+ "callback-routes/register",
132
+ { method: "POST" },
133
+ {
134
+ body: { callback_url: callbackUrl },
135
+ status: 201,
136
+ },
137
+ );
138
+
139
+ const result = await registerEmailCallbackRoute();
140
+
141
+ expect(result).toBe(callbackUrl);
142
+
143
+ const calls = getMockFetchCalls();
144
+ expect(calls).toHaveLength(1);
145
+ const headers = calls[0].init.headers as Record<string, string>;
146
+ expect(headers?.["Authorization"]).toBe("Bearer internal-key");
147
+ });
148
+
149
+ test("throws on non-ok response", async () => {
150
+ const caches = makeCaches({
151
+ platformBaseUrl: "https://platform.example.com",
152
+ assistantApiKey: "test-api-key",
153
+ platformAssistantId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
154
+ });
155
+
156
+ mockFetch(
157
+ "callback-routes/register",
158
+ { method: "POST" },
159
+ new Response("Forbidden", { status: 403 }),
160
+ );
161
+
162
+ await expect(registerEmailCallbackRoute(caches)).rejects.toThrow(
163
+ /Email callback route registration failed \(HTTP 403\)/,
164
+ );
165
+ });
166
+
167
+ test("throws when response has no callback_url", async () => {
168
+ const caches = makeCaches({
169
+ platformBaseUrl: "https://platform.example.com",
170
+ assistantApiKey: "test-api-key",
171
+ platformAssistantId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
172
+ });
173
+
174
+ mockFetch(
175
+ "callback-routes/register",
176
+ { method: "POST" },
177
+ {
178
+ body: { id: "route-id" },
179
+ status: 201,
180
+ },
181
+ );
182
+
183
+ await expect(registerEmailCallbackRoute(caches)).rejects.toThrow(
184
+ /did not include callback_url/,
185
+ );
186
+ });
187
+
188
+ test("sends callback_base_url when ingress URL is configured (self-hosted)", async () => {
189
+ const caches = makeCaches({
190
+ platformBaseUrl: "https://platform.example.com",
191
+ assistantApiKey: "vak_selfhosted",
192
+ platformAssistantId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
193
+ ingressUrl: "https://my-assistant.example.com",
194
+ });
195
+
196
+ const callbackUrl =
197
+ "https://my-assistant.example.com/v1/gateway/callbacks/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/webhooks/email/";
198
+
199
+ mockFetch(
200
+ "callback-routes/register",
201
+ { method: "POST" },
202
+ {
203
+ body: { callback_url: callbackUrl },
204
+ status: 201,
205
+ },
206
+ );
207
+
208
+ const result = await registerEmailCallbackRoute(caches);
209
+
210
+ expect(result).toBe(callbackUrl);
211
+
212
+ const calls = getMockFetchCalls();
213
+ expect(calls).toHaveLength(1);
214
+ const body = JSON.parse(calls[0].init.body as string);
215
+ expect(body).toEqual({
216
+ assistant_id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
217
+ callback_path: EMAIL_CALLBACK_PATH,
218
+ type: "email",
219
+ callback_base_url: "https://my-assistant.example.com",
220
+ });
221
+ });
222
+
223
+ test("omits callback_base_url when no ingress URL is configured (platform-managed)", async () => {
224
+ const caches = makeCaches({
225
+ platformBaseUrl: "https://platform.example.com",
226
+ assistantApiKey: "vak_managed",
227
+ platformAssistantId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
228
+ });
229
+
230
+ const callbackUrl =
231
+ "https://platform.example.com/v1/gateway/callbacks/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/webhooks/email/";
232
+
233
+ mockFetch(
234
+ "callback-routes/register",
235
+ { method: "POST" },
236
+ {
237
+ body: { callback_url: callbackUrl },
238
+ status: 201,
239
+ },
240
+ );
241
+
242
+ await registerEmailCallbackRoute(caches);
243
+
244
+ const calls = getMockFetchCalls();
245
+ expect(calls).toHaveLength(1);
246
+ const body = JSON.parse(calls[0].init.body as string);
247
+ expect(body).not.toHaveProperty("callback_base_url");
248
+ });
249
+
250
+ test("EMAIL_CALLBACK_PATH matches gateway route", () => {
251
+ expect(EMAIL_CALLBACK_PATH).toBe("webhooks/email");
252
+ });
253
+ });
@@ -0,0 +1,135 @@
1
+ import type { ConfigFileCache } from "../config-file-cache.js";
2
+ import type { CredentialCache } from "../credential-cache.js";
3
+ import { credentialKey } from "../credential-key.js";
4
+ import { fetchImpl } from "../fetch.js";
5
+ import { getLogger } from "../logger.js";
6
+
7
+ const log = getLogger("email-callback");
8
+
9
+ /**
10
+ * The callback path registered with the platform for inbound email webhooks.
11
+ * Must match the gateway route path in index.ts ("/webhooks/email").
12
+ */
13
+ export const EMAIL_CALLBACK_PATH = "webhooks/email";
14
+ const EMAIL_CALLBACK_TYPE = "email";
15
+
16
+ interface PlatformCallbackRouteResponse {
17
+ callback_url?: string;
18
+ }
19
+
20
+ /**
21
+ * Register a callback route with the Vellum platform so that inbound email
22
+ * webhooks are forwarded to this gateway instance.
23
+ *
24
+ * Follows the same pattern as Telegram's managed callback route registration
25
+ * in `telegram/webhook-manager.ts`. Requires platform credentials (base URL,
26
+ * API key, assistant ID) either from the credential cache or environment
27
+ * variables.
28
+ *
29
+ * Self-hosted assistants with a configured ``ingress.publicBaseUrl`` send
30
+ * their own base URL so the callback route points directly at the gateway
31
+ * rather than through the platform proxy.
32
+ *
33
+ * Returns the platform-assigned callback URL on success, or `undefined` if
34
+ * credentials are not available.
35
+ */
36
+ export async function registerEmailCallbackRoute(caches?: {
37
+ credentials?: CredentialCache;
38
+ configFile?: ConfigFileCache;
39
+ }): Promise<string | undefined> {
40
+ // Read from credential cache when available
41
+ const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
42
+ caches?.credentials
43
+ ? await Promise.all([
44
+ caches.credentials.get(credentialKey("vellum", "platform_base_url")),
45
+ caches.credentials.get(credentialKey("vellum", "assistant_api_key")),
46
+ caches.credentials.get(
47
+ credentialKey("vellum", "platform_assistant_id"),
48
+ ),
49
+ ])
50
+ : [undefined, undefined, undefined];
51
+
52
+ // Fall back to env vars when credential cache values are missing, matching
53
+ // the daemon's resolvePlatformCallbackRegistrationContext() behaviour.
54
+ const platformBaseUrl = (
55
+ platformBaseUrlRaw?.trim() ||
56
+ process.env.VELLUM_PLATFORM_URL?.trim() ||
57
+ ""
58
+ ).replace(/\/+$/, "");
59
+
60
+ const platformInternalApiKey =
61
+ process.env.PLATFORM_INTERNAL_API_KEY?.trim() || undefined;
62
+ const assistantApiKey = !platformInternalApiKey
63
+ ? assistantApiKeyRaw?.trim() || undefined
64
+ : undefined;
65
+ const authToken = platformInternalApiKey || assistantApiKey;
66
+ const authScheme = platformInternalApiKey ? "Bearer" : "Api-Key";
67
+
68
+ const assistantId =
69
+ process.env.PLATFORM_ASSISTANT_ID?.trim() ||
70
+ assistantIdRaw?.trim() ||
71
+ undefined;
72
+
73
+ if (!platformBaseUrl || !authToken || !assistantId) {
74
+ log.debug(
75
+ {
76
+ hasPlatformBaseUrl: !!platformBaseUrl,
77
+ hasApiKey: !!authToken,
78
+ hasAssistantId: !!assistantId,
79
+ },
80
+ "Email callback route registration unavailable — missing credentials",
81
+ );
82
+ return undefined;
83
+ }
84
+
85
+ // Self-hosted assistants send their public ingress URL so the platform
86
+ // registers a callback pointing directly at the gateway rather than
87
+ // routing through the platform proxy (matching Telegram's two-tier
88
+ // pattern in telegram/webhook-manager.ts).
89
+ const ingressUrl = caches?.configFile
90
+ ?.getString("ingress", "publicBaseUrl")
91
+ ?.trim()
92
+ .replace(/\/+$/, "");
93
+
94
+ const requestBody: Record<string, string> = {
95
+ assistant_id: assistantId,
96
+ callback_path: EMAIL_CALLBACK_PATH,
97
+ type: EMAIL_CALLBACK_TYPE,
98
+ };
99
+ if (ingressUrl) {
100
+ requestBody.callback_base_url = ingressUrl;
101
+ }
102
+
103
+ const response = await fetchImpl(
104
+ `${platformBaseUrl}/v1/internal/gateway/callback-routes/register/`,
105
+ {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: `${authScheme} ${authToken}`,
109
+ "Content-Type": "application/json",
110
+ },
111
+ body: JSON.stringify(requestBody),
112
+ signal: AbortSignal.timeout(10_000),
113
+ },
114
+ );
115
+
116
+ if (!response.ok) {
117
+ const detail = await response.text().catch(() => "");
118
+ throw new Error(
119
+ detail
120
+ ? `Email callback route registration failed (HTTP ${response.status}): ${detail}`
121
+ : `Email callback route registration failed (HTTP ${response.status})`,
122
+ );
123
+ }
124
+
125
+ const data = (await response.json()) as PlatformCallbackRouteResponse;
126
+ const callbackUrl = data.callback_url?.trim();
127
+ if (!callbackUrl) {
128
+ throw new Error(
129
+ "Email callback route registration response did not include callback_url",
130
+ );
131
+ }
132
+
133
+ log.info({ callbackUrl }, "Email callback route registered with platform");
134
+ return callbackUrl;
135
+ }
@@ -178,11 +178,11 @@
178
178
  "defaultEnabled": false
179
179
  },
180
180
  {
181
- "id": "settings-integrations-grid",
181
+ "id": "multi-platform-assistant",
182
182
  "scope": "assistant",
183
- "key": "settings-integrations-grid",
184
- "label": "Integrations Grid",
185
- "description": "Show the Integrations grid in Models & Services settings, replacing individual OAuth provider cards",
183
+ "key": "multi-platform-assistant",
184
+ "label": "Multi-Platform Assistant Switcher",
185
+ "description": "Enable the menu-bar assistant switcher for managing multiple platform-hosted assistants (new/switch/retire)",
186
186
  "defaultEnabled": false
187
187
  },
188
188
  {
@@ -288,7 +288,46 @@
288
288
  "label": "Permission Controls V2",
289
289
  "description": "Replace risk-level permission system with two independent controls: 'Ask before acting' (LLM behavior toggle) and 'Host access' (system-enforced gate)",
290
290
  "defaultEnabled": false
291
+ },
292
+ {
293
+ "id": "apple-container",
294
+ "scope": "macos",
295
+ "key": "apple-container",
296
+ "label": "Apple Container",
297
+ "description": "Enable assistant sandboxing via Apple Containers on macOS 26+, providing a lightweight native sandbox without third-party dependencies",
298
+ "defaultEnabled": false
299
+ },
300
+ {
301
+ "id": "tool-result-truncation",
302
+ "scope": "assistant",
303
+ "key": "tool-result-truncation",
304
+ "label": "Post-Turn Tool Result Truncation",
305
+ "description": "Truncate large tool results after each assistant turn, persisting full content to disk for on-demand re-reads",
306
+ "defaultEnabled": false
307
+ },
308
+ {
309
+ "id": "managed-gemini-embeddings-enabled",
310
+ "scope": "assistant",
311
+ "key": "managed-gemini-embeddings-enabled",
312
+ "label": "Managed Gemini Embeddings Enabled",
313
+ "description": "Route embedding requests through the platform runtime proxy using Vellum-managed Gemini credentials when available",
314
+ "defaultEnabled": false
315
+ },
316
+ {
317
+ "id": "fork-from-message",
318
+ "scope": "macos",
319
+ "key": "fork-from-message",
320
+ "label": "Fork from Message",
321
+ "description": "Show the 'Fork from here' option in message overflow menus",
322
+ "defaultEnabled": false
323
+ },
324
+ {
325
+ "id": "fork-from-message",
326
+ "scope": "macos",
327
+ "key": "fork-from-message",
328
+ "label": "Fork from Message",
329
+ "description": "Show the Fork from here option in message overflow menus",
330
+ "defaultEnabled": false
291
331
  }
292
332
  ]
293
333
  }
294
-