@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.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- 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:
|
|
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 =
|
|
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:
|
|
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: "
|
|
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
|
|
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
|
|
163
|
-
const
|
|
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(
|
|
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/
|
|
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
|
-
"
|
|
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["
|
|
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(
|
|
363
|
-
|
|
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", () => {
|
|
@@ -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,
|