@vellumai/vellum-gateway 0.7.1 → 0.7.2

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 (96) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +62 -20
  3. package/Dockerfile +1 -0
  4. package/README.md +46 -5
  5. package/bun.lock +9 -2
  6. package/knip.json +2 -1
  7. package/package.json +2 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/config-file-watcher.test.ts +181 -0
  10. package/src/__tests__/credential-watcher.test.ts +20 -2
  11. package/src/__tests__/feature-flags-route.test.ts +3 -3
  12. package/src/__tests__/guardian-init-lockfile.test.ts +24 -0
  13. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  14. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  15. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  16. package/src/__tests__/route-schema-guard.test.ts +42 -6
  17. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  18. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  19. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +24 -0
  20. package/src/__tests__/twilio-webhooks.test.ts +220 -2
  21. package/src/__tests__/upstream-transport.test.ts +0 -36
  22. package/src/auth/guardian-refresh.ts +4 -18
  23. package/src/backup/backup-key.ts +138 -0
  24. package/src/backup/backup-routes.ts +159 -0
  25. package/src/backup/backup-worker.ts +374 -0
  26. package/src/backup/list-snapshots.ts +97 -0
  27. package/src/backup/local-writer.ts +87 -0
  28. package/src/backup/offsite-writer.ts +182 -0
  29. package/src/backup/paths.ts +123 -0
  30. package/src/backup/stream-crypt.ts +258 -0
  31. package/src/chrome-extension-origins.ts +28 -0
  32. package/src/config-file-cache.ts +3 -19
  33. package/src/config-file-utils.ts +124 -0
  34. package/src/config-file-watcher.ts +57 -25
  35. package/src/config.ts +4 -0
  36. package/src/db/contact-store.ts +30 -1
  37. package/src/db/data-migrations/index.ts +2 -0
  38. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  39. package/src/db/schema.ts +30 -0
  40. package/src/db/slack-store.ts +144 -11
  41. package/src/feature-flag-registry.json +21 -133
  42. package/src/handlers/handle-inbound.ts +90 -0
  43. package/src/http/middleware/auth.ts +1 -1
  44. package/src/http/middleware/cors.ts +84 -0
  45. package/src/http/middleware/rate-limit.ts +6 -8
  46. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  47. package/src/http/routes/channel-verification-session-proxy.ts +17 -35
  48. package/src/http/routes/contact-prompt.ts +149 -0
  49. package/src/http/routes/log-tail.test.ts +336 -0
  50. package/src/http/routes/log-tail.ts +87 -0
  51. package/src/http/routes/pair.ts +322 -0
  52. package/src/http/routes/privacy-config.ts +65 -79
  53. package/src/http/routes/runtime-proxy.ts +3 -1
  54. package/src/http/routes/stt-stream-websocket.ts +2 -3
  55. package/src/http/routes/twilio-media-websocket.ts +5 -5
  56. package/src/http/routes/twilio-voice-verify-callback.ts +30 -2
  57. package/src/http/routes/twilio-voice-webhook.test.ts +60 -0
  58. package/src/http/routes/twilio-voice-webhook.ts +8 -0
  59. package/src/index.ts +327 -246
  60. package/src/ipc/contact-handlers.ts +88 -3
  61. package/src/ipc/threshold-handlers.ts +2 -0
  62. package/src/risk/bash-risk-classifier.test.ts +35 -3
  63. package/src/risk/bash-risk-classifier.ts +44 -14
  64. package/src/risk/command-registry/commands/assistant.ts +5 -0
  65. package/src/risk/risk-classifier-parity.test.ts +1 -1
  66. package/src/runtime/client.ts +58 -3
  67. package/src/schema.ts +220 -67
  68. package/src/slack/normalize.test.ts +24 -0
  69. package/src/slack/normalize.ts +8 -0
  70. package/src/slack/slack-web.ts +213 -0
  71. package/src/slack/socket-mode.ts +520 -20
  72. package/src/twilio/validate-webhook.ts +53 -14
  73. package/src/twilio/webhook-sync-trigger.ts +58 -0
  74. package/src/twilio/webhook-sync.test.ts +286 -0
  75. package/src/twilio/webhook-sync.ts +84 -0
  76. package/src/util/is-loopback-address.ts +27 -0
  77. package/src/velay/bridge-utils.ts +228 -0
  78. package/src/velay/client.test.ts +939 -0
  79. package/src/velay/client.ts +555 -0
  80. package/src/velay/http-bridge.test.ts +217 -0
  81. package/src/velay/http-bridge.ts +83 -0
  82. package/src/velay/protocol.ts +178 -0
  83. package/src/velay/test-fake-websocket.ts +69 -0
  84. package/src/velay/websocket-bridge.test.ts +367 -0
  85. package/src/velay/websocket-bridge.ts +324 -0
  86. package/src/verification/contact-helpers.ts +137 -0
  87. package/src/version.ts +35 -0
  88. package/src/__tests__/browser-relay-websocket.test.ts +0 -697
  89. package/src/auth/capability-tokens.ts +0 -248
  90. package/src/http/routes/browser-extension-pair.ts +0 -455
  91. package/src/http/routes/browser-relay-websocket.ts +0 -381
  92. package/src/http/routes/config-file-utils.ts +0 -73
  93. package/src/ipc/capability-token-handlers.ts +0 -30
  94. package/src/pairing/approved-devices-store.ts +0 -110
  95. package/src/pairing/pairing-routes.ts +0 -379
  96. package/src/pairing/pairing-store.ts +0 -218
