@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.
- package/bun.lock +11 -11
- package/package.json +12 -12
- package/src/__tests__/browser-relay-websocket.test.ts +391 -5
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +5 -5
- package/src/__tests__/mock-fetch.ts +87 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +65 -1
- package/src/__tests__/slack-deliver.test.ts +0 -1
- package/src/auth/token-exchange.ts +0 -20
- package/src/channels/transport-hints.ts +36 -3
- package/src/credential-reader.ts +6 -0
- package/src/email/register-callback.test.ts +253 -0
- package/src/email/register-callback.ts +135 -0
- package/src/feature-flag-registry.json +44 -5
- package/src/http/middleware/cors.ts +90 -0
- package/src/http/middleware/rate-limit.ts +1 -3
- package/src/http/routes/browser-relay-websocket.ts +152 -16
- package/src/http/routes/email-webhook.test.ts +7 -7
- package/src/http/routes/email-webhook.ts +15 -5
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/index.ts +132 -27
- package/src/remote-feature-flag-sync.ts +36 -1
- package/src/schema.ts +1 -38
|
@@ -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://
|
|
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
|
-
|
|
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
|
|
85
|
+
hints,
|
|
53
86
|
uxBrief: EMAIL_CHANNEL_TRANSPORT_UX_BRIEF,
|
|
54
87
|
};
|
|
55
88
|
}
|
package/src/credential-reader.ts
CHANGED
|
@@ -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": "
|
|
181
|
+
"id": "multi-platform-assistant",
|
|
182
182
|
"scope": "assistant",
|
|
183
|
-
"key": "
|
|
184
|
-
"label": "
|
|
185
|
-
"description": "
|
|
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
|
-
|