@vellumai/vellum-gateway 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -0,0 +1,181 @@
1
+ import { existsSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+
5
+ import {
6
+ ConfigFileWatcher,
7
+ type ConfigChangeEvent,
8
+ } from "../config-file-watcher.js";
9
+ import {
10
+ isOnlyVelayPublicBaseUrlChange,
11
+ shouldSyncTwilioPhoneWebhooksAfterConfigChange,
12
+ } from "../twilio/webhook-sync-trigger.js";
13
+ import { testWorkspaceDir } from "./test-preload.js";
14
+
15
+ const configPath = join(testWorkspaceDir, "config.json");
16
+
17
+ function writeConfig(data: Record<string, unknown>): void {
18
+ writeFileSync(configPath, JSON.stringify(data), "utf-8");
19
+ }
20
+
21
+ function pollOnce(watcher: ConfigFileWatcher): void {
22
+ (
23
+ watcher as unknown as {
24
+ pollOnce: () => void;
25
+ }
26
+ ).pollOnce();
27
+ }
28
+
29
+ function makeEvent(
30
+ changedKeys: string[],
31
+ changedFields: Record<string, string[]>,
32
+ ): ConfigChangeEvent {
33
+ return {
34
+ data: {},
35
+ changedKeys: new Set(changedKeys),
36
+ changedFields: new Map(
37
+ Object.entries(changedFields).map(([section, fields]) => [
38
+ section,
39
+ new Set(fields),
40
+ ]),
41
+ ),
42
+ };
43
+ }
44
+
45
+ afterEach(() => {
46
+ try {
47
+ if (existsSync(configPath)) unlinkSync(configPath);
48
+ } catch {
49
+ // best-effort cleanup
50
+ }
51
+ });
52
+
53
+ describe("ConfigFileWatcher", () => {
54
+ test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
55
+ writeConfig({
56
+ ingress: {
57
+ publicBaseUrl: "https://public.example.test",
58
+ },
59
+ });
60
+ const events: ConfigChangeEvent[] = [];
61
+ const watcher = new ConfigFileWatcher((event) => {
62
+ events.push(event);
63
+ });
64
+
65
+ pollOnce(watcher);
66
+ writeConfig({
67
+ ingress: {
68
+ publicBaseUrl: "https://velay.example.test",
69
+ publicBaseUrlManagedBy: "velay",
70
+ },
71
+ });
72
+ pollOnce(watcher);
73
+
74
+ expect(events).toHaveLength(2);
75
+ expect(events[1].changedKeys).toEqual(new Set(["ingress"]));
76
+ expect(events[1].changedFields.get("ingress")).toEqual(
77
+ new Set(["publicBaseUrl", "publicBaseUrlManagedBy"]),
78
+ );
79
+ });
80
+
81
+ test("reports Twilio-only fields when Velay creates ingress from scratch", () => {
82
+ writeConfig({
83
+ gateway: {
84
+ runtimeProxyRequireAuth: false,
85
+ },
86
+ });
87
+ const events: ConfigChangeEvent[] = [];
88
+ const watcher = new ConfigFileWatcher((event) => {
89
+ events.push(event);
90
+ });
91
+
92
+ pollOnce(watcher);
93
+ writeConfig({
94
+ gateway: {
95
+ runtimeProxyRequireAuth: false,
96
+ },
97
+ ingress: {
98
+ publicBaseUrl: "https://velay.example.test",
99
+ publicBaseUrlManagedBy: "velay",
100
+ },
101
+ });
102
+ pollOnce(watcher);
103
+
104
+ expect(events).toHaveLength(2);
105
+ expect(events[1].changedKeys).toEqual(new Set(["ingress"]));
106
+ expect(events[1].changedFields.get("ingress")).toEqual(
107
+ new Set(["publicBaseUrl", "publicBaseUrlManagedBy"]),
108
+ );
109
+ });
110
+
111
+ test("detects public base URL changes", () => {
112
+ writeConfig({
113
+ ingress: {
114
+ publicBaseUrl: "https://old-public.example.test",
115
+ publicBaseUrlManagedBy: "velay",
116
+ },
117
+ });
118
+ const events: ConfigChangeEvent[] = [];
119
+ const watcher = new ConfigFileWatcher((event) => {
120
+ events.push(event);
121
+ });
122
+
123
+ pollOnce(watcher);
124
+ writeConfig({
125
+ ingress: {
126
+ publicBaseUrl: "https://new-public.example.test",
127
+ publicBaseUrlManagedBy: "velay",
128
+ },
129
+ });
130
+ pollOnce(watcher);
131
+
132
+ expect(events).toHaveLength(2);
133
+ expect(events[1].changedFields.get("ingress")).toEqual(
134
+ new Set(["publicBaseUrl"]),
135
+ );
136
+ });
137
+ });
138
+
139
+ describe("Twilio webhook sync config-change triggers", () => {
140
+ test("syncs when generic public ingress changes without a Twilio override", () => {
141
+ const event = makeEvent(["ingress"], { ingress: ["publicBaseUrl"] });
142
+
143
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(false);
144
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
145
+ });
146
+
147
+ test("syncs when the Twilio-specific public ingress changes", () => {
148
+ const event = makeEvent(["ingress"], {
149
+ ingress: ["publicBaseUrl", "publicBaseUrlManagedBy"],
150
+ });
151
+
152
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(true);
153
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
154
+ });
155
+
156
+ test("does not sync when only the Velay manager marker changes", () => {
157
+ const event = makeEvent(["ingress"], {
158
+ ingress: ["publicBaseUrlManagedBy"],
159
+ });
160
+
161
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(true);
162
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(false);
163
+ });
164
+
165
+ test("syncs when Twilio phone configuration becomes available", () => {
166
+ const event = makeEvent(["twilio"], {
167
+ twilio: ["phoneNumber", "accountSid"],
168
+ });
169
+
170
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(false);
171
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
172
+ });
173
+
174
+ test("does not sync when unrelated Twilio configuration changes", () => {
175
+ const event = makeEvent(["twilio"], {
176
+ twilio: ["assistantPhoneNumbers"],
177
+ });
178
+
179
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(false);
180
+ });
181
+ });
@@ -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 { createServer, 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,37 @@ 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;
196
+
197
+ /** Ask the OS for a free port by briefly binding to port 0. */
198
+ function getFreePort(): Promise<number> {
199
+ return new Promise((resolve, reject) => {
200
+ const srv = createServer();
201
+ srv.listen(0, "127.0.0.1", () => {
202
+ const addr = srv.address();
203
+ if (!addr || typeof addr === "string") {
204
+ srv.close();
205
+ reject(new Error("Failed to get free port"));
206
+ return;
207
+ }
208
+ const p = addr.port;
209
+ srv.close(() => resolve(p));
210
+ });
211
+ srv.on("error", reject);
212
+ });
213
+ }
192
214
 
193
215
  async function startGateway(): Promise<void> {
194
- port = 49152 + Math.floor(Math.random() * 16383);
216
+ port = await getFreePort();
217
+
218
+ const workspaceDir = join(testDir, ".vellum", "workspace");
219
+ fakeAssistantIpc = startFakeAssistantIpc(workspaceDir);
220
+
195
221
  gatewayProc = spawn("bun", ["run", gatewayEntry], {
196
222
  env: {
197
223
  ...process.env,
198
224
  GATEWAY_SECURITY_DIR: join(testDir, ".vellum", "protected"),
199
- VELLUM_WORKSPACE_DIR: join(testDir, ".vellum", "workspace"),
225
+ VELLUM_WORKSPACE_DIR: workspaceDir,
200
226
  GATEWAY_PORT: String(port),
201
227
  // Ensure Telegram is NOT configured via env vars
202
228
  TELEGRAM_BOT_TOKEN: "",
@@ -229,6 +255,8 @@ async function startGateway(): Promise<void> {
229
255
  }
230
256
 
231
257
  afterEach(async () => {
258
+ fakeAssistantIpc?.close();
259
+ fakeAssistantIpc = null;
232
260
  if (gatewayProc) {
233
261
  const proc = gatewayProc;
234
262
  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 () => {
@@ -488,12 +488,12 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
488
488
 
489
489
  const handler = createFeatureFlagsPatchHandler();
490
490
  const res = await handler(
491
- new Request("http://gateway.test/v1/feature-flags/browser", {
491
+ new Request("http://gateway.test/v1/feature-flags/email-channel", {
492
492
  method: "PATCH",
493
493
  headers: { "content-type": "application/json" },
494
494
  body: JSON.stringify({ enabled: true }),
495
495
  }),
496
- "browser",
496
+ "email-channel",
497
497
  );
498
498
 
499
499
  expect(res.status).toBe(200);
@@ -501,7 +501,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
501
501
 
502
502
  clearFeatureFlagStoreCache();
503
503
  const persisted = readPersistedFeatureFlags();
504
- expect(persisted["browser"]).toBe(true);
504
+ expect(persisted["email-channel"]).toBe(true);
505
505
  });
506
506
 
507
507
  // Validation tests
@@ -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<
@@ -581,6 +583,7 @@ describe("guardian/reset-bootstrap", () => {
581
583
  describe("guardian/init bare-metal loopback gating", () => {
582
584
  test("rejects non-loopback clients in bare-metal mode", async () => {
583
585
  delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
586
+ delete process.env.IS_PLATFORM;
584
587
  const handler = createChannelVerificationSessionProxyHandler(makeConfig());
585
588
  const res = await handler.handleGuardianInit(
586
589
  new Request("http://localhost:7830/v1/guardian/init", {
@@ -598,6 +601,7 @@ describe("guardian/init bare-metal loopback gating", () => {
598
601
 
599
602
  test("allows loopback clients in bare-metal mode", async () => {
600
603
  delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
604
+ delete process.env.IS_PLATFORM;
601
605
  const handler = createChannelVerificationSessionProxyHandler(makeConfig());
602
606
  const res = await handler.handleGuardianInit(
603
607
  new Request("http://localhost:7830/v1/guardian/init", {
@@ -632,6 +636,28 @@ describe("guardian/init bare-metal loopback gating", () => {
632
636
  const body = await res.json();
633
637
  expect(body.accessToken).toBeTruthy();
634
638
  });
639
+
640
+ test("skips loopback check in platform-managed mode (IS_PLATFORM=true)", async () => {
641
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
642
+ process.env.IS_PLATFORM = "true";
643
+ try {
644
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
645
+ const res = await handler.handleGuardianInit(
646
+ new Request("http://localhost:7830/v1/guardian/init", {
647
+ method: "POST",
648
+ headers: { "Content-Type": "application/json" },
649
+ body: JSON.stringify({ platform: "web", deviceId: "platform-abc123" }),
650
+ }),
651
+ "::ffff:10.112.1.68",
652
+ );
653
+
654
+ expect(res.status).toBe(200);
655
+ const body = await res.json();
656
+ expect(body.accessToken).toBeTruthy();
657
+ } finally {
658
+ delete process.env.IS_PLATFORM;
659
+ }
660
+ });
635
661
  });
636
662
 
637
663
  describe("guardian/init request validation", () => {
@@ -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,