package/AGENTS.md CHANGED
@@ -37,6 +37,10 @@ In Docker mode, the gateway is the sole owner of trust rule storage. Trust files
37
37
 
38
38
  The assistant reads and writes trust rules via the gateway's HTTP trust API instead of accessing the filesystem directly. This ensures the security boundary is enforced at the container level — even if the assistant container is compromised, it cannot tamper with trust rules without going through the gateway's API.
39
39
 
40
+ ### Backup Encryption Key
41
+
42
+ The backup encryption key (`backup.key`) lives in `GATEWAY_SECURITY_DIR` and is never exposed to the assistant daemon or workspace. The gateway owns all backup encryption/decryption — the assistant produces plaintext vbundles via `/v1/migrations/export`, and the gateway encrypts them for offsite storage. The assistant cannot read the key via `file_read` or any other tool. This is a security boundary: even if the assistant is prompt-injected, backup encryption remains intact.
43
+
40
44
  ### Credential Access in Docker Mode
41
45
 
42
46
  In Docker mode, the gateway accesses stored credentials via the CES HTTP API (`CES_CREDENTIAL_URL`), authenticated with `CES_SERVICE_TOKEN`. The gateway does not have direct filesystem access to credential encryption keys (`keys.enc`, `store.key`), which reside on the CES security volume.
package/ARCHITECTURE.md CHANGED
@@ -280,9 +280,10 @@ Channel bindings follow a three-phase lifecycle:
280
280
 
281
281
  The public URL where the gateway is reachable is configured via:
282
282
 
283
- | Source | Description |
284
- | ------------------------------------------ | ---------------------------------------------------------------------------- |
285
- | `ingress.publicBaseUrl` (workspace config) | Set via Settings UI > Public Ingress, or directly in workspace `config.json` |
283
+ | Source | Description |
284
+ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `ingress.publicBaseUrl` (workspace config) | Canonical public ingress URL for Telegram webhooks, OAuth callbacks, email callbacks, generic JSON webhooks, and custom/ngrok tunnel based Twilio fallback |
286
+ | `ingress.twilioPublicBaseUrl` (workspace config) | Twilio-specific public ingress URL written by Velay registration; used only by Twilio URL builders when present |
286
287
 
287
288
  ### Tunnel-Agnostic Setup
288
289
 
@@ -291,7 +292,34 @@ To expose the gateway for external callbacks during local development:
291
292
  1. **Start your tunnel** service (ngrok, Cloudflare Tunnel, or any similar tool), pointing it at the local gateway: `http://127.0.0.1:7830`
292
293
  2. **Set the public URL** provided by the tunnel as `ingress.publicBaseUrl` in the Settings UI (Public Ingress section)
293
294
 
