@vellumai/vellum-gateway 0.1.10 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -2
- package/README.md +171 -5
- package/package.json +2 -1
- package/src/__tests__/config.test.ts +28 -3
- package/src/__tests__/credential-reader.test.ts +291 -0
- package/src/__tests__/dedup-cache.test.ts +97 -6
- package/src/__tests__/load-guards.test.ts +6 -2
- package/src/__tests__/oauth-callback.test.ts +5 -1
- package/src/__tests__/probes.test.ts +19 -25
- package/src/__tests__/resolve-assistant.test.ts +6 -2
- package/src/__tests__/runtime-client.test.ts +27 -9
- package/src/__tests__/runtime-proxy-auth.test.ts +6 -2
- package/src/__tests__/runtime-proxy.test.ts +115 -2
- package/src/__tests__/schema.test.ts +19 -20
- package/src/__tests__/sms-ingress-guard.test.ts +130 -0
- package/src/__tests__/telegram-api-redaction.test.ts +137 -0
- package/src/__tests__/telegram-deliver-auth.test.ts +416 -0
- package/src/__tests__/telegram-normalize.test.ts +159 -2
- package/src/__tests__/telegram-only-default.test.ts +29 -33
- package/src/__tests__/telegram-reconcile-route.test.ts +276 -0
- package/src/__tests__/telegram-send-attachments.test.ts +204 -2
- package/src/__tests__/telegram-webhook-handler.test.ts +529 -0
- package/src/__tests__/telegram-webhook-manager.test.ts +196 -0
- package/src/__tests__/twilio-relay-websocket.test.ts +11 -7
- package/src/__tests__/twilio-webhooks.test.ts +66 -10
- package/src/config.ts +64 -13
- package/src/credential-reader.ts +68 -11
- package/src/dedup-cache.ts +138 -3
- package/src/handlers/handle-inbound.ts +28 -20
- package/src/http/routes/runtime-proxy.ts +20 -0
- package/src/http/routes/sms-deliver.test.ts +230 -0
- package/src/http/routes/sms-deliver.ts +113 -0
- package/src/http/routes/telegram-deliver.test.ts +436 -0
- package/src/http/routes/telegram-deliver.ts +104 -4
- package/src/http/routes/telegram-reconcile.ts +80 -0
- package/src/http/routes/telegram-webhook.test.ts +188 -0
- package/src/http/routes/telegram-webhook.ts +169 -22
- package/src/http/routes/twilio-relay-websocket.ts +8 -8
- package/src/http/routes/twilio-sms-webhook.test.ts +325 -0
- package/src/http/routes/twilio-sms-webhook.ts +144 -0
- package/src/index.ts +45 -8
- package/src/logger.ts +3 -2
- package/src/runtime/client.ts +51 -5
- package/src/schema.ts +336 -1
- package/src/telegram/api.ts +33 -2
- package/src/telegram/normalize.test.ts +65 -0
- package/src/telegram/normalize.ts +75 -1
- package/src/telegram/send.test.ts +170 -0
- package/src/telegram/send.ts +72 -17
- package/src/telegram/webhook-manager.ts +61 -0
- package/src/twilio/validate-webhook.ts +58 -14
- package/src/types.ts +3 -1
package/.env.example
CHANGED
|
@@ -58,6 +58,22 @@ ASSISTANT_RUNTIME_BASE_URL=http://localhost:7821
|
|
|
58
58
|
# Optional: Max concurrent attachment download/upload operations (default: 3)
|
|
59
59
|
# GATEWAY_MAX_ATTACHMENT_CONCURRENCY=3
|
|
60
60
|
|
|
61
|
-
# Optional: Canonical public
|
|
62
|
-
#
|
|
61
|
+
# Optional: Canonical public base URL where the gateway is reachable externally.
|
|
62
|
+
# Used by the assistant runtime to construct webhook and OAuth callback URLs.
|
|
63
|
+
# Required for Twilio SMS webhook signature validation when behind a tunnel.
|
|
64
|
+
# Set this to your tunnel's public URL during local development.
|
|
63
65
|
# INGRESS_PUBLIC_BASE_URL=https://your-public-domain.com
|
|
66
|
+
|
|
67
|
+
# --- SMS (Twilio) ---
|
|
68
|
+
|
|
69
|
+
# Optional: Twilio Account SID for outbound SMS via the Messages API
|
|
70
|
+
# TWILIO_ACCOUNT_SID=
|
|
71
|
+
|
|
72
|
+
# Optional: Twilio Auth Token for webhook signature validation and outbound SMS
|
|
73
|
+
# TWILIO_AUTH_TOKEN=
|
|
74
|
+
|
|
75
|
+
# Optional: Twilio phone number (E.164) used as the From number for outbound SMS
|
|
76
|
+
# TWILIO_PHONE_NUMBER=
|
|
77
|
+
|
|
78
|
+
# Optional: Dev-only bypass for bearer auth on /deliver/sms (default: false)
|
|
79
|
+
# GATEWAY_SMS_DELIVER_AUTH_BYPASS=false
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vellum Gateway
|
|
2
2
|
|
|
3
|
-
Standalone service that owns Telegram integration end-to-end 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 and SMS webhooks, handles OAuth callbacks, and optionally acts as an authenticated reverse proxy for the assistant runtime.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
@@ -26,16 +26,19 @@ bun run dev
|
|
|
26
26
|
|
|
27
27
|
| Variable | Required | Default | Description |
|
|
28
28
|
|----------|----------|---------|-------------|
|
|
29
|
-
| `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset) |
|
|
30
|
-
| `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset) |
|
|
29
|
+
| `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 via the credential reader fallback chain: macOS Keychain first (via `security` CLI), then encrypted file store (`~/.vellum/protected/keys.enc`). The keychain reader discriminates exit code 44 (`errSecItemNotFound` — credential genuinely missing) from other non-zero exit codes (transient errors), logging the latter as warnings. On non-macOS platforms, only the encrypted store is used. |
|
|
30
|
+
| `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
|
|
31
31
|
| `TELEGRAM_API_BASE_URL` | No | `https://api.telegram.org` | Override Telegram API base URL |
|
|
32
32
|
| `ASSISTANT_RUNTIME_BASE_URL` | Yes | — | Base URL of the assistant runtime HTTP server |
|
|
33
33
|
| `GATEWAY_ASSISTANT_ROUTING_JSON` | No | `{}` | JSON mapping of Telegram identities to assistant IDs |
|
|
34
34
|
| `GATEWAY_DEFAULT_ASSISTANT_ID` | No | — | Default assistant ID for unmapped users |
|
|
35
35
|
| `GATEWAY_UNMAPPED_POLICY` | No | `reject` | Policy for unmapped users: `reject` or `default` |
|
|
36
36
|
| `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
|
|
37
|
+
| `GATEWAY_INTERNAL_BASE_URL` | No | `http://127.0.0.1:${GATEWAY_PORT}` | Base URL for runtime→gateway callbacks (e.g., the `replyCallbackUrl` sent to the assistant runtime for Telegram reply delivery). Defaults to `http://127.0.0.1:${GATEWAY_PORT}`. Override when the gateway and runtime are not co-located (e.g., separate containers, hosts, or behind a service mesh). |
|
|
38
|
+
| `INGRESS_PUBLIC_BASE_URL` | No | — | Public URL where the gateway is reachable (e.g. `https://abc123.ngrok-free.app`). Used by the assistant runtime to construct webhook and OAuth callback URLs. Set this to your tunnel's public URL. |
|
|
37
39
|
| `GATEWAY_RUNTIME_PROXY_ENABLED` | No | `false` | Enable runtime proxy for non-Telegram requests |
|
|
38
40
|
| `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH` | No | `true` | Require bearer auth for proxied requests |
|
|
41
|
+
| `RUNTIME_BEARER_TOKEN` | No | `~/.vellum/http-token` (if present) | Bearer token used by gateway when forwarding requests to assistant runtime internal endpoints (Twilio/OAuth/proxy upstream). |
|
|
39
42
|
| `RUNTIME_PROXY_BEARER_TOKEN` | Conditional | — | Bearer token for proxy auth (required when proxy + auth enabled) |
|
|
40
43
|
| `GATEWAY_SHUTDOWN_DRAIN_MS` | No | `5000` | Graceful shutdown drain window in milliseconds |
|
|
41
44
|
| `GATEWAY_RUNTIME_TIMEOUT_MS` | No | `30000` | Timeout for runtime HTTP calls (ms) |
|
|
@@ -45,6 +48,11 @@ bun run dev
|
|
|
45
48
|
| `GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES` | No | `1048576` | Max inbound webhook payload size (rejects with 413) |
|
|
46
49
|
| `GATEWAY_MAX_ATTACHMENT_BYTES` | No | `20971520` | Max single attachment size (oversized are skipped) |
|
|
47
50
|
| `GATEWAY_MAX_ATTACHMENT_CONCURRENCY` | No | `3` | Max concurrent attachment download/upload operations |
|
|
51
|
+
| `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/telegram` when no token is configured |
|
|
52
|
+
| `TWILIO_ACCOUNT_SID` | No | — | Twilio Account SID for sending outbound SMS via the Messages API |
|
|
53
|
+
| `TWILIO_AUTH_TOKEN` | No | — | Twilio Auth Token for HMAC-SHA1 webhook signature validation and outbound SMS |
|
|
54
|
+
| `TWILIO_PHONE_NUMBER` | No | — | Twilio phone number (E.164) used as the `From` for outbound SMS |
|
|
55
|
+
| `GATEWAY_SMS_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/sms` when no token is configured |
|
|
48
56
|
|
|
49
57
|
## Routing
|
|
50
58
|
|
|
@@ -65,13 +73,158 @@ v1 uses deterministic settings-based routing (no database):
|
|
|
65
73
|
|
|
66
74
|
## Setting up the Telegram webhook
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
Webhook registration is now handled automatically by the gateway. On startup, the gateway reconciles the Telegram webhook by registering it at `${INGRESS_PUBLIC_BASE_URL}/webhooks/telegram` with the configured secret and allowed updates. This also runs whenever the credential watcher detects changes to the bot token or webhook secret (e.g., secret rotation). If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
|
|
77
|
+
|
|
78
|
+
For manual setup (or reference), register the webhook with Telegram using the `setWebhook` API method. Pass:
|
|
69
79
|
- `url` — your gateway URL, e.g. `https://your-host/webhooks/telegram`
|
|
70
80
|
- The verify value matching your `TELEGRAM_WEBHOOK_SECRET` env var
|
|
71
|
-
- `allowed_updates` — `["message", "edited_message"]`
|
|
81
|
+
- `allowed_updates` — `["message", "edited_message", "callback_query"]`
|
|
72
82
|
|
|
73
83
|
See the [Telegram Bot API docs](https://core.telegram.org/bots/api#setwebhook) for the full API reference.
|
|
74
84
|
|
|
85
|
+
## Telegram Deliver Endpoint Security
|
|
86
|
+
|
|
87
|
+
The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed). The security behavior is:
|
|
88
|
+
|
|
89
|
+
| Condition | Result |
|
|
90
|
+
|-----------|--------|
|
|
91
|
+
| Bearer token configured + valid `Authorization` header | Request allowed |
|
|
92
|
+
| Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
|
|
93
|
+
| No bearer token configured + `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) |
|
|
94
|
+
| No bearer token configured + bypass not set | 503 Service Not Configured |
|
|
95
|
+
|
|
96
|
+
This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, always configure `RUNTIME_PROXY_BEARER_TOKEN`. The `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` flag is intended for local development only.
|
|
97
|
+
|
|
98
|
+
## SMS Ingress (Twilio)
|
|
99
|
+
|
|
100
|
+
The `/webhooks/twilio/sms` endpoint receives inbound SMS messages from Twilio. On each request:
|
|
101
|
+
|
|
102
|
+
1. **Signature validation** — The `X-Twilio-Signature` header is validated using HMAC-SHA1 with the `TWILIO_AUTH_TOKEN`. When behind a tunnel or reverse proxy, the gateway reconstructs the canonical request URL from `INGRESS_PUBLIC_BASE_URL` for validation.
|
|
103
|
+
2. **MessageSid dedup** — Each `MessageSid` is tracked in an in-memory dedup cache. Duplicate webhook deliveries (Twilio retries) are silently accepted without re-forwarding.
|
|
104
|
+
3. **Normalization** — The form-encoded Twilio payload is normalized into a `GatewayInboundEventV1` with `sourceChannel: "sms"`. The sender's phone number (`From`) is used as both `externalChatId` and `externalUserId`.
|
|
105
|
+
4. **Routing** — The same routing resolver used for Telegram (chat_id -> user_id -> default/reject) determines the target assistant.
|
|
106
|
+
5. **Forwarding** — The event is forwarded to the runtime via `POST /channels/inbound` with SMS-specific transport hints (`chat-first-medium`, `sms-character-limits`, etc.) and a `replyCallbackUrl` pointing to `/deliver/sms`.
|
|
107
|
+
|
|
108
|
+
SMS is text-only in v1 — MMS (media attachments) is deferred.
|
|
109
|
+
|
|
110
|
+
## SMS Deliver Endpoint Security
|
|
111
|
+
|
|
112
|
+
The `/deliver/sms` endpoint requires the same fail-closed bearer auth as `/deliver/telegram`:
|
|
113
|
+
|
|
114
|
+
| Condition | Result |
|
|
115
|
+
|-----------|--------|
|
|
116
|
+
| Bearer token configured + valid `Authorization` header | Request allowed |
|
|
117
|
+
| Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
|
|
118
|
+
| No bearer token configured + `GATEWAY_SMS_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) |
|
|
119
|
+
| No bearer token configured + bypass not set | 503 Service Not Configured |
|
|
120
|
+
|
|
121
|
+
The endpoint also requires `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_PHONE_NUMBER` to be configured. If any are missing, requests return `503 SMS integration not configured`.
|
|
122
|
+
|
|
123
|
+
Outbound SMS is sent via the Twilio Messages API using the configured `TWILIO_PHONE_NUMBER` as the `From` number. The request body is `{ to: string, text: string }`.
|
|
124
|
+
|
|
125
|
+
## Callback Query Handling
|
|
126
|
+
|
|
127
|
+
The gateway normalizes Telegram `callback_query` updates (inline button clicks) into the same `GatewayInboundEventV1` format used for regular messages. When a `callback_query` is present in the webhook payload, the normalizer extracts:
|
|
128
|
+
|
|
129
|
+
- `callbackQueryId` — the Telegram callback query ID
|
|
130
|
+
- `callbackData` — the opaque data string attached to the button (e.g., `apr:<runId>:<action>`)
|
|
131
|
+
- `content` — set to the callback data string (so the runtime always has content to process)
|
|
132
|
+
|
|
133
|
+
These fields are forwarded to the runtime in the `/channels/inbound` payload alongside the standard `externalChatId`, `externalMessageId`, and sender metadata. The runtime uses `callbackData` to route the click to the appropriate approval handler.
|
|
134
|
+
|
|
135
|
+
## Approval Buttons and Inline Keyboard
|
|
136
|
+
|
|
137
|
+
The `/deliver/telegram` endpoint accepts an optional `approval` field in the request body. When present, the gateway renders Telegram inline keyboard buttons below the message text.
|
|
138
|
+
|
|
139
|
+
**Approval payload shape:**
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"chatId": "123456",
|
|
144
|
+
"text": "The assistant wants to use the tool \"bash\". Do you want to allow this?",
|
|
145
|
+
"approval": {
|
|
146
|
+
"runId": "run-uuid",
|
|
147
|
+
"requestId": "request-uuid",
|
|
148
|
+
"actions": [
|
|
149
|
+
{ "id": "approve_once", "label": "Approve once" },
|
|
150
|
+
{ "id": "approve_always", "label": "Approve always" },
|
|
151
|
+
{ "id": "reject", "label": "Reject" }
|
|
152
|
+
],
|
|
153
|
+
"plainTextFallback": "Reply \"yes\" to approve once, \"always\" to approve always, or \"no\" to reject."
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Inline keyboard format:** Each action is rendered as a single-button row. The callback data uses the compact format `apr:<runId>:<action>` (e.g., `apr:run-uuid:approve_once`) so the runtime can parse it back when the button is clicked.
|
|
159
|
+
|
|
160
|
+
**Fallback behavior:** For non-Telegram channels that do not support inline keyboards, the `plainTextFallback` string is included in the prompt text, providing plain-text instructions for the user to type their decision.
|
|
161
|
+
|
|
162
|
+
## Public Ingress Routes
|
|
163
|
+
|
|
164
|
+
The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding:
|
|
165
|
+
|
|
166
|
+
| Route | Method | Description |
|
|
167
|
+
|-------|--------|-------------|
|
|
168
|
+
| `/webhooks/telegram` | POST | Telegram bot webhook (validated via `TELEGRAM_WEBHOOK_SECRET`) |
|
|
169
|
+
| `/deliver/telegram` | POST | Internal endpoint for the assistant runtime to deliver outbound messages/attachments to Telegram chats |
|
|
170
|
+
| `/webhooks/twilio/voice` | POST | Twilio voice webhook (validated via HMAC-SHA1 signature) |
|
|
171
|
+
| `/webhooks/twilio/status` | POST | Twilio status callback (validated via HMAC-SHA1 signature) |
|
|
172
|
+
| `/webhooks/twilio/connect-action` | POST | Twilio connect-action callback (validated via HMAC-SHA1 signature) |
|
|
173
|
+
| `/webhooks/twilio/relay` | WS | Twilio ConversationRelay WebSocket (bidirectional proxy to runtime, requires `callSessionId` query param) |
|
|
174
|
+
| `/webhooks/twilio/sms` | POST | Twilio SMS webhook — validates X-Twilio-Signature (HMAC-SHA1), normalizes into `GatewayInboundEventV1` with `sourceChannel: "sms"`, deduplicates by `MessageSid`, and forwards to runtime |
|
|
175
|
+
| `/deliver/sms` | POST | Internal endpoint for the assistant runtime to deliver outbound SMS messages via the Twilio Messages API |
|
|
176
|
+
| `/webhooks/oauth/callback` | GET | OAuth2 callback endpoint — receives authorization codes from OAuth providers (Google, Slack, etc.) and forwards them to the assistant runtime |
|
|
177
|
+
| `/healthz` | GET | Liveness probe |
|
|
178
|
+
| `/readyz` | GET | Readiness probe |
|
|
179
|
+
| `/schema` | GET | Returns the OpenAPI 3.1 schema for this gateway |
|
|
180
|
+
|
|
181
|
+
#### Backward-Compatibility Paths
|
|
182
|
+
|
|
183
|
+
The following legacy paths are aliases that map to their canonical equivalents above:
|
|
184
|
+
|
|
185
|
+
| Legacy Path | Canonical Path |
|
|
186
|
+
|-------------|---------------|
|
|
187
|
+
| `/v1/calls/twilio/voice-webhook` | `/webhooks/twilio/voice` |
|
|
188
|
+
| `/v1/calls/twilio/status` | `/webhooks/twilio/status` |
|
|
189
|
+
| `/v1/calls/twilio/connect-action` | `/webhooks/twilio/connect-action` |
|
|
190
|
+
| `/v1/calls/relay` | `/webhooks/twilio/relay` |
|
|
191
|
+
|
|
192
|
+
### Tunnel Setup
|
|
193
|
+
|
|
194
|
+
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:
|
|
195
|
+
|
|
196
|
+
#### Test Gateway Source Changes Locally (No Release Needed)
|
|
197
|
+
|
|
198
|
+
Use this flow when you are changing files under `gateway/` and need to validate immediately without publishing `@vellumai/vellum-gateway`.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Terminal 1: restart assistant runtime HTTP server
|
|
202
|
+
cd assistant
|
|
203
|
+
bun run daemon:restart:http
|
|
204
|
+
|
|
205
|
+
# Terminal 2: run gateway from local source with runtime proxy enabled
|
|
206
|
+
cd gateway
|
|
207
|
+
bun run dev:proxy
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
If `7830` is already in use, start the gateway on another port:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
cd gateway
|
|
214
|
+
GATEWAY_PORT=7840 bun run dev:proxy
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Then point your tunnel to that same local target (for example `http://127.0.0.1:7840`).
|
|
218
|
+
|
|
219
|
+
1. Start your tunnel (e.g. ngrok, Cloudflare Tunnel, or similar) targeting `http://127.0.0.1:7830`
|
|
220
|
+
2. Copy the public URL provided by the tunnel service (e.g. `https://abc123.ngrok-free.app`)
|
|
221
|
+
3. Set the URL as `ingress.publicBaseUrl` in the Settings UI (Public Ingress section) **or** as the `INGRESS_PUBLIC_BASE_URL` environment variable.
|
|
222
|
+
4. Use the Settings UI "Local Gateway Target" value as the source of truth for tunnel destination (it reflects `GATEWAY_PORT`).
|
|
223
|
+
|
|
224
|
+
In local tunnel setups, updating `ingress.publicBaseUrl` in Settings is typically live for Twilio inbound validation (no manual gateway restart required) because the gateway also validates signatures against forwarded public URL headers.
|
|
225
|
+
|
|
226
|
+
The assistant runtime uses this URL to construct all webhook and OAuth callback URLs automatically.
|
|
227
|
+
|
|
75
228
|
## Default Mode: Telegram-Only
|
|
76
229
|
|
|
77
230
|
By default the gateway only serves the Telegram webhook endpoint (`/webhooks/telegram`). All other HTTP requests return `404`. The runtime proxy is **opt-in** — set `GATEWAY_RUNTIME_PROXY_ENABLED=true` to enable it. This behavior is enforced by automated tests.
|
|
@@ -145,6 +298,8 @@ docker run --rm -p 7830:7830 \
|
|
|
145
298
|
|
|
146
299
|
The image runs as non-root user `gateway` (uid 1001) and exposes port `7830`.
|
|
147
300
|
|
|
301
|
+
When the runtime and gateway run in separate containers or hosts, set `GATEWAY_INTERNAL_BASE_URL` so the runtime can reach the gateway for callbacks (e.g., Telegram reply delivery). By default it points to `http://127.0.0.1:${GATEWAY_PORT}`, which only works when both services share the same host.
|
|
302
|
+
|
|
148
303
|
## Development
|
|
149
304
|
|
|
150
305
|
```bash
|
|
@@ -184,3 +339,14 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
|
|
|
184
339
|
| "No route configured" replies | Add a routing entry or set `GATEWAY_UNMAPPED_POLICY=default` with a default assistant |
|
|
185
340
|
| Runtime errors | Is `ASSISTANT_RUNTIME_BASE_URL` reachable? Check runtime logs. |
|
|
186
341
|
| No reply from assistant | Is the assistant runtime processing messages? Check for `RUNTIME_HTTP_PORT` env var. |
|
|
342
|
+
| 403 `GATEWAY_ORIGIN_REQUIRED` on channel inbound | The runtime rejected the request because it lacks a valid `X-Gateway-Origin` header. Ensure `RUNTIME_BEARER_TOKEN` (or the `~/.vellum/http-token` file) is set so the gateway and runtime share the same secret. |
|
|
343
|
+
|
|
344
|
+
### Guardian-Specific Troubleshooting
|
|
345
|
+
|
|
346
|
+
| Symptom | Cause | Resolution |
|
|
347
|
+
|---------|-------|------------|
|
|
348
|
+
| `/guardian_verify` command gets no reply | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
|
|
349
|
+
| Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The system is fail-closed: without a guardian, all sensitive actions are denied. | Set up a guardian by running the verification flow from the desktop UI. |
|
|
350
|
+
| Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_INTERNAL_BASE_URL` is set correctly (especially in containerized deployments). Re-verify the guardian if the chat ID has changed. |
|
|
351
|
+
| Guardian approval expired | The 30-minute TTL elapsed without a decision | The approval is auto-denied. The non-guardian user must re-trigger the action. |
|
|
352
|
+
| "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `externalUserId` matches the approval request can approve or deny. |
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/vellum-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bun run --watch src/index.ts",
|
|
7
|
+
"dev:proxy": "GATEWAY_RUNTIME_PROXY_ENABLED=true GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=false bun run --watch src/index.ts",
|
|
7
8
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
8
9
|
"schema": "bun run src/cli/schema.ts",
|
|
9
10
|
"start": "bun run src/index.ts",
|
|
@@ -29,6 +29,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void)
|
|
|
29
29
|
"GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES",
|
|
30
30
|
"GATEWAY_MAX_ATTACHMENT_BYTES",
|
|
31
31
|
"GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
|
|
32
|
+
"GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS",
|
|
32
33
|
"VELLUM_HTTP_TOKEN_PATH",
|
|
33
34
|
];
|
|
34
35
|
|
|
@@ -220,17 +221,41 @@ describe("config: runtime proxy flags", () => {
|
|
|
220
221
|
});
|
|
221
222
|
|
|
222
223
|
describe("config: runtime bearer token", () => {
|
|
223
|
-
test("runtimeBearerToken is undefined when
|
|
224
|
-
withEnv({}, () => {
|
|
224
|
+
test("runtimeBearerToken is undefined when env is unset and http-token file is missing", () => {
|
|
225
|
+
withEnv({ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
|
|
225
226
|
const config = loadConfig();
|
|
226
227
|
expect(config.runtimeBearerToken).toBeUndefined();
|
|
227
228
|
});
|
|
228
229
|
});
|
|
229
230
|
|
|
230
231
|
test("runtimeBearerToken is set from RUNTIME_BEARER_TOKEN env var", () => {
|
|
231
|
-
withEnv({ RUNTIME_BEARER_TOKEN: "rt-secret" }, () => {
|
|
232
|
+
withEnv({ RUNTIME_BEARER_TOKEN: "rt-secret", VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
|
|
232
233
|
const config = loadConfig();
|
|
233
234
|
expect(config.runtimeBearerToken).toBe("rt-secret");
|
|
234
235
|
});
|
|
235
236
|
});
|
|
237
|
+
|
|
238
|
+
test("runtimeBearerToken is read from http-token file when env var is unset", () => {
|
|
239
|
+
withEnv({ VELLUM_HTTP_TOKEN_PATH: "/tmp/test-runtime-http-token" }, () => {
|
|
240
|
+
writeFileSync("/tmp/test-runtime-http-token", "runtime-file-token\n");
|
|
241
|
+
const config = loadConfig();
|
|
242
|
+
expect(config.runtimeBearerToken).toBe("runtime-file-token");
|
|
243
|
+
unlinkSync("/tmp/test-runtime-http-token");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("RUNTIME_BEARER_TOKEN env var takes precedence over http-token file", () => {
|
|
248
|
+
withEnv(
|
|
249
|
+
{
|
|
250
|
+
RUNTIME_BEARER_TOKEN: "runtime-env-token",
|
|
251
|
+
VELLUM_HTTP_TOKEN_PATH: "/tmp/test-runtime-http-token-priority",
|
|
252
|
+
},
|
|
253
|
+
() => {
|
|
254
|
+
writeFileSync("/tmp/test-runtime-http-token-priority", "runtime-file-token");
|
|
255
|
+
const config = loadConfig();
|
|
256
|
+
expect(config.runtimeBearerToken).toBe("runtime-env-token");
|
|
257
|
+
unlinkSync("/tmp/test-runtime-http-token-priority");
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
});
|
|
236
261
|
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Mock logger — capture log calls to verify no secrets leak
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const logCalls: { level: string; args: unknown[] }[] = [];
|
|
12
|
+
|
|
13
|
+
mock.module("../logger.js", () => ({
|
|
14
|
+
getLogger: () =>
|
|
15
|
+
new Proxy({} as Record<string, unknown>, {
|
|
16
|
+
get: (_target, prop) =>
|
|
17
|
+
(...args: unknown[]) => {
|
|
18
|
+
logCalls.push({ level: String(prop), args });
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Mock execFileSync — intercept keychain CLI calls
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
let execFileSyncMock: ReturnType<typeof mock>;
|
|
28
|
+
|
|
29
|
+
mock.module("node:child_process", () => {
|
|
30
|
+
execFileSyncMock = mock(() => {
|
|
31
|
+
throw new Error("not found");
|
|
32
|
+
});
|
|
33
|
+
return { execFileSync: execFileSyncMock };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
readTelegramCredentials,
|
|
38
|
+
readCredential,
|
|
39
|
+
readKeychainCredential,
|
|
40
|
+
getMetadataPath,
|
|
41
|
+
getRootDir,
|
|
42
|
+
} from "../credential-reader.js";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Temp directory for metadata / encrypted store fixtures
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const testDir = join(tmpdir(), `cred-reader-test-${randomBytes(4).toString("hex")}`);
|
|
49
|
+
|
|
50
|
+
function metadataDir(): string {
|
|
51
|
+
return join(testDir, ".vellum", "workspace", "data", "credentials");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeMetadata(credentials: { service: string; field: string }[]): void {
|
|
55
|
+
const dir = metadataDir();
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
writeFileSync(join(dir, "metadata.json"), JSON.stringify({ credentials }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const originalPlatform = process.platform;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
process.env.BASE_DATA_DIR = testDir;
|
|
64
|
+
logCalls.length = 0;
|
|
65
|
+
execFileSyncMock.mockReset();
|
|
66
|
+
// Default: execFileSync throws with exit code 44 (errSecItemNotFound)
|
|
67
|
+
execFileSyncMock.mockImplementation(() => {
|
|
68
|
+
const err = new Error("not found") as Error & { status: number };
|
|
69
|
+
err.status = 44;
|
|
70
|
+
throw err;
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
delete process.env.BASE_DATA_DIR;
|
|
76
|
+
try {
|
|
77
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
78
|
+
} catch {
|
|
79
|
+
// best-effort cleanup
|
|
80
|
+
}
|
|
81
|
+
// Restore platform in case a test changed it
|
|
82
|
+
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Tests
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe("readTelegramCredentials: encrypted store only (existing behavior)", () => {
|
|
90
|
+
test("returns null when metadata file does not exist", () => {
|
|
91
|
+
const result = readTelegramCredentials();
|
|
92
|
+
expect(result).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns null when metadata has no Telegram entries", () => {
|
|
96
|
+
writeMetadata([{ service: "github", field: "token" }]);
|
|
97
|
+
const result = readTelegramCredentials();
|
|
98
|
+
expect(result).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns null when metadata exists but secrets are missing from both backends", () => {
|
|
102
|
+
writeMetadata([
|
|
103
|
+
{ service: "telegram", field: "bot_token" },
|
|
104
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// Keychain returns nothing (throws), encrypted store has no file
|
|
108
|
+
const result = readTelegramCredentials();
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("readTelegramCredentials: keychain on macOS", () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
Object.defineProperty(process, "platform", { value: "darwin", writable: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("returns credentials from keychain when available on macOS", () => {
|
|
119
|
+
writeMetadata([
|
|
120
|
+
{ service: "telegram", field: "bot_token" },
|
|
121
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
// Simulate keychain returning credentials
|
|
125
|
+
execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
126
|
+
const aIdx = (args as string[]).indexOf("-a");
|
|
127
|
+
const account = (args as string[])[aIdx + 1];
|
|
128
|
+
if (account === "credential:telegram:bot_token") return "kc-bot-token\n";
|
|
129
|
+
if (account === "credential:telegram:webhook_secret") return "kc-webhook-secret\n";
|
|
130
|
+
throw new Error("not found");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = readTelegramCredentials();
|
|
134
|
+
expect(result).toEqual({
|
|
135
|
+
botToken: "kc-bot-token",
|
|
136
|
+
webhookSecret: "kc-webhook-secret",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("prefers keychain over encrypted store on macOS", () => {
|
|
141
|
+
writeMetadata([
|
|
142
|
+
{ service: "telegram", field: "bot_token" },
|
|
143
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Keychain returns credentials — encrypted store should not be consulted
|
|
147
|
+
execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
148
|
+
const aIdx = (args as string[]).indexOf("-a");
|
|
149
|
+
const account = (args as string[])[aIdx + 1];
|
|
150
|
+
if (account === "credential:telegram:bot_token") return "keychain-token\n";
|
|
151
|
+
if (account === "credential:telegram:webhook_secret") return "keychain-secret\n";
|
|
152
|
+
throw new Error("not found");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const result = readTelegramCredentials();
|
|
156
|
+
expect(result).not.toBeNull();
|
|
157
|
+
expect(result!.botToken).toBe("keychain-token");
|
|
158
|
+
expect(result!.webhookSecret).toBe("keychain-secret");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("falls back to encrypted store when keychain has no credentials", () => {
|
|
162
|
+
writeMetadata([
|
|
163
|
+
{ service: "telegram", field: "bot_token" },
|
|
164
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
// Keychain throws (credential not found) — fall through to encrypted store
|
|
168
|
+
// Encrypted store also has nothing, so result should be null
|
|
169
|
+
const result = readTelegramCredentials();
|
|
170
|
+
expect(result).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("readTelegramCredentials: non-macOS platforms", () => {
|
|
175
|
+
test("skips keychain on non-macOS and uses encrypted store", () => {
|
|
176
|
+
Object.defineProperty(process, "platform", { value: "linux", writable: true });
|
|
177
|
+
|
|
178
|
+
writeMetadata([
|
|
179
|
+
{ service: "telegram", field: "bot_token" },
|
|
180
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
// readKeychainCredential should return undefined on linux
|
|
184
|
+
const keychainResult = readKeychainCredential("credential:telegram:bot_token");
|
|
185
|
+
expect(keychainResult).toBeUndefined();
|
|
186
|
+
|
|
187
|
+
// execFileSync should NOT have been called since platform is not darwin
|
|
188
|
+
expect(execFileSyncMock).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("readTelegramCredentials: neither backend has credentials", () => {
|
|
193
|
+
test("returns null when both keychain and encrypted store have nothing", () => {
|
|
194
|
+
writeMetadata([
|
|
195
|
+
{ service: "telegram", field: "bot_token" },
|
|
196
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
// Keychain throws, encrypted store file doesn't exist
|
|
200
|
+
const result = readTelegramCredentials();
|
|
201
|
+
expect(result).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("readKeychainCredential", () => {
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
Object.defineProperty(process, "platform", { value: "darwin", writable: true });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns credential value from keychain on macOS", () => {
|
|
211
|
+
execFileSyncMock.mockImplementation(() => "my-secret-value\n");
|
|
212
|
+
|
|
213
|
+
const result = readKeychainCredential("credential:telegram:bot_token");
|
|
214
|
+
expect(result).toBe("my-secret-value");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("returns undefined when keychain item not found (exit code 44)", () => {
|
|
218
|
+
execFileSyncMock.mockImplementation(() => {
|
|
219
|
+
const err = new Error("security: SecKeychainSearchCopyNext: The specified item could not be found") as Error & { status: number };
|
|
220
|
+
err.status = 44;
|
|
221
|
+
throw err;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = readKeychainCredential("credential:telegram:bot_token");
|
|
225
|
+
expect(result).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("returns undefined for transient keychain errors (non-44 exit code)", () => {
|
|
229
|
+
execFileSyncMock.mockImplementation(() => {
|
|
230
|
+
const err = new Error("security: The user name or passphrase you entered is not correct.") as Error & { status: number };
|
|
231
|
+
err.status = 51;
|
|
232
|
+
throw err;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Should still return undefined (graceful fallback), but logs a warning
|
|
236
|
+
const result = readKeychainCredential("credential:telegram:bot_token");
|
|
237
|
+
expect(result).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("returns undefined on non-darwin platforms", () => {
|
|
241
|
+
Object.defineProperty(process, "platform", { value: "linux", writable: true });
|
|
242
|
+
|
|
243
|
+
const result = readKeychainCredential("credential:telegram:bot_token");
|
|
244
|
+
expect(result).toBeUndefined();
|
|
245
|
+
expect(execFileSyncMock).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("passes correct service name and account to security CLI", () => {
|
|
249
|
+
execFileSyncMock.mockImplementation(() => "value\n");
|
|
250
|
+
|
|
251
|
+
readKeychainCredential("credential:telegram:bot_token");
|
|
252
|
+
|
|
253
|
+
expect(execFileSyncMock).toHaveBeenCalledWith(
|
|
254
|
+
"security",
|
|
255
|
+
["find-generic-password", "-s", "vellum-assistant", "-a", "credential:telegram:bot_token", "-w"],
|
|
256
|
+
expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("log output: no plaintext secrets", () => {
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
Object.defineProperty(process, "platform", { value: "darwin", writable: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("log messages never contain secret values", () => {
|
|
267
|
+
writeMetadata([
|
|
268
|
+
{ service: "telegram", field: "bot_token" },
|
|
269
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
273
|
+
const aIdx = (args as string[]).indexOf("-a");
|
|
274
|
+
const account = (args as string[])[aIdx + 1];
|
|
275
|
+
if (account === "credential:telegram:bot_token") return "SUPER_SECRET_TOKEN_123\n";
|
|
276
|
+
if (account === "credential:telegram:webhook_secret") return "SUPER_SECRET_WEBHOOK_456\n";
|
|
277
|
+
throw new Error("not found");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const result = readTelegramCredentials();
|
|
281
|
+
|
|
282
|
+
// Verify credentials were actually returned (mock is working)
|
|
283
|
+
expect(result).not.toBeNull();
|
|
284
|
+
expect(result!.botToken).toBe("SUPER_SECRET_TOKEN_123");
|
|
285
|
+
|
|
286
|
+
// Verify no secret values appear in any log output
|
|
287
|
+
const allLogText = JSON.stringify(logCalls);
|
|
288
|
+
expect(allLogText).not.toContain("SUPER_SECRET_TOKEN_123");
|
|
289
|
+
expect(allLogText).not.toContain("SUPER_SECRET_WEBHOOK_456");
|
|
290
|
+
});
|
|
291
|
+
});
|