@vellumai/vellum-gateway 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +57 -0
- package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/slack-display-name.test.ts +218 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +98 -4
- package/src/__tests__/twilio-webhooks.test.ts +47 -0
- package/src/auth/ipc-route-policy.ts +6 -0
- package/src/channels/inbound-event.ts +8 -2
- package/src/channels/types.ts +2 -0
- package/src/config-file-watcher.ts +44 -1
- package/src/db/slack-store.ts +10 -0
- package/src/feature-flag-registry.json +111 -23
- package/src/handlers/handle-inbound.ts +6 -4
- package/src/http/routes/a2a-routes.test.ts +129 -0
- package/src/http/routes/a2a-routes.ts +121 -0
- package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
- package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
- package/src/http/routes/twilio-voice-webhook.ts +10 -2
- package/src/index.ts +16 -0
- package/src/ipc/slack-thread-handlers.ts +39 -0
- package/src/risk/bash-risk-classifier.test.ts +24 -0
- package/src/risk/command-registry/commands/assistant.ts +33 -0
- package/src/risk/command-registry.test.ts +5 -0
- package/src/runtime/client.ts +66 -14
- package/src/slack/normalize.ts +78 -26
- package/src/slack/socket-mode.ts +2 -2
- package/src/twilio/validate-webhook.ts +7 -1
- package/src/types.ts +1 -0
- package/src/velay/client.test.ts +100 -0
- package/src/velay/client.ts +73 -0
package/src/runtime/client.ts
CHANGED
|
@@ -503,15 +503,58 @@ export type TwilioForwardResponse = {
|
|
|
503
503
|
* to Twilio, keeping the signing key out of the daemon for this flow.
|
|
504
504
|
*/
|
|
505
505
|
const TWILIO_RELAY_TOKEN_PLACEHOLDER = "__VELLUM_RELAY_TOKEN__";
|
|
506
|
+
const PLATFORM_CALLBACK_MARKER = "/gateway/callbacks/";
|
|
507
|
+
|
|
508
|
+
function toWebSocketBaseUrl(url: string): string {
|
|
509
|
+
return url.replace(/^http(s?)/, "ws$1");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function extractPlatformCallbackAssistantId(
|
|
513
|
+
value: unknown,
|
|
514
|
+
): string | undefined {
|
|
515
|
+
const normalized = normalizePublicBaseUrl(value);
|
|
516
|
+
if (!normalized) return undefined;
|
|
517
|
+
|
|
518
|
+
let pathname: string;
|
|
519
|
+
try {
|
|
520
|
+
pathname = new URL(normalized).pathname;
|
|
521
|
+
} catch {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const markerIndex = pathname.indexOf(PLATFORM_CALLBACK_MARKER);
|
|
526
|
+
if (markerIndex === -1) return undefined;
|
|
527
|
+
|
|
528
|
+
const assistantIdStart = markerIndex + PLATFORM_CALLBACK_MARKER.length;
|
|
529
|
+
const assistantId = pathname.slice(assistantIdStart).split("/")[0]?.trim();
|
|
530
|
+
return assistantId || undefined;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function resolveVelayBaseWssUrl(
|
|
534
|
+
config: GatewayConfig,
|
|
535
|
+
platformAssistantId?: string,
|
|
536
|
+
): string | undefined {
|
|
537
|
+
if (!config.velayBaseUrl || !platformAssistantId) return undefined;
|
|
538
|
+
|
|
539
|
+
const withPath =
|
|
540
|
+
config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
|
|
541
|
+
const normalizedVelayUrl = normalizePublicBaseUrl(withPath);
|
|
542
|
+
return normalizedVelayUrl
|
|
543
|
+
? toWebSocketBaseUrl(normalizedVelayUrl)
|
|
544
|
+
: undefined;
|
|
545
|
+
}
|
|
506
546
|
|
|
507
547
|
/**
|
|
508
548
|
* Resolve the public base URL as a WebSocket URL (`wss://…`).
|
|
509
549
|
*
|
|
510
550
|
* Sources (in priority order):
|
|
511
|
-
* 1. `
|
|
512
|
-
*
|
|
513
|
-
* 2. `
|
|
514
|
-
*
|
|
551
|
+
* 1. `VELAY_BASE_URL` + the assistant ID from the signed platform callback
|
|
552
|
+
* URL.
|
|
553
|
+
* 2. `ingress.publicBaseUrl` from the config file when it is a real public
|
|
554
|
+
* gateway base URL — written by Velay after tunnel registration, or set
|
|
555
|
+
* manually for self-hosted.
|
|
556
|
+
* 3. `VELAY_BASE_URL` + the platform assistant ID from a configured callback
|
|
557
|
+
* URL or credential cache.
|
|
515
558
|
*
|
|
516
559
|
* Returns `undefined` when no source provides a value — the placeholder
|
|
517
560
|
* will remain in TwiML and Twilio will fail to connect, which is the
|
|
@@ -521,20 +564,29 @@ export function resolvePublicBaseWssUrl(
|
|
|
521
564
|
config: GatewayConfig,
|
|
522
565
|
configFile?: ConfigFileCache,
|
|
523
566
|
platformAssistantId?: string,
|
|
567
|
+
validatedPublicUrl?: string,
|
|
524
568
|
): string | undefined {
|
|
525
569
|
const raw = configFile?.getString("ingress", "publicBaseUrl");
|
|
526
570
|
const normalized = normalizePublicBaseUrl(raw);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
571
|
+
|
|
572
|
+
const validatedCallbackAssistantId =
|
|
573
|
+
extractPlatformCallbackAssistantId(validatedPublicUrl);
|
|
574
|
+
const validatedCallbackWssUrl = resolveVelayBaseWssUrl(
|
|
575
|
+
config,
|
|
576
|
+
validatedCallbackAssistantId,
|
|
577
|
+
);
|
|
578
|
+
if (validatedCallbackWssUrl) return validatedCallbackWssUrl;
|
|
579
|
+
|
|
580
|
+
const configuredCallbackAssistantId = extractPlatformCallbackAssistantId(raw);
|
|
581
|
+
|
|
582
|
+
if (normalized && !configuredCallbackAssistantId) {
|
|
583
|
+
return toWebSocketBaseUrl(normalized);
|
|
536
584
|
}
|
|
537
|
-
|
|
585
|
+
|
|
586
|
+
return resolveVelayBaseWssUrl(
|
|
587
|
+
config,
|
|
588
|
+
configuredCallbackAssistantId ?? platformAssistantId,
|
|
589
|
+
);
|
|
538
590
|
}
|
|
539
591
|
|
|
540
592
|
/**
|
package/src/slack/normalize.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderSlackTextForModel } from "@vellumai/slack-text";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import type { GatewayConfig } from "../config.js";
|
|
3
4
|
import { fetchImpl } from "../fetch.js";
|
|
4
5
|
import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
|
|
@@ -11,8 +12,20 @@ import type { GatewayInboundEvent } from "../types.js";
|
|
|
11
12
|
interface SlackUserInfo {
|
|
12
13
|
displayName: string;
|
|
13
14
|
username: string;
|
|
15
|
+
timezone?: string;
|
|
16
|
+
timezoneLabel?: string;
|
|
17
|
+
timezoneOffsetSeconds?: number;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
export type SlackUserActorFields = Pick<
|
|
21
|
+
SlackUserInfo,
|
|
22
|
+
| "displayName"
|
|
23
|
+
| "username"
|
|
24
|
+
| "timezone"
|
|
25
|
+
| "timezoneLabel"
|
|
26
|
+
| "timezoneOffsetSeconds"
|
|
27
|
+
>;
|
|
28
|
+
|
|
16
29
|
interface SlackChannelInfo {
|
|
17
30
|
name: string;
|
|
18
31
|
}
|
|
@@ -48,6 +61,16 @@ const inFlightChannelFetches = new Map<
|
|
|
48
61
|
Promise<SlackChannelInfo | undefined>
|
|
49
62
|
>();
|
|
50
63
|
|
|
64
|
+
function slackUserCacheKey(userId: string, botToken: string): string {
|
|
65
|
+
const authScope = createHash("sha256").update(botToken).digest("hex");
|
|
66
|
+
return `${authScope}:${userId}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slackChannelCacheKey(channelId: string, botToken: string): string {
|
|
70
|
+
const authScope = createHash("sha256").update(botToken).digest("hex");
|
|
71
|
+
return `${authScope}:${channelId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
function evictExpired<T>(cache: Map<string, CacheEntry<T>>): void {
|
|
52
75
|
const now = Date.now();
|
|
53
76
|
for (const [key, entry] of cache) {
|
|
@@ -106,11 +129,12 @@ export async function resolveSlackUser(
|
|
|
106
129
|
userId: string,
|
|
107
130
|
botToken: string,
|
|
108
131
|
): Promise<SlackUserInfo | undefined> {
|
|
109
|
-
const
|
|
132
|
+
const cacheKey = slackUserCacheKey(userId, botToken);
|
|
133
|
+
const cached = cacheGet(userInfoCache, cacheKey);
|
|
110
134
|
if (cached) return cached;
|
|
111
135
|
|
|
112
136
|
// If another caller is already fetching this user, reuse that promise
|
|
113
|
-
const existing = inFlightUserFetches.get(
|
|
137
|
+
const existing = inFlightUserFetches.get(cacheKey);
|
|
114
138
|
if (existing) return existing;
|
|
115
139
|
|
|
116
140
|
const fetchPromise = (async (): Promise<SlackUserInfo | undefined> => {
|
|
@@ -129,6 +153,9 @@ export async function resolveSlackUser(
|
|
|
129
153
|
user?: {
|
|
130
154
|
name?: string;
|
|
131
155
|
real_name?: string;
|
|
156
|
+
tz?: string;
|
|
157
|
+
tz_label?: string;
|
|
158
|
+
tz_offset?: number;
|
|
132
159
|
profile?: { display_name?: string; real_name?: string };
|
|
133
160
|
};
|
|
134
161
|
};
|
|
@@ -141,11 +168,27 @@ export async function resolveSlackUser(
|
|
|
141
168
|
data.user.name ||
|
|
142
169
|
userId;
|
|
143
170
|
const username = data.user.name || userId;
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
const timezone =
|
|
172
|
+
typeof data.user.tz === "string" ? data.user.tz : undefined;
|
|
173
|
+
const timezoneLabel =
|
|
174
|
+
typeof data.user.tz_label === "string" ? data.user.tz_label : undefined;
|
|
175
|
+
const timezoneOffsetSeconds =
|
|
176
|
+
typeof data.user.tz_offset === "number"
|
|
177
|
+
? data.user.tz_offset
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
180
|
+
const info: SlackUserInfo = {
|
|
181
|
+
displayName,
|
|
182
|
+
username,
|
|
183
|
+
...(timezone !== undefined ? { timezone } : {}),
|
|
184
|
+
...(timezoneLabel !== undefined ? { timezoneLabel } : {}),
|
|
185
|
+
...(timezoneOffsetSeconds !== undefined
|
|
186
|
+
? { timezoneOffsetSeconds }
|
|
187
|
+
: {}),
|
|
188
|
+
};
|
|
146
189
|
cacheSet(
|
|
147
190
|
userInfoCache,
|
|
148
|
-
|
|
191
|
+
cacheKey,
|
|
149
192
|
info,
|
|
150
193
|
USER_CACHE_TTL_MS,
|
|
151
194
|
USER_CACHE_MAX_SIZE,
|
|
@@ -156,11 +199,11 @@ export async function resolveSlackUser(
|
|
|
156
199
|
}
|
|
157
200
|
})();
|
|
158
201
|
|
|
159
|
-
inFlightUserFetches.set(
|
|
202
|
+
inFlightUserFetches.set(cacheKey, fetchPromise);
|
|
160
203
|
try {
|
|
161
204
|
return await fetchPromise;
|
|
162
205
|
} finally {
|
|
163
|
-
inFlightUserFetches.delete(
|
|
206
|
+
inFlightUserFetches.delete(cacheKey);
|
|
164
207
|
}
|
|
165
208
|
}
|
|
166
209
|
|
|
@@ -175,10 +218,11 @@ export async function resolveSlackChannel(
|
|
|
175
218
|
channelId: string,
|
|
176
219
|
botToken: string,
|
|
177
220
|
): Promise<SlackChannelInfo | undefined> {
|
|
178
|
-
const
|
|
221
|
+
const cacheKey = slackChannelCacheKey(channelId, botToken);
|
|
222
|
+
const cached = cacheGet(channelInfoCache, cacheKey);
|
|
179
223
|
if (cached) return cached;
|
|
180
224
|
|
|
181
|
-
const existing = inFlightChannelFetches.get(
|
|
225
|
+
const existing = inFlightChannelFetches.get(cacheKey);
|
|
182
226
|
if (existing) return existing;
|
|
183
227
|
|
|
184
228
|
const fetchPromise = (async (): Promise<SlackChannelInfo | undefined> => {
|
|
@@ -207,7 +251,7 @@ export async function resolveSlackChannel(
|
|
|
207
251
|
const info: SlackChannelInfo = { name };
|
|
208
252
|
cacheSet(
|
|
209
253
|
channelInfoCache,
|
|
210
|
-
|
|
254
|
+
cacheKey,
|
|
211
255
|
info,
|
|
212
256
|
CHANNEL_CACHE_TTL_MS,
|
|
213
257
|
CHANNEL_CACHE_MAX_SIZE,
|
|
@@ -218,11 +262,11 @@ export async function resolveSlackChannel(
|
|
|
218
262
|
}
|
|
219
263
|
})();
|
|
220
264
|
|
|
221
|
-
inFlightChannelFetches.set(
|
|
265
|
+
inFlightChannelFetches.set(cacheKey, fetchPromise);
|
|
222
266
|
try {
|
|
223
267
|
return await fetchPromise;
|
|
224
268
|
} finally {
|
|
225
|
-
inFlightChannelFetches.delete(
|
|
269
|
+
inFlightChannelFetches.delete(cacheKey);
|
|
226
270
|
}
|
|
227
271
|
}
|
|
228
272
|
|
|
@@ -235,8 +279,9 @@ export function resolveSlackUserSync(
|
|
|
235
279
|
userId: string,
|
|
236
280
|
botToken: string,
|
|
237
281
|
): SlackUserInfo | undefined {
|
|
238
|
-
const
|
|
239
|
-
|
|
282
|
+
const cacheKey = slackUserCacheKey(userId, botToken);
|
|
283
|
+
const cached = cacheGet(userInfoCache, cacheKey);
|
|
284
|
+
if (!cached && !inFlightUserFetches.has(cacheKey)) {
|
|
240
285
|
// Fire-and-forget: warm the cache for next time
|
|
241
286
|
resolveSlackUser(userId, botToken).catch(() => {});
|
|
242
287
|
}
|
|
@@ -395,6 +440,22 @@ function renderSlackInboundText(
|
|
|
395
440
|
});
|
|
396
441
|
}
|
|
397
442
|
|
|
443
|
+
export function slackUserActorFields(
|
|
444
|
+
userInfo: SlackUserInfo,
|
|
445
|
+
): SlackUserActorFields {
|
|
446
|
+
return {
|
|
447
|
+
displayName: userInfo.displayName,
|
|
448
|
+
username: userInfo.username,
|
|
449
|
+
...(userInfo.timezone !== undefined ? { timezone: userInfo.timezone } : {}),
|
|
450
|
+
...(userInfo.timezoneLabel !== undefined
|
|
451
|
+
? { timezoneLabel: userInfo.timezoneLabel }
|
|
452
|
+
: {}),
|
|
453
|
+
...(userInfo.timezoneOffsetSeconds !== undefined
|
|
454
|
+
? { timezoneOffsetSeconds: userInfo.timezoneOffsetSeconds }
|
|
455
|
+
: {}),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
398
459
|
function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
|
|
399
460
|
type: "image" | "document";
|
|
400
461
|
fileId: string;
|
|
@@ -505,10 +566,7 @@ export function normalizeSlackDirectMessage(
|
|
|
505
566
|
},
|
|
506
567
|
actor: {
|
|
507
568
|
actorExternalId: event.user,
|
|
508
|
-
...(userInfo
|
|
509
|
-
displayName: userInfo.displayName,
|
|
510
|
-
username: userInfo.username,
|
|
511
|
-
}),
|
|
569
|
+
...(userInfo ? slackUserActorFields(userInfo) : {}),
|
|
512
570
|
},
|
|
513
571
|
source: {
|
|
514
572
|
updateId: eventId,
|
|
@@ -573,10 +631,7 @@ export function normalizeSlackChannelMessage(
|
|
|
573
631
|
},
|
|
574
632
|
actor: {
|
|
575
633
|
actorExternalId: event.user,
|
|
576
|
-
...(userInfo
|
|
577
|
-
displayName: userInfo.displayName,
|
|
578
|
-
username: userInfo.username,
|
|
579
|
-
}),
|
|
634
|
+
...(userInfo ? slackUserActorFields(userInfo) : {}),
|
|
580
635
|
},
|
|
581
636
|
source: {
|
|
582
637
|
updateId: eventId,
|
|
@@ -638,10 +693,7 @@ export function normalizeSlackAppMention(
|
|
|
638
693
|
},
|
|
639
694
|
actor: {
|
|
640
695
|
actorExternalId: event.user,
|
|
641
|
-
...(userInfo
|
|
642
|
-
displayName: userInfo.displayName,
|
|
643
|
-
username: userInfo.username,
|
|
644
|
-
}),
|
|
696
|
+
...(userInfo ? slackUserActorFields(userInfo) : {}),
|
|
645
697
|
},
|
|
646
698
|
source: {
|
|
647
699
|
updateId: eventId,
|
package/src/slack/socket-mode.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
normalizeSlackReactionRemoved,
|
|
26
26
|
resolveSlackChannel,
|
|
27
27
|
resolveSlackUser,
|
|
28
|
+
slackUserActorFields,
|
|
28
29
|
type SlackAppMentionEvent,
|
|
29
30
|
type SlackDirectMessageEvent,
|
|
30
31
|
type SlackChannelMessageEvent,
|
|
@@ -1018,8 +1019,7 @@ export class SlackSocketModeClient {
|
|
|
1018
1019
|
),
|
|
1019
1020
|
]);
|
|
1020
1021
|
if (userInfo) {
|
|
1021
|
-
actor
|
|
1022
|
-
actor.username = userInfo.username;
|
|
1022
|
+
Object.assign(actor, slackUserActorFields(userInfo));
|
|
1023
1023
|
}
|
|
1024
1024
|
}
|
|
1025
1025
|
|
|
@@ -182,6 +182,8 @@ export type TwilioValidationSuccess = {
|
|
|
182
182
|
rawBody: string;
|
|
183
183
|
/** Parsed key-value pairs from the form body. */
|
|
184
184
|
params: Record<string, string>;
|
|
185
|
+
/** Candidate URL that matched X-Twilio-Signature. */
|
|
186
|
+
validatedCandidateUrl?: string;
|
|
185
187
|
};
|
|
186
188
|
|
|
187
189
|
/** Options bag for optional cache injection into Twilio webhook validation. */
|
|
@@ -411,5 +413,9 @@ export async function validateTwilioWebhookRequest(
|
|
|
411
413
|
log.info(successLogContext, "Twilio webhook signature validated");
|
|
412
414
|
}
|
|
413
415
|
|
|
414
|
-
return {
|
|
416
|
+
return {
|
|
417
|
+
rawBody,
|
|
418
|
+
params,
|
|
419
|
+
validatedCandidateUrl: validatingCandidate.url,
|
|
420
|
+
};
|
|
415
421
|
}
|
package/src/types.ts
CHANGED
package/src/velay/client.test.ts
CHANGED
|
@@ -44,6 +44,7 @@ const WS_CLOSED = WebSocket.CLOSED;
|
|
|
44
44
|
function makeCredentials(values: Record<string, string | undefined>) {
|
|
45
45
|
return {
|
|
46
46
|
get: async (key: string) => values[key],
|
|
47
|
+
onInvalidate: () => () => {},
|
|
47
48
|
} as unknown as CredentialCache;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -526,6 +527,105 @@ describe("VelayTunnelClient", () => {
|
|
|
526
527
|
expect(reconnectDelays).toEqual([10, 20, 10]);
|
|
527
528
|
});
|
|
528
529
|
|
|
530
|
+
test("refreshCredentials cancels stale credential backoff and reconnects immediately", async () => {
|
|
531
|
+
const sockets: FakeWebSocket[] = [];
|
|
532
|
+
const reconnectDelays: number[] = [];
|
|
533
|
+
const reconnectCallbacks: Array<() => void> = [];
|
|
534
|
+
const credentialValues: Record<string, string | undefined> = {};
|
|
535
|
+
const credentials = makeCredentials(credentialValues);
|
|
536
|
+
const client = new VelayTunnelClient({
|
|
537
|
+
velayBaseUrl: "http://velay.example.test",
|
|
538
|
+
gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
|
|
539
|
+
credentials,
|
|
540
|
+
configFile: makeConfigFileCache({ count: 0 }),
|
|
541
|
+
webSocketConstructor: makeFakeWebSocketConstructor(sockets),
|
|
542
|
+
reconnect: { baseDelayMs: 10, maxDelayMs: 80, jitterRatio: 0 },
|
|
543
|
+
heartbeat: { intervalMs: 0, readTimeoutMs: 0 },
|
|
544
|
+
timerApi: makeManualTimerApi(reconnectDelays, reconnectCallbacks),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
client.start();
|
|
548
|
+
await flushPromises();
|
|
549
|
+
|
|
550
|
+
expect(sockets).toHaveLength(0);
|
|
551
|
+
expect(reconnectDelays).toEqual([10]);
|
|
552
|
+
|
|
553
|
+
credentialValues[credentialKey("vellum", "assistant_api_key")] =
|
|
554
|
+
"api-key-123";
|
|
555
|
+
credentialValues[credentialKey("vellum", "platform_assistant_id")] =
|
|
556
|
+
"asst-123";
|
|
557
|
+
|
|
558
|
+
client.refreshCredentials("vellum credentials changed");
|
|
559
|
+
await flushPromises();
|
|
560
|
+
|
|
561
|
+
expect(sockets).toHaveLength(1);
|
|
562
|
+
expect(sockets[0].options).toEqual({
|
|
563
|
+
protocols: [VELAY_TUNNEL_SUBPROTOCOL],
|
|
564
|
+
headers: {
|
|
565
|
+
Authorization: "Api-Key api-key-123",
|
|
566
|
+
"X-Vellum-Velay-Allowed-Paths": VELAY_ALLOWED_PATHS_HEADER_VALUE,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
expect(reconnectDelays).toEqual([10]);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("refreshCredentials retries immediately when credentials change during active connect", async () => {
|
|
573
|
+
const sockets: FakeWebSocket[] = [];
|
|
574
|
+
const reconnectDelays: number[] = [];
|
|
575
|
+
const apiKeyCredential = credentialKey("vellum", "assistant_api_key");
|
|
576
|
+
const assistantIdCredential = credentialKey(
|
|
577
|
+
"vellum",
|
|
578
|
+
"platform_assistant_id",
|
|
579
|
+
);
|
|
580
|
+
let resolveFirstApiKeyRead: (value: string | undefined) => void = () => {};
|
|
581
|
+
const firstApiKeyRead = new Promise<string | undefined>((resolve) => {
|
|
582
|
+
resolveFirstApiKeyRead = resolve;
|
|
583
|
+
});
|
|
584
|
+
let useFreshCredentials = false;
|
|
585
|
+
const credentials = {
|
|
586
|
+
get: async (key: string) => {
|
|
587
|
+
if (key === apiKeyCredential) {
|
|
588
|
+
return useFreshCredentials ? "api-key-123" : firstApiKeyRead;
|
|
589
|
+
}
|
|
590
|
+
if (key === assistantIdCredential) return "asst-123";
|
|
591
|
+
return undefined;
|
|
592
|
+
},
|
|
593
|
+
onInvalidate: () => () => {},
|
|
594
|
+
} as unknown as CredentialCache;
|
|
595
|
+
const client = new VelayTunnelClient({
|
|
596
|
+
velayBaseUrl: "http://velay.example.test",
|
|
597
|
+
gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
|
|
598
|
+
credentials,
|
|
599
|
+
configFile: makeConfigFileCache({ count: 0 }),
|
|
600
|
+
webSocketConstructor: makeFakeWebSocketConstructor(sockets),
|
|
601
|
+
reconnect: { baseDelayMs: 10, maxDelayMs: 80, jitterRatio: 0 },
|
|
602
|
+
heartbeat: { intervalMs: 0, readTimeoutMs: 0 },
|
|
603
|
+
timerApi: makeTimerApi(reconnectDelays),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
client.start();
|
|
607
|
+
await flushPromises();
|
|
608
|
+
|
|
609
|
+
expect(sockets).toHaveLength(0);
|
|
610
|
+
expect(reconnectDelays).toEqual([]);
|
|
611
|
+
|
|
612
|
+
client.refreshCredentials("vellum credentials changed");
|
|
613
|
+
useFreshCredentials = true;
|
|
614
|
+
resolveFirstApiKeyRead(undefined);
|
|
615
|
+
await flushPromises();
|
|
616
|
+
|
|
617
|
+
expect(sockets).toHaveLength(1);
|
|
618
|
+
expect(sockets[0].options).toEqual({
|
|
619
|
+
protocols: [VELAY_TUNNEL_SUBPROTOCOL],
|
|
620
|
+
headers: {
|
|
621
|
+
Authorization: "Api-Key api-key-123",
|
|
622
|
+
"X-Vellum-Velay-Allowed-Paths": VELAY_ALLOWED_PATHS_HEADER_VALUE,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
expect(reconnectDelays).toEqual([]);
|
|
626
|
+
await client.stop();
|
|
627
|
+
});
|
|
628
|
+
|
|
529
629
|
test("writes only ingress.publicBaseUrl when publishing a Velay URL", async () => {
|
|
530
630
|
const sockets: FakeWebSocket[] = [];
|
|
531
631
|
writeConfig({
|
package/src/velay/client.ts
CHANGED
|
@@ -92,6 +92,7 @@ export class VelayTunnelClient {
|
|
|
92
92
|
private readTimeoutTimer: unknown = null;
|
|
93
93
|
private peerHeartbeatConfirmed = false;
|
|
94
94
|
private publishedPublicBaseUrl: string | undefined;
|
|
95
|
+
private credentialRefreshPending = false;
|
|
95
96
|
private unsubscribeConfigInvalidation: (() => void) | undefined;
|
|
96
97
|
|
|
97
98
|
constructor(private readonly options: VelayTunnelClientOptions) {
|
|
@@ -127,6 +128,37 @@ export class VelayTunnelClient {
|
|
|
127
128
|
};
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
refreshCredentials(reason = "credentials changed"): void {
|
|
132
|
+
if (!this.running) return;
|
|
133
|
+
|
|
134
|
+
this.reconnectAttempt = 0;
|
|
135
|
+
if (this.reconnectTimer) {
|
|
136
|
+
this.timerApi.clearTimeout(this.reconnectTimer);
|
|
137
|
+
this.reconnectTimer = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ws = this.ws;
|
|
141
|
+
if (ws) {
|
|
142
|
+
log.info(
|
|
143
|
+
{ reason },
|
|
144
|
+
"Restarting Velay tunnel with refreshed credentials",
|
|
145
|
+
);
|
|
146
|
+
this.disconnectActiveWebSocket(ws, 1000, "credentials changed");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.connecting) {
|
|
151
|
+
this.credentialRefreshPending = true;
|
|
152
|
+
log.info(
|
|
153
|
+
{ reason },
|
|
154
|
+
"Queued Velay credential refresh behind active connect",
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.connectForCredentialRefresh(reason);
|
|
160
|
+
}
|
|
161
|
+
|
|
130
162
|
start(): void {
|
|
131
163
|
if (this.running) return;
|
|
132
164
|
this.running = true;
|
|
@@ -145,6 +177,7 @@ export class VelayTunnelClient {
|
|
|
145
177
|
async stop(): Promise<void> {
|
|
146
178
|
this.running = false;
|
|
147
179
|
this.connecting = false;
|
|
180
|
+
this.credentialRefreshPending = false;
|
|
148
181
|
this.unsubscribeConfigInvalidation?.();
|
|
149
182
|
this.unsubscribeConfigInvalidation = undefined;
|
|
150
183
|
if (this.reconnectTimer) {
|
|
@@ -192,6 +225,9 @@ export class VelayTunnelClient {
|
|
|
192
225
|
} catch (err) {
|
|
193
226
|
this.connecting = false;
|
|
194
227
|
log.warn({ err }, "Failed to read Velay tunnel credentials");
|
|
228
|
+
if (this.consumePendingCredentialRefresh("credentials read failed")) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
195
231
|
this.scheduleReconnect();
|
|
196
232
|
return;
|
|
197
233
|
}
|
|
@@ -205,6 +241,9 @@ export class VelayTunnelClient {
|
|
|
205
241
|
const platformAssistantId = platformAssistantIdRaw?.trim() || undefined;
|
|
206
242
|
if (!apiKey) {
|
|
207
243
|
this.connecting = false;
|
|
244
|
+
if (this.consumePendingCredentialRefresh("assistant API key missing")) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
208
247
|
log.info("Velay tunnel waiting for assistant API key");
|
|
209
248
|
this.scheduleReconnect();
|
|
210
249
|
return;
|
|
@@ -217,6 +256,9 @@ export class VelayTunnelClient {
|
|
|
217
256
|
} catch (err) {
|
|
218
257
|
this.connecting = false;
|
|
219
258
|
log.error({ err }, "Invalid Velay base URL");
|
|
259
|
+
if (this.consumePendingCredentialRefresh("Velay base URL invalid")) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
220
262
|
this.scheduleReconnect();
|
|
221
263
|
return;
|
|
222
264
|
}
|
|
@@ -261,14 +303,45 @@ export class VelayTunnelClient {
|
|
|
261
303
|
this.disconnectActiveWebSocket(ws);
|
|
262
304
|
}
|
|
263
305
|
});
|
|
306
|
+
|
|
307
|
+
if (this.credentialRefreshPending) {
|
|
308
|
+
this.credentialRefreshPending = false;
|
|
309
|
+
log.info(
|
|
310
|
+
"Restarting Velay tunnel because credentials changed during connect",
|
|
311
|
+
);
|
|
312
|
+
this.disconnectActiveWebSocket(ws, 1000, "credentials changed");
|
|
313
|
+
}
|
|
264
314
|
} catch (err) {
|
|
265
315
|
this.ws = null;
|
|
266
316
|
this.connecting = false;
|
|
267
317
|
log.warn({ err }, "Failed to connect Velay tunnel");
|
|
318
|
+
if (this.consumePendingCredentialRefresh("WebSocket connect failed")) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
268
321
|
this.scheduleReconnect();
|
|
269
322
|
}
|
|
270
323
|
}
|
|
271
324
|
|
|
325
|
+
private connectForCredentialRefresh(reason: string): void {
|
|
326
|
+
this.connect().catch((err) => {
|
|
327
|
+
this.connecting = false;
|
|
328
|
+
log.error({ err, reason }, "Velay credential refresh reconnect failed");
|
|
329
|
+
this.scheduleReconnect();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private consumePendingCredentialRefresh(reason: string): boolean {
|
|
334
|
+
if (!this.credentialRefreshPending || !this.running) return false;
|
|
335
|
+
|
|
336
|
+
this.credentialRefreshPending = false;
|
|
337
|
+
log.info(
|
|
338
|
+
{ reason },
|
|
339
|
+
"Retrying Velay tunnel connect with refreshed credentials",
|
|
340
|
+
);
|
|
341
|
+
this.connectForCredentialRefresh(reason);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
272
345
|
private async handleMessage(
|
|
273
346
|
data: unknown,
|
|
274
347
|
originWs: WebSocket,
|