294
- The assistant runtime reads this URL via the centralized `public-ingress-urls.ts` module and uses it to construct all webhook and callback URLs automatically (Twilio voice/status/relay webhooks, Telegram webhooks, OAuth redirect URIs, etc.).
295
+ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts` module and uses it to construct webhook and callback URLs automatically. Ngrok and custom tunnels remain supported for every ingress surface, including Twilio fallback.
296
+
297
+ ### Velay Twilio Ingress
298
+
299
+ Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. It is intentionally additive to the tunnel-agnostic setup above: Velay registration does not overwrite `ingress.publicBaseUrl`, and operators can continue using ngrok or custom tunnels for non-Twilio webhooks.
300
+
301
+ When `VELAY_BASE_URL` is present in the gateway environment, the gateway starts `VelayTunnelClient`. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.twilioPublicBaseUrl` only. When the tunnel disconnects, it clears that same value if it still matches the URL the tunnel published, leaving `ingress.publicBaseUrl` intact.
302
+
303
+ Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
304
+
305
+ ```text
306
+ Public Velay HTTPS/WSS URL
307
+ → Velay tunnel session
308
+ → Gateway Velay bridge
309
+ → Gateway loopback listener (http://127.0.0.1:<GATEWAY_PORT>)
310
+ → Existing gateway route handlers
311
+ ```
312
+
313
+ The HTTP bridge can carry normal JSON requests and health checks, so it is useful for local bridge smoke tests. The advertised URL split is Twilio-scoped in this phase, though: only Twilio URL generation prefers `ingress.twilioPublicBaseUrl`; Telegram webhook registration, OAuth redirects, email callbacks, and generic public URL builders continue to use `ingress.publicBaseUrl`.
314
+
315
+ Local platform smoke-test flow:
316
+
317
+ 1. In `vellum-assistant-platform`, run `vel up velay`.
318
+ 2. Ensure vembda passes `VELAY_BASE_URL=http://host.docker.internal:8501` into assistant gateway containers.
319
+ 3. Re-hatch or restart the assistant so the gateway receives the new environment.
320
+ 4. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
321
+ 5. Verify HTTP forwarding by requesting `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/healthz` and `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/schema`. When validating a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm it reaches the loopback gateway.
322
+ 6. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after `VELAY_PUBLIC_BASE_URL` is backed by a public HTTPS/WSS tunnel.
295
323
 
296
324
  ### URL Builders
297
325
 
