@vellumai/vellum-gateway 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/ARCHITECTURE.md +5 -5
  2. package/Dockerfile +1 -0
  3. package/README.md +4 -8
  4. package/bun.lock +7 -0
  5. package/knip.json +2 -1
  6. package/package.json +2 -1
  7. package/src/__tests__/browser-relay-websocket.test.ts +0 -1
  8. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  9. package/src/__tests__/config.test.ts +0 -1
  10. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  11. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  12. package/src/__tests__/credential-watcher.test.ts +11 -1
  13. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  14. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  15. package/src/__tests__/feature-flags-route.test.ts +5 -5
  16. package/src/__tests__/guardian-init-lockfile.test.ts +6 -4
  17. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  18. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  19. package/src/__tests__/load-guards.test.ts +0 -1
  20. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  21. package/src/__tests__/oauth-callback.test.ts +0 -1
  22. package/src/__tests__/resolve-assistant.test.ts +0 -1
  23. package/src/__tests__/runtime-client.test.ts +0 -1
  24. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  25. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  26. package/src/__tests__/runtime-proxy.test.ts +0 -1
  27. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  28. package/src/__tests__/slack-display-name.test.ts +66 -1
  29. package/src/__tests__/slack-normalize.test.ts +158 -4
  30. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  31. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +630 -0
  32. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  33. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  35. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  36. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  37. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  38. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  39. package/src/__tests__/twilio-webhooks.test.ts +0 -1
  40. package/src/__tests__/whatsapp-download.test.ts +0 -1
  41. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  42. package/src/auth/ipc-route-policy.ts +217 -0
  43. package/src/cli/enable-proxy.ts +0 -1
  44. package/src/config.ts +0 -7
  45. package/src/db/connection.ts +65 -3
  46. package/src/db/schema.ts +62 -0
  47. package/src/feature-flag-registry.json +30 -30
  48. package/src/handlers/handle-inbound.ts +33 -0
  49. package/src/http/middleware/auth.ts +43 -0
  50. package/src/http/routes/brain-graph-proxy.ts +1 -1
  51. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  52. package/src/http/routes/channel-verification-session-proxy.ts +2 -2
  53. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  54. package/src/http/routes/email-webhook.test.ts +0 -1
  55. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  56. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  57. package/src/http/routes/log-export.test.ts +0 -1
  58. package/src/http/routes/migration-proxy.ts +1 -2
  59. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  60. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  61. package/src/http/routes/runtime-health-proxy.ts +2 -2
  62. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  63. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  64. package/src/http/routes/telegram-webhook.test.ts +0 -1
  65. package/src/http/routes/telegram-webhook.ts +6 -0
  66. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  67. package/src/http/routes/trust-rules.ts +7 -0
  68. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  69. package/src/http/routes/twilio-voice-verify-callback.ts +282 -0
  70. package/src/http/routes/twilio-voice-webhook.test.ts +5 -1
  71. package/src/http/routes/twilio-voice-webhook.ts +37 -1
  72. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  73. package/src/index.ts +31 -33
  74. package/src/ipc/assistant-client.ts +8 -4
  75. package/src/post-assistant-ready.ts +5 -3
  76. package/src/risk/bash-risk-classifier.test.ts +0 -24
  77. package/src/risk/command-registry/commands/assistant.ts +3 -19
  78. package/src/risk/command-registry.test.ts +0 -15
  79. package/src/risk/risk-classifier-parity.test.ts +0 -2
  80. package/src/schema.ts +57 -37
  81. package/src/slack/normalize.test.ts +74 -0
  82. package/src/slack/normalize.ts +99 -32
  83. package/src/slack/socket-mode.ts +184 -22
  84. package/src/telegram/send.test.ts +0 -1
  85. package/src/verification/binding-helpers.ts +107 -0
  86. package/src/verification/code-parsing.ts +44 -0
  87. package/src/verification/contact-helpers.ts +205 -0
  88. package/src/verification/identity-match.ts +68 -0
  89. package/src/verification/identity.ts +61 -0
  90. package/src/verification/rate-limit-helpers.ts +205 -0
  91. package/src/verification/reply-delivery.ts +109 -0
  92. package/src/verification/session-helpers.ts +164 -0
  93. package/src/verification/text-verification.ts +372 -0
  94. package/src/voice/verification.ts +456 -0
  95. package/src/webhook-pipeline.ts +4 -0
  96. package/src/__tests__/telegram-only-default.test.ts +0 -133
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
 
