@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.
Files changed (32) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-file-watcher.test.ts +57 -0
  4. package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -0
  5. package/src/__tests__/route-schema-guard.test.ts +4 -0
  6. package/src/__tests__/slack-display-name.test.ts +218 -0
  7. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +98 -4
  8. package/src/__tests__/twilio-webhooks.test.ts +47 -0
  9. package/src/auth/ipc-route-policy.ts +6 -0
  10. package/src/channels/inbound-event.ts +8 -2
  11. package/src/channels/types.ts +2 -0
  12. package/src/config-file-watcher.ts +44 -1
  13. package/src/db/slack-store.ts +10 -0
  14. package/src/feature-flag-registry.json +111 -23
  15. package/src/handlers/handle-inbound.ts +6 -4
  16. package/src/http/routes/a2a-routes.test.ts +129 -0
  17. package/src/http/routes/a2a-routes.ts +121 -0
  18. package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
  19. package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
  20. package/src/http/routes/twilio-voice-webhook.ts +10 -2
  21. package/src/index.ts +16 -0
  22. package/src/ipc/slack-thread-handlers.ts +39 -0
  23. package/src/risk/bash-risk-classifier.test.ts +24 -0
  24. package/src/risk/command-registry/commands/assistant.ts +33 -0
  25. package/src/risk/command-registry.test.ts +5 -0
  26. package/src/runtime/client.ts +66 -14
  27. package/src/slack/normalize.ts +78 -26
  28. package/src/slack/socket-mode.ts +2 -2
  29. package/src/twilio/validate-webhook.ts +7 -1
  30. package/src/types.ts +1 -0
  31. package/src/velay/client.test.ts +100 -0
  32. package/src/velay/client.ts +73 -0
@@ -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. `ingress.publicBaseUrl` from the config file written by Velay
512
- * after tunnel registration, or set manually for self-hosted.
513
- * 2. `VELAY_BASE_URL` + platform assistant ID fallback for platform
514
- * sidecars before the config cache has observed the registered URL.
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
- if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
528
-
529
- if (config.velayBaseUrl && platformAssistantId) {
530
- const withPath =
531
- config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
532
- const normalizedVelayUrl = normalizePublicBaseUrl(withPath);
533
- if (normalizedVelayUrl) {
534
- return normalizedVelayUrl.replace(/^http(s?)/, "ws$1");
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
- return undefined;
585
+
586
+ return resolveVelayBaseWssUrl(
587
+ config,
588
+ configuredCallbackAssistantId ?? platformAssistantId,
589
+ );
538
590
  }
539
591
 
540
592
  /**
@@ -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 cached = cacheGet(userInfoCache, userId);
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(userId);
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
- const info: SlackUserInfo = { displayName, username };
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
- userId,
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(userId, fetchPromise);
202
+ inFlightUserFetches.set(cacheKey, fetchPromise);
160
203
  try {
161
204
  return await fetchPromise;
162
205
  } finally {
163
- inFlightUserFetches.delete(userId);
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 cached = cacheGet(channelInfoCache, channelId);
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(channelId);
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
- channelId,
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(channelId, fetchPromise);
265
+ inFlightChannelFetches.set(cacheKey, fetchPromise);
222
266
  try {
223
267
  return await fetchPromise;
224
268
  } finally {
225
- inFlightChannelFetches.delete(channelId);
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 cached = cacheGet(userInfoCache, userId);
239
- if (!cached && !inFlightUserFetches.has(userId)) {
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,
@@ -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.displayName = userInfo.displayName;
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 { rawBody, params };
416
+ return {
417
+ rawBody,
418
+ params,
419
+ validatedCandidateUrl: validatingCandidate.url,
420
+ };
415
421
  }
package/src/types.ts CHANGED
@@ -6,4 +6,5 @@ export type {
6
6
  WhatsAppInboundEvent,
7
7
  SlackInboundEvent,
8
8
  EmailInboundEvent,
9
+ A2aInboundEvent,
9
10
  } from "./channels/inbound-event.js";
@@ -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({
@@ -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,