@vellumai/vellum-gateway 0.7.0 → 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 (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. 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
@@ -128,7 +128,7 @@ The assistant daemon does not read or distribute a feature-flag token. All featu
128
128
 
129
129
  ### Channel Verification Session Control-Plane Proxy
130
130
 
131
- Channel verification session endpoints are exposed directly by the gateway and forwarded to runtime integration handlers even when the broad runtime proxy is disabled. This keeps assistant skills and user-facing tooling on gateway URLs only.
131
+ Channel verification session endpoints are exposed directly by the gateway and forwarded to runtime integration handlers for dedicated auth handling. This keeps assistant skills and user-facing tooling on gateway URLs only.
132
132
 
133
133
  **Forwarded endpoints:**
134
134
 
@@ -158,7 +158,7 @@ The `/v1/guardian/refresh` endpoint is the only public ingress for rotating JWT
158
158
 
159
159
  ### Runtime Health Proxy
160
160
 
161
- Runtime health is exposed directly by the gateway at `GET /v1/health` and forwarded to the runtime's `GET /v1/health` endpoint even when the broad runtime proxy is disabled.
161
+ Runtime health is exposed directly by the gateway at `GET /v1/health` and forwarded to the runtime's `GET /v1/health` endpoint for dedicated auth handling.
162
162
 
163
163
  **Authentication boundary:**
164
164
 
@@ -175,7 +175,7 @@ Runtime health is exposed directly by the gateway at `GET /v1/health` and forwar
175
175
 
176
176
  ### Telegram + Contacts Control-Plane Proxies
177
177
 
178
- Telegram integration setup/config endpoints and contacts/invites endpoints are also exposed directly by the gateway and forwarded to runtime handlers even when the broad runtime proxy is disabled.
178
+ Telegram integration setup/config endpoints and contacts/invites endpoints are also exposed directly by the gateway and forwarded to runtime handlers for dedicated auth handling.
179
179
 
180
180
  **Forwarded Telegram endpoints:**
181
181
 
@@ -213,7 +213,7 @@ Telegram integration setup/config endpoints and contacts/invites endpoints are a
213
213
 
214
214
  ### Twilio Control-Plane Proxy
215
215
 
216
- Twilio integration setup/config endpoints are exposed directly by the gateway and forwarded to runtime handlers even when the broad runtime proxy is disabled. This keeps skills and clients on gateway URLs exclusively.
216
+ Twilio integration setup/config endpoints are exposed directly by the gateway and forwarded to runtime handlers for dedicated auth handling. This keeps skills and clients on gateway URLs exclusively.
217
217
 
218
218
  **Forwarded endpoints:**
219
219
 
@@ -242,7 +242,7 @@ Twilio integration setup/config endpoints are exposed directly by the gateway an
242
242
 
243
243
  ### Channel Readiness Proxy
244
244
 
245
- Channel readiness endpoints are exposed directly by the gateway and forwarded to runtime handlers even when the broad runtime proxy is disabled.
245
+ Channel readiness endpoints are exposed directly by the gateway and forwarded to runtime handlers for dedicated auth handling.
246
246
 
247
247
  **Forwarded endpoints:**
248
248
 
@@ -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
@@ -12,6 +12,8 @@ COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
12
12
  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
+ COPY packages/slack-text ./packages/slack-text
16
+ COPY packages/twilio-client ./packages/twilio-client
15
17
 
16
18
  # Install deps for shared packages that have their own file: dependencies.
17
19
  RUN cd /app/packages/ces-client && bun install --frozen-lockfile
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vellum Gateway
2
2
 
3
- Standalone service that serves as the public ingress boundary for all external webhooks and callbacks. It owns Telegram integration end-to-end, routes Twilio voice webhooks, handles OAuth callbacks, and optionally acts as an authenticated reverse proxy for the assistant runtime.
3
+ Standalone service that serves as the public ingress boundary for all external webhooks and callbacks. It owns Telegram integration end-to-end, routes Twilio voice webhooks, handles OAuth callbacks, and acts as an authenticated reverse proxy for the assistant runtime.
4
4
 
5
5
  ## Architecture
6
6
 
@@ -10,7 +10,7 @@ Telegram → gateway/ → Assistant Runtime (/v1/assistants/:id/channels/inbound
10
10
  Client → gateway/ (Bearer auth) → Assistant Runtime (any path)
11
11
  ```
12
12
 
13
- The web app is **not** in the Telegram request path. When proxy mode is enabled, non-Telegram requests are forwarded to the assistant runtime with optional bearer token authentication.
13
+ The web app is **not** in the Telegram request path. All non-Telegram requests that don't match a dedicated gateway route are forwarded to the assistant runtime with bearer token authentication.
14
14
 
15
15
  For ingress and channel architecture details, see [`ARCHITECTURE.md`](ARCHITECTURE.md).
16
16
 
@@ -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.
@@ -218,13 +259,9 @@ The gateway is the **sole public ingress point** for all external webhooks. The
218
259
 
219
260
  When the ingress public base URL is configured (via `ingress.publicBaseUrl` in workspace config, read through `ConfigFileCache`), the gateway prioritizes it as the canonical URL for Twilio signature validation. If the signature only validates against the raw local request URL (fallback), a warning is logged indicating potential drift between the configured ingress URL and the actual webhook registration. The raw URL fallback is preserved for local-dev operability.
220
261
 
221
- ## Default Mode: Dedicated Routes Only
222
-
223
- By default, the broad runtime proxy is disabled. Dedicated gateway-managed routes (webhooks, delivery endpoints, explicit control-plane proxies such as `/v1/channel-verification-sessions/*`, `/v1/integrations/telegram/*`, `/v1/integrations/slack/*`, and `/v1/contacts/invites/*`, plus the authenticated runtime health route `/v1/health`) remain available, but arbitrary runtime passthrough routes return `404` unless the runtime proxy is enabled via workspace config.
224
-
225
- ## Runtime Proxy Mode
262
+ ## Runtime Proxy
226
263
 
227
- When the runtime proxy is enabled (via workspace config), the gateway forwards all non-Telegram HTTP requests to the assistant runtime. This allows the gateway to serve as a single ingress point for both Telegram and API traffic.
264
+ The gateway acts as the single ingress point for all traffic. Dedicated gateway routes (webhooks, control-plane proxies, health checks) are matched first; any request that doesn't match a specific route is forwarded to the assistant runtime via a catch-all proxy.
228
265
 
229
266
  ### Auth behavior
230
267
 
package/bun.lock CHANGED
@@ -8,6 +8,8 @@
8
8
  "@vellumai/assistant-client": "file:../packages/assistant-client",
9
9
  "@vellumai/ces-client": "file:../packages/ces-client",
10
10
  "@vellumai/service-contracts": "file:../packages/service-contracts",
11
+ "@vellumai/slack-text": "file:../packages/slack-text",
12
+ "@vellumai/twilio-client": "file:../packages/twilio-client",
11
13
  "drizzle-kit": "0.30.6",
12
14
  "drizzle-orm": "0.45.2",
13
15
  "file-type": "21.3.0",
@@ -209,6 +211,10 @@
209
211
 
210
212
  "@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" } }],
211
213
 
214
+ "@vellumai/slack-text": ["@vellumai/slack-text@file:../packages/slack-text", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
215
+
216
+ "@vellumai/twilio-client": ["@vellumai/twilio-client@file:../packages/twilio-client", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
217
+
212
218
  "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
213
219
 
214
220
  "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -349,7 +355,7 @@
349
355
 
350
356
  "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
351
357
 
352
- "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=="],
353
359
 
354
360
  "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
355
361
 
@@ -491,7 +497,7 @@
491
497
 
492
498
  "@vellumai/ces-client/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
493
499
 
494
- "@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", {}],
495
501
 
496
502
  "@vellumai/ces-client/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
497
503
 
@@ -499,6 +505,10 @@
499
505
 
500
506
  "@vellumai/service-contracts/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
501
507
 
508
+ "@vellumai/slack-text/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
509
+
510
+ "@vellumai/twilio-client/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
511
+
502
512
  "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
503
513
 
504
514
  "gel/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -559,6 +569,10 @@
559
569
 
560
570
  "@vellumai/service-contracts/@types/bun/bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
561
571
 
572
+ "@vellumai/slack-text/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
573
+
574
+ "@vellumai/twilio-client/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
575
+
562
576
  "gel/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
563
577
 
564
578
  "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
package/knip.json CHANGED
@@ -4,6 +4,8 @@
4
4
  "ignoreDependencies": [
5
5
  "@vellumai/assistant-client",
6
6
  "@vellumai/ces-client",
7
- "@vellumai/service-contracts"
7
+ "@vellumai/service-contracts",
8
+ "@vellumai/slack-text",
9
+ "@vellumai/twilio-client"
8
10
  ]
9
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,8 @@
27
27
  "@vellumai/assistant-client": "file:../packages/assistant-client",
28
28
  "@vellumai/ces-client": "file:../packages/ces-client",
29
29
  "@vellumai/service-contracts": "file:../packages/service-contracts",
30
+ "@vellumai/slack-text": "file:../packages/slack-text",
31
+ "@vellumai/twilio-client": "file:../packages/twilio-client",
30
32
  "drizzle-kit": "0.30.6",
31
33
  "drizzle-orm": "0.45.2",
32
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
  });
@@ -27,7 +27,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
27
27
  defaultAssistantId: undefined,
28
28
  unmappedPolicy: "reject",
29
29
  port: 7830,
30
- runtimeProxyEnabled: false,
31
30
  runtimeProxyRequireAuth: true,
32
31
  shutdownDrainMs: 5000,
33
32
  runtimeTimeoutMs: 30000,