package/Dockerfile CHANGED
@@ -12,6 +12,7 @@ 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
15
16
 
16
17
  # Install deps for shared packages that have their own file: dependencies.
17
18
  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
 
@@ -218,13 +218,9 @@ The gateway is the **sole public ingress point** for all external webhooks. The
218
218
 
219
219
  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
220
 
221
- ## Default Mode: Dedicated Routes Only
221
+ ## Runtime Proxy
222
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
226
-
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.
223
+ 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
224
 
229
225
  ### Auth behavior
230
226
 
package/bun.lock CHANGED
@@ -8,6 +8,7 @@
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",
11
12
  "drizzle-kit": "0.30.6",
12
13
  "drizzle-orm": "0.45.2",
13
14
  "file-type": "21.3.0",
@@ -209,6 +210,8 @@
209
210
 
210
211
  "@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
212
 
213
+ "@vellumai/slack-text": ["@vellumai/slack-text@file:../packages/slack-text", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
214
+
212
215
  "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
213
216
 
214
217
  "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -499,6 +502,8 @@
499
502
 
500
503
  "@vellumai/service-contracts/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
501
504
 
505
+ "@vellumai/slack-text/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
506
+
502
507
  "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
503
508
 
504
509
  "gel/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -559,6 +564,8 @@
559
564
 
560
565
  "@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
566
 
567
+ "@vellumai/slack-text/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
568
+
562
569
  "gel/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
563
570
 
564
571
  "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
package/knip.json CHANGED
@@ -4,6 +4,7 @@
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"
8
9
  ]
9
10
  }
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.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,7 @@
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",
30
31
  "drizzle-kit": "0.30.6",
31
32
  "drizzle-orm": "0.45.2",
32
33
  "file-type": "21.3.0",
@@ -51,7 +51,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
51
51
  defaultAssistantId: undefined,
52
52
  unmappedPolicy: "reject",
53
53
  port: 7830,
54
- runtimeProxyEnabled: false,
55
54
  runtimeProxyRequireAuth: true,
56
55
  shutdownDrainMs: 5000,
57
56
  runtimeTimeoutMs: 30000,
@@ -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,
@@ -17,7 +17,6 @@ describe("config: hardcoded defaults", () => {
17
17
  default: 100 * 1024 * 1024,
18
18
  });
19
19
  expect(config.maxAttachmentConcurrency).toBe(3);
20
- expect(config.runtimeProxyEnabled).toBe(false);
21
20
  expect(config.runtimeProxyRequireAuth).toBe(true);
22
21
  expect(config.trustProxy).toBe(false);
23
22
  expect(config.unmappedPolicy).toBe("reject");
@@ -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,
@@ -1,11 +1,13 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { createServer } from "node:net";
2
+ import { createServer, type Server } from "node:net";
3
3
  import { spawn, type ChildProcess } from "node:child_process";
4
4
  import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
+ import { startFakeAssistantIpc } from "./fake-assistant-ipc.js";
10
+
9
11
  const TEST_SERVICE_TOKEN = "test-ces-service-token";
10
12
 
11
13
  const testDir = join(tmpdir(), `gw-managed-${Date.now()}-${Math.random()}`);
@@ -54,6 +56,7 @@ let gatewayProc: ChildProcess | null = null;
54
56
  let gatewayPort = 0;
55
57
  let cesPort = 0;
56
58
  let cesServer: ReturnType<typeof Bun.serve> | null = null;
59
+ let fakeAssistantIpc: Server | null = null;
57
60
 
58
61
  /** Ask the OS for a free port by briefly binding to port 0. */
