@vellumai/vellum-gateway 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -569,7 +569,7 @@ If no guardian binding exists for the channel, escalation fails closed -- the me
569
569
 
570
570
  ### Telegram Credential Flow
571
571
 
572
- In desktop deployments, Telegram bot tokens are stored in secure storage (the encrypted file store at `~/.vellum/protected/keys.enc`, with macOS Keychain access available via the keychain broker) and never in plaintext config files. When deploying the gateway standalone, operators may also supply credentials via environment variables (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`).
572
+ In desktop deployments, Telegram bot tokens are stored in secure storage (CES HTTP API when available, or the encrypted file store at `~/.vellum/protected/keys.enc`) and never in plaintext config files. When deploying the gateway standalone, operators may also supply credentials via environment variables (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`).
573
573
 
574
574
  ```
575
575
  Entry points:
@@ -603,7 +603,7 @@ The `telegram_config` HTTP endpoint supports three actions:
603
603
  - **`set`** — validates the bot token against the Telegram API, stores it in secure storage, auto-generates a webhook secret if none exists (with rollback on failure), and self-heals webhook_secret metadata if it already exists. The gateway's credential watcher detects the storage change and triggers webhook reconciliation automatically
604
604
  - **`clear`** — deregisters the webhook by calling Telegram's `deleteWebhook` API directly (while the token is still available), then deletes the bot token and webhook secret from both secure storage and credential metadata. The gateway's credential watcher detects the storage change and updates its readiness state automatically
605
605
 
606
- The gateway reads Telegram credentials via its `credential-reader` module (`gateway/src/credential-reader.ts`), which uses a broker-first fallback strategy: it tries the keychain broker first, then falls back to the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (e.g., daemon not running or non-macOS platform), the encrypted store is used directly.
606
+ The gateway reads Telegram credentials via its `credential-reader` module (`gateway/src/credential-reader.ts`), which uses a CES-first fallback strategy: it tries the CES HTTP API first (when `CES_CREDENTIAL_URL` is configured), then falls back to the encrypted file store (`~/.vellum/protected/keys.enc`).
607
607
 
608
608
  ### Webhook Reconciliation
609
609
 
@@ -660,7 +660,7 @@ The Slack channel requires two tokens:
660
660
  | App token | `xapp-...` | Used for `apps.connections.open` to establish the Socket Mode WebSocket connection |
661
661
  | Bot token | `xoxb-...` | Used for `chat.postMessage` to send outbound messages and for `auth.test` validation |
662
662
 
663
- Both tokens are stored in secure storage (`credential/slack_channel/app_token`, `credential/slack_channel/bot_token`) via the assistant's Slack channel config endpoints (see `assistant/ARCHITECTURE.md`). The gateway reads them via its `credential-reader` module using the same broker-first fallback strategy as Telegram credentials.
663
+ Both tokens are stored in secure storage (`credential/slack_channel/app_token`, `credential/slack_channel/bot_token`) via the assistant's Slack channel config endpoints (see `assistant/ARCHITECTURE.md`). The gateway reads them via its `credential-reader` module using the same CES-first fallback strategy as Telegram credentials.
664
664
 
665
665
  **Auto-reconnect behavior:**
666
666
 
package/README.md CHANGED
@@ -28,8 +28,8 @@ bun run dev
28
28
 
29
29
  | Variable | Required | Default | Description |
30
30
  | ------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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: keychain broker first (UDS to the assistant process), then the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (assistant not running or non-macOS), the encrypted store is used directly. |
32
- | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
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
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,5 @@
1
1
  import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
2
- import { mkdirSync, writeFileSync, rmSync, unlinkSync } from "node:fs";
3
- import { createServer, type Server } from "node:net";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
4
3
  import { join } from "node:path";
5
4
  import { hostname, tmpdir, userInfo } from "node:os";
6
5
  import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
@@ -144,88 +143,6 @@ function writeEncryptedStoreV2(entries: Record<string, string>): void {
144
143
  writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
145
144
  }
146
145
 
147
- // ---------------------------------------------------------------------------
148
- // Broker test helpers — mock UDS server
149
- // ---------------------------------------------------------------------------
150
-
151
- const TEST_TOKEN = "test-auth-token-abc123";
152
-
153
- function writeBrokerToken(token: string): void {
154
- const tokenDir = join(testDir, ".vellum", "protected");
155
- mkdirSync(tokenDir, { recursive: true });
156
- writeFileSync(join(tokenDir, "keychain-broker.token"), token);
157
- }
158
-
159
- /**
160
- * Create a mock keychain broker UDS server that responds to key.get requests.
161
- * Listens on the derived socket path (getRootDir() + keychain-broker.sock).
162
- * Returns the socket path and a handle to close the server.
163
- */
164
- function createMockBroker(credentials: Record<string, string>): {
165
- socketPath: string;
166
- server: Server;
167
- close: () => void;
168
- } {
169
- const socketPath = join(testDir, ".vellum", "keychain-broker.sock");
170
- mkdirSync(join(testDir, ".vellum"), { recursive: true });
171
-
172
- const server = createServer((conn) => {
173
- let buf = "";
174
- conn.on("data", (chunk) => {
175
- buf += chunk.toString();
176
- const idx = buf.indexOf("\n");
177
- if (idx !== -1) {
178
- const line = buf.slice(0, idx);
179
- buf = buf.slice(idx + 1);
180
- try {
181
- const req = JSON.parse(line);
182
- if (req.token !== TEST_TOKEN) {
183
- conn.write(
184
- JSON.stringify({
185
- id: req.id,
186
- ok: false,
187
- error: { code: "UNAUTHORIZED", message: "bad token" },
188
- }) + "\n",
189
- );
190
- return;
191
- }
192
- if (req.method === "key.get") {
193
- const account = req.params?.account;
194
- const value = credentials[account];
195
- conn.write(
196
- JSON.stringify({
197
- id: req.id,
198
- ok: true,
199
- result:
200
- value !== undefined
201
- ? { found: true, value }
202
- : { found: false },
203
- }) + "\n",
204
- );
205
- }
206
- } catch {
207
- // ignore malformed requests
208
- }
209
- }
210
- });
211
- });
212
-
213
- server.listen(socketPath);
214
-
215
- return {
216
- socketPath,
217
- server,
218
- close: () => {
219
- server.close();
220
- try {
221
- unlinkSync(socketPath);
222
- } catch {
223
- // best-effort
224
- }
225
- },
226
- };
227
- }
228
-
229
146
  // ---------------------------------------------------------------------------
230
147
  // Setup / teardown
231
148
  // ---------------------------------------------------------------------------
@@ -357,88 +274,6 @@ describe("v1 encrypted store backward compatibility", () => {
357
274
  });
358
275
  });
359
276
 
360
- // ---------------------------------------------------------------------------
361
- // Tests: broker credential reading
362
- // ---------------------------------------------------------------------------
363
-
364
- describe("readCredential broker integration", () => {
365
- test("returns undefined when socket file does not exist", async () => {
366
- const result = await readCredential(credentialKey("test", "key"));
367
- expect(result).toBeUndefined();
368
- });
369
-
370
- test("falls back to encrypted store when socket file does not exist", async () => {
371
- writeEncryptedStore({
372
- [credentialKey("test", "key")]: "encrypted-value",
373
- });
374
-
375
- const result = await readCredential(credentialKey("test", "key"));
376
- expect(result).toBe("encrypted-value");
377
- });
378
-
379
- test("falls back to encrypted store when broker token file is missing", async () => {
380
- // Create socket file but no token file
381
- mkdirSync(join(testDir, ".vellum"), { recursive: true });
382
- writeFileSync(join(testDir, ".vellum", "keychain-broker.sock"), "");
383
-
384
- writeEncryptedStore({
385
- [credentialKey("test", "key")]: "encrypted-value",
386
- });
387
-
388
- const result = await readCredential(credentialKey("test", "key"));
389
- expect(result).toBe("encrypted-value");
390
- });
391
-
392
- test("reads credential from broker when available", async () => {
393
- const broker = createMockBroker({
394
- [credentialKey("test", "key")]: "broker-secret-value",
395
- });
396
- try {
397
- writeBrokerToken(TEST_TOKEN);
398
-
399
- const result = await readCredential(credentialKey("test", "key"));
400
- expect(result).toBe("broker-secret-value");
401
- } finally {
402
- broker.close();
403
- }
404
- });
405
-
406
- test("broker result takes priority over encrypted store", async () => {
407
- const broker = createMockBroker({
408
- [credentialKey("test", "key")]: "broker-value",
409
- });
410
- try {
411
- writeBrokerToken(TEST_TOKEN);
412
-
413
- writeEncryptedStore({
414
- [credentialKey("test", "key")]: "encrypted-value",
415
- });
416
-
417
- const result = await readCredential(credentialKey("test", "key"));
418
- expect(result).toBe("broker-value");
419
- } finally {
420
- broker.close();
421
- }
422
- });
423
-
424
- test("falls back to encrypted store when broker returns not found", async () => {
425
- // Broker has no entry for the test credential key
426
- const broker = createMockBroker({});
427
- try {
428
- writeBrokerToken(TEST_TOKEN);
429
-
430
- writeEncryptedStore({
431
- [credentialKey("test", "key")]: "encrypted-value",
432
- });
433
-
434
- const result = await readCredential(credentialKey("test", "key"));
435
- expect(result).toBe("encrypted-value");
436
- } finally {
437
- broker.close();
438
- }
439
- });
440
- });
441
-
442
277
  // ---------------------------------------------------------------------------
443
278
  // Tests: secret values must not leak into log output
444
279
  // ---------------------------------------------------------------------------
@@ -448,26 +283,6 @@ describe("secret leak prevention", () => {
448
283
  return JSON.stringify(logCalls);
449
284
  }
450
285
 
451
- test("broker read does not leak secret values into logs", async () => {
452
- const secretValue = "super-secret-broker-credential-value";
453
- const broker = createMockBroker({
454
- [credentialKey("leak-test", "key")]: secretValue,
455
- });
456
- try {
457
- writeBrokerToken(TEST_TOKEN);
458
-
459
- const result = await readCredential(credentialKey("leak-test", "key"));
460
- expect(result).toBe(secretValue);
461
-
462
- const serialized = allLogStrings();
463
- expect(serialized).not.toContain(secretValue);
464
- // The auth token used for broker handshake should also stay out of logs
465
- expect(serialized).not.toContain(TEST_TOKEN);
466
- } finally {
467
- broker.close();
468
- }
469
- });
470
-
471
286
  test("encrypted store read does not leak secret values into logs", async () => {
472
287
  const secretValue = "super-secret-encrypted-credential-value";
473
288
 
@@ -2,14 +2,12 @@
2
2
  * Read-only reader for the assistant's credential stores.
3
3
  *
4
4
  * Resolution order:
5
- * 1. CES HTTP API (when CES_CREDENTIAL_URL is set — containerized mode)
6
- * 2. Keychain broker (UDS — native macOS/Linux)
7
- * 3. Encrypted-at-rest file (~/.vellum/protected/keys.enc)
5
+ * 1. CES HTTP API (when CES_CREDENTIAL_URL is set)
6
+ * 2. Encrypted-at-rest file (~/.vellum/protected/keys.enc)
8
7
  */
9
8
 
10
- import { createDecipheriv, pbkdf2Sync, randomUUID } from "node:crypto";
9
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto";
11
10
  import { existsSync, readFileSync } from "node:fs";
12
- import { createConnection } from "node:net";
13
11
  import { hostname, userInfo } from "node:os";
14
12
  import { join } from "node:path";
15
13
  import { credentialKey } from "./credential-key.js";
@@ -196,126 +194,6 @@ function readEncryptedCredential(account: string): string | undefined {
196
194
  }
197
195
  }
198
196
 
199
- // ---------------------------------------------------------------------------
200
- // Keychain broker reader (UDS) — native async implementation
201
- // ---------------------------------------------------------------------------
202
-
203
- const BROKER_TIMEOUT_MS = 5_000;
204
-
205
- function getBrokerTokenPath(): string {
206
- return join(getRootDir(), "protected", "keychain-broker.token");
207
- }
208
-
209
- /**
210
- * Try to read a credential from the keychain broker over its Unix domain socket.
211
- * Uses a native UDS connection (no external process spawn).
212
- * Returns `undefined` if the broker is unavailable, the socket file doesn't exist,
213
- * the token file is missing, or the broker doesn't have the requested key.
214
- */
215
- async function readBrokerCredential(
216
- account: string,
217
- ): Promise<string | undefined> {
218
- const socketPath = join(getRootDir(), "keychain-broker.sock");
219
-
220
- // Check socket file exists before attempting connection — createConnection
221
- // can throw synchronously in some runtimes (e.g. Bun) for ENOENT.
222
- if (!existsSync(socketPath)) return undefined;
223
-
224
- const tokenPath = getBrokerTokenPath();
225
- let token: string;
226
- try {
227
- if (!existsSync(tokenPath)) return undefined;
228
- token = readFileSync(tokenPath, "utf-8").trim();
229
- if (!token) return undefined;
230
- } catch {
231
- return undefined;
232
- }
233
-
234
- const reqId = randomUUID();
235
- const request = JSON.stringify({
236
- v: 1,
237
- id: reqId,
238
- method: "key.get",
239
- token,
240
- params: { account },
241
- });
242
-
243
- try {
244
- return await new Promise<string | undefined>((resolve) => {
245
- let buf = "";
246
- let settled = false;
247
-
248
- // Declare socket before the timer so the timeout closure never
249
- // hits a TDZ if createConnection throws synchronously.
250
- let socket: ReturnType<typeof createConnection> | undefined;
251
-
252
- const timer = setTimeout(() => {
253
- if (!settled) {
254
- settled = true;
255
- try {
256
- socket?.destroy();
257
- } catch {
258
- /* already destroyed or never created */
259
- }
260
- log.debug({ account }, "Broker read timed out");
261
- resolve(undefined);
262
- }
263
- }, BROKER_TIMEOUT_MS);
264
-
265
- try {
266
- socket = createConnection({ path: socketPath });
267
- } catch (err) {
268
- clearTimeout(timer);
269
- settled = true;
270
- log.debug({ err, account }, "Failed to connect to keychain broker");
271
- resolve(undefined);
272
- return;
273
- }
274
-
275
- socket.on("connect", () => {
276
- socket!.write(request + "\n");
277
- });
278
-
279
- socket.on("data", (chunk) => {
280
- buf += chunk.toString();
281
- const idx = buf.indexOf("\n");
282
- if (idx !== -1) {
283
- clearTimeout(timer);
284
- if (settled) return;
285
- settled = true;
286
- try {
287
- const resp = JSON.parse(buf.slice(0, idx));
288
- if (
289
- resp.ok &&
290
- resp.result?.found &&
291
- typeof resp.result.value === "string"
292
- ) {
293
- resolve(resp.result.value);
294
- } else {
295
- resolve(undefined);
296
- }
297
- } catch {
298
- resolve(undefined);
299
- }
300
- socket!.destroy();
301
- }
302
- });
303
-
304
- socket.on("error", (err) => {
305
- clearTimeout(timer);
306
- if (!settled) {
307
- settled = true;
308
- log.debug({ err, account }, "Failed to read from keychain broker");
309
- resolve(undefined);
310
- }
311
- });
312
- });
313
- } catch (err) {
314
- log.debug({ err, account }, "Failed to read from keychain broker");
315
- return undefined;
316
- }
317
- }
318
-
319
197
  // ---------------------------------------------------------------------------
320
198
  // CES HTTP credential reader (containerized mode)
321
199
  // ---------------------------------------------------------------------------
@@ -382,7 +260,7 @@ async function readCesCredential(
382
260
  }
383
261
 
384
262
  // ---------------------------------------------------------------------------
385
- // Public credential reader — tries CES, broker, then encrypted store
263
+ // Public credential reader — tries CES, then encrypted store
386
264
  // ---------------------------------------------------------------------------
387
265
 
388
266
  /**
@@ -390,8 +268,7 @@ async function readCesCredential(
390
268
  *
391
269
  * Resolution order:
392
270
  * 1. CES HTTP API (when CES_CREDENTIAL_URL is set)
393
- * 2. Keychain broker (UDS)
394
- * 3. Encrypted-at-rest store (keys.enc)
271
+ * 2. Encrypted-at-rest store (keys.enc)
395
272
  */
396
273
  export async function readCredential(
397
274
  account: string,
@@ -400,10 +277,6 @@ export async function readCredential(
400
277
  const cesValue = await readCesCredential(account);
401
278
  if (cesValue !== undefined) return cesValue;
402
279
 
403
- // Keychain broker (native mode)
404
- const brokerValue = await readBrokerCredential(account);
405
- if (brokerValue !== undefined) return brokerValue;
406
-
407
280
  // Encrypted file fallback
408
281
  return readEncryptedCredential(account);
409
282
  }