@vellumai/vellum-gateway 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +20 -21
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +1 -1
- package/src/__tests__/contact-prompt-submit.test.ts +349 -0
- package/src/__tests__/ipc-route-policy.test.ts +24 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
- package/src/__tests__/slack-display-name.test.ts +6 -2
- package/src/__tests__/slack-normalize.test.ts +36 -56
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
- package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
- package/src/__tests__/twilio-webhooks.test.ts +2 -6
- package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
- package/src/auth/guardian-bootstrap.ts +49 -0
- package/src/auth/ipc-route-policy.ts +5 -0
- package/src/db/contact-store.ts +27 -1
- package/src/email/register-callback.test.ts +4 -4
- package/src/email/register-callback.ts +12 -16
- package/src/feature-flag-registry.json +27 -3
- package/src/handlers/handle-inbound.ts +12 -0
- package/src/http/routes/contact-prompt.ts +134 -23
- package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
- package/src/http/routes/ipc-runtime-proxy.ts +18 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
- package/src/http/routes/twilio-voice-webhook.ts +53 -0
- package/src/index.ts +4 -2
- package/src/ipc/velay-handlers.ts +31 -0
- package/src/remote-feature-flag-sync.ts +10 -8
- package/src/risk/command-registry/commands/assistant.ts +1 -0
- package/src/risk/skill-risk-classifier.ts +12 -3
- package/src/runtime/client.ts +25 -12
- package/src/slack/normalize.test.ts +3 -3
- package/src/slack/normalize.ts +6 -69
- package/src/slack/socket-mode.ts +1 -5
- package/src/telegram/webhook-manager.ts +9 -13
- package/src/velay/client.ts +27 -16
- package/src/verification/contact-helpers.ts +6 -3
package/ARCHITECTURE.md
CHANGED
|
@@ -280,10 +280,10 @@ Channel bindings follow a three-phase lifecycle:
|
|
|
280
280
|
|
|
281
281
|
The public URL where the gateway is reachable is configured via:
|
|
282
282
|
|
|
283
|
-
| Source
|
|
284
|
-
|
|
|
285
|
-
| `ingress.publicBaseUrl` (workspace config)
|
|
286
|
-
| `ingress.
|
|
283
|
+
| Source | Description |
|
|
284
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
285
|
+
| `ingress.publicBaseUrl` (workspace config) | Canonical public ingress URL for Telegram webhooks, OAuth callbacks, email callbacks, generic JSON webhooks, Twilio webhooks, and Twilio WebSocket URLs |
|
|
286
|
+
| `ingress.publicBaseUrlManagedBy` (workspace config) | Ownership marker used when Velay published `ingress.publicBaseUrl`; lets the gateway clear stale Velay-managed URLs without disturbing manual URLs |
|
|
287
287
|
|
|
288
288
|
### Tunnel-Agnostic Setup
|
|
289
289
|
|
|
@@ -296,9 +296,9 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
|
|
|
296
296
|
|
|
297
297
|
### Velay Twilio Ingress
|
|
298
298
|
|
|
299
|
-
Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic.
|
|
299
|
+
Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. When it is active, Velay publishes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`.
|
|
300
300
|
|
|
301
|
-
When `VELAY_BASE_URL` is present in the gateway environment, the gateway starts `VelayTunnelClient`. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.
|
|
301
|
+
When `VELAY_BASE_URL` is present in the gateway environment, the gateway starts `VelayTunnelClient`. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
|
|
302
302
|
|
|
303
303
|
Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
|
|
304
304
|
|
|
@@ -310,16 +310,16 @@ Public Velay HTTPS/WSS URL
|
|
|
310
310
|
→ Existing gateway route handlers
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
-
The HTTP bridge can carry normal JSON requests and health checks, so it is useful for local bridge smoke tests.
|
|
313
|
+
The HTTP bridge can carry normal JSON requests and health checks, so it is useful for local bridge smoke tests. Velay-managed `ingress.publicBaseUrl` changes are tagged with `publicBaseUrlManagedBy` so gateway side effects can skip unrelated webhook reconciliation while still refreshing Twilio phone-number webhooks.
|
|
314
314
|
|
|
315
315
|
Local platform smoke-test flow:
|
|
316
316
|
|
|
317
317
|
1. In `vellum-assistant-platform`, run `vel up velay`.
|
|
318
|
-
2. Ensure vembda passes `VELAY_BASE_URL
|
|
318
|
+
2. Ensure vembda passes the environment-appropriate `VELAY_BASE_URL` into assistant gateway containers.
|
|
319
319
|
3. Re-hatch or restart the assistant so the gateway receives the new environment.
|
|
320
320
|
4. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
|
|
321
321
|
5. Verify HTTP forwarding by requesting `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/healthz` and `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/schema`. When validating a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm it reaches the loopback gateway.
|
|
322
|
-
6. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after
|
|
322
|
+
6. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after the gateway has registered with Velay.
|
|
323
323
|
|
|
324
324
|
### URL Builders
|
|
325
325
|
|
|
@@ -328,10 +328,10 @@ All public-facing URLs are constructed by `assistant/src/inbound/public-ingress-
|
|
|
328
328
|
| Function | URL Pattern |
|
|
329
329
|
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
330
330
|
| `getPublicBaseUrl()` | Resolves the canonical base URL from `ingress.publicBaseUrl` in workspace config or module-level state (assistant-side; the gateway reads via `ConfigFileCache`) |
|
|
331
|
-
| `getTwilioVoiceWebhookUrl()` | `${
|
|
332
|
-
| `getTwilioStatusCallbackUrl()` | `${
|
|
333
|
-
| `getTwilioConnectActionUrl()` | `${
|
|
334
|
-
| `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay`,
|
|
331
|
+
| `getTwilioVoiceWebhookUrl()` | `${base}/webhooks/twilio/voice?callSessionId=...`, using `ingress.publicBaseUrl` |
|
|
332
|
+
| `getTwilioStatusCallbackUrl()` | `${base}/webhooks/twilio/status`, using `ingress.publicBaseUrl` |
|
|
333
|
+
| `getTwilioConnectActionUrl()` | `${base}/webhooks/twilio/connect-action`, using `ingress.publicBaseUrl` |
|
|
334
|
+
| `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay`, using `ingress.publicBaseUrl` |
|
|
335
335
|
| `getOAuthCallbackUrl()` | `${base}/webhooks/oauth/callback` |
|
|
336
336
|
| `getTelegramWebhookUrl()` | `${base}/webhooks/telegram` |
|
|
337
337
|
|
|
@@ -1077,21 +1077,20 @@ In gateway-fronted deployments, the TwiML WebSocket URL (returned by the voice w
|
|
|
1077
1077
|
|
|
1078
1078
|
Signature validation is **fail-closed**: if the Twilio auth token is not configured, all webhook requests are rejected with `403`. Missing or invalid `X-Twilio-Signature` headers are also rejected with `403`. Payload size is capped by `maxWebhookPayloadBytes` (checked via both `Content-Length` header and actual body size).
|
|
1079
1079
|
|
|
1080
|
-
**Webhook base URL resolution:** Public ingress URL construction is centralized in `public-ingress-urls.ts
|
|
1080
|
+
**Webhook base URL resolution:** Public ingress URL construction is centralized in `public-ingress-urls.ts`:
|
|
1081
1081
|
|
|
1082
|
-
- Twilio voice/status/connect-action/relay/media-stream URLs use `ingress.
|
|
1083
|
-
-
|
|
1084
|
-
- Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs use `ingress.publicBaseUrl`; Velay
|
|
1082
|
+
- Twilio voice/status/connect-action/relay/media-stream URLs use `ingress.publicBaseUrl`.
|
|
1083
|
+
- Velay registration publishes its public assistant URL to `ingress.publicBaseUrl` with `ingress.publicBaseUrlManagedBy: "velay"`.
|
|
1084
|
+
- Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs also use `ingress.publicBaseUrl`; Velay-managed URL changes are tagged so unrelated reconciliation can be skipped when appropriate.
|
|
1085
1085
|
- Module-level assistant state remains a fallback for legacy tunnel start/stop flows.
|
|
1086
1086
|
|
|
1087
1087
|
All webhook paths (`/webhooks/twilio/voice`, `/webhooks/twilio/status`, `/webhooks/telegram`, `/webhooks/oauth/callback`, etc.) are appended automatically.
|
|
1088
1088
|
|
|
1089
1089
|
For **inbound Twilio signature validation** at the gateway, URL reconstruction now supports multiple candidates in order:
|
|
1090
1090
|
|
|
1091
|
-
1. `ConfigFileCache.getString("ingress", "
|
|
1092
|
-
2. `
|
|
1093
|
-
3.
|
|
1094
|
-
4. Raw request URL (always included as the final fallback)
|
|
1091
|
+
1. `ConfigFileCache.getString("ingress", "publicBaseUrl")` (if configured)
|
|
1092
|
+
2. Forwarded public URL headers from the tunnel/proxy (`X-Forwarded-Proto` + `X-Forwarded-Host`/`X-Original-Host` fallbacks)
|
|
1093
|
+
3. Raw request URL (always included as the final fallback)
|
|
1095
1094
|
|
|
1096
1095
|
This makes ingress URL updates smoother in local tunnel workflows because Twilio webhooks can continue validating immediately. For Telegram, the config file watcher detects ingress URL changes and triggers webhook reconciliation directly, so neither channel requires a gateway restart.
|
|
1097
1096
|
|
package/README.md
CHANGED
|
@@ -210,9 +210,9 @@ In local tunnel setups, updating `ingress.publicBaseUrl` in Settings is typicall
|
|
|
210
210
|
|
|
211
211
|
The assistant runtime uses this URL to construct all webhook and OAuth callback URLs automatically.
|
|
212
212
|
|
|
213
|
-
### Velay for Twilio
|
|
213
|
+
### Velay for Twilio Testing
|
|
214
214
|
|
|
215
|
-
Velay is
|
|
215
|
+
Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. When Velay registration succeeds, the gateway writes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`. Twilio URL builders use that public base URL for voice, status, relay, and media-stream endpoints.
|
|
216
216
|
|
|
217
217
|
Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
|
|
218
218
|
|
|
@@ -222,16 +222,16 @@ Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades throug
|
|
|
222
222
|
vel up velay
|
|
223
223
|
```
|
|
224
224
|
|
|
225
|
-
2. Ensure vembda injects the Velay endpoint into assistant gateway containers:
|
|
225
|
+
2. Ensure vembda injects the Velay endpoint into assistant gateway containers. For local Docker-hosted assistants, the gateway container must dial the Velay service running on the host:
|
|
226
226
|
|
|
227
227
|
```bash
|
|
228
228
|
VELAY_BASE_URL=http://host.docker.internal:8501
|
|
229
229
|
```
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
Hosted environments should use their environment's deployed Velay URL instead.
|
|
232
232
|
|
|
233
233
|
3. Re-hatch or restart the assistant so the gateway process receives `VELAY_BASE_URL`.
|
|
234
|
-
4. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.
|
|
234
|
+
4. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.publicBaseUrl`.
|
|
235
235
|
|
|
236
236
|
For an HTTP bridge smoke test, send a request to the registered Velay public URL and confirm it reaches the loopback gateway, for example:
|
|
237
237
|
|
|
@@ -249,7 +249,7 @@ bun -e 'const ws = new WebSocket(process.argv[1]); ws.onopen = () => { console.l
|
|
|
249
249
|
"wss://<velay-host>/<assistant-id>/webhooks/twilio/relay?callSessionId=session-123&token=<edge-token>"
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
-
For a real Twilio call, expose local Velay with a public HTTPS/WSS tunnel and configure the platform Velay service with that origin as `VELAY_PUBLIC_BASE_URL`. After the assistant re-registers, Twilio should fetch `/webhooks/twilio/voice` and open `/webhooks/twilio/relay` or `/webhooks/twilio/media-stream/...` through the Velay URL.
|
|
252
|
+
For a real Twilio call, expose local Velay with a public HTTPS/WSS tunnel and configure the platform Velay service with that origin as `VELAY_PUBLIC_BASE_URL`. After the assistant re-registers, Twilio should fetch `/webhooks/twilio/voice` and open `/webhooks/twilio/relay` or `/webhooks/twilio/media-stream/...` through the Velay URL. Use ngrok or another custom tunnel in `ingress.publicBaseUrl` only for local/self-hosted workflows that are not routed through Velay.
|
|
253
253
|
|
|
254
254
|
## Ingress Boundary Guarantees
|
|
255
255
|
|
package/package.json
CHANGED
|
@@ -144,7 +144,7 @@ describe("Twilio webhook sync config-change triggers", () => {
|
|
|
144
144
|
expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
test("syncs when
|
|
147
|
+
test("syncs when Velay-managed public ingress changes", () => {
|
|
148
148
|
const event = makeEvent(["ingress"], {
|
|
149
149
|
ingress: ["publicBaseUrl", "publicBaseUrlManagedBy"],
|
|
150
150
|
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for POST /v1/contacts/prompt/submit.
|
|
3
|
+
*
|
|
4
|
+
* Covers the key contact-first resolution logic:
|
|
5
|
+
* - Guardian prompts always bind to the existing guardian contact.
|
|
6
|
+
* - Guardian prompts conflict (409) when the channel belongs to another contact.
|
|
7
|
+
* - Non-guardian prompts create or reuse contacts via channel lookup.
|
|
8
|
+
* - All writes are dual-written to the gateway DB.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
afterAll,
|
|
12
|
+
afterEach,
|
|
13
|
+
beforeAll,
|
|
14
|
+
beforeEach,
|
|
15
|
+
describe,
|
|
16
|
+
expect,
|
|
17
|
+
mock,
|
|
18
|
+
test,
|
|
19
|
+
} from "bun:test";
|
|
20
|
+
import { Database } from "bun:sqlite";
|
|
21
|
+
|
|
22
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
23
|
+
|
|
24
|
+
initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long-xx"));
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Mock assistant DB proxy with a real in-memory SQLite.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
let testAssistantDb: Database | null = null;
|
|
31
|
+
|
|
32
|
+
mock.module("../db/assistant-db-proxy.js", () => ({
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
async assistantDbQuery(sql: string, bind?: any[]) {
|
|
35
|
+
if (!testAssistantDb) throw new Error("test assistant DB not initialized");
|
|
36
|
+
const stmt = testAssistantDb.prepare(sql);
|
|
37
|
+
return bind ? stmt.all(...bind) : stmt.all();
|
|
38
|
+
},
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
async assistantDbRun(sql: string, bind?: any[]) {
|
|
41
|
+
if (!testAssistantDb) throw new Error("test assistant DB not initialized");
|
|
42
|
+
const stmt = testAssistantDb.prepare(sql);
|
|
43
|
+
const result = bind ? stmt.run(...bind) : stmt.run();
|
|
44
|
+
return { changes: result.changes, lastInsertRowid: Number(result.lastInsertRowid) };
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Mock IPC so resolve_contact_prompt doesn't try to dial a real socket.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const ipcMock = mock(async () => ({ resolved: true }));
|
|
53
|
+
|
|
54
|
+
mock.module("../ipc/assistant-client.js", () => ({
|
|
55
|
+
ipcCallAssistant: ipcMock,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Imports that depend on the mocks above.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const { handleContactPromptSubmit } = await import(
|
|
63
|
+
"../http/routes/contact-prompt.js"
|
|
64
|
+
);
|
|
65
|
+
const { initGatewayDb, getGatewayDb, resetGatewayDb } = await import(
|
|
66
|
+
"../db/connection.js"
|
|
67
|
+
);
|
|
68
|
+
const { contactChannels: gwContactChannels, contacts: gwContacts } =
|
|
69
|
+
await import("../db/schema.js");
|
|
70
|
+
const { eq } = await import("drizzle-orm");
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Schema helpers
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function initAssistantDb(): Database {
|
|
77
|
+
const db = new Database(":memory:");
|
|
78
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE TABLE contacts (
|
|
81
|
+
id TEXT PRIMARY KEY,
|
|
82
|
+
display_name TEXT NOT NULL,
|
|
83
|
+
notes TEXT,
|
|
84
|
+
created_at INTEGER NOT NULL,
|
|
85
|
+
updated_at INTEGER NOT NULL,
|
|
86
|
+
role TEXT NOT NULL DEFAULT 'contact',
|
|
87
|
+
principal_id TEXT,
|
|
88
|
+
user_file TEXT,
|
|
89
|
+
contact_type TEXT NOT NULL DEFAULT 'human'
|
|
90
|
+
)
|
|
91
|
+
`);
|
|
92
|
+
db.exec(`
|
|
93
|
+
CREATE TABLE contact_channels (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
96
|
+
type TEXT NOT NULL,
|
|
97
|
+
address TEXT NOT NULL,
|
|
98
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
external_user_id TEXT,
|
|
100
|
+
external_chat_id TEXT,
|
|
101
|
+
status TEXT NOT NULL DEFAULT 'unverified',
|
|
102
|
+
policy TEXT NOT NULL DEFAULT 'allow',
|
|
103
|
+
verified_at INTEGER,
|
|
104
|
+
verified_via TEXT,
|
|
105
|
+
invite_id TEXT,
|
|
106
|
+
revoked_reason TEXT,
|
|
107
|
+
blocked_reason TEXT,
|
|
108
|
+
last_seen_at INTEGER,
|
|
109
|
+
interaction_count INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
last_interaction INTEGER,
|
|
111
|
+
updated_at INTEGER,
|
|
112
|
+
created_at INTEGER NOT NULL
|
|
113
|
+
)
|
|
114
|
+
`);
|
|
115
|
+
db.exec(
|
|
116
|
+
`CREATE UNIQUE INDEX idx_contact_channels_type_address ON contact_channels(type, address)`,
|
|
117
|
+
);
|
|
118
|
+
return db;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Request factory
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function makeRequest(body: Record<string, unknown>): Request {
|
|
126
|
+
return new Request("http://localhost:7830/v1/contacts/prompt/submit", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Suite setup
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
beforeAll(async () => {
|
|
138
|
+
await initGatewayDb();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterAll(() => {
|
|
142
|
+
resetGatewayDb();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
testAssistantDb = initAssistantDb();
|
|
147
|
+
ipcMock.mockClear();
|
|
148
|
+
|
|
149
|
+
const gwDb = getGatewayDb();
|
|
150
|
+
gwDb.delete(gwContactChannels).run();
|
|
151
|
+
gwDb.delete(gwContacts).run();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
testAssistantDb?.close();
|
|
156
|
+
testAssistantDb = null;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Tests
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe("handleContactPromptSubmit", () => {
|
|
164
|
+
test("guardian prompt — creates channel bound to existing guardian contact", async () => {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
// Seed an existing guardian contact in the assistant DB.
|
|
167
|
+
testAssistantDb!.run(
|
|
168
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
169
|
+
VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
|
|
170
|
+
[now, now],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const res = await handleContactPromptSubmit(
|
|
174
|
+
makeRequest({ requestId: "req-1", address: "+15551234567", channelType: "phone", role: "guardian" }),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(res.status).toBe(200);
|
|
178
|
+
const body = await res.json() as Record<string, unknown>;
|
|
179
|
+
expect(body.accepted).toBe(true);
|
|
180
|
+
|
|
181
|
+
// Channel should be created in assistant DB pointing to guardian.
|
|
182
|
+
const channels = testAssistantDb!
|
|
183
|
+
.prepare(`SELECT contact_id FROM contact_channels WHERE type = 'phone' AND address = ?`)
|
|
184
|
+
.all("+15551234567") as { contact_id: string }[];
|
|
185
|
+
expect(channels).toHaveLength(1);
|
|
186
|
+
expect(channels[0].contact_id).toBe("guardian-1");
|
|
187
|
+
|
|
188
|
+
// IPC should have been called with the guardian contactId.
|
|
189
|
+
expect(ipcMock).toHaveBeenCalledTimes(1);
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
192
|
+
expect(ipcCall.body.contactId).toBe("guardian-1");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("guardian prompt — reuses channel already bound to guardian", async () => {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
testAssistantDb!.run(
|
|
198
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
199
|
+
VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
|
|
200
|
+
[now, now],
|
|
201
|
+
);
|
|
202
|
+
testAssistantDb!.run(
|
|
203
|
+
`INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
|
|
204
|
+
VALUES ('chan-1', 'guardian-1', 'phone', '+15551234567', 1, 'active', 'allow', 5, ?, ?)`,
|
|
205
|
+
[now, now],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const res = await handleContactPromptSubmit(
|
|
209
|
+
makeRequest({ requestId: "req-2", address: "+15551234567", channelType: "phone", role: "guardian" }),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
const body = await res.json() as Record<string, unknown>;
|
|
214
|
+
expect(body.accepted).toBe(true);
|
|
215
|
+
|
|
216
|
+
// No new channel should have been inserted.
|
|
217
|
+
const channels = testAssistantDb!
|
|
218
|
+
.prepare(`SELECT id FROM contact_channels WHERE type = 'phone'`)
|
|
219
|
+
.all() as { id: string }[];
|
|
220
|
+
expect(channels).toHaveLength(1);
|
|
221
|
+
expect(channels[0].id).toBe("chan-1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("guardian prompt — 409 when channel already belongs to another contact", async () => {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
// Guardian contact.
|
|
227
|
+
testAssistantDb!.run(
|
|
228
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
229
|
+
VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
|
|
230
|
+
[now, now],
|
|
231
|
+
);
|
|
232
|
+
// A different (orphaned or stale) contact that owns the channel.
|
|
233
|
+
testAssistantDb!.run(
|
|
234
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
235
|
+
VALUES ('other-1', 'Orphan', 'contact', 'human', ?, ?)`,
|
|
236
|
+
[now, now],
|
|
237
|
+
);
|
|
238
|
+
testAssistantDb!.run(
|
|
239
|
+
`INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
|
|
240
|
+
VALUES ('chan-other', 'other-1', 'phone', '+15551234567', 1, 'unverified', 'allow', 0, ?, ?)`,
|
|
241
|
+
[now, now],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const res = await handleContactPromptSubmit(
|
|
245
|
+
makeRequest({ requestId: "req-3", address: "+15551234567", channelType: "phone", role: "guardian" }),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(409);
|
|
249
|
+
const body = await res.json() as Record<string, unknown>;
|
|
250
|
+
expect(body.accepted).toBe(false);
|
|
251
|
+
|
|
252
|
+
// The stale channel must not have been deleted.
|
|
253
|
+
const channels = testAssistantDb!
|
|
254
|
+
.prepare(`SELECT id FROM contact_channels WHERE type = 'phone'`)
|
|
255
|
+
.all() as { id: string }[];
|
|
256
|
+
expect(channels).toHaveLength(1);
|
|
257
|
+
expect(channels[0].id).toBe("chan-other");
|
|
258
|
+
|
|
259
|
+
// IPC should have been called with an error so the CLI doesn't hang.
|
|
260
|
+
expect(ipcMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
262
|
+
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
263
|
+
expect(typeof ipcCall.body.error).toBe("string");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("non-guardian prompt — creates new contact and channel", async () => {
|
|
267
|
+
const res = await handleContactPromptSubmit(
|
|
268
|
+
makeRequest({
|
|
269
|
+
requestId: "req-4",
|
|
270
|
+
address: "alice@example.com",
|
|
271
|
+
channelType: "email",
|
|
272
|
+
role: "trusted-contact",
|
|
273
|
+
displayName: "Alice",
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
|
|
279
|
+
const contacts = testAssistantDb!
|
|
280
|
+
.prepare(`SELECT id, role FROM contacts WHERE display_name = 'Alice'`)
|
|
281
|
+
.all() as { id: string; role: string }[];
|
|
282
|
+
expect(contacts).toHaveLength(1);
|
|
283
|
+
expect(contacts[0].role).toBe("contact");
|
|
284
|
+
|
|
285
|
+
const channels = testAssistantDb!
|
|
286
|
+
.prepare(`SELECT contact_id FROM contact_channels WHERE type = 'email' AND address = ?`)
|
|
287
|
+
.all("alice@example.com") as { contact_id: string }[];
|
|
288
|
+
expect(channels).toHaveLength(1);
|
|
289
|
+
expect(channels[0].contact_id).toBe(contacts[0].id);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("non-guardian prompt — reuses existing contact when channel already known", async () => {
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
testAssistantDb!.run(
|
|
295
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
296
|
+
VALUES ('contact-1', 'Alice', 'contact', 'human', ?, ?)`,
|
|
297
|
+
[now, now],
|
|
298
|
+
);
|
|
299
|
+
testAssistantDb!.run(
|
|
300
|
+
`INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
|
|
301
|
+
VALUES ('chan-alice', 'contact-1', 'email', 'alice@example.com', 1, 'active', 'allow', 3, ?, ?)`,
|
|
302
|
+
[now, now],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const res = await handleContactPromptSubmit(
|
|
306
|
+
makeRequest({ requestId: "req-5", address: "alice@example.com", channelType: "email" }),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
expect(res.status).toBe(200);
|
|
310
|
+
|
|
311
|
+
// Should not have created a second contact.
|
|
312
|
+
const contacts = testAssistantDb!
|
|
313
|
+
.prepare(`SELECT id FROM contacts`)
|
|
314
|
+
.all() as { id: string }[];
|
|
315
|
+
expect(contacts).toHaveLength(1);
|
|
316
|
+
expect(contacts[0].id).toBe("contact-1");
|
|
317
|
+
|
|
318
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
319
|
+
const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
|
|
320
|
+
expect(ipcCall.body.contactId).toBe("contact-1");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("gateway DB receives dual-write for new contact and channel", async () => {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
testAssistantDb!.run(
|
|
326
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
327
|
+
VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
|
|
328
|
+
[now, now],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Also seed guardian in gateway DB so FK is satisfied.
|
|
332
|
+
getGatewayDb()
|
|
333
|
+
.insert(gwContacts)
|
|
334
|
+
.values({ id: "guardian-1", displayName: "Vargas", role: "guardian", createdAt: now, updatedAt: now })
|
|
335
|
+
.run();
|
|
336
|
+
|
|
337
|
+
await handleContactPromptSubmit(
|
|
338
|
+
makeRequest({ requestId: "req-6", address: "+15559876543", channelType: "phone", role: "guardian" }),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const gwChannels = getGatewayDb()
|
|
342
|
+
.select()
|
|
343
|
+
.from(gwContactChannels)
|
|
344
|
+
.where(eq(gwContactChannels.address, "+15559876543"))
|
|
345
|
+
.all();
|
|
346
|
+
expect(gwChannels).toHaveLength(1);
|
|
347
|
+
expect(gwChannels[0].contactId).toBe("guardian-1");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { getIpcRoutePolicy } from "../auth/ipc-route-policy.js";
|
|
4
|
+
|
|
5
|
+
describe("ipc-route-policy: gateway-only daemon routes", () => {
|
|
6
|
+
// The gateway IPC proxy default-allows operationIds with no policy entry.
|
|
7
|
+
// Routes that the daemon's HTTP route policy marks as gateway-only
|
|
8
|
+
// (internal.write + svc_gateway) MUST also have a matching IPC policy
|
|
9
|
+
// entry — otherwise an authenticated edge JWT can reach them by setting
|
|
10
|
+
// X-Vellum-Proxy-Server: ipc, bypassing the daemon HTTP router entirely.
|
|
11
|
+
test.each([
|
|
12
|
+
"admin_rollbackmigrations_post",
|
|
13
|
+
"internal_mcp_auth_start",
|
|
14
|
+
"internal_mcp_auth_status",
|
|
15
|
+
"internal_mcp_reload",
|
|
16
|
+
"internal_oauth_connect_start",
|
|
17
|
+
"internal_oauth_connect_status",
|
|
18
|
+
])("%s requires internal.write and svc_gateway", (operationId) => {
|
|
19
|
+
const policy = getIpcRoutePolicy(operationId);
|
|
20
|
+
expect(policy).toBeDefined();
|
|
21
|
+
expect(policy!.requiredScopes).toEqual(["internal.write"]);
|
|
22
|
+
expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -499,3 +499,53 @@ describe("graceful fallback when cache not initialized", () => {
|
|
|
499
499
|
expect(result.matchType).toBe("registry");
|
|
500
500
|
});
|
|
501
501
|
});
|
|
502
|
+
|
|
503
|
+
describe("SkillLoadRiskClassifier inline command risk elevation", () => {
|
|
504
|
+
test("skill with inline expansions is classified as medium risk", async () => {
|
|
505
|
+
const classifier = new SkillLoadRiskClassifier();
|
|
506
|
+
const result = await classifier.classify({
|
|
507
|
+
toolName: "skill_load",
|
|
508
|
+
skillSelector: "my-skill",
|
|
509
|
+
resolvedMetadata: {
|
|
510
|
+
skillId: "my-skill",
|
|
511
|
+
selector: "my-skill",
|
|
512
|
+
versionHash: "abc123",
|
|
513
|
+
transitiveHash: "def456",
|
|
514
|
+
hasInlineExpansions: true,
|
|
515
|
+
isDynamic: true,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
expect(result.riskLevel).toBe("medium");
|
|
520
|
+
expect(result.reason).toContain("inline command expansions");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("skill without inline expansions is classified as low risk", async () => {
|
|
524
|
+
const classifier = new SkillLoadRiskClassifier();
|
|
525
|
+
const result = await classifier.classify({
|
|
526
|
+
toolName: "skill_load",
|
|
527
|
+
skillSelector: "plain-skill",
|
|
528
|
+
resolvedMetadata: {
|
|
529
|
+
skillId: "plain-skill",
|
|
530
|
+
selector: "plain-skill",
|
|
531
|
+
versionHash: "abc123",
|
|
532
|
+
transitiveHash: undefined,
|
|
533
|
+
hasInlineExpansions: false,
|
|
534
|
+
isDynamic: false,
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
expect(result.riskLevel).toBe("low");
|
|
539
|
+
expect(result.reason).toBe("Skill load (default)");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("skill_load with no resolved metadata defaults to low risk", async () => {
|
|
543
|
+
const classifier = new SkillLoadRiskClassifier();
|
|
544
|
+
const result = await classifier.classify({
|
|
545
|
+
toolName: "skill_load",
|
|
546
|
+
skillSelector: "unknown-skill",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(result.riskLevel).toBe("low");
|
|
550
|
+
});
|
|
551
|
+
});
|