@@ -300,10 +328,10 @@ All public-facing URLs are constructed by `assistant/src/inbound/public-ingress-
300
328
  | Function | URL Pattern |
301
329
  | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
302
330
  | `getPublicBaseUrl()` | Resolves the canonical base URL from `ingress.publicBaseUrl` in workspace config or module-level state (assistant-side; the gateway reads via `ConfigFileCache`) |
303
- | `getTwilioVoiceWebhookUrl()` | `${base}/webhooks/twilio/voice?callSessionId=...` |
304
- | `getTwilioStatusCallbackUrl()` | `${base}/webhooks/twilio/status` |
305
- | `getTwilioConnectActionUrl()` | `${base}/webhooks/twilio/connect-action` |
306
- | `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay` |
331
+ | `getTwilioVoiceWebhookUrl()` | `${twilioBase}/webhooks/twilio/voice?callSessionId=...`, where `twilioBase` is `ingress.twilioPublicBaseUrl` when present, otherwise `ingress.publicBaseUrl` |
332
+ | `getTwilioStatusCallbackUrl()` | `${twilioBase}/webhooks/twilio/status`, with the same Twilio-specific base resolution |
333
+ | `getTwilioConnectActionUrl()` | `${twilioBase}/webhooks/twilio/connect-action`, with the same Twilio-specific base resolution |
334
+ | `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay`, with the same Twilio-specific base resolution |
307
335
  | `getOAuthCallbackUrl()` | `${base}/webhooks/oauth/callback` |
308
336
  | `getTelegramWebhookUrl()` | `${base}/webhooks/telegram` |
309
337
 
@@ -702,7 +730,7 @@ The Slack channel enables inbound and outbound messaging via Slack's Socket Mode
702
730
 
703
731
  1. Every Socket Mode envelope is ACKed immediately by echoing `{ envelope_id }` back on the WebSocket — this is required by Slack regardless of whether the event is processed.
704
732
  2. Only `events_api` envelopes with `app_mention` events are processed in MVP. Other envelope types (slash commands, interactive payloads) are ACKed but ignored.
705
- 3. Events are deduplicated by `event_id` using an in-memory `Map<string, number>` with a 24-hour TTL. A periodic cleanup sweep runs every hour to evict expired entries.
733
+ 3. Events are deduplicated by a compound key in the SQLite-backed `slack_seen_events` table: every event records its Slack `event_id`, and message-shaped events additionally record `msg:${channel}:${ts}` so the live and reconnect-replay paths dedup symmetrically. Entries TTL out after 24h; a periodic cleanup sweep evicts expired rows.
706
734
  4. The `normalizeSlackAppMention()` function strips leading bot-mention tokens (`<@U...>`) from the message text and produces a `GatewayInboundEvent` with `sourceChannel: "slack"`, using the Slack channel ID as `conversationExternalId` and the sender's user ID as `actorExternalId`.
707
735
  5. Routing uses the standard `resolveAssistant()` chain (conversation_id -> actor_id -> default/reject). Events that cannot be routed are dropped.
708
736
  6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with a `replyCallbackUrl` pointing to `/deliver/slack`.
@@ -729,13 +757,24 @@ Both tokens are stored in secure storage (`credential/slack_channel/app_token`,
729
757
 
730
758
  The Socket Mode client auto-reconnects on any WebSocket close or error. The backoff schedule is: `min(1000 * 2^attempt, 30000)` + random jitter. After a successful connection, the attempt counter resets. Slack-initiated disconnects (envelope `type: "disconnect"`) trigger an immediate reconnect with no backoff.
731
759
 
760
+ **Reconnect catch-up:**
761
+
762
+ Slack [does not buffer Socket Mode events](https://api.slack.com/apis/socket-mode#events) for disconnected clients, so any @mention or DM that arrives during a reconnect window is lost on the wire. The client recovers these by maintaining a persistent high-watermark (`slack_last_seen_ts`) of the latest accepted event timestamp and, on every WebSocket `open`, fetching a bounded slice of [`conversations.history`](https://api.slack.com/methods/conversations.history) and [`conversations.replies`](https://api.slack.com/methods/conversations.replies) since that watermark. Recovered messages are wrapped in synthetic Socket Mode envelopes and dispatched through the same `processEventPayload` path as live events, so filter, dedup, normalize, and routing logic stay in one place.
763
+
764
+ Catch-up is scoped to channels the gateway can reasonably reach: routing entries with a Slack-shaped conversation ID (`C…` / `D…` / `G…`), tracked active threads (`slack_active_threads`), and previously-seen DM channels (`contact_channels` rows of type `slack`). It is bounded by max-lookback (1h), per-channel limit (50), and concurrency (4); on a 429 the cycle aborts immediately and resumes on the next reconnect. Brand-new mentions in unrouted, never-engaged channels remain unrecoverable here — the daemon's existing inbound-triggered Slack backfill (`triggerSlackThreadBackfillIfNeeded` and `tryBackfillSlackDmIfCold`) hydrates context once the next live event arrives.
765
+
766
+ **General principle — stateful-stream transports require catch-up-on-reconnect:**
767
+
768
+ Any persistent-stream transport that does not buffer events for disconnected clients (Slack Socket Mode, similar WebSocket gateways) must combine the WebSocket with a HTTP catch-up path on reconnect. A persisted high-watermark + bounded replay through the shared event pipeline is the standard pattern; without it, every transient disconnect silently drops events.
769
+
732
770
  **Key modules:**
733
771
 
734
- | Module | Purpose |
735
- | ------------------------------------------ | ---------------------------------------------------------------------------- |
736
- | `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient` — WebSocket lifecycle, ACK, dedup, auto-reconnect |
737
- | `gateway/src/slack/normalize.ts` | `normalizeSlackAppMention()` event normalization and bot-mention stripping |
738
- | `gateway/src/http/routes/slack-deliver.ts` | `/deliver/slack` — outbound message delivery via `chat.postMessage` |
772
+ | Module | Purpose |
773
+ | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
774
+ | `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient` — WebSocket lifecycle, ACK, dedup, auto-reconnect, reconnect catch-up |
775
+ | `gateway/src/slack/slack-web.ts` | `conversations.history` / `conversations.replies` helpers for reconnect catch-up |
776
+ | `gateway/src/slack/normalize.ts` | `normalizeSlackAppMention()`event normalization and bot-mention stripping |
777
+ | `gateway/src/http/routes/slack-deliver.ts` | `/deliver/slack` — outbound message delivery via `chat.postMessage` |
739
778
 
740
779
  **Limitations (MVP):** Text-only — attachments are rejected. Only `app_mention` events are processed (direct messages to the bot are not handled). Rich approval UI (inline buttons) is not supported.
741
780
 
@@ -1038,18 +1077,21 @@ In gateway-fronted deployments, the TwiML WebSocket URL (returned by the voice w
1038
1077
 
1039
1078
  Signature validation is **fail-closed**: if the Twilio auth token is not configured, all webhook requests are rejected with `403`. Missing or invalid `X-Twilio-Signature` headers are also rejected with `403`. Payload size is capped by `maxWebhookPayloadBytes` (checked via both `Content-Length` header and actual body size).
1040
1079
 
1041
- **Webhook base URL resolution:** The base URL used when constructing all public ingress URLs (Twilio webhooks, OAuth callbacks, Telegram webhooks, etc.) is resolved by `public-ingress-urls.ts`:
1080
+ **Webhook base URL resolution:** Public ingress URL construction is centralized in `public-ingress-urls.ts`, with a Twilio-specific override for Velay:
1042
1081
 
1043
- 1. `ingress.publicBaseUrl` in workspace config (set via Settings UI > Public Ingress)
1044
- 2. Module-level state in the assistant (set by config handlers when tunnels start/stop)
1082
+ - Twilio voice/status/connect-action/relay/media-stream URLs use `ingress.twilioPublicBaseUrl` when present. This is the value published by Velay registration.
1083
+ - If `ingress.twilioPublicBaseUrl` is absent, Twilio falls back to `ingress.publicBaseUrl`.
1084
+ - Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs use `ingress.publicBaseUrl`; Velay does not replace those advertised URLs in this phase.
1085
+ - Module-level assistant state remains a fallback for legacy tunnel start/stop flows.
1045
1086
 
1046
1087
  All webhook paths (`/webhooks/twilio/voice`, `/webhooks/twilio/status`, `/webhooks/telegram`, `/webhooks/oauth/callback`, etc.) are appended automatically.
1047
1088
 
1048
1089
  For **inbound Twilio signature validation** at the gateway, URL reconstruction now supports multiple candidates in order:
1049
1090
 
1050
- 1. `ConfigFileCache.getString("ingress", "publicBaseUrl")` (if configured)
1051
- 2. Forwarded public URL headers from the tunnel/proxy (`X-Forwarded-Proto` + `X-Forwarded-Host`/`X-Original-Host` fallbacks)
1052
- 3. Raw request URL (always included as the final fallback)
1091
+ 1. `ConfigFileCache.getString("ingress", "twilioPublicBaseUrl")` (if configured)
1092
+ 2. `ConfigFileCache.getString("ingress", "publicBaseUrl")` (if configured)
1093
+ 3. Forwarded public URL headers from the tunnel/proxy (`X-Forwarded-Proto` + `X-Forwarded-Host`/`X-Original-Host` fallbacks)
1094
+ 4. Raw request URL (always included as the final fallback)
1053
1095
 
1054
1096
  This makes ingress URL updates smoother in local tunnel workflows because Twilio webhooks can continue validating immediately. For Telegram, the config file watcher detects ingress URL changes and triggers webhook reconciliation directly, so neither channel requires a gateway restart.
1055
1097
 
package/Dockerfile CHANGED
@@ -13,6 +13,7 @@ COPY packages/assistant-client ./packages/assistant-client
13
13
  COPY packages/ces-client ./packages/ces-client
14
14
  COPY packages/service-contracts ./packages/service-contracts
15
15
  COPY packages/slack-text ./packages/slack-text
16
+ COPY packages/twilio-client ./packages/twilio-client
16
17
 
17
18
  # Install deps for shared packages that have their own file: dependencies.
18
19
  RUN cd /app/packages/ces-client && bun install --frozen-lockfile
package/README.md CHANGED
@@ -26,11 +26,11 @@ bun run dev
26
26
 
27
27
  ## Configuration
28
28
 
29
- | Variable | Required | Default | Description |
30
- | ------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | Variable | Required | Default | Description |
30
+ | ------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
31
  | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: CES HTTP API first (when `CES_CREDENTIAL_URL` is configured), then the encrypted file store (`~/.vellum/protected/keys.enc`). |
32
- | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
- | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
32
+ | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
+ | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
34
34
 
35
35
  Most gateway behavior is now configured via hardcoded defaults or workspace config (`~/.vellum/workspace/config.json`) rather than environment variables. Channel operational settings (Telegram API base URL, timeouts, deliver auth bypass flags, runtime base URL, routing, proxy settings, attachment limits, shutdown drain) are managed via `workspace/config.json` through `ConfigFileCache`. See the channel-specific sections in `ARCHITECTURE.md` for details.
36
36
 
@@ -176,7 +176,7 @@ The gateway serves as the single public ingress point for all external callbacks
176
176
 
177
177
  ### Tunnel Setup
178
178
 
179
- To receive external callbacks during local development, point a tunnel service at the local gateway (default `http://127.0.0.1:7830`) and configure the resulting public URL:
179
+ To receive external callbacks during local development, point a tunnel service at the local gateway (default `http://127.0.0.1:7830`) and configure the resulting public URL. Ngrok, Cloudflare Tunnel, and other custom HTTPS/WSS tunnels remain supported.
180
180
 