59
62
  function getFreePort(): Promise<number> {
@@ -95,11 +98,14 @@ async function startGateway(): Promise<void> {
95
98
  );
96
99
  gatewayPort = await getFreePort();
97
100
 
101
+ const workspaceDir = join(testDir, ".vellum", "workspace");
102
+ fakeAssistantIpc = startFakeAssistantIpc(workspaceDir);
103
+
98
104
  gatewayProc = spawn("bun", ["run", gatewayEntry], {
99
105
  env: {
100
106
  ...process.env,
101
107
  GATEWAY_SECURITY_DIR: join(testDir, ".vellum", "protected"),
102
- VELLUM_WORKSPACE_DIR: join(testDir, ".vellum", "workspace"),
108
+ VELLUM_WORKSPACE_DIR: workspaceDir,
103
109
  GATEWAY_PORT: String(gatewayPort),
104
110
  CES_CREDENTIAL_URL: `http://127.0.0.1:${cesPort}`,
105
111
  CES_SERVICE_TOKEN: TEST_SERVICE_TOKEN,
@@ -190,6 +196,8 @@ function startFakeCes(opts: {
190
196
  }
191
197
 
192
198
  afterEach(async () => {
199
+ fakeAssistantIpc?.close();
200
+ fakeAssistantIpc = null;
193
201
  cesServer?.stop(true);
194
202
  cesServer = null;
195
203
  gatewayPort = 0;
@@ -17,8 +17,11 @@ import {
17
17
  import { mkdirSync, renameSync, writeFileSync, rmSync } from "node:fs";
18
18
  import { hostname, tmpdir, userInfo } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
+ import type { Server } from "node:net";
20
21
  import { fileURLToPath } from "node:url";
21
22
 
23
+ import { startFakeAssistantIpc } from "./fake-assistant-ipc.js";
24
+
22
25
  // ---------------------------------------------------------------------------
23
26
  // Constants — must match credential-reader.ts
24
27
  // ---------------------------------------------------------------------------
@@ -189,14 +192,19 @@ const gatewayEntry = join(gatewayRoot, "src", "index.ts");
189
192
 
190
193
  let gatewayProc: ChildProcess | null = null;
191
194
  let port = 0;
195
+ let fakeAssistantIpc: Server | null = null;
192
196
 
193
197
  async function startGateway(): Promise<void> {
194
198
  port = 49152 + Math.floor(Math.random() * 16383);
199
+
200
+ const workspaceDir = join(testDir, ".vellum", "workspace");
201
+ fakeAssistantIpc = startFakeAssistantIpc(workspaceDir);
202
+
195
203
  gatewayProc = spawn("bun", ["run", gatewayEntry], {
196
204
  env: {
197
205
  ...process.env,
198
206
  GATEWAY_SECURITY_DIR: join(testDir, ".vellum", "protected"),
199
- VELLUM_WORKSPACE_DIR: join(testDir, ".vellum", "workspace"),
207
+ VELLUM_WORKSPACE_DIR: workspaceDir,
200
208
  GATEWAY_PORT: String(port),
201
209
  // Ensure Telegram is NOT configured via env vars
202
210
  TELEGRAM_BOT_TOKEN: "",
@@ -229,6 +237,8 @@ async function startGateway(): Promise<void> {
229
237
  }
230
238
 
231
239
  afterEach(async () => {
240
+ fakeAssistantIpc?.close();
241
+ fakeAssistantIpc = null;
232
242
  if (gatewayProc) {
233
243
  const proc = gatewayProc;
234
244
  gatewayProc = null;
@@ -0,0 +1,157 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ realpathSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { initGatewayDb, resetGatewayDb } from "../db/connection.js";
15
+
16
+ const originalSecurityDir = process.env.GATEWAY_SECURITY_DIR;
17
+ const originalAllowRealSecurity =
18
+ process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
19
+ const originalTestRealSecurity =
20
+ process.env.VELLUM_TEST_REAL_GATEWAY_SECURITY_DIR;
21
+ const originalHome = process.env.HOME;
22
+
23
+ afterEach(() => {
24
+ resetGatewayDb();
25
+ if (originalSecurityDir === undefined) {
26
+ delete process.env.GATEWAY_SECURITY_DIR;
27
+ } else {
28
+ process.env.GATEWAY_SECURITY_DIR = originalSecurityDir;
29
+ }
30
+
31
+ if (originalAllowRealSecurity === undefined) {
32
+ delete process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
33
+ } else {
34
+ process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS =
35
+ originalAllowRealSecurity;
36
+ }
37
+
38
+ if (originalTestRealSecurity === undefined) {
39
+ delete process.env.VELLUM_TEST_REAL_GATEWAY_SECURITY_DIR;
40
+ } else {
41
+ process.env.VELLUM_TEST_REAL_GATEWAY_SECURITY_DIR =
42
+ originalTestRealSecurity;
43
+ }
44
+
45
+ if (originalHome === undefined) {
46
+ delete process.env.HOME;
47
+ } else {
48
+ process.env.HOME = originalHome;
49
+ }
50
+ });
51
+
52
+ test("initGatewayDb refuses test runs without an isolated security dir", async () => {
53
+ resetGatewayDb();
54
+ delete process.env.GATEWAY_SECURITY_DIR;
55
+ delete process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
56
+
57
+ await expect(initGatewayDb()).rejects.toThrow(
58
+ "Refusing to open the gateway DB during tests without GATEWAY_SECURITY_DIR",
59
+ );
60
+ });
61
+
62
+ test("initGatewayDb refuses the real security dir during tests even when explicitly set", async () => {
63
+ resetGatewayDb();
64
+ process.env.GATEWAY_SECURITY_DIR = join(homedir(), ".vellum", "protected");
65
+ delete process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
66
+
67
+ await expect(initGatewayDb()).rejects.toThrow(
68
+ "Refusing to open the real gateway security DB during tests",
69
+ );
70
+ });
71
+
72
+ test("initGatewayDb refuses symlink aliases to the real security dir during tests", async () => {
73
+ resetGatewayDb();
74
+ const testRoot = realpathSync(
75
+ mkdtempSync(join(tmpdir(), "vellum-gateway-db-isolation-")),
76
+ );
77
+
78
+ try {
79
+ const fakeHome = join(testRoot, "home");
80
+ const realSecurityDir = join(fakeHome, ".vellum", "protected");
81
+ const aliasParent = join(testRoot, "aliases");
82
+ const securityAlias = join(aliasParent, "gateway-security-link");
83
+
84
+ mkdirSync(realSecurityDir, { recursive: true });
85
+ mkdirSync(aliasParent, { recursive: true });
86
+ symlinkSync(realSecurityDir, securityAlias, "dir");
87
+
88
+ process.env.HOME = fakeHome;
89
+ process.env.GATEWAY_SECURITY_DIR = securityAlias;
90
+ process.env.VELLUM_TEST_REAL_GATEWAY_SECURITY_DIR = realSecurityDir;
91
+ delete process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
92
+
93
+ await expect(initGatewayDb()).rejects.toThrow(
94
+ "Refusing to open the real gateway security DB during tests",
95
+ );
96
+ } finally {
97
+ rmSync(testRoot, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ test("initGatewayDb refuses missing children under symlink aliases to the real security dir", async () => {
102
+ resetGatewayDb();
103
+ const testRoot = realpathSync(
104
+ mkdtempSync(join(tmpdir(), "vellum-gateway-db-isolation-")),
105
+ );
106
+
107
+ try {
108
+ const fakeHome = join(testRoot, "home");
109
+ const realSecurityDir = join(fakeHome, ".vellum", "protected");
110
+ const aliasParent = join(testRoot, "aliases");
111
+ const securityLink = join(aliasParent, "gateway-security-link");
112
+ const missingChild = join(securityLink, "new-security-dir");
113
+
114
+ mkdirSync(realSecurityDir, { recursive: true });
115
+ mkdirSync(aliasParent, { recursive: true });
116
+ symlinkSync(realSecurityDir, securityLink, "dir");
117
+
118
+ process.env.HOME = fakeHome;
119
+ process.env.GATEWAY_SECURITY_DIR = missingChild;
120
+ process.env.VELLUM_TEST_REAL_GATEWAY_SECURITY_DIR = realSecurityDir;
121
+ delete process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
122
+
123
+ await expect(initGatewayDb()).rejects.toThrow(
124
+ "Refusing to open the real gateway security DB during tests",
125
+ );
126
+ } finally {
127
+ rmSync(testRoot, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test("initGatewayDb does not migrate legacy gateway DBs during tests", async () => {
132
+ resetGatewayDb();
133
+ const testRoot = realpathSync(
134
+ mkdtempSync(join(tmpdir(), "vellum-gateway-db-isolation-")),
135
+ );
136
+
137
+ try {
138
+ const fakeHome = join(testRoot, "home");
139
+ const legacyDir = join(fakeHome, ".vellum", "data");
140
+ const legacyDb = join(legacyDir, "gateway.sqlite");
141
+ const securityDir = join(testRoot, "gateway-security");
142
+
143
+ mkdirSync(legacyDir, { recursive: true });
144
+ writeFileSync(legacyDb, "legacy gateway db");
145
+
146
+ process.env.HOME = fakeHome;
147
+ process.env.GATEWAY_SECURITY_DIR = securityDir;
148
+ delete process.env.VELLUM_ALLOW_REAL_GATEWAY_SECURITY_IN_TESTS;
149
+
150
+ await initGatewayDb();
151
+
152
+ expect(existsSync(legacyDb)).toBe(true);
153
+ expect(existsSync(join(securityDir, "gateway.sqlite"))).toBe(true);
154
+ } finally {
155
+ rmSync(testRoot, { recursive: true, force: true });
156
+ }
157
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Minimal fake assistant IPC server for tests.
3
+ *
4
+ * Listens on assistant.sock inside the given workspace dir and responds
5
+ * to the "health" JSON-RPC call with { status: "ok" }. This satisfies
6
+ * the gateway's waitForAssistant() poll so it starts immediately.
7
+ */
8
+ import { createServer, type Server } from "node:net";
9
+ import { join } from "node:path";
10
+ import { mkdirSync } from "node:fs";
11
+
12
+ export function startFakeAssistantIpc(workspaceDir: string): Server {
13
+ mkdirSync(workspaceDir, { recursive: true });
14
+ const socketPath = join(workspaceDir, "assistant.sock");
15
+
16
+ const server = createServer((conn) => {
17
+ let buffer = "";
18
+ conn.on("data", (chunk) => {
19
+ buffer += chunk.toString();
20
+ let idx: number;
21
+ while ((idx = buffer.indexOf("\n")) !== -1) {
22
+ const line = buffer.slice(0, idx).trim();
23
+ buffer = buffer.slice(idx + 1);
24
+ if (!line) continue;
25
+ try {
26
+ const req = JSON.parse(line) as { id: string; method: string };
27
+ conn.write(
28
+ JSON.stringify({ id: req.id, result: { status: "ok" } }) + "\n",
29
+ );
30
+ } catch {
31
+ // ignore malformed
32
+ }
33
+ }
34
+ });
35
+ });
36
+
37
+ server.listen(socketPath);
38
+ return server;
39
+ }
@@ -41,7 +41,7 @@ const TEST_REGISTRY = {
41
41
  },
42
42
  {
43
43
  id: "user-hosted-enabled",
44
- scope: "macos",
44
+ scope: "client",
45
45
  key: "user-hosted-enabled",
46
46
  label: "User Hosted Enabled",
47
47
  description: "Enable user-hosted onboarding flow",
@@ -103,7 +103,7 @@ describe("GET /v1/feature-flags handler", () => {
103
103
  const defaults = loadFeatureFlagDefaults();
104
104
  const declaredKeys = Object.keys(defaults);
105
105
 
106
- // Should return all declared assistant-scope flags (not macos-scope)
106
+ // Should return all declared assistant-scope flags (not client-scope)
107
107
  expect(body.flags.length).toBe(declaredKeys.length);
108
108
  expect(body.flags.length).toBeGreaterThan(0);
109
109
 
@@ -159,11 +159,11 @@ describe("GET /v1/feature-flags handler", () => {
159
159
  expect(res.status).toBe(200);
160
160
  const body = await res.json();
161
161
 
162
- // The macos-scope flag should not appear
163
- const macosFlag = body.flags.find(
162
+ // The client-scope flag should not appear
163
+ const clientFlag = body.flags.find(
164
164
  (f: { key: string }) => f.key === "user-hosted-enabled",
165
165
  );
166
- expect(macosFlag).toBeUndefined();
166
+ expect(clientFlag).toBeUndefined();
167
167
  });
168
168
 
169
169
  test("returns all declared flags even when store has no persisted values", async () => {
@@ -138,7 +138,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
138
138
  defaultAssistantId: undefined,
139
139
  unmappedPolicy: "reject",
140
140
  port: 7830,
141
- runtimeProxyEnabled: false,
142
141
  runtimeProxyRequireAuth: true,
143
142
  shutdownDrainMs: 5000,
144
143
  runtimeTimeoutMs: 30000,
@@ -359,9 +358,12 @@ describe("guardian/init one-time-use lockfile", () => {
359
358
  const body = await res.json();
360
359
 
361
360
  // Verify contact records were written to the assistant DB
362
- const assistantDb = new Database(join(testRoot, "data", "db", "assistant.db"), {
363
- readonly: true,
364
- });
361
+ const assistantDb = new Database(
362
+ join(testRoot, "data", "db", "assistant.db"),
363
+ {
364
+ readonly: true,
365
+ },
366
+ );
365
367
 
366
368
  const contact = assistantDb
367
369
  .query<
@@ -39,7 +39,7 @@ const TEST_REGISTRY = {
39
39
  },
40
40
  {
41
41
  id: "user-hosted-enabled",
42
- scope: "macos",
42
+ scope: "client",
43
43
  key: "user-hosted-enabled",
44
44
  label: "User Hosted Enabled",
45
45
  description: "Enable user-hosted onboarding flow",
@@ -45,7 +45,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
45
45
  defaultAssistantId: undefined,
46
46
  unmappedPolicy: "reject",
47
47
  port: 7830,
48
- runtimeProxyEnabled: false,
49
48
  runtimeProxyRequireAuth: true,
50
49
  shutdownDrainMs: 5000,
51
50
  runtimeTimeoutMs: 30000,
@@ -22,7 +22,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
22
22
  defaultAssistantId: undefined,
23
23
  unmappedPolicy: "reject",
24
24
  port: 7830,
25
- runtimeProxyEnabled: false,
26
25
  runtimeProxyRequireAuth: true,
27
26
  shutdownDrainMs: 5000,
28
27
  runtimeTimeoutMs: 30000,
@@ -30,7 +30,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
30
30
  defaultAssistantId: undefined,
31
31
  unmappedPolicy: "reject",
32
32
  port: 7830,
33
- runtimeProxyEnabled: false,
34
33
  runtimeProxyRequireAuth: true,
35
34
  shutdownDrainMs: 5000,
36
35
  runtimeTimeoutMs: 30000,
@@ -34,7 +34,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
34
34
  defaultAssistantId: undefined,
35
35
  unmappedPolicy: "reject",
36
36
  port: 7830,
37
- runtimeProxyEnabled: false,
38
37
  runtimeProxyRequireAuth: true,
39
38
  shutdownDrainMs: 5000,
40
39
  runtimeTimeoutMs: 30000,
@@ -9,7 +9,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
9
9
  defaultAssistantId: undefined,
10
10
  unmappedPolicy: "reject",
11
11
  port: 7830,
12
- runtimeProxyEnabled: false,
13
12
  runtimeProxyRequireAuth: true,
14
13
  shutdownDrainMs: 5000,
15
14
  runtimeTimeoutMs: 30000,
@@ -38,7 +38,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
38
38
  defaultAssistantId: undefined,
39
39
  unmappedPolicy: "reject",
40
40
  port: 7830,
41
- runtimeProxyEnabled: false,
42
41
  runtimeProxyRequireAuth: true,
43
42
  shutdownDrainMs: 5000,
44
43
  runtimeTimeoutMs: 30000,
@@ -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,
@@ -41,7 +41,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
41
41
  defaultAssistantId: undefined,
42
42
  unmappedPolicy: "reject",
43
43
  port: 7830,
44
- runtimeProxyEnabled: true,
45
44
  runtimeProxyRequireAuth: true,
46
45
  shutdownDrainMs: 5000,
47
46
  runtimeTimeoutMs: 30000,
@@ -27,7 +27,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
27
27
  defaultAssistantId: undefined,
28
28
  unmappedPolicy: "reject",
29
29
  port: 7830,
30
- runtimeProxyEnabled: true,
31
30
  runtimeProxyRequireAuth: false,
32
31
  shutdownDrainMs: 5000,
33
32
  runtimeTimeoutMs: 30000,
@@ -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,