181
181
  #### Test Gateway Source Changes Locally (No Release Needed)
182
182
 
@@ -210,6 +210,47 @@ In local tunnel setups, updating `ingress.publicBaseUrl` in Settings is typicall
210
210
 
211
211
  The assistant runtime uses this URL to construct all webhook and OAuth callback URLs automatically.
212
212
 
213
+ ### Velay for Twilio Local Testing
214
+
215
+ Velay is an additional managed ingress transport for Twilio calls. It does not replace ngrok or `ingress.publicBaseUrl`. In this phase, Velay registration writes only `ingress.twilioPublicBaseUrl`; Twilio URL builders prefer that value when it is present, while Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs continue to use `ingress.publicBaseUrl`.
216
+
217
+ Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
218
+
219
+ 1. In `vellum-assistant-platform`, start the local Velay service:
220
+
221
+ ```bash
222
+ vel up velay
223
+ ```
224
+
225
+ 2. Ensure vembda injects the Velay endpoint into assistant gateway containers:
226
+
227
+ ```bash
228
+ VELAY_BASE_URL=http://host.docker.internal:8501
229
+ ```
230
+
231
+ The `host.docker.internal` host is important for Docker-hosted assistants because the gateway container must dial the Velay service running on the host.
232
+
233
+ 3. Re-hatch or restart the assistant so the gateway process receives `VELAY_BASE_URL`.
234
+ 4. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.twilioPublicBaseUrl` without changing `ingress.publicBaseUrl`.
235
+
236
+ For an HTTP bridge smoke test, send a request to the registered Velay public URL and confirm it reaches the loopback gateway, for example:
237
+
238
+ ```bash
239
+ curl -i "$VELAY_PUBLIC_BASE_URL/<assistant-id>/healthz"
240
+ curl -i "$VELAY_PUBLIC_BASE_URL/<assistant-id>/schema"
241
+ ```
242
+
243
+ When testing a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm the gateway logs or handler response show the request reached the loopback listener.
244
+
245
+ For a synthetic Twilio WebSocket smoke test, connect a local WebSocket client to the Velay public URL using one of the gateway Twilio WebSocket paths, such as:
246
+
247
+ ```bash
248
+ bun -e 'const ws = new WebSocket(process.argv[1]); ws.onopen = () => { console.log("open"); ws.close(); }; ws.onerror = (event) => console.error(event);' \
249
+ "wss://<velay-host>/<assistant-id>/webhooks/twilio/relay?callSessionId=session-123&token=<edge-token>"
250
+ ```
251
+
252
+ For a real Twilio call, expose local Velay with a public HTTPS/WSS tunnel and configure the platform Velay service with that origin as `VELAY_PUBLIC_BASE_URL`. After the assistant re-registers, Twilio should fetch `/webhooks/twilio/voice` and open `/webhooks/twilio/relay` or `/webhooks/twilio/media-stream/...` through the Velay URL. Keep using ngrok or another custom tunnel in `ingress.publicBaseUrl` when you need Telegram, OAuth, email, or non-Twilio webhook ingress.
253
+
213
254
  ## Ingress Boundary Guarantees
214
255
 
215
256
  The gateway is the **sole public ingress point** for all external webhooks. The assistant runtime never directly accepts public webhook traffic — all Twilio and Telegram webhook routes on the runtime return `410 GATEWAY_ONLY` when accessed directly.
package/bun.lock CHANGED
@@ -9,6 +9,7 @@
9
9
  "@vellumai/ces-client": "file:../packages/ces-client",
10
10
  "@vellumai/service-contracts": "file:../packages/service-contracts",
11
11
  "@vellumai/slack-text": "file:../packages/slack-text",
12
+ "@vellumai/twilio-client": "file:../packages/twilio-client",
12
13
  "drizzle-kit": "0.30.6",
13
14
  "drizzle-orm": "0.45.2",
14
15
  "file-type": "21.3.0",
@@ -212,6 +213,8 @@
212
213
 
213
214
  "@vellumai/slack-text": ["@vellumai/slack-text@file:../packages/slack-text", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
214
215
 
216
+ "@vellumai/twilio-client": ["@vellumai/twilio-client@file:../packages/twilio-client", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
217
+
215
218
  "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
216
219
 
217
220
  "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -352,7 +355,7 @@
352
355
 
353
356
  "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
354
357
 
355
- "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
358
+ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
356
359
 
357
360
  "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
358
361
 
@@ -494,7 +497,7 @@
494
497
 
495
498
  "@vellumai/ces-client/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
496
499
 
497
- "@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", { "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
500
+ "@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", {}],
498
501
 
499
502
  "@vellumai/ces-client/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
500
503
 
@@ -504,6 +507,8 @@
504
507
 
505
508
  "@vellumai/slack-text/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
506
509
 
510
+ "@vellumai/twilio-client/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
511
+
507
512
  "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
508
513
 
509
514
  "gel/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -566,6 +571,8 @@
566
571
 
567
572
  "@vellumai/slack-text/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
568
573
 
574
+ "@vellumai/twilio-client/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
575
+
569
576
  "gel/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
570
577
 
571
578
  "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
package/knip.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "@vellumai/assistant-client",
6
6
  "@vellumai/ces-client",
7
7
  "@vellumai/service-contracts",
8
- "@vellumai/slack-text"
8
+ "@vellumai/slack-text",
9
+ "@vellumai/twilio-client"
9
10
  ]
10
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,6 +28,7 @@
28
28
  "@vellumai/ces-client": "file:../packages/ces-client",
29
29
  "@vellumai/service-contracts": "file:../packages/service-contracts",
30
30
  "@vellumai/slack-text": "file:../packages/slack-text",
31
+ "@vellumai/twilio-client": "file:../packages/twilio-client",
31
32
  "drizzle-kit": "0.30.6",
32
33
  "drizzle-orm": "0.45.2",
33
34
  "file-type": "21.3.0",
@@ -52,6 +52,7 @@ describe("auto-approve thresholds", () => {
52
52
  expect(data).toEqual({
53
53
  interactive: "medium",
54
54
  autonomous: "low",
55
+ headless: "none",
55
56
  });
56
57
  });
57
58
 
@@ -59,16 +60,15 @@ describe("auto-approve thresholds", () => {
59
60
  const putHandler = createGlobalThresholdPutHandler();
60
61
  const getHandler = createGlobalThresholdGetHandler();
61
62
 
62
- // First PUT to set values
63
63
  await putHandler(makeRequest({ interactive: "none" }));
64
64
 
65
- // GET should reflect the update
66
65
  const res = await getHandler(makeRequest(undefined, "GET"));
67
66
  expect(res.status).toBe(200);
68
67
  const data = await res.json();
69
68
  expect(data).toEqual({
70
69
  interactive: "none",
71
70
  autonomous: "low",
71
+ headless: "none",
72
72
  });
73
73
  });
74
74
  });
@@ -83,6 +83,7 @@ describe("auto-approve thresholds", () => {
83
83
  expect(data).toEqual({
84
84
  interactive: "none",
85
85
  autonomous: "low",
86
+ headless: "none",
86
87
  });
87
88
  });
88
89
 
@@ -105,6 +106,7 @@ describe("auto-approve thresholds", () => {
105
106
  expect(data).toEqual({
106
107
  interactive: "high",
107
108
  autonomous: "low",
109
+ headless: "none",
108
110
  });
109
111
  });
110
112
 
@@ -160,7 +162,6 @@ describe("auto-approve thresholds", () => {
160
162
  test("upserts correctly — first write creates, second write updates", async () => {
161
163
  const handler = createGlobalThresholdPutHandler();
162
164
 
163
- // First write — creates the row
164
165
  const res1 = await handler(
165
166
  makeRequest({ interactive: "none", autonomous: "low" }),
166
167
  );
@@ -169,17 +170,16 @@ describe("auto-approve thresholds", () => {
169
170
  expect(data1).toEqual({
170
171
  interactive: "none",
171
172
  autonomous: "low",
173
+ headless: "none",
172
174
  });
173
175
 
174
- // Second write updates the existing row
175
- const res2 = await handler(
176
- makeRequest({ autonomous: "medium" }),
177
- );
176
+ const res2 = await handler(makeRequest({ autonomous: "medium" }));
178
177
  expect(res2.status).toBe(200);
179
178
  const data2 = await res2.json();
180
179
  expect(data2).toEqual({
181
180
  interactive: "none",
182
181
  autonomous: "medium",
182
+ headless: "none",
183
183
  });
184
184
  });
185
185
 
@@ -187,42 +187,69 @@ describe("auto-approve thresholds", () => {
187
187
  const handler = createGlobalThresholdPutHandler();
188
188
 
189
189
  const res = await handler(
190
- makeRequest({
191
- interactive: "medium",
192
- autonomous: "low",
193
- }),
190
+ makeRequest({ interactive: "medium", autonomous: "low" }),
194
191
  );
195
192
  expect(res.status).toBe(200);
196
193
  const data = await res.json();
197
194
  expect(data).toEqual({
198
195
  interactive: "medium",
199
196
  autonomous: "low",
197
+ headless: "none",
200
198
  });
201
199
  });
202
200
 
203
201
  test("empty object preserves existing values when row exists", async () => {
204
202
  const putHandler = createGlobalThresholdPutHandler();
205
203
 
206
- // First: set non-default values
207
- await putHandler(
208
- makeRequest({
209
- interactive: "medium",
210
- autonomous: "low",
211
- }),
212
- );
204
+ await putHandler(makeRequest({ interactive: "medium", autonomous: "low" }));
213
205
 
214
- // Then PUT empty object — existing values should be preserved
215
206
  const res = await putHandler(makeRequest({}));
216
207
  expect(res.status).toBe(200);
217
208
  const data = await res.json();
218
209
  expect(data).toEqual({
219
210
  interactive: "medium",
220
211
  autonomous: "low",
212
+ headless: "none",
213
+ });
214
+ });
215
+
216
+ // ── headless field ────────────────────────────────────────────────────────
217
+
218
+ test("can set headless threshold", async () => {
219
+ const handler = createGlobalThresholdPutHandler();
220
+
221
+ const res = await handler(makeRequest({ headless: "low" }));
222
+ expect(res.status).toBe(200);
223
+ const data = await res.json();
224
+ expect(data).toEqual({
225
+ interactive: "medium",
226
+ autonomous: "low",
227
+ headless: "low",
221
228
  });
222
229
  });
223
230
 
224
- // Note: "empty PUT inserts schema defaults when no row" is covered by
225
- // the GET handler test suite. The PUT tests run after prior PUTs leave
226
- // a row in the DB (bun test reuse), so we test preserve-existing above.
231
+ test("returns 400 for invalid headless value", async () => {
232
+ const handler = createGlobalThresholdPutHandler();
233
+
234
+ const res = await handler(makeRequest({ headless: "extreme" }));
235
+ expect(res.status).toBe(400);
236
+ const data = await res.json();
237
+ expect(data.error).toContain("headless");
238
+ expect(data.error).toContain("none, low, medium, high");
239
+ });
240
+
241
+ test("headless is preserved when other fields are updated", async () => {
242
+ const handler = createGlobalThresholdPutHandler();
243
+
244
+ // Explicitly set headless to a non-default value
245
+ await handler(makeRequest({ headless: "low" }));
246
+
247
+ // Update only interactive — headless should remain "low"
248
+ const res = await handler(makeRequest({ interactive: "high" }));
249
+ expect(res.status).toBe(200);
250
+ const data = await res.json();
251
+ expect(data.headless).toBe("low");
252
+ expect(data.interactive).toBe("high");
253
+ });
227
254
  });
228